Skip to main content

Cluster Add-ons

Add-ons are platform-curated versions of the standard Kubernetes ecosystem apps most clusters need: installed, upgraded, and monitored with one command or one click. Availability depends on the add-on and cluster type, as shown below.

You declare intent: which add-on, and a few typed options. AstroPulse owns the rest: the chart, the version, the deployment lifecycle, and health. You never handle Helm values, and the platform never asks for (or stores) cloud credentials in an add-on configuration.

Ways to manage add-ons

Manage add-ons through whichever surface fits how you work:

  • Console: the cluster's Add-ons tab; point and click.
  • CLI: astroctl infra k8s addons … commands; good for GitOps and reproducible setups.
  • Nova (AI): ask in plain language. Nova is available as Nova Cloud (hosted chat in the console), Nova Connect (Nova in your editor or IDE via the AstroPulse MCP endpoint platform.astropulse.io/mcp), and Nova Direct. See One AI, Any Interface for how the three fit together.

All three drive the same platform and enforce the same validation and approvals. There's no separate path with looser rules.

Available add-ons

Add-onWhat it doesClusters
NGINX Ingress (nginx-ingress)HTTP/S ingress controller; routes external traffic to your apps and powers custom domainsAll
ExternalDNS (external-dns)Publishes DNS records for your services and ingressesAll
cert-manager (cert-manager)Issues and renews TLS certificates automaticallyAll
Karpenter (karpenter)Just-in-time node autoscalingAWS (EKS and self-hosted)
Cost Visibility (cost-visibility)Measures cluster and application resource allocation, usage, and costProvisioned or registered clusters with a compatible agent

Add-ons are cluster capabilities, addressed by cluster + add-on name. One instance of each add-on per cluster. They are managed here (in the console's cluster page and the astroctl infra k8s addons commands) and intentionally do not appear in your Applications view: your app list stays yours.

Each add-on installs into its own dedicated namespace (the ecosystem standard), created automatically:

Add-onNamespaceService account
nginx-ingressingress-nginxingress-nginx
external-dnsexternal-dnsexternal-dns
cert-managercert-managercert-manager
karpenterkube-systemkarpenter
cost-visibilityastro-costManaged internally

What each add-on does by default

You declare intent; these are the defaults AstroPulse applies unless you override them in the add-on configuration.

NGINX Ingress

  • Runs 1 replica; ingress class nginx; no autoscaling.
  • Service is a cloud LoadBalancer by default. On clusters without a cloud LB (local, bare-metal), set nginx.enableNodePort: true to expose it via NodePort.
  • TLS is terminated at the controller: you attach a TLS Secret to each Ingress (spec.tls) and NGINX terminates HTTPS and forwards plaintext to your Service. SSL passthrough is not enabled by default (the controller does not forward encrypted traffic to the backend). Pair with cert-manager to get and renew those certificates automatically.

ExternalDNS

  • Watches both Ingresses and Services for hostnames and publishes the matching records to your DNS provider.
  • Runs with policy: sync: it creates, updates, and deletes records to match the cluster. Removing an Ingress/Service removes its DNS record. (It only touches records it owns; see the TXT owner ID below.)
  • Uses a TXT registry for ownership, tagged with an owner ID that defaults to the cluster name, so multiple clusters can safely share a DNS zone.
  • No domain filter by default (it manages every zone the cluster identity can reach); set one to scope it. Not a dry run by default. See ExternalDNS: DNS automation for the required cluster identity and per-provider setup.

cert-manager

  • Installs the cert-manager CRDs by default (certManager.installCRDs: true). Turn this off only if the CRDs are already present on the cluster.
  • No ClusterIssuer by default: you define your own Issuer/ClusterIssuer and reference it from your Certificates/Ingresses.
  • Optionally, the add-on can create a ClusterIssuer for you: set certManager.enableClusterIssuer: true with issuerType: letsencrypt (a public Let's Encrypt ACME issuer; email is required for expiry notices) or issuerType: selfsigned (internal/testing). Leave it off to manage issuers yourself.

Karpenter

  • AWS only (EKS and self-hosted). On EKS it is installed with the cluster and managed as part of it (upgrade-only; not independently deletable). Installs the Karpenter controller into kube-system. Spot-interruption handling is off unless you enable it. Needs AWS identity: see Karpenter cloud access.

Cost Visibility

Once installed, see Cost and Resource Metrics for what the numbers mean and how to read coverage.

Cost Visibility requires a connected, compatible Astro agent. The agent can run on provisioned or registered clusters across supported providers. AstroPulse manages its installation and capabilities; BYO Kubernetes registration installs it by default. If prompted, update an existing agent before installing the add-on:

astroctl infra k8s register agent update --cluster-name my-cluster

The default is deliberately one click: AstroPulse installs and configures the complete metering stack, including a dedicated, cost-scoped Prometheus, and validates the whole allocation query before reporting the add-on healthy. Open Advanced settings only when reusing a metrics backend already running in the cluster:

SelectionWhat AstroPulse deploysWhat you provide
Astro-managedThe metering stack and a scoped PrometheusNothing
Existing Prometheus-compatible backendThe metering stack onlyA Prometheus, Thanos, Cortex/Mimir, or VictoriaMetrics query Service/URL

When you know the namespace but not the Service, list the Services and their ports first:

kubectl -n monitoring get svc
kubectl -n monitoring get svc <service-name> -o jsonpath='{range .spec.ports[*]}{.name}{" "}{.port}{"\n"}{end}'

In the console, enter the namespace, Service name, and Service port; it constructs the stable cluster DNS URL http://<service>.<namespace>.svc:<port>. The full-URL option is for HTTPS, proxies, path prefixes, or query frontends. The endpoint must be reachable from inside the cluster and must not contain credentials.

For an existing Prometheus-compatible backend, AstroPulse points the managed metering stack at that exact endpoint. Prometheus, Thanos, Cortex/Mimir, and VictoriaMetrics all fit the same prometheusEndpoint contract when they expose the Prometheus query API. The selected endpoint must contain cAdvisor, kube-state-metrics, and node-exporter series for this cluster. External source changes verify connectivity, required series, and single-cluster scope before sync; every installation then verifies a real allocation response. A wrong external replacement fails with an actionable validation error while the previous source remains active. It is never accepted as healthy cost data.

How usage is priced

Installing the add-on gives you quantities — allocated and used CPU, memory, and storage per namespace and application, with utilization and coverage. That needs no configuration and is enough to find request-to-average usage gaps for review. Requests may intentionally include burst and reliability headroom, so validate workload behavior before changing them.

Turning quantities into money is one explicit choice:

ModeRates come fromSetupOngoing cost
Not pricednonenone
Your own ratesyoua few numbersnone
Your cloud billyour provider's invoicea billing exportprovider query charges

Not priced is not a failure state. Allocation, utilization and efficiency all work; figures read unpriced rather than being guessed at rates nobody chose.

AstroPulse never stores a credential for any of these. Reading your cloud bill uses an identity your cluster already holds — Workload Identity on GCP, IRSA on AWS — so there is no key to rotate or leak.

What your cluster can use

Reading a cloud bill needs an identity the cluster can assume without a stored key, and not every cloud offers one. Your own rates work everywhere, because they depend on nothing outside the cluster.

ClusterNot pricedYour own ratesYour cloud bill
GKE, or an agent-registered cluster on GCP
EKS, or an agent-registered cluster on AWS
AKS, or an agent-registered cluster on Azure⚙️ your integration
Self-hosted (kops) on AWS or GCP
Local, bare metal, or any other cloud❌ no bill to read

Azure has no keyless authentication for its cost export — it wants a storage account key, and AstroPulse will not hold one. You have two routes: use your own rates with your negotiated Azure prices, which for an Enterprise Agreement is more accurate than any rate card; or supply the integration yourself, below.

AWS needs two values the platform cannot derive: the 12-digit account owning the Cost and Usage Report, and the workloadIdentity role ARN the metering workload assumes. You create that role and its trust policy in your own account; naming it here is what makes the binding survive — the ServiceAccount belongs to the add-on, so an annotation applied out of band is drift the next reconcile removes.

The console only offers what your cluster can use, with the reason attached where it cannot. The CLI does not filter — a manifest can request anything and the API rejects what the cluster cannot do, so you meet the constraint at apply time rather than while choosing. The examples below are per mode and per provider for that reason.

Pricing with a price sheet

Supply your rates and the same views carry currency. This works on every cluster — any cloud, self-hosted, or on-premises — because it depends on nothing outside the cluster, needs no credential, and adds no charges from your provider.

configuration:
costVisibility:
priceSheet:
cpuCoreHour: "0.031611"
ramGbHour: "0.004237"
storageGbHour: "0.000137"
currency: USD

A resource you supply no rate for contributes nothing to the total — it is never filled in with a rate you did not choose. Figures produced this way report custom, so it is always visible which rates produced a number.

Without a price sheet, cost reads unpriced: quantities are measured in full, allocation and utilization work normally, and no currency figure is invented.

The rest of this section is about finding the numbers to put in it.

Finding your rates

You need about four numbers, not a price list — the sheet is flat, so one CPU rate and one memory rate cover the cluster.

Start by finding what your nodes actually are. Rates depend on machine type and region, so look before you price:

kubectl get nodes -o custom-columns=\
'NODE:.metadata.name,\
TYPE:.metadata.labels.node\.kubernetes\.io/instance-type,\
REGION:.metadata.labels.topology\.kubernetes\.io/region'
NODE TYPE REGION
gke-prod-pool-a-3f2c n1-standard-4 us-central1
gke-prod-pool-a-9b1d n1-standard-4 us-central1

Then fetch the price for those machine types. Pricing pages quote a price per machine per hour; the sheet wants per core-hour and per GB-hour, so there is one conversion step.

Do the conversion here

Enter the machines you run and this produces the rates, blended by capacity if you have more than one type. Nothing is sent anywhere — it is arithmetic in your browser.

Machine typeNodes$ / hourvCPUGB RAM

Enter at least one machine type with a node count, hourly price and vCPU count.

Blending matters when node types differ: rates derived from the whole fleet make cluster totals reconcile, whereas rates taken from one machine type are wrong for every other type in the cluster. What stays approximate is attribution between workloads — a pod pinned to expensive nodes is charged the blended rate — so if you run a wide price spread and need per-workload precision, a flat sheet is the wrong tool.

The sections below show where to find each provider's numbers, and the arithmetic the calculator is doing.

GCP is the easy one: it publishes vCPU and memory as separate line items, so there is no conversion to do. On the Compute Engine pricing page, select your region and find your machine family:

N1 predefined vCPU $0.031611 per vCPU-hour → cpuCoreHour
N1 predefined memory $0.004237 per GB-hour → ramGbHour

Those two values go straight into the sheet.

To pull them programmatically instead, the Cloud Billing Catalog API serves the same public data. It needs an API key of your own — create one restricted to the Cloud Billing API, then filter Compute Engine SKUs for your region:

curl -s "https://cloudbilling.googleapis.com/v1/services/6F81-5844-456A/skus?key=$GCP_API_KEY&currencyCode=USD" \
| jq -r '.skus[]
| select(.category.resourceGroup=="N1Standard")
| select(.serviceRegions[]=="us-central1")
| "\(.description) \(.pricingInfo[0].pricingExpression.tieredRates[-1].unitPrice.nanos/1e9)"'

Disks are priced per GB-month, so divide by 730:

Balanced PD $0.100 per GB-month ÷ 730 = $0.000137 per GB-hour
Use your real rates, not list price

If you have committed-use discounts, reserved instances, or negotiated rates, put those numbers in. A price sheet built from your actual rates is more accurate than any public rate card, because a rate card cannot know your agreement.

Spot nodes need their own rates

A flat sheet prices every node at on-demand rates. Spot commonly runs 60–90% below on-demand, so a cluster with a spot pool is materially over-reported without separate rates.

Supply spot rates together with the label that identifies spot capacity. Spot rates without a label are rejected, not ignored: applied to every node they would under-report the whole cluster, and accepting them silently would leave a sheet that reads as spot-aware and is not.

spotCpuCoreHour: "0.0094"
spotRamGbHour: "0.0010"
spotNodeLabel: cloud.google.com/gke-provisioning
spotNodeLabelValue: spot

The label differs per provider:

ProviderLabelValue
GKEcloud.google.com/gke-provisioningspot
EKSeks.amazonaws.com/capacityTypeSPOT
AKSkubernetes.azure.com/scalesetpriorityspot

Confirm what your nodes actually carry — a self-managed pool may set its own:

kubectl get nodes -o json | jq -r '.items[].metadata.labels | keys[]' | sort -u | grep -i -E 'spot|preempt|capacity'

Egress, if you want it

Network egress is priced per GB by destination and is not charged unless you supply a rate:

zoneEgressGb: "0.01"
regionEgressGb: "0.01"
internetEgressGb: "0.12"

Applying a price sheet

Rates live in the add-on configuration, so they are applied the same way as any other add-on change — through the console, or by reapplying the manifest:

astroctl infra k8s addons apply -f cost-visibility.yaml
cost-visibility.yaml
apiVersion: platform.astropulse.io/v1
kind: K8sClusterAddon
metadata:
name: cost-visibility
spec:
clusterName: my-cluster
configuration:
costVisibility:
priceSheet:
cpuCoreHour: "0.031611"
ramGbHour: "0.004237"
storageGbHour: "0.000137"
spotCpuCoreHour: "0.0094"
spotRamGbHour: "0.0010"
spotNodeLabel: cloud.google.com/gke-provisioning
spotNodeLabelValue: spot
currency: USD

Rates change what future windows are priced at. Closed windows keep the rates they were priced with, so a correction does not silently rewrite history.

Without a price sheet, figures read unpriced

A cluster with no price sheet measures quantities in full — allocation, usage and utilization all work — and reports unpriced rather than guessing.

That is a deliberate outcome, not a failure. A plausible-looking number derived from rates nobody chose is worse than no number, and the whole feature is built on the rule that a missing measurement is never presented as a real one.

Every mode, by manifest

All four apply the same way:

astroctl infra k8s addons apply -f cost-visibility.yaml

Not priced — measure without pricing. This is also how you turn pricing off:

apiVersion: platform.astropulse.io/v1
kind: K8sClusterAddon
metadata:
name: cost-visibility
spec:
clusterName: my-cluster
configuration:
costVisibility: {}

Your own rates — works on every cluster:

costVisibility:
priceSheet:
cpuCoreHour: "0.031611" # required together
ramGbHour: "0.004237" # required together
storageGbHour: "0.000137" # optional
gpuHour: "0.95" # optional
currency: USD

Your cloud bill, GCP — needs the detailed export table:

costVisibility:
billingExport:
projectId: my-project
dataset: billing
table: gcp_billing_export_resource_v1_0123AB_CDEF45_678901

Your cloud bill, AWSaccount and workloadIdentity are both required here; neither can be derived from the cluster:

costVisibility:
billingExport:
bucket: my-cur-reports
database: athenacurcfn_my_cur
table: my_cur
account: "111122223333"
workloadIdentity: arn:aws:iam::111122223333:role/astro-cost-visibility
# region: us-east-1 # only if the report is not in the cluster's region
# currency: EUR # only if the bill is not in USD

Add currency on any of these when your bill is not in US dollars. The metering stack reports whatever your provider invoiced, and nothing in the export says which currency that is — so an unset currency labels a euro bill as dollars rather than converting it. It takes an ISO 4217 code (EUR, GBP, INR); nothing is converted either way, the figures are simply labelled correctly.

Your own integration — advanced, and the only route on Azure. You create a Secret in the add-on's namespace holding a complete cloud-integration.json, and name it here. That Secret is the integration: the fields above are unused, it never leaves your cluster, and AstroPulse stores only the name.

kubectl create secret generic azure-billing -n astro-cost \
--from-file=cloud-integration.json
costVisibility:
billingExport:
secretName: azure-billing

Because you wrote it, AstroPulse cannot validate its contents and does not try. Rotate or revoke it as you would any Secret in your own cluster; the add-on picks up the change on its next sync.

Pricing from your cloud bill

Instead of supplying rates, read what your provider actually invoiced. Your committed-use, sustained-use and spot discounts are already applied, so the figures are your real spend rather than an approximation. Figures produced this way report cloud-bill.

Everything here is yours: your cloud account, your billing export, your discounts. The data never reaches AstroPulse. The metering workload runs in your cluster and queries your export directly, using an identity you grant it — Workload Identity on GCP, IRSA on AWS. AstroPulse stores the name of a table, never a credential and never a figure from your bill.

GCP and AWS have a keyless identity the cluster can assume, so the tabs below are all you need there. Azure has no keyless equivalent — its cost export authenticates with a storage account key, which AstroPulse will not hold on your behalf. On Azure you have two routes: use your own rates with your negotiated Azure prices, or create the Secret yourself and name it — see Supplying the integration yourself below.

Enable a Detailed usage cost export to BigQuery (Billing → Billing export), then grant the cluster read access in your own project:

gcloud iam service-accounts create astro-cost-visibility --project=my-project

for R in roles/compute.viewer roles/bigquery.user \
roles/bigquery.dataViewer roles/bigquery.jobUser; do
gcloud projects add-iam-policy-binding my-project \
--member="serviceAccount:astro-cost-visibility@my-project.iam.gserviceaccount.com" \
--role="$R"
done

gcloud iam service-accounts add-iam-policy-binding \
astro-cost-visibility@my-project.iam.gserviceaccount.com \
--role=roles/iam.workloadIdentityUser \
--member="serviceAccount:my-project.svc.id.goog[astro-cost/cost-visibility-opencost]"
configuration:
costVisibility:
billingExport:
projectId: my-project
dataset: billing
table: gcp_billing_export_resource_v1_0123AB_CDEF45_678901

Find the table with bq ls --project_id=my-project billing. It must be the _resource_ one — the service-level table has no per-resource rows and cannot attribute cost to a workload, so it is rejected.

A cluster you registered with the agent works the same way, with one difference: AstroPulse has no connection to your cloud account for it, so nothing can be pre-filled and you supply the export location in full.

Supplying the integration yourself

Applies to any provider. It is the only route to invoice-exact figures on Azure, which has no keyless authentication for its cost export.

You create a Secret in the cluster; AstroPulse stores only its name. Because you wrote it, the platform cannot inspect its contents and does not try — no per-provider validation applies, and no workload identity is annotated, since the Secret carries its own authentication.

# The filename becomes the Secret key, and the key must be exactly
# cloud-integration.json — so create the file under that name and pass it
# directly. --from-file=./billing.json would produce the wrong key.
kubectl create secret generic azure-billing -n astro-cost \
--from-file=cloud-integration.json
apiVersion: platform.astropulse.io/v1
kind: K8sClusterAddon
metadata:
name: cost-visibility
spec:
clusterName: my-cluster
configuration:
costVisibility:
billingExport:
secretName: azure-billing

The Secret is the integration, so the other billingExport fields are unused and must be left out — sending both is rejected.

Three requirements, because the metering workload mounts the Secret by name and never inspects it:

RequirementIf it is wrong
Namespaceastro-cost — the workload only sees its own namespaceThe pod never starts
Keyexactly cloud-integration.jsonThe pod stays in ContainerCreating
ExclusivitysecretName alone, no typed export fieldsThe configuration is rejected

The first two are enforced by Kubernetes when the pod starts, not by the API when you apply — so a Secret with the wrong key is accepted and then fails inside the cluster about a minute later. If the add-on will not start, check the key name first:

kubectl get secret azure-billing -n astro-cost -o jsonpath='{.data}' | tr ',' '\n'

It is created out of band, lives only in your cluster, and is yours to rotate or revoke — rotation needs no change here, since the name does not change. This is what makes Azure workable: its cost export authenticates with a storage account key, which AstroPulse will not hold on your behalf. Supplying it yourself keeps the credential in the cluster you control while still producing cloud-bill figures.

Your provider charges for the queries

Reading a billing export is billed by scanned bytes — roughly $6.25/TiB on BigQuery (first TiB each month free) and $5/TB on Athena. Small estates typically stay inside the free tier; a large one can reach tens of dollars a month. Nothing else in this add-on adds to your cloud bill.

kube-state-metrics needs node labels enabled

This applies only to a backend you bring

When AstroPulse installs the metering stack, it configures this for you — there is nothing to do. The requirement below is only for an existing Prometheus-compatible backend, because that one is yours to configure.

One requirement is easy to miss because the backend looks correctly configured without it: kube_node_labels must be present. Cost attribution resolves nodes through that series, and without it every node fails to resolve — the metering stack logs failed to find node for each one and reports no cost, even though it connected to your endpoint successfully and every other series is there.

In kube-state-metrics v2 the kube_*_labels metrics are opt-in. A default install collects nodes and exports kube_node_status_capacity and the rest, but exports no node labels at all, so the gap does not look like a misconfiguration from the outside.

Check first — a result of 0 means it is missing:

count(kube_node_labels)

Enable it on the kube-state-metrics that feeds your backend:

helm upgrade <release> prometheus-community/prometheus -n <namespace> --reuse-values \
--set kube-state-metrics.metricLabelsAllowlist="{nodes=[*]}"

Or, if you run kube-state-metrics directly, add the flag:

--metric-labels-allowlist=nodes=[*]

nodes=[*] exports every node label. To keep cardinality down, list only the labels you need — the topology and instance-type labels are the ones that matter for cost:

--metric-labels-allowlist=nodes=[topology.kubernetes.io/region,topology.kubernetes.io/zone,node.kubernetes.io/instance-type]

Restart the metering workload afterwards so it re-resolves:

kubectl -n astro-cost rollout restart deploy/cost-visibility-opencost

A centralized backend that contains multiple clusters needs a query endpoint or frontend already scoped to this cluster. Phase 1 deliberately rejects mixed cluster results rather than silently attributing another cluster's usage. Use the full-URL option for a query frontend or authentication proxy; credentials cannot be embedded in the endpoint.

The source is reconfigurable. Use Configure in the console or edit and reapply the manifest to rotate an endpoint or move between the three modes. Expect a visible data gap for any closed hour that cannot be collected while a replacement source is unavailable.

Troubleshooting

Cost reads unpriced. No pricing is configured, or what was configured could not load. This is deliberate: a figure derived from rates nobody chose is worse than no figure. Note that closed windows keep the rates they were priced with, so rates added now apply to the next window, not to history.

No cost at all, add-on healthy — right after installing. Expected for up to an hour. Spend is reported one closed hour at a time, so the first figures appear once the current hour ends; installing at 10:50 means waiting until 11:00, installing at 10:05 means most of an hour. Usage, allocation and utilization are measured continuously and are available immediately — it is only the money that waits.

This differs from running a collector yourself, which will happily show a partial, still-moving hour. Only closed hours are shipped here so a figure never changes after you have read it, which is what makes the history worth comparing against. The console says the same thing while it waits rather than showing an empty chart.

A configuration change never applies, or the add-on sits in DEPLOYING. The preflight validates a metrics backend before switching to it and refuses rather than replacing a working source with a broken one. Read why:

kubectl -n astro-cost get jobs
kubectl -n astro-cost logs job/cost-visibility-source-preflight

That Job is removed once it succeeds, so its absence on a healthy add-on is normal. The usual cause of failure is a bring-your-own Prometheus missing a required series — check all four:

for m in container_cpu_usage_seconds_total container_memory_working_set_bytes \
kube_pod_owner node_cpu_seconds_total; do
echo -n "$m: "
curl -s "$PROM/api/v1/query?query=count($m)" | jq -r '.data.result[0].value[1] // "MISSING"'
done

node_cpu_seconds_total missing means no node-exporter; kube_pod_owner means no kube-state-metrics. Switching to the Astro-managed stack installs both.

Every node reports no cost. kube_node_labels is absent — see kube-state-metrics needs node labels enabled. Applies only to a backend you bring.

A rate was rejected. Rates are non-negative decimals such as 0.031611, not $0.03 or 0.03/hr. CPU and memory are required together: a rate left out is priced at zero, so pricing one alone reports the other as free.

Spot rates seem ignored. They apply only to nodes carrying the spot label you declared:

kubectl get nodes -o json | jq -r '.items[].metadata.labels | keys[]' \
| sort -u | grep -iE 'spot|preempt|capacity'

A billing export was rejected. On GCP the table must be the _resource_ one. On AWS all of bucket, database and table are required.

Checking what the metering stack was actually configured with. Rates, the billing integration, and the endpoint it queries are all visible in the cluster:

# the rates in effect — yours, with unset ones at 0
kubectl -n astro-cost get cm custom-pricing-model -o jsonpath='{.data}'

# which Prometheus it queries, and whether cloud billing is switched on
kubectl -n astro-cost get deploy cost-visibility-opencost \
-o jsonpath='{range .spec.template.spec.containers[*].env[*]}{.name}={.value}{"\n"}{end}' \
| grep -E 'PROMETHEUS|CLOUD_COST|CUSTOM_COST'

# what it is currently reporting, priced
kubectl -n astro-cost exec deploy/cost-visibility-opencost -- \
wget -qO- 'http://localhost:9003/allocation/compute?window=15m&aggregate=namespace'

A non-zero totalCost there with unpriced in the console means the rates are applied and the hour simply has not closed yet. Zero everywhere with rates present usually means node resolution failed — check kube_node_labels.

A wrong Prometheus endpoint. The add-on refuses to switch to it, so cost keeps flowing from the previous source and nothing is lost. The endpoint must be reachable from inside the cluster and carry this cluster's series only:

kubectl -n astro-cost exec deploy/cost-visibility-opencost -- \
wget -qO- "$PROM/api/v1/query?query=count(kube_pod_owner)"

Empty means unreachable or empty; a value means it is queryable. A backend holding several clusters needs an endpoint already scoped to this one — mixed results are rejected rather than silently attributing another cluster's usage to yours.

Install

Three routes, same result: pick whichever you prefer.

Console (UI)

  1. Open your cluster and go to the Add-ons tab.
  2. Click Install Add-on and pick from the searchable, category-grouped catalog (Networking, Security, Autoscaling). Add-ons that don't fit this cluster's cloud are marked incompatible.
  3. Each add-on installs with sensible defaults: the form shows only the choice that matters (for NGINX, whether TLS is terminated at the ingress or passed through). Open Advanced settings to tune anything the API supports: sizing/autoscaling, node selectors, the cert-manager ClusterIssuer, the ExternalDNS domain filter, and so on. Inputs are validated inline.
  4. After install, the add-on's row opens a detail view with live Events, Logs, and History (roll back from there).

CLI (manifest)

Declare the add-on in a small K8sClusterAddon manifest and apply it: good for GitOps and reproducible setups. Ready-to-copy examples for every add-on, with all options annotated, live in the public astro-platform-apps repo.

cert-manager.yaml
apiVersion: platform.astropulse.io/v1
kind: K8sClusterAddon
metadata:
name: cert-manager # which add-on
spec:
clusterName: production # which cluster
configuration:
certManager:
installCRDs: true
astroctl infra k8s addons apply -f cert-manager.yaml
astroctl infra k8s addons status production cert-manager # watch it converge

apply is declarative and idempotent: edit spec.version or the configuration and apply the same file again to upgrade or reconfigure.

Nova (AI)

Describe what you want in plain language. Nova generates and applies the same add-on intent the console and CLI produce (the manifest, the configuration, the apply), then reports back on health once it converges.

"Install NGINX, cert-manager with a Let's Encrypt issuer, and ExternalDNS for example.com on my production cluster."

Test it locally (kind / any cluster without a cloud load balancer)

You can exercise the whole flow on a local cluster (for example kind), no cloud account needed:

  • NGINX: install with Expose via NodePort enabled (Advanced settings, or nginx.enableNodePort: true). A local cluster has no cloud LoadBalancer, so NodePort is how the controller is reachable. Then port-forward it and curl:

    kubectl -n ingress-nginx port-forward svc/ingress-nginx-controller 8080:80
    curl -H 'Host: myapp.local' http://localhost:8080/ # routes to your app's Ingress
  • cert-manager: enable a ClusterIssuer with issuerType: selfsigned. It needs no ACME account, public DNS, or email, so astro-selfsigned becomes Ready entirely offline, ideal for validating TLS wiring in kind.

  • ExternalDNS: skip it locally. It needs a real cloud DNS zone and cloud identity to create records, which a local cluster doesn't have.

Deploy any app with an Ingress (ingressClassName: nginx, a host:), and it's reachable through the NodePort exactly as it would be through a cloud LoadBalancer in production.

Observe

astroctl infra k8s addons get production # all add-ons, health at a glance
astroctl infra k8s addons get production nginx-ingress # one add-on in detail
astroctl infra k8s addons logs production nginx-ingress # workload logs
astroctl infra k8s addons events production nginx-ingress # deployment + K8s events
astroctl infra k8s addons history production nginx-ingress # version history

You can also ask Nova to show an add-on's health, logs, or version history.

Upgrade and roll back

astroctl infra k8s addons versions production cert-manager # curated versions
# then set spec.version in your manifest and re-apply
astroctl infra k8s addons rollback production cert-manager # previous eligible version

The console shows an Upgrade action on an installed add-on whenever a newer curated version is available.

Nova can upgrade or roll back an add-on the same way. Because rollback is destructive, Nova will ask you to confirm the exact version to roll back to: you choose the target; it is never implicit.

Uninstall

astroctl infra k8s addons delete production cert-manager

Uninstall is destructive, so via Nova, Nova hands you the exact console or CLI step to run and you confirm it, rather than deleting the add-on on its own.

Add-ons are independent: each one installs and uninstalls on its own. ExternalDNS, for example, works with any ingress controller or plain LoadBalancer Services, so it neither requires NGINX Ingress nor blocks its removal. One rule protects your cluster:

  • Karpenter is cluster-managed on EKS. On EKS clusters provisioned by AstroPulse it is installed as part of the cluster itself, appears here for visibility and upgrades, and cannot be deleted on its own; it is removed when the cluster is deleted. On AWS self-hosted clusters Karpenter is a regular add-on: install and uninstall it like any other.

ExternalDNS: DNS automation

ExternalDNS watches your Ingresses and Services and creates the matching records in your cloud DNS zone, so a hostname you put on an Ingress becomes a live DNS record automatically. It runs on your cluster and authenticates to your DNS provider with the cluster's own identity: AstroPulse never receives, stores, or asks for DNS credentials, and cannot act on your DNS account itself.

Before you install: give the cluster permission to your DNS

ExternalDNS runs on your cluster and needs an identity that is allowed to change records in your DNS zone. This is the one thing you must set up yourself: ExternalDNS deploys and runs healthy without it, but creates no records until the identity exists. It is also the most common reason people say "I installed it and nothing happened."

Identity only: the managed add-on never takes a secret

The managed add-on authenticates only with the cluster's ambient cloud identity: IRSA / EKS Pod Identity (AWS), Workload Identity (GCP/Azure), or a node role. It has no credential fields, and AstroPulse never receives, mounts, or stores DNS credentials. This is a deliberate security decision. There is nothing to leak.

Need to authenticate with a static access key or Secret instead? Then don't use the managed add-on: see Route B below.

Step 1 — Pick your route

Which route applies depends on whether your cluster has workload/ambient identity available. That is independent of who created the cluster: you can provision an AstroPulse cluster with or without IRSA / Workload Identity enabled, and bring-your-own clusters vary by how they were built. Check what your cluster actually has, then follow the matching route:

Your clusterWorkload / ambient identityRoute
AstroPulse-provisioned with IRSA / Workload IdentityAvailableA: grant DNS permission to the identity
AstroPulse-provisioned without IRSA / Workload IdentityNot enabledEnable it on the cluster, then A, or use B
Registered / bring-your-own — EKS, GKE, AKSUsually available (IRSA / WI)A
Registered / bring-your-own — kOps / self-managed on a cloudNode role availableA (node role)
On-prem, bare-metal, or a provider with only static API keysNoneB

Route A — Workload / ambient identity (use the managed add-on)

Set up the identity for your cluster's cloud and grant it DNS permission. In every case the identity binds to the external-dns service account in the external-dns namespace (where the add-on runs). On AstroPulse-provisioned clusters that already have identity enabled, astroctl cloud <provider> connect --cluster <name> can wire the trust relationship for you. You then only attach the DNS permission below.

AWS · Route 53: IRSA / Pod Identity (EKS) or node role (self-managed)

  1. Create an IAM policy allowing ExternalDNS to update Route 53: route53:ChangeResourceRecordSets on your hosted zone, plus route53:ListHostedZones, route53:ListResourceRecordSets, and route53:ListTagsForResources. Scope the zone ARN to your zone(s) for least privilege.
  2. EKS: ensure the cluster's OIDC provider is associated, create an IAM role trusting system:serviceaccount:external-dns:external-dns, and attach the policy. The chart annotates the service account, so the pod assumes the role automatically. (astroctl cloud aws connect can set up the OIDC trust on provisioned clusters.)
  3. Self-managed / kOps: attach the policy to the node instance role instead. ExternalDNS picks it up from instance metadata. No IRSA required.

GCP · Cloud DNS: Workload Identity (GKE)

  1. Enable Workload Identity on the cluster.
  2. Create a Google service account and grant it DNS Administrator (roles/dns.admin) on the project that owns the Cloud DNS zone.
  3. Bind it to external-dns/external-dns (namespace/serviceaccount) via Workload Identity (iam.gke.io/gcp-service-account annotation + roles/iam.workloadIdentityUser).

Azure · Azure DNS: Workload Identity (AKS)

  1. Enable the OIDC issuer and Workload Identity on the cluster and create a managed identity.
  2. Assign it DNS Zone Contributor on the DNS zone and Reader on the zone's resource group.
  3. Create a federated credential binding the identity to system:serviceaccount:external-dns:external-dns.

Once the identity is in place, install ExternalDNS with Dry run on (below), confirm from its logs that it sees your zone, then re-apply with Dry run off.

Route B — Static credentials (deploy ExternalDNS as an app)

If your cluster has no workload/ambient identity, or you specifically want to authenticate with a DNS API key or Secret, the managed add-on is not the right tool. It is intentionally credential-free. Instead, deploy ExternalDNS yourself as a regular AstroPulse application, with your credential mounted as a Kubernetes Secret and referenced by the chart values. Ready-to-adapt manifests live in the astro-platform-apps repo (apps/external-dns/aws.yaml, apps/external-dns/gcp.yaml). Applications are yours to run however you like; the managed add-on stays credential-free.

Configuration options

OptionWhat it is
DNS providerWhich cloud DNS service manages your zone: AWS Route 53, Google Cloud DNS, or Azure DNS. Defaults to your cluster's cloud.
Domain filterAn exact DNS zone/domain suffix, e.g. example.com. ExternalDNS then manages only records at or below it (example.com, app.example.com, …). It is a suffix match, not a regex. No wildcards or patterns. Leave empty to manage every zone the identity can reach (not recommended).
Dry runExternalDNS logs the records it would create, update, or delete and writes nothing. Turn it on for the first install to validate the identity and domain filter, then re-apply with it off to go live.
Owner ID (automatic)ExternalDNS tags each record it manages with an owner ID (defaults to the cluster name) so it never touches records another cluster or tool owns. This matters when several clusters share one DNS zone; you don't set it.

How records get created

Deploy your app with a hostname under your Domain filter: an Ingress host: (NGINX Ingress powers custom domains) or a external-dns.alpha.kubernetes.io/hostname annotation on a Service. ExternalDNS reads it and creates the matching A/CNAME record in your zone. Remove the Ingress/Service and the record is cleaned up.

Recipe: automatic public HTTPS

Install NGINX Ingress, ExternalDNS, and cert-manager together and a new app gets a public DNS name and a trusted TLS certificate with no manual steps: you only declare the hostname on the Ingress. The three compose like this: ExternalDNS publishes the DNS record, cert-manager obtains the certificate via Let's Encrypt, and NGINX terminates HTTPS with it.

  1. ExternalDNS: set the Domain filter to your zone (e.g. example.com) and give the cluster DNS permission (see the setup above).
  2. cert-manager: enable Create a Let's Encrypt ClusterIssuer with an ACME contact email. The add-on creates a ClusterIssuer named astro-letsencrypt whose ACME HTTP-01 challenge is solved through the nginx ingress class, so it works the moment NGINX is installed.
  3. NGINX Ingress: install with defaults (ingress class nginx).

Then give any app an Ingress that references both; ExternalDNS reads host, cert-manager reads the cluster-issuer annotation and tls block:

app-ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: my-app
annotations:
cert-manager.io/cluster-issuer: astro-letsencrypt # cert-manager issues the cert
spec:
ingressClassName: nginx # NGINX terminates TLS
tls:
- hosts: [app.example.com]
secretName: my-app-tls # cert-manager fills this in
rules:
- host: app.example.com # ExternalDNS publishes this
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: my-app
port:
number: 80

Apply it and, within a minute or two: app.example.com resolves to the NGINX load balancer, my-app-tls is populated with a Let's Encrypt certificate, and https://app.example.com serves trusted TLS. For a self-signed internal issuer instead, set cert-manager's ClusterIssuer type to self-signed and reference astro-selfsigned.

Karpenter cloud access

Like ExternalDNS, Karpenter reaches AWS with the cluster's own identity (an IRSA role or the node role with EC2/pricing/interruption-queue permissions), set up as part of the EKS cluster. AstroPulse never places AWS credentials in the add-on configuration.