diff --git a/.devcontainer/kubernetes-agent/devcontainer.json b/.devcontainer/kubernetes-agent/devcontainer.json new file mode 100644 index 0000000..b7ae4a6 --- /dev/null +++ b/.devcontainer/kubernetes-agent/devcontainer.json @@ -0,0 +1,14 @@ +{ + "name": "Cortex Kubernetes Agent Demo", + "image": "mcr.microsoft.com/devcontainers/base:ubuntu-24.04", + "features": { + "ghcr.io/devcontainers/features/docker-in-docker:2": {}, + "ghcr.io/devcontainers/features/sshd:1": {} + }, + "onCreateCommand": "bash .devcontainer/kubernetes-agent/onCreate.sh", + "remoteEnv": { + "CORTEX_API_KEY": "${localEnv:CORTEX_API_KEY}", + "GHCR_TOKEN": "${localEnv:GHCR_TOKEN}" + }, + "postCreateMessage": "kind cluster is ready. Run cortex solutions post-install -s kubernetes-agent from your local machine." +} diff --git a/.devcontainer/kubernetes-agent/onCreate.sh b/.devcontainer/kubernetes-agent/onCreate.sh new file mode 100644 index 0000000..2a2d05a --- /dev/null +++ b/.devcontainer/kubernetes-agent/onCreate.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Log all output so failures are diagnosable: cat /tmp/onCreate.log +exec > >(tee /tmp/onCreate.log) 2>&1 + +# Write a failure sentinel on non-zero exit so setup.py can detect it fast +_on_exit() { + local rc=$? + if [[ $rc -ne 0 ]]; then + echo "$rc" > /tmp/onCreate.failed + fi +} +trap _on_exit EXIT + +ARCH=$(uname -m) +BIN_ARCH="amd64" +[ "$ARCH" = "aarch64" ] && BIN_ARCH="arm64" + +echo "==> Installing kubectl..." +curl -Lo /tmp/kubectl "https://dl.k8s.io/release/$(curl -Ls https://dl.k8s.io/release/stable.txt)/bin/linux/${BIN_ARCH}/kubectl" +sudo install -o root -g root -m 0755 /tmp/kubectl /usr/local/bin/kubectl + +echo "==> Installing helm..." +curl -fsSL https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash +sudo chmod a+rx /usr/local/bin/helm + +echo "==> Installing kind..." +curl -Lo /tmp/kind "https://kind.sigs.k8s.io/dl/latest/kind-linux-${BIN_ARCH}" +sudo install -o root -g root -m 0755 /tmp/kind /usr/local/bin/kind + +echo "==> Creating kind cluster 'cortex-demo'..." +kind create cluster --name cortex-demo --wait 60s + +echo "==> Verifying cluster..." +kubectl cluster-info --context kind-cortex-demo + +echo "==> Done." diff --git a/cortexapps_cli/solutions/kubernetes-agent/catalog/demo-kubernetes.yaml b/cortexapps_cli/solutions/kubernetes-agent/catalog/demo-kubernetes.yaml new file mode 100644 index 0000000..b53abc8 --- /dev/null +++ b/cortexapps_cli/solutions/kubernetes-agent/catalog/demo-kubernetes.yaml @@ -0,0 +1,6 @@ +openapi: 3.0.0 +info: + title: Demo Kubernetes + description: Demo entity for the Kubernetes agent integration + x-cortex-tag: demo-kubernetes + x-cortex-type: service diff --git a/cortexapps_cli/solutions/kubernetes-agent/helm-chart/.helmignore b/cortexapps_cli/solutions/kubernetes-agent/helm-chart/.helmignore new file mode 100644 index 0000000..0e8a0eb --- /dev/null +++ b/cortexapps_cli/solutions/kubernetes-agent/helm-chart/.helmignore @@ -0,0 +1,23 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*.orig +*~ +# Various IDEs +.project +.idea/ +*.tmproj +.vscode/ diff --git a/cortexapps_cli/solutions/kubernetes-agent/helm-chart/Chart.yaml b/cortexapps_cli/solutions/kubernetes-agent/helm-chart/Chart.yaml new file mode 100644 index 0000000..03d7e45 --- /dev/null +++ b/cortexapps_cli/solutions/kubernetes-agent/helm-chart/Chart.yaml @@ -0,0 +1,9 @@ +# Bundled copy of the Cortex k8s-agent helm chart for demo purposes. +# This copy is not kept up-to-date. Once the chart is published to a +# public helm repo, this bundle will be replaced with a helm repo reference. +apiVersion: v2 +name: cortex-k8s-agent +description: A Helm chart for deploying Cortex K8s agent in your cluster +type: application +version: 0.1.0 +appVersion: 1.16.0 diff --git a/cortexapps_cli/solutions/kubernetes-agent/helm-chart/README.md b/cortexapps_cli/solutions/kubernetes-agent/helm-chart/README.md new file mode 100644 index 0000000..a6dfa9a --- /dev/null +++ b/cortexapps_cli/solutions/kubernetes-agent/helm-chart/README.md @@ -0,0 +1,28 @@ +# Cortex k8s Helm Chart + +## Requirements +* [Helm](https://helm.sh/docs/intro/install/) +* A token for our package registry + +## Process +1. Generate a new Cortex API Key on the [API Keys Settings tab](https://app.getcortexapp.com/admin/settings/api-keys) in Cortex. + - This will be used for the Cortex Kubernetes agent to communicate and push service information to Cortex backend without exposing your public API Key. +2. Inside your Kubernetes cluster, run the following command to generate a Kubernetes secret for the Cortex API Key. + `kubectl create secret generic cortex-key --from-literal api-key=YOUR_API_KEY` +3. Run `kubectl create secret docker-registry cortex-docker-registry-secret --docker-server=ghcr.io --docker-username=$GITHUB_USERNAME --docker-password=$GITHUB_PASSWORD --docker-email=` +4. Download the helm chart and inside the repository run the following command to install the agent in your cluster. + `helm install YOUR_SELECTED_CHART_NAME .` + +## Customization +The helm chart make installation quick and simple, but if you want to customize any of the installation features for the Cortex agent you can do so by changing the following information in the `values.yaml` of the helm chart. +### Service Account +To authenticate the Cortex agent in your cluster and grant it access to service information, the agent needs its own service account. The helm chart by default creates a Service Account `cortex-service-account`, but you can customize the `name` and `namespace` of this Service Account. If you already have a Service Account that you want the Cortex agent to use, set `create: false` under `serviceAccount` and enter the `name` and `namespace` of the Service Account you wish to use. +### Service +The service type and port can be customized as well. For security, the agent uses a default `ClusterIP` service type that only allows the service to be accessed from within the cluster. +### Resources +By default, no resources are specified. While the Cortex Kubernetes agent is designed to be lightweight and minimize resource utilization, you have the option to add custom CPU limits and requests. +### Base URL +The Base URL defaults to that for the hosted version of Cortex. If you are using the on-prem version of Cortex, you should change the `app/baseUrl` value to the correct URL for your on-prem Cortex. + +# Usage +After installation, usage is very simple as no additional steps are required. The next time you go to create a new service in your Service Directory Homepage, you should see all of your Kubernetes services already added, ready for you to use in Cortex. If you do not want to import all of your Kubernetes discovered services, you can simply remove the ones you do not want to add. Removed services will still show up in the Kubernetes tab of Discovered Services if you want to go back and add them later. diff --git a/cortexapps_cli/solutions/kubernetes-agent/helm-chart/templates/_helpers.tpl b/cortexapps_cli/solutions/kubernetes-agent/helm-chart/templates/_helpers.tpl new file mode 100644 index 0000000..d8ec254 --- /dev/null +++ b/cortexapps_cli/solutions/kubernetes-agent/helm-chart/templates/_helpers.tpl @@ -0,0 +1,73 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "helm-chart.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). +If release name contains chart name it will be used as a full name. +*/}} +{{- define "helm-chart.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "helm-chart.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "helm-chart.labels" -}} +helm.sh/chart: {{ include "helm-chart.chart" . }} +{{ include "helm-chart.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "helm-chart.selectorLabels" -}} +app.kubernetes.io/name: {{ include "helm-chart.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Create the name of the service account to use +*/}} +{{- define "helm-chart.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "helm-chart.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} + +{{/* +Create the namespace of the service account to use +*/}} +{{- define "helm-chart.serviceAccountNamespace" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "helm-chart.fullname" .) .Values.serviceAccount.namespace }} +{{- else }} +{{- default "default" .Values.serviceAccount.namespace }} +{{- end }} +{{- end }} diff --git a/cortexapps_cli/solutions/kubernetes-agent/helm-chart/templates/clusterrole.yaml b/cortexapps_cli/solutions/kubernetes-agent/helm-chart/templates/clusterrole.yaml new file mode 100644 index 0000000..b12f078 --- /dev/null +++ b/cortexapps_cli/solutions/kubernetes-agent/helm-chart/templates/clusterrole.yaml @@ -0,0 +1,10 @@ +{{- if .Values.clusterRole.create -}} +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: {{ include "helm-chart.serviceAccountName" . }} +rules: + - apiGroups: ["apps", "argoproj.io", "batch"] + resources: ["deployments", "services", "pods", "replicationcontrollers", "statefulsets", "rollouts", "cronjobs"] + verbs: ["get", "watch", "list"] + {{- end -}} diff --git a/cortexapps_cli/solutions/kubernetes-agent/helm-chart/templates/clusterrolebinding.yaml b/cortexapps_cli/solutions/kubernetes-agent/helm-chart/templates/clusterrolebinding.yaml new file mode 100644 index 0000000..a6874ac --- /dev/null +++ b/cortexapps_cli/solutions/kubernetes-agent/helm-chart/templates/clusterrolebinding.yaml @@ -0,0 +1,14 @@ +{{- if .Values.clusterRoleBinding.create -}} +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: {{ include "helm-chart.serviceAccountName" . }} +subjects: + - kind: ServiceAccount + name: {{ include "helm-chart.serviceAccountName" . }} + namespace: {{ include "helm-chart.serviceAccountNamespace" . }} +roleRef: + kind: ClusterRole + name: {{ include "helm-chart.serviceAccountName" . }} + apiGroup: rbac.authorization.k8s.io + {{- end -}} diff --git a/cortexapps_cli/solutions/kubernetes-agent/helm-chart/templates/configmap.yaml b/cortexapps_cli/solutions/kubernetes-agent/helm-chart/templates/configmap.yaml new file mode 100644 index 0000000..a184370 --- /dev/null +++ b/cortexapps_cli/solutions/kubernetes-agent/helm-chart/templates/configmap.yaml @@ -0,0 +1,9 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ template "helm-chart.fullname" . }}-configmap +data: + SPRING_PROFILES_ACTIVE: prod + BASE_URL: {{ required "Base URL must be defined." .Values.app.baseUrl }} + {{ if .Values.app.clusterName }}CORTEX_CLUSTER: {{ .Values.app.clusterName }}{{ end }} + {{ if .Values.app.namespace }}SELECTED_NAMESPACE: {{ .Values.app.namespace }}{{ end }} diff --git a/cortexapps_cli/solutions/kubernetes-agent/helm-chart/templates/deployment.yaml b/cortexapps_cli/solutions/kubernetes-agent/helm-chart/templates/deployment.yaml new file mode 100644 index 0000000..21071c3 --- /dev/null +++ b/cortexapps_cli/solutions/kubernetes-agent/helm-chart/templates/deployment.yaml @@ -0,0 +1,51 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "helm-chart.fullname" . }} + labels: + {{- include "helm-chart.labels" . | nindent 4 }} +spec: + replicas: 1 + selector: + matchLabels: + {{- include "helm-chart.selectorLabels" . | nindent 6 }} + template: + metadata: + annotations: + checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }} + {{- with .Values.podAnnotations }} + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "helm-chart.selectorLabels" . | nindent 8 }} + spec: + {{- with .Values.image.secrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + serviceAccountName: {{ include "helm-chart.serviceAccountName" . }} + containers: + - name: {{ .Chart.Name }} + image: "{{ required "Image repository must be defined" .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - name: http-api + containerPort: 80 + protocol: TCP + readinessProbe: + initialDelaySeconds: 30 + periodSeconds: 5 + httpGet: + path: /actuator/health + port: 8080 + resources: + {{- toYaml .Values.resources | nindent 12 }} + env: + - name: CORTEX_API_KEY + valueFrom: + secretKeyRef: + name: {{ required "A secret containing api-key=CORTEX_API_KEY is required" .Values.app.keySecret }} + key: api-key + envFrom: + - configMapRef: + name: {{ template "helm-chart.fullname" . }}-configmap diff --git a/cortexapps_cli/solutions/kubernetes-agent/helm-chart/templates/service.yaml b/cortexapps_cli/solutions/kubernetes-agent/helm-chart/templates/service.yaml new file mode 100644 index 0000000..64fefc2 --- /dev/null +++ b/cortexapps_cli/solutions/kubernetes-agent/helm-chart/templates/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "helm-chart.fullname" . }} + labels: + {{- include "helm-chart.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + targetPort: http-api + protocol: TCP + name: http-{{- include "helm-chart.fullname" . }} + selector: + {{- include "helm-chart.selectorLabels" . | nindent 4 }} diff --git a/cortexapps_cli/solutions/kubernetes-agent/helm-chart/templates/serviceaccount.yaml b/cortexapps_cli/solutions/kubernetes-agent/helm-chart/templates/serviceaccount.yaml new file mode 100644 index 0000000..7d0532d --- /dev/null +++ b/cortexapps_cli/solutions/kubernetes-agent/helm-chart/templates/serviceaccount.yaml @@ -0,0 +1,13 @@ +{{- if .Values.serviceAccount.create -}} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "helm-chart.serviceAccountName" . }} + namespace: {{ include "helm-chart.serviceAccountNamespace" . }} + labels: + {{- include "helm-chart.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +{{- end }} diff --git a/cortexapps_cli/solutions/kubernetes-agent/helm-chart/values.yaml b/cortexapps_cli/solutions/kubernetes-agent/helm-chart/values.yaml new file mode 100644 index 0000000..639ef2d --- /dev/null +++ b/cortexapps_cli/solutions/kubernetes-agent/helm-chart/values.yaml @@ -0,0 +1,33 @@ +image: + repository: ghcr.io/cortexapps/k8s-agent/k8s-agent + pullPolicy: IfNotPresent + tag: "" + secrets: + - name: cortex-docker-registry-secret + +imagePullSecrets: [] +nameOverride: "" +fullnameOverride: cortex-k8s-agent + +serviceAccount: + create: true + annotations: {} + name: cortex-service-account + namespace: default + +clusterRole: + create: true + +clusterRoleBinding: + create: true + +service: + type: ClusterIP + port: 80 + +resources: {} + +app: + baseUrl: https://api.getcortexapp.com + keySecret: cortex-key + clusterName: cortex-demo diff --git a/cortexapps_cli/solutions/kubernetes-agent/manifests/cronjob.yaml b/cortexapps_cli/solutions/kubernetes-agent/manifests/cronjob.yaml new file mode 100644 index 0000000..87016de --- /dev/null +++ b/cortexapps_cli/solutions/kubernetes-agent/manifests/cronjob.yaml @@ -0,0 +1,22 @@ +apiVersion: batch/v1 +kind: CronJob +metadata: + name: demo-cronjob + labels: + app: demo-k8s-label + annotations: + cortex.io/tag: demo-kubernetes +spec: + schedule: "*/10 * * * *" + jobTemplate: + spec: + template: + spec: + containers: + - name: hello + image: busybox:latest + command: + - /bin/sh + - -c + - echo "$(date '+%Y-%m-%d %H:%M:%S') - Hello from demo-kubernetes cronjob" >> /tmp/hello-world.txt + restartPolicy: OnFailure diff --git a/cortexapps_cli/solutions/kubernetes-agent/manifests/deployment.yaml b/cortexapps_cli/solutions/kubernetes-agent/manifests/deployment.yaml new file mode 100644 index 0000000..3d9fa18 --- /dev/null +++ b/cortexapps_cli/solutions/kubernetes-agent/manifests/deployment.yaml @@ -0,0 +1,23 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: demo-deployment + labels: + app: demo-k8s-label + annotations: + cortex.io/tag: demo-kubernetes +spec: + replicas: 1 + selector: + matchLabels: + app: demo-k8s + template: + metadata: + labels: + app: demo-k8s + spec: + containers: + - name: hello + image: nginx:alpine + ports: + - containerPort: 80 diff --git a/cortexapps_cli/solutions/kubernetes-agent/manifests/rollout.yaml b/cortexapps_cli/solutions/kubernetes-agent/manifests/rollout.yaml new file mode 100644 index 0000000..903826d --- /dev/null +++ b/cortexapps_cli/solutions/kubernetes-agent/manifests/rollout.yaml @@ -0,0 +1,22 @@ +apiVersion: argoproj.io/v1alpha1 +kind: Rollout +metadata: + name: demo-rollout + labels: + app: demo-k8s-label + annotations: + cortex.io/tag: demo-kubernetes +spec: + replicas: 1 + selector: + matchLabels: + app: demo-k8s-rollout + workloadRef: + apiVersion: apps/v1 + kind: Deployment + name: demo-deployment + scaleDown: onsuccess + strategy: + canary: + steps: + - setWeight: 100 diff --git a/cortexapps_cli/solutions/kubernetes-agent/manifests/statefulset.yaml b/cortexapps_cli/solutions/kubernetes-agent/manifests/statefulset.yaml new file mode 100644 index 0000000..7f410fa --- /dev/null +++ b/cortexapps_cli/solutions/kubernetes-agent/manifests/statefulset.yaml @@ -0,0 +1,24 @@ +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: demo-statefulset + labels: + app: demo-k8s-label + annotations: + cortex.io/tag: demo-kubernetes +spec: + serviceName: demo-k8s + replicas: 1 + selector: + matchLabels: + app: demo-k8s-ss + template: + metadata: + labels: + app: demo-k8s-ss + spec: + containers: + - name: hello + image: nginx:alpine + ports: + - containerPort: 80 diff --git a/cortexapps_cli/solutions/kubernetes-agent/setup.py b/cortexapps_cli/solutions/kubernetes-agent/setup.py new file mode 100644 index 0000000..7a967fe --- /dev/null +++ b/cortexapps_cli/solutions/kubernetes-agent/setup.py @@ -0,0 +1,483 @@ +""" +Post-install setup script for the kubernetes-agent solution. +Deploys the Cortex k8s-agent either by creating a GitHub Codespace with a kind +cluster, or against an existing Kubernetes cluster. +Run via: cortex solutions post-install -s kubernetes-agent +""" + +SETUP_DESCRIPTION = ( + "This solution deploys the Cortex Kubernetes agent to a Kubernetes cluster " + "and creates a demo entity to demonstrate the k8s integration. " + "It can spin up a GitHub Codespace with a kind cluster " + "(https://kind.sigs.k8s.io) automatically, or deploy to any existing cluster." +) + +import shlex +import subprocess +import sys +import time +from pathlib import Path + +import requests + +try: + from cortexapps_cli.solutions._lib.setup_base import SolutionSetup +except ImportError: + sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + from _lib.setup_base import SolutionSetup + +SOLUTION_DIR = Path(__file__).parent +CATALOG_FILE = SOLUTION_DIR / "catalog" / "demo-kubernetes.yaml" +MANIFESTS_DIR = SOLUTION_DIR / "manifests" +HELM_CHART_DIR = SOLUTION_DIR / "helm-chart" + +ARGO_CRD_URL = "https://raw.githubusercontent.com/argoproj/argo-rollouts/stable/manifests/crds/rollout-crd.yaml" + +CODESPACE_BRANCH = "worktree-kubernetes-agent-solution" +CODESPACE_READY_TIMEOUT = 900 # 15 minutes for Codespace + kind cluster startup +CODESPACE_POLL_INTERVAL = 20 # seconds between readiness checks + + +class KubernetesAgentSetup(SolutionSetup): + solution_tag = "kubernetes-agent" + + def __init__( + self, + cortex_api_key: str = None, + cortex_base_url: str = None, + no_prompt: bool = False, + **kwargs, + ): + super().__init__(no_prompt=no_prompt, **kwargs) + self._api_key = cortex_api_key or "" + self._base_url = (cortex_base_url or "https://api.getcortexapp.com").rstrip("/") + self._ghcr_token = "" + self._cluster_name = "" + self._github_repo = "" + # Recover codespace name from previous run; its presence means codespace mode + self._codespace_name = self._state.get("codespace_name", "") + self._use_codespace = bool(self._codespace_name) + + def collect_prompts(self) -> None: + # If a Codespace was already created in a prior run, stay in codespace mode. + # Otherwise ask the user which path they want. + if not self._codespace_name: + use_cs_raw = self.prompt( + "use_codespace", + "Create a new GitHub Codespace with a kind cluster?" + " (yes = spin up Codespace, no = use an existing configured cluster)", + default="yes", + ) + self._use_codespace = use_cs_raw.lower() in ("yes", "y", "true", "1") + + if self._use_codespace: + self._github_repo = self.prompt( + "github_repo", + "GitHub repository to create the Codespace from (org/repo)", + default="cortexapps/cli", + ) + + self._ghcr_token = self.prompt( + "GHCR_TOKEN", + "GitHub PAT provided by Cortex Customer Engineering for pulling the k8s-agent image" + " (see https://docs.cortex.io/ingesting-data-into-cortex/integrations/kubernetes#prerequisites)", + env_var="GHCR_TOKEN", + hidden=True, + ) + self._cluster_name = self.prompt( + "cluster_name", + "Name for this cluster as it will appear in Cortex", + default="cortex-demo", + ) + + # ------------------------------------------------------------------------- + # Helpers + # ------------------------------------------------------------------------- + + def _remote_solution_dir(self) -> str: + """Path to the solution directory inside the Codespace.""" + repo_name = self._github_repo.split("/")[-1] + return f"/workspaces/{repo_name}/cortexapps_cli/solutions/kubernetes-agent" + + def _run_remote(self, bash_cmd: str) -> None: + """Run a bash command inside the Codespace. + + Pipes the script to 'tee' via SSH stdin, then executes it. + This avoids two pitfalls of 'gh codespace ssh -- bash -c SCRIPT': + 1. gh joins post-'--' args with spaces before the remote shell + sees them, so metacharacters (|, >, ;) in the script are + interpreted by the remote shell instead of bash. + 2. 'bash -lc' sources profile scripts that print to stdout, + corrupting piped commands (e.g. kubectl create | kubectl apply). + """ + script = ( + "#!/bin/bash\n" + "set -euo pipefail\n" + "export PATH=/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin\n" + "export KUBECONFIG=/home/vscode/.kube/config\n" + f"{bash_cmd}\n" + ).encode() + remote_script = "/home/vscode/cortex-run.sh" + subprocess.run( + ["gh", "codespace", "ssh", "-c", self._codespace_name, + "--", "tee", remote_script], + input=script, + check=True, + capture_output=True, + ) + subprocess.run( + ["gh", "codespace", "ssh", "-c", self._codespace_name, + "--", "bash", remote_script], + check=True, + ) + + def _fetch_image_tag(self) -> str: + """Fetch the latest k8s-agent image tag from the GitHub API.""" + r = requests.get( + "https://api.github.com/orgs/cortexapps/packages/container/k8s-agent%2Fk8s-agent/versions", + headers={ + "Authorization": f"Bearer {self._ghcr_token}", + "Accept": "application/vnd.github+json", + }, + ) + r.raise_for_status() + versions = r.json() + if not versions: + raise RuntimeError("No k8s-agent versions found in GHCR — is GHCR_TOKEN valid?") + tags = versions[0].get("metadata", {}).get("container", {}).get("tags", []) + tag = tags[0] if tags else "" + if not tag: + raise RuntimeError("Could not determine k8s-agent image tag from GHCR API response") + print(f" Using image tag: {tag}") + return tag + + # ------------------------------------------------------------------------- + # Step: Create GitHub Codespace (codespace mode only) + # ------------------------------------------------------------------------- + + def _fetch_codespace_log(self) -> str: + """Fetch /tmp/onCreate.log from the Codespace, or a placeholder if unavailable.""" + result = subprocess.run( + ["gh", "codespace", "ssh", "-c", self._codespace_name, "--", "cat", "/tmp/onCreate.log"], + capture_output=True, + text=True, + ) + return result.stdout.strip() if result.stdout.strip() else "(log not available)" + + def _check_gh_cli(self) -> None: + """Verify gh CLI is installed and authenticated.""" + if subprocess.run(["gh", "--version"], capture_output=True).returncode != 0: + raise RuntimeError( + "The 'gh' CLI is required but not found.\n" + "Install it from https://cli.github.com and run 'gh auth login' first." + ) + if subprocess.run(["gh", "auth", "status"], capture_output=True).returncode != 0: + raise RuntimeError( + "The 'gh' CLI is not authenticated.\n" + "Run 'gh auth login' and try again." + ) + + def _create_codespace(self) -> None: + """Create a GitHub Codespace and wait for the kind cluster to be ready.""" + # Always check gh CLI — also needed for SSH steps that follow + self._check_gh_cli() + + existing = bool(self._codespace_name) + if self._codespace_name: + # Verify the saved Codespace still exists; if not, create a fresh one + probe = subprocess.run( + ["gh", "codespace", "view", "-c", self._codespace_name, "--json", "name"], + capture_output=True, + ) + if probe.returncode == 0: + print(f" Using existing Codespace: {self._codespace_name}") + else: + print(f" Saved Codespace '{self._codespace_name}' no longer exists — creating a new one...") + self._codespace_name = "" + self._state.pop("codespace_name", None) + existing = False + + if not self._codespace_name: + print(f" Creating GitHub Codespace from {self._github_repo}...") + result = subprocess.run( + [ + "gh", "codespace", "create", + "--repo", self._github_repo, + "--branch", CODESPACE_BRANCH, + "--devcontainer-path", ".devcontainer/kubernetes-agent/devcontainer.json", + "--machine", "basicLinux32gb", + ], + check=True, + capture_output=True, + text=True, + ) + self._codespace_name = result.stdout.strip() + if not self._codespace_name: + raise RuntimeError("gh codespace create did not return a codespace name") + print(f" Codespace created: {self._codespace_name}") + + # Persist the name so re-runs find the existing Codespace + self._state["codespace_name"] = self._codespace_name + self._save_file() + + # Phase 1: wait for Codespace to reach Available state (own deadline) + if not existing: + print(" Waiting for Codespace to start...") + phase1_deadline = time.time() + CODESPACE_READY_TIMEOUT + while time.time() < phase1_deadline: + state_result = subprocess.run( + ["gh", "codespace", "view", "-c", self._codespace_name, "--json", "state"], + capture_output=True, + text=True, + ) + if state_result.returncode == 0: + import json as _json + state = _json.loads(state_result.stdout).get("state", "") + if state == "Available": + print(" Codespace is up.") + break + time.sleep(CODESPACE_POLL_INTERVAL) + else: + raise RuntimeError( + f"Timed out waiting for Codespace '{self._codespace_name}' to start.\n" + "Re-run this command to retry." + ) + + # Phase 2: wait for onCreate.sh to finish (kind cluster ready) + # Fresh deadline — Phase 1 timing does not eat into this budget. + # Always poll — even for existing Codespaces that may still be initializing. + deadline = time.time() + CODESPACE_READY_TIMEOUT + print(" Waiting for kind cluster to be ready (may take 15-20 min on first run)...") + while time.time() < deadline: + # Check failure sentinel — simple command, no shell metacharacters. + fail_check = subprocess.run( + ["gh", "codespace", "ssh", "-c", self._codespace_name, + "--", "test", "-f", "/tmp/onCreate.failed"], + capture_output=True, + ) + if fail_check.returncode == 0: + log = self._fetch_codespace_log() + raise RuntimeError( + f"onCreate.sh failed in Codespace '{self._codespace_name}'.\n\n" + f"--- /tmp/onCreate.log ---\n{log}\n---" + ) + + # Check cluster readiness with explicit binary + kubeconfig paths. + # Non-interactive SSH sessions don't load .bashrc, so PATH and + # KUBECONFIG are not set from the user's shell configuration. + ready_check = subprocess.run( + ["gh", "codespace", "ssh", "-c", self._codespace_name, + "--", "/usr/local/bin/kubectl", + "--kubeconfig", "/home/vscode/.kube/config", + "cluster-info"], + capture_output=True, + ) + if ready_check.returncode == 0: + print(" Kind cluster is ready.") + return + time.sleep(CODESPACE_POLL_INTERVAL) + + log = self._fetch_codespace_log() + raise RuntimeError( + f"Timed out waiting for the kind cluster in Codespace '{self._codespace_name}'.\n\n" + f"--- /tmp/onCreate.log ---\n{log}\n---\n\n" + "Re-run this command to retry once the cluster is ready." + ) + + # ------------------------------------------------------------------------- + # Step: Check existing cluster (existing-cluster mode only) + # ------------------------------------------------------------------------- + + def _check_cluster(self) -> None: + """Verify kubectl can reach a running cluster.""" + result = subprocess.run(["kubectl", "cluster-info"], capture_output=True) + if result.returncode != 0: + raise RuntimeError( + "kubectl cannot reach a cluster.\n" + "Ensure your kubectl context points to a running Kubernetes cluster and try again." + ) + + # ------------------------------------------------------------------------- + # Steps: shared between both modes + # ------------------------------------------------------------------------- + + def _create_secrets(self) -> None: + if self.already_done("create_secrets"): + return + print(" Creating cortex-docker-registry-secret...") + if self._use_codespace: + self._run_remote( + f"kubectl create secret docker-registry cortex-docker-registry-secret " + f"--docker-server=ghcr.io --docker-username=cortex " + f"--docker-password={shlex.quote(self._ghcr_token)} " + f"--dry-run=client -o yaml | kubectl apply -f -" + ) + else: + result = subprocess.run( + [ + "kubectl", "create", "secret", "docker-registry", + "cortex-docker-registry-secret", + "--docker-server=ghcr.io", + "--docker-username=cortex", + f"--docker-password={self._ghcr_token}", + "--dry-run=client", "-o", "yaml", + ], + check=True, + capture_output=True, + ) + subprocess.run(["kubectl", "apply", "-f", "-"], input=result.stdout, check=True) + + print(" Creating cortex-key secret...") + if self._use_codespace: + self._run_remote( + f"kubectl create secret generic cortex-key " + f"--from-literal=api-key={shlex.quote(self._api_key)} " + f"--dry-run=client -o yaml | kubectl apply -f -" + ) + else: + result = subprocess.run( + [ + "kubectl", "create", "secret", "generic", "cortex-key", + f"--from-literal=api-key={self._api_key}", + "--dry-run=client", "-o", "yaml", + ], + check=True, + capture_output=True, + ) + subprocess.run(["kubectl", "apply", "-f", "-"], input=result.stdout, check=True) + + self.mark_done("create_secrets") + + def _helm_install(self) -> None: + if self.already_done("helm_install"): + return + image_tag = self._fetch_image_tag() + print(" Installing k8s-agent via helm...") + if self._use_codespace: + helm_chart = f"{self._remote_solution_dir()}/helm-chart" + self._run_remote( + f"helm upgrade --install cortex-k8s-agent {helm_chart} " + f"--set image.tag={shlex.quote(image_tag)} " + f"--set app.baseUrl={shlex.quote(self._base_url)} " + f"--set app.clusterName={shlex.quote(self._cluster_name)}" + ) + self._run_remote("kubectl rollout restart deployment/cortex-k8s-agent") + else: + subprocess.run( + [ + "helm", "upgrade", "--install", "cortex-k8s-agent", + str(HELM_CHART_DIR), + "--set", f"image.tag={image_tag}", + "--set", f"app.baseUrl={self._base_url}", + "--set", f"app.clusterName={self._cluster_name}", + ], + check=True, + ) + # Restart to ensure secrets/configmaps are picked up + subprocess.run( + ["kubectl", "rollout", "restart", "deployment/cortex-k8s-agent"], + check=True, + ) + self.mark_done("helm_install") + + def _wait_for_readiness(self) -> None: + if self.already_done("wait_for_readiness"): + return + print(" Waiting for k8s-agent pod to be ready (timeout: 120s)...") + if self._use_codespace: + self._run_remote( + "kubectl rollout status deployment/cortex-k8s-agent --timeout=120s" + ) + else: + subprocess.run( + [ + "kubectl", "rollout", "status", "deployment/cortex-k8s-agent", + "--timeout=120s", + ], + check=True, + ) + self.mark_done("wait_for_readiness") + + def _install_argo_crd(self) -> None: + if self.already_done("install_argo_crd"): + return + print(" Installing Argo Rollouts CRD...") + # --server-side avoids the 262144-byte annotation limit that + # kubectl apply (client-side) hits with large CRDs like Argo Rollouts. + if self._use_codespace: + self._run_remote(f"kubectl apply --server-side -f {ARGO_CRD_URL}") + else: + subprocess.run( + ["kubectl", "apply", "--server-side", "-f", ARGO_CRD_URL], check=True + ) + self.mark_done("install_argo_crd") + + def _apply_manifests(self) -> None: + if self.already_done("apply_manifests"): + return + print(" Applying demo k8s manifests...") + if self._use_codespace: + manifests = f"{self._remote_solution_dir()}/manifests" + self._run_remote(f"kubectl apply -f {manifests}") + else: + subprocess.run(["kubectl", "apply", "-f", str(MANIFESTS_DIR)], check=True) + self.mark_done("apply_manifests") + + def _create_entity(self) -> None: + if self.already_done("create_entity"): + return + print(" Creating demo-kubernetes Cortex entity...") + yaml_content = CATALOG_FILE.read_bytes() + r = requests.post( + f"{self._base_url}/api/v1/open-api", + headers={ + "Authorization": f"Bearer {self._api_key}", + "Content-Type": "application/openapi;charset=UTF-8", + }, + data=yaml_content, + ) + if not r.ok: + raise RuntimeError( + f"Failed to create Cortex entity: {r.status_code} {r.text}" + ) + self.mark_done("create_entity") + + def steps(self) -> list: + first_step = ( + ("Create GitHub Codespace", self._create_codespace) + if self._use_codespace + else ("Check cluster connectivity", self._check_cluster) + ) + return [ + first_step, + ("Create k8s secrets", self._create_secrets), + ("Install k8s-agent via helm", self._helm_install), + ("Wait for agent readiness", self._wait_for_readiness), + ("Install Argo Rollouts CRD", self._install_argo_crd), + ("Apply demo k8s manifests", self._apply_manifests), + ("Create demo Cortex entity", self._create_entity), + ] + + def post_steps(self) -> None: + print("\n✓ Kubernetes agent deployed and demo workloads running.\n") + if self._codespace_name: + print(f"Codespace: {self._codespace_name}") + print(f" Open terminal: gh codespace ssh -c {self._codespace_name}") + print(f" Stop Codespace: gh codespace stop -c {self._codespace_name}") + print() + print("The agent syncs every 5 minutes. After the first sync, visit:") + print(f" {self._base_url.replace('api.', 'app.')}/admin/resources?tag=demo-kubernetes") + print("\nYou should see: demo-deployment, demo-statefulset, demo-cronjob, demo-rollout") + print("\nNote: GHCR_TOKEN requirement goes away once the k8s-agent image is made public.") + + +def main(cortex_api_key=None, cortex_base_url=None, no_prompt=False, **kwargs): + KubernetesAgentSetup( + cortex_api_key=cortex_api_key, + cortex_base_url=cortex_base_url, + no_prompt=no_prompt, + ).run() + + +if __name__ == "__main__": + main() diff --git a/docs/superpowers/plans/2026-08-25-kubernetes-agent-solution.md b/docs/superpowers/plans/2026-08-25-kubernetes-agent-solution.md new file mode 100644 index 0000000..93af123 --- /dev/null +++ b/docs/superpowers/plans/2026-08-25-kubernetes-agent-solution.md @@ -0,0 +1,754 @@ +# Kubernetes Agent Solution Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a `kubernetes-agent` Cortex CLI solution bundle that deploys the k8s-agent + demo workloads in a GitHub Codespace kind cluster, demonstrating the Cortex k8s integration end-to-end. + +**Architecture:** A devcontainer configures a kind cluster on Codespace open; `cortex solutions post-install -s kubernetes-agent` creates k8s secrets, helm-installs the agent from the bundled chart, applies demo workloads, and registers one Cortex entity. The agent auto-registers with Cortex on connect. + +**Tech Stack:** Python 3.11+, Typer (CLI), `subprocess` for kubectl/helm, `requests` for GHCR tag fetch + Cortex API, kind (k8s in Docker), helm 3, GitHub Codespaces devcontainer. + +**Spec:** `docs/superpowers/specs/2026-08-25-kubernetes-agent-solution-design.md` + +## Global Constraints + +- Python 3.11+; follow patterns in `cortexapps_cli/solutions/workday/setup.py` exactly +- `SolutionSetup` base class: `cortexapps_cli/solutions/_lib/setup_base.py` — read it before coding `setup.py` +- Solution tag: `kubernetes-agent`; Cortex entity tag: `demo-kubernetes` +- All kubectl/helm calls: `subprocess.run([...], check=True)` +- k8s secret names are fixed: API key secret = `cortex-key` (key field = `api-key`); image pull secret = `cortex-docker-registry-secret` +- Helm chart lives at `cortexapps_cli/solutions/kubernetes-agent/helm-chart/` (bundled, not a remote repo) +- Argo Rollouts CRD URL: `https://raw.githubusercontent.com/argoproj/argo-rollouts/stable/manifests/crds/rollout-crd.yaml` +- GHCR image: `ghcr.io/cortexapps/k8s-agent/k8s-agent`; tag fetched from GitHub API at setup time +- All manifests annotated with `cortex.io/tag: demo-kubernetes` +- Workload names: `demo-deployment`, `demo-statefulset`, `demo-cronjob`, `demo-rollout` + +--- + +## File Map + +**Create:** +- `.devcontainer/kubernetes-agent/devcontainer.json` — Codespace config with Docker-in-Docker + tool install +- `.devcontainer/kubernetes-agent/onCreate.sh` — installs kind/kubectl/helm, creates kind cluster +- `cortexapps_cli/solutions/kubernetes-agent/catalog/demo-kubernetes.yaml` — Cortex service entity +- `cortexapps_cli/solutions/kubernetes-agent/manifests/deployment.yaml` — nginx Deployment +- `cortexapps_cli/solutions/kubernetes-agent/manifests/statefulset.yaml` — nginx StatefulSet +- `cortexapps_cli/solutions/kubernetes-agent/manifests/cronjob.yaml` — busybox CronJob +- `cortexapps_cli/solutions/kubernetes-agent/manifests/rollout.yaml` — Argo Rollout (workloadRef → deployment) +- `cortexapps_cli/solutions/kubernetes-agent/helm-chart/` — copied verbatim from `internal/k8s/helm-chart/` +- `cortexapps_cli/solutions/kubernetes-agent/setup.py` — post-install automation script +- `cortexapps_cli/solutions/kubernetes-agent/README.md` — user-facing docs + +--- + +### Task 1: Catalog entity + demo k8s manifests + +**Files:** +- Create: `cortexapps_cli/solutions/kubernetes-agent/catalog/demo-kubernetes.yaml` +- Create: `cortexapps_cli/solutions/kubernetes-agent/manifests/deployment.yaml` +- Create: `cortexapps_cli/solutions/kubernetes-agent/manifests/statefulset.yaml` +- Create: `cortexapps_cli/solutions/kubernetes-agent/manifests/cronjob.yaml` +- Create: `cortexapps_cli/solutions/kubernetes-agent/manifests/rollout.yaml` + +**Interfaces:** +- Produces: `catalog/demo-kubernetes.yaml` (consumed by Task 3 setup.py entity creation step), manifests dir (consumed by Task 3 manifest apply step) + +- [ ] **Step 1: Create the solution directory structure** + +```bash +mkdir -p cortexapps_cli/solutions/kubernetes-agent/catalog +mkdir -p cortexapps_cli/solutions/kubernetes-agent/manifests +``` + +- [ ] **Step 2: Create `catalog/demo-kubernetes.yaml`** + +```yaml +openapi: 3.0.0 +info: + title: Demo Kubernetes + description: Demo entity for the Kubernetes agent integration + x-cortex-tag: demo-kubernetes + x-cortex-type: service +``` + +- [ ] **Step 3: Create `manifests/deployment.yaml`** + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: demo-deployment + labels: + app: demo-k8s-label + annotations: + cortex.io/tag: demo-kubernetes +spec: + replicas: 1 + selector: + matchLabels: + app: demo-k8s + template: + metadata: + labels: + app: demo-k8s + spec: + containers: + - name: hello + image: nginx:alpine + ports: + - containerPort: 80 +``` + +- [ ] **Step 4: Create `manifests/statefulset.yaml`** + +```yaml +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: demo-statefulset + labels: + app: demo-k8s-label + annotations: + cortex.io/tag: demo-kubernetes +spec: + serviceName: demo-k8s + replicas: 1 + selector: + matchLabels: + app: demo-k8s-ss + template: + metadata: + labels: + app: demo-k8s-ss + spec: + containers: + - name: hello + image: nginx:alpine + ports: + - containerPort: 80 +``` + +- [ ] **Step 5: Create `manifests/cronjob.yaml`** + +```yaml +apiVersion: batch/v1 +kind: CronJob +metadata: + name: demo-cronjob + labels: + app: demo-k8s-label + annotations: + cortex.io/tag: demo-kubernetes +spec: + schedule: "*/10 * * * *" + jobTemplate: + spec: + template: + spec: + containers: + - name: hello + image: busybox:latest + command: + - /bin/sh + - -c + - echo "$(date '+%Y-%m-%d %H:%M:%S') - Hello from demo-kubernetes cronjob" >> /tmp/hello-world.txt + restartPolicy: OnFailure +``` + +- [ ] **Step 6: Create `manifests/rollout.yaml`** + +The Rollout references `demo-deployment` via `workloadRef` — Cortex resolves containers from the referenced Deployment. + +```yaml +apiVersion: argoproj.io/v1alpha1 +kind: Rollout +metadata: + name: demo-rollout + labels: + app: demo-k8s-label + annotations: + cortex.io/tag: demo-kubernetes +spec: + replicas: 1 + selector: + matchLabels: + app: demo-k8s-rollout + workloadRef: + apiVersion: apps/v1 + kind: Deployment + name: demo-deployment + scaleDown: onsuccess + strategy: + canary: + steps: + - setWeight: 100 +``` + +- [ ] **Step 7: Validate all YAML files parse correctly** + +```bash +python -c " +import yaml, pathlib +for f in pathlib.Path('cortexapps_cli/solutions/kubernetes-agent').rglob('*.yaml'): + try: + yaml.safe_load(f.read_text()) + print(f'OK: {f}') + except yaml.YAMLError as e: + print(f'FAIL: {f}: {e}') + exit(1) +" +``` + +Expected: `OK:` line for each `.yaml` file, no FAIL lines. + +- [ ] **Step 8: Commit** + +```bash +git add cortexapps_cli/solutions/kubernetes-agent/catalog/ \ + cortexapps_cli/solutions/kubernetes-agent/manifests/ +git commit -m "feat: add kubernetes-agent solution catalog entity and demo manifests" +``` + +--- + +### Task 2: Bundle helm chart + +**Files:** +- Create: `cortexapps_cli/solutions/kubernetes-agent/helm-chart/` (copy of `internal/k8s/helm-chart/`) + +**Interfaces:** +- Produces: `helm-chart/` directory (consumed by Task 3 helm install step — path is `Path(__file__).parent / "helm-chart"`) + +- [ ] **Step 1: Copy the helm chart from internal** + +```bash +cp -r internal/k8s/helm-chart cortexapps_cli/solutions/kubernetes-agent/helm-chart +``` + +- [ ] **Step 2: Remove the dev-only comment from the deployment template** + +The template at `helm-chart/templates/deployment.yaml` has a commented minikube host alias block that is confusing in a public-facing solution. Remove lines 26–31: + +``` + ######### remove before deploy - used for local testing ########### + # hostAliases: + # - ip: "192.168.64.1" + # hostnames: + # - "host.minikube.internal" + ################################################################### +``` + +Open `cortexapps_cli/solutions/kubernetes-agent/helm-chart/templates/deployment.yaml` and delete those 6 lines. + +- [ ] **Step 3: Add a warning comment to `helm-chart/Chart.yaml`** + +Open `cortexapps_cli/solutions/kubernetes-agent/helm-chart/Chart.yaml` and add a comment at the top: + +```yaml +# Bundled copy of the Cortex k8s-agent helm chart for demo purposes. +# This copy is not kept up-to-date. Once the chart is published to a +# public helm repo, this bundle will be replaced with a helm repo reference. +apiVersion: v2 +name: cortex-k8s-agent +description: A Helm chart for deploying Cortex K8s agent in your cluster +type: application +version: 0.1.0 +appVersion: 1.16.0 +``` + +- [ ] **Step 4: Verify helm can render the chart (requires helm installed locally)** + +```bash +helm template test-release cortexapps_cli/solutions/kubernetes-agent/helm-chart \ + --set image.tag=test \ + --set app.keySecret=cortex-key \ + --set app.baseUrl=https://api.getcortexapp.com \ + --set app.clusterName=demo \ + > /dev/null && echo "Helm template OK" +``` + +Expected: `Helm template OK` with no errors. If helm is not installed locally, skip this step — it will be verified in the Codespace. + +- [ ] **Step 5: Commit** + +```bash +git add cortexapps_cli/solutions/kubernetes-agent/helm-chart/ +git commit -m "feat: bundle k8s-agent helm chart in kubernetes-agent solution" +``` + +--- + +### Task 3: setup.py + +**Files:** +- Create: `cortexapps_cli/solutions/kubernetes-agent/setup.py` + +**Interfaces:** +- Consumes: `catalog/demo-kubernetes.yaml` (`Path(__file__).parent / "catalog" / "demo-kubernetes.yaml"`), `manifests/` dir, `helm-chart/` dir +- Consumes: `SolutionSetup` base from `cortexapps_cli/solutions/_lib/setup_base.py` — read this file before writing setup.py to understand all available methods +- Produces: `main(cortex_api_key, cortex_base_url, no_prompt, **kwargs)` entry point (called by `cortex solutions post-install`) + +**Before coding:** Read `cortexapps_cli/solutions/_lib/setup_base.py` in full to understand `prompt()`, `confirm()`, `mark_done()`, `already_done()`, `mark_undone()`, and how `steps()` returns `list[tuple[str, callable]]`. + +Also read `cortexapps_cli/solutions/workday/setup.py` for the exact class pattern to follow. + +- [ ] **Step 1: Create `setup.py` with imports and constants** + +```python +""" +Post-install setup script for the kubernetes-agent solution. +Deploys the Cortex k8s-agent to a kind cluster and creates a demo entity. +Run via: cortex solutions post-install -s kubernetes-agent +""" + +SETUP_DESCRIPTION = ( + "This solution deploys the Cortex Kubernetes agent to a local kind cluster " + "and creates a demo entity to demonstrate the k8s integration." +) + +import subprocess +import sys +from pathlib import Path + +import requests + +try: + from cortexapps_cli.solutions._lib.setup_base import SolutionSetup +except ImportError: + sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + from _lib.setup_base import SolutionSetup + +SOLUTION_DIR = Path(__file__).parent +CATALOG_FILE = SOLUTION_DIR / "catalog" / "demo-kubernetes.yaml" +MANIFESTS_DIR = SOLUTION_DIR / "manifests" +HELM_CHART_DIR = SOLUTION_DIR / "helm-chart" + +GHCR_IMAGE = "ghcr.io/cortexapps/k8s-agent/k8s-agent" +ARGO_CRD_URL = "https://raw.githubusercontent.com/argoproj/argo-rollouts/stable/manifests/crds/rollout-crd.yaml" +``` + +- [ ] **Step 2: Create the `KubernetesAgentSetup` class with `__init__` and `collect_prompts`** + +```python +class KubernetesAgentSetup(SolutionSetup): + solution_tag = "kubernetes-agent" + + def __init__( + self, + cortex_api_key: str = None, + cortex_base_url: str = None, + no_prompt: bool = False, + **kwargs, + ): + super().__init__(no_prompt=no_prompt, **kwargs) + self._api_key = cortex_api_key or "" + self._base_url = (cortex_base_url or "https://api.getcortexapp.com").rstrip("/") + self._ghcr_token = "" + self._cluster_name = "" + + def _cortex_headers(self) -> dict: + return { + "Authorization": f"Bearer {self._api_key}", + "Content-Type": "application/yaml", + } + + def collect_prompts(self) -> None: + self._ghcr_token = self.prompt( + "GHCR_TOKEN", + description="GitHub PAT with read:packages scope for pulling the k8s-agent image", + env_var="GHCR_TOKEN", + secret=True, + ) + self._cluster_name = self.prompt( + "cluster_name", + description="Name for this cluster as it will appear in Cortex", + default="demo", + ) +``` + +- [ ] **Step 3: Add `_fetch_image_tag` helper** + +```python + def _fetch_image_tag(self) -> str: + """Fetch the latest k8s-agent image tag from the GitHub API.""" + r = requests.get( + "https://api.github.com/orgs/cortexapps/packages/container/k8s-agent%2Fk8s-agent/versions", + headers={ + "Authorization": f"Bearer {self._ghcr_token}", + "Accept": "application/vnd.github+json", + }, + ) + r.raise_for_status() + versions = r.json() + if not versions: + raise RuntimeError("No k8s-agent versions found in GHCR — is GHCR_TOKEN valid?") + tags = versions[0].get("metadata", {}).get("container", {}).get("tags", []) + tag = tags[0] if tags else "" + if not tag: + raise RuntimeError("Could not determine k8s-agent image tag from GHCR API response") + print(f" Using image tag: {tag}") + return tag +``` + +- [ ] **Step 4: Add `_create_secrets` step** + +```python + def _create_secrets(self) -> None: + if self.already_done("create_secrets"): + return + print(" Creating cortex-docker-registry-secret...") + subprocess.run( + [ + "kubectl", "create", "secret", "docker-registry", + "cortex-docker-registry-secret", + "--docker-server=ghcr.io", + "--docker-username=cortex", + f"--docker-password={self._ghcr_token}", + "--dry-run=client", "-o", "yaml", + ], + check=True, + capture_output=True, + ) + # Pipe output to apply (dry-run=client means we need to apply separately) + result = subprocess.run( + [ + "kubectl", "create", "secret", "docker-registry", + "cortex-docker-registry-secret", + "--docker-server=ghcr.io", + "--docker-username=cortex", + f"--docker-password={self._ghcr_token}", + "--dry-run=client", "-o", "yaml", + ], + check=True, + capture_output=True, + ) + subprocess.run(["kubectl", "apply", "-f", "-"], input=result.stdout, check=True) + + print(" Creating cortex-key secret...") + result = subprocess.run( + [ + "kubectl", "create", "secret", "generic", "cortex-key", + f"--from-literal=api-key={self._api_key}", + "--dry-run=client", "-o", "yaml", + ], + check=True, + capture_output=True, + ) + subprocess.run(["kubectl", "apply", "-f", "-"], input=result.stdout, check=True) + self.mark_done("create_secrets") +``` + +- [ ] **Step 5: Add `_helm_install` step** + +```python + def _helm_install(self) -> None: + if self.already_done("helm_install"): + return + image_tag = self._fetch_image_tag() + print(f" Installing k8s-agent via helm (chart: {HELM_CHART_DIR})...") + subprocess.run( + [ + "helm", "upgrade", "--install", "cortex-k8s-agent", + str(HELM_CHART_DIR), + "--set", f"image.tag={image_tag}", + "--set", f"app.baseUrl={self._base_url}", + "--set", f"app.clusterName={self._cluster_name}", + ], + check=True, + ) + # Restart to ensure secrets/configmaps are picked up + subprocess.run( + ["kubectl", "rollout", "restart", "deployment", + "-l", "app.kubernetes.io/name=cortex-k8s-agent"], + check=True, + ) + self.mark_done("helm_install") +``` + +- [ ] **Step 6: Add `_wait_for_readiness` step** + +```python + def _wait_for_readiness(self) -> None: + if self.already_done("wait_for_readiness"): + return + print(" Waiting for k8s-agent pod to be ready (timeout: 120s)...") + subprocess.run( + [ + "kubectl", "rollout", "status", "deployment", + "-l", "app.kubernetes.io/name=cortex-k8s-agent", + "--timeout=120s", + ], + check=True, + ) + self.mark_done("wait_for_readiness") +``` + +- [ ] **Step 7: Add `_install_argo_crd` step** + +```python + def _install_argo_crd(self) -> None: + if self.already_done("install_argo_crd"): + return + print(f" Installing Argo Rollouts CRD...") + subprocess.run( + ["kubectl", "apply", "-f", ARGO_CRD_URL], + check=True, + ) + self.mark_done("install_argo_crd") +``` + +- [ ] **Step 8: Add `_apply_manifests` step** + +```python + def _apply_manifests(self) -> None: + if self.already_done("apply_manifests"): + return + print(f" Applying demo k8s manifests from {MANIFESTS_DIR}...") + subprocess.run( + ["kubectl", "apply", "-f", str(MANIFESTS_DIR)], + check=True, + ) + self.mark_done("apply_manifests") +``` + +- [ ] **Step 9: Add `_create_entity` step** + +```python + def _create_entity(self) -> None: + if self.already_done("create_entity"): + return + print(f" Creating demo-kubernetes Cortex entity...") + yaml_content = CATALOG_FILE.read_bytes() + r = requests.post( + f"{self._base_url}/api/v1/open-api", + headers={ + "Authorization": f"Bearer {self._api_key}", + "Content-Type": "application/openapi;charset=UTF-8", + }, + data=yaml_content, + ) + if not r.ok: + raise RuntimeError( + f"Failed to create Cortex entity: {r.status_code} {r.text}" + ) + self.mark_done("create_entity") +``` + +- [ ] **Step 10: Add `steps`, `post_steps`, and `main`** + +```python + def steps(self) -> list: + return [ + ("Create k8s secrets", self._create_secrets), + ("Install k8s-agent via helm", self._helm_install), + ("Wait for agent readiness", self._wait_for_readiness), + ("Install Argo Rollouts CRD", self._install_argo_crd), + ("Apply demo k8s manifests", self._apply_manifests), + ("Create demo Cortex entity", self._create_entity), + ] + + def post_steps(self) -> None: + print("\n✓ Kubernetes agent deployed and demo workloads running.\n") + print("The agent syncs every 5 minutes. After the first sync, visit:") + print(f" {self._base_url.replace('api.', 'app.')}/catalog/demo-kubernetes/k8s") + print("\nYou should see: demo-deployment, demo-statefulset, demo-cronjob, demo-rollout") + print("\nNote: GHCR_TOKEN requirement goes away once the k8s-agent image is made public.") + + +def main(cortex_api_key=None, cortex_base_url=None, no_prompt=False, **kwargs): + KubernetesAgentSetup( + cortex_api_key=cortex_api_key, + cortex_base_url=cortex_base_url, + no_prompt=no_prompt, + ).run() + + +if __name__ == "__main__": + main() +``` + +- [ ] **Step 11: Verify the script imports cleanly (no syntax errors)** + +```bash +python -c "import cortexapps_cli.solutions.kubernetes_agent.setup as s; print('OK')" +``` + +If that path doesn't work (no `__init__.py`), try: + +```bash +cd cortexapps_cli/solutions/kubernetes-agent && python -c "import setup; print('OK')" +``` + +Expected: `OK` + +- [ ] **Step 12: Commit** + +```bash +git add cortexapps_cli/solutions/kubernetes-agent/setup.py +git commit -m "feat: add kubernetes-agent solution post-install setup script" +``` + +--- + +### Task 4: Devcontainer + +**Files:** +- Create: `.devcontainer/kubernetes-agent/devcontainer.json` +- Create: `.devcontainer/kubernetes-agent/onCreate.sh` + +**Interfaces:** +- Produces: a working Codespace environment with kind cluster running, `CORTEX_API_KEY` and `GHCR_TOKEN` available as env vars from Codespace secrets + +- [ ] **Step 1: Create `devcontainer.json`** + +```json +{ + "name": "Cortex Kubernetes Agent Demo", + "image": "mcr.microsoft.com/devcontainers/base:ubuntu-24.04", + "features": { + "ghcr.io/devcontainers/features/docker-in-docker:2": {}, + "ghcr.io/devcontainers/features/kubectl-helm-minikube:1": { + "version": "latest", + "helm": "latest", + "minikube": "none" + } + }, + "onCreateCommand": "bash .devcontainer/kubernetes-agent/onCreate.sh", + "remoteEnv": { + "CORTEX_API_KEY": "${localEnv:CORTEX_API_KEY}", + "GHCR_TOKEN": "${localEnv:GHCR_TOKEN}" + }, + "postCreateMessage": "Run: cortex solutions install -s kubernetes-agent && cortex solutions post-install -s kubernetes-agent" +} +``` + +Note: `kubectl-helm-minikube` feature installs kubectl + helm without minikube (set to `"none"`). kind is installed separately in `onCreate.sh`. + +- [ ] **Step 2: Create `onCreate.sh`** + +```bash +#!/usr/bin/env bash +set -euo pipefail + +echo "==> Installing kind..." +curl -Lo /usr/local/bin/kind \ + https://kind.sigs.k8s.io/dl/latest/kind-linux-amd64 +chmod +x /usr/local/bin/kind + +echo "==> Creating kind cluster 'cortex-demo'..." +kind create cluster --name cortex-demo --wait 60s + +echo "==> Verifying cluster..." +kubectl cluster-info --context kind-cortex-demo + +echo "==> Installing cortexapps-cli..." +pip install cortexapps-cli --quiet + +echo "==> Done. Run: cortex solutions post-install -s kubernetes-agent" +``` + +- [ ] **Step 3: Verify `devcontainer.json` is valid JSON** + +```bash +python -c "import json; json.load(open('.devcontainer/kubernetes-agent/devcontainer.json')); print('OK')" +``` + +Expected: `OK` + +- [ ] **Step 4: Commit** + +```bash +git add .devcontainer/kubernetes-agent/ +git commit -m "feat: add kubernetes-agent Codespace devcontainer" +``` + +--- + +### Task 5: README + +**Files:** +- Create: `cortexapps_cli/solutions/kubernetes-agent/README.md` + +- [ ] **Step 1: Create `README.md`** + +```markdown +# Kubernetes Agent Solution + +Demonstrates the [Cortex Kubernetes agent](https://docs.cortex.io/docs/reference/integrations/kubernetes) integration using a GitHub Codespace with a local kind cluster. + +## What this installs + +- **Cortex k8s-agent** — connects your cluster to Cortex and syncs workload metadata +- **Demo workloads** — Deployment, StatefulSet, CronJob, and Argo Rollout, all tagged `demo-kubernetes` +- **demo-kubernetes** — a Cortex service entity that the workloads annotate to + +After setup, visit your entity's K8s tab to see live workload data synced from the cluster. + +## Prerequisites + +- A [GitHub Codespace](https://github.com/features/codespaces) opened from this repository +- A Cortex API key (`CORTEX_API_KEY`) — get from Cortex Settings → API Keys +- A GitHub PAT with `read:packages` scope (`GHCR_TOKEN`) — request from Cortex support + +Set both as [Codespace secrets](https://docs.github.com/en/codespaces/managing-your-codespaces/managing-secrets-for-your-codespaces) before opening the Codespace. + +## Quick start + +1. Open a Codespace from this repository (select the `kubernetes-agent` devcontainer configuration) +2. Wait for `onCreate` to finish (installs tools + creates kind cluster, ~2 min) +3. Run the solution: + +```bash +cortex solutions install -s kubernetes-agent +cortex solutions post-install -s kubernetes-agent +``` + +4. Wait ~5 minutes for the agent's first sync, then visit: + `https://app.getcortexapp.com/catalog/demo-kubernetes/k8s` + +## What you should see + +- `demo-deployment` (Deployment) +- `demo-statefulset` (StatefulSet) +- `demo-cronjob` (CronJob) +- `demo-rollout` (Argo Rollout — containers resolved from `demo-deployment`) + +## Re-running setup + +The setup script is idempotent — re-run `cortex solutions post-install -s kubernetes-agent` to retry any failed step. Completed steps are skipped. + +## Temporary limitation + +The k8s-agent image is currently private on GHCR, requiring `GHCR_TOKEN`. This requirement will be removed once the image is made public. +``` + +- [ ] **Step 2: Commit** + +```bash +git add cortexapps_cli/solutions/kubernetes-agent/README.md +git commit -m "docs: add kubernetes-agent solution README" +``` + +--- + +## Self-Review + +**Spec coverage check:** +- ✓ Devcontainer with Docker-in-Docker, kind install, onCreate → Task 4 +- ✓ `cortex solutions install` + `cortex solutions post-install` flow → Tasks 1–3 (install picks up catalog/; post-install runs setup.py) +- ✓ Create k8s image pull secret (`cortex-docker-registry-secret`) → Task 3 Step 4 +- ✓ Create API key secret (`cortex-key`, key `api-key`) → Task 3 Step 4 +- ✓ Fetch image tag from GHCR API → Task 3 Step 3 +- ✓ Helm install from bundled chart → Task 3 Step 5 +- ✓ Wait for agent readiness → Task 3 Step 6 +- ✓ Install Argo Rollouts CRD → Task 3 Step 7 +- ✓ Apply 4 demo manifests (Deployment, StatefulSet, CronJob, Rollout) → Tasks 1 + 3 Step 8 +- ✓ Rollout `workloadRef` points to `demo-deployment` → Task 1 Step 6 +- ✓ Create `demo-kubernetes` Cortex entity → Task 3 Step 9 +- ✓ Warning about GHCR_TOKEN requirement → Task 3 Step 10 (post_steps), Task 5 README +- ✓ Dev comment removed from helm chart deployment template → Task 2 Step 2 +- ✓ Helm chart bundle warning comment → Task 2 Step 3 + +**No placeholders found.** + +**Type consistency:** All step method names referenced in `steps()` (Task 3 Step 10) match the method definitions in Steps 4–9. `CATALOG_FILE`, `MANIFESTS_DIR`, `HELM_CHART_DIR` constants defined once in Step 1 and used consistently throughout. diff --git a/docs/superpowers/specs/2026-08-25-kubernetes-agent-solution-design.md b/docs/superpowers/specs/2026-08-25-kubernetes-agent-solution-design.md new file mode 100644 index 0000000..9d6e987 --- /dev/null +++ b/docs/superpowers/specs/2026-08-25-kubernetes-agent-solution-design.md @@ -0,0 +1,162 @@ +# Kubernetes Agent Solution Design + +**Date:** 2026-08-25 +**Status:** Approved +**Linear:** [CX-14](https://linear.app/cortexio/issue/CX-14/cli-solution-kubernetes) + +## Overview + +A Cortex CLI solution bundle that demonstrates the Kubernetes agent integration end-to-end. Users run the solution inside a GitHub Codespace with a pre-configured kind cluster. One command installs the k8s-agent (via public helm chart), deploys sample workloads (Deployment, StatefulSet, CronJob, Argo Rollout), and configures a demo entity in Cortex — making it easy to show how the integration works without any local setup. + +**Audience:** Internal testers initially; broadens when the k8s-agent image is made public. + +--- + +## Architecture + +### Two-Phase Setup + +**Phase 1 — Devcontainer (one-time Codespace initialization)** + +`.devcontainer/kubernetes-agent/devcontainer.json` configures the Codespace environment: + +- Base image: `mcr.microsoft.com/devcontainers/base:ubuntu` with Docker-in-Docker feature +- `onCreate` script installs: `kind`, `kubectl`, `helm` +- `onCreate` script creates the kind cluster: `kind create cluster --name cortex-demo` + +Users pre-set two Codespace secrets before opening the Codespace: +- `CORTEX_API_KEY` — their Cortex API key +- `GHCR_TOKEN` — GitHub PAT with `read:packages` scope (requested from Cortex support until image is public) + +Both secrets are injected as environment variables automatically when the Codespace starts. + +**Phase 2 — Solution post-install (user-initiated)** + +```bash +cortex solutions install -s kubernetes-agent +cortex solutions post-install -s kubernetes-agent +``` + +The post-install script (`setup.py`) runs the following steps in order, with idempotency via `already_done()` / `mark_done()` state: + +1. **Prompt for inputs** — `CORTEX_API_KEY`, `GHCR_TOKEN`, `CORTEX_BASE_URL` (default: `https://api.getcortexapp.com`), cluster name (default: `demo`) +2. **Create k8s image pull secret** — `kubectl create secret docker-registry cortex-ghcr-secret --docker-server=ghcr.io --docker-username= --docker-password=` +3. **Helm install k8s-agent** — from the public helm chart repo; passes `CORTEX_API_KEY`, `CORTEX_BASE_URL`, cluster name, and pull secret name as values +4. **Wait for agent readiness** — polls `kubectl rollout status deployment/k8s-agent` with timeout +5. **Install Argo Rollouts CRD** — `kubectl apply -f ` +6. **Apply demo k8s manifests** — `kubectl apply -f manifests/` (Deployment, StatefulSet, CronJob, Rollout — all annotated `cortex.io/tag: demo-kubernetes`) +7. **Create demo Cortex entity** — `cortex catalog create -f catalog/demo-kubernetes.yaml` + +Agent auto-registers with Cortex on connect using the API key — no explicit Cortex-side integration configuration step required. + +--- + +## Solution Bundle Structure + +``` +cortexapps_cli/solutions/kubernetes-agent/ +├── README.md +├── setup.py +├── catalog/ +│ └── demo-kubernetes.yaml # demo service entity, tag: demo-kubernetes +└── manifests/ # k8s demo workloads (sourced from internal/k8s/manifests) + ├── deployment.yaml # nginx Deployment, annotated cortex.io/tag: demo-kubernetes + ├── statefulset.yaml # StatefulSet, annotated cortex.io/tag: demo-kubernetes + ├── cronjob.yaml # CronJob, annotated cortex.io/tag: demo-kubernetes + └── rollout.yaml # Argo Rollout (workloadRef → deployment), annotated demo-kubernetes +``` + +``` +.devcontainer/kubernetes-agent/ +├── devcontainer.json +└── onCreate.sh # kind cluster creation + tool install script +``` + +### Demo Entity (`catalog/demo-kubernetes.yaml`) + +```yaml +openapi: 3.0.0 +info: + title: Demo Kubernetes + description: Demo entity for the Kubernetes agent integration + x-cortex-tag: demo-kubernetes + x-cortex-type: service +``` + +### Demo Manifests + +All four manifests are adapted from `internal/k8s/manifests/` with the cortex tag updated from `k8s-test-annotation` to `demo-kubernetes`. The Rollout uses `workloadRef` pointing to the Deployment (same pattern as `k8s-test-rollout.yaml`). + +--- + +## Helm Chart + +The k8s-agent helm chart is public. The solution uses `helm repo add` + `helm install` — the chart URL needs to be confirmed and hardcoded before implementation begins. + +Key helm values passed by the setup script: +- `cortexApiKey` — from `CORTEX_API_KEY` +- `cortexBaseUrl` — from `CORTEX_BASE_URL` +- `clusterName` — from user prompt (default: `demo`) +- `image.pullSecrets[0].name` — `cortex-ghcr-secret` + +--- + +## `setup.py` Design + +Follows the `SolutionSetup` base class pattern (same as workday): + +```python +class KubernetesAgentSetup(SolutionSetup): + solution_tag = "kubernetes-agent" + + def collect_prompts(self): + # Prompt for CORTEX_API_KEY, GHCR_TOKEN, CORTEX_BASE_URL, cluster name + # Reads from env vars first (Codespace secrets auto-inject them) + + def steps(self): + return [ + ("Create image pull secret", self._create_pull_secret), + ("Install k8s-agent via helm", self._helm_install), + ("Wait for agent readiness", self._wait_for_readiness), + ("Install Argo Rollouts CRD", self._install_argo_crds), + ("Apply demo k8s manifests", self._apply_manifests), + ("Create demo Cortex entity", self._create_entity), + ] + + def post_steps(self): + # Print success message + link to Cortex entity k8s tab +``` + +State persistence via `~/.cortex/solutions/kubernetes-agent.json` ensures re-runs skip completed steps. + +--- + +## Devcontainer + +`.devcontainer/kubernetes-agent/devcontainer.json`: +- Uses Docker-in-Docker feature so kind can run containers inside the Codespace container +- `onCreate` installs kind, kubectl, helm via apt/curl/brew and creates the `cortex-demo` kind cluster +- Codespace secrets (`CORTEX_API_KEY`, `GHCR_TOKEN`) are automatically available as env vars in the terminal + +--- + +## Image Credential Note + +The k8s-agent container image (`ghcr.io/cortexapps/k8s-agent/k8s-agent`) is currently private on GHCR. Users need a `GHCR_TOKEN` (GitHub PAT, `read:packages` scope) obtained from Cortex support. This requirement goes away once the image is made public — the pull secret creation step will be removed at that point. + +--- + +## Out of Scope (v1) + +- Scorecard +- Making the GHCR image public (tracked separately) +- Playwright UI verification (internal tooling only) +- Local machine support (Codespace only for v1) + +--- + +## Resolved Decisions + +1. **Helm chart** — bundle from `internal/k8s/helm-chart/` into the solution directory; no public helm repo exists yet. +2. **Argo Rollouts CRD URL** — `https://raw.githubusercontent.com/argoproj/argo-rollouts/stable/manifests/crds/rollout-crd.yaml` (just the CRD, not the full controller; `stable` channel). +3. **GHCR pull secret username** — use placeholder `cortex`; GHCR PATs authenticate by token, not username.