Docs

Build a Kubernetes agent

This example builds an agent that runs in each customer's Kubernetes cluster: they install it once with helm install, and you update it on every release without asking them again. It reports each cluster's pods, deployments, and nodes to your API and answers your backend's questions on demand.

Architecture diagram. Left: Your cloud, Your API, Alien Manager. Right: Customer cluster containing acme-agent namespace containing agent; Operator; Kubernetes API.

The agent reads the Kubernetes API and connects out. The Operator, which the chart installs, pulls releases and sends logs.

You build a small TypeScript agent that:

  • Posts a cluster inventory to your API every five minutes.
  • Answers two commands, named calls from your backend: list-pods and sync-now.
  • Runs with read-only cluster access and an enrollment token that the chart generates.

Compared with shipping your own chart, you get:

  • Updates without the customer. Every alien release reaches every cluster on the release channel at the next sync of the Operator, a small Alien process the chart installs in the cluster. It syncs about every 30 seconds.
  • One view of every copy. Each cluster is a deployment with a status, a heartbeat, the release it runs, and its logs, in the dashboard and from alien deployments ls.
  • Read-only access, generated. The chart creates a ClusterRole with get, list, and watch on the resource types you name. It can't grant writes or Secrets.
  • Outbound only. The agent and the Operator open every connection over HTTPS 443. The cluster needs no inbound port, VPN, or load balancer.

Declare it

The stack is one live Container on the kubernetes platform:

alien.ts
import * as alien from "@alienplatform/core"

const agent = new alien.Container("agent")
  .code({ type: "source", src: ".", toolchain: { type: "typescript" } })
  .cpu(0.25)
  .memory("256Mi")
  .replicas(1)
  .port(8080)
  .environment({
    ACME_API_URL: "https://api.acme.com",
    // Trust the cluster's CA when calling the Kubernetes API.
    NODE_EXTRA_CA_CERTS: "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt",
  })
  .kubernetesSecretMount({ secretName: "acme-agent-token", mountPath: "/etc/acme" })
  .kubernetesLivenessProbe({ path: "/healthz", port: 8080 })
  .security({
    profile: "restricted",
    runAsUser: 10001,
    runAsGroup: 10001,
    readOnlyRootFilesystem: true,
  })
  .commandsEnabled(true)
  .permissions("agent")
  .build()

export default new alien.Stack("acme-cluster-agent")
  .platforms(["kubernetes"])
  .add(agent, "live")
  .permissions({ profiles: { agent: {} } })
  .build()
  • .platforms(["kubernetes"]) builds a Helm chart that installs Alien's Operator, which deploys and updates the agent.
  • .kubernetesSecretMount() mounts the enrollment Secret read-only. Releases never overwrite it.
  • .security() runs the agent as UID 10001 with a read-only root filesystem.
  • NODE_EXTRA_CA_CERTS points at the CA that Kubernetes mounts into every pod. The TypeScript toolchain compiles the agent with Bun, which needs it to trust the Kubernetes API's certificate.
  • .permissions("agent") gives the agent its own ServiceAccount. The agent profile grants no cloud permissions; cluster access comes from the chart (see Set up the chart).
  • .replicas(1) keeps one agent per cluster.

Project setup

You need:

  • The alien CLI, signed in with alien login.
  • Bun. The TypeScript toolchain compiles the agent with it.
  • Node 24 or later, to run the agent on your laptop.
  • For the local run: kind, or any cluster in your kubeconfig.

Create the project:

package.json
{
  "name": "acme-cluster-agent",
  "private": true,
  "type": "module",
  "dependencies": {
    "@alienplatform/commands": "^3.3.41",
    "@kubernetes/client-node": "^2.0.1",
    "zod": "^4.1.0"
  },
  "devDependencies": {
    "@alienplatform/core": "^3.3.41",
    "@types/node": "^24.0.0",
    "typescript": "^5.9.0"
  }
}
npm install

Write the agent

The whole agent is one file. It reports inventory, answers commands, and serves a health check:

  • Every five minutes, it lists nodes, deployments, and pods, and posts a summary to your API with the enrollment token as a bearer token.
  • It registers two commands: list-pods returns the pods in one namespace, and sync-now sends a report right away.
  • It answers /healthz on port 8080 for the liveness probe.
src/index.ts
import { readFileSync } from "node:fs"
import { createServer } from "node:http"
import { createCommandReceiver } from "@alienplatform/commands"
import * as k8s from "@kubernetes/client-node"
import { z } from "zod"

const apiUrl = process.env.ACME_API_URL ?? "https://api.acme.com"
const token = readFileSync(process.env.ACME_TOKEN_FILE ?? "/etc/acme/token", "utf8").trim()
const syncIntervalMs = 5 * 60_000

// In the pod this uses the agent's ServiceAccount. On your laptop, your kubeconfig.
const kc = new k8s.KubeConfig()
kc.loadFromDefault()
const core = kc.makeApiClient(k8s.CoreV1Api)
const apps = kc.makeApiClient(k8s.AppsV1Api)

function summarizePod(pod: k8s.V1Pod) {
  return {
    namespace: pod.metadata?.namespace,
    name: pod.metadata?.name,
    phase: pod.status?.phase,
    node: pod.spec?.nodeName,
    restarts: pod.status?.containerStatuses?.reduce((sum, c) => sum + c.restartCount, 0) ?? 0,
  }
}

async function collectInventory() {
  const [kubeSystem, nodes, deployments, pods] = await Promise.all([
    core.readNamespace({ name: "kube-system" }),
    core.listNode(),
    apps.listDeploymentForAllNamespaces(),
    core.listPodForAllNamespaces(),
  ])
  return {
    // The kube-system namespace UID is a stable ID for the cluster.
    clusterId: kubeSystem.metadata?.uid,
    nodes: nodes.items.map(node => ({
      name: node.metadata?.name,
      kubeletVersion: node.status?.nodeInfo?.kubeletVersion,
      ready: node.status?.conditions?.some(c => c.type === "Ready" && c.status === "True") ?? false,
    })),
    deployments: deployments.items.map(d => ({
      namespace: d.metadata?.namespace,
      name: d.metadata?.name,
      replicas: d.spec?.replicas ?? 0,
      readyReplicas: d.status?.readyReplicas ?? 0,
      images: d.spec?.template.spec?.containers.map(c => c.image) ?? [],
    })),
    pods: pods.items.map(summarizePod),
  }
}

async function report() {
  const inventory = await collectInventory()
  const res = await fetch(`${apiUrl}/v1/clusters/inventory`, {
    method: "POST",
    headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
    body: JSON.stringify({
      // Set by Alien. Your backend keeps it to send this cluster commands.
      deploymentId: process.env.ALIEN_DEPLOYMENT_ID,
      collectedAt: new Date().toISOString(),
      ...inventory,
    }),
  })
  if (!res.ok) throw new Error(`inventory upload failed with HTTP ${res.status}`)
  console.log(`reported ${inventory.nodes.length} nodes, ${inventory.pods.length} pods`)
}

async function reportForever() {
  for (;;) {
    await report().catch(err => console.error("report failed:", err))
    await new Promise(resolve => setTimeout(resolve, syncIntervalMs))
  }
}

function commandReceiver() {
  const receiver = createCommandReceiver()

  receiver.command(
    "list-pods",
    z.object({ namespace: z.string(), labelSelector: z.string().optional() }),
    async ({ namespace, labelSelector }) => {
      const pods = await core.listNamespacedPod({ namespace, labelSelector })
      return { pods: pods.items.map(summarizePod) }
    },
  )

  receiver.command("sync-now", async () => {
    await report()
    return { ok: true }
  })

  return receiver
}

async function main() {
  createServer((req, res) => res.writeHead(req.url === "/healthz" ? 200 : 404).end()).listen(8080)
  // Alien sets ALIEN_COMMANDS_URL in the cluster. On your laptop, run without commands.
  const commands = process.env.ALIEN_COMMANDS_URL ? commandReceiver().run() : undefined
  await Promise.all([reportForever(), commands])
}

main().catch(err => {
  console.error(err)
  process.exit(1)
})
  • loadFromDefault() uses your kubeconfig on your laptop. In the cluster there is none, so it uses the pod's ServiceAccount, which the chart grants read access.
  • The token file is the Secret the chart generates, mounted at /etc/acme.
  • ALIEN_DEPLOYMENT_ID and ALIEN_COMMANDS_URL are set by Alien in the cluster, because the agent has commands enabled. The report carries the deployment ID, so your API can store it next to the cluster and send it commands later.
  • createCommandReceiver() reads its configuration from those variables and leases commands for this Container over outbound HTTPS. See Containers and Daemons.
  • The report holds names, phases, images, and restart counts. Full object specs can hold environment values, so they stay in the cluster.

Run it locally

Run the agent against a local cluster before you release. .security() and the Kubernetes-only settings don't run under alien dev, so start the process with Node, which runs TypeScript directly.

Create a cluster and a token file:

kind create cluster --name acme-dev
printf 'acme_dev_token' > dev-token

In one terminal, start a stand-in for your API that prints what it receives:

node -e 'require("node:http").createServer((req, res) => { let body = ""; req.on("data", c => (body += c)).on("end", () => { console.log(req.method, req.url, req.headers.authorization, body.slice(0, 120)); res.end("ok") }) }).listen(4000)'

In a second terminal, start the agent:

ACME_API_URL=http://localhost:4000 ACME_TOKEN_FILE=./dev-token node src/index.ts
reported 1 nodes, 9 pods

The stand-in prints the request:

POST /v1/clusters/inventory Bearer acme_dev_token {"collectedAt":"…","clusterId":"…","nodes":[{"name":"acme-dev-control-plane",…

Without ALIEN_COMMANDS_URL, the agent skips the command receiver.

Set up the chart

Tell Alien what the chart should create before the agent starts: the enrollment Secret, the read-only ClusterRole, and log collection.

packages.json
{
  "helm": {
    "setupResources": {
      "tokenSecret": { "name": "acme-agent-token", "key": "token", "prefix": "acme_", "randomLength": 40 },
      "workloadReadAccess": {
        "serviceAccountProfile": "agent",
        "rules": [
          { "apiGroup": "", "resources": ["pods", "namespaces", "nodes"] },
          { "apiGroup": "apps", "resources": ["deployments"] }
        ]
      }
    },
    "logCollector": { "enabled": true, "mode": "podApi" }
  }
}
alien link
alien projects packages update --file packages.json
  • The rules match what the agent reads: nodes, pods, and the kube-system namespace in the core group, deployments in apps. Verbs are always get, list, and watch.
  • The Secret's name matches the secretName in alien.ts. The chart refuses to build if no Container with the agent profile mounts it.
  • podApi makes the Operator read the agent's logs through the Kubernetes API, with no DaemonSet.

The chart is part of the initial setup, the one-time step the customer's admin runs: a release can't add a frozen resource or a permission to an existing Helm install. Keep releases to the agent's code and settings.

Release

alien release

This compiles the agent for Linux on x64 and ARM64, pushes the image to Alien, creates the release, and moves the production channel to it.

Alien then builds the Helm chart with the settings above. Artifacts in the dashboard, or alien packages list, shows when it's ready.

Deploy to a cluster

Create a setup link for the customer, a URL that opens the setup page Alien hosts for their admin:

alien onboard customer-1 --platforms kubernetes

The customer's admin runs the initial setup once:

Open the link. It opens the deployment portal. Choose On-Prem, then Compute → Kubernetes, and save values.yaml.

Run the command the portal shows:

helm install acme-cluster-agent <chart-from-portal> \
  --namespace acme-agent --create-namespace \
  --values values.yaml --wait

Read the enrollment token with the command the chart's notes print:

kubectl -n acme-agent get secret acme-agent-token -o jsonpath='{.data.token}' | base64 -d

Paste the token into your product. Your product ties that token, and the cluster sending it, to their organization.

Your API should accept reports only for enrolled tokens.

In a minute or two the deployment shows Running:

alien deployments ls
alien status customer-1/customer-1

The deployment name defaults to the customer's name, so this one is customer-1/customer-1. In the cluster, the Operator and the agent run in acme-agent:

kubectl -n acme-agent get pods

Call it

Ask the agent for the pods in one namespace:

alien commands invoke --deployment customer-1/customer-1 \
  --command list-pods --params '{"namespace": "kube-system"}'

Create the API key with alien api-keys create --for deployments and keep it on your server.

Read its logs

alien logs --deployment customer-1/customer-1 --since 15m

In the dashboard, open Operations → Logs and filter by the deployment. Each reported … nodes, … pods line and each report failed: error appears there.

Ship an update

Change the agent, for example the sync interval, and release:

alien release

This is ongoing management: each cluster's Operator picks up the release on its next sync and rolls the agent. Nobody runs helm upgrade. The enrollment Secret, and with it the agent's identity, stays the same.

Follow the rollout:

alien releases get <release-id>
alien deployments ls

Before production

  • Buffer reports. Keep the last inventory in memory and retry with backoff when your API is down. Make your API accept the same report twice.
  • Page large clusters. listPodForAllNamespaces() returns every pod in one response. Pass limit and follow _continue, or switch to an informer that watches for changes.
  • Allow revoking a cluster. Let the customer remove an enrolled token in your product, and reject reports with it.
  • Keep commands read-only. For actions that change the cluster, use Remote Operator operations, which carry an approval policy.

Next

On this page