From 3edd9a6e545e4d14192914563ff85fbbfdbee672 Mon Sep 17 00:00:00 2001 From: Drew Minnear Date: Thu, 18 Jun 2026 17:44:12 -0400 Subject: [PATCH 1/5] make skill for authoring patterns --- skills/pattern-author/SKILL.md | 238 ++++++++ skills/pattern-author/reference.md | 950 +++++++++++++++++++++++++++++ 2 files changed, 1188 insertions(+) create mode 100644 skills/pattern-author/SKILL.md create mode 100644 skills/pattern-author/reference.md diff --git a/skills/pattern-author/SKILL.md b/skills/pattern-author/SKILL.md new file mode 100644 index 0000000..824de80 --- /dev/null +++ b/skills/pattern-author/SKILL.md @@ -0,0 +1,238 @@ +--- +name: pattern-author +description: > + Author and modify Validated Patterns — GitOps-based deployment configurations + for OpenShift. Use when creating new Patterns, adding applications/subscriptions/namespaces + to existing Patterns, configuring secrets, setting up hub/spoke clusters, or working with + clustergroup values files. +when_to_use: > + When the user asks to create a new Validated Pattern, add a helm chart or application to a + Pattern, configure secrets or vault, set up spoke clusters, modify clustergroup values, + or work with values-global.yaml, values-*.yaml, or values-secret.yaml.template files. + Also when the user mentions "Validated Patterns", "clustergroup", "pattern init", or + "patternizer". +allowed-tools: Read Bash(pattern *) Bash(helm *) Bash(find *) Bash(ls *) +--- + +# Validated Patterns Author + +You are helping author Validated Patterns — GitOps-based deployment configurations for OpenShift built on the [clustergroup helm chart](https://github.com/validatedpatterns/clustergroup-chart/). A Pattern is a git repo containing values files that define what namespaces, operators, and applications to deploy on one or more OpenShift clusters via ArgoCD. + +For complete framework documentation, read [reference.md](reference.md) in this skill directory. Read it before your first Pattern authoring task in a session, or when you need details on a specific framework feature. + +## Authoring Workflow + +When creating a new Pattern: + +1. **Initialize** — Determine the Pattern name and whether secrets are needed. Run `pattern init` or `pattern init --with-secrets` in the Pattern directory. +2. **Identify requirements** — What operators, applications, and custom helm charts does this Pattern need? +3. **Define namespaces** — Add all required namespaces to the clustergroup values file (`values-.yaml`). Include OperatorGroup configuration for operator namespaces. +4. **Define subscriptions** — Add operator subscriptions with at minimum the operator `name`. Set `namespace`, `channel`, and `source` as needed. +5. **Define applications** — Wire in helm charts as applications: + - Local charts: set `path` to the chart location in the repo + - VP-published charts: set `chart` and `chartVersion` + - External git charts: set `repoURL`, `path`, and `chartVersion` (git ref) +6. **Configure secrets** (if applicable) — Define secrets in `values-secret.yaml.template` and create corresponding ExternalSecret CRDs in chart templates. +7. **Set up hub/spoke** (if multi-cluster) — Add ACM subscription and `managedClusterGroups` to the hub. Create spoke values files. +8. **Add imperative jobs** (if needed) — Configure Ansible playbooks in the imperative framework for tasks that don't fit the declarative model. + +When modifying an existing Pattern, read the current `values-global.yaml` and clustergroup values files first to understand the existing structure before making changes. + +## Rules + +These rules must always be followed: + +- **Map form for namespaces** — Always define namespaces as a map, never a list. Maps merge across values files; lists override entirely. +- **No secrets in git** — Never put real secrets or credentials in the Pattern repo. Secrets belong in `~/values-secret-.yaml` on the user's machine. +- **`singleArgoCD: true`** — Always set this for new Patterns. +- **`multiSourceConfig.enabled: true`** — Always set this for new Patterns. +- **Vault only on hub** — The Vault application and namespace belong only on the hub/main cluster. Spoke clusters need ESO only (no Vault). The VP `openshift-external-secrets` chart auto-configures spokes to use the hub's Vault. +- **Chart values stubs** — A chart's `values.yaml` must include default stubs for any `.Values.global.*` or `.Values.clusterGroup.*` values referenced in its templates, so `helm template` works standalone during development. +- **ESO backtick escaping** — In ExternalSecret templates, escape ESO template expressions with backticks to prevent Helm from interpreting them: `"{{ ` + "`" + `{{ .field_name }}` + "`" + ` }}"`. +- **Idempotent imperative jobs** — All imperative jobs run on a schedule (every 10 minutes by default) and must be idempotent. +- **Re-run `pattern init`** — After adding new local helm charts, re-run `pattern init` to wire them into the clustergroup values file. It is idempotent. + +## Common Tasks + +### Adding an Operator + +Add three things to the clustergroup values file: a namespace, a subscription, and (if the operator needs its own chart for configuration) an application. + +```yaml +clusterGroup: + namespaces: + my-operator: + operatorGroup: true + targetNamespaces: [] + + subscriptions: + my-operator: + name: my-operator + namespace: my-operator + channel: stable + + applications: + my-operator-config: + name: my-operator-config + namespace: my-operator + path: charts/my-operator-config +``` + +Not every operator needs a local chart. If the operator requires no additional configuration beyond installation, the namespace and subscription are sufficient. + +### Adding a Local Helm Chart + +Place the chart anywhere in the repo (convention: `charts/`). Run `pattern init` to auto-discover it, or manually add it to the clustergroup values: + +```yaml +clusterGroup: + namespaces: + my-app: + + applications: + my-app: + name: my-app + namespace: my-app + path: charts/my-app +``` + +### Adding a VP-Published Chart + +```yaml +clusterGroup: + applications: + vault: + name: vault + namespace: vault + chart: hashicorp-vault + chartVersion: 0.1.* +``` + +### Adding a Chart from an External Git Repo + +```yaml +clusterGroup: + applications: + external-app: + name: external-app + namespace: external-app + repoURL: https://github.com/org/repo.git + chartVersion: main + path: charts/the-chart +``` + +### Adding a Secret + +1. Define the secret in `values-secret.yaml.template`: + +```yaml +version: "2.0" + +secrets: + - name: my-secret + vaultPrefixes: + - global + fields: + - name: api-key + onMissingValue: prompt + - name: password + onMissingValue: generate + vaultPolicy: validatedPatternDefaultPolicy +``` + +2. Add `secretStore` defaults to the chart's `values.yaml`: + +```yaml +secretStore: + name: vault-backend + kind: ClusterSecretStore + +mysecret: + key: secret/data/global/my-secret + refreshInterval: 2m0s +``` + +3. Create an ExternalSecret template in the chart: + +```yaml +apiVersion: external-secrets.io/v1 +kind: ExternalSecret +metadata: + name: my-secret +spec: + refreshInterval: {{ .Values.mysecret.refreshInterval }} + secretStoreRef: + name: {{ .Values.secretStore.name }} + kind: {{ .Values.secretStore.kind }} + target: + name: my-secret + template: + type: Opaque + data: + api-key: "{{ `{{ .api_key }}` }}" + password: "{{ `{{ .password }}` }}" + data: + - secretKey: api_key + remoteRef: + key: {{ .Values.mysecret.key }} + property: api-key + - secretKey: password + remoteRef: + key: {{ .Values.mysecret.key }} + property: password +``` + +The Vault path is `secret/data//`. The `secretKey` values become the template variables in `target.template.data`. + +### Adding a Spoke Cluster + +1. Add ACM and `managedClusterGroups` to the hub clustergroup values: + +```yaml +clusterGroup: + name: hub + + namespaces: + open-cluster-management: + + subscriptions: + acm: + name: advanced-cluster-management + namespace: open-cluster-management + channel: release-2.16 + + applications: + acm: + name: acm + namespace: open-cluster-management + chart: acm + chartVersion: 0.2.* + + managedClusterGroups: + region-one: + name: group-one + acmlabels: + - name: clusterGroup + value: group-one +``` + +2. Create `values-group-one.yaml` with the spoke's namespaces, subscriptions, and applications. + +3. If using secrets on the spoke, include ESO components (without Vault) in the spoke values. + +### Adding Conditional Overrides + +Define a custom global variable and use `sharedValueFiles`: + +```yaml +# values-global.yaml +global: + device: gpu + +# values-.yaml +clusterGroup: + sharedValueFiles: + - /overrides/values-{{ $.Values.global.device }}.yaml +``` + +Create the override file (e.g., `/overrides/values-gpu.yaml`) with the conditional namespaces, subscriptions, and applications. diff --git a/skills/pattern-author/reference.md b/skills/pattern-author/reference.md new file mode 100644 index 0000000..6e5dee7 --- /dev/null +++ b/skills/pattern-author/reference.md @@ -0,0 +1,950 @@ +# What are Validated Patterns + +[Validated Patterns](https://validatedpatterns.io/) are an advanced form of reference architectures that offer a streamlined approach to deploying complex business solutions. + +Validated Patterns are GitOps-based. A Validated Pattern is a git repo containing the values files for the [clustergroup helm chart](https://github.com/validatedpatterns/clustergroup-chart/). + +Validated Patterns are installed via the [Validated Patterns Operator](https://github.com/validatedpatterns/patterns-operator) (available as a community operator in OpenShift's operator catalog). The operator creates and manages a subscription for OpenShift GitOps (ArgoCD) and creates the app-of-apps Application in ArgoCD — the clustergroup chart with values taken from the Pattern repo (as a multi-source ArgoCD Application). + +# Creating a New Pattern + +1. Create an empty directory (or create a new repo on GitHub/GitLab and clone it). The directory name becomes the Pattern name. +2. Run `podman run --pull=newer -v "$PWD:$PWD:z" -w "$PWD" quay.io/validatedpatterns/patternizer init` to create the new Pattern. Add `--with-secrets` to include the necessary components for the Validated Patterns secret framework: + `podman run --pull=newer -v "$PWD:$PWD:z" -w "$PWD" quay.io/validatedpatterns/patternizer init --with-secrets` + +[The patternizer source is available on GitHub.](https://github.com/validatedpatterns/patternizer) + +You can shorten the patternizer command by adding a shell function to your shell's startup: + +```bash +pattern() { + podman run --pull=newer \ + -v "$PWD:$PWD:z" \ + -w "$PWD" \ + quay.io/validatedpatterns/patternizer "$@" +} +``` + +Then run `pattern init` or `pattern init --with-secrets`. + +`pattern init` is idempotent. You can add secrets to a Pattern initialized without them by running `pattern init --with-secrets` later. You can also re-run `pattern init` as you add helm charts to the repo and it will wire them into the clustergroup values file. + +# Pattern File Structure + +Given a fresh Pattern directory with a user-defined helm chart: + +```bash +mkdir -p fresh-pattern/charts +cd fresh-pattern/charts +helm create user-defined-chart +cd .. +pattern init --with-secrets +``` + +The resulting structure: + +``` +fresh-pattern +├── ansible.cfg +├── charts +│ └── user-defined-chart +├── Makefile +├── Makefile-common +├── pattern.sh +├── values-global.yaml +├── values-prod.yaml +└── values-secret.yaml.template +``` + +## File Descriptions + +`ansible.cfg` contains defaults for the ansible playbooks invoked via the Makefile (e.g., `make install`). These playbooks are defined in the [rhvp.cluster_utils collection](https://github.com/validatedpatterns/rhvp.cluster_utils). + +`charts/` is the recommended location for local helm charts. `pattern init` discovers charts anywhere in the repo, but `charts/` is the convention. + +`Makefile` includes `Makefile-common` and provides a place for Pattern-specific make targets or overrides. Most Patterns never need to override the common targets, but it's useful for custom tests, linting, or convenience functions. + +`Makefile-common` provides the core make targets for the Patterns framework. Documented more fully on the [VP blog](https://validatedpatterns.io/blog/2025-08-29-new-common-makefile-structure/). The primary targets are `make install`, `make uninstall`, and `make load-secrets`. These targets are run via the `./pattern.sh` wrapper script, which executes them inside the [VP Utility Container](https://github.com/validatedpatterns/utility-container). The utility container includes make, oc, helm, aws, and other CLIs, so the only local dependency is podman. A user can run `./pattern.sh make install` to install a Pattern (assuming they are logged into an OpenShift cluster). + +`pattern.sh` is the wrapper script that runs make targets inside the utility container. + +`values-secret.yaml.template` is a template showing how the Pattern's secrets should be formatted for the [secrets framework](#the-secrets-framework). This file should never contain real secrets. Copy it to your home directory (`cp values-secret.yaml.template ~/values-secret-fresh-pattern.yaml`) so secrets stay on your local machine, not in your git repo. + +## values-global.yaml + +```yaml +global: + pattern: fresh-pattern + singleArgoCD: true + secretLoader: + disabled: false +main: + clusterGroupName: prod + multiSourceConfig: + enabled: true + clusterGroupChartVersion: 0.9.* +``` + +### Field Reference + +`global.pattern` — The Pattern name, taken from the directory name by patternizer. Used as a label on ArgoCD Applications and as part of the ArgoCD namespace name. + +`global.singleArgoCD` — When `true` (the patternizer default), each cluster uses a single ArgoCD instance for both the app-of-apps and all child applications. When `false` (legacy behavior), each cluster runs two ArgoCD instances: one for the clustergroup chart (app-of-apps) and a separate one for the applications it defines. A hub/spoke Pattern with `singleArgoCD: false` would have two ArgoCD instances on the hub and two on the spoke. With `singleArgoCD: true`, there is one ArgoCD instance on the hub and one on the spoke. New Patterns should always use `true`. + +`global.secretLoader.disabled` — When `true`, skip loading secrets. Useful for Patterns that don't use the secrets framework. + +`main.clusterGroupName` — The name of the primary clustergroup. Determines which `values-.yaml` file defines the main cluster. If you change this value, the next `pattern init` run creates the corresponding values file (you would need to manually delete the old unused one). + +`main.multiSourceConfig.enabled` — Enable ArgoCD multi-source applications. Should always be `true` for modern Patterns. + +`main.multiSourceConfig.clusterGroupChartVersion` — Semver constraint for the clustergroup chart version from the VP chart repository. + +There are also some global options that can be set in `values-global.yaml` but are not included by default: + +`global.options.syncPolicy` — Controls the ArgoCD sync policy for all applications. `"Automatic"` (the default) enables auto-sync with retry. `"Manual"` disables auto-sync. Per-application `syncPolicy` overrides this global default. + +`global.options.installPlanApproval` — Default `installPlanApproval` for all subscriptions. `"Automatic"` (the default) allows operators to auto-upgrade when new updates are available in their channel. Set to `"Manual"` to prevent auto-upgrades. Per-subscription values override this. + +`global.options.useCSV` — When `True` (the default), subscriptions include a `startingCSV` field if their `.csv` value is set. + +## values-prod.yaml + +`values-prod.yaml` is the values file that defines the main clustergroup of the Pattern. In hub/spoke Patterns (see [Spoke clusters](#spoke-clusters)) this would be the hub cluster. In single cluster Patterns this defines the solitary cluster. If there is one file to look at which defines the Pattern, it is this one. Its contents are explored in [The clustergroup values](#the-clustergroup-values) section. + +## The Pattern CR + +When you run `./pattern.sh make install` to deploy the Pattern, the values in `values-global.yaml` are passed to the [pattern-install chart](https://github.com/validatedpatterns/pattern-install-chart) by the [rhvp.cluster_utils.install](https://github.com/validatedpatterns/rhvp.cluster_utils/blob/main/playbooks/install.yml) playbook. This chart creates a subscription for the Validated Patterns Operator, a configmap with default operator configuration, and the Pattern CR: + +```yaml +apiVersion: gitops.hybrid-cloud-patterns.io/v1alpha1 +kind: Pattern +metadata: + name: fresh-pattern + namespace: openshift-operators +spec: + clusterGroupName: prod + gitSpec: + targetRepo: https://github.com/validatedpatterns/fresh-pattern.git + targetRevision: main + multiSourceConfig: + enabled: true + clusterGroupChartVersion: 0.9.* +``` + +Your branch (`main` in this example) must have an upstream remote set and be pushed to that remote. + +# Values File Hierarchy + +When the Patterns Operator creates the app-of-apps representing the Pattern, it renders the [clustergroup chart](https://github.com/validatedpatterns/clustergroup-chart/) and automatically includes certain values files (if they exist) to customize resources for a given clustergroup, OCP version, and platform. + +The following values files are automatically included for each application, in this order: + +1. `values-global.yaml` — included for all clustergroups +2. `values-.yaml` — values specific to the clustergroup +3. `values-.yaml` — values specific to the platform (e.g., `values-AWS.yaml`) +4. `values--.yaml` — platform and version specific (e.g., `values-AWS-4.21.yaml`) +5. `values--.yaml` — platform and clustergroup specific (e.g., `values-AWS-hub.yaml`) +6. `values--.yaml` — version and clustergroup specific (e.g., `values-4.21-hub.yaml`) +7. `values-.yaml` — values for a specifically named cluster (e.g., `values-test-cluster.yaml`) +8. Files from `global.extraValueFiles` — additional global value files +9. Per-clustergroup `sharedValueFiles` — from `clusterGroup.sharedValueFiles` +10. Per-application `extraValueFiles` — from the application's `extraValueFiles` field + +ArgoCD is configured with `ignoreMissingValueFiles: true`, so it silently skips any of these files that do not exist. Only create the files you actually need. + +In multi-source mode (the default), values files from the Pattern repo are prefixed with `$patternref/` to tell ArgoCD which source they come from. This is handled automatically by the clustergroup chart. + +Some of these values files are cross-clustergroup. For example, `values-AWS.yaml` would apply to both hub and spoke clustergroups running on AWS. + +## Shared Value Files + +The clustergroup provides a `sharedValueFiles` field for including additional overrides for all applications in that clustergroup: + +```yaml +clusterGroup: + sharedValueFiles: + - '/overrides/values-{{ $.Values.global.clusterPlatform }}.yaml' + - '/overrides/values-{{ $.Values.global.clusterPlatform }}-{{ $.Values.global.clusterVersion }}.yaml' + - '/overrides/values-{{ $.Values.global.clusterPlatform }}-{{ $.Values.clusterGroup.name }}.yaml' + - '/overrides/values-{{ $.Values.global.clusterVersion }}-{{ $.Values.clusterGroup.name }}.yaml' + - '/overrides/values-{{ $.Values.global.localClusterName }}.yaml' +``` + +These paths support Helm template interpolation. The Validated Patterns operator handles resolving the global variables. As with the auto-included files, any that do not exist are silently skipped. + +By convention, extra value files are placed in an `overrides/` directory in the Pattern repo, but any location works. + +## Custom Globals for Conditional Overrides + +You can define your own global variables in `values-global.yaml` and use them in `sharedValueFiles` to enable conditional configuration. Consider this example: + +```yaml +# values-global.yaml +global: + pattern: ai-quickstart-rag + device: cpu # one of 'cpu' (no GPU) or 'gpu' (NVIDIA GPU) +main: + clusterGroupName: prod + multiSourceConfig: + enabled: true + clusterGroupChartVersion: 0.9.* +``` + +This enables conditional value file inclusion: + +```yaml +clusterGroup: + sharedValueFiles: + - /overrides/values-{{ $.Values.global.device }}.yaml + - /overrides/values-{{ $.Values.global.device }}-{{ $.Values.global.clusterPlatform }}.yaml +``` + +```yaml +# /overrides/values-gpu.yaml +global: + models: + llama-3-2-3b-instruct: + enabled: true + +llm-service: + device: gpu + +clusterGroup: + namespaces: + openshift-nfd: + nvidia-gpu-operator: + + subscriptions: + nfd: + name: nfd + namespace: openshift-nfd + nvidia: + name: gpu-operator-certified + namespace: nvidia-gpu-operator + source: certified-operators + + applications: + nfd: + name: nfd + namespace: openshift-nfd + path: charts/nfd + nvidia-config: + name: nvidia-config + namespace: nvidia-gpu-operator + path: charts/nvidia-config +``` + +```yaml +# /overrides/values-gpu-AWS.yaml +clusterGroup: + imperative: + jobs: + - name: deploy-nvidia-gpu + playbook: rhvp.cluster_utils.create_machineset + verbosity: -vvv + extravars: + - max_machineset_count=1 + - machineset_replicas=1 + - ensure_two_machine_minimum=false + - machineset_name=nvidia-gpu + - machineset_labels= + - machineset_instance_type=g6.2xlarge + - 'machineset_taints=[{"effect":"NoSchedule","key":"nvidia.com/gpu","value":"true"}]' + - 'machineset_node_labels={"node-role.kubernetes.io/nvidia-gpu":""}' + - machineset_api_version=machine.openshift.io/v1beta1 + clusterRoleYaml: + - apiGroups: + - "*" + resources: + - machinesets + - persistentvolumeclaims + - datavolumes + - dataimportcrons + - datasources + verbs: + - "*" + - apiGroups: + - "*" + resources: + - "*" + verbs: + - get + - list + - watch +``` + +This example leverages the `global.device` value to install GPU-specific subscriptions and create an additional machineset when deployed to AWS with a GPU. + +# The Clustergroup Values + +A Pattern IS a clustergroup. The git repo containing a Validated Pattern contains the values files for the [clustergroup helm chart](https://github.com/validatedpatterns/clustergroup-chart/). The clustergroup values file (`values-.yaml`) is the central definition of what a Pattern deploys — which namespaces to create, which operators to install, and which applications (helm charts) to run. + +Here is the `values-prod.yaml` generated by `pattern init --with-secrets` for our example Pattern. It defines a single clustergroup named `prod` with the namespaces, operator subscriptions, and applications needed for the secrets framework alongside the user's own chart: + +```yaml +clusterGroup: + name: prod + namespaces: + fresh-pattern: + vault: + external-secrets-operator: + operatorGroup: true + targetNamespaces: [] + external-secrets: + subscriptions: + eso: + name: openshift-external-secrets-operator + namespace: external-secrets-operator + channel: stable-v1 + applications: + openshift-external-secrets: + name: openshift-external-secrets + namespace: external-secrets + chart: openshift-external-secrets + chartVersion: 0.0.* + user-defined-chart: + name: user-defined-chart + namespace: fresh-pattern + path: charts/user-defined-chart + vault: + name: vault + namespace: vault + chart: hashicorp-vault + chartVersion: 0.1.* +``` + +These three sections — `namespaces`, `subscriptions`, and `applications` — are the core building blocks of every clustergroup. Each is detailed below. + +## Namespaces + +The namespace section accepts simple strings or more complex mappings: + +```yaml +clusterGroup: + namespaces: + fresh-pattern: + external-secrets-operator: + operatorGroup: true + targetNamespaces: [] +``` + +Setting `operatorGroup: true` creates an OperatorGroup with the same name as the namespace using the specified `targetNamespaces`. + +Labels and annotations can also be set: + +```yaml +clusterGroup: + namespaces: + rag-llm: + labels: + opendatahub.io/dashboard: "true" + modelmesh-enabled: "false" +``` + +Namespaces can also be defined as a list: + +```yaml +clusterGroup: + namespaces: + - fresh-pattern + - vault + - external-secrets-operator: + operatorGroup: true + targetNamespaces: [] + - external-secrets +``` + +The map form is recommended over the list form. When merging values files, lists are overridden entirely whereas maps are merged. + +## Subscriptions + +Subscriptions define operators that should be installed on the cluster. + +The only required field is the name of the subscription: + +```yaml +clusterGroup: + subscriptions: + servicemesh-console: + name: kiali-ossm +``` + +The clustergroup chart provides defaults for the other fields, making this equivalent to: + +```yaml +clusterGroup: + subscriptions: + servicemesh-console: + name: kiali-ossm + namespace: openshift-operators + source: redhat-operators + sourceNamespace: openshift-marketplace + channel: stable + installPlanApproval: Automatic +``` + +You can also specify `.csv` for the `startingCSV` of the subscription if needed. + +The `name` must be the operator's name in the `source` catalog. The default source is `redhat-operators`, but some operators are in `certified-operators`, `community-operators`, or `redhat-marketplace`. + +`namespace` is where the operator is installed. For operators like ESO, this is an OperatorGroup namespace. + +`source` and `sourceNamespace` only need updating in disconnected or custom install scenarios where the standard catalog sources are unavailable. + +`channel` is operator-specific. Some operators publish on multiple channels (e.g., `fast`, `stable-3.x`). + +Set `installPlanApproval` to `Manual` to prevent the operator from upgrading automatically when new updates are available in its channel. The default is `Automatic`. + +## Applications + +### Local Helm Charts + +If `path` is provided (and `repoURL` and `chartVersion` are not), the helm chart is sourced from the Pattern repo: + +```yaml +clusterGroup: + applications: + user-defined-chart: + name: user-defined-chart + namespace: fresh-pattern + path: charts/user-defined-chart +``` + +### VP-Published Helm Charts + +If neither `repoURL` nor `path` are provided, charts are sourced from the [Validated Patterns chart repository](https://charts.validatedpatterns.io/). Specify a version with `chartVersion`: + +```yaml +clusterGroup: + applications: + vault: + name: vault + namespace: vault + chart: hashicorp-vault + chartVersion: 0.1.* +``` + +### Helm Charts from External Git Repositories + +Helm charts from external git repos can be referenced with `repoURL` (the publicly reachable git URL), `path` (the path within the repo), and `chartVersion` (the git revision to use): + +```yaml +clusterGroup: + applications: + maas-quickstart: + name: maas-quickstart + repoURL: https://github.com/dminnear-rh/maas-code-assistant.git + chartVersion: main + path: charts/maas-code-assistant +``` + +### Additional Application Fields + +Several additional fields are available for any application type: + +`extraValueFiles` — Paths (relative to the Pattern repo root) to additional values files passed to the helm chart: + +```yaml +extraValueFiles: + - /overrides/maas-quickstart.yaml +``` + +`overrides` — Direct helm value overrides: + +```yaml +overrides: + - name: grafana.namespace + value: grafana +``` + +`ignoreDifferences` — Instructs ArgoCD to ignore certain differences when computing the sync diff: + +```yaml +ignoreDifferences: + - kind: Secret + name: grafana-proxy + namespace: grafana + jsonPointers: + - /data/session_secret +``` + +A full example combining these: + +```yaml +clusterGroup: + applications: + maas-quickstart: + name: maas-quickstart + repoURL: https://github.com/dminnear-rh/maas-code-assistant.git + chartVersion: main + path: charts/maas-code-assistant + extraValueFiles: + - /overrides/maas-quickstart.yaml + overrides: + - name: grafana.namespace + value: grafana + ignoreDifferences: + - kind: Secret + name: grafana-proxy + namespace: grafana + jsonPointers: + - /data/session_secret +``` + +# Using Helm Charts in VP + +This section covers what the VP framework provides to your charts, not helm chart authoring in general. + +## Where Charts Live + +The default location is `charts/` in the Pattern repo. `patternizer init` auto-discovers charts anywhere in the repo, so you can organize them however you like. + +Charts can also live in separate git repositories and be referenced via `repoURL` in the application definition. VP-published charts are available from [charts.validatedpatterns.io](https://charts.validatedpatterns.io/) (referenced with `chart:` and `chartVersion:`). + +## How Values Flow into Charts + +Every ArgoCD Application created by the clustergroup chart receives the full merged tree of values files described in [Values File Hierarchy](#values-file-hierarchy). This means every chart sees: + +- `.Values.global.*` — global Pattern values +- `.Values.clusterGroup.*` — clustergroup configuration +- Chart-specific values from the chart's own `values.yaml` + +In addition to the values files, the clustergroup chart injects helm parameters for cluster-specific values that are known at deploy time: + +- `global.repoURL` — Pattern repository URL +- `global.targetRevision` — git branch/commit/ref +- `global.namespace` — the ArgoCD app namespace +- `global.pattern` — Pattern name +- `global.clusterDomain` — cluster FQDN +- `global.localClusterName` — local cluster identifier +- `global.clusterVersion` — OpenShift version +- `global.clusterPlatform` — platform type (e.g., AWS, Azure, GCP) +- `global.hubClusterDomain` — hub cluster FQDN +- `global.localClusterDomain` — local cluster FQDN + +These are available in templates as `.Values.global.`. + +A chart's `values.yaml` should include default stubs for any `global.*` or `clusterGroup.*` values referenced in its templates. These defaults enable standalone `helm template` to work during development and are overridden at deploy time by the merged values tree. + +## Example: config-demo Chart + +The [config-demo chart](https://github.com/validatedpatterns/multicloud-gitops/tree/main/charts/all/config-demo) from multicloud-gitops is a minimal working example. Its `values.yaml`: + +```yaml +secretStore: + name: vault-backend + kind: ClusterSecretStore + +configdemosecret: + key: secret/data/global/config-demo + refreshInterval: 2m0s + +global: + hubClusterDomain: hub.example.com + localClusterDomain: region-one.example.com + +clusterGroup: + isHubCluster: true + +image: + repository: registry.access.redhat.com/ubi10/httpd-24 + tag: "10.0-1755779646" + pullPolicy: IfNotPresent +``` + +The `global` and `clusterGroup` stubs provide defaults for development. The `secretStore` and `configdemosecret` sections are chart-specific values used by the ExternalSecret template (see [Consuming Secrets in Charts](#consuming-secrets-in-charts)). + +The deployment template uses chart-specific values: + +```yaml +containers: +- name: apache + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} +``` + +The ConfigMap template uses global values injected by the framework, demonstrating how charts can reference cluster-specific information without hardcoding it: + +```yaml +data: + "index.html": |- +

+ Hub Cluster domain is '{{ .Values.global.hubClusterDomain }}'
+ Pod is running on Local Cluster Domain '{{ .Values.global.localClusterDomain }}'
+

+``` + +# The Secrets Framework + +Secrets in the VP framework are stored in Vault and consumed in charts via External Secrets Operator (ESO). The workflow is: define secrets in `values-secret.yaml.template`, load them into Vault with `./pattern.sh make install` or `./pattern.sh make load-secrets`, and consume them in charts using ExternalSecret CRDs. + +## Defining Secrets + +The `values-secret.yaml.template` file defines the secrets a Pattern needs. The install/load-secrets command looks for secrets in `~/values-secret-.yaml` and falls back to the template in the Pattern repo. This encourages users to copy the template to their home directory and keep secrets out of the git repo. + +Example from [multicloud-gitops](https://github.com/validatedpatterns/multicloud-gitops/blob/main/values-secret.yaml.template): + +```yaml +version: "2.0" + +secrets: + - name: config-demo + vaultPrefixes: + - global + fields: + - name: secret + onMissingValue: generate + vaultPolicy: validatedPatternDefaultPolicy +``` + +### Secret Field Reference + +Each secret entry supports these fields: + +- `name` — secret name (becomes the Vault path segment) +- `vaultPrefixes` — list of Vault path prefixes controlling which clustergroups can read the secret +- `fields[].name` — field name within the secret +- `fields[].value` — literal value +- `fields[].path` — path to a file containing the value +- `fields[].ini_file`, `ini_section`, `ini_key` — read a value from an INI file +- `fields[].onMissingValue` — set to `generate` to auto-generate the value +- `fields[].vaultPolicy` — policy name for generation (either `validatedPatternDefaultPolicy` or a custom policy) + +Example with various field types: + +```yaml +secrets: + # AWS credentials from INI file + - name: aws + fields: + - name: aws_access_key_id + ini_file: ~/.aws/credentials + ini_section: default + ini_key: aws_access_key_id + - name: aws_secret_access_key + ini_file: ~/.aws/credentials + ini_key: aws_secret_access_key + + # SSH keys from files + - name: publickey + fields: + - name: content + path: ~/.ssh/id_rsa.pub + - name: privatekey + fields: + - name: content + path: ~/.ssh/id_rsa + + # OpenShift pull secret from file + - name: openshiftPullSecret + fields: + - name: content + path: ~/.pullsecret.json +``` + +## Vault Prefixes and Access Control + +The `vaultPrefixes` field controls which clustergroups can access a secret. The Vault path convention is `secret/data//`. + +- `global` — readable by all clustergroups (hub and spoke) +- A clustergroup name (e.g., `hub`) — readable only by that clustergroup +- Multiple prefixes write the secret to multiple paths, making it accessible from multiple clustergroups + +## Secret Generation and Policies + +Secrets can be auto-generated if no value is provided by setting `onMissingValue: generate`. The generation is controlled by a vault policy: + +```yaml +version: "2.0" + +vaultPolicies: + basicPolicy: | + length=16 + rule "charset" { charset = "abcdefghijklmnopqrstuvwxyz" min-chars = 1 } + rule "charset" { charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" min-chars = 1 } + rule "charset" { charset = "0123456789" min-chars = 1 } + +secrets: + - name: pgvector + fields: + - name: user + value: postgres + - name: password + onMissingValue: generate + vaultPolicy: basicPolicy + - name: dbname + value: rag_blueprint + - name: host + value: pgvector + - name: port + value: "5432" +``` + +## Consuming Secrets in Charts + +Secrets stored in Vault are consumed in charts using ESO ExternalSecret CRDs. The VP `openshift-external-secrets` chart sets up a `ClusterSecretStore` named `vault-backend` that points to the Vault instance. + +A chart that needs secrets should include `secretStore` defaults in its `values.yaml`: + +```yaml +secretStore: + name: vault-backend + kind: ClusterSecretStore + +configdemosecret: + key: secret/data/global/config-demo + refreshInterval: 2m0s +``` + +The ExternalSecret template maps secrets from Vault into Kubernetes Secrets: + +```yaml +apiVersion: external-secrets.io/v1 +kind: ExternalSecret +metadata: + name: config-demo-secret + namespace: config-demo +spec: + refreshInterval: {{ .Values.configdemosecret.refreshInterval }} + secretStoreRef: + name: {{ .Values.secretStore.name }} + kind: {{ .Values.secretStore.kind }} + target: + name: config-demo-secret + template: + type: Opaque + data: + secret: "{{ `{{ .configdemo_secret }}` }}" + data: + - secretKey: configdemo_secret + remoteRef: + key: {{ .Values.configdemosecret.key }} + property: secret +``` + +### How the Mapping Works + +Given a secret defined in `values-secret.yaml.template` as: + +```yaml +secrets: + - name: config-demo + vaultPrefixes: + - global + fields: + - name: secret +``` + +The Vault path is: `secret/data/global/config-demo` (constructed as `secret/data//`). + +In the ExternalSecret: +- `remoteRef.key` is set to this Vault path (`secret/data/global/config-demo`) +- `remoteRef.property` is the field name (`secret`) +- `data[].secretKey` (`configdemo_secret`) is the local key used to reference the fetched value in the `target.template` + +### Backtick Escaping for ESO Templates + +The `target.template.data` section uses ESO's own template syntax (`{{ .fieldname }}`) to map fetched values into the Kubernetes Secret. Since the ExternalSecret is itself rendered by Helm, the ESO template expressions must be escaped to prevent Helm from interpreting them. The pattern is: + +``` +"{{ `{{ .configdemo_secret }}` }}" +``` + +The backticks create a Go raw string literal that Helm passes through unchanged. ESO then processes `{{ .configdemo_secret }}` at runtime. + +## Hub vs. Spoke Secret Infrastructure + +The Vault/ESO components should only be defined on the hub/main cluster: + +```yaml +# Hub cluster needs both Vault and ESO +clusterGroup: + namespaces: + vault: + external-secrets-operator: + operatorGroup: true + targetNamespaces: [] + external-secrets: + subscriptions: + eso: + name: openshift-external-secrets-operator + namespace: external-secrets-operator + channel: stable-v1 + applications: + openshift-external-secrets: + name: openshift-external-secrets + namespace: external-secrets + chart: openshift-external-secrets + chartVersion: 0.0.* + vault: + name: vault + namespace: vault + chart: hashicorp-vault + chartVersion: 0.1.* +``` + +Spoke clusters only need ESO — no Vault: + +```yaml +# Spoke cluster needs ESO only +clusterGroup: + namespaces: + external-secrets-operator: + operatorGroup: true + targetNamespaces: [] + external-secrets: + subscriptions: + eso: + name: openshift-external-secrets-operator + namespace: external-secrets-operator + channel: stable-v1 + applications: + openshift-external-secrets: + name: openshift-external-secrets + namespace: external-secrets + chart: openshift-external-secrets + chartVersion: 0.0.* +``` + +The VP `openshift-external-secrets` chart automatically configures spoke clusters to use the Vault instance on the hub cluster as the external secret store. + +For more information, see [Secrets management in the Validated Patterns framework](https://validatedpatterns.io/learn/secrets-management-in-the-validated-patterns-framework/). + +# Spoke Clusters + +Hub/spoke cluster support uses ACM (Advanced Cluster Management). Include the ACM components in your hub clustergroup: + +```yaml +clusterGroup: + name: hub + + namespaces: + open-cluster-management: + + subscriptions: + acm: + name: advanced-cluster-management + namespace: open-cluster-management + channel: release-2.16 + + applications: + acm: + name: acm + namespace: open-cluster-management + chart: acm + chartVersion: 0.2.* + + managedClusterGroups: + exampleRegion: + name: group-one + acmlabels: + - name: clusterGroup + value: group-one +``` + +This installs ACM on the hub cluster via the Validated Patterns ACM chart. The `managedClusterGroups` defines the mapping the framework uses to determine which clusters imported into ACM correspond to which clustergroup values files. + +To create a spoke clustergroup, create `values-group-one.yaml` with its own namespaces, subscriptions, and applications. For secrets, include the ESO components (without Vault) as described in [Hub vs. Spoke Secret Infrastructure](#hub-vs-spoke-secret-infrastructure). + +```yaml +clusterGroup: + name: group-one + + namespaces: + config-demo: + hello-world: + external-secrets-operator: + operatorGroup: true + targetNamespaces: [] + external-secrets: + + subscriptions: + eso: + name: openshift-external-secrets-operator + namespace: external-secrets-operator + channel: stable-v1 + + applications: + openshift-external-secrets: + name: openshift-external-secrets + namespace: external-secrets + chart: openshift-external-secrets + chartVersion: 0.0.* + config-demo: + name: config-demo + namespace: config-demo + path: charts/all/config-demo + hello-world: + name: hello-world + namespace: hello-world + path: charts/all/hello-world +``` + +Any cluster imported into ACM with the label `clusterGroup: group-one` will have the clustergroup chart applied using `values-global.yaml` and `values-group-one.yaml` (plus all the other automatically included platform/version values files). + +In short, spoke and hub clusters are both just clustergroups. The difference is that the hub cluster includes ACM and the Vault components. While the Patterns Operator installs the Pattern via the clustergroup chart in ArgoCD on the hub cluster, the ACM chart pushes policies to spoke clusters for installing OpenShift GitOps (ArgoCD) and the clustergroup chart for that spoke cluster. + +# The Imperative Framework + +Sometimes tasks don't fit neatly into a declarative framework. The imperative framework runs Ansible playbooks on a schedule against the cluster. + +```yaml +clusterGroup: + imperative: + jobs: + - name: trilio-enable-cr + playbook: ansible/playbooks/imperative-enable-cr.yaml + timeout: 900 + + - name: trilio-cr-backup + playbook: ansible/playbooks/imperative-cr-backup.yaml + timeout: 1200 + + - name: trilio-backup + playbook: ansible/playbooks/imperative-backup.yaml + timeout: 1200 + + - name: trilio-restore-standard + playbook: ansible/playbooks/imperative-restore-standard.yaml + timeout: 1800 + + - name: trilio-e2e-status + playbook: ansible/playbooks/imperative-e2e-status.yaml + timeout: 120 +``` + +Imperative jobs typically reference playbooks stored in the `ansible/` directory of the Pattern repo, or playbooks from the `rhvp.cluster_utils` ansible collection. Jobs are defined as a list since they run in order — if one fails, the imperative job fails and remaining jobs are aborted. Jobs must be idempotent since they run on a schedule (every 10 minutes by default). + +The full set of imperative framework defaults: + +```yaml +imperative: + jobs: [] + image: quay.io/validatedpatterns/imperative-container:v1 + ansibleDevMode: + enabled: false + requirementsFile: "requirements.yml" + requirementsContent: "" + ansibleCfgFile: "ansible.cfg" + ansibleCfgContent: "" + namespace: "imperative" + valuesConfigMap: "helm-values-configmap" + cronJobName: "imperative-cronjob" + jobName: "imperative-job" + imagePullPolicy: Always + activeDeadlineSeconds: 3600 + schedule: "*/10 * * * *" + insecureUnsealVaultInsideClusterSchedule: "*/5 * * * *" + verbosity: "" + extraPlaybookArgs: [] + serviceAccountCreate: true + serviceAccountName: imperative-sa + clusterRoleName: imperative-cluster-role + clusterRoleYaml: "" + roleName: imperative-role + roleYaml: "" + adminServiceAccountCreate: true + adminServiceAccountName: imperative-admin-sa + adminClusterRoleName: imperative-admin-cluster-role + vaultNamespace: "vault" +``` From 7e425c80b97a471acb80ddc9b6af8e280a4b9f68 Mon Sep 17 00:00:00 2001 From: Drew Minnear Date: Wed, 24 Jun 2026 13:45:10 -0400 Subject: [PATCH 2/5] add skills install to init and upgrade commands --- Containerfile | 6 ++ src/cmd/cmd_suite_test.go | 25 +++++++- src/cmd/init.go | 4 ++ src/cmd/init_test.go | 4 ++ src/cmd/upgrade.go | 4 ++ src/cmd/upgrade_test.go | 4 ++ src/internal/fileutils/fileutils.go | 49 +++++++++++++++- src/internal/fileutils/fileutils_test.go | 73 ++++++++++++++++++++++++ src/internal/fileutils/skills.go | 41 +++++++++++++ 9 files changed, 208 insertions(+), 2 deletions(-) create mode 100644 src/internal/fileutils/skills.go diff --git a/Containerfile b/Containerfile index 5d02f6e..60ca16b 100644 --- a/Containerfile +++ b/Containerfile @@ -24,7 +24,13 @@ WORKDIR ${PATTERNIZER_RESOURCES_DIR} COPY resources/* . +ARG PATTERNIZER_SKILLS_DIR=/tmp/skills +WORKDIR ${PATTERNIZER_SKILLS_DIR} + +COPY skills/ . + ENV PATTERNIZER_RESOURCES_DIR=${PATTERNIZER_RESOURCES_DIR} +ENV PATTERNIZER_SKILLS_DIR=${PATTERNIZER_SKILLS_DIR} ENTRYPOINT ["patternizer"] CMD ["help"] diff --git a/src/cmd/cmd_suite_test.go b/src/cmd/cmd_suite_test.go index 7d7e62b..76e35f6 100644 --- a/src/cmd/cmd_suite_test.go +++ b/src/cmd/cmd_suite_test.go @@ -19,6 +19,7 @@ import ( var ( binaryPath string resourcesPath string + skillsPath string projectRoot string ) @@ -39,6 +40,10 @@ var _ = BeforeSuite(func() { Expect(resourcesPath).To(BeADirectory(), "Could not find resources directory") os.Setenv("PATTERNIZER_RESOURCES_DIR", resourcesPath) + skillsPath = filepath.Join(projectRoot, "skills") + Expect(skillsPath).To(BeADirectory(), "Could not find skills directory") + os.Setenv("PATTERNIZER_SKILLS_DIR", skillsPath) + binaryPath, err = gexec.Build(filepath.Join(projectRoot, "src")) Expect(err).NotTo(HaveOccurred()) }) @@ -130,7 +135,10 @@ func createTestDir() string { func runCLI(dir string, args ...string) *gexec.Session { cmd := exec.Command(binaryPath, args...) cmd.Dir = dir - cmd.Env = append(os.Environ(), "PATTERNIZER_RESOURCES_DIR="+resourcesPath) + cmd.Env = append(os.Environ(), + "PATTERNIZER_RESOURCES_DIR="+resourcesPath, + "PATTERNIZER_SKILLS_DIR="+skillsPath, + ) session, err := gexec.Start(cmd, GinkgoWriter, GinkgoWriter) Expect(err).NotTo(HaveOccurred()) @@ -138,6 +146,21 @@ func runCLI(dir string, args ...string) *gexec.Session { return session } +func verifySkillsInstalled(dir string) { + for _, target := range []string{".claude", ".cursor"} { + skillDir := filepath.Join(dir, target, "skills", "pattern-author") + Expect(skillDir).To(BeADirectory(), fmt.Sprintf("Expected %s to exist", skillDir)) + + skillMD := filepath.Join(skillDir, "SKILL.md") + expectedMD := filepath.Join(skillsPath, "pattern-author", "SKILL.md") + verifyFilesMatch(skillMD, expectedMD) + + refMD := filepath.Join(skillDir, "reference.md") + expectedRef := filepath.Join(skillsPath, "pattern-author", "reference.md") + verifyFilesMatch(refMD, expectedRef) + } +} + func addDummyChart(dir, name string) { path := filepath.Join(dir, "charts", name) Expect(os.MkdirAll(path, 0o755)).To(Succeed()) diff --git a/src/cmd/init.go b/src/cmd/init.go index 7fa50ff..218d2fd 100644 --- a/src/cmd/init.go +++ b/src/cmd/init.go @@ -77,6 +77,10 @@ func runInit(withSecrets bool) error { } } + if err := fileutils.InstallSkills(repoRoot); err != nil { + return fmt.Errorf("error installing skills: %w", err) + } + fmt.Printf("Successfully initialized pattern '%s' in %s\n", actualPatternName, repoRoot) if withSecrets { fmt.Println("Secrets configuration has been enabled.") diff --git a/src/cmd/init_test.go b/src/cmd/init_test.go index c0bb1f9..e32afd2 100644 --- a/src/cmd/init_test.go +++ b/src/cmd/init_test.go @@ -69,6 +69,10 @@ var _ = Describe("patternizer init", func() { verifyScaffoldFilesCopied(tempDir) }) + It("should install skills for Claude and Cursor", func() { + verifySkillsInstalled(tempDir) + }) + It("should create an appropriate global values file", func() { globalValuesFile := filepath.Join(tempDir, "values-global.yaml") expectedGlobalValues := types.ValuesGlobal{ diff --git a/src/cmd/upgrade.go b/src/cmd/upgrade.go index e03a1fa..46bbf54 100644 --- a/src/cmd/upgrade.go +++ b/src/cmd/upgrade.go @@ -82,6 +82,10 @@ func runUpgrade(replaceMakefile bool) error { } } + if err := fileutils.InstallSkills(repoRoot); err != nil { + return fmt.Errorf("error installing skills: %w", err) + } + fmt.Printf("Successfully upgraded pattern repository in %s\n", repoRoot) return nil } diff --git a/src/cmd/upgrade_test.go b/src/cmd/upgrade_test.go index 0b5dc58..c93f71e 100644 --- a/src/cmd/upgrade_test.go +++ b/src/cmd/upgrade_test.go @@ -61,6 +61,10 @@ var _ = Describe("patternizer upgrade", func() { verifyAnsibleCfgCopied(tempDir) }) + It("should install skills for Claude and Cursor", func() { + verifySkillsInstalled(tempDir) + }) + It("should inject the include for Makefile-common into the existing Makefile", func() { f, err := os.ReadFile(filepath.Join(tempDir, "Makefile")) Expect(err).NotTo(HaveOccurred()) diff --git a/src/internal/fileutils/fileutils.go b/src/internal/fileutils/fileutils.go index 56fb174..a989cdf 100644 --- a/src/internal/fileutils/fileutils.go +++ b/src/internal/fileutils/fileutils.go @@ -72,10 +72,57 @@ func GetResourcesPath() (path string, err error) { return path, nil } - // Error out if the resources directory is not found return "", fmt.Errorf("PATTERNIZER_RESOURCES_DIR environment variable is not set") } +func GetSkillsPath() (string, error) { + path := os.Getenv("PATTERNIZER_SKILLS_DIR") + if path != "" { + return path, nil + } + + return "", fmt.Errorf("PATTERNIZER_SKILLS_DIR environment variable is not set") +} + +// CopyDir recursively copies the contents of src into dst. +// It creates dst and any necessary subdirectories. +// Existing files in dst are overwritten, but files not present in src are left untouched. +func CopyDir(src, dst string) error { + srcInfo, err := os.Stat(src) + if err != nil { + return err + } + if !srcInfo.IsDir() { + return fmt.Errorf("%s is not a directory", src) + } + + if err := os.MkdirAll(dst, 0o755); err != nil { + return err + } + + entries, err := os.ReadDir(src) + if err != nil { + return err + } + + for _, entry := range entries { + srcPath := filepath.Join(src, entry.Name()) + dstPath := filepath.Join(dst, entry.Name()) + + if entry.IsDir() { + if err := CopyDir(srcPath, dstPath); err != nil { + return err + } + } else { + if err := CopyFile(srcPath, dstPath); err != nil { + return err + } + } + } + + return nil +} + // RemovePathIfExists removes a file, directory, or symlink at the given path if it exists. // It does nothing if the path does not exist. func RemovePathIfExists(targetPath string) error { diff --git a/src/internal/fileutils/fileutils_test.go b/src/internal/fileutils/fileutils_test.go index 15f38eb..c494e66 100644 --- a/src/internal/fileutils/fileutils_test.go +++ b/src/internal/fileutils/fileutils_test.go @@ -85,6 +85,79 @@ var _ = Describe("GetResourcesPath", func() { }) }) +var _ = Describe("GetSkillsPath", func() { + It("should return the path when the environment variable is set", func() { + old := os.Getenv("PATTERNIZER_SKILLS_DIR") + DeferCleanup(func() { os.Setenv("PATTERNIZER_SKILLS_DIR", old) }) + + tmp := GinkgoT().TempDir() + Expect(os.Setenv("PATTERNIZER_SKILLS_DIR", tmp)).To(Succeed()) + got, err := GetSkillsPath() + Expect(err).NotTo(HaveOccurred()) + Expect(got).To(Equal(tmp)) + }) + + It("should return an error when the environment variable is unset", func() { + old := os.Getenv("PATTERNIZER_SKILLS_DIR") + DeferCleanup(func() { os.Setenv("PATTERNIZER_SKILLS_DIR", old) }) + + Expect(os.Unsetenv("PATTERNIZER_SKILLS_DIR")).To(Succeed()) + _, err := GetSkillsPath() + Expect(err).To(HaveOccurred()) + }) +}) + +var _ = Describe("CopyDir", func() { + It("should recursively copy a directory and its contents", func() { + src := GinkgoT().TempDir() + dst := filepath.Join(GinkgoT().TempDir(), "dest") + + Expect(os.MkdirAll(filepath.Join(src, "sub"), 0o755)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(src, "file1.txt"), []byte("hello"), 0o644)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(src, "sub", "file2.txt"), []byte("world"), 0o644)).To(Succeed()) + + Expect(CopyDir(src, dst)).To(Succeed()) + + got1, err := os.ReadFile(filepath.Join(dst, "file1.txt")) + Expect(err).NotTo(HaveOccurred()) + Expect(string(got1)).To(Equal("hello")) + + got2, err := os.ReadFile(filepath.Join(dst, "sub", "file2.txt")) + Expect(err).NotTo(HaveOccurred()) + Expect(string(got2)).To(Equal("world")) + }) + + It("should overwrite existing files without deleting unrelated files", func() { + src := GinkgoT().TempDir() + dst := GinkgoT().TempDir() + + Expect(os.WriteFile(filepath.Join(src, "file.txt"), []byte("new"), 0o644)).To(Succeed()) + + Expect(os.WriteFile(filepath.Join(dst, "file.txt"), []byte("old"), 0o644)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(dst, "unrelated.txt"), []byte("keep me"), 0o644)).To(Succeed()) + + Expect(CopyDir(src, dst)).To(Succeed()) + + got, err := os.ReadFile(filepath.Join(dst, "file.txt")) + Expect(err).NotTo(HaveOccurred()) + Expect(string(got)).To(Equal("new")) + + kept, err := os.ReadFile(filepath.Join(dst, "unrelated.txt")) + Expect(err).NotTo(HaveOccurred()) + Expect(string(kept)).To(Equal("keep me")) + }) + + It("should return an error when source is not a directory", func() { + dir := GinkgoT().TempDir() + file := filepath.Join(dir, "file.txt") + Expect(os.WriteFile(file, []byte("x"), 0o644)).To(Succeed()) + + err := CopyDir(file, filepath.Join(dir, "dst")) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("is not a directory")) + }) +}) + var _ = Describe("RemovePathIfExists", func() { It("should remove a file", func() { base := GinkgoT().TempDir() diff --git a/src/internal/fileutils/skills.go b/src/internal/fileutils/skills.go new file mode 100644 index 0000000..b3256a2 --- /dev/null +++ b/src/internal/fileutils/skills.go @@ -0,0 +1,41 @@ +package fileutils + +import ( + "fmt" + "os" + "path/filepath" +) + +var skillTargets = []string{".claude", ".cursor"} + +func InstallSkills(repoRoot string) error { + skillsDir, err := GetSkillsPath() + if err != nil { + return fmt.Errorf("error getting skills path: %w", err) + } + + entries, err := os.ReadDir(skillsDir) + if err != nil { + return fmt.Errorf("error reading skills directory: %w", err) + } + + for _, entry := range entries { + if !entry.IsDir() { + continue + } + + skillName := entry.Name() + skillSrc := filepath.Join(skillsDir, skillName) + + for _, target := range skillTargets { + skillDst := filepath.Join(repoRoot, target, "skills", skillName) + if err := CopyDir(skillSrc, skillDst); err != nil { + return fmt.Errorf("error installing skill %s to %s: %w", skillName, target, err) + } + } + + fmt.Printf("Installed skill '%s'\n", skillName) + } + + return nil +} From 27ef70acd251e8bb78ff7120ae526f343333baa8 Mon Sep 17 00:00:00 2001 From: Drew Minnear Date: Wed, 24 Jun 2026 13:53:11 -0400 Subject: [PATCH 3/5] update go version and packages --- Containerfile | 2 +- Makefile | 6 +++--- src/go.mod | 24 ++++++++++++------------ src/go.sum | 45 +++++++++++++++++++++++---------------------- 4 files changed, 39 insertions(+), 38 deletions(-) diff --git a/Containerfile b/Containerfile index 60ca16b..00d92f7 100644 --- a/Containerfile +++ b/Containerfile @@ -1,4 +1,4 @@ -ARG GO_VERSION=1.25.7 +ARG GO_VERSION=1.26.3 ARG GOARCH=amd64 # Build stage diff --git a/Makefile b/Makefile index 12d4501..c845223 100644 --- a/Makefile +++ b/Makefile @@ -12,9 +12,9 @@ GO_TEST := $(GO_CMD) test GO_CLEAN := $(GO_CMD) clean GO_VET := $(GO_CMD) vet GO_FMT := gofmt -GO_VERSION := 1.25.7 -GOLANGCI_LINT_VERSION := v2.10.1 -GINKGO_VERSION := v2.28.1 +GO_VERSION := 1.26.3 +GOLANGCI_LINT_VERSION := v2.12.2 +GINKGO_VERSION := v2.32.0 SRC_DIR := src # Default target diff --git a/src/go.mod b/src/go.mod index 7be8329..57e2cf7 100644 --- a/src/go.mod +++ b/src/go.mod @@ -1,11 +1,11 @@ module github.com/validatedpatterns/patternizer -go 1.25.7 +go 1.26.3 require ( - github.com/onsi/ginkgo/v2 v2.28.1 - github.com/onsi/gomega v1.39.1 - github.com/spf13/cobra v1.9.1 + github.com/onsi/ginkgo/v2 v2.32.0 + github.com/onsi/gomega v1.42.1 + github.com/spf13/cobra v1.10.2 gopkg.in/yaml.v3 v3.0.1 ) @@ -14,16 +14,16 @@ require ( github.com/go-logr/logr v1.4.3 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/google/go-cmp v0.7.0 // indirect - github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 // indirect + github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect - github.com/spf13/pflag v1.0.6 // indirect + github.com/spf13/pflag v1.0.10 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/mod v0.32.0 // indirect - golang.org/x/net v0.49.0 // indirect - golang.org/x/sync v0.19.0 // indirect - golang.org/x/sys v0.40.0 // indirect - golang.org/x/text v0.33.0 // indirect - golang.org/x/tools v0.41.0 // indirect + golang.org/x/mod v0.36.0 // indirect + golang.org/x/net v0.56.0 // indirect + golang.org/x/sync v0.21.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/text v0.38.0 // indirect + golang.org/x/tools v0.45.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect ) diff --git a/src/go.sum b/src/go.sum index ec6f177..f11141d 100644 --- a/src/go.sum +++ b/src/go.sum @@ -17,8 +17,8 @@ github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/vP9vJGqPwcdqsWjOt+V8J7+bTc= -github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= +github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 h1:EwtI+Al+DeppwYX2oXJCETMO23COyaKGP6fHVpkpWpg= +github.com/google/pprof v0.0.0-20260402051712-545e8a4df936/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE= @@ -34,19 +34,20 @@ github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE= github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A= -github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI= -github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE= -github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28= -github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg= +github.com/onsi/ginkgo/v2 v2.32.0 h1:Hw7s2pVrQo/8Yz5N77qdnpHaoc+c6cC9WIV1Jce+J6E= +github.com/onsi/ginkgo/v2 v2.32.0/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44= +github.com/onsi/gomega v1.42.1 h1:iN1rCUX+44NZ1Dc97MPoeFYbFR0vh8zxoxMFwKdyZ6I= +github.com/onsi/gomega v1.42.1/go.mod h1:REff/hsDsodHoKlWsP2mAPhu1+5/6hVYNf9rIEBpeSg= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= -github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= -github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= -github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= @@ -59,18 +60,18 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= -golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= -golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= -golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= -golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= -golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= -golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= -golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= +golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= +golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= +golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A= google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= From 9f3b5b754079b183faff320c76e70c7edfd788a5 Mon Sep 17 00:00:00 2001 From: Drew Minnear Date: Wed, 24 Jun 2026 13:54:38 -0400 Subject: [PATCH 4/5] bump patternizer version to 1.3.0 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index cde3945..25073af 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Patternizer -![Version: 1.2.0](https://img.shields.io/badge/Version-1.2.0-informational?style=flat-square) +![Version: 1.3.0](https://img.shields.io/badge/Version-1.3.0-informational?style=flat-square) [![Quay Repository](https://img.shields.io/badge/Quay.io-patternizer-blue?logo=quay)](https://quay.io/repository/validatedpatterns/patternizer) [![CI Pipeline](https://github.com/validatedpatterns/patternizer/actions/workflows/build-push.yaml/badge.svg?branch=main)](https://github.com/validatedpatterns/patternizer/actions/workflows/build-push.yaml) From 3bcea209f3341012cc2301b5b511576629980722 Mon Sep 17 00:00:00 2001 From: Drew Minnear Date: Wed, 24 Jun 2026 14:43:03 -0400 Subject: [PATCH 5/5] update github action to use latest go version --- .github/workflows/lint-test.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/lint-test.yaml b/.github/workflows/lint-test.yaml index 92a0f94..4c7c2a5 100644 --- a/.github/workflows/lint-test.yaml +++ b/.github/workflows/lint-test.yaml @@ -5,7 +5,7 @@ on: branches: ["main"] env: - GO_VERSION: '1.25' + GO_VERSION: '1.26' jobs: lint: