From 558e564e2517c112a8f1b896e48116fb2adda95b Mon Sep 17 00:00:00 2001 From: Drew Minnear Date: Fri, 4 Sep 2026 11:05:55 -0400 Subject: [PATCH] update pattern to modern conventions and update repo to use new VP CI platform --- .claude/skills/pattern-author/SKILL.md | 243 +++++ .claude/skills/pattern-author/reference.md | 951 ++++++++++++++++++ .cursor/skills/pattern-author/SKILL.md | 243 +++++ .cursor/skills/pattern-author/reference.md | 951 ++++++++++++++++++ .gitignore | 3 + Makefile-common | 20 +- ansible.cfg | 15 +- .../create-gpu-machineset-azure.yaml | 2 +- ansible/playbooks/create-gpu-machineset.yaml | 14 +- .../azure-sql/templates/external-secret.yaml | 4 +- .../mssql/templates/external-secret.yaml | 4 +- .../charts/pgvector/templates/secret.yaml | 4 +- .../rag-llm/templates/edb-pull-secret.yaml | 4 +- .../rag-llm/templates/external-secret.yaml | 6 +- charts/all/rhods/templates/dsc.yaml | 2 - overrides/ci.yaml | 13 - pattern-metadata.yaml | 12 +- pattern.sh | 113 ++- tests/all-config-demo.expected.diff | 13 - tests/ci/README.md | 42 + tests/ci/__init__.py | 1 + tests/ci/conftest.py | 1 + tests/ci/pytest.ini | 6 + tests/ci/requirements.txt | 17 + tests/ci/test_ci.py | 330 ++++++ tests/common-acm.expected.diff | 151 --- tests/common-clustergroup.expected.diff | 764 -------------- tests/common-examples-blank.expected.diff | 0 ...-examples-kustomize-renderer.expected.diff | 14 - ...mmon-golang-external-secrets.expected.diff | 11 - tests/common-hashicorp-vault.expected.diff | 11 - tests/common-install.expected.diff | 43 - tests/common-operator-install.expected.diff | 11 - tests/interop/__init__.py | 2 - tests/interop/conftest.py | 2 - tests/interop/test_subscription_status_hub.py | 29 - tests/interop/test_validate_gpu_nodes.py | 166 --- .../test_validate_hub_site_components.py | 166 --- tests/interop/ui/playwright.config.ts | 82 -- .../ragllm-add-provider-1-chromium-linux.png | Bin 32059 -> 0 bytes ...llm-grafana-dashboard-1-chromium-linux.png | Bin 50321 -> 0 bytes tests/interop/ui/ragllm.spec.ts.j2 | 60 -- values-global.yaml | 3 +- variants/ci/values-AWS.yaml | 24 + variants/ci/values-Azure.yaml | 29 + variants/ci/values-ci.yaml | 122 +++ .../hub/values-hub.yaml | 71 +- 47 files changed, 3134 insertions(+), 1641 deletions(-) create mode 100644 .claude/skills/pattern-author/SKILL.md create mode 100644 .claude/skills/pattern-author/reference.md create mode 100644 .cursor/skills/pattern-author/SKILL.md create mode 100644 .cursor/skills/pattern-author/reference.md delete mode 100644 overrides/ci.yaml delete mode 100644 tests/all-config-demo.expected.diff create mode 100644 tests/ci/README.md create mode 100644 tests/ci/__init__.py create mode 100644 tests/ci/conftest.py create mode 100644 tests/ci/pytest.ini create mode 100644 tests/ci/requirements.txt create mode 100644 tests/ci/test_ci.py delete mode 100644 tests/common-acm.expected.diff delete mode 100644 tests/common-clustergroup.expected.diff delete mode 100644 tests/common-examples-blank.expected.diff delete mode 100644 tests/common-examples-kustomize-renderer.expected.diff delete mode 100644 tests/common-golang-external-secrets.expected.diff delete mode 100644 tests/common-hashicorp-vault.expected.diff delete mode 100644 tests/common-install.expected.diff delete mode 100644 tests/common-operator-install.expected.diff delete mode 100644 tests/interop/__init__.py delete mode 100644 tests/interop/conftest.py delete mode 100644 tests/interop/test_subscription_status_hub.py delete mode 100644 tests/interop/test_validate_gpu_nodes.py delete mode 100644 tests/interop/test_validate_hub_site_components.py delete mode 100644 tests/interop/ui/playwright.config.ts delete mode 100644 tests/interop/ui/ragllm.spec.ts-snapshots/ragllm-add-provider-1-chromium-linux.png delete mode 100644 tests/interop/ui/ragllm.spec.ts-snapshots/ragllm-grafana-dashboard-1-chromium-linux.png delete mode 100644 tests/interop/ui/ragllm.spec.ts.j2 create mode 100644 variants/ci/values-AWS.yaml create mode 100644 variants/ci/values-Azure.yaml create mode 100644 variants/ci/values-ci.yaml rename values-hub.yaml => variants/hub/values-hub.yaml (64%) diff --git a/.claude/skills/pattern-author/SKILL.md b/.claude/skills/pattern-author/SKILL.md new file mode 100644 index 00000000..281eef3b --- /dev/null +++ b/.claude/skills/pattern-author/SKILL.md @@ -0,0 +1,243 @@ +--- +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 repository 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 repository + - 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 repository. 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: + + ```text + "{{ `{{ .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 repository (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 Repository + +```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/.claude/skills/pattern-author/reference.md b/.claude/skills/pattern-author/reference.md new file mode 100644 index 00000000..1a24ddaf --- /dev/null +++ b/.claude/skills/pattern-author/reference.md @@ -0,0 +1,951 @@ +# 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 repository 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 repository (as a multi-source ArgoCD Application). + +## Creating a New Pattern + +1. Create an empty directory (or create a new repository 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 repository 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: + +```text +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 repository, 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 repository. + +### 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 repository 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 repository, 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 repository 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 repository: + +```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 repositories can be referenced with `repoURL` (the publicly reachable Git URL), `path` (the path within the repository), 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 repository 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 repository. `patternizer init` auto-discovers charts anywhere in the repository, 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 repository. This encourages users to copy the template to their home directory and keep secrets out of the Git repository. + +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: + +```text +"{{ `{{ .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 repository, 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" +``` diff --git a/.cursor/skills/pattern-author/SKILL.md b/.cursor/skills/pattern-author/SKILL.md new file mode 100644 index 00000000..281eef3b --- /dev/null +++ b/.cursor/skills/pattern-author/SKILL.md @@ -0,0 +1,243 @@ +--- +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 repository 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 repository + - 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 repository. 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: + + ```text + "{{ `{{ .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 repository (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 Repository + +```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/.cursor/skills/pattern-author/reference.md b/.cursor/skills/pattern-author/reference.md new file mode 100644 index 00000000..1a24ddaf --- /dev/null +++ b/.cursor/skills/pattern-author/reference.md @@ -0,0 +1,951 @@ +# 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 repository 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 repository (as a multi-source ArgoCD Application). + +## Creating a New Pattern + +1. Create an empty directory (or create a new repository 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 repository 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: + +```text +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 repository, 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 repository. + +### 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 repository 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 repository, 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 repository 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 repository: + +```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 repositories can be referenced with `repoURL` (the publicly reachable Git URL), `path` (the path within the repository), 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 repository 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 repository. `patternizer init` auto-discovers charts anywhere in the repository, 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 repository. This encourages users to copy the template to their home directory and keep secrets out of the Git repository. + +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: + +```text +"{{ `{{ .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 repository, 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" +``` diff --git a/.gitignore b/.gitignore index 9724d136..e729fc16 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,6 @@ vault.init super-linter.log common/pattern-vault.init common/scripts/pattern-util.sh +tests/ci/.results +tests/ci/__pycache__ +tests/ci/.pytest_cache diff --git a/Makefile-common b/Makefile-common index 537ca883..cf9b7bfe 100644 --- a/Makefile-common +++ b/Makefile-common @@ -1,6 +1,6 @@ MAKEFLAGS += --no-print-directory -ANSIBLE_STDOUT_CALLBACK ?= null # null silences all ansible output. Override this with default, minimal, oneline, etc. when debugging. -ANSIBLE_RUN := ANSIBLE_STDOUT_CALLBACK=$(ANSIBLE_STDOUT_CALLBACK) ansible-playbook $(EXTRA_PLAYBOOK_OPTS) +ANSIBLE_STDOUT_CALLBACK ?= rhvp.cluster_utils.readable +ANSIBLE_RUN ?= ANSIBLE_STDOUT_CALLBACK=$(ANSIBLE_STDOUT_CALLBACK) ansible-playbook $(EXTRA_PLAYBOOK_OPTS) DOCS_URL := https://validatedpatterns.io/blog/2025-08-29-new-common-makefile-structure/ .PHONY: help @@ -20,9 +20,9 @@ operator-deploy operator-upgrade: ## Installs/updates the pattern on a cluster ( .PHONY: install install: pattern-install ## Installs the pattern onto a cluster (Loads secrets as well if configured) -.PHONY: uninstall ## Prints a notice that patterns cannot currently be uninstalled -uninstall: - @echo "Uninstall is not possible at the moment so this target is empty. We are working to implement it as well as we can." +.PHONY: uninstall +uninstall: ## (EXPERIMENTAL) See https://validatedpatterns.io/blog/2026-02-16-pattern-uninstall/. + @$(ANSIBLE_RUN) rhvp.cluster_utils.uninstall .PHONY: pattern-install pattern-install: @@ -32,6 +32,11 @@ pattern-install: load-secrets: ## Loads secrets onto the cluster (unless explicitly disabled in values-global.yaml) @$(ANSIBLE_RUN) rhvp.cluster_utils.load_secrets +##@ Debug Tasks +.PHONY: display-secrets-info +display-secrets-info: ## Display your secret material on terminal or show secret loading error. Use with caution! + @$(ANSIBLE_RUN) rhvp.cluster_utils.display_secrets_info + ##@ Validation Tasks .PHONY: validate-prereq validate-prereq: ## verify pre-requisites @@ -52,3 +57,8 @@ validate-schema: ## validates values files against schema in common/clustergroup .PHONY: argo-healthcheck argo-healthcheck: ## Checks if all argo applications are synced @$(ANSIBLE_RUN) rhvp.cluster_utils.argo_healthcheck + +##@ Testing (CI) Tasks +.PHONY: run-ci-tests +run-ci-tests: ## To run ci-tests, set any needed env vars + @$(ANSIBLE_RUN) rhvp.cluster_utils.run_ci_tests diff --git a/ansible.cfg b/ansible.cfg index 516f8b84..528a8cb6 100644 --- a/ansible.cfg +++ b/ansible.cfg @@ -1,6 +1,15 @@ [defaults] localhost_warning=False retry_files_enabled=False -library=~/.ansible/plugins/modules:./ansible/plugins/modules:./common/ansible/plugins/modules:/usr/share/ansible/plugins/modules -roles_path=~/.ansible/roles:./ansible/roles:./common/ansible/roles:/usr/share/ansible/roles:/etc/ansible/roles -filter_plugins=~/.ansible/plugins/filter:./ansible/plugins/filter:./common/ansible/plugins/filter:/usr/share/ansible/plugins/filter +# Retry files disabled to avoid cluttering CI/CD environments +interpreter_python=auto_silent +timeout=30 +library=~/.ansible/plugins/modules:./ansible/plugins/modules:/usr/share/ansible/plugins/modules +roles_path=~/.ansible/roles:./ansible/roles:/usr/share/ansible/roles:/etc/ansible/roles +filter_plugins=~/.ansible/plugins/filter:./ansible/plugins/filter:/usr/share/ansible/plugins/filter +# use the collections from the util. container, +# change below if you want to test local collections +collections_path=/usr/share/ansible/collections + +[inventory] +inventory_unparsed_warning=False diff --git a/ansible/playbooks/create-gpu-machineset-azure.yaml b/ansible/playbooks/create-gpu-machineset-azure.yaml index 5a96896d..318ff9c2 100644 --- a/ansible/playbooks/create-gpu-machineset-azure.yaml +++ b/ansible/playbooks/create-gpu-machineset-azure.yaml @@ -57,7 +57,7 @@ src: templates/gpu-machineset-azure.j2 dest: /tmp/gpu-machineset-azure.yaml vars: - ms_name: "nvidia-worker-{{ azure_location | replace(' ', '') }}{{ gpu_zone }}" + ms_name: "nvidia-gpu-worker-{{ azure_location | replace(' ', '') }}{{ gpu_zone }}" - name: Apply the GPU MachineSet kubernetes.core.k8s: diff --git a/ansible/playbooks/create-gpu-machineset.yaml b/ansible/playbooks/create-gpu-machineset.yaml index 62c8d437..8fb2ea7d 100644 --- a/ansible/playbooks/create-gpu-machineset.yaml +++ b/ansible/playbooks/create-gpu-machineset.yaml @@ -39,19 +39,6 @@ ansible.builtin.set_fact: cloudRegion: "{{ infraInfo.resources[0].status.platformStatus[cloudProvider].region }}" - - name: "[create-gpu-machine-set] Search for MachineSets" - kubernetes.core.k8s_info: - api: machine.openshift.io/v1beta1 - kind: MachineSet - namespace: openshift-machine-api - register: machineset - - - name: "[create-gpu-machine-set] Get the availability zone from the first worker MachineSets" - ansible.builtin.set_fact: - cloudAvailabilityZone: "{{ item.spec.template.spec.providerSpec.value.placement.availabilityZone }}" - with_items: - - "{{ machineset.resources[0] }}" - - name: "[create-gpu-machine-set] Search for worker MachineSets" kubernetes.core.k8s_info: api: machine.openshift.io/v1beta1 @@ -69,6 +56,7 @@ securityGroups: "{{ machines.resources[0].spec.providerSpec.value.securityGroups }}" subnets: "{{ machines.resources[0].spec.providerSpec.value.subnet }}" tags: "{{ machines.resources[0].spec.providerSpec.value.tags }}" + cloudAvailabilityZone: "{{ machines.resources[0].spec.providerSpec.value.placement.availabilityZone }}" - name: "[create-gpu-machine-set] Generate machineset" ansible.builtin.template: diff --git a/charts/all/rag-llm/charts/azure-sql/templates/external-secret.yaml b/charts/all/rag-llm/charts/azure-sql/templates/external-secret.yaml index da4d6d56..5c1b0b42 100644 --- a/charts/all/rag-llm/charts/azure-sql/templates/external-secret.yaml +++ b/charts/all/rag-llm/charts/azure-sql/templates/external-secret.yaml @@ -1,10 +1,10 @@ {{- if eq .Values.global.db.type "AZURESQL" }} -apiVersion: "external-secrets.io/v1beta1" +apiVersion: "external-secrets.io/v1" kind: ExternalSecret metadata: name: azuresql-external-secret spec: - refreshInterval: 15s + refreshInterval: 2m0s secretStoreRef: name: {{ .Values.secretStore.name }} kind: {{ .Values.secretStore.kind }} diff --git a/charts/all/rag-llm/charts/mssql/templates/external-secret.yaml b/charts/all/rag-llm/charts/mssql/templates/external-secret.yaml index d67f4394..4fc12072 100644 --- a/charts/all/rag-llm/charts/mssql/templates/external-secret.yaml +++ b/charts/all/rag-llm/charts/mssql/templates/external-secret.yaml @@ -1,10 +1,10 @@ {{- if eq .Values.global.db.type "MSSQL" }} -apiVersion: "external-secrets.io/v1beta1" +apiVersion: "external-secrets.io/v1" kind: ExternalSecret metadata: name: mssql-external-secret spec: - refreshInterval: 15s + refreshInterval: 2m0s secretStoreRef: name: {{ .Values.secretStore.name }} kind: {{ .Values.secretStore.kind }} diff --git a/charts/all/rag-llm/charts/pgvector/templates/secret.yaml b/charts/all/rag-llm/charts/pgvector/templates/secret.yaml index dbfeb3a7..d1300cc4 100644 --- a/charts/all/rag-llm/charts/pgvector/templates/secret.yaml +++ b/charts/all/rag-llm/charts/pgvector/templates/secret.yaml @@ -1,10 +1,10 @@ {{- if eq .Values.global.db.type "PGVECTOR" }} -apiVersion: "external-secrets.io/v1beta1" +apiVersion: "external-secrets.io/v1" kind: ExternalSecret metadata: name: pgvector-external-secret spec: - refreshInterval: 15s + refreshInterval: 2m0s secretStoreRef: name: {{ .Values.secretStore.name }} kind: {{ .Values.secretStore.kind }} diff --git a/charts/all/rag-llm/templates/edb-pull-secret.yaml b/charts/all/rag-llm/templates/edb-pull-secret.yaml index aa96b574..77238a08 100644 --- a/charts/all/rag-llm/templates/edb-pull-secret.yaml +++ b/charts/all/rag-llm/templates/edb-pull-secret.yaml @@ -1,11 +1,11 @@ {{- if eq .Values.global.db.type "EDB" }} -apiVersion: external-secrets.io/v1beta1 +apiVersion: external-secrets.io/v1 kind: ExternalSecret metadata: name: edb-operator-pull-secret namespace: openshift-operators spec: - refreshInterval: 15s + refreshInterval: 2m0s secretStoreRef: name: {{ .Values.secretStore.name }} kind: {{ .Values.secretStore.kind }} diff --git a/charts/all/rag-llm/templates/external-secret.yaml b/charts/all/rag-llm/templates/external-secret.yaml index 92432720..11ba8b79 100644 --- a/charts/all/rag-llm/templates/external-secret.yaml +++ b/charts/all/rag-llm/templates/external-secret.yaml @@ -1,13 +1,13 @@ --- -apiVersion: "external-secrets.io/v1beta1" +apiVersion: "external-secrets.io/v1" kind: ExternalSecret metadata: name: huggingface-secret spec: - refreshInterval: 15s + refreshInterval: 2m0s secretStoreRef: name: {{ .Values.secretStore.name }} kind: {{ .Values.secretStore.kind }} dataFrom: - extract: - key: {{ .Values.hfmodel.key }} \ No newline at end of file + key: {{ .Values.hfmodel.key }} diff --git a/charts/all/rhods/templates/dsc.yaml b/charts/all/rhods/templates/dsc.yaml index c7ea8ade..e032ce8a 100644 --- a/charts/all/rhods/templates/dsc.yaml +++ b/charts/all/rhods/templates/dsc.yaml @@ -10,8 +10,6 @@ spec: managementState: Managed workbenches: managementState: Managed - datasciencepipelines: - managementState: Managed kueue: managementState: Removed ray: diff --git a/overrides/ci.yaml b/overrides/ci.yaml deleted file mode 100644 index c447c4ad..00000000 --- a/overrides/ci.yaml +++ /dev/null @@ -1,13 +0,0 @@ -global: - model: - vllm: ibm-granite/granite-4.0-h-350m - -vllmServingRuntime: - args: - - /cache/models - - command: [] - - image: - repository: vllm/vllm-openai - tag: v0.21.0 diff --git a/pattern-metadata.yaml b/pattern-metadata.yaml index c315406a..22669575 100644 --- a/pattern-metadata.yaml +++ b/pattern-metadata.yaml @@ -12,7 +12,7 @@ docs_url: https://validatedpatterns.io/patterns/rag-llm-gitops/ ci_url: https://validatedpatterns.io/ci/?pattern=ragllm # can be sandbox, tested or maintained tier: tested -owners: day0hero, sauagarwa +owners: dminnear-rh requirements: hub: # Main cluster compute: @@ -20,11 +20,17 @@ requirements: aws: replicas: 3 type: m5.2xlarge + azure: + replicas: 3 + type: Standard_D8s_v4 controlPlane: platform: aws: - replicas: 1 - type: m5.2xlarge + replicas: 3 + type: m5.xlarge + azure: + replicas: 3 + type: Standard_D4s_v4 # Loosely defined extra features like hypershift support, non-openshift # kubernetes support, spoke support diff --git a/pattern.sh b/pattern.sh index d6daa15e..7d8937c5 100755 --- a/pattern.sh +++ b/pattern.sh @@ -1,19 +1,73 @@ #!/bin/bash +set -euo pipefail function is_available { - command -v $1 >/dev/null 2>&1 || { echo >&2 "$1 is required but it's not installed. Aborting."; exit 1; } + command -v "$1" >/dev/null 2>&1 || { echo >&2 "$1 is required but it's not installed. Aborting."; exit 1; } } function version { - echo "$@" | awk -F. '{ printf("%d%03d%03d%03d\n", $1,$2,$3,$4); }' + echo "$1" | awk -F. '{ printf("%d%03d%03d%03d\n", $1,$2,$3,$4); }' } -if [ -z "$PATTERN_UTILITY_CONTAINER" ]; then +# We need this check mostly for CI testing, we do not want to run container in container +function is_container() { + [ -n "${KUBERNETES_SERVICE_HOST:-}" ] && return 0 + [ -f /.dockerenv ] && return 0 + [ -f /run/.containerenv ] && return 0 + return 1 +} + +function verify_image() { + local image="$1" + + case "${image}" in + quay.io/validatedpatterns/*|quay.io/hybridcloudpatterns/*) + ;; + *) + echo "Skipping image verification for third-party registry" + return 0 + ;; + esac + + if ! command -v cosign >/dev/null 2>&1; then + echo "WARNING: cosign is not installed, cannot verify image signature" + echo "Install cosign to enable image verification: https://docs.sigstore.dev/cosign/system_config/installation/" + return 0 + fi + + echo "Verifying image signature for ${image}..." + local output rc + local oidc_issuer="${VP_COSIGN_OIDC_ISSUER:-https://token.actions.githubusercontent.com}" + local cert_identity="${VP_COSIGN_CERT_IDENTITY:-https://github.com/validatedpatterns/utility-container/.*}" + output=$(cosign verify \ + --certificate-oidc-issuer "${oidc_issuer}" \ + --certificate-identity-regexp "${cert_identity}" \ + "${image}" 2>&1) && rc=$? || rc=$? + + if [ "${rc}" -eq 0 ]; then + echo "Image signature verified successfully" + elif [ "${rc}" -ge 10 ] && [ "${rc}" -le 13 ]; then + echo "ERROR: Image signature verification failed for ${image} (exit code ${rc})" + echo "${output}" + echo "Set VP_VERIFY_IMAGE=false to skip this check" + exit 1 + else + echo "WARNING: Could not verify image signature for ${image} (likely a network issue)" + echo "Set VP_VERIFY_IMAGE=false to skip this check" + fi +} + +if is_container; then + echo "Already running in a container" + exec "$@" +fi + +if [ -z "${PATTERN_UTILITY_CONTAINER:-}" ]; then PATTERN_UTILITY_CONTAINER="quay.io/validatedpatterns/utility-container" fi # If PATTERN_DISCONNECTED_HOME is set it will be used to populate both PATTERN_UTILITY_CONTAINER # and PATTERN_INSTALL_CHART automatically -if [ -n "${PATTERN_DISCONNECTED_HOME}" ]; then +if [ -n "${PATTERN_DISCONNECTED_HOME:-}" ]; then PATTERN_UTILITY_CONTAINER="${PATTERN_DISCONNECTED_HOME}/utility-container" PATTERN_INSTALL_CHART="oci://${PATTERN_DISCONNECTED_HOME}/pattern-install" echo "PATTERN_DISCONNECTED_HOME is set to ${PATTERN_DISCONNECTED_HOME}" @@ -23,10 +77,10 @@ if [ -n "${PATTERN_DISCONNECTED_HOME}" ]; then fi readonly commands=(podman) -for cmd in ${commands[@]}; do is_available "$cmd"; done +for cmd in "${commands[@]}"; do is_available "$cmd"; done UNSUPPORTED_PODMAN_VERSIONS="1.6 1.5" -PODMAN_VERSION_STR=$(podman --version) +PODMAN_VERSION_STR=$(podman --version) || { echo "Failed to get podman version"; exit 1; } for i in ${UNSUPPORTED_PODMAN_VERSIONS}; do # We add a space if echo "${PODMAN_VERSION_STR}" | grep -q -E "\b${i}"; then @@ -41,19 +95,20 @@ done PODMAN_VERSION=$(echo "${PODMAN_VERSION_STR}" | awk '{ print $NF }') # podman < 4.3.0 do not support keep-id:uid=... -if [ $(version "${PODMAN_VERSION}") -lt $(version "4.3.0") ]; then - PODMAN_ARGS="-v ${HOME}:/root" +PODMAN_ARGS=() +if [ "$(version "${PODMAN_VERSION}")" -lt "$(version "4.3.0")" ]; then + PODMAN_ARGS=(-v "${HOME}:/root") else # We do not rely on bash's $UID and $GID because on MacOSX $GID is not set MYNAME=$(id -n -u) MYUID=$(id -u) MYGID=$(id -g) - PODMAN_ARGS="--passwd-entry ${MYNAME}:x:${MYUID}:${MYGID}::/pattern-home:/bin/bash --user ${MYUID}:${MYGID} --userns keep-id:uid=${MYUID},gid=${MYGID}" - + PODMAN_ARGS=(--passwd-entry "${MYNAME}:x:${MYUID}:${MYGID}::/pattern-home:/bin/bash" --user "${MYUID}:${MYGID}" --userns "keep-id:uid=${MYUID},gid=${MYGID}") fi -if [ -n "$KUBECONFIG" ]; then - if [[ ! "${KUBECONFIG}" =~ ^$HOME* ]]; then +if [ -n "${KUBECONFIG:-}" ]; then + # Check if KUBECONFIG path starts with HOME directory + if [[ ! "${KUBECONFIG}" =~ ^"${HOME}" ]]; then echo "${KUBECONFIG} is pointing outside of the HOME folder, this will make it unavailable from the container." echo "Please move it somewhere inside your $HOME folder, as that is what gets bind-mounted inside the container" exit 1 @@ -62,20 +117,30 @@ fi # Detect if we use podman machine. If we do not then we bind mount local host ssl folders # if we are using podman machine then we do not bind mount anything (for now!) -REMOTE_PODMAN=$(podman system connection list | tail -n +2 | wc -l) -if [ $REMOTE_PODMAN -eq 0 ]; then # If we are not using podman machine we check the hosts folders +REMOTE_PODMAN=$(podman system connection list | tail -n +2 | wc -l) || REMOTE_PODMAN=0 +PKI_HOST_MOUNT_ARGS=() +if [ "${REMOTE_PODMAN}" -eq 0 ]; then # If we are not using podman machine we check the hosts folders # We check /etc/pki/tls because on ubuntu /etc/pki/fwupd sometimes # exists but not /etc/pki/tls and we do not want to bind mount in such a case # as it would find no certificates at all. if [ -d /etc/pki/tls ]; then - PKI_HOST_MOUNT_ARGS="-v /etc/pki:/etc/pki:ro" + PKI_HOST_MOUNT_ARGS=(-v /etc/pki:/etc/pki:ro) elif [ -d /etc/ssl ]; then - PKI_HOST_MOUNT_ARGS="-v /etc/ssl:/etc/ssl:ro" + PKI_HOST_MOUNT_ARGS=(-v /etc/ssl:/etc/ssl:ro) else - PKI_HOST_MOUNT_ARGS="-v /usr/share/ca-certificates:/usr/share/ca-certificates:ro" + PKI_HOST_MOUNT_ARGS=(-v /usr/share/ca-certificates:/usr/share/ca-certificates:ro) fi -else - PKI_HOST_MOUNT_ARGS="" +fi + +# Parse EXTRA_ARGS into an array if set +EXTRA_ARGS_ARRAY=() +if [ -n "${EXTRA_ARGS:-}" ]; then + # shellcheck disable=SC2206 + EXTRA_ARGS_ARRAY=(${EXTRA_ARGS}) +fi + +if [ "${VP_VERIFY_IMAGE:-true}" != "false" ]; then + verify_image "$PATTERN_UTILITY_CONTAINER" fi # Copy Kubeconfig from current environment. The utilities will pick up ~/.kube/config if set so it's not mandatory @@ -101,17 +166,19 @@ podman run -it --rm --pull=newer \ -e PATTERN_NAME \ -e TARGET_BRANCH \ -e TARGET_CLUSTERGROUP \ + -e TARGET_VARIANT \ -e TARGET_ORIGIN \ -e TOKEN_NAMESPACE \ -e TOKEN_SECRET \ -e UUID_FILE \ -e VALUES_SECRET \ - ${PKI_HOST_MOUNT_ARGS} \ + -e 'VP_*' \ + ${PKI_HOST_MOUNT_ARGS[@]+"${PKI_HOST_MOUNT_ARGS[@]}"} \ -v "$(pwd -P)":"$(pwd -P)" \ -v "${HOME}":"${HOME}" \ -v "${HOME}":/pattern-home \ - ${PODMAN_ARGS} \ - ${EXTRA_ARGS} \ + "${PODMAN_ARGS[@]}" \ + ${EXTRA_ARGS_ARRAY[@]+"${EXTRA_ARGS_ARRAY[@]}"} \ -w "$(pwd -P)" \ "$PATTERN_UTILITY_CONTAINER" \ - $@ + "$@" diff --git a/tests/all-config-demo.expected.diff b/tests/all-config-demo.expected.diff deleted file mode 100644 index 28bdbbfc..00000000 --- a/tests/all-config-demo.expected.diff +++ /dev/null @@ -1,13 +0,0 @@ ---- tests/all-config-demo-naked.expected.yaml -+++ tests/all-config-demo-normal.expected.yaml -@@ -23,8 +23,8 @@ - - -

-- Hub Cluster domain is 'hub.example.com'
-- Pod is running on Local Cluster Domain 'region-one.example.com'
-+ Hub Cluster domain is 'apps.hub.example.com'
-+ Pod is running on Local Cluster Domain 'apps.region.example.com'
-

-

- The secret is secret diff --git a/tests/ci/README.md b/tests/ci/README.md new file mode 100644 index 00000000..c62c7fc0 --- /dev/null +++ b/tests/ci/README.md @@ -0,0 +1,42 @@ +# CI Tests + +The requirements.txt file is just a placeholder to show the installed packages in the utility container: +[utility-container requirements.txt](https://github.com/validatedpatterns/utility-container/blob/main/requirements.txt) + +## The ci will run pytest based on file pattern + +pytest -lv test\_\.py --junit-xml .results/test\_\.xml + +## To run upstream tests locally + +Set the env variables pointing to the clusters needed to run the tests on:\ +`VP_HUBCONFIG` (`VP_SPOKECONFIG` if applicable) pointing to the kubconfig of hub (and spoke) clusters\ +(all VP\_\* env var will be available inside the container)\ +`TARGET_CLUSTERGROUP` if its different from the default (values-global.yaml main.clusterGroupName)\ +Test logs and junit-xml will be saved in ci/.results/\ +Run from root repository: + +```bash +./pattern.sh make run-ci-tests +``` + +## Writing additional tests + +The openshift_dyn_client fixture will return a DynamicClient which can be used inside test functions.\ +It requires only an env variable param to use it as a kubeconfig. + +```python +@pytest.mark.parametrize( + "openshift_dyn_client", + ["VP_HUBCONFIG"], + indirect=True, +) +``` + +If your tests requires additional python packages, you might want to either run them fully locally in a venv or similar.\ +Or if you want to use the pattern framework (make/Ansible wrappers) you need to use your own util container + +```bash +PATTERN_UTILITY_CONTAINER="your_utility container" ./pattern.sh make run-ci-tests + +``` diff --git a/tests/ci/__init__.py b/tests/ci/__init__.py new file mode 100644 index 00000000..3dc1f76b --- /dev/null +++ b/tests/ci/__init__.py @@ -0,0 +1 @@ +__version__ = "0.1.0" diff --git a/tests/ci/conftest.py b/tests/ci/conftest.py new file mode 100644 index 00000000..12cc3c06 --- /dev/null +++ b/tests/ci/conftest.py @@ -0,0 +1 @@ +from validatedpatterns_tests.interop.conftest_openshift import * # noqa: F401,F403 diff --git a/tests/ci/pytest.ini b/tests/ci/pytest.ini new file mode 100644 index 00000000..6408ef04 --- /dev/null +++ b/tests/ci/pytest.ini @@ -0,0 +1,6 @@ +[pytest] +# Cluster routes are fronted by self-signed certs, so we make requests with +# verify=False (mirroring playwright's ignore_https_errors). Silence the +# resulting InsecureRequestWarning noise from urllib3. +filterwarnings = + ignore::urllib3.exceptions.InsecureRequestWarning diff --git a/tests/ci/requirements.txt b/tests/ci/requirements.txt new file mode 100644 index 00000000..47480078 --- /dev/null +++ b/tests/ci/requirements.txt @@ -0,0 +1,17 @@ +ansible-core==2.18.* +ansible-runner +awxkit +kubernetes +openshift +boto3>=1.21 +botocore>=1.24 +awscli>=1.22 +azure-cli>=2.34 +gcloud +humanize +pytz +pytest +pytest-playwright +requests +junitparser +vp-qe-test-common @ git+https://github.com/validatedpatterns/vp-qe-test-common.git@v1 diff --git a/tests/ci/test_ci.py b/tests/ci/test_ci.py new file mode 100644 index 00000000..186f254e --- /dev/null +++ b/tests/ci/test_ci.py @@ -0,0 +1,330 @@ +import os + +import pytest +import requests +from ocp_resources.machine_set import MachineSet +from ocp_resources.node import Node +from ocp_resources.pod import Pod +from ocp_resources.resource import Resource +from ocp_resources.route import Route +from playwright.sync_api import expect, sync_playwright +from validatedpatterns_tests.interop import components, subscription + + +def _route_url(openshift_dyn_client, namespace, name): + routes = [ + route + for route in Route.get( + dyn_client=openshift_dyn_client, namespace=namespace, name=name + ) + ] + + assert ( + len(routes) == 1 + ), f"Expected to find the route '{name}' in the namespace '{namespace}'" + + spec = routes[0].instance.spec + scheme = "https" if getattr(spec, "tls", None) else "http" + return f"{scheme}://{spec.host}" + + +@pytest.mark.parametrize( + "openshift_dyn_client", + ["VP_HUBCONFIG"], + indirect=True, +) +def test_subscription_status(openshift_dyn_client): + expected_subs = { + "openshift-gitops-operator": ["openshift-gitops-operator"], + "prometheus": ["llm-monitoring"], + "grafana-operator": ["llm-monitoring"], + "nfd": ["openshift-nfd"], + "gpu-operator-certified": ["nvidia-gpu-operator"], + } + + subscription.assert_subscription_status(openshift_dyn_client, expected_subs) + + +@pytest.mark.parametrize( + "openshift_dyn_client", + ["VP_HUBCONFIG"], + indirect=True, +) +def test_site_reachable(openshift_dyn_client): + components.assert_site_reachable(openshift_dyn_client) + + +@pytest.mark.parametrize( + "openshift_dyn_client", + ["VP_HUBCONFIG"], + indirect=True, +) +def test_pod_count(openshift_dyn_client): + projects = {"rag-llm": 4} + + errors = [] + for key in projects.keys(): + pods = [pod for pod in Pod.get(dyn_client=openshift_dyn_client, namespace=key)] + expected_count = projects[key] + actual_count = len(pods) + + if actual_count < expected_count: + errors.append( + f"Expected the namespace '{key}' to contain at least {expected_count} pods but it actually contains {actual_count} pods" + ) + + assert not errors, "\n".join(errors) + + +@pytest.mark.parametrize( + "openshift_dyn_client", + ["VP_HUBCONFIG"], + indirect=True, +) +def test_llm_ui_route(openshift_dyn_client): + # _route_url asserts that exactly one 'llm-ui' route exists in 'rag-llm'. + url = _route_url(openshift_dyn_client, "rag-llm", "llm-ui") + + response = requests.get(url, verify=False, timeout=60) + assert ( + response.status_code == 200 + ), f"Expected a 200 from the llm-ui route '{url}' but got {response.status_code}" + + +@pytest.mark.parametrize( + "openshift_dyn_client", + ["VP_HUBCONFIG"], + indirect=True, +) +def test_vllm_inference_service_route(openshift_dyn_client): + base_url = _route_url(openshift_dyn_client, "rag-llm", "vllm-inference-service") + + models_url = f"{base_url}/v1/models" + models_response = requests.get(models_url, verify=False, timeout=60) + assert ( + models_response.status_code == 200 + ), f"Expected a 200 from '{models_url}' but got {models_response.status_code}" + + models = models_response.json().get("data", []) + assert models, f"Expected vLLM to be serving at least one model at '{base_url}'" + model = models[0]["id"] + + completions_url = f"{base_url}/v1/chat/completions" + payload = { + "model": model, + "messages": [{"role": "user", "content": "Reply with a short greeting."}], + "max_tokens": 20, + } + response = requests.post(completions_url, json=payload, verify=False, timeout=300) + assert ( + response.status_code == 200 + ), f"Expected a 200 from '{completions_url}' but got {response.status_code}: {response.text}" + + content = response.json()["choices"][0]["message"]["content"] + assert ( + content + ), f"Expected a non-empty completion from the LLM at '{completions_url}'" + + +@pytest.mark.parametrize( + "openshift_dyn_client", + ["VP_HUBCONFIG"], + indirect=True, +) +def test_nodefeaturediscovery(openshift_dyn_client): + name = "nfd-instance" + namespace = "openshift-nfd" + + class NodeFeatureDiscovery(Resource): + api_group = "nfd.openshift.io" + api_version = "v1" + kind = "NodeFeatureDiscovery" + + nfds = [ + nfd + for nfd in NodeFeatureDiscovery.get( + dyn_client=openshift_dyn_client, namespace=namespace, name=name + ) + ] + + assert ( + len(nfds) == 1 + ), f"Expected to find the NodeFeatureDiscovery '{name}' in the namespace '{namespace}'" + + +@pytest.mark.parametrize( + "openshift_dyn_client", + ["VP_HUBCONFIG"], + indirect=True, +) +def test_gpu_clusterpolicy(openshift_dyn_client): + name = "rag-llm-gpu-cluster-policy" + expected_tolerations = [ + {"effect": "NoSchedule", "key": "odh-notebook", "value": "true"} + ] + + class ClusterPolicy(Resource): + api_group = "nvidia.com" + api_version = "v1" + kind = "ClusterPolicy" + + policies = [ + policy + for policy in ClusterPolicy.get(dyn_client=openshift_dyn_client, name=name) + ] + + assert len(policies) == 1, f"Expected to find the ClusterPolicy '{name}'" + + actual_tolerations = [ + item.to_dict() for item in policies[0].instance.spec.daemonsets.tolerations + ] + + assert ( + actual_tolerations == expected_tolerations + ), f"Expected the ClusterPolicy '{name}' to contain the tolerations '{expected_tolerations}' but it actually contains '{actual_tolerations}'" + + +@pytest.mark.parametrize( + "openshift_dyn_client", + ["VP_HUBCONFIG"], + indirect=True, +) +def test_gpu_machineset(openshift_dyn_client): + namespace = "openshift-machine-api" + machinesets = [ + ms + for ms in MachineSet.get(dyn_client=openshift_dyn_client, namespace=namespace) + ] + + gpu_machinesets = [ms for ms in machinesets if "-gpu-" in ms.instance.metadata.name] + assert ( + len(gpu_machinesets) == 1 + ), f"Expected to find a GPU MachineSet in the namespace '{namespace}'" + + gpu_machineset = gpu_machinesets[0] + gpu_machineset_name = gpu_machineset.instance.metadata.name + + actual_taints = [ + item.to_dict() for item in gpu_machineset.instance.spec.template.spec.taints + ] + expected_taints = [{"effect": "NoSchedule", "key": "odh-notebook", "value": "true"}] + assert ( + actual_taints == expected_taints + ), f"Expected GPU MachineSet '{gpu_machineset_name}' to contain the taints '{expected_taints}' but it actually contains '{actual_taints}'" + + actual_labels = [ + item for item in gpu_machineset.instance.spec.template.spec.metadata.labels + ] + expected_labels = [("node-role.kubernetes.io/odh-notebook", "")] + assert ( + actual_labels == expected_labels + ), f"Expected GPU MachineSet '{gpu_machineset_name}' to contain the labels '{expected_labels}' but it actually contains '{actual_labels}'" + + +@pytest.mark.parametrize( + "openshift_dyn_client", + ["VP_HUBCONFIG"], + indirect=True, +) +def test_gpu_node_role_labels_pods(openshift_dyn_client): + def is_gpu_node(node: Node) -> bool: + node_labels = [label for label in node.instance.metadata.labels] + odh_label = ("node-role.kubernetes.io/odh-notebook", "") + worker_label = ("node-role.kubernetes.io/worker", "") + + return odh_label in node_labels and worker_label in node_labels + + gpu_nodes = [ + node for node in Node.get(dyn_client=openshift_dyn_client) if is_gpu_node(node) + ] + + num_gpu_nodes = len(gpu_nodes) + assert ( + num_gpu_nodes == 1 + ), f"Expected to find 1 GPU Node but actually found '{num_gpu_nodes}'" + + namespace = "nvidia-gpu-operator" + gpu_node_name = gpu_nodes[0].instance.metadata.name + nvidia_pods = [ + pod + for pod in Pod.get(dyn_client=openshift_dyn_client, namespace=namespace) + if pod.instance.spec.nodeName == gpu_node_name + and "nvidia" in pod.instance.metadata.name + ] + + actual_count = len(nvidia_pods) + expected_count = 8 + assert ( + actual_count == expected_count + ), f"Expected to find {expected_count} Nvidia pods on Node '{gpu_node_name}' but actually found {actual_count}" + + +@pytest.mark.parametrize( + "openshift_dyn_client", + ["VP_HUBCONFIG"], + indirect=True, +) +def test_ragllm_ui(openshift_dyn_client): + rag_ui_url = _route_url(openshift_dyn_client, "rag-llm", "llm-ui") + grafana_url = _route_url( + openshift_dyn_client, "llm-monitoring", "ai-llm-grafana-route" + ) + + results_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".results") + os.makedirs(results_dir, exist_ok=True) + + with sync_playwright() as p: + browser = p.chromium.launch(headless=True) + context = browser.new_context(ignore_https_errors=True) + context.set_default_timeout(120_000) + page = context.new_page() + try: + # Generate a proposal in the RAG-LLM demo UI + page.goto(rag_ui_url) + page.get_by_role("textbox", name="Customer Enter the customer").fill( + "validated-patterns-qe" + ) + page.get_by_role("textbox", name="Product Enter the Red Hat").fill( + "RedHat OpenShift AI" + ) + page.get_by_role("button", name="Generate").click() + + # Wait for generation to complete, then submit a rating + rating = page.get_by_role("radio", name="3") + expect(rating).to_be_visible(timeout=180_000) + rating.check() + + # Add a provider on the Configuration tab + page.get_by_role("tab", name="Configuration").click() + page.get_by_role("button", name="Add Provider").click() + page.get_by_role("listbox", name="Providers").click() + page.get_by_role("option", name="OpenAI").click() + page.get_by_role("textbox", name="Model Enter the model name").fill( + "gpt-4o-mini" + ) + page.get_by_role("textbox", name="URL Enter the URL").fill( + "https://api.openai.com/v1/chat/completions" + ) + page.get_by_test_id("password").fill("12121212") + page.get_by_role("button", name="Add", exact=True).click() + + toast_close = page.get_by_test_id("toast-close") + expect(toast_close).to_be_visible() + toast_close.click() + page.screenshot(path=os.path.join(results_dir, "ragllm-add-provider.png")) + + # Check the Grafana LLM ratings dashboard + gpage = context.new_page() + gpage.goto(grafana_url) + gpage.get_by_role("link", name="Dashboards").click() + gpage.get_by_role("link", name="llm-monitoring").click() + + feedback = gpage.get_by_role("link", name="MODEL FEEDBACK/RATING") + expect(feedback).to_be_visible() + feedback.click() + gpage.screenshot( + path=os.path.join(results_dir, "ragllm-grafana-dashboard.png") + ) + finally: + context.close() + browser.close() diff --git a/tests/common-acm.expected.diff b/tests/common-acm.expected.diff deleted file mode 100644 index 2c8924b9..00000000 --- a/tests/common-acm.expected.diff +++ /dev/null @@ -1,151 +0,0 @@ ---- tests/common-acm-naked.expected.yaml -+++ tests/common-acm-normal.expected.yaml -@@ -1,7 +1,4 @@ - --- --# Source: acm/templates/policies/application-policies.yaml --# TODO: Also create a GitOpsCluster.apps.open-cluster-management.io ----- - # Source: acm/templates/multiclusterhub.yaml - apiVersion: operator.open-cluster-management.io/v1 - kind: MultiClusterHub -@@ -12,6 +9,22 @@ - argocd.argoproj.io/sync-wave: "-1" - spec: {} - --- -+# Source: acm/templates/policies/application-policies.yaml -+apiVersion: policy.open-cluster-management.io/v1 -+kind: PlacementBinding -+metadata: -+ name: group-one-placement-binding -+ annotations: -+ argocd.argoproj.io/sync-options: SkipDryRunOnMissingResource=true -+placementRef: -+ name: group-one-placement -+ kind: PlacementRule -+ apiGroup: apps.open-cluster-management.io -+subjects: -+ - name: group-one-clustergroup-policy -+ kind: Policy -+ apiGroup: policy.open-cluster-management.io -+--- - # Source: acm/templates/policies/ocp-gitops-policy.yaml - apiVersion: policy.open-cluster-management.io/v1 - kind: PlacementBinding -@@ -28,6 +41,19 @@ - kind: Policy - apiGroup: policy.open-cluster-management.io - --- -+# Source: acm/templates/policies/application-policies.yaml -+apiVersion: apps.open-cluster-management.io/v1 -+kind: PlacementRule -+metadata: -+ name: group-one-placement -+spec: -+ clusterConditions: -+ - status: 'True' -+ type: ManagedClusterConditionAvailable -+ clusterSelector: -+ matchLabels: -+ clusterGroup: group-one -+--- - # Source: acm/templates/policies/ocp-gitops-policy.yaml - apiVersion: apps.open-cluster-management.io/v1 - kind: PlacementRule -@@ -44,6 +70,97 @@ - values: - - OpenShift - --- -+# Source: acm/templates/policies/application-policies.yaml -+# TODO: Also create a GitOpsCluster.apps.open-cluster-management.io -+apiVersion: policy.open-cluster-management.io/v1 -+kind: Policy -+metadata: -+ name: group-one-clustergroup-policy -+ annotations: -+ argocd.argoproj.io/sync-options: SkipDryRunOnMissingResource=true -+ argocd.argoproj.io/compare-options: IgnoreExtraneous -+spec: -+ remediationAction: enforce -+ disabled: false -+ policy-templates: -+ - objectDefinition: -+ apiVersion: policy.open-cluster-management.io/v1 -+ kind: ConfigurationPolicy -+ metadata: -+ name: group-one-clustergroup-config -+ spec: -+ remediationAction: enforce -+ severity: medium -+ namespaceSelector: -+ include: -+ - default -+ object-templates: -+ - complianceType: mustonlyhave -+ objectDefinition: -+ apiVersion: argoproj.io/v1alpha1 -+ kind: Application -+ metadata: -+ name: mypattern-group-one -+ namespace: openshift-gitops -+ finalizers: -+ - resources-finalizer.argocd.argoproj.io/foreground -+ spec: -+ project: default -+ source: -+ repoURL: https://github.com/pattern-clone/mypattern -+ targetRevision: main -+ path: common/clustergroup -+ helm: -+ ignoreMissingValueFiles: true -+ valueFiles: -+ - "/values-global.yaml" -+ - "/values-group-one.yaml" -+ - '/values-{{ (lookup "config.openshift.io/v1" "Infrastructure" "" "cluster").spec.platformSpec.type }}-group-one.yaml' -+ # We cannot use $.Values.global.clusterVersion because that gets resolved to the -+ # hub's cluster version, whereas we want to include the spoke cluster version -+ - '/values-{{ printf "%d.%d" ((semver (lookup "operator.openshift.io/v1" "OpenShiftControllerManager" "" "cluster").status.version).Major) ((semver (lookup "operator.openshift.io/v1" "OpenShiftControllerManager" "" "cluster").status.version).Minor) }}-group-one.yaml' -+ parameters: -+ - name: global.repoURL -+ value: $ARGOCD_APP_SOURCE_REPO_URL -+ - name: global.targetRevision -+ value: $ARGOCD_APP_SOURCE_TARGET_REVISION -+ - name: global.namespace -+ value: $ARGOCD_APP_NAMESPACE -+ - name: global.pattern -+ value: mypattern -+ - name: global.hubClusterDomain -+ value: apps.hub.example.com -+ - name: global.localClusterDomain -+ value: '{{ (lookup "config.openshift.io/v1" "Ingress" "" "cluster").spec.domain }}' -+ # Requires ACM 2.6 or higher -+ - name: global.clusterDomain -+ value: '{{ (lookup "config.openshift.io/v1" "Ingress" "" "cluster").spec.domain | replace "apps." "" }}' -+ # Requires ACM 2.6 or higher (I could not come up with something less terrible to get maj.min) -+ - name: global.clusterVersion -+ value: '{{ printf "%d.%d" ((semver (lookup "operator.openshift.io/v1" "OpenShiftControllerManager" "" "cluster").status.version).Major) ((semver (lookup "operator.openshift.io/v1" "OpenShiftControllerManager" "" "cluster").status.version).Minor) }}' -+ - name: global.clusterPlatform -+ value: -+ - name: clusterGroup.name -+ value: group-one -+ - name: clusterGroup.isHubCluster -+ value: "false" -+ destination: -+ server: https://kubernetes.default.svc -+ namespace: mypattern-group-one -+ syncPolicy: -+ automated: -+ prune: false -+ selfHeal: true -+ ignoreDifferences: -+ - group: apps -+ kind: Deployment -+ jsonPointers: -+ - /spec/replicas -+ - group: route.openshift.io -+ kind: Route -+ jsonPointers: -+ - /status -+--- - # Source: acm/templates/policies/ocp-gitops-policy.yaml - apiVersion: policy.open-cluster-management.io/v1 - kind: Policy diff --git a/tests/common-clustergroup.expected.diff b/tests/common-clustergroup.expected.diff deleted file mode 100644 index dee42fc0..00000000 --- a/tests/common-clustergroup.expected.diff +++ /dev/null @@ -1,764 +0,0 @@ ---- tests/common-clustergroup-naked.expected.yaml -+++ tests/common-clustergroup-normal.expected.yaml -@@ -1,17 +1,204 @@ - --- -+# Source: pattern-clustergroup/templates/core/namespaces.yaml -+apiVersion: v1 -+kind: Namespace -+metadata: -+ labels: -+ argocd.argoproj.io/managed-by: mypattern-hub -+ name: open-cluster-management -+spec: -+--- -+# Source: pattern-clustergroup/templates/core/namespaces.yaml -+apiVersion: v1 -+kind: Namespace -+metadata: -+ labels: -+ argocd.argoproj.io/managed-by: mypattern-hub -+ name: vault -+spec: -+--- -+# Source: pattern-clustergroup/templates/core/namespaces.yaml -+apiVersion: v1 -+kind: Namespace -+metadata: -+ labels: -+ argocd.argoproj.io/managed-by: mypattern-hub -+ name: golang-external-secrets -+spec: -+--- -+# Source: pattern-clustergroup/templates/core/namespaces.yaml -+apiVersion: v1 -+kind: Namespace -+metadata: -+ labels: -+ argocd.argoproj.io/managed-by: mypattern-hub -+ name: config-demo -+spec: -+--- -+# Source: pattern-clustergroup/templates/imperative/namespace.yaml -+apiVersion: v1 -+kind: Namespace -+metadata: -+ labels: -+ name: imperative -+ argocd.argoproj.io/managed-by: mypattern-hub -+ name: imperative -+--- - # Source: pattern-clustergroup/templates/plumbing/gitops-namespace.yaml - apiVersion: v1 - kind: Namespace - metadata: - labels: -- name: common-example -+ name: mypattern-hub - # The name here needs to be consistent with - # - acm/templates/policies/application-policies.yaml - # - clustergroup/templates/applications.yaml - # - any references to secrets and route URLs in documentation -- name: common-example -+ name: mypattern-hub - spec: {} - --- -+# Source: pattern-clustergroup/templates/imperative/serviceaccount.yaml -+apiVersion: v1 -+kind: ServiceAccount -+metadata: -+ name: imperative-sa -+ namespace: imperative -+--- -+# Source: pattern-clustergroup/templates/imperative/configmap.yaml -+apiVersion: v1 -+kind: ConfigMap -+metadata: -+ name: helm-values-configmap-hub -+ namespace: imperative -+data: -+ values.yaml: | -+ clusterGroup: -+ applications: -+ acm: -+ ignoreDifferences: -+ - group: internal.open-cluster-management.io -+ jsonPointers: -+ - /spec/loggingCA -+ kind: ManagedClusterInfo -+ name: acm -+ namespace: open-cluster-management -+ path: common/acm -+ project: hub -+ config-demo: -+ name: config-demo -+ namespace: config-demo -+ path: charts/all/config-demo -+ project: config-demo -+ golang-external-secrets: -+ name: golang-external-secrets -+ namespace: golang-external-secrets -+ path: common/golang-external-secrets -+ project: hub -+ vault: -+ name: vault -+ namespace: vault -+ path: common/hashicorp-vault -+ project: hub -+ imperative: -+ activeDeadlineSeconds: 3600 -+ clusterRoleName: imperative-cluster-role -+ clusterRoleYaml: "" -+ cronJobName: imperative-cronjob -+ image: registry.redhat.io/ansible-automation-platform-22/ee-supported-rhel8:latest -+ imagePullPolicy: Always -+ insecureUnsealVaultInsideClusterSchedule: '*/5 * * * *' -+ jobName: imperative-job -+ jobs: -+ - name: test -+ playbook: ansible/test.yml -+ timeout: 234 -+ namespace: imperative -+ roleName: imperative-role -+ roleYaml: "" -+ schedule: '*/10 * * * *' -+ serviceAccountCreate: true -+ serviceAccountName: imperative-sa -+ valuesConfigMap: helm-values-configmap -+ verbosity: "" -+ insecureUnsealVaultInsideCluster: true -+ isHubCluster: true -+ managedClusterGroups: -+ exampleRegion: -+ helmOverrides: -+ - name: clusterGroup.isHubCluster -+ value: false -+ labels: -+ - name: clusterGroup -+ value: group-one -+ name: group-one -+ name: hub -+ namespaces: -+ - open-cluster-management -+ - vault -+ - golang-external-secrets -+ - config-demo -+ projects: -+ - hub -+ - config-demo -+ subscriptions: -+ acm: -+ channel: release-2.6 -+ name: advanced-cluster-management -+ namespace: open-cluster-management -+ targetCluster: in-cluster -+ enabled: all -+ global: -+ clusterDomain: region.example.com -+ hubClusterDomain: apps.hub.example.com -+ localClusterDomain: apps.region.example.com -+ namespace: pattern-namespace -+ options: -+ installPlanApproval: Automatic -+ syncPolicy: Automatic -+ useCSV: false -+ pattern: mypattern -+ repoURL: https://github.com/pattern-clone/mypattern -+ targetRevision: main -+ main: -+ clusterGroupName: hub -+ git: -+ repoURL: https://github.com/pattern-clone/mypattern -+ revision: main -+ secretStore: -+ kind: ClusterSecretStore -+ name: vault-backend -+ secretsBase: -+ key: secret/data/hub -+--- -+# Source: pattern-clustergroup/templates/imperative/clusterrole.yaml -+apiVersion: rbac.authorization.k8s.io/v1 -+kind: ClusterRole -+metadata: -+ name: imperative-cluster-role -+rules: -+ - apiGroups: -+ - '*' -+ resources: -+ - '*' -+ verbs: -+ - get -+ - list -+ - watch -+--- -+# Source: pattern-clustergroup/templates/imperative/rbac.yaml -+apiVersion: rbac.authorization.k8s.io/v1 -+kind: ClusterRoleBinding -+metadata: -+ name: imperative-cluster-admin-rolebinding -+roleRef: -+ apiGroup: rbac.authorization.k8s.io -+ kind: ClusterRole -+ name: imperative-cluster-role -+subjects: -+ - kind: ServiceAccount -+ name: imperative-sa -+ namespace: imperative -+--- - # Source: pattern-clustergroup/templates/plumbing/argocd-super-role.yaml - # WARNING: ONLY USE THIS FOR MANAGING CLUSTERS NOT FOR REGULAR USERS - apiVersion: rbac.authorization.k8s.io/v1 -@@ -36,7 +223,7 @@ - apiVersion: rbac.authorization.k8s.io/v1 - kind: ClusterRoleBinding - metadata: -- name: common-example-cluster-admin-rolebinding -+ name: mypattern-hub-cluster-admin-rolebinding - roleRef: - apiGroup: rbac.authorization.k8s.io - kind: ClusterRole -@@ -44,17 +231,436 @@ - subjects: - - kind: ServiceAccount - # This is the {ArgoCD.name}-argocd-application-controller -- name: example-gitops-argocd-application-controller -- namespace: common-example -+ name: hub-gitops-argocd-application-controller -+ namespace: mypattern-hub - # NOTE: THIS MUST BE FIXED FOR MULTITENANT SETUP - - kind: ServiceAccount - # This is the {ArgoCD.name}-argocd-server -- name: example-gitops-argocd-server -- namespace: common-example -+ name: hub-gitops-argocd-server -+ namespace: mypattern-hub - # NOTE: This is needed starting with gitops-1.5.0 (see issue common#76) - - kind: ServiceAccount -- name: example-gitops-argocd-dex-server -- namespace: common-example -+ name: hub-gitops-argocd-dex-server -+ namespace: mypattern-hub -+--- -+# Source: pattern-clustergroup/templates/imperative/role.yaml -+apiVersion: rbac.authorization.k8s.io/v1 -+kind: Role -+metadata: -+ name: imperative-role -+ namespace: imperative -+rules: -+ - apiGroups: -+ - '*' -+ resources: -+ - '*' -+ verbs: -+ - '*' -+--- -+# Source: pattern-clustergroup/templates/imperative/rbac.yaml -+apiVersion: rbac.authorization.k8s.io/v1 -+kind: RoleBinding -+metadata: -+ name: imperative-admin-rolebinding -+ namespace: imperative -+roleRef: -+ apiGroup: rbac.authorization.k8s.io -+ kind: Role -+ name: imperative-role -+subjects: -+ - kind: ServiceAccount -+ name: imperative-sa -+ namespace: imperative -+--- -+# Source: pattern-clustergroup/templates/imperative/job.yaml -+apiVersion: batch/v1 -+kind: CronJob -+metadata: -+ name: imperative-cronjob -+ namespace: imperative -+spec: -+ schedule: "*/10 * * * *" -+ # if previous Job is still running, skip execution of a new Job -+ concurrencyPolicy: Forbid -+ jobTemplate: -+ spec: -+ activeDeadlineSeconds: 3600 -+ template: -+ metadata: -+ name: imperative-job -+ spec: -+ serviceAccountName: imperative-sa -+ initContainers: -+ # git init happens in /git/repo so that we can set the folder to 0770 permissions -+ # reason for that is ansible refuses to create temporary folders in there -+ - name: git-init -+ image: registry.redhat.io/ansible-automation-platform-22/ee-supported-rhel8:latest -+ imagePullPolicy: Always -+ env: -+ - name: HOME -+ value: /git/home -+ command: -+ - 'sh' -+ - '-c' -+ - "mkdir /git/{repo,home};git clone --single-branch --branch main --depth 1 -- https://github.com/pattern-clone/mypattern /git/repo;chmod 0770 /git/{repo,home}" -+ volumeMounts: -+ - name: git -+ mountPath: "/git" -+ - name: test -+ image: registry.redhat.io/ansible-automation-platform-22/ee-supported-rhel8:latest -+ imagePullPolicy: Always -+ env: -+ - name: HOME -+ value: /git/home -+ workingDir: /git/repo -+ # We have a default timeout of 600s for each playbook. Can be overridden -+ # on a per-job basis -+ command: -+ - timeout -+ - "234" -+ - ansible-playbook -+ - -e -+ - "@/values/values.yaml" -+ - ansible/test.yml -+ volumeMounts: -+ - name: git -+ mountPath: "/git" -+ - name: values-volume -+ mountPath: /values/values.yaml -+ subPath: values.yaml -+ containers: -+ - name: "done" -+ image: registry.redhat.io/ansible-automation-platform-22/ee-supported-rhel8:latest -+ imagePullPolicy: Always -+ command: -+ - 'sh' -+ - '-c' -+ - 'echo' -+ - 'done' -+ - '\n' -+ volumes: -+ - name: git -+ emptyDir: {} -+ - name: values-volume -+ configMap: -+ name: helm-values-configmap-hub -+ restartPolicy: Never -+--- -+# Source: pattern-clustergroup/templates/imperative/unsealjob.yaml -+apiVersion: batch/v1 -+kind: CronJob -+metadata: -+ name: unsealvault-cronjob -+ namespace: imperative -+spec: -+ schedule: "*/5 * * * *" -+ # if previous Job is still running, skip execution of a new Job -+ concurrencyPolicy: Forbid -+ jobTemplate: -+ spec: -+ activeDeadlineSeconds: 3600 -+ template: -+ metadata: -+ name: unsealvault-job -+ spec: -+ serviceAccountName: imperative-sa -+ initContainers: -+ # git init happens in /git/repo so that we can set the folder to 0770 permissions -+ # reason for that is ansible refuses to create temporary folders in there -+ - name: git-init -+ image: registry.redhat.io/ansible-automation-platform-22/ee-supported-rhel8:latest -+ imagePullPolicy: Always -+ env: -+ - name: HOME -+ value: /git/home -+ command: -+ - 'sh' -+ - '-c' -+ - "mkdir /git/{repo,home};git clone --single-branch --branch main --depth 1 -- https://github.com/pattern-clone/mypattern /git/repo;chmod 0770 /git/{repo,home}" -+ volumeMounts: -+ - name: git -+ mountPath: "/git" -+ - name: unseal-playbook -+ image: registry.redhat.io/ansible-automation-platform-22/ee-supported-rhel8:latest -+ imagePullPolicy: Always -+ env: -+ - name: HOME -+ value: /git/home -+ workingDir: /git/repo -+ # We have a default timeout of 600s for each playbook. Can be overridden -+ # on a per-job basis -+ command: -+ - timeout -+ - "600" -+ - ansible-playbook -+ - -e -+ - "@/values/values.yaml" -+ - -e -+ - '{"file_unseal": false}' -+ - -t -+ - 'vault_init,vault_unseal,vault_secrets_init' -+ - "common/ansible/playbooks/vault/vault.yaml" -+ volumeMounts: -+ - name: git -+ mountPath: "/git" -+ - name: values-volume -+ mountPath: /values/values.yaml -+ subPath: values.yaml -+ containers: -+ - name: "done" -+ image: registry.redhat.io/ansible-automation-platform-22/ee-supported-rhel8:latest -+ imagePullPolicy: Always -+ command: -+ - 'sh' -+ - '-c' -+ - 'echo' -+ - 'done' -+ - '\n' -+ volumes: -+ - name: git -+ emptyDir: {} -+ - name: values-volume -+ configMap: -+ name: helm-values-configmap-hub -+ restartPolicy: Never -+--- -+# Source: pattern-clustergroup/templates/core/subscriptions.yaml -+--- -+--- -+# Source: pattern-clustergroup/templates/plumbing/projects.yaml -+apiVersion: argoproj.io/v1alpha1 -+kind: AppProject -+metadata: -+ name: hub -+ namespace: mypattern-hub -+spec: -+ description: "Pattern hub" -+ destinations: -+ - namespace: '*' -+ server: '*' -+ clusterResourceWhitelist: -+ - group: '*' -+ kind: '*' -+ namespaceResourceWhitelist: -+ - group: '*' -+ kind: '*' -+ sourceRepos: -+ - '*' -+status: {} -+--- -+# Source: pattern-clustergroup/templates/plumbing/projects.yaml -+apiVersion: argoproj.io/v1alpha1 -+kind: AppProject -+metadata: -+ name: config-demo -+ namespace: mypattern-hub -+spec: -+ description: "Pattern config-demo" -+ destinations: -+ - namespace: '*' -+ server: '*' -+ clusterResourceWhitelist: -+ - group: '*' -+ kind: '*' -+ namespaceResourceWhitelist: -+ - group: '*' -+ kind: '*' -+ sourceRepos: -+ - '*' -+status: {} -+--- -+# Source: pattern-clustergroup/templates/plumbing/applications.yaml -+apiVersion: argoproj.io/v1alpha1 -+kind: Application -+metadata: -+ name: acm -+ namespace: mypattern-hub -+ finalizers: -+ - resources-finalizer.argocd.argoproj.io/foreground -+spec: -+ destination: -+ name: in-cluster -+ namespace: open-cluster-management -+ project: hub -+ source: -+ repoURL: https://github.com/pattern-clone/mypattern -+ targetRevision: main -+ path: common/acm -+ helm: -+ ignoreMissingValueFiles: true -+ valueFiles: -+ - "/values-global.yaml" -+ - "/values-hub.yaml" -+ # Watch the progress of https://issues.redhat.com/browse/GITOPS-891 and update accordingly -+ parameters: -+ - name: global.repoURL -+ value: $ARGOCD_APP_SOURCE_REPO_URL -+ - name: global.targetRevision -+ value: $ARGOCD_APP_SOURCE_TARGET_REVISION -+ - name: global.namespace -+ value: $ARGOCD_APP_NAMESPACE -+ - name: global.pattern -+ value: mypattern -+ - name: global.clusterDomain -+ value: region.example.com -+ - name: global.clusterVersion -+ value: "" -+ - name: global.clusterPlatform -+ value: "" -+ - name: global.hubClusterDomain -+ value: apps.hub.example.com -+ - name: global.localClusterDomain -+ value: apps.region.example.com -+ ignoreDifferences: [ -+ { -+ "group": "internal.open-cluster-management.io", -+ "jsonPointers": [ -+ "/spec/loggingCA" -+ ], -+ "kind": "ManagedClusterInfo" -+ } -+] -+ syncPolicy: -+ automated: {} -+ # selfHeal: true -+--- -+# Source: pattern-clustergroup/templates/plumbing/applications.yaml -+apiVersion: argoproj.io/v1alpha1 -+kind: Application -+metadata: -+ name: config-demo -+ namespace: mypattern-hub -+ finalizers: -+ - resources-finalizer.argocd.argoproj.io/foreground -+spec: -+ destination: -+ name: in-cluster -+ namespace: config-demo -+ project: config-demo -+ source: -+ repoURL: https://github.com/pattern-clone/mypattern -+ targetRevision: main -+ path: charts/all/config-demo -+ helm: -+ ignoreMissingValueFiles: true -+ valueFiles: -+ - "/values-global.yaml" -+ - "/values-hub.yaml" -+ # Watch the progress of https://issues.redhat.com/browse/GITOPS-891 and update accordingly -+ parameters: -+ - name: global.repoURL -+ value: $ARGOCD_APP_SOURCE_REPO_URL -+ - name: global.targetRevision -+ value: $ARGOCD_APP_SOURCE_TARGET_REVISION -+ - name: global.namespace -+ value: $ARGOCD_APP_NAMESPACE -+ - name: global.pattern -+ value: mypattern -+ - name: global.clusterDomain -+ value: region.example.com -+ - name: global.clusterVersion -+ value: "" -+ - name: global.clusterPlatform -+ value: "" -+ - name: global.hubClusterDomain -+ value: apps.hub.example.com -+ - name: global.localClusterDomain -+ value: apps.region.example.com -+ syncPolicy: -+ automated: {} -+ # selfHeal: true -+--- -+# Source: pattern-clustergroup/templates/plumbing/applications.yaml -+apiVersion: argoproj.io/v1alpha1 -+kind: Application -+metadata: -+ name: golang-external-secrets -+ namespace: mypattern-hub -+ finalizers: -+ - resources-finalizer.argocd.argoproj.io/foreground -+spec: -+ destination: -+ name: in-cluster -+ namespace: golang-external-secrets -+ project: hub -+ source: -+ repoURL: https://github.com/pattern-clone/mypattern -+ targetRevision: main -+ path: common/golang-external-secrets -+ helm: -+ ignoreMissingValueFiles: true -+ valueFiles: -+ - "/values-global.yaml" -+ - "/values-hub.yaml" -+ # Watch the progress of https://issues.redhat.com/browse/GITOPS-891 and update accordingly -+ parameters: -+ - name: global.repoURL -+ value: $ARGOCD_APP_SOURCE_REPO_URL -+ - name: global.targetRevision -+ value: $ARGOCD_APP_SOURCE_TARGET_REVISION -+ - name: global.namespace -+ value: $ARGOCD_APP_NAMESPACE -+ - name: global.pattern -+ value: mypattern -+ - name: global.clusterDomain -+ value: region.example.com -+ - name: global.clusterVersion -+ value: "" -+ - name: global.clusterPlatform -+ value: "" -+ - name: global.hubClusterDomain -+ value: apps.hub.example.com -+ - name: global.localClusterDomain -+ value: apps.region.example.com -+ syncPolicy: -+ automated: {} -+ # selfHeal: true -+--- -+# Source: pattern-clustergroup/templates/plumbing/applications.yaml -+apiVersion: argoproj.io/v1alpha1 -+kind: Application -+metadata: -+ name: vault -+ namespace: mypattern-hub -+ finalizers: -+ - resources-finalizer.argocd.argoproj.io/foreground -+spec: -+ destination: -+ name: in-cluster -+ namespace: vault -+ project: hub -+ source: -+ repoURL: https://github.com/pattern-clone/mypattern -+ targetRevision: main -+ path: common/hashicorp-vault -+ helm: -+ ignoreMissingValueFiles: true -+ valueFiles: -+ - "/values-global.yaml" -+ - "/values-hub.yaml" -+ # Watch the progress of https://issues.redhat.com/browse/GITOPS-891 and update accordingly -+ parameters: -+ - name: global.repoURL -+ value: $ARGOCD_APP_SOURCE_REPO_URL -+ - name: global.targetRevision -+ value: $ARGOCD_APP_SOURCE_TARGET_REVISION -+ - name: global.namespace -+ value: $ARGOCD_APP_NAMESPACE -+ - name: global.pattern -+ value: mypattern -+ - name: global.clusterDomain -+ value: region.example.com -+ - name: global.clusterVersion -+ value: "" -+ - name: global.clusterPlatform -+ value: "" -+ - name: global.hubClusterDomain -+ value: apps.hub.example.com -+ - name: global.localClusterDomain -+ value: apps.region.example.com -+ syncPolicy: -+ automated: {} -+ # selfHeal: true - --- - # Source: pattern-clustergroup/templates/plumbing/argocd.yaml - apiVersion: argoproj.io/v1alpha1 -@@ -64,8 +670,8 @@ - - argoproj.io/finalizer - # Changing the name affects the ClusterRoleBinding, the generated secret, - # route URL, and argocd.argoproj.io/managed-by annotations -- name: example-gitops -- namespace: common-example -+ name: hub-gitops -+ namespace: mypattern-hub - annotations: - argocd.argoproj.io/compare-options: IgnoreExtraneous - spec: -@@ -90,15 +696,15 @@ - command: ["/bin/bash", "-c"] - args: ["helm template . --name-template ${ARGOCD_APP_NAME:0:52} - -f $(git rev-parse --show-toplevel)/values-global.yaml -- -f $(git rev-parse --show-toplevel)/values-example.yaml -+ -f $(git rev-parse --show-toplevel)/values-hub.yaml - --set global.repoURL=$ARGOCD_APP_SOURCE_REPO_URL - --set global.targetRevision=$ARGOCD_APP_SOURCE_TARGET_REVISION - --set global.namespace=$ARGOCD_APP_NAMESPACE -- --set global.pattern=common -- --set global.clusterDomain= -- --set global.hubClusterDomain= -- --set global.localClusterDomain= -- --set clusterGroup.name=example -+ --set global.pattern=mypattern -+ --set global.clusterDomain=region.example.com -+ --set global.hubClusterDomain=apps.hub.example.com -+ --set global.localClusterDomain=apps.region.example.com -+ --set clusterGroup.name=hub - --post-renderer ./kustomize"] - applicationSet: - resources: -@@ -173,12 +779,65 @@ - apiVersion: console.openshift.io/v1 - kind: ConsoleLink - metadata: -- name: example-gitops-link -- namespace: common-example -+ name: hub-gitops-link -+ namespace: mypattern-hub - spec: - applicationMenu: - section: OpenShift GitOps - imageURL: data:image/png;base64,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 -- href: 'https://example-gitops-server-common-example.' -+ href: 'https://hub-gitops-server-mypattern-hub.apps.region.example.com' - location: ApplicationMenu -- text: 'Example ArgoCD' -+ text: 'Hub ArgoCD' -+--- -+# Source: pattern-clustergroup/templates/core/operatorgroup.yaml -+apiVersion: operators.coreos.com/v1 -+kind: OperatorGroup -+metadata: -+ name: open-cluster-management-operator-group -+ namespace: open-cluster-management -+spec: -+ targetNamespaces: -+ - open-cluster-management -+--- -+# Source: pattern-clustergroup/templates/core/operatorgroup.yaml -+apiVersion: operators.coreos.com/v1 -+kind: OperatorGroup -+metadata: -+ name: vault-operator-group -+ namespace: vault -+spec: -+ targetNamespaces: -+ - vault -+--- -+# Source: pattern-clustergroup/templates/core/operatorgroup.yaml -+apiVersion: operators.coreos.com/v1 -+kind: OperatorGroup -+metadata: -+ name: golang-external-secrets-operator-group -+ namespace: golang-external-secrets -+spec: -+ targetNamespaces: -+ - golang-external-secrets -+--- -+# Source: pattern-clustergroup/templates/core/operatorgroup.yaml -+apiVersion: operators.coreos.com/v1 -+kind: OperatorGroup -+metadata: -+ name: config-demo-operator-group -+ namespace: config-demo -+spec: -+ targetNamespaces: -+ - config-demo -+--- -+# Source: pattern-clustergroup/templates/core/subscriptions.yaml -+apiVersion: operators.coreos.com/v1alpha1 -+kind: Subscription -+metadata: -+ name: advanced-cluster-management -+ namespace: open-cluster-management -+spec: -+ name: advanced-cluster-management -+ source: redhat-operators -+ sourceNamespace: openshift-marketplace -+ channel: release-2.6 -+ installPlanApproval: Automatic diff --git a/tests/common-examples-blank.expected.diff b/tests/common-examples-blank.expected.diff deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/common-examples-kustomize-renderer.expected.diff b/tests/common-examples-kustomize-renderer.expected.diff deleted file mode 100644 index dd709677..00000000 --- a/tests/common-examples-kustomize-renderer.expected.diff +++ /dev/null @@ -1,14 +0,0 @@ ---- tests/common-examples-kustomize-renderer-naked.expected.yaml -+++ tests/common-examples-kustomize-renderer-normal.expected.yaml -@@ -10,9 +10,9 @@ - GIT_EMAIL: SOMEWHERE@EXAMPLE.COM - GIT_DEV_REPO_URL: https:///PLAINTEXT/manuela-dev.git - GIT_DEV_REPO_REVISION: main -- GIT_OPS_REPO_TEST_URL: -+ GIT_OPS_REPO_TEST_URL: https://github.com/pattern-clone/mypattern - GIT_OPS_REPO_TEST_REVISION: -- GIT_OPS_REPO_PROD_URL: -+ GIT_OPS_REPO_PROD_URL: https://github.com/pattern-clone/mypattern - GIT_OPS_REPO_PROD_REVISION: - IOT_CONSUMER_IMAGE: iot-consumer - IOT_CONSUMER_YAML_PATH: images.(name==messaging).newTag diff --git a/tests/common-golang-external-secrets.expected.diff b/tests/common-golang-external-secrets.expected.diff deleted file mode 100644 index e80e716f..00000000 --- a/tests/common-golang-external-secrets.expected.diff +++ /dev/null @@ -1,11 +0,0 @@ ---- tests/common-golang-external-secrets-naked.expected.yaml -+++ tests/common-golang-external-secrets-normal.expected.yaml -@@ -6337,7 +6337,7 @@ - spec: - provider: - vault: -- server: https://vault-vault.hub.example.com -+ server: https://vault-vault.apps.hub.example.com - path: secret - # Version of KV backend - version: v2 diff --git a/tests/common-hashicorp-vault.expected.diff b/tests/common-hashicorp-vault.expected.diff deleted file mode 100644 index d9923297..00000000 --- a/tests/common-hashicorp-vault.expected.diff +++ /dev/null @@ -1,11 +0,0 @@ ---- tests/common-hashicorp-vault-naked.expected.yaml -+++ tests/common-hashicorp-vault-normal.expected.yaml -@@ -341,7 +341,7 @@ - applicationMenu: - section: HashiCorp Vault - imageURL: data:image/png;base64,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 -- href: 'https://vault-vault.apps.foo.cluster.com' -+ href: 'https://vault-vault.apps.region.example.com' - location: ApplicationMenu - text: 'Vault' - --- diff --git a/tests/common-install.expected.diff b/tests/common-install.expected.diff deleted file mode 100644 index f002ff47..00000000 --- a/tests/common-install.expected.diff +++ /dev/null @@ -1,43 +0,0 @@ ---- tests/common-install-naked.expected.yaml -+++ tests/common-install-normal.expected.yaml -@@ -11,14 +11,14 @@ - apiVersion: argoproj.io/v1alpha1 - kind: Application - metadata: -- name: common-install-default -+ name: common-install-hub - namespace: openshift-gitops - finalizers: - - resources-finalizer.argocd.argoproj.io/foreground - spec: - destination: - name: in-cluster -- namespace: common-install-default -+ namespace: common-install-hub - project: default - source: - repoURL: https://github.com/pattern-clone/mypattern -@@ -28,7 +28,7 @@ - ignoreMissingValueFiles: true - valueFiles: - - "/values-global.yaml" -- - "/values-default.yaml" -+ - "/values-hub.yaml" - # Track the progress of https://github.com/argoproj/argo-cd/pull/6280 - parameters: - - name: global.repoURL -@@ -40,7 +40,7 @@ - - name: global.pattern - value: common-install - - name: global.hubClusterDomain -- value: -+ value: apps.hub.example.com - - name: global.clusterVersion - value: "" - syncPolicy: -@@ -63,4 +63,4 @@ - config: - env: - - name: ARGOCD_CLUSTER_CONFIG_NAMESPACES -- value: common-install-default,openshift-gitops -+ value: common-install-hub,openshift-gitops diff --git a/tests/common-operator-install.expected.diff b/tests/common-operator-install.expected.diff deleted file mode 100644 index 7cfc98df..00000000 --- a/tests/common-operator-install.expected.diff +++ /dev/null @@ -1,11 +0,0 @@ ---- tests/common-operator-install-naked.expected.yaml -+++ tests/common-operator-install-normal.expected.yaml -@@ -6,7 +6,7 @@ - name: common-operator-install - namespace: openshift-operators - spec: -- clusterGroupName: default -+ clusterGroupName: hub - gitSpec: - targetRepo: https://github.com/pattern-clone/mypattern - targetRevision: main diff --git a/tests/interop/__init__.py b/tests/interop/__init__.py deleted file mode 100644 index 890362ce..00000000 --- a/tests/interop/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -__version__ = "0.1.0" -__loggername__ = "css_logger" diff --git a/tests/interop/conftest.py b/tests/interop/conftest.py deleted file mode 100644 index fb301d57..00000000 --- a/tests/interop/conftest.py +++ /dev/null @@ -1,2 +0,0 @@ -from validatedpatterns_tests.interop.conftest_logger import * # noqa: F401, F403 -from validatedpatterns_tests.interop.conftest_openshift import * # noqa: F401, F403 diff --git a/tests/interop/test_subscription_status_hub.py b/tests/interop/test_subscription_status_hub.py deleted file mode 100644 index 22d4fc80..00000000 --- a/tests/interop/test_subscription_status_hub.py +++ /dev/null @@ -1,29 +0,0 @@ -import logging - -import pytest -from validatedpatterns_tests.interop import subscription - -from . import __loggername__ - -logger = logging.getLogger(__loggername__) - - -@pytest.mark.subscription_status_hub -def test_subscription_status_hub(openshift_dyn_client): - # These are the operator subscriptions and their associated namespaces - expected_subs = { - "openshift-gitops-operator": ["openshift-gitops-operator"], - "prometheus": ["llm-monitoring"], - "grafana-operator": ["llm-monitoring"], - "nfd": ["openshift-nfd"], - "gpu-operator-certified": ["nvidia-gpu-operator"], - } - - err_msg = subscription.subscription_status( - openshift_dyn_client, expected_subs, diff=False - ) - if err_msg: - logger.error(f"FAIL: {err_msg}") - assert False, err_msg - else: - logger.info("PASS: Subscription status check passed") diff --git a/tests/interop/test_validate_gpu_nodes.py b/tests/interop/test_validate_gpu_nodes.py deleted file mode 100644 index c3ae585c..00000000 --- a/tests/interop/test_validate_gpu_nodes.py +++ /dev/null @@ -1,166 +0,0 @@ -import logging -import os -import re - -import pytest -from ocp_resources.machine_set import MachineSet -from ocp_resources.node import Node -from ocp_resources.pod import Pod - -from . import __loggername__ - -logger = logging.getLogger(__loggername__) - -oc = os.environ["HOME"] + "/oc_client/oc" - - -@pytest.mark.validate_gpu_machineset -def test_validate_gpu_nodes(openshift_dyn_client): - """ - Check for the existence of the GPU machineset - """ - logger.info("Checking GPU machineset") - machinesets = MachineSet.get( - dyn_client=openshift_dyn_client, namespace="openshift-machine-api" - ) - - found = False - for machineset in machinesets: - logger.info(machineset.instance.metadata.name) - # "gpu" for AWS machineset - # "nvidia" for Azure machineset - if re.search("gpu", machineset.instance.metadata.name) or re.search( - "nvidia", machineset.instance.metadata.name - ): - gpu_machineset = machineset - found = True - break - - err_msg = "GPU machineset not found" - if found: - logger.info( - f"PASS: Found GPU machineset: {gpu_machineset.instance.metadata.name}" - ) - else: - logger.error(f"FAIL: {err_msg}") - assert False, err_msg - - """ - Check for the existence of the GPU machineset taint - """ - logger.info("Checking GPU machineset taint") - - err_msg = "No taints found for GPU machineset" - try: - logger.info(gpu_machineset.instance.spec.template.spec.taints) - except AttributeError: - logger.error(f"FAIL: {err_msg}") - assert False, err_msg - - if gpu_machineset.instance.spec.template.spec.taints == "None": - logger.error(f"FAIL: {err_msg}") - assert False, err_msg - - logger.info( - f"PASS: Found GPU machineset taint: {gpu_machineset.instance.spec.template.spec.taints}" - ) - - """ - Check for the existence of the GPU machineset label - """ - logger.info("Checking GPU machineset label") - - err_msg = "No label found for GPU machineset" - try: - logger.info(gpu_machineset.instance.spec.template.spec.metadata.labels) - except AttributeError: - logger.error(f"FAIL: {err_msg}") - assert False, err_msg - - labels = str(gpu_machineset.instance.spec.template.spec.metadata.labels) - if labels == "None": - logger.error(f"FAIL: {err_msg}") - assert False, err_msg - - logger.info(f"PASS: Found GPU machineset labels: {labels}") - - """ - Check for the existence of the GPU machineset instance type - """ - logger.info("Checking GPU machineset instance type") - - err_msg = "No instanceType found for GPU machineset" - - # for AWS - instance_type = ( - gpu_machineset.instance.spec.template.spec.providerSpec.value.instanceType - ) - if instance_type is None: - # for Azure - instance_type = ( - gpu_machineset.instance.spec.template.spec.providerSpec.value.vmSize - ) - - logger.info(instance_type) - - if instance_type is None: - logger.error(f"FAIL: {err_msg}") - assert False, err_msg - - logger.info(f"PASS: Found GPU machineset instance type: {instance_type}") - - -@pytest.mark.validate_gpu_node_role_labels_pods -def test_validate_gpu_node_role_labels_pods(openshift_dyn_client): - """ - Check for the expected node-role labels for GPU nodes - """ - logger.info("Checking GPU node-role labels") - - nodes = Node.get(dyn_client=openshift_dyn_client) - gpu_nodes = [] - expected_count = 1 - for node in nodes: - logger.info(node.instance.metadata.name) - labels = node.instance.metadata.labels - logger.info(labels) - label_str = str(labels) - - odh_label = "'node-role.kubernetes.io/odh-notebook': ''" - worker_label = "'node-role.kubernetes.io/worker': ''" - - if odh_label in label_str and worker_label in label_str: - gpu_nodes.append(node) - - if len(gpu_nodes) == int(expected_count): - logger.info("PASS: Found 'worker' and 'odh-notebook' GPU node-role labels") - else: - err_msg = "Could not find 'worker' and 'odh-notebook' GPU node-role label" - logger.error(f"FAIL: {err_msg}") - assert False, err_msg - - """ - Check for the expected number of pods deployed on GPU nodes - """ - logger.info("Checking pod count on GPU nodes") - - # We are assuming one GPU node - gpu_node = gpu_nodes[0].instance.metadata.name - nvidia_pods = [] - expected_count = 8 - project = "nvidia-gpu-operator" - pods = Pod.get(dyn_client=openshift_dyn_client, namespace=project) - - for pod in pods: - if "nvidia" in pod.instance.metadata.name: - logger.info(f"nvidia pod: {pod.instance.metadata.name}") - if gpu_node in pod.instance.spec.nodeName: - logger.info(f"nvidia pod node name: {pod.instance.spec.nodeName}") - nvidia_pods.append(pod.instance.metadata.name) - - if len(nvidia_pods) == int(expected_count): - logger.info("PASS: Found the expected nvidia pod count for GPU nodes") - else: - err_msg = "Did not find the expected nvidia pod count for GPU nodes" - logger.error(f"FAIL: {err_msg}") - assert False, err_msg diff --git a/tests/interop/test_validate_hub_site_components.py b/tests/interop/test_validate_hub_site_components.py deleted file mode 100644 index 3519b673..00000000 --- a/tests/interop/test_validate_hub_site_components.py +++ /dev/null @@ -1,166 +0,0 @@ -import logging -import os -import subprocess - -import pytest -from ocp_resources.pod import Pod -from ocp_resources.route import Route -from ocp_resources.storage_class import StorageClass -from openshift.dynamic.exceptions import NotFoundError -from validatedpatterns_tests.interop import application, components - -from . import __loggername__ - -logger = logging.getLogger(__loggername__) - -oc = os.environ["HOME"] + "/oc_client/oc" - - -@pytest.mark.test_validate_hub_site_components -def test_validate_hub_site_components(openshift_dyn_client): - logger.info("Checking Openshift version on hub site") - version_out = components.dump_openshift_version() - logger.info(f"Openshift version:\n{version_out}") - - logger.info("Dump PVC and storageclass info") - pvcs_out = components.dump_pvc() - logger.info(f"PVCs:\n{pvcs_out}") - - for sc in StorageClass.get(dyn_client=openshift_dyn_client): - logger.info(sc.instance) - - -@pytest.mark.validate_hub_site_reachable -def test_validate_hub_site_reachable(kube_config, openshift_dyn_client): - logger.info("Check if hub site API end point is reachable") - err_msg = components.validate_site_reachable(kube_config, openshift_dyn_client) - if err_msg: - logger.error(f"FAIL: {err_msg}") - assert False, err_msg - else: - logger.info("PASS: Hub site is reachable") - - -@pytest.mark.check_pod_status_hub -def test_check_pod_status(openshift_dyn_client): - logger.info("Checking pod status") - projects = ["nvidia-gpu-operator", "rag-llm"] - err_msg = components.check_pod_status(openshift_dyn_client, projects) - if err_msg: - logger.error(f"FAIL: {err_msg}") - assert False, err_msg - else: - logger.info("PASS: Pod status check succeeded.") - - -@pytest.mark.check_pod_count_hub -def test_check_pod_count_hub(openshift_dyn_client): - logger.info("Checking pod count") - projects = {"rag-llm": 4} - - failed = [] - for key in projects.keys(): - logger.info(f"Checking project: {key}") - pods = Pod.get(dyn_client=openshift_dyn_client, namespace=key) - - count = 0 - for pod in pods: - logger.info(pod.instance.metadata.name) - count += 1 - - logger.info(f"Found {count} pods") - if count < projects[key]: - failed.append(key) - - if len(failed) > 0: - err_msg = f"Failed to find the expected pod count for: {failed}" - logger.error(f"FAIL: {err_msg}") - assert False, err_msg - else: - logger.info("PASS: Found the expected pod count") - - -@pytest.mark.validate_argocd_reachable_hub_site -def test_validate_argocd_reachable_hub_site(openshift_dyn_client): - logger.info("Check if argocd route/url on hub site is reachable") - err_msg = components.validate_argocd_reachable(openshift_dyn_client) - if err_msg: - logger.error(f"FAIL: {err_msg}") - assert False, err_msg - else: - logger.info("PASS: Argocd is reachable") - - -@pytest.mark.validate_llm_ui_route -def test_validate_llm_ui_route(openshift_dyn_client): - namespace = "rag-llm" - logger.info("Check for the existence of the llm-ui route") - try: - for route in Route.get( - dyn_client=openshift_dyn_client, - namespace=namespace, - name="llm-ui", - ): - logger.info(route.instance.spec.host) - except NotFoundError: - err_msg = "llm-ui url/route is missing in rag-llm namespace" - logger.error(f"FAIL: {err_msg}") - assert False, err_msg - - logger.info("PASS: Found llm-ui route") - - -@pytest.mark.validate_nodefeaturediscovery -def test_validate_nodefeaturediscovery(): - namespace = "openshift-nfd" - name = "nfd-instance" - logger.info("Check for nodefeaturediscovery instance") - - cmd_out = subprocess.run( - [oc, "get", "NodeFeatureDiscovery", "-n", namespace, name, "--no-headers"], - capture_output=True, - ) - if cmd_out.stdout: - logger.info(cmd_out.stdout.decode("utf-8")) - logger.info("PASS: Found nodefeaturediscovery instance") - else: - assert False, cmd_out.stderr - - -@pytest.mark.validate_gpu_clusterpolicy -def test_validate_gpu_clusterpolicy(): - name = "rag-llm-gpu-cluster-policy" - tolerations = ( - '"tolerations":[{"effect":"NoSchedule","key":"odh-notebook","value":"true"}]' - ) - logger.info("Check for GPU clusterpolicy") - - cmd_out = subprocess.run( - [oc, "get", "ClusterPolicy", "-o", "yaml", name], capture_output=True - ) - if cmd_out.stdout: - logger.info(cmd_out.stdout.decode("utf-8")) - - if tolerations in cmd_out.stdout.decode("utf-8"): - logger.info("PASS: Found GPU clusterpolicy and tolerations") - else: - err_msg = "FAIL: Expected tolerations not found" - logger.error(f"FAIL: {err_msg}") - assert False, err_msg - else: - assert False, cmd_out.stderr - - -@pytest.mark.validate_argocd_applications_health_hub_site -def test_validate_argocd_applications_health_hub_site(openshift_dyn_client): - logger.info("Get all applications deployed by argocd on hub site") - projects = ["openshift-gitops", "rag-llm-gitops-hub"] - unhealthy_apps = application.get_argocd_application_status( - openshift_dyn_client, projects - ) - if unhealthy_apps: - err_msg = "Some or all applications deployed on hub site are unhealthy" - logger.error(f"FAIL: {err_msg}:\n{unhealthy_apps}") - assert False, err_msg - else: - logger.info("PASS: All applications deployed on hub site are healthy.") diff --git a/tests/interop/ui/playwright.config.ts b/tests/interop/ui/playwright.config.ts deleted file mode 100644 index 3e862795..00000000 --- a/tests/interop/ui/playwright.config.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { defineConfig, devices } from '@playwright/test'; - -/** - * Read environment variables from file. - * https://github.com/motdotla/dotenv - */ -// import dotenv from 'dotenv'; -// import path from 'path'; -// dotenv.config({ path: path.resolve(__dirname, '.env') }); - -/** - * See https://playwright.dev/docs/test-configuration. - */ -export default defineConfig({ - testDir: './tests', - /* Run tests in files in parallel */ - fullyParallel: true, - /* Fail the build on CI if you accidentally left test.only in the source code. */ - forbidOnly: !!process.env.CI, - /* Retry on CI only */ - retries: process.env.CI ? 2 : 0, - /* Opt out of parallel tests on CI. */ - workers: process.env.CI ? 1 : undefined, - /* Reporter to use. See https://playwright.dev/docs/test-reporters */ - reporter: [ ['html', { open: 'never' }] ], - /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ - use: { - /* Base URL to use in actions like `await page.goto('/')`. */ - // baseURL: 'http://127.0.0.1:3000', - - /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ - trace: 'on-first-retry', - ignoreHTTPSErrors: true, - }, - timeout: 600 * 1000, - globalTimeout: 600 * 1000, - - /* Configure projects for major browsers */ - projects: [ - { - name: 'chromium', - use: { ...devices['Desktop Chrome'] }, - }, - -// { -// name: 'firefox', -// use: { ...devices['Desktop Firefox'] }, -// }, -// -// { -// name: 'webkit', -// use: { ...devices['Desktop Safari'] }, -// }, - - /* Test against mobile viewports. */ - // { - // name: 'Mobile Chrome', - // use: { ...devices['Pixel 5'] }, - // }, - // { - // name: 'Mobile Safari', - // use: { ...devices['iPhone 12'] }, - // }, - - /* Test against branded browsers. */ - // { - // name: 'Microsoft Edge', - // use: { ...devices['Desktop Edge'], channel: 'msedge' }, - // }, - // { - // name: 'Google Chrome', - // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, - // }, - ], - - /* Run your local dev server before starting the tests */ - // webServer: { - // command: 'npm run start', - // url: 'http://127.0.0.1:3000', - // reuseExistingServer: !process.env.CI, - // }, -}); diff --git a/tests/interop/ui/ragllm.spec.ts-snapshots/ragllm-add-provider-1-chromium-linux.png b/tests/interop/ui/ragllm.spec.ts-snapshots/ragllm-add-provider-1-chromium-linux.png deleted file mode 100644 index a19b79ff90828b1076324ba9e210c55dfdc7c362..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 32059 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 diff --git a/tests/interop/ui/ragllm.spec.ts.j2 b/tests/interop/ui/ragllm.spec.ts.j2 deleted file mode 100644 index c80b168f..00000000 --- a/tests/interop/ui/ragllm.spec.ts.j2 +++ /dev/null @@ -1,60 +0,0 @@ -import { test, expect } from '@playwright/test'; - -test.use({ - ignoreHTTPSErrors: true -}); - -test('ragllm: test proposal generation and grafana dashboard', async ({ page }) => { - test.setTimeout(240000); - // Log in to Openshift console - await page.goto("{{ hub_console.stdout }}"); - await page.getByLabel('Username *').fill('kubeadmin'); - await page.getByLabel('Password *').fill("{{ kubeadmin }}"); - await page.getByRole('button', { name: 'Log in' }).click(); - - // Launch RAG-LLM demo UI - await page.locator('[data-test-id="application-launcher"]').click(); - const page1Promise = page.waitForEvent('popup'); - await page.getByRole('menuitem', { name: 'Retrieval-Augmented' }).click(); - const page1 = await page1Promise; - - // Supply customer and product names - await page1.getByRole('textbox', { name: 'Customer Enter the customer' }).click(); - await page1.getByRole('textbox', { name: 'Customer Enter the customer' }).fill('validated-patterns-qe'); - await page1.getByRole('textbox', { name: 'Product Enter the Red Hat' }).click(); - await page1.getByRole('textbox', { name: 'Product Enter the Red Hat' }).fill('RedHat OpenShift AI'); - - // Generate proposal and add rating - await page1.getByRole('button', { name: 'Generate' }).click(); - await page1.waitForTimeout(60000); - await page1.getByRole('radio', { name: '3' }).check(); - await page1.waitForTimeout(60000); - - // Add a provider - await page1.getByRole('tab', { name: 'Configuration' }).click(); - await page1.getByRole('button', { name: 'Add Provider' }).click(); - await page1.getByRole('listbox', { name: 'Providers' }).click(); - await page1.getByRole('option', { name: 'OpenAI' }).click(); - await page1.getByRole('textbox', { name: 'Model Enter the model name' }).click(); - await page1.getByRole('textbox', { name: 'Model Enter the model name' }).fill('gtp-4o-mini'); - await page1.getByRole('textbox', { name: 'URL Enter the URL' }).click(); - await page1.getByRole('textbox', { name: 'URL Enter the URL' }).fill('https://api.openai.com/vi/chat/completions'); - await page1.getByTestId('password').click(); - await page1.getByTestId('password').fill('12121212'); - await page1.getByRole('button', { name: 'Add', exact: true }).click(); - await page1.getByTestId('toast-close').click(); - await page1.screenshot({ path: 'ragllm-add-provider-1-chromium-linux.png' }); - // await page1.screenshot({ path: 'add-provider.png', fullPage: true }); - - // Check grafana dashboard - await page.locator('[data-test-id="application-launcher"]').click(); - const page2Promise = page.waitForEvent('popup'); - await page.getByRole('menuitem', { name: 'Grafana UI for LLM Ratings' }).click(); - const page2 = await page2Promise; - await page2.getByTestId('data-testid navigation mega-menu').getByRole('link', { name: 'Dashboards' }).click(); - await page2.getByRole('link', { name: 'llm-monitoring' }).click(); - await page2.getByRole('link', { name: 'MODEL FEEDBACK/RATING' }).click(); - await page2.waitForTimeout(10000); - await page2.screenshot({ path: 'ragllm-grafana-dashboard-1-chromium-linux.png' }); - // await page2.screenshot({ path: 'grafana-dashboard.png', fullPage: true }); -}); diff --git a/values-global.yaml b/values-global.yaml index 3f10827b..b754b878 100644 --- a/values-global.yaml +++ b/values-global.yaml @@ -1,6 +1,7 @@ --- global: pattern: rag-llm-gitops + singleArgoCD: true options: useCSV: false syncPolicy: Automatic @@ -23,7 +24,7 @@ global: storageClass: gp3-csi main: - clusterGroupName: hub + variant: hub multiSourceConfig: enabled: true clusterGroupChartVersion: 0.9.* diff --git a/variants/ci/values-AWS.yaml b/variants/ci/values-AWS.yaml new file mode 100644 index 00000000..8206e7ca --- /dev/null +++ b/variants/ci/values-AWS.yaml @@ -0,0 +1,24 @@ +clusterGroup: + imperative: + jobs: + - name: create-gpu-machineset + playbook: ansible/playbooks/create-gpu-machineset.yaml + clusterRoleYaml: + - apiGroups: + - "*" + resources: + - machinesets + - persistentvolumeclaims + - datavolumes + - dataimportcrons + - datasources + verbs: + - "*" + - apiGroups: + - "*" + resources: + - "*" + verbs: + - get + - list + - watch diff --git a/variants/ci/values-Azure.yaml b/variants/ci/values-Azure.yaml new file mode 100644 index 00000000..0df42d6f --- /dev/null +++ b/variants/ci/values-Azure.yaml @@ -0,0 +1,29 @@ +global: + db: + type: MSSQL + storageClass: managed-csi + +clusterGroup: + imperative: + jobs: + - name: create-gpu-machineset + playbook: ansible/playbooks/create-gpu-machineset-azure.yaml + clusterRoleYaml: + - apiGroups: + - "*" + resources: + - machinesets + - persistentvolumeclaims + - datavolumes + - dataimportcrons + - datasources + verbs: + - "*" + - apiGroups: + - "*" + resources: + - "*" + verbs: + - get + - list + - watch diff --git a/variants/ci/values-ci.yaml b/variants/ci/values-ci.yaml new file mode 100644 index 00000000..7b8c12f8 --- /dev/null +++ b/variants/ci/values-ci.yaml @@ -0,0 +1,122 @@ +clusterGroup: + name: ci + + namespaces: + open-cluster-management: + vault: + external-secrets-operator: + operatorGroup: true + targetNamespaces: [] + external-secrets: + openshift-nfd: + nvidia-gpu-operator: + redhat-ods-operator: + operatorGroup: true + targetNamespaces: [] + rag-llm: + operatorGroup: true + targetNamespaces: + - rag-llm + labels: + opendatahub.io/dashboard: "true" + modelmesh-enabled: 'false' + openshift-serverless: + operatorGroup: true + targetNamespaces: [] + + subscriptions: + eso: + name: openshift-external-secrets-operator + namespace: external-secrets-operator + channel: stable-v1 + nfd: + name: nfd + namespace: openshift-nfd + nvidia: + name: gpu-operator-certified + namespace: nvidia-gpu-operator + source: certified-operators + edb: + name: cloud-native-postgresql + namespace: openshift-operators + source: certified-operators + elastic: + name: elasticsearch-eck-operator-certified + namespace: rag-llm + source: certified-operators + rhoai: + name: rhods-operator + namespace: redhat-ods-operator + + sharedValueFiles: + - '/variants/ci/values-{{ $.Values.global.clusterPlatform }}.yaml' + + applications: + vault: + name: vault + namespace: vault + chart: hashicorp-vault + chartVersion: 0.1.* + openshift-external-secrets: + name: openshift-external-secrets + namespace: external-secrets + chart: openshift-external-secrets + chartVersion: 0.0.* + vllm-inference-service: + name: vllm-inference-service + namespace: rag-llm + path: charts/all/vllm-inference-service + syncPolicy: + automated: + selfHeal: true + retry: + limit: 20 + rag-llm: + name: rag-llm + namespace: rag-llm + path: charts/all/rag-llm + llm-monitoring: + name: llm-monitoring + namespace: llm-monitoring + kustomize: true + path: charts/all/llm-monitoring/kustomize/overlays/dev + nfd-config: + name: nfd-config + namespace: openshift-cfd + path: charts/all/nfd-config + nvidia-config: + name: nvidia-config + namespace: nvidia-network-operator + path: charts/all/nvidia-gpu-config + llm-ui-config: + name: rag-llm-ui-config + namespace: rag-llm + path: charts/all/rag-llm-ui-config + llm-monitoring-config: + name: grafana-ui-config + namespace: llm-monitoring + path: charts/all/llm-monitoring-config + openshift-ai: + name: openshift-ai + namespace: redhat-ods-operator + path: charts/all/rhods + +global: + model: + vllm: ibm-granite/granite-4.0-h-350m + +vllmServingRuntime: + args: + - /cache/models + # Cap KV-cache reservation so there is free VRAM headroom for the Mamba + # Triton kernel autotuner, which allocates scratch outside vLLM's pool on + # the first request. Needed on 16 GB GPUs (e.g. Azure Tesla T4); the + # default 0.9 leaves too little free and OOMs. The model is ~0.65 GiB, so + # a small KV cache is plenty for CI. + - --gpu-memory-utilization=0.5 + + command: [] + + image: + repository: vllm/vllm-openai + tag: v0.21.0 diff --git a/values-hub.yaml b/variants/hub/values-hub.yaml similarity index 64% rename from values-hub.yaml rename to variants/hub/values-hub.yaml index c1bc6588..730dc87d 100644 --- a/values-hub.yaml +++ b/variants/hub/values-hub.yaml @@ -1,28 +1,34 @@ clusterGroup: name: hub - isHubCluster: true namespaces: - - open-cluster-management - - vault - - golang-external-secrets - - openshift-nfd - - nvidia-gpu-operator - - redhat-ods-operator: - operatorGroup: true - targetNamespaces: [] - - rag-llm: - operatorGroup: true - targetNamespaces: - - rag-llm - labels: - opendatahub.io/dashboard: "true" - modelmesh-enabled: 'false' - - openshift-serverless: - operatorGroup: true - targetNamespaces: [] + open-cluster-management: + vault: + external-secrets-operator: + operatorGroup: true + targetNamespaces: [] + external-secrets: + openshift-nfd: + nvidia-gpu-operator: + redhat-ods-operator: + operatorGroup: true + targetNamespaces: [] + rag-llm: + operatorGroup: true + targetNamespaces: + - rag-llm + labels: + opendatahub.io/dashboard: "true" + modelmesh-enabled: 'false' + openshift-serverless: + operatorGroup: true + targetNamespaces: [] subscriptions: + eso: + name: openshift-external-secrets-operator + namespace: external-secrets-operator + channel: stable-v1 nfd: name: nfd namespace: openshift-nfd @@ -42,13 +48,6 @@ clusterGroup: name: rhods-operator namespace: redhat-ods-operator - projects: - - hub - - rag-llm - - llm-monitoring - - gpu-config - - openshift-ai - sharedValueFiles: - '/overrides/values-{{ $.Values.global.clusterPlatform }}.yaml' @@ -56,19 +55,16 @@ clusterGroup: vault: name: vault namespace: vault - project: hub chart: hashicorp-vault chartVersion: 0.1.* - golang-external-secrets: - name: golang-external-secrets - namespace: golang-external-secrets - project: hub - chart: golang-external-secrets - chartVersion: 0.1.* + openshift-external-secrets: + name: openshift-external-secrets + namespace: external-secrets + chart: openshift-external-secrets + chartVersion: 0.0.* vllm-inference-service: name: vllm-inference-service namespace: rag-llm - project: hub path: charts/all/vllm-inference-service syncPolicy: automated: @@ -78,36 +74,29 @@ clusterGroup: rag-llm: name: rag-llm namespace: rag-llm - project: rag-llm path: charts/all/rag-llm llm-monitoring: name: llm-monitoring namespace: llm-monitoring - project: llm-monitoring kustomize: true path: charts/all/llm-monitoring/kustomize/overlays/dev nfd-config: name: nfd-config namespace: openshift-cfd - project: gpu-config path: charts/all/nfd-config nvidia-config: name: nvidia-config namespace: nvidia-network-operator - project: gpu-config path: charts/all/nvidia-gpu-config llm-ui-config: name: rag-llm-ui-config namespace: rag-llm - project: gpu-config path: charts/all/rag-llm-ui-config llm-monitoring-config: name: grafana-ui-config namespace: llm-monitoring - project: gpu-config path: charts/all/llm-monitoring-config openshift-ai: name: openshift-ai namespace: redhat-ods-operator - project: openshift-ai path: charts/all/rhods