Add clusters
A workload cluster joins by running one agent, which dials out to the hub. There are two ways to register it: the Add cluster wizard in the UI, or the clusters[] list in your Helm values. Both create a Cluster resource in the management cluster and a token Secret named eddy-agent-<name> in the hub's namespace. You can mix them freely.
Full reference: docs/agent.md and docs/install.md in the repository.
Option A: the Add cluster wizard
With onboarding.enabled: true (the default), a user who may create clusters.gitops.eddy.dev in the management cluster sees Add cluster on the fleet page (press n).
- Allow your platform group to manage clusters. The hub asks the management cluster, as the signed-in user, whether they may create, update, delete and get
clusters.gitops.eddy.dev. The hub chart grants that to the groups and users you list inhub-values.yaml, and to nobody by default:Then runonboarding:admins:groups: [eddy:platform] # or a GitHub team, e.g. eddy:github:acme/platform
helm upgrade. The chart creates the ClusterRoleeddy-cluster-admin(get, list, watch, create, update, patch and delete on clusters) and binds it to those subjects in the management cluster. Groups must start witheddy:(config.auth.groups.prefix), andsystem:subjects are refused. The install notes say who is bound. Withrbac.create: false, create an equivalent ClusterRole and ClusterRoleBinding yourself. - Fill in the form: a name (a DNS label such as
prod-eu), display name, environment, region, colour, protection and order. The hub creates theCluster(phasePending), its token Secret and a one-time join token, valid for one hour by default (onboarding.joinTokenTTL, at most 24 hours). - Copy the install command from the Connect screen. It comes in three forms: a
helmcommand, avalues.yaml, and plain manifests for GitOps-managed clusters. Run it against the workload cluster. The plain manifests do not include the user roleseddy-viewerandeddy-operator, so applydeploy/rbac/eddy-user-rbac.yamlas well (see Access and RBAC). - Watch the live checklist. It ticks as the agent connects: agent connected, protocol compatible, Flux detected, informers synced, SubjectAccessReview working, impersonation pinned and the watched namespaces. Rejected attempts (a bad, expired or used join token, the wrong cluster, a protocol mismatch) are listed with the reason. Click Finish when done.
The agent trades the join token for a permanent token on its first connection and stores it in its own Secret, so the join token is then worthless. Later, the cluster card's Connection panel shows the same checklist, with Regenerate join token and Delete (a typed confirmation on protected clusters).
Set agentsPublicURL in the hub values, so the guide shows the address agents really dial. With onboarding.enabled: false the button is hidden and the hub keeps read-only RBAC on the management cluster.
Option B: declare clusters in Helm values
List each workload cluster in the hub's hub-values.yaml and upgrade. Colour is a hex string, environment is free text shown on the cluster, and protected: true makes writes need a typed confirmation of the cluster name.
clusters:- name: prod-eudisplayName: prod-euenvironment: Productionregion: eu-central-1color: "#C2410C"protected: trueorder: 1- name: stagingenvironment: Stagingcolor: "#0F766E"order: 2
$ helm upgrade eddy-hub oci://ghcr.io/idestis/charts/eddy-hub --version 1.0.1 \--namespace eddy -f hub-values.yaml
For each entry the chart creates a Cluster and a random token Secret, and keeps both across upgrades. To bring your own token Secret (key token), set existingTokenSecret on the entry. Under Argo CD this is the way to go, because the chart cannot generate a token there.
helm upgrade prints the exact agent install command for each cluster in its notes (helm -n eddy get notes eddy-hub).
Install the agent
The agent runs in its own namespace, eddy-system, not in flux-system, so Flux stays untouched and the agent's RBAC and policies are isolated. Run these steps against each workload cluster.
- Create the namespace and apply the Pod Security label (the agent meets the
restrictedprofile):$ kubectl --context prod-eu create namespace eddy-system$ kubectl --context prod-eu label namespace eddy-system pod-security.kubernetes.io/enforce=restricted
- Write
agent-values.yaml. TheimpersonationanduserRBACblocks decide who sees and does what here; Access and RBAC explains them:cluster:name: prod-eu # must equal the Cluster name in the hubhub:url: wss://eddy-agents.internal.example.com/agent/v1/connect # wss:// only# caBundle: | # PEM, only if the hub certificate is not publicly trusted# -----BEGIN CERTIFICATE-----watch:namespaces: [] # empty = the whole clusterpresets: [karpenter, externalSecrets] # optional, see belowimpersonation:groups: [eddy:authenticated, eddy:github:acme/platform] # pin group impersonation (recommended)userRBAC:operator:groups: ["eddy:github:acme/platform"] # who may reconcile, suspend and resume herereplicaCount: 2
- Install with the join token from the wizard:For a cluster from
$ helm install eddy-agent oci://ghcr.io/idestis/charts/eddy-agent --version 1.0.1 \--kube-context prod-eu --namespace eddy-system \-f agent-values.yaml \--set-string joinToken=eddy_join_...
clusters[], pass the agent token instead, taken from the hub cluster. Prefertoken.existingSecretunder GitOps:$ helm install eddy-agent oci://ghcr.io/idestis/charts/eddy-agent --version 1.0.1 \--kube-context prod-eu --namespace eddy-system \-f agent-values.yaml \--set-string token.value="$(kubectl --context mgmt -n eddy get secret eddy-agent-prod-eu -o jsonpath='{.data.token}' | base64 -d)"
Agent values worth knowing
| Value | Purpose |
|---|---|
cluster.name | Must equal the Cluster name in the hub |
hub.url, hub.caBundle | The agent endpoint (wss:// only) and an optional CA for a private certificate (--set-file hub.caBundle=ca.pem) |
joinToken | The one-time eddy_join_… token. The chart adds a Role that lets the agent get and update that one Secret, where it stores its permanent token |
token.existingSecret, token.value | A pre-provisioned agent token instead of a join token |
watch.namespaces | Limit what the agent watches. Empty means the whole cluster |
watch.presets | karpenter and externalSecrets |
impersonation.groups | Pin impersonation to an exact list of groups. Include eddy:authenticated |
userRBAC.* | Who may read and act in this cluster: viewer.groups (default eddy:authenticated), operator.groups, namespaces and bindings. See Access and RBAC |
replicaCount | Default 2. Every replica connects, and the hub uses the oldest and keeps the others as hot standbys |
limits.* | Control-plane protection for the whole Deployment: qps (20), burst (40), concurrency (16), logStreams (8), sarConcurrency (8). The chart divides them by replicaCount |
networkPolicy.enabled | On by default: egress to DNS, the Kubernetes API and the hub only. Set hubTo to the hub endpoint's CIDR |
Presets
Presets are opt-in groups of extra kinds. Each adds its kinds to the agent's cache, only for kinds the cluster serves, and read access on them to the agent's ClusterRole. Unknown preset names fail the chart.
karpenter: NodePool and NodeClaim (karpenter.sh) and EC2NodeClass (karpenter.k8s.aws).externalSecrets: ExternalSecret, ClusterExternalSecret, SecretStore, ClusterSecretStore and PushSecret (external-secrets.io). The Secrets they produce are never read.
People need matching read RBAC to see these kinds. The example roles in Access and RBAC already include them.
Protected clusters, environment and colour
environment (for example Production or Staging) and color label the cluster everywhere in the UI, so a production cluster is hard to mistake for staging. protected: true adds a server-checked typed confirmation to every write on that cluster. See Access and RBAC.
Network requirements
- Outbound only. The agent needs TCP 443 to the hub's agent endpoint, plus DNS and the Kubernetes API. It accepts no inbound connections, and has no Service.
- The hub must accept TCP 443 on the agent endpoint only, from your agent networks. The UI endpoint is a separate listener and hostname.
- Behind an egress proxy, the agent honours
HTTPS_PROXYandNO_PROXY(set throughextraEnv). Keep the Kubernetes API inNO_PROXY. - The connection is one long-lived WebSocket with a ping every 20 seconds, so any load balancer idle timeout above that works; 3600 seconds is recommended, as in Install. Reconnects use jittered backoff.
| Where the workload cluster is | Path to the agent endpoint |
|---|---|
| Same VPC as the hub | Internal NLB or ALB, security group allowing 443 from node or pod CIDRs |
| Other VPC, same account | VPC peering or Transit Gateway |
| Other AWS account | PrivateLink: the agent NLB behind a VPC endpoint service, an interface endpoint per VPC |
| Other cloud, on-prem, a laptop | A public endpoint restricted by source CIDR (TLS plus token), or a tunnel such as Tailscale or Cloudflare Tunnel |
Verify
- Within about a minute the cluster shows as
Connectedin the UI and on the management cluster:$ kubectl --context mgmt get clusters.gitops.eddy.dev - Read the agent log in the workload cluster:
$ kubectl --context prod-eu -n eddy-system get pods$ kubectl --context prod-eu -n eddy-system logs deploy/eddy-agent
- Check the wizard's checklist or the cluster's Connection panel. Impersonation should read as pinned, and the Flux kinds as synced.
- People see a cluster only through RBAC. The chart's default
userRBAClets every signed-in user read it. Decide who may operate in Access and RBAC.
Troubleshooting
- The cluster stays
Pending. Open its Connection panel. Rejected attempts say why: an expired or used join token (regenerate one), a token for another cluster, or a protocol mismatch (upgrade the agent). No attempts at all means the agent never reached the hub: checkhub.url, DNS, and outbound 443. - The cluster stays
Disconnected. Read the agent log. Usual causes are a wronghub.url, a certificate the agent does not trust (sethub.caBundle), a token that does not match theeddy-agent-<name>Secret, or an ingress that closes idle WebSockets. - The cluster is connected but the UI is empty. The user has no RBAC in that cluster, for example because
userRBAC.viewer.groupswas set to an empty list. - A GitOps controller keeps removing the permanent token. The agent writes it under the
tokenkey of its own Secret. Tell the controller to ignore that key (for example an Argo CDignoreDifferenceson/data/token).