← Control PlaneCONTENT HISTORY

Update to Control Plane

Snapshot Sep 30, 2026 · 23:00 UTC · version 1.0.1

Collection source: not recorded for this historical snapshot.

WHAT CHANGED · RULE-BASED ANALYSIS

First saved snapshot

No earlier snapshot is available to establish a change.

Compare saved observations

Download comparison JSON
Full technical diff · 0 changed fields
Full snapshot data
{
  "name": "migration-patterns",
  "description": "Migrate workloads from Kubernetes, Docker Compose, or Helm to Control Plane. Use when the user asks to convert k8s manifests, a docker-compose.yml, or Helm charts, or to move an existing app onto the platform.",
  "included_files": [],
  "skill_md_contents": "---\nname: migration-patterns\ndescription: \"Migrate workloads from Kubernetes, Docker Compose, or Helm to Control Plane. Use when the user asks to convert k8s manifests, a docker-compose.yml, or Helm charts, or to move an existing app onto the platform.\"\n---\n\n# Migrating to Control Plane\n\nEach source format has its own converter, and they are not interchangeable: Kubernetes through `cpln convert`, Docker Compose through `cpln stack`, a Helm chart of Control Plane resources through `cpln helm`. All three are **CLI-only — there is no MCP converter.** The dominant failure is hand-translating a Compose/k8s/Helm artifact into Control Plane YAML — even one \"small enough to do by hand\" — instead of running the tool and then reviewing what it left behind. The converter gets the mechanical translation right; your value is the gap analysis on top of it. If asked to translate by hand, push back: convert first, then work through the fix-ups.\n\n## Pick the conversion path\n\n| Source | Convert (CLI-only) | One-shot deploy |\n|---|---|---|\n| Kubernetes manifests | `cpln convert -f k8s.yaml --gvc GVC` | `cpln apply -f k8s.yaml --k8s true` |\n| Kubernetes Helm chart | `helm template R ./chart \\| cpln convert -f - --gvc GVC` | — |\n| Docker Compose | `cpln stack manifest --gvc GVC` (preview) | `cpln stack deploy --gvc GVC` |\n| Helm chart of CPLN resources | — | `cpln helm install R ./chart --gvc GVC` |\n\n`cpln helm` does **not** convert Kubernetes manifests — its charts must render only Control Plane kinds. There is no `cpln stack convert`; `cpln stack manifest` previews the generated YAML.\n\n## Kubernetes (`cpln convert`)\n\n`cpln convert -f FILE [--gvc GVC]` reads a single file, a directory (recursive), or `-` for stdin, and writes Control Plane YAML. `cpln apply -f FILE --k8s true` runs the same converter and applies the result in one step. Resources that become their own Control Plane resource:\n\n| K8s resource | Control Plane resource |\n|---|---|\n| Deployment, StatefulSet, ReplicaSet, ReplicationController, DaemonSet | Workload (type derived) |\n| CronJob | Workload (cron, schedule from spec) |\n| Job | Workload (cron, default schedule `* * * * *`) |\n| Secret | Secret (type-mapped, below) |\n| ConfigMap | Secret (dictionary) |\n| Ingress | Domain (with routes) |\n| PersistentVolumeClaim | VolumeSet |\n\nResources that shape the conversion without becoming their own resource: **Service** (port-protocol inference, public-exposure detection that sets the workload firewall, ingress route resolution), **HorizontalPodAutoscaler** (workload `minScale`/`maxScale`/`scaleToZeroDelay`/CPU target), **ServiceAccount** (image pull-secret extraction), **PersistentVolume + StorageClass** (volumeset capacity, performance class, filesystem), **EndpointSlice** (pod mapping for selectorless services).\n\n**Workload type — `cron > stateful > standard`:** a Job/CronJob becomes `cron`; otherwise any container mounting a volumeset (from a PVC or `volumeClaimTemplates`) becomes `stateful`; everything else is `standard`. The converter never emits `serverless` or `vm` — switch a workload to those yourself after converting.\n\n**Secret type mapping:**\n\n| K8s secret | Control Plane type |\n|---|---|\n| `kubernetes.io/dockerconfigjson` | `docker` |\n| any data key named `payload` | `opaque` |\n| `kubernetes.io/basic-auth` | `userpass` |\n| `kubernetes.io/tls` | `dictionary` (validated for `tls.crt`/`tls.key`, stored as a dictionary) |\n| everything else / ConfigMap | `dictionary` |\n\n**PVC performance class:** `io1`, `io2`, `pd-extreme`, `UltraSSD_LRS`, `thick`, `fast`, `persistent_1` map to `high-throughput-ssd` (matched on the StorageClass parameter value); everything else — `gp2`, `gp3`, the default — maps to `general-purpose-ssd`.\n\n**Port protocol** (when `--protocol` is not forced): Service `appProtocol` wins outright; otherwise the converter gathers hints from the Service and container port-name prefixes, the probe type, and the port number, then picks the most specific (grpc > http2 > http > tcp); default `tcp`.\n\nThe converter auto-creates an identity `identity-<workload>` and policy `policy-<workload>` granting `reveal` for every workload that references secrets. When `--gvc` is omitted, workload links carry a `{{GVC}}` placeholder — replace it before applying.\n\n## What `cpln convert` leaves for you\n\nThe converter translates structure faithfully but **warns on only two things** — a ConfigMap/Secret name collision (it renames the ConfigMap with a `-config` suffix) and an `acceptAll*` domain needing a dedicated load balancer. Everything below changes or disappears **silently**, so diff the source against the output.\n\n- **Scaling is pinned, not autoscaled.** A converted workload gets `minScale = maxScale =` the Deployment's `replicas` (or `1` if unset) with `capacityAI: false` — no headroom. An HPA, if present, supplies min/max and a CPU target. Raise `maxScale` above `minScale` for anything that should scale, keep customer-facing `minScale ≥ 2`, and consider Capacity AI (autoscaling-capacity skill).\n- **Silently dropped from the pod spec** (the workload runs, but differently): `envFrom` (bulk ConfigMap/Secret env — re-add the keys as `env` or a mounted dictionary secret), `initContainers` (migrations/setup — run as a separate cron workload or an entrypoint step), `startupProbe` (only liveness/readiness carry over), container-level `securityContext` (only the pod-level `securityContext.fsGroup` carries over, as `filesystemGroupId`), and `hostPath` volumes. `emptyDir` becomes a `scratch://` volume.\n- **Not converted at all** (no resource, no warning): NetworkPolicy, PodDisruptionBudget, RBAC, ResourceQuota/LimitRange, ServiceMonitor and other CRDs, and Namespaces — every namespace collapses into the one target GVC. Re-express network rules as the workload firewall (firewall-networking skill) and RBAC as policies (access-control skill).\n- **Images stay literal, sizing is minimal.** `image: nginx:1.25` is kept verbatim, not rewritten to an internal `//image/` ref. `imagePullSecrets` (pod or ServiceAccount) carry over as `//secret/NAME`, but the secret must already exist for a private registry to pull. A container with no `resources` set defaults to a tiny `50m` CPU / `128Mi` memory — size it for production.\n\n## Docker Compose (`cpln stack`)\n\n`cpln stack deploy` (alias `up`) builds and deploys; `cpln stack manifest` previews the generated YAML without deploying; `cpln stack rm` (alias `down`) tears down. All take `--dir`/`--directory` and `--compose-file`. `--build` defaults `true` for `deploy` (local `docker build` for `linux/amd64`, then push as `<service>:1.0`) and `false` for `manifest`. Conversion rules:\n\n- **Workload type:** `standard`, or `stateful` if the service attaches a named volume.\n- **Volumes:** a named volume becomes a VolumeSet (stateful). A **file** bind mount becomes an opaque secret mounted at the target path. A **directory** bind mount is rejected with an error — split it into individual file bind mounts.\n- **Ports:** `\"PORT[:TARGET]/PROTO\"` where `PROTO` is `http`, `http2`, `tcp`, or `grpc`; with no `/PROTO` suffix the port has no protocol set. Example: `\"50051:50051/grpc\"`.\n- **Resources:** default `cpu: 42m`, `memory: 128Mi` (override via `deploy.resources.limits`). A GPU forces a minimum `cpu: 2000m`, `memory: 7168Mi`.\n- **Firewall:** external inbound is opened (`0.0.0.0/0`) when the service has `ports` or `network_mode: host`; outbound is open unless `network_mode: none`.\n- **Secrets/configs:** compose `secrets` and `configs` become opaque secrets with an auto-created identity and a `reveal` policy.\n\n**`x-cpln` override block:** any top-level key under a service's `x-cpln` **replaces that entire `spec.<key>` section wholesale** (it does not deep-merge) — there is no allowlist, so any spec field works (`type`, `containers`, `defaultOptions`, `firewallConfig`, `identityLink`, …). Overriding `containers` means restating the full container spec.\n\n```yaml\nservices:\n  api:\n    image: my-api:latest\n    x-cpln:\n      type: serverless              # replaces the derived workload type\n      defaultOptions:               # replaces the whole defaultOptions block\n        capacityAI: false\n        autoscaling: { minScale: 2, maxScale: 10 }\n```\n\n`cpln stack` does **not** rewrite service URLs in your code or config. Update them to the internal form `<workload>.<gvc>.cpln.local[:<port>]` (e.g. `http://redis:6379` becomes `http://redis.GVC.cpln.local:6379`).\n\n## Bind-mounts: content vs config\n\nThe decisive question for any bind-mounted file: **does it change between environments, or is it identical in dev/staging/prod?**\n\n| Type | Examples | Where it goes |\n|---|---|---|\n| **Application content** — versioned with the code, same everywhere | `index.html`, JS/CSS bundle, fonts, ML model weights | Bake into the image (`COPY` in the Dockerfile), or serve from a CDN |\n| **Configuration** — env-specific values | `nginx.conf` with `proxy_pass`, app config with env URLs, `.env` | Opaque secret mounted as a file volume — the ConfigMap equivalent |\n\nBaking config into the image couples that image to one environment: a hostname or feature-flag change then forces a rebuild. Mounting application content as secret volumes is the opposite mistake — it decouples content from its image version and makes rollbacks strange. A single container's migration is usually **mixed**, decided file by file. Rule of thumb: if changing the file between environments would not count as a code change, it is config and belongs in a secret volume.\n\nWorkloads mount secrets as read-only files via `cpln://secret/<name>` volumes (the `workload`/`stateful-storage` skills own the mechanism; `get_resource_schema` for `workload` gives the exact shape):\n\n- **Opaque** (single config file): the path needs at least one subpath; the last segment becomes the file name and holds the `payload`.\n- **Dictionary** (multi-key ConfigMap): mount the secret at a directory path and each key becomes a file.\n- **Docker / GCP / Azure SDK**: mounted as a single file `___cpln___.secret` in the path directory.\n\nSo an nginx workload migrates *mixed*: `index.html` baked into a custom image, while `nginx.conf` mounts from a `cpln://secret/<name>` opaque secret at `/etc/nginx/conf.d/default.conf`.\n\n## Helm (`cpln helm`)\n\n`cpln helm install|upgrade|uninstall|list|template` manages releases of charts that render **only Control Plane kinds**. A rendered object carrying `apiVersion` or `metadata`, or an unknown kind, aborts with `ERROR: Some resources in the rendered template are not CPLN resources`. To migrate an existing Kubernetes Helm chart, render it first and pipe through the converter: `helm template R ./chart | cpln convert -f -`.\n\n- `cpln.org` and `cpln.gvc` (plus `globals.cpln.*` / `global.cpln.*`) are injected as `--set` overrides — don't define a top-level `cpln` key in `values.yaml`, it gets clobbered.\n- Release state is an opaque secret per revision; `cpln helm list` is org-scoped and takes no `--gvc`.\n- GVC-scoped kinds (`workload`, `identity`, `volumeset`) need `--gvc` or a profile GVC; org-scoped kinds like `domain` do not.\n\nRelease-name rules, `--history-limit`, OCI charts, and `--wait` are general helm-release operations — the gitops-cicd and cpln skills own those.\n\n## Exporting to Terraform / IaC\n\nWhen the target is Infrastructure-as-Code rather than live resources, turn the converted Control Plane YAML into HCL with `convert_to_terraform` (dry-run validated against the API first, so the HCL always matches a schema-valid resource), or capture already-created resources with `export_terraform`. `list_terraform_kinds` and `export_terraform_batch` are in the `full` profile. The `iac-terraform-pulumi` skill owns the full Terraform/Pulumi story, including `terraform import`.\n\n## Verify\n\n- After `cpln convert`: confirm each workload's derived type, scaling (`maxScale` raised where needed), port protocols (gRPC/HTTP2), ingress-to-domain routes, and that any `{{GVC}}` placeholder is replaced.\n- After create/apply: `cpln apply -f cpln.yaml --ready`, or poll `list_deployments` until each workload reports ready. Pair every mutation with a read.\n\n## Troubleshooting\n\n| Symptom | Cause and fix |\n|---|---|\n| `cpln helm`: \"…not CPLN resources\" | Chart renders Kubernetes objects (`apiVersion`/`metadata`). Render then convert: `helm template \\| cpln convert`. |\n| Env vars missing, or a setup step never ran | `envFrom` and `initContainers` are dropped silently — re-add env keys as `env`/a dictionary secret, and run init logic as a cron workload or entrypoint step. |\n| Workload won't scale under load | `minScale = maxScale` from the source replicas — raise `maxScale` (and enable Capacity AI / a metric). |\n| Compose: \"Directory bind mount found\" | Directory bind mounts are rejected — mount individual files (each becomes a secret). |\n| App can't reach another service | The converters don't rewrite URLs — point them at `<workload>.<gvc>.cpln.local[:port]`. |\n| Private image won't pull | The image string is kept literal; create the pull secret it references and link it (image skill). |\n| Deployment stuck after converting | The workload references a secret without an identity/policy; the converter adds `identity-<wl>`/`policy-<wl>` — if you re-authored, wire `reveal` yourself (access-control skill). |\n\n## Quick reference\n\n### MCP tools\n\n- `create_workload` / `create_gvc` / `create_identity` / `create_volumeset` — author converted resources with production-grade defaults (secrets are created by the user — draft each manifest for them to fill and apply, `setup-secret` skill; verify with `get_resource` before referencing)\n- `get_resource_schema` — exact shape before hand-editing or re-authoring a converted manifest\n- `list_deployments` — poll converted workloads to ready\n- `convert_to_terraform` / `export_terraform` — converted YAML or live resources to HCL (`iac-terraform-pulumi` skill)\n\nThe converters themselves (`cpln convert`, `cpln stack`, `cpln helm`, `cpln apply --k8s`) are CLI-only. In CI/CD, `CPLN_TOKEN` + `cpln apply -f` applies the converted manifest headlessly.\n\n### Related skills\n\n| Skill | Use for |\n|---|---|\n| workload | the spec the converter emits; deploy/diagnose flow, injected `CPLN_*` vars |\n| cpln | the CLI that runs every converter; `apply` ordering, `exec`/`logs` |\n| autoscaling-capacity | giving converted workloads scaling headroom and Capacity AI |\n| stateful-storage | volumeset shape for converted PVCs and compose named volumes |\n| iac-terraform-pulumi | turning converted YAML into Terraform or Pulumi |\n| template-catalog | deploy a database from a template instead of converting one |\n\n## Documentation\n\n- [cpln convert](https://docs.controlplane.com/guides/cli/cpln-convert.md)\n- [Compose Deploy](https://docs.controlplane.com/guides/compose-deploy.md)\n- [cpln helm](https://docs.controlplane.com/guides/cpln-helm.md)\n- [cpln apply](https://docs.controlplane.com/guides/cpln-apply.md)\n- [Workload Volumes](https://docs.controlplane.com/reference/workload/volumes.md)\n"
}

SHA-256: 217bc7bdc6b11e277c0a30b5fdb263e1dbc244b366b7a9c64caff06d62cfb351