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-on | What it does | Clusters |
|---|---|---|
NGINX Ingress (nginx-ingress) | HTTP/S ingress controller; routes external traffic to your apps and powers custom domains | All |
ExternalDNS (external-dns) | Publishes DNS records for your services and ingresses | All |
cert-manager (cert-manager) | Issues and renews TLS certificates automatically | All |
Karpenter (karpenter) | Just-in-time node autoscaling | AWS (EKS and self-hosted) |
Cost Visibility (cost-visibility) | Measures cluster and application resource allocation, usage, and cost | Provisioned 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-on | Namespace | Service account |
|---|---|---|
| nginx-ingress | ingress-nginx | ingress-nginx |
| external-dns | external-dns | external-dns |
| cert-manager | cert-manager | cert-manager |
| karpenter | kube-system | karpenter |
| cost-visibility | astro-cost | Managed 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: trueto 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: truewithissuerType: letsencrypt(a public Let's Encrypt ACME issuer;emailis required for expiry notices) orissuerType: 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:
| Selection | What AstroPulse deploys | What you provide |
|---|---|---|
| Astro-managed | The metering stack and a scoped Prometheus | Nothing |
| Existing Prometheus-compatible backend | The metering stack only | A 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:
| Mode | Rates come from | Setup | Ongoing cost |
|---|---|---|---|
| Not priced | — | none | none |
| Your own rates | you | a few numbers | none |
| Your cloud bill | your provider's invoice | a billing export | provider 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.
| Cluster | Not priced | Your own rates | Your 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 type | Nodes | $ / hour | vCPU | GB 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
- AWS
- Azure
- On-premises
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¤cyCode=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
AWS prices whole instances, so fetch the instance price and split it. The
Price List API works with your existing credentials (it is served only from
us-east-1 and ap-south-1, regardless of which region you are pricing):
aws pricing get-products --region us-east-1 --service-code AmazonEC2 \
--filters Type=TERM_MATCH,Field=instanceType,Value=m5.xlarge \
Type=TERM_MATCH,Field=location,Value="US East (N. Virginia)" \
Type=TERM_MATCH,Field=operatingSystem,Value=Linux \
Type=TERM_MATCH,Field=tenancy,Value=Shared \
Type=TERM_MATCH,Field=preInstalledSw,Value=NA \
Type=TERM_MATCH,Field=capacitystatus,Value=Used \
--query 'PriceList[0]' --output text \
| jq -r '.terms.OnDemand[].priceDimensions[].pricePerUnit.USD'
0.1920000000
Then split it across the instance's vCPU and memory:
m5.xlarge $0.192 / hour 4 vCPU, 16 GiB
CPU 0.192 × 0.70 ÷ 4 = $0.0336 per core-hour
RAM 0.192 × 0.30 ÷ 16 = $0.0036 per GB-hour
The 70/30 split is a convention rather than a published figure — it approximates how general-purpose instances are weighted. Compute-optimised fleets sit nearer 80/20, memory-optimised nearer 50/50. What matters is that the two products sum back to the instance price, so cluster totals stay correct even if the split between CPU and memory is approximate — only the attribution between a CPU-heavy and a memory-heavy workload shifts.
EBS gp3 is $0.08 per GB-month → ÷ 730 = $0.00011 per GB-hour.
Azure's Retail Prices API is public — no credentials, no account needed:
curl -s "https://prices.azure.com/api/retail/prices?\$filter=\
armRegionName eq 'eastus' and armSkuName eq 'Standard_D4s_v5' \
and priceType eq 'Consumption'" \
| jq -r '.Items[] | "\(.retailPrice)\t\(.meterName)"'
0.192 D4s v5
0.079374 D4s v5 Spot
0.376 D4s v5 (Windows)
Take the Linux on-demand row and split it:
D4s v5, East US $0.192 / hour 4 vCPU, 16 GiB
CPU 0.192 × 0.70 ÷ 4 = $0.0336 per core-hour
RAM 0.192 × 0.30 ÷ 16 = $0.0036 per GB-hour
That same response gives you the spot rate, so you can fill in the spot
fields at the same time — $0.079374 against $0.192 is 59% cheaper, which
is exactly the gap a flat sheet would otherwise miss.
Managed disk prices are per month → divide by 730.
There is no rate card, so use whatever your organisation charges back. Amortise hardware and hosting over its useful life:
Server $12,000 over 3 years = $0.457 / hour
Power, cooling, space, support = $0.150 / hour
─────────────
$0.607 / hour for 64 cores, 256 GB
CPU 0.607 × 0.70 ÷ 64 = $0.00664 per core-hour
RAM 0.607 × 0.30 ÷ 256 = $0.00071 per GB-hour
Storage, similarly: total array cost ÷ usable GB ÷ hours in the amortisation period.
Rates you choose here are as valid as any provider's. The point is that figures reconcile against something your finance team already recognises.
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:
| Provider | Label | Value |
|---|---|---|
| GKE | cloud.google.com/gke-provisioning | spot |
| EKS | eks.amazonaws.com/capacityType | SPOT |
| AKS | kubernetes.azure.com/scalesetpriority | spot |
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
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.
unpricedA 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, AWS — account 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.
- GCP
- AWS
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.
Enable a Cost and Usage Report to S3 with Athena integration, then bind the identity in your own account:
eksctl create iamserviceaccount \
--name cost-visibility-opencost --namespace astro-cost \
--cluster my-cluster --approve \
--attach-policy-arn arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess \
--attach-policy-arn arn:aws:iam::aws:policy/AmazonAthenaFullAccess
configuration:
costVisibility:
billingExport:
bucket: my-cur-reports
database: athenacurcfn_my_cur
table: my_cur
# The 12-digit account owning the report. The metering stack refuses an
# Athena configuration without it, and the cluster record has no account
# number to fall back on.
account: "111122223333"
# The role assumed through IRSA. An identifier, not a credential: it
# grants nothing without the trust policy you create in your own account.
workloadIdentity: arn:aws:iam::111122223333:role/astro-cost-visibility
region is optional and defaults to the cluster's own. Set it when the report
lives elsewhere — a bucket in us-east-1 read from a cluster in eu-west-1 is
an ordinary setup, and the default would point Athena at the wrong region.
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:
| Requirement | If it is wrong | |
|---|---|---|
| Namespace | astro-cost — the workload only sees its own namespace | The pod never starts |
| Key | exactly cloud-integration.json | The pod stays in ContainerCreating |
| Exclusivity | secretName alone, no typed export fields | The 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.
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
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)
- Open your cluster and go to the Add-ons tab.
- 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.
- 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.
- 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.
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:80curl -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, soastro-selfsignedbecomes 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."
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 cluster | Workload / ambient identity | Route |
|---|---|---|
| AstroPulse-provisioned with IRSA / Workload Identity | Available | A: grant DNS permission to the identity |
| AstroPulse-provisioned without IRSA / Workload Identity | Not enabled | Enable it on the cluster, then A, or use B |
| Registered / bring-your-own — EKS, GKE, AKS | Usually available (IRSA / WI) | A |
| Registered / bring-your-own — kOps / self-managed on a cloud | Node role available | A (node role) |
| On-prem, bare-metal, or a provider with only static API keys | None | B |
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)
- Create an IAM policy allowing ExternalDNS to update Route 53:
route53:ChangeResourceRecordSetson your hosted zone, plusroute53:ListHostedZones,route53:ListResourceRecordSets, androute53:ListTagsForResources. Scope the zone ARN to your zone(s) for least privilege. - 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 connectcan set up the OIDC trust on provisioned clusters.) - 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)
- Enable Workload Identity on the cluster.
- Create a Google service account and grant it DNS Administrator
(
roles/dns.admin) on the project that owns the Cloud DNS zone. - Bind it to
external-dns/external-dns(namespace/serviceaccount) via Workload Identity (iam.gke.io/gcp-service-accountannotation +roles/iam.workloadIdentityUser).
Azure · Azure DNS: Workload Identity (AKS)
- Enable the OIDC issuer and Workload Identity on the cluster and create a managed identity.
- Assign it DNS Zone Contributor on the DNS zone and Reader on the zone's resource group.
- 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
| Option | What it is |
|---|---|
| DNS provider | Which cloud DNS service manages your zone: AWS Route 53, Google Cloud DNS, or Azure DNS. Defaults to your cluster's cloud. |
| Domain filter | An 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 run | ExternalDNS 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.
- ExternalDNS: set the Domain filter to your zone (e.g.
example.com) and give the cluster DNS permission (see the setup above). - cert-manager: enable Create a Let's Encrypt ClusterIssuer with an
ACME contact email. The add-on creates a ClusterIssuer named
astro-letsencryptwhose ACME HTTP-01 challenge is solved through thenginxingress class, so it works the moment NGINX is installed. - 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:
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.