diff --git a/api/solar/target_types.go b/api/solar/target_types.go index 5bef744a..866ad34d 100644 --- a/api/solar/target_types.go +++ b/api/solar/target_types.go @@ -25,6 +25,12 @@ type TargetSpec struct { // This enables target-specific customization and deployment parameters. // +optional Userdata runtime.RawExtension `json:"userdata,omitempty"` + + // AgentAccessSecretRef references a Secret in the same namespace containing a + // "kubeconfig" key with credentials for the target's own cluster. When set, + // solar-controller-manager installs solar-agent onto that cluster directly + // +optional + AgentAccessSecretRef *corev1.LocalObjectReference `json:"agentAccessSecretRef,omitempty"` } // TargetStatus defines the observed state of a Target. diff --git a/api/solar/v1alpha1/target_types.go b/api/solar/v1alpha1/target_types.go index 18a2a183..0b00433a 100644 --- a/api/solar/v1alpha1/target_types.go +++ b/api/solar/v1alpha1/target_types.go @@ -25,6 +25,12 @@ type TargetSpec struct { // This enables target-specific customization and deployment parameters. // +optional Userdata runtime.RawExtension `json:"userdata,omitempty"` + + // AgentAccessSecretRef references a Secret in the same namespace containing a + // "kubeconfig" key with credentials for the target's own cluster. When set, + // solar-controller-manager installs solar-agent onto that cluster directly + // +optional + AgentAccessSecretRef *corev1.LocalObjectReference `json:"agentAccessSecretRef,omitempty"` } // TargetStatus defines the observed state of a Target. diff --git a/api/solar/v1alpha1/zz_generated.conversion.go b/api/solar/v1alpha1/zz_generated.conversion.go index bdff5e8b..f9131f92 100644 --- a/api/solar/v1alpha1/zz_generated.conversion.go +++ b/api/solar/v1alpha1/zz_generated.conversion.go @@ -2170,6 +2170,7 @@ func autoConvert_v1alpha1_TargetSpec_To_solar_TargetSpec(in *TargetSpec, out *so out.RenderRegistryRef = in.RenderRegistryRef out.RenderRegistryNamespace = in.RenderRegistryNamespace out.Userdata = in.Userdata + out.AgentAccessSecretRef = (*corev1.LocalObjectReference)(unsafe.Pointer(in.AgentAccessSecretRef)) return nil } @@ -2182,6 +2183,7 @@ func autoConvert_solar_TargetSpec_To_v1alpha1_TargetSpec(in *solar.TargetSpec, o out.RenderRegistryRef = in.RenderRegistryRef out.RenderRegistryNamespace = in.RenderRegistryNamespace out.Userdata = in.Userdata + out.AgentAccessSecretRef = (*corev1.LocalObjectReference)(unsafe.Pointer(in.AgentAccessSecretRef)) return nil } diff --git a/api/solar/v1alpha1/zz_generated.deepcopy.go b/api/solar/v1alpha1/zz_generated.deepcopy.go index ba108ede..47bac530 100644 --- a/api/solar/v1alpha1/zz_generated.deepcopy.go +++ b/api/solar/v1alpha1/zz_generated.deepcopy.go @@ -1482,6 +1482,11 @@ func (in *TargetSpec) DeepCopyInto(out *TargetSpec) { *out = *in out.RenderRegistryRef = in.RenderRegistryRef in.Userdata.DeepCopyInto(&out.Userdata) + if in.AgentAccessSecretRef != nil { + in, out := &in.AgentAccessSecretRef, &out.AgentAccessSecretRef + *out = new(corev1.LocalObjectReference) + **out = **in + } return } diff --git a/api/solar/zz_generated.deepcopy.go b/api/solar/zz_generated.deepcopy.go index c312da39..e3413b1d 100644 --- a/api/solar/zz_generated.deepcopy.go +++ b/api/solar/zz_generated.deepcopy.go @@ -1482,6 +1482,11 @@ func (in *TargetSpec) DeepCopyInto(out *TargetSpec) { *out = *in out.RenderRegistryRef = in.RenderRegistryRef in.Userdata.DeepCopyInto(&out.Userdata) + if in.AgentAccessSecretRef != nil { + in, out := &in.AgentAccessSecretRef, &out.AgentAccessSecretRef + *out = new(corev1.LocalObjectReference) + **out = **in + } return } diff --git a/cmd/solar-agent/main.go b/cmd/solar-agent/main.go new file mode 100644 index 00000000..cbe199b1 --- /dev/null +++ b/cmd/solar-agent/main.go @@ -0,0 +1,123 @@ +// Copyright 2026 BWI GmbH and Solution Arsenal contributors +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "context" + "flag" + "os" + "os/signal" + "syscall" + "time" + + "github.com/go-logr/logr" + corev1 "k8s.io/api/core/v1" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/tools/clientcmd" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + + solarv1alpha1 "go.opendefense.cloud/solar/api/solar/v1alpha1" + solarclientset "go.opendefense.cloud/solar/client-go/clientset/versioned" + "go.opendefense.cloud/solar/pkg/agent" +) + +func main() { + var ( + namespace string + interval time.Duration + apiserverKubeconfig string + targetNamespace string + targetName string + renderRegistry string + renderRegistryNS string + ) + + flag.StringVar(&namespace, "namespace", "", "namespace to watch for Flux release objects (\"\" for all namespaces)") + flag.DurationVar(&interval, "interval", 30*time.Second, "poll/report interval") + flag.StringVar(&apiserverKubeconfig, "apiserver-kubeconfig", "", + "kubeconfig for solar-apiserver (the bootstrap credential from the agent config). "+ + "If set, the agent self-registers its own Target on startup.") + flag.StringVar(&targetNamespace, "target-namespace", "", "tenant namespace to register the Target in") + flag.StringVar(&targetName, "target-name", "", "name to register the Target under") + flag.StringVar(&renderRegistry, "render-registry", "", "name of the Registry to render this target's desired state to") + flag.StringVar(&renderRegistryNS, "render-registry-namespace", "", + "namespace of the Registry, if different from target-namespace. Requires a ReferenceGrant "+ + "in that namespace permitting Target access from target-namespace (see ADR-012).") + opts := zap.Options{Development: true} + opts.BindFlags(flag.CommandLine) + flag.Parse() + + log := zap.New(zap.UseFlagOptions(&opts)).WithName("solar-agent") + + cfg, err := ctrl.GetConfig() + if err != nil { + log.Error(err, "loading local cluster kubeconfig") + os.Exit(1) + } + + client, err := kubernetes.NewForConfig(cfg) + if err != nil { + log.Error(err, "building kubernetes client") + os.Exit(1) + } + + dyn, err := dynamic.NewForConfig(cfg) + if err != nil { + log.Error(err, "building dynamic client") + os.Exit(1) + } + + if apiserverKubeconfig != "" { + if err := registerTarget(log, apiserverKubeconfig, targetNamespace, targetName, renderRegistry, renderRegistryNS); err != nil { + log.Error(err, "self-registering target") + os.Exit(1) + } + } + + a := &agent.Agent{ + Collector: &agent.Collector{Client: client, Dynamic: dyn, Namespace: namespace}, + Publisher: agent.LogPublisher{Log: log}, + Interval: interval, + Log: log, + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + log.Info("starting solar-agent (POC)", "interval", interval, "namespace", namespace) + a.Run(ctx) +} + +func registerTarget(log logr.Logger, kubeconfigPath, namespace, name, renderRegistry, renderRegistryNamespace string) error { + apiserverCfg, err := clientcmd.BuildConfigFromFlags("", kubeconfigPath) + if err != nil { + return err + } + + solarClient, err := solarclientset.NewForConfig(apiserverCfg) + if err != nil { + return err + } + + registrar := &agent.Registrar{ + Client: solarClient, + Namespace: namespace, + Name: name, + Spec: solarv1alpha1.TargetSpec{ + RenderRegistryRef: corev1.LocalObjectReference{Name: renderRegistry}, + RenderRegistryNamespace: renderRegistryNamespace, + }, + } + + target, err := registrar.EnsureTarget(context.Background()) + if err != nil { + return err + } + + log.Info("target registered", "namespace", target.Namespace, "name", target.Name) + + return nil +} diff --git a/cmd/solar-controller-manager/main.go b/cmd/solar-controller-manager/main.go index d954a8ab..88cf4ec0 100644 --- a/cmd/solar-controller-manager/main.go +++ b/cmd/solar-controller-manager/main.go @@ -293,6 +293,14 @@ func main() { os.Exit(1) } + if err := (&controller.TargetAgentInstallerReconciler{ + Client: mgr.GetClient(), + Installer: controller.MarkerInstaller{}, + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "target-agent-installer") + os.Exit(1) + } + // healthz / readyz setup if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { diff --git a/docs/developer-guide/adrs/014-Solar-Agent-Architecture.md b/docs/developer-guide/adrs/014-Solar-Agent-Architecture.md new file mode 100644 index 00000000..e5738390 --- /dev/null +++ b/docs/developer-guide/adrs/014-Solar-Agent-Architecture.md @@ -0,0 +1,109 @@ +--- +status: draft +date: 2026-07-13 +--- + +# Solar Agent Architecture + +## Context and Problem Statement + +[#61](https://github.com/opendefensecloud/solution-arsenal/issues/61) ("Implement Solar Agent") has four sub-issues: + +- [#407](https://github.com/opendefensecloud/solution-arsenal/issues/407) (registration), +- [#408](https://github.com/opendefensecloud/solution-arsenal/issues/408) (status reporting), +- [#409](https://github.com/opendefensecloud/solution-arsenal/issues/409) (preflight), +- [#410](https://github.com/opendefensecloud/solution-arsenal/issues/410) (Helm chart/deployment) + +each carrying open design questions. Per [#665](https://github.com/opendefensecloud/solution-arsenal/issues/665), this ADR seeks to answer the ones that are architectural. + +## Decisions + +### Agent <-> apiserver: polling vs event-driven watch + +#408 asks for "real-time visibility," but also for efficient change-only updates, resilience to intermittent +connectivity, and stale-status detection. Those four pull in the same direction, and none of them require an +event-driven local watch to satisfy. A poll loop that pushes on change, with a heartbeat, delivers all four with +far fewer moving parts. + +**Efficient, changes-only updates.** each tick, the agent diffs the freshly-collected report against the last one it +successfully pushed, and skips the push if nothing changed. A poll loop that diffs before pushing is exactly as +bandwidth-efficient as a watch-triggered push, but simpler. + +**Resilient to intermittent connectivity.** A failed push is retried on the next tick. The poll interval doubles +as backoff, so no separate retry scheduler is needed. No durable queue is needed either: a report is a snapshot, not +a command that must eventually apply, so a report from three ticks ago has no value once a fresher one exists. +The agent can simply drop the old report and try again on the next tick. + +### Deployment status source of truth: FluxCD conditions + +The bootstrap chart creates one `OCIRepository`/`HelmRelease` pair per bound Release; the agent can roll up +their `Ready` conditions rather than tracking rollout state itself. + +### Status API surface + +A new per-target resource (tentatively `TargetReport`), owned solely by the agent, one per `Target`. Not +`Target.status`: that subresource is already written by solar-controller-manager +and a second writer risks data races on it. Not `ReleaseBinding.status`: that resource is provider-owned +and one Target can have many bindings, which would fragment a single agent's report across N objects. +Dead-agent detection is a `lastReportTime` heartbeat field, checked centrally by solar-controller-manager, so +the agent itself needs no self-monitoring logic. + +### Registration flow / agent config + +- **Auth**: a ServiceAccount token (kubeconfig-shaped), RBAC-scoped to only this Target's own report/status +- **Delivery**: a Secret, referenced from `Target.status` (e.g. `status.agentConfigSecretRef`) +- **Persistence**: a normal rotatable credential, not single-use. Rotation mechanics are out of scope for this ADR +- **Additional path, built during this spike (not required by #407)**: self-registration. Given a + namespace-scoped bootstrap token, `solar-agent` can create its own `Target` on first run instead of requiring one + to exist first (`pkg/agent/registrar.go`, `test/fixtures/setup-agent-self-register.sh`). This is additive to, not + a replacement for, the Target-creation-generates-config flow #407 asks for. Where the self-registered `Target` + lands governs whether it needs a `ReferenceGrant` to resolve its render `Registry` + ([ADR-012](./012-ReferenceGrants.md)): registering directly into the Registry's own namespace (`solar-system` in + the dev cluster) needs none; a separate tenant namespace needs a `ReferenceGrant` there, same as any other + cross-namespace Target → Registry reference. + +### Preflight checks on every reconciliation + +#409 flags this as a TBD ("run before each reconciliation, not just on first deployment (TBD?)"). Decided: every +reconciliation. A one-time gate can't catch regressions: FluxCD CRDs removed, RBAC narrowed, a namespace deleted, +after the first successful bootstrap; the agent would then fail later reconciles with no diagnostic trail, or +worse, silently stop reconciling with no visible cause. Recomputing every reconcile also matches how every other +condition in this codebase already behaves (`RegistryResolved`, `ReleasesRendered`, ...). A sticky-once-true +`Preflight` would be the odd one out. The checks are cheap (a handful of `Get`/`List` calls), well inside the +per-tick budget the poll loop already spends on reachability and status collection. + +Checks split into two kinds: + +- **Self-healing**: FluxCD CRDs missing -> the agent (re-)installs FluxCD, since it (possibly) owns that install already (see + "Deployment engine" below); target namespace missing -> the agent creates it, matching the AC's "exist or can be + created." These aren't gates so much as repair actions the agent takes each tick before proceeding. +- **Hard-fail (external dependency, agent can't self-heal)**: apiserver reachability, OCI registry reachability, + RBAC self-check (`SelfSubjectAccessReview`). These set `Preflight=False` with a reason/message and the tick stops + there; the next tick retries, same as any other push failure. +- **Capacity constraints**: blocked on `Target` gaining capacity fields (#406). But once it exists, it belongs in + the hard-fail category and needs the same every-reconciliation cadence, not just first deployment + +### Deployment engine + +The agent installs FluxCD itself (one easy solution proposed to ensure air-gapped capability: a pinned version +embedded in the agent binary) rather than requiring it pre-installed, then installs the target's bootstrap chart, +which creates the per-release Flux objects. This keeps the agent self-contained. + +### Deployment packaging + +A new `charts/solar-agent` chart is needed: single-replica Deployment with a ServiceAccount and RBAC. +A solar-controller-manager-initiated push install (`Target.spec.agentAccessSecretRef`, built during this +spike, see `pkg/controller/target_agent_installer_controller.go`) is an additional, optional path for target +clusters SolAr is already given access to. Not a replacement for manual deploy, and not something #410 asked for, +but complementary to it. + +## Out of Scope / Left for Sub-Issue Implementation + +- `TargetReport` resource and real status push (#408): Draft API Surface exists in `pkg/agent/status.go`, + but might change once the real status fields are known +- Real `solar-agent` Helm chart and image (#410): not built; the current remote-install path uses a + placeholder installer (`MarkerInstaller`). +- Agent-config Secret generation on Target creation (#407): not implemented; the self-registration + path currently assumes a bootstrap token was provisioned some other way. +- Capacity-constraint preflight checks (#409): blocked on Target capacity fields. +- Credential rotation, for either agent config or remote-install kubeconfigs. diff --git a/docs/developer-guide/dev-cluster-with-kind.md b/docs/developer-guide/dev-cluster-with-kind.md index b2e884da..95fb4e12 100644 --- a/docs/developer-guide/dev-cluster-with-kind.md +++ b/docs/developer-guide/dev-cluster-with-kind.md @@ -38,7 +38,7 @@ This will: ## What Gets Installed | Component | Namespace | Description | -| -- | -- | -- | +| ------------- | ------------ | -------------------------------- | | cert-manager | cert-manager | TLS certificate management | | trust-manager | cert-manager | Trust bundle management | | zot-discovery | zot | OCI registry for discovery | @@ -123,12 +123,12 @@ This will: ### Environment Variables -| Variable | Default | Description | -| -- | -- | -- | -| `KIND_CLUSTER_DEV` | `solar-dev` | Kind cluster name | -| `KUBECTL` | `kubectl` | Kubernetes CLI | -| `OCM` | `ocm` | OCM CLI path | -| `OCM_DEMO_DIR` | `test/fixtures/ocm-demo-ctf` | ocm-demo CTF location | +| Variable | Default | Description | +| ------------------ | ---------------------------- | --------------------- | +| `KIND_CLUSTER_DEV` | `solar-dev` | Kind cluster name | +| `KUBECTL` | `kubectl` | Kubernetes CLI | +| `OCM` | `ocm` | OCM CLI path | +| `OCM_DEMO_DIR` | `test/fixtures/ocm-demo-ctf` | ocm-demo CTF location | Example: @@ -170,7 +170,7 @@ This will apply: ### Environment Variables | Variable | Default | Description | -| -- | -- | -- | +| ------------------ | -------------- | ----------------- | | `KIND_CLUSTER_DEV` | `solar-dev` | Kind cluster name | | `KUBECTL` | `kubectl` | Kubernetes CLI | | `NAMESPACE` | `solar-system` | Target namespace | @@ -196,6 +196,108 @@ The flow is: 3. The rendertask_controller creates a **Job** in the same namespace 4. The Job spawns a **Pod** that renders the release and pushes it to the zot-deploy registry +## Setting Up Solar Agent Workflows for Testing + +`solar-agent` supports two ways a `Target` and its agent come to exist together; see +[ADR 014](adrs/014-Solar-Agent-Architecture.md) for the reasoning behind both. Each has its own setup script. + +### Workflow A: Agent Self-Registration + +The agent creates its own `Target` on startup instead of one having to already exist. + +#### Running the Script + +```bash +./test/fixtures/setup-agent-self-register.sh +``` + +This will: + +1. Create the namespace(s) if they don't exist +2. Apply a ServiceAccount/Role/RoleBinding scoped to `get`/`list`/`create` on `targets` in the target namespace only +3. Ensure a real `Registry` (`deploy-registry`, pointing at zot-deploy) exists in the registry namespace, so + `RegistryResolved` actually succeeds instead of failing with `NotFound` +4. If the target and registry namespaces differ, apply a `ReferenceGrant` permitting the target namespace's + `Target`s to reference `Registry`s in the registry namespace +5. Mint a token and write a bootstrap kubeconfig +6. Print the `go run ./cmd/solar-agent ...` command to run with it + +Run the printed command, then verify: + +```bash +kubectl get target agent-self-registered -n solar-system -o yaml +``` + +By default the `Target` self-registers directly into `solar-system` (same namespace as the `Registry`, so no +`ReferenceGrant` is needed. To exercise the cross-namespace path instead, point the target namespace elsewhere +and the registry namespace at `solar-system`: + +```bash +NAMESPACE=tenant-demo REGISTRY_NAMESPACE=solar-system TARGET_NAME=agent-cross-ns \ + ./test/fixtures/setup-agent-self-register.sh +``` + +#### Environment Variables + +| Variable | Default | Description | +| -------------------- | --------------------------------------- | -------------------------------------------------------------------------------------- | +| `KIND_CLUSTER_DEV` | `solar-dev` | Kind cluster name | +| `KUBECTL` | `kubectl` | Kubernetes CLI | +| `NAMESPACE` | `solar-system` | Namespace the Target self-registers into | +| `TARGET_NAME` | `agent-self-registered` | Name the agent registers itself under | +| `RENDER_REGISTRY` | `deploy-registry` | Name of the Registry to reference | +| `REGISTRY_NAMESPACE` | same as `NAMESPACE` | Namespace the Registry lives in; set differently to exercise the `ReferenceGrant` path | +| `OUT_KUBECONFIG` | `/tmp/solar-agent-bootstrap.kubeconfig` | Where to write the bootstrap kubeconfig | + +### Workflow B: Solar-Initiated Remote Install + +The `test/fixtures/setup-agent-remote-install.sh` script creates a `Target` with `agentAccessSecretRef` set, so +solar-controller-manager installs the agent itself instead of waiting for a manual deploy. + +#### Running the Script + +```bash +./test/fixtures/setup-agent-remote-install.sh +``` + +This will apply: + +- `test/fixtures/e2e/agent-remote-install-rbac.yaml` -- ServiceAccount + ClusterRole for the remote installer +- `test/fixtures/e2e/agent-remote-install-target.yaml` -- a Target with `agentAccessSecretRef` set + +and imperatively create a ClusterRoleBinding plus a `kubeconfig` Secret, since both need the namespace filled in at +apply time. + +This demo is self-referential: the "remote" cluster the installer targets is the same kind-solar-dev cluster +solar-apiserver runs in (via the in-cluster `https://kubernetes.default.svc` address), since a real second target +cluster isn't part of the dev-cluster setup. Against a real target cluster, the Secret would hold that cluster's own +kubeconfig instead. + +#### Watching the Results + +```bash +kubectl get target agent-remote-install -n tenant-demo -w +``` + +The flow is: + +1. `TargetAgentInstallerReconciler` sees `agentAccessSecretRef` set and `AgentInstalled` not yet `True` +2. It reads the kubeconfig Secret and calls the current `AgentInstaller` (`MarkerInstaller` -- a placeholder for the + real `helm upgrade --install` of the `solar-agent` chart, which doesn't exist yet) +3. `MarkerInstaller` creates a marker in the target cluster: + ```bash + kubectl get configmap solar-agent-installed -n solar-system -o yaml + ``` +4. `AgentInstalled` flips to `True` on the `Target` + +#### Environment Variables + +| Variable | Default | Description | +| ------------------ | ------------- | ----------------- | +| `KIND_CLUSTER_DEV` | `solar-dev` | Kind cluster name | +| `KUBECTL` | `kubectl` | Kubernetes CLI | +| `NAMESPACE` | `tenant-demo` | Tenant namespace | + ## Rebuilding Without Full Setup After making code changes, rebuild images and reload them: diff --git a/pkg/agent/agent.go b/pkg/agent/agent.go new file mode 100644 index 00000000..5cfcdc9c --- /dev/null +++ b/pkg/agent/agent.go @@ -0,0 +1,62 @@ +// Copyright 2026 BWI GmbH and Solution Arsenal contributors +// SPDX-License-Identifier: Apache-2.0 + +package agent + +import ( + "context" + "time" + + "github.com/go-logr/logr" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// Agent runs the collect -> report loop on a fixed interval +type Agent struct { + Collector *Collector + Publisher Publisher + Interval time.Duration + Log logr.Logger +} + +func (a *Agent) Run(ctx context.Context) { + ticker := time.NewTicker(a.Interval) + defer ticker.Stop() + + a.tick(ctx) + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + a.tick(ctx) + } + } +} + +func (a *Agent) tick(ctx context.Context) { + capacity, err := a.Collector.CollectCapacity(ctx) + if err != nil { + a.Log.Error(err, "collecting capacity") + + return + } + + releases, err := a.Collector.CollectReleases(ctx) + if err != nil { + a.Log.Error(err, "collecting release status") + + return + } + + report := TargetReport{ + LastReportTime: metav1.Now(), + Capacity: capacity, + Releases: releases, + } + + if err := a.Publisher.Publish(report); err != nil { + a.Log.Error(err, "pushing report") + } +} diff --git a/pkg/agent/collector.go b/pkg/agent/collector.go new file mode 100644 index 00000000..c9bb35bf --- /dev/null +++ b/pkg/agent/collector.go @@ -0,0 +1,116 @@ +// Copyright 2026 BWI GmbH and Solution Arsenal contributors +// SPDX-License-Identifier: Apache-2.0 + +package agent + +import ( + "context" + "fmt" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/kubernetes" +) + +// fluxReleaseGVRs are the resource types the bootstrap chart creates one pair +// of per bound Release. Both carry a Ready condition; ociRepository is +// listed alongside helmRelease so a source-side failure (e.g. the chart +// can't be pulled) is visible even before HelmRelease has anything to say. +var fluxReleaseGVRs = []schema.GroupVersionResource{ + {Group: "helm.toolkit.fluxcd.io", Version: "v2", Resource: "helmreleases"}, +} + +// Collector gathers point-in-time facts from the local target cluster. +type Collector struct { + Client kubernetes.Interface + Dynamic dynamic.Interface + Namespace string // "" lists across all namespaces +} + +// CollectCapacity sums node Allocatable and requested-by-Pods resources. +func (c *Collector) CollectCapacity(ctx context.Context) (ClusterCapacity, error) { + nodes, err := c.Client.CoreV1().Nodes().List(ctx, metav1.ListOptions{}) + if err != nil { + return ClusterCapacity{}, fmt.Errorf("listing nodes: %w", err) + } + + capacity := ClusterCapacity{ + NodeCount: int32(len(nodes.Items)), //nolint:gosec // node count from a real cluster never approaches MaxInt32 + Allocatable: corev1.ResourceList{}, + Used: corev1.ResourceList{}, + } + + for _, n := range nodes.Items { + addResourceList(capacity.Allocatable, n.Status.Allocatable) + } + + pods, err := c.Client.CoreV1().Pods(c.Namespace).List(ctx, metav1.ListOptions{}) + if err != nil { + return ClusterCapacity{}, fmt.Errorf("listing pods: %w", err) + } + + for _, p := range pods.Items { + for _, container := range p.Spec.Containers { + addResourceList(capacity.Used, container.Resources.Requests) + } + } + + return capacity, nil +} + +// CollectReleases lists Flux HelmRelease objects labeled with +// ReleaseLabelKey and rolls each one's Ready condition into a ReleaseStatus. +func (c *Collector) CollectReleases(ctx context.Context) ([]ReleaseStatus, error) { + var out []ReleaseStatus + + for _, gvr := range fluxReleaseGVRs { + list, err := c.Dynamic.Resource(gvr).Namespace(c.Namespace).List(ctx, metav1.ListOptions{ + LabelSelector: ReleaseLabelKey, + }) + if err != nil { + return nil, fmt.Errorf("listing %s: %w", gvr.Resource, err) + } + + for _, item := range list.Items { + out = append(out, releaseStatusFromUnstructured(item)) + } + } + + return out, nil +} + +func releaseStatusFromUnstructured(obj unstructured.Unstructured) ReleaseStatus { + status := ReleaseStatus{Name: obj.GetLabels()[ReleaseLabelKey]} + + conditions, found, _ := unstructured.NestedSlice(obj.Object, "status", "conditions") + if !found { + status.Reason = "NoStatus" + status.Message = "no status.conditions reported yet" + + return status + } + + for _, c := range conditions { + cond, ok := c.(map[string]any) + if !ok || cond["type"] != "Ready" { + continue + } + + status.Ready = cond["status"] == "True" + status.Reason, _ = cond["reason"].(string) + status.Message, _ = cond["message"].(string) + } + + return status +} + +func addResourceList(dst, src corev1.ResourceList) { + for name, qty := range src { + total := dst[name] + total.Add(qty) + dst[name] = total + } +} diff --git a/pkg/agent/collector_test.go b/pkg/agent/collector_test.go new file mode 100644 index 00000000..a8842f83 --- /dev/null +++ b/pkg/agent/collector_test.go @@ -0,0 +1,140 @@ +// Copyright 2026 BWI GmbH and Solution Arsenal contributors +// SPDX-License-Identifier: Apache-2.0 + +package agent + +import ( + "context" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + dynamicfake "k8s.io/client-go/dynamic/fake" + "k8s.io/client-go/kubernetes/fake" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Collector", func() { + ctx := context.Background() + + Describe("CollectCapacity", func() { + It("sums node allocatable and pod requests across the cluster", func() { + client := fake.NewClientset( + &corev1.Node{ + ObjectMeta: metav1.ObjectMeta{Name: "node-a"}, + Status: corev1.NodeStatus{ + Allocatable: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("4"), + corev1.ResourceMemory: resource.MustParse("8Gi"), + }, + }, + }, + &corev1.Node{ + ObjectMeta: metav1.ObjectMeta{Name: "node-b"}, + Status: corev1.NodeStatus{ + Allocatable: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("2"), + corev1.ResourceMemory: resource.MustParse("4Gi"), + }, + }, + }, + &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: "pod-a", Namespace: "default"}, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{ + Resources: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("500m"), + corev1.ResourceMemory: resource.MustParse("1Gi"), + }, + }, + }}, + }, + }, + ) + + c := &Collector{Client: client} + + capacity, err := c.CollectCapacity(ctx) + Expect(err).NotTo(HaveOccurred()) + Expect(capacity.NodeCount).To(Equal(int32(2))) + Expect(capacity.Allocatable.Cpu().String()).To(Equal("6")) + Expect(capacity.Allocatable.Memory().String()).To(Equal("12Gi")) + Expect(capacity.Used.Cpu().String()).To(Equal("500m")) + Expect(capacity.Used.Memory().String()).To(Equal("1Gi")) + }) + }) + + Describe("CollectReleases", func() { + It("rolls up the Ready condition of labeled HelmRelease objects", func() { + gvr := schema.GroupVersionResource{Group: "helm.toolkit.fluxcd.io", Version: "v2", Resource: "helmreleases"} + listKinds := map[schema.GroupVersionResource]string{gvr: "HelmReleaseList"} + + hr := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "helm.toolkit.fluxcd.io/v2", + "kind": "HelmRelease", + "metadata": map[string]any{ + "name": "demo-app", + "namespace": "tenant-a", + "labels": map[string]any{ReleaseLabelKey: "demo-app"}, + }, + "status": map[string]any{ + "conditions": []any{ + map[string]any{ + "type": "Ready", + "status": "True", + "reason": "InstallSucceeded", + "message": "Helm install succeeded", + }, + }, + }, + }} + + dyn := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(), listKinds, hr) + + c := &Collector{Dynamic: dyn, Namespace: "tenant-a"} + + releases, err := c.CollectReleases(ctx) + Expect(err).NotTo(HaveOccurred()) + Expect(releases).To(ConsistOf(ReleaseStatus{ + Name: "demo-app", + Ready: true, + Reason: "InstallSucceeded", + Message: "Helm install succeeded", + })) + }) + + It("reports NoStatus for objects without conditions yet", func() { + gvr := schema.GroupVersionResource{Group: "helm.toolkit.fluxcd.io", Version: "v2", Resource: "helmreleases"} + listKinds := map[schema.GroupVersionResource]string{gvr: "HelmReleaseList"} + + hr := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "helm.toolkit.fluxcd.io/v2", + "kind": "HelmRelease", + "metadata": map[string]any{ + "name": "pending-app", + "namespace": "tenant-a", + "labels": map[string]any{ReleaseLabelKey: "pending-app"}, + }, + }} + + dyn := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(), listKinds, hr) + + c := &Collector{Dynamic: dyn, Namespace: "tenant-a"} + + releases, err := c.CollectReleases(ctx) + Expect(err).NotTo(HaveOccurred()) + Expect(releases).To(ConsistOf(ReleaseStatus{ + Name: "pending-app", + Ready: false, + Reason: "NoStatus", + Message: "no status.conditions reported yet", + })) + }) + }) +}) diff --git a/pkg/agent/registrar.go b/pkg/agent/registrar.go new file mode 100644 index 00000000..b541c265 --- /dev/null +++ b/pkg/agent/registrar.go @@ -0,0 +1,48 @@ +// Copyright 2026 BWI GmbH and Solution Arsenal contributors +// SPDX-License-Identifier: Apache-2.0 + +package agent + +import ( + "context" + "fmt" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + solarv1alpha1 "go.opendefense.cloud/solar/api/solar/v1alpha1" + solarclientset "go.opendefense.cloud/solar/client-go/clientset/versioned" +) + +// Registrar ensures this agent's Target exists on solar-apiserver, creating +// it from Spec on first run +type Registrar struct { + Client solarclientset.Interface + Namespace string + Name string + Spec solarv1alpha1.TargetSpec +} + +// EnsureTarget returns the agent's Target, creating it if it doesn't exist yet. +func (r *Registrar) EnsureTarget(ctx context.Context) (*solarv1alpha1.Target, error) { + existing, err := r.Client.SolarV1alpha1().Targets(r.Namespace).Get(ctx, r.Name, metav1.GetOptions{}) + if err == nil { + return existing, nil + } + + if !apierrors.IsNotFound(err) { + return nil, fmt.Errorf("getting target %s/%s: %w", r.Namespace, r.Name, err) + } + + target := &solarv1alpha1.Target{ + ObjectMeta: metav1.ObjectMeta{Name: r.Name, Namespace: r.Namespace}, + Spec: r.Spec, + } + + created, err := r.Client.SolarV1alpha1().Targets(r.Namespace).Create(ctx, target, metav1.CreateOptions{}) + if err != nil { + return nil, fmt.Errorf("creating target %s/%s: %w", r.Namespace, r.Name, err) + } + + return created, nil +} diff --git a/pkg/agent/registrar_test.go b/pkg/agent/registrar_test.go new file mode 100644 index 00000000..40f4034c --- /dev/null +++ b/pkg/agent/registrar_test.go @@ -0,0 +1,51 @@ +// Copyright 2026 BWI GmbH and Solution Arsenal contributors +// SPDX-License-Identifier: Apache-2.0 + +package agent + +import ( + "context" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + solarv1alpha1 "go.opendefense.cloud/solar/api/solar/v1alpha1" + solarfake "go.opendefense.cloud/solar/client-go/clientset/versioned/fake" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Registrar", func() { + ctx := context.Background() + spec := solarv1alpha1.TargetSpec{ + RenderRegistryRef: corev1.LocalObjectReference{Name: "my-registry"}, + } + + It("creates the target when it doesn't exist yet", func() { + client := solarfake.NewSimpleClientset() + r := &Registrar{Client: client, Namespace: "tenant-a", Name: "cluster-1", Spec: spec} + + target, err := r.EnsureTarget(ctx) + Expect(err).NotTo(HaveOccurred()) + Expect(target.Name).To(Equal("cluster-1")) + Expect(target.Spec).To(Equal(spec)) + + stored, err := client.SolarV1alpha1().Targets("tenant-a").Get(ctx, "cluster-1", metav1.GetOptions{}) + Expect(err).NotTo(HaveOccurred()) + Expect(stored.Spec).To(Equal(spec)) + }) + + It("returns the existing target unmodified when one is already present", func() { + existing := &solarv1alpha1.Target{ + ObjectMeta: metav1.ObjectMeta{Name: "cluster-1", Namespace: "tenant-a"}, + Spec: solarv1alpha1.TargetSpec{RenderRegistryRef: corev1.LocalObjectReference{Name: "someone-elses-registry"}}, + } + client := solarfake.NewSimpleClientset(existing) + r := &Registrar{Client: client, Namespace: "tenant-a", Name: "cluster-1", Spec: spec} + + target, err := r.EnsureTarget(ctx) + Expect(err).NotTo(HaveOccurred()) + Expect(target.Spec.RenderRegistryRef.Name).To(Equal("someone-elses-registry")) + }) +}) diff --git a/pkg/agent/reporter.go b/pkg/agent/reporter.go new file mode 100644 index 00000000..2fd3f4e7 --- /dev/null +++ b/pkg/agent/reporter.go @@ -0,0 +1,29 @@ +// Copyright 2026 BWI GmbH and Solution Arsenal contributors +// SPDX-License-Identifier: Apache-2.0 + +package agent + +import "github.com/go-logr/logr" + +// Publisher delivers a TargetReport somewhere. LogPublisher is the POC +// stand-in for a client that pushes to the TargetReport resource on +// solar-apiserver (proposed in the design doc but not implemented here). +type Publisher interface { + Publish(r TargetReport) error +} + +// LogPublisher logs the report instead of sending it anywhere. +type LogPublisher struct { + Log logr.Logger +} + +func (l LogPublisher) Publish(r TargetReport) error { + l.Log.Info("target report", + "nodeCount", r.Capacity.NodeCount, + "allocatable", r.Capacity.Allocatable, + "used", r.Capacity.Used, + "releases", r.Releases, + ) + + return nil +} diff --git a/pkg/agent/suite_test.go b/pkg/agent/suite_test.go new file mode 100644 index 00000000..d4741df3 --- /dev/null +++ b/pkg/agent/suite_test.go @@ -0,0 +1,16 @@ +// Copyright 2026 BWI GmbH and Solution Arsenal contributors +// SPDX-License-Identifier: Apache-2.0 + +package agent + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestAgent(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Agent") +} diff --git a/pkg/agent/types.go b/pkg/agent/types.go new file mode 100644 index 00000000..6696f85e --- /dev/null +++ b/pkg/agent/types.go @@ -0,0 +1,46 @@ +// Copyright 2026 BWI GmbH and Solution Arsenal contributors +// SPDX-License-Identifier: Apache-2.0 + +// Package agent implements the solar-agent poll/report loop that runs on a +// registered target cluster. This is a POC: it proves the collect -> report +// shape described in docs/superpowers/specs/2026-07-07-solar-agent-design.md +// against real local-cluster data. Preflight, Helm/Flux installs and the +// TargetReport API type are intentionally out of scope -- see README in that +// spec for the full design. +package agent + +import ( + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// ReleaseLabelKey is the label the bootstrap chart sets on every +// OCIRepository/HelmRelease pair it creates (see +// pkg/renderer/template/bootstrap/templates/release.yaml). +const ReleaseLabelKey = "solar.opendefense.cloud/release" + +// TargetReport is a POC stand-in for the TargetReportStatus API type +// proposed in the design doc. Shape mirrors it so swapping the log-based +// Publisher for a real apiserver client later is a straight field-for-field +// move. +type TargetReport struct { + LastReportTime metav1.Time `json:"lastReportTime"` + Capacity ClusterCapacity `json:"capacity"` + Releases []ReleaseStatus `json:"releases"` +} + +// ClusterCapacity summarizes target-cluster node capacity and requested use. +type ClusterCapacity struct { + NodeCount int32 `json:"nodeCount"` + Allocatable corev1.ResourceList `json:"allocatable"` + Used corev1.ResourceList `json:"used"` +} + +// ReleaseStatus is one OCIRepository/HelmRelease pair's rolled-up Ready +// condition, keyed by ReleaseLabelKey. +type ReleaseStatus struct { + Name string `json:"name"` + Ready bool `json:"ready"` + Reason string `json:"reason,omitempty"` + Message string `json:"message,omitempty"` +} diff --git a/pkg/controller/agent_installer.go b/pkg/controller/agent_installer.go new file mode 100644 index 00000000..7989e48e --- /dev/null +++ b/pkg/controller/agent_installer.go @@ -0,0 +1,51 @@ +// Copyright 2026 BWI GmbH and Solution Arsenal contributors +// SPDX-License-Identifier: Apache-2.0 + +package controller + +import ( + "context" + "fmt" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + + solarv1alpha1 "go.opendefense.cloud/solar/api/solar/v1alpha1" +) + +// agentInstallNamespace is where the marker (and, eventually, the real +// solar-agent Deployment) is created on the target's own cluster. +const agentInstallNamespace = "solar-system" + +// MarkerInstaller proves the remote-kubeconfig-driven install mechanism end +// to end without a real solar-agent chart/image, which don't exist yet: it +// creates a namespace and a ConfigMap on the target cluster via the +// provided restConfig +type MarkerInstaller struct{} + +func (MarkerInstaller) Install(ctx context.Context, restConfig *rest.Config, target *solarv1alpha1.Target) error { + client, err := kubernetes.NewForConfig(restConfig) + if err != nil { + return fmt.Errorf("building client for target cluster: %w", err) + } + + ns := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: agentInstallNamespace}} + if _, err := client.CoreV1().Namespaces().Create(ctx, ns, metav1.CreateOptions{}); err != nil && !apierrors.IsAlreadyExists(err) { + return fmt.Errorf("creating %s namespace: %w", agentInstallNamespace, err) + } + + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: "solar-agent-installed", Namespace: agentInstallNamespace}, + Data: map[string]string{ + "target": target.Namespace + "/" + target.Name, + }, + } + if _, err := client.CoreV1().ConfigMaps(agentInstallNamespace).Create(ctx, cm, metav1.CreateOptions{}); err != nil && !apierrors.IsAlreadyExists(err) { + return fmt.Errorf("creating solar-agent-installed marker: %w", err) + } + + return nil +} diff --git a/pkg/controller/suite_test.go b/pkg/controller/suite_test.go index fc6c2fee..26494477 100644 --- a/pkg/controller/suite_test.go +++ b/pkg/controller/suite_test.go @@ -53,6 +53,9 @@ var ( releaseBindingReconciler *ReleaseBindingReconciler registryBindingReconciler *RegistryBindingReconciler + targetAgentInstallerReconciler *TargetAgentInstallerReconciler + fakeAgentInstaller *stubAgentInstaller + // fakeTagDeleter is injected into RenderArtifactReconciler so tests can // control OCI delete outcomes without making real network calls. fakeTagDeleter *stubTagDeleter @@ -183,6 +186,13 @@ var _ = BeforeSuite(func() { } Expect(registryBindingReconciler.SetupWithManager(mgr)).To(Succeed()) + fakeAgentInstaller = &stubAgentInstaller{} + targetAgentInstallerReconciler = &TargetAgentInstallerReconciler{ + Client: mgr.GetClient(), + Installer: fakeAgentInstaller, + } + Expect(targetAgentInstallerReconciler.SetupWithManager(mgr)).To(Succeed()) + go func() { defer GinkgoRecover() Expect(mgr.Start(ctx)).To(Succeed(), "failed to start manager") diff --git a/pkg/controller/target_agent_installer_controller.go b/pkg/controller/target_agent_installer_controller.go new file mode 100644 index 00000000..17a18e70 --- /dev/null +++ b/pkg/controller/target_agent_installer_controller.go @@ -0,0 +1,122 @@ +// Copyright 2026 BWI GmbH and Solution Arsenal contributors +// SPDX-License-Identifier: Apache-2.0 + +package controller + +import ( + "context" + "fmt" + "time" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + apimeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + + solarv1alpha1 "go.opendefense.cloud/solar/api/solar/v1alpha1" +) + +// ConditionTypeAgentInstalled reflects whether solar-agent has been +// installed onto a Target's cluster via its AgentAccessSecretRef. +const ConditionTypeAgentInstalled = "AgentInstalled" + +// AgentInstaller installs solar-agent onto the cluster reachable via +// restConfig. HelmAgentInstaller is the real implementation; tests inject a +// fake so this reconciler's tests never make real Helm calls. +type AgentInstaller interface { + Install(ctx context.Context, restConfig *rest.Config, target *solarv1alpha1.Target) error +} + +// TargetAgentInstallerReconciler installs solar-agent onto a Target's own +// cluster when the Target carries an AgentAccessSecretRef, instead of +// waiting for it to be deployed there manually. See "Workflow B" in +// docs/superpowers/specs/2026-07-07-solar-agent-design.md. +type TargetAgentInstallerReconciler struct { + client.Client + Installer AgentInstaller +} + +//+kubebuilder:rbac:groups=solar.opendefense.cloud,resources=targets,verbs=get;list;watch +//+kubebuilder:rbac:groups=solar.opendefense.cloud,resources=targets/status,verbs=get;update;patch +//+kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch + +func (r *TargetAgentInstallerReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + log := ctrl.LoggerFrom(ctx) + + target := &solarv1alpha1.Target{} + if err := r.Get(ctx, req.NamespacedName, target); err != nil { + if apierrors.IsNotFound(err) { + return ctrl.Result{}, nil + } + + return ctrl.Result{}, errLogAndWrap(log, err, "failed to get target") + } + + if target.Spec.AgentAccessSecretRef == nil { + return ctrl.Result{}, nil + } + + if apimeta.IsStatusConditionTrue(target.Status.Conditions, ConditionTypeAgentInstalled) { + return ctrl.Result{}, nil + } + + secret := &corev1.Secret{} + secretKey := client.ObjectKey{Namespace: target.Namespace, Name: target.Spec.AgentAccessSecretRef.Name} + + if err := r.Get(ctx, secretKey, secret); err != nil { + if apierrors.IsNotFound(err) { + return ctrl.Result{RequeueAfter: 30 * time.Second}, + r.setCondition(ctx, target, metav1.ConditionFalse, "SecretNotFound", err.Error()) + } + + return ctrl.Result{}, errLogAndWrap(log, err, "failed to get agent access secret") + } + + kubeconfig, ok := secret.Data["kubeconfig"] + if !ok { + return ctrl.Result{}, r.setCondition(ctx, target, metav1.ConditionFalse, "MissingKubeconfigKey", + fmt.Sprintf("secret %s has no %q key", secret.Name, "kubeconfig")) + } + + restConfig, err := clientcmd.RESTConfigFromKubeConfig(kubeconfig) + if err != nil { + return ctrl.Result{}, r.setCondition(ctx, target, metav1.ConditionFalse, "InvalidKubeconfig", err.Error()) + } + + if err := r.Installer.Install(ctx, restConfig, target); err != nil { + log.Error(err, "installing solar-agent") + + return ctrl.Result{RequeueAfter: 30 * time.Second}, + r.setCondition(ctx, target, metav1.ConditionFalse, "InstallFailed", err.Error()) + } + + return ctrl.Result{}, r.setCondition(ctx, target, metav1.ConditionTrue, "Installed", "solar-agent installed") +} + +func (r *TargetAgentInstallerReconciler) setCondition(ctx context.Context, target *solarv1alpha1.Target, status metav1.ConditionStatus, reason, message string) error { + changed := apimeta.SetStatusCondition(&target.Status.Conditions, metav1.Condition{ + Type: ConditionTypeAgentInstalled, + Status: status, + ObservedGeneration: target.Generation, + Reason: reason, + Message: message, + }) + if changed { + if err := r.Status().Update(ctx, target); err != nil { + return fmt.Errorf("failed to update target agent-installed condition: %w", err) + } + } + + return nil +} + +func (r *TargetAgentInstallerReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&solarv1alpha1.Target{}). + Named("target-agent-installer"). + Complete(r) +} diff --git a/pkg/controller/target_agent_installer_controller_test.go b/pkg/controller/target_agent_installer_controller_test.go new file mode 100644 index 00000000..a7fcb333 --- /dev/null +++ b/pkg/controller/target_agent_installer_controller_test.go @@ -0,0 +1,147 @@ +// Copyright 2026 BWI GmbH and Solution Arsenal contributors +// SPDX-License-Identifier: Apache-2.0 + +package controller + +import ( + "context" + "errors" + "sync" + + corev1 "k8s.io/api/core/v1" + apimeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/rest" + "sigs.k8s.io/controller-runtime/pkg/client" + + solarv1alpha1 "go.opendefense.cloud/solar/api/solar/v1alpha1" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// stubAgentInstaller is a thread-safe fake whose behaviour is controlled by +// tests. The zero value succeeds silently and records every call. +type stubAgentInstaller struct { + mu sync.Mutex + failErr error + calls []string // "/" of each Target passed to Install +} + +func (s *stubAgentInstaller) Install(_ context.Context, _ *rest.Config, target *solarv1alpha1.Target) error { + s.mu.Lock() + defer s.mu.Unlock() + s.calls = append(s.calls, target.Namespace+"/"+target.Name) + + return s.failErr +} + +func (s *stubAgentInstaller) failWith(err error) { + s.mu.Lock() + defer s.mu.Unlock() + s.failErr = err +} + +func (s *stubAgentInstaller) callCount() int { + s.mu.Lock() + defer s.mu.Unlock() + + return len(s.calls) +} + +const validKubeconfig = `apiVersion: v1 +kind: Config +clusters: +- cluster: + server: https://example.invalid:6443 + name: test +contexts: +- context: + cluster: test + user: test + name: test +current-context: test +users: +- name: test + user: + token: fake-token +` + +var _ = Describe("TargetAgentInstallerReconciler", Ordered, func() { + BeforeEach(func() { + fakeAgentInstaller.mu.Lock() + fakeAgentInstaller.failErr = nil + fakeAgentInstaller.calls = nil + fakeAgentInstaller.mu.Unlock() + }) + + It("does nothing for a target without AgentAccessSecretRef", func() { + target := &solarv1alpha1.Target{ + ObjectMeta: metav1.ObjectMeta{Name: "no-remote-access", Namespace: ns.Name}, + Spec: solarv1alpha1.TargetSpec{RenderRegistryRef: corev1.LocalObjectReference{Name: "reg"}}, + } + Expect(k8sClient.Create(ctx, target)).To(Succeed()) + + Consistently(func() int { return fakeAgentInstaller.callCount() }).Should(Equal(0)) + }) + + It("installs the agent and sets AgentInstalled=True once a valid kubeconfig secret exists", func() { + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "remote-kubeconfig", Namespace: ns.Name}, + Data: map[string][]byte{"kubeconfig": []byte(validKubeconfig)}, + } + Expect(k8sClient.Create(ctx, secret)).To(Succeed()) + + target := &solarv1alpha1.Target{ + ObjectMeta: metav1.ObjectMeta{Name: "with-remote-access", Namespace: ns.Name}, + Spec: solarv1alpha1.TargetSpec{ + RenderRegistryRef: corev1.LocalObjectReference{Name: "reg"}, + AgentAccessSecretRef: &corev1.LocalObjectReference{Name: "remote-kubeconfig"}, + }, + } + Expect(k8sClient.Create(ctx, target)).To(Succeed()) + + Eventually(func() bool { + got := &solarv1alpha1.Target{} + if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(target), got); err != nil { + return false + } + + return apimeta.IsStatusConditionTrue(got.Status.Conditions, ConditionTypeAgentInstalled) + }).Should(BeTrue()) + + Expect(fakeAgentInstaller.callCount()).To(Equal(1)) + }) + + It("sets AgentInstalled=False with reason InstallFailed when the installer errors", func() { + fakeAgentInstaller.failWith(errors.New("boom")) + + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "remote-kubeconfig-fail", Namespace: ns.Name}, + Data: map[string][]byte{"kubeconfig": []byte(validKubeconfig)}, + } + Expect(k8sClient.Create(ctx, secret)).To(Succeed()) + + target := &solarv1alpha1.Target{ + ObjectMeta: metav1.ObjectMeta{Name: "install-fails", Namespace: ns.Name}, + Spec: solarv1alpha1.TargetSpec{ + RenderRegistryRef: corev1.LocalObjectReference{Name: "reg"}, + AgentAccessSecretRef: &corev1.LocalObjectReference{Name: "remote-kubeconfig-fail"}, + }, + } + Expect(k8sClient.Create(ctx, target)).To(Succeed()) + + Eventually(func() *metav1.Condition { + got := &solarv1alpha1.Target{} + if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(target), got); err != nil { + return nil + } + + return apimeta.FindStatusCondition(got.Status.Conditions, ConditionTypeAgentInstalled) + }).Should(SatisfyAll( + Not(BeNil()), + HaveField("Status", metav1.ConditionFalse), + HaveField("Reason", "InstallFailed"), + )) + }) +}) diff --git a/test/fixtures/e2e/agent-remote-install-rbac.yaml b/test/fixtures/e2e/agent-remote-install-rbac.yaml new file mode 100644 index 00000000..eb724034 --- /dev/null +++ b/test/fixtures/e2e/agent-remote-install-rbac.yaml @@ -0,0 +1,21 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: solar-agent-remote-installer +--- +# ClusterRole because creating a Namespace (part of what the installer does) is +# cluster-scoped. In this demo the "remote" cluster is the same kind-solar-dev +# cluster solar-apiserver runs in (self-referential); against a real target +# cluster this ServiceAccount would live there instead, scoped no more broadly +# than solar-agent's own install actually needs. +kind: ClusterRole +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: solar-agent-remote-installer +rules: + - apiGroups: [""] + resources: ["namespaces", "configmaps"] + verbs: ["get", "create"] +# ClusterRoleBinding is created imperatively by setup-agent-remote-install.sh instead of +# here, since its subject namespace can't be set by `kubectl apply -n` the way a +# namespace-scoped resource's can. diff --git a/test/fixtures/e2e/agent-remote-install-target.yaml b/test/fixtures/e2e/agent-remote-install-target.yaml new file mode 100644 index 00000000..37ec9654 --- /dev/null +++ b/test/fixtures/e2e/agent-remote-install-target.yaml @@ -0,0 +1,9 @@ +apiVersion: solar.opendefense.cloud/v1alpha1 +kind: Target +metadata: + name: agent-remote-install +spec: + renderRegistryRef: + name: demo-registry + agentAccessSecretRef: + name: solar-agent-remote-kubeconfig diff --git a/test/fixtures/e2e/agent-self-register-rbac.yaml b/test/fixtures/e2e/agent-self-register-rbac.yaml new file mode 100644 index 00000000..cc0c129a --- /dev/null +++ b/test/fixtures/e2e/agent-self-register-rbac.yaml @@ -0,0 +1,25 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: solar-agent-bootstrap +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: solar-agent-bootstrap +rules: + - apiGroups: ["solar.opendefense.cloud"] + resources: ["targets"] + verbs: ["get", "list", "create"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: solar-agent-bootstrap +subjects: + - kind: ServiceAccount + name: solar-agent-bootstrap +roleRef: + kind: Role + name: solar-agent-bootstrap + apiGroup: rbac.authorization.k8s.io diff --git a/test/fixtures/setup-agent-remote-install.sh b/test/fixtures/setup-agent-remote-install.sh new file mode 100755 index 00000000..355f8694 --- /dev/null +++ b/test/fixtures/setup-agent-remote-install.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +set -euo pipefail + +KIND_CLUSTER_DEV="${KIND_CLUSTER_DEV:-solar-dev}" +KUBECTL="${KUBECTL:-kubectl} --context kind-${KIND_CLUSTER_DEV}" + +NAMESPACE="${NAMESPACE:-tenant-demo}" + +$KUBECTL get namespace "$NAMESPACE" >/dev/null 2>&1 || \ + $KUBECTL create namespace "$NAMESPACE" + +echo -e "\nSETTING UP AGENT REMOTE INSTALL (workflow B):\n" + +echo "Applying remote-installer ServiceAccount/ClusterRole to namespace '$NAMESPACE'" +$KUBECTL apply -n "$NAMESPACE" -f test/fixtures/e2e/agent-remote-install-rbac.yaml + +echo "Binding the ClusterRole to the ServiceAccount in '$NAMESPACE'" +$KUBECTL create clusterrolebinding solar-agent-remote-installer \ + --clusterrole=solar-agent-remote-installer \ + --serviceaccount="$NAMESPACE:solar-agent-remote-installer" \ + --dry-run=client -o yaml | $KUBECTL apply -f - + +echo "Minting a scoped token and building the remote-access kubeconfig Secret" +# NOTE: this demo is self-referential, the "remote" cluster is the same +# kind-solar-dev cluster solar-apiserver/solar-controller-manager run in, so the +# in-cluster DNS name is used instead of the host-mapped API server port (which +# isn't reachable from inside a Pod). Against a real target cluster, this Secret +# would instead hold that cluster's own externally-reachable kubeconfig. +CA=$($KUBECTL get configmap kube-root-ca.crt -n "$NAMESPACE" -o jsonpath='{.data.ca\.crt}' | base64 | tr -d '\n') +TOKEN=$($KUBECTL create token solar-agent-remote-installer -n "$NAMESPACE" --duration=2h) + +REMOTE_KUBECONFIG=$(cat </dev/null 2>&1 || $KUBECTL create namespace "$ns" +done + +echo -e "\nSETTING UP AGENT SELF-REGISTRATION (workflow A):\n" + +echo "Applying bootstrap ServiceAccount/Role/RoleBinding to namespace '$NAMESPACE'" +$KUBECTL apply -n "$NAMESPACE" -f test/fixtures/e2e/agent-self-register-rbac.yaml + +echo "Ensuring Registry '$RENDER_REGISTRY' exists in namespace '$REGISTRY_NAMESPACE'" +$KUBECTL apply -n "$REGISTRY_NAMESPACE" -f test/fixtures/e2e/zot-deploy-auth.yaml +$KUBECTL apply -n "$REGISTRY_NAMESPACE" -f test/fixtures/e2e/registry.yaml + +if [ "$NAMESPACE" != "$REGISTRY_NAMESPACE" ]; then + echo "Granting Targets in '$NAMESPACE' access to Registries in '$REGISTRY_NAMESPACE' (ReferenceGrant, ADR-012 Pattern 2)" + sed "s/TARGET_NAMESPACE/$NAMESPACE/" test/fixtures/e2e/cross-ns-registry-grant.yaml | \ + $KUBECTL apply -n "$REGISTRY_NAMESPACE" -f - +fi + +echo "Minting a scoped token for solar-agent-bootstrap (only allowed to get/list/create Targets in '$NAMESPACE')" +SERVER=$($KUBECTL config view --minify --raw -o jsonpath='{.clusters[0].cluster.server}') +CA=$($KUBECTL config view --minify --raw -o jsonpath='{.clusters[0].cluster.certificate-authority-data}') +TOKEN=$($KUBECTL create token solar-agent-bootstrap -n "$NAMESPACE" --duration=2h) + +cat > "$OUT_KUBECONFIG" <