Skip to main content

Architecture

This document describes the internal architecture of kindling — a Kubernetes operator that gives every developer an isolated staging environment on their local machine using Kind.


System overview​


Components​

1. Kind cluster​

A local Kubernetes cluster created by Kind. The cluster configuration includes:

  • Single control-plane node with the ingress-ready label
  • Port mappings for HTTP (80) and HTTPS (443) on the host
  • Containerd mirror pointing registry:5000 to the in-cluster registry container, so Kubernetes can pull images built by Kaniko without leaving the cluster

2. Operator (controller-manager)​

A Kubebuilder-based Go controller that runs in the kindling-system namespace. It watches two CRDs:

CRDPurpose
DevStagingEnvironmentDeclares an app + its backing services
CIRunnerPoolDeclares a self-hosted CI runner pool (GitHub Actions or GitLab CI)

Reconcile loop for DevStagingEnvironment:

CR applied → reconcileDeployment
→ reconcileService
→ reconcileIngress (if enabled)
→ reconcileDependencies (for each dep: Secret + Deployment + Service)
→ updateStatus

All child resources have OwnerReferences pointing back to the CR, so deleting the CR garbage-collects everything.

Spec-hash annotations: The operator computes a SHA-256 hash of each sub-spec and stores it as the apps.example.com/spec-hash annotation. On reconcile, if the hash hasn't changed, the update is skipped — this prevents unnecessary writes and reconcile loops.

3. CI Runner Pod​

Created by the CIRunnerPool controller. Each runner pod has:

ContainerImagePurpose
runnerPlatform-specific runner imageRegisters with CI platform (GitHub or GitLab), polls for jobs
build-agentbitnami/kubectlWatches /builds/ for build requests, launches Kaniko pods

The two containers share an emptyDir volume mounted at /builds/.

4. Kaniko build-agent (sidecar)​

The build-agent sidecar watches for signal files in /builds/.

Dockerfile requirement

Kaniko executes the Dockerfile from the build context exactly as-is. It does not generate or modify Dockerfiles. Each service must ship a Dockerfile that builds successfully on its own (docker build .). Kaniko is stricter than local Docker — for example, COPY-ing a file that doesn't exist (like a missing lockfile) will fail the build immediately.

Signal file protocol:

Runner writes: Build-agent reads & acts:
────────────── ─────────────────────────
/builds/<name>.tar.gz Build context (tarball)
/builds/<name>.dest Target image reference
/builds/<name>.dockerfile Dockerfile path (optional)
/builds/<name>.request Trigger → start build

Build-agent writes back:
────────────────────────
/builds/<name>.done Build finished
/builds/<name>.exitcode Exit code (0 = success)
/builds/<name>.log Build log output

For kubectl operations, the sidecar watches for .kubectl signal files:

  Runner writes:                    Build-agent reads & acts:
────────────── ─────────────────────────
/builds/<name>.sh Shell script to execute
/builds/<name>.kubectl Trigger → run script

Build-agent writes back:
────────────────────────
/builds/<name>.kubectl-done Execution finished
/builds/<name>.kubectl-exitcode Exit code
/builds/<name>.kubectl-log Output log

For DSE YAML apply operations:

  Runner writes:                    Build-agent reads & acts:
────────────── ─────────────────────────
/builds/<name>-dse.yaml Generated DSE manifest
/builds/<name>-dse.apply Trigger → kubectl apply

Build-agent writes back:
────────────────────────
/builds/<name>-dse.apply-done Apply finished
/builds/<name>-dse.apply-exitcode Exit code
/builds/<name>-dse.apply-log Output log

5. In-cluster registry​

A standard Docker registry (registry:2) running as a Deployment + Service at registry:5000. The Kind node's containerd is configured to mirror this registry, so image: registry:5000/myapp:tag works without any imagePullPolicy hacks.

6. Traefik ingress controller​

Traefik v3.6 runs as a DaemonSet with hostNetwork in the traefik namespace, providing HTTP routing from *.localhost hostnames to in-cluster Services. The IngressClass name is traefik.


Data flow: git push → running app​


Namespace layout​

NamespaceContents
kindling-systemOperator Deployment, ServiceAccount, RBAC
defaultRunner pods, DSE resources (apps, deps, services, ingresses), registry
traefikTraefik ingress controller pods

Dependency provisioning​

When the operator encounters a dependencies: block in a DSE CR, for each dependency it creates:

  1. Secret (<name>-<type>-credentials) — credential key/values plus the computed CONNECTION_URL
  2. Deployment (<name>-<type>) — single-replica pod running the service image with appropriate env vars and args
  3. Service (<name>-<type>) — ClusterIP service exposing the default port

The operator then injects connection-string env vars (e.g. DATABASE_URL, REDIS_URL) directly into the app container's env block. Some dependencies inject additional env vars:

DependencyExtra env vars injected into app
MinIOS3_ACCESS_KEY, S3_SECRET_KEY
VaultVAULT_TOKEN
InfluxDBINFLUXDB_ORG, INFLUXDB_BUCKET
JaegerOTEL_EXPORTER_OTLP_ENDPOINT

See Dependency Reference for the full reference.


AI workflow generation pipeline​

kindling generate uses a multi-stage pipeline to produce accurate workflow files:

Repo scan → docker-compose analysis → Helm/Kustomize render → .env template scan → Credential detection → OAuth detection → Prompt assembly → AI call → YAML output

Stage 1: Repo scan​

Walks the directory tree collecting Dockerfiles, dependency manifests (go.mod, package.json, requirements.txt, etc.), docker-compose.yml, and source file entry points.

Stage 2: docker-compose analysis​

If docker-compose.yml or docker-compose.yaml is found, it becomes the authoritative source for build contexts, inter-service dependencies, and environment variable mappings.

Stage 3: Helm & Kustomize rendering​

If Chart.yaml or kustomization.yaml is found, runs helm template or kustomize build to produce rendered manifests.

Stage 4: .env template file scanning​

Scans .env.sample, .env.example, .env.development, and .env.template files for required configuration variables.

Stage 5: External credential detection​

Scans all collected content for env var patterns matching external credentials (*_API_KEY, *_SECRET, *_TOKEN, *_DSN, etc.).

Stage 6: OAuth / OIDC detection​

Scans for 40+ patterns indicating OAuth usage (Auth0, Okta, Firebase Auth, NextAuth, Passport.js, OIDC discovery endpoints, redirect URIs, callback routes).

Stage 7: Prompt assembly​

Builds a system prompt with kindling conventions and a user prompt containing all collected context. The system prompt covers 9 languages, 15 dependency types, build timeout guidance, Dockerfile pitfalls, and a dev staging environment philosophy.

Stage 8: AI call & output​

Calls OpenAI or Anthropic, cleans the response (strips markdown fences), and writes the YAML. For OpenAI reasoning models (o3, o3-mini), uses the developer role and max_completion_tokens with a 5-minute timeout.


Secrets management​

kindling secrets stores external credentials as Kubernetes Secrets with the label app.kubernetes.io/managed-by=kindling.

kindling secrets set STRIPE_KEY sk_live_...
│
├──→ kubectl create secret generic kindling-secret-stripe-key
│ --from-literal=value=sk_live_...
│ -l app.kubernetes.io/managed-by=kindling
│
└──→ .kindling/secrets.yaml (base64-encoded local backup)

Naming convention: STRIPE_KEY → K8s Secret kindling-secret-stripe-key

The local backup at .kindling/secrets.yaml survives cluster rebuilds. After kindling destroy + kindling init, run kindling secrets restore to re-create all secrets from the backup.


Public HTTPS tunnels​

kindling expose creates a secure tunnel for OAuth callbacks:

Internet → Tunnel Provider (TLS) → localhost:80 → Traefik → App Pod

Supported providers:

  • cloudflared — Cloudflare Tunnel quick tunnels (free, no account)
  • ngrok — requires free account + auth token

Owner references and garbage collection​

Every resource the operator creates has an OwnerReference pointing to the parent DevStagingEnvironment CR. When you delete the CR:

kubectl delete devstagingenvironment myapp

Kubernetes' garbage collector automatically deletes all child resources.


CI Provider Abstraction​

kindling has decoupled all CI/CD-platform-specific code behind a provider interface layer in pkg/ci. Two implementations are shipped: GitHub Actions and GitLab CI. The interfaces are designed so that additional providers (Bitbucket Pipelines, Gitea Actions, etc.) can be added without touching the operator or CLI code.

Provider registry​

Providers register themselves at init-time via ci.Register(). All consumers call ci.Default() to get the active provider — by default that returns the GitHub Actions provider. Use ci.Get("gitlab") or the --ci-provider gitlab CLI flag to select GitLab.

provider := ci.Default()              // → GitHubProvider
provider.Name() // "github"
provider.DisplayName() // "GitHub Actions"
provider.Runner() // → RunnerAdapter
provider.Workflow() // → WorkflowGenerator
provider.CLILabels() // → CLILabels

Interface: Provider​

Top-level interface that wraps all provider-specific functionality.

MethodReturnsDescription
Name()stringShort identifier ("github", "gitlab")
DisplayName()stringHuman-readable name ("GitHub Actions")
Runner()RunnerAdapterRunner registration and lifecycle
Workflow()WorkflowGeneratorAI workflow file generation
CLILabels()CLILabelsHuman-facing labels for CLI prompts

Interface: RunnerAdapter​

Abstracts CI runner registration and lifecycle management. The operator controller uses this interface to build runner Deployments, RBAC resources, and startup scripts without knowing which CI platform is in use.

MethodSignatureDescription
DefaultImage() stringContainer image for self-hosted runners
DefaultTokenKey() stringKey name within the CI token Secret
APIBaseURL(platformURL string) stringCompute platform API URL from base URL
RunnerEnvVars(cfg RunnerEnvConfig) []ContainerEnvVarEnv vars for the runner container
StartupScript() stringShell script to register, run, and de-register the runner
RunnerLabels(username, crName string) map[string]stringKubernetes labels for runner resources
DeploymentName(username string) stringRunner Deployment name
ServiceAccountName(username string) stringRunner ServiceAccount name
ClusterRoleName(username string) stringRunner ClusterRole name
ClusterRoleBindingName(username string) stringRunner ClusterRoleBinding name

Supporting types:

// RunnerEnvConfig — provider-agnostic runner configuration
type RunnerEnvConfig struct {
Username, Repository, PlatformURL string
TokenSecretName, TokenSecretKey string
Labels []string
RunnerGroup, WorkDir, CRName string
}

// ContainerEnvVar — either a plain value or a Secret reference
type ContainerEnvVar struct {
Name string
Value string // plain text
SecretRef *SecretRef // mutually exclusive with Value
}

type SecretRef struct { Name, Key string }

Interface: WorkflowGenerator​

Abstracts CI workflow file generation for kindling generate.

MethodSignatureDescription
DefaultOutputPath() stringDefault workflow file path (e.g. .github/workflows/dev-deploy.yml)
PromptContext() PromptContextCI-specific values interpolated into the AI system prompt
ExampleWorkflows() (single, multi string)Reference workflow examples for the AI prompt
StripTemplateExpressions(content string) stringRemove CI-specific template expressions (for fuzz/analysis)

PromptContext struct:

FieldTypeExample (GitHub)
PlatformNamestring"GitHub Actions"
WorkflowNounstring"workflow"
BuildActionRefstring"kindling-sh/kindling/.github/actions/kindling-build@main"
DeployActionRefstring"kindling-sh/kindling/.github/actions/kindling-deploy@main"
CheckoutActionstring"actions/checkout@v4"
ActorExprstring"${{ github.actor }}"
SHAExprstring"${{ github.sha }}"
WorkspaceExprstring"${{ github.workspace }}"
RunnerSpecstring[self-hosted, "${{ github.actor }}"]
EnvTagExprstring"${{ github.actor }}-${{ github.sha }}"
TriggerBlockfunc(branch) stringYAML trigger block for a given branch
WorkflowFileDescriptionstring"GitHub Actions workflow"

Struct: CLILabels​

Human-facing labels used throughout CLI commands for prompts, output, and resource naming.

FieldTypeExample (GitHub)
Usernamestring"GitHub username"
Repositorystring"GitHub repository (owner/repo)"
Tokenstring"GitHub PAT (repo scope)"
SecretNamestring"github-runner-token"
CRDKindstring"CIRunnerPool"
CRDPluralstring"cirunnerpools"
CRDListHeaderstring"GitHub Actions Runner Pools"
RunnerComponentstring"github-actions-runner"
ActionsURLFmtstring"https://github.com/%s/actions"
CRDAPIVersionstring"apps.example.com/v1alpha1"

Adding a new provider​

To add support for a new CI platform (e.g. GitLab CI):

  1. Create pkg/ci/gitlab.go implementing Provider, RunnerAdapter, and WorkflowGenerator
  2. Register it in an init() function: Register(&GitLabProvider{})
  3. No changes needed in the operator controller or CLI commands — they call ci.Default() and use the interfaces

Project layout​

kindling/
├── api/v1alpha1/ # CRD type definitions
│ ├── devstagingenvironment_types.go
│ ├── cirunnerpool_types.go
│ └── groupversion_info.go
├── internal/controller/ # Reconcile logic
│ ├── devstagingenvironment_controller.go
│ └── cirunnerpool_controller.go
├── cmd/main.go # Operator entrypoint
├── pkg/ci/ # CI provider abstraction
│ ├── types.go # Provider, RunnerAdapter, WorkflowGenerator interfaces
│ ├── registry.go # Provider registry (Register, Default, Get)
│ ├── github.go # GitHub Actions implementation
│ └── gitlab.go # GitLab CI implementation
├── cli/ # CLI tool (separate Go module)
│ ├── cmd/
│ │ ├── root.go
│ │ ├── init.go
│ │ ├── runners.go
│ │ ├── generate.go
│ │ ├── secrets.go
│ │ ├── expose.go
│ │ ├── env.go
│ │ ├── reset.go
│ │ ├── deploy.go
│ │ ├── status.go
│ │ ├── logs.go
│ │ ├── destroy.go
│ │ ├── version.go
│ │ └── helpers.go
│ ├── main.go
│ └── go.mod
├── config/ # Kustomize manifests
├── .github/actions/ # Reusable composite actions
│ ├── kindling-build/action.yml
│ └── kindling-deploy/action.yml
├── examples/ # Example apps
├── docs/ # Documentation
├── kind-config.yaml # Kind cluster config
├── setup-ingress.sh # Ingress + registry installer
├── Makefile # Build targets
└── Dockerfile # Operator container image