diff --git a/go/deployment-operator/dockerfiles/agent-harness/base.Dockerfile b/go/deployment-operator/dockerfiles/agent-harness/base.Dockerfile index c0bca9c2e7..7404d27626 100644 --- a/go/deployment-operator/dockerfiles/agent-harness/base.Dockerfile +++ b/go/deployment-operator/dockerfiles/agent-harness/base.Dockerfile @@ -105,11 +105,20 @@ RUN groupadd -g 65532 nonroot && \ WORKDIR /plural COPY deployment-operator/dockerfiles/agent-harness/system /plural/system - -RUN mkdir -p /plural/.opencode && \ - mkdir -p /plural/.claude && \ - mkdir -p /plural/.gemini && \ - mkdir -p /plural/.codex +COPY deployment-operator/dockerfiles/agent-harness/skills /plural/skills + +# Copy bundled skills into each runtime's discovery path under /plural. +# Shell vars use single `$` here (not `$$`) — `$$` expands to the shell PID in RUN. +RUN for provider in .claude .codex .gemini .opencode; do \ + mkdir -p "/plural/${provider}/skills"; \ + done && \ + for skill in /plural/skills/*/; do \ + name="$(basename "$skill")"; \ + [ -f "$skill/SKILL.md" ] || continue; \ + for provider in .claude .codex .gemini .opencode; do \ + cp -a "$skill" "/plural/${provider}/skills/${name}"; \ + done; \ + done RUN chown -R 65532:65532 /plural && \ mkdir -p /run/user/65532 && \ diff --git a/go/deployment-operator/dockerfiles/agent-harness/skills/plural-cd-extensions/SKILL.md b/go/deployment-operator/dockerfiles/agent-harness/skills/plural-cd-extensions/SKILL.md new file mode 100644 index 0000000000..6e192fbf02 --- /dev/null +++ b/go/deployment-operator/dockerfiles/agent-harness/skills/plural-cd-extensions/SKILL.md @@ -0,0 +1,134 @@ +--- +name: plural-cd-extensions +description: Advanced Plural CD patterns including service-of-services, multi-source services, Liquid/Lua templating, observers, and pipelines. Use when implementing advanced continuous deployment features in Plural GitOps. +--- +# Official Continuous Deployment Extensions + +This file maps common "advanced" Plural CD workflows to official docs so agents can quickly +jump from CRD basics to the right extension pattern. + +Primary docs entrypoint: +- https://docs.plural.sh/plural-features/continuous-deployment + +CRD field reference (all management kinds): +- https://docs.plural.sh/api-reference/kubernetes/management-api-reference + +--- + +## Service-of-services (recursive GitOps) + +Use this when one `ServiceDeployment` should apply a folder containing other +`deployments.plural.sh` objects (`ServiceDeployment`, `GlobalService`, `InfrastructureStack`, etc.). + +Official docs: +- https://docs.plural.sh/plural-features/continuous-deployment/git-service + +Agent guidance: +- Treat this as a composition/orchestration layer, not an app workload. +- Keep child objects in deterministic folder paths; avoid mixing unrelated environments. +- Expect reconciliation to overwrite manual edits to generated/child resources. + +--- + +## GlobalService fleet templating + +Use `GlobalService` when you need one service template replicated across many clusters, +with small per-cluster overrides. + +Official docs: +- https://docs.plural.sh/plural-features/continuous-deployment/global-service +- https://docs.plural.sh/plural-features/continuous-deployment/service-templating + +Agent guidance: +- Prefer `spec.template` + `spec.context.raw` for cluster-specific values. +- Use cluster metadata/tags for routing and Liquid variables for substitutions. +- If per-cluster behavior diverges heavily, split into multiple `ServiceDeployment`s. + +--- + +## Multi-source services + +Use this when one deployment needs manifests from multiple sources (for example, +operator chart from Helm + CRs from Git). + +Official docs: +- https://docs.plural.sh/plural-features/continuous-deployment/multi-source-services + +Agent guidance: +- Model each source explicitly via `spec.sources`. +- Control render behavior with `spec.renderers` by path. +- Keep source path boundaries clear to avoid accidental file shadowing. + +--- + +## Dynamic Helm values via Lua + +Use this for runtime generation of Helm values or value files from context. + +Official docs: +- https://docs.plural.sh/plural-features/continuous-deployment/helm-service +- https://docs.plural.sh/plural-features/continuous-deployment/lua + +Agent guidance: +- Keep Lua logic deterministic and side-effect free. +- Reserve Lua for cases where static values files or Liquid cannot express required logic. +- Document expected input context near the script. + +--- + +## Resource application and sync behavior + +Official docs: +https://docs.plural.sh/plural-features/continuous-deployment/resource-application-logic + +Supported annotations on **any managed manifest**: + +| Annotation | Effect | +|------------|--------| +| `deployment.plural.sh/sync-options: Replace=True` | Replace semantics (PUT) instead of server-side apply — drops fields absent from desired manifest | +| `deployment.plural.sh/sync-options: Force=True` | On apply failure (e.g. immutable field), delete and recreate | +| `deployment.plural.sh/sync-wave: ""` | Apply ordering — lower numbers first | +| `argocd.argoproj.io/sync-options` | Argo CD-compatible alias (same semantics) | +| `argocd.argoproj.io/sync-wave` | Argo CD-compatible wave alias | + +Hook delete policies and lifecycle hooks are also supported — see the full doc for +pre/post-sync hook resources. + +Agent guidance: +- Use sync waves for strict ordering (CRDs → namespace → operator → app). +- Prefer minimal overrides; document non-obvious ordering in commit messages. + +--- + +## Event-driven updates and promotion workflows + +Use observers/pipelines when deployments should respond to upstream changes or environment +promotion rules. + +Official docs: +- https://docs.plural.sh/plural-features/continuous-deployment/observer +- https://docs.plural.sh/plural-features/continuous-deployment/pipelines + +Agent guidance: +- Use `Observer` for trigger detection (registry/git/other sources) and action execution. +- Use `Pipeline` for deterministic or AI-assisted promotion across environments. +- Keep promotion stages explicit and test-gated. + +--- + +## Operator behavior and ownership boundaries + +The deployment operator is an **API-driven frontend** — it automates Console provisioning and +applies manifests on clusters; it does not replace Terraform for cluster creation. + +Official docs: +- https://docs.plural.sh/plural-features/continuous-deployment/deployment-operator +- https://docs.plural.sh/plural-features/continuous-deployment/management-controllers-reconciliation-logic + +Agent guidance: +- **Creation mode** CRs: edit the GitOps YAML as the source of truth. +- **Read-only mode** (`.status.readonly: true`): resource owned elsewhere — update Terraform / + Console directly, not the observing CR. +- Avoid conflicting writes: Terraform owns infra, Plural CD owns k8s app manifests unless + explicitly composed via service-of-services. + diff --git a/go/deployment-operator/dockerfiles/agent-harness/skills/plural-git-repository/SKILL.md b/go/deployment-operator/dockerfiles/agent-harness/skills/plural-git-repository/SKILL.md new file mode 100644 index 0000000000..b20f8b66ef --- /dev/null +++ b/go/deployment-operator/dockerfiles/agent-harness/skills/plural-git-repository/SKILL.md @@ -0,0 +1,101 @@ +--- +name: plural-git-repository +description: Explains GitRepository CRs and how GitOps repos relate to source repos. Use when editing Plural GitOps repos, ServiceDeployment repositoryRef, spec.git.folder, or deployments.plural.sh GitRepository CRs. +--- +# GitRepository + +Plural CD separates **where CRs are defined** from **where application or IaC code lives**: + +| Repo role | Contents | Typical path | +|---|---|---| +| **GitOps repo** | Plural management CRs (`ServiceDeployment`, `GitRepository`, `Cluster`, …) | `manifests/*.yaml` | +| **Source repo** | Helm charts, Kustomize, raw YAML, Terraform, etc. | paths referenced by `spec.git.folder` | + +A `GitRepository` CR registers a **source repo** in Console so `ServiceDeployment` and +`InfrastructureStack` can clone it without repeating the URL. + +--- + +## GitRepository CR + +Cluster-scoped (`deployments.plural.sh/v1alpha1`, no namespace): + +```yaml +apiVersion: deployments.plural.sh/v1alpha1 +kind: GitRepository +metadata: + name: my-app-repo +spec: + url: https://github.com/my-org/my-app.git + connectionRef: # optional — reuse an ScmConnection for auth + name: github + credentialsRef: # optional — Secret with privateKey, username, password, … + name: my-app-repo-credentials + namespace: infra +``` + +| Field | Purpose | +|---|---| +| `spec.url` | HTTPS or SSH git URL — **immutable** after creation | +| `spec.connectionRef` | Existing SCM connection for credentials | +| `spec.credentialsRef` | Direct Secret reference for repo auth | +| `status.health` | `PULLABLE` or `FAILED` — Console can reach the repo | + +Find existing registrations in the GitOps repo: + +```bash +grep -rl 'kind: GitRepository' manifests/ +``` + +--- + +## How consumers use it + +`ServiceDeployment` and `InfrastructureStack` point at a `GitRepository` via `repositoryRef`, +then specify **where inside that repo** to read with `spec.git`: + +```yaml +apiVersion: deployments.plural.sh/v1alpha1 +kind: ServiceDeployment +metadata: + name: my-app-prod + namespace: infra +spec: + cluster: prod-eu-1 + repositoryRef: + name: my-app-repo + git: + ref: main + folder: deploy/k8s + namespace: my-app +``` + +| Field | Purpose | +|---|---| +| `repositoryRef.name` | Name of the `GitRepository` CR | +| `spec.git.ref` | Branch, tag, or commit | +| `spec.git.folder` | Subdirectory with manifests, chart, or Terraform | + +**Inline URL** (no `GitRepository` CR) is also supported — set `spec.git.url` directly on the +consumer CR instead of `repositoryRef`. + +--- + +## Working in an unfamiliar repo + +**If you are in the GitOps repo** — read `manifests/` for `GitRepository`, `ServiceDeployment`, +and `Cluster` CRs. Use `Cluster` resources with `apiVersion: deployments.plural.sh/v1alpha1` +(not CAPI `cluster.x-k8s.io`) to find valid `spec.handle` values for `spec.cluster`. + +**If you are in a source repo** — find the matching `GitRepository.spec.url` and follow +`repositoryRef` + `spec.git.folder` on the consuming CRs in the GitOps repo's `manifests/`. + +--- + +## Official docs + +- [Management API Reference — GitRepository](https://docs.plural.sh/api-reference/kubernetes/management-api-reference) +- [Git-sourced services](https://docs.plural.sh/plural-features/continuous-deployment/git-service) +- [Deployment operator](https://docs.plural.sh/plural-features/continuous-deployment/deployment-operator) + +Plural CD is **Flux-interoperable** for source types. diff --git a/go/deployment-operator/dockerfiles/agent-harness/skills/plural-infrastructure-stack/SKILL.md b/go/deployment-operator/dockerfiles/agent-harness/skills/plural-infrastructure-stack/SKILL.md new file mode 100644 index 0000000000..e1c32397f8 --- /dev/null +++ b/go/deployment-operator/dockerfiles/agent-harness/skills/plural-infrastructure-stack/SKILL.md @@ -0,0 +1,259 @@ +--- +name: plural-infrastructure-stack +description: Maps InfrastructureStack CRs to Terraform, Terragrunt, and Ansible stack runs. Use when creating or modifying InfrastructureStack CRs, stack imports/outputs, or IaC in Plural GitOps. +--- +# InfrastructureStack — Terraform / IaC Mapping + +`InfrastructureStack` is the Plural GitOps CRD for IaC. Each CR maps to **stack runs** in +Console that the deployment-operator executes as **Batch Jobs** on `spec.clusterRef` (commonly +the management cluster). + +The management controller syncs the CR to Console; the operator runs plan/apply on git changes, +cron, or manual trigger — it does not continuously reconcile cloud resources like a k8s +controller. + +This document explains the field-level mapping so you can create, read, and modify stacks +correctly. + +--- + +## Minimal Terraform stack + +```yaml +apiVersion: deployments.plural.sh/v1alpha1 +kind: InfrastructureStack +metadata: + name: vpc-prod-eu + namespace: infra +spec: + type: TERRAFORM # IaC tool: TERRAFORM | TERRAGRUNT | ANSIBLE | CUSTOM + repositoryRef: + name: infra-repo # GitRepository CR name + namespace: infra + git: + ref: main # Branch, tag, or commit SHA + folder: terraform/vpc # Path inside the repo containing .tf files + clusterRef: + name: mgmt # Cluster where the job pod runs (often mgmt, not workload cluster) + namespace: infra + manageState: true # Plural provisions remote state (optional) +``` + +--- + +## Type → tool mapping + +| `spec.type` | Binary executed | Notes | +|---|---|---| +| `TERRAFORM` | `terraform` (or `tofu` if `configuration.terraform.tofu: true`) | Standard HCL, state managed by Plural or remote backend | +| `TERRAGRUNT` | `terragrunt` | Wraps Terraform; supports HCL-based DRY configs | +| `ANSIBLE` | `ansible-playbook` | Requires `configuration.ansible.playbook` | +| `CUSTOM` | User-supplied image | Full control; no automatic `plan`/`apply` lifecycle | + +--- + +## `spec.git` — locating the IaC source + +```yaml +git: + ref: main # Git ref to check out + folder: terraform/vpc # Subdirectory of the repo to use as working directory +``` + +- The operator checks out `ref` and `cd`s into `folder` before running the tool. +- Use `spec.workdir` as an **override** if the entry-point differs from `git.folder` (e.g. + for Terragrunt repos with external modules that must be run from a parent dir). + +--- + +## `spec.stackDefinitionRef` — reusable stack scaffold + +Point at a `StackDefinition` CR for shared configuration (tool version, hooks, defaults) +instead of duplicating `spec.configuration` on every stack. + +--- + +## `spec.configuration` — tool version & hooks + +```yaml +configuration: + version: "1.9.0" # Pin Terraform/Terragrunt version (image tag) + image: "hashicorp/terraform" # Override default tool image + + terraform: + parallelism: 10 # -parallelism flag + refresh: true # -refresh flag + approveEmpty: true # Auto-approve when plan has zero changes + tofu: true # Use OpenTofu instead of Terraform + tofuRegistry: true # Use OpenTofu registry for providers/modules + + terragrunt: + parallelism: 10 + refresh: true + approveEmpty: true + + ansible: + playbook: site.yml # Playbook to run + inventory: hosts.ini # Inventory file (relative to git.folder) + additionalArgs: ["-v"] + privateKeyFile: /secrets/id_rsa + configFile: ansible.cfg + supportsCheck: true # Whether --check mode is supported + deletePlaybook: delete.yml # Playbook run on stack deletion + + hooks: + - cmd: "pre-plan.sh" + afterStage: INIT # INIT | PLAN | APPLY | DESTROY | ... +``` + +--- + +## `spec.manageState` — Plural-managed Terraform state + +When `true`, Plural provisions and manages an S3/GCS-backed remote state bucket automatically. +When `false` (default), your Terraform code must configure a `backend {}` block to point at +an external state store. + +--- + +## `spec.variables` — variable file injection + +Arbitrary YAML/JSON injected as a variables file before the IaC run. For Terraform this +becomes a `.tfvars.json` file; for Ansible it becomes extra vars. + +```yaml +variables: + region: eu-west-1 + instance_count: 3 +``` + +--- + +## `spec.environment` — environment variable injection + +```yaml +environment: + - name: TF_LOG + value: DEBUG + - name: AWS_ROLE_ARN + secretRef: # Pull from a Kubernetes Secret + name: aws-creds + key: role_arn +``` + +--- + +## `spec.files` — mounting credential files + +```yaml +files: + - mountPath: /root/.aws/credentials + secretRef: + name: aws-credentials # Secret key must be the file content +``` + +This is the escape hatch for tools that read credentials from files rather than env vars. +IRSA / Workload Identity is preferred where available. + +--- + +## `spec.approval` — human gate before apply + +When `approval: true`, a plan is generated and the run pauses until a Plural Console user +approves it. Set this for production stacks where a human review of the diff is required. + +--- + +## `spec.detach` — deletion behavior + +- `detach: false` (default): deleting the CR triggers a `terraform destroy` run. +- `detach: true`: deleting the CR removes it from Plural Console but leaves cloud resources + in place. + +--- + +## `spec.cron` — scheduled runs + +```yaml +cron: + cron: "0 6 * * *" # Standard cron expression + autoApprove: true # Skip approval gate for scheduled runs +``` + +--- + +## Stack run lifecycle + +``` +InfrastructureStack CR created/updated (GitOps repo) + │ + ▼ + Management controller → Console InfrastructureStack + │ + ▼ + Git commit / cron / manual trigger → StackRun (PENDING → RUNNING → …) + │ + ▼ + deployment-operator creates StackRunJob → Batch Job on spec.clusterRef cluster + ┌─────────────────────────────────┐ + │ git clone + cd git.folder │ + │ terraform init │ + │ terraform plan ──► (approval) │ + │ terraform apply │ + └─────────────────────────────────┘ + │ + ▼ + Outputs in Console → available via ServiceDeployment.spec.imports +``` + +--- + +## Importing stack outputs into ServiceDeployments + +```yaml +spec: + imports: + - stackRef: + name: vpc-prod-eu + namespace: infra +``` + +In Liquid templates, outputs appear under `imports..` (see the +`plural-services` skill). + +--- + +## Service-of-services (IaC + CD composition) + +A `ServiceDeployment` can sync a git folder containing **other** Plural CRs (including more +`InfrastructureStack` / `ServiceDeployment` YAML). This batches complex provisioning into one +git-driven tree. See +[git-sourced services](https://docs.plural.sh/plural-features/continuous-deployment/git-service). + +--- + +## Common mistakes + +| Mistake | Fix | +|---|---| +| `git.folder` points at the repo root when `.tf` files are in a subdirectory | Set `git.folder: terraform/` | +| Wrong `clusterRef` — pointing at a workload cluster with no operator | Use the management cluster handle (`mgmt`) unless the workload cluster has the operator installed | +| Missing backend block with `manageState: false` | Either set `manageState: true` or add a `terraform { backend "s3" {} }` block | +| Secrets in `spec.variables` | Use `spec.environment[*].secretRef` or `spec.files` instead | + +--- + +## Official docs references + +- [Management API Reference](https://docs.plural.sh/api-reference/kubernetes/management-api-reference) — `InfrastructureStack`, `StackDefinition`, and related field specs +- Continuous deployment overview: + https://docs.plural.sh/plural-features/continuous-deployment +- Deployment operator behavior: + https://docs.plural.sh/plural-features/continuous-deployment/deployment-operator +- Reconciliation modes (creation/read-only): + https://docs.plural.sh/plural-features/continuous-deployment/management-controllers-reconciliation-logic + +When stack-adjacent resources are primarily managed by Terraform/Terragrunt, keep ownership +clear: use `InfrastructureStack` as the write path for IaC, and avoid conflicting direct +edits from unrelated CD resources. + diff --git a/go/deployment-operator/dockerfiles/agent-harness/skills/plural-services/SKILL.md b/go/deployment-operator/dockerfiles/agent-harness/skills/plural-services/SKILL.md new file mode 100644 index 0000000000..ec0097f2bb --- /dev/null +++ b/go/deployment-operator/dockerfiles/agent-harness/skills/plural-services/SKILL.md @@ -0,0 +1,315 @@ +--- +name: plural-services +description: Compares ServiceDeployment vs GlobalService and fleet deployment patterns. Use when creating or modifying ServiceDeployment, GlobalService, or child service CRs in Plural GitOps repos. +--- +# GlobalService vs ServiceDeployment + +Plural provides two CRDs for deploying workloads to Kubernetes clusters. Choosing the right +one — and knowing how they relate — is essential for working with GitOps repos correctly. + +--- + +## TL;DR + +| | `ServiceDeployment` | `GlobalService` | +|---|---|---| +| **Target** | Exactly **one** cluster | **All clusters** matching a selector | +| **Manifest** | Defined inline in the CR | Defined in `spec.template` (a `ServiceTemplate`) | +| **Child resources** | None (leaf) | Creates one `ServiceDeployment` per matched cluster | +| **Use when** | Single-env deployment or env-specific config | Fleet-wide services (monitoring, security agents, etc.) | + +--- + +## ServiceDeployment + +A `ServiceDeployment` deploys a workload to **one** cluster. It is the fundamental unit of +deployment in Plural. + +### Minimal example + +```yaml +apiVersion: deployments.plural.sh/v1alpha1 +kind: ServiceDeployment +metadata: + name: my-app-prod-eu + namespace: infra +spec: + cluster: prod-eu-1 + repositoryRef: + name: my-app-repo + namespace: infra + git: + ref: main + folder: deploy/k8s + namespace: my-app # Kubernetes namespace on the target cluster +``` + +Inline git URL (no `GitRepository` CR) is also supported: + +```yaml +spec: + cluster: prod-eu-1 + git: + url: git@github.com:my-org/my-app.git + ref: main + folder: kubernetes/manifests + namespace: my-app +``` + +### Key spec fields + +| Field | Purpose | +|---|---| +| `spec.cluster` | Cluster handle — short string matching `Cluster.spec.handle` | +| `spec.clusterRef` | ObjectReference alternative to `spec.cluster` | +| `spec.git` | Git ref + folder for raw/Kustomize manifests | +| `spec.helm` | Helm chart source, values, and release name | +| `spec.kustomize` | Kustomize base path | +| `spec.namespace` | Target namespace on the cluster | +| `spec.configuration` | Non-secret key-value pairs for Liquid templating | +| `spec.configurationRef` | Secret reference for sensitive template values | +| `spec.contexts` | Names of `ServiceContext` CRs to inject | +| `spec.imports` | Consume outputs from `InfrastructureStack` runs | +| `spec.dependencies` | Services that must be healthy first | +| `spec.protect` | Prevent accidental deletion | +| `spec.detach` | Remove from Console without deleting cluster resources | +| `spec.templated` | Enable Liquid templating on raw YAML files (default: true) | +| `spec.syncConfig` | Namespace creation, drift detection, ownership rules | + +### Helm example + +```yaml +spec: + cluster: prod-eu-1 + helm: + chart: nginx + version: "1.2.3" + repository: + name: bitnami + namespace: infra + values: + replicaCount: 2 + service: + type: ClusterIP + valuesFiles: + - values/prod.yaml +``` + +### Kustomize example + +```yaml +spec: + cluster: prod-eu-1 + repositoryRef: + name: my-app-repo + namespace: infra + git: + ref: main + folder: deploy + kustomize: + path: overlays/prod +``` + +--- + +## GlobalService + +A `GlobalService` is a **fleet multiplexer**: it watches all clusters that match its +selector and automatically creates (and manages) one `ServiceDeployment` child per matching +cluster. When clusters are added or removed the controller reconciles accordingly. + +### Minimal example + +```yaml +apiVersion: deployments.plural.sh/v1alpha1 +kind: GlobalService +metadata: + name: datadog-agent + namespace: infra +spec: + tags: + env: prod + template: # ServiceTemplate — fields are flat, NOT nested under .spec + namespace: monitoring + repositoryRef: + name: monitoring-repo + namespace: infra + git: + ref: main + folder: agents/datadog + helm: + chart: datadog + version: "3.x" + repository: + name: bitnami + namespace: infra +``` + +### Targeting clusters + +GlobalService offers multiple, composable selectors: + +```yaml +spec: + tags: + env: prod # All clusters tagged env=prod + distro: EKS # Further restrict to EKS clusters only + mgmt: false # Exclude the management cluster (default) + projectRef: + name: platform-project # Only clusters in this project + ignoreClusters: + - staging-eu-1 # Explicit exclusion by handle +``` + +All specified selectors are ANDed together. + +### `spec.template` — the ServiceTemplate + +`spec.template` is a `ServiceTemplate` with the **same top-level fields** as `ServiceSpec` +(`namespace`, `git`, `helm`, `kustomize`, `repositoryRef`, …). Do **not** nest them under +`template.spec`: + +```yaml +spec: + template: + namespace: monitoring + helm: + chart: kube-state-metrics + version: "5.3.0" + repository: + name: prometheus-community + namespace: infra +``` + +Official example: +[Global services](https://docs.plural.sh/plural-features/continuous-deployment/global-service). + +### Per-cluster values — prefer `Cluster.spec.metadata` + +For fleet-wide services (external-dns, IRSA roles, per-cluster domains), put cluster-specific +data on each `Cluster` CR (or via Terraform `plural_cluster.metadata`) and reference it in +Liquid as `cluster.metadata.` inside `.liquid` manifest or values files. + +Example Cluster metadata (from official docs): + +```yaml +spec: + handle: eks-prod + tags: + fabric: eks + metadata: + externaldnsRoleArn: arn:aws:iam::123456789012:role/external-dns + domain: prod.example.com +``` + +### `spec.context` — GlobalService-level template data + +`spec.context.raw` adds YAML available to the template engine across all matched clusters. +Use for defaults; use `Cluster.spec.metadata` when values differ per cluster. + +### `spec.serviceRef` — reuse an existing ServiceDeployment as template + +Instead of writing a full `spec.template`, you can point at an existing `ServiceDeployment` +and the `GlobalService` will clone its spec to all targeted clusters: + +```yaml +spec: + serviceRef: + name: my-app-canary + namespace: infra + tags: + env: staging +``` + +### Deletion cascade + +Control what happens when the `GlobalService` is deleted: + +```yaml +spec: + cascade: + delete: true # Delete child ServiceDeployments AND cluster resources + detach: false # (alternatively) Orphan cluster resources, remove from Console +``` + +--- + +## Finding child ServiceDeployments + +The Console links children via `GlobalService.services`. In a GitOps repo, child +`ServiceDeployment` CRs may also exist — they are **reconciled from** the parent +`GlobalService` and manual edits to children are overwritten. + +To download rendered manifests via Plural MCP `downloadServiceManifests`, use the **child** +`ServiceDeployment` name and cluster handle — not the `GlobalService` name. + +Naming is deterministic (derived from GlobalService name + cluster handle) but varies — list +services in Console or search the repo for CRs targeting the same template source. + +--- + +## Decision guide + +Use **`ServiceDeployment`** when: +- Deploying to a single, known cluster. +- Each environment (dev/staging/prod) needs meaningfully different configuration that + cannot be expressed through a template + context. +- You need fine-grained ordering via `spec.dependencies`. + +Use **`GlobalService`** when: +- The same workload should run on all (or a tagged subset of) clusters — e.g. logging + agents, security scanners, cert-manager, monitoring exporters. +- You want the fleet to stay in sync automatically as new clusters are added. +- Per-cluster overrides are small enough to fit in `spec.context`. + +--- + +## Relationship diagram + +``` +GlobalService (fleet selector) + │ spec.tags: {env: prod} + │ spec.distro: EKS + │ + ├──► creates ──► ServiceDeployment (cluster: prod-eu-1) + ├──► creates ──► ServiceDeployment (cluster: prod-us-1) + └──► creates ──► ServiceDeployment (cluster: prod-ap-1) + │ + └──► deploys ──► Kubernetes workload +``` + +--- + +## Common mistakes + +| Mistake | Fix | +|---|---| +| Editing a child `ServiceDeployment` that belongs to a `GlobalService` | Edit the parent `GlobalService.spec.template` instead — child changes are overwritten on the next reconcile | +| Using `GlobalService` for a service that needs totally different config per cluster | Use separate `ServiceDeployment` CRs per cluster | +| Passing `GlobalService` name to `downloadServiceManifests` | Find and use the child `ServiceDeployment` name | +| Forgetting `spec.mgmt: true` when you want the management cluster included | Set `mgmt: true` explicitly — it defaults to false | + +--- + +## Official docs extensions + +For field-level CRD specs, start with the +[Management API Reference](https://docs.plural.sh/api-reference/kubernetes/management-api-reference) +(`ServiceDeployment`, `GlobalService`, `ServiceContext`, …). + +For advanced service patterns, cross-check these pages: + +- Global service targeting and cluster-specific templating: + https://docs.plural.sh/plural-features/continuous-deployment/global-service +- Service templating model (Liquid + available data): + https://docs.plural.sh/plural-features/continuous-deployment/service-templating +- Multi-source services (`spec.sources` + `spec.renderers`): + https://docs.plural.sh/plural-features/continuous-deployment/multi-source-services +- Helm service variants (including Lua-driven dynamic values): + https://docs.plural.sh/plural-features/continuous-deployment/helm-service +- Resource application logic (`sync-options`, `sync-wave`): + https://docs.plural.sh/plural-features/continuous-deployment/resource-application-logic + +Use these extensions when one standard `git`/`helm` service definition is not enough, but keep +the base distinction intact: `ServiceDeployment` is single-cluster, `GlobalService` is fleet-wide. + diff --git a/go/deployment-operator/dockerfiles/agent-harness/system/analyze.md.tmpl b/go/deployment-operator/dockerfiles/agent-harness/system/analyze.md.tmpl index 703fef0244..c3d962447a 100644 --- a/go/deployment-operator/dockerfiles/agent-harness/system/analyze.md.tmpl +++ b/go/deployment-operator/dockerfiles/agent-harness/system/analyze.md.tmpl @@ -32,6 +32,21 @@ When analysis is finished, you **must** persist the report by calling the Plural --- +## Plural GitOps skills + +If this repository contains `deployments.plural.sh` CRs, load the bundled Plural GitOps agent +skills before starting the environment scan. They are auto-discovered from `/plural/skills/`: + +- `plural-git-repository` — GitRepository CRs and source vs GitOps repo wiring +- `plural-infrastructure-stack` — InfrastructureStack → Terraform / Terragrunt / Ansible +- `plural-services` — ServiceDeployment vs GlobalService +- `plural-cd-extensions` — advanced CD patterns + +After loading a skill, apply it internally. Do **not** paste or summarize skill text in your +report — read `references/guide.md` inside a skill only when you need examples or field detail. + +--- + ## 1. Hard rules You MUST always obey: diff --git a/go/deployment-operator/dockerfiles/agent-harness/system/babysit.md.tmpl b/go/deployment-operator/dockerfiles/agent-harness/system/babysit.md.tmpl index 0787c41b74..ac26f3d2d4 100644 --- a/go/deployment-operator/dockerfiles/agent-harness/system/babysit.md.tmpl +++ b/go/deployment-operator/dockerfiles/agent-harness/system/babysit.md.tmpl @@ -48,6 +48,21 @@ Docker-in-Docker is enabled. You can use `docker` and `docker-compose` for testi --- {{ end }} +## Plural GitOps skills + +If this repository contains `deployments.plural.sh` CRs, load the bundled Plural GitOps agent +skills before analysing or editing manifests. They are auto-discovered from `/plural/skills/`: + +- `plural-git-repository` — GitRepository CRs and source vs GitOps repo wiring +- `plural-infrastructure-stack` — InfrastructureStack → Terraform / Terragrunt / Ansible +- `plural-services` — ServiceDeployment vs GlobalService +- `plural-cd-extensions` — advanced CD patterns + +After loading a skill, apply it internally. Do **not** paste or summarize skill text in your +replies — read `references/guide.md` inside a skill only when you need examples or field detail. + +--- + ## Plural MCP tool hints ### `downloadServiceManifests` diff --git a/go/deployment-operator/dockerfiles/agent-harness/system/write.md.tmpl b/go/deployment-operator/dockerfiles/agent-harness/system/write.md.tmpl index c0c958188e..d0405648cd 100644 --- a/go/deployment-operator/dockerfiles/agent-harness/system/write.md.tmpl +++ b/go/deployment-operator/dockerfiles/agent-harness/system/write.md.tmpl @@ -132,6 +132,23 @@ Each failure → **one** cycle updating only the relevant todo’s `description` --- +## Plural GitOps skills + +If this repository contains `deployments.plural.sh` CRs (`ServiceDeployment`, `GlobalService`, +`InfrastructureStack`, …), load the bundled Plural GitOps agent skills before editing manifests. +They are auto-discovered from `/plural/skills/`: + +- `plural-git-repository` — GitRepository CRs and source vs GitOps repo wiring +- `plural-infrastructure-stack` — InfrastructureStack → Terraform / Terragrunt / Ansible +- `plural-services` — ServiceDeployment vs GlobalService +- `plural-cd-extensions` — advanced CD patterns (multi-source, Lua, observers, pipelines) + +After loading a skill, apply it internally. Do **not** paste or summarize skill text in your +replies — the Skill tool output is for you, not the user. Read `references/guide.md` inside a +skill only when you need examples or field-level detail. + +--- + ## Plural MCP tool hints ### `downloadServiceManifests` diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/claude/agents.go b/go/deployment-operator/pkg/agentrun-harness/tool/claude/agents.go index 090bc14655..ca35e1dc93 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/claude/agents.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/claude/agents.go @@ -62,13 +62,13 @@ var ( analysisAgent = agentJSON("analysis", agentDef{ Description: "Analyze code for potential issues, vulnerabilities and improvements. Use PROACTIVELY.", Prompt: "You are a read-only autonomous analysis agent.", - Tools: appendTools([]string{"Read", "Grep", "Glob", "Bash"}, analyzePluralMCPTools), + Tools: appendTools([]string{"Read", "Grep", "Glob", "Bash", "Skill"}, analyzePluralMCPTools), }) autonomousAgent = agentJSON("autonomous", agentDef{ Description: "Autonomous agent for making code changes and creating pull requests. Use PROACTIVELY.", Prompt: "You are an autonomous coding agent, highly skilled in coding and code analysis.", Tools: appendTools( - []string{"Read", "Write", "Edit", "MultiEdit", "Bash", "Grep", "Glob", "WebFetch"}, + []string{"Read", "Write", "Edit", "MultiEdit", "Bash", "Grep", "Glob", "WebFetch", "Skill"}, writePluralMCPTools, ), }) @@ -76,7 +76,7 @@ var ( Description: "Autonomous agent responding to pull request feedback. Commits to the existing PR branch. Does NOT create new PRs. Use PROACTIVELY.", Prompt: "You are an autonomous coding agent. Your pull request is already open. Address reviewer comments and fix CI failures, then commit to the existing branch.", Tools: appendTools( - []string{"Read", "Write", "Edit", "MultiEdit", "Bash", "Grep", "Glob", "WebFetch"}, + []string{"Read", "Write", "Edit", "MultiEdit", "Bash", "Grep", "Glob", "WebFetch", "Skill"}, babysitPluralMCPTools, ), }) diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/claude/claude.go b/go/deployment-operator/pkg/agentrun-harness/tool/claude/claude.go index 61e29db3b2..df1eae082d 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/claude/claude.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/claude/claude.go @@ -60,15 +60,7 @@ func (in *Claude) BabysitRun(ctx context.Context, bCtx *v1.BabysitContext) bool promptFile := path.Join(in.Config.WorkDir, ".claude", "prompts", v1.SystemPromptFile) agent := babysitAgent - args := []string{ - "--add-dir", in.Config.RepositoryDir, - "--agents", agent, - "--system-prompt-file", promptFile, - "--model", string(in.model), - "-p", bCtx.Prompt, - "--output-format", "stream-json", - "--verbose", - } + args := in.baseArgs(promptFile, agent, bCtx.Prompt) var envOpt exec.Option if in.Config.Run.IsProxyEnabled() { @@ -134,15 +126,7 @@ func (in *Claude) AnalysisFollowUpRun(ctx context.Context, followUpPrompt string if in.Config.Run.Mode == console.AgentRunModeWrite { agent = autonomousAgent } - args := []string{ - "--add-dir", in.Config.RepositoryDir, - "--agents", agent, - "--system-prompt-file", promptFile, - "--model", string(in.model), - "-p", followUpPrompt, - "--output-format", "stream-json", - "--verbose", - } + args := in.baseArgs(promptFile, agent, followUpPrompt) var opts []exec.Option if in.Config.Run.IsProxyEnabled() { @@ -195,14 +179,7 @@ func (in *Claude) start(ctx context.Context, options ...exec.Option) { if in.Config.Run.Mode == console.AgentRunModeWrite { agent = autonomousAgent } - args := []string{ - "--add-dir", in.Config.RepositoryDir, - "--agents", agent, - "--system-prompt-file", promptFile, - "--model", string(in.model), - "-p", in.Config.Run.Prompt, - "--output-format", "stream-json", - "--verbose"} + args := in.baseArgs(promptFile, agent, in.Config.Run.Prompt) if in.Config.Run.IsProxyEnabled() { options = append(options, @@ -273,6 +250,7 @@ func (in *Claude) ConfigureBabysitRun() error { "MultiEdit", "Bash", "WebFetch", + "Skill", PluralMCPToolsWildcard, ) defaultTimeout := fmt.Sprintf("%d", in.Config.Run.Runtime.Config.Claude.BashTimeout.Milliseconds()) @@ -321,6 +299,7 @@ func (in *Claude) Configure(consoleURL, consoleToken string) error { "Bash(rg:*)", "Bash(find:*)", "WebFetch", + "Skill", PluralMCPToolsWildcard, ).DenyTools("Edit", "Write", "Bash(rm:*)", "Bash(sudo:*)") } else { @@ -331,6 +310,7 @@ func (in *Claude) Configure(consoleURL, consoleToken string) error { "MultiEdit", "Bash", "WebFetch", + "Skill", PluralMCPToolsWildcard, ) } @@ -348,6 +328,22 @@ func (in *Claude) configPath() string { return path.Join(in.Config.WorkDir, ".claude") } +func (in *Claude) baseArgs(promptFile, agentsJSON, userPrompt string) []string { + args := []string{ + "--add-dir", in.Config.WorkDir, + "--add-dir", in.Config.RepositoryDir, + "--agents", agentsJSON, + "--system-prompt-file", promptFile, + "--model", string(in.model), + "--output-format", "stream-json", + "--verbose", + } + if userPrompt != "" { + args = append(args, "-p", userPrompt) + } + return args +} + func (in *Claude) withConfigEnv(env []string) []string { return append(env, fmt.Sprintf("CLAUDE_CONFIG_DIR=%s", in.configPath())) } diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/codex/codex.go b/go/deployment-operator/pkg/agentrun-harness/tool/codex/codex.go index f2bcefec11..151413be30 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/codex/codex.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/codex/codex.go @@ -186,6 +186,15 @@ func (in *Codex) writeCodexConfig() error { if err != nil { return err } + cfg.Features = &CodexGlobalFeatures{Skills: true} + + skillConfigs, err := bundledSkillConfigs(bundledSkillsDir(in.Config.WorkDir)) + if err != nil { + return err + } + if len(skillConfigs) > 0 { + cfg.Skills = &SkillsSettings{Config: skillConfigs} + } config, err := WriteCodexConfig(path.Join(in.Config.WorkDir, ".codex"), cfg) if err != nil { diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/codex/codex_types.go b/go/deployment-operator/pkg/agentrun-harness/tool/codex/codex_types.go index 1b4de18a0b..2923c0005b 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/codex/codex_types.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/codex/codex_types.go @@ -262,4 +262,19 @@ type CodexConfig struct { ModelProviders map[string]*ModelProviderConfig `toml:"model_providers,omitempty"` Profiles map[string]*Profile `toml:"profiles"` MCPServers map[string]*MCPServer `toml:"mcp_servers"` + Features *CodexGlobalFeatures `toml:"features,omitempty"` + Skills *SkillsSettings `toml:"skills,omitempty"` +} + +type SkillsSettings struct { + Config []SkillConfigEntry `toml:"config,omitempty"` +} + +type SkillConfigEntry struct { + Path string `toml:"path"` + Enabled bool `toml:"enabled,omitempty"` +} + +type CodexGlobalFeatures struct { + Skills bool `toml:"skills,omitempty"` } diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/codex/skills.go b/go/deployment-operator/pkg/agentrun-harness/tool/codex/skills.go new file mode 100644 index 0000000000..75217878d2 --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/codex/skills.go @@ -0,0 +1,48 @@ +package codex + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" +) + +const bundledSkillsDirName = "skills" + +func bundledSkillsDir(workDir string) string { + return filepath.Join(workDir, bundledSkillsDirName) +} + +func bundledSkillConfigs(skillsDir string) ([]SkillConfigEntry, error) { + entries, err := os.ReadDir(skillsDir) + if err != nil { + return nil, fmt.Errorf("read bundled skills dir %q: %w", skillsDir, err) + } + + var configs []SkillConfigEntry + for _, entry := range entries { + if !entry.IsDir() { + continue + } + + skillPath := filepath.Join(skillsDir, entry.Name(), "SKILL.md") + if _, err := os.Stat(skillPath); err != nil { + if os.IsNotExist(err) { + continue + } + return nil, fmt.Errorf("stat skill %q: %w", skillPath, err) + } + + configs = append(configs, SkillConfigEntry{ + Path: skillPath, + Enabled: true, + }) + } + + sort.Slice(configs, func(i, j int) bool { + return strings.ToLower(configs[i].Path) < strings.ToLower(configs[j].Path) + }) + + return configs, nil +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/codex/skills_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/codex/skills_test.go new file mode 100644 index 0000000000..66b870114e --- /dev/null +++ b/go/deployment-operator/pkg/agentrun-harness/tool/codex/skills_test.go @@ -0,0 +1,65 @@ +package codex + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/pelletier/go-toml/v2" +) + +func TestBundledSkillConfigs(t *testing.T) { + skillsDir := t.TempDir() + for _, name := range []string{"plural-services", "plural-git-repository"} { + dir := filepath.Join(skillsDir, name) + if err := os.MkdirAll(dir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte("---\nname: "+name+"\n---\n"), 0644); err != nil { + t.Fatal(err) + } + } + + configs, err := bundledSkillConfigs(skillsDir) + if err != nil { + t.Fatalf("bundledSkillConfigs: %v", err) + } + if len(configs) != 2 { + t.Fatalf("expected 2 skills, got %d", len(configs)) + } + if configs[0].Path != filepath.Join(skillsDir, "plural-git-repository", "SKILL.md") { + t.Fatalf("unexpected first skill path: %s", configs[0].Path) + } + if !configs[0].Enabled { + t.Fatal("expected skills to be enabled") + } +} + +func TestCodexConfigSkillsConfigTOML(t *testing.T) { + cfg := &CodexConfig{ + Features: &CodexGlobalFeatures{Skills: true}, + Skills: &SkillsSettings{ + Config: []SkillConfigEntry{{ + Path: "/plural/skills/plural-git-repository/SKILL.md", + Enabled: true, + }}, + }, + } + + data, err := toml.Marshal(cfg) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + out := string(data) + if !strings.Contains(out, "[[skills.config]]") { + t.Fatalf("expected [[skills.config]] in config, got:\n%s", out) + } + if !strings.Contains(out, "/plural/skills/plural-git-repository/SKILL.md") { + t.Fatalf("expected skill path in config, got:\n%s", out) + } + if !strings.Contains(out, "enabled = true") { + t.Fatalf("expected enabled = true in config, got:\n%s", out) + } +} diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/templates/settings.json.gotmpl b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/templates/settings.json.gotmpl index 8a50958e7d..a7cfd0e65e 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/gemini/templates/settings.json.gotmpl +++ b/go/deployment-operator/pkg/agentrun-harness/tool/gemini/templates/settings.json.gotmpl @@ -59,5 +59,8 @@ "shell": { "inactivityTimeout": {{ .InactivityTimeout }} } + }, + "skills": { + "enabled": true } } diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/opencode/opencode_templates_test.go b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/opencode_templates_test.go index c14cb9f152..2faa0d9619 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/opencode/opencode_templates_test.go +++ b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/opencode_templates_test.go @@ -201,6 +201,21 @@ func TestConfigTemplate_Provider(t *testing.T) { }) } +func TestConfigTemplate_SkillPermissions(t *testing.T) { + t.Run("analysis and autonomous agents allow all skills", func(t *testing.T) { + out := renderJSON(t, baseInput(console.AgentRunModeWrite)) + + agent := out["agent"].(map[string]any) + for _, name := range []string{"analysis", "autonomous"} { + permission := agent[name].(map[string]any)["permission"].(map[string]any) + skill := permission["skill"].(map[string]any) + if skill["*"] != "allow" { + t.Fatalf("agent.%s permission.skill.* = %v, want allow", name, skill["*"]) + } + } + }) +} + func TestConfigTemplate_AgentTools(t *testing.T) { t.Run("analysis agent has plural* tool only", func(t *testing.T) { out := renderJSON(t, baseInput(console.AgentRunModeWrite)) diff --git a/go/deployment-operator/pkg/agentrun-harness/tool/opencode/templates/opencode.json.gotmpl b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/templates/opencode.json.gotmpl index 95feebe493..9f0be08ebb 100644 --- a/go/deployment-operator/pkg/agentrun-harness/tool/opencode/templates/opencode.json.gotmpl +++ b/go/deployment-operator/pkg/agentrun-harness/tool/opencode/templates/opencode.json.gotmpl @@ -30,7 +30,10 @@ }, {{- end }} "edit": "deny", - "webfetch": "allow" + "webfetch": "allow", + "skill": { + "*": "allow" + } } }, "autonomous": { @@ -48,7 +51,10 @@ "bash": "allow", "edit": "allow", "webfetch": "allow", - "git": "deny" + "git": "deny", + "skill": { + "*": "allow" + } } } },