Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions api/solar/target_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions api/solar/v1alpha1/target_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions api/solar/v1alpha1/zz_generated.conversion.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions api/solar/v1alpha1/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions api/solar/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

123 changes: 123 additions & 0 deletions cmd/solar-agent/main.go
Original file line number Diff line number Diff line change
@@ -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
}
8 changes: 8 additions & 0 deletions cmd/solar-controller-manager/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
109 changes: 109 additions & 0 deletions docs/developer-guide/adrs/014-Solar-Agent-Architecture.md
Original file line number Diff line number Diff line change
@@ -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.
Loading
Loading