diff --git a/.gitignore b/.gitignore index 99ad2fb8..d6d33e61 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,9 @@ +.DS_Store +**/.DS_Store + +# Local developer notes and machine-specific setup — not for version control +/local/ + # Local .terraform directories **/.terraform/* **/.terraform.lock.hcl @@ -36,3 +42,30 @@ override.tf.json # Include tfplan files to ignore the plan output of command: terraform plan -out=tfplan # example: *tfplan* + +# Temporary soong clone for generating ABFS patches +.soong_r4_check/ +# Go compiled binaries +**/bin/ +tools/horizon/horizon + +# ----------------------------------------------------------------------------- +# JavaScript / Node (dependencies and local caches — install via npm ci / pnpm) +# ----------------------------------------------------------------------------- +**/node_modules/ + +# Vite default build output (CI/Docker runs npm run build; do not commit) +**/dist/ +**/dist-ssr/ + +# Vite dev cache +**/.vite/ + +# TypeScript incremental build metadata +*.tsbuildinfo + +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* diff --git a/docs/deployment_guide.md b/docs/deployment_guide.md index a3be71d7..82172a44 100644 --- a/docs/deployment_guide.md +++ b/docs/deployment_guide.md @@ -27,6 +27,7 @@ Horizon SDV is designed to simplify the deployment and management of Android wor - [Section #3f - Headlamp Access via Keycloak Groups](#section-3f---headlamp-access-via-keycloak-groups) - [Section #3g - Grafana Access via Keycloak Groups](#section-3g---grafana-access-via-keycloak-groups) - [Section #3h - MCP Gateway Registry Access via Keycloak Groups](#section-3h---mcp-gateway-registry-access-via-keycloak-groups) + - [Section #3i - Enable Workloads Modules in Developer Portal](#section-3i---enable-workloads-modules-in-developer-portal) - [Section #4 - Run Cluster Apps](#section-4---run-cluster-apps) - [Section #4a - Horizon Landing Page](#section-4a---horizon-landing-page) - [Section #4b - Argo CD](#section-4b---argo-cd) @@ -270,21 +271,27 @@ git clone - Environment Metadata - `sdv_env_name`: Environment name of Horizon SDV platform of your choice which will also be used as sub-domain name. Preferably set same value as ``. - `sdv_root_domain`: Domain name, same value as ``. - - Git Repository Integration - - `sdv_git_repo_name`: Name of your git repository. (example: `horizon-sdv`). - - `sdv_git_repo_owner`: Git organization name or user name who owns the repository (example: `GoogleCloudPlatform`). - - `sdv_git_repo_branch`: Name of branch from the repository to be used for deployment. - - `git_auth_method`: Select the authentication method of your choice. Set `pat` to use a Personal Access Token. Set `app` to use GitHub App credentials (GitHub only). - - If PAT - - `sdv_git_pat`: [Personal Access Token (Classic)](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#creating-a-personal-access-token-classic). - - If GitHub App (see [Section #5c - Create and Install GitHub Application](#section-5c---create-and-install-github-application)) - - `sdv_github_app_id`: GitHub App ID from [Section #5c - Create and Install GitHub Application](#section-5c---create-and-install-github-application). - - `sdv_github_app_install_id`: - - Navigate to Organization Settings, GitHub Apps and click on "Configure". - - Once in the GitHub App configuration page, the `GH_INSTALLATION_ID` is present in the URL of the page as below, - - `https://github.com/organizations//settings/installations/` - - Enter the value of `` - - `sdv_github_app_private_key`: GitHub App Private key downloaded from [Section #5c - Create and Install GitHub Application](#section-5c---create-and-install-github-application). Paste the content between `-----BEGIN RSA PRIVATE KEY-----` and `-----END RSA PRIVATE KEY-----`. + - SCM Configuration (Source Code Management) + - `scm_type`: Set to `github` for GitHub or `git` for other Git servers (Gerrit, GitLab, etc.). + - `scm_auth_method`: Select authentication method: + - `app`: GitHub App authentication (only for GitHub) + - `userpass`: Username/Password or token (works with any Git server) + - `none`: Public repository (no authentication required) + - `scm_repo_url`: Full repository URL (e.g., `https://github.com/owner/repo` or `https://gerrit.example.com/a/project`) + - `scm_repo_branch`: Branch name to deploy from + - If using `userpass` authentication: + - `scm_username`: Username (use `git` for GitHub PAT, actual username for Gerrit/GitLab) + - `scm_password`: Password or token (GitHub PAT, Gerrit HTTP password, GitLab token, etc.) + - If using `app` authentication (GitHub only): + - `sdv_github_app_id`: GitHub App ID from [Section #1c - Create and Install GitHub Application](#section-1c---create-and-install-github-application). + - `sdv_github_app_install_id`: + - Navigate to Organization Settings, GitHub Apps and click on "Configure". + - Once in the GitHub App configuration page, the `GH_INSTALLATION_ID` is present in the URL of the page as below, + - `https://github.com/organizations//settings/installations/` + - Enter the value of `` + - `sdv_github_app_private_key`: GitHub App Private key downloaded from [Section #1c - Create and Install GitHub Application](#section-1c---create-and-install-github-application). Paste the content between `-----BEGIN RSA PRIVATE KEY-----` and `-----END RSA PRIVATE KEY-----`. + - If using `none` authentication (public repos): + - No credentials needed - simply provide the repository URL and branch - Required Admin Secrets - `sdv_keycloak_admin_password`: Plain text value following the required password rules mentioned above. - `sdv_keycloak_horizon_admin_password`: Plain text value following the required password rules mentioned above. @@ -301,12 +308,16 @@ Steps to start the Terraform workflow. **Containerized deployment:** 1. Navigate to `tools/scripts/deployment` -2. Provisioning can be done by running `./container-deploy.sh` (Requires **Docker** to be installed on the machine). +2. Provisioning can be done by running `./container-deploy.sh [options]` or (Requires **Docker** to be installed on the machine). +>**Options :** + - `-p, --plan` Preview infrastructure changes without applying them. Example: `./container-deploy.sh -p` or `./container-deploy.sh --plan` + - `-a, --apply` Provision or update the infrastructure using Terraform. Example: `./container-deploy.sh -a` or `./container-deploy.sh --apply` + - `-d, --destroy` Deprovision all Terraform-managed infrastructure resources. Example: `./container-deploy.sh -d` or `./container-deploy.sh --destroy` + - `-h, --help` Display the help message with available commands. Example: `./container-deploy.sh -h` or `./container-deploy.sh --help` + 3. The script `./container-deploy.sh` supports [**Google Cloud Shell**](https://docs.cloud.google.com/shell/docs/launching-cloud-shell), Linux or Windows (with [WSL](https://learn.microsoft.com/en-us/windows/wsl/install)). > [!Note] -> To deprovision the platform, run the same script with `--destroy` or `-d` flag. -> > GCE Disks or other GCE resources provisioned by the GKE cluster will not be automatically removed while running > deployment script with `--destroy` or `-d` flag as they are not managed by Terraform. A manual cleanup is required. @@ -315,16 +326,21 @@ Steps to start the Terraform workflow. > Use this deployment method ONLY on **Linux** (Ubuntu or Debian based distros), not tested on WSL or other platforms. 1. The below listed tools are required to be installed. - 1. [Kubectl](https://kubernetes.io/docs/tasks/tools/) + 1. [Kubectl](https://kubernetes.io/docs/tasks/tools/) 2. [Terraform](https://developer.hashicorp.com/terraform/tutorials/aws-get-started/install-cli) 3. Docker - [Linux](https://docs.docker.com/engine/install/) 4. [Google Cloud CLI](https://docs.cloud.google.com/sdk/docs/install-sdk) 2. Navigate to `tools/scripts/deployment` -3. Provisioning can be done by running `./deploy.sh` requires all above-mentioned tools to be installed. +3. Provisioning can be done by running `./deploy.sh [options]` (Requires all above-mentioned tools to be installed on the machine). +>**Options :** + - `-p, --plan` Preview infrastructure changes without applying them. Example: `./deploy.sh -p` or `./deploy.sh --plan` + - `-a, --apply` Provision or update the infrastructure using Terraform. Example: `./deploy.sh -a` or `./deploy.sh --apply` + - `-d, --destroy` Deprovision all Terraform-managed infrastructure resources. Example: `./deploy.sh -d` or `./deploy.sh --destroy` + - `-h, --help` Display the help message with available commands. Example: `./deploy.sh -h` or `./deploy.sh --help` + +4. The script `./deploy.sh` supports Linux distributions (Ubuntu/Debian based distros). > [!Note] -> To deprovision the platform, run the same script with `--destroy` or `-d` flag. -> > GCE Disks or other GCE resources provisioned by the GKE cluster will not be automatically removed while running > deployment script with `--destroy` or `-d` flag as they are not managed by Terraform. A manual cleanup is required. @@ -357,7 +373,7 @@ If you set `sdv_dns_dnssec_enabled = true` in `terraform.tfvars`, Terraform crea - **Disabling DNSSEC:** DNSSEC can be disabled in `terraform.tfvars`; in that case the steps above are not needed. -### Section #3d - Connect to GKE via Connect Gateway +### Section #3b - Connect to GKE via Connect Gateway Follow the steps mentioned in this section to connect to the GKE cluster using the Connect Gateway. 1. Install the pre-requisite tools `gcloud` (with `gke-gcloud-auth-plugin` component) and `kubectl` CLI tools. @@ -446,9 +462,9 @@ Below table details the Keycloak to jenkins RBAC mapping with their access level | Keycloak Group | Jenkins Role | Access Level | |------------------------------------------------|--------------------------------------|----------------------------------------| -| `horizon-jenkins-administrators` | Global: Admin | Full admin access | -| `horizon-jenkins-workloads-developers` | Item: workloads-developers | Full build/config rights for Workloads | -| `horizon-jenkins-workloads-users` | Item: workloads-users | Limited build access for Workloads | +| `administrators` | Global: Admin | Full admin access | +| `developers` | Item: workloads-developers | Full build/config rights for Workloads | +| `viewers` | Item: workloads-viewers | Limited build access for Workloads | #### Steps to Assign a User to a Group >[!NOTE] @@ -464,22 +480,22 @@ Follow the below steps to assign a user to required Keycloak group, - Use the search bar to locate the user. - Click on the username to open their details. 3. Assign the Group - - **horizon-jenkins-admininstrators** + - **admininstrators** - Click on the **Groups** tab. - Click on **Join Group** which opens a new pop-up window. - - Select the group `horizon-jenkins-admininstrators`. + - Select the group `admininstrators`. - Click **Join**. - - **horizon-jenkins-workloads-developers** + - **developers** - Click on the **Groups** tab. - Click on **Join Group** which opens a new pop-up window. - - Select the group `horizon-jenkins-workloads-developers`. + - Select the group `developers`. - Click **Join**. - - **horizon-jenkins-workloads-users** + - **viewers** - Click on the **Groups** tab. - Click on **Join Group** which opens a new pop-up window. - - Select the group `horizon-jenkins-workloads-users`. + - Select the group `viewers`. - Click **Join**. 4. Verify Group Assignment @@ -493,7 +509,7 @@ Below table details the Keycloak to Argo CD mapping with their access level gran | Keycloak Group | Argo CD Role | Access Level | |------------------------------------------------|--------------------------------------|------------------------------------------| -| `horizon-argocd-administrators` | role: admin | Full admin access | +| `administrators` | role: admin | Full admin access | #### Steps to Assign a User to a Group >[!NOTE] @@ -509,10 +525,10 @@ Follow the below steps to assign a user to required Keycloak group, - Use the search bar to locate the user. - Click on the username to open their details. 3. Assign the Group - - **horizon-argocd-admininstrators** + - **admininstrators** - Click on the **Groups** tab. - Click on **Join Group** which opens a new pop-up window. - - Select the group `horizon-argocd-admininstrators`. + - Select the group `admininstrators`. - Click **Join**. 4. Verify Group Assignment @@ -526,7 +542,7 @@ Below table details the Keycloak to Headlamp mapping with their access level gra | Keycloak Group | Headlamp Role | Access Level | |------------------------------------------------|--------------------------------------|------------------------------------------| -| `horizon-headlamp-administrators` | role: cluster-admin | Full admin access | +| `administrators` | role: cluster-admin | Full admin access | #### Steps to Assign a User to a Group >[!NOTE] @@ -542,10 +558,10 @@ Follow the below steps to assign a user to required Keycloak group, - Use the search bar to locate the user. - Click on the username to open their details. 3. Assign the Group - - **horizon-headlamp-admininstrators** + - **admininstrators** - Click on the **Groups** tab. - Click on **Join Group** which opens a new pop-up window. - - Select the group `horizon-headlamp-admininstrators`. + - Select the group `admininstrators`. - Click **Join**. 4. Verify Group Assignment @@ -559,8 +575,8 @@ Below table details the Keycloak to Grafana mapping with their access level gran | Keycloak Group | Access Level | |------------------------------------------------|--------------------------------------------------------------------------------| -| `horizon-grafana-administrators` | Full admin access. Edit dashboard, and other settings admin permissions | -| `horizon-grafana-viewers` | Viewers access. Only limited access, possible to view dashboards | +| `administrators` | Full admin access. Edit dashboard, and other settings admin permissions | +| `viewers` | Viewers access. Only limited access, possible to view dashboards | #### Steps to Assign a User to a Group >[!NOTE] @@ -576,10 +592,10 @@ Follow the below steps to assign a user to required Keycloak group, - Use the search bar to locate the user. - Click on the username to open their details. 3. Assign the Group - - **horizon-grafana-admininstrators** or **horizon-grafana-viewers** + - **admininstrators** or **viewers** - Click on the **Groups** tab. - Click on **Join Group** which opens a new pop-up window. - - Select the group `horizon-grafana-admininstrators` or `horizon-grafana-viewers`. + - Select the group `admininstrators` or `viewers`. - Click **Join**. 4. Verify Group Assignment @@ -593,8 +609,8 @@ Below table details the Keycloak to MCP Gateway Registry mapping with their acce | Keycloak Group | Access Level | |------------------------------------------------|--------------------------------------------------------------------------------| -| `horizon-mcp-gateway-registry-admins` | Full admin access for both UI and API. Add new or Edit registered MCP servers and agents, and other admin permissions | -| `horizon-mcp-gateway-registry-users` | View-only access for UI to view existing MCP Servers and agents added by admins. Full usage access for MCP servers and agents. Read-only access for API. | +| `administrators` | Full admin access for both UI and API. Add new or Edit registered MCP servers and agents, and other admin permissions | +| `viewers` | View-only access for UI to view existing MCP Servers and agents added by admins. Full usage access for MCP servers and agents. Read-only access for API. | #### Steps to Assign a User to a Group >[!NOTE] @@ -610,16 +626,33 @@ Follow the below steps to assign a user to required Keycloak group, - Use the search bar to locate the user. - Click on the username to open their details. 3. Assign the Group - - **horizon-mcp-gateway-registry-admins** or **horizon-mcp-gateway-registry-users** + - **administrators** or **viewers** - Click on the **Groups** tab. - Click on **Join Group** which opens a new pop-up window. - - Select the group `horizon-mcp-gateway-registry-admins` or `horizon-mcp-gateway-registry-users`. + - Select the group `administratord` or `viewers`. - Click **Join**. 4. Verify Group Assignment - The group should now appear under the user's "Group Membership". +### Section #3i - Enable Workloads Modules in Developer Portal + +> [!NOTE] +> Admin access via Keycloak `administrators` group is required. See [Section #3d - Jenkins Access via Keycloak Groups](#section-3d---jenkins-access-via-keycloak-groups) for group assignment steps. + +Where Android workloads are required, enable modules in the **Horizon Developer Portal** so that Jenkins seed jobs and workflow templates work properly: + +1. Open **Administration → Modules** at + `https://./developer-portal/admin/modules` +2. Under **Administration**, enable **`workloads-common`**, then **`workloads-android`**, in that order. + +Skip or adjust modules if your environment intentionally does not use Android workloads. + +After modules are enabled, run the Jenkins seed job and regenerate workload templates as described in [`workloads/seed.md`](workloads/seed.md). + +--- + ## Section #4 - Run Cluster Apps This section details how to sign in to and use cluster applications, including their functionalities within the cluster environment. When using sub-environments, each sub-environment has its own landing page and application URLs at `https://..`. See the [Sub-Environment Deployment Guide – Accessing Sub-Environment Applications](guides/sub_environments/sub_environment_deployment_guide.md#accessing-sub-environment-applications) for the URL pattern and application list. @@ -639,7 +672,7 @@ It ensures the Kubernetes Cluster (GKE) always matches that desired state. Here, 1. To Access Argo CD UI, go to the Horizon Landing page here: `https://.` and click on the Launch button within the Argo CD app card as below. -2. Log-in to Argo CD by clicking on the "Log in via Keycloak" button. (Your user must be assigned to `horizon-argocd-administrators` on Keycloak for SSO access) +2. Log-in to Argo CD by clicking on the "Log in via Keycloak" button. (Your user must be assigned to `administrators` on Keycloak for SSO access)
Click for more details on Argo CD diff --git a/docs/gitops.md b/docs/gitops.md index 2954c3e2..d899c870 100644 --- a/docs/gitops.md +++ b/docs/gitops.md @@ -26,6 +26,7 @@ - [OAuth2 Proxy](#oauth-proxy) - [Token Injector](#token-injector) - [Post Jobs](#post-jobs) +- [Config Connector webhook reliability](#config-connector-webhook-reliability) ## GitOps overview @@ -317,3 +318,8 @@ A collection of scripts that handle application-specific configurations when sta - **mtk-connect-post** – Configures MTK Connect after installation, ensuring it is properly set up for use. - **mtk-connect-post-key** – Generates and configures necessary API keys for MTK Connect. - **gerrit-post** – Uses the gerrit-admin account to perform the initial setup and configuration of Gerrit. + +## Config Connector webhook reliability + +Config Connector webhook TLS verification is centralized via CronJob in +`gitops/templates/config-connector.yaml`, while module charts keep ordered sync waves. diff --git a/docs/guides/terraform_sdv_wi_workload_identity_bindings.md b/docs/guides/terraform_sdv_wi_workload_identity_bindings.md new file mode 100644 index 00000000..094b127e --- /dev/null +++ b/docs/guides/terraform_sdv_wi_workload_identity_bindings.md @@ -0,0 +1,59 @@ +# Terraform `sdv-wi`: Workload Identity bindings on Google service accounts + +This guide explains how the **`sdv-wi`** module (`terraform/modules/sdv-wi`) wires **GKE Workload Identity (WI)** and why **IAM bindings must be attached to each Google service account (GSA)**, not to the **project**. + +For Argo Workflows–specific KSA/GSA naming and pod behavior, see **[Argo Workflows and Google Cloud Workload Identity](argo_workflows_workload_identity.md)**. + +--- + +## Symptom + +Workloads that call Google APIs using WI (for example **External Secrets Operator** reading **Secret Manager** via a `SecretStore` with `gcpsm` + `workloadIdentity`) can fail during token exchange with: + +```text +Permission 'iam.serviceAccounts.getAccessToken' denied on resource (or it may not exist). +``` + +Example: syncing a secret such as `github-app-id-b64` from GSM into the cluster. + +--- + +## Root cause + +For GKE Workload Identity, the Kubernetes service account (KSA) must be allowed to **impersonate** the target GSA. Google’s documented binding is: + +- **Role:** `roles/iam.workloadIdentityUser` +- **Resource:** the **Google service account** (not the project) +- **Member:** `serviceAccount:PROJECT_ID.svc.id.goog[NAMESPACE/KUBERNETES_SA_NAME]` + +Granting `roles/iam.workloadIdentityUser` at **project** scope to that member does **not** replace the per–service-account binding required for impersonation. Clients then fail when obtaining an access token for the GSA. + +--- + +## What the `sdv-wi` module does + +1. **`google_service_account.sdv_wi_sa`** — Creates one GSA per entry in `var.wi_service_accounts` (for example `gke-argo-workflows-sa`, `gke-argocd-sa`). + +2. **`google_project_iam_member.sdv_wi_sa_iam_2`** — Grants **project-level roles** to each GSA **email** (Secret Manager, storage, etc., as defined in `roles` for each SA). This is unchanged. + +3. **`google_service_account_iam_member.sdv_wi_sa_workload_identity_user`** — For each `(GSA, KSA)` pair in `gke_sas`, grants **`roles/iam.workloadIdentityUser`** on **that GSA** to the WI member + `serviceAccount:PROJECT_ID.svc.id.goog[gke_ns/gke_sa]`. + +The third block is the **Workload Identity** link between cluster identities and GSAs. + +--- + +## Terraform apply notes + +After switching from project-level WI bindings to **GSA-level** `google_service_account_iam_member`: + +- **Plan** will show **destroy** of old `google_project_iam_member` resources that previously attempted WI at project scope (if still in state), and **create** of `google_service_account_iam_member` resources. +- **Short risk:** brief window while bindings are replaced; re-run apply if needed. +- No change is required in **gitops** KSA annotations (`iam.gke.io/gcp-service-account`) if they already point at the correct GSA email. + +--- + +## References + +- [Authenticate to Google Cloud APIs from GKE workloads](https://cloud.google.com/kubernetes-engine/docs/how-to/workload-identity) (Google Cloud) +- Module: `terraform/modules/sdv-wi/main.tf` diff --git a/docs/guides/upgrade_guide_3_1_0_to_4_0_0.md b/docs/guides/upgrade_guide_3_1_0_to_4_0_0.md new file mode 100644 index 00000000..83d1c643 --- /dev/null +++ b/docs/guides/upgrade_guide_3_1_0_to_4_0_0.md @@ -0,0 +1,332 @@ +# Upgrade Guide: 3.1.0 to 4.0.0 + +This guide explains how to upgrade an existing Horizon SDV 3.1.0 environment to 4.0.0. + +## Table of Contents + +- [Overview](#overview) +- [Prerequisites](#prerequisites) +- [Configuration Placeholders](#configuration-placeholders) +- [Section #1 - Update the Repository](#section-1---update-the-repository) +- [Section #2 - Run the Deployment Script](#section-2---run-the-deployment-script) +- [Section #3 - Post-Upgrade Steps](#section-3---post-upgrade-steps) + - [Section #3a - Update DNS entry](#section-3a---update-dns-entry) + - [Section #3b - Access Argo CD](#section-3b---access-argo-cd) + - [Section #3c - Wait for ExternalSecrets to Recover](#section-3c---wait-for-externalsecrets-to-recover) + - [Section #3d - Delete and Recreate Affected Resources](#section-3d---delete-and-recreate-affected-resources) + - [Section #3e - Workload upgrades (release checklist) - CI/CD](#section-3e---workload-upgrades-release-checklist---cicd) +- [Section #4 - Verification](#section-4---verification) +- [Troubleshooting](#troubleshooting) + +--- + +## Overview + +Release 4.0.0 introduces the following breaking changes that require manual intervention when upgrading a live 4.0.0 environment: + +- **Add non-GitHub SCM repository support (TAA-1489):** terraform/env/terraform.tfvars.sample changes. SCM credentials for Jenkins were renamed in GitOps: the live Secret target is now jenkins-scm-creds (driven by jenkins-scm-creds-secret ExternalSecret in gitops/templates/jenkins-init.yaml), and CasC references jenkins-scm-creds jenkins secrets was renamed secrets. When migrating from release 3.1.0 or below to release 4.0.0, ArgoCD may show the `horizon-sdv` application as OutOfSync +Release 4.0.0 introduces the following breaking changes that require manual intervention when upgrading a live 3.1.0 environment: + +- **Keycloak Roles and Groups consistency (TAA-1141):** Old group and clients were using groups for authentication and authorization which needs to be deleted. New groups `administrators`, `viewers` and `developers` have been created for simplicity. And these 3 new keycloak groups are integrated to use client roles for authentication and authorization. + +--- + +## Prerequisites + +Before starting the upgrade, ensure the following: + +- The **3.1.0 environment is fully deployed and healthy**. All Argo CD applications must be `Synced` and `Healthy` before starting. +- You have access to new template `terraform/env/terraform.tfvars` ( based on `terraform/env/terraform.tfvars.sample`) and can run the deployment workflow. +- You have `kubectl` connectivity to the cluster. Refer to [Connect to GKE via Connect Gateway](../deployment_guide.md#section-3b---connect-to-gke-via-connect-gateway) if needed. +- You have the Argo CD admin credentials available. The admin password is stored in GCP Secret Manager under the secret named `argocd-admin-password-b64`. + +--- + +## Configuration Placeholders + +Throughout this guide the following placeholders are used. Replace them with your actual values. + +| Placeholder | Description | Example | +|------------------|-------------------------------------------------------------------|------------------| +| `SUB_DOMAIN` | The subdomain of your environment (`sdv_env_name` in tfvars) | `sbx` | +| `HORIZON_DOMAIN` | Your root domain (`sdv_root_domain` in tfvars) | `example.com` | + +--- + +## Section #1 - Update the Repository + +Switch your local repository to the 4.0.0 branch (or the branch containing the 4.0.0 changes) and pull the latest changes. + +```bash +git fetch origin +git checkout +git pull +``` + +If your `terraform/env/terraform.tfvars` requires any updates for new variables introduced in 4.0.0, apply them now. Few required variables are introduced in this release. Refer to the [Deployment Guide – Configure Terraform Variables](../deployment_guide.md#section-2c---configure-terraform-variables) for the full variable reference. + +--- + +## Section #2 - Run the Deployment Script + +Run the standard deployment script from `tools/scripts/deployment`. + +**Containerized deployment:** + +> [!NOTE] +> If you already have the deployer container image from a previous deployment, remove it first so that a new image is built with the latest `deploy.sh` and repository code. For example: `docker rmi horizon-sdv-deployer:latest`. Then run the following from `tools/scripts/deployment`: + +```bash +./container-deploy.sh --apply +``` + +**Linux Native deployment:** + +```bash +./deploy.sh --apply +``` + +## Section #3 - Post-Upgrade Steps + +### Section #3a - Update DNS entry + +1. open GCP Console `https://console.cloud.google.com` +2. Search `Cloud DNS` +3. Click on your Zone from the list +4. Open NS (Name Server) from the list +5. Copy the zone’s nameservers from Cloud DNS and paste them as the domain’s NS records at your registrar. + +### Section #3b - Access Argo CD + +> [!IMPORTANT] +> The Horizon landing page will be temporarily broken during the upgrade due to the resources being deleted and recreated in the steps below. Use the direct Argo CD URL to access the UI. + +Access Argo CD directly at: + +``` +https://./argocd +``` + +Log in with the Argo CD admin credentials: +- **Username:** `admin` +- **Password:** Retrieve from GCP Secret Manager under the secret named `argocd-admin-password-b64`. Could be retrieve from terraform.tfvars configuration file manual_secrets named s5 ( if set) + +### Section #3c - Wait for ExternalSecrets to Recover + +Once `terraform apply` finishes successfully, the ExternalSecret and SecretStore resources managed by Argo CD will temporarily enter a **Degraded** health state. This happens because the ArgoCD namespace and related resources were migrated and the External Secrets Operator needs to re-establish its connections. + +Wait until they recover to **Healthy**. This may take several minutes. To speed up recovery: + +1. Open the `horizon-sdv` application in Argo CD (you are already logged in from the previous step). +2. Click the **Refresh** button, then click **Sync** if the application remains out of sync. + +Do not proceed to the next section until all ExternalSecret and SecretStore resources are `Healthy`. + +### Section #3d - Delete and Recreate Affected Resources + +> [!IMPORTANT] +> Deleting the resources below will cause brief downtime for the affected applications. + +#### ArgoCD OutOfSync: orphaned `jenkins-git-creds` secret after upgrade + +When migrating from release 3.1.0 or below to release 4.0.0, ArgoCD may show the` horizon-sdv` application as OutOfSync. The diff shows resource `Secret/jenkins/jenkins-git-creds` (or prefixed namespace) with credentials present on one side and absent/empty on the other. + +The previously orphaned Secret `jenkins-git-creds` may remain in the cluster after the upgrade. + +Solution : + +First, confirm that Jenkins CasC uses jenkins-scm-creds only and properly authenticates with Git SCM (GitHub, etc.). + +ArgoCD, locate the `horizon-sdv` application (OutOfSync) and perform the following steps: + + - Secrets, find jenkins-git-creds (shown with empty Sync status). Click ... → Delete → select Non-cascading (Orphan) Delete → type the confirmation word → click OK. + + - External Secrets, find jenkins-git-creds-secret (shown with empty Sync status). If visible, repeat the Delete step from point 1. + + - Sync the `horizon-sdv` application with Prune enabled. When finished, the Sync Status should change to Synced. + + Step 1 and 2 could be done as kubectl commands: + + `kubectl delete secret jenkins-git-creds -n jenkins` + + `kubectl delete externalsecret jenkins-git-creds-secret -n jenkins` (could be not found) +Several resources must be deleted so that Argo CD can recreate them with the updated configuration. Follow the steps below in order. + +#### Delete all the groups in Keycloak application + +In Keycloak: + +1. Navigate to `Groups` tab + - Select all groups + - Click on 3 dots option and select **Delete**. + - This is required to clear unwanted groups +2. Navigate to `Clients` tab + - Select/Open `argocd` + - Click on "Action" dropdown button on the top right corner. + - Select Delete + - Repeat above steps for `grafana-oauth`, `jenkins` and `oauth2-headlamp` +3. Navigate to `Realm roles` + - Select/Open `horizon-grafana-administrators` + - Click on "Action" dropdown button on the top right corner. + - Select Delete this role. + - Repeat above steps for `horizon-grafana-viewers`, `horizon-jenkins-administrators`, `horizon-jenkins-workloads-developers` and `horizon-jenkins-workloads-users`. + +#### Delete below apps using ArgoCD application + +Login to ArgoCD using admin user: + +1. Delete app - `Grafana` +2. Delete app - `Jenkins` +3. Delete app - `landingpage` + +#### Sync the `horizon-sdv` application with Hard Refresh option + +Once the above resources are deleted: + +1. Click **Sync**. +2. Select `horizon-sdv` application. +3. Click **Synchronize**. +4. Click **Refresh Apps** +5. Select `horizon-sdv` application. +6. Select **Hard** option +7. Click **Refresh** + +This will recreate all deleted resources with the updated 4.0.0 configuration. + +### Section #3e - Workload upgrades (release checklist) - CI/CD + +Use this checklist whenever you cut over to a new Horizon-SDV release that includes Workloads-related changes. The same operational steps apply for **in-place upgrades** and **clean / greenfield** deployments. For greenfield installs, also follow any ordering and prerequisite notes in the linked Workloads guides so dependencies (GitOps, secrets, Jenkins, and so on) are satisfied before you run jobs. + +> [!IMPORTANT] +> This subsection is a **summary**. For exact parameters, job names, and sequencing, follow the authoritative guides in the [workloads documentation table](#workloads-documentation-authoritative) below—not this list alone. + +```mermaid +flowchart TD + A[Developer Portal: enable Workloads modules] --> R[Jenkins: Manage and Assign Roles] + R --> B[Jenkins seed: None then workloads] + B --> C[Regenerate all Docker image templates] + C --> D[Cuttlefish GCE only: new templates + prune obsolete assets] +``` + +#### Developer Portal — Workloads modules + +Where Android workloads are required, enable modules in the **Horizon Developer Portal**: + +1. Open **Administration → Modules** at + `https://./developer-portal/admin/modules` +2. Under **Administration**, enable **`workloads-common`**, then **`workloads-android`**, in that order. + +Skip or adjust modules if your environment intentionally does not use Android workloads. + +#### Jenkins — RBAC before seed + +Before running **Seed Workloads**, ensure operators and developers who will seed or run Workloads jobs have **Role-based Authorization** assignments in Jenkins: + +1. In Jenkins, open **Manage Jenkins** → **Manage and Assign Roles** → **Assign Roles**. +2. Map users to the correct **Global** and **Item** roles as described in the guides below. + +Authoritative setup (Keycloak groups, role names, and **Assign Roles** steps): [Role Based Strategy — Pipeline Guide](../workloads/guides/pipeline_guide.md#rolebasedstrategy). Keycloak group membership: [Jenkins access via Keycloak groups](../deployment_guide.md#section-3d---jenkins-access-via-keycloak-groups). + +#### Jenkins seed job + +1. Run the **seed job once** with workload **`None`** so Jenkins job parameters refresh to match the new release. +2. Seed additional workloads as needed for your environment—for example **`all`**, **`android`**, **`openbsw`**, **`utilities`**, or other values your seed job supports. + +See the [workloads documentation table](#workloads-documentation-authoritative). + +#### Docker image templates + +Regenerate **all** Docker image templates for **all** workloads you use (**full template coverage**) so built images pick up Dockerfile and template changes from the release. Run the jobs or pipelines described in each of the Workloads `docker_image_template` guides (Android, utilities, OpenBSW, and ABFS where applicable). + +#### Android Cuttlefish — GCE instance templates + +If you use Cuttlefish on GCE, **regenerate Cuttlefish instance templates from scratch** using the documented job or process, and **remove obsolete templates and related GCP resources** as part of that flow. The goal is to avoid leaving stale GCE assets that could be selected accidentally after an upgrade. + +#### Workloads documentation (authoritative) + +| Area | Documentation | +|------|----------------| +| Jenkins RBAC (**Manage and Assign Roles**) | [Pipeline Guide — Role Based Strategy](../workloads/guides/pipeline_guide.md#rolebasedstrategy); [Deployment Guide — Jenkins access via Keycloak groups](../deployment_guide.md#section-3d---jenkins-access-via-keycloak-groups) | +| Seed jobs & Jenkins parameters | [workloads/seed.md](../workloads/seed.md) | +| How pipelines and jobs fit together | [workloads/guides/pipeline_guide.md](../workloads/guides/pipeline_guide.md) | +| Docker image templates — Android | [workloads/android/environment/docker_image_template.md](../workloads/android/environment/docker_image_template.md) | +| Docker image templates — Utilities | [workloads/utilities/docker_image_template.md](../workloads/utilities/docker_image_template.md) | +| Docker image templates — OpenBSW | [workloads/openbsw/environment/docker_image_template.md](../workloads/openbsw/environment/docker_image_template.md) | +| Docker image templates — ABFS (where used) | [workloads/android/environment/abfs/docker_image_template.md](../workloads/android/environment/abfs/docker_image_template.md) | +| Cuttlefish GCE instance templates | [workloads/android/environment/cf_instance_template.md](../workloads/android/environment/cf_instance_template.md) | + +--- + +## Section #4 - Verification + +Once all post-upgrade steps are complete, verify the environment is healthy: + +1. In Argo CD, confirm that all applications under `horizon-sdv` are `Synced` and `Healthy`. +2. Confirm the Horizon landing page is accessible at `https://.`. +3. Confirm that the following applications are reachable and functioning: + - Landing Page: `https://.` + - Argo CD: `https://./argocd` + - Keycloak: `https://./keycloak` + - Gerrit: `https://./gerrit` + - Jenkins: `https://./jenkins` + - MTK Connect: `https://./mtk-connect` + - MCP Gateway Registry: `https://mcp..` +4. Confirm that assigning specific Keycloak Group and accessing applications is working fine. + +--- + +## Troubleshooting + +### Cloud DNS Error 400: existing records prevent zone replacement + +``` +Error: Error creating DnsAuthorization / Error 400: The resource already has records that cannot be replaced or removed. +``` + +This error occurs when the Cloud DNS zone already contains records (typically created by `external-dns` or a previous deployment) that conflict with what Terraform is trying to replace during the upgrade. + +Resolution: + +1. In Argo CD, delete the `external-dns` application (use **Non-cascading** delete so the namespace is preserved) to stop it from recreating DNS records during cleanup. +2. In GCP Console, navigate to **Network Services → Cloud DNS** and open your zone. +3. Delete all records in the zone **except** the `SOA` and `NS` records. +4. Re-run the deployment script: + ```bash + ./deploy.sh --apply + # or + ./container-deploy.sh --apply + ``` +5. After apply completes successfully, resync the `external-dns` application in Argo CD so it recreates the DNS records. + +### Argo CD namespace stuck in `Terminating` + +If the `argocd` namespace remains in a `Terminating` state for more than a few minutes after `terraform apply`, it is likely blocked by a finalizer on the `horizon-sdv` Argo CD `Application` resource whose controller was torn down during the migration. Remove the finalizer manually: + +1. Remove the finalizer from the `horizon-sdv` Application: + ```bash + kubectl patch application horizon-sdv -n argocd \ + --type=json \ + -p='[{"op":"remove","path":"/metadata/finalizers"}]' + ``` +2. If the namespace is still stuck, remove its finalizers directly: + ```bash + kubectl patch namespace argocd \ + --type=json \ + -p='[{"op":"remove","path":"/metadata/finalizers"}]' + ``` +3. Terraform will recreate the namespace and all ArgoCD resources on the next apply. + +### ExternalSecret or SecretStore remains Degraded after a long wait + +If ExternalSecrets or SecretStores do not recover to `Healthy`: + +1. Check the External Secrets Operator pod is running: + ```bash + kubectl get pods -n external-secrets + ``` +2. Force a refresh in Argo CD by clicking **Refresh** on the `horizon-sdv` application, then **Sync**. +3. If a specific ExternalSecret continues to fail, check its events: + ```bash + kubectl describe externalsecret -n + ``` + Verify that the referenced GCP Secret Manager secret exists and that the Workload Identity service account has the `Secret Manager Secret Accessor` role. diff --git a/docs/terraform.md b/docs/terraform.md index d8ca81f5..ae38c1bc 100644 --- a/docs/terraform.md +++ b/docs/terraform.md @@ -58,19 +58,21 @@ Terraform implementation in the Horizon SDV poject repository is organized into Main entry point for terraform execution is `env/main.tf` file. This file contains all input configuration parameters that are needed to be provided before execution. List of input configuration parameters is provided in the `local-env.sh` file which can be modified and sourced if there is a need of running terraform manually. If GitHub Workflows are used - all these input variables are provided automatically. -- sdv_github_app_id (Github Application ID) -- sdv_github_app_install_id (GitHub Installation ID) -- sdv_github_app_private_key (GitHub Application Private Key) -- git_auth_method (Authentication method either app (GitHub) or pat) -- sdv_git_pat (Git Personal Access Token) +- scm_type (SCM type: 'github' or 'git') +- scm_auth_method (Authentication method: 'app', 'userpass', or 'none') +- scm_repo_url (Full repository URL) +- scm_repo_branch (Repository branch) +- scm_username (SCM username, default: 'git') +- scm_password (SCM password or token) +- sdv_github_app_id (GitHub App ID, for app authentication) +- sdv_github_app_install_id (GitHub App Installation ID, for app authentication) +- sdv_github_app_private_key (GitHub App Private Key, for app authentication) - sdv_jenkins_admin_password (Jenkins initial admin account password) - sdv_keycloak_admin_password (Keycloak initial admin account password) - sdv_gerrit_admin_password (Gerrit initial admin accont password) - sdv_gerrit_ssh_private_key (Gerrit initial admin SSH private key) - sdv_keycloak_horizon_admin_password (Keycloak initial horizon realm admin account password) - sdv_cuttlefish_ssh_private_key (GCE SSH access to Cuttlefish VMs private key) -- sdv_git_repo_name (Repository Name) -- sdv_git_repo_owner (Repository Owner: GitHub Organization name or Git user name who owns the Git repo) - sdv_env_name (Environment and SubDomain name) - sdv_root_domain (Top level Domain Name) - sdv_gcp_project_id (GCP Project ID) @@ -234,8 +236,10 @@ Module Workload Identity is used to manage Google Cloud Service Accounts and the - google_service_account.sdv_wi_sa: Creates service accounts for each entry in var.wi_service_accounts. - flattened_roles_with_sa and flattened_gke_sas: Flattens the roles associated with each service account into a list. - roles_with_sa_map and gke_sas_with_sa_map: Maps each role-service account combination to a unique key. -- google_project_iam_member.sdv_wi_sa_iam_2 and sdv_wi_sa_wi_users_gke_ns_sa: Assigns roles to service accounts based on roles_with_sa_map. -In case of GKE assigns the roles/iam.workloadIdentityUser role to GKE service accounts based on gke_sas_with_sa_map. +- google_project_iam_member.sdv_wi_sa_iam_2: Assigns **project** roles (Secret Manager, storage, etc.) to each GSA email based on roles_with_sa_map. +- google_service_account_iam_member.sdv_wi_sa_workload_identity_user: Grants **roles/iam.workloadIdentityUser** on each **Google service account** to the corresponding GKE principal `serviceAccount:PROJECT_ID.svc.id.goog[namespace/ksa]` from gke_sas_with_sa_map (required for GKE Workload Identity; not project IAM). + +See **[terraform_sdv_wi_workload_identity_bindings.md](guides/terraform_sdv_wi_workload_identity_bindings.md)** for symptoms, rationale, and apply notes. ## Execute terraform scripts diff --git a/docs/workloads/android/abfs.md b/docs/workloads/android/abfs.md index c7482f0f..013e56d8 100644 --- a/docs/workloads/android/abfs.md +++ b/docs/workloads/android/abfs.md @@ -87,6 +87,10 @@ Once Google provide you the ABFS license in JSON form, the license must be suppl - Job will report failure if any of the uploader instances have not been provisioned correctly for ABFS. - **Note**: - There are additional parameters, currently set to defaults, e.g. `UPLOADER_GIT_BRANCH` is set to seed `android-16.0.0_r3` + - `GOOGLE_ABFS_TERRAFORM_VERSION` defaults to `v0.10.0` and accepts either a git tag or a specific commit sha1. + - Advanced module parameters are exposed in server/uploader operation jobs (extra ABFS args, uploader manifest scheme, spanner node/table options, and optional pre-start hooks). + - Server-side Spanner schema control is module-driven. Legacy `SPANNER_DDL_FILE` script path has been removed. + - Set `ABFS_SPANNER_DATABASE_CREATE_TABLES=true` only for new ABFS Spanner DB creation; keep `false` for upgrades/legacy DBs. - Refer to specific README files and parameter descriptions for additional details. - This task can take ~24 hours per branch/tag and the only way of knowing it is complete is to monitor the docker logs on the uploader instances to ensure all repositories have been seeded fully. Discuss with Google for details. diff --git a/docs/workloads/android/builds/aaos_abfs_builder.md b/docs/workloads/android/builds/aaos_abfs_builder.md index 7099bacc..6ed7a6bc 100644 --- a/docs/workloads/android/builds/aaos_abfs_builder.md +++ b/docs/workloads/android/builds/aaos_abfs_builder.md @@ -163,6 +163,21 @@ For GCS buckets, these labels can be applied as key=value pairs and can be provi E.g. `Release=X.Y.Z,Workload=Android` +### `ENABLE_GEMINI_AI_ASSISTANT` + +Enable Gemini AI to support in diagnosis of build and test failures. + +### Gemini prompts + +The job uses the prompt file from the repository only; there is no Jenkins parameter to override it (default: `build_prompt_v2.txt`). + +### `GEMINI_COMMAND_LINE` + +Interface for the headless [gemini-cli](https://geminicli.com/docs/cli/headless/). +Use this to specify settings such as the [Gemini model](https://ai.google.dev/gemini-api/docs/models) etc, e.g. +`--debug` to include debug output. +Note: Prompts are piped via `stdin` and output is redirected to a JSON file. + ## SYSTEM VARIABLES There are a number of system environment variables that are unique to each platform but required by Jenkins build, test and environment pipelines. @@ -192,11 +207,11 @@ These are as follows: - `HORIZON_DOMAIN` - The URL domain which is required by pipeline jobs to derive URL for tools and GCP. -- `HORIZON_GIT_URL` +- `HORIZON_SCM_URL` - The URL to the Horizon SDV Git repository. -- `HORIZON_GIT_BRANCH` - - The branch name the job will be configured for from `HORIZON_GIT_URL`. +- `HORIZON_SCM_BRANCH` + - The branch name the job will be configured for from `HORIZON_SCM_URL`. - `JENKINS_SERVICE_ACCOUNT` - Service account to use for pipelines. Required to ensure correct roles and permissions for GCP resources. diff --git a/docs/workloads/android/builds/aaos_builder.md b/docs/workloads/android/builds/aaos_builder.md index 8d2abc3a..22d691fd 100644 --- a/docs/workloads/android/builds/aaos_builder.md +++ b/docs/workloads/android/builds/aaos_builder.md @@ -139,6 +139,29 @@ Reference [Fleet management](https://docs.cloud.google.com/kubernetes-engine/ent - `gcloud container fleet memberships list` - `gcloud container fleet memberships get-credentials sdv-cluster` +### `ENABLE_GEMINI_AI_ASSISTANT` + +Enable Gemini AI to support in diagnosis of build and test failures. + +### Gemini prompts + +The job uses prompt files from the repository only; there is no Jenkins parameter to override them. When an analysis path is set, the script uses three default **sequenced** prompts (step1 → step2 → step3; order matters). A single prompt is supported by the script but we do not ship single-prompt files. + +### `GEMINI_COMMAND_LINE` + +Interface for the headless [gemini-cli](https://geminicli.com/docs/cli/headless/). +Use this to specify settings such as the [Gemini model](https://ai.google.dev/gemini-api/docs/models) etc, e.g. +`--debug` to include debug output. +Note: Prompts are piped via `stdin` and output is redirected to a JSON file. + +### `GEMINI_AI_EXECUTION_TIMEOUT` + +AI Execution Timeout (Hours) +Set the maximum duration for the Gemini AI assistant. + +* Prevents jobs from hanging indefinitely. +* Automatically terminates runaway processes to save build resources. + ### `AAOS_ARTIFACT_STORAGE_SOLUTION` Define storage solution used to push artifacts. @@ -286,11 +309,11 @@ These are as follows: - `HORIZON_DOMAIN` - The URL domain which is required by pipeline jobs to derive URL for tools and GCP. -- `HORIZON_GIT_URL` - - The URL to the Horizon SDV Git repository. +- `HORIZON_SCM_URL` + - The URL to the Horizon SDV git repository. -- `HORIZON_GIT_BRANCH` - - The branch name the job will be configured for from `HORIZON_GIT_URL`. +- `HORIZON_SCM_BRANCH` + - The branch name the job will be configured for from `HORIZON_SCM_URL`. - `JENKINS_AAOS_BUILD_CACHE_STORAGE_PREFIX` - This identifies the Persistent Volume Claim (PVC) prefix that is used to provision persistent storage for build cache, ensuring efficient reuse of cached resources across builds. The default is [`pd-balanced`](https://cloud.google.com/compute/docs/disks/performance), which strikes a balance between optimal performance and cost-effectiveness. @@ -367,3 +390,9 @@ These are as follows: ### Build Cache Corruption - The shared build cache can significantly accelerate build jobs, but it's not without risks. If a build job crashes or is aborted during initialization, the cache can become unstable, causing subsequent jobs to encounter issues with the `repo sync` command due to lingering lock files. To mitigate this, a retry and recovery process is triggered after multiple failed attempts, which involves deleting and recreating the cache. However, this process can substantially prolong the build job duration. + +### Gemini AI assistant (`ENABLE_GEMINI_AI_ASSISTANT`) + +- **Experimental:** Gemini-assisted diagnosis in this pipeline is experimental; behavior, quality, and availability can change without notice. +- **Examples only:** Repository prompts and skills are **illustrative examples**—tune, replace, or disable them for your environment. +- **Upstream issues:** Problems may come from [Gemini CLI](https://github.com/google-gemini/gemini-cli) itself; see [open issues](https://github.com/google-gemini/gemini-cli/issues) for known bugs and workarounds. \ No newline at end of file diff --git a/docs/workloads/android/builds/gerrit.md b/docs/workloads/android/builds/gerrit.md index 7ccd0681..a1f31b6d 100644 --- a/docs/workloads/android/builds/gerrit.md +++ b/docs/workloads/android/builds/gerrit.md @@ -38,6 +38,8 @@ One-time setup requirements. - Before running this pipeline job, ensure that the following templates have been created by running the corresponding jobs: - Docker image template: `Android Workflows/Environment/Docker Image Template` - Cuttlefish instance template: `Android Workflows/Environment/CF Instance Template` + - The CF job now uses a Packer-based build flow for template creation. + - Script stage mapping is `1=build`, `2=ssh refresh`, `3=delete` (documented in `docs/workloads/android/environment/cf_instance_template.md`). To successfully run the pipeline, ensure that the referenced Cuttlefish instance template exists, as specified in the `JENKINS_GCE_CLOUD_LABEL` variable defined in the Android Seed job. If the template is missing, the job will fail. The variable must reference align with the `computeEngine` label of the instance you intend to use. @@ -114,11 +116,11 @@ These are as follows: - `HORIZON_DOMAIN` - The URL domain which is required by pipeline jobs to derive URL for tools and GCP. -- `HORIZON_GIT_URL` - - The URL to the Horizon SDV Git repository. +- `HORIZON_SCM_URL` + - The URL to the Horizon SDV git repository. -- `HORIZON_GIT_BRANCH` - - The branch name the job will be configured for from `HORIZON_GIT_URL`. +- `HORIZON_SCM_BRANCH` + - The branch name the job will be configured for from `HORIZON_SCM_URL`. - `JENKINS_AAOS_BUILD_CACHE_STORAGE_PREFIX` - This identifies the Persistent Volume Claim (PVC) prefix that is used to provision persistent storage for build cache, ensuring efficient reuse of cached resources across builds. The default is [`pd-balanced`](https://cloud.google.com/compute/docs/disks/performance), which strikes a balance between optimal performance and cost-effectiveness. diff --git a/docs/workloads/android/environment/abfs/docker_image_template.md b/docs/workloads/android/environment/abfs/docker_image_template.md index d60a8a98..d0d5b061 100644 --- a/docs/workloads/android/environment/abfs/docker_image_template.md +++ b/docs/workloads/android/environment/abfs/docker_image_template.md @@ -66,6 +66,15 @@ Define `latest` if wishing to use the latest available version. Version of `kubectl` to install. The version is typically `1:${GCLOUD_CLI_VERSION}`. Define `latest` if wishing to use the latest available version. +### `ENABLE_GEMINI_AI_ASSISTANT` + +Enable Gemini AI to support in diagnosis of build and test failures. + +### `GEMINI_CLI_VERSION` + +The version of gemini-cli to be installed. +Run `npm view @google/gemini-cli versions` for a full list of valid versions. + ## SYSTEM VARIABLES There are a number of system environment variables that are unique to each platform but required by Jenkins build, test and environment pipelines. @@ -83,11 +92,11 @@ These are as follows: - `CLOUD_REGION` - The GCP project region. Important for bucket, registry paths used in pipelines. -- `HORIZON_GIT_URL` - - The URL to the Horizon SDV Git repository. +- `HORIZON_SCM_URL` + - The URL to the Horizon SDV git repository. -- `HORIZON_GIT_BRANCH` - - The branch name the job will be configured for from `HORIZON_GIT_URL`. +- `HORIZON_SCM_BRANCH` + - The branch name the job will be configured for from `HORIZON_SCM_URL`. - `JENKINS_SERVICE_ACCOUNT` - Service account to use for pipelines. Required to ensure correct roles and permissions for GCP resources. diff --git a/docs/workloads/android/environment/abfs/docker_infra_template.md b/docs/workloads/android/environment/abfs/docker_infra_template.md index 00b5bb98..1d77940a 100644 --- a/docs/workloads/android/environment/abfs/docker_infra_template.md +++ b/docs/workloads/android/environment/abfs/docker_infra_template.md @@ -32,7 +32,7 @@ simply use `latest` because all pipelines that depend on this container image ar Define the Linux Distribution to create the Docker image from. Values must be supported by the Dockerfile `FROM` instruction. -### `TERRAFORM_CATEGORY` +### `TERRAFORM_VERSION` Define the terraform version to install. @@ -75,11 +75,11 @@ These are as follows: - `CLOUD_REGION` - The GCP project region. Important for bucket, registry paths used in pipelines. -- `HORIZON_GIT_URL` - - The URL to the Horizon SDV Git repository. +- `HORIZON_SCM_URL` + - The URL to the Horizon SDV git repository. -- `HORIZON_GIT_BRANCH` - - The branch name the job will be configured for from `HORIZON_GIT_URL`. +- `HORIZON_SCM_BRANCH` + - The branch name the job will be configured for from `HORIZON_SCM_URL`. - `JENKINS_SERVICE_ACCOUNT` - Service account to use for pipelines. Required to ensure correct roles and permissions for GCP resources. diff --git a/docs/workloads/android/environment/abfs/server.md b/docs/workloads/android/environment/abfs/server.md index 9ecaac1c..1f669b1a 100644 --- a/docs/workloads/android/environment/abfs/server.md +++ b/docs/workloads/android/environment/abfs/server.md @@ -60,17 +60,79 @@ Note: this can change, so the `Seed Workloads` job supports this parameter to al This is the Docker registry the ABDS docker image/containers for server will be pulled from. -### `SPANNER_DDL_FILE` +### `GOOGLE_ABFS_TERRAFORM_GIT_URL` -Spanner is used by the ABFS server and as such, this is the schema file for the database. +The URL for Google Terraform modules for ABFS. -### `TERRAFORM_GIT_URL` +### `GOOGLE_ABFS_TERRAFORM_VERSION` -The URL for Google Terraform modules for ABFS. +The git tag or sha1 for Google Terraform modules for ABFS. Default is `v0.10.0`. + +### `ABFS_EXTRA_PARAMS` + +Optional. + +Purpose: pass advanced ABFS server runtime flags without changing Terraform/module code. + +Format: JSON array of strings, e.g. `["--flag=value","--other-flag"]`. + +Default: `[]` (no extra parameters). + +### `EXISTING_BUCKET_NAME` + +Optional. + +Purpose: reuse an existing GCS bucket for ABFS data and avoid creating a new one. + +Format: bucket name string. + +Default: empty string `""` (Terraform creates and manages ABFS bucket). + +### `ABFS_SPANNER_INSTANCE_MIN_NODES` + +Optional. + +Purpose: lower bound for ABFS Spanner instance autoscaling. + +Format: integer value. + +Default: `1`. + +### `ABFS_SPANNER_INSTANCE_MAX_NODES` + +Optional. + +Purpose: upper bound for ABFS Spanner instance autoscaling. + +Format: integer value. + +Default: `10`. + +### `ABFS_SPANNER_DATABASE_CREATE_TABLES` + +Optional. + +Purpose: control whether module creates ABFS Spanner tables from schema metadata. + +Format: boolean (`true` or `false`). + +Default: `false`. + +Upgrade guidance: keep this as `false` for Terraform module version upgrades or when a legacy ABFS Spanner DB already exists. + +New DB guidance: set this to `true` only when provisioning a new ABFS Spanner DB and you want module-driven table creation. + +Pipeline guard: for `APPLY`, the pipeline fails if this is `true` and an ABFS database already exists. + +### `ABFS_SPANNER_DATABASE_SCHEMA_VERSION` + +Optional. + +Purpose: choose schema version applied when table creation is enabled (`ABFS_SPANNER_DATABASE_CREATE_TABLES=true`). -### `TERRAFORM_GIT_VERSION` +Format: schema version string (for example `0.0.31`). -The sha1 for Google Terraform modules for ABFS. +Default: `0.0.31`. ### `ABFS_COS_IMAGE_REF` Defines the ABFS Containerized OS images used on server and uploader instances. @@ -92,11 +154,11 @@ These are as follows: - `CLOUD_REGION` - The GCP project region. Important for bucket, registry paths used in pipelines. -- `HORIZON_GIT_URL` - - The URL to the Horizon SDV Git repository. +- `HORIZON_SCM_URL` + - The URL to the Horizon SDV git repository. -- `HORIZON_GIT_BRANCH` - - The branch name the job will be configured for from `HORIZON_GIT_URL`. +- `HORIZON_SCM_BRANCH` + - The branch name the job will be configured for from `HORIZON_SCM_URL`. - `JENKINS_SERVICE_ACCOUNT` - Service account to use for pipelines. Required to ensure correct roles and permissions for GCP resources. diff --git a/docs/workloads/android/environment/abfs/uploader.md b/docs/workloads/android/environment/abfs/uploader.md index 8a0b9a0c..e6fc603a 100644 --- a/docs/workloads/android/environment/abfs/uploader.md +++ b/docs/workloads/android/environment/abfs/uploader.md @@ -80,17 +80,85 @@ This is the Gerrit manifest file from which the uploader will seed the ABFS serv ### `UPLOADER_GIT_BRANCH` -This is the Gerrit Android branch/tag the uploader will seed the ABFS server. +This is the Gerrit Android branch/tag list the uploader will seed to the ABFS server. -Note: you may use a comma separated list to define more than one branch, or apply new branches, simply change the name. +Format: JSON array of strings. -### `TERRAFORM_GIT_URL` +Examples: +- Single branch: `["android-15.0.0_r36"]` +- Multiple branches: `["android-15.0.0_r36","android-16.0.0_r3"]` + +Usage notes: +- Keep branch names explicit in the JSON list. +- Adding a branch adds it to desired seeding state. +- Removing a branch/tag from the list removes it from desired seeding state. + +### `GOOGLE_ABFS_TERRAFORM_GIT_URL` The URL for Google Terraform modules for ABFS. -### `TERRAFORM_GIT_VERSION` +### `GOOGLE_ABFS_TERRAFORM_VERSION` + +The git tag or sha1 for Google Terraform modules for ABFS. Default is `v0.10.0`. + +### `UPLOADER_MANIFEST_SCHEME` + +Optional. + +Purpose: set the URL scheme used by uploaders when connecting to the manifest server. + +Format: string (for example `https` or `http`). + +Default: `https`. + +### `ABFS_EXTRA_PARAMS` + +Optional. + +Purpose: pass advanced ABFS runtime flags for uploader instances. + +Format: JSON array of strings, e.g. `["--flag=value","--other-flag"]`. + +Default: `[]`. + +### `ABFS_GERRIT_UPLOADER_EXTRA_PARAMS` + +Optional. + +Purpose: pass advanced flags specifically to the `gerrit upload-daemon` subcommand. + +Format: JSON array of strings. + +Default: `[]`. + +### `ABFS_ENABLE_GIT_LFS` + +Optional. + +Purpose: enable Git LFS support in uploader workflows when repositories use large file storage. + +Format: boolean (`true` or `false`). + +Default: `false`. + +### `PRE_START_HOOKS` + +Optional. + +Purpose: provide custom pre-start scripts for environment-specific setup before uploader runtime starts. + +Format: absolute local directory path string. + +Default: empty value in Jenkins, which results in Terraform module default `null` (no hooks). + +Behavior: +- The directory is passed to Terraform as `pre_start_hooks`. +- Hook scripts are copied/mounted by the uploader module and executed during pre-start. -The sha1 for Google Terraform modules for ABFS. +Recommended usage: +- Keep scripts idempotent and non-interactive. +- Keep scripts short and deterministic to avoid startup delays. +- Use executable scripts and a predictable naming convention/order. ### `ABFS_COS_IMAGE_REF` Defines the ABFS Containerized OS images used on server and uploader instances. @@ -112,11 +180,11 @@ These are as follows: - `CLOUD_REGION` - The GCP project region. Important for bucket, registry paths used in pipelines. -- `HORIZON_GIT_URL` - - The URL to the Horizon SDV Git repository. +- `HORIZON_SCM_URL` + - The URL to the Horizon SDV git repository. -- `HORIZON_GIT_BRANCH` - - The branch name the job will be configured for from `HORIZON_GIT_URL`. +- `HORIZON_SCM_BRANCH` + - The branch name the job will be configured for from `HORIZON_SCM_URL`. - `JENKINS_SERVICE_ACCOUNT` - Service account to use for pipelines. Required to ensure correct roles and permissions for GCP resources. diff --git a/docs/workloads/android/environment/cf_instance_template.md b/docs/workloads/android/environment/cf_instance_template.md index ac7e50db..3dda1916 100644 --- a/docs/workloads/android/environment/cf_instance_template.md +++ b/docs/workloads/android/environment/cf_instance_template.md @@ -2,8 +2,10 @@ ## Table of contents - [Introduction](#introduction) +- [Packer and Startup Files](#packer-and-startup-files) - [Prerequisites](#prerequisites) - [Environment Variables/Parameters](#environment-variables) +- [Private repo and branch (e.g. horizon/main) — artifacts and Jenkins GCE](#private-repo-and-branch-eg-horizonmain--artifacts-and-jenkins-gce) - [Example Usage](#examples) - [System Variables](#system-variables) @@ -17,7 +19,7 @@ Users may select from standard machine types or create custom machine types. If - `CUSTOM_CPUS` - `CUSTOM_MEMORY` -During the process of creating an instance template, this pipeline also creates a custom image which is referenced by the created instance template. This image is created using the same naming convention as the instance template. +During the process of creating an instance template, this pipeline also creates a custom image which is referenced by the created instance template. The image is baked with Packer and then used by `gcloud` to create the final instance template. This image is created using the same naming convention as the instance template. For example: @@ -32,6 +34,14 @@ The following gcloud commands can be used to view images and instance templates: Important: This pipeline may not be run concurrently - this is to avoid clashes with temporary artifacts the job creates in order to produce the Cuttlefish instance template. +### Pipeline execution stages + +The script command interface uses three primary stages: + +- `1`: Build image with Packer and create/update the instance template. +- `2`: Refresh SSH key metadata on a **new** instance template revision (no Packer image rebuild; template resource is recreated with updated `jenkins-authorized-key` / related metadata). +- `3`: Delete generated instance/template/image artifacts. + ### References - [Cuttlefish Virtual Devices](https://source.android.com/docs/devices/cuttlefish) for use with [CTS](https://source.android.com/docs/compatibility/cts) and emulators. @@ -39,6 +49,38 @@ The following gcloud commands can be used to view images and instance templates: - [Compatibility Test Suite downloads](https://source.android.com/docs/compatibility/cts/downloads) - [Compute Instance Templates](https://cloud.google.com/sdk/gcloud/reference/compute/instance-templates/create) +## Packer and Startup Files + +The Cuttlefish image/template flow uses the following files together: + +- `workloads/android/pipelines/environment/cf_instance_template/packer/cuttlefish.pkr.hcl` + - Main Packer template. + - Defines the temporary build VM source image/machine/network. + - Copies provisioning scripts and runs provisioning steps. + - Produces the final GCE disk image used by the CF instance template. + +- `workloads/android/pipelines/environment/cf_instance_template/packer/provision_cf_host.sh` + - Script executed by Packer on the temporary build VM. + - Calls `cf_host_initialise.sh` to install/configure Cuttlefish host tooling. + - Ensures default user SSH bootstrap content is present during image bake. + +- `workloads/android/pipelines/environment/cf_instance_template/cf_host_initialise.sh` + - Performs host-side setup used by the Packer provisioning phase. + - Installs dependencies, builds/install Cuttlefish packages, and prepares host runtime. + +- `workloads/android/pipelines/environment/cf_instance_template/startup/refresh_authorized_keys.sh` + - Runtime startup script attached via instance template metadata. + - Runs when a VM boots from the template and rewrites **`authorized_keys` for the VM login user** (see below). + - Reads the public key from instance metadata attribute `jenkins-authorized-key` on every boot. + - Reads the target account from instance metadata attribute **`jenkins-user`** (historical name). If `jenkins-user` is empty, the script defaults to `jenkins`. + - The file updated is always **`/home//.ssh/authorized_keys`** — **not** necessarily `/home/jenkins/...` if the template was built with a different `DEFAULT_USER`. + +**VM SSH user (Cuttlefish GCE agent) vs Docker image user:** Jenkins CasC (`values-jenkins.yaml` / `jenkins-init.yaml`) configures the GCE plugin to SSH as **`jenkins`** with `remoteFs` `/home/jenkins`. The Packer/template pipeline therefore defaults **`DEFAULT_USER` to `jenkins`** in `cf_create_instance_template.sh` so the baked image, instance metadata (`jenkins-user`), and Jenkins agree. That is **independent** of the non-root user in the AAOS **Docker** image (`docker_image_template/Dockerfile`, typically `builder`), which only applies inside Kubernetes build pods. + +In short: **Packer files create the immutable image**, while the **startup script updates SSH key material at boot** so key rotation does not require rebuilding the image. + +When the Jenkins SSH key rotates, use `UPDATE_SSH_AUTHORIZED_KEYS=true` to republish the instance template with updated metadata (including `jenkins-authorized-key`). That path **does not** run a new Packer bake, but it **recreates** the instance template resource (same as a full template publish step), not an in-place metadata patch on an existing template API object. + ## Prerequisites One-time setup requirements. @@ -62,9 +104,9 @@ If using your own repository, and it a private repository, ensure `REPO_USERNAME This defines the branch/tag to use from `ANDROID_CUTTLEFISH_REPO_URL`, e.g. - `main` - the main working branch of `android-cuttlefish` -- `v1.27.0` - the latest tagged version. +- `v1.41.0` - the latest tagged version. - `horizon/main` - a private repository fork of `main` -- `horizon/v1.35.0` - a private fork of tag `v1.35.0` +- `horizon/v1.41.0` - a private fork of tag `v1.41.0` User may define any valid version so long as that version contains `tools/buildutils/build_packages.sh` which is a dependency for these scripts. @@ -100,47 +142,43 @@ If user is deleting a uniquely-created instance template (i.e. name specified by - `DELETE`: This ensures the instance template, disk image and VM instance are deleted. - `Build` : trigger build to delete all artifacts. -### `REPO_USERNAME` +### `UPDATE_SSH_AUTHORIZED_KEYS` -Required if using a private repository defined in `ANDROID_CUTTLEFISH_REPO_URL`. +Republishes the instance template with an updated `jenkins-authorized-key` (and related fields) using the public key derived from `SSH_PRIVATE_KEY_NAME`. The implementation **recreates** the instance template; it does **not** run Packer. -### `REPO_PASSWORD` +Use this when rotating the Jenkins SSH key and you need new VMs created from the template to pick up the new key without rebuilding the Packer image. -Required if using a private repository defined in `ANDROID_CUTTLEFISH_REPO_URL`. +- `UPDATE_SSH_AUTHORIZED_KEYS`: set to `true` +- `DELETE`: keep `false` +- `ANDROID_CUTTLEFISH_REVISION` or `CUTTLEFISH_INSTANCE_NAME`: set to target template +- `Build`: triggers stage 2 (template republish; no image bake) -### `ANDROID_CUTTLEFISH_POST_COMMAND` +### `SSH_PRIVATE_KEY_NAME` -Command to run in the `ANDROID_CUTTLEFISH_REPO_URL` defined repo. e.g. -- To fix the netsimd build issues with cxxbridge: - - `git cherry-pick 78b66377` -- Replace stale repos cuttlefish may be using, such as old kernel.org repos that have been deleted: - - `sed -i 's|https://git.kernel.org/pub/scm/linux/kernel/git/jaegeuk/f2fs-tools|https://github.com/jaegeuk/f2fs-tools|g' base/cvd/MODULE.bazel` +Jenkins credential name of the private SSH key used by the pipeline. -### `ANDROID_CUTTLEFISH_PREBUILT` +The pipeline derives the matching public key and injects it into instance template metadata (`jenkins-authorized-key`). +At VM boot, the startup script writes that key to **`/home//.ssh/authorized_keys`**, where **`jenkins-user`** instance metadata is set from **`DEFAULT_USER`** when the template is created (default **`jenkins`** in `cf_create_instance_template.sh`). If you change `DEFAULT_USER`, you must align Jenkins CasC (`runAsUser`, `remoteFs`, and the SSH credential **username**) and rebuild the Packer image so the account exists on disk. -Users have the option to build cuttlefish from scratch, ie. from [android-cuttlefish.git](https://github.com/google/android-cuttlefish.git) repository. Alternatively they may choose to install Google prebuilt versions of cuttlefish. +### `DEFAULT_USER` -Disabled: build and install from repo. -Enabled: download and install Google prebuilt versions. +Unix account created on the Cuttlefish VM during the Packer bake and propagated to instance template metadata as **`jenkins-user`**. Default in `cf_create_instance_template.sh` is **`jenkins`**, matching the Cuttlefish GCE cloud configuration in GitOps. Override only when you intentionally change CasC and the SSH credential to the same username; it is **not** tied to the Docker image `ARG USER` used by the CF Instance Template build pod. -Note: this is only applicable to `ANDROID_CUTTLEFISH_REVISION` `main` branch currently, and if packages are not found it will default to building cuttlefish from scratch. +### `REPO_USERNAME` -### `VM_INSTANCE_CREATE` +Required if using a private repository defined in `ANDROID_CUTTLEFISH_REPO_URL`. -**Enable Stopped VM Instance Creation** +### `REPO_PASSWORD` -If enabled, this job will create a Cuttlefish VM instance from the final instance template. It will be placed in stop -state after creation. This is provided for development testing and debugging. +Required if using a private repository defined in `ANDROID_CUTTLEFISH_REPO_URL`. -This would allow developers to: -- Connect to the instance directly -- Run tests on the instance manually, bypassing Jenkins +### `ANDROID_CUTTLEFISH_POST_COMMAND` -**Important:** -- Be aware that creating this instance may incur additional costs for your project. -- Enable this only for instance templates created for developement purposes that are created with a well defined `CUTTLEFISH_INSTANCE_NAME`. -- Set `MAX_RUN_DURATION` to 0 to ensure VM instance is never deleted on runtime expiry. -- It is advisable to `DELETE` these development instances when testing is completed. +Command to run in the `ANDROID_CUTTLEFISH_REPO_URL` defined repo. e.g. +- To fix the netsimd build issues with cxxbridge: + - `git cherry-pick 78b66377` +- Replace stale repos cuttlefish may be using, such as old kernel.org repos that have been deleted: + - `sed -i 's|https://git.kernel.org/pub/scm/linux/kernel/git/jaegeuk/f2fs-tools|https://github.com/jaegeuk/f2fs-tools|g' ./base/cvd/build_external/f2fs_tools/f2fs_tools.MODULE.bazel` ### `MACHINE_TYPE` @@ -186,9 +224,12 @@ User may disable by setting the value to 0, but they must be aware of any costs ### `JAVA_VERSION` -Specify the version of Java to install (`openjdk-17-jdk-headless`). +Exact **Debian/Ubuntu apt package name** for the JDK (no hidden fallbacks). Examples: **`temurin-21-jdk`** (Eclipse Temurin), **`openjdk-21-jdk-headless`** (distro OpenJDK). Match the Java major to your Jenkins controller (e.g. **2.555+** agents need **21**). + +- **x86 Jenkins job** default: **`temurin-21-jdk`**. If the package name starts with **`temurin-`**, provisioning adds the **Adoptium** apt repo, then runs **`apt install ${JAVA_VERSION}`**. +- **ARM64 Jenkins job** default: **`openjdk-21-jdk-headless`** (Ubuntu repos). Use **`temurin-21-jdk`** there only if you want Temurin (Adoptium repo is added the same way). -Must be OpenJDK and headless to avoid installation issues with various operating system versions. +Build VMs need outbound **HTTPS** to distro mirrors and, for Temurin, **packages.adoptium.net**. ### `OS_VERSION` @@ -243,6 +284,73 @@ Region of the instance to create. Leave black to use the default platform region Region of the instance to create. Leave black to use the default platform zone. +## Private repo and branch (e.g. `horizon/main`) — artifacts and Jenkins GCE + +Use this when **`ANDROID_CUTTLEFISH_REPO_URL`** points to a **private** fork (or mirror) and **`ANDROID_CUTTLEFISH_REVISION`** is a branch such as **`horizon/main`**. + +### 1. Jenkins job parameters (CF Instance Template) + +| Parameter | Example | Purpose | +|-----------|---------|---------| +| `ANDROID_CUTTLEFISH_REPO_URL` | `https://github.com/your-org/android-cuttlefish.git` | Clone URL for Cuttlefish sources (HTTPS is typical for `REPO_*` credentials). | +| `ANDROID_CUTTLEFISH_REVISION` | `horizon/main` | Branch or tag to `git checkout` during the image bake. | +| `REPO_USERNAME` | service account or PAT user | Required for **private** HTTPS clones. | +| `REPO_PASSWORD` | PAT or password | Use a credential with **read** access to the repo. | +| `CUTTLEFISH_INSTANCE_NAME` | *(empty)* | Leave empty to **derive** the VM/template name from the revision (see below), or set an explicit name starting with `cuttlefish-vm-`. | + +Run stage **`1`** (normal build) with **`DELETE=false`** unless you are deleting artifacts. + +### 2. What gets created in GCP (auto-derived name from `horizon/main`) + +Naming follows `cf_create_instance_template.sh`: the revision string is sanitized for GCE (`.` removed, **`/` → `-`**), then prefixed with `cuttlefish-vm-` when `CUTTLEFISH_INSTANCE_NAME` is left at the default `cuttlefish-vm`. + +For **`ANDROID_CUTTLEFISH_REVISION=horizon/main`** and default instance naming: + +| Resource | Name | +|----------|------| +| Cuttlefish “version” token | `horizon-main` (from `horizon/main`) | +| Logical / VM prefix | **`cuttlefish-vm-horizon-main`** | +| Disk image | **`image-cuttlefish-vm-horizon-main`** | +| Instance template | **`instance-template-cuttlefish-vm-horizon-main`** | + +Verify after the job: + +```bash +gcloud compute instance-templates list | grep cuttlefish-vm-horizon-main +gcloud compute images list | grep image-cuttlefish-vm-horizon-main +``` + +### 3. Wire Jenkins GCE Cloud (GitOps — preferred) + +Test jobs (CVD Launcher, CTS Execution, etc.) provision agents via the **Google Compute Engine** plugin using a **label** that must match a **cloud** whose **`template`** URL points at your new instance template. + +1. Open **`gitops/workloads/values-jenkins.yaml`** (CasC for Jenkins). +2. Under **`jenkins:` → `controller:` → `JCasC` → `configScripts:`** (or equivalent), find the **`clouds:`** list and the existing **`computeEngine`** entries (e.g. `cuttlefish-vm-main`). +3. **Add a new** `- computeEngine:` block by **copying** an existing Cuttlefish entry and changing only what identifies the template and labels: + - **`cloudName`**: e.g. **`cuttlefish-vm-horizon-main`** (this is the **cloud id** in Jenkins). + - **`labelString`** / **`labels`** / **`namePrefix`**: use the **same** string as `cloudName` (e.g. **`cuttlefish-vm-horizon-main`**), consistent with existing entries. + - **`template`**: set to the full instance-template self-link for **`instance-template-cuttlefish-vm-horizon-main`** in your project (same pattern as siblings — only the template **name suffix** changes). + - Keep **`zone`**, **`region`**, **`projectId`**, **`credentialsId`**, **`sshConfiguration`**, **`remoteFs`**, **`runAsUser`** aligned with other Cuttlefish clouds unless you intentionally differ. +4. Merge and deploy via your normal **GitOps** process so CasC reapplies Jenkins configuration. + +After sync, **Manage Jenkins → Clouds** should list the new cloud (e.g. `cuttlefish-vm-horizon-main`). + +### 4. Wire Jenkins GCE Cloud (UI only — not source of truth) + +You can **verify** or **prototype** under **Manage Jenkins → Clouds →** (Google Compute Engine plugin): + +- Add or edit a cloud so the **Instance template** points at `instance-template-cuttlefish-vm-horizon-main` and the **labels** match what test jobs use. + +**Caution:** Manual UI edits are **overwritten** on the next CasC sync unless the same settings exist in **`values-jenkins.yaml`**. Treat GitOps as authoritative for production. + +### 5. Point test jobs at the new cloud + +Jobs that run on Cuttlefish VMs expose **`JENKINS_GCE_CLOUD_LABEL`** (see job DSL under `workloads/android/pipelines/tests/*/groovy/job.groovy`). Set it to the **label** that matches the cloud — typically the same as **`cloudName`** / **`labelString`**, e.g.: + +- **`JENKINS_GCE_CLOUD_LABEL=cuttlefish-vm-horizon-main`** + +Default parameter values may still reference `cuttlefish-vm-main`; change per run or update the job default in **Jenkins** / **Seed job** / **CasC** if this template becomes the platform standard. + ## Example Usage If user wishes to create a temporary test instance to work with, then they can do so as follows from Jenkins: @@ -250,7 +358,6 @@ If user wishes to create a temporary test instance to work with, then they can d - `ANDROID_CUTTLEFISH_REVISION`: choose the version you wish to build the template from - `CUTTLEFISH_INSTANCE_NAME` : provide a name, starting with cuttlefish-vm, e.g. `cuttlefish-vm-test-instance-v110.` - `MAX_RUN_DURATION` : set to 0 to avoid instance being deleted after this time. -- `VM_INSTANCE_CREATE` : Enable this option so that the instance template will create a VM instance for user to start, connect to and work with. - `Build` Once they have finished with the instances, they should delete to avoid excessive costs. @@ -282,11 +389,11 @@ These are as follows: - `HORIZON_DOMAIN` - The URL domain which is required by pipeline jobs to derive URL for tools and GCP. -- `HORIZON_GIT_URL` - - The URL to the Horizon SDV Git repository. +- `HORIZON_SCM_URL` + - The URL to the Horizon SDV git repository. -- `HORIZON_GIT_BRANCH` - - The branch name the job will be configured for from `HORIZON_GIT_URL`. +- `HORIZON_SCM_BRANCH` + - The branch name the job will be configured for from `HORIZON_SCM_URL`. - `JENKINS_SERVICE_ACCOUNT` - Service account to use for pipelines. Required to ensure correct roles and permissions for GCP resources. diff --git a/docs/workloads/android/environment/delete_cf_vm_instance.md b/docs/workloads/android/environment/delete_cf_vm_instance.md index 654792ea..3d2fa153 100644 --- a/docs/workloads/android/environment/delete_cf_vm_instance.md +++ b/docs/workloads/android/environment/delete_cf_vm_instance.md @@ -47,11 +47,11 @@ These are as follows: - `CLOUD_ZONE` - The GCP project zone. Important for bucket, registry paths used in pipelines. -- `HORIZON_GIT_URL` - - The URL to the Horizon SDV Git repository. +- `HORIZON_SCM_URL` + - The URL to the Horizon SDV git repository. -- `HORIZON_GIT_BRANCH` - - The branch name the job will be configured for from `HORIZON_GIT_URL`. +- `HORIZON_SCM_BRANCH` + - The branch name the job will be configured for from `HORIZON_SCM_URL`. - `JENKINS_SERVICE_ACCOUNT` - Service account to use for pipelines. Required to ensure correct roles and permissions for GCP resources. diff --git a/docs/workloads/android/environment/delete_mtkc_testbench.md b/docs/workloads/android/environment/delete_mtkc_testbench.md index f3577947..bc2abe75 100644 --- a/docs/workloads/android/environment/delete_mtkc_testbench.md +++ b/docs/workloads/android/environment/delete_mtkc_testbench.md @@ -47,11 +47,11 @@ These are as follows: - `CLOUD_ZONE` - The GCP project zone. Important for bucket, registry paths used in pipelines. -- `HORIZON_GIT_URL` - - The URL to the Horizon SDV Git repository. +- `HORIZON_SCM_URL` + - The URL to the Horizon SDV git repository. -- `HORIZON_GIT_BRANCH` - - The branch name the job will be configured for from `HORIZON_GIT_URL`. +- `HORIZON_SCM_BRANCH` + - The branch name the job will be configured for from `HORIZON_SCM_URL`. - `JENKINS_SERVICE_ACCOUNT` - Service account to use for pipelines. Required to ensure correct roles and permissions for GCP resources. diff --git a/docs/workloads/android/environment/dev_instance_build.md b/docs/workloads/android/environment/dev_instance_build.md index e2209469..b9af96ff 100644 --- a/docs/workloads/android/environment/dev_instance_build.md +++ b/docs/workloads/android/environment/dev_instance_build.md @@ -97,11 +97,11 @@ These are as follows: - `HORIZON_DOMAIN` - The URL domain which is required by pipeline jobs to derive URL for tools and GCP. -- `HORIZON_GIT_URL` - - The URL to the Horizon SDV Git repository. +- `HORIZON_SCM_URL` + - The URL to the Horizon SDV git repository. -- `HORIZON_GIT_BRANCH` - - The branch name the job will be configured for from `HORIZON_GIT_URL`. +- `HORIZON_SCM_BRANCH` + - The branch name the job will be configured for from `HORIZON_SCM_URL`. - `JENKINS_AAOS_BUILD_CACHE_STORAGE_PREFIX` - This identifies the Persistent Volume Claim (PVC) prefix that is used to provision persistent storage for build cache, ensuring efficient reuse of cached resources across builds. The default is [`pd-balanced`](https://cloud.google.com/compute/docs/disks/performance), which strikes a balance between optimal performance and cost-effectiveness. diff --git a/docs/workloads/android/environment/dev_instance_test.md b/docs/workloads/android/environment/dev_instance_test.md index ba57c6b7..ad00b8c6 100644 --- a/docs/workloads/android/environment/dev_instance_test.md +++ b/docs/workloads/android/environment/dev_instance_test.md @@ -22,6 +22,8 @@ One-time setup requirements. - Before running this pipeline job, ensure that the following template has been created by running the corresponding job: - Cuttlefish Instance template: `Android Workflows/Environment/CF Instance Template` + - The CF job now uses a Packer-based build flow for template creation. + - Script stage mapping is `1=build`, `2=ssh refresh`, `3=delete` (documented in `docs/workloads/android/environment/cf_instance_template.md`). - VM instances used for test must exist. ## Environment Variables/Parameters @@ -61,9 +63,9 @@ These are as follows: - `HORIZON_DOMAIN` - The URL domain which is required by pipeline jobs to derive URL for tools and GCP. -- `HORIZON_GIT_URL` - - The URL to the Horizon SDV Git repository. +- `HORIZON_SCM_URL` + - The URL to the Horizon SDV git repository. -- `HORIZON_GIT_BRANCH` - - The branch name the job will be configured for from `HORIZON_GIT_URL`. +- `HORIZON_SCM_BRANCH` + - The branch name the job will be configured for from `HORIZON_SCM_URL`. diff --git a/docs/workloads/android/environment/docker_image_template.md b/docs/workloads/android/environment/docker_image_template.md index 5275a236..67443cb0 100644 --- a/docs/workloads/android/environment/docker_image_template.md +++ b/docs/workloads/android/environment/docker_image_template.md @@ -58,6 +58,20 @@ Define `latest` if wishing to use the latest available version. Version of `kubectl` to install. The version is typically `1:${GCLOUD_CLI_VERSION}`. Define `latest` if wishing to use the latest available version. +### `PACKER_VERSION` + +Version of `packer` to install in the builder image. +Set this to a pinned supported release (recommended), or `latest` if you want the newest available package. + +### `ENABLE_GEMINI_AI_ASSISTANT` + +Enable Gemini AI to support in diagnosis of build and test failures. + +### `GEMINI_CLI_VERSION` + +The version of gemini-cli to be installed. +Run `npm view @google/gemini-cli versions` for a full list of valid versions. + ## SYSTEM VARIABLES There are a number of system environment variables that are unique to each platform but required by Jenkins build, test and environment pipelines. @@ -75,11 +89,11 @@ These are as follows: - `CLOUD_REGION` - The GCP project region. Important for bucket, registry paths used in pipelines. -- `HORIZON_GIT_URL` - - The URL to the Horizon SDV Git repository. +- `HORIZON_SCM_URL` + - The URL to the Horizon SDV git repository. -- `HORIZON_GIT_BRANCH` - - The branch name the job will be configured for from `HORIZON_GIT_URL`. +- `HORIZON_SCM_BRANCH` + - The branch name the job will be configured for from `HORIZON_SCM_URL`. - `JENKINS_SERVICE_ACCOUNT` - Service account to use for pipelines. Required to ensure correct roles and permissions for GCP resources. diff --git a/docs/workloads/android/environment/warm_build_caches.md b/docs/workloads/android/environment/warm_build_caches.md index e9804599..7176ef30 100644 --- a/docs/workloads/android/environment/warm_build_caches.md +++ b/docs/workloads/android/environment/warm_build_caches.md @@ -1,113 +1,110 @@ -# Android Builds +# Warm Build Caches -## Table of contents -- [Introduction](#introduction) -- [Prerequisites](#prerequisites) -- [Environment Variables/Parameters](#environment-variables) - * [Targets](#targets) -- [System Variables](#system-variables) - -## Introduction - -This job is used to create pre-warmed persistent volumes with build caches used to improve performance for Android builds. - -Run the jobs in parallel to ensure each build job has clean persistent volume. - -## Prerequisites - -One-time setup requirements. +Jenkins job **`Android/Environment/Warm Build Caches`** pre-populates AAOS build cache persistent storage by running a fixed sequence of AAOS Builder–style builds for a chosen manifest and revision. -- Before running this pipeline job, ensure that the following template has been created by running the corresponding job: - - Docker image template: `Android Workflows/Environment/Docker Image Template` -- Ensure Persistent Volume Claims (PVCs) have been deleted. +**Definitions:** `workloads/android/pipelines/environment/warm_build_caches/groovy/job.groovy` (parameters, job UI), `workloads/android/pipelines/environment/warm_build_caches/Jenkinsfile` (pipeline). -## Environment Variables/Parameters - -**Jenkins Parameters:** Defined in the groovy job definition `groovy/job.groovy`. - -### `AAOS_GERRIT_MANIFEST_URL` - -This provides the URL for the Android repo manifest. Such as: - -- https://dev.horizon-sdv.com/gerrit/android/platform/manifest (default Horizon manifest) -- https://android.googlesource.com/platform/manifest (Google OSS manifest) +## Table of contents -### `AAOS_REVISION` +- [Introduction](#introduction) +- [Prerequisites](#prerequisites) +- [Parameters](#parameters) +- [Pipeline behavior](#pipeline-behavior) +- [Lunch targets and revision prefixes](#lunch-targets-and-revision-prefixes) +- [Kubernetes agent](#kubernetes-agent) +- [System variables](#system-variables) -The Android revision, i.e. branch or tag to build. Tested versions are below: +## Introduction -- `horizon/android-14.0.0_r30` (ap1a) -- `horizon/android-15.0.0_r36` (bp1a) -- `horizon/android-16.0.0_r3` (bp3a - default) -- `android-14.0.0_r30` (ap1a) -- `android-15.0.0_r36` (bp1a) -- `android-16.0.0_r3` (bp3a) +The job accelerates later AAOS builds by filling the build cache PVC before normal developer or CI builds attach to it. Each run uses an **ephemeral** `aaos-cache` volume (see [Kubernetes agent](#kubernetes-agent)); the storage class is keyed off the Android pool (`ANDROID_VERSION` / inferred SDK version). -### `ANDROID_VERSION` +Run multiple warm-cache jobs **in parallel** if you need several warm PVs (respect cluster limits, e.g. Kubernetes caps). -This specifies which build disk pool to use for the build cache. If `default` then the job will determine the pool based on `AAOS_REVISION`. +## Prerequisites -### `ARCHIVE_ARTIFACTS` +- **Docker image:** Build image must exist (e.g. from **Android Workflows → Environment → Docker Image Template**), matching what AAOS Builder uses (`ANDROID_BUILD_DOCKER_ARTIFACT_PATH_NAME`). +- **Empty build PVs:** Per job UI, **delete existing build cache PVCs** before running so warm runs start from a defined state (see groovy description). +- **AOSP mirror (optional):** If **`USE_LOCAL_AOSP_MIRROR`** is enabled, mirror PVC and **Android Workflows → Environment → Mirror** setup must exist or the job will fail. -Option to archive the build artifacts to bucket. +## Parameters -### `USE_LOCAL_AOSP_MIRROR` +Defined in **`groovy/job.groovy`** (Jenkins UI). -If checked, the build will use the AOSP Mirror setup in your GCP project to fetch Android source code during `repo sync`. -**Note:** -- The AOSP Mirror must be setup prior to running this job. If not setup, the job will fail. -- The setup jobs are in folder `Android Workflows -> Environment -> Mirror`. +| Parameter | Description | +|-----------|-------------| +| **`AAOS_GERRIT_MANIFEST_URL`** | Repo manifest URL (default: Horizon Gerrit manifest). | +| **`AAOS_REVISION`** | Branch or tag (default in groovy: `horizon/android-16.0.0_r3`). Must contain `android-14`, `android-15`, or `android-16` so the job can infer the SDK / storage pool when **`ANDROID_VERSION`** is `default` (see **`Jenkinsfile`** `getAndroidVersion()`). | +| **`ANDROID_VERSION`** | `default` \| `14` \| `15` \| `16`. Selects the build-cache disk pool / `STORAGE_CLASS_SUFFIX` (`android-${version}`). With **`default`**, the job derives the version from **`AAOS_REVISION`**. | +| **`GERRIT_REPO_SYNC_JOBS`** | Parallel jobs for `repo sync` (default from seed / `${REPO_SYNC_JOBS}`). | +| **`AAOS_CLEAN`** | `NO_CLEAN` \| `CLEAN_BUILD` \| `CLEAN_ALL`. Controls clean behavior across stages (see [Pipeline behavior](#pipeline-behavior)). | +| **`ARCHIVE_ARTIFACTS`** | If true, artifacts are stored via GCS (`ARTIFACT_STORAGE_SOLUTION=GCS_BUCKET` in **`Jenkinsfile`**); if false, storage is effectively noop for that flag. | +| **`USE_LOCAL_AOSP_MIRROR`** | Mounts the preset Filestore mirror PVC read-only and sets mirror paths for `repo sync`. | +| **`AOSP_MIRROR_DIR_NAME`** | Mirror directory on Filestore (required when mirror is enabled). | -### `AOSP_MIRROR_DIR_NAME` +### `AAOS_CLEAN` (aligned with `Jenkinsfile`) -This defines the directory name on the Filestore volume where the Mirror is located. -**Note:** -- This is required if `USE_LOCAL_AOSP_MIRROR` is checked. -- e.g. If you provided `my-mirror` when creating the mirror, provide the same value here. +- **`CLEAN_ALL`:** Applied only to the **first** build stage (**Build: aosp_cf_x86_64_auto**) via `AAOS_CLEAN_FIRST_STAGE`. Later stages use **`CLEAN_BUILD`**, not another full **`CLEAN_ALL`**. +- **`CLEAN_BUILD`:** Same mode on all stages (`AAOS_CLEAN_LATER_STAGES` matches). +- **`NO_CLEAN`:** Same on all stages. -## SYSTEM VARIABLES +## Pipeline behavior -There are a number of system environment variables that are unique to each platform but required by Jenkins build, test and environment pipelines. +Single top-level stage **Start Build VM Instance** runs these stages in order: -These are defined in Jenkins CasC `values-jenkins.yaml` and can be viewed in Jenkins UI under `Manage Jenkins` -> `System` -> `Global Properties` -> `Environment variables`. +1. **Initialise** — Records build description, resolves **`ANDROID_BUILD_ID`** prefix from **`AAOS_REVISION`** (see table below), sets **`AAOS_CLEAN_FIRST_STAGE`** / **`AAOS_CLEAN_LATER_STAGES`**, writes **`build_cache_volume.txt`**, configures Gerrit git credentials, archives `build_cache*.txt`. +2. **Build: aosp_cf_x86_64_auto** — First full warm; uses **`AAOS_CLEAN_FIRST_STAGE`**. +3. **Build: aosp_cf_arm64_auto** — Uses **`AAOS_CLEAN_LATER_STAGES`**. +4. **Build: sdk_car_x86_64** — Sets **`ANDROID_VERSION`** to the resolved SDK version; runs **`aaos_avd_sdk.sh`** (allowed to fail: `|| true`); uses later-stage clean mode. +5. **Build: sdk_car_arm64** — Same pattern as sdk_car_x86_64. +6. **Build: aosp_tangorpro_car** — Runs **only if** **`AAOS_REVISION`** does **not** contain **`android-16.0.0_r`** (see `when { expression { ... } }` in **`Jenkinsfile`**). -These are as follows: +Each build stage invokes **`aaos_initialise.sh`**, **`aaos_build.sh`**, and **`aaos_storage.sh`** (and **`aaos_avd_sdk.sh`** where shown), with **`AAOS_LUNCH_TARGET`** and **`AAOS_BUILD_NUMBER`** set per stage. Stages use **`catchError`** so a failure is recorded but later stages may still run depending on Jenkins behavior. -- `ANDROID_BUILD_BUCKET_ROOT_NAME` - - Defines the name of the Google Storage bucket that will be used to store build and test artifacts +Gerrit credentials: **Initialise** and **sdk_car_x86_64** use **`GERRIT_CREDENTIALS_ID`**; other build stages use the fixed credential id **`jenkins-gerrit-http-password`** (as in **`Jenkinsfile`**). -- `ANDROID_BUILD_DOCKER_ARTIFACT_PATH_NAME` - - Defines the registry path where the Docker image used by builds, tests and environments is stored. +## Lunch targets and revision prefixes -- `CLOUD_PROJECT` - - The GCP project, unique to each project. Important for bucket, registry paths used in pipelines. +**`AAOS_LUNCH_TARGET`** per stage (suffix is **`${ANDROID_BUILD_ID}userdebug`**): -- `CLOUD_REGION` - - The GCP project region. Important for bucket, registry paths used in pipelines. +| Stage | Lunch target base | +|-------|-------------------| +| aosp_cf_x86_64_auto | `aosp_cf_x86_64_auto-…` | +| aosp_cf_arm64_auto | `aosp_cf_arm64_auto-…` | +| sdk_car_x86_64 | `sdk_car_x86_64-…` | +| sdk_car_arm64 | `sdk_car_arm64-…` | +| aosp_tangorpro_car | `aosp_tangorpro_car-…` | -- `CLOUD_ZONE` - - The GCP project zone. Important for bucket, registry paths used in pipelines. +**`ANDROID_BUILD_ID`** prefix (empty if no match), from **`Jenkinsfile`**: -- `GERRIT_CREDENTIALS_ID` - - The credential for access to Gerrit, required for build pipelines. +| If `AAOS_REVISION` contains | `ANDROID_BUILD_ID` | +|-----------------------------|--------------------| +| `android-14.0.0_r30` | `ap1a-` | +| `android-14.0.0_r74` | `ap2a-` | +| `android-15.0.0_r36` | `bp1a-` | +| `android-16.0.0_r3` | `bp3a-` | +| `android-16.0.0_r4` | `bp4a-` | -- `HORIZON_DOMAIN` - - The URL domain which is required by pipeline jobs to derive URL for tools and GCP. +If **`AAOS_REVISION`** does not match **`android-14`**, **`android-15`**, or **`android-16`**, the job fails in **`getAndroidVersion()`** when **`ANDROID_VERSION`** is **`default`**. -- `HORIZON_GIT_URL` - - The URL to the Horizon SDV Git repository. +## Kubernetes agent -- `HORIZON_GIT_BRANCH` - - The branch name the job will be configured for from `HORIZON_GIT_URL`. +From **`Jenkinsfile`** `environment` block: -- `JENKINS_AAOS_BUILD_CACHE_STORAGE_PREFIX` - - This identifies the Persistent Volume Claim (PVC) prefix that is used to provision persistent storage for build cache, ensuring efficient reuse of cached resources across builds. The default is [`pd-balanced`](https://cloud.google.com/compute/docs/disks/performance), which strikes a balance between optimal performance and cost-effectiveness. +- **Agent:** Kubernetes pod from **`POD_TEMPLATE`**: without mirror (**`kubernetesPodTemplateWithoutMirror`**) or with mirror (**`kubernetesPodTemplateWithMirror`**) when **`USE_LOCAL_AOSP_MIRROR`** is true. +- **Pool:** `nodeSelector` **`workloadLabel: android`**, toleration **`workloadType=android:NoSchedule`**, pod anti-affinity on **`aaos_pod`** so pods spread across nodes. +- **Container `builder`:** AAOS build image (`ANDROID_BUILD_DOCKER_ARTIFACT_PATH_NAME`), **privileged**, **`sleep`** 6h (no mirror) or 5h (mirror). +- **Resources:** `98000m` CPU, `180000Mi` memory (limits = requests). +- **Cache volume:** Ephemeral PVC **`aaos-cache`** mounted at **`/aaos-cache`**, **1000Gi**, storage class **`${JENKINS_AAOS_BUILD_CACHE_STORAGE_PREFIX}-${STORAGE_CLASS_SUFFIX}`** with **`STORAGE_CLASS_SUFFIX`** = `android-${SDK_ANDROID_VERSION}`. +- **Mirror (optional):** Extra read-only mount from **`MIRROR_PRESET_FILESTORE_PVC_NAME`** at **`MIRROR_PRESET_FILESTORE_PVC_MOUNT_PATH_IN_CONTAINER`**. -- `JENKINS_SERVICE_ACCOUNT` - - Service account to use for pipelines. Required to ensure correct roles and permissions for GCP resources. +## System variables -- `MIRROR_PRESET_FILESTORE_PVC_MOUNT_PATH_IN_CONTAINER` +Shared Jenkins / Horizon variables (CasC, global properties) used by this and other Android pipelines include: -- `MIRROR_PRESET_MIRROR_ROOT_SUBDIR_NAME` +- **`ANDROID_BUILD_BUCKET_ROOT_NAME`**, **`ANDROID_BUILD_DOCKER_ARTIFACT_PATH_NAME`** +- **`CLOUD_PROJECT`**, **`CLOUD_REGION`**, **`CLOUD_ZONE`** +- **`GERRIT_CREDENTIALS_ID`**, **`HORIZON_DOMAIN`**, **`HORIZON_SCM_URL`**, **`HORIZON_SCM_BRANCH`** +- **`JENKINS_AAOS_BUILD_CACHE_STORAGE_PREFIX`**, **`JENKINS_SERVICE_ACCOUNT`** +- **`MIRROR_PRESET_FILESTORE_PVC_MOUNT_PATH_IN_CONTAINER`**, **`MIRROR_PRESET_MIRROR_ROOT_SUBDIR_NAME`**, **`MIRROR_PRESET_FILESTORE_PVC_NAME`** (and related mirror settings) -- `MIRROR_DIR_NAME` +See Jenkins **Manage Jenkins → System → Global properties** and `gitops/workloads/values-jenkins.yaml` for the authoritative list. \ No newline at end of file diff --git a/docs/workloads/android/guides/developer_guide.md b/docs/workloads/android/guides/developer_guide.md index 48b23e78..88096984 100644 --- a/docs/workloads/android/guides/developer_guide.md +++ b/docs/workloads/android/guides/developer_guide.md @@ -142,6 +142,8 @@ To build Android targets and utilise other pipeline jobs, it is mandatory to run This job generates the Google Compute Engine (GCE) instance templates required by test pipelines to provision Cuttlefish-ready and CTS-ready cloud instances. These instances are then used to launch [CVD](https://source.android.com/docs/devices/cuttlefish) and execute [CTS](https://source.android.com/docs/compatibility/cts) tests. +The CF Instance Template job now uses a Packer-based image build flow and creates templates from the baked image. Script stage mapping is `1=build`, `2=ssh refresh`, `3=delete`; see `docs/workloads/android/environment/cf_instance_template.md`. + **Prerequisites:** The `Docker Image Template` job must be completed before running this job. @@ -2021,7 +2023,7 @@ Gerrit triggers are based on a single project/repo build, i.e. build one compone Cuttlefish instance templates are instances pre-installed with Android cuttlefish debian host packages, Android 14, 15 and 16 CTS, together with other tools required to launch CVD and run CTS tests. There are two instances we have created ahead of time: -- `cuttlefish-vm-v1350` based on [android-cuttlefish.git v1.35.0 tag](https://github.com/google/android-cuttlefish/tree/v1.35.0) +- `cuttlefish-vm-v1410` based on [android-cuttlefish.git v1.41.0 tag](https://github.com/google/android-cuttlefish/tree/v1.41.0) - `cuttlefish-vm-main` based on [android-cuttlefish.git main branch](https://github.com/google/android-cuttlefish/tree/main) Users may wish to create newer versions as the android-cuttlefish repo is updated and new tagged versions appear. This section describes how to create the instance templates and configure the test jobs to use those instances. @@ -2029,7 +2031,7 @@ Users may wish to create newer versions as the android-cuttlefish repo is update > [!NOTE] > Instance templates take around 1 hour to create. This is because the install of android-cuttlefish takes a significant time, together with downloading and installing Android 14, 15 and 16 CTS takes around 25 minutes. The remaining time is based on GCP gcloud CLI commands, these commands can complete before the change is actually visible, therefore there are some mandatory delays included to ensure settling time of the gcloud CLI updates. > -> Refer to the OSS repo: [horizon-sdv](https://github.com/googlecloudplatform/horizon-sdv) `docs/workloads/environment/cf_instance_template.md` for more details. +> Refer to the OSS repo: [horizon-sdv](https://github.com/googlecloudplatform/horizon-sdv) `docs/workloads/android/environment/cf_instance_template.md` for more details.
Create Instance Template diff --git a/docs/workloads/android/tests/cts_execution.md b/docs/workloads/android/tests/cts_execution.md index 0f5f32f0..e3cf0987 100644 --- a/docs/workloads/android/tests/cts_execution.md +++ b/docs/workloads/android/tests/cts_execution.md @@ -2,8 +2,12 @@ ## Table of contents - [Introduction](#introduction) +- [CTS vs CVD Launcher (scope)](#cts-vs-cvd-launcher-scope) +- [Jenkins pipeline and shared library hooks](#jenkins-pipeline-and-shared-library-hooks) - [Prerequisites](#prerequisites) - [Environment Variables/Parameters](#environment-variables) + * [`ENABLE_GEMINI_AI_ASSISTANT` / Gemini prompts](#enable_gemini_ai_assistant) + * [`GEMINI_ANALYSE_ON_SUCCESS`](#gemini_analyse_on_success) - [Example Usage](#examples) - [System Variables](#system-variables) - [Known Issues](#known-issues) @@ -21,10 +25,27 @@ Note: - It allows users to keep the cuttlefish virtual devices alive for a certain amount of time after the CTS run has completed in order to facilitate debugging via MTK Connect. MTK Connect must be enabled for this option. - To view Test Results in Jenkins with CSS, you may wish to lower the [content security level](https://www.jenkins.io/doc/book/security/configuring-content-security-policy/) from `Script Console`, allowing the full HTML to be accessible, e.g. `System.setProperty("hudson.model.DirectoryBrowserSupport.CSP", "")` +### CTS vs CVD Launcher (scope) + +This job is the **CTS Execution** pipeline: it runs **Tradefed** against Cuttlefish virtual devices and publishes **Compatibility Test Suite** results (XML/HTML under `android-cts-results/` and `android-cts-results-html/`). When **Gemini AI Review** is enabled on a failed build, it uses **`preset: 'cts'`** and sequenced prompts that **prioritize failed tests from Tradefed** when suite artifacts exist, and **still analyze guest `kernel.log` and other CVD logs** for boot and bring-up issues (including when **devices never became healthy** and Tradefed left no usable summary—see [Gemini prompts and artifacts](#gemini-prompts-and-artifacts)). See `prompt/sequenced/README_SKILLS.md`. + +The **[CVD Launcher](cvd_launcher.md)** job is **not** a substitute for CTS: it does **not** run the Compatibility Test Suite. It exists to exercise **Cuttlefish runtime** (launch, MTK Connect, logs) without Tradefed. Use it when you need a **dedicated deep dive into CVD runtime issues**—guest `kernel.log`, host orchestration, boot failures, and related artifacts—without CTS test-result correlation. Its AI Review uses **`preset: 'cvd'`** and **guest-first** prompts tuned only for that scenario. If your question is purely “why did the virtual device fail to boot or misbehave at runtime,” CVD Launcher (or its documentation) is the clearer entry point; if your question is “which CTS tests failed and why,” use this CTS job. + **Resources:** Ensure you select appropriate values for `NUM_INSTANCES`, `VM_CPUS`, `VM_MEMORY_MB` that align with the VM instance used for test, ie `JENKINS_GCE_CLOUD_LABEL`. +### Jenkins pipeline and shared library hooks + +The job’s **`Jenkinsfile`** (`workloads/android/pipelines/tests/cts_execution/Jenkinsfile`) calls the shared library step **`cvdPipeline(...)`** from `cvd-pipeline-shared-library`. CTS-specific steps (list tests when in list-only mode, full CTS run after devices are up) are not inlined in the Jenkinsfile; they are supplied as **hook lists**: + +- **`preLaunchStages`** — runs in the **Pre-launch** stage **before** **Launch Virtual Devices** (e.g. list test plans/modules when `CTS_TEST_LISTS_ONLY` is true). +- **`postMtkConnectStages`** — runs in the **Post-MTK Connect** stage **after** a successful **MTK Connect to Virtual Devices** step (or after launch when MTK is disabled), e.g. **`CTS execution`** with tradefed, result archives, and HTML report publishing. + +Hook bodies are defined in **`ctsCvdPipelineHooks()`** (`workloads/common/jenkins/shared-libraries/cvd-pipeline-shared-library/vars/ctsCvdPipelineHooks.groovy`). The Jenkinsfile passes `preLaunchStages: ctsHooks.preLaunchStages` and `postMtkConnectStages: ctsHooks.postMtkConnectStages`. + +[CVD Launcher](cvd_launcher.md#jenkins-pipeline-and-shared-library) uses the same **`cvdPipeline`** without these hooks (Cuttlefish + MTK + keep-alive only). Full parameter and stage reference: **`workloads/common/jenkins/shared-libraries/cvd-pipeline-shared-library/vars/README.md`**. + ### References - [Cuttlefish Virtual Devices](https://source.android.com/docs/devices/cuttlefish) for use with [CTS](https://source.android.com/docs/compatibility/cts) and emulators. @@ -37,6 +58,8 @@ One-time setup requirements. - Before running this pipeline job, ensure that the following templates have been created by running the corresponding jobs: - Docker image template: `Android Workflows/Environment/Docker Image Template` - Cuttlefish instance template: `Android Workflows/Environment/CF Instance Template` + - The CF job now uses a Packer-based build flow for template creation. + - Script stage mapping is `1=build`, `2=ssh refresh`, `3=delete` (documented in `docs/workloads/android/environment/cf_instance_template.md`). - Must be rebuilt if using `CUTTLEFISH_INSTALL_WIFI` option, to ensure WiFi APK is stored with the image files. ## Environment Variables/Parameters @@ -149,6 +172,10 @@ Enable if user wishes to view devices via MTK Connect (e.g. to watch UI tests). When checked, the MTK Connect testbench is visible to everyone and can be shared. By default, testbenches are private and only visible to their creator and MTK Connect administrators. +### `MTK_CONNECT_TUNNEL_PORT` + +ADB tunnel **`caller.port`** for MTK Connect testbench creation (`workloads/common/mtk-connect/create-testbench.js`). Default **8555**; override if the port conflicts on the agent. Used when MTK Connect runs (`MTK_CONNECT_ENABLE`). Same behavior as documented for [CVD Launcher](cvd_launcher.md#mtk_connect_tunnel_port). + ### `CUTTLEFISH_KEEP_ALIVE_TIME` If wishing to debug HOST using MTK Connect, Cuttlefish VM instance must be allowed to continue to run. This timeout, in @@ -156,18 +183,52 @@ minutes, gives the tester time to keep the instance alive so they may work with It is only applicable when `MTK_CONNECT_ENABLE` is enabled. -### `CVD_ADDITIONAL_FLAGS` +### `CVD_COMMAND_LINE` -Append additional flags to `cvd` command, e.g. +Same semantics as in [CVD Launcher](cvd_launcher.md#cvd_command_line): default is the full `/usr/bin/cvd create …` line with shell placeholders `${NUM_INSTANCES}`, `${VM_CPUS}`, `${VM_MEMORY_MB}` and CI-oriented flags `--setupwizard_mode DISABLED`, `--enable_host_bluetooth false`, `--gpu_mode guest_swiftshader` (those values derive from the respective job parameters automatically); an empty value clears to the script default. Edit the parameter to change launch arguments. + +Optional `cvd` flags are **not** a separate parameter: add them **inside** the `CVD_COMMAND_LINE` string. Further example fragments (see [CVD Launcher — `CVD_COMMAND_LINE`](cvd_launcher.md#cvd_command_line) for context): -- `--setupwizard_mode DISABLED --enable_host_bluetooth false --gpu_mode guest_swiftshader` - `--display0=width=1920,height=1080,dpi=160` +- `--verbosity=DEBUG` + +### `ENABLE_GEMINI_AI_ASSISTANT` + +Enable Gemini **AI Review** in the shared `cvd-pipeline-shared-library` **Diagnostics** stage (`cvdPipeline`). The **AI Review** sub-stage runs only when **all** of these are true (see `cvdPipeline.groovy`): **`ENABLE_GEMINI_AI_ASSISTANT`** is **`true`**, the overall pipeline result is **`FAILURE`**, **`MTK_CONNECT_STAGE_FAILED`** is not **`true`**, and—because this job’s **`Jenkinsfile`** passes **`aiReview`** with **`requireCtsNotListOnly: true`**—**`CTS_TEST_LISTS_ONLY`** is **`false`** (list-only / plan-discovery runs skip AI Review). + +### `GEMINI_ANALYSE_ON_SUCCESS` + +Optional. Default **`false`**. + +When **`true`**, the shared pipeline’s **Diagnostics → AI Review** stage is allowed to run even when the overall pipeline result is **`SUCCESS`**. This is useful to analyze CTS/CVD logs for hidden / underlying issues without forcing a failing build. + +Notes: + +- AI Review still requires **`ENABLE_GEMINI_AI_ASSISTANT=true`** and still skips when `CTS_TEST_LISTS_ONLY` is `true` (`requireCtsNotListOnly`). +- When **`GEMINI_ANALYSE_ON_SUCCESS=true`**, a failing Gemini step in AI Review can still mark the overall job **`FAILURE`** (same `catchError` behaviour as when AI Review runs after a failed pipeline). +- **Offline CVD analysis via Gemini AI Assistant (success or failure):** to investigate CVD/Cuttlefish behaviour for a CTS run that has already completed — whether tests passed, tests failed, or devices never booted — use [**Workloads → Utilities → Gemini AI Assistant**](../utilities/gemini_ai_assistant.md) with the **CVD Launcher** sequenced prompts and `skills.yaml` from `workloads/android/pipelines/tests/cvd_launcher/prompt/sequenced/` (**not** the CTS Execution set; the CVD Launcher prompts cover both the boot-failure lane and the runtime-health lane via Phase 0). Point **`GEMINI_ARTIFACTS_COMMAND`** at the CTS Execution build's archived Cuttlefish/CVD artifacts (host `cvd*.log`, unpacked `cuttlefish_logs*.zip`, guest `kernel.log`/`launcher.log`/`logcat`, `wifi*.log`), then upload the three CVD Launcher prompts and matching **`GEMINI_SKILLS_YAML`**. **Phase 0 — CVD boot preflight** classifies `CVD_STATUS` from artifacts only (no pipeline state) and auto-routes to boot-failure triage on failed boots or runtime-health analysis on booted devices — leaving CTS suite/plan semantics to the normal CTS Execution AI Review. + +### Gemini prompts and artifacts + +The job uses prompt files from the repository only; there is no Jenkins parameter to override the default path. Sequenced prompts (order matters), under `workloads/android/pipelines/tests/cts_execution/prompt/sequenced/`: `step1_triage.txt`, `step2_rca.txt`, `step3_fixes.txt`. Outputs: `step1_output.md`, `step2_output.md`, `step3_output.md`. Skills are defined in `skills.yaml` (`triage-cts`, `rca-cts`, `fix-cts`); see `workloads/android/pipelines/tests/cts_execution/prompt/sequenced/README_SKILLS.md` for how `gemini_initialise.sh` loads them. [CVD Launcher](cvd_launcher.md#gemini-prompts-and-ai-review) uses a separate `cvd_launcher/prompt/sequenced` set when `preset: 'cvd'`—that preset is **dedicated to Cuttlefish/CVD runtime** triage without CTS suite semantics; see [CTS vs CVD Launcher (scope)](#cts-vs-cvd-launcher-scope) above. + +**CVD logs when devices do not boot:** On failure, AI Review still receives **Cuttlefish-oriented** artifacts (`**/cvd*.log`, **`cuttlefish_logs*.zip`**, guest logs under **`test-results/cvd/**` after unpack, Wi‑Fi logs, etc.) alongside any CTS XML/HTML. Skills **always** treat guest **`kernel.log`** as high signal: when Tradefed produced a failure report, triage correlates **failed tests** but still runs **early-boot / bootconfig-oriented** greps on **`kernel.log`** (those errors usually do **not** contain testcase names). When **no** usable Tradefed failure summary exists—typical if virtual devices **never came up** or CTS aborted before writing results—the skills follow a **CVD-only** path aligned with [CVD Launcher](cvd_launcher.md) (guest-first analysis and optional follow-up to run a **CVD Launcher** job with **`preset: 'cvd'`** for a dedicated boot/runtime pass). Details: `prompt/sequenced/README_SKILLS.md`. + +Cuttlefish/CVD line-number grep guidance (example failure buckets, log paths, and substrings, plus what to do when the failure is non-obvious) is under `skills.yaml` → `global_constraints` → **“CVD errors — what to do”**. That block is aligned with [CVD Launcher](cvd_launcher.md#gemini-prompts-and-ai-review) and applies on this job’s **CVD-only** triage path when Tradefed suite artifacts are missing. + +For AI Review, the shared library copies artifacts from the current build using the **CTS Execution** filter: `android-cts-results/**`, `android-cts-results-html/**`, plus shared Cuttlefish-oriented patterns (`**/wifi*.log`, `**/cvd*.log`, `**/cts_execution_parameters.txt`, `**/cuttlefish_logs*.zip`). The canonical filter list lives with **`cvdPipeline`** / **`aiReview`** in `workloads/common/jenkins/shared-libraries/cvd-pipeline-shared-library/vars/` (see that README for implementation pointers). + +### `GEMINI_COMMAND_LINE` + +Interface for the headless [gemini-cli](https://geminicli.com/docs/cli/headless/). +Use this to specify settings such as the [Gemini model](https://ai.google.dev/gemini-api/docs/models) etc, e.g. +`--debug` to include debug output. +Note: Prompts are piped via `stdin` and output is redirected to a JSON file. ## Example Usage Refer to `docs/workloads/android/tests/cvd_launcher.md` for an example of how to create and set up a test instance and boot the Cuttlefish Virtual Devices. Once the devices are booted, CTS tests can be run as follows: - ``` ANDROID_VERSION=14 \ ./workloads/android/pipelines/tests/cts_execution/cts_initialise.sh @@ -204,11 +265,11 @@ These are as follows: - `HORIZON_DOMAIN` - The URL domain which is required by pipeline jobs to derive URL for tools and GCP. -- `HORIZON_GIT_URL` +- `HORIZON_SCM_URL` - The URL to the Horizon SDV git repository. -- `HORIZON_GIT_BRANCH` - - The branch name the job will be configured for from `HORIZON_GIT_URL`. +- `HORIZON_SCM_BRANCH` + - The branch name the job will be configured for from `HORIZON_SCM_URL`. - `JENKINS_SERVICE_ACCOUNT` - Service account to use for pipelines. Required to ensure correct roles and permissions for GCP resources. diff --git a/docs/workloads/android/tests/cvd_launcher.md b/docs/workloads/android/tests/cvd_launcher.md index 87af7e82..2eeda6b7 100644 --- a/docs/workloads/android/tests/cvd_launcher.md +++ b/docs/workloads/android/tests/cvd_launcher.md @@ -2,8 +2,13 @@ ## Table of contents - [Introduction](#introduction) +- [Jenkins pipeline and shared library](#jenkins-pipeline-and-shared-library) - [Prerequisites](#prerequisites) - [Environment Variables/Parameters](#environment-variables) + * [`CVD_COMMAND_LINE`](#cvd_command_line) + * [`ENABLE_GEMINI_AI_ASSISTANT`](#enable_gemini_ai_assistant) + * [`GEMINI_ANALYSE_ON_SUCCESS`](#gemini_analyse_on_success) + * [Gemini prompts and AI Review](#gemini-prompts-and-ai-review) - [Example Usage](#examples) - [System Variables](#system-variables) - [Known Issues](#known-issues) @@ -14,6 +19,14 @@ This pipeline is run on GCE Cuttlefish VM instances from the instance templates The pipeline first runs CVD on the Cuttlefish VM Instance to instantiate the specified number of virtual devices and then connects to MTK Connect so that users can test their builds (UI and adb). Devices are kept alive for the user-specified amount of time. +When **Gemini AI Review** is enabled and the build **fails**, the **Diagnostics** stage can run **AI Review** (`preset: 'cvd'`) over archived **Cuttlefish/CVD logs** to surface **boot, launch, and runtime** issues—for example when devices **do not boot** or **never stabilize**—using guest **`kernel.log`**, **`launcher.log`**, and related artifacts (see [Gemini prompts and AI Review](#gemini-prompts-and-ai-review)). + +### Jenkins pipeline and shared library + +The job **`Jenkinsfile`** (`workloads/android/pipelines/tests/cvd_launcher/Jenkinsfile`) calls **`cvdPipeline`** with **`aiReview`** (`preset: 'cvd'`) only — it does **not** pass **`preLaunchStages`** or **`postMtkConnectStages`**. Those optional hooks are used by [CTS Execution](cts_execution.md#jenkins-pipeline-and-shared-library-hooks) to plug list-tests and CTS run stages into the same shared pipeline. + +See **`workloads/common/jenkins/shared-libraries/cvd-pipeline-shared-library/vars/README.md`** for stage order, `config` keys, and **`ctsCvdPipelineHooks`**. + ### References - [Cuttlefish Virtual Devices](https://source.android.com/docs/devices/cuttlefish) @@ -26,6 +39,8 @@ One-time setup requirements. - Before running this pipeline job, ensure that the following templates have been created by running the corresponding jobs: - Docker image template: `Android Workflows/Environment/Docker Image Template` - Cuttlefish instance template: `Android Workflows/Environment/CF Instance Template` + - The CF job now uses a Packer-based build flow for template creation. + - Script stage mapping is `1=build`, `2=ssh refresh`, `3=delete` (documented in `docs/workloads/android/environment/cf_instance_template.md`). - Must be rebuilt if using `CUTTLEFISH_INSTALL_WIFI` option, to ensure WiFi APK is stored with the image files. ## Environment Variables/Parameters @@ -90,12 +105,78 @@ This applies to CVD `memory_mb` parameter. When checked, the MTK Connect testbench is visible to everyone and can be shared. By default, testbenches are private and only visible to their creator and MTK Connect administrators. -### `CVD_ADDITIONAL_FLAGS` +### `MTK_CONNECT_TUNNEL_PORT` + +TCP port passed into MTK Connect as the ADB tunnel **`caller.port`** when creating the testbench (`workloads/common/mtk-connect/create-testbench.js`). Default **8555**. Change it if that port is already in use on the agent. The shared library passes this job parameter through to `mtk_connect.sh` as the environment variable of the same name; the shell script and Node script both default to **8555** when unset. + +### `CVD_COMMAND_LINE` + +Full Cuttlefish launch command: the binary plus all arguments. The start script runs it with `sudo HOME=` and log redirection. + +- **Default (Jenkins and `cvd_environment.sh`):** + + `/usr/bin/cvd create --noresume -config=auto -report_anonymous_usage_stats=no --num_instances="${NUM_INSTANCES}" --cpus="${VM_CPUS}" --memory_mb="${VM_MEMORY_MB}" --console=true --setupwizard_mode DISABLED --enable_host_bluetooth false --gpu_mode guest_swiftshader` -Append additional flags to `cvd` command, e.g. + The `${NUM_INSTANCES}`, `${VM_CPUS}`, and `${VM_MEMORY_MB}` fragments are **shell** placeholders: they are expanded when the start script runs `eval` on the launch command, using values that **derive from the job parameters** of the same names. They are **not** Groovy interpolations—the job DSL stores that string literally in the parameter default. + + The trailing flags target **typical CI**: non-interactive boot (`--setupwizard_mode DISABLED`), no host Bluetooth (`--enable_host_bluetooth false`), and **software** guest rendering (`--gpu_mode guest_swiftshader`) when agents do not use GPU passthrough. Confirm supported flag names against your Cuttlefish package version. + +- **Empty:** if you clear the parameter so it is empty after trim, `cvd_environment.sh` sets the same default as above. + +- **Override:** set a different full command line to change any `cvd create` arguments (for example host GPU modes, display geometry, or verbosity). + +Optional flags are not a separate Jenkins field: **include them in the `CVD_COMMAND_LINE` value** together with the `cvd create` binary and any other arguments you need. Examples of **additional** fragments (check your Cuttlefish version for supported flags): -- `--setupwizard_mode DISABLED --enable_host_bluetooth false --gpu_mode guest_swiftshader` - `--display0=width=1920,height=1080,dpi=160` +- `--verbosity=DEBUG` + +### `ENABLE_GEMINI_AI_ASSISTANT` + +Enable Gemini **AI Review** in the shared `cvd-pipeline-shared-library` **Diagnostics** stage (`cvdPipeline`, same family as [CTS Execution](cts_execution.md)). The **AI Review** sub-stage runs only when **`ENABLE_GEMINI_AI_ASSISTANT`** is **`true`**, the overall pipeline result is **`FAILURE`**, and **`MTK_CONNECT_STAGE_FAILED`** is not **`true`** (see `cvdPipeline.groovy`). This job does **not** use **`requireCtsNotListOnly`** (that gate applies only to CTS Execution). + +The **`Jenkinsfile`** sets **`aiReview`** with **`preset: 'cvd'`**: artifact collection matches CTS Execution’s Cuttlefish/host patterns but **does not** include CTS result trees (`android-cts-results/**`, `android-cts-results-html/**`), which do not apply to this job. + +### `GEMINI_ANALYSE_ON_SUCCESS` + +Optional. Default **`false`**. + +When **`true`**, the shared pipeline’s **Diagnostics → AI Review** stage is allowed to run even when the overall pipeline result is **`SUCCESS`**. This is useful to analyze CVD logs for hidden / underlying issues without forcing a failing build. + +The sequenced prompts and **`triage-cvd` / `rca-cvd` / `fix-cvd`** skills recognise both outcomes via a **Phase 0 — CVD boot preflight**. Triage emits a **`## CVD boot preflight`** header with **`CVD_STATUS`** (`BOOT_OK` / `BOOT_FAILED` / `BOOT_UNKNOWN`) derived from `"status": "Running"` in host CVD JSON logs and **`VIRTUAL_DEVICE_BOOT_COMPLETED`** per guest. On **`BOOT_OK`**, bootconfig warnings are **informational** and runtime/logcat findings are **primary**; if no actionable issue is found, Step 1 emits a single **`[CVD_HEALTHY]`** row, Step 2 confirms no root cause, and Step 3 writes a single observations note (or returns `FIX_UNKNOWN`). On **`BOOT_FAILED`** / **`BOOT_UNKNOWN`**, the existing boot-first priority is preserved. + +**Offline analysis via Gemini AI Assistant (success or failure).** The [Utilities / Gemini AI Assistant](../utilities/gemini_ai_assistant.md) job can run the **same** CVD Launcher sequenced prompts and `skills.yaml` against archived `test-results/` for any previous CVD Launcher **or** CTS Execution build — pipeline **passed or failed**, devices **booted or not**. Always use the **CVD Launcher** prompts (not the CTS Execution set) whenever the focus is CVD / Cuttlefish behaviour: the same prompts cover both the boot-failure lane and the runtime-health lane via Phase 0. Upload `step1_triage.txt`, `step2_rca.txt`, `step3_fixes.txt`, and `skills.yaml` from `workloads/android/pipelines/tests/cvd_launcher/prompt/sequenced/`, and set `GEMINI_ARTIFACTS_COMMAND` to the archived results location (e.g. `gcloud storage cp -r gs://...//test-results/ .`). Phase 0 classifies `CVD_STATUS` from artifacts only (no pipeline state) and routes triage to the correct lane — useful both when a failed pipeline needs deeper CVD triage and when a passed pipeline masked questionable device behaviour. + +Notes: + +- AI Review still requires **`ENABLE_GEMINI_AI_ASSISTANT=true`**. +- When **`GEMINI_ANALYSE_ON_SUCCESS=true`**, a failing Gemini step in AI Review can still mark the overall job **`FAILURE`** (same `catchError` behaviour as when AI Review runs after a failed pipeline). +- **Post-hoc analysis of a successful boot (recommended):** rather than re-running the pipeline with `GEMINI_ANALYSE_ON_SUCCESS=true`, use [**Workloads → Utilities → Gemini AI Assistant**](../utilities/gemini_ai_assistant.md) against the archived artifacts. Point **`GEMINI_ARTIFACTS_COMMAND`** at the CVD Launcher build's Cuttlefish/CVD artifacts (e.g. `gcloud storage cp -r gs://// .`), upload the three CVD Launcher sequenced prompts as **`GEMINI_PROMPT_FILE`** / **`GEMINI_PROMPT_FILE_2`** / **`GEMINI_PROMPT_FILE_3`** from `workloads/android/pipelines/tests/cvd_launcher/prompt/sequenced/step{1,2,3}_*.txt`, and upload the matching **`skills.yaml`** as **`GEMINI_SKILLS_YAML`**. The **Phase 0 — CVD boot preflight** will classify the run as **`BOOT_OK`** and route Gemini into the runtime-health lane, highlighting logcat/WiFi/late-kernel issues observed on booted devices without re-running the build. + +### `GEMINI_COMMAND_LINE` + +Interface for the headless [gemini-cli](https://geminicli.com/docs/cli/headless/). Same role as on CTS Execution; defaults are seeded in `groovy/job.groovy` (including optional `GEMINI_MODEL` via job environment). + +### `CTS_ARTIFACT_STORAGE_SOLUTION` + +Where to upload Gemini outputs after analysis (for example `GCS_BUCKET`, or empty to skip upload). Same variable name as CTS Execution for shared scripts. + +### `STORAGE_BUCKET_DESTINATION` + +Optional override for Gemini artifact destination; align with [CTS Execution](cts_execution.md) if you use bucket overrides there. + +### `STORAGE_LABELS` + +Optional GCS object metadata labels for stored Gemini artifacts. + +### Gemini prompts and AI Review + +Default sequenced prompts and `skills.yaml` for this job live under `workloads/android/pipelines/tests/cvd_launcher/prompt/sequenced/` (CVD host/guest/kernel/logcat-focused skills: `triage-cvd`, `rca-cvd`, `fix-cvd`). See `workloads/android/pipelines/tests/cvd_launcher/prompt/sequenced/README_SKILLS.md` for loading behavior. [CTS Execution](cts_execution.md) continues to use `cts_execution/prompt/sequenced` for suite + CVD correlation. + +Cuttlefish/CVD line-number grep guidance (example failure buckets, log paths, and substrings, plus what to do when the failure is non-obvious) is under `skills.yaml` → `global_constraints` → **“CVD errors — what to do”**; the same subsection is duplicated under [CTS Execution](cts_execution.md#gemini-prompts-and-artifacts) `skills.yaml` for **CVD-only** parity. + +**CVD logs and devices that do not boot:** This job does **not** run Tradefed. When Gemini is enabled and the build **fails**, AI Review analyzes the copied Cuttlefish/CVD material to explain **launch and runtime** problems—especially **guest `kernel.log`** (bootconfig, panics, early boot), **`launcher.log`** (crosvm / assemble), workspace **`cvd*.log`**, Wi‑Fi logs, and **`cuttlefish_logs*.zip`**—not Compatibility Test assertions. That is the right diagnostic path when virtual devices **fail to boot**, **fail to stabilize**, or **never become usable** before any CTS run would matter. For failures that also require **which CTS tests failed** and Tradefed HTML/XML, use [CTS Execution](cts_execution.md) (`preset: 'cts'`). + +For AI Review, the shared library copies artifacts using the **CVD** filter: `**/wifi*.log`, `**/cvd*.log`, `**/cts_execution_parameters.txt`, `**/cuttlefish_logs*.zip`. The job definition grants **Copy Artifact** permission on itself so the Diagnostics stage can copy from the same build. ## Example Usage @@ -106,7 +187,6 @@ From `Workloads/Android/Environment/CF Instance Template` create a Cuttlefish te - `ANDROID_CUTTLEFISH_REVISION`: choose the version you wish to build the template from - `CUTTLEFISH_INSTANCE_NAME` : provide a unique name, starting with cuttlefish-vm, e.g. `cuttlefish-vm-test-instance-v110.` - `MAX_RUN_DURATION` : set to 0 to avoid instance being deleted after this time. -- `VM_INSTANCE_CREATE` : Enable this option so that the instance template will create a VM instance for user to start, connect to and work with. Connect to the instance, e.g. @@ -215,11 +295,11 @@ These are as follows: - `HORIZON_DOMAIN` - The URL domain which is required by pipeline jobs to derive URL for tools and GCP. -- `HORIZON_GIT_URL` +- `HORIZON_SCM_URL` - The URL to the Horizon SDV git repository. -- `HORIZON_GIT_BRANCH` - - The branch name the job will be configured for from `HORIZON_GIT_URL`. +- `HORIZON_SCM_BRANCH` + - The branch name the job will be configured for from `HORIZON_SCM_URL`. - `JENKINS_SERVICE_ACCOUNT` - Service account to use for pipelines. Required to ensure correct roles and permissions for GCP resources. diff --git a/docs/workloads/cloud-workstations/cluster_admin_operations.md b/docs/workloads/cloud-workstations/cluster_admin_operations.md index d6df1301..a6965247 100644 --- a/docs/workloads/cloud-workstations/cluster_admin_operations.md +++ b/docs/workloads/cloud-workstations/cluster_admin_operations.md @@ -72,11 +72,11 @@ These are as follows: - `CLOUD_REGION` - The GCP project region. Important for bucket, registry paths used in pipelines. -- `HORIZON_GIT_URL` +- `HORIZON_SCM_URL` - The URL to the Horizon SDV git repository. -- `HORIZON_GIT_BRANCH` - - The branch name the job will be configured for from `HORIZON_GIT_URL`. +- `HORIZON_SCM_BRANCH` + - The branch name the job will be configured for from `HORIZON_SCM_URL`. - `JENKINS_SERVICE_ACCOUNT` - Service account to use for pipelines. Required to ensure correct roles and permissions for GCP resources. diff --git a/docs/workloads/cloud-workstations/config_admin_operations.md b/docs/workloads/cloud-workstations/config_admin_operations.md index aa368ff7..995b59f8 100644 --- a/docs/workloads/cloud-workstations/config_admin_operations.md +++ b/docs/workloads/cloud-workstations/config_admin_operations.md @@ -292,11 +292,11 @@ These are as follows: - `CLOUD_REGION` - The GCP project region. Important for bucket, registry paths used in pipelines. -- `HORIZON_GIT_URL` +- `HORIZON_SCM_URL` - The URL to the Horizon SDV git repository. -- `HORIZON_GIT_BRANCH` - - The branch name the job will be configured for from `HORIZON_GIT_URL`. +- `HORIZON_SCM_BRANCH` + - The branch name the job will be configured for from `HORIZON_SCM_URL`. - `JENKINS_SERVICE_ACCOUNT` - Service account to use for pipelines. Required to ensure correct roles and permissions for GCP resources. diff --git a/docs/workloads/cloud-workstations/environment.md b/docs/workloads/cloud-workstations/environment.md index 5866ec83..482c5034 100644 --- a/docs/workloads/cloud-workstations/environment.md +++ b/docs/workloads/cloud-workstations/environment.md @@ -46,11 +46,11 @@ These are as follows: - `CLOUD_REGION` - The GCP project region. Important for bucket, registry paths used in pipelines. -- `HORIZON_GIT_URL` +- `HORIZON_SCM_URL` - The URL to the Horizon SDV git repository. -- `HORIZON_GIT_BRANCH` - - The branch name the job will be configured for from `HORIZON_GIT_URL`. +- `HORIZON_SCM_BRANCH` + - The branch name the job will be configured for from `HORIZON_SCM_URL`. - `JENKINS_SERVICE_ACCOUNT` - Service account to use for pipelines. Required to ensure correct roles and permissions for GCP resources. diff --git a/docs/workloads/cloud-workstations/workstation_admin_operations.md b/docs/workloads/cloud-workstations/workstation_admin_operations.md index c4174997..38b82a0d 100644 --- a/docs/workloads/cloud-workstations/workstation_admin_operations.md +++ b/docs/workloads/cloud-workstations/workstation_admin_operations.md @@ -151,11 +151,11 @@ These are as follows: - `CLOUD_REGION` - The GCP project region. Important for bucket, registry paths used in pipelines. -- `HORIZON_GIT_URL` +- `HORIZON_SCM_URL` - The URL to the Horizon SDV git repository. -- `HORIZON_GIT_BRANCH` - - The branch name the job will be configured for from `HORIZON_GIT_URL`. +- `HORIZON_SCM_BRANCH` + - The branch name the job will be configured for from `HORIZON_SCM_URL`. - `JENKINS_SERVICE_ACCOUNT` - Service account to use for pipelines. Required to ensure correct roles and permissions for GCP resources. diff --git a/docs/workloads/cloud-workstations/workstation_images.md b/docs/workloads/cloud-workstations/workstation_images.md index a423d5fc..16f86357 100644 --- a/docs/workloads/cloud-workstations/workstation_images.md +++ b/docs/workloads/cloud-workstations/workstation_images.md @@ -55,11 +55,11 @@ These are as follows: - `CLOUD_REGION` - The GCP project region. Important for bucket, registry paths used in pipelines. -- `HORIZON_GIT_URL` +- `HORIZON_SCM_URL` - The URL to the Horizon SDV git repository. -- `HORIZON_GIT_BRANCH` - - The branch name the job will be configured for from `HORIZON_GIT_URL`. +- `HORIZON_SCM_BRANCH` + - The branch name the job will be configured for from `HORIZON_SCM_URL`. - `JENKINS_SERVICE_ACCOUNT` - Service account to use for pipelines. Required to ensure correct roles and permissions for GCP resources. diff --git a/docs/workloads/cloud-workstations/workstation_user_operations.md b/docs/workloads/cloud-workstations/workstation_user_operations.md index ae1ded26..4ea985fc 100644 --- a/docs/workloads/cloud-workstations/workstation_user_operations.md +++ b/docs/workloads/cloud-workstations/workstation_user_operations.md @@ -109,11 +109,11 @@ These are as follows: - `CLOUD_REGION` - The GCP project region. Important for bucket, registry paths used in pipelines. -- `HORIZON_GIT_URL` +- `HORIZON_SCM_URL` - The URL to the Horizon SDV git repository. -- `HORIZON_GIT_BRANCH` - - The branch name the job will be configured for from `HORIZON_GIT_URL`. +- `HORIZON_SCM_BRANCH` + - The branch name the job will be configured for from `HORIZON_SCM_URL`. - `JENKINS_SERVICE_ACCOUNT` - Service account to use for pipelines. Required to ensure correct roles and permissions for GCP resources. diff --git a/docs/workloads/common/agentic-ai/gemini.md b/docs/workloads/common/agentic-ai/gemini.md new file mode 100644 index 00000000..8f100aeb --- /dev/null +++ b/docs/workloads/common/agentic-ai/gemini.md @@ -0,0 +1,159 @@ +# Gemini CLI integration + +Scripts and environment for running the [Gemini CLI](https://geminicli.com/docs/cli/headless/) in headless mode (e.g. from Jenkins or Argo). Used by the AAOS Builder AI review step and the Gemini AI Assistant utility. + +## Argo Workflows (`ai-review`) + +The WorkflowTemplate is named **`ai-review`** (resource name and template name inside the manifest; Helm manifest **`templates/workflowtemplates.yaml`**). Its Helm chart lives next to these scripts: **`workloads/common/agentic-ai/gemini/helm/`** (chart name `common-ai-review`). Platform GitOps deploys it as a source on the **`workloads-android`** Module Manager Application. Apply manually with `helm template common-ai-review workloads/common/agentic-ai/gemini/helm | kubectl apply -f - -n `. + +## Scripts + +| Script | Purpose | +|--------|--------| +| `gemini_environment.sh` | Sets and exports variables (prompt file, command line, artifact path, preview/location). Source this before other scripts. | +| `gemini_initialise.sh` | Cleans artifacts, installs/upgrades gemini-cli, writes `.gemini/settings.json`. If skills.yaml is found (`GEMINI_SKILLS_YAML` or `skills.yaml` next to prompt path), runs `gemini_skills_from_yaml.py` to populate `.gemini/skills/`. Skills file is always named `skills.yaml`. In Jenkins / `CI=true` / Argo workflow runs, copies every `*.toml` under `gemini/policies/` into `.gemini/policies/` (see [Workspace policy files](#workspace-policy-files-toml)). | +| `gemini_analysis.sh` | One prompt → single run. Two or three prompts → **sequenced**: **one Gemini CLI invocation per step**, each with its own JSON output file (`headless_output_stepN__.json`). Prior-step context is file-based (`stepN_output.md`). Step 3 includes **both** `step1_output.md` and `step2_output.md` when present (optional byte caps). Set `GEMINI_PROMPT_FILE` (step 1), optionally `GEMINI_PROMPT_FILE_2` and `GEMINI_PROMPT_FILE_3`. Default when unset: AAOS three-step. Requires `jq` for extraction. See [Sequenced analysis](#sequenced-analysis). | + +## Key environment variables + +- **GEMINI_PROMPT_FILE** – Step 1 prompt file path or base64-encoded content. +- **GEMINI_PROMPT_FILE_2** – Optional step 2 prompt; required only for sequenced (two or three steps). +- **GEMINI_PROMPT_FILE_3** – Optional step 3 prompt (do not reorder). +- **GEMINI_COMMAND_LINE** – Full CLI invocation (e.g. `gemini --yolo --output-format json`). To pin the model and avoid auto-routing to preview-only models, add `--model ` (e.g. `--model gemini-2.5-pro`). +- **GEMINI_PREVIEW_FEATURES**, **GEMINI_LOCATION_GLOBAL** – Passed into `.gemini/settings.json` and location. +- **GEMINI_SKILLS_YAML** – Optional path to `skills.yaml`. When set, `gemini_initialise.sh` converts it to `.gemini/skills/*/SKILL.md` (requires Python PyYAML). The skills file is always named `skills.yaml`. +- **GEMINI_ARTIFACT_PATH** – Directory for analysis output (default `gemini-assist`). +- **GEMINI_OUTPUT_FILE_NAME** – JSON path for the CLI’s `--output-format json` sink. In **sequenced** runs, `gemini_analysis.sh` **overwrites** this per step with `headless_output_stepN_*.json`; do not rely on a single global name across steps. +- **GEMINI_STEP2_PRIOR_CONTEXT_BYTES** – If set to a positive integer, maximum bytes of `step1_output.md` appended into the **step 2** composed prompt (`head -c`). Unset or `0` = append full step 1 text. Some jobs set `131072` to limit prompt size. +- **GEMINI_STEP3_PRIOR_STEP1_BYTES** – Maximum bytes of `step1_output.md` inlined into the **step 3** composed prompt (when step 3 includes both prior steps). Unset or `0` = full file. +- **GEMINI_STEP3_PRIOR_STEP2_BYTES** – Maximum bytes of `step2_output.md` for the same step 3 composed prompt. Unset or `0` = full file. +- **GEMINI_CLI_TRUST_WORKSPACE** – Default `true`. Bypasses the headless [Folder Trust](https://geminicli.com/docs/cli/trusted-folders/) check so the CLI loads our workspace `.gemini/settings.json` (auth `selectedType=vertex-ai`, policies, skills). Without this, recent Gemini CLI versions run in restricted *safe mode* and ignore workspace settings, surfacing as `Please set an Auth method ... GEMINI_API_KEY, GOOGLE_GENAI_USE_VERTEXAI, GOOGLE_GENAI_USE_GCA` even though `GOOGLE_GENAI_USE_VERTEXAI=True` is exported. Equivalent to passing `--skip-trust` on the CLI; set to `false` only if you intentionally want safe mode. +- **TERM** – Forced to `xterm-256color` when unset or `dumb` (Jenkins `sh` steps export `TERM=dumb`; Argo leaves it unset). Silences the CLI's `Warning: Basic terminal detected (TERM=dumb)...` and `Warning: 256-color support not detected...` advisories. The checks are on terminal capability, not colour output, so `NO_COLOR` alone does not silence them. Set to any other non-`dumb` value in the caller to override. +- **COLORTERM** – Default `truecolor`. Standard advertisement for 24-bit colour; silences the CLI's `Warning: True color (24-bit) support not detected...` warning in headless runs. +- **NO_COLOR** – Default `1`. Standard [`NO_COLOR`](https://no-color.org) opt-out that suppresses ANSI styling in the CLI's stderr/stdout, keeping the JSON output clean for `jq`. Set to empty (`NO_COLOR=`) in the caller to re-enable colour. + +## Sequenced analysis + +`gemini_analysis.sh` treats each step as a **separate** headless CLI run (stdin = composed prompt for that step only). There is no single growing chat session in the shell; **handoff** is via files on disk. + +**Benefits of this separation (vs one ambiguous multi-step blob):** + +- **Clearer artifacts:** Each step has its own `headless_output_stepN_*.json` and matching `stepN_output.md`, so extraction and debugging target the **failing step** without guessing which JSON is “newest.” +- **Failure isolation:** If step 2 or 3 fails, step 1 output is still on disk for replay or manual review; you are not forced to redo triage unless step 1 itself failed. +- **Tunable context per step:** Byte caps apply **per handoff** (`GEMINI_STEP2_*`, `GEMINI_STEP3_PRIOR_*`), so you can trim **RCA** or **fix** inputs independently instead of one oversized combined prompt. +- **Richer fix step:** Step 3 can include **both** triage and RCA markdown explicitly, so remediation sees **structured** prior outputs rather than only whatever step 2 chose to repeat from step 1. + +**Trade-off:** Step 3’s composed prompt can be **larger** than the older “append only step 2” behavior unless you use the step 3 byte caps—see [Prompt size and token budget](#prompt-size-and-token-budget-adjust-measure-set). + +| Step | Composed input | Raw JSON output | +|------|----------------|-----------------| +| 1 | Step 1 prompt file only | `headless_output_step1__.json` | +| 2 | Step 2 prompt + `## Context from previous step(s)` + contents of `step1_output.md` (optional cap: `GEMINI_STEP2_PRIOR_CONTEXT_BYTES`) | `headless_output_step2_*.json` | +| 3 | Step 3 prompt + context sections for **`step1_output.md`** and **`step2_output.md`** when both exist (optional caps: `GEMINI_STEP3_PRIOR_STEP1_BYTES`, `GEMINI_STEP3_PRIOR_STEP2_BYTES`). If those files are missing, falls back to appending only the immediate prior step file as in older behavior. | `headless_output_step3_*.json` | + +After each successful step, the script extracts the model text from **that step’s JSON** into `step1_output.md` / `step2_output.md` / `step3_output.md` (using `jq`). Step outputs are also copied into `GEMINI_ARTIFACT_PATH` (default `gemini-assist/`). Stray `*proposed_fix*.md` files in the working directory are moved into `GEMINI_ARTIFACT_PATH` after each successful step. + +**Downstream tooling:** Jobs that assume **exactly one** `headless_output*.json` per run should be updated to expect **one JSON file per sequenced step** (or to select by glob / newest as appropriate). + +### Prompt size and token budget (adjust, measure, set) + +**What the repo controls:** `gemini_analysis.sh` limits **how many bytes** of prior-step markdown are **appended** into the composed prompt for step 2 and step 3 (`GEMINI_STEP2_PRIOR_CONTEXT_BYTES`, `GEMINI_STEP3_PRIOR_STEP1_BYTES`, `GEMINI_STEP3_PRIOR_STEP2_BYTES`). It does **not** cap the step prompt files themselves, skills content, or the Gemini CLI’s own system overhead. Billing and **token** usage are reported by **Google Cloud / Vertex** (or the CLI) if your org enables that; the shell script does **not** print token counts. + +**Set (where):** Export or inject these variables in the same place you set other `GEMINI_*` vars for the job—e.g. Jenkins pipeline env, Argo workflow parameters, or a wrapper before sourcing `gemini_environment.sh` and calling `gemini_analysis.sh`. Values are **byte counts** (e.g. `131072` ≈ 128 KiB of appended prior-step text per variable). + +**Measure (practical):** + +- **Rough size of composed input:** After a run, `wc -c step2_composed.txt` / `wc -c step3_composed.txt` in the workspace (these files are written during sequenced runs when prior context is appended). Add the byte size of the base prompt file and account for skills loaded by the CLI separately. +- **Rough token estimate:** There is no single universal rule; for a **ballpark** on Latin-heavy text, **≈ 1 token per 4 characters** is sometimes used, but model tokenizers differ. Use Cloud metrics or API usage dashboards for **authoritative** token counts. +- **Symptoms of oversize:** API errors mentioning **context length**, **max tokens**, or **request too large**; or step failures with no useful `stepN_output.md`. If failures are intermittent, compare **composed file sizes** across runs. + +**Tune (suggested order):** + +1. If step 2 fails or is slow, set **`GEMINI_STEP2_PRIOR_CONTEXT_BYTES`** (AAOS-style jobs often use `131072` as a starting point). +2. If step 3 hits limits after step 3 started including **both** prior outputs, set **`GEMINI_STEP3_PRIOR_STEP1_BYTES`** and **`GEMINI_STEP3_PRIOR_STEP2_BYTES`** independently (truncate the larger or more repetitive file first). +3. Re-run and compare triage/RCA/fix quality; **increase** caps only where truncation clearly hurts results; **decrease** if limits errors persist. + +**Production / roadmap:** Hard enforcement, per-stage budgets, and observability hooks are broader **Agentic AI** / platform work; these variables are the **current** levers in this repository. + +## Workspace skills + +Skills are populated in `.gemini/skills/` before analysis from **skills.yaml** (always this filename; contains a `skills:` list of `name`, `description`, `system_instructions`). When the job finds `skills.yaml` next to the prompt path—or when `GEMINI_SKILLS_YAML` is set—it converts it to `.gemini/skills//SKILL.md` via `gemini_skills_from_yaml.py` (requires `pip install pyyaml`). + +**Prompt vs skill:** The **prompt** is the *task* (the short invocation for this run). The **skill** is the *instruction* (how to do it: role, procedure, rules, output format). In skills, use "Procedure" or "Steps" for the steps to follow—reserve "task" for the prompt. Keep the prompt minimal; put all behavior in the skill. + +## Workspace policy files (TOML) + +The Gemini CLI [policy engine](https://geminicli.com/docs/reference/policy-engine) reads rules from **`.toml` files** (not JSON or YAML). User policies default to `~/.gemini/policies/*.toml`; **workspace** policies—scoped to the current working directory—live under **`.gemini/policies/*.toml`**. The upstream doc defines the `[[rule]]` schema (`toolName`, `decision`, `priority`, `interactive`, and so on). + +Policy TOML files live under **`workloads/common/agentic-ai/gemini/policies/`** (any **`*.toml`**; the repo ships **`00-ci-headless-shell.toml`**). `gemini_initialise.sh` copies **all** of them into **`.gemini/policies/`** when Vertex auth runs **and** the environment looks like CI: **`JENKINS_URL`** set, **`CI=true`**, or **`ARGO_WORKFLOW_NAME`** set (Argo Workflows). That shipped rule **`allow`s `run_shell_command`** when **`interactive = false`**, so shell is not blocked where the default policy would treat **`ask_user`** as **deny** without a TTY. + +## Prompt structure and customisation + +**Layout (per workload):** Put prompts under e.g. `workloads/.../prompt/sequenced/`: + +| File | Role | +|------|------| +| `step1_triage.txt` | Short task for step 1 (e.g. “Run triage… Follow your skill instructions.”). | +| `step2_rca.txt` | Step 2 task; may mention `step1_output.md` as prior context. | +| `step3_fixes.txt` | Step 3 task; may mention `step1_output.md` and `step2_output.md`. | +| `skills.yaml` | Single source of truth: `skills:` list with `name`, `description`, optional `output_schema`, `system_instructions` per skill. | + +**Sequenced run:** See [Sequenced analysis](#sequenced-analysis) for per-step JSON files, step 3 dual context, and token-related environment variables. Fix artifacts go under `GEMINI_ARTIFACT_PATH` (default `gemini-assist/`). + +**How to customise:** + +| What | How | +|------|-----| +| Different prompts | Set `GEMINI_PROMPT_FILE`, `GEMINI_PROMPT_FILE_2`, `GEMINI_PROMPT_FILE_3` (file path or base64). Do not reorder steps. | +| Different skills | Place `skills.yaml` beside those prompts, or set `GEMINI_SKILLS_YAML` (path or base64). | +| Model / CLI flags | Set `GEMINI_COMMAND_LINE` (e.g. add `--model `). | +| Artifact dir / globs | `GEMINI_ARTIFACT_PATH`, `GEMINI_ARTIFACT_FILES_WILDCARD` (see `gemini_environment.sh`). | +| Limit prompt size (sequenced) | `GEMINI_STEP2_PRIOR_CONTEXT_BYTES`, `GEMINI_STEP3_PRIOR_STEP1_BYTES`, `GEMINI_STEP3_PRIOR_STEP2_BYTES` (see [Sequenced analysis](#sequenced-analysis)). | +| Defaults when unset | If all three prompt env vars are empty, defaults point at AAOS builder `prompt/sequenced/` (see `gemini_analysis.sh`). | + +## Usage + +- **Single or sequenced:** One prompt file → single run. For sequenced (context chaining), set `GEMINI_PROMPT_FILE` and `GEMINI_PROMPT_FILE_2` (and optionally `GEMINI_PROMPT_FILE_3`). Order matters. We do not ship single-prompt files. When no prompt env is set, default AAOS three-step prompts are used. + +## Known issues + +### Model pinning and `GEMINI_PREVIEW_FEATURES=FALSE` + +- **What you configure:** You pin a model (via the `GEMINI_MODEL` environment variable or `--model ` in the CLI) and set `GEMINI_PREVIEW_FEATURES=FALSE`, expecting only the pinned model to be used. +- **What happens:** The main analysis uses your pinned model. Follow-up or sub-agent calls may still *attempt* to use a preview model. The Gemini CLI's model-routing allows this (Pro for planning, Flash for implementation), and built-in sub-agents are enabled by default. Because preview is disabled, those calls fall back to your pinned model instead. +- **Bottom line:** The console logs may show that there were attempts made to access the preview models. In practice, only the pinned model is used; the "attempt to use preview model and then fallback" is internal behaviour and does not change the models actually used. +- **Recommended settings:** Enable preview and pin a model. Preview models are available only through the global endpoint, so set `GEMINI_LOCATION_GLOBAL=TRUE` with `GEMINI_PREVIEW_FEATURES=TRUE`, and pin via `GEMINI_MODEL` or `--model ` (e.g. `GEMINI_MODEL=gemini-2.5-pro`). +- **Related bug:** [google-gemini/gemini-cli#13475](https://github.com/google-gemini/gemini-cli/issues/13475) (closed; fix was reverted in [PR #13483](https://github.com/google-gemini/gemini-cli/pull/13483)). +- **Further reading:** [Sub Agents](https://geminicli.com/docs/core/subagents/), [model routing](https://github.com/google-gemini/gemini-cli/blob/3d4956aa57539977f64c9fc83de9334b0e6c8106/docs/reference/configuration.md). + +### Model output quality and proposed fixes + +- **Hallucination:** The model can occasionally invent paths, APIs, or references that do not match your tree or branch. This is uncommon but possible. +- **Proposed fixes:** Suggestions in `gemini-assist/` (and in step outputs) are **assistive only**. They may be incomplete, wrong for your configuration, or miss edge cases. Always **verify** against the real build log, source, and your product constraints before applying changes. +- **Expectation:** Treat outputs as a starting point for triage and investigation—not a substitute for engineer review and further debugging when needed. + +### `missing pgrep output` in the debug console + +- **What it is:** A message that can appear in the Gemini CLI **debug console** (for example after tool use), not a generic operating-system error from your host. +- **Why it appears:** The CLI uses **process monitoring** around shell/tool runs: it may run `pgrep` to correlate child processes. If the child **exits very quickly**, `pgrep` can find no matching process. The tooling treats that as “missing pgrep output” and surfaces it in the debug console. In many cases this is **noise** from timing/race behaviour rather than evidence of a failed command. +- **Bottom line:** You can usually **ignore** this when the underlying tool run actually succeeded; it reflects internal bookkeeping, not necessarily a real failure on your machine or in your pipeline. +- **Related:** [google-gemini/gemini-cli#4095](https://github.com/google-gemini/gemini-cli/issues/4095). + +### Linux: CLI hangs after OAuth (GNOME Keyring / keytar) + +- **What happens:** On some Linux desktops (e.g. Ubuntu with GNOME), the CLI can **block silently** after authentication when token storage uses the system keyring and `keytar.setPassword()` does not return. +- **Workaround:** Set `GEMINI_FORCE_FILE_STORAGE=true` to use encrypted file storage instead of the keychain path (as described in upstream discussion). +- **Related bug:** [google-gemini/gemini-cli#21622](https://github.com/google-gemini/gemini-cli/issues/21622). + +## Maintenance + +| File | Role | When to touch | +|------|------|----------------| +| `gemini_environment.sh` | Defaults and exports for all jobs. | Adding a new `GEMINI_*` or `GOOGLE_*` env var; document in the header block. | +| `gemini_initialise.sh` | Cleanup, CLI install, auth, skills setup; copies `gemini/policies/*.toml` into `.gemini/policies/` in CI. | Changing skills resolution (e.g. new source for skills.yaml); flow order; add or edit policy files under `gemini/policies/` (see [Workspace policy files](#workspace-policy-files-toml)). | +| `gemini_analysis.sh` | Prompt resolution, single/sequenced run, JSON extraction, per-step `headless_output_stepN_*.json`, step 3 composed context from step1+step2. | Adding step 4+; changing default prompt dir; jq extraction paths; caps for prior-step bytes. | +| `gemini_skills_from_yaml.py` | Converts `skills.yaml` → `.gemini/skills//SKILL.md`. | New skill YAML fields (e.g. `tools`, `tags`); SKILL.md frontmatter format. | + +- **Skills schema:** `skills.yaml` has a top-level `skills:` list; each entry: `name` (required), `description`, `system_instructions`. The Python script writes frontmatter and body only; if Gemini CLI adds new frontmatter keys, extend `gemini_skills_from_yaml.py` and this table. +- **Prompts:** Generic filenames are `step1_triage.txt`, `step2_rca.txt`, `step3_fixes.txt`. Each job points to its own prompt directory; skills file is always `skills.yaml` beside those prompts (or via `GEMINI_SKILLS_YAML`). +- **PyYAML:** Required for skills conversion. Provided by `python3-yaml` (apt) in the utilities infra image; Builder/CTS images use pip or apt as per their Dockerfiles. diff --git a/docs/workloads/guides/pipeline_guide.md b/docs/workloads/guides/pipeline_guide.md index f386c906..2621a0f9 100644 --- a/docs/workloads/guides/pipeline_guide.md +++ b/docs/workloads/guides/pipeline_guide.md @@ -24,12 +24,12 @@ To run pipeline jobs, users must have access to Jenkins and be granted permissio - In `Jenkins` → `Manage Jenkins` → `Manage and Assign Roles` → `Assign Roles`. - Those roles are: - `Global:` - - `horizon-jenkins-administrators` - - `horizon-jenkins-workloads-developers` - - `horizon-jenkins-workloads-users` + - `administrators` + - `developers` + - `viewers` - `Items:` - - `workloads-developers` - - `workloads-users` + - `developers` + - `viewers` - Add the user to appropriate Global and Item Roles: - In `Global Roles` select `Add User`, enter the email address of the user and select the appropriate Keycloak Group. - In `Item Roles` select `Add User`, enter the email address of the user and select the appropriate Jenkins Item Role. diff --git a/docs/workloads/openbsw/builds/bsw_builder.md b/docs/workloads/openbsw/builds/bsw_builder.md index 4f594dc3..be798197 100644 --- a/docs/workloads/openbsw/builds/bsw_builder.md +++ b/docs/workloads/openbsw/builds/bsw_builder.md @@ -42,10 +42,10 @@ options to override the build and test commands. ### References -- [Welcome to Eclipse OpenBSW](https://eclipse-openbsw.github.io/openbsw/sphinx_docs/doc/index.html). -- [Building and Running Unit Tests.](https://eclipse-openbsw.github.io/openbsw/sphinx_docs/doc/learning/unit_tests/index.html). -- [POSIX Platform](https://eclipse-openbsw.github.io/openbsw/sphinx_docs/doc/learning/setup/setup_posix_ubuntu_build.html#setup-posix-ubuntu-build). -- [NXP S32K148 Platform](https://eclipse-openbsw.github.io/openbsw/sphinx_docs/doc/learning/setup/setup_s32k148_ubuntu_build.html). +- [Welcome to Eclipse OpenBSW](https://eclipse-openbsw.github.io/openbsw/sphinx_docs/doc/dev/index.html). +- [Building and Running Unit Tests.](https://eclipse-openbsw.github.io/openbsw/sphinx_docs/doc/dev/learning/unit_tests/index.html). +- [POSIX Platform](ttps://eclipse-openbsw.github.io/openbsw/sphinx_docs/doc/dev/learning/setup/setup_posix_build.html#setup-posix-build). +- [NXP S32K148 Platform](https://eclipse-openbsw.github.io/openbsw/sphinx_docs/doc/dev/learning/setup/setup_s32k148_ubuntu_build.htmll). - [OpenBSW GitHub repo](https://github.com/eclipse-openbsw/openbsw.git). ## Prerequisites @@ -73,6 +73,20 @@ This provides the branch/tag revision for the OpenBSW repository. Optional parameter that allows the user to include additional commands to run after the repository has been cloned. Useful to pin OpenBSW to a particular sha1. +### `RTOS_PLATFORM` + +RTOS Kernel implementation, e.g. `threadx` or `freertos`, currently supported. + +Note: Ensure the selected toolchain supports the specific kernel port. + +### `BUILD_CONFIG` + +Compilation profile, `debug` or `release`. + +### `TOOLCHAIN` + +Select the Compiler Toolchain for the build. GCC is standard; Clang provides enhanced static analysis. + ### `IMAGE_TAG` Specifies the name of the Docker image to be used when running this job. @@ -133,7 +147,7 @@ directory. e.g. `UNIT_TEST_TARGET` set to `bspTest` use the following override: -`ctest --test-dir build/tests/Debug/libs/bsw/bsp/test/gtest --parallel ${CMAKE_SYNC_JOBS}` +`ctest --test-dir build/tests/posix/Debug/libs/bsw/bsp/test--parallel ${CMAKE_SYNC_JOBS}` ### `BUILD_POSIX` @@ -193,6 +207,21 @@ The override must be a full GCS URI, including the `gs://` prefix, bucket name, `gs://${OPENBSW_BUILD_BUCKET_ROOT_NAME}/OpenBSW/Releases/010129` +### `ENABLE_GEMINI_AI_ASSISTANT` + +Enable Gemini AI to support in diagnosis of build and test failures. + +### Gemini prompts + +The job uses prompt files from the repository only; there is no Jenkins parameter to override them. Sequenced prompts (order matters): `prompt/sequenced/step1_triage.txt`, `step2_rca.txt`, `step3_fixes.txt`. Outputs: `step1_output.md`, `step2_output.md`, `step3_output.md`. + +### `GEMINI_COMMAND_LINE` + +Interface for the headless [gemini-cli](https://geminicli.com/docs/cli/headless/). +Use this to specify settings such as the [Gemini model](https://ai.google.dev/gemini-api/docs/models) etc, e.g. +`--debug` to include debug output. +Note: Prompts are piped via `stdin` and output is redirected to a JSON file. + ## SYSTEM VARIABLES There are a number of system environment variables that are unique to each platform but required by Jenkins build, test and environment pipelines. @@ -216,16 +245,22 @@ These are as follows: - `HORIZON_DOMAIN` - The URL domain which is required by pipeline jobs to derive URL for tools and GCP. -- `HORIZON_GIT_URL` +- `HORIZON_SCM_URL` - The URL to the Horizon SDV git repository. -- `HORIZON_GIT_BRANCH` - - The branch name the job will be configured for from `HORIZON_GIT_URL`. +- `HORIZON_SCM_BRANCH` + - The branch name the job will be configured for from `HORIZON_SCM_URL`. - `JENKINS_SERVICE_ACCOUNT` - Service account to use for pipelines. Required to ensure correct roles and permissions for GCP resources. -## Known Limitations +## Known Limitations + +### Gemini AI assistant (`ENABLE_GEMINI_AI_ASSISTANT`) + +- **Experimental:** Gemini-assisted diagnosis in this pipeline is experimental; behavior, quality, and availability can change without notice. +- **Examples only:** Repository prompts and skills are **illustrative examples**—tune, replace, or disable them for your environment. +- **Upstream issues:** Problems may come from [Gemini CLI](https://github.com/google-gemini/gemini-cli) itself; see [open issues](https://github.com/google-gemini/gemini-cli/issues) for known bugs and workarounds. **Document Generation:** diff --git a/docs/workloads/openbsw/environment/delete_mtkc_testbench.md b/docs/workloads/openbsw/environment/delete_mtkc_testbench.md index a5da58af..295d7a29 100644 --- a/docs/workloads/openbsw/environment/delete_mtkc_testbench.md +++ b/docs/workloads/openbsw/environment/delete_mtkc_testbench.md @@ -53,11 +53,11 @@ These are as follows: - `HORIZON_DOMAIN` - The URL domain which is required by pipeline jobs to derive URL for tools and GCP. -- `HORIZON_GIT_URL` +- `HORIZON_SCM_URL` - The URL to the Horizon SDV git repository. -- `HORIZON_GIT_BRANCH` - - The branch name the job will be configured for from `HORIZON_GIT_URL`. +- `HORIZON_SCM_BRANCH` + - The branch name the job will be configured for from `HORIZON_SCM_URL`. - `JENKINS_SERVICE_ACCOUNT` - Service account to use for pipelines. Required to ensure correct roles and permissions for GCP resources. diff --git a/docs/workloads/openbsw/environment/dev_instance.md b/docs/workloads/openbsw/environment/dev_instance.md index a6dc47a1..95d6ff56 100644 --- a/docs/workloads/openbsw/environment/dev_instance.md +++ b/docs/workloads/openbsw/environment/dev_instance.md @@ -79,11 +79,11 @@ These are as follows: - `HORIZON_DOMAIN` - The URL domain which is required by pipeline jobs to derive URL for tools and GCP. -- `HORIZON_GIT_URL` +- `HORIZON_SCM_URL` - The URL to the Horizon SDV git repository. -- `HORIZON_GIT_BRANCH` - - The branch name the job will be configured for from `HORIZON_GIT_URL`. +- `HORIZON_SCM_BRANCH` + - The branch name the job will be configured for from `HORIZON_SCM_URL`. - `JENKINS_SERVICE_ACCOUNT` - Service account to use for pipelines. Required to ensure correct roles and permissions for GCP resources. diff --git a/docs/workloads/openbsw/environment/docker_image_template.md b/docs/workloads/openbsw/environment/docker_image_template.md index 73614ff1..5e94bd25 100644 --- a/docs/workloads/openbsw/environment/docker_image_template.md +++ b/docs/workloads/openbsw/environment/docker_image_template.md @@ -60,38 +60,18 @@ Define the Linux Distribution to create the Docker image from. Values must be su User may override the default ARM GNU toolchain that will be installed in the Docker image and used for builds. Available toolchains are provided under [Arm GNU Toolchain Downloads](https://developer.arm.com/downloads/-/arm-gnu-toolchain-downloads). -### `CLANG_TOOLS_URL` - -URL of the CLANG tools to install in the Docker image. - -### `CMAKE_URL` - -URL of the CMAKE shell script to install in the Docker image. - ### `LLVM_ARM_TOOLCHAIN_URL` URL of LLVM Embedded Toolchain for Arm. -### `LLVM_PROJECT_URL` - -URL of LLVM Compiler Infrastructure. - ### `NODEJS_VERSION` The NodeJS version to install in the Docker image. This is required in order to use MTK Connect with the container. -### `PYELFTOOLS_VERSION` - -pyelftools package version to install - ### `PYTHON_VERSION` Python version version to install -### `SSCACHE_URL` - -URL of Shared Compilation Cache. - ### `TREEFMT_URL` URL of the treefmt tools to install in the Docker image. @@ -114,6 +94,15 @@ Define `latest` if wishing to use the latest available version. Version of `kubectl` to install. The version is typically `1:${GCLOUD_CLI_VERSION}`. Define `latest` if wishing to use the latest available version. +### `ENABLE_GEMINI_AI_ASSISTANT` + +Enable Gemini AI to support in diagnosis of build and test failures. + +### `GEMINI_CLI_VERSION` + +The version of gemini-cli to be installed. +Run `npm view @google/gemini-cli versions` for a full list of valid versions. + ## SYSTEM VARIABLES There are a number of system environment variables that are unique to each platform but required by Jenkins build, test and environment pipelines. @@ -131,11 +120,11 @@ These are as follows: - `CLOUD_REGION` - The GCP project region. Important for bucket, registry paths used in pipelines. -- `HORIZON_GIT_URL` +- `HORIZON_SCM_URL` - The URL to the Horizon SDV git repository. -- `HORIZON_GIT_BRANCH` - - The branch name the job will be configured for from `HORIZON_GIT_URL`. +- `HORIZON_SCM_BRANCH` + - The branch name the job will be configured for from `HORIZON_SCM_URL`. - `JENKINS_SERVICE_ACCOUNT` - Service account to use for pipelines. Required to ensure correct roles and permissions for GCP resources. diff --git a/docs/workloads/openbsw/tests/posix.md b/docs/workloads/openbsw/tests/posix.md index 55368915..fffce5c0 100644 --- a/docs/workloads/openbsw/tests/posix.md +++ b/docs/workloads/openbsw/tests/posix.md @@ -92,11 +92,11 @@ These are as follows: - `HORIZON_DOMAIN` - The URL domain which is required by pipeline jobs to derive URL for tools and GCP. -- `HORIZON_GIT_URL` +- `HORIZON_SCM_URL` - The URL to the Horizon SDV git repository. -- `HORIZON_GIT_BRANCH` - - The branch name the job will be configured for from `HORIZON_GIT_URL`. +- `HORIZON_SCM_BRANCH` + - The branch name the job will be configured for from `HORIZON_SCM_URL`. - `JENKINS_SERVICE_ACCOUNT` - Service account to use for pipelines. Required to ensure correct roles and permissions for GCP resources. diff --git a/docs/workloads/seed.md b/docs/workloads/seed.md index c8ce76bd..f5458054 100644 --- a/docs/workloads/seed.md +++ b/docs/workloads/seed.md @@ -100,6 +100,27 @@ This provides the URL for the OpenBSW repository. Such as: ### `OPENBSW_GIT_BRANCH` This provides the branch/tag revision for the OpenBSW repository. +### `NODEJS_VERSION` +Version of NodeJS to install across workloads. + +### `ENABLE_GEMINI_AI_ASSISTANT` +Enable Gemini AI to support in diagnosis of build and test failures. + +### `GEMINI_PREVIEW_FEATURES` +Enable preview features to experiment with alternative model versions. +For known issues and recommended settings, see [Gemini CLI integration](common/agentic-ai/gemini.md#known-issues). + +### `GEMINI_LOCATION_GLOBAL` +Change to global endpoint to allow for additional models. +For known issues and recommended settings, see [Gemini CLI integration](common/agentic-ai/gemini.md#known-issues). + +### `GEMINI_MODEL` +Override the model to use. + +### `GEMINI_CLI_VERSION` +The version of gemini-cli to be installed. +Run `npm view @google/gemini-cli versions` for a full list of valid versions. + ### Groovy Scripts This job uses the "Authorize Project" plugin to set an authorization property, allowing the job to run as the user who triggered the build. This is configured as follows: @@ -138,8 +159,8 @@ def replacements = [ ['${CLOUD_REGION}', "${CLOUD_REGION}"], ['${CLOUD_PROJECT}', "${CLOUD_PROJECT}"], ['${HORIZON_DOMAIN}', "${HORIZON_DOMAIN}"], - ['${HORIZON_GIT_URL}', "${HORIZON_GIT_URL}"], - ['${HORIZON_GIT_BRANCH}', "${HORIZON_GIT_BRANCH}"], + ['${HORIZON_SCM_URL}', "${HORIZON_SCM_URL}"], + ['${HORIZON_SCM_BRANCH}', "${HORIZON_SCM_BRANCH}"], ['${HEADER_STYLE}', "${HEADER_STYLE}"], ['${SEPARATOR_STYLE}', "${SEPARATOR_STYLE}"]] ``` diff --git a/docs/workloads/utilities/docker_image_template.md b/docs/workloads/utilities/docker_image_template.md index ba1db5d0..29959afd 100644 --- a/docs/workloads/utilities/docker_image_template.md +++ b/docs/workloads/utilities/docker_image_template.md @@ -71,11 +71,11 @@ These are as follows: - `CLOUD_REGION` - The GCP project region. Important for bucket, registry paths used in pipelines. -- `HORIZON_GIT_URL` +- `HORIZON_SCM_URL` - The URL to the Horizon SDV git repository. -- `HORIZON_GIT_BRANCH` - - The branch name the job will be configured for from `HORIZON_GIT_URL`. +- `HORIZON_SCM_BRANCH` + - The branch name the job will be configured for from `HORIZON_SCM_URL`. - `JENKINS_SERVICE_ACCOUNT` - Service account to use for pipelines. Required to ensure correct roles and permissions for GCP resources. diff --git a/docs/workloads/utilities/gemini_ai_assistant.md b/docs/workloads/utilities/gemini_ai_assistant.md new file mode 100644 index 00000000..dd949d1e --- /dev/null +++ b/docs/workloads/utilities/gemini_ai_assistant.md @@ -0,0 +1,52 @@ +# Gemini AI Assistant utility + +Jenkins job that runs the Gemini AI assistant CLI against user-provided artifacts. You supply a command to fetch artifacts (e.g. from GCS) and a prompt file; the job runs the CLI in headless mode and archives the output. + +## Parameters + +- **GEMINI_ARTIFACTS_COMMAND** (required) – Command to populate the workspace with content to analyse (e.g. `gcloud storage cp -r gs://bucket/path/ .`). +- **GEMINI_PROMPT_FILE** (required) – Step 1 prompt file (upload only). +- **GEMINI_PROMPT_FILE_2** (optional) – Step 2 prompt file (upload). Required only for sequenced analysis; when set with step 1, runs two or three steps with context chaining (order matters). We do not ship single-prompt files. +- **GEMINI_PROMPT_FILE_3** (optional) – Step 3 prompt file (upload). For three-step sequenced analysis. +- **GEMINI_COMMAND_LINE** – Full Gemini CLI invocation (e.g. `gemini --yolo --output-format json`). To pin the model and avoid auto-routing to preview-only models, add `--model ` (e.g. `--model gemini-2.5-pro`). +- **GEMINI_SKILLS_YAML** (optional) – Upload `skills.yaml` (file parameter, like prompts). When provided, the job decodes it and converts to `.gemini/skills/*/SKILL.md` before analysis. Use when prompts are uploaded so the job cannot auto-detect the prompt dir. + +Prompts are provided only via upload; there are no workspace path parameters. + +### Why `skills.yaml` and `gemini_skills_from_yaml.py` (not only `SKILL.md` files) + +The Gemini CLI discovers skills under **`.gemini/skills//SKILL.md`**, with YAML frontmatter (`name`, `description`) and a Markdown body. You *could* maintain those directories by hand and skip the converter. + +This repo uses **`skills.yaml` plus `gemini_skills_from_yaml.py`** because: + +- **Single source of truth** – One file holds **global_constraints** (prepended to every skill) and all step skills. Shared rules are not duplicated or edited in three separate Markdown files. +- **Structured fields** – Per-skill `output_schema`, `system_instructions`, and descriptions stay explicit; the script appends **Expected Output Format** from `output_schema` consistently. +- **Automation** – `gemini_initialise.sh` runs the converter when `skills.yaml` is present (or when `GEMINI_SKILLS_YAML` is set), so `.gemini/skills/` is regenerated before each run with no manual copy step. +- **Upload-friendly jobs** – For this utility, **`GEMINI_SKILLS_YAML`** can be supplied as base64 or a path; generating the CLI layout from YAML is simpler than uploading three separate `SKILL.md` trees. + +The CLI expects **one directory per skill**, not one monolithic `skills.md`. A single mega-file would still need splitting to match that layout; YAML keeps editing in one place while matching the expected on-disk shape. + +## Artifacts + +- `gemini-assist/*` – Analysis output (e.g. proposed fixes). +- `headless_output*.json` – Raw CLI output. +- `step1_output.md`, `step2_output.md`, `step3_output.md` – Extracted step outputs when using sequenced prompts. +- `gemini-client-error.zip` – Error report if the CLI fails. + +## Offline analysis & prompt/skill iteration + +Use this utility against archived artifacts from any pipeline — Build, CVD Launcher, CTS Execution — to analyse passes or failures without re-running the original job, and as a sandbox for prompt and `skills.yaml` development. + +- **Pick your artifacts.** Set `GEMINI_ARTIFACTS_COMMAND` to fetch the archived workspace or `test-results/` of the run you want to investigate (success or failure). Examples: + - `gcloud storage cp -r gs://${ANDROID_BUILD_BUCKET_ROOT_NAME}/Android/Tests/CVD_Launcher//test-results/ .` + - `gcloud storage cp -r gs://${ANDROID_BUILD_BUCKET_ROOT_NAME}/Android/Tests/CTS_Execution//test-results/ .` +- **Pick the matching prompts** from the relevant pipeline's `prompt/sequenced/` directory (`step1_triage.txt` → `GEMINI_PROMPT_FILE`, `step2_rca.txt` → `_2`, `step3_fixes.txt` → `_3`) and its `skills.yaml` → `GEMINI_SKILLS_YAML`. +- **For Cuttlefish/virtual-device focus, always use the CVD Launcher prompts** (not the CTS Execution set). Their Phase 0 boot preflight classifies `CVD_STATUS` (`BOOT_OK` / `BOOT_FAILED` / `BOOT_UNKNOWN`) from artifact-only signals (`"status":"Running"` in host CVD JSON, `VIRTUAL_DEVICE_BOOT_COMPLETED` per guest), so the same prompts auto-route between the runtime-health lane and the boot-failure lane — covering CVD logs whether captured by CVD Launcher or by CTS Execution. +- **Iterate on prompts and skills.** Edit `step*_.txt` or `skills.yaml` locally, re-upload, re-run against the same archived artifacts, and compare `step*_output.md` until happy — then promote the changes back into the source pipeline's `prompt/sequenced/`. + +## See also + +- Scripts: `workloads/common/agentic-ai/gemini/` (e.g. `gemini_analysis.sh`). +- AAOS Builder AI review: `workloads/android/pipelines/builds/aaos_builder/` (sequenced prompts for deep scan). +- CVD Launcher AI Review: [`docs/workloads/android/tests/cvd_launcher.md`](../android/tests/cvd_launcher.md#gemini_analyse_on_success). +- CTS Execution AI Review: [`docs/workloads/android/tests/cts_execution.md`](../android/tests/cts_execution.md#gemini_analyse_on_success). diff --git a/docs/workloads/utilities/storage/gcs/filter_objects_by_metadata.md b/docs/workloads/utilities/storage/gcs/filter_objects_by_metadata.md index abbe21de..5a59890d 100644 --- a/docs/workloads/utilities/storage/gcs/filter_objects_by_metadata.md +++ b/docs/workloads/utilities/storage/gcs/filter_objects_by_metadata.md @@ -8,7 +8,7 @@ ## Introduction -This job allows the user to list all objects in a bucket path based on the metadata that is set on them. +This job allows the user to list all objects that match the specified bucket path based on the metadata that is set on them. The user can choose to list objects with specific metadata (matching the provided key and/or value), objects with any metadata or objects with no metadata. @@ -22,7 +22,7 @@ Run the `Jenkins → Utilities → Docker Image Template` to create a container ### `BUCKET_PATH` -Path to desired folder (ending with / or /*) (e.g. `gs://bucketname/path/`) +Path to desired folder - can contain any number of wildcard characters (e.g. `gs://bucketname/path/` or `gs://bucketname/subpath/*x86*`) ### `FILTER_TYPE` Type of filtering to be done: Specific Metadata, Any Metadata, No Metadata @@ -53,11 +53,11 @@ These are as follows: - `CLOUD_REGION` - The GCP project region. Important for bucket, registry paths used in pipelines. -- `HORIZON_GIT_URL` +- `HORIZON_SCM_URL` - The URL to the Horizon SDV git repository. -- `HORIZON_GIT_BRANCH` - - The branch name the job will be configured for from `HORIZON_GIT_URL`. +- `HORIZON_SCM_BRANCH` + - The branch name the job will be configured for from `HORIZON_SCM_URL`. - `JENKINS_SERVICE_ACCOUNT` - Service account to use for pipelines. Required to ensure correct roles and permissions for GCP resources. diff --git a/docs/workloads/utilities/storage/gcs/filtered_objects_delete.md b/docs/workloads/utilities/storage/gcs/filtered_objects_delete.md index 52f863b6..d1886bd0 100644 --- a/docs/workloads/utilities/storage/gcs/filtered_objects_delete.md +++ b/docs/workloads/utilities/storage/gcs/filtered_objects_delete.md @@ -8,7 +8,7 @@ ## Introduction -This job allows the user to delete all objects in a bucket path based on the metadata that is set on them. +This job allows the user to delete all objects that match the specified bucket path based on the metadata that is set on them. The user can choose to delete objects with specific metadata (matching the provided key and/or value), objects with any metadata or objects with no metadata. @@ -22,7 +22,7 @@ Run the `Jenkins → Utilities → Docker Image Template` to create a container ### `BUCKET_PATH` -Path to desired folder (ending with / or /*) (e.g. `gs://bucketname/path/`) +Path to desired folder - can contain any number of wildcard characters (e.g. `gs://bucketname/path/` or `gs://bucketname/subpath/*x86*`) ### `FILTER_TYPE` Type of filtering to be done: Specific Metadata, Any Metadata, No Metadata @@ -59,11 +59,11 @@ These are as follows: - `CLOUD_REGION` - The GCP project region. Important for bucket, registry paths used in pipelines. -- `HORIZON_GIT_URL` +- `HORIZON_SCM_URL` - The URL to the Horizon SDV git repository. -- `HORIZON_GIT_BRANCH` - - The branch name the job will be configured for from `HORIZON_GIT_URL`. +- `HORIZON_SCM_BRANCH` + - The branch name the job will be configured for from `HORIZON_SCM_URL`. - `JENKINS_SERVICE_ACCOUNT` - Service account to use for pipelines. Required to ensure correct roles and permissions for GCP resources. diff --git a/docs/workloads/utilities/storage/gcs/filtered_objects_move.md b/docs/workloads/utilities/storage/gcs/filtered_objects_move.md index de3fac62..50fc8481 100644 --- a/docs/workloads/utilities/storage/gcs/filtered_objects_move.md +++ b/docs/workloads/utilities/storage/gcs/filtered_objects_move.md @@ -22,7 +22,7 @@ Run the `Jenkins → Utilities → Docker Image Template` to create a container ### `BUCKET_PATH` -Path to desired folder (ending with / or /*) (e.g. `gs://bucketname/path/`) +Path to desired folder - can contain any number of wildcard characters (e.g. `gs://bucketname/path/` or `gs://bucketname/subpath/*x86*`) ### `FILTER_TYPE` Type of filtering to be done: Specific Metadata, Any Metadata, No Metadata @@ -58,11 +58,11 @@ These are as follows: - `CLOUD_REGION` - The GCP project region. Important for bucket, registry paths used in pipelines. -- `HORIZON_GIT_URL` +- `HORIZON_SCM_URL` - The URL to the Horizon SDV git repository. -- `HORIZON_GIT_BRANCH` - - The branch name the job will be configured for from `HORIZON_GIT_URL`. +- `HORIZON_SCM_BRANCH` + - The branch name the job will be configured for from `HORIZON_SCM_URL`. - `JENKINS_SERVICE_ACCOUNT` - Service account to use for pipelines. Required to ensure correct roles and permissions for GCP resources. diff --git a/docs/workloads/utilities/storage/gcs/filtered_objects_remove_metadata.md b/docs/workloads/utilities/storage/gcs/filtered_objects_remove_metadata.md index 5b32ba7e..4b960825 100644 --- a/docs/workloads/utilities/storage/gcs/filtered_objects_remove_metadata.md +++ b/docs/workloads/utilities/storage/gcs/filtered_objects_remove_metadata.md @@ -20,7 +20,7 @@ Run the `Jenkins → Utilities → Docker Image Template` to create a container ### `BUCKET_PATH` -Path to desired folder (ending with / or /*) (e.g. `gs://bucketname/path/`) +Path to desired folder - can contain any number of wildcard characters (e.g. `gs://bucketname/path/` or `gs://bucketname/subpath/*x86*`) ### `KEY_OR_KEYVALUE_PAIR` @@ -46,11 +46,11 @@ These are as follows: - `CLOUD_REGION` - The GCP project region. Important for bucket, registry paths used in pipelines. -- `HORIZON_GIT_URL` +- `HORIZON_SCM_URL` - The URL to the Horizon SDV git repository. -- `HORIZON_GIT_BRANCH` - - The branch name the job will be configured for from `HORIZON_GIT_URL`. +- `HORIZON_SCM_BRANCH` + - The branch name the job will be configured for from `HORIZON_SCM_URL`. - `JENKINS_SERVICE_ACCOUNT` - Service account to use for pipelines. Required to ensure correct roles and permissions for GCP resources. diff --git a/docs/workloads/utilities/storage/gcs/filtered_objects_update_metadata.md b/docs/workloads/utilities/storage/gcs/filtered_objects_update_metadata.md index e6a001e2..4d368b7a 100644 --- a/docs/workloads/utilities/storage/gcs/filtered_objects_update_metadata.md +++ b/docs/workloads/utilities/storage/gcs/filtered_objects_update_metadata.md @@ -20,7 +20,7 @@ Run the `Jenkins → Utilities → Docker Image Template` to create a container ### `BUCKET_PATH` -Path to desired folder (ending with / or /*) (e.g. `gs://bucketname/path/`) +Path to desired folder - can contain any number of wildcard characters (e.g. `gs://bucketname/path/` or `gs://bucketname/subpath/*x86*`) ### `KEY_OR_KEYVALUE_PAIR` @@ -51,11 +51,11 @@ These are as follows: - `CLOUD_REGION` - The GCP project region. Important for bucket, registry paths used in pipelines. -- `HORIZON_GIT_URL` +- `HORIZON_SCM_URL` - The URL to the Horizon SDV git repository. -- `HORIZON_GIT_BRANCH` - - The branch name the job will be configured for from `HORIZON_GIT_URL`. +- `HORIZON_SCM_BRANCH` + - The branch name the job will be configured for from `HORIZON_SCM_URL`. - `JENKINS_SERVICE_ACCOUNT` - Service account to use for pipelines. Required to ensure correct roles and permissions for GCP resources. diff --git a/docs/workloads/utilities/storage/gcs/object_add_metadata.md b/docs/workloads/utilities/storage/gcs/object_add_metadata.md index 9f6e02ad..236d88e5 100644 --- a/docs/workloads/utilities/storage/gcs/object_add_metadata.md +++ b/docs/workloads/utilities/storage/gcs/object_add_metadata.md @@ -25,7 +25,7 @@ Path to the desired object in GCS (e.g. `gs://bucketname/path/objectname`) or -Path to desired folder in GCS (ending with / or /*) (e.g. `gs://bucketname/path/`) +Path to desired folder - can contain any number of wildcard characters (e.g. `gs://bucketname/path/` or `gs://bucketname/subpath/*x86*`) ### `KEYVALUE_PAIRS` @@ -49,11 +49,11 @@ These are as follows: - `CLOUD_REGION` - The GCP project region. Important for bucket, registry paths used in pipelines. -- `HORIZON_GIT_URL` +- `HORIZON_SCM_URL` - The URL to the Horizon SDV git repository. -- `HORIZON_GIT_BRANCH` - - The branch name the job will be configured for from `HORIZON_GIT_URL`. +- `HORIZON_SCM_BRANCH` + - The branch name the job will be configured for from `HORIZON_SCM_URL`. - `JENKINS_SERVICE_ACCOUNT` - Service account to use for pipelines. Required to ensure correct roles and permissions for GCP resources. diff --git a/docs/workloads/utilities/storage/gcs/object_list_metadata.md b/docs/workloads/utilities/storage/gcs/object_list_metadata.md index 96953215..a0126524 100644 --- a/docs/workloads/utilities/storage/gcs/object_list_metadata.md +++ b/docs/workloads/utilities/storage/gcs/object_list_metadata.md @@ -24,7 +24,7 @@ Path to the desired object in GCS (e.g. `gs://bucketname/path/objectname`) or -Path to desired folder in GCS (ending with / or /*) (e.g. `gs://bucketname/path/`) +Path to desired folder - can contain any number of wildcard characters (e.g. `gs://bucketname/path/` or `gs://bucketname/subpath/*x86*`) ## SYSTEM VARIABLES @@ -42,11 +42,11 @@ These are as follows: - `CLOUD_REGION` - The GCP project region. Important for bucket, registry paths used in pipelines. -- `HORIZON_GIT_URL` +- `HORIZON_SCM_URL` - The URL to the Horizon SDV git repository. -- `HORIZON_GIT_BRANCH` - - The branch name the job will be configured for from `HORIZON_GIT_URL`. +- `HORIZON_SCM_BRANCH` + - The branch name the job will be configured for from `HORIZON_SCM_URL`. - `JENKINS_SERVICE_ACCOUNT` - Service account to use for pipelines. Required to ensure correct roles and permissions for GCP resources. diff --git a/docs/workloads/utilities/storage/gcs/object_list_storage_class.md b/docs/workloads/utilities/storage/gcs/object_list_storage_class.md index 81224937..a9268b0d 100644 --- a/docs/workloads/utilities/storage/gcs/object_list_storage_class.md +++ b/docs/workloads/utilities/storage/gcs/object_list_storage_class.md @@ -24,7 +24,7 @@ Path to the desired object in GCS (e.g. `gs://bucketname/path/objectname`) or -Path to desired folder in GCS (ending with / or /*) (e.g. `gs://bucketname/path/`) +Path to desired folder - can contain any number of wildcard characters (e.g. `gs://bucketname/path/` or `gs://bucketname/subpath/*x86*`) ## SYSTEM VARIABLES @@ -42,11 +42,11 @@ These are as follows: - `CLOUD_REGION` - The GCP project region. Important for bucket, registry paths used in pipelines. -- `HORIZON_GIT_URL` +- `HORIZON_SCM_URL` - The URL to the Horizon SDV git repository. -- `HORIZON_GIT_BRANCH` - - The branch name the job will be configured for from `HORIZON_GIT_URL`. +- `HORIZON_SCM_BRANCH` + - The branch name the job will be configured for from `HORIZON_SCM_URL`. - `JENKINS_SERVICE_ACCOUNT` - Service account to use for pipelines. Required to ensure correct roles and permissions for GCP resources. diff --git a/docs/workloads/utilities/storage/gcs/object_remove_metadata.md b/docs/workloads/utilities/storage/gcs/object_remove_metadata.md index 62de8c4f..11b23461 100644 --- a/docs/workloads/utilities/storage/gcs/object_remove_metadata.md +++ b/docs/workloads/utilities/storage/gcs/object_remove_metadata.md @@ -24,7 +24,7 @@ Path to the desired object in GCS (e.g. `gs://bucketname/path/objectname`) or -Path to desired folder in GCS (ending with / or /*) (e.g. `gs://bucketname/path/`) +Path to desired folder - can contain any number of wildcard characters (e.g. `gs://bucketname/path/` or `gs://bucketname/subpath/*x86*`) ### `REMOVE_ALL` @@ -53,11 +53,11 @@ These are as follows: - `CLOUD_REGION` - The GCP project region. Important for bucket, registry paths used in pipelines. -- `HORIZON_GIT_URL` +- `HORIZON_SCM_URL` - The URL to the Horizon SDV git repository. -- `HORIZON_GIT_BRANCH` - - The branch name the job will be configured for from `HORIZON_GIT_URL`. +- `HORIZON_SCM_BRANCH` + - The branch name the job will be configured for from `HORIZON_SCM_URL`. - `JENKINS_SERVICE_ACCOUNT` - Service account to use for pipelines. Required to ensure correct roles and permissions for GCP resources. diff --git a/gitops/apps/argo-events-resources/Chart.yaml b/gitops/apps/argo-events-resources/Chart.yaml new file mode 100644 index 00000000..9afec7e6 --- /dev/null +++ b/gitops/apps/argo-events-resources/Chart.yaml @@ -0,0 +1,20 @@ +# Copyright (c) 2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +apiVersion: v2 +name: argo-events-resources +description: EventBus and EventSource resources for platform Argo Events +version: 0.1.0 +type: application +appVersion: "0.1.0" diff --git a/gitops/apps/argo-events-resources/templates/eventbus.yaml b/gitops/apps/argo-events-resources/templates/eventbus.yaml new file mode 100644 index 00000000..722bed6d --- /dev/null +++ b/gitops/apps/argo-events-resources/templates/eventbus.yaml @@ -0,0 +1,25 @@ +# Copyright (c) 2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +apiVersion: argoproj.io/v1alpha1 +kind: EventBus +metadata: + name: default + namespace: {{ .Values.config.namespacePrefix }}argo-events +spec: + nats: + native: + replicas: 3 + # Use token auth so EventSource and Sensor share eventbus credentials consistently. + auth: token diff --git a/gitops/apps/argo-events-resources/templates/eventsource.yaml b/gitops/apps/argo-events-resources/templates/eventsource.yaml new file mode 100644 index 00000000..dbf192a4 --- /dev/null +++ b/gitops/apps/argo-events-resources/templates/eventsource.yaml @@ -0,0 +1,30 @@ +# Copyright (c) 2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +apiVersion: argoproj.io/v1alpha1 +kind: EventSource +metadata: + name: webhook + namespace: {{ .Values.config.namespacePrefix }}argo-events +spec: + eventBusName: default + service: + ports: + - port: 12000 + targetPort: 12000 + webhook: + workflow-dispatch: + port: "12000" + endpoint: /events/workflow + method: POST diff --git a/gitops/apps/argo-events-resources/values.yaml b/gitops/apps/argo-events-resources/values.yaml new file mode 100644 index 00000000..b0b3fd92 --- /dev/null +++ b/gitops/apps/argo-events-resources/values.yaml @@ -0,0 +1,16 @@ +# Copyright (c) 2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +config: + namespacePrefix: "" diff --git a/gitops/apps/argo-workflows-github-app/Chart.yaml b/gitops/apps/argo-workflows-github-app/Chart.yaml new file mode 100644 index 00000000..ea15e709 --- /dev/null +++ b/gitops/apps/argo-workflows-github-app/Chart.yaml @@ -0,0 +1,20 @@ +# Copyright (c) 2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +apiVersion: v2 +name: argo-workflows-github-app +description: GitHub App workflow resources for Argo Workflows (ClusterWorkflowTemplates and supporting resources) +version: 0.1.0 +type: application +appVersion: "0.1.0" diff --git a/gitops/apps/argo-workflows-github-app/values.yaml b/gitops/apps/argo-workflows-github-app/values.yaml new file mode 100644 index 00000000..b0b3fd92 --- /dev/null +++ b/gitops/apps/argo-workflows-github-app/values.yaml @@ -0,0 +1,16 @@ +# Copyright (c) 2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +config: + namespacePrefix: "" diff --git a/gitops/apps/horizon-api/Chart.yaml b/gitops/apps/horizon-api/Chart.yaml new file mode 100644 index 00000000..fabf3af0 --- /dev/null +++ b/gitops/apps/horizon-api/Chart.yaml @@ -0,0 +1,20 @@ +# Copyright (c) 2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +apiVersion: v2 +name: horizon-api +description: Horizon API — catalog of exposed WorkflowTemplates and authenticated submission +version: 0.2.0 +type: application +appVersion: "0.2.0" diff --git a/gitops/apps/horizon-api/templates/deployment.yaml b/gitops/apps/horizon-api/templates/deployment.yaml new file mode 100644 index 00000000..13dd8547 --- /dev/null +++ b/gitops/apps/horizon-api/templates/deployment.yaml @@ -0,0 +1,92 @@ +# Copyright (c) 2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +apiVersion: apps/v1 +kind: Deployment +metadata: + name: horizon-api + namespace: {{ .Values.namespace }} + labels: + app.kubernetes.io/name: horizon-api +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: horizon-api + template: + metadata: + labels: + app.kubernetes.io/name: horizon-api + spec: + serviceAccountName: horizon-api + containers: + - name: horizon-api + image: {{ .Values.image | quote }} + imagePullPolicy: Always + args: + - "--namespace={{ .Values.namespace }}" + - "--module-manager-namespace={{ .Values.moduleManagerNamespace }}" + - "--module-manager-state-name={{ .Values.moduleManagerStateName }}" + - "--module-manager-catalog-name={{ .Values.moduleManagerCatalogName }}" + {{- $wfNs := .Values.workflowsNamespace | default "workflows" }} + - "--workflows-namespace={{ $wfNs }}" + - "--oidc-issuer-url={{ .Values.oidcIssuerUrl }}" + {{- if .Values.oidcClientId }} + - "--oidc-client-id={{ .Values.oidcClientId }}" + {{- end }} + - "--oidc-skip-client-id-check={{ .Values.oidcSkipClientIdCheck }}" + - "--events-webhook-url={{ .Values.eventsWebhookUrl }}" + # API lives at /api/v1/... ; /workflows/ is only the UI baseHref (requests under /workflows/api/... return HTML SPA). + # Default namespace when parent Application omits argoWorkflowsNamespace (empty → broken host ..svc). + {{- $argoNs := .Values.argoWorkflowsNamespace | default "argo-workflows" }} + - "--argo-base-url=http://argo-workflows-server.{{ $argoNs }}.svc.cluster.local:2746" + {{- if .Values.gcsArtifactBucket }} + - "--gcs-artifact-bucket={{ .Values.gcsArtifactBucket }}" + {{- end }} + {{- if .Values.gcsSigningServiceAccount }} + - "--gcs-signing-service-account={{ .Values.gcsSigningServiceAccount }}" + {{- end }} + - "--workflow-ttl-seconds-after-success={{ .Values.workflowTtlSecondsAfterSuccess }}" + - "--workflow-ttl-seconds-after-failure={{ .Values.workflowTtlSecondsAfterFailure }}" + - "--workflow-ttl-seconds-after-completion={{ .Values.workflowTtlSecondsAfterCompletion }}" + {{- if .Values.workflowDeleteWaitTimeout }} + - "--workflow-delete-wait-timeout={{ .Values.workflowDeleteWaitTimeout }}" + {{- end }} + - "--http-bind-address=:8082" + - "--metrics-bind-address=:8080" + - "--health-probe-bind-address=:8081" + {{- if .Values.gceMetadataHost }} + env: + - name: GCE_METADATA_HOST + value: {{ .Values.gceMetadataHost | quote }} + {{- end }} + ports: + - name: metrics + containerPort: 8080 + - name: health + containerPort: 8081 + - name: http + containerPort: 8082 + livenessProbe: + httpGet: + path: /healthz + port: health + initialDelaySeconds: 15 + periodSeconds: 20 + readinessProbe: + httpGet: + path: /readyz + port: health + initialDelaySeconds: 5 + periodSeconds: 10 diff --git a/gitops/apps/horizon-api/templates/rbac-cluster.yaml b/gitops/apps/horizon-api/templates/rbac-cluster.yaml new file mode 100644 index 00000000..bc0f74d3 --- /dev/null +++ b/gitops/apps/horizon-api/templates/rbac-cluster.yaml @@ -0,0 +1,43 @@ +# Copyright (c) 2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# controller-runtime default cache lists/watches at cluster scope; Roles alone are not enough. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: horizon-api-catalog-read + labels: + app.kubernetes.io/name: horizon-api +rules: + - apiGroups: ["horizon-sdv.io"] + resources: ["modulemanagerstates", "modulecatalogs"] + verbs: ["get", "list", "watch"] + - apiGroups: ["argoproj.io"] + resources: ["workflowtemplates", "clusterworkflowtemplates"] + verbs: ["get", "list", "watch"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: horizon-api-catalog-read + labels: + app.kubernetes.io/name: horizon-api +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: horizon-api-catalog-read +subjects: + - kind: ServiceAccount + name: horizon-api + namespace: {{ .Values.namespace }} diff --git a/gitops/apps/horizon-api/templates/rbac.yaml b/gitops/apps/horizon-api/templates/rbac.yaml new file mode 100644 index 00000000..22732d48 --- /dev/null +++ b/gitops/apps/horizon-api/templates/rbac.yaml @@ -0,0 +1,77 @@ +# Copyright (c) 2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: horizon-api-module-manager + namespace: {{ .Values.moduleManagerNamespace }} + labels: + app.kubernetes.io/name: horizon-api +rules: + - apiGroups: ["horizon-sdv.io"] + resources: + - modulemanagerstates + - modulecatalogs + verbs: ["get", "list", "watch"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: horizon-api-module-manager + namespace: {{ .Values.moduleManagerNamespace }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: horizon-api-module-manager +subjects: + - kind: ServiceAccount + name: horizon-api + namespace: {{ .Values.namespace }} +--- +{{- $workflowsNs := .Values.workflowsNamespace | default "workflows" }} +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: horizon-api-workflows + namespace: {{ $workflowsNs }} + labels: + app.kubernetes.io/name: horizon-api +rules: + - apiGroups: ["argoproj.io"] + resources: ["workflowtemplates"] + verbs: ["get", "list", "watch"] + - apiGroups: ["argoproj.io"] + resources: ["workflows"] + verbs: ["get", "list", "watch", "patch", "delete"] + - apiGroups: [""] + resources: ["pods"] + verbs: ["get", "list", "watch"] + - apiGroups: [""] + resources: ["pods/log"] + verbs: ["get"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: horizon-api-workflows + namespace: {{ $workflowsNs }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: horizon-api-workflows +subjects: + - kind: ServiceAccount + name: horizon-api + namespace: {{ .Values.namespace }} diff --git a/gitops/apps/horizon-api/templates/service.yaml b/gitops/apps/horizon-api/templates/service.yaml new file mode 100644 index 00000000..7d573c9f --- /dev/null +++ b/gitops/apps/horizon-api/templates/service.yaml @@ -0,0 +1,29 @@ +# Copyright (c) 2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +apiVersion: v1 +kind: Service +metadata: + name: horizon-api + namespace: {{ .Values.namespace }} + labels: + app.kubernetes.io/name: horizon-api +spec: + type: ClusterIP + selector: + app.kubernetes.io/name: horizon-api + ports: + - name: http + port: 80 + targetPort: http diff --git a/gitops/apps/horizon-api/templates/serviceaccount.yaml b/gitops/apps/horizon-api/templates/serviceaccount.yaml new file mode 100644 index 00000000..9b7ce55a --- /dev/null +++ b/gitops/apps/horizon-api/templates/serviceaccount.yaml @@ -0,0 +1,25 @@ +# Copyright (c) 2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +apiVersion: v1 +kind: ServiceAccount +metadata: + name: horizon-api + namespace: {{ .Values.namespace }} + labels: + app.kubernetes.io/name: horizon-api + {{- if .Values.gcsSigningServiceAccount }} + annotations: + iam.gke.io/gcp-service-account: {{ .Values.gcsSigningServiceAccount | quote }} + {{- end }} diff --git a/gitops/apps/horizon-api/values.yaml b/gitops/apps/horizon-api/values.yaml new file mode 100644 index 00000000..0805d6c4 --- /dev/null +++ b/gitops/apps/horizon-api/values.yaml @@ -0,0 +1,56 @@ +# Copyright (c) 2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +namespace: horizon-api +# Full image ref; parent Application sets this from config.containerImages.horizonApi. +image: "horizon-api-app:1.0.0" + +moduleManagerNamespace: module-manager +moduleManagerStateName: cluster +moduleManagerCatalogName: cluster + +# Full issuer URL e.g. https://example.com/auth/realms/horizon +oidcIssuerUrl: "" + +workflowsNamespace: "" + +# Optional: restrict OIDC aud/azp (leave empty with skip true) +oidcClientId: "" +oidcSkipClientIdCheck: true + +# Argo Events webhook POST target (required by the app). +eventsWebhookUrl: "" + +# Namespace where Argo Workflows server runs (for live log streaming). +argoWorkflowsNamespace: "" + +# Default GCS bucket for gs:// archived log links when Workflow status omits bucket (match artifactRepository.gcs.bucket). +gcsArtifactBucket: "" + +# Optional: service account email used to sign V4 GET URLs for GET .../downloadArtifact/... (IAM signBlob; Workload Identity). +gcsSigningServiceAccount: "" + +# When set, exported as GCE_METADATA_HOST so Google ADC hits the GKE metadata server. Standard GKE 1.21+: 169.254.169.252:988. +# Leave empty on Dataplane V2 clusters (defaults to 169.254.169.254:80). See cloud.google.com/kubernetes-engine/docs/how-to/network-policy. +gceMetadataHost: "" + +# Echoed on GET /v1/workflows/running and /history (mirror Argo workflowDefaults.ttlStrategy). +workflowTtlSecondsAfterSuccess: 86400 +workflowTtlSecondsAfterFailure: 259200 +workflowTtlSecondsAfterCompletion: 86400 + +# Optional: max duration (Go duration, e.g. 15m) DELETE /v1/workflows/{name} waits for CR removal (finalizers). Empty = binary default (10m). Gateway timeouts may need to exceed this. +workflowDeleteWaitTimeout: "" + +config: {} diff --git a/gitops/apps/horizon-dev-portal/Chart.yaml b/gitops/apps/horizon-dev-portal/Chart.yaml new file mode 100644 index 00000000..0a8539a4 --- /dev/null +++ b/gitops/apps/horizon-dev-portal/Chart.yaml @@ -0,0 +1,21 @@ +# Copyright (c) 2024-2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +apiVersion: v2 +name: horizon-dev-portal +description: Horizon Developer Portal (SPA + API proxy) +type: application +version: 1.0.0 +appVersion: 1.0.0 diff --git a/gitops/apps/horizon-dev-portal/README.md b/gitops/apps/horizon-dev-portal/README.md new file mode 100644 index 00000000..6d1978f4 --- /dev/null +++ b/gitops/apps/horizon-dev-portal/README.md @@ -0,0 +1,13 @@ +# Horizon Developer Portal (GitOps) + +Child chart deployed by [`gitops/templates/horizon-dev-portal.yaml`](../templates/horizon-dev-portal.yaml). + +- **Image**: built from [`terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal`](../../../terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal) (Vite SPA + Go proxy). The parent Application sets the container image from [`gitops/values.yaml`](../../values.yaml) `config.containerImages.horizondevelopmentportal` (same pattern as other workloads; Terraform supplies the full ref per environment). +- **Public path**: `config.publicPath` in this chart (default `/developer-portal`). The parent passes values from `config.horizonDevelopmentPortal.publicPath` so the same value drives the Gateway HTTPRoute, Keycloak redirect URIs, and `PUBLIC_PATH` in the portal ConfigMap. Changing a live cluster may require updating Keycloak redirect URIs for an existing client. +- **Keycloak**: set in **this** chart’s `values.yaml` under `config`, or via parent `config.horizonDevelopmentPortal`: `keycloakRealm`, `keycloakHttpPathPrefix`, `keycloakClientId`, `keycloakTokenPath`, plus full `oidcIssuerUrl` / `keycloakTokenUrl` when not using the parent Application (the parent template derives them from domain + realm + path). `horizonApiCiClientId` must match the Keycloak confidential client used by the Go proxy (`HORIZON_API_CI_CLIENT_ID` in the ConfigMap). +- **In-cluster dependencies**: `moduleManagerBaseUrl` and `horizonApiBaseUrl` default to `http://module-manager.{prefix}module-manager...` and `http://horizon-api.{prefix}horizon-api...`. Override via parent [`gitops/values.yaml`](../../values.yaml) `config.horizonDevelopmentPortal.moduleManagerInternalBaseUrl` / `horizonApiInternalBaseUrl` when Service names differ. +- **Secrets**: the `keycloak-post-horizon-api` post-job writes `HORIZON_API_CI_CLIENT_SECRET` into Secret `{{namespacePrefix}}horizon-dev-portal-secrets` (same Keycloak client as `horizonApiCiClientId`). Optionally set Helm `secret.horizonApiCiClientSecret` to manage that Secret from GitOps instead. + +```bash +helm lint . +``` diff --git a/gitops/apps/horizon-dev-portal/templates/configmap.yaml b/gitops/apps/horizon-dev-portal/templates/configmap.yaml new file mode 100644 index 00000000..b5beb201 --- /dev/null +++ b/gitops/apps/horizon-dev-portal/templates/configmap.yaml @@ -0,0 +1,32 @@ +# Copyright (c) 2024-2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ .Values.config.namespacePrefix }}horizon-dev-portal-config + namespace: {{ .Values.config.namespacePrefix }}horizon-dev-portal +data: + LISTEN_ADDR: ":8080" + OIDC_ISSUER_URL: {{ .Values.config.oidcIssuerUrl | quote }} + KEYCLOAK_TOKEN_URL: {{ .Values.config.keycloakTokenUrl | quote }} + MODULE_MANAGER_BASE_URL: {{ .Values.config.moduleManagerBaseUrl | quote }} + HORIZON_API_BASE_URL: {{ .Values.config.horizonApiBaseUrl | quote }} + PUBLIC_BASE_URL: {{ .Values.config.publicBaseUrl | quote }} + # HTTP path prefix where the SPA is mounted (no trailing slash); must match Gateway and Keycloak redirect URIs. + PUBLIC_PATH: {{ .Values.config.publicPath | quote }} + KEYCLOAK_TOKEN_PATH: {{ .Values.config.keycloakTokenPath | quote }} + KEYCLOAK_CLIENT_ID: {{ .Values.config.keycloakClientId | quote }} + HORIZON_API_CI_CLIENT_ID: {{ .Values.config.horizonApiCiClientId | quote }} diff --git a/gitops/apps/horizon-dev-portal/templates/deployment.yaml b/gitops/apps/horizon-dev-portal/templates/deployment.yaml new file mode 100644 index 00000000..572279da --- /dev/null +++ b/gitops/apps/horizon-dev-portal/templates/deployment.yaml @@ -0,0 +1,67 @@ +# Copyright (c) 2024-2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ .Values.config.namespacePrefix }}horizon-dev-portal + namespace: {{ .Values.config.namespacePrefix }}horizon-dev-portal +spec: + replicas: {{ .Values.replicaCount }} + selector: + matchLabels: + app.kubernetes.io/name: horizon-dev-portal + template: + metadata: + labels: + app.kubernetes.io/name: horizon-dev-portal + annotations: + checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }} + checksum/secret: {{ include (print $.Template.BasePath "/secret.yaml") . | sha256sum }} + {{- with .Values.podAnnotations }} + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + containers: + - name: horizon-dev-portal + image: {{ .Values.config.gitops.horizonDevPortal }} + imagePullPolicy: Always + ports: + - name: http + containerPort: 8080 + protocol: TCP + envFrom: + - configMapRef: + name: {{ .Values.config.namespacePrefix }}horizon-dev-portal-config + env: + - name: HORIZON_API_CI_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: {{ .Values.config.namespacePrefix }}horizon-dev-portal-secrets + key: HORIZON_API_CI_CLIENT_SECRET + readinessProbe: + httpGet: + path: /healthz + port: http + initialDelaySeconds: 5 + periodSeconds: 10 + livenessProbe: + httpGet: + path: /healthz + port: http + initialDelaySeconds: 15 + periodSeconds: 20 + resources: + {{- toYaml .Values.resources | nindent 12 }} diff --git a/gitops/apps/horizon-dev-portal/templates/secret.yaml b/gitops/apps/horizon-dev-portal/templates/secret.yaml new file mode 100644 index 00000000..77b3a2df --- /dev/null +++ b/gitops/apps/horizon-dev-portal/templates/secret.yaml @@ -0,0 +1,25 @@ +# Copyright (c) 2024-2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +{{- if .Values.secret.horizonApiCiClientSecret }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ .Values.config.namespacePrefix }}horizon-dev-portal-secrets + namespace: {{ .Values.config.namespacePrefix }}horizon-dev-portal +type: Opaque +stringData: + HORIZON_API_CI_CLIENT_SECRET: {{ .Values.secret.horizonApiCiClientSecret | quote }} +{{- end }} diff --git a/gitops/apps/horizon-dev-portal/templates/service.yaml b/gitops/apps/horizon-dev-portal/templates/service.yaml new file mode 100644 index 00000000..e847a46a --- /dev/null +++ b/gitops/apps/horizon-dev-portal/templates/service.yaml @@ -0,0 +1,27 @@ +# Copyright (c) 2024-2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +apiVersion: v1 +kind: Service +metadata: + name: {{ .Values.config.namespacePrefix }}horizon-dev-portal + namespace: {{ .Values.config.namespacePrefix }}horizon-dev-portal +spec: + selector: + app.kubernetes.io/name: horizon-dev-portal + ports: + - protocol: TCP + port: {{ .Values.service.port }} + targetPort: http diff --git a/gitops/apps/horizon-dev-portal/values.yaml b/gitops/apps/horizon-dev-portal/values.yaml new file mode 100644 index 00000000..b5dad505 --- /dev/null +++ b/gitops/apps/horizon-dev-portal/values.yaml @@ -0,0 +1,73 @@ +# Copyright (c) 2024-2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +spec: + destination: + server: https://kubernetes.default.svc + +global: + nameOverride: "" + fullnameOverride: "" + +replicaCount: 1 + +podAnnotations: {} + +image: + repository: horizon-dev-portal + tag: "1.0.0" + pullPolicy: IfNotPresent + +resources: + requests: + memory: "128Mi" + cpu: "100m" + limits: + memory: "512Mi" + cpu: "500m" + +secret: + horizonApiCiClientSecret: "" + +config: + namespacePrefix: "" + gitops: + horizonDevPortal: "" + + domain: "" + # Browser URL path prefix for the SPA (leading slash, no trailing slash), e.g. /developer-portal or /portal. + publicPath: "/developer-portal" + # Full browser origin for window.APP_CONFIG.baseUrl (scheme + host, optional port). Do not set this to the Argo Workflows UI path (/workflows); the portal appends /workflows/... for deep links itself. + publicBaseUrl: "" + + # Keycloak / OIDC (browser uses relative keycloakTokenPath; Go proxy uses full issuer + token URLs). + keycloakRealm: "horizon" + keycloakHttpPathPrefix: "/auth" + oidcIssuerUrl: "" + keycloakTokenUrl: "" + # Relative path for window.APP_CONFIG.keycloakUrl (same host as the portal). + keycloakTokenPath: "/auth/realms/horizon/protocol/openid-connect/token" + keycloakClientId: "horizon-dev-portal" + # Confidential client id for Horizon API access from the Go proxy (must match Keycloak + Secret). + horizonApiCiClientId: "horizon-api-ci" + + # In-cluster HTTP base URLs (override if your Service names differ). + moduleManagerBaseUrl: "http://module-manager.module-manager.svc.cluster.local:8082" + horizonApiBaseUrl: "" + +service: + type: ClusterIP + port: 80 + targetPort: 8080 diff --git a/gitops/apps/mcp-gateway-registry/files/scopes.yml b/gitops/apps/mcp-gateway-registry/files/scopes.yml index 1a8e6c79..321f8003 100644 --- a/gitops/apps/mcp-gateway-registry/files/scopes.yml +++ b/gitops/apps/mcp-gateway-registry/files/scopes.yml @@ -125,11 +125,11 @@ mcp-agents-unrestricted/execute: # The names in the list on the right are the names of the permission scopes defined in this file # (e.g., 'mcp-registry-admins' from UI-Scopes or 'mcp-servers-unrestricted/read' from MCP SERVER SCOPES). group_mappings: - horizon-mcp-gateway-registry-admins: + administrators: - mcp-registry-admin # UI scope - mcp-servers-unrestricted/execute # can manage all servers; note: app allows modify_service only when this exact scope name or `/execute` is present in scope name - mcp-agents-unrestricted/execute # can manage all agents - horizon-mcp-gateway-registry-users: + viewers: - mcp-registry-user # UI scope - mcp-servers-unrestricted/read # can view and operate all servers - mcp-agents-unrestricted/read # can view all agents \ No newline at end of file diff --git a/gitops/apps/mcp-gateway-registry/templates/auth-server.yaml b/gitops/apps/mcp-gateway-registry/templates/auth-server.yaml index f9ec497a..ca70e537 100644 --- a/gitops/apps/mcp-gateway-registry/templates/auth-server.yaml +++ b/gitops/apps/mcp-gateway-registry/templates/auth-server.yaml @@ -87,4 +87,4 @@ spec: - protocol: TCP port: {{ .Values.authServer.service.ports.api }} targetPort: 8888 - type: {{ .Values.authServer.service.type }} + type: {{ .Values.authServer.service.type }} \ No newline at end of file diff --git a/gitops/apps/mcp-gateway-registry/templates/configmaps.yaml b/gitops/apps/mcp-gateway-registry/templates/configmaps.yaml index b754b653..d11dce17 100644 --- a/gitops/apps/mcp-gateway-registry/templates/configmaps.yaml +++ b/gitops/apps/mcp-gateway-registry/templates/configmaps.yaml @@ -64,5 +64,4 @@ data: "is_python": false, "license": "MIT", "tool_list": [] - } - + } \ No newline at end of file diff --git a/gitops/apps/module-manager/Chart.yaml b/gitops/apps/module-manager/Chart.yaml new file mode 100644 index 00000000..8fca28af --- /dev/null +++ b/gitops/apps/module-manager/Chart.yaml @@ -0,0 +1,20 @@ +# Copyright (c) 2024-2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +apiVersion: v2 +name: module-manager +description: Horizon SDV Module Manager - controller and REST API for module enable/disable +version: 0.2.2 +type: application +appVersion: "0.2.2" diff --git a/gitops/apps/module-manager/crds/modulecatalog.yaml b/gitops/apps/module-manager/crds/modulecatalog.yaml new file mode 100644 index 00000000..badb91b5 --- /dev/null +++ b/gitops/apps/module-manager/crds/modulecatalog.yaml @@ -0,0 +1,134 @@ +# Copyright (c) 2024-2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: modulecatalogs.horizon-sdv.io + annotations: + helm.sh/hook: crd-install + helm.sh/hook-weight: "-5" +spec: + group: horizon-sdv.io + names: + kind: ModuleCatalog + listKind: ModuleCatalogList + plural: modulecatalogs + singular: modulecatalog + shortNames: + - mcatalog + scope: Namespaced + versions: + - name: v1alpha1 + served: true + storage: true + schema: + openAPIV3Schema: + type: object + required: + - spec + properties: + spec: + type: object + properties: + modules: + type: array + description: List of known modules (name and repo path). + items: + type: object + required: + - name + - path + properties: + name: + type: string + description: Logical module name (e.g. sample-module). + path: + type: string + description: Path to the module chart in the repo (e.g. gitops/modules/sample-module). + overviewPath: + type: string + description: >- + Path to a self-contained HTML file under the module directory (relative to path), + e.g. portal/overview.html, used when packaging the module Helm chart (e.g. Files.Get). + Not used at runtime by Module Manager to fetch overview HTML. + overviewService: + type: string + description: >- + Kubernetes Service name in overviewServiceNamespace that serves the module overview HTML over HTTP on port 80 at path /. + When unset (or overviewServiceNamespace unset), GET /modules/{name}/overview returns 404. + overviewServiceNamespace: + type: string + description: >- + Namespace where overviewService runs. Must match the namespace where the module chart deploys + the overview workload (often the hello-world namespace or prefixed workflows namespace). + Module Manager always uses port 80 and path /. + hardDependencies: + type: array + description: Module names that must be enabled before this module. + items: + type: string + softDependencies: + type: array + description: Optional modules; when enabled, dependents may be reconfigured. + items: + type: string + applications: + type: array + description: >- + Module-deployed applications exposed in the Developer Portal (URLs may be + site-relative paths starting with / or absolute https URLs). + items: + type: object + required: + - id + - url + properties: + id: + type: string + description: Stable id for the application (e.g. hello-world). + title: + type: string + description: Human-readable label for UIs. + url: + type: string + description: >- + Public URL or path. If it starts with /, clients resolve it against + the portal origin; otherwise treated as an absolute URL. + softFeaturesPropagation: + type: string + enum: + - HelmValues + - ConfigMap + - HelmValuesAndConfigMap + description: >- + How Module Manager propagates soft-dependency flags. HelmValues (default): merge + softFeaturesEnabled into the parent Argo CD Application helm values (pod rollout on + toggle). ConfigMap: upsert a ConfigMap only; omit Helm merge for live flags. + HelmValuesAndConfigMap: both (mixed workloads). + softFeaturesConfigMapNamespaces: + type: array + description: >- + Namespaces where Module Manager writes the soft-features ConfigMap when + softFeaturesPropagation is ConfigMap or HelmValuesAndConfigMap. + items: + type: string + autoDisableWhenUnused: + type: boolean + description: >- + When true, Module Manager may automatically disable this module once both + hard and soft dependent counts transition from greater than zero to zero. + status: + type: object + x-kubernetes-preserve-unknown-fields: true diff --git a/gitops/apps/module-manager/crds/modulemanagerstate.yaml b/gitops/apps/module-manager/crds/modulemanagerstate.yaml new file mode 100644 index 00000000..c3ea9f5a --- /dev/null +++ b/gitops/apps/module-manager/crds/modulemanagerstate.yaml @@ -0,0 +1,71 @@ +# Copyright (c) 2024-2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: modulemanagerstates.horizon-sdv.io + annotations: + helm.sh/hook: crd-install + helm.sh/hook-weight: "-5" +spec: + group: horizon-sdv.io + names: + kind: ModuleManagerState + listKind: ModuleManagerStateList + plural: modulemanagerstates + singular: modulemanagerstate + shortNames: + - mmstate + scope: Namespaced + versions: + - name: v1alpha1 + served: true + storage: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + description: Reserved for future use (e.g. desired state). + status: + type: object + properties: + enabledModules: + type: array + items: + type: string + description: List of module IDs that are currently enabled. + moduleIds: + type: object + additionalProperties: + type: string + description: Map of module name to assigned stable ID. + moduleTargetRevisions: + type: object + additionalProperties: + type: string + description: Map of module name to Git ref (branch, tag, SHA) for the module Argo CD Application. + workflowsVisibility: + type: object + description: Developer Portal workflow list visibility (optional). + properties: + allowedSubmittedFrom: + type: array + description: Allowed horizon-sdv.io/submitted-from label values; empty array hides all. + items: + type: string + subresources: + status: {} diff --git a/gitops/apps/module-manager/templates/deployment.yaml b/gitops/apps/module-manager/templates/deployment.yaml new file mode 100644 index 00000000..e996fca0 --- /dev/null +++ b/gitops/apps/module-manager/templates/deployment.yaml @@ -0,0 +1,68 @@ +# Copyright (c) 2024-2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +apiVersion: apps/v1 +kind: Deployment +metadata: + name: module-manager + namespace: {{ .Values.namespace }} + labels: + app.kubernetes.io/name: module-manager +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: module-manager + template: + metadata: + labels: + app.kubernetes.io/name: module-manager + spec: + serviceAccountName: module-manager + containers: + - name: module-manager + image: {{ .Values.image }} + imagePullPolicy: Always + args: + - "--namespace={{ .Values.namespace }}" + - "--argocd-namespace={{ .Values.argocd.namespace }}" + - "--argocd-project={{ .Values.argocd.project }}" + - "--state-cr-name={{ .Values.stateCRName }}" + - "--catalog-cr-name={{ .Values.catalogCRName }}" + - "--target-revision={{ .Values.repo.revision }}" + - "--gitops-root-app-name={{ .Values.gitopsRootAppName }}" + env: + - name: REPO_URL + value: "{{ .Values.repo.url }}" + - name: MODULE_CONFIG + value: {{ .Values.config | toYaml | quote }} + ports: + - name: metrics + containerPort: 8080 + - name: health + containerPort: 8081 + - name: api + containerPort: 8082 + livenessProbe: + httpGet: + path: /healthz + port: health + initialDelaySeconds: 10 + periodSeconds: 10 + readinessProbe: + httpGet: + path: /readyz + port: health + initialDelaySeconds: 5 + periodSeconds: 5 diff --git a/gitops/apps/module-manager/templates/module-catalog.yaml b/gitops/apps/module-manager/templates/module-catalog.yaml new file mode 100644 index 00000000..71aa3178 --- /dev/null +++ b/gitops/apps/module-manager/templates/module-catalog.yaml @@ -0,0 +1,64 @@ +# Copyright (c) 2024-2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# Singleton ModuleCatalog CR — module list is defined here (not in values.yaml). +# overviewServiceNamespace is the source of truth for Module Manager and for parent Helm overviewNamespace (injected by MM). +# Workloads: parent module charts (mod-workloads-*) deploy to the same namespace as Module Manager (.Values.namespace), so overview Services live there—not in {prefix}workflows (that namespace is for child Argo Applications only). +apiVersion: horizon-sdv.io/v1alpha1 +kind: ModuleCatalog +metadata: + name: {{ .Values.catalogCRName }} + namespace: {{ .Values.namespace }} + labels: + app.kubernetes.io/name: module-manager +spec: + modules: + - name: sample-hard + path: gitops/modules/sample-hard-module + overviewPath: portal/overview.html + overviewService: mod-sample-hard-overview + overviewServiceNamespace: sample-hard-module-hello + # Demo: auto-disable when no enabled module lists sample-hard as a hard dependency. + autoDisableWhenUnused: true + - name: sample-soft + path: gitops/modules/sample-soft-module + overviewPath: portal/overview.html + overviewService: mod-sample-soft-overview + overviewServiceNamespace: sample-soft-module-hello + # Demo: auto-disable when no enabled module depends on sample-soft (hard or soft). + autoDisableWhenUnused: true + - name: sample + path: gitops/modules/sample-module + overviewPath: portal/overview.html + overviewService: mod-sample-overview + overviewServiceNamespace: sample-module-hello + hardDependencies: + - sample-hard + softDependencies: + - sample-soft + softFeaturesPropagation: HelmValuesAndConfigMap + softFeaturesConfigMapNamespaces: + - sample-module-hello + - name: workloads-common + path: gitops/modules/workloads-common + overviewPath: portal/overview.html + overviewService: mod-workloads-common-overview + overviewServiceNamespace: {{ .Values.namespace | quote }} + - name: workloads-android + path: gitops/modules/workloads-android + overviewPath: portal/overview.html + overviewService: mod-workloads-android-overview + overviewServiceNamespace: {{ .Values.namespace | quote }} + hardDependencies: + - workloads-common diff --git a/gitops/apps/module-manager/templates/namespace.yaml b/gitops/apps/module-manager/templates/namespace.yaml new file mode 100644 index 00000000..d1f471e1 --- /dev/null +++ b/gitops/apps/module-manager/templates/namespace.yaml @@ -0,0 +1,21 @@ +# Copyright (c) 2024-2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +apiVersion: v1 +kind: Namespace +metadata: + name: {{ .Values.namespace }} + labels: + app.kubernetes.io/name: module-manager diff --git a/gitops/apps/module-manager/templates/rbac.yaml b/gitops/apps/module-manager/templates/rbac.yaml new file mode 100644 index 00000000..ae69656f --- /dev/null +++ b/gitops/apps/module-manager/templates/rbac.yaml @@ -0,0 +1,131 @@ +# Copyright (c) 2024-2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# Module Manager: Role in its namespace (ModuleManagerState, ModuleCatalog) and Role in argocd (Applications). +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: module-manager + namespace: {{ .Values.namespace }} + labels: + app.kubernetes.io/name: module-manager +rules: + - apiGroups: ["horizon-sdv.io"] + resources: + - modulemanagerstates + - modulemanagerstates/status + - modulecatalogs + verbs: ["get", "list", "watch", "create", "update", "patch"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: module-manager + namespace: {{ .Values.namespace }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: module-manager +subjects: + - kind: ServiceAccount + name: module-manager + namespace: {{ .Values.namespace }} +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: module-manager-applications + namespace: {{ .Values.argocd.namespace }} + labels: + app.kubernetes.io/name: module-manager +rules: + - apiGroups: ["argoproj.io"] + resources: ["applications", "applications/finalizers"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: module-manager-applications + namespace: {{ .Values.argocd.namespace }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: module-manager-applications +subjects: + - kind: ServiceAccount + name: module-manager + namespace: {{ .Values.namespace }} +--- +# KCC finalizer stripper: Module Manager needs to list and patch KCC-managed resources in module +# destination namespaces during platform drain so that cnrm.cloud.google.com/finalizers on +# objects stuck in DeleteFailed do not block namespace termination. +# The apiGroup wildcard covers all *.cnrm.cloud.google.com groups (pubsub, iam, storage, etc.) +# without enumerating them, since RBAC does not support suffix wildcards. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: module-manager-kcc-drain + labels: + app.kubernetes.io/name: module-manager +rules: + - apiGroups: ["*"] + resources: ["*"] + verbs: ["get", "list", "update"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: module-manager-kcc-drain + labels: + app.kubernetes.io/name: module-manager +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: module-manager-kcc-drain +subjects: + - kind: ServiceAccount + name: module-manager + namespace: {{ .Values.namespace }} +--- +# Soft-features ConfigMap mode: Module Manager upserts ConfigMaps in workload namespaces (see ModuleCatalog softFeaturesPropagation). +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: module-manager-soft-features + labels: + app.kubernetes.io/name: module-manager +rules: + - apiGroups: [""] + resources: ["configmaps"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + - apiGroups: [""] + resources: ["namespaces"] + verbs: ["get"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: module-manager-soft-features + labels: + app.kubernetes.io/name: module-manager +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: module-manager-soft-features +subjects: + - kind: ServiceAccount + name: module-manager + namespace: {{ .Values.namespace }} diff --git a/gitops/apps/module-manager/templates/service.yaml b/gitops/apps/module-manager/templates/service.yaml new file mode 100644 index 00000000..b0e95d59 --- /dev/null +++ b/gitops/apps/module-manager/templates/service.yaml @@ -0,0 +1,35 @@ +# Copyright (c) 2024-2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +apiVersion: v1 +kind: Service +metadata: + name: module-manager + namespace: {{ .Values.namespace }} + labels: + app.kubernetes.io/name: module-manager +spec: + selector: + app.kubernetes.io/name: module-manager + ports: + - name: metrics + port: 8080 + targetPort: metrics + - name: health + port: 8081 + targetPort: health + - name: api + port: 8082 + targetPort: api diff --git a/gitops/apps/module-manager/templates/serviceaccount.yaml b/gitops/apps/module-manager/templates/serviceaccount.yaml new file mode 100644 index 00000000..25704344 --- /dev/null +++ b/gitops/apps/module-manager/templates/serviceaccount.yaml @@ -0,0 +1,22 @@ +# Copyright (c) 2024-2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +apiVersion: v1 +kind: ServiceAccount +metadata: + name: module-manager + namespace: {{ .Values.namespace }} + labels: + app.kubernetes.io/name: module-manager diff --git a/gitops/apps/module-manager/values.yaml b/gitops/apps/module-manager/values.yaml new file mode 100644 index 00000000..00a4591e --- /dev/null +++ b/gitops/apps/module-manager/values.yaml @@ -0,0 +1,41 @@ +# Copyright (c) 2024-2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Namespace where Module Manager runs (ModuleManagerState and ModuleCatalog CRs). +namespace: module-manager + +# ArgoCD namespace and project for module Applications. +argocd: + namespace: argocd + project: horizon-sdv + +# Git repo URL and revision for module charts (used when creating ArgoCD Applications). +repo: + url: "" # set via parent or --set repo.url + revision: HEAD + +# Controller image (full AR URL with tag, set by parent via Terraform). +image: "" + +# Environment config injected by the parent chart (Terraform -> root GitOps -> here). +# Module Manager passes this block into every ArgoCD Application it creates. +config: + namespacePrefix: "" + +# Root Argo CD app-of-apps Application name(s), comma-separated; must match Terraform (prefix + horizon-sdv). +gitopsRootAppName: horizon-sdv + +# Singleton CR names (state and catalog in module-manager namespace). +stateCRName: cluster +catalogCRName: cluster diff --git a/gitops/apps/mtk-connect/templates/mtk-connect.yaml b/gitops/apps/mtk-connect/templates/mtk-connect.yaml index 09a39f11..642aec70 100644 --- a/gitops/apps/mtk-connect/templates/mtk-connect.yaml +++ b/gitops/apps/mtk-connect/templates/mtk-connect.yaml @@ -243,7 +243,7 @@ spec: memory: 400Mi env: - name: devices__version - value: {{ .Values.config.applications.mtkconnect.version }} + value: {{ .Values.config.chartVersions.mtkconnect.version }} volumeMounts: - mountPath: /usr/src/config/config.json name: mtk-connect-config @@ -265,7 +265,7 @@ spec: name: mtk-connect-config subPath: config.json - name: installers - image: "{{ .Values.config.gitops.mtkconnect.installerlocation }}:{{ .Values.config.applications.mtkconnect.version }}" + image: "{{ .Values.config.gitops.mtkconnect.installerlocation }}:{{ .Values.config.chartVersions.mtkconnect.version }}" resources: requests: cpu: 50m diff --git a/gitops/apps/mtk-connect/values.yaml b/gitops/apps/mtk-connect/values.yaml index 8b5e4294..628da8a9 100644 --- a/gitops/apps/mtk-connect/values.yaml +++ b/gitops/apps/mtk-connect/values.yaml @@ -13,6 +13,10 @@ # limitations under the License. config: + # Parent horizon-sdv chart passes chartVersions from root config.chartVersions (required for deploy). + chartVersions: + mtkconnect: + version: "v1.10.0" gitops: mtkconnect: router: harbor.scpmtk.com/mtk-connect/mtk-connect-router:v2.3.1 diff --git a/gitops/apps/workflow-namespace-drain/Chart.yaml b/gitops/apps/workflow-namespace-drain/Chart.yaml new file mode 100644 index 00000000..0d1db2b1 --- /dev/null +++ b/gitops/apps/workflow-namespace-drain/Chart.yaml @@ -0,0 +1,20 @@ +# Copyright (c) 2024-2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +apiVersion: v2 +name: workflow-namespace-drain +description: Horizon SDV controller that drains Argo Workflows during platform teardown +version: 1.0.0 +type: application +appVersion: "1.0.0" diff --git a/gitops/apps/workflow-namespace-drain/README.md b/gitops/apps/workflow-namespace-drain/README.md new file mode 100644 index 00000000..25d56573 --- /dev/null +++ b/gitops/apps/workflow-namespace-drain/README.md @@ -0,0 +1,5 @@ +# Workflow Namespace Drain + +WARNING: This chart is consumed by Terraform (`helm_release.workflow_namespace_drain` in `terraform/modules/sdv-gke-apps/main.tf`) and MUST NOT be added to `gitops/templates/` or referenced by the ArgoCD app-of-apps. Doing so would cause the controller to be pruned by the cascade during platform destroy, defeating its purpose. + +This controller owns the `horizon-sdv.io/workflow-namespace-drain` finalizer on the root `horizon-sdv` ArgoCD Application. During platform teardown it deletes remaining Argo Workflow CRs in the configured workflows namespace, force-clears stuck Workflow finalizers after a grace period, and then removes only its own root Application finalizer. diff --git a/gitops/apps/workflow-namespace-drain/templates/deployment.yaml b/gitops/apps/workflow-namespace-drain/templates/deployment.yaml new file mode 100644 index 00000000..a43dc032 --- /dev/null +++ b/gitops/apps/workflow-namespace-drain/templates/deployment.yaml @@ -0,0 +1,60 @@ +# Copyright (c) 2024-2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +apiVersion: apps/v1 +kind: Deployment +metadata: + name: workflow-namespace-drain + namespace: {{ .Values.namespace }} + labels: + app.kubernetes.io/name: workflow-namespace-drain +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: workflow-namespace-drain + template: + metadata: + labels: + app.kubernetes.io/name: workflow-namespace-drain + spec: + serviceAccountName: workflow-namespace-drain + containers: + - name: workflow-namespace-drain + image: {{ .Values.image }} + imagePullPolicy: Always + args: + - "--argocd-namespace={{ .Values.argocd.namespace }}" + - "--gitops-root-app-name={{ .Values.gitopsRootAppName }}" + - "--root-finalizer={{ .Values.rootFinalizer }}" + - "--workflows-namespace={{ .Values.workflowsNamespace }}" + - "--graceful-timeout={{ .Values.gracefulTimeout }}" + - "--workflow-list-page-size={{ .Values.workflowListPageSize }}" + ports: + - name: metrics + containerPort: 8080 + - name: health + containerPort: 8081 + livenessProbe: + httpGet: + path: /healthz + port: health + initialDelaySeconds: 10 + periodSeconds: 10 + readinessProbe: + httpGet: + path: /readyz + port: health + initialDelaySeconds: 5 + periodSeconds: 5 diff --git a/gitops/apps/workflow-namespace-drain/templates/rbac.yaml b/gitops/apps/workflow-namespace-drain/templates/rbac.yaml new file mode 100644 index 00000000..43fdc375 --- /dev/null +++ b/gitops/apps/workflow-namespace-drain/templates/rbac.yaml @@ -0,0 +1,78 @@ +# Copyright (c) 2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# The controller namespace is created by Terraform Helm provider's +# create_namespace=true. Do not render a Namespace object here: Helm creates the +# release namespace before rendering chart resources, and also templating that +# same Namespace can cause Helm ownership/adoption conflicts on fresh installs. + +apiVersion: v1 +kind: ServiceAccount +metadata: + name: workflow-namespace-drain + namespace: {{ .Values.namespace }} + labels: + app.kubernetes.io/name: workflow-namespace-drain +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: {{ .Release.Name }}-workflows + labels: + app.kubernetes.io/name: workflow-namespace-drain +rules: + - apiGroups: ["argoproj.io"] + resources: ["workflows", "workflows/finalizers"] + verbs: ["get", "list", "watch", "delete", "patch"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: {{ .Release.Name }}-workflows + labels: + app.kubernetes.io/name: workflow-namespace-drain +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: {{ .Release.Name }}-workflows +subjects: + - kind: ServiceAccount + name: workflow-namespace-drain + namespace: {{ .Values.namespace }} +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: workflow-namespace-drain-applications + namespace: {{ .Values.argocd.namespace }} + labels: + app.kubernetes.io/name: workflow-namespace-drain +rules: + - apiGroups: ["argoproj.io"] + resources: ["applications", "applications/finalizers"] + verbs: ["get", "list", "watch", "update", "patch"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: workflow-namespace-drain-applications + namespace: {{ .Values.argocd.namespace }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: workflow-namespace-drain-applications +subjects: + - kind: ServiceAccount + name: workflow-namespace-drain + namespace: {{ .Values.namespace }} diff --git a/gitops/apps/workflow-namespace-drain/values.yaml b/gitops/apps/workflow-namespace-drain/values.yaml new file mode 100644 index 00000000..acb44f7b --- /dev/null +++ b/gitops/apps/workflow-namespace-drain/values.yaml @@ -0,0 +1,38 @@ +# Copyright (c) 2024-2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Namespace where this Terraform-managed controller runs. +namespace: workflow-namespace-drain + +# Controller image (full AR URL with tag, set by Terraform). +image: "" + +# ArgoCD namespace containing the root horizon-sdv Application. +argocd: + namespace: argocd + +# Namespace containing Argo Workflow CRs. +workflowsNamespace: workflows + +# Root Argo CD app-of-apps Application name; must match Terraform (prefix + horizon-sdv). +gitopsRootAppName: horizon-sdv + +# Finalizer owned by this controller on the root Application. +rootFinalizer: horizon-sdv.io/workflow-namespace-drain + +# How long to wait before force-clearing finalizers on deleting Workflow CRs. +gracefulTimeout: 60s + +# Page size used when listing Workflow CRs during teardown. +workflowListPageSize: 200 diff --git a/gitops/modules/sample-hard-module/Chart.yaml b/gitops/modules/sample-hard-module/Chart.yaml new file mode 100644 index 00000000..c2a4ad49 --- /dev/null +++ b/gitops/modules/sample-hard-module/Chart.yaml @@ -0,0 +1,20 @@ +# Copyright (c) 2024-2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +apiVersion: v2 +name: sample-hard-module +description: Sample hard-dependency module (required by sample-module) +version: 0.2.0 +type: application +appVersion: "0.2.0" diff --git a/gitops/modules/sample-hard-module/argo-workflows/Chart.yaml b/gitops/modules/sample-hard-module/argo-workflows/Chart.yaml new file mode 100644 index 00000000..d7300fbd --- /dev/null +++ b/gitops/modules/sample-hard-module/argo-workflows/Chart.yaml @@ -0,0 +1,19 @@ +# Copyright (c) 2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +apiVersion: v2 +name: sample-hard-module-argo-workflows +description: Smoke-test WorkflowTemplates and Sensors for module sample-hard (not Horizon-exposed) +version: 0.1.0 +type: application diff --git a/gitops/modules/sample-hard-module/argo-workflows/templates/sensors.yaml b/gitops/modules/sample-hard-module/argo-workflows/templates/sensors.yaml new file mode 100644 index 00000000..4aaf4a64 --- /dev/null +++ b/gitops/modules/sample-hard-module/argo-workflows/templates/sensors.yaml @@ -0,0 +1,149 @@ +# Copyright (c) 2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# Argo Events Sensors for module sample-hard (pair with workflowtemplates.yaml). +apiVersion: argoproj.io/v1alpha1 +kind: Sensor +metadata: + name: webhook-sample-hard-smoke-test + namespace: {{ .Values.eventsNamespace }} + annotations: + argocd.argoproj.io/sync-wave: "8" +spec: + eventBusName: default + template: + serviceAccountName: sensor-submit-workflow + dependencies: + - name: webhook-dep + eventSourceName: webhook + eventName: workflow-dispatch + filters: + data: + - path: body.workflowTemplateName + type: string + value: + - "sample-hard-smoke-test" + triggers: + - template: + name: submit-smoke-test + argoWorkflow: + operation: submit + source: + resource: + apiVersion: argoproj.io/v1alpha1 + kind: Workflow + metadata: + generateName: webhook-smoke- + namespace: {{ .Values.workflowNamespace }} + spec: + workflowTemplateRef: + name: sample-hard-smoke-test + arguments: + parameters: + - name: horizonSubmittedFrom + - name: sampleEnv + - name: sampleBuildId + - name: sampleNote + parameters: + - src: + dependencyName: webhook-dep + dataKey: body.horizonSubmittedBy + dest: metadata.annotations.horizon-sdv\.io/submitted-by + - src: + dependencyName: webhook-dep + dataKey: body.horizonSubmittedFrom + dest: metadata.labels.horizon-sdv\.io/submitted-from + - src: + dependencyName: webhook-dep + dataKey: body.horizonSubmittedFrom + dest: spec.arguments.parameters.0.value + - src: + dependencyName: webhook-dep + dataKey: body.sampleEnv + dest: spec.arguments.parameters.1.value + - src: + dependencyName: webhook-dep + dataKey: body.sampleBuildId + dest: spec.arguments.parameters.2.value + - src: + dependencyName: webhook-dep + dataKey: body.sampleNote + dest: spec.arguments.parameters.3.value +--- +apiVersion: argoproj.io/v1alpha1 +kind: Sensor +metadata: + name: webhook-sample-hard-smoke-test-alt + namespace: {{ .Values.eventsNamespace }} + annotations: + argocd.argoproj.io/sync-wave: "8" +spec: + eventBusName: default + template: + serviceAccountName: sensor-submit-workflow + dependencies: + - name: webhook-dep + eventSourceName: webhook + eventName: workflow-dispatch + filters: + data: + - path: body.workflowTemplateName + type: string + value: + - "sample-hard-smoke-test-alt" + triggers: + - template: + name: submit-smoke-test-alt + argoWorkflow: + operation: submit + source: + resource: + apiVersion: argoproj.io/v1alpha1 + kind: Workflow + metadata: + generateName: webhook-smoke-alt- + namespace: {{ .Values.workflowNamespace }} + spec: + workflowTemplateRef: + name: sample-hard-smoke-test-alt + arguments: + parameters: + - name: horizonSubmittedFrom + - name: releaseChannel + - name: ticketId + - name: owner + parameters: + - src: + dependencyName: webhook-dep + dataKey: body.horizonSubmittedBy + dest: metadata.annotations.horizon-sdv\.io/submitted-by + - src: + dependencyName: webhook-dep + dataKey: body.horizonSubmittedFrom + dest: metadata.labels.horizon-sdv\.io/submitted-from + - src: + dependencyName: webhook-dep + dataKey: body.horizonSubmittedFrom + dest: spec.arguments.parameters.0.value + - src: + dependencyName: webhook-dep + dataKey: body.releaseChannel + dest: spec.arguments.parameters.1.value + - src: + dependencyName: webhook-dep + dataKey: body.ticketId + dest: spec.arguments.parameters.2.value + - src: + dependencyName: webhook-dep + dataKey: body.owner + dest: spec.arguments.parameters.3.value diff --git a/gitops/modules/sample-hard-module/argo-workflows/templates/workflowtemplates.yaml b/gitops/modules/sample-hard-module/argo-workflows/templates/workflowtemplates.yaml new file mode 100644 index 00000000..9a7ec084 --- /dev/null +++ b/gitops/modules/sample-hard-module/argo-workflows/templates/workflowtemplates.yaml @@ -0,0 +1,151 @@ +# Copyright (c) 2024-2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# WorkflowTemplates for module sample-hard (not exposed on Horizon API catalog). Argo Events: templates/sensors.yaml. +apiVersion: argoproj.io/v1alpha1 +kind: WorkflowTemplate +metadata: + name: sample-hard-smoke-test + namespace: {{ .Values.workflowNamespace }} + labels: + app.kubernetes.io/name: smoke-test + horizon-sdv.io/module: sample-hard + annotations: + argocd.argoproj.io/sync-wave: "7" +spec: + entrypoint: run-smoke-test + serviceAccountName: workflow-executor + arguments: + parameters: + - name: horizonSubmittedFrom + value: "" + - name: sampleEnv + value: "" + - name: sampleBuildId + value: "" + - name: sampleNote + value: "" + templates: + - name: run-smoke-test + dag: + tasks: + - name: log-parameters + template: log-parameters + - name: echo-and-artifact + template: echo-and-artifact + dependencies: [log-parameters] + - name: verify-gcs + template: verify-gcs + dependencies: [echo-and-artifact] + - name: log-parameters + container: + image: alpine:3.19 + command: [sh, -c] + args: + - | + echo "WorkflowTemplate: smoke-test" + echo "sampleEnv={{ "{{workflow.parameters.sampleEnv}}" }}" + echo "sampleBuildId={{ "{{workflow.parameters.sampleBuildId}}" }}" + echo "sampleNote={{ "{{workflow.parameters.sampleNote}}" }}" + echo "at $(date -u +%Y-%m-%dT%H:%M:%SZ)" + - name: echo-and-artifact + container: + image: alpine:3.19 + command: [sh, -c] + args: + - | + echo "Smoke test running at $(date)" + echo "SA: $(cat /var/run/secrets/kubernetes.io/serviceaccount/namespace)/$(cat /var/run/secrets/kubernetes.io/serviceaccount/..data/namespace 2>/dev/null || echo 'n/a')" + echo "Smoke test passed - $(date -u +%Y-%m-%dT%H:%M:%SZ)" > /tmp/result.txt + cat /tmp/result.txt + outputs: + artifacts: + - name: smoke-result + path: /tmp/result.txt + - name: verify-gcs + container: + image: alpine:3.19 + command: [sh, -c] + args: + - | + echo "Artifact upload verified by reaching this step." + echo "All checks passed." +--- +apiVersion: argoproj.io/v1alpha1 +kind: WorkflowTemplate +metadata: + name: sample-hard-smoke-test-alt + namespace: {{ .Values.workflowNamespace }} + labels: + app.kubernetes.io/name: smoke-test + horizon-sdv.io/module: sample-hard + annotations: + argocd.argoproj.io/sync-wave: "7" +spec: + entrypoint: run-smoke-test + serviceAccountName: workflow-executor + arguments: + parameters: + - name: horizonSubmittedFrom + value: "" + - name: releaseChannel + value: "" + - name: ticketId + value: "" + - name: owner + value: "" + templates: + - name: run-smoke-test + dag: + tasks: + - name: log-parameters + template: log-parameters + - name: echo-and-artifact + template: echo-and-artifact + dependencies: [log-parameters] + - name: verify-gcs + template: verify-gcs + dependencies: [echo-and-artifact] + - name: log-parameters + container: + image: alpine:3.19 + command: [sh, -c] + args: + - | + echo "WorkflowTemplate: smoke-test-alt (variant)" + echo "releaseChannel={{ "{{workflow.parameters.releaseChannel}}" }}" + echo "ticketId={{ "{{workflow.parameters.ticketId}}" }}" + echo "owner={{ "{{workflow.parameters.owner}}" }}" + echo "at $(date -u +%Y-%m-%dT%H:%M:%SZ)" + - name: echo-and-artifact + container: + image: alpine:3.19 + command: [sh, -c] + args: + - | + echo "Smoke test running at $(date)" + echo "SA: $(cat /var/run/secrets/kubernetes.io/serviceaccount/namespace)/$(cat /var/run/secrets/kubernetes.io/serviceaccount/..data/namespace 2>/dev/null || echo 'n/a')" + echo "Smoke test passed - $(date -u +%Y-%m-%dT%H:%M:%SZ)" > /tmp/result.txt + cat /tmp/result.txt + outputs: + artifacts: + - name: smoke-result + path: /tmp/result.txt + - name: verify-gcs + container: + image: alpine:3.19 + command: [sh, -c] + args: + - | + echo "Artifact upload verified by reaching this step." + echo "All checks passed." diff --git a/gitops/modules/sample-hard-module/argo-workflows/values.yaml b/gitops/modules/sample-hard-module/argo-workflows/values.yaml new file mode 100644 index 00000000..6007a4ff --- /dev/null +++ b/gitops/modules/sample-hard-module/argo-workflows/values.yaml @@ -0,0 +1,16 @@ +# Copyright (c) 2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +workflowNamespace: workflows +eventsNamespace: argo-events diff --git a/gitops/modules/sample-hard-module/hello-world/Chart.yaml b/gitops/modules/sample-hard-module/hello-world/Chart.yaml new file mode 100644 index 00000000..18239690 --- /dev/null +++ b/gitops/modules/sample-hard-module/hello-world/Chart.yaml @@ -0,0 +1,21 @@ +# Copyright (c) 2024-2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +apiVersion: v2 +name: hello-world +description: Hello-world app for sample-hard-module +version: 0.1.0 +type: application +appVersion: "0.1.0" diff --git a/gitops/modules/sample-hard-module/hello-world/templates/config-connector-context.yaml b/gitops/modules/sample-hard-module/hello-world/templates/config-connector-context.yaml new file mode 100644 index 00000000..7d2db336 --- /dev/null +++ b/gitops/modules/sample-hard-module/hello-world/templates/config-connector-context.yaml @@ -0,0 +1,27 @@ +# Copyright (c) 2024-2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# ConfigConnectorContext is required in every namespace that manages KCC resources +# (namespaced-mode Config Connector). Created here so each module namespace is +# self-sufficient without the platform chart needing to enumerate module namespaces. +# gcpProjectId is required for this module and is always expected to be set. +apiVersion: core.cnrm.cloud.google.com/v1beta1 +kind: ConfigConnectorContext +metadata: + name: configconnectorcontext.core.cnrm.cloud.google.com + namespace: {{ .Values.namespace }} + annotations: + argocd.argoproj.io/sync-wave: "0" +spec: + googleServiceAccount: gke-config-connector-sa@{{ .Values.gcpProjectId }}.iam.gserviceaccount.com diff --git a/gitops/modules/sample-hard-module/hello-world/templates/deployment.yaml b/gitops/modules/sample-hard-module/hello-world/templates/deployment.yaml new file mode 100644 index 00000000..be955c28 --- /dev/null +++ b/gitops/modules/sample-hard-module/hello-world/templates/deployment.yaml @@ -0,0 +1,40 @@ +# Copyright (c) 2024-2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +apiVersion: apps/v1 +kind: Deployment +metadata: + name: hello-world + namespace: {{ .Values.namespace }} + labels: + app.kubernetes.io/name: hello-world + annotations: + argocd.argoproj.io/sync-wave: "2" +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: hello-world + template: + metadata: + labels: + app.kubernetes.io/name: hello-world + spec: + containers: + - name: hello-world + image: hashicorp/http-echo:0.2.3 + args: ["-listen=:8080", "-text=Hello from sample-hard"] + ports: + - containerPort: 8080 diff --git a/gitops/modules/sample-hard-module/hello-world/templates/pubsub-topic.yaml b/gitops/modules/sample-hard-module/hello-world/templates/pubsub-topic.yaml new file mode 100644 index 00000000..6350096b --- /dev/null +++ b/gitops/modules/sample-hard-module/hello-world/templates/pubsub-topic.yaml @@ -0,0 +1,26 @@ +# Copyright (c) 2024-2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +apiVersion: pubsub.cnrm.cloud.google.com/v1beta1 +kind: PubSubTopic +metadata: + name: sample-hard-events + namespace: {{ .Values.namespace }} + annotations: + argocd.argoproj.io/sync-wave: "1" + cnrm.cloud.google.com/project-id: "{{ .Values.gcpProjectId }}" + labels: + app.kubernetes.io/name: hello-world +spec: {} diff --git a/gitops/modules/sample-hard-module/hello-world/templates/service.yaml b/gitops/modules/sample-hard-module/hello-world/templates/service.yaml new file mode 100644 index 00000000..895036c8 --- /dev/null +++ b/gitops/modules/sample-hard-module/hello-world/templates/service.yaml @@ -0,0 +1,30 @@ +# Copyright (c) 2024-2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +apiVersion: v1 +kind: Service +metadata: + name: hello-world + namespace: {{ .Values.namespace }} + labels: + app.kubernetes.io/name: hello-world + annotations: + argocd.argoproj.io/sync-wave: "2" +spec: + selector: + app.kubernetes.io/name: hello-world + ports: + - port: 8080 + targetPort: 8080 diff --git a/gitops/modules/sample-hard-module/hello-world/values.yaml b/gitops/modules/sample-hard-module/hello-world/values.yaml new file mode 100644 index 00000000..4e67b3d4 --- /dev/null +++ b/gitops/modules/sample-hard-module/hello-world/values.yaml @@ -0,0 +1,17 @@ +# Copyright (c) 2024-2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +namespace: sample-hard-module-hello +gcpProjectId: "" diff --git a/gitops/modules/sample-hard-module/portal/overview.html b/gitops/modules/sample-hard-module/portal/overview.html new file mode 100644 index 00000000..be46d9fd --- /dev/null +++ b/gitops/modules/sample-hard-module/portal/overview.html @@ -0,0 +1,123 @@ + + + + + + + + sample-hard module + + + +
+
+

Horizon module

+

sample-hard

+

+ A deliberately small module used to model hard dependencies in the catalog. + Other modules list sample-hard under hardDependencies so Module Manager + enables it first and keeps ordering predictable in demos and tests. +

+
+
+
+

Child applications

+

+ Deploys a hello-world chart (deployment, service, Pub/Sub topic, Config Connector context) + in sample-hard-module-hello, plus an argo-workflows child chart with + internal smoke WorkflowTemplates and sensors. Those workflows are not published + to the Horizon API catalog; they exist for in-cluster validation and Events wiring. +

+
+
+

When to enable

+

+ Enable when you need the dependency graph that the sample module exercises, or when you + want a minimal footprint module that still provisions real namespaced resources alongside Argo Workflows. +

+
+
+
+ + diff --git a/gitops/modules/sample-hard-module/templates/application-argo-workflows.yaml b/gitops/modules/sample-hard-module/templates/application-argo-workflows.yaml new file mode 100644 index 00000000..2c394532 --- /dev/null +++ b/gitops/modules/sample-hard-module/templates/application-argo-workflows.yaml @@ -0,0 +1,44 @@ +# Copyright (c) 2024-2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Child Application: sample-hard-module argo-workflows (templates/workflowtemplates.yaml + templates/sensors.yaml; not Horizon-exposed). +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: mod-{{ .Values.moduleName }}-argo-workflows + namespace: {{ .Values.argocd.namespace }} + labels: + app.kubernetes.io/name: {{ .Values.moduleName }} + horizon-sdv.io/module: {{ .Values.moduleName }} + horizon-sdv.io/app-role: child + horizon-sdv.io/module-manager-managed: "true" + finalizers: + - resources-finalizer.argocd.argoproj.io +spec: + project: {{ .Values.argocd.project }} + source: + repoURL: {{ .Values.repo.url | quote }} + targetRevision: {{ .Values.repo.revision | quote }} + path: gitops/modules/sample-hard-module/argo-workflows + helm: + values: | + workflowNamespace: {{ .Values.config.namespacePrefix }}workflows + eventsNamespace: {{ .Values.config.namespacePrefix }}argo-events + destination: + server: https://kubernetes.default.svc + namespace: {{ .Values.config.namespacePrefix }}workflows + syncPolicy: + syncOptions: + - CreateNamespace=true + automated: {} diff --git a/gitops/modules/sample-hard-module/templates/application-hello-world.yaml b/gitops/modules/sample-hard-module/templates/application-hello-world.yaml new file mode 100644 index 00000000..de5335bb --- /dev/null +++ b/gitops/modules/sample-hard-module/templates/application-hello-world.yaml @@ -0,0 +1,44 @@ +# Copyright (c) 2024-2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: mod-{{ .Values.moduleName }}-hello-world + namespace: {{ .Values.argocd.namespace }} + labels: + app.kubernetes.io/name: {{ .Values.moduleName }} + horizon-sdv.io/module: {{ .Values.moduleName }} + horizon-sdv.io/app-role: child + horizon-sdv.io/module-manager-managed: "true" + finalizers: + - resources-finalizer.argocd.argoproj.io +spec: + project: {{ .Values.argocd.project }} + source: + repoURL: {{ .Values.repo.url | quote }} + targetRevision: {{ .Values.repo.revision | quote }} + path: gitops/modules/sample-hard-module/hello-world + helm: + values: | + namespace: {{ .Values.helloWorldNamespace }} + gcpProjectId: {{ .Values.config.projectID | quote }} + destination: + server: https://kubernetes.default.svc + namespace: {{ .Values.helloWorldNamespace }} + syncPolicy: + syncOptions: + - CreateNamespace=true + automated: {} diff --git a/gitops/modules/sample-hard-module/templates/module-overview-http.yaml b/gitops/modules/sample-hard-module/templates/module-overview-http.yaml new file mode 100644 index 00000000..7113a7a2 --- /dev/null +++ b/gitops/modules/sample-hard-module/templates/module-overview-http.yaml @@ -0,0 +1,101 @@ +# Copyright (c) 2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# In-cluster static overview HTML for the Developer Portal (Module Manager GET /modules/{name}/overview). +# Synced with the parent module Application; not a separate Argo Application. +# overviewServiceName and overviewNamespace are chart values (Module Manager merges overviewNamespace for workload modules). +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ .Values.overviewServiceName }}-html + namespace: {{ .Values.overviewNamespace }} + labels: + app.kubernetes.io/name: module-overview + app.kubernetes.io/component: module-overview-http + horizon-sdv.io/module: {{ .Values.moduleName | quote }} +data: + index.html: |- +{{ .Files.Get "portal/overview.html" | nindent 4 }} +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ .Values.overviewServiceName }} + namespace: {{ .Values.overviewNamespace }} + labels: + app.kubernetes.io/name: module-overview + horizon-sdv.io/module: {{ .Values.moduleName | quote }} +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: module-overview + horizon-sdv.io/module: {{ .Values.moduleName | quote }} + template: + metadata: + labels: + app.kubernetes.io/name: module-overview + horizon-sdv.io/module: {{ .Values.moduleName | quote }} + spec: + containers: + - name: nginx + image: nginx:1.23 + ports: + - containerPort: 80 + name: http + volumeMounts: + - name: html + mountPath: /usr/share/nginx/html + readOnly: true + readinessProbe: + httpGet: + path: / + port: http + initialDelaySeconds: 2 + periodSeconds: 10 + livenessProbe: + httpGet: + path: / + port: http + initialDelaySeconds: 5 + periodSeconds: 20 + resources: + requests: + cpu: 5m + memory: 16Mi + limits: + memory: 64Mi + volumes: + - name: html + configMap: + name: {{ .Values.overviewServiceName }}-html +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ .Values.overviewServiceName }} + namespace: {{ .Values.overviewNamespace }} + labels: + app.kubernetes.io/name: module-overview + horizon-sdv.io/module: {{ .Values.moduleName | quote }} +spec: + type: ClusterIP + selector: + app.kubernetes.io/name: module-overview + horizon-sdv.io/module: {{ .Values.moduleName | quote }} + ports: + - name: http + port: 80 + targetPort: http diff --git a/gitops/modules/sample-hard-module/values.yaml b/gitops/modules/sample-hard-module/values.yaml new file mode 100644 index 00000000..8d395ec3 --- /dev/null +++ b/gitops/modules/sample-hard-module/values.yaml @@ -0,0 +1,26 @@ +# Copyright (c) 2024-2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +moduleName: "" +moduleManagerNamespace: module-manager +argocd: + namespace: argocd + project: horizon-sdv +repo: + url: "" + revision: HEAD +helloWorldNamespace: sample-hard-module-hello +overviewServiceName: mod-sample-hard-overview +overviewNamespace: sample-hard-module-hello +config: {} diff --git a/gitops/modules/sample-module/Chart.yaml b/gitops/modules/sample-module/Chart.yaml new file mode 100644 index 00000000..5d99e888 --- /dev/null +++ b/gitops/modules/sample-module/Chart.yaml @@ -0,0 +1,20 @@ +# Copyright (c) 2024-2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +apiVersion: v2 +name: sample-module +description: Sample Horizon SDV module (PubSub KCC, smoke-test workflows) +version: 0.2.0 +type: application +appVersion: "0.2.0" diff --git a/gitops/modules/sample-module/argo-workflows/Chart.yaml b/gitops/modules/sample-module/argo-workflows/Chart.yaml new file mode 100644 index 00000000..1984e503 --- /dev/null +++ b/gitops/modules/sample-module/argo-workflows/Chart.yaml @@ -0,0 +1,20 @@ +# Copyright (c) 2024-2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +apiVersion: v2 +name: sample-module-argo-workflows +description: Smoke-test WorkflowTemplates and Sensors for module sample (Horizon-exposed; deployed after Argo Workflows CRDs are available) +version: 0.1.0 +type: application diff --git a/gitops/modules/sample-module/argo-workflows/templates/sensors.yaml b/gitops/modules/sample-module/argo-workflows/templates/sensors.yaml new file mode 100644 index 00000000..a50dd86c --- /dev/null +++ b/gitops/modules/sample-module/argo-workflows/templates/sensors.yaml @@ -0,0 +1,149 @@ +# Copyright (c) 2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# Argo Events Sensors for module sample (pair with workflowtemplates.yaml). +apiVersion: argoproj.io/v1alpha1 +kind: Sensor +metadata: + name: webhook-sample-smoke-test + namespace: {{ .Values.eventsNamespace }} + annotations: + argocd.argoproj.io/sync-wave: "8" +spec: + eventBusName: default + template: + serviceAccountName: sensor-submit-workflow + dependencies: + - name: webhook-dep + eventSourceName: webhook + eventName: workflow-dispatch + filters: + data: + - path: body.workflowTemplateName + type: string + value: + - "sample-smoke-test" + triggers: + - template: + name: submit-smoke-test + argoWorkflow: + operation: submit + source: + resource: + apiVersion: argoproj.io/v1alpha1 + kind: Workflow + metadata: + generateName: webhook-smoke- + namespace: {{ .Values.workflowNamespace }} + spec: + workflowTemplateRef: + name: sample-smoke-test + arguments: + parameters: + - name: horizonSubmittedFrom + - name: sampleEnv + - name: sampleBuildId + - name: sampleNote + parameters: + - src: + dependencyName: webhook-dep + dataKey: body.horizonSubmittedBy + dest: metadata.annotations.horizon-sdv\.io/submitted-by + - src: + dependencyName: webhook-dep + dataKey: body.horizonSubmittedFrom + dest: metadata.labels.horizon-sdv\.io/submitted-from + - src: + dependencyName: webhook-dep + dataKey: body.horizonSubmittedFrom + dest: spec.arguments.parameters.0.value + - src: + dependencyName: webhook-dep + dataKey: body.sampleEnv + dest: spec.arguments.parameters.1.value + - src: + dependencyName: webhook-dep + dataKey: body.sampleBuildId + dest: spec.arguments.parameters.2.value + - src: + dependencyName: webhook-dep + dataKey: body.sampleNote + dest: spec.arguments.parameters.3.value +--- +apiVersion: argoproj.io/v1alpha1 +kind: Sensor +metadata: + name: webhook-sample-smoke-test-alt + namespace: {{ .Values.eventsNamespace }} + annotations: + argocd.argoproj.io/sync-wave: "8" +spec: + eventBusName: default + template: + serviceAccountName: sensor-submit-workflow + dependencies: + - name: webhook-dep + eventSourceName: webhook + eventName: workflow-dispatch + filters: + data: + - path: body.workflowTemplateName + type: string + value: + - "sample-smoke-test-alt" + triggers: + - template: + name: submit-smoke-test-alt + argoWorkflow: + operation: submit + source: + resource: + apiVersion: argoproj.io/v1alpha1 + kind: Workflow + metadata: + generateName: webhook-smoke-alt- + namespace: {{ .Values.workflowNamespace }} + spec: + workflowTemplateRef: + name: sample-smoke-test-alt + arguments: + parameters: + - name: horizonSubmittedFrom + - name: releaseChannel + - name: ticketId + - name: owner + parameters: + - src: + dependencyName: webhook-dep + dataKey: body.horizonSubmittedBy + dest: metadata.annotations.horizon-sdv\.io/submitted-by + - src: + dependencyName: webhook-dep + dataKey: body.horizonSubmittedFrom + dest: metadata.labels.horizon-sdv\.io/submitted-from + - src: + dependencyName: webhook-dep + dataKey: body.horizonSubmittedFrom + dest: spec.arguments.parameters.0.value + - src: + dependencyName: webhook-dep + dataKey: body.releaseChannel + dest: spec.arguments.parameters.1.value + - src: + dependencyName: webhook-dep + dataKey: body.ticketId + dest: spec.arguments.parameters.2.value + - src: + dependencyName: webhook-dep + dataKey: body.owner + dest: spec.arguments.parameters.3.value diff --git a/gitops/modules/sample-module/argo-workflows/templates/workflowtemplates.yaml b/gitops/modules/sample-module/argo-workflows/templates/workflowtemplates.yaml new file mode 100644 index 00000000..1f60b38b --- /dev/null +++ b/gitops/modules/sample-module/argo-workflows/templates/workflowtemplates.yaml @@ -0,0 +1,182 @@ +# Copyright (c) 2024-2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# WorkflowTemplates for module sample (Horizon catalog: exposed). Argo Events: templates/sensors.yaml. +apiVersion: argoproj.io/v1alpha1 +kind: WorkflowTemplate +metadata: + name: sample-smoke-test + namespace: {{ .Values.workflowNamespace }} + labels: + app.kubernetes.io/name: smoke-test + horizon-sdv.io/expose: "true" + horizon-sdv.io/module: sample + annotations: + argocd.argoproj.io/sync-wave: "7" +spec: + entrypoint: run-smoke-test + serviceAccountName: workflow-executor + arguments: + parameters: + - name: horizonSubmittedFrom + value: "" + - name: sampleEnv + value: "" + - name: sampleBuildId + value: "" + - name: sampleNote + value: "" + templates: + - name: run-smoke-test + dag: + tasks: + - name: log-parameters + template: log-parameters +{{- if .Values.referenceSampleSoftWorkflowTemplate }} + - name: sample-soft-stage + template: sample-soft-stage + depends: log-parameters + # Soft stage is deploy-time controlled by referenceSampleSoftWorkflowTemplate. +{{- end }} + - name: echo-and-artifact + template: echo-and-artifact +{{- if .Values.referenceSampleSoftWorkflowTemplate }} + depends: "log-parameters && sample-soft-stage" +{{- else }} + depends: log-parameters +{{- end }} + - name: verify-gcs + template: verify-gcs + # Same DAG must use only `depends` (not `dependencies`) once any task uses `depends` (Argo validation). + depends: echo-and-artifact + - name: log-parameters + container: + image: alpine:3.19 + command: [sh, -c] + args: + - | + echo "WorkflowTemplate: smoke-test" + echo "sampleEnv={{ "{{workflow.parameters.sampleEnv}}" }}" + echo "sampleBuildId={{ "{{workflow.parameters.sampleBuildId}}" }}" + echo "sampleNote={{ "{{workflow.parameters.sampleNote}}" }}" + echo "at $(date -u +%Y-%m-%dT%H:%M:%SZ)" +{{- if .Values.referenceSampleSoftWorkflowTemplate }} + - name: sample-soft-stage + steps: + - - name: invoke-sample-soft-smoke-test + templateRef: + name: sample-soft-smoke-test + template: run-smoke-test + arguments: + parameters: + - name: horizonSubmittedFrom + value: "{{ "{{workflow.parameters.horizonSubmittedFrom}}" }}" + - name: sampleEnv + value: "{{ "{{workflow.parameters.sampleEnv}}" }}" + - name: sampleBuildId + value: "{{ "{{workflow.parameters.sampleBuildId}}" }}" + - name: sampleNote + value: "{{ "{{workflow.parameters.sampleNote}}" }}" +{{- end }} + - name: echo-and-artifact + container: + image: alpine:3.19 + command: [sh, -c] + args: + - | + echo "Smoke test running at $(date)" + echo "SA: $(cat /var/run/secrets/kubernetes.io/serviceaccount/namespace)/$(cat /var/run/secrets/kubernetes.io/serviceaccount/..data/namespace 2>/dev/null || echo 'n/a')" + echo "Smoke test passed - $(date -u +%Y-%m-%dT%H:%M:%SZ)" > /tmp/result.txt + cat /tmp/result.txt + outputs: + artifacts: + - name: smoke-result + path: /tmp/result.txt + - name: verify-gcs + container: + image: alpine:3.19 + command: [sh, -c] + args: + - | + echo "Artifact upload verified by reaching this step." + echo "All checks passed." +--- +apiVersion: argoproj.io/v1alpha1 +kind: WorkflowTemplate +metadata: + name: sample-smoke-test-alt + namespace: {{ .Values.workflowNamespace }} + labels: + app.kubernetes.io/name: smoke-test + horizon-sdv.io/expose: "true" + horizon-sdv.io/module: sample + annotations: + argocd.argoproj.io/sync-wave: "7" +spec: + entrypoint: run-smoke-test + serviceAccountName: workflow-executor + arguments: + parameters: + - name: horizonSubmittedFrom + value: "" + - name: releaseChannel + value: "" + - name: ticketId + value: "" + - name: owner + value: "" + templates: + - name: run-smoke-test + dag: + tasks: + - name: log-parameters + template: log-parameters + - name: echo-and-artifact + template: echo-and-artifact + dependencies: [log-parameters] + - name: verify-gcs + template: verify-gcs + dependencies: [echo-and-artifact] + - name: log-parameters + container: + image: alpine:3.19 + command: [sh, -c] + args: + - | + echo "WorkflowTemplate: smoke-test-alt (variant)" + echo "releaseChannel={{ "{{workflow.parameters.releaseChannel}}" }}" + echo "ticketId={{ "{{workflow.parameters.ticketId}}" }}" + echo "owner={{ "{{workflow.parameters.owner}}" }}" + echo "at $(date -u +%Y-%m-%dT%H:%M:%SZ)" + - name: echo-and-artifact + container: + image: alpine:3.19 + command: [sh, -c] + args: + - | + echo "Smoke test running at $(date)" + echo "SA: $(cat /var/run/secrets/kubernetes.io/serviceaccount/namespace)/$(cat /var/run/secrets/kubernetes.io/serviceaccount/..data/namespace 2>/dev/null || echo 'n/a')" + echo "Smoke test passed - $(date -u +%Y-%m-%dT%H:%M:%SZ)" > /tmp/result.txt + cat /tmp/result.txt + outputs: + artifacts: + - name: smoke-result + path: /tmp/result.txt + - name: verify-gcs + container: + image: alpine:3.19 + command: [sh, -c] + args: + - | + echo "Artifact upload verified by reaching this step." + echo "All checks passed." diff --git a/gitops/modules/sample-module/argo-workflows/values.yaml b/gitops/modules/sample-module/argo-workflows/values.yaml new file mode 100644 index 00000000..87503a57 --- /dev/null +++ b/gitops/modules/sample-module/argo-workflows/values.yaml @@ -0,0 +1,19 @@ +# Copyright (c) 2024-2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# Namespace where WorkflowTemplates and submitted Workflows run (must match Argo Workflows controller.workflowNamespaces). +workflowNamespace: workflows + +# Namespace where Argo Events Sensors are installed (must match EventSource/EventBus from platform argo-events). +eventsNamespace: argo-events diff --git a/gitops/modules/sample-module/hello-world/Chart.yaml b/gitops/modules/sample-module/hello-world/Chart.yaml new file mode 100644 index 00000000..b09d5e94 --- /dev/null +++ b/gitops/modules/sample-module/hello-world/Chart.yaml @@ -0,0 +1,20 @@ +# Copyright (c) 2024-2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +apiVersion: v2 +name: hello-world +description: Sample hello-world app and PubSub KCC (deployed by sample-module app-of-apps) +version: 0.1.0 +type: application +appVersion: "0.1.0" diff --git a/gitops/modules/sample-module/hello-world/templates/config-connector-context.yaml b/gitops/modules/sample-module/hello-world/templates/config-connector-context.yaml new file mode 100644 index 00000000..7d2db336 --- /dev/null +++ b/gitops/modules/sample-module/hello-world/templates/config-connector-context.yaml @@ -0,0 +1,27 @@ +# Copyright (c) 2024-2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# ConfigConnectorContext is required in every namespace that manages KCC resources +# (namespaced-mode Config Connector). Created here so each module namespace is +# self-sufficient without the platform chart needing to enumerate module namespaces. +# gcpProjectId is required for this module and is always expected to be set. +apiVersion: core.cnrm.cloud.google.com/v1beta1 +kind: ConfigConnectorContext +metadata: + name: configconnectorcontext.core.cnrm.cloud.google.com + namespace: {{ .Values.namespace }} + annotations: + argocd.argoproj.io/sync-wave: "0" +spec: + googleServiceAccount: gke-config-connector-sa@{{ .Values.gcpProjectId }}.iam.gserviceaccount.com diff --git a/gitops/modules/sample-module/hello-world/templates/deployment.yaml b/gitops/modules/sample-module/hello-world/templates/deployment.yaml new file mode 100644 index 00000000..1337ff09 --- /dev/null +++ b/gitops/modules/sample-module/hello-world/templates/deployment.yaml @@ -0,0 +1,283 @@ +# Copyright (c) 2024-2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +apiVersion: apps/v1 +kind: Deployment +metadata: + name: hello-world + namespace: {{ .Values.namespace }} + labels: + app.kubernetes.io/name: hello-world + annotations: + argocd.argoproj.io/sync-wave: "2" +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: hello-world + template: + metadata: + labels: + app.kubernetes.io/name: hello-world + spec: +{{- if .Values.parentModuleName }} + volumes: + - name: soft-features + configMap: + name: horizon-sdv-soft-features-{{ .Values.parentModuleName | replace "_" "-" }} + optional: true + initContainers: + - name: log-soft-feature + image: busybox:1.36 + command: ["sh", "-c", "cat /etc/horizon-soft-features/features.env 2>/dev/null || true"] + volumeMounts: + - name: soft-features + mountPath: /etc/horizon-soft-features + readOnly: true + containers: + # Soft flags from Module Manager ConfigMap (ModuleCatalog softFeaturesPropagation: ConfigMap); pod template stays stable when toggling. + - name: hello-world + image: busybox:1.36 + command: ["/bin/sh", "-c"] + args: + - | + FEATURE_DIR=/etc/horizon-soft-features + gen() { + mkdir -p /www + { + cat << 'HTMLHEAD' + + + + + + Hello — sample-module + + + +
+

Hello from sample-module

+

Gateway route · busybox httpd · soft flags from ConfigMap

+
+

Soft feature flags

+ + + + HTMLHEAD + if [ ! -f "$FEATURE_DIR/features.env" ] || [ ! -s "$FEATURE_DIR/features.env" ]; then + printf ' \n' + else + while IFS= read -r line || [ -n "$line" ]; do + [ -z "$line" ] && continue + case "$line" in SOFT_FEATURE_ENABLED_*=*) ;; + *) continue ;; + esac + key="${line%%=*}" + val="${line#*=}" + esc_key=$(printf '%s' "$key" | sed -e 's/&/\&/g' -e 's//\>/g') + esc_val=$(printf '%s' "$val" | sed -e 's/&/\&/g' -e 's//\>/g') + printf ' \n' "$esc_key" "$esc_val" + done < "$FEATURE_DIR/features.env" + fi + cat << 'HTMLTAIL' + +
VariableValue
No soft-feature flags (ConfigMap empty or not mounted yet).
%s%s
+
+
+ + + HTMLTAIL + } > /www/index.html + } + gen + ( while true; do gen; sleep 3; done ) & + exec busybox httpd -f -p 8080 -h /www + volumeMounts: + - name: soft-features + mountPath: /etc/horizon-soft-features + readOnly: true + ports: + - containerPort: 8080 + readinessProbe: + httpGet: + path: / + port: 8080 + initialDelaySeconds: 3 + periodSeconds: 10 + livenessProbe: + httpGet: + path: / + port: 8080 + initialDelaySeconds: 10 + periodSeconds: 20 +{{- else }} + initContainers: + - name: log-soft-feature + image: busybox:1.36 + command: ["sh", "-c", "env | grep '^SOFT_FEATURE_ENABLED_' || true"] + env: + {{- range $name, $on := .Values.softFeaturesEnabled }} + - name: SOFT_FEATURE_ENABLED_{{ $name | replace "-" "_" | replace "." "_" | upper }} + value: {{ $on | quote }} + {{- end }} + containers: + # Soft-feature toggles change Helm values → this Deployment’s env → pod rollout (expected). + - name: hello-world + image: busybox:1.36 + command: ["/bin/sh", "-c"] + args: + - | + mkdir -p /www + { + cat << 'HTMLHEAD' + + + + + + Hello — sample-module + + + +
+

Hello from sample-module

+

Gateway route · busybox httpd

+
+

Soft feature flags

+ + + + HTMLHEAD + if ! env | grep -q '^SOFT_FEATURE_ENABLED_'; then + printf ' \n' + else + env | grep '^SOFT_FEATURE_ENABLED_' | sort | while IFS= read -r line; do + key="${line%%=*}" + val="${line#*=}" + esc_key=$(printf '%s' "$key" | sed -e 's/&/\&/g' -e 's//\>/g') + esc_val=$(printf '%s' "$val" | sed -e 's/&/\&/g' -e 's//\>/g') + printf ' \n' "$esc_key" "$esc_val" + done + fi + cat << 'HTMLTAIL' + +
VariableValue
No SOFT_FEATURE_ENABLED_* variables in this pod.
%s%s
+
+
+ + + HTMLTAIL + } > /www/index.html + exec busybox httpd -f -p 8080 -h /www + env: + {{- range $name, $on := .Values.softFeaturesEnabled }} + - name: SOFT_FEATURE_ENABLED_{{ $name | replace "-" "_" | replace "." "_" | upper }} + value: {{ $on | quote }} + {{- end }} + ports: + - containerPort: 8080 + readinessProbe: + httpGet: + path: / + port: 8080 + initialDelaySeconds: 3 + periodSeconds: 10 + livenessProbe: + httpGet: + path: / + port: 8080 + initialDelaySeconds: 10 + periodSeconds: 20 +{{- end }} diff --git a/gitops/modules/sample-module/hello-world/templates/gateway-hello-world.yaml b/gitops/modules/sample-module/hello-world/templates/gateway-hello-world.yaml new file mode 100644 index 00000000..e2043ef8 --- /dev/null +++ b/gitops/modules/sample-module/hello-world/templates/gateway-hello-world.yaml @@ -0,0 +1,91 @@ +# Copyright (c) 2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +apiVersion: gateway.networking.k8s.io/v1beta1 +kind: HTTPRoute +metadata: + name: hello-world-route + namespace: {{ .Values.namespace }} + labels: + gateway: gke-gateway + annotations: + argocd.argoproj.io/sync-wave: "5" +spec: + parentRefs: + - kind: Gateway + name: gke-gateway + namespace: {{ .Values.config.namespacePrefix }}gke-gateway + sectionName: https + hostnames: + - {{ .Values.config.domain }} + rules: + - matches: + - path: + type: PathPrefix + value: {{ .Values.rootPath | quote }} + filters: + - type: URLRewrite + urlRewrite: + hostname: {{ .Values.config.domain }} + path: + type: ReplacePrefixMatch + replacePrefixMatch: / + backendRefs: + - name: hello-world + port: 8080 +--- +apiVersion: networking.gke.io/v1 +kind: HealthCheckPolicy +metadata: + name: hello-world-healthcheck + namespace: {{ .Values.namespace }} + annotations: + argocd.argoproj.io/sync-wave: "5" +spec: + default: + checkIntervalSec: 15 + timeoutSec: 15 + healthyThreshold: 1 + unhealthyThreshold: 2 + logConfig: + enabled: true + config: + type: HTTP + httpHealthCheck: + port: 8080 + requestPath: / + targetRef: + group: "" + kind: Service + name: hello-world +--- +{{- if .Values.config.enableNetworkPolicies }} +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: allow-hello-world-ingress-from-gateway + namespace: {{ .Values.namespace }} + annotations: + argocd.argoproj.io/sync-wave: "0" +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: hello-world + policyTypes: + - Ingress + ingress: + - ports: + - protocol: TCP + port: 8080 +{{- end }} diff --git a/gitops/modules/sample-module/hello-world/templates/pubsub-topic.yaml b/gitops/modules/sample-module/hello-world/templates/pubsub-topic.yaml new file mode 100644 index 00000000..b0a295d6 --- /dev/null +++ b/gitops/modules/sample-module/hello-world/templates/pubsub-topic.yaml @@ -0,0 +1,26 @@ +# Copyright (c) 2024-2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# PubSub KCC resource. +apiVersion: pubsub.cnrm.cloud.google.com/v1beta1 +kind: PubSubTopic +metadata: + name: sample-events + namespace: {{ .Values.namespace }} + annotations: + argocd.argoproj.io/sync-wave: "1" + cnrm.cloud.google.com/project-id: "{{ .Values.gcpProjectId }}" + labels: + app.kubernetes.io/name: hello-world +spec: {} diff --git a/gitops/modules/sample-module/hello-world/templates/service.yaml b/gitops/modules/sample-module/hello-world/templates/service.yaml new file mode 100644 index 00000000..895036c8 --- /dev/null +++ b/gitops/modules/sample-module/hello-world/templates/service.yaml @@ -0,0 +1,30 @@ +# Copyright (c) 2024-2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +apiVersion: v1 +kind: Service +metadata: + name: hello-world + namespace: {{ .Values.namespace }} + labels: + app.kubernetes.io/name: hello-world + annotations: + argocd.argoproj.io/sync-wave: "2" +spec: + selector: + app.kubernetes.io/name: hello-world + ports: + - port: 8080 + targetPort: 8080 diff --git a/gitops/modules/sample-module/hello-world/values.yaml b/gitops/modules/sample-module/hello-world/values.yaml new file mode 100644 index 00000000..8c6c7e9a --- /dev/null +++ b/gitops/modules/sample-module/hello-world/values.yaml @@ -0,0 +1,31 @@ +# Copyright (c) 2024-2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# Namespace for hello-world and PubSub (should have KCC configured if gcpProjectId is set). +namespace: sample-module-hello + +# GCP project ID for PubSubTopic (KCC). Leave empty to skip PubSub. +gcpProjectId: "" + +# When empty, soft flags come from parent Helm values (pod rollout on toggle). When set by the parent Application, flags come from a Module Manager–managed ConfigMap (see ModuleCatalog softFeaturesPropagation). +parentModuleName: "" + +# Legacy: used only when parentModuleName is empty (Helm-injected env SOFT_FEATURE_ENABLED_*). +softFeaturesEnabled: {} + +# Public URL path prefix (leading slash, no trailing slash). Parent sample-module passes helloWorld.rootPath. Must match Module Catalog application URL for this app. +rootPath: /hello-world + +# Injected by parent sample-module Application (domain, namespacePrefix, enableNetworkPolicies, projectID, …). +config: {} diff --git a/gitops/modules/sample-module/portal/overview.html b/gitops/modules/sample-module/portal/overview.html new file mode 100644 index 00000000..db54f532 --- /dev/null +++ b/gitops/modules/sample-module/portal/overview.html @@ -0,0 +1,133 @@ + + + + + + + + sample module + + + +
+
+

Horizon module

+

sample

+

+ End-to-end reference module for the Developer Portal and Horizon API: exposed smoke + WorkflowTemplates, Argo Events sensors, GCS log and artifact paths, and a public + hello-world service behind the cluster Gateway. +

+
+
+
+

Dependencies

+

+ Requires sample-hard as a hard prerequisite. Optionally integrates + sample-soft as a soft dependency: when that module is enabled, the smoke + workflow can call the soft module’s template via templateRef; Module Manager also + propagates soft-feature flags through Helm values and a ConfigMap in sample-module-hello. +

+
+
+

Horizon catalog

+

+ Workflow templates in this chart carry horizon-sdv.io/expose: "true" so they appear under + Workflow Templates in the portal once the module is READY. Use them to validate + submissions, labels such as horizon-sdv.io/submitted-from, and artifact retention without + pulling in Android build farms. +

+
+
+

Gateway

+

+ The hello-world child chart publishes an HTTPRoute (configurable root path, default + /hello-world) aligned with catalog applications[] links so operators can open + the demo service from the Applications tab. +

+
+
+
+ + diff --git a/gitops/modules/sample-module/templates/application-argo-workflows.yaml b/gitops/modules/sample-module/templates/application-argo-workflows.yaml new file mode 100644 index 00000000..9a38c2db --- /dev/null +++ b/gitops/modules/sample-module/templates/application-argo-workflows.yaml @@ -0,0 +1,48 @@ +# Copyright (c) 2024-2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Child Application that deploys sample-module argo-workflows (templates/workflowtemplates.yaml + templates/sensors.yaml; catalog-exposed). +# Lives in its own Application so ArgoCD only validates WorkflowTemplate resources +# after the Argo Workflows CRDs are already installed on the cluster. +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: mod-{{ .Values.moduleName }}-argo-workflows + namespace: {{ .Values.argocd.namespace }} + labels: + app.kubernetes.io/name: {{ .Values.moduleName }} + horizon-sdv.io/module: {{ .Values.moduleName }} + horizon-sdv.io/app-role: child + horizon-sdv.io/module-manager-managed: "true" + finalizers: + - resources-finalizer.argocd.argoproj.io +spec: + project: {{ .Values.argocd.project }} + source: + repoURL: {{ .Values.repo.url | quote }} + targetRevision: {{ .Values.repo.revision | quote }} + path: gitops/modules/sample-module/argo-workflows + helm: + values: | + workflowNamespace: {{ .Values.config.namespacePrefix }}workflows + eventsNamespace: {{ .Values.config.namespacePrefix }}argo-events + # Derived from Module Manager soft dependency state for sample-soft. + referenceSampleSoftWorkflowTemplate: {{ index .Values.softFeaturesEnabled "sample-soft" | default false }} + destination: + server: https://kubernetes.default.svc + namespace: {{ .Values.config.namespacePrefix }}workflows + syncPolicy: + syncOptions: + - CreateNamespace=true + automated: {} diff --git a/gitops/modules/sample-module/templates/application-hello-world.yaml b/gitops/modules/sample-module/templates/application-hello-world.yaml new file mode 100644 index 00000000..24cd9142 --- /dev/null +++ b/gitops/modules/sample-module/templates/application-hello-world.yaml @@ -0,0 +1,54 @@ +# Copyright (c) 2024-2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Child app-of-apps Application: installs the hello-world chart (Deployment + Service + PubSubTopic KCC). +# This is applied when the Module Manager "enable" creates the parent Application for sample-module. +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: mod-{{ .Values.moduleName }}-hello-world + namespace: {{ .Values.argocd.namespace }} + labels: + app.kubernetes.io/name: {{ .Values.moduleName }} + horizon-sdv.io/module: {{ .Values.moduleName }} + horizon-sdv.io/app-role: child + horizon-sdv.io/expose: "true" + horizon-sdv.io/module-manager-managed: "true" + annotations: + horizon-sdv.io/portal-url: {{ .Values.helloWorld.rootPath | quote }} + horizon-sdv.io/portal-title: "Hello World" + horizon-sdv.io/portal-id: hello-world + finalizers: + - resources-finalizer.argocd.argoproj.io +spec: + project: {{ .Values.argocd.project }} + source: + repoURL: {{ .Values.repo.url | quote }} + targetRevision: {{ .Values.repo.revision | quote }} + path: gitops/modules/sample-module/hello-world + helm: + values: | + namespace: {{ .Values.helloWorldNamespace }} + rootPath: {{ .Values.helloWorld.rootPath | quote }} + gcpProjectId: {{ .Values.config.projectID | quote }} + parentModuleName: {{ .Values.moduleName | quote }} + config: +{{ .Values.config | toYaml | nindent 10 }} + destination: + server: https://kubernetes.default.svc + namespace: {{ .Values.helloWorldNamespace }} + syncPolicy: + syncOptions: + - CreateNamespace=true + automated: {} diff --git a/gitops/modules/sample-module/templates/module-overview-http.yaml b/gitops/modules/sample-module/templates/module-overview-http.yaml new file mode 100644 index 00000000..50033da0 --- /dev/null +++ b/gitops/modules/sample-module/templates/module-overview-http.yaml @@ -0,0 +1,101 @@ +# Copyright (c) 2024-2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# In-cluster static overview HTML for the Developer Portal (Module Manager GET /modules/{name}/overview). +# Synced with the parent module Application; not a separate Argo Application. +# overviewServiceName and overviewNamespace are chart values (Module Manager merges overviewNamespace for workload modules). +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ .Values.overviewServiceName }}-html + namespace: {{ .Values.overviewNamespace }} + labels: + app.kubernetes.io/name: module-overview + app.kubernetes.io/component: module-overview-http + horizon-sdv.io/module: {{ .Values.moduleName | quote }} +data: + index.html: |- +{{ .Files.Get "portal/overview.html" | nindent 4 }} +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ .Values.overviewServiceName }} + namespace: {{ .Values.overviewNamespace }} + labels: + app.kubernetes.io/name: module-overview + horizon-sdv.io/module: {{ .Values.moduleName | quote }} +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: module-overview + horizon-sdv.io/module: {{ .Values.moduleName | quote }} + template: + metadata: + labels: + app.kubernetes.io/name: module-overview + horizon-sdv.io/module: {{ .Values.moduleName | quote }} + spec: + containers: + - name: nginx + image: nginx:1.23 + ports: + - containerPort: 80 + name: http + volumeMounts: + - name: html + mountPath: /usr/share/nginx/html + readOnly: true + readinessProbe: + httpGet: + path: / + port: http + initialDelaySeconds: 2 + periodSeconds: 10 + livenessProbe: + httpGet: + path: / + port: http + initialDelaySeconds: 5 + periodSeconds: 20 + resources: + requests: + cpu: 5m + memory: 16Mi + limits: + memory: 64Mi + volumes: + - name: html + configMap: + name: {{ .Values.overviewServiceName }}-html +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ .Values.overviewServiceName }} + namespace: {{ .Values.overviewNamespace }} + labels: + app.kubernetes.io/name: module-overview + horizon-sdv.io/module: {{ .Values.moduleName | quote }} +spec: + type: ClusterIP + selector: + app.kubernetes.io/name: module-overview + horizon-sdv.io/module: {{ .Values.moduleName | quote }} + ports: + - name: http + port: 80 + targetPort: http diff --git a/gitops/modules/sample-module/values.yaml b/gitops/modules/sample-module/values.yaml new file mode 100644 index 00000000..b69f500d --- /dev/null +++ b/gitops/modules/sample-module/values.yaml @@ -0,0 +1,45 @@ +# Copyright (c) 2024-2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Module name (injected by Module Manager when enabling). Used to derive child Application names. +moduleName: "" + +# Namespace where Module Manager runs (ModuleCatalog is created here so the controller sees it). +moduleManagerNamespace: module-manager + +# ArgoCD namespace and project for the child app-of-apps Application (passed by Module Manager when enabling). +argocd: + namespace: argocd + project: horizon-sdv + +# Repo URL and revision for the child Application (passed by Module Manager when enabling the module). +repo: + url: "" + revision: HEAD + +# Namespace for the hello-world child app and PubSub (KCC). Created by ArgoCD when the child Application syncs. +helloWorldNamespace: sample-module-hello +overviewServiceName: mod-sample-overview +overviewNamespace: sample-module-hello + +# Public Gateway path for hello-world (leading slash, no trailing slash). Keep in sync with module-manager catalog applications[].url. +helloWorld: + rootPath: /hello-world + +# Environment config injected by Module Manager (projectID, region, zone, namespacePrefix, etc.). +# Smoke WorkflowTemplates deploy via child app gitops/modules/sample-module/argo-workflows (namespaces from config.namespacePrefix). +config: {} + +# Per soft-dependency module name -> enabled (Module Manager patches from catalog softDependencies + state). +softFeaturesEnabled: {} diff --git a/gitops/modules/sample-soft-module/Chart.yaml b/gitops/modules/sample-soft-module/Chart.yaml new file mode 100644 index 00000000..0dfd2fef --- /dev/null +++ b/gitops/modules/sample-soft-module/Chart.yaml @@ -0,0 +1,20 @@ +# Copyright (c) 2024-2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +apiVersion: v2 +name: sample-soft-module +description: Sample soft-dependency module (optional enhancement for sample-module) +version: 0.2.0 +type: application +appVersion: "0.2.0" diff --git a/gitops/modules/sample-soft-module/argo-workflows/Chart.yaml b/gitops/modules/sample-soft-module/argo-workflows/Chart.yaml new file mode 100644 index 00000000..e043f16d --- /dev/null +++ b/gitops/modules/sample-soft-module/argo-workflows/Chart.yaml @@ -0,0 +1,19 @@ +# Copyright (c) 2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +apiVersion: v2 +name: sample-soft-module-argo-workflows +description: Smoke-test WorkflowTemplates and Sensors for module sample-soft (not Horizon-exposed) +version: 0.1.0 +type: application diff --git a/gitops/modules/sample-soft-module/argo-workflows/templates/sensors.yaml b/gitops/modules/sample-soft-module/argo-workflows/templates/sensors.yaml new file mode 100644 index 00000000..e4ef1033 --- /dev/null +++ b/gitops/modules/sample-soft-module/argo-workflows/templates/sensors.yaml @@ -0,0 +1,149 @@ +# Copyright (c) 2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# Argo Events Sensors for module sample-soft (pair with workflowtemplates.yaml). +apiVersion: argoproj.io/v1alpha1 +kind: Sensor +metadata: + name: webhook-sample-soft-smoke-test + namespace: {{ .Values.eventsNamespace }} + annotations: + argocd.argoproj.io/sync-wave: "8" +spec: + eventBusName: default + template: + serviceAccountName: sensor-submit-workflow + dependencies: + - name: webhook-dep + eventSourceName: webhook + eventName: workflow-dispatch + filters: + data: + - path: body.workflowTemplateName + type: string + value: + - "sample-soft-smoke-test" + triggers: + - template: + name: submit-smoke-test + argoWorkflow: + operation: submit + source: + resource: + apiVersion: argoproj.io/v1alpha1 + kind: Workflow + metadata: + generateName: webhook-smoke- + namespace: {{ .Values.workflowNamespace }} + spec: + workflowTemplateRef: + name: sample-soft-smoke-test + arguments: + parameters: + - name: horizonSubmittedFrom + - name: sampleEnv + - name: sampleBuildId + - name: sampleNote + parameters: + - src: + dependencyName: webhook-dep + dataKey: body.horizonSubmittedBy + dest: metadata.annotations.horizon-sdv\.io/submitted-by + - src: + dependencyName: webhook-dep + dataKey: body.horizonSubmittedFrom + dest: metadata.labels.horizon-sdv\.io/submitted-from + - src: + dependencyName: webhook-dep + dataKey: body.horizonSubmittedFrom + dest: spec.arguments.parameters.0.value + - src: + dependencyName: webhook-dep + dataKey: body.sampleEnv + dest: spec.arguments.parameters.1.value + - src: + dependencyName: webhook-dep + dataKey: body.sampleBuildId + dest: spec.arguments.parameters.2.value + - src: + dependencyName: webhook-dep + dataKey: body.sampleNote + dest: spec.arguments.parameters.3.value +--- +apiVersion: argoproj.io/v1alpha1 +kind: Sensor +metadata: + name: webhook-sample-soft-smoke-test-alt + namespace: {{ .Values.eventsNamespace }} + annotations: + argocd.argoproj.io/sync-wave: "8" +spec: + eventBusName: default + template: + serviceAccountName: sensor-submit-workflow + dependencies: + - name: webhook-dep + eventSourceName: webhook + eventName: workflow-dispatch + filters: + data: + - path: body.workflowTemplateName + type: string + value: + - "sample-soft-smoke-test-alt" + triggers: + - template: + name: submit-smoke-test-alt + argoWorkflow: + operation: submit + source: + resource: + apiVersion: argoproj.io/v1alpha1 + kind: Workflow + metadata: + generateName: webhook-smoke-alt- + namespace: {{ .Values.workflowNamespace }} + spec: + workflowTemplateRef: + name: sample-soft-smoke-test-alt + arguments: + parameters: + - name: horizonSubmittedFrom + - name: releaseChannel + - name: ticketId + - name: owner + parameters: + - src: + dependencyName: webhook-dep + dataKey: body.horizonSubmittedBy + dest: metadata.annotations.horizon-sdv\.io/submitted-by + - src: + dependencyName: webhook-dep + dataKey: body.horizonSubmittedFrom + dest: metadata.labels.horizon-sdv\.io/submitted-from + - src: + dependencyName: webhook-dep + dataKey: body.horizonSubmittedFrom + dest: spec.arguments.parameters.0.value + - src: + dependencyName: webhook-dep + dataKey: body.releaseChannel + dest: spec.arguments.parameters.1.value + - src: + dependencyName: webhook-dep + dataKey: body.ticketId + dest: spec.arguments.parameters.2.value + - src: + dependencyName: webhook-dep + dataKey: body.owner + dest: spec.arguments.parameters.3.value diff --git a/gitops/modules/sample-soft-module/argo-workflows/templates/workflowtemplates.yaml b/gitops/modules/sample-soft-module/argo-workflows/templates/workflowtemplates.yaml new file mode 100644 index 00000000..150a1e63 --- /dev/null +++ b/gitops/modules/sample-soft-module/argo-workflows/templates/workflowtemplates.yaml @@ -0,0 +1,151 @@ +# Copyright (c) 2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# WorkflowTemplates for module sample-soft (not exposed on Horizon API catalog). Argo Events: templates/sensors.yaml. +apiVersion: argoproj.io/v1alpha1 +kind: WorkflowTemplate +metadata: + name: sample-soft-smoke-test + namespace: {{ .Values.workflowNamespace }} + labels: + app.kubernetes.io/name: smoke-test + horizon-sdv.io/module: sample-soft + annotations: + argocd.argoproj.io/sync-wave: "7" +spec: + entrypoint: run-smoke-test + serviceAccountName: workflow-executor + arguments: + parameters: + - name: horizonSubmittedFrom + value: "" + - name: sampleEnv + value: "" + - name: sampleBuildId + value: "" + - name: sampleNote + value: "" + templates: + - name: run-smoke-test + dag: + tasks: + - name: log-parameters + template: log-parameters + - name: echo-and-artifact + template: echo-and-artifact + depends: log-parameters + - name: verify-gcs + template: verify-gcs + depends: echo-and-artifact + - name: log-parameters + container: + image: alpine:3.19 + command: [sh, -c] + args: + - | + echo "WorkflowTemplate: smoke-test" + echo "sampleEnv={{ "{{workflow.parameters.sampleEnv}}" }}" + echo "sampleBuildId={{ "{{workflow.parameters.sampleBuildId}}" }}" + echo "sampleNote={{ "{{workflow.parameters.sampleNote}}" }}" + echo "at $(date -u +%Y-%m-%dT%H:%M:%SZ)" + - name: echo-and-artifact + container: + image: alpine:3.19 + command: [sh, -c] + args: + - | + echo "Smoke test running at $(date)" + echo "SA: $(cat /var/run/secrets/kubernetes.io/serviceaccount/namespace)/$(cat /var/run/secrets/kubernetes.io/serviceaccount/..data/namespace 2>/dev/null || echo 'n/a')" + echo "Smoke test passed - $(date -u +%Y-%m-%dT%H:%M:%SZ)" > /tmp/result.txt + cat /tmp/result.txt + outputs: + artifacts: + - name: smoke-result + path: /tmp/result.txt + - name: verify-gcs + container: + image: alpine:3.19 + command: [sh, -c] + args: + - | + echo "Artifact upload verified by reaching this step." + echo "All checks passed." +--- +apiVersion: argoproj.io/v1alpha1 +kind: WorkflowTemplate +metadata: + name: sample-soft-smoke-test-alt + namespace: {{ .Values.workflowNamespace }} + labels: + app.kubernetes.io/name: smoke-test + horizon-sdv.io/module: sample-soft + annotations: + argocd.argoproj.io/sync-wave: "7" +spec: + entrypoint: run-smoke-test + serviceAccountName: workflow-executor + arguments: + parameters: + - name: horizonSubmittedFrom + value: "" + - name: releaseChannel + value: "" + - name: ticketId + value: "" + - name: owner + value: "" + templates: + - name: run-smoke-test + dag: + tasks: + - name: log-parameters + template: log-parameters + - name: echo-and-artifact + template: echo-and-artifact + depends: log-parameters + - name: verify-gcs + template: verify-gcs + depends: echo-and-artifact + - name: log-parameters + container: + image: alpine:3.19 + command: [sh, -c] + args: + - | + echo "WorkflowTemplate: smoke-test-alt (variant)" + echo "releaseChannel={{ "{{workflow.parameters.releaseChannel}}" }}" + echo "ticketId={{ "{{workflow.parameters.ticketId}}" }}" + echo "owner={{ "{{workflow.parameters.owner}}" }}" + echo "at $(date -u +%Y-%m-%dT%H:%M:%SZ)" + - name: echo-and-artifact + container: + image: alpine:3.19 + command: [sh, -c] + args: + - | + echo "Smoke test running at $(date)" + echo "SA: $(cat /var/run/secrets/kubernetes.io/serviceaccount/namespace)/$(cat /var/run/secrets/kubernetes.io/serviceaccount/..data/namespace 2>/dev/null || echo 'n/a')" + echo "Smoke test passed - $(date -u +%Y-%m-%dT%H:%M:%SZ)" > /tmp/result.txt + cat /tmp/result.txt + outputs: + artifacts: + - name: smoke-result + path: /tmp/result.txt + - name: verify-gcs + container: + image: alpine:3.19 + command: [sh, -c] + args: + - | + echo "Artifact upload verified by reaching this step." + echo "All checks passed." diff --git a/gitops/modules/sample-soft-module/argo-workflows/values.yaml b/gitops/modules/sample-soft-module/argo-workflows/values.yaml new file mode 100644 index 00000000..d28d0f79 --- /dev/null +++ b/gitops/modules/sample-soft-module/argo-workflows/values.yaml @@ -0,0 +1,16 @@ +# Copyright (c) 2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +workflowNamespace: workflows +eventsNamespace: argo-events diff --git a/gitops/modules/sample-soft-module/hello-world/Chart.yaml b/gitops/modules/sample-soft-module/hello-world/Chart.yaml new file mode 100644 index 00000000..ede5d40e --- /dev/null +++ b/gitops/modules/sample-soft-module/hello-world/Chart.yaml @@ -0,0 +1,21 @@ +# Copyright (c) 2024-2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +apiVersion: v2 +name: hello-world +description: Hello-world app for sample-soft-module +version: 0.1.0 +type: application +appVersion: "0.1.0" diff --git a/gitops/modules/sample-soft-module/hello-world/templates/config-connector-context.yaml b/gitops/modules/sample-soft-module/hello-world/templates/config-connector-context.yaml new file mode 100644 index 00000000..7d2db336 --- /dev/null +++ b/gitops/modules/sample-soft-module/hello-world/templates/config-connector-context.yaml @@ -0,0 +1,27 @@ +# Copyright (c) 2024-2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# ConfigConnectorContext is required in every namespace that manages KCC resources +# (namespaced-mode Config Connector). Created here so each module namespace is +# self-sufficient without the platform chart needing to enumerate module namespaces. +# gcpProjectId is required for this module and is always expected to be set. +apiVersion: core.cnrm.cloud.google.com/v1beta1 +kind: ConfigConnectorContext +metadata: + name: configconnectorcontext.core.cnrm.cloud.google.com + namespace: {{ .Values.namespace }} + annotations: + argocd.argoproj.io/sync-wave: "0" +spec: + googleServiceAccount: gke-config-connector-sa@{{ .Values.gcpProjectId }}.iam.gserviceaccount.com diff --git a/gitops/modules/sample-soft-module/hello-world/templates/deployment.yaml b/gitops/modules/sample-soft-module/hello-world/templates/deployment.yaml new file mode 100644 index 00000000..ebad1873 --- /dev/null +++ b/gitops/modules/sample-soft-module/hello-world/templates/deployment.yaml @@ -0,0 +1,40 @@ +# Copyright (c) 2024-2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +apiVersion: apps/v1 +kind: Deployment +metadata: + name: hello-world + namespace: {{ .Values.namespace }} + labels: + app.kubernetes.io/name: hello-world + annotations: + argocd.argoproj.io/sync-wave: "2" +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: hello-world + template: + metadata: + labels: + app.kubernetes.io/name: hello-world + spec: + containers: + - name: hello-world + image: hashicorp/http-echo:0.2.3 + args: ["-listen=:8080", "-text=Hello from sample-soft"] + ports: + - containerPort: 8080 diff --git a/gitops/modules/sample-soft-module/hello-world/templates/pubsub-topic.yaml b/gitops/modules/sample-soft-module/hello-world/templates/pubsub-topic.yaml new file mode 100644 index 00000000..7bfda815 --- /dev/null +++ b/gitops/modules/sample-soft-module/hello-world/templates/pubsub-topic.yaml @@ -0,0 +1,26 @@ +# Copyright (c) 2024-2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +apiVersion: pubsub.cnrm.cloud.google.com/v1beta1 +kind: PubSubTopic +metadata: + name: sample-soft-events + namespace: {{ .Values.namespace }} + annotations: + argocd.argoproj.io/sync-wave: "1" + cnrm.cloud.google.com/project-id: "{{ .Values.gcpProjectId }}" + labels: + app.kubernetes.io/name: hello-world +spec: {} diff --git a/gitops/modules/sample-soft-module/hello-world/templates/service.yaml b/gitops/modules/sample-soft-module/hello-world/templates/service.yaml new file mode 100644 index 00000000..895036c8 --- /dev/null +++ b/gitops/modules/sample-soft-module/hello-world/templates/service.yaml @@ -0,0 +1,30 @@ +# Copyright (c) 2024-2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +apiVersion: v1 +kind: Service +metadata: + name: hello-world + namespace: {{ .Values.namespace }} + labels: + app.kubernetes.io/name: hello-world + annotations: + argocd.argoproj.io/sync-wave: "2" +spec: + selector: + app.kubernetes.io/name: hello-world + ports: + - port: 8080 + targetPort: 8080 diff --git a/gitops/modules/sample-soft-module/hello-world/values.yaml b/gitops/modules/sample-soft-module/hello-world/values.yaml new file mode 100644 index 00000000..dbeb0a0c --- /dev/null +++ b/gitops/modules/sample-soft-module/hello-world/values.yaml @@ -0,0 +1,17 @@ +# Copyright (c) 2024-2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +namespace: sample-soft-module-hello +gcpProjectId: "" diff --git a/gitops/modules/sample-soft-module/portal/overview.html b/gitops/modules/sample-soft-module/portal/overview.html new file mode 100644 index 00000000..7071588e --- /dev/null +++ b/gitops/modules/sample-soft-module/portal/overview.html @@ -0,0 +1,124 @@ + + + + + + + + sample-soft module + + + +
+
+

Horizon module

+

sample-soft

+

+ Companion module for soft dependencies. Catalog entries can list sample-soft + under softDependencies so parents can merge optional Helm values and ConfigMaps without + blocking enable or disable when this module is off. +

+
+
+
+

Workflows

+

+ Ships sample-soft-smoke-test and related Argo Events sensors in the module’s + argo-workflows chart. Templates are intentionally not labeled for the Horizon + workflow catalog; they are invoked from other modules (for example via templateRef) when + soft wiring is enabled. +

+
+
+

Child applications

+

+ Same pattern as other sample modules: a lightweight hello-world application + (workload plus GCP resources) and the workflows chart, both managed as Argo CD child Applications under + this module’s umbrella. +

+
+
+
+ + diff --git a/gitops/modules/sample-soft-module/templates/application-argo-workflows.yaml b/gitops/modules/sample-soft-module/templates/application-argo-workflows.yaml new file mode 100644 index 00000000..fb24f46d --- /dev/null +++ b/gitops/modules/sample-soft-module/templates/application-argo-workflows.yaml @@ -0,0 +1,44 @@ +# Copyright (c) 2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Child Application: sample-soft-module argo-workflows (templates/workflowtemplates.yaml + templates/sensors.yaml; not Horizon-exposed). +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: mod-{{ .Values.moduleName }}-argo-workflows + namespace: {{ .Values.argocd.namespace }} + labels: + app.kubernetes.io/name: {{ .Values.moduleName }} + horizon-sdv.io/module: {{ .Values.moduleName }} + horizon-sdv.io/app-role: child + horizon-sdv.io/module-manager-managed: "true" + finalizers: + - resources-finalizer.argocd.argoproj.io +spec: + project: {{ .Values.argocd.project }} + source: + repoURL: {{ .Values.repo.url | quote }} + targetRevision: {{ .Values.repo.revision | quote }} + path: gitops/modules/sample-soft-module/argo-workflows + helm: + values: | + workflowNamespace: {{ .Values.config.namespacePrefix }}workflows + eventsNamespace: {{ .Values.config.namespacePrefix }}argo-events + destination: + server: https://kubernetes.default.svc + namespace: {{ .Values.config.namespacePrefix }}workflows + syncPolicy: + syncOptions: + - CreateNamespace=true + automated: {} diff --git a/gitops/modules/sample-soft-module/templates/application-hello-world.yaml b/gitops/modules/sample-soft-module/templates/application-hello-world.yaml new file mode 100644 index 00000000..6cfb9103 --- /dev/null +++ b/gitops/modules/sample-soft-module/templates/application-hello-world.yaml @@ -0,0 +1,43 @@ +# Copyright (c) 2024-2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: mod-{{ .Values.moduleName }}-hello-world + namespace: {{ .Values.argocd.namespace }} + labels: + app.kubernetes.io/name: {{ .Values.moduleName }} + horizon-sdv.io/module: {{ .Values.moduleName }} + horizon-sdv.io/app-role: child + horizon-sdv.io/module-manager-managed: "true" + finalizers: + - resources-finalizer.argocd.argoproj.io +spec: + project: {{ .Values.argocd.project }} + source: + repoURL: {{ .Values.repo.url | quote }} + targetRevision: {{ .Values.repo.revision | quote }} + path: gitops/modules/sample-soft-module/hello-world + helm: + values: | + namespace: {{ .Values.helloWorldNamespace }} + gcpProjectId: {{ .Values.config.projectID | quote }} + destination: + server: https://kubernetes.default.svc + namespace: {{ .Values.helloWorldNamespace }} + syncPolicy: + syncOptions: + - CreateNamespace=true + automated: {} diff --git a/gitops/modules/sample-soft-module/templates/module-overview-http.yaml b/gitops/modules/sample-soft-module/templates/module-overview-http.yaml new file mode 100644 index 00000000..7113a7a2 --- /dev/null +++ b/gitops/modules/sample-soft-module/templates/module-overview-http.yaml @@ -0,0 +1,101 @@ +# Copyright (c) 2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# In-cluster static overview HTML for the Developer Portal (Module Manager GET /modules/{name}/overview). +# Synced with the parent module Application; not a separate Argo Application. +# overviewServiceName and overviewNamespace are chart values (Module Manager merges overviewNamespace for workload modules). +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ .Values.overviewServiceName }}-html + namespace: {{ .Values.overviewNamespace }} + labels: + app.kubernetes.io/name: module-overview + app.kubernetes.io/component: module-overview-http + horizon-sdv.io/module: {{ .Values.moduleName | quote }} +data: + index.html: |- +{{ .Files.Get "portal/overview.html" | nindent 4 }} +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ .Values.overviewServiceName }} + namespace: {{ .Values.overviewNamespace }} + labels: + app.kubernetes.io/name: module-overview + horizon-sdv.io/module: {{ .Values.moduleName | quote }} +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: module-overview + horizon-sdv.io/module: {{ .Values.moduleName | quote }} + template: + metadata: + labels: + app.kubernetes.io/name: module-overview + horizon-sdv.io/module: {{ .Values.moduleName | quote }} + spec: + containers: + - name: nginx + image: nginx:1.23 + ports: + - containerPort: 80 + name: http + volumeMounts: + - name: html + mountPath: /usr/share/nginx/html + readOnly: true + readinessProbe: + httpGet: + path: / + port: http + initialDelaySeconds: 2 + periodSeconds: 10 + livenessProbe: + httpGet: + path: / + port: http + initialDelaySeconds: 5 + periodSeconds: 20 + resources: + requests: + cpu: 5m + memory: 16Mi + limits: + memory: 64Mi + volumes: + - name: html + configMap: + name: {{ .Values.overviewServiceName }}-html +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ .Values.overviewServiceName }} + namespace: {{ .Values.overviewNamespace }} + labels: + app.kubernetes.io/name: module-overview + horizon-sdv.io/module: {{ .Values.moduleName | quote }} +spec: + type: ClusterIP + selector: + app.kubernetes.io/name: module-overview + horizon-sdv.io/module: {{ .Values.moduleName | quote }} + ports: + - name: http + port: 80 + targetPort: http diff --git a/gitops/modules/sample-soft-module/values.yaml b/gitops/modules/sample-soft-module/values.yaml new file mode 100644 index 00000000..e3ebc68a --- /dev/null +++ b/gitops/modules/sample-soft-module/values.yaml @@ -0,0 +1,26 @@ +# Copyright (c) 2024-2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +moduleName: "" +moduleManagerNamespace: module-manager +argocd: + namespace: argocd + project: horizon-sdv +repo: + url: "" + revision: HEAD +helloWorldNamespace: sample-soft-module-hello +overviewServiceName: mod-sample-soft-overview +overviewNamespace: sample-soft-module-hello +config: {} diff --git a/gitops/modules/workloads-android/Chart.yaml b/gitops/modules/workloads-android/Chart.yaml new file mode 100644 index 00000000..7273a3bd --- /dev/null +++ b/gitops/modules/workloads-android/Chart.yaml @@ -0,0 +1,20 @@ +# Copyright (c) 2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +apiVersion: v2 +name: workloads-android +description: Android AAOS pipelines (dynpvc + aaos-builder + runtime-image) via Module Manager +version: 0.2.0 +type: application +appVersion: "0.2.0" diff --git a/gitops/modules/workloads-android/portal/overview.html b/gitops/modules/workloads-android/portal/overview.html new file mode 100644 index 00000000..c33148d1 --- /dev/null +++ b/gitops/modules/workloads-android/portal/overview.html @@ -0,0 +1,135 @@ + + + + + + + + workloads-android module + + + +
+
+

Horizon module

+

workloads-android

+

+ Brings the Android / AAOS pipeline stack into the shared workflows namespace: curated GitOps under + gitops/workloads/android, the AAOS builder Helm release, reusable docker-image workflow templates, + and optional Gemini-assisted review workflows. +

+
+
+
+

Argo CD sources

+

The child Application merges four sources on one destination namespace:

+
    +
  • gitops/workloads/android — umbrella values and wiring
  • +
  • workloads/android/pipelines/builds/aaos_builder/helm — builder pipelines
  • +
  • workloads/android/pipelines/environment/docker_image_template/helm — image build templates
  • +
  • workloads/common/agentic-ai/gemini/helm — AI review workflow templates
  • +
+
+
+

Prerequisite

+

+ Requires workloads-common so ClusterWorkflowTemplates, GitHub-app credential prep, and shared + workflow RBAC exist before Android charts sync. Project, region, zone, domain, and Android repo URL or + branch are injected from the root MODULE_CONFIG. +

+
+
+

Developer Portal

+

+ After the module is READY, use this portal’s Workflow Templates, + Running Workflows, and History tabs to drive and inspect Horizon-exposed + templates owned by these charts. +

+
+
+
+ + diff --git a/gitops/modules/workloads-android/templates/application-workloads-android.yaml b/gitops/modules/workloads-android/templates/application-workloads-android.yaml new file mode 100644 index 00000000..2e17e7cb --- /dev/null +++ b/gitops/modules/workloads-android/templates/application-workloads-android.yaml @@ -0,0 +1,90 @@ +# Copyright (c) 2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# Child Application: multi-source workloads-android (dynpvc + aaos-builder + runtime-image + gemini ai-review WT; Sensors live in those charts). +# Deployed only via Module Manager enable (mod-workloads-android → this Application). +# Applied when Module Manager enable creates mod-workloads-android. +{{- if and .Values.repo.url .Values.repo.revision }} +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: {{ .Values.config.namespacePrefix }}workloads-android + namespace: {{ .Values.config.namespacePrefix }}argocd + labels: + app.kubernetes.io/name: workloads-android + horizon-sdv.io/module: {{ .Values.moduleName }} + horizon-sdv.io/app-role: child + horizon-sdv.io/module-manager-managed: "true" + annotations: + argocd.argoproj.io/sync-wave: "3" + finalizers: + - resources-finalizer.argocd.argoproj.io +spec: + project: {{ .Values.config.namespacePrefix }}horizon-sdv + sources: + - repoURL: {{ .Values.repo.url | quote }} + targetRevision: {{ .Values.repo.revision | quote }} + path: gitops/workloads/android + helm: + values: | + config: + namespacePrefix: {{ .Values.config.namespacePrefix | quote }} + - repoURL: {{ .Values.repo.url | quote }} + targetRevision: {{ .Values.repo.revision | quote }} + path: workloads/android/pipelines/builds/aaos_builder/helm + helm: + values: | + namespacePrefix: {{ .Values.config.namespacePrefix | quote }} + scm: + authMethod: {{ .Values.config.scm.authMethod | quote }} + spec: + cloudProject: {{ .Values.config.projectID | quote }} + cloudRegion: {{ .Values.config.region | quote }} + cloudZone: {{ .Values.config.zone | quote }} + horizonDomain: {{ .Values.config.domain | quote }} + pipelineRepoUrl: {{ .Values.config.workloads.android.url | quote }} + pipelineRepoRevision: {{ .Values.config.workloads.android.branch | quote }} + pipelineRepoSecret: {{ if or (eq .Values.config.scm.authMethod "userpass") (eq .Values.config.scm.authMethod "app") }}{{ "workflow-pipeline-git-creds" | quote }}{{ else }}{{ "" | quote }}{{ end }} + - repoURL: {{ .Values.repo.url | quote }} + targetRevision: {{ .Values.repo.revision | quote }} + path: workloads/android/pipelines/environment/docker_image_template/helm + helm: + values: | + namespacePrefix: {{ .Values.config.namespacePrefix | quote }} + scm: + authMethod: {{ .Values.config.scm.authMethod | quote }} + cloudEnvConfigMapName: "horizon-workflow-cloud-env" + spec: + cloudProject: {{ .Values.config.projectID | quote }} + cloudRegion: {{ .Values.config.region | quote }} + cloudZone: {{ .Values.config.zone | quote }} + pipelineRepoUrl: {{ .Values.config.workloads.android.url | quote }} + pipelineRepoRevision: {{ .Values.config.workloads.android.branch | quote }} + pipelineRepoSecret: {{ if or (eq .Values.config.scm.authMethod "userpass") (eq .Values.config.scm.authMethod "app") }}{{ "workflow-pipeline-git-creds" | quote }}{{ else }}{{ "" | quote }}{{ end }} + - repoURL: {{ .Values.repo.url | quote }} + targetRevision: {{ .Values.repo.revision | quote }} + path: workloads/common/agentic-ai/gemini/helm + helm: + releaseName: workloads-android-ai-review-wt + values: | + namespacePrefix: {{ .Values.config.namespacePrefix | quote }} + destination: + server: https://kubernetes.default.svc + namespace: {{ .Values.config.namespacePrefix }}workflows + syncPolicy: + syncOptions: + - CreateNamespace=true + automated: {} +{{- end }} + diff --git a/gitops/modules/workloads-android/templates/module-overview-http.yaml b/gitops/modules/workloads-android/templates/module-overview-http.yaml new file mode 100644 index 00000000..7113a7a2 --- /dev/null +++ b/gitops/modules/workloads-android/templates/module-overview-http.yaml @@ -0,0 +1,101 @@ +# Copyright (c) 2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# In-cluster static overview HTML for the Developer Portal (Module Manager GET /modules/{name}/overview). +# Synced with the parent module Application; not a separate Argo Application. +# overviewServiceName and overviewNamespace are chart values (Module Manager merges overviewNamespace for workload modules). +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ .Values.overviewServiceName }}-html + namespace: {{ .Values.overviewNamespace }} + labels: + app.kubernetes.io/name: module-overview + app.kubernetes.io/component: module-overview-http + horizon-sdv.io/module: {{ .Values.moduleName | quote }} +data: + index.html: |- +{{ .Files.Get "portal/overview.html" | nindent 4 }} +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ .Values.overviewServiceName }} + namespace: {{ .Values.overviewNamespace }} + labels: + app.kubernetes.io/name: module-overview + horizon-sdv.io/module: {{ .Values.moduleName | quote }} +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: module-overview + horizon-sdv.io/module: {{ .Values.moduleName | quote }} + template: + metadata: + labels: + app.kubernetes.io/name: module-overview + horizon-sdv.io/module: {{ .Values.moduleName | quote }} + spec: + containers: + - name: nginx + image: nginx:1.23 + ports: + - containerPort: 80 + name: http + volumeMounts: + - name: html + mountPath: /usr/share/nginx/html + readOnly: true + readinessProbe: + httpGet: + path: / + port: http + initialDelaySeconds: 2 + periodSeconds: 10 + livenessProbe: + httpGet: + path: / + port: http + initialDelaySeconds: 5 + periodSeconds: 20 + resources: + requests: + cpu: 5m + memory: 16Mi + limits: + memory: 64Mi + volumes: + - name: html + configMap: + name: {{ .Values.overviewServiceName }}-html +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ .Values.overviewServiceName }} + namespace: {{ .Values.overviewNamespace }} + labels: + app.kubernetes.io/name: module-overview + horizon-sdv.io/module: {{ .Values.moduleName | quote }} +spec: + type: ClusterIP + selector: + app.kubernetes.io/name: module-overview + horizon-sdv.io/module: {{ .Values.moduleName | quote }} + ports: + - name: http + port: 80 + targetPort: http diff --git a/gitops/modules/workloads-android/values.yaml b/gitops/modules/workloads-android/values.yaml new file mode 100644 index 00000000..5b064dfb --- /dev/null +++ b/gitops/modules/workloads-android/values.yaml @@ -0,0 +1,41 @@ +# Copyright (c) 2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Injected by Module Manager on enable. +moduleName: "" + +moduleManagerNamespace: module-manager + +argocd: + namespace: argocd + project: horizon-sdv + +repo: + url: "" + revision: HEAD + +# From MODULE_CONFIG (root module-manager Helm values): projectID, region, zone, namespacePrefix, +# domain, workloads.android.url/branch, and pipeline auth via config.scm.authMethod (Module Manager → config.scm). +# AAOS webhook Sensors (webhook-aaos-builder*) deploy from the aaos_builder and docker_image_template +# Helm sources on workloads-android into namespacePrefix+argo-events (templates/workflow/sensors.yaml). +# webhooks.enabled is retained for MODULE_CONFIG compatibility (previously gated a duplicate Argo Application). +config: + workloads: + android: + webhooks: + enabled: true + +overviewServiceName: mod-workloads-android-overview + +softFeaturesEnabled: {} diff --git a/gitops/modules/workloads-common/Chart.yaml b/gitops/modules/workloads-common/Chart.yaml new file mode 100644 index 00000000..dc0f1b64 --- /dev/null +++ b/gitops/modules/workloads-common/Chart.yaml @@ -0,0 +1,20 @@ +# Copyright (c) 2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +apiVersion: v2 +name: workloads-common +description: Shared ClusterWorkflowTemplates (common-docker-image-build, prepare-github-app-git-creds) via Module Manager +version: 0.2.0 +type: application +appVersion: "0.2.0" diff --git a/gitops/modules/workloads-common/README.md b/gitops/modules/workloads-common/README.md new file mode 100644 index 00000000..4c557dea --- /dev/null +++ b/gitops/modules/workloads-common/README.md @@ -0,0 +1,10 @@ +# workloads-common (Module Manager) + +Deploys shared **ClusterWorkflowTemplates** and colocated **Argo Events Sensors** via Argo CD (`application-workloads-common.yaml` multi-source): + +- `workloads/common/common_docker_image/helm` → **common-docker-image-build** + Sensor **webhook-common-docker-image-build** +- `prepare-github-app-git-creds/` (Helm chart) → **prepare-pipeline-git-creds** (umbrella) + **prepare-github-app-git-creds** + Sensor **webhook-prepare-github-app-git-creds** + ConfigMap **{namespacePrefix}workflow-github-app-token-script** when `scm.authMethod` is **app** (GitHub App pipeline git; ExternalSecret **workflow-github-app** stays in platform `argo-workflows-init`) + +Enable this module **before** **workloads-android** (hard dependency). Sync wave **2** in the child Application; **workloads-android** is wave **3**. + +Migration from the removed root-chart Application `workflows-common-cluster-templates`: enable **`workloads-common`** in Module Manager so the new child Application applies before or as you rely on those CWTs. diff --git a/gitops/modules/workloads-common/portal/overview.html b/gitops/modules/workloads-common/portal/overview.html new file mode 100644 index 00000000..6082978b --- /dev/null +++ b/gitops/modules/workloads-common/portal/overview.html @@ -0,0 +1,132 @@ + + + + + + + + workloads-common module + + + +
+
+

Horizon module

+

workloads-common

+

+ Shared workflow platform for the whole cluster. It layers ClusterWorkflowTemplates, + sensors, and helper jobs into the prefixed workflows namespace so downstream modules (notably + Android) do not duplicate boilerplate. +

+
+
+
+

What deploys

+

+ A single Argo CD Application with two Helm sources: the repository’s + workloads/common/common_docker_image/helm chart (generic container-image build template) and + gitops/modules/workloads-common/prepare-github-app-git-creds, which installs GitHub App token + preparation workflows and their EventBus wiring. +

+
+
+

Downstream use

+

+ workloads-android lists this module as a hard dependency. Enable + workloads-common first so AAOS builder, runtime image, and related pipelines can resolve shared + templates and credentials automation in a consistent namespace. +

+
+
+

Operations note

+

+ Sync waves keep this Application ahead of heavier Android charts. Git auth mode from root config + (pat vs app) is passed into the prepare-github chart so workflows match your + cluster’s Git integration. +

+
+
+
+ + diff --git a/gitops/modules/workloads-common/prepare-github-app-git-creds/Chart.yaml b/gitops/modules/workloads-common/prepare-github-app-git-creds/Chart.yaml new file mode 100644 index 00000000..e7163e7f --- /dev/null +++ b/gitops/modules/workloads-common/prepare-github-app-git-creds/Chart.yaml @@ -0,0 +1,20 @@ +# Copyright (c) 2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +apiVersion: v2 +name: prepare-github-app-git-creds +description: ClusterWorkflowTemplate prepare-github-app-git-creds (GitHub App token for pipeline git) +type: application +version: 0.1.0 +appVersion: "1.0" diff --git a/gitops/modules/workloads-common/prepare-github-app-git-creds/files/github_app_installation_token.py b/gitops/modules/workloads-common/prepare-github-app-git-creds/files/github_app_installation_token.py new file mode 100644 index 00000000..5ed69654 --- /dev/null +++ b/gitops/modules/workloads-common/prepare-github-app-git-creds/files/github_app_installation_token.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2024-2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# +# Mint a GitHub App installation token and create a Kubernetes Secret for Argo +# Workflows git HTTPS artifacts (username=x-access-token, password=token). +from __future__ import annotations + +import json +import os +import ssl +import sys +import time +import urllib.error +import urllib.request + +try: + import jwt +except ImportError: + print("Missing dependency: pip install pyjwt cryptography", file=sys.stderr) + sys.exit(1) + + +def _read_sa() -> tuple[str, str, str]: + base = "/var/run/secrets/kubernetes.io/serviceaccount" + with open(os.path.join(base, "namespace"), encoding="utf-8") as f: + namespace = f.read().strip() + with open(os.path.join(base, "token"), encoding="utf-8") as f: + token = f.read().strip() + ca = os.path.join(base, "ca.crt") + return namespace, token, ca + + +def _github_jwt(app_id: str, private_key_pem: str) -> str: + now = int(time.time()) + # PyJWT 2.12+ requires iss to be a str; GitHub accepts the App ID as a decimal string. + iss = str(int(app_id.strip(), 10)) + payload = { + "iat": now - 60, + "exp": now + 9 * 60, + "iss": iss, + } + return jwt.encode(payload, private_key_pem, algorithm="RS256") + + +def _installation_token(jwt_token: str, installation_id: str) -> str: + req = urllib.request.Request( + f"https://api.github.com/app/installations/{installation_id}/access_tokens", + method="POST", + headers={ + "Authorization": f"Bearer {jwt_token}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "horizon-sdv-workflows", + }, + ) + ctx = ssl.create_default_context() + with urllib.request.urlopen(req, context=ctx, timeout=60) as resp: + body = json.loads(resp.read().decode("utf-8")) + return body["token"] + + +def _create_git_secret( + namespace: str, + sa_token: str, + ca_path: str, + secret_name: str, + password: str, +) -> None: + api = "https://kubernetes.default.svc" + url = f"{api}/api/v1/namespaces/{namespace}/secrets" + body_obj = { + "apiVersion": "v1", + "kind": "Secret", + "metadata": {"name": secret_name}, + "type": "Opaque", + "stringData": {"username": "x-access-token", "password": password}, + } + data = json.dumps(body_obj).encode("utf-8") + req = urllib.request.Request( + url, + data=data, + method="POST", + headers={ + "Authorization": f"Bearer {sa_token}", + "Content-Type": "application/json", + }, + ) + ctx = ssl.create_default_context(cafile=ca_path) + try: + with urllib.request.urlopen(req, context=ctx, timeout=60) as resp: + if resp.status not in (200, 201): + print(f"Unexpected status creating secret: {resp.status}", file=sys.stderr) + sys.exit(1) + except urllib.error.HTTPError as e: + if e.code == 409: + # Idempotent retry: replace + patch_url = f"{api}/api/v1/namespaces/{namespace}/secrets/{secret_name}" + merge_patch = json.dumps({"stringData": {"username": "x-access-token", "password": password}}) + preq = urllib.request.Request( + patch_url, + data=merge_patch.encode("utf-8"), + method="PATCH", + headers={ + "Authorization": f"Bearer {sa_token}", + "Content-Type": "application/merge-patch+json", + }, + ) + with urllib.request.urlopen(preq, context=ctx, timeout=60) as resp: + if resp.status not in (200,): + print(f"PATCH secret failed: {resp.status}", file=sys.stderr) + sys.exit(1) + return + print(e.read().decode("utf-8", errors="replace"), file=sys.stderr) + raise + + +def main() -> None: + app_id = os.environ.get("GITHUB_APP_ID", "").strip() + installation_id = os.environ.get("GITHUB_APP_INSTALLATION_ID", "").strip() + private_key = os.environ.get("GITHUB_APP_PRIVATE_KEY", "") + uid = os.environ.get("WORKFLOW_UID", "").strip() + if not app_id or not installation_id or not private_key or not uid: + print( + "GITHUB_APP_ID, GITHUB_APP_INSTALLATION_ID, GITHUB_APP_PRIVATE_KEY, WORKFLOW_UID required", + file=sys.stderr, + ) + sys.exit(1) + secret_name = f"{uid}-pipeline-git-creds" + gh_jwt = _github_jwt(app_id, private_key) + token = _installation_token(gh_jwt, installation_id) + ns, sa_token, ca = _read_sa() + _create_git_secret(ns, sa_token, ca, secret_name, token) + print(f"Created secret {secret_name} in namespace {ns}") + + +if __name__ == "__main__": + main() diff --git a/gitops/modules/workloads-common/prepare-github-app-git-creds/templates/clusterworkflowtemplates.yaml b/gitops/modules/workloads-common/prepare-github-app-git-creds/templates/clusterworkflowtemplates.yaml new file mode 100644 index 00000000..fd7a1dfb --- /dev/null +++ b/gitops/modules/workloads-common/prepare-github-app-git-creds/templates/clusterworkflowtemplates.yaml @@ -0,0 +1,67 @@ +# Copyright (c) 2024-2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# Cluster-wide templateRef for aaos-builder / runtime-image when scm.authMethod is app (via prepare-pipeline-git-creds umbrella). +# ExternalSecret workflow-github-app: gitops/templates/argo-workflows-init.yaml (sync wave 1). Script ConfigMap: this chart (when scm.authMethod is app). +# Argo Events: Sensor webhook-prepare-github-app-git-creds (templates/sensors.yaml). Horizon: labels on metadata. +apiVersion: argoproj.io/v1alpha1 +kind: ClusterWorkflowTemplate +metadata: + name: prepare-github-app-git-creds + labels: + horizon-sdv.io/expose: "false" + annotations: + argocd.argoproj.io/sync-wave: "2" +spec: + # Align with Argo workflow-controller artifactRepository.archiveLogs (GCS); explicit for steps invoked from workloads-android pipelines. + archiveLogs: true + arguments: + parameters: + - name: horizonSubmittedFrom + value: "" + templates: + - name: prepare-github-app-git-creds + container: + image: python:3.12-slim + command: [sh, -ec] + args: + - | + pip install --no-cache-dir 'pyjwt>=2.8.0' 'cryptography>=42.0.0' + python3 /scripts/github_app_installation_token.py + env: + - name: GITHUB_APP_ID + valueFrom: + secretKeyRef: + name: workflow-github-app + key: appID + - name: GITHUB_APP_PRIVATE_KEY + valueFrom: + secretKeyRef: + name: workflow-github-app + key: privateKey + - name: GITHUB_APP_INSTALLATION_ID + valueFrom: + secretKeyRef: + name: workflow-github-app + key: installationId + - name: WORKFLOW_UID + value: "{{ "{{" }}workflow.uid{{ "}}" }}" + volumeMounts: + - name: github-app-script + mountPath: /scripts + readOnly: true + volumes: + - name: github-app-script + configMap: + name: {{ .Values.namespacePrefix }}workflow-github-app-token-script diff --git a/gitops/modules/workloads-common/prepare-github-app-git-creds/templates/configmap-workflow-github-app-token-script.yaml b/gitops/modules/workloads-common/prepare-github-app-git-creds/templates/configmap-workflow-github-app-token-script.yaml new file mode 100644 index 00000000..3fea28e6 --- /dev/null +++ b/gitops/modules/workloads-common/prepare-github-app-git-creds/templates/configmap-workflow-github-app-token-script.yaml @@ -0,0 +1,26 @@ +# Copyright (c) 2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +{{- if eq .Values.scm.authMethod "app" }} +# ConfigMap for ClusterWorkflowTemplate prepare-github-app-git-creds (script mounted at /scripts). +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ .Values.namespacePrefix }}workflow-github-app-token-script + annotations: + argocd.argoproj.io/sync-wave: "1" +data: + github_app_installation_token.py: | +{{ .Files.Get "files/github_app_installation_token.py" | nindent 4 }} +{{- end }} diff --git a/gitops/modules/workloads-common/prepare-github-app-git-creds/templates/prepare-pipeline-git-creds.yaml b/gitops/modules/workloads-common/prepare-github-app-git-creds/templates/prepare-pipeline-git-creds.yaml new file mode 100644 index 00000000..bfc5ebb2 --- /dev/null +++ b/gitops/modules/workloads-common/prepare-github-app-git-creds/templates/prepare-pipeline-git-creds.yaml @@ -0,0 +1,87 @@ +# Copyright (c) 2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# Umbrella ClusterWorkflowTemplate: single templateRef for workloads-android pipelines. +# - scmAuthMethod == app: delegates to prepare-github-app-git-creds (installation token). +# - scmAuthMethod == userpass: copies username/password from a static Secret into +# workflow UID + "-pipeline-git-creds" so git artifacts use the same per-run Secret shape as app. +# +# Callers must pass workflow parameters scmAuthMethod and pipelineStaticGitSecretName (default in CWT). +apiVersion: argoproj.io/v1alpha1 +kind: ClusterWorkflowTemplate +metadata: + name: prepare-pipeline-git-creds + labels: + horizon-sdv.io/expose: "false" + annotations: + argocd.argoproj.io/sync-wave: "2" +spec: + archiveLogs: true + arguments: + parameters: + - name: scmAuthMethod + - name: pipelineStaticGitSecretName + value: workflow-pipeline-git-creds + - name: horizonSubmittedFrom + value: "" + templates: + - name: prepare-pipeline-git-creds + steps: + - - name: github-app + when: "{{ "{{" }}workflow.parameters.scmAuthMethod{{ "}}" }} == app" + templateRef: + name: prepare-github-app-git-creds + template: prepare-github-app-git-creds + clusterScope: true + arguments: + parameters: + - name: horizonSubmittedFrom + value: "{{ "{{" }}workflow.parameters.horizonSubmittedFrom{{ "}}" }}" + - - name: userpass-copy + when: "{{ "{{" }}workflow.parameters.scmAuthMethod{{ "}}" }} == userpass" + template: copy-static-pipeline-git-creds + arguments: + parameters: + - name: sourceSecretName + value: "{{ "{{" }}workflow.parameters.pipelineStaticGitSecretName{{ "}}" }}" + - name: copy-static-pipeline-git-creds + inputs: + parameters: + - name: sourceSecretName + container: + # registry.k8s.io/kubectl and rancher/kubectl are kubectl-only (no shell). Bitnami legacy images are Debian-based (sh + kubectl); use full semver tags — docker.io/bitnami/kubectl:1.31 is not published. + image: docker.io/bitnamilegacy/kubectl:1.31.4 + command: [sh, -ec] + args: + - | + # dash (/bin/sh on Debian) does not support `set -o pipefail`; keep POSIX-only for portable images. + set -eu + NS="${POD_NAMESPACE}" + DEST="${WORKFLOW_UID}-pipeline-git-creds" + SRC="{{ "{{" }}inputs.parameters.sourceSecretName{{ "}}" }}" + kubectl delete secret "$DEST" --ignore-not-found=true -n "$NS" + u_b64="$(kubectl get secret "$SRC" -n "$NS" -o jsonpath='{.data.username}')" + p_b64="$(kubectl get secret "$SRC" -n "$NS" -o jsonpath='{.data.password}')" + user="$(printf '%s' "$u_b64" | base64 -d)" + pass="$(printf '%s' "$p_b64" | base64 -d)" + kubectl create secret generic "$DEST" -n "$NS" \ + --from-literal=username="$user" \ + --from-literal=password="$pass" + env: + - name: POD_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + - name: WORKFLOW_UID + value: "{{ "{{" }}workflow.uid{{ "}}" }}" diff --git a/gitops/modules/workloads-common/prepare-github-app-git-creds/templates/sensors.yaml b/gitops/modules/workloads-common/prepare-github-app-git-creds/templates/sensors.yaml new file mode 100644 index 00000000..5bcf1b3a --- /dev/null +++ b/gitops/modules/workloads-common/prepare-github-app-git-creds/templates/sensors.yaml @@ -0,0 +1,69 @@ +# Copyright (c) 2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# Argo Events Sensor for ClusterWorkflowTemplate prepare-github-app-git-creds (same EventSource `webhook` / `workflow-dispatch`). +apiVersion: argoproj.io/v1alpha1 +kind: Sensor +metadata: + name: webhook-prepare-github-app-git-creds + namespace: {{ .Values.namespacePrefix }}argo-events + annotations: + argocd.argoproj.io/sync-wave: "9" +spec: + eventBusName: default + template: + serviceAccountName: sensor-submit-workflow + dependencies: + - name: webhook-dep + eventSourceName: webhook + eventName: workflow-dispatch + filters: + data: + - path: body.workflowTemplateName + type: string + value: + - "prepare-github-app-git-creds" + triggers: + - template: + name: submit-prepare-github-app-git-creds + argoWorkflow: + operation: submit + source: + resource: + apiVersion: argoproj.io/v1alpha1 + kind: Workflow + metadata: + generateName: webhook-prepare-gh-app- + namespace: {{ .Values.namespacePrefix }}workflows + spec: + entrypoint: prepare-github-app-git-creds + workflowTemplateRef: + name: prepare-github-app-git-creds + clusterScope: true + arguments: + parameters: + - name: horizonSubmittedFrom + parameters: + - src: + dependencyName: webhook-dep + dataKey: body.horizonSubmittedBy + dest: metadata.annotations.horizon-sdv\.io/submitted-by + - src: + dependencyName: webhook-dep + dataKey: body.horizonSubmittedFrom + dest: metadata.labels.horizon-sdv\.io/submitted-from + - src: + dependencyName: webhook-dep + dataKey: body.horizonSubmittedFrom + dest: spec.arguments.parameters.0.value diff --git a/gitops/modules/workloads-common/prepare-github-app-git-creds/values.yaml b/gitops/modules/workloads-common/prepare-github-app-git-creds/values.yaml new file mode 100644 index 00000000..ca1c61e2 --- /dev/null +++ b/gitops/modules/workloads-common/prepare-github-app-git-creds/values.yaml @@ -0,0 +1,18 @@ +# Copyright (c) 2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# Matches gitops config.namespacePrefix (ConfigMap workflow-github-app-token-script name). +namespacePrefix: "" +# scm.authMethod is set only by the workloads-common Application (Module Manager / MODULE_CONFIG). Not defaulted here. +# Argo Events Sensor: templates/sensors.yaml. diff --git a/gitops/modules/workloads-common/templates/application-workloads-common.yaml b/gitops/modules/workloads-common/templates/application-workloads-common.yaml new file mode 100644 index 00000000..a959ec3c --- /dev/null +++ b/gitops/modules/workloads-common/templates/application-workloads-common.yaml @@ -0,0 +1,56 @@ +# Copyright (c) 2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# Child Application: shared ClusterWorkflowTemplates (common-docker-image-build + prepare-github-app-git-creds); Sensors in those charts. +# Deployed via Module Manager enable (mod-workloads-common). Sync wave 2 before workloads-android (wave 3). +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: {{ .Values.config.namespacePrefix }}workloads-common + namespace: {{ .Values.config.namespacePrefix }}argocd + labels: + app.kubernetes.io/name: workloads-common + horizon-sdv.io/module: {{ .Values.moduleName }} + horizon-sdv.io/app-role: child + horizon-sdv.io/module-manager-managed: "true" + annotations: + argocd.argoproj.io/sync-wave: "2" + finalizers: + - resources-finalizer.argocd.argoproj.io +spec: + project: {{ .Values.config.namespacePrefix }}horizon-sdv + sources: + - repoURL: {{ .Values.repo.url | quote }} + targetRevision: {{ .Values.repo.revision | quote }} + path: workloads/common/common_docker_image/helm + helm: + releaseName: workloads-common-docker-image-cwt + values: | + namespacePrefix: {{ .Values.config.namespacePrefix | quote }} + - repoURL: {{ .Values.repo.url | quote }} + targetRevision: {{ .Values.repo.revision | quote }} + path: gitops/modules/workloads-common/prepare-github-app-git-creds + helm: + releaseName: workloads-common-prepare-github-app-cwt + values: | + namespacePrefix: {{ .Values.config.namespacePrefix | quote }} + scm: + authMethod: {{ .Values.config.scm.authMethod | quote }} + destination: + server: https://kubernetes.default.svc + namespace: {{ .Values.config.namespacePrefix }}workflows + syncPolicy: + syncOptions: + - CreateNamespace=true + automated: {} diff --git a/gitops/modules/workloads-common/templates/module-overview-http.yaml b/gitops/modules/workloads-common/templates/module-overview-http.yaml new file mode 100644 index 00000000..7113a7a2 --- /dev/null +++ b/gitops/modules/workloads-common/templates/module-overview-http.yaml @@ -0,0 +1,101 @@ +# Copyright (c) 2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# In-cluster static overview HTML for the Developer Portal (Module Manager GET /modules/{name}/overview). +# Synced with the parent module Application; not a separate Argo Application. +# overviewServiceName and overviewNamespace are chart values (Module Manager merges overviewNamespace for workload modules). +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ .Values.overviewServiceName }}-html + namespace: {{ .Values.overviewNamespace }} + labels: + app.kubernetes.io/name: module-overview + app.kubernetes.io/component: module-overview-http + horizon-sdv.io/module: {{ .Values.moduleName | quote }} +data: + index.html: |- +{{ .Files.Get "portal/overview.html" | nindent 4 }} +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ .Values.overviewServiceName }} + namespace: {{ .Values.overviewNamespace }} + labels: + app.kubernetes.io/name: module-overview + horizon-sdv.io/module: {{ .Values.moduleName | quote }} +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: module-overview + horizon-sdv.io/module: {{ .Values.moduleName | quote }} + template: + metadata: + labels: + app.kubernetes.io/name: module-overview + horizon-sdv.io/module: {{ .Values.moduleName | quote }} + spec: + containers: + - name: nginx + image: nginx:1.23 + ports: + - containerPort: 80 + name: http + volumeMounts: + - name: html + mountPath: /usr/share/nginx/html + readOnly: true + readinessProbe: + httpGet: + path: / + port: http + initialDelaySeconds: 2 + periodSeconds: 10 + livenessProbe: + httpGet: + path: / + port: http + initialDelaySeconds: 5 + periodSeconds: 20 + resources: + requests: + cpu: 5m + memory: 16Mi + limits: + memory: 64Mi + volumes: + - name: html + configMap: + name: {{ .Values.overviewServiceName }}-html +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ .Values.overviewServiceName }} + namespace: {{ .Values.overviewNamespace }} + labels: + app.kubernetes.io/name: module-overview + horizon-sdv.io/module: {{ .Values.moduleName | quote }} +spec: + type: ClusterIP + selector: + app.kubernetes.io/name: module-overview + horizon-sdv.io/module: {{ .Values.moduleName | quote }} + ports: + - name: http + port: 80 + targetPort: http diff --git a/gitops/modules/workloads-common/values.yaml b/gitops/modules/workloads-common/values.yaml new file mode 100644 index 00000000..c8b29d31 --- /dev/null +++ b/gitops/modules/workloads-common/values.yaml @@ -0,0 +1,33 @@ +# Copyright (c) 2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# Injected by Module Manager on enable. +moduleName: "" + +moduleManagerNamespace: module-manager + +argocd: + namespace: argocd + project: horizon-sdv + +repo: + url: "" + revision: HEAD + +# From MODULE_CONFIG (root module-manager Helm values): projectID, namespacePrefix, scm.authMethod, etc. +config: {} + +overviewServiceName: mod-workloads-common-overview + +softFeaturesEnabled: {} diff --git a/gitops/templates/workloads-android.yaml b/gitops/templates/argo-events-resources.yaml similarity index 62% rename from gitops/templates/workloads-android.yaml rename to gitops/templates/argo-events-resources.yaml index 1f2d398b..80b292e7 100644 --- a/gitops/templates/workloads-android.yaml +++ b/gitops/templates/argo-events-resources.yaml @@ -1,4 +1,4 @@ -# Copyright (c) 2024-2026 Accenture, All Rights Reserved. +# Copyright (c) 2026 Accenture, All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -11,14 +11,19 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +# +# Dedicated platform Application for EventBus and EventSource resources. +# Keep this wave after argo-events (wave 4) so CRDs exist before these CRs apply. +# During platform teardown, delete this Application before argo-events when possible so +# the controller is still present while EventBus/EventSource finalizers are reconciled. apiVersion: argoproj.io/v1alpha1 kind: Application metadata: - name: {{ .Values.config.namespacePrefix }}workloads-android + name: {{ .Values.config.namespacePrefix }}argo-events-resources namespace: {{ .Values.config.namespacePrefix }}argocd annotations: - argocd.argoproj.io/sync-wave: "2" + argocd.argoproj.io/sync-wave: "5" finalizers: - resources-finalizer.argocd.argoproj.io spec: @@ -26,18 +31,14 @@ spec: source: repoURL: {{ .Values.spec.source.repoURL }} targetRevision: {{ .Values.spec.source.targetRevision }} + path: gitops/apps/argo-events-resources helm: values: | config: - namespacePrefix: {{ .Values.config.namespacePrefix }} - spec: - source: - repoURL: {{ .Values.spec.source.repoURL }} - targetRevision: {{ .Values.spec.source.targetRevision }} - path: gitops/workloads/android + namespacePrefix: {{ .Values.config.namespacePrefix | quote }} destination: server: https://kubernetes.default.svc - namespace: {{ .Values.config.namespacePrefix }}dynpvc + namespace: {{ .Values.config.namespacePrefix }}argo-events syncPolicy: syncOptions: - CreateNamespace=true diff --git a/gitops/templates/argo-events.yaml b/gitops/templates/argo-events.yaml new file mode 100644 index 00000000..ae7b7808 --- /dev/null +++ b/gitops/templates/argo-events.yaml @@ -0,0 +1,157 @@ +# Copyright (c) 2024-2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: {{ .Values.config.namespacePrefix }}argo-events + namespace: {{ .Values.config.namespacePrefix }}argocd + annotations: + argocd.argoproj.io/sync-wave: "4" + finalizers: + - resources-finalizer.argocd.argoproj.io +spec: + project: {{ .Values.config.namespacePrefix }}horizon-sdv + source: + chart: argo-events + repoURL: https://argoproj.github.io/argo-helm + targetRevision: {{ .Values.config.chartVersions.argoEvents.version | quote }} + helm: + values: | + # Cluster-wide installation (cluster-admin role in use): controller watches all namespaces. + # Chart uses ClusterRole/ClusterRoleBinding when namespaced is false. + controller: + namespaced: false + crds: + install: true + # EventBus + EventSource are managed by a dedicated child Application: + # gitops/templates/argo-events-resources.yaml (wave 5). Keep argo-events + # at wave 4 so CRDs are ready before EventBus/EventSource are applied. + destination: + server: https://kubernetes.default.svc + namespace: {{ .Values.config.namespacePrefix }}argo-events + syncPolicy: + syncOptions: + - CreateNamespace=true + automated: {} +--- +# NetworkPolicy: allow webhook EventSource ingress on port 12000 (sensors in argo-events + Horizon API submit). +{{- if .Values.config.enableNetworkPolicies }} +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: allow-webhook-ingress + namespace: {{ .Values.config.namespacePrefix }}argo-events + annotations: + argocd.argoproj.io/sync-wave: "0" +spec: + podSelector: + matchLabels: + eventsource-name: webhook + policyTypes: + - Ingress + ingress: + - from: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: {{ .Values.config.namespacePrefix }}argo-events + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: {{ .Values.config.namespacePrefix }}horizon-api + ports: + - protocol: TCP + port: 12000 +{{- end }} +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: sensor-submit-workflow + namespace: {{ .Values.config.namespacePrefix }}argo-events + annotations: + argocd.argoproj.io/sync-wave: "1" +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: {{ .Values.config.namespacePrefix }}argo-events-sensor-clusterworkflowtemplates + annotations: + argocd.argoproj.io/sync-wave: "1" +rules: + - apiGroups: + - argoproj.io + resources: + - clusterworkflowtemplates + verbs: + - get + - list + - watch +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: {{ .Values.config.namespacePrefix }}argo-events-sensor-clusterworkflowtemplates + annotations: + argocd.argoproj.io/sync-wave: "1" +subjects: + - kind: ServiceAccount + name: sensor-submit-workflow + namespace: {{ .Values.config.namespacePrefix }}argo-events +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: {{ .Values.config.namespacePrefix }}argo-events-sensor-clusterworkflowtemplates +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: argo-events-sensor-workflows + namespace: {{ .Values.config.namespacePrefix }}workflows + annotations: + argocd.argoproj.io/sync-wave: "1" +rules: + - apiGroups: + - argoproj.io + resources: + - workflows + verbs: + - create + - get + - list + - watch + - apiGroups: + - argoproj.io + resources: + - workflowtemplates + verbs: + - get + - list +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: argo-events-sensor-workflows + namespace: {{ .Values.config.namespacePrefix }}workflows + annotations: + argocd.argoproj.io/sync-wave: "1" +subjects: + - kind: ServiceAccount + name: sensor-submit-workflow + namespace: {{ .Values.config.namespacePrefix }}argo-events +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: argo-events-sensor-workflows +# EventBus + EventSource live in the dedicated child Application: +# gitops/templates/argo-events-resources.yaml. diff --git a/gitops/templates/argo-workflows-init.yaml b/gitops/templates/argo-workflows-init.yaml new file mode 100644 index 00000000..395875d3 --- /dev/null +++ b/gitops/templates/argo-workflows-init.yaml @@ -0,0 +1,509 @@ +# Copyright (c) 2024-2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# ConfigMap horizon-workflow-cloud-env: CLOUD_PROJECT, CLOUD_REGION, CLOUD_ZONE, HORIZON_DOMAIN for workflow pods +# (gitops config). Sync wave 0 before workflow SAs (wave 1). + +apiVersion: v1 +kind: ConfigMap +metadata: + name: horizon-workflow-cloud-env + namespace: {{ .Values.config.namespacePrefix }}workflows + labels: + # Required so the workflow controller informer detects this CM for valueFrom.configMapKeyRef parameters. + workflows.argoproj.io/configmap-type: Parameter + annotations: + argocd.argoproj.io/sync-wave: "0" +data: + CLOUD_PROJECT: {{ .Values.config.projectID | quote }} + CLOUD_REGION: {{ .Values.config.region | quote }} + CLOUD_ZONE: {{ .Values.config.zone | quote }} + HORIZON_DOMAIN: {{ .Values.config.domain | quote }} +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: argo-workflows-server + namespace: {{ .Values.config.namespacePrefix }}argo-workflows + annotations: + iam.gke.io/gcp-service-account: gke-{{ .Values.config.namespacePrefix }}argo-workflows-sa@{{ .Values.config.projectID }}.iam.gserviceaccount.com + argocd.argoproj.io/sync-wave: "1" +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: argo-workflows-sso-admin + namespace: {{ .Values.config.namespacePrefix }}argo-workflows + annotations: + workflows.argoproj.io/rbac-rule: "'administrators' in groups" + workflows.argoproj.io/rbac-rule-precedence: "20" + workflows.argoproj.io/service-account-token.name: argo-workflows-sso-admin.service-account-token + argocd.argoproj.io/sync-wave: "1" +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: argo-workflows-sso-user + namespace: {{ .Values.config.namespacePrefix }}argo-workflows + annotations: + workflows.argoproj.io/rbac-rule: "'developers' in groups" + workflows.argoproj.io/rbac-rule-precedence: "10" + workflows.argoproj.io/service-account-token.name: argo-workflows-sso-user.service-account-token + argocd.argoproj.io/sync-wave: "1" +--- +apiVersion: v1 +kind: Secret +metadata: + name: argo-workflows-sso-admin.service-account-token + namespace: {{ .Values.config.namespacePrefix }}argo-workflows + annotations: + kubernetes.io/service-account.name: argo-workflows-sso-admin + argocd.argoproj.io/sync-wave: "1" +type: kubernetes.io/service-account-token +--- +apiVersion: v1 +kind: Secret +metadata: + name: argo-workflows-sso-user.service-account-token + namespace: {{ .Values.config.namespacePrefix }}argo-workflows + annotations: + kubernetes.io/service-account.name: argo-workflows-sso-user + argocd.argoproj.io/sync-wave: "1" +type: kubernetes.io/service-account-token +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: argo-workflows-sso-admin + namespace: {{ .Values.config.namespacePrefix }}workflows + annotations: + argocd.argoproj.io/sync-wave: "1" +rules: +- apiGroups: + - argoproj.io + resources: + - workflows + - workflowtemplates + - cronworkflows + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - "" + resources: + - pods + - pods/log + - events + verbs: + - delete + - get + - list + - watch +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: argo-workflows-sso-user + namespace: {{ .Values.config.namespacePrefix }}workflows + annotations: + argocd.argoproj.io/sync-wave: "1" +rules: +- apiGroups: + - argoproj.io + resources: + - workflows + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - argoproj.io + resources: + - workflowtemplates + - cronworkflows + verbs: + - get + - list + - watch +- apiGroups: + - "" + resources: + - pods + - pods/log + - events + verbs: + - get + - list + - watch +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: argo-workflows-sso-admin + namespace: {{ .Values.config.namespacePrefix }}workflows + annotations: + argocd.argoproj.io/sync-wave: "1" +subjects: +- kind: ServiceAccount + name: argo-workflows-sso-admin + namespace: {{ .Values.config.namespacePrefix }}argo-workflows +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: argo-workflows-sso-admin +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: argo-workflows-sso-user + namespace: {{ .Values.config.namespacePrefix }}workflows + annotations: + argocd.argoproj.io/sync-wave: "1" +subjects: +- kind: ServiceAccount + name: argo-workflows-sso-user + namespace: {{ .Values.config.namespacePrefix }}argo-workflows +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: argo-workflows-sso-user +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: argo-workflows-sso-ui-read + namespace: {{ .Values.config.namespacePrefix }}argo-workflows + annotations: + argocd.argoproj.io/sync-wave: "1" +rules: +- apiGroups: + - argoproj.io + resources: + - workflows + - workflowtemplates + - cronworkflows + - eventsources + - sensors + - workfloweventbindings + verbs: + - get + - list + - watch +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: argo-workflows-sso-ui-read + namespace: {{ .Values.config.namespacePrefix }}argo-workflows + annotations: + argocd.argoproj.io/sync-wave: "1" +subjects: +- kind: ServiceAccount + name: argo-workflows-sso-admin + namespace: {{ .Values.config.namespacePrefix }}argo-workflows +- kind: ServiceAccount + name: argo-workflows-sso-user + namespace: {{ .Values.config.namespacePrefix }}argo-workflows +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: argo-workflows-sso-ui-read +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: argo-workflows-sso-cluster-read + annotations: + argocd.argoproj.io/sync-wave: "1" +rules: +- apiGroups: + - "" + resources: + - namespaces + verbs: + - get + - list + - watch +- apiGroups: + - argoproj.io + resources: + - clusterworkflowtemplates + - workflows + - workflowtemplates + - cronworkflows + verbs: + - get + - list + - watch +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: argo-workflows-sso-cluster-read + annotations: + argocd.argoproj.io/sync-wave: "1" +subjects: +- kind: ServiceAccount + name: argo-workflows-sso-admin + namespace: {{ .Values.config.namespacePrefix }}argo-workflows +- kind: ServiceAccount + name: argo-workflows-sso-user + namespace: {{ .Values.config.namespacePrefix }}argo-workflows +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: argo-workflows-sso-cluster-read +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: workflow-executor + namespace: {{ .Values.config.namespacePrefix }}workflows + annotations: + iam.gke.io/gcp-service-account: gke-{{ .Values.config.namespacePrefix }}argo-workflows-sa@{{ .Values.config.projectID }}.iam.gserviceaccount.com + argocd.argoproj.io/sync-wave: "1" +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: workflow-executor-elevated + namespace: {{ .Values.config.namespacePrefix }}workflows + annotations: + iam.gke.io/gcp-service-account: gke-{{ .Values.config.namespacePrefix }}argo-workflows-elevated-sa@{{ .Values.config.projectID }}.iam.gserviceaccount.com + argocd.argoproj.io/sync-wave: "1" +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: workflow-executor-writer-role + namespace: {{ .Values.config.namespacePrefix }}workflows + annotations: + argocd.argoproj.io/sync-wave: "1" +rules: + - apiGroups: [""] + resources: ["secrets"] + verbs: ["get", "list", "watch", "create", "update", "delete", "patch"] + - apiGroups: [""] + resources: ["pods"] + verbs: + [ + "create", + "delete", + "deletecollection", + "get", + "list", + "patch", + "update", + "watch", + ] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: workflow-executor-writer-rolebinding + namespace: {{ .Values.config.namespacePrefix }}workflows + annotations: + argocd.argoproj.io/sync-wave: "1" +subjects: + - kind: ServiceAccount + name: workflow-executor + namespace: {{ .Values.config.namespacePrefix }}workflows +roleRef: + kind: Role + name: workflow-executor-writer-role + apiGroup: rbac.authorization.k8s.io +--- +# Argo executor must write WorkflowTaskResult objects in this namespace or steps hang / 403 near completion. +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: argo-workflowtaskresults + namespace: {{ .Values.config.namespacePrefix }}workflows + annotations: + argocd.argoproj.io/sync-wave: "1" +rules: + - apiGroups: ["argoproj.io"] + resources: ["workflowtaskresults"] + verbs: ["create", "get", "list", "patch", "update"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: argo-workflowtaskresults + namespace: {{ .Values.config.namespacePrefix }}workflows + annotations: + argocd.argoproj.io/sync-wave: "1" +subjects: + - kind: ServiceAccount + name: workflow-executor + namespace: {{ .Values.config.namespacePrefix }}workflows + - kind: ServiceAccount + name: workflow-executor-elevated + namespace: {{ .Values.config.namespacePrefix }}workflows +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: argo-workflowtaskresults +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: {{ .Values.config.namespacePrefix }}workflow-executor-elevated-cluster-role + annotations: + argocd.argoproj.io/sync-wave: "1" +rules: + - apiGroups: [""] + resources: ["secrets"] + verbs: ["get", "list", "watch", "create", "update", "delete", "patch"] + - apiGroups: [""] + resources: ["pods"] + verbs: + [ + "create", + "delete", + "deletecollection", + "get", + "list", + "patch", + "update", + "watch", + ] + - apiGroups: [""] + resources: ["persistentvolumes", "persistentvolumeclaims"] + verbs: + [ + "create", + "get", + "list", + "watch", + "delete", + "update", + "patch", + ] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: {{ .Values.config.namespacePrefix }}workflow-executor-elevated-cluster-rolebinding + annotations: + argocd.argoproj.io/sync-wave: "1" +subjects: + - kind: ServiceAccount + name: workflow-executor-elevated + namespace: {{ .Values.config.namespacePrefix }}workflows +roleRef: + kind: ClusterRole + name: {{ .Values.config.namespacePrefix }}workflow-executor-elevated-cluster-role + apiGroup: rbac.authorization.k8s.io +{{- if or (eq .Values.scm.authMethod "userpass") (eq .Values.scm.authMethod "app") }} +# GSM-backed SecretStore for workflow ExternalSecrets (PAT or GitHub App fields). +--- +apiVersion: external-secrets.io/v1beta1 +kind: SecretStore +metadata: + name: workflows-secret-store + namespace: {{ .Values.config.namespacePrefix }}workflows + annotations: + argocd.argoproj.io/sync-wave: "1" +spec: + provider: + gcpsm: + projectID: {{ .Values.config.projectID }} + auth: + workloadIdentity: + clusterLocation: {{ .Values.config.region }} + clusterName: sdv-cluster + serviceAccountRef: + name: workflow-executor +{{- end }} +{{- if eq .Values.scm.authMethod "userpass" }} +# HTTPS username/password for Argo git artifacts (same GSM key as Jenkins scm-password-b64). +--- +apiVersion: v1 +kind: Secret +metadata: + name: workflow-pipeline-git-creds + namespace: {{ .Values.config.namespacePrefix }}workflows + annotations: + argocd.argoproj.io/sync-wave: "1" +type: Opaque +stringData: + username: {{ .Values.scm.username | quote }} +--- +apiVersion: external-secrets.io/v1beta1 +kind: ExternalSecret +metadata: + name: workflow-pipeline-git-creds-secret + namespace: {{ .Values.config.namespacePrefix }}workflows + annotations: + argocd.argoproj.io/sync-wave: "1" +spec: + refreshInterval: 10s + secretStoreRef: + kind: SecretStore + name: workflows-secret-store + target: + name: workflow-pipeline-git-creds + creationPolicy: Merge + data: + - secretKey: password + remoteRef: + key: {{ .Values.config.namespacePrefix }}scm-password-b64 + decodingStrategy: Base64 +{{- end }} +{{- if eq .Values.scm.authMethod "app" }} +# GitHub App (same GSM keys as Jenkins jenkins-scm-creds). CWT (not namespaced WorkflowTemplate): +# one cluster-wide templateRef target for aaos-builder / runtime-image DAGs (clusterScope: true); +# must be a workflow template so the Argo workflow UID can name the per-run Secret (WORKFLOW_UID env). +# Rationale: docs/guides/argo_workflows_platform_integration_handoff.md#why-prepare-github-app-git-creds-is-a-clusterworkflowtemplate +--- +apiVersion: external-secrets.io/v1beta1 +kind: ExternalSecret +metadata: + name: workflow-github-app-secret + namespace: {{ .Values.config.namespacePrefix }}workflows + annotations: + argocd.argoproj.io/sync-wave: "1" +spec: + refreshInterval: 10s + secretStoreRef: + kind: SecretStore + name: workflows-secret-store + target: + name: workflow-github-app + creationPolicy: Owner + data: + - secretKey: appID + remoteRef: + key: {{ .Values.config.namespacePrefix }}github-app-id-b64 + decodingStrategy: Base64 + - secretKey: privateKey + remoteRef: + key: {{ .Values.config.namespacePrefix }}github-app-private-key-pkcs8-b64 + decodingStrategy: Base64 + - secretKey: installationId + remoteRef: + key: {{ .Values.config.namespacePrefix }}github-app-installation-id-b64 + decodingStrategy: Base64 +{{- end }} +--- diff --git a/gitops/templates/argo-workflows.yaml b/gitops/templates/argo-workflows.yaml new file mode 100644 index 00000000..bdf9e435 --- /dev/null +++ b/gitops/templates/argo-workflows.yaml @@ -0,0 +1,381 @@ +# Copyright (c) 2024-2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: {{ .Values.config.namespacePrefix }}argo-workflows + namespace: {{ .Values.config.namespacePrefix }}argocd + annotations: + argocd.argoproj.io/sync-wave: "6" + finalizers: + - resources-finalizer.argocd.argoproj.io +spec: + project: {{ .Values.config.namespacePrefix }}horizon-sdv + source: + chart: argo-workflows + repoURL: https://argoproj.github.io/argo-helm + targetRevision: {{ .Values.config.chartVersions.argo_workflows.version }} + helm: + values: | + # Controller configuration + controller: + workflowNamespaces: + - {{ .Values.config.namespacePrefix }}workflows + serviceAccount: + create: true + name: argo-workflows-controller + workflowDefaults: + spec: + serviceAccountName: workflow-executor + # With podGC / completed pod deletion, the in-cluster log stream is empty; main.log must be + # uploaded to the GCS artifactRepository (archiveLogs in config + workflow-executor WI to the bucket). + archiveLogs: true + ttlStrategy: + secondsAfterCompletion: 86400 # Clean up after 24 hours + secondsAfterSuccess: 86400 + secondsAfterFailure: 259200 # Keep failures for 3 days + # OnWorkflowDeletion runs GCS cleanup before the Workflow CR is removed; failures leave the + # workflows.argoproj.io/artifact-gc finalizer and the UI shows "Artifact garbage collection failed". + # forceFinalizerRemoval drops the finalizer if GC still fails (objects may remain in the bucket). + artifactGC: + strategy: OnWorkflowDeletion + forceFinalizerRemoval: true + metricsConfig: + enabled: true + telemetryConfig: + enabled: false + + # Server configuration + server: + enabled: true + serviceAccount: + create: false + name: argo-workflows-server + baseHref: /workflows/ + secure: false # Using GKE Gateway for TLS termination + + # SSO with Keycloak + sso: + enabled: true + issuer: https://{{ .Values.config.domain }}/auth/realms/horizon + clientId: + name: argo-workflows-sso + key: client-id + clientSecret: + name: argo-workflows-sso + key: client-secret + redirectUrl: https://{{ .Values.config.domain }}/workflows/oauth2/callback + customGroupClaimName: roles + scopes: + - openid + - profile + - email + - roles + rbac: + enabled: true + + # SSO for humans; client for in-cluster SA bearer (horizon-api → Argo log API). + # Chart renders extraArgs before authModes; use split so we never end up with duplicate --auth-mode=sso only. + authModes: + - sso + extraArgs: + - --auth-mode=client + + # Ingress disabled (using GKE Gateway) + ingress: + enabled: false + + # Workflow defaults + workflow: + serviceAccount: + create: false + name: workflow-executor + rbac: + create: true + # Grant workflow-executor access to WorkflowArtifactGCTask (required for artifact GC pods). + # Without this, OnWorkflowDeletion GC often fails and workflows stay stuck terminating. + artifactGC: true + + # Workflow executor (argoexec: init containers for git/artifact fetch, wait sidecar, etc.). + # Default 512Mi is too small for large monorepo git clones — OOMKilled (137) on the pod's init container. + executor: + resources: + requests: + cpu: 500m + memory: 2Gi + limits: + cpu: "4" + memory: 16Gi + + # Use existing service accounts + useStaticCredentials: false + + # Persist workflow artifacts and archived logs in GCS (Project: same as config.projectID; bucket + # from Terraform module.sdv_gcs_argo_workflows). If UIs show no log stream after steps finish, check + # status.nodes[].outputs.artifacts for main-logs in the Workflow CR, GCS object presence, and that + # gitops horizon-api gcsSigningServiceAccount matches the Workload Identity GSA for this environment. + artifactRepository: + archiveLogs: true + gcs: + bucket: {{ .Values.config.projectID }}-argo-workflows + + # Custom links + links: + - name: "Workflow Documentation" + scope: "workflow" + url: "https://argo-workflows.readthedocs.io" + destination: + server: https://kubernetes.default.svc + namespace: {{ .Values.config.namespacePrefix }}argo-workflows + syncPolicy: + automated: + prune: true + selfHeal: true + syncOptions: + - CreateNamespace=true +--- +# Allow Argo Server ingress from Gateway +{{- if .Values.config.enableNetworkPolicies }} +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: allow-argo-server-ingress-from-gateway + namespace: {{ .Values.config.namespacePrefix }}argo-workflows + annotations: + argocd.argoproj.io/sync-wave: "0" +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: argo-workflows-server + policyTypes: + - Ingress + ingress: + - ports: + - protocol: TCP + port: 2746 # Argo Server HTTP port + +{{- end }} +--- +# Allow Controller to access workflows namespace +{{- if .Values.config.enableNetworkPolicies }} +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: allow-controller-to-workflows-namespace + namespace: {{ .Values.config.namespacePrefix }}workflows + annotations: + argocd.argoproj.io/sync-wave: "0" +spec: + podSelector: {} + policyTypes: + - Ingress + ingress: + - from: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: {{ .Values.config.namespacePrefix }}argo-workflows + podSelector: + matchLabels: + app.kubernetes.io/name: argo-workflows-workflow-controller + +{{- end }} +--- +# Allow Controller to access Kubernetes API +{{- if .Values.config.enableNetworkPolicies }} +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: allow-controller-to-kube-api + namespace: {{ .Values.config.namespacePrefix }}argo-workflows + annotations: + argocd.argoproj.io/sync-wave: "0" +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: argo-workflows-workflow-controller + policyTypes: + - Egress + egress: + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + ports: + - protocol: TCP + port: 443 # Kubernetes API + - protocol: TCP + port: 6443 # Kubernetes API (alternative port) + +{{- end }} +--- +# Allow Server to access Kubernetes API +{{- if .Values.config.enableNetworkPolicies }} +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: allow-server-to-kube-api + namespace: {{ .Values.config.namespacePrefix }}argo-workflows + annotations: + argocd.argoproj.io/sync-wave: "0" +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: argo-workflows-server + policyTypes: + - Egress + egress: + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + ports: + - protocol: TCP + port: 443 # Kubernetes API + - protocol: TCP + port: 6443 # Kubernetes API (alternative port) + +{{- end }} +--- +# Allow Server to access workflows namespace +{{- if .Values.config.enableNetworkPolicies }} +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: allow-server-to-workflows-namespace + namespace: {{ .Values.config.namespacePrefix }}workflows + annotations: + argocd.argoproj.io/sync-wave: "0" +spec: + podSelector: {} + policyTypes: + - Ingress + ingress: + - from: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: {{ .Values.config.namespacePrefix }}argo-workflows + podSelector: + matchLabels: + app.kubernetes.io/name: argo-workflows-server + +{{- end }} +--- +# Allow workflow pods to access GCE metadata server for Workload Identity tokens. +# Required for GCS artifact uploads, Secret Manager, and any GCP API call. +# +# On GKE with Calico CNI, iptables DNAT redirects 169.254.169.254:80 to the +# gke-metadata-server DaemonSet on 169.254.169.252:988. Calico evaluates +# policies post-DNAT, so both link-local IPs must be allowed without port +# restriction. This mirrors the approach in jenkins.yaml for agent metadata. +{{- if .Values.config.enableNetworkPolicies }} +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: allow-workflows-to-metadata-server + namespace: {{ .Values.config.namespacePrefix }}workflows + annotations: + argocd.argoproj.io/sync-wave: "0" +spec: + podSelector: {} + policyTypes: + - Egress + egress: + - to: + - ipBlock: + cidr: 169.254.169.254/32 + - ipBlock: + cidr: 169.254.169.252/32 + +{{- end }} +--- +# Allow workflow pods to access Artifact Registry and GCS (HTTPS) +{{- if .Values.config.enableNetworkPolicies }} +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: allow-workflows-to-artifact-registry + namespace: {{ .Values.config.namespacePrefix }}workflows + annotations: + argocd.argoproj.io/sync-wave: "0" +spec: + podSelector: {} + policyTypes: + - Egress + egress: + - to: + - ipBlock: + cidr: 0.0.0.0/0 + except: + - 169.254.169.254/32 + ports: + - protocol: TCP + port: 443 # HTTPS for Artifact Registry and GCS + +{{- end }} +--- +# Allow workflow pods to access Kubernetes API +{{- if .Values.config.enableNetworkPolicies }} +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: allow-workflows-to-kube-api + namespace: {{ .Values.config.namespacePrefix }}workflows + annotations: + argocd.argoproj.io/sync-wave: "0" +spec: + podSelector: {} + policyTypes: + - Egress + egress: + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + ports: + - protocol: TCP + port: 443 # Kubernetes API + - protocol: TCP + port: 6443 # Kubernetes API (alternative port) + +{{- end }} +--- +# Allow Server to access Keycloak for SSO +{{- if .Values.config.enableNetworkPolicies }} +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: allow-server-to-keycloak + namespace: {{ .Values.config.namespacePrefix }}argo-workflows + annotations: + argocd.argoproj.io/sync-wave: "0" +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: argo-workflows-server + policyTypes: + - Egress + egress: + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: {{ .Values.config.namespacePrefix }}keycloak + podSelector: + matchLabels: + app.kubernetes.io/name: keycloakx + ports: + - protocol: TCP + port: 8080 # Keycloak HTTP + +{{- end }} diff --git a/gitops/templates/config-connector.yaml b/gitops/templates/config-connector.yaml new file mode 100644 index 00000000..296c2925 --- /dev/null +++ b/gitops/templates/config-connector.yaml @@ -0,0 +1,254 @@ +# Copyright (c) 2024-2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# The Config Connector operator (GKE add-on) scans all namespaces that have a +# ConfigConnectorContext to verify CNRM resource counts. It needs read access +# to every installed CNRM API group (including edgenetwork) cluster-wide, so a +# ClusterRole/ClusterRoleBinding is the correct scope rather than per-namespace +# Roles. Ref: https://cloud.google.com/config-connector/docs/how-to/install-upgrade-uninstall + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: configconnector-operator-cnrm-discovery + labels: + app.kubernetes.io/name: horizon-sdv + app.kubernetes.io/component: config-connector + annotations: + argocd.argoproj.io/sync-wave: "0" +rules: + - apiGroups: + - edgenetwork.cnrm.cloud.google.com + resources: + - "*" + verbs: + - get + - list + - watch +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: configconnector-operator-cnrm-discovery + labels: + app.kubernetes.io/name: horizon-sdv + app.kubernetes.io/component: config-connector + annotations: + argocd.argoproj.io/sync-wave: "0" +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: configconnector-operator-cnrm-discovery +subjects: + - kind: ServiceAccount + name: configconnector-operator + namespace: configconnector-operator-system +--- +apiVersion: core.cnrm.cloud.google.com/v1beta1 +kind: ConfigConnectorContext +metadata: + name: configconnectorcontext.core.cnrm.cloud.google.com + namespace: gcp + annotations: + argocd.argoproj.io/sync-wave: "1" +spec: + googleServiceAccount: gke-config-connector-sa@{{ .Values.config.projectID }}.iam.gserviceaccount.com +--- +# Centralized KCC webhook TLS monitor: +# periodically verifies that the certificate served by cnrm-validating-webhook +# chains to the CA bundle configured in ValidatingWebhookConfiguration. +apiVersion: v1 +kind: ServiceAccount +metadata: + name: kcc-webhook-cert-monitor + namespace: gcp + labels: + app.kubernetes.io/name: horizon-sdv + app.kubernetes.io/component: config-connector + annotations: + argocd.argoproj.io/sync-wave: "1" +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: kcc-webhook-cert-monitor + labels: + app.kubernetes.io/name: horizon-sdv + app.kubernetes.io/component: config-connector + annotations: + argocd.argoproj.io/sync-wave: "1" +rules: + - apiGroups: ["admissionregistration.k8s.io"] + resources: ["validatingwebhookconfigurations"] + resourceNames: ["validating-webhook.cnrm.cloud.google.com"] + verbs: ["get"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: kcc-webhook-cert-monitor + labels: + app.kubernetes.io/name: horizon-sdv + app.kubernetes.io/component: config-connector + annotations: + argocd.argoproj.io/sync-wave: "1" +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: kcc-webhook-cert-monitor +subjects: + - kind: ServiceAccount + name: kcc-webhook-cert-monitor + namespace: gcp +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: kcc-webhook-cert-monitor + namespace: cnrm-system + labels: + app.kubernetes.io/name: horizon-sdv + app.kubernetes.io/component: config-connector + annotations: + argocd.argoproj.io/sync-wave: "1" +rules: + - apiGroups: ["apps"] + resources: ["deployments"] + resourceNames: ["cnrm-webhook-manager"] + verbs: ["get", "patch"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: kcc-webhook-cert-monitor + namespace: cnrm-system + labels: + app.kubernetes.io/name: horizon-sdv + app.kubernetes.io/component: config-connector + annotations: + argocd.argoproj.io/sync-wave: "1" +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: kcc-webhook-cert-monitor +subjects: + - kind: ServiceAccount + name: kcc-webhook-cert-monitor + namespace: gcp +--- +apiVersion: batch/v1 +kind: CronJob +metadata: + name: kcc-webhook-cert-monitor + namespace: gcp + labels: + app.kubernetes.io/name: horizon-sdv + app.kubernetes.io/component: config-connector + annotations: + argocd.argoproj.io/sync-wave: "2" +spec: + schedule: "*/5 * * * *" + concurrencyPolicy: Forbid + successfulJobsHistoryLimit: 1 + failedJobsHistoryLimit: 3 + jobTemplate: + spec: + backoffLimit: 1 + ttlSecondsAfterFinished: 600 + template: + spec: + serviceAccountName: kcc-webhook-cert-monitor + restartPolicy: Never + securityContext: + runAsNonRoot: true + runAsUser: 65534 + containers: + - name: verify-webhook-tls + image: {{ .Values.config.containerImages.kccWebhookCertMonitor | quote }} + command: ["/bin/sh", "-c"] + args: + - | + set -euo pipefail + THRESHOLD_SECONDS={{ int .Values.config.configConnector.webhookMonitor.thresholdSeconds }} + COOLDOWN_SECONDS={{ int .Values.config.configConnector.webhookMonitor.cooldownSeconds }} + API_SERVER="https://kubernetes.default.svc" + TOKEN="$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)" + CA_CERT_PATH="/var/run/secrets/kubernetes.io/serviceaccount/ca.crt" + RESTART_KEY="kcc-webhook-cert-monitor/restarted-at" + LAST_RESTART_KEY="kcc-webhook-cert-monitor/last-auto-restart-epoch" + CA="$(curl -fsS \ + --cacert "${CA_CERT_PATH}" \ + -H "Authorization: Bearer ${TOKEN}" \ + "${API_SERVER}/apis/admissionregistration.k8s.io/v1/validatingwebhookconfigurations/validating-webhook.cnrm.cloud.google.com" \ + | jq -r '.webhooks[] | select(.name=="deny-unknown-fields.cnrm.cloud.google.com") | .clientConfig.caBundle')" + if [ -z "${CA:-}" ] || [ "$CA" = "null" ]; then + echo "ERROR: missing caBundle for deny-unknown-fields.cnrm.cloud.google.com" + exit 1 + fi + printf '%s' "$CA" | base64 -d > /tmp/ca.pem + echo | openssl s_client -connect cnrm-validating-webhook.cnrm-system.svc:443 \ + -servername cnrm-validating-webhook.cnrm-system.svc 2>/dev/null \ + | openssl x509 -outform PEM > /tmp/leaf.pem + openssl verify -CAfile /tmp/ca.pem /tmp/leaf.pem + echo "OK: cnrm-validating-webhook certificate chains to configured CA bundle" + + if openssl x509 -in /tmp/leaf.pem -checkend "${THRESHOLD_SECONDS}" -noout; then + echo "OK: webhook certificate is valid for more than ${THRESHOLD_SECONDS}s" + exit 0 + fi + + echo "WARN: webhook certificate expires within ${THRESHOLD_SECONDS}s" + DEPLOYMENT_JSON="$(curl -fsS \ + --cacert "${CA_CERT_PATH}" \ + -H "Authorization: Bearer ${TOKEN}" \ + "${API_SERVER}/apis/apps/v1/namespaces/cnrm-system/deployments/cnrm-webhook-manager")" + NOW_EPOCH="$(date -u +%s)" + NOW_ISO="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + LAST_RESTART_EPOCH="$(printf '%s' "${DEPLOYMENT_JSON}" | jq -r --arg key "${LAST_RESTART_KEY}" '.metadata.annotations[$key] // "0"')" + + case "${LAST_RESTART_EPOCH}" in + ''|*[!0-9]*) + echo "WARN: invalid ${LAST_RESTART_KEY}='${LAST_RESTART_EPOCH}', treating as 0" + LAST_RESTART_EPOCH=0 + ;; + esac + + ELAPSED_SECONDS=$((NOW_EPOCH - LAST_RESTART_EPOCH)) + if [ "${ELAPSED_SECONDS}" -lt "${COOLDOWN_SECONDS}" ]; then + echo "INFO: restart skipped due to cooldown (${ELAPSED_SECONDS}s < ${COOLDOWN_SECONDS}s)" + exit 0 + fi + + PATCH_PAYLOAD="$(jq -nc \ + --arg nowIso "${NOW_ISO}" \ + --arg nowEpoch "${NOW_EPOCH}" \ + --arg restartKey "${RESTART_KEY}" \ + --arg lastRestartKey "${LAST_RESTART_KEY}" \ + '{metadata:{annotations:{($lastRestartKey):$nowEpoch}},spec:{template:{metadata:{annotations:{($restartKey):$nowIso}}}}}')" + + curl -fsS -X PATCH \ + --cacert "${CA_CERT_PATH}" \ + -H "Authorization: Bearer ${TOKEN}" \ + -H "Content-Type: application/merge-patch+json" \ + "${API_SERVER}/apis/apps/v1/namespaces/cnrm-system/deployments/cnrm-webhook-manager" \ + -d "${PATCH_PAYLOAD}" >/dev/null + + echo "INFO: auto-restart triggered for cnrm-webhook-manager at ${NOW_ISO}" + resources: + requests: + cpu: 10m + memory: 32Mi + limits: + memory: 128Mi diff --git a/gitops/templates/dynpvc-provisioner.yaml b/gitops/templates/dynpvc-provisioner.yaml index d1afb3cb..ab3ff094 100644 --- a/gitops/templates/dynpvc-provisioner.yaml +++ b/gitops/templates/dynpvc-provisioner.yaml @@ -1,4 +1,4 @@ -# Copyright (c) 2024-2025 Accenture, All Rights Reserved. +# Copyright (c) 2024-2026 Accenture, All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/gitops/templates/dynpvc-releaser.yaml b/gitops/templates/dynpvc-releaser.yaml index 19edc100..d46d9829 100644 --- a/gitops/templates/dynpvc-releaser.yaml +++ b/gitops/templates/dynpvc-releaser.yaml @@ -1,4 +1,4 @@ -# Copyright (c) 2024-2025 Accenture, All Rights Reserved. +# Copyright (c) 2024-2026 Accenture, All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/gitops/templates/external-dns.yaml b/gitops/templates/external-dns.yaml index f4b69d4a..7c01ec8f 100644 --- a/gitops/templates/external-dns.yaml +++ b/gitops/templates/external-dns.yaml @@ -11,7 +11,8 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. - +# Only deploy external-dns when not using static A records (zone delegation flow). +{{- if not (index .Values.config "useStaticDnsARecords" | default false) }} apiVersion: argoproj.io/v1alpha1 kind: Application metadata: @@ -52,7 +53,10 @@ spec: # Apex TXT prefix so ExternalDNS can own and update the apex A record # (e.g. .horizon-sdv.com). Without this, the apex gets no valid # ownership TXT and is not updated when the LB IP changes. - txtPrefix: "%{record_type}-." + # + # Must be a valid DNS label. The previous value ("%{record_type}-.") expands + # to labels like "a-" which are rejected by IDNA and cause record churn. + txtPrefix: "txt-%{record_type}." destination: server: https://kubernetes.default.svc namespace: {{ .Values.config.namespacePrefix }}external-dns @@ -61,4 +65,5 @@ spec: prune: true selfHeal: true syncOptions: - - CreateNamespace=true \ No newline at end of file + - CreateNamespace=true +{{- end }} \ No newline at end of file diff --git a/gitops/templates/gateway-argo-workflows.yaml b/gitops/templates/gateway-argo-workflows.yaml new file mode 100644 index 00000000..3c975a41 --- /dev/null +++ b/gitops/templates/gateway-argo-workflows.yaml @@ -0,0 +1,90 @@ +# Copyright (c) 2024-2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +apiVersion: gateway.networking.k8s.io/v1beta1 +kind: HTTPRoute +metadata: + name: argo-workflows-route + namespace: {{ .Values.config.namespacePrefix }}argo-workflows + labels: + gateway: gke-gateway + annotations: + argocd.argoproj.io/sync-wave: "8" +spec: + parentRefs: + - kind: Gateway + name: gke-gateway + namespace: {{ .Values.config.namespacePrefix }}gke-gateway + sectionName: https + hostnames: + - {{ .Values.config.domain }} + rules: + - matches: + - path: + type: PathPrefix + value: /workflows + filters: + - type: URLRewrite + urlRewrite: + hostname: {{ .Values.config.domain }} + path: + type: ReplacePrefixMatch + replacePrefixMatch: / + backendRefs: + - name: {{ .Values.config.namespacePrefix }}argo-workflows-server + port: 2746 +--- +apiVersion: networking.gke.io/v1 +kind: HealthCheckPolicy +metadata: + name: argo-workflows-healthcheck + namespace: {{ .Values.config.namespacePrefix }}argo-workflows + annotations: + argocd.argoproj.io/sync-wave: "8" +spec: + default: + checkIntervalSec: 15 + timeoutSec: 15 + healthyThreshold: 1 + unhealthyThreshold: 2 + logConfig: + enabled: true + config: + type: HTTP + httpHealthCheck: + port: 2746 + requestPath: / + targetRef: + group: "" + kind: Service + name: {{ .Values.config.namespacePrefix }}argo-workflows-server +--- +# Argo Server UI holds long-lived GET streams (api/v1/workflow-events/...) — Kubernetes watch over HTTP/2. +# Without an extended backend timeout, GKE Gateway / external HTTPS LB defaults (~30s or idle rules) can cut +# those connections; the SPA reconnects (resourceVersion in URL) and shows "Failed to connect" briefly. +# Match gateway-mtk-connect websocket backends (86400s) so multi-hour AAOS runs can keep the live list/watch. +apiVersion: networking.gke.io/v1 +kind: GCPBackendPolicy +metadata: + name: argo-workflows-server-backendpolicy + namespace: {{ .Values.config.namespacePrefix }}argo-workflows + annotations: + argocd.argoproj.io/sync-wave: "8" +spec: + default: + timeoutSec: 86400 + targetRef: + group: "" + kind: Service + name: {{ .Values.config.namespacePrefix }}argo-workflows-server diff --git a/gitops/templates/gateway-argocd.yaml b/gitops/templates/gateway-argocd.yaml index 94dba0c5..33ce7e19 100644 --- a/gitops/templates/gateway-argocd.yaml +++ b/gitops/templates/gateway-argocd.yaml @@ -70,6 +70,24 @@ spec: kind: Service name: {{ .Values.config.namespacePrefix }}argocd-server --- +# Argo CD UI keeps long-lived streams (WebSocket / gRPC-Web) to the API server for live Application sync. +# Default Gateway/LB backend timeouts can close those connections; the SPA then reconnects and can appear to +# refresh the whole UI. Align with gateway-argo-workflows / mtk-connect long-connection backends. +apiVersion: networking.gke.io/v1 +kind: GCPBackendPolicy +metadata: + name: argocd-server-backendpolicy + namespace: {{ .Values.config.namespacePrefix }}argocd + annotations: + argocd.argoproj.io/sync-wave: "1" +spec: + default: + timeoutSec: 86400 + targetRef: + group: "" + kind: Service + name: {{ .Values.config.namespacePrefix }}argocd-server +--- # Allow ArgoCD Server ingress from Gateway # Security is enforced by GCP Load Balancer and HTTPRoute configuration {{- if .Values.config.enableNetworkPolicies }} diff --git a/gitops/templates/gateway-gerrit.yaml b/gitops/templates/gateway-gerrit.yaml index 73d7fe30..d126846c 100644 --- a/gitops/templates/gateway-gerrit.yaml +++ b/gitops/templates/gateway-gerrit.yaml @@ -1,4 +1,4 @@ -# Copyright (c) 2024-2025 Accenture, All Rights Reserved. +# Copyright (c) 2024-2026 Accenture, All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/gitops/templates/gateway-grafana.yaml b/gitops/templates/gateway-grafana.yaml index d9743ba9..7b80c19d 100644 --- a/gitops/templates/gateway-grafana.yaml +++ b/gitops/templates/gateway-grafana.yaml @@ -1,4 +1,4 @@ -# Copyright (c) 2024-2025 Accenture, All Rights Reserved. +# Copyright (c) 2024-2026 Accenture, All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/gitops/templates/gateway-headlamp.yaml b/gitops/templates/gateway-headlamp.yaml index 48c92f94..80e98bc0 100644 --- a/gitops/templates/gateway-headlamp.yaml +++ b/gitops/templates/gateway-headlamp.yaml @@ -95,4 +95,4 @@ spec: - protocol: TCP port: 44180 # Token injector -{{- end }} +{{- end }} \ No newline at end of file diff --git a/gitops/templates/gateway-horizon-api.yaml b/gitops/templates/gateway-horizon-api.yaml new file mode 100644 index 00000000..bb3f01e8 --- /dev/null +++ b/gitops/templates/gateway-horizon-api.yaml @@ -0,0 +1,176 @@ +# Copyright (c) 2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +apiVersion: gateway.networking.k8s.io/v1beta1 +kind: HTTPRoute +metadata: + name: horizon-api-route + namespace: {{ .Values.config.namespacePrefix }}horizon-api + labels: + gateway: gke-gateway + annotations: + # After horizon-api Application (wave 7); backend Service is created by that child app. + # Matches gateway-argo-workflows.yaml (app wave 6, gateway wave 8). + argocd.argoproj.io/sync-wave: "8" +spec: + parentRefs: + - kind: Gateway + name: gke-gateway + namespace: {{ .Values.config.namespacePrefix }}gke-gateway + sectionName: https + hostnames: + - {{ .Values.config.domain }} + rules: + - matches: + - path: + type: PathPrefix + value: /horizon-api/ + filters: + - type: URLRewrite + urlRewrite: + hostname: {{ .Values.config.domain }} + path: + type: ReplacePrefixMatch + replacePrefixMatch: / + backendRefs: + - name: horizon-api + port: 80 +--- +apiVersion: networking.gke.io/v1 +kind: HealthCheckPolicy +metadata: + name: horizon-api-healthcheck + namespace: {{ .Values.config.namespacePrefix }}horizon-api + annotations: + argocd.argoproj.io/sync-wave: "8" +spec: + default: + checkIntervalSec: 15 + timeoutSec: 15 + healthyThreshold: 1 + unhealthyThreshold: 2 + logConfig: + enabled: true + config: + type: HTTP + httpHealthCheck: + # NEG probes pod targetPort (8082), not Service port 80; port 80 yields no listener on the pod. + port: 8082 + requestPath: /healthz + targetRef: + group: "" + kind: Service + name: horizon-api +--- +# Submit waits on Argo Events webhook (HTTP); default LB timeout (~15s) returns 504 before the app responds. +apiVersion: networking.gke.io/v1 +kind: GCPBackendPolicy +metadata: + name: horizon-api-backendpolicy + namespace: {{ .Values.config.namespacePrefix }}horizon-api + annotations: + argocd.argoproj.io/sync-wave: "8" +spec: + default: + timeoutSec: 3600 + targetRef: + group: "" + kind: Service + name: horizon-api +--- +{{- if .Values.config.enableNetworkPolicies }} +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: allow-horizon-api-ingress-from-gateway + namespace: {{ .Values.config.namespacePrefix }}horizon-api + annotations: + argocd.argoproj.io/sync-wave: "0" +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: horizon-api + policyTypes: + - Ingress + ingress: + # Service port 80 maps to container port http (8082); Gateway/NEG targets pod:8082. + - ports: + - protocol: TCP + port: 8082 +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: allow-horizon-api-egress + namespace: {{ .Values.config.namespacePrefix }}horizon-api + annotations: + argocd.argoproj.io/sync-wave: "0" +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: horizon-api + policyTypes: + - Egress + egress: + # Argo Events EventSource webhook + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: {{ .Values.config.namespacePrefix }}argo-events + ports: + - protocol: TCP + port: 12000 + # Argo Workflows server (NDJSON log proxy) + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: {{ .Values.config.namespacePrefix }}argo-workflows + ports: + - protocol: TCP + port: 2746 + # DNS (cluster DNS) + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + ports: + - protocol: UDP + port: 53 + - protocol: TCP + port: 53 + # GKE Workload Identity + ADC: GKE metadata server (see cloud.google.com/kubernetes-engine/docs/how-to/network-policy). + # - GKE 1.21+ (default datapath): 169.254.169.252:988 + # - Dataplane V2: 169.254.169.254:80 + - to: + - ipBlock: + cidr: 169.254.169.252/32 + ports: + - protocol: TCP + port: 988 + - to: + - ipBlock: + cidr: 169.254.169.254/32 + ports: + - protocol: TCP + port: 80 + # Kubernetes API + Keycloak OIDC / JWKS over HTTPS + - to: + - ipBlock: + cidr: 0.0.0.0/0 + except: + - 169.254.169.254/32 + ports: + - protocol: TCP + port: 443 +{{- end }} diff --git a/gitops/templates/gateway-horizon-dev-portal.yaml b/gitops/templates/gateway-horizon-dev-portal.yaml new file mode 100644 index 00000000..d205f608 --- /dev/null +++ b/gitops/templates/gateway-horizon-dev-portal.yaml @@ -0,0 +1,128 @@ +# Copyright (c) 2024-2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +apiVersion: gateway.networking.k8s.io/v1beta1 +kind: HTTPRoute +metadata: + name: horizon-dev-portal-route + namespace: {{ .Values.config.namespacePrefix }}horizon-dev-portal + labels: + gateway: gke-gateway + annotations: + argocd.argoproj.io/sync-wave: "7" +spec: + parentRefs: + - kind: Gateway + name: gke-gateway + namespace: {{ .Values.config.namespacePrefix }}gke-gateway + sectionName: https + hostnames: + - {{ .Values.config.domain }} + rules: + # Without a trailing slash, relative asset URLs (./assets/..., ./config.js) resolve to the site root. + # Redirect /developer-portal → /developer-portal/ so the browser's base URL is correct. + # Gateway API RequestRedirect supports only 301/302 (not 308). +{{- $pp := .Values.config.horizonDevelopmentPortal.publicPath | trimSuffix "/" }} +{{- if $pp }} + - matches: + - path: + type: Exact + value: {{ $pp | quote }} + filters: + - type: RequestRedirect + requestRedirect: + path: + type: ReplaceFullPath + replaceFullPath: {{ print $pp "/" | quote }} + statusCode: 301 +{{- end }} + - matches: + - path: + type: PathPrefix + value: {{ .Values.config.horizonDevelopmentPortal.publicPath }} + filters: + - type: URLRewrite + urlRewrite: + hostname: {{ .Values.config.domain }} + path: + type: ReplacePrefixMatch + replacePrefixMatch: / + backendRefs: + - name: {{ .Values.config.namespacePrefix }}horizon-dev-portal + port: 80 +--- +apiVersion: networking.gke.io/v1 +kind: HealthCheckPolicy +metadata: + name: {{ .Values.config.namespacePrefix }}horizon-dev-portal-healthcheck + namespace: {{ .Values.config.namespacePrefix }}horizon-dev-portal + annotations: + argocd.argoproj.io/sync-wave: "7" + +spec: + default: + checkIntervalSec: 15 + timeoutSec: 15 + healthyThreshold: 1 + unhealthyThreshold: 2 + logConfig: + enabled: true + config: + type: HTTP + httpHealthCheck: + # NEG probes pod targetPort (8080), not Service port 80; port 80 yields no listener on the pod. + port: 8080 + requestPath: /healthz + + targetRef: + group: "" + kind: Service + name: {{ .Values.config.namespacePrefix }}horizon-dev-portal +--- +# Default LB backend timeout (~15s) returns 504 while the Go proxy waits on Horizon API (catalog) or streams workflow logs. +apiVersion: networking.gke.io/v1 +kind: GCPBackendPolicy +metadata: + name: {{ .Values.config.namespacePrefix }}horizon-dev-portal-backendpolicy + namespace: {{ .Values.config.namespacePrefix }}horizon-dev-portal + annotations: + argocd.argoproj.io/sync-wave: "7" +spec: + default: + timeoutSec: 3600 + targetRef: + group: "" + kind: Service + name: {{ .Values.config.namespacePrefix }}horizon-dev-portal +--- +{{- if .Values.config.enableNetworkPolicies }} +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: allow-horizon-dev-portal-ingress-from-gateway + namespace: {{ .Values.config.namespacePrefix }}horizon-dev-portal + annotations: + argocd.argoproj.io/sync-wave: "0" +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: horizon-dev-portal + policyTypes: + - Ingress + ingress: + # Service port 80 maps to containerPort 8080; Gateway/NEG targets pod:8080. + - ports: + - protocol: TCP + port: 8080 +{{- end }} diff --git a/gitops/templates/gateway-keycloak.yaml b/gitops/templates/gateway-keycloak.yaml index df7a5db6..47948a26 100644 --- a/gitops/templates/gateway-keycloak.yaml +++ b/gitops/templates/gateway-keycloak.yaml @@ -1,4 +1,4 @@ -# Copyright (c) 2024-2025 Accenture, All Rights Reserved. +# Copyright (c) 2024-2026 Accenture, All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/gitops/templates/gateway-landingpage.yaml b/gitops/templates/gateway-landingpage.yaml index 92cc198c..55ec1e8c 100644 --- a/gitops/templates/gateway-landingpage.yaml +++ b/gitops/templates/gateway-landingpage.yaml @@ -1,4 +1,4 @@ -# Copyright (c) 2024-2025 Accenture, All Rights Reserved. +# Copyright (c) 2024-2026 Accenture, All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -90,4 +90,4 @@ spec: - protocol: TCP port: 80 # HTTP UI -{{- end }} +{{- end }} \ No newline at end of file diff --git a/gitops/templates/gateway-mcp-gateway-registry.yaml b/gitops/templates/gateway-mcp-gateway-registry.yaml index ed87c97a..9854c713 100644 --- a/gitops/templates/gateway-mcp-gateway-registry.yaml +++ b/gitops/templates/gateway-mcp-gateway-registry.yaml @@ -30,6 +30,124 @@ spec: hostnames: - {{ printf "mcp.%s" .Values.config.domain | quote }} rules: + # Block doc paths served by the registry pod. GKE requires URLRewrite with + # replacePrefixMatch to have exactly one PathPrefix match per rule. + # Send blocked paths to auth-server because registry serves SPA index.html + # for unknown paths (200), while auth-server returns a real 404. + - matches: + - path: + type: PathPrefix + value: /docs + filters: + - type: URLRewrite + urlRewrite: + path: + type: ReplacePrefixMatch + replacePrefixMatch: /__horizon_blocked__ + backendRefs: + - name: auth-server + namespace: {{ .Values.config.namespacePrefix }}mcp-gateway-registry + port: 8888 + - matches: + - path: + type: PathPrefix + value: /redoc + filters: + - type: URLRewrite + urlRewrite: + path: + type: ReplacePrefixMatch + replacePrefixMatch: /__horizon_blocked__ + backendRefs: + - name: auth-server + namespace: {{ .Values.config.namespacePrefix }}mcp-gateway-registry + port: 8888 + - matches: + - path: + type: PathPrefix + value: /openapi.json + filters: + - type: URLRewrite + urlRewrite: + path: + type: ReplacePrefixMatch + replacePrefixMatch: /__horizon_blocked__ + backendRefs: + - name: auth-server + namespace: {{ .Values.config.namespacePrefix }}mcp-gateway-registry + port: 8888 + - matches: + - path: + type: PathPrefix + value: /openapi.yaml + filters: + - type: URLRewrite + urlRewrite: + path: + type: ReplacePrefixMatch + replacePrefixMatch: /__horizon_blocked__ + backendRefs: + - name: auth-server + namespace: {{ .Values.config.namespacePrefix }}mcp-gateway-registry + port: 8888 + # Block the same doc paths exposed by the auth-server pod (FastAPI on :8888). + # The longer prefix (`/auth-server/docs`) wins over `/auth-server` by specificity. + - matches: + - path: + type: PathPrefix + value: /auth-server/docs + filters: + - type: URLRewrite + urlRewrite: + path: + type: ReplacePrefixMatch + replacePrefixMatch: /__horizon_blocked__ + backendRefs: + - name: auth-server + namespace: {{ .Values.config.namespacePrefix }}mcp-gateway-registry + port: 8888 + - matches: + - path: + type: PathPrefix + value: /auth-server/redoc + filters: + - type: URLRewrite + urlRewrite: + path: + type: ReplacePrefixMatch + replacePrefixMatch: /__horizon_blocked__ + backendRefs: + - name: auth-server + namespace: {{ .Values.config.namespacePrefix }}mcp-gateway-registry + port: 8888 + - matches: + - path: + type: PathPrefix + value: /auth-server/openapi.json + filters: + - type: URLRewrite + urlRewrite: + path: + type: ReplacePrefixMatch + replacePrefixMatch: /__horizon_blocked__ + backendRefs: + - name: auth-server + namespace: {{ .Values.config.namespacePrefix }}mcp-gateway-registry + port: 8888 + - matches: + - path: + type: PathPrefix + value: /auth-server/openapi.yaml + filters: + - type: URLRewrite + urlRewrite: + path: + type: ReplacePrefixMatch + replacePrefixMatch: /__horizon_blocked__ + backendRefs: + - name: auth-server + namespace: {{ .Values.config.namespacePrefix }}mcp-gateway-registry + port: 8888 # route `mcp.domain` to nginx port inside registry service - matches: - path: diff --git a/gitops/templates/gateway-mtk-connect.yaml b/gitops/templates/gateway-mtk-connect.yaml index 68713e95..b2f3bfde 100644 --- a/gitops/templates/gateway-mtk-connect.yaml +++ b/gitops/templates/gateway-mtk-connect.yaml @@ -1,4 +1,4 @@ -# Copyright (c) 2024-2025 Accenture, All Rights Reserved. +# Copyright (c) 2024-2026 Accenture, All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/gitops/templates/gerrit-mcp-server.yaml b/gitops/templates/gerrit-mcp-server.yaml index d14a54e6..c7bf63f2 100644 --- a/gitops/templates/gerrit-mcp-server.yaml +++ b/gitops/templates/gerrit-mcp-server.yaml @@ -32,7 +32,7 @@ spec: config: namespacePrefix: {{ .Values.config.namespacePrefix }} gitops: - gerritMcpServer: {{ .Values.config.apps.gerritMcpServer }} + gerritMcpServer: {{ .Values.config.containerImages.gerritMcpServer | quote }} domain: {{ .Values.config.domain }} spec: source: diff --git a/gitops/templates/gerrit-operator.yaml b/gitops/templates/gerrit-operator.yaml index 72f688ee..4f059a6a 100644 --- a/gitops/templates/gerrit-operator.yaml +++ b/gitops/templates/gerrit-operator.yaml @@ -1,4 +1,4 @@ -# Copyright (c) 2024-2025 Accenture, All Rights Reserved. +# Copyright (c) 2024-2026 Accenture, All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/gitops/templates/gerrit-post.yaml b/gitops/templates/gerrit-post.yaml index 9f3f00fe..44fcfd7c 100644 --- a/gitops/templates/gerrit-post.yaml +++ b/gitops/templates/gerrit-post.yaml @@ -1,4 +1,4 @@ -# Copyright (c) 2024-2025 Accenture, All Rights Reserved. +# Copyright (c) 2024-2026 Accenture, All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -84,6 +84,7 @@ metadata: spec: backoffLimit: 0 ttlSecondsAfterFinished: 120 + activeDeadlineSeconds: 600 template: spec: serviceAccountName: gerrit-writer-sa diff --git a/gitops/templates/grafana-post.yaml b/gitops/templates/grafana-post.yaml index 1fc8a540..9c88add9 100644 --- a/gitops/templates/grafana-post.yaml +++ b/gitops/templates/grafana-post.yaml @@ -1,4 +1,4 @@ -# Copyright (c) 2024-2025 Accenture, All Rights Reserved. +# Copyright (c) 2024-2026 Accenture, All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -71,6 +71,7 @@ metadata: spec: backoffLimit: 0 ttlSecondsAfterFinished: 120 + activeDeadlineSeconds: 600 template: spec: serviceAccountName: grafana-writer-sa diff --git a/gitops/templates/grafana.yaml b/gitops/templates/grafana.yaml index 0e3ba81b..94ae286c 100644 --- a/gitops/templates/grafana.yaml +++ b/gitops/templates/grafana.yaml @@ -93,7 +93,7 @@ spec: token_url: https://{{ .Values.config.domain }}/auth/realms/horizon/protocol/openid-connect/token api_url: https://{{ .Values.config.domain }}/auth/realms/horizon/protocol/openid-connect/userinfo allow_assign_grafana_admin: true - role_attribute_path: contains(groups[*], 'horizon-grafana-administrators') && 'Admin' || contains(groups[*], 'horizon-grafana-viewers') && 'Viewer' + role_attribute_path: contains(roles[*], 'administrators') && 'Admin' || contains(roles[*], 'viewers') && 'Viewer' login_attribute_path: username name_attribute_path: name email_attribute_path: email diff --git a/gitops/templates/headlamp-init.yaml b/gitops/templates/headlamp-init.yaml index 542d491d..a647fc74 100644 --- a/gitops/templates/headlamp-init.yaml +++ b/gitops/templates/headlamp-init.yaml @@ -1,4 +1,4 @@ -# Copyright (c) 2024-2025 Accenture, All Rights Reserved. +# Copyright (c) 2024-2026 Accenture, All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -33,4 +33,4 @@ roleRef: subjects: - kind: ServiceAccount name: headlamp-admin - namespace: {{ .Values.config.namespacePrefix }}headlamp + namespace: {{ .Values.config.namespacePrefix }}headlamp \ No newline at end of file diff --git a/gitops/templates/headlamp-oauth2-proxy.yaml b/gitops/templates/headlamp-oauth2-proxy.yaml index 4d5cee63..5e39d066 100644 --- a/gitops/templates/headlamp-oauth2-proxy.yaml +++ b/gitops/templates/headlamp-oauth2-proxy.yaml @@ -1,4 +1,4 @@ -# Copyright (c) 2024-2025 Accenture, All Rights Reserved. +# Copyright (c) 2024-2026 Accenture, All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -43,14 +43,14 @@ spec: redirect-url: "https://{{ .Values.config.domain }}/headlamp/oauth2/callback" cookie-secure: "true" cookie-samesite: "lax" - scope: "openid profile email groups" + scope: "openid profile email roles" upstream: "http://{{ .Values.config.namespacePrefix }}headlamp-token-injector.{{ .Values.config.namespacePrefix }}headlamp.svc.cluster.local:8080/" http-address: "0.0.0.0:4180" pass-user-headers: "true" pass-authorization-header: "false" set-xauthrequest: "true" - allowed-group: "horizon-headlamp-administrators" - oidc-groups-claim: "groups" + allowed-group: "administrators" + oidc-groups-claim: "roles" skip-jwt-bearer-tokens: "false" skip-provider-button: "true" cookie-expire: "2h" @@ -166,4 +166,4 @@ spec: - protocol: TCP port: 80 # HTTP -{{- end }} +{{- end }} \ No newline at end of file diff --git a/gitops/templates/headlamp-token-injector.yaml b/gitops/templates/headlamp-token-injector.yaml index 9e2c080d..81951ede 100644 --- a/gitops/templates/headlamp-token-injector.yaml +++ b/gitops/templates/headlamp-token-injector.yaml @@ -127,4 +127,4 @@ spec: - protocol: TCP port: 443 # Kubernetes API -{{- end }} +{{- end }} \ No newline at end of file diff --git a/gitops/templates/headlamp.yaml b/gitops/templates/headlamp.yaml index 31c83486..1832ed39 100644 --- a/gitops/templates/headlamp.yaml +++ b/gitops/templates/headlamp.yaml @@ -1,4 +1,4 @@ -# Copyright (c) 2024-2025 Accenture, All Rights Reserved. +# Copyright (c) 2024-2026 Accenture, All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -111,4 +111,4 @@ spec: - protocol: TCP port: 443 # Kubernetes API -{{- end }} +{{- end }} \ No newline at end of file diff --git a/gitops/templates/horizon-api.yaml b/gitops/templates/horizon-api.yaml new file mode 100644 index 00000000..f5c68e9b --- /dev/null +++ b/gitops/templates/horizon-api.yaml @@ -0,0 +1,56 @@ +# Copyright (c) 2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: {{ .Values.config.namespacePrefix }}horizon-api + namespace: {{ .Values.config.namespacePrefix }}argocd + annotations: + argocd.argoproj.io/sync-wave: "7" + finalizers: + - resources-finalizer.argocd.argoproj.io +spec: + project: {{ .Values.config.namespacePrefix }}horizon-sdv + source: + repoURL: {{ .Values.spec.source.repoURL }} + targetRevision: {{ .Values.spec.source.targetRevision }} + path: gitops/apps/horizon-api + helm: + values: | + image: {{ .Values.config.containerImages.horizonApi | quote }} + namespace: {{ .Values.config.namespacePrefix }}horizon-api + moduleManagerNamespace: module-manager + moduleManagerStateName: cluster + workflowsNamespace: {{ .Values.config.namespacePrefix }}workflows + argoWorkflowsNamespace: {{ .Values.config.namespacePrefix }}argo-workflows + oidcIssuerUrl: "https://{{ .Values.config.domain }}/auth/realms/horizon" + oidcSkipClientIdCheck: true + eventsWebhookUrl: "http://webhook-eventsource-svc.{{ .Values.config.namespacePrefix }}argo-events.svc.cluster.local:12000/events/workflow" + gcsArtifactBucket: {{ .Values.config.projectID }}-argo-workflows + gcsSigningServiceAccount: gke-{{ .Values.config.namespacePrefix }}argo-workflows-sa@{{ .Values.config.projectID }}.iam.gserviceaccount.com + # GKE 1.21+ default datapath metadata; Dataplane V2 uses 169.254.169.254:80 — omit or set gceMetadataHost: "" in overrides. + gceMetadataHost: "169.254.169.252:988" + workflowTtlSecondsAfterSuccess: 86400 + workflowTtlSecondsAfterFailure: 259200 + workflowTtlSecondsAfterCompletion: 86400 + config: + enableNetworkPolicies: {{ .Values.config.enableNetworkPolicies }} + destination: + server: https://kubernetes.default.svc + namespace: {{ .Values.config.namespacePrefix }}horizon-api + syncPolicy: + syncOptions: + - CreateNamespace=true + automated: {} diff --git a/gitops/templates/horizon-dev-portal.yaml b/gitops/templates/horizon-dev-portal.yaml new file mode 100644 index 00000000..5dfb2868 --- /dev/null +++ b/gitops/templates/horizon-dev-portal.yaml @@ -0,0 +1,70 @@ +# Copyright (c) 2024-2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: {{ .Values.config.namespacePrefix }}horizon-dev-portal + namespace: {{ .Values.config.namespacePrefix }}argocd + annotations: + # After keycloak-post-horizon-api (wave 5) — that job creates horizon-dev-portal-secrets with CI client secret. + argocd.argoproj.io/sync-wave: "6" + finalizers: + - resources-finalizer.argocd.argoproj.io + +spec: + project: {{ .Values.config.namespacePrefix }}horizon-sdv + + source: + repoURL: {{ .Values.spec.source.repoURL }} + targetRevision: {{ .Values.spec.source.targetRevision }} + + helm: + values: | + config: + namespacePrefix: {{ .Values.config.namespacePrefix | quote }} + gitops: + horizonDevPortal: {{ .Values.config.containerImages.horizondevelopmentportal | quote }} + domain: {{ .Values.config.domain | quote }} + publicPath: {{ .Values.config.horizonDevelopmentPortal.publicPath | quote }} + publicBaseUrl: "https://{{ .Values.config.domain }}" + keycloakRealm: {{ .Values.config.horizonDevelopmentPortal.keycloakRealm | quote }} + keycloakHttpPathPrefix: {{ .Values.config.horizonDevelopmentPortal.keycloakHttpPathPrefix | quote }} + oidcIssuerUrl: "https://{{ .Values.config.domain }}{{ .Values.config.horizonDevelopmentPortal.keycloakHttpPathPrefix }}/realms/{{ .Values.config.horizonDevelopmentPortal.keycloakRealm }}" + keycloakTokenUrl: "https://{{ .Values.config.domain }}{{ .Values.config.horizonDevelopmentPortal.keycloakHttpPathPrefix }}/realms/{{ .Values.config.horizonDevelopmentPortal.keycloakRealm }}/protocol/openid-connect/token" + keycloakTokenPath: "{{ .Values.config.horizonDevelopmentPortal.keycloakHttpPathPrefix }}/realms/{{ .Values.config.horizonDevelopmentPortal.keycloakRealm }}/protocol/openid-connect/token" + keycloakClientId: {{ .Values.config.horizonDevelopmentPortal.keycloakClientId | quote }} + horizonApiCiClientId: {{ .Values.config.horizonDevelopmentPortal.horizonApiCiClientId | quote }} + horizonApiBaseUrl: {{ if .Values.config.horizonDevelopmentPortal.horizonApiInternalBaseUrl }}{{ .Values.config.horizonDevelopmentPortal.horizonApiInternalBaseUrl | quote }}{{ else }}"http://horizon-api.{{ .Values.config.namespacePrefix }}horizon-api.svc.cluster.local"{{ end }} + moduleManagerBaseUrl: {{ if .Values.config.horizonDevelopmentPortal.moduleManagerInternalBaseUrl }}{{ .Values.config.horizonDevelopmentPortal.moduleManagerInternalBaseUrl | quote }}{{ else }}"http://module-manager.{{ .Values.config.namespacePrefix }}module-manager.svc.cluster.local:8082"{{ end }} + secret: + horizonApiCiClientSecret: "" + spec: + source: + repoURL: {{ .Values.spec.source.repoURL | quote }} + targetRevision: {{ .Values.spec.source.targetRevision | quote }} + path: gitops/apps/horizon-dev-portal + destination: + server: https://kubernetes.default.svc + namespace: {{ .Values.config.namespacePrefix }}horizon-dev-portal + + syncPolicy: + syncOptions: + - CreateNamespace=true + automated: {} + + ignoreDifferences: + - kind: ConfigMap + name: {{ .Values.config.namespacePrefix }}horizon-dev-portal-config + namespace: {{ .Values.config.namespacePrefix }}horizon-dev-portal diff --git a/gitops/templates/jenkins-init.yaml b/gitops/templates/jenkins-init.yaml index ab697784..e818fca3 100644 --- a/gitops/templates/jenkins-init.yaml +++ b/gitops/templates/jenkins-init.yaml @@ -51,7 +51,7 @@ spec: - ReadWriteOnce resources: requests: - storage: 32Gi + storage: 64Gi storageClassName: {{ .Values.config.namespacePrefix }}jenkins-rwo volumeMode: Filesystem --- @@ -178,22 +178,22 @@ stringData: apiVersion: v1 kind: Secret metadata: - name: jenkins-git-creds + name: jenkins-scm-creds namespace: {{ .Values.config.namespacePrefix }}jenkins labels: - {{- if eq .Values.git.authMethod "app" }} + {{- if eq .Values.scm.authMethod "app" }} "jenkins.io/credentials-type": "gitHubApp" {{- else }} "jenkins.io/credentials-type": "usernamePassword" {{- end }} annotations: - "jenkins.io/credentials-description": "Git" + "jenkins.io/credentials-description": "SCM Credentials" argocd.argoproj.io/sync-wave: "1" type: Opaque stringData: - id: "git-creds" - {{- if eq .Values.git.authMethod "pat" }} - username: {{ .Values.git.username }} + id: "jenkins-scm-creds" + {{- if ne .Values.scm.authMethod "app" }} + username: {{ .Values.scm.username }} {{- end }} --- apiVersion: v1 @@ -264,10 +264,11 @@ spec: key: {{ .Values.config.namespacePrefix }}jenkins-admin-password-b64 decodingStrategy: Base64 --- +{{- if ne .Values.scm.authMethod "none" }} apiVersion: external-secrets.io/v1beta1 kind: ExternalSecret metadata: - name: jenkins-git-creds-secret + name: jenkins-scm-creds-secret namespace: {{ .Values.config.namespacePrefix }}jenkins annotations: argocd.argoproj.io/sync-wave: "1" @@ -277,10 +278,10 @@ spec: kind: SecretStore name: jenkins-secret-store target: - name: jenkins-git-creds + name: jenkins-scm-creds creationPolicy: Merge data: - {{- if eq .Values.git.authMethod "app" }} + {{- if eq .Values.scm.authMethod "app" }} - secretKey: appID remoteRef: key: {{ .Values.config.namespacePrefix }}github-app-id-b64 @@ -292,9 +293,10 @@ spec: {{- else }} - secretKey: password remoteRef: - key: {{ .Values.config.namespacePrefix }}git-pat-b64 + key: {{ .Values.config.namespacePrefix }}scm-password-b64 decodingStrategy: Base64 {{- end }} +{{- end }} --- apiVersion: external-secrets.io/v1beta1 kind: ExternalSecret @@ -335,4 +337,4 @@ spec: - secretKey: privateKey remoteRef: key: {{ .Values.config.namespacePrefix }}jenkins-cuttlefish-ssh-key-b64 - decodingStrategy: Base64 \ No newline at end of file + decodingStrategy: Base64 diff --git a/gitops/templates/jenkins.yaml b/gitops/templates/jenkins.yaml index d2ccd51e..957266b7 100644 --- a/gitops/templates/jenkins.yaml +++ b/gitops/templates/jenkins.yaml @@ -29,9 +29,16 @@ spec: targetRevision: {{ .Values.config.workloads.android.helm.version }} helm: values: | - git: - repoOwner: {{ .Values.git.repoOwner }} - repoName: {{ .Values.git.repoName }} + scm: + type: {{ .Values.scm.type }} + authMethod: {{ .Values.scm.authMethod }} + username: {{ .Values.scm.username }} + repoUrl: {{ .Values.scm.repoUrl }} + branch: {{ .Values.scm.branch }} + {{- if eq .Values.scm.type "github" }} + repoOwner: {{ .Values.scm.repoOwner }} + repoName: {{ .Values.scm.repoName }} + {{- end }} config: projectID: {{ .Values.config.projectID }} region: {{ .Values.config.region }} @@ -41,8 +48,8 @@ spec: namespacePrefix: {{ .Values.config.namespacePrefix }} workloads: android: - url: {{ .Values.config.workloads.android.url }} - branch: {{ .Values.config.workloads.android.branch }} + url: {{ .Values.scm.repoUrl }} + branch: {{ .Values.scm.branch }} controller: jenkinsUrl: https://{{ .Values.config.domain }}/jenkins jenkinsUriPrefix: /jenkins diff --git a/gitops/templates/keycloak-init.yaml b/gitops/templates/keycloak-init.yaml index d1c529b5..c2df2de3 100644 --- a/gitops/templates/keycloak-init.yaml +++ b/gitops/templates/keycloak-init.yaml @@ -78,7 +78,17 @@ metadata: type: Opaque stringData: username: "admin" - +--- +apiVersion: v1 +kind: Secret +metadata: + name: keycloak-argocd-admin + namespace: {{ .Values.config.namespacePrefix }}keycloak + annotations: + argocd.argoproj.io/sync-wave: "1" +type: Opaque +stringData: + username: "argocd-admin" --- apiVersion: v1 kind: Secret @@ -226,7 +236,27 @@ spec: remoteRef: key: {{ .Values.config.namespacePrefix }}grafana-admin-password-b64 decodingStrategy: Base64 - +--- +apiVersion: external-secrets.io/v1beta1 +kind: ExternalSecret +metadata: + name: argocd-admin-initial-secret + namespace: {{ .Values.config.namespacePrefix }}keycloak + annotations: + argocd.argoproj.io/sync-wave: "1" +spec: + refreshInterval: 10s + secretStoreRef: + kind: SecretStore + name: keycloak-secret-store + target: + name: keycloak-argocd-admin + creationPolicy: Merge + data: + - secretKey: password + remoteRef: + key: {{ .Values.config.namespacePrefix }}argocd-admin-password-b64 + decodingStrategy: Base64 --- apiVersion: external-secrets.io/v1beta1 kind: ExternalSecret diff --git a/gitops/templates/keycloak-post-apps.yaml b/gitops/templates/keycloak-post-apps.yaml index d11c7b5c..ad266352 100644 --- a/gitops/templates/keycloak-post-apps.yaml +++ b/gitops/templates/keycloak-post-apps.yaml @@ -1,4 +1,4 @@ -# Copyright (c) 2024-2025 Accenture, All Rights Reserved. +# Copyright (c) 2024-2026 Accenture, All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -71,6 +71,7 @@ metadata: spec: backoffLimit: 0 ttlSecondsAfterFinished: 120 + activeDeadlineSeconds: 600 template: spec: serviceAccountName: keycloak-writer-sa @@ -105,6 +106,7 @@ metadata: spec: backoffLimit: 0 ttlSecondsAfterFinished: 120 + activeDeadlineSeconds: 600 template: spec: serviceAccountName: keycloak-writer-sa @@ -149,6 +151,7 @@ metadata: spec: backoffLimit: 0 ttlSecondsAfterFinished: 120 + activeDeadlineSeconds: 600 template: spec: serviceAccountName: keycloak-writer-sa @@ -183,6 +186,7 @@ metadata: spec: backoffLimit: 0 ttlSecondsAfterFinished: 120 + activeDeadlineSeconds: 600 template: spec: serviceAccountName: keycloak-writer-sa @@ -202,6 +206,16 @@ spec: secretKeyRef: name: keycloak-initial-creds key: password + - name: ARGOCD_ADMIN_USERNAME + valueFrom: + secretKeyRef: + name: keycloak-argocd-admin + key: username + - name: ARGOCD_ADMIN_PASSWORD + valueFrom: + secretKeyRef: + name: keycloak-argocd-admin + key: password image: {{ .Values.config.postjobs.keycloakargocd }} imagePullPolicy: Always restartPolicy: Never @@ -217,6 +231,7 @@ metadata: spec: backoffLimit: 0 ttlSecondsAfterFinished: 120 + activeDeadlineSeconds: 600 template: spec: serviceAccountName: keycloak-writer-sa @@ -261,6 +276,7 @@ metadata: spec: backoffLimit: 0 ttlSecondsAfterFinished: 120 + activeDeadlineSeconds: 600 template: spec: serviceAccountName: keycloak-writer-sa @@ -305,6 +321,7 @@ metadata: spec: backoffLimit: 0 ttlSecondsAfterFinished: 120 + activeDeadlineSeconds: 600 template: spec: serviceAccountName: keycloak-writer-sa @@ -336,4 +353,74 @@ spec: key: password image: {{ .Values.config.postjobs.keycloakMcpGatewayRegistry }} imagePullPolicy: Always + restartPolicy: Never +--- +apiVersion: batch/v1 +kind: Job +metadata: + name: {{ .Values.config.namespacePrefix }}keycloak-post-argo-workflows-job + namespace: {{ .Values.config.namespacePrefix }}keycloak + annotations: + argocd.argoproj.io/sync-wave: "5" + argocd.argoproj.io/hook: Sync +spec: + backoffLimit: 5 + ttlSecondsAfterFinished: 3600 + activeDeadlineSeconds: 600 + template: + spec: + serviceAccountName: keycloak-writer-sa + containers: + - name: keycloak-post-argo-workflows-container + env: + - name: NAMESPACE_PREFIX + value: {{ .Values.config.namespacePrefix }} + - name: PLATFORM_URL + value: http://{{ .Values.config.namespacePrefix }}keycloak-keycloakx-http:8080 + - name: DOMAIN + value: https://{{ .Values.config.domain }} + - name: KEYCLOAK_USERNAME + value: "admin" + - name: KEYCLOAK_PASSWORD + valueFrom: + secretKeyRef: + name: keycloak-initial-creds + key: password + image: {{ .Values.config.postjobs.keycloakArgoWorkflows }} + imagePullPolicy: Always + restartPolicy: Never +--- +apiVersion: batch/v1 +kind: Job +metadata: + name: {{ .Values.config.namespacePrefix }}keycloak-post-horizon-api-job + namespace: {{ .Values.config.namespacePrefix }}keycloak + annotations: + argocd.argoproj.io/sync-wave: "5" + argocd.argoproj.io/hook: Sync +spec: + backoffLimit: 0 + ttlSecondsAfterFinished: 120 + activeDeadlineSeconds: 600 + template: + spec: + serviceAccountName: keycloak-writer-sa + containers: + - name: keycloak-post-horizon-api-container + env: + - name: NAMESPACE_PREFIX + value: {{ .Values.config.namespacePrefix }} + - name: PLATFORM_URL + value: http://{{ .Values.config.namespacePrefix }}keycloak-keycloakx-http:8080 + - name: DOMAIN + value: https://{{ .Values.config.domain }} + - name: KEYCLOAK_USERNAME + value: "admin" + - name: KEYCLOAK_PASSWORD + valueFrom: + secretKeyRef: + name: keycloak-initial-creds + key: password + image: {{ .Values.config.postjobs.keycloakhorizonapi }} + imagePullPolicy: Always restartPolicy: Never \ No newline at end of file diff --git a/gitops/templates/keycloak-post-horizon-dev-portal-client.yaml b/gitops/templates/keycloak-post-horizon-dev-portal-client.yaml new file mode 100644 index 00000000..e4512f63 --- /dev/null +++ b/gitops/templates/keycloak-post-horizon-dev-portal-client.yaml @@ -0,0 +1,105 @@ +# Copyright (c) 2024-2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +apiVersion: batch/v1 +kind: Job +metadata: + name: {{ .Values.config.namespacePrefix }}keycloak-post-horizon-dev-portal-client-job + namespace: {{ .Values.config.namespacePrefix }}keycloak + annotations: + argocd.argoproj.io/sync-wave: "5" + argocd.argoproj.io/hook: Sync +spec: + backoffLimit: 2 + ttlSecondsAfterFinished: 120 + activeDeadlineSeconds: 600 + template: + spec: + restartPolicy: Never + containers: + - name: create-horizon-dev-portal-client + image: curlimages/curl:8.11.0 + terminationMessagePolicy: FallbackToLogsOnError + env: + - name: PLATFORM_URL + value: "http://{{ .Values.config.namespacePrefix }}keycloak-keycloakx-http:8080" + - name: DOMAIN + value: "https://{{ .Values.config.domain }}" + - name: HORIZON_DEV_PORTAL_PATH + value: {{ .Values.config.horizonDevelopmentPortal.publicPath | quote }} + - name: KEYCLOAK_HTTP_PATH_PREFIX + value: {{ .Values.config.horizonDevelopmentPortal.keycloakHttpPathPrefix | quote }} + - name: KEYCLOAK_REALM + value: {{ .Values.config.horizonDevelopmentPortal.keycloakRealm | quote }} + - name: HORIZON_DEV_PORTAL_OIDC_CLIENT_ID + value: {{ .Values.config.horizonDevelopmentPortal.keycloakClientId | quote }} + - name: KEYCLOAK_PASSWORD + valueFrom: + secretKeyRef: + name: keycloak-initial-creds + key: password + command: + - sh + - -c + - | + exec 2>&1 + echo "keycloak-post-horizon-dev-portal-client: starting" + set -e + + KP="${KEYCLOAK_HTTP_PATH_PREFIX%/}" + TOKEN_URL="${PLATFORM_URL}${KP}/realms/master/protocol/openid-connect/token" + REALM_ADMIN="${PLATFORM_URL}${KP}/admin/realms/${KEYCLOAK_REALM}" + + BODY=$(mktemp) + trap 'rm -f "$BODY"' EXIT + CODE=$(curl -sS -o "$BODY" -w "%{http_code}" \ + --data-urlencode "client_id=admin-cli" \ + --data-urlencode "username=admin" \ + --data-urlencode "password=${KEYCLOAK_PASSWORD}" \ + --data-urlencode "grant_type=password" \ + "$TOKEN_URL") + if [ "$CODE" != "200" ]; then + echo "keycloak-post-horizon-dev-portal-client: token request failed HTTP $CODE from $TOKEN_URL" + cat "$BODY" + exit 1 + fi + TOKEN=$(sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p' "$BODY") + if [ -z "$TOKEN" ]; then + echo "keycloak-post-horizon-dev-portal-client: could not parse access_token from response" + cat "$BODY" + exit 1 + fi + + EXISTING=$(curl -sS -H "Authorization: Bearer ${TOKEN}" \ + "${REALM_ADMIN}/clients?clientId=${HORIZON_DEV_PORTAL_OIDC_CLIENT_ID}" | sed 's/\[\]/EMPTY/') + + if echo "${EXISTING}" | grep -q "\"clientId\":\"${HORIZON_DEV_PORTAL_OIDC_CLIENT_ID}\""; then + echo "Client ${HORIZON_DEV_PORTAL_OIDC_CLIENT_ID} already exists — skipping creation." + exit 0 + fi + + POST_BODY=$(printf '%s' "{\"clientId\":\"${HORIZON_DEV_PORTAL_OIDC_CLIENT_ID}\",\"name\":\"Horizon Developer Portal\",\"publicClient\":true,\"standardFlowEnabled\":true,\"directAccessGrantsEnabled\":true,\"attributes\":{\"pkce.code.challenge.method\":\"S256\"},\"redirectUris\":[\"${DOMAIN}${HORIZON_DEV_PORTAL_PATH}/*\"],\"webOrigins\":[\"${DOMAIN}\"]}") + + CODE=$(curl -sS -o "$BODY" -w "%{http_code}" -X POST \ + -H "Authorization: Bearer ${TOKEN}" \ + -H "Content-Type: application/json" \ + -d "${POST_BODY}" \ + "${REALM_ADMIN}/clients") + if [ "$CODE" != "201" ] && [ "$CODE" != "409" ]; then + echo "keycloak-post-horizon-dev-portal-client: create client failed HTTP $CODE" + cat "$BODY" + exit 1 + fi + + echo "Client ${HORIZON_DEV_PORTAL_OIDC_CLIENT_ID} created successfully (HTTP $CODE)." diff --git a/gitops/templates/keycloak-post.yaml b/gitops/templates/keycloak-post.yaml index 9d4d818b..1e0bdf39 100644 --- a/gitops/templates/keycloak-post.yaml +++ b/gitops/templates/keycloak-post.yaml @@ -1,4 +1,4 @@ -# Copyright (c) 2024-2025 Accenture, All Rights Reserved. +# Copyright (c) 2024-2026 Accenture, All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -23,6 +23,7 @@ metadata: spec: backoffLimit: 0 ttlSecondsAfterFinished: 120 + activeDeadlineSeconds: 600 template: spec: containers: diff --git a/gitops/templates/keycloak.yaml b/gitops/templates/keycloak.yaml index f80e546c..35ac5ecf 100644 --- a/gitops/templates/keycloak.yaml +++ b/gitops/templates/keycloak.yaml @@ -1,4 +1,4 @@ -# Copyright (c) 2024-2025 Accenture, All Rights Reserved. +# Copyright (c) 2024-2026 Accenture, All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -26,7 +26,7 @@ spec: source: chart: keycloakx repoURL: https://codecentric.github.io/helm-charts - targetRevision: {{ .Values.config.applications.keycloak.version }} + targetRevision: {{ .Values.config.chartVersions.keycloak.version }} helm: values: | command: @@ -115,6 +115,9 @@ spec: - namespaceSelector: matchLabels: kubernetes.io/metadata.name: {{ .Values.config.namespacePrefix }}argocd + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: {{ .Values.config.namespacePrefix }}horizon-api ports: - protocol: TCP port: 8080 # HTTP API @@ -197,6 +200,9 @@ spec: - {{ .Values.config.namespacePrefix }}keycloak-post-mtk-connect-job - {{ .Values.config.namespacePrefix }}keycloak-post-gerrit-job - {{ .Values.config.namespacePrefix }}keycloak-post-grafana-job + - {{ .Values.config.namespacePrefix }}keycloak-post-argo-workflows-job + - {{ .Values.config.namespacePrefix }}keycloak-post-horizon-dev-portal-client-job + - {{ .Values.config.namespacePrefix }}keycloak-post-horizon-api-job policyTypes: - Egress egress: @@ -232,6 +238,9 @@ spec: - {{ .Values.config.namespacePrefix }}keycloak-post-mtk-connect-job - {{ .Values.config.namespacePrefix }}keycloak-post-gerrit-job - {{ .Values.config.namespacePrefix }}keycloak-post-grafana-job + - {{ .Values.config.namespacePrefix }}keycloak-post-argo-workflows-job + - {{ .Values.config.namespacePrefix }}keycloak-post-horizon-dev-portal-client-job + - {{ .Values.config.namespacePrefix }}keycloak-post-horizon-api-job policyTypes: - Egress egress: @@ -270,6 +279,9 @@ spec: - {{ .Values.config.namespacePrefix }}keycloak-post-mtk-connect-job - {{ .Values.config.namespacePrefix }}keycloak-post-gerrit-job - {{ .Values.config.namespacePrefix }}keycloak-post-grafana-job + - {{ .Values.config.namespacePrefix }}keycloak-post-argo-workflows-job + - {{ .Values.config.namespacePrefix }}keycloak-post-horizon-dev-portal-client-job + - {{ .Values.config.namespacePrefix }}keycloak-post-horizon-api-job policyTypes: - Egress egress: diff --git a/gitops/templates/kubescape-operator-init.yaml b/gitops/templates/kubescape-operator-init.yaml index 29f0569f..acab8a1b 100644 --- a/gitops/templates/kubescape-operator-init.yaml +++ b/gitops/templates/kubescape-operator-init.yaml @@ -1,4 +1,4 @@ -# Copyright (c) 2024-2025 Accenture, All Rights Reserved. +# Copyright (c) 2024-2026 Accenture, All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/gitops/templates/kubescape-operator.yaml b/gitops/templates/kubescape-operator.yaml index 81740fdc..eef933a3 100644 --- a/gitops/templates/kubescape-operator.yaml +++ b/gitops/templates/kubescape-operator.yaml @@ -1,4 +1,4 @@ -# Copyright (c) 2024-2025 Accenture, All Rights Reserved. +# Copyright (c) 2024-2026 Accenture, All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -28,7 +28,7 @@ spec: chart: kubescape-operator repoURL: https://kubescape.github.io/helm-charts/ targetRevision: - {{ .Values.config.applications.kubescape_operator.version }} + {{ .Values.config.chartVersions.kubescape_operator.version }} helm: values: | clusterName: "gke_{{ .Values.config.projectID }}_{{ .Values.config.region }}_sdv-cluster" diff --git a/gitops/templates/landingpage.yaml b/gitops/templates/landingpage.yaml index 2d7839ac..a6a82c92 100644 --- a/gitops/templates/landingpage.yaml +++ b/gitops/templates/landingpage.yaml @@ -31,7 +31,7 @@ spec: config: namespacePrefix: {{ .Values.config.namespacePrefix }} gitops: - landingpage: {{ .Values.config.apps.landingpage }} + landingpage: {{ .Values.config.containerImages.landingpage | quote }} spec: source: repoURL: {{ .Values.spec.source.repoURL }} diff --git a/gitops/templates/module-manager.yaml b/gitops/templates/module-manager.yaml new file mode 100644 index 00000000..70ffb22c --- /dev/null +++ b/gitops/templates/module-manager.yaml @@ -0,0 +1,64 @@ +# Copyright (c) 2024-2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: {{ .Values.config.namespacePrefix }}module-manager + namespace: {{ .Values.config.namespacePrefix }}argocd + annotations: + argocd.argoproj.io/sync-wave: "2" + finalizers: + - resources-finalizer.argocd.argoproj.io +spec: + project: {{ .Values.config.namespacePrefix }}horizon-sdv + source: + repoURL: {{ .Values.spec.source.repoURL }} + targetRevision: {{ .Values.spec.source.targetRevision }} + helm: + # Overrides merged on top of gitops/apps/module-manager/values.yaml (module list is in templates/module-catalog.yaml). + values: | + image: {{ .Values.config.containerImages.moduleManager | quote }} + namespace: module-manager + argocd: + namespace: {{ .Values.config.namespacePrefix }}argocd + project: {{ .Values.config.namespacePrefix }}horizon-sdv + gitopsRootAppName: {{ .Values.config.namespacePrefix }}horizon-sdv + repo: + url: {{ .Values.spec.source.repoURL | quote }} + revision: {{ .Values.spec.source.targetRevision | quote }} + config: + domain: {{ .Values.config.domain }} + projectID: {{ .Values.config.projectID }} + region: {{ .Values.config.region }} + zone: {{ .Values.config.zone }} + backendBucket: {{ .Values.config.backendBucket }} + namespacePrefix: {{ .Values.config.namespacePrefix | quote }} + isSubEnvironment: {{ .Values.config.isSubEnvironment }} + environmentName: {{ .Values.config.environmentName | quote }} + enableNetworkPolicies: {{ .Values.config.enableNetworkPolicies }} + scm: + authMethod: {{ .Values.scm.authMethod | quote }} + workloads: + android: + url: {{ .Values.config.workloads.android.url | quote }} + branch: {{ .Values.config.workloads.android.branch | quote }} + path: gitops/apps/module-manager + destination: + server: https://kubernetes.default.svc + namespace: module-manager + syncPolicy: + syncOptions: + - CreateNamespace=true + automated: {} diff --git a/gitops/templates/monitoring-tools-init.yaml b/gitops/templates/monitoring-tools-init.yaml index 1520e02a..ae38ef2b 100644 --- a/gitops/templates/monitoring-tools-init.yaml +++ b/gitops/templates/monitoring-tools-init.yaml @@ -1,4 +1,4 @@ -# Copyright (c) 2024-2025 Accenture, All Rights Reserved. +# Copyright (c) 2024-2026 Accenture, All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/gitops/templates/mtk-connect-init.yaml b/gitops/templates/mtk-connect-init.yaml index 6efb4dfc..67311cb5 100644 --- a/gitops/templates/mtk-connect-init.yaml +++ b/gitops/templates/mtk-connect-init.yaml @@ -1,4 +1,4 @@ -# Copyright (c) 2024-2025 Accenture, All Rights Reserved. +# Copyright (c) 2024-2026 Accenture, All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/gitops/templates/mtk-connect-post.yaml b/gitops/templates/mtk-connect-post.yaml index bf3e0642..3252c59c 100644 --- a/gitops/templates/mtk-connect-post.yaml +++ b/gitops/templates/mtk-connect-post.yaml @@ -1,4 +1,4 @@ -# Copyright (c) 2024-2025 Accenture, All Rights Reserved. +# Copyright (c) 2024-2026 Accenture, All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -71,6 +71,7 @@ metadata: spec: backoffLimit: 0 ttlSecondsAfterFinished: 120 + activeDeadlineSeconds: 600 template: spec: serviceAccountName: mtk-connect-writer-sa diff --git a/gitops/templates/mtk-connect.yaml b/gitops/templates/mtk-connect.yaml index 2f3ca3a9..59434322 100644 --- a/gitops/templates/mtk-connect.yaml +++ b/gitops/templates/mtk-connect.yaml @@ -34,9 +34,9 @@ spec: targetRevision: {{ .Values.spec.source.targetRevision }} config: namespacePrefix: {{ .Values.config.namespacePrefix }} - applications: + chartVersions: mtkconnect: - version: {{ .Values.config.applications.mtkconnect.version }} + version: {{ .Values.config.chartVersions.mtkconnect.version }} path: gitops/apps/mtk-connect destination: server: https://kubernetes.default.svc diff --git a/gitops/templates/namespaces.yaml b/gitops/templates/namespaces.yaml index ae2bc566..61e40521 100644 --- a/gitops/templates/namespaces.yaml +++ b/gitops/templates/namespaces.yaml @@ -1,4 +1,4 @@ -# Copyright (c) 2024-2025 Accenture, All Rights Reserved. +# Copyright (c) 2024-2026 Accenture, All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -98,6 +98,109 @@ spec: --- apiVersion: v1 kind: Namespace +metadata: + name: {{ .Values.config.namespacePrefix }}horizon-api + annotations: + argocd.argoproj.io/sync-wave: "0" +--- +apiVersion: v1 +kind: Namespace +metadata: + name: {{ .Values.config.namespacePrefix }}horizon-dev-portal + annotations: + argocd.argoproj.io/sync-wave: "0" +--- +# Horizon Developer Portal — default deny +{{- if .Values.config.enableNetworkPolicies }} +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: default-deny-all + namespace: {{ .Values.config.namespacePrefix }}horizon-dev-portal + annotations: + argocd.argoproj.io/sync-wave: "0" +spec: + podSelector: {} + policyTypes: + - Ingress + - Egress +{{- end }} +--- +# Allow DNS for horizon-dev-portal namespace +{{- if .Values.config.enableNetworkPolicies }} +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: allow-dns + namespace: {{ .Values.config.namespacePrefix }}horizon-dev-portal + annotations: + argocd.argoproj.io/sync-wave: "0" +spec: + podSelector: {} + policyTypes: + - Egress + egress: + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + - podSelector: + matchLabels: + k8s-app: kube-dns + ports: + - protocol: UDP + port: 53 + - protocol: TCP + port: 53 +{{- end }} +--- +# Allow Horizon Developer Portal egress (OIDC/JWKS + Module Manager + Horizon API) +{{- if .Values.config.enableNetworkPolicies }} +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: allow-horizon-dev-portal-egress + namespace: {{ .Values.config.namespacePrefix }}horizon-dev-portal + annotations: + argocd.argoproj.io/sync-wave: "0" +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: horizon-dev-portal + policyTypes: + - Egress + egress: + - to: + - ipBlock: + cidr: 0.0.0.0/0 + except: + - 169.254.169.254/32 + ports: + - protocol: TCP + port: 443 + - protocol: TCP + port: 80 + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: module-manager + ports: + - protocol: TCP + port: 8082 + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: {{ .Values.config.namespacePrefix }}horizon-api + ports: + # Service port 80 maps to containerPort 8082; on GKE Dataplane V2 egress matches the pod destination port. + - protocol: TCP + port: 80 + - protocol: TCP + port: 8082 +{{- end }} +--- +apiVersion: v1 +kind: Namespace metadata: name: {{ .Values.config.namespacePrefix }}keycloak annotations: @@ -922,6 +1025,39 @@ spec: port: 80 # HTTP {{- end }} --- +# GKE Managed Service for Prometheus query frontend: Workload Identity uses the +# instance metadata service (169.254.169.254:80) to obtain tokens for +# monitoring.googleapis.com. allow-prometheus-egress-to-internet excludes +# 169.254.169.254/32 from 0.0.0.0/0, so that component cannot reach the GKE +# metadata proxy; queries then return 500. Allow both well-known link-local +# metadata addresses (see allow-workflows-to-metadata-server in argo-workflows). +{{- if .Values.config.enableNetworkPolicies }} +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: allow-prometheus-egress-to-gke-metadata + namespace: {{ .Values.config.namespacePrefix }}monitoring + annotations: + argocd.argoproj.io/sync-wave: "0" +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: prometheus + policyTypes: + - Egress + egress: + - to: + - ipBlock: + cidr: 169.254.169.254/32 + - ipBlock: + cidr: 169.254.169.252/32 + ports: + - protocol: TCP + port: 80 + - protocol: TCP + port: 988 +{{- end }} +--- # Allow Grafana internet egress (for OS updates) {{- if .Values.config.enableNetworkPolicies }} apiVersion: networking.k8s.io/v1 @@ -1029,6 +1165,136 @@ spec: port: 80 # HTTP {{- end }} --- +apiVersion: v1 +kind: Namespace +metadata: + name: {{ .Values.config.namespacePrefix }}argo-events + annotations: + argocd.argoproj.io/sync-wave: "0" +--- +# Default Deny All Traffic - Argo Events Namespace +{{- if .Values.config.enableNetworkPolicies }} +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: default-deny-all + namespace: {{ .Values.config.namespacePrefix }}argo-events + annotations: + argocd.argoproj.io/sync-wave: "0" +spec: + podSelector: {} + policyTypes: + - Ingress + - Egress +{{- end }} +--- +# Allow DNS for argo-events namespace +{{- if .Values.config.enableNetworkPolicies }} +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: allow-dns + namespace: {{ .Values.config.namespacePrefix }}argo-events + annotations: + argocd.argoproj.io/sync-wave: "0" +spec: + podSelector: {} + policyTypes: + - Egress + egress: + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + - podSelector: + matchLabels: + k8s-app: kube-dns + ports: + - protocol: UDP + port: 53 + - protocol: TCP + port: 53 +{{- end }} +--- +# Allow any pod in argo-events to reach EventBus (NATS) in same namespace (EventSource/Sensor pods) +{{- if .Values.config.enableNetworkPolicies }} +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: allow-argo-events-to-eventbus + namespace: {{ .Values.config.namespacePrefix }}argo-events + annotations: + argocd.argoproj.io/sync-wave: "0" +spec: + podSelector: {} + policyTypes: + - Egress + egress: + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: {{ .Values.config.namespacePrefix }}argo-events + ports: + - protocol: TCP + port: 4222 # NATS client + - protocol: TCP + port: 6222 # NATS cluster + - protocol: TCP + port: 8222 # NATS monitoring +{{- end }} +--- +# Allow pods in argo-events to receive NATS traffic from same namespace (EventBus accepts from EventSource/Sensor) +{{- if .Values.config.enableNetworkPolicies }} +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: allow-argo-events-ingress-to-eventbus + namespace: {{ .Values.config.namespacePrefix }}argo-events + annotations: + argocd.argoproj.io/sync-wave: "0" +spec: + podSelector: {} + policyTypes: + - Ingress + ingress: + - from: + - podSelector: {} + ports: + - protocol: TCP + port: 4222 # NATS client + - protocol: TCP + port: 6222 # NATS cluster + - protocol: TCP + port: 8222 # NATS monitoring +{{- end }} +--- +# Allow all pods in argo-events to reach Kubernetes API (10.x.x.x:443) and internet (80/443) +# Required for Sensor k8s triggers (create Pod via API) and controller-created pods without part-of label +{{- if .Values.config.enableNetworkPolicies }} +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: allow-argo-events-egress-to-internet + namespace: {{ .Values.config.namespacePrefix }}argo-events + annotations: + argocd.argoproj.io/sync-wave: "0" +spec: + podSelector: {} + policyTypes: + - Egress + egress: + - to: + - ipBlock: + cidr: 0.0.0.0/0 + except: + - 169.254.169.254/32 + ports: + - protocol: TCP + port: 443 # HTTPS + Kubernetes API + - protocol: TCP + port: 80 # HTTP +{{- end }} +--- # Default Deny All Traffic - ArgoCD Namespace {{- if .Values.config.enableNetworkPolicies }} apiVersion: networking.k8s.io/v1 @@ -1100,3 +1366,198 @@ spec: - protocol: TCP port: 80 # HTTP {{- end }} +--- +apiVersion: v1 +kind: Namespace +metadata: + name: gcp + annotations: + argocd.argoproj.io/sync-wave: "0" + cnrm.cloud.google.com/project-id: {{ .Values.config.projectID }} +--- +apiVersion: v1 +kind: Namespace +metadata: + name: {{ .Values.config.namespacePrefix }}argo-workflows + annotations: + argocd.argoproj.io/sync-wave: "0" +--- +# Default Deny All Traffic - Argo Workflows Namespace +{{- if .Values.config.enableNetworkPolicies }} +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: default-deny-all + namespace: {{ .Values.config.namespacePrefix }}argo-workflows + annotations: + argocd.argoproj.io/sync-wave: "0" +spec: + podSelector: {} + policyTypes: + - Ingress + - Egress +{{- end }} +--- +# Allow DNS for argo-workflows namespace +{{- if .Values.config.enableNetworkPolicies }} +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: allow-dns + namespace: {{ .Values.config.namespacePrefix }}argo-workflows + annotations: + argocd.argoproj.io/sync-wave: "0" +spec: + podSelector: {} + policyTypes: + - Egress + egress: + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + - podSelector: + matchLabels: + k8s-app: kube-dns + ports: + - protocol: UDP + port: 53 + - protocol: TCP + port: 53 +{{- end }} +--- +# Allow Argo Workflows Server internet egress +{{- if .Values.config.enableNetworkPolicies }} +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: allow-argo-workflows-server-egress-to-internet + namespace: {{ .Values.config.namespacePrefix }}argo-workflows + annotations: + argocd.argoproj.io/sync-wave: "0" +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: argo-workflows-server + policyTypes: + - Egress + egress: + - to: + - ipBlock: + cidr: 0.0.0.0/0 + except: + - 169.254.169.254/32 + ports: + - protocol: TCP + port: 443 # HTTPS + - protocol: TCP + port: 80 # HTTP +{{- end }} +--- +# Allow Workflow Controller internet egress +{{- if .Values.config.enableNetworkPolicies }} +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: allow-workflow-controller-egress-to-internet + namespace: {{ .Values.config.namespacePrefix }}argo-workflows + annotations: + argocd.argoproj.io/sync-wave: "0" +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: argo-workflows-workflow-controller + policyTypes: + - Egress + egress: + - to: + - ipBlock: + cidr: 0.0.0.0/0 + except: + - 169.254.169.254/32 + ports: + - protocol: TCP + port: 443 # HTTPS + - protocol: TCP + port: 80 # HTTP +{{- end }} +--- +apiVersion: v1 +kind: Namespace +metadata: + name: {{ .Values.config.namespacePrefix }}workflows + annotations: + argocd.argoproj.io/sync-wave: "0" +--- +# Default Deny All Traffic - Workflows Namespace +{{- if .Values.config.enableNetworkPolicies }} +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: default-deny-all + namespace: {{ .Values.config.namespacePrefix }}workflows + annotations: + argocd.argoproj.io/sync-wave: "0" +spec: + podSelector: {} + policyTypes: + - Ingress + - Egress +{{- end }} +--- +# Allow DNS for workflows namespace +{{- if .Values.config.enableNetworkPolicies }} +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: allow-dns + namespace: {{ .Values.config.namespacePrefix }}workflows + annotations: + argocd.argoproj.io/sync-wave: "0" +spec: + podSelector: {} + policyTypes: + - Egress + egress: + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + - podSelector: + matchLabels: + k8s-app: kube-dns + ports: + - protocol: UDP + port: 53 + - protocol: TCP + port: 53 +{{- end }} +--- +# Allow Workflow pods internet egress +{{- if .Values.config.enableNetworkPolicies }} +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: allow-workflow-pods-egress-to-internet + namespace: {{ .Values.config.namespacePrefix }}workflows + annotations: + argocd.argoproj.io/sync-wave: "0" +spec: + podSelector: + matchExpressions: + - key: workflows.argoproj.io/workflow + operator: Exists + policyTypes: + - Egress + egress: + - to: + - ipBlock: + cidr: 0.0.0.0/0 + except: + - 169.254.169.254/32 + ports: + - protocol: TCP + port: 443 # HTTPS + - protocol: TCP + port: 80 # HTTP +{{- end }} diff --git a/gitops/templates/postgresql.yaml b/gitops/templates/postgresql.yaml index f20fbcbf..5c23193c 100644 --- a/gitops/templates/postgresql.yaml +++ b/gitops/templates/postgresql.yaml @@ -1,4 +1,4 @@ -# Copyright (c) 2024-2025 Accenture, All Rights Reserved. +# Copyright (c) 2024-2026 Accenture, All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -25,7 +25,7 @@ spec: project: {{ .Values.config.namespacePrefix }}horizon-sdv source: repoURL: https://github.com/bitnami/charts - targetRevision: {{ .Values.config.applications.postgresql.version }} + targetRevision: {{ .Values.config.chartVersions.postgresql.version }} path: bitnami/postgresql helm: values: | diff --git a/gitops/templates/zookeeper-init.yaml b/gitops/templates/zookeeper-init.yaml index 3ecdca3c..2a24a3c5 100644 --- a/gitops/templates/zookeeper-init.yaml +++ b/gitops/templates/zookeeper-init.yaml @@ -1,4 +1,4 @@ -# Copyright (c) 2024-2025 Accenture, All Rights Reserved. +# Copyright (c) 2024-2026 Accenture, All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/gitops/templates/zookeeper.yaml b/gitops/templates/zookeeper.yaml index 5d2a31bb..50036e27 100644 --- a/gitops/templates/zookeeper.yaml +++ b/gitops/templates/zookeeper.yaml @@ -1,4 +1,4 @@ -# Copyright (c) 2024-2025 Accenture, All Rights Reserved. +# Copyright (c) 2024-2026 Accenture, All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -25,7 +25,7 @@ spec: project: {{ .Values.config.namespacePrefix }}horizon-sdv source: repoURL: https://github.com/bitnami/charts - targetRevision: {{ .Values.config.applications.zookeeper.version }} + targetRevision: {{ .Values.config.chartVersions.zookeeper.version }} path: bitnami/zookeeper helm: values: | diff --git a/gitops/values.yaml b/gitops/values.yaml index 42201b15..9e22078d 100644 --- a/gitops/values.yaml +++ b/gitops/values.yaml @@ -1,4 +1,4 @@ -# Copyright (c) 2024-2025 Accenture, All Rights Reserved. +# Copyright (c) 2024-2026 Accenture, All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -15,7 +15,12 @@ config: name: Horizon SDV enableNetworkPolicies: true - applications: + + # config.containerImages: set only by the root Argo CD Application (Terraform). Do not add empty keys + # here — they merge over environment-specific image refs and yield invalid Deployments (empty image:). + + # Helm chart version pins for Argo CD Applications (upstream chart versions / revisions). + chartVersions: keycloak: version: "7.1.4" postgresql: @@ -26,10 +31,52 @@ config: version: "1.29.11" mtkconnect: version: "v1.10.0" + argoEvents: + version: "2.3.2" + # NATS StatefulSet size for the default EventBus. Use 1 on small/dev clusters (3 needs quorum + # and enough nodes); if NATS is not Ready, the webhook EventSource often hangs on POST (502 from Horizon API). + eventBusNATSReplicas: 1 + argo_workflows: + version: "0.47.4" + + # Developer Portal (SPA + proxy): Gateway path, Keycloak, in-cluster URL overrides — not a chart version. + horizonDevelopmentPortal: + # HTTP path prefix (Gateway HTTPRoute, Keycloak redirect URIs, portal ConfigMap PUBLIC_PATH). + publicPath: "/developer-portal" + # Must match Keycloak deployment (realm / http path). + keycloakRealm: "horizon" + keycloakHttpPathPrefix: "/auth" + # OAuth public client id (Keycloak client + config.js). + keycloakClientId: "horizon-dev-portal" + # Confidential client for the Go proxy (token exchange to Horizon API); must exist in Keycloak. + horizonApiCiClientId: "horizon-api-ci" + # Optional in-cluster service URLs (empty = defaults from namespace prefix + standard Service names). + horizonApiInternalBaseUrl: "" + moduleManagerInternalBaseUrl: "" + + # Config Connector configuration (templates/config-connector.yaml). + configConnector: + # Namespaced Config Connector: one ConfigConnectorContext per namespace. + # Must include every namespace where CNRM resources are deployed (e.g. PubSubTopic in module hello-world charts). + namespaces: + - gcp + - sample-module-hello + - sample-hard-module-hello + - sample-soft-module-hello + + # Centralized KCC webhook cert monitor: + # - thresholdSeconds: restart cnrm-webhook-manager when cert expires within this window. + # - cooldownSeconds: minimum interval between automatic restarts. + webhookMonitor: + thresholdSeconds: 2592000 # 30 days + cooldownSeconds: 86400 # 24 hours workloads: android: helm: - version: '5.8.114' + version: '5.9.18' + # Set by Terraform on live clusters (MODULE_CONFIG for Module Manager module workloads-android). + url: "" + branch: "" spec: destination: server: https://kubernetes.default.svc diff --git a/gitops/workloads/android/templates/dynpvc-sc.yaml b/gitops/workloads/android/templates/dynpvc-sc.yaml index cb23c5a3..9a6e7d8e 100644 --- a/gitops/workloads/android/templates/dynpvc-sc.yaml +++ b/gitops/workloads/android/templates/dynpvc-sc.yaml @@ -13,10 +13,12 @@ # limitations under the License. # # Description: -# Storage classes used for Jenkins build caches. +# Storage classes used for Jenkins build caches and Argo workflow volumes. # Prefix reclaimable-storage-class is referenced in System Variables so # jobs may choose the correct storage class based on Android version and # target. +# pipeline-workspace-delete: single Delete reclaim class for aaos-builder shared monorepo PVC (any Android +# lunch/revision); disk removed when PVC is deleted. aaos-cache keeps per-version reclaimable Retain classes. kind: StorageClass apiVersion: storage.k8s.io/v1 @@ -34,6 +36,19 @@ parameters: --- kind: StorageClass apiVersion: storage.k8s.io/v1 +metadata: + name: {{ .Values.config.namespacePrefix }}pipeline-workspace-delete + annotations: + argocd.argoproj.io/sync-wave: "3" +provisioner: pd.csi.storage.gke.io +reclaimPolicy: Delete +volumeBindingMode: Immediate +parameters: + type: pd-balanced + csi.storage.k8s.io/fstype: ext4 +--- +kind: StorageClass +apiVersion: storage.k8s.io/v1 metadata: name: {{ .Values.config.namespacePrefix }}reclaimable-storage-class-android-14-rpi annotations: diff --git a/gitops/workloads/android/values.yaml b/gitops/workloads/android/values.yaml index 7bdc43cd..d1101d5a 100644 --- a/gitops/workloads/android/values.yaml +++ b/gitops/workloads/android/values.yaml @@ -1,4 +1,4 @@ -# Copyright (c) 2024-2025 Accenture, All Rights Reserved. +# Copyright (c) 2024-2026 Accenture, All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -spec: - destination: - server: https://kubernetes.default.svc - +# Defaults for `helm template` / CI; Argo CD Application workloads-android overrides via helm.values. +# Workflow charts are additional sources on the workloads-android Application (gitops/modules/workloads-android). +config: + namespacePrefix: "" diff --git a/gitops/workloads/values-jenkins.yaml b/gitops/workloads/values-jenkins.yaml index bb156a65..47d04505 100644 --- a/gitops/workloads/values-jenkins.yaml +++ b/gitops/workloads/values-jenkins.yaml @@ -16,7 +16,7 @@ config: workloads: android: cuttlefish: - tag: "v1350" + tag: "v1410" mirror: workloadEnv: image: "horizon-sdv/mirror-workloads/env" @@ -64,111 +64,113 @@ controller: - apache-httpcomponents-client-4-api:4.5.14-269.vfa_2321039a_83 - asm-api:9.9.1-189.vb_5ef2964da_91 - authentication-tokens:1.144.v5ff4a_5ec5c33 - - bootstrap5-api:5.3.8-895.v4d0d8e47fea_d - - bouncycastle-api:2.30.1.82-277.v70ca_0b_877184 - - branch-api:2.1268.v044a_87612da_8 + - bootstrap5-api:5.3.8-1038.vee76a_fe825ff + - bouncycastle-api:2.30.1.84-291.v9f17b_21896e2 + - branch-api:2.1280.v0d4e5b_b_460ef - caffeine-api:3.2.3-194.v31a_b_f7a_b_5a_81 - - checks-api:373.vfe7645102093 - - cloudbees-folder:6.1073.va_7888eb_dd514 - - command-launcher:123.v37cfdc92ef67 + - checks-api:402.vca_263b_f200e3 + - cloudbees-folder:6.1100.ve9eed61d16c4 + - command-launcher:134.v025a_5fcf9dea_ - commons-lang3-api:3.20.0-109.ve43756e2d2b_4 - - commons-text-api:1.15.0-210.v7480a_da_70b_9e - - configuration-as-code:2006.v001a_2ca_6b_574 - - credentials-binding:702.vfe613e537e88 - - credentials:1453.v9b_a_29777a_b_fd + - commons-text-api:1.15.0-218.va_61573470393 + - configuration-as-code:2077.v41f1011a_5110 + - credentials:1502.v5c95e620ddfe + - credentials-binding:720.v3f6decef43ea_ - display-url-api:2.217.va_6b_de84cc74b_ - - durable-task:639.vefb_3d8372f6d - - echarts-api:6.0.0-1165.vd1283a_3e37d4 - - eddsa-api:0.3.0.1-19.vc432d923e5ee - - font-awesome-api:7.1.0-882.v1dfb_771e3278 - - git-client:6.4.3 - - git:5.8.1 + - durable-task:664.v2b_e7a_dfff66c + - echarts-api:6.0.0-1287.vfd24c22a_3d00 + - eddsa-api:0.3.0.1-29.v67e9a_1c969b_b_ + - font-awesome-api:7.2.0-990.vf220b_2a_496f9 + - git-client:6.6.0 + - git:5.10.1 - github-api:1.330-492.v3941a_032db_2a_ - - github:1.45.0 - - google-oauth-plugin:1.335.ve6de40e2db_18 - - gson-api:2.13.2-173.va_a_092315913c + - github:1.46.0.1 + - google-oauth-plugin:1.346.v712b_a_e0d1e60 + - gson-api:2.14.0-201.v8eefe5515533 - instance-identity:203.v15e81a_1b_7a_38 - ionicons-api:94.vcc3065403257 - - jackson2-api:2.20.1-423.v13951f6b_6532 + - jackson2-api:2.21.2-436.v29efdb_7418ff - jakarta-activation-api:2.1.4-1 - jakarta-mail-api:2.1.5-1 - - jakarta-xml-bind-api:4.0.6-10.v9b_7e1d1fc40b_ + - jakarta-xml-bind-api:4.0.6-12.vb_1833c1231d3 - javax-activation-api:1.2.0-8 - javax-mail-api:1.6.2-11 - - jaxb:2.3.9-133.vb_ec76a_73f706 + - jaxb:2.3.9-143.v5979df3304e6 - jdk-tool:83.v417146707a_3d - - jjwt-api:0.11.5-120.v0268cf544b_89 - - joda-time-api:2.14.0-149.v1c3ce991d1b_9 - - jquery3-api:3.7.1-619.vdb_10e002501a_ + - jjwt-api:0.13.0-141.vd58b_a_9592b_6c + - joda-time-api:2.14.2-193.v422b_efce56e0 + - jquery3-api:3.7.1-687.v68d468e40b_30 - jsch:0.2.16-95.v3eecb_55fa_b_78 - - json-api:20250517-173.v596efb_962a_31 - - json-path-api:2.10.0-202.va_9cc16c1e476 - - junit:1369.v15da_00283f06 + - json-api:20251224-185.v0cc18490c62c + - json-path-api:3.0.0-218.vcd4dd1355de2 + - junit:1403.vd9d1413fd205 - kubernetes-client-api:7.3.1-256.v788a_0b_787114 - - kubernetes-credentials:206.vde31a_b_0f71a_c - - mailer:525.v2458b_d8a_1a_71 + - kubernetes-credentials:207.v492f58828b_ed + - mailer:534.v1b_36f5864073 - matrix-project:870.v9db_fcfc2f45b_ - - metrics:4.2.37-489.vb_6db_69b_ce753 + - metrics:4.2.37-494.v06f9a_939d33a_ - mina-sshd-api-common:2.16.0-167.va_269f38cc024 - mina-sshd-api-core:2.16.0-167.va_269f38cc024 - - oauth-credentials:0.657.v7d8dd90b_0382 - - okhttp-api:4.12.0-195.vc02552c04ffd - - pipeline-build-step:571.v08a_fffd4b_0ce - - pipeline-graph-analysis:245.v88f03631a_b_21 - - pipeline-groovy-lib:787.ve2fef0efdca_6 - - pipeline-input-step:540.v14b_100d754dd - - pipeline-milestone-step:138.v78ca_76831a_43 + - oauth-credentials:0.660.vce7c108f1f3a_ + - okhttp-api:5.3.2-200.vedb_720a_cf1f8 + - pipeline-build-step:584.vdb_a_2cc3a_d07a_ + - pipeline-graph-analysis:254.v0f63a_a_447dca_ + - pipeline-groovy-lib:797.v90ea_a_9b_e45a_0 + - pipeline-input-step:551.vdff487c5998c + - pipeline-milestone-step:152.v6e22b_8cfc66c - pipeline-model-api:2.2277.v00573e73ddf1 - pipeline-model-definition:2.2277.v00573e73ddf1 - pipeline-model-extensions:2.2277.v00573e73ddf1 - - pipeline-rest-api:2.38 - - pipeline-stage-step:322.vecffa_99f371c + - pipeline-rest-api:2.41 + - pipeline-stage-step:345.va_96187909426 - pipeline-stage-tags-metadata:2.2277.v00573e73ddf1 - plain-credentials:199.v9f8e1f741799 - - plugin-util-api:6.1192.v30fe6e2837ff - - prism-api:1.30.0-630.va_e19d17f83b_0 - - scm-api:724.v7d839074eb_5c - - script-security:1385.v7d2d9ec4d909 - - snakeyaml-api:2.5-143.v93b_c004f89de - - ssh-credentials:361.vb_f6760818e8c - - sshd:3.374.v19b_d59ce6610 + - plugin-util-api:7.1341.v039f146993d9 + - prism-api:1.30.0-727.vc475481f034d + - scm-api:728.vc30dcf7a_0df5 + - script-security:1402.v94c9ce464861 + - snakeyaml-api:2.5-149.v72471e9c6371 + - ssh-credentials:372.va_250881b_08cd + - sshd:3.384.vc89b_5e138cf9 - structs:362.va_b_695ef4fdf9 - token-macro:477.vd4f0dc3cb_cf1 - trilead-api:2.284.v1974ea_324382 - variant:70.va_d9f17f859e0 - workflow-aggregator:608.v67378e9d3db_1 - - workflow-api:1384.vdc05a_48f535f + - workflow-api:1413.v2ff1a_5e720fa_ - workflow-basic-steps:1098.v808b_fd7f8cf4 - - workflow-cps:4238.va_6fb_65c1f699 - - workflow-durable-task-step:1464.v2d3f5c68f84c - - workflow-job:1559.va_a_533730b_ea_d + - workflow-cps:4315.va_e456c4e7f4f + - workflow-durable-task-step:1475.ved562f6ec8b_3 + - workflow-job:1571.1580.v18e46842c125 - workflow-multibranch:821.vc3b_4ea_780798 - workflow-scm-step:466.va_d69e602552b_ - - workflow-step-api:710.v3e456cc85233 - - workflow-support:1010.vb_b_39488a_9841 + - workflow-step-api:724.v538c2362b_dfb_ + - workflow-support:1015.v785e5a_b_b_8b_22 additionalPlugins: - - ansicolor:1.0.6 + - ansicolor:536.v13fa_b_860c267 - authorize-project:2.0.0 - - build-blocker-plugin:166.vc82fc20b_a_ed6 - - build-user-vars-plugin:212.vd6b_e9f6d0cdb_ + - build-blocker-plugin:175.vc57a_d7dff5b_4 + - build-user-vars-plugin:214.va_eed2ed849ca_ - configuration-as-code-groovy:1.1 - - envinject-api:1.236.v35fd4d7eb_515 - - envinject:2.926.v69c9b_3896a_96 + - copyartifact:795.ve8e151429b_27 + - envinject-api:1.241.vdd714803b_403 + - envinject:2.941.v351a_20c0a_3ca_ + - file-parameters:425.v3fa_801681b_5e - gerrit-code-review:0.5.0 - - gerrit-trigger:3.1969.v65d614ec771a_ - - github-branch-source:1917.v9ee8a_39b_3d0d - - google-compute-engine:4.683.v0ce26579a_ee7 - - google-kubernetes-engine:0.443.vcf71f5903ef3 - - htmlpublisher:427 - - job-dsl:1.93 + - gerrit-trigger:3.1971.v217d381e3a_5a_ + - github-branch-source:1967.1969.v205fd594c821 + - google-compute-engine:4.782.v56d37cb_c391c + - google-kubernetes-engine:0.456.vb_ea_ee7d94048 + - htmlpublisher:427.1 + - job-dsl:3654.vdf58f53e2d15 - keycloak:2.3.2 - - kubernetes-credentials-provider:1.299.v610fa_e76761a_ - - kubernetes:4398.vb_b_33d9e7fe23 - - mask-passwords:212.v4967a_a_73b_506 - - parameter-separator:296.v9b_a_90c81160d - - pipeline-stage-view:2.38 + - kubernetes-credentials-provider:1.303.vdfcf47fb_b_fef + - kubernetes:4423.vb_59f230b_ce53 + - mask-passwords:220.v95819055b_265 + - parameter-separator:334.v1fc0e534c71d + - pipeline-stage-view:2.41 - role-strategy:848.va_a_ea_673cf0b_c - - ssh-slaves:3.1085.vc64d040efa_85 + - ssh-slaves:3.1097.v868116049892 - startup-trigger-plugin:2.9.4 - timestamper:1.30 javaOpts: "-Dcom.cloudbees.workflow.rest.external.JobExt.maxRunsPerJob=50" @@ -217,73 +219,93 @@ controller: - "jenkins.diagnostics.ControllerExecutorsAgents" # Disable: Role-Based Naming Strategy not enabled - "org.jenkinsci.plugins.rolestrategy.NamingStrategyAdministrativeMonitor" + # Disable: CSP Notifications + - "jenkins.security.csp.impl.CspRecommendation" authorizationStrategy: roleBased: permissionTemplates: - - name: "horizon-jenkins-workload-developers" + - name: "administrators" permissions: - "Job/Read" - "Job/Discover" - "Job/Build" - - "View/Read" - "Job/Configure" - "Job/Delete" - "Job/Create" - "Job/Move" - "Job/Workspace" - "Job/Cancel" - - name: "horizon-jenkins-workload-users" + - "Run/Replay" + - "Run/Delete" + - "Run/Update" + - "Run/Artifacts" + - "View/Read" + - "View/Configure" + - "View/Delete" + - "View/Create" + - name: "developers" permissions: - "Job/Read" - "Job/Discover" - "Job/Build" - "View/Read" + - "Job/Configure" + - "Job/Delete" + - "Job/Create" + - "Job/Move" + - "Job/Workspace" - "Job/Cancel" + - "Run/Replay" + - name: "viewers" + permissions: + - "Job/Read" + - "Job/Discover" + - "View/Read" roles: global: - - name: "horizon-jenkins-administrators" - description: "Jenkins administration role for unrestricted access." + - name: "administrators" + description: "Full Jenkins administration (Overall/Administer)." permissions: - "Overall/Administer" entries: - - group: "horizon-jenkins-administrators" + - group: "administrators" # Example user # - user: "john.doe@example.com" - - name: "horizon-jenkins-workloads-developers" - description: "Jenkins workloads developers role for workload usage and configuration." + - name: "developers" + description: "Developer access: run builds on agents, create jobs; workload permissions via item role." permissions: - "Overall/Read" - "Agent/Build" - "Job/Create" entries: - - group: "horizon-jenkins-workloads-developers" + - group: "developers" # Example user - # - user: "john.doe@example.com - - name: "horizon-jenkins-workloads-users" - description: "Jenkins workloads users role with restricted access to workloads." + # - user: "john.doe@example.com" + - name: "viewers" + description: "Read-only UI access; job visibility via item role." permissions: - "Overall/Read" entries: - - group: "horizon-jenkins-workloads-users" + - group: "viewers" # Example user - # - user: "john.doe@example.com + # - user: "john.doe@example.com" items: - - name: "workloads-developers" - description: "Workloads developers item role with unrestricted access." + - name: "developers" + description: "Developer permissions on all jobs (build, configure, workspace, cancel, replay)." pattern: ".*" - templateName: "horizon-jenkins-workload-developers" + templateName: "developers" entries: - - group: "horizon-jenkins-workloads-developers" + - group: "developers" # Example user - # - user: "john.doe@example.com - - name: "workloads-users" - description: "Workloads user item role with restricted access." + # - user: "john.doe@example.com" + - name: "viewers" + description: "View-only access to jobs and views." pattern: ".*" - templateName: "horizon-jenkins-workload-users" + templateName: "viewers" entries: - - group: "horizon-jenkins-workloads-users" + - group: "viewers" # Example user - # - user: "john.doe@example.com + # - user: "john.doe@example.com" clouds: - computeEngine: cloudName: cuttlefish-vm-main @@ -434,10 +456,10 @@ controller: value: "jenkins-gerrit-http-password" - key: "HORIZON_DOMAIN" value: {{ .Values.config.domain }} - - key: "HORIZON_GIT_URL" - value: {{ .Values.config.workloads.android.url }} - - key: "HORIZON_GIT_BRANCH" - value: {{ .Values.config.workloads.android.branch }} + - key: "HORIZON_SCM_URL" + value: {{ .Values.scm.repoUrl }} + - key: "HORIZON_SCM_BRANCH" + value: {{ .Values.scm.branch }} - key: "ANDROID_BUILD_BUCKET_ROOT_NAME" value: {{ .Values.config.projectID }}-aaos - key: "ANDROID_BUILD_DOCKER_ARTIFACT_PATH_NAME" @@ -454,6 +476,10 @@ controller: value: {{ .Values.config.projectID }}-openbsw - key: "OPENBSW_BUILD_DOCKER_ARTIFACT_PATH_NAME" value: "horizon-sdv/openbsw-builder" + - key: "SAMPLE_BUILD_DOCKER_ARTIFACT_PATH_NAME" + value: "horizon-sdv/sample-horizon-api" + - key: "SAMPLE_IMAGE_TAG" + value: "latest" - key: "INFRA_DOCKER_ARTIFACT_PATH_NAME" value: "horizon-sdv/abfs-infra" - key: "ABFS_BUILD_DOCKER_ARTIFACT_PATH_NAME" @@ -528,10 +554,12 @@ controller: scm { git { remote { - url('{{ .Values.config.workloads.android.url }}') - credentials('jenkins-git-creds') + url('{{ .Values.scm.repoUrl }}') + {{- if ne .Values.scm.authMethod "none" }} + credentials('jenkins-scm-creds') + {{- end }} } - branch('*/{{ .Values.config.workloads.android.branch }}') + branch('*/{{ .Values.scm.branch }}') } } scriptPath('workloads/seed/Jenkinsfile') @@ -559,9 +587,12 @@ controller: verdictValue: "Code-Review" - verdictDescription: "Verified" verdictValue: "Verified" + - verdictDescription: "Ready for Build" + verdictValue: "Ready-for-Build" gerritAuthKeyFile: "/run/secrets/additional/jenkins-gerrit-ssh-private-key-privateKey" gerritFrontEndUrl: "https://{{ .Values.config.domain }}/gerrit/" gerritHostName: "gerrit-service.{{ .Values.config.namespacePrefix }}gerrit.svc.cluster.local" + gerritSshPort: 29418 gerritUserName: "gerrit-admin" useRestApi: false timestamper: @@ -570,18 +601,21 @@ controller: elapsedTimeFormat: "''HH:mm:ss.S' '" globalLibraries: libraries: - - defaultVersion: "{{ .Values.config.workloads.android.branch }}" + - defaultVersion: "{{ .Values.scm.branch }}" name: "cvd-pipeline-shared-library" retriever: modernSCM: libraryPath: "workloads/common/jenkins/shared-libraries/cvd-pipeline-shared-library" scm: + {{- if eq .Values.scm.type "github" }} github: configuredByUrl: true - credentialsId: "jenkins-git-creds" - repoOwner: "{{ .Values.git.repoOwner }}" - repository: "{{ .Values.git.repoName }}" - repositoryUrl: "{{ .Values.config.workloads.android.url }}" + {{- if ne .Values.scm.authMethod "none" }} + credentialsId: "jenkins-scm-creds" + {{- end }} + repositoryUrl: "{{ .Values.scm.repoUrl }}" + repoOwner: "{{ .Values.scm.repoOwner }}" + repository: "{{ .Values.scm.repoName }}" traits: - gitHubBranchDiscovery: strategyId: 3 @@ -590,3 +624,12 @@ controller: - gitHubForkDiscovery: strategyId: 2 trust: "gitHubTrustPermissions" + {{- else }} + git: + {{- if ne .Values.scm.authMethod "none" }} + credentialsId: "jenkins-scm-creds" + {{- end }} + remote: "{{ .Values.scm.repoUrl }}" + traits: + - gitBranchDiscovery + {{- end }} diff --git a/release-notes.md b/release-notes.md index b3b1c6e3..caf7163f 100644 --- a/release-notes.md +++ b/release-notes.md @@ -1,735 +1,6736 @@ # Horizon SDV Release Notes -## Horizon SDV - Release 3.1.0 (2026-03-16) +

Release Notes document is the public document which provides a brief information for the new features, improvements and bug fixes included in a Horizon SDV delivery.

+

The file ‘release-notes.md’ is stored in https://github.com/GoogleCloudPlatform/horizon-sdv/blob/main/release-notes.md directory.

+

Additional extended release notes for a particular release can be stored in /doc/extended-release-notes/ folder.

+

https://github.com/GoogleCloudPlatform/horizon-sdv/blob/main/docs/extended-release-notes/release-notes-2-0-0.md

+
+ + + + + + + + + + + + + + + + + + + + +

Platform

+

Horizon SDV

+

Version

+

Release 4.0.0

+

Date

+

14.04.2026

+
+ +

Summary

+

Horizon SDV 4.0.0 is the new Horizon major release which extends platform capabilities with support for Agentic AI features and experimental implementation for new Horizon Modular Architecture designed based on ARGO application suite (Events/Workflows/CD).

+

Horizon SDV solution in Rel.4.0.0 enables coexistence of know Horizon architecture with the new modular architecture which introduces Module Manager to manage Horizon entities defined as Modules. Google KCC solution is used to spin up dynamic GCP resources required for Horizon. This release introduce limited PoC/MVP implementation with open Horizon API and CLI with support for the first Android Build Module. New Developer Portal Application can be used to manage Horizon services in the new architecture.

+

Horizon Rel.4.0.0 also delivers also several feature improvements and critical bug fixes.

+

Horizon SDV 3.1.0 package offers fully verified and documented upgrade patch (from Rel.3.0.0 to Rel.3.1.0). (see details in /docs/guides/upgrade_guide_3_0_0_to_3_1_0.md)

+ +

New Features

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

ID

+

Feature

+

Description

+

TAA-1272

+

+

Agentic AI for Build Error Resolution

+

Gemini AI–assisted review option is available to analyse failures in Android builds, CTS results and OpenBSW builds. It can be enabled in each of these build/test jobs via the Jenkins parameter ENABLE_GEMINI_AI_ASSISTANT (with related Gemini parameters where applicable), where it uses the provided prompts and skills. It can also be performed in a standalone job (Utilities → Gemini AI Assistant) which supports AI review of downloaded artifacts using custom prompts and skills. Gemini-assisted diagnosis is experimental; behavior, quality, and availability can change without notice.

+

Agent skills model (task vs instruction) is utilised where

+ +
    +
  • Prompt = the task for this run: a short, one-line invocation (what to do now).

    +
  • + +
  • Skill = the instruction set: role, procedure, rules, and expected output format.

    +
  • +
+

The skills to be used in each type of review (AAOS Build, OpenBSW Build, CTS test run) are defined in a skills.yaml file located in each pipeline's prompt/sequenced/ directory. Specialized skills (e.g. triage, root-cause, proposed fixes) are defined in skills.yamlso behavior stays consistent and review steps stay maintainable in Git.

+

Both single pass(one prompt) and sequenced analysis(two or three prompts in order) are supported.

+

Build Jobs enhancements

+

Android — AAOS Builder and ABFS Builder

+

These pipelines build Android Automotive / Cuttlefish / AVD-style targets from manifest-driven source (standard AAOS) or from the Android Build Filesystem (ABFS) stack when using ABFS Builder. Optional Gemini AI Review runs after a failed build when ENABLE_GEMINI_AI_ASSISTANT is enabled and AAOS_LUNCH_TARGET is set, using the shared three-step flow: triage, root-cause analysis, then suggested fixes. Prompts and skills.yaml live under workloads/android/pipelines/builds/aaos_builder/prompt/sequenced/; ABFS Builder reuses that same directory so behavior stays aligned even though ABFS uses different cache paths and may prepare build logs differently (for example preserving more of aaos-build.log for early failures). Configuration follows the other Android build jobs: GEMINI_COMMAND_LINE for the headless Gemini CLI (model and flags), and GEMINI_AI_EXECUTION_TIMEOUT on AAOS Builder to cap how long the assistant may run. Artifact upload uses the usual storage parameters (AAOS_ARTIFACT_STORAGE_SOLUTION, bucket overrides, labels).

+

OpenBSW — BSW Builder

+

BSW Builder builds Eclipse OpenBSW (POSIX and embedded targets, unit tests, optional coverage and platform builds as configured). It is not an Android job, but it follows the same overall AI Review pattern: when ENABLE_GEMINI_AI_ASSISTANT is true and the job status is FAILURE, AI Review stage runs gemini_initialise.sh then gemini_analysis.sh (with a bounded runtime), driven by repo prompts under workloads/openbsw/pipelines/builds/bsw_builder/prompt/sequenced/step1_triage.txt, step2_rca.txt, step3_fixes.txt plus skills.yaml loaded like the Android flows. AI Review to be treated as assistive analysis on failures, not as a substitute for manual reading of compiler and test logs.

+ +
    +
  • AI analysis output: For each issue a file,gemini_proposed_fixes_<error_ID_or_unique_identifier>_<timestamp>.md, is created in the folder gemini-assist. Each such file includes a short root cause summary, location of file, a git style diff patch (where applicable), verification steps to ensure the suggested fixes work as expected and reference links if available.

    +
  • +
+

TAA-1270

+

+

Agentic AI for CTS Failure Triage & Root Cause Analysis

+

Test Jobs enhancements

+

CTS Execution focuses on Tradefed / CTS results plus Cuttlefish logs; CVD Launcher focuses on Cuttlefish / CVD runtime only (no Compatibility Test Suite).

+ +

CTS Execution

+

The CTS Execution pipeline runs Tradefed against Cuttlefish virtual devices and publishes Compatibility Test Suite artifacts (HTML/XML under the usual result paths). Optional Gemini AI Review runs in the shared cvdPipeline Diagnostics stage when ENABLE_GEMINI_AI_ASSISTANT is enabled, the overall result is a failure, Launch Virtual Devices did not fail its stage, and the job is not in list-only mode (CTS_TEST_LISTS_ONLY must be false—plan-discovery runs skip AI Review). The preset is 'cts'. Sequenced prompts under workloads/android/pipelines/tests/cts_execution/prompt/sequenced/ drive triage-cts, rca-cts, and fix-cts in skills.yaml. When Tradefed outputs exist, analysis emphasizes which tests failed and ties them to logs; when suite summaries are missing (for example devices never came up), triage follows a CVD-oriented path that still prioritizes guest kernel.log and related Cuttlefish material. Set GEMINI_ANALYSE_ON_SUCCESS=true if you want AI Review to run on successful builds too (Gemini must still be enabled; a failing Gemini step can still fail the job). Artifact collection includes CTS result trees plus shared Cuttlefish patterns (host cvd logs, cuttlefish_logs*.zip, Wi‑Fi logs, etc.).

+

CVD Launcher

+

The CVD Launcher job exercises Cuttlefish (launch, MTK Connect, keep-alive) without running CTS. Its AI Review uses preset: 'cvd' and the sequenced bundle under workloads/android/pipelines/tests/cvd_launcher/prompt/sequenced/triage-cvd, rca-cvd, fix-cvd — focused on boot, launch, and runtime issues (guest kernel.log, launcher.log, host orchestration), not Tradefed assertions. The same gates apply as above except there is no CTS list-only parameter. GEMINI_ANALYSE_ON_SUCCESS is supported so you can analyze logs even when the pipeline passed, which helps spot questionable device health that a green build might hide; optional Utilities → Gemini AI Assistant flows documented for these workloads use the CVD Launcher prompt set when the question is purely Cuttlefish behavior.

+ +
    +
  • AI analysis output: For each failure reported a file, gemini-assist/proposed_fix_{failure_type}_{timestamp}.md, is created in the folder gemini-assist. Each such file contains a short summary, evidence log (stack trace), technical root cause, proposed code remediation and reference links if available.

    +
  • +
+

Utilities: Gemini AI Assistant

+ +
    +
  • Utilities → Gemini AI Assistant is a Jenkins job for ad-hoc runs: upload one or more prompt files (single or multi-step analysis), provide a command to fetch artifacts (for example, copying Build or CTS test-results from GCS), and run gemini-cli with parameters.

    +
  • +
+

TAA-1179

+

+

Modularize Horizon into separately usable services [R4: MVP]

+
+
    +
  • Architecture proposal for modularization designed and agreed, covering e.g. deployment approach for a single component (Cloud Android Orchestration, ABFS) vs. deployment of full Horizon and usage of technologies on different levels of deployment.

    +
  • + +
  • Modular deployment of selected components possible by a simple change of configuration.

    +
  • + +
  • Components that are separately deployable are defined.

    +
  • + +
  • Components and parts that will always be required are defined.

    +
  • + +
  • Components abstracted (e.g. Build as Service, CTS as Service, Virtual Device as Service,…) and isolated with clear interfaces / handover points

    +
  • +
+

TAA-1584

+

+

Developer Portal [MVP]

+
+
    +
  • Developer Portal supports Keycloak authentication

    +
  • + +
  • Developer Portal discovers existing exposed Horizon API and show it to the user

    +
  • + +
  • Developer Portal controls Horizon API for running Workflow Templates

    +
  • + +
  • Developer Portal controls Module Manager for enabling or disabling modules (through Rest API and internal endpoint)

    +
  • + +
  • Developer Portal reads current module state from Module Manager

    +
  • + +
  • Developer Portal shows Workflow execution logs live

    +
  • +
+

TAA-1745

+

+

Enable support for non-GitHub SCM repository

+
+
    +
  • Added support for non-GitHub SCM repository like Gerrit or Gitlab

    +
  • + +
  • new template for terraform.tfvars

    +
  • + +
  • Extends the Horizon SDV platform to work with any Git-based source code management system — not just GitHub. Introduces a configurable SCM layer (scm_type, scm_auth_method) that supports three authentication modes:
    app — GitHub App (existing behavior, GitHub-only)
    userpass — username/password or token via HTTP basic auth (works with Gerrit, GitLab, Gitea, Bitbucket, or any Git server)
    none — public repositories with no authentication

    +
  • +
+

TAA-1744

+

+

Static IP Support

+
+
    +
  • Horizon enables possibility selecting zone between delegation with “NS” record and static IP assignments with “A” record

    +
  • +
+
+ +

Improved Features

+

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

ID

+

Feature

+

Description

+

TAA-1313

+

+

Update OpenBSW to support latest 'main'

+

OpenBSW has been updated to incorporate the latest features and tooling enhancements:

+ +
    +
  • Dockerized build environment: Docker container image updates for additional tools, packages and build support.

    +
  • + +
  • Build and test targets: Updated to latest support.

    +
  • +
+

Change Summary

+

OpenBSW Workflows → Environment → Docker Image Templateupdates:

+ +
    +
  • Tools and python updated.

    +
  • +
+

OpenBSW Workflows → Builds → BSW Builder updates:

+ +
    +
  • Added default parameters for RTOS, compiler, and build configuration. These values remain overridable via the command line

    +
  • + +
  • Build logs and details are uploaded to bucket storage for OpenBSW → Builds → BSW Builder for archival

    +
  • + +
  • Documentation builds updated.

    +
  • +
+

TAA-483

+

+

[Gerrit] Support TOPIC: build changes spanning multiple repositories

+

Gerrit Builds

+ +
    +
  • Gerrit now includes a Ready for Build label so users can decide when Jenkins builds should start.

    +
  • + +
  • To trigger a build, select REPLY → Ready for Build +1 → SEND.

    +
  • + +
  • The Jenkins Gerrit job triggers on a Ready for Build +1 vote. It waits 2 minutes before starting to allow users to change their mind (e.g., Ready for Build 0/-1).

    +
  • + +
  • The Jenkins job builds all commits in GERRIT_TOPIC; if no topic is set, it builds the single change by Change‑Id.

    +
  • + +
  • The job updates Gerrit with per‑target build status and posts the overall Verified vote.

    +
  • +
+

TAA-1269

+

+

[Cuttlefish] Adjust CVD/CTS options for improved costs

+
+

Cuttlefish Updates

+

Additional flexibility for creation of Cuttlefish instance templates to:

+ +
    +
  • let developers create Cuttlefish VM instance templates from their custom machine types, i.e. specific series, CPU and Memory configuration over the standard machine types.

    +
  • + +
  • provide additional options to support building Cuttlefish from other repositories as an alternative to standard Google repository

    +
  • +
+

Jobs updated

+ +
    +
  • Android Workflows → Environment → CF Instance Template

    + +
      +
    • Added support to define the Android Cuttlefish repository to use:
      ANDROID_CUTTLEFISH_REPO_URL

      + +
        +
      • If a private repo, then provide credentials by defining REPO_USERNAME and REPO_PASSWORD

        + +
          +
        • Note: the password is never exposed in Jenkins nor console. Added support for custom machine types:
          CUSTOM_VM_TYPE
          CUSTOM_CPU
          CUSTOM_MEMORY

          +
        • +
        +
      • +
      +
    • + +
    • Simply unset MACHINE_TYPE and define custom fields that match users requirements.

      +
    • + +
    • Default MACHINE_TYPE has changed to n2-standard-32 as a trade off for costs vs performance.

      +
    • + +
    • Miscellaneous:

      + +
        +
      • Curl Upgrade Support has been added to update the version on debian-12 based Cuttlefish instances, see:
        CURL_UPDATE_COMMAND

        + +
          +
        • x86_64:

          + +
            +
          • The default parameter will upgrade curl to 8.1x from debian backports.

            +
          • + +
          • Users may remove the parameter if they wish to remain on 7.88.1.

            +
          • +
          +
        • + +
        • ARM64:

          + +
            +
          • ARM instances currently, only support ubuntu 22.04 LTS version, so the parameter has no default defined.

            +
          • +
          +
        • +
        +
      • + +
      • New parameter to support working around android-cuttlefish.git build issues:
        ANDROID_CUTTLEFISH_POST_COMMAND

        + +
          +
        • Use git commands to switch to a specific sha1 when using branches (main), or cherry-pick workarounds/fixes to tagged versions which cannot be modified by google. e.g.

          + +
            +
          • git cherry-pick <sha1>

            +
          • + +
          • sed -i 's|https://git.kernel.org/pub/scm/linux/kernel/git/jaegeuk/f2fs-tools|https://github.com/jaegeuk/f2fs-tools|g' base/cvd/MODULE.bazel

            +
          • +
          +
        • +
        +
      • + +
      • Options to update CTS revisions and even offer capability to pull from GCS and not just official download site (i.e. add more control to versions because the download site has updated the same revision several times in the past, changing the tests):
        CTS_ANDROID_16_URL
        CTS_ANDROID_15_URL
        CTS_ANDROID_14_URL

        +
      • +
      +
    • + +
    • Android Workflows → Environment → CF Instance Template ARM64

      + +
        +
      • Same as CF Instance Template but ARM64 is currently only supported on one machine type. So use MACHINE_TYPE for now.

        +
      • +
      +
    • + +
    • Android Workflows → Tests → CTS Execution

      + +
        +
      • The resources have changed to align with the default Cuttlefish instance MACHINE_TYPE

        + +
          +
        • NUM_INSTANCES = 7, CPU=4, MEMORY = 8192

          +
        • + +
        • Adjust these to suit your test instance, these are simply set to align with the Horizon default Cuttlefish instance. For example, the ARM64 instance is a 96CPU instance and thus more resources are available.

          +
        • +
        +
      • + +
      • Failures are summarized in the test_result_failures_suite.html file with the respective CTS job and published to Jenkins.

        +
      • +
      +
    • +
    +
  • +
+

TAA-1473

+

+

CF - Support SSH key updates on existing instances

+

Added the UPDATE_SSH_AUTHORIZED_KEYS parameter to allow refreshing SSH keys without recreating the full instance. This reduces deployment time and minimizes costs associated with instance churn.

+

TAA-1474

+

+

CF reduce Jenkins noise to minimum

+

These can all be run independent of Jenkins, so reduce as much Jenkins noise in variable names.

+

TAA-1141

+

+

Keycloak Roles and Groups consistency improvement

+

Roles have been updated for Keycloak to use Client Roles rather than Groups. This change is applied to ArgoCD, Grafana, Headlamp and Jenkins.

+

There are two roles created in each client in Keycloak:

+ +
    +
  • administrators

    +
  • + +
  • viewers

    +
  • +
+

And there are two Groups created named administrators and viewers. Group administrators has role mapping to each client’s admin role. So if a user wants admin permissions then it can be directly assigned to group administrators. And same apply to viewers group and roles.

+ +
    +
  • Added client roles to be used for authentication and authorization

    +
  • + +
  • Each application must use roles in scope. No group in scope.

    +
  • + +
  • A new group called administrators created. If a user is part of this group then user should get admin permission to those applications.

    +
  • + +
  • Applications should show admin permission to users who are part of group administrators. If user us not part if group administrators then admin resources/permission should not be visible/allowed.

    +
  • + +
  • A new group called viewers created. If a user is part if this group then user should not have admin permission to resources.

    +
  • + +
  • If a user is not part of either of these two groups administrator and viewers then user should not have any permission to any resource.

    +
  • +
+

TAA-1479

+

+

Support for terraform -plan in Horizon deployment flow

+
+
    +
  • Enhanced the Horizon deployment flow by introducing additional command-line options in the deploy.sh script to improve deployment control and flexibility.

    +
  • + +
  • Added support for deployment operations using the following options:

    + +
      +
    • -p / --plan to preview infrastructure changes.

      +
    • + +
    • -a / --apply to provision or update infrastructure.

      +
    • + +
    • -d / --destroy to remove Terraform-managed infrastructure resources.

      +
    • +
    +
  • + +
  • Introduced -h / --help option to display usage instructions and available commands for the deployment script.

    +
  • + +
  • Improved script behavior by ensuring Terraform initialization is executed only when deployment operations (plan, apply, or destroy) are triggered.

    +
  • +
+

TAA-1682

+

+

Update Landing Page with MCP Server application

+
+
    +
  • Added a new app card in the "Applications" section linking to the MCP Gateway Registry subdomain (mcp.<domain>)

    +
  • + +
  • The Launch button dynamically resolves the URL using location.protocol + '//mcp.' + location.hostname, matching the gateway route pattern defined in gitops/templates/gateway-mcp-gateway-registry.yaml

    +
  • + +
  • Uses the official logo mcp-gateway-registry-logo.png asset for the card icon

    +
  • +
+

TAA-1644

+

+

[Jenkins] Replace CVD_ADDITIONAL_FLAGS with full CVD_COMMAND_LINE for CVD Launcher and CTS Execution

+
+
    +
  • Expose the full Cuttlefish launch command as a single Jenkins string parameter
    with a default /usr/bin/cvd create line using shell placeholders for
    NUM_INSTANCES, VM_CPUS, and VM_MEMORY_MB.

    +
  • + +
  • cvd_environment.sh applies the same default when empty; cvd_start_stop.sh runs the command after sudo HOME=..… Update cvdPipeline and Job DSL for CVD Launcher and CTS Execution; refresh cvd_launcher.md and cts_execution.md.

    +
  • + +
  • Expose the full Cuttlefish launch command as a single Jenkins string parameter with a default /usr/bin/cvd create line using shell placeholders for NUM_INSTANCES, VM_CPUS, and VM_MEMORY_MB. cvd_environment.sh applies the same default when empty; cvd_start_stop.sh runs the command after sudo HOME=....

    +
  • + +
  • Update cvdPipeline and Job DSL for CVD Launcher and CTS Execution; refresh cvd_launcher.md and cts_execution.md.

    +
  • +
+

TAA-1532

+

+

GCS metadata - support for wildcard characters in paths

+

Updated get_object_list function to allow wildcard characters be able to be used so that that artifacts can more easily be accessed & managed. This expands the functionality of all GCS Utility pipeline jobs. Markdown files updated to reflect the support for wildcards.

+

TAA-1689

+

+

[MTK Connect] testbench: configurable ADB tunnel caller.port for device interfaces

+
+
    +
  • Set tunnel.types[].caller.port from MTK_CONNECT_TUNNEL_PORT in create-testbench.js.

    +
  • + +
  • Propagate via mtk_connect.sh (.env) and cvdPipeline mtk_tunnel_port on mtk_connect invocations.

    +
  • + +
  • Add MTK_CONNECT_TUNNEL_PORT job parameter to CVD Launcher and CTS Execution Job DSL.

    +
  • + +
  • Document the parameter in docs/workloads/android/tests/cvd_launcher.md and cts_execution.md.

    +
  • +
+
+ +

Documentation update

+ +
    +
  • docs/workloads/android/guides/developer_guide.md split into a more navigable set of smaller and more focused per-topic guides which are grouped into setup and training areas.

    +
  • + +
  • /docs/workloads/guides/pipeline_guide.md document updated with information how Keycloak Group and Roles improvements impacts workflows management and executions.

    +
  • + +
  • Rel.4.0.0 provides with several updates in Horizon documentation including e.g. Horizon Deployment Guide (/docs/deployment_guide.md).

    +
  • + +
  • The new Upgrade Guide (/docs/guides/upgrade_guide_3_1_0_to_4_0_0.md) provide guideline for Rel.3.1.0 -> Rel.4.0.0 upgrade.

    +
  • +
+ +

Bug Fixes

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

ID

+

Bug

+

Description

+

SHA

+

TAA-1274

+

[Cuttlefish] CTS hangs - android-cuttlefish issues

+
+
    +
  • Move to 1.31.1 with cxx crate fix. Not as good as v1.28.0 but they created v1.28.1 without the patch! v1.28.1 == v1.28.0!

    +
  • +
+
+
    +
  • 6f385bf6b9cbc2f7e3c0b5790254cad6b59b2712

    +
  • +
+

TAA-1290

+

[Cuttlefish] ARM64 builds broken on f2fs-tools (missing)

+
+
    +
  • f2fs-tools fix for dead repo

    +
  • + +
  • CF show diffs made to build

    +
  • +
+
+
    +
  • 94913c903d5d9da2fc0c497580423a1a93bfedfe

    +
  • + +
  • e61b8d2c8d78685fcf4250aef622dee745026f67

    +
  • +
+

TAA-1317

+

[Jenkins] File Parameter Plugin fix

+
+
    +
  • File Parameter update

    +
  • +
+
+
    +
  • e26ab2953ae82a850214a8d1e664fe907220e7e0

    +
  • +
+

TAA-1506

+

[Jenkins] CF instances - missing SSH authorized_keys (transient)

+

TAA-1506: Harden SSH key setup in CF instance template: sync and verify

+ +
    +
  • Run sudo sync after writing authorized_keys so the instance disk is flushed before creating the template/disk image, avoiding missing .ssh/authorized_keys when the FS was not synced.

    +
  • + +
  • Verify the key was copied by reading authorized_keys from the instance and ensuring it contains the expected public key; fail fast with clear errors and ask the user to repeat the job if verification fails.

    +
  • + +
  • Skip deletion if simply a key update.

    +
  • +
+

https://github.com/AGBG-ASG/acn-horizon-sdv/pull/882

+
+
    +
  • 2594c4cb6d23bf581bedae74e8e4b89e8332c824

    +
  • +
+

TAA-1524

+

[Jenkins] GCS metadata not applied for wildcard artifact paths

+

TAA-1524: Fix: GCS metadata listing for artifacts with wildcard character in name

+ +
    +
  • Code refactored to consolidate multiple metadata requests (with differing formatting requirements) into a single object_get_data call.
    Metadata request now being done using the command gcloud storage object list instead of gcloud storage object describe

    +
  • +
+

https://github.com/AGBG-ASG/acn-horizon-sdv/pull/909

+ +
    +
  • Code refactored to consolidate multiple metadata requests (with differing formatting requirements) into a single object_get_data call.
    Metadata request now being done using the command gcloud storage object list instead of gcloud storage object describe

    +
  • +
+

https://github.com/AGBG-ASG/acn-horizon-sdv/pull/913

+
+
    +
  • 96a249959a788242e23afdd0453fcac53acc9095

    +
  • + +
  • 5004ce19f1463c9259add9e5aec97c475f1b6b4b

    +
  • +
+

TAA-1533

+

[ABFS] Build with GERRIT_TOPIC failing on initialise (HTTP/2)

+

TAA-1533: fix(aaos): harden Gerrit git fetch for ABFS only (HTTP/1.1, TLS 1.2, retry)
When ABFS_BUILDER is set (ABFS path), avoid HTTP/2 INTERNAL_ERROR, early EOF, and GnuTLS "Error decoding the received TLS packet" (curl 56) by configuring Git and retrying fetches. Non-ABFS builds keep the original single-run fetch behavior.

+ +
    +
  • configure_git_gerrit_fetch(): run only when ABFS_BUILDER != "false"; set postBuffer (500MB), keep-alive, HTTP/1.1, and TLS 1.2 for the Gerrit host.

    +
  • + +
  • fetch_and_cherry_pick_with_retry(): use only when ABFS; else eval REPO_CMD (topic and single-patchset paths).

    +
  • + +
  • Retry: up to 5 attempts, 20s delay; abort/reset between attempts.

    +
  • +
+

https://github.com/AGBG-ASG/acn-horizon-sdv/pull/960

+
+
    +
  • 5003334924a16d205586f2266a1cc43972611a64

    +
  • +
+

TAA-1611

+

[Security] Dependabot - Axios is Vulnerable to Denial of Service

+

TAA-1611: [Security] Dependabot - Axios is Vulnerable to Denial of Service
Dependabot reports 1 security alert for axios

+ +
    +
  • Current version is 1.13.2; a fix is available in 1.13.5 or later.

    +
  • + +
  • Updated the axios version to 1.13.6

    +
  • +
+

https://github.com/AGBG-ASG/acn-horizon-sdv/pull/958

+
+
    +
  • 5697512bf2315476287a3ddb0cc93508e96f0fea

    +
  • +
+

TAA-1527

+

After implementing strict firewall rule, filestore may not be available

+

TAA-1527: After implementing strict firewall rule, filestore may not be available

+ +
    +
  • reserved-ipv4-cidr: "10.152.0.0/24" added to the storage class

    +
  • +
+

https://github.com/AGBG-ASG/acn-horizon-sdv/pull/899

+
+
    +
  • 7f66bd4386a2a423b5d0682e0be2e63fe8195cbb

    +
  • +
+

TAA-1160

+

[ARM64] Lack of available instances on us-central1-b/f zone

+

TAA-1160: ARM64 instances availability

+ +
    +
  • Instance availability: us-central1-b do not have enough instances, so move to us-central1-f.

    +
  • +
+
+
    +
  • 7f66bd4386a2a423b5d0682e0be2e63fe8195cbb

    +
  • +
+

TAA-1656

+

[Gemini] Keychain init fails in AAOS and utilities Docker images: missing libsecret-1.so.0

+

TAA-1656: [Gemini] Keychain init fails in AAOS and utilities Docker images: missing libsecret-1.so.0

+

Update gemini documentation with known issues:

+ +
    +
  • missing pgrep output

    +
  • + +
  • Linux: CLI hangs after OAuth (GNOME Keyring / keytar)
    TAA-1620: [Gemini] Add libsecret-1-0 so Secret Service clients (e.g. gemini-cli, IDE credential helpers) find libsecret-1.so.0 when running inside the built images.

    +
  • +
+

TAA-1620: gemini: default GEMINI_FORCE_FILE_STORAGE for headless keychain/D-Bus
Set GEMINI_FORCE_FILE_STORAGE=true in gemini_environment.sh so gemini-cli

+ +
    +
  • skips libsecret/keytar (GNOME Keyring + D-Bus) in Argo/Jenkins containers without DISPLAY. Document the variable in the env header comment. Override with GEMINI_FORCE_FILE_STORAGE=false for interactive desktop use.

    +
  • +
+
+
    +
  • 2546405452ebd760008d4a74fdff177e0cb52941

    +
  • + +
  • 55e57fe40d2b1c96fecbc17812f764bbd8fec72c

    +
  • + +
  • 6cf633359eb02c5698f3dfd0958797a7f8f73181

    +
  • +
+

TAA-1519

+

Gemini CLI auto-selects preview models when preview is disabled and location is non-global

+

TAA-1519: Remove debug from Gemini
Debug is too verbose and results in huge logs, in some cases 455MB. This can also impact on job performance in Jenkins, exhaust resources.
TAA-1519: Reduce Jenkins build retention (job.groovy) to save built-in node pool space
daysToKeep: 60 → 7, numToKeep: 200 → 50 across all pipelines
TAA-1519: Increase Jenkins node pool resources 32Gi to 64Gi

+
+
    +
  • 512924f91803a23032803f6b6a68b98ad9ea2ab6

    +
  • + +
  • c0ef77577461417d890b9c74bcf202273855db2c

    +
  • + +
  • f04aaeb8662066a5996de2eb37af134e1334f364

    +
  • +
+

TAA-1663

+

[AAOS Builder] Gemini CLI step runs ~58 minutes with no clear errors in logs

+

TAA-1663: cap Step 3 AI review shell use (android-fix run_shell_command)
Add Stage 3 latency guardrails in aaos_builder skills.yaml: anchor fixes on RCA context, cap read/grep/list tools, and treat run_shell_command as high-risk— forbid find/recursive grep/rg and build commands (m/ninja/bit); allow at most two bounded shell calls (e.g. head/tail/wc) on a single path already named in RCA.
Tighten android-fix system_instructions (plan-then-act, prefer read_file/grep over shell). Update step3_fixes.txt with the same budgets at invoke time. Targets multi-hour Step 3 runs dominated by run_shell_command duration in headless stats.

+

https://github.com/AGBG-ASG/acn-horizon-sdv/pull/989

+

TAA-1663: refine AI review: AAOS skills, step2 prompt, and optional step2 context cap
AAOS builder (workloads/android/pipelines/ builds/aaos_builder/ prompt/sequenced/skills.yaml):

+ +
    +
  • Clarify global output rules: android-triage and android-rca stay response-only; android-fix may use write tools only for files under gemini-assist/ per FILENAME_RULE,

    +
  • + +
  • Tool-use guidance: narrow search scope (prefer rg with explicit paths and globs; exclude out/, out_*, and .repo); on grep failure or abort, narrow or read_file—do not widen scope.

    +
  • + +
  • Stage 1 latency: log-first triage from aaos-build.log and aaos-build-info.txt only (no deep tree scan), last 1000-line tail, soft cap of three read_file calls.

    +
  • + +
  • Stage 2 latency: triage-first (no full log re-scan; at most one 500-line tail if ambiguous); read paths from the matrix before grep; narrow grep roots; soft cap of eight ad/grep/list_directory calls with at most four grep_search; if still incomplete, finish with cs.android.com / search queries.

    +
  • + +
  • Triage matches expected output schema; RCA adds plan-then-act, context minimization, narrow-path search, stop when output_schema is filled; fix stage prefers search queries in headless/CI and verified URLs only when confirmable.

    +
  • +
+

AAOS step2 task prompt (step2_rca.txt): echo tool budget and no full-log rescan at invoke time.
Shared sequenced pipeline (gemini_analysis.sh): optional cap when composing step 2 input—if GEMINI_STEP2_PRIOR_CONTEXT_BYTES is set to a positive integer, append only that many bytes of step1 output (head -c); unset or 0 = full step1 text (previous behavior for CTS/BSW and other jobs). Documented in gemini_environment.sh.
AAOS-only wiring: GEMINI_STEP2_PRIOR_CONTEXT_BYTES=131072 on ai-review
ClusterWorkflowTemplate, aaos_builder and aaos_abfs_builder Jenkinsfiles (override or set 0 to disable). CTS and BSW Jenkins do not set it, so they behave as before.

+

https://github.com/AGBG-ASG/acn-horizon-sdv/pull/986

+

TAA-1663: harden AAOS Gemini AI review (triage tail, caps, AAOS-only step2 context)

+

AAOS skills (prompt/sequenced/skills.yaml):

+ +
    +
  • Global output rules and write scope (triage/rca response-only; fix under gemini-assist/).

    +
  • + +
  • Narrow search guidance; Stage 1 log-first + TAA-1325-style triage limits; forbid triage grep_search and repo run_shell_command; use pipeline aaos-build.log.tail; Stage 2 caps and triage-first RCA; fix references favor queries in CI.

    +
  • +
+

Prompts:

+ +
    +
  • step1_triage.txt: tail-first, tool budget, no repo search in Step 1.

    +
  • + +
  • step2_rca.txt: tool budget and no full-log rescan.

    +
  • +
+

Pipeline:

+ +
    +
  • Write aaos-build.log.tail (last 2500 lines) before Gemini in ai-review CWT, aaos_builder and aaos_abfs_builder Jenkinsfiles.

    +
  • + +
  • Remove aaos-build.log.tail after ai-review on Argo (keep aaos-build.log for storage); Jenkins still cleans via aaos-build*.*.

    +
  • +
+

Shared gemini_analysis.sh:

+ +
    +
  • Optional GEMINI_STEP2_PRIOR_CONTEXT_BYTES (positive = head -c cap on step1 context for step 2); unset or 0 = full prior step (CTS/BSW unchanged).

    +
  • + +
  • gemini_environment.sh documents the variable.

    +
  • +
+

Argo ai-review CWT: GEMINI_STEP2_PRIOR_CONTEXT_BYTES=131072; Jenkins AAOS/ABFS default the
same so only AAOS sequenced jobs cap step2 context by default.

+

https://github.com/AGBG-ASG/acn-horizon-sdv/pull/987

+
+
    +
  • c1271dd15617a68e9901e0c029c978b42b7018ec

    +
  • + +
  • d305394004520202f8d320a56185f3afd76ea5cc

    +
  • + +
  • d1cab720a8bb105928e11a074353f29ff24ebd01

    +
  • +
+

TAA-1684

+

[Module Manager] Module Manager container image build fails

+
+
    +
  • Remove unused import of 'errors' from handler.go to clean up code.

    +
  • + +
  • Commenting out to avoid CRD not found error.

    +
  • + +
  • Enable workflows

    +
  • +
+

https://github.com/AGBG-ASG/acn-horizon-sdv/pull/990

+
+
    +
  • 13ba334fd636b8cc795b2b685d91b5f3e308badd

    +
  • + +
  • 3ab8a1d5c6f7d4a17cc050e8a9b30d612ac5851c

    +
  • + +
  • 83eaefadff4a429dd9a0557c68031c1c056e59f3

    +
  • +
+

TAA-1686

+

[Jenkins] Warm Build Caches Jenkins job cannot select AAOS clean mode and always forces NO_CLEAN

+

TAA-1686: docs(android): align warm_build_caches README with Jenkinsfile and job.groovy
Document job location, parameters (including AAOS_CLEAN first vs later stages), build stage order, tangorpro skip when AAOS_REVISION contains android-16.0.0_r, ANDROID_BUILD_ID prefixes, mirror vs no-mirror pod templates, and cache PVC settings.

+

TAA-1686: feat(android): add AAOS_CLEAN to Warm Build Caches Jenkins job
Add the same AAOS_CLEAN choices as the main AAOS Builder job (NO_CLEAN, CLEAN_BUILD, CLEAN_ALL) with default NO_CLEAN. The first warm-cache stage (Build: aosp_cf_x86_64_auto) uses the parameter as-is. Later stages repeat NO_CLEAN or CLEAN_BUILD when that is what was selected; if CLEAN_ALL is selected, only the first stage runs it and later stages use CLEAN_BUILD so a full cache wipe cannot run more than once per pipeline. Replaces the previous hardcoded NO_CLEAN on every stage.

+
+
    +
  • d0b92263d63806a6782695bd2f9e6a57b51578cb

    +
  • + +
  • 37ef6cc4d548cef8b547867234af9d2087c34f89

    +
  • +
+

TAA-1687

+

[Agentic AI] ABFS build does not stop at the first error, so Gemini AI Review can miss the real failure

+

TAA-1687: ABFS AI Review uses full build log; align triage skills with ABFS vs AAOS
ABFS Jenkins copies aaos-build.log to aaos-build.log.tail so Gemini triage can see early failures (e.g. #error) that sit far above a short tail when the build does not stop on the first error. Document the intent in the Jenkinsfile. Update skills.yaml android-triage guardrails: allow grep_search scoped to the build log files, describe bounded tail/grep-prefix on AAOS/Argo versus full-copy .tail on ABFS, and keep read_file caps. Replace the step1_triage latency paragraph with a short pointer to skills.yaml so prompts stay in sync with the skill.

+
+
    +
  • c8b723c3edeb2f4185e6bb6ff93fb792a96cd235

    +
  • +
+

TAA-1691

+

[Cuttlefish] Improve private repo support (private forks)

+

TAA-1691: Cuttlefish instance template private repo improvements.
Fix HTTPS clone URL construction: strip trailing slashes, percent-encode credentials for embedded user:pass URLs, and require python3 when using private repos. Surface git clone/checkout failures, clone into an explicit
repo directory, and clean up from the Packer working tree.
Document private forks (e.g. horizon/main), derived GCP image and instance template names, and wiring the Jenkins GCE plugin via GitOps (values-jenkins.yaml) and the UI, including JENKINS_GCE_CLOUD_LABEL

+
+
    +
  • 82365d4f0def6eefaa6ff3cc38881d6d4a1c3ad5

    +
  • +
+

TAA-1650

+

Users in Viewer/Developer groups unable to view jobs on Jenkins dashboard

+

Fix for problem of Users in Viewer/Developer groups unable to view jobs on Jenkins dashboard by adding missing roles.

+
+
    +
  • e159b3306911375c4f7a3055cc37390c4f6e3f36

    +
  • + +
  • f2d64e1462f9b778b925beacbec995bcad828009

    +
  • +
+

TAA-1666

+

WorkflowTemplate.argoproj.io "" not found. TST deployment from env/dev not successful.

+
+
    +
  • Split EventBus and EventSource out of gitops/templates/argo-events.yaml Helm extraObjects into a dedicated root child Application (argo-events-resources) to separate platform CR ownership from the Helm controller release.

    +
  • + +
  • Moved Argo Events CR chart to gitops/apps/argo-events-resources.

    +
  • + +
  • Added explicit sync ordering (argo-events wave 4 -> argo-events-resources wave 5) so CRDs/controller are present before EventBus/EventSource apply.

    +
  • + +
  • Split AAOS webhook Sensors into a dedicated workloads-android-aaos-webhooks child Application so Android-specific webhook triggers are owned by the Android module and not rendered by the root chart.

    +
  • + +
  • Moved the AAOS webhook enable/disable toggle from root gitops/values.yaml to gitops/modules/workloads-android/values.yaml under config.workloads.android.webhooks.enabled, because root templates no longer consume that key and the module template does.

    +
  • + +
  • Updated comments/docs to clarify that sync waves help install ordering, but destroy-time finalizer behavior is still an operational concern and needs runtime validation.

    +
  • +
+

https://github.com/AGBG-ASG/acn-horizon-sdv/pull/997

+
+
    +
  • 35104b0a3c40bc4e6034a93860bafab3e6cebe6e

    +
  • + +
  • 07aef7da3858520b9eb78732b0549621b44a5863

    +
  • + +
  • 893bef57d7bfa585b32cbfeb9787ce8ffa44883f

    +
  • + +
  • d56157e3076f3c82799fe646d89f0ef7b393d1e4

    +
  • + +
  • b0470eace1cc6343c6101e4af48722b092422966

    +
  • + +
  • a81ec5b834c260debb714b7b73664a544deb6feb

    +
  • + +
  • 3fef1a1ba3288db10cfc41c72a8598749bf32d97

    +
  • + +
  • a8abaf421424b98d8bd262a201f7301fb04dc5a7

    +
  • + +
  • 7b77a27d9acca823319241697f772da74fc1fc88

    +
  • + +
  • 178e0c3b9071412bab4efe278e2e3a792dc4a4e9

    +
  • +
+

TAA-1693

+

[Agentic AI] Gemini AI Utility - pod evicted during run

+

TAA-1693: fix(utilities): harden Gemini AI Assistant pod against eviction

+

Align the Utility Jenkins agent with ai-review Gemini resources (16 CPU / 48Gi requests, 32 / 96Gi limits), schedule on the Android workload pool (nodeSelector + tolerations), set safe-to-evict false for autoscaler, extend idle sleep past the 2h analysis timeout, and document Android pool dependency in TODO comments.

+ +
    +
  • feat(gke): add utility node pool for Gemini AI Assistant Jenkins workload

    +
  • +
+

Introduce a dedicated GKE node pool (workloadLabel utility, taint workloadType=utility) sized for Vertex/Gemini CLI pod limits (aligned with ai-review resources). Default machine type is n2-standard-48 so allocatable CPU stays above 32-core limits after kube-reserved overhead

+
+
    +
  • 572d84e9b412450ae1f2b54ad6275b1fb8a00bee

    +
  • +
+

TAA-1692

+

[Agentic AI] Gemini CLI denies run_shell_command in headless --yolo mode

+

TAA-1692: gemini: workspace policy TOML for headless shell in CI

+

Fix headless Gemini runs failing with "Tool execution denied by policy" on run_shell_command: the CLI defaults shell to ask_user, which becomes deny without a TTY, even when GEMINI_COMMAND_LINE includes --yolo.

+

Add repo-maintained TOML under workloads/common/agentic-ai/gemini/policies/; gemini_initialise.sh copies every *.toml into .gemini/policies/ when Vertex auth runs and the job is Jenkins, CI=true, or Argo (ARGO_WORKFLOW_NAME), so workspace-tier rules can allow run_shell_command for interactive=false.

+
+
    +
  • 02bde31a7ded989528b7567f3a4609638192f713

    +
  • +
+

TAA-1706

+

[MTK Connect] Skip testbench deletion when CVD launch fails

+

TAA-1706: fix(cvd): skip MTK testbench teardown when MTK stage never ran
CVD launch failure skips "MTK Connect to Virtual Devices", so no testbench exists. Gate "MTK Connect Delete Testbench" and "Delete Offline Testbenches" on MTK_CONNECT_STAGE_ENTERED, set only when the MTK Connect stage runs.

+
+
    +
  • feb2c1cc47d87e20c562e7fb5593f2dfb1d5ae13

    +
  • +
+

TAA-1704

+

[Jenkins] Kubernetes agent pod evicted during build

+

TAA-1704: fix(cvd): trim diagnostics pod resources and relax scheduling
Align the CVD shared-library Kubernetes agent with Gemini AI Assistant requests/limits (16/32 CPU, 48Gi/96Gi) while keeping the Android build image. Drop pod anti-affinity and the aaos_pod label so the pod schedules more freely; other aaos_pod workloads may co-locate on the same node.

+

https://github.com/AGBG-ASG/acn-horizon-sdv/pull/1009

+

TAA-1704: fix(cvd): merge Jenkins pod YAML so safe-to-evict wins over cloud template
Use yamlMergeStrategy merge() on the Diagnostics & Teardown Kubernetes agent so the inline pod spec (including cluster-autoscaler.kubernetes.io/safe-to-evict) is not overridden by the inherited Kubernetes cloud podTemplate.

+

TAA-1704: Align CVD/CTS Diagnostics Jenkins pod with AAOS Builder agent
Match the kubernetes builder spec used in aaos_builder/Jenkinsfile: set requests and limits to 98000m CPU and 180000Mi memory, add the aaos_pod label, and required podAntiAffinity on aaos_pod so Diagnostics shares the
same one-pod-per-node behaviour as other android build workloads.
Set cluster-autoscaler.kubernetes.io/safe-to-evict to "false" (Utility Gemini pattern) so long AI Review runs are less likely to be evicted via the Eviction API during cluster-autoscaler scale-down; node pressure can still evict.

+

https://github.com/AGBG-ASG/acn-horizon-sdv/pull/1005

+
+
    +
  • 54e4aa21a869a985495f059c61a8785fed06b873

    +
  • + +
  • a0ba27167a62d22fbc9a44a477f872bde88e742a

    +
  • + +
  • 62ffcf9a9a15216f6d44ca2da2a3370d4a817c1f

    +
  • +
+

TAA-1518

+

Ensure artifact summary stands out in logs

+

TAA-1518: Ensure artifact summary stands out in logs
Display summary in one colour. Then display all the artifact lines in another. These will now stand out in Jenkins console and Argo Workflow logs.

+

https://github.com/AGBG-ASG/acn-horizon-sdv/pull/893

+
+
    +
  • a98cb32a148c3d93fe8bafff937bf3ca3ac934b2

    +
  • +
+

TAA-1440

+

[Cuttlefish] Persistent Host Build Regression (Bazel/Debian Dependency Failure)

+

Fix for [Cuttlefish] Persistent Host Build Regression (Bazel/Debian Dependency Failure)

+
+
    +
  • 29cb5fbf5e07ed322148100219a910e4ef53da60

    +
  • +
+

TAA-1665

+

Jenkins PVC uses hardcoded jenkins-rwo storage class instead of prefixed name

+

TAA-1665: fix(gitops): use prefixed storage class for Jenkins PVC in sub-environments

+

The Jenkins home PVC must reference the same StorageClass name as defined above the manifest ({{ .Values.config.namespacePrefix }}jenkins-rwo). A hardcoded jenkins-rwo breaks sub-environments where the class is prefixed and the PVC stays Pending. Aligns with int/3.1.0 / TAA-1057 naming.

+
+
    +
  • 04159ba6d4f65fe4426d1f1727cbd7e65a470e07

    +
  • +
+

TAA-1664

+

[Cuttlefish] authentication retry warning; instances unreachable after rebuild

+

TAA-1664: Cuttlefish - clarify Cuttlefish user vs Docker user

+
+
    +
  • d23ae7df4e88973f2fccc23319dc5ffe0c6040da

    +
  • +
+

TAA-1674

+

[AAOS Builder] Ambiguous parameter AAOS_BUILD_CTS - needs clarification

+

Added clarifications to ensure the user knows that by selecting the AAOS_BUILD_CTS option, only the test suite will be built and that no other target images will be built.

+

https://github.com/AGBG-ASG/acn-horizon-sdv/pull/982

+
+
    +
  • 3e2d8aa10a887805484cd3745f6ad84534434423

    +
  • +
+

TAA-1708

+

[Cuttlefish] launch can report failure when host is missing lsof

+

TAA-1708: fix(cvd): install lsof for Cuttlefish host

+
+
    +
  • 2013780b6a29c4d350c5cfc6c11cc975579c59fb

    +
  • +
+

TAA-1690

+

[MTK Connect] start may hang on CVD Launcher testbench until pipeline timeout (exit 124)

+

TAA-1690: Skip CVD pipeline AI Review when MTK Connect --start fails

+

Set env.MTK_CONNECT_STAGE_FAILED from mtk_connect.sh exit status so Gemini does not triage CVD logs when the failure was an MTK Connect timeout or other connect-layer error. Unset when the MTK stage is skipped (CVD/CTS failure, CTS without MTK) so AI Review still runs for those cases.

+
+
    +
  • b79f39b8d5b02f5cc0d5a30a87447c262958d53d

    +
  • +
+

TAA-1688

+

[Agentic AI] Improve CTS Execution and CVD log handling for failures analysis

+

TAA-1688: fix(jenkins): pod yaml merge + safe-to-evict false to reduce CA eviction
Avoid eviction issues, k8s evicting while job is running.

+

TAA-1688: fix(cvd): MTK delete stages gate on MTK_CONNECT_STAGE_ENTERED only

+

TAA-1688: feat(cvd): default CVD_COMMAND_LINE for CI without GPU or Bluetooth

+

Append --setupwizard_mode DISABLED, --enable_host_bluetooth false, and --gpu_mode guest_swiftshader to the standard cvd create line in cvd_environment.sh and the CVD Launcher / CTS Execution Jenkins parameter defaults. This matches typical automation hosts: non-interactive boot, no host Bluetooth integration, and guest SwiftShader when GPU passthrough is not available.

+

TAA-1688: feat(gemini): per-step CLI runs, dual step3 context, strategy + docs
- gemini_analysis.sh: unique headless_output_stepN_.json per step; extract from explicit JSON; step3 composed from step1 + step2 (GEMINI_STEP3_PRIOR_STEP_BYTES); single-step uses step1 JSON + extraction
- gemini_environment.sh: document GEMINI_STEP3_PRIOR_STEP*_BYTES
- gemini.md: sequenced analysis (benefits, table, prompt/token budget how-to)
- token-budget follow-up

+
+
    +
  • a35457dc4fdef442b63092f6bd8f53ffd46c40a6

    +
  • + +
  • 7a329bf9cf7382d0f32e076721ce720c7f5b30e7

    +
  • + +
  • 785f31e2ab66ec9f2f9547bb9f9749789db12420

    +
  • + +
  • 9c8386f13fe104ffc7d66bde170cadc7369730d1

    +
  • +
+

TAA-1715

+

[Jenkins] R4.0.0: upgrade Jenkins and current BOM (final)

+

Install the JDK with apt using only ${JAVA_VERSION}: add Adoptium apt when the package name starts with temurin-, then apt update/install. Remove Debian bookworm-backports and OpenJDK→Temurin fallback chains; verify with java -version and resolved JAVA_HOME.

+ +
    +
  • cf_host_initialise.sh: install_jdk_from_apt + verify_jdk; drop default-jdk from extra packages (alternatives clash)

    +
  • + +
  • cf_create_instance_template.sh: default JAVA_VERSION=temurin-21-jdk

    +
  • + +
  • job.groovy: default temurin-21-jdk (x86/Debian)

    +
  • + +
  • job_arm.groovy: default openjdk-21-jdk-headless (ARM/Ubuntu); Temurin optional

    +
  • + +
  • cf_instance_template.md: document apt package names and HTTPS egress needs

    +
  • +
+

https://github.com/AGBG-ASG/acn-horizon-sdv/pull/1021

+
+
    +
  • bb580aec1cde6c5347494198b6835fb466761f68

    +
  • +
+

TAA-1705

+

prepare-github-app-git-creds fail on fresh deploy due to CRD ordering

+

This PR fixes fresh deployment failures caused by CRD ordering for prepare-github-app-git-creds.
The ClusterWorkflowTemplate is moved out of the root horizon-sdv app and into a dedicated child Application under gitops/apps, so it syncs only after argo-workflows is in place.

+

Changes

+

argo-workflows-init.yaml

+

File path: gitops/templates/argo-workflows-init.yaml

+ +
    +
  • Removed inline ClusterWorkflowTemplate prepare-github-app-git-creds from the git.authMethod == "app" block.

    +
  • + +
  • Kept workflow-github-app-secret and workflow-github-app-token-script in this file.

    +
  • + +
  • Why: avoids the root app trying to apply a ClusterWorkflowTemplate before its CRD exists.

    +
  • +
+

argo-workflows-github-app.yaml

+

File path: gitops/templates/argo-workflows-github-app.yaml

+ +
    +
  • Added a new child Argo CD Application, gated by git.authMethod == "app".

    +
  • + +
  • Set child Application sync wave to 7.

    +
  • + +
  • Points source path to gitops/apps/argo-workflows-github-app.

    +
  • + +
  • Why: ensures this app runs after argo-workflows (wave 6) so the ClusterWorkflowTemplate CRD is available.

    +
  • +
+

Chart.yaml

+

File path: gitops/apps/argo-workflows-github-app/Chart.yaml

+ +
    +
  • Added new Helm chart metadata for dedicated GitHub App workflow resources.

    +
  • + +
  • Uses generalized app name argo-workflows-github-app for future extensibility.

    +
  • +
+

values.yaml

+

File path: gitops/apps/argo-workflows-github-app/values.yaml

+ +
    +
  • Added chart values with config.namespacePrefix.

    +
  • + +
  • Why: keeps namespace prefixing consistent with existing GitOps patterns.

    +
  • +
+

prepare-github-app-git-creds.yaml

+

File path: gitops/apps/argo-workflows-github-app/templates/prepare-github-app-git-creds.yaml

+ +
    +
  • Added extracted ClusterWorkflowTemplate prepare-github-app-git-creds.

    +
  • + +
  • Resource sync wave is 2 within this child app.

    +
  • + +
  • Why: keeps the template logic unchanged while placing it in the correct app-level sync order.

    +
  • +
+

https://github.com/AGBG-ASG/acn-horizon-sdv/pull/1006

+
+
    +
  • 85d25b652e067a46e10f7e08cb1755fe4e91d95d

    +
  • + +
  • 78bbe76a7b4b9b8a897a3d5df75f37f4421274ce

    +
  • + +
  • b7837325dc89c15d0957f3dd88d01626ad561c68

    +
  • +
+

TAA-1699

+

[Security] OSS Lodash module update (4.17.21->4.18.1)

+
+
    +
  • Updated lodash from 4.17.21 to 4.18.1 in:

    + +
      +
    • terraform/modules/sdv-container-images/images/keycloak/keycloak-post-horizon-api/package.json

      +
    • + +
    • terraform/modules/sdv-container-images/images/keycloak/keycloak-post-horizon-api/package-lock.json

      +
    • +
    +
  • + +
  • This addresses TAA-1699 security remediation for CVE-2026-4800.

    +
  • + +
  • Confirmed no remaining lodash 4.17.x references in repository lock/manifests.

    +
  • +
+

https://github.com/AGBG-ASG/acn-horizon-sdv/pull/1075

+
+
    +
  • 98a877f0ba65497d7055ccfb98a05947fa8fdebb

    +
  • +
+

TAA-1710

+

R4.0.0: Terraform duplicate certificate manager config, blocks deployment

+

Terraform duplicate certificate manager config

+

The fix replaces all hardcoded single-resource definitions in resource "google_certificate_manager_certificate" "horizon_sdv_cert" for_each over var.domains, so each domain gets its own uniquely-named DNS auth, certificate, and map entries (apex + wildcard) — eliminating duplicate resource name conflicts.

+

https://github.com/AGBG-ASG/acn-horizon-sdv/pull/1032

+
+
    +
  • ce0a3089f3d6fd44af9a219af5162d075d0edc8e

    +
  • + +
  • 358fc69e2c165a77de879456724c7e9ace315473

    +
  • + +
  • e643957ebf1c5e77ebbd3514d6bcd5b72aabcd86

    +
  • + +
  • cc34f87f479a1f04bba3f3f70e11d0055b8b73c9

    +
  • +
+

TAA-1739

+

Add optional “analyse on success” toggle for Gemini AI Review in CVD Launcher + CTS Execution

+

TAA-1739: Allow Gemini AI Review on successful CVD/CTS runs (opt-in)

+

Add a GEMINI_ANALYSE_ON_SUCCESS job parameter for CVD Launcher and CTS Execution to optionally run Diagnostics -> AI Review on SUCCESS as well as FAILURE, without turning green builds red.

+

https://github.com/AGBG-ASG/acn-horizon-sdv/pull/1038

+
+
    +
  • be7d36f1b48a4c4a82f3c9a16b40cae6893e579b

    +
  • +
+

TAA-1700

+

[Security] OSS Axios module update (1.13.2->1.15.0)

+

This PR updates the Axios dependency to address security vulnerabilities identified during OSS legal scanning and to ensure compliance for Horizon Release 4.0.0.

+ +
    +
  • Upgraded Axios SDK to version 1.15.0 to mitigate known security vulnerabilities.

    +
  • + +
  • Updated dependency configuration to reflect the new version.

    +
  • + +
  • Executed required tests for workloads/android/pipelines/tests/cvd_launcher pipeline and verified successful execution.

    +
  • + +
  • Verified that existing functionality continues to work as expected after the update.

    +
  • +
+

https://github.com/AGBG-ASG/acn-horizon-sdv/pull/1015

+
+
    +
  • 468ece95b9ead471fd383c659b25f7f256949979

    +
  • +
+

TAA-1734

+

Sample workload seed fails Job DSL with sandbox error on CLOUD_REGION

+

TAA-1734 sample workload seed fails job dsl

+

Sample workload seed fails Job DSL with sandbox error on CLOUD_REGION. Fixed templates HORIZON_SCM_* used by groovy files.

+

https://github.com/AGBG-ASG/acn-horizon-sdv/pull/1037

+
+
    +
  • ff335e17275db47995ca05478b400bdce63bab42

    +
  • + +
  • 5c912d51698d2a21bb2b846715a59003c2fd9fd4

    +
  • + +
  • fe01d1b0fac717e88f35b39282d54006adb66288

    +
  • +
+

TAA-1733

+

kcc-webhook-cert-monitor : missing platform arch

+

TAA-1733: Fix platform arch - kcc-webhook-cert-monitor

+

https://github.com/AGBG-ASG/acn-horizon-sdv/pull/1044

+
+
    +
  • da5873ab9e3a835b45986067bfa000c7f1e3fc6a

    +
  • +
+

TAA-1701

+

sbx deployment is failed on WSL

+

TAA-1701 deployment failing wsl

+

Deployment fail on WSL - fix. Set open: false for the visualizer (still writes dist/bundle-analysis.html; you open it manually when you want).

+

https://github.com/AGBG-ASG/acn-horizon-sdv/pull/1003

+
+
    +
  • 19c0b7c16cb64111a434cec514119c9d169c437b

    +
  • +
+

TAA-1723

+

[Cuttlefish] improvements from AI Review

+

TAA-1723: CVD AI Review — dual-lane preflight + Utilities Gemini AI Assistant guidance
Extend the CVD Launcher sequenced skills so the same prompts/skills.yaml cover both failed and successful CVD runs:

+ +
    +
  • Add Phase 0 boot preflight to global_constraints: classify CVD_STATUS as BOOT_OK / BOOT_FAILED / BOOT_UNKNOWN from artifact-only signals ("status":"Running" in cvd-*.log, VIRTUAL_DEVICE_BOOT_COMPLETED per guest, and strong negatives in kernel.log). No pipeline/env state is consulted, so the CVD Launcher pipeline and Utilities / Gemini AI Assistant produce the same classification for the same test-results/.

    +
  • + +
  • Route triage-cvd / rca-cvd / fix-cvd by CVD_STATUS: BOOT_OK drives runtime-health analysis (logcat primary, bootconfig informational) and emits a single [CVD_HEALTHY] / [NO_RUNTIME_ISSUE] row when clean; fix-cvd writes a single observations note (or FIX_UNKNOWN) on healthy runs instead of fabricating AOSP diffs. BOOT_FAILED / BOOT_UNKNOWN keep the existing guest-first boot triage.

    +
  • + +
  • Update step1/step2/step3 prompt one-liners to reference the new lane routing.

    +
  • +
+

TAA-1723: cf_host_initialise: noninteractive apt; Mesa EGL/Vulkan to avoid CVD host probe errors

+

Prefix apt/apt-get with sudo env DEBIAN_FRONTEND=noninteractive so Packer/CI installs never block on debconf. Install libegl1-mesa, libvulkan1, and mesa-vulkan-drivers (Debian and Ubuntu) so assemble_cvd EGL/Vulkan checks do not fail on minimal images; warn and continue if that install fails.

+

https://github.com/AGBG-ASG/acn-horizon-sdv/pull/1043

+
+
    +
  • 6b388b6121ff1757cd6a335a491227438d35178f

    +
  • + +
  • 6ab4774f6df349f4bab5bce48476933c4a95bb50

    +
  • +
+

TAA-1737

+

[Module Manager] Module enable races cause transient Argo Application and soft-features ConfigMap errors

+

This branch fixes two bugs observed when enabling modules via the Horizon Developer Portal.

+

Bug 1 (RBAC — hard error): When enabling the sample module, module-manager tried to verify that the sample-module-hello namespace was ready before writing the soft-features ConfigMap into it. This get namespace call was denied with 403 Forbidden because the module-manager-soft-features ClusterRole only granted access to configmaps, not namespaces. The error surfaced as a red banner in the Developer Portal on every enable of sample.

+

Bug 2 (race conditions — transient errors): Concurrent enable/disable requests and parallel reconciler runs (API handler + ModuleCatalogReconciler) could race on shared state: Argo CD Applications were created before a prior delete had completed ("object is being deleted: applications.argoproj.io already exists"), and soft-features ConfigMap writes could collide ("unable to create new content in namespace ... because it is being terminated"). These were transient but consistently reproducible during rapid module toggling.

+

Both fixes are scoped entirely to module-manager. horizon-api, horizon-dev-portal, and KCC are not involved.

+

Changes

+

Chart.yaml

+

File path: gitops/apps/module-manager/Chart.yaml

+ +
    +
  • Bumped version and appVersion from 0.2.0 to 0.2.2.

    +
  • + +
  • Why: tracks the two fix commits as distinct releases so Argo CD records the changes as versioned diffs.

    +
  • +
+

rbac.yaml

+

File path: gitops/apps/module-manager/templates/rbac.yaml

+ +
    +
  • Added a second rule to the module-manager-soft-features ClusterRole granting get on namespaces (core API group).

    +
  • + +
  • Why: ensureNamespaceReady in soft_features_configmap.go calls apiReader.Get on a corev1.Namespace before writing each soft-features ConfigMap. Without this verb the call returned 403 Forbidden, which propagated out as a hard error on every soft-features sync. namespaces is cluster-scoped so a ClusterRole is required; only get is added (minimum privilege).

    +
  • +
+

transaction.go (new file)

+

File path: terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/controller/transaction.go

+ +
    +
  • Declares the package-level var ModuleOpsMutex sync.Mutex.

    +
  • + +
  • Why: provides a single, shared mutex that all module lifecycle entry points (REST enable, REST disable, ModuleCatalogReconciler) hold for the duration of their critical section, preventing concurrent mutations of ModuleManagerState and Argo CD Applications.

    +
  • +
+

handler.go

+

File path: terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/api/handler.go

+ +
    +
  • Added controller.ModuleOpsMutex.Lock() / defer Unlock() at the top of both enableModule and disableModule.

    +
  • + +
  • Replaced h.client.Create(ctx, app) with createApplicationIdempotent(ctx, h.client, app) in enableOneModule.

    +
  • + +
  • Added a call to controller.RunAutoDisableSweep after patchDependentsSoftFeature in enableModule (error logged, not returned).

    +
  • + +
  • Why: the mutex serialises concurrent API requests; idempotent create tolerates the "object is being deleted" race by retrying with exponential backoff until the old Application is gone; the post-enable sweep ensures autoDisableWhenUnused modules with no dependents are cleaned up immediately even when reached by a direct enable rather than only on disable or catalog change.

    +
  • +
+

catalog_reconciler.go

+

File path: terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/controller/catalog_reconciler.go

+ +
    +
  • Added ModuleOpsMutex.Lock() / defer Unlock() at the start of Reconcile.

    +
  • + +
  • Why: the ModuleCatalogReconciler fires on every catalog spec change and runs the same auto-disable + soft-features sync paths as the REST handlers; holding the same mutex prevents it from racing with an in-flight enable or disable request.

    +
  • +
+

argo_app.go

+

File path: terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/api/argo_app.go

+ +
    +
  • Added createApplicationIdempotent and createApplicationIdempotentWithBackoff: if the Application already exists and is managed by module-manager, treat it as success; if it is terminating, retry with exponential backoff (up to ~32 s) until deleted, then re-create.

    +
  • + +
  • Extracted the label value "horizon-sdv.io/module-manager-managed" into the package-level constant moduleManagerManagedLabelKey used by both the builder and the new idempotency check.

    +
  • + +
  • Why: eliminates the "object is being deleted: applications.argoproj.io already exists" error that appeared when a new enable arrived while the previous Application was still finalising.

    +
  • +
+

soft_features_configmap.go

+

File path: terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/controller/soft_features_configmap.go

+ +
    +
  • Wrapped the ConfigMap create/update inside wait.ExponentialBackoffWithContext (up to ~32 s, 7 steps).

    +
  • + +
  • Added ensureNamespaceReady (get namespace, check deletion timestamp before every attempt).

    +
  • + +
  • Added isSoftFeaturesRetryableError, isNamespaceNotFoundError, isNamespaceTerminatingStatusError to classify transient errors (namespace not yet created, namespace terminating, Kubernetes conflict) as retryable and hard RBAC/API errors as fatal.

    +
  • + +
  • Why: eliminates the "unable to create new content in namespace ... because it is being terminated" error that appeared when a soft-features write raced with a namespace teardown from a prior disable cycle. The namespace-ready guard also provides the correct hook point for the RBAC permission added in rbac.yaml.

    +
  • +
+

https://github.com/AGBG-ASG/acn-horizon-sdv/pull/1047

+
+
    +
  • b1675790157ee88d692904608c2845a663c90f30

    +
  • + +
  • c3a44f693aa8df1a50cbde217460db76e3a30e14

    +
  • +
+

TAA-1732

+

Grafana does not show data on SBX instance

+

Metrics not showing in Grafana. All Dashboards were showing blank

+ +
    +
  • A new NetworkPolicy allow-prometheus-egress-to-gke-metadata was added in namespaces.yaml so pods labeled app.kubernetes.io/name: prometheus can reach 169.254.169.254/32 and 169.254.169.252/32 (GKE metadata / DNAT, per your existing comments elsewhere).
    A one-off kubectl apply of the same policy was run on your cluster: after that, wget to .../api/v1/query?query=up returned 200 with data.

    +
  • +
+

TXT and A records not created after deployment

+ +
    +
  • Adjust txtPrefix so the generated label is valid (no label ending with -). Typical patterns are a fixed prefix like extdns- / external-dns- / _externaldns style names, or a prefix where the record-type is not the last character before a dot in a way that leaves a trailing hyphen on a label.

    +
  • + +
  • Updated gitops/templates/external-dns.yaml to stop generating invalid ownership-TXT names like a-.demo5.horizon-sdv.com (that trailing - is what caused the IDNA errors and then external-dns pruned your A records).

    +
  • +
+

https://github.com/AGBG-ASG/acn-horizon-sdv/pull/1049

+
+
    +
  • 29eab40e376ea139a2b92be48994b729a0fd251f

    +
  • +
+

TAA-1736

+

Regression: Apache 2.0 file headers were truncated when refactoring Helm/workflow

+

Fixes an Apache 2.0 license header regression introduced during Helm/workflow refactoring in commit 7b6f7b0.

+

The refactor caused multiple files to lose parts of the standard multi-line license header (including the license URL and disclaimer block), resulting in inconsistent and incomplete license notices.

+ +
    +
  • Restores full Apache 2.0 headers where they were truncated

    +
  • + +
  • Adds missing header blocks to affected files

    +
  • + +
  • Ensures consistency with repository standards across all touched files

    +
  • +
+

https://github.com/AGBG-ASG/acn-horizon-sdv/pull/1048

+
+
    +
  • 4f9fb545de7b20987ffb6bf22b871c2f816a665d

    +
  • + +
  • f0a9847d59d0ac9f64119b965b77b445d68cd2d9

    +
  • +
+

TAA-1729

+

horizon improvements- unnecessary GCP resources

+

Fixes to makes feature " static IP support " working

+

There is option in terraform.tfvars configuration file:
sdv_dns_use_static_a_records - by default is false, but in case of true DNS is able to use static IP from load balancer and create DNS "A type" record to support static IP

+

https://github.com/AGBG-ASG/acn-horizon-sdv/pull/1042

+
+
    +
  • 4dc6de48ff244f95cf345619b91cfb3325aae8fd

    +
  • +
+

TAA-1743

+

[Security] OSS psf-requests module update (2.32.5->2.33.1)

+

OSS psf-requests module update (2.32.5->2.33.1)

+

https://github.com/AGBG-ASG/acn-horizon-sdv/pull/1051

+
+
    +
  • ef8bd95c8193a12ce08cebc376d6c55890562da5

    +
  • +
+

TAA-1747

+

[Gemini CLI] Folder Trust feature introduced - breaking Horizon Agentic-AI 

+

TAA-1747: gemini: default GEMINI_CLI_TRUST_WORKSPACE (headless folder trust) GEMINI_CLI_TRUST_WORKSPACE=true avoids recent CLI security (folder trust) defaults in headless mode so workspace.

+

https://github.com/AGBG-ASG/acn-horizon-sdv/pull/1058

+
+
    +
  • a3399b756d487443d6cfe9c5ad5ffe49c324d3e8

    +
  • +
+

TAA-1753

+

[Upgrade] Orphan keycloak groups after new groups added

+

Upgrade guide update on how to avoid orphan Keycloak Groups

+

https://github.com/AGBG-ASG/acn-horizon-sdv/pull/1061

+
+
    +
  • 87e567876093abe0d2b1cc4b3c7f00314a428071

    +
  • +
+

TAA-1712

+

[Upgrade] Orphan jenkins-git-creds Secret after rename to jenkins-scm-creds

+

Updated docs/guides/upgrade_guide_3_1_0_to_4_0_0.md with description how to handle orphan jenkins-git-creds secret

+

https://github.com/AGBG-ASG/acn-horizon-sdv/pull/1060

+
+
    +
  • 56b5040a05556db72dc09af88aa90a743ad6eac4

    +
  • + +
  • d6767d5a7102b0ff272f4e01836fc32ee7cf2460

    +
  • +
+

TAA-1738

+

Platform destroy blocked due to workflows namespace stuck in Terminating

+

Platform destroy could stall when the Argo Workflows namespace stayed in Terminating, often because Workflow CRs or their finalizers did not clear before namespaces and GitOps tore down. In parallel, Config Connector (KCC) objects could keep cnrm.cloud.google.com/finalizer on resources in module namespaces while workload identity or GCP auth was already going away, which also blocked namespace completion.

+

This fix addresses that by:

+ +
    +
  1. Workflow drain controller (workflow-namespace-drain-app): A small operator that holds a dedicated finalizer on the root Argo CD Application, deletes remaining Workflow objects in the workflows namespace, and after a grace period clears stuck Workflow finalizers, then removes only its own finalizer. The chart is installed with Terraform helm_release, outside the Argo app-of-apps tree, so it is not pruned during cascade destroy (see chart README.md).

    +
  2. + +
  3. Module Manager platform drain: After disabling modules in dependency order, it strips KCC finalizers in managed destination namespaces (discovery-driven sweep of *.cnrm.cloud.google.com APIs) so namespaces can finish deleting when KCC can no longer reconcile. While child Applications still exist, the root reconciler re-runs the KCC stripper on a timer so objects unblock as auth disappears.

    +
  4. + +
  5. Destroy order / WI: Comments in terraform/modules/base/main.tf document that sdv_wi is destroyed after sdv_gke_apps and sdv_gke_cluster, so workload identity remains usable during Argo cascade and cluster teardown.

    +
  6. + +
  7. GitOps: Child Applications that ship Argo Workflows (and related samples) get horizon-sdv.io/module-manager-managed: "true" so Module Manager can treat them as managed during drain.

    +
  8. +
+

Hosting the drain controller under the root GitOps Application would let Argo delete the drain controller first and undo teardown (documented in gitops/apps/workflow-namespace-drain/README.md). Relying only on KCC to finish deletes fails when Terraform removes IAM/WI while objects are still terminating (kcc_drain.go comments). This correction combines a Terraform-managed drain chart, root Application finalizers, Module Manager KCC finalizer handling, and documented WI destroy ordering.

+

Changes

+ +

terraform/modules/sdv-gke-apps/main.tf

+

File path: terraform/modules/sdv-gke-apps/main.tf

+ +
    +
  • Add helm_release.workflow_namespace_drain (chart gitops/apps/workflow-namespace-drain) with image, Argo CD namespace, workflows namespace, and root app name.

    +
  • + +
  • Add finalizer horizon-sdv.io/workflow-namespace-drain on the root Argo CD Application alongside existing finalizers.
    Why: Run workflow drain outside the GitOps cascade and tie it into root app deletion order.

    +
  • +
+ +

gitops/apps/workflow-namespace-drain/**

+

File path: gitops/apps/workflow-namespace-drain/ (Chart.yaml, values.yaml, README.md, templates/)

+ +
    +
  • templates/rbac.yaml: Single Helm template with multiple YAML documents (---): ServiceAccount, ClusterRole / ClusterRoleBinding for Argo Workflows CRs, namespaced Role / RoleBinding in the Argo CD namespace for root Application finalizers. Top-of-file comments include the Apache license and the note that the release namespace is created by Terraform (create_namespace=true), not as a chart-rendered Namespace object (avoids Helm ownership conflicts).

    +
  • + +
  • templates/deployment.yaml: Controller Deployment only (unchanged split from RBAC).

    +
  • + +
  • README.md: Do not add this chart to gitops/templates/ or the app-of-apps.
    Why: Same RBAC and workload behavior as multiple small template files; one file is easier to review for RBAC; Deployment stays isolated for clarity.

    +
  • +
+

terraform/modules/sdv-container-images/images/workflow-namespace-drain/workflow-namespace-drain-app/**

+

File path: terraform/modules/sdv-container-images/images/workflow-namespace-drain/workflow-namespace-drain-app/

+ +
    +
  • New Go service: root Application finalizer reconciliation, workflow delete and timed finalizer clear, main.go, Dockerfile, modules, tests.
    Why: Implement drain logic the Helm chart runs.

    +
  • +
+

terraform/modules/base/locals.tf

+

File path: terraform/modules/base/locals.tf

+ +
    +
  • Register workflow-namespace-drain-app in the container images map.
    Why: Build and version the new image like other platform images.

    +
  • +
+

terraform/modules/base/main.tf

+

File path: terraform/modules/base/main.tf

+ +
    +
  • Comments on module.sdv_wi relative destroy order vs GKE apps and cluster.
    Why: Explain why WI is torn down after cluster-side teardown still needs credentials.

    +
  • +
+

terraform/modules/sdv-container-images/images/module-manager/module-manager-app/**

+

File paths:

+ +
    +
  • terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/controller/kcc_drain.go

    +
  • + +
  • terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/controller/kcc_drain_test.go

    +
  • + +
  • terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/controller/platform_drain.go

    +
  • + +
  • terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/controller/platform_drain_test.go

    +
  • + +
  • terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/controller/platform_root_finalize_reconciler.go

    +
  • + +
  • terraform/modules/sdv-container-images/images/module-manager/module-manager-app/main.go

    +
  • + +
  • terraform/modules/sdv-container-images/images/module-manager/module-manager-app/go.mod

    +
  • + +
  • KCC finalizer removal in managed module destination namespaces; discovery client wired in main.go; root reconciler requeues and re-strips while child Applications remain; tests.
    Why: Unblock namespace termination when KCC cannot complete deletes during destroy; keep sequencing in Module Manager.

    +
  • +
+

gitops/apps/module-manager/templates/rbac.yaml

+

File path: gitops/apps/module-manager/templates/rbac.yaml

+ +
    +
  • ClusterRole (and related bindings) for listing/patching KCC API groups during drain.
    Why: RBAC required for the KCC finalizer sweep.

    +
  • +
+

https://github.com/AGBG-ASG/acn-horizon-sdv/pull/1067

+
+
    +
  • 841ef827e11600f6d3d5d909690d92125f55c5d5

    +
  • + +
  • 76853cf77a4626601f5b2ad5075612301a610f6e

    +
  • + +
  • 8fe113da88f7e9d9d693473eced597c729e35b05

    +
  • + +
  • 24ebc8af5b06a48bb012c6852e7d13a546966eb5

    +
  • + +
  • e1840b7b3e29461ae788258fde5aaff9f47534a7

    +
  • + +
  • 4b53f7fe710d29a3986f6443cc4bef7384e47e1a

    +
  • + +
  • a66a3f559892a806f55f6bc5125c1db07ec78413

    +
  • + +
  • 62340e7fbf517a5ac5f0083404010718d5f88de2

    +
  • +
+

TAA-1772

+

Workload Identity IAM binding fails before GKE pool exists

+

Terraform apply failed with Identity Pool does not exist (<project>.svc.id.goog) when creating Workload Identity bindings on Google service accounts, because those bindings ran before the GKE cluster existed. The cluster is what enables the project Workload Identity pool. This change flips module ordering: create the GKE cluster first, then run sdv_wi so google_service_account_iam_member can bind principals under PROJECT_ID.svc.id.goog after the pool exists.

+ +

terraform/modules/base/main.tf

+

File path: terraform/modules/base/main.tf

+ +
    +
  • Add depends_on = [module.sdv_gke_cluster] to module "sdv_wi".

    +
  • + +
  • Remove module.sdv_wi from module.sdv_gke_cluster’s depends_on (keep other dependencies unchanged).

    +
  • + +
  • Replace the old comment block on sdv_wi (about GKE modules depending on WI) with the new depends_on wiring.

    +
  • +
+

https://github.com/AGBG-ASG/acn-horizon-sdv/pull/1072

+
+
    +
  • 1b3786833bb6c54ac82aeb4cec699a619bec08ad

    +
  • +
+

TAA-1774

+

[Security] OSS Axios module update (1.15.0->1.16.0)

+

TAA-1774: Remove AXIOS/WAITON from deprecated portal and OpenAPI spec
Horizon-Portal will be removed at a later date and the OpenAPI spec is well out of date, so changes are largely pointless but help reduce search noise.

+

TAA-1774: Remove global wait-on/axios installs
Pin MTK Connect wait-on and axios in package.json (npm overrides) and run wait-on from the local install instead of global npm plus manual overlays. Centralises dependency management, makes security updates a single file change, and removes divergent risks having to regenerate Docker images and Cuttlefish instances.
https://github.com/AGBG-ASG/acn-horizon-sdv/pull/1077

+
+
    +
  • f59fb2e7ac7f4c74986fd5ae2060613adf3e9ba1

    +
  • + +
  • 0a087da2a099df97e5e72a13c94f63aec8cc6b29

    +
  • +
+

+

TAA-1727

+

[Horizon Dev Portal] HTTPRoute fails when Gateway sync wave runs before Application

+

This fix includes fix for deployment failing due to the Horizon-dev portal app's gateway http-route being provisioned before the app which caused sync failures.

+

gateway-horizon-dev-portal.yaml

+

File path: gitops/templates/gateway-horizon-dev-portal.yaml

+ +
    +
  • Update Gateway http-route sync wave to 7 (After horizon-dev-portal) to resolve sync issues.

    +
  • +
+
+
    +
  • 2ed778f4cbb36d22bce28ce4ca331c365bb5d9e5

    +
  • +
+

TAA-1672

+

Mitigate axios supply-chain exposure from unpinned global wait-on installs

+

Mitigate axios transitive-dependency vulnerability in wait-on global installs across Android, OpenBSW, and Cuttlefish pipelines. While TAA-1700 updated the direct axios dependency in package.json to 1.15.0, the globally installed wait-on CLI still resolves its own transitive axios via an unpinned caret range — leaving it exposed to supply-chain attacks (e.g. the axios 1.14.1 compromise) and CVE-2026-40175 (header injection → RCE / AWS IMDSv2 bypass, CVSS 10.0).

+

This PR adds defense-in-depth by:

+ +
    +
  1. Pinning wait-on to an explicit version (9.0.4) with --ignore-scripts to block malicious postinstall hooks.

    +
  2. + +
  3. Overlaying a known-safe axios@1.15.0 into wait-on's node_modules, ensuring the transitive dependency is also patched.

    +
  4. + +
  5. Parameterizing both versions (WAITON_VERSION, AXIOS_VERSION) through the full pipeline chain: Seed Job → Groovy DSL → Jenkinsfile → Dockerfile ARG / shell defaults → Helm values → Argo WorkflowTemplate → Sensor, so versions can be bumped without code changes.

    +
  6. +
+

Changes (30 files)

+

Seed Job & Jenkins Parameterization

+ +
    +
  • workloads/seed/Jenkinsfile — Add WAITON_VERSION (default 9.0.4) and AXIOS_VERSION (default 1.15.0) string parameters; propagate to Android Docker Image, CF Instance Template, OpenBSW Docker Image, and Utilities Docker Image downstream jobs.

    +
  • +
+

Android Docker Image Template

+ +
    +
  • Dockerfile — Add ARG WAITON_VERSION / ARG AXIOS_VERSION; pin wait-on install with --ignore-scripts; overlay axios into wait-on's node_modules.

    +
  • + +
  • Jenkinsfile — Pass WAITON_VERSION and AXIOS_VERSION as Docker build-arg.

    +
  • + +
  • groovy/job.groovy — Add WAITON_VERSION / AXIOS_VERSION string parameters to the Jenkins job DSL.

    +
  • + +
  • helm/values.yaml — Add waitonVersion / axiosVersion values.

    +
  • + +
  • helm/templates/workflow/workflowtemplates.yaml — Declare new workflow parameters.

    +
  • + +
  • helm/templates/workflow/_build.tpl — Pass parameters into the build container's Docker build-args.

    +
  • + +
  • helm/templates/workflow/sensors.yaml — Add waitonVersion / axiosVersion parameter declarations and webhook body → workflow parameter index mappings (indexes 7–8; existing parameters shifted accordingly).

    +
  • +
+

Cuttlefish Instance Template (Packer / VM)

+ +
    +
  • Jenkinsfile — Pass WAITON_VERSION and AXIOS_VERSION to Packer and init scripts.

    +
  • + +
  • groovy/job.groovy — Add WAITON_VERSION / AXIOS_VERSION string parameters.

    +
  • + +
  • cf_create_instance_template.sh — Accept and forward both versions to Packer variables.

    +
  • + +
  • cf_environment.sh — Export WAITON_VERSION / AXIOS_VERSION with defaults.

    +
  • + +
  • packer/cuttlefish.pkr.hcl — Declare Packer variables; pass through to startup script.

    +
  • + +
  • cf_host_initialise.sh — Pin wait-on install with --ignore-scripts; overlay safe axios.

    +
  • +
+

OpenBSW Docker Image Template

+ +
    +
  • Dockerfile — Add ARG WAITON_VERSION / ARG AXIOS_VERSION; pin wait-on install with --ignore-scripts; overlay axios into wait-on's node_modules.

    +
  • + +
  • Jenkinsfile — Pass WAITON_VERSION and AXIOS_VERSION as Docker build-arg.

    +
  • + +
  • groovy/job.groovy — Add WAITON_VERSION / AXIOS_VERSION string parameters.

    +
  • +
+

MTK Connect (Runtime)

+ +
    +
  • mtk_connect.sh — Add WAITON_VERSION / AXIOS_VERSION env defaults; pin wait-on install with --ignore-scripts; overlay safe axios into wait-on's global node_modules.

    +
  • +
+ +

AAOS Builder Helm

+ +
    +
  • workloads/android/pipelines/builds/aaos_builder/helm/values.yaml — Add waitonVersion / axiosVersion values.

    +
  • +
+

Horizon Portal UI

+ +
    +
  • ABFSCreateContainerForm.tsx — Add waitonVersion / axiosVersion fields to the ABFS container creation form.

    +
  • + +
  • SeedWorkloadsForm.tsx — Add waitonVersion / axiosVersion fields to the Seed Workloads form.

    +
  • + +
  • AndroidDockerImageTemplateForm.tsx — Add waitonVersion / axiosVersion fields to the Android Docker Image Template form.

    +
  • + +
  • OpenBSWDockerImageForm.tsx — Add waitonVersion / axiosVersion fields to the OpenBSW Docker Image form.

    +
  • +
+

API Documentation

+ +
    +
  • docs/api/v1/openapi.yaml — Add waitonVersion / axiosVersion properties to Android Docker Image, CF Instance Template, and OpenBSW Docker Image request schemas.

    +
  • + +
  • clients/horizon-portal/public/docs/api/v1/openapi.yaml — Same (portal copy).

    +
  • +
+
+
    +
  • 5a3e5f891461e2f8ce2eb9398aef67ce1f779658

    +
  • + +
  • 1e4575d0f0c6da76aba704dbeec7214914e0ea98

    +
  • + +
  • d34c028de7306a6fb5f7093b84caf6c4494fb798

    +
  • + +
  • e47c96e86927e08b81d4a462023631ed526ad096

    +
  • + +
  • 0f3878b20ce38a39dba46e0683b55701b6626a40

    +
  • + +
  • 5a73dfe6ded6198715ea6f5506b8ffb1e31d0047

    +
  • + +
  • d04da45c4aee5f27c09b0540ec51a31a6d98f9c5

    +
  • + +
  • 681c377b216231f263a7d11b276ad02bd53f87732

    +
  • + +
  • b6b51902c27acb75ddb0b446d1da4f2dc2aa46b2

    +
  • + +
  • 8d9cc9c7fadf5ed428a3c26601cebf348a04baca

    +
  • + +
  • ce23ad3111c28174d06b1e891c9dcf17455170ce

    +
  • +
+

TAA-1685

+

[Cloud Workstations] Horizon ASfP image build fails on Cuttlefish Bazel fetch (f2fs-tools / git.kernel.org)

+

Fixes the Horizon Android Studio for Platform (ASfP) Cloud Workstation image build when the Cuttlefish stage fails because Bazel cannot fetch f2fs_tools from git.kernel.org (timeouts, egress restrictions, or deprecated hosting). After cloning android-cuttlefish, the image build now rewrites the Bazel git_repository remote to the GitHub mirror before running build_packages.sh.

+

https://github.com/AGBG-ASG/acn-horizon-sdv/pull/992

+
+
    +
  • 3655d2a460d4f4f48b71faa2652af0bbe4534dcc

    +
  • +
+

TAA-1779

+

No access to signed urls

+

Added roles/iam.serviceAccountTokenCreator to argo-workflows SA roles in terraform/env/main.tf.
Added the same role to sub-env template in terraform/env/locals.tf.
Also added sub-env Workload Identity mapping for horizon-api KSA (<env>-horizon-api/horizon-api) in terraform/env/locals.tf so prefixed environments can impersonate the same signer GSA correctly.

+

https://github.com/AGBG-ASG/acn-horizon-sdv/pull/1076

+
+
    +
  • ffd47bfdf6a88b93c70807ec9fa8b85f31e01d66

    +
  • +
+

TAA-1761

+

[Security] R4.0.0: upgrade Jenkins plugins to address security issues

+

Security issues and bug fixes requiring plugin updates.

+

https://github.com/AGBG-ASG/acn-horizon-sdv/pull/1080

+
+
    +
  • 1f455f331c810ce6ddd1b0b6926e542e1a5527c5

    +
  • +
+

TAA-1778

+

[Security]  grpc-go module update (1.58.3 -> 1.81.0)

+

Addresses TAA-1778 by upgrading grpc-go in the horizon-api/horizon-api-app/ from v1.80.0 to v1.81.0.0 to resolve the reported security finding.

+

Updated terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/go.mod and go.sum

+

few other components where updated to newest versions:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

Component

+

New version

+

Type

+

cloud.google.com/go/storage

+

direct (promoted)

+

Promotion

+

http://google.golang.org/api

+

direct (promoted)

+

Promotion

+

http://github.com/cncf/xds/go

+

20260202

+

Patch

+

http://github.com/envoyproxy/go-control-plane/envoy

+

v1.37.0

+

Minor

+

http://github.com/envoyproxy/protoc-gen-validate

+

v1.3.3

+

Patch

+

http://github.com/go-jose/go-jose/v4

+

v4.1.4

+

Patch

+

http://go.opentelemetry.io/contrib/detectors/gcp

+

v1.42.0

+

Minor

+
+

horizon-api component
Security issue caused by vulnerability in grpc-go 1.58.3 module
fix for [Security] grpc-go module update (1.58.3 -> 1.81.0)

+

https://github.com/AGBG-ASG/acn-horizon-sdv/pull/1081

+
+
    +
  • f784fe49259880220db26a9bb772556906d2238b

    +
  • + +
  • 0e51da4a73f72137211d0449e9c9d78e475be191

    +
  • + +
  • b0695c0328d5552408c70529af22105afd5e629d

    +
  • +
+

TAA-1776

+

[Security]  golang.org/x/crypto module update (v0.25.0-v.0.50.0)

+

Addresses TAA-1776 by upgrading golang.org/x/crypto in the Horizon Dev Portal proxy from v0.25.0 to v0.50.0 to resolve the reported security finding.

+ +
    +
  • Updated terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/proxy/go.mod

    + +
      +
    • golang.org/x/crypto v0.25.0 -> v0.50.0

      +
    • + +
    • Go version 1.22 -> 1.25.0 (required by x/crypto v0.50.0)

      +
    • +
    +
  • + +
  • Updated terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/proxy/go.sum for the new crypto version.

    +
  • + +
  • Updated terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/Dockerfile

    + +
      +
    • Builder image golang:1.22-alpine -> golang:1.25-alpine to match module/toolchain requirements.

      +
    • +
    +
  • +
+

https://github.com/AGBG-ASG/acn-horizon-sdv/pull/1082

+
+
    +
  • 4bb44a76fd597fa461a729dce23d46242ab844eb

    +
  • +
+

TAA-1777

+

+

[Security] golang.org/x/crypto module update (v0.16.0-v.0.50.0)

+

Fixes TAA-1777 by remediating transitive golang.org/x/crypto v0.16.0 findings in both module-manager-app and workflow-namespace-drain-app, pinning resolution to v0.50.0.

+ +
    +
  • Updated:

    + +
      +
    • terraform/modules/sdv-container-images/images/module-manager/module-manager-app/go.mod

      +
    • + +
    • terraform/modules/sdv-container-images/images/module-manager/module-manager-app/go.sum

      +
    • + +
    • terraform/modules/sdv-container-images/images/module-manager/module-manager-app/Dockerfile

      +
    • + +
    • terraform/modules/sdv-container-images/images/workflow-namespace-drain/workflow-namespace-drain-app/go.mod

      +
    • + +
    • terraform/modules/sdv-container-images/images/workflow-namespace-drain/workflow-namespace-drain-app/go.sum

      +
    • + +
    • terraform/modules/sdv-container-images/images/workflow-namespace-drain/workflow-namespace-drain-app/Dockerfile

      +
    • +
    +
  • + +
  • Go toolchain bumped to 1.25.0 in both modules because golang.org/x/crypto v0.50.0 requires Go >= 1.25.

    +
  • + +
  • Builder images bumped from golang:1.22-alpine to golang:1.25-alpine so container builds do not fail with the updated Go requirement.

    +
  • + +
  • Added replace golang.org/x/crypto => golang.org/x/crypto v0.50.0 in both go.mod files to enforce the transitive security pin.

    + +
      +
    • In these modules, x/crypto is transitive-only and not directly imported, so a plain require ... // indirect is removed by go mod tidy (lazy loading).

      +
    • + +
    • replace is preserved through tidy and keeps the resolved graph at v0.50.0 for consistent scan/build results.

      +
    • +
    +
  • +
+

https://github.com/AGBG-ASG/acn-horizon-sdv/pull/1083

+
+
    +
  • 1262347c7dcc827bd0b8ffeadcf0ac0eb7b3b0d1

    +
  • +
+

TAA-1780

+

+

[Security] http://golang.org/x/crypto module update (v0.49.0-v.0.50.0)

+

Fixes TAA-1780 by upgrading golang.org/x/crypto in horizon-api-app from v0.49.0 to v0.50.0.

+

Changes

+ +
    +
  • Updated terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/go.mod

    + +
      +
    • golang.org/x/crypto v0.49.0 -> v0.50.0 (indirect)

      +
    • +
    +
  • + +
  • Updated terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/go.sum via go mod tidy.

    +
  • + +
  • No changes to Dockerfile, source code, Helm, or Terraform configuration.

    +
  • +
+

https://github.com/AGBG-ASG/acn-horizon-sdv/pull/1084

+
+
    +
  • 8f0d98e5cb6c5388b0fbce84f8d0c06f2cbdc1f6

    +
  • +
+

TAA-1762

+

+

[ESRlabs] [Security] OpenAPI Specification File Exposed

+

Fixes TAA-1762 by blocking public access to MCP Gateway Registry and auth-server OpenAPI/Swagger documentation endpoints on the mcp.<domain> Gateway route. This update ensures blocked documentation paths return a real 404, which is clearer for security scanners and confirms the API spec is not exposed.

+

Key Changes

+ +
    +
  • Added Gateway HTTPRoute rules to block:

    + +
      +
    • /docs

      +
    • + +
    • /redoc

      +
    • + +
    • /openapi.json

      +
    • + +
    • /openapi.yaml

      +
    • + +
    • /auth-server/docs

      +
    • + +
    • /auth-server/redoc

      +
    • + +
    • /auth-server/openapi.json

      +
    • + +
    • /auth-server/openapi.yaml

      +
    • +
    +
  • + +
  • Used PathPrefix + ReplacePrefixMatch because GKE Gateway rejects ReplaceFullPath and requires exactly one PathPrefix match per URLRewrite rule.

    +
  • + +
  • Routed blocked registry documentation paths to the auth-server fallback after rewriting to /__horizon_blocked__, because the registry backend returns SPA index.html with 200 for unknown paths, while auth-server returns a proper 404.

    +
  • +
+
+
    +
  • 7a9f5c883367270fe4f42dbc439836d82aaeb944

    +
  • + +
  • d844d7af68d860f358c9d562c75f9d0b8e77af96

    +
  • + +
  • 73be7f518e8dda54f547187b3c16b2937877c9ca

    +
  • + +
  • cc566d0b5439cd84e2153405822b7c32ce5ff61b

    +
  • +
+
+ +

Known Issues

+

TAA-1763 - Gemini Code Assist may require a license for use in workstation IDEs (especially ASfP)

+

Following recent changes to Gemini Code Assist, the pre-installed Code Assist plugin in the Cloud Workstation IDEs may now require an active Gemini Code Assist license before it will work. This has been observed consistently in Android Studio for Platform (ASfP), and intermittently in Android Studio and Code OSS depending on which features (agent mode, IDE-side MCP) are used.

+

Typical symptoms

+ +
    +
  • "selected project doesn't have valid license" in the project picker.

    +
  • + +
  • "missing a valid license for Gemini Code Assist" banner.

    +
  • + +
  • Agent mode failing with "There was a problem getting a response."

    +
  • +
+

If you hit this, the deploying team needs to:

+ +
    +
  1. Billing admin - purchase a Gemini Code Assist subscription on the platform's GCP billing account. Choose Enterprise if users need agent mode or IDE-side MCP (recommended for ASfP); Standard is chat-only.

    +
  2. + +
  3. Billing admin - enable automatic license assignment at Cloud Console → Gemini Admin → Code Assist → Settings. Seats then attach automatically the first time each user opens the IDE.

    +
  4. + +
  5. Project admin - grant per-user IAM on the platform's GCP project to every Code Assist user:

    + +
      +
    • roles/cloudaicompanion.user

      +
    • + +
    • roles/serviceusage.serviceUsageConsumer

      +
    • +
    +
  6. + +
  7. End user - sign into the IDE with the entitled email and select the platform's GCP project. This also enables cloudaicompanion.googleapis.com on the project automatically.

    +
  8. +
+

Workaround: the embedded Gemini CLI (gemini in the workstation terminal) is unaffected, does not consume a Code Assist seat, and supports MCP via gemini-mcp-agent.

+

+
+ + + + + + + + + + + + + + + + + + + + +

Platform

+

Horizon SDV

+

Version

+

Release 3.1.0

+

Date

+

13.03.2026

+
+ +

Summary

+

Horizon SDV 3.1.0 is the minor release which extends platform capabilities with support for Sub-environments and additional MCP server configuration for Android Studio and Android Studio for Platforms IDEs. Horizon 3.1.0 also delivers several critical bug fixes including security fixes for network configurations and vulnerabilities in application containers.

+

Rel.3.1.0 defines rules for Partner Contributions Repository and recommended directory structure for third party modules provided from external Horizon Partners which are documented in contributing.md file located in the /doc directory of Horizon SDV repository.

+

Horizon SDV 3.1.0 package offers fully verified and documented upgrade patch (from Rel.3.0.0 to Rel.3.1.0). (see details in /docs/guides/upgrade_guide_3_0_0_to_3_1_0.md)

+ +

New Features

+ + + + + + + + + + + + + + + + + + + +

ID

+

Feature

+

Description

+

TAA-1057

+

Support for Sub-Environments in Horizon SDV platform

+

+

Horizon SDV 3.1.0 introduces sub-environments: multiple isolated copies of the platform that run on the same GKE cluster as the main environment. Each sub-environment has its own namespaces (prefixed by sub-environment name, e.g. sub-jenkins, sub-keycloak), its own Argo CD instance, its own sub-domain (e.g. sub.<SUB_DOMAIN>.<HORIZON_DOMAIN>), and its own GCP Certificate Manager certificate, Secret Manager secrets, and Workload Identity service accounts. Sub-environments are defined entirely in terraform.tfvars via the sdv_sub_env_configs variable; no code changes are required to add or remove them. Typical use cases include giving teams isolated instances without extra clusters, testing platform changes on a branch before merge, and running a stable environment alongside a short-lived experimental one.

+

Changes

+ +
    +
  • Terraform: New variable sdv_sub_env_configs in terraform/env/terraform.tfvars (optional; defaults to empty map). Each key is the sub-environment name; each value supplies required Keycloak passwords and optional branch and manual_secrets.

    +
  • + +
  • Certificate Manager: DNS Authorization and certificate resources converted to for_each to support one certificate per sub-environment. Upgrade from 3.0.0 uses moved {} blocks and a name-preserving conditional to avoid destroying and recreating existing GCP resources.

    +
  • + +
  • Argo CD: Argo CD-related Kubernetes resources managed by Terraform converted to for_each. One Argo CD instance per sub-environment (e.g. helm_release.argocd_subenvs["sub"] in sub-argocd namespace). Upgrade from 3.0.0 uses moved {} blocks to migrate state without destroying live resources.

    +
  • + +
  • GCP: Per sub-environment: Workload Identity service accounts (e.g. gke-<SUB_ENV_NAME>-<app>-sa), Secret Manager secrets (prefixed <SUB_ENV_NAME>-), Certificate Manager certificate and DNS authorization for <SUB_ENV_NAME>.<SUB_DOMAIN>.<HORIZON_DOMAIN>, and Cloud DNS CNAME for certificate verification.

    +
  • + +
  • GitOps: Helm values namespacePrefix, isSubEnvironment, and environmentName drive namespace and resource naming. Cluster-scoped components (External Secrets Operator, Node Exporter, Kubescape Operator, Gerrit Operator) are gated with isSubEnvironment and remain single-instance; sub-environments use namespace-scoped resources and the shared operators.

    +
  • + +
  • Documentation: [Sub-Environment Deployment Guide](guides/sub_environments/sub_environment_deployment_guide.md) (configuration, deploy, access, destroy) and [Sub-Environment Developer Guide](guides/sub_environments/sub_environment_developer_guide.md) (architecture, adding apps, naming). Deployment guide referenced from main [Deployment Guide](deployment_guide.md).

    +
  • +
+

Action Required

+ +
    +
  • None for existing 3.0.0 users who do not use sub-environments. Upgrade path is described in [Upgrade Guide: 3.0.0 to 3.1.0](guides/upgrade_guide_3_0_0_to_3_1_0.md); follow post-upgrade steps (e.g. delete/recreate affected resources, sync with prune) as documented.

    +
  • + +
  • To use sub-environments: Add sdv_sub_env_configs to terraform/env/terraform.tfvars with at least keycloak_admin_password and keycloak_horizon_admin_password per sub-environment. Sub-environment names must be lowercase alphanumeric with hyphens, 1-4 characters. See [Sub-Environment Deployment Guide – Configuring Sub-Environments](guides/sub_environments/sub_environment_deployment_guide.md#configuring-sub-environments).

    +
  • +
+
+ +

Improved Features

+

+ + + + + + + + + + + + + + + + + + + +

TAA-1328

+

MCP server configuration caching by Android Studio and ASfP IDE

+

This improvement provides the MCP configuration caching by Android Studio and ASfP IDE that makes MCP requests by Gemini Code Assist use expired tokens.

+

MCP configuration caching in Android Studio and ASfP

+

The Android Studio and Android Studio for Platform IDEs cache the MCP configuration (mcp.json) for their current session.

+ +
    +
  • This means, if we store auth tokens in mcp.json and later update them, the IDE will still use the old tokens from its cache.

    +
  • + +
  • To fix this, a standard workaround has been implemented in gemini-mcp-agent using the --mcp-client-bridge mode where each MCP server configured in mcp.json spawns its own MCP-client bridge.

    +
  • + +
  • It transparently forwards requests from the IDE to the MCP server (and vice-versa), injecting a fresh authentication token each time from .gemini/settings.json. This ensures seamless access without needing to restart your IDE.

    +
  • + +
  • Note that, structure of mcp.json is now slightly different from settings.json as mcp.json now configures servers in a pseduo-stdio mode using command, args and env blocks instead of standard httpUrl block so that the client-bridge can proxy requests with latest token injection.

    +
  • +
+

Key Changes

+ +

gemini-mcp-setup.py

+ +
    +
  • Renamed gemini-mcp-setup.py to gemini-mcp-agent.py to reflect its upgraded feature set.

    +
  • + +
  • gemini-mcp-agent now provides an internal-use command option --mcp-client-bridge for IDEs like Android Studio (and ASfP) that cache configurations

    + +
      +
    • where each MCP server configured in mcp.json spawns its own MCP-client bridge.

      +
    • + +
    • The bridge uses stdio to communicate with the IDE, injects updated tokens from .gemini/settings.json, and forwards JSON-RPC requests to the MCP server over HTTPS (and vice-versa).

      +
    • + +
    • This solves the MCP config caching issue in such IDEs, ensuring seamless access without needing to restart your IDE.

      +
    • +
    +
  • + +
  • Updated mcp_setup.md guide for new features and improved clarity

    +
  • +
+

Cloud-WS images (all 3):

+ +
    +
  • added GOOGLE_CLOUD_PROJECT as dockerfile ARG and set as container ENV

    +
  • + +
  • passing value for GOOGLE_CLOUD_PROJECT from Jenkins env var CLOUD_PROJECT

    +
  • + +
  • Updated descriptions in jenkinsfile for all 3 cloud-ws groovy files

    +
  • + +
  • Yarn GPG key fix that caused build failure

    +
  • + +
  • simplified and optimized image layers

    +
  • +
+

More on gemini-mcp-agent changes

+ +
    +
  • new func discover_android_studio_mcp_file_path to find mcp.json if platform is Android Studio or ASFP and set the constant ANDROID_STUDIO_MCP_FILE_PATH

    +
  • + +
  • agent updates the mcp.json only when ANDROID_STUDIO_MCP_FILE_PATH holds a non-None value.

    +
  • + +
  • added update_android_studio_mcp_file which has slightly diff logic to update_gemini_cli_settings_file as mcp.json structure is diff from settings.json as mcp.json now defines MCP servers with command as this agent script with args --mcp-client-bridge and --mcp-server name. This option combo calls the new run_mcp_client_bridge function.

    +
  • + +
  • added new run_mcp_client_bridge function to read MCP JSON-RPC requests from android studio IDE (via stdio) and forward it to remote MCP server (via HTTPs)

    +
  • + +
  • updated is_managed_server function to accept server_http_url instead of entire block

    +
  • + +
  • renamed ensure_config_dir to ensure_configs_exist that always creates config files for gemini-cli and optionally for as/asfp only if the environment is as/asfp based

    +
  • + +
  • renamed update_gemini_config to update_gemini_cli_settings_file

    +
  • + +
  • added new env var ENV_FILE_PATH to store env file path

    +
  • + +
  • added new func load_env_config to load env vars from ENV_FILE_PATH or .env file in current dir or global fallback dir of ~/.gemini/.env

    +
  • + +
  • updated func update_android_studio_mcp_file to store env vars into mcp.json file for mcp-client-bridge processes to use them

    +
  • +
+

TAA-1334

+

Generate GitHub App private key PKCS#8 format via Terraform

+

Extension to the new simplified deployment flow for Horizon SDV introduced in Rel.3.0.0.

+ +
    +
  • PKCS#8 format of the GitHub App private key is created automatically by terraform.

    +
  • + +
  • The variable sdv_github_app_private_key_pkcs8 is removed.

    +
  • + +
  • PKCS#8 format of the GitHub App private key is stored in the GCP Secret Manager

    +
  • +
+
+ +

GCP changes [Google]

+

Google has changed Client Secret Handling and Visibility . This affects redeployments of the Horizon SDV platform if the Client Secret was not securely stored previously.

+

This secret is required by Keycloak for the Google Identity Provider (Client Secret). If the secrets do not match, OAuth 2.0 authentication will fail and users will lose access.

+ +

Solution:

+ +
    +
  • Create a new secret in Google Cloud:

    + +
      +
    • In Credentials, select the Horizon client secret

      +
    • + +
    • Disable the old secret and create a new one.

      +
    • + +
    • Download or copy the new secret and store it securely.

      +
    • +
    +
  • + +
  • Verify login (for apps from Landing Page) fail.

    +
  • + +
  • Update Keycloak:

    + +
      +
    • Go to Identity Provider → Google.

      +
    • + +
    • Update the Client Secret and save.

      +
    • +
    +
  • + +
  • Verify login works as expected.

    +
  • +
+ +

Documentation update

+ +
    +
  • Rel.3.1.0 provides with several updates in Horizon documentation including e.g. Horizon Deployment Guide (/docs/deployment_guide.md).

    +
  • + +
  • The new contributing.md document (/doc/contributing.md) defines rules for Partner Contributions Repository integration and recommended directory structure for third party modules provided from external Horizon Partners.

    +
  • + +
  • The new Upgrade Guide (/docs/guides/upgrade_guide_3_0_0_to_3_1_0.md) provide guideline for Rel.3.0.0 -> Rel.3.1.0 upgrade.

    +
  • +
+ +

Bug Fixes

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

ID

+

Bug

+

Description

+

SHA

+

TAA-1236

+

[Volvo] Google platform failures on jenkins-mtk-connect-apikey

+
+
    +
  • mtk-connect-post-key: add create_or_update_jenkins_secret() so the jenkins-mtk-connect-apikey secret is created if absent (CronJob or one-off can now establish the credential; previously only updated existing secret, causing "Could not find credentials entry" when mtk-connect-post-job had not run or had failed).

    +
  • + +
  • mtk-connect-post configure.sh: make DELETE curls non-fatal (|| true) so 404 on first run does not exit; remove if block so any real failure exits the job visibly.

    +
  • +
+
+
    +
  • ea84ef88c7236d582707601e368fd1803a3345c4

    +
  • +
+

TAA-1260

+

Sync Mirror pipeline hangs after modifying MIRROR_VOLUME_CAPACITY_GB during Infra creation

+
+
    +
  • Fixed issue where Filestore expansion (e.g., 4TB → 5TB) caused PVCs to remain stuck in Pending state with 0 capacity

    +
  • + +
  • Resolved Kubernetes binding conflicts caused by static PV/PVC provisioning without a StorageClass or CSI driver

    +
  • + +
  • Eliminated race conditions during resize where old PVCs were not released and PVs entered Failed state

    +
  • + +
  • Removed incompatible ReclaimPolicy=Delete usage on statically‑provisioned NFS volumes

    +
  • + +
  • Migrated Mirror storage from static PV/PVC management to Filestore CSI driver–based dynamic provisioning

    +
  • + +
  • Introduced new StorageClass with:

    + +
      +
    • filestore.csi.storage.gke.io provisioner

      +
    • + +
    • allowVolumeExpansion=true for online resize

      +
    • + +
    • ReclaimPolicy=Retain for data safety

      +
    • +
    +
  • + +
  • Simplified Terraform to manage only the PVC; CSI driver now owns PV lifecycle

    +
  • + +
  • Added safeguards to prevent volume downsizing, avoiding potential data loss

    +
  • + +
  • Standardized naming by removing legacy aosp references across configs and scripts

    +
  • +
+
+
    +
  • 86bee3badf422614629752a19bcf19d8555789ef

    +
  • +
+

TAA-1326

+

Cloud WS: Create Configuration fails for region other than europe-west1

+
+
    +
  • Parameter WS_REPLICA_ZONES as default value was partially hardcoded ({CLOUD_REGION}-b, -d) )For some zones eg “us-central1-d” is not existing ( currently us-central1-a, b, c, f) .

    +
  • + +
  • Implemented solution: If user will not add any replica_zone values The default value will retrieve all zones in region and automatically select the first two zones in current region

    +
  • +
+
+
    +
  • 1ea0c42ed4ccc2adcbae0126d34664af9599b79e

    +
  • + +
  • 71a7316c70873e57da6395ee51a0a87684fe5d08

    +
  • + +
  • 73d4f09c55f4130e1023df7546b53a37c42118cf

    +
  • + +
  • b8caa3676843d104b1e4fa7120dc76dbd6c9acfa

    +
  • +
+

TAA-1327

+

Cloud WS: Create Workstation pipeline fails (WS created but IAM user add fails)

+
+
    +
  • Fix: Ensure the workstation is fully created and ready before applying IAM bindings.
    This helps prevent concurrent IAM policy modification conflicts (409 errors)

    +
  • +
+
+
    +
  • 818bda3e6d5580c8b339b26dfe4b8dad5f28fdac

    +
  • + +
  • 18ee772625d5abd7377906c6c9865c7be91dec0f

    +
  • +
+

TAA-1340

+

[Jenkins] ABFS license no longer applied in deployment

+
+
    +
  • Simplified Horizon deployment dropped support of creating the ABFS license and as such, this must now be applied via Jenkins ABFS server and uploaders when action is APPLY.

    +
  • + +
  • Mask the license for security reasons.

    +
  • +
+
+
    +
  • 290bf5dea46d4f058d3fc96f8b67881c1efbdf9c

    +
  • +
+

TAA-1416

+

Remove obsolete ABFS secrets created via Terraform and GitOps

+

This PR removes deprecated ABFS license resources that were previously managed through Terraform and GitOps. The ABFS license is now exclusively managed by Jenkins, and all unused license-related resources and references have been cleaned up accordingly.

+

Details:

+ +
    +
  • Removed the Terraform variable and references for sdv_abfs_license_key_b64.

    +
  • + +
  • Removed the Kubernetes/secret resources and references for jenkins-abfs-license-b64.

    +
  • + +
  • Cleaned up all dependent configurations and references to ensure no residual usage of the removed license resources.

    +
  • +
+

Verification

+ +
    +
  • Deployed the platform after removing the deprecated ABFS license resources.

    +
  • + +
  • Confirmed no deployment or runtime issues related to ABFS licensing.

    +
  • +
+

Purpose

+

These changes simplify license management by consolidating ABFS license handling within Jenkins, reduce configuration complexity in Terraform and GitOps, and prevent confusion caused by unused or legacy license resources.

+
+
    +
  • a7c2bbbf6e1189b6a5119c983183bfb7001133e6

    +
  • +
+

TAA-1418

+

Fails on pkcs8_converter (jq missing)

+

TAA-1418: install jq dependency for pkcs8 conversion

+ +
    +
  • Resolves deployment failures in TAA-1418

    +
  • + +
  • Adds missing 'jq' binary required by the external terraform data source

    +
  • +
+
+
    +
  • b80c14290470ac483b8d1eb587acc20084b3a422

    +
  • +
+

TAA-1428

+

Password check incorrect (12 should mean 12)

+

TAA-1428: Correct password length check

+

If it states it should be at least 12 characters, ensure the check is correct, ie >= 12 not > 12!

+
+
    +
  • f29c70246fe52a4f880a2e332660157e1459af2e

    +
  • +
+

TAA-1429

+

argocd namespace stuck in 'Terminating'

+

Update deployment script with deletion of resources which cause the namespace argocd to be stuck in terminating state indefinitely.

+

Changes

+

deploy.sh

+

File path: tools/scripts/deployment/deploy.sh

+ +
    +
  • Added two new functions

    + +
      +
    • cleanup_gateways() - Deletes the GKE Gateway which triggers the deletion of backends, load balancers and NEGs.

      +
    • + +
    • cleanup_argocd() - Deletes all Apps created by horizon-sdv app to prevent it from being stuck in terminating state.

      +
    • +
    +
  • +
+
+
    +
  • d2d32295bc4580bf77fc6f59cb11301de1451636

    +
  • +
+

TAA-1430

+

Enable 'force_destroy' on buckets

+

Enable force_destroy for GCS buckets to destroy the buckets on Terraform destroy workflow even if it contains objects.

+

Changes

+

main.tf

+

File path: terraform/modules/sdv-gcs/main.tf

+ +
    +
  • Add force_destroy = true to enable force destruction of GCS buckets.

    +
  • +
+
+
    +
  • 211d4564d0265b38ee789dddca7708a8982502af

    +
  • +
+

TAA-1432

+

landingpage 'exec format error'

+

landingpage 'exec format error' fix

+

Ensure docker images are built for the target platform, not the architecture of the platform they are deployed on.

+
+
    +
  • 4322698a334d01c2c84ab72967537063b3c557ca

    +
  • +
+

TAA-1435

+

Cross architecture support

+

Cross architecture support fix.

+

Explicitly set Docker base image platform to linux/amd64 to ensure cross-architecture deployment consistency.

+
+
    +
  • 3ef9eb0b71f45bb920a9d62606118ee130895f76

    +
  • +
+

TAA-1438

+

Cuttlefish SSH key incorrectly created (blocks CF jobs)

+

Cuttlefish SSH Key Update: Regenerate VM Templates

+

This fix updates the SSH key generation algorithm used by Cuttlefish VM instances. To avoid any impact, regenerate the VM instance templates.

+

In Jenkins:

+ +
    +
  • Android Workflow → Environment → Docker Image Template → Build with Parameters

    + +
      +
    • Deselect NO_PUSH to ensure image is uploaded to registry.

      +
    • + +
    • Click Build

      +
    • +
    +
  • + +
  • Android Workflow → Environment → CF Instance Template → Build with Parameters

    + +
      +
    • Set ANDROID_CUTTLEFISH_REVISION=main

      +
    • + +
    • Click Build

      +
    • + +
    • Repeat for the tagged version of Android Cuttlefish

      +
    • +
    +
  • + +
  • Android Workflow → Environment → CF Instance Template ARM64 → Build with Parameters

    + +
      +
    • Repeat for ARM64 if enabled.

      +
    • + +
    • Set ANDROID_CUTTLEFISH_REVISION=main

      +
    • + +
    • Click Build

      +
    • + +
    • Repeat for the tagged version of Android Cuttlefish

      +
    • +
    +
  • +
+

If SSH key issues appear in any of the following jobs, regenerate the instance templates to ensure the latest keys are installed:

+ +
    +
  • Android Workflow → Environment → Development Test Instance

    +
  • + +
  • Android Workflow → Builds → Gerrit

    +
  • + +
  • Android Workflow → Tests → CVD Launcher

    +
  • + +
  • Android Workflow → Tests → CTS Execution

    +
  • +
+
+
    +
  • eb61aefb3e86a1e16022708a13b0657eaf5b79f0

    +
  • + +
  • 03f52993fbf637c084e1db0f61be65f21f5c2853

    +
  • + +
  • 172781210fba6573434ba8e9b6da2b68b0b206d3

    +
  • + +
  • 501e12e97e89e26eb74fa7c855ca15b3e03921a0

    +
  • + +
  • d80ccf7323c22d2b85a2f4a8d09be4b1983c95e9

    +
  • + +
  • 5442aecc9a0cd98ef7b98699f095b0b9332f3e9e

    +
  • +
+

TAA-1441

+

Finalize cross architecture support - R31.0

+

Updates in deployment scripts and containers to emulate linux/amd64

+

Changes

+

container-deploy.sh

+

File path: tools/scripts/deployment/container-deploy.sh

+ +
    +
  • Update the script to run the deployment container with linux/amd64 emulation pinned.

    +
  • +
+

Dockerfile

+

File path: tools/scripts/deployment/container/Dockerfile

+ +
    +
  • Update the Dockerfile to be built for linux/amd64.

    +
  • +
+
+
    +
  • 076c2c57434c2596e2db44ffb60e4c435f55b1a6

    +
  • +
+

TAA-1443

+

Gerrit MCP Server issues

+

Fix syntax error for gerrit-mcp-server-config causing gerrit-mcp-server deployment errors.

+

Changes

+

gerrit-mcp-server.yaml

+

File path: gitops/apps/gerrit-mcp-server/templates/gerrit-mcp-server.yaml

+ +
    +
  • Remove - causing syntax issues.

    +
  • +
+
+
    +
  • e6e2375372b4b16ce8d78a017818989ee911d954

    +
  • +
+

TAA-1446

+

TF OpenSSH conversion failing

+

Fixed a bug where the OpenSSH key was not being updated after the initial RSA key creation.

+

Replaced null_resource with terraform_data and added a timestamp trigger to force an idempotent conversion check on every run. This ensures that if an RSA key exists without the OpenSSH format, the conversion logic is triggered, while the grep check protects against unnecessary overwrites.

+
+
    +
  • a1f7ce4beaa59dd9acbd09a5c2571cbb8b5af2b8

    +
  • +
+

TAA-1447

+

Shell Script Permission Denied

+

Update Dockerfiles for sdv-container-images module which when built with Terraform as a non-root user causes permission denied error for configure.sh

+

Changes

+

Resolve permission related issues.

+

File paths:

+ +
    +
  • Grafana Post: terraform/modules/sdv-container-images/images/grafana/grafana-post/Dockerfile

    +
  • + +
  • Keycloak Post Argo CD: terraform/modules/sdv-container-images/images/keycloak/keycloak-post-argocd/Dockerfile

    +
  • + +
  • Keycloak Post Gerrit: terraform/modules/sdv-container-images/images/keycloak/keycloak-post-gerrit/Dockerfile

    +
  • + +
  • Keycloak Post Grafana: terraform/modules/sdv-container-images/images/keycloak/keycloak-post-grafana/Dockerfile

    +
  • + +
  • Keycloak Post Headlamp: terraform/modules/sdv-container-images/images/keycloak/keycloak-post-headlamp/Dockerfile

    +
  • + +
  • Keycloak Post Jenkins: terraform/modules/sdv-container-images/images/keycloak/keycloak-post-jenkins/Dockerfile

    +
  • + +
  • Keycloak Post MCP Gateway Resgistry: terraform/modules/sdv-container-images/images/keycloak/keycloak-post-mcp-gateway-registry/Dockerfile

    +
  • + +
  • Keycloak Post MTK Connect: terraform/modules/sdv-container-images/images/keycloak/keycloak-post-mtk-connect/Dockerfile

    +
  • + +
  • Keycloak Post: terraform/modules/sdv-container-images/images/keycloak/keycloak-post/Dockerfile

    +
  • + +
  • MTK Connect Post Key: terraform/modules/sdv-container-images/images/mtk-connect/mtk-connect-post-key/Dockerfile

    +
  • + +
  • LandingPage App: terraform/modules/sdv-container-images/images/landingpage/landingpage-app/Dockerfile

    +
  • +
+
+
    +
  • 1e1532c5ca5a2a41f8a20ceaf9012f868947aed4

    +
  • +
+

TAA-1450

+

High severity violation of security rules - "GCP DNS zones DNSSEC disabled" #4

+

DNSSEC support in GCP DNS zones enabled by default.

+
+
    +
  • 363659c78c41d6a3db7cf6877ec7320eb2b443a0

    +
  • +
+

TAA-1453

+

Vulnerabilities in /horizon-sdv/landingpage-app container

+
+
    +
  • CVE-2025-48174 is fixed in 1.3.0 for libavif

    +
  • + +
  • CVE-2026-22801 is fixed in 1.6.54-r0 for libpng

    +
  • + +
  • CVE-2026-22695 is fixed in 1.6.54-r0 for libpng

    +
  • +
+
+
    +
  • a2b3bbb91091cc3c9e99014c1acacac6855bce3a

    +
  • +
+

TAA-1457

+

Vulnerabilities in /horizon-sdv/keycloak-post-headlamp container

+

32 Vulnerabilities fixed fixed in keycloak-post-headlamp container. Base OS Change - node:22.13.0 → node:22-bookworm

+

Base Image Changes:

+ +
    +
  • debian:12.12debian:12.13

    +
  • + +
  • node:22.13.0node:22-bookworm (includes Debian 12.13)

    +
  • + +
  • python:3.9-slimpython:3.9-slim-bookworm (explicit)

    +
  • +
+
+
    +
  • a2b3bbb91091cc3c9e99014c1acacac6855bce3a

    +
  • +
+

TAA-1458

+

Vulnerabilities in /horizon-sdv/keycloak-post-grafana container

+

32 Vulnerabilities fixed in keycloak-post-grafana container. Base OS Change - Node:22.13.0 → node:22-bookworm

+

Base Image Changes:

+ +
    +
  • debian:12.12debian:12.13

    +
  • + +
  • node:22.13.0node:22-bookworm (includes Debian 12.13)

    +
  • + +
  • python:3.9-slimpython:3.9-slim-bookworm (explicit)

    +
  • +
+
+
    +
  • a2b3bbb91091cc3c9e99014c1acacac6855bce3a

    +
  • +
+

TAA-1459

+

Vulnerabilities in /horizon-sdv/keycloak-post-gerrit container

+

33 Vulnerabilities fixed in keycloak-post-gerrit container. Base OS Change - Node:22.13.0 → node:22-bookworm

+

Base Image Changes:

+ +
    +
  • debian:12.12debian:12.13

    +
  • + +
  • node:22.13.0node:22-bookworm (includes Debian 12.13)

    +
  • + +
  • python:3.9-slimpython:3.9-slim-bookworm (explicit)

    +
  • +
+
+
    +
  • a2b3bbb91091cc3c9e99014c1acacac6855bce3a

    +
  • +
+

TAA-1460

+

Vulnerabilities in /horizon-sdv/keycloak-post-argocd container

+

33 Vulnerabilities fixed in keycloak-post-argocd container. Base OS Change - Node:22.13.0 → node:22-bookworm

+

Base Image Changes:

+ +
    +
  • debian:12.12debian:12.13

    +
  • + +
  • node:22.13.0node:22-bookworm (includes Debian 12.13)

    +
  • + +
  • python:3.9-slimpython:3.9-slim-bookworm (explicit)

    +
  • +
+
+
    +
  • a2b3bbb91091cc3c9e99014c1acacac6855bce3a

    +
  • +
+

TAA-1461

+

Vulnerabilities in /horizon-sdv/keycloak-post container

+

33 Vulnerabilities fixed in keycloak-post container. Base OS Change - Node:22.13.0 → node:22-bookworm

+

Base Image Changes:

+ +
    +
  • debian:12.12debian:12.13

    +
  • + +
  • node:22.13.0node:22-bookworm (includes Debian 12.13)

    +
  • + +
  • python:3.9-slimpython:3.9-slim-bookworm (explicit)

    +
  • +
+
+
    +
  • a2b3bbb91091cc3c9e99014c1acacac6855bce3a

    +
  • +
+

TAA-1462

+

Vulnerabilities in /horizon-sdv/grafana-post container

+

33 Vulnerabilities fixed in keycloak-post container. Base OS Change-Node:22.13.0 → node:22-bookworm

+

Base Image Changes:

+ +
    +
  • debian:12.12debian:12.13

    +
  • + +
  • node:22.13.0node:22-bookworm (includes Debian 12.13)

    +
  • + +
  • python:3.9-slimpython:3.9-slim-bookworm (explicit)

    +
  • +
+
+
    +
  • a2b3bbb91091cc3c9e99014c1acacac6855bce3a

    +
  • +
+

TAA-1463

+

Vulnerabilities in /horizon-sdv/gerrit-post container

+

7 Vulnerabilities fixed in gerrit-post container. Base OS Change - Debian 12.12 → Debian 12.13

+

Base Image Changes:

+ +
    +
  • debian:12.12debian:12.13

    +
  • + +
  • node:22.13.0node:22-bookworm (includes Debian 12.13)

    +
  • + +
  • python:3.9-slimpython:3.9-slim-bookworm (explicit)

    +
  • +
+
+
    +
  • a2b3bbb91091cc3c9e99014c1acacac6855bce3a

    +
  • +
+

TAA-1455

+

Vulnerabilities in /horizon-sdv/keycloak-post-mtk-connect container

+

32 Vulnerabilities fixed fixed in keycloak-post-mtk-connect container. Base OS Change - node:22.13.0 → node:22-bookworm

+

Base Image Changes:

+ +
    +
  • debian:12.12debian:12.13

    +
  • + +
  • node:22.13.0node:22-bookworm (includes Debian 12.13)

    +
  • + +
  • python:3.9-slimpython:3.9-slim-bookworm (explicit)

    +
  • +
+
+
    +
  • a2b3bbb91091cc3c9e99014c1acacac6855bce3a

    +
  • +
+

TAA-1452

+

Vulnerabilities in /horizon-sdv/mtk-connect-post container

+

5 Vulnerabilities fixed in gerrit-post container. Base OS Change - Debian 12.12 → Debian 12.13

+

Base Image Changes:

+ +
    +
  • debian:12.12debian:12.13

    +
  • + +
  • node:22.13.0node:22-bookworm (includes Debian 12.13)

    +
  • + +
  • python:3.9-slimpython:3.9-slim-bookworm (explicit)

    +
  • +
+
+
    +
  • a2b3bbb91091cc3c9e99014c1acacac6855bce3a

    +
  • +
+

TAA-1468

+

High severity violation of security rules "GCP GKE Application-layer Secrets encryption disabled " #7

+

KMS can be deployed based on settings in terraform.tfvars - (sdv_enable_kms_encryption = false).

+

KMS implementation details:

+ +
    +
  • It is possible to use KMS to encrypt kubernetes secrets (“Application-layer secrets encryption” option in GKE)

    +
  • + +
  • If enabled – a KMS keyring is created, then a symmetric key (at version 1) is created inside the keyring

    +
  • + +
  • Encryption is fully transparent to the cluster

    +
  • + +
  • Once key is created – it is not easy to destroy it, it is rather that version 2 of the key will be created, and previous version 1 even if marked “destroy” – will be gone after 30 days.

    +
  • + +
  • Once keyring is created – IT IS NOT POSSIBLE TO DESTROY IT , so it makes trouble in terraform state when created and tried to delete it later on

    +
  • + +
  • KMS feature is disabled by default.

    +
  • + +
  • Keyring can easily be deleted only if entire GCP project is deleted.

    +
  • +
+
+
    +
  • 4ea1c55f90d22d77d74a2206c7c326c3dfeef495

    +
  • +
+

TAA-1475

+

[Cuttlefish] OS Login Cleanup Script Errors - Improper Parsing & Excessive Latency

+

Avoid issues with using table that can lead to erroneous values leading to us delaying 1m per loop and taking too long.

+

Make it a function so we can use elsewhere if required.

+
+
    +
  • 5442aecc9a0cd98ef7b98699f095b0b9332f3e9e

    +
  • +
+

TAA-1481

+

mtk-connect-post-key Post-job container image build fails

+

The permission issue which causes the container image build to fail has been resolved.

+

Changes

+

Dockerfile

+

File path: terraform/modules/sdv-container-images/images/mtk-connect/mtk-connect-post-key/Dockerfile

+ +
    +
  • Add --chown=appuser:appuser to fix permission issues.

    +
  • +
+
+
    +
  • ef72216ba232586dea96306431a8860b64b9d5e5

    +
  • +
+

TAA-1482

+

Terraform destroy fails to delete VPC

+

This merge fixes the issue which cause terraform destroy to fail due to the failure in deletion of the VPC sdv-network caused due to remaining NEGs (Network Endpoint Groups).

+

Changes

+

deploy.sh

+

File path: tools/scripts/deployment/deploy.sh

+ +
    +
  • Update the script's cleanup_gateways() function to also remove http-routes which triggers the deletion of NEGs.

    +
  • +
+
+
    +
  • d24100db5874a9591404fe522be1f39617448831

    +
  • +
+

TAA-1492

+

Refactor Argo CD Application Lifecycle to Terraform-Native Cascading Delete

+

Update the Terraform module sdv-gke-apps module to enable cascading delete for the App of Apps horizon-sdv (argocd_application) and update dependency chain for the module sdv-gke-cluster.

+

Changes

+

main.tf

+

File path: terraform/modules/base/main.tf

+ +
    +
  • Update the module sdv-gke-cluster with depency on sdv_certificate_manager and sdv_ssl_policy to enable deletion of GKE cluster before deletion of SSL Policy and Certificate Manager Certificates to avoid issues or errors while running Terraform destroy workflow.

    +
  • +
+

main.tf

+

File path: terraform/modules/sdv-gke-apps/main.tf

+ +
    +
  • Update dependency, add required finalizer to enable cascading delete for the horizon-sdv app.

    +
  • + +
  • Add wait= true to ensure complete deletion of horizon-sdv app before Terraform destroy workflow proceeds to destroy other resources in the module.

    +
  • +
+

Dockerfile

+

File path: tools/scripts/deployment/container/Dockerfile

+ +
    +
  • Remove kubectl from Dockerfile as it is no longer required.

    +
  • +
+

deploy.sh

+

File path: tools/scripts/deployment/deploy.sh

+ +
    +
  • Remove kubectl operation from deploy.sh as it is no longer required to perform clean-up activities.

    +
  • +
+

+
+
    +
  • d438544bd1469a8aec19bf31fa35ecdfbb3648d1

    +
  • + +
  • 7f1486291a1e81bb4fdd1d55c77c54d05097ec5c

    +
  • + +
  • f81ba04d48ab5b7b9f8f59cd85b2acc14252116c

    +
  • +
+

TAA-1493

+

Cloud-WS Image Builds: Yarn GPG Key Issue

+

Added Yarn GPG key refresh before first apt-get update in all Dockerfiles

+

+

TAA-1494

+

Kubernetes NetworkPolicies update breaks deployment

+

Missing closing brace breaking deployment.

+
+
    +
  • c95c4c1cbb6ff7f1e47a296868fbc094aa9b619b

    +
  • +
+

TAA-1495

+

Security hardening breaks deployment

+

An input variable with the name "sdv_dns_dnssec_enabled" has not been declared. This variable can be declared with a variable "sdv_dns_dnssec_enabled" {} block.

+
+
    +
  • 781c30d3e9c9f76c52e508cb4da2f0e7cf0fc1eb

    +
  • +
+

TAA-1498

+

Terraform local-exec fails because gcloud project is not explicitly set in script

+

Gcloud project is explicitly set in script

+
+
    +
  • 4114bbaefb3305216541cce6a21f5874ff647de8

    +
  • +
+

TAA-1499

+

Terraform destroy blocks redeployment when KMS is enabled (sdv_enable_kms_encryption = true) 

+

Several fixes for KMS deployment

+
+
    +
  • fe8c58c57f440cbebb32d6ad48b567245f3a07e6

    +
  • +
+

TAA-1507

+

[Jenkins] CF instances - Fails to connect via ssh

+
+
    +
  • Firewall: allow SSH to Cuttlefish from GKE node range (10.1.0.0/24).

    +
  • + +
  • Jenkins: allow controller egress SSH to Cuttlefish (22); allow agent
    SSH to Cuttlefish.

    +
  • +
+
+
    +
  • b65cda8a4af97e788af259396445415c243d0919

    +
  • +
+

TAA-1508

+

[Jenkins] Fix Jenkins startup and Gerrit connectivity

+

Set noConnectionOnStartup: true for Gerrit so Jenkins starts and the UI is available without waiting for Gerrit; the plugin connects when Gerrit is reachable.

+

Add allow-jenkins-controller-egress-to-gerrit NetworkPolicy so the
controller can reach Gerrit on 29418 (SSH) and 8080 (HTTP). Default-deny had limited controller egress to 80/443, so the Gerrit Trigger never connected.

+
+
    +
  • b6cb82e5122502f2225d2511d227e6715074e8f2

    +
  • + +
  • 667a01271394ac723922cc321da365a78f62b915

    +
  • +
+

TAA-1517

+

[Cloud-WS] terminal monospace rendering & gemini-mcp-agent executable broken entrypoint

+

Fixes applied

+ +
    +
  • move gemini-mcp-agent shebang to line 1 so the binary executes with python

    +
  • + +
  • install fonts-dejavu-core in android-studio, asfp, and code-oss images

    +
  • + +
  • set GNOME Terminal dconf defaults (DejaVu Sans Mono 12, cell width/height scale 1.0) for desktop images

    +
  • + +
  • run dconf update during image setup to apply terminal defaults

    +
  • +
+

Minor changes

+ +
    +
  • updated docs/guides/mcp_setup.md for clear info on gemini-mcp-agent and mcp servers settings in android studio IDE

    +
  • +
+
+
    +
  • 101a10e02cca3979f2d3633f28ccd33fef69e39d

    +
  • + +
  • 9f8f771b984319b687eb9d0739be2ae725094444

    +
  • +
+

TAA-1528

+

ABFS server and uploader: SSH on port 22 blocked; get_server_details / get_uploader_details and Console SSH fail.

+

Code in this PR fixes port 22 opening.
And deployment issue which fixes "Error: googleapi: Error 400: The network policy addon must be enabled before updating the nodes." in file terraform/modules/sdv-gke-cluster/main.tf

+
+
    +
  • f8758c356c6e376c2548340614f6fcdd3fe56232

    +
  • + +
  • 4e974f84bb0c6ea5c209ec9e14e918ba25260a3c

    +
  • +
+

TAA-1529

+

Pin ABFS build node pool to a fixed GKE version so CASFS kernel module stays compatible

+

This PR pins the ABFS build node pool to a configurable GKE version to ensure CASFS kernel compatibility and prevent breakage caused by automatic node upgrades.

+

Details

+ +
    +
  • Introduced sdv_abfs_build_node_pool_version variable to configure the ABFS build node pool GKE version.

    +
  • + +
  • Set the node pool version attribute using this variable to pin the node image and kernel.

    +
  • + +
  • Replaced release channel usage with an explicit cluster version (sdv_cluster_version) to allow disabling auto-upgrade on the ABFS node pool.

    +
  • + +
  • Updated terraform.tfvars and terraform.tfvars.sample with pinned values (e.g. 1.32.7-gke.1079000).

    +
  • +
+

Purpose

+

CASFS is a kernel module and must match the running node kernel. By pinning the ABFS node pool GKE version, we ensure the kernel remains stable and compatible, preventing unexpected failures caused by GKE auto-upgrades.

+
+
    +
  • f81bc7a22a434fa578190b2c28b03f5c0a9d23b6

    +
  • + +
  • be32e04e32df4098ffb8b27bea745008feb44916

    +
  • + +
  • 5f2625760dabe05e85e3936cef0823161163a4ae

    +
  • + +
  • f4d7724799bcb36732cfcd2d56ff2468ee1f1900

    +
  • + +
  • 44ae1d9a659a9d49f8f3dba32c791caa57b52440

    +
  • + +
  • 30d8eb8d3309306cfb8e37e021f18c44e80b1bcc

    +
  • + +
  • f04bf569d1d0e08cdb3d5e6040b0e1c3ecdb35d2

    +
  • +
+

TAA-1535

+

 GKE deployment fails on first run due to STABLE release channel conflict

+

Fix the error Error: error creating NodePool: googleapi: Error 400: Auto_upgrade must be true when release_channel STABLE is set.

+

GCP requires auto_upgrade = true on node pools when a named release channel (STABLE/REGULAR/RAPID with REGULAR being the default option if release channel is unset) is active.
Setting channel = "UNSPECIFIED" explicitly opts the cluster out of any release channel, removing this constraint and allowing Terraform to pin versions directly.

+

Also formatted all Terraform files in terraform/ for alignment consistency (no logic changes).

+

Changes

+

terraform/modules/sdv-gke-cluster/main.tf

+ +
    +
  • Add release_channel { channel = "UNSPECIFIED" } block so the GCP API treats the cluster as unenrolled from any release channel.

    +
  • +
+

tools/scripts/deployment/deploy.sh

+ +
    +
  • Remove the unenroll_cluster_release_channel function as the release channel is now managed declaratively by Terraform, making the gcloud workaround obsolete.

    +
  • +
+
+
    +
  • 4a81e523ede0e405465dbe366148a866f571b624

    +
  • +
+

TAA-1569

+

Gerrit-Operator in ArgoCD application goes into Unknown sync state and the Gerrit application fails to sync

+

Update gerrit-operator repoURL from Googlesource to GitHub, avoiding rate limits and fixing issues with gerrit-operator deployment on fresh platforms.

+

Changes

+

gerrit-operator.yaml

+

File path: gitops/templates/gerrit-operator.yaml

+ +
    +
  • Update repoURL

    +
  • +
+
+
    +
  • 37709c24d51326d61cd2da2c833a56af2b0e29b0

    +
  • +
+

TAA-1570

+

Terraform workloads Service Account name mismatch in GCP and k8s

+

Service Account sa7 name in terraform/env/main.tf should be gke-tf-wl-sa instead of current value of gke-terraform-workloads-sa to match with other instances of the SA in yaml files.

+
+
    +
  • d055ccc982ff4ced993dd99a6a359cda5b6b571d

    +
  • +
+

TAA-1573

+

terraform apply fails with Error 400 when removing a sub-environment due to cert map referenced by TargetHTTPSProxy

+

This PR resolves two issues affecting the sandbox environment:

+ +
    +
  • Fix terraform apply Error 400 on sub-environment removal - Previously, each environment (main + each sub-env) created its own google_certificate_manager_certificate_map via a for_each loop. When a sub-environment was removed, Terraform would attempt to delete its cert map while it was still referenced by the TargetHTTPSProxy, causing a 400 error. All certificates (main env + sub-envs) are now consolidated into a single cert map (horizon-sdv-map), eliminating the per-environment map lifecycle issue.

    +
  • + +
  • Enable GKE main node pool autoscaling - The sdv_main_node_pool previously had a static node count with no autoscaling. Autoscaling has been enabled to allow the cluster to scale up when resource pressure occurs (e.g. Gerrit pod scheduling failures), with a configurable min/max range (default: 1-6 nodes).

    +
  • +
+

Changes:

+

Certificate Manager Consolidation

+ +
    +
  • terraform/modules/base/locals.tf - Replaced per-environment cert_domains_per_env map with a single flat cert_domains map merging main and sub-env domains.

    +
  • + +
  • terraform/modules/base/main.tf - Removed for_each from module.sdv_certificate_manager, calling it once with all domains. Updated dns_auth_records reference accordingly.

    +
  • + +
  • terraform/modules/sdv-certificate-manager/main.tf - Hardcoded cert map name to horizon-sdv-map so it is stable across all environments.

    +
  • + +
  • gitops/templates/gateway.yaml - Updated networking.gke.io/certmap annotation to reference the fixed name horizon-sdv-map instead of the namespaced name.

    +
  • +
+

Main Node Pool Autoscaling

+ +
    +
  • terraform/modules/sdv-gke-cluster/main.tf - Enabled autoscaling block on sdv_main_node_pool using min_node_count / max_node_count variables.

    +
  • + +
  • terraform/modules/sdv-gke-cluster/variables.tf - Added node_pool_min_node_count (default: 1) and node_pool_max_node_count (default: 6).

    +
  • + +
  • terraform/modules/base/variables.tf - Added sdv_cluster_node_pool_min_node_count and sdv_cluster_node_pool_max_node_count to expose these as configurable inputs.

    +
  • +
+
+
    +
  • b010250548f9df5ff7db2afd89da96acfbfa5174

    +
  • + +
  • 831a59f8e9c4f8f8de5b5b9d525acb3b29426641

    +
  • +
+

TAA-1579

+

Cloud WS: Create Config pipeline fails due to inconsistent order of resource creation

+
+
    +
  • Fixed Terraform apply failures caused by google_workstations_workstation_config_iam_binding executing before the target workstation config was fully created

    +
  • + +
  • Resolved consistent 404 Resource Not Found errors from GCP IAM API due to premature policy application

    +
  • + +
  • Identified missing dependency in Terraform graph caused by using each.key (raw input string) for workstation_config_id

    +
  • + +
  • Corrected implicit dependency handling by replacing hardcoded each.key with a direct reference to the workstation config resource attribute

    +
  • + +
  • Ensured Terraform now waits for successful workstation config provisioning before applying IAM bindings

    +
  • + +
  • Eliminated parallel execution race condition between workstation config creation and IAM policy attachment

    +
  • +
+

06bbd1cf74d6e47993c0d394e441ae96ea722c8c

+

TAA-1601

+

AAOS Builder: Build that uses mirror for repo sync fails because of empty variable `MIRROR_DIR_NAME`

+

Fixes AOSP mirror path resolution in Android Jenkins pipelines by using AOSP_MIRROR_DIR_NAME when constructing MIRROR_DIR_FULL_PATH.

+

Pipeline parameters are defined as AOSP_MIRROR_DIR_NAME, but Jenkinsfiles were reading MIRROR_DIR_NAME.
This mismatch could produce an invalid mirror path when USE_LOCAL_AOSP_MIRROR=true.

+

Change

+

Updated Jenkinsfiles to build mirror path with:
.../${AOSP_MIRROR_DIR_NAME} (instead of .../${MIRROR_DIR_NAME}).

+
+
    +
  • 952611a5c6e8ee26ff25488e03904bbe5822cc73

    +
  • +
+

TAA-1602

+

ExternalDNS does not update apex A record when load balancer IP changes

+

ExternalDNS was not updating the apex domain A record (e.g. <env_name>.horizon-sdv.com) when the Gateway load balancer was recreated, only subdomains such as mcp.<env_name>.horizon-sdv.com were updated. ExternalDNS only updates records it owns, and ownership is stored in TXT records. With the default TXT registry, no valid ownership TXT was created for the zone apex, so the apex A record was never updated. This change sets txtPrefix: "%{record_type}-." so the ownership TXT is created in the same zone and ExternalDNS can own and update the apex A record.

+

Changes

+

external-dns.yaml

+

File path: gitops/templates/external-dns.yaml

+ +
    +
  • Add txtPrefix: "%{record_type}-." so ExternalDNS can create the heritage TXT for the apex and update the apex A record when the LB IP changes.

    +
  • +
+
+
    +
  • 5e585c4f1e9548a7dbc616fc990d6313725a480f

    +
  • +
+

TAA-1605

+

cloud-ws/gemini-cli/gemini-mcp-agent: MCP tool calls fail after some time in gemini-cli due to JWT token caching

+

This fix hardens and standardizes how MCP authentication is handled across Gemini clients by using mcp-client-bridge for registry-managed servers, instead of relying on cached config tokens.
It also updates setup documentation to reflect the actual runtime model and adds clearer operational guidance for Android Studio/ASfP cache reload behavior.

+

Changes

+

Command-based MCP entries for registry-managed servers

+ +
    +
  • Registry-managed servers are now written as command + args + env bridge entries instead of static httpUrl + headers token entries.

    +
  • + +
  • This is applied in both:

    + +
      +
    • update_gemini_cli_settings_file(...)

      +
    • + +
    • update_android_studio_mcp_file(...)

      +
    • +
    +
  • +
+

Unified bridge entry generation

+ +
    +
  • Added reusable helpers:

    + +
      +
    • build_bridge_env_payload()

      +
    • + +
    • build_bridge_server_entry(...)

      +
    • + +
    • get_entry_http_url(...)

      +
    • +
    +
  • + +
  • Added managed-entry marker: MCP_GATEWAY_REGISTRY_MANAGED=1.

    +
  • +
+

Bridge now injects auth from token file, not config headers

+ +
    +
  • run_mcp_client_bridge(...) now obtains auth via token file flow (~/.gemini/mcp-gateway-registry-token.json) using non-interactive refresh path.

    +
  • + +
  • Removed dependency on cached settings.json bearer values for bridge auth.

    +
  • +
+

Transport compatibility for Gemini clients

+ +
    +
  • Bridge now supports both:

    + +
      +
    • MCP stdio framed protocol (Content-Length headers)

      +
    • + +
    • NDJSON mode (legacy behavior)

      +
    • +
    +
  • + +
  • Added:

    + +
      +
    • _bridge_read_message(...)

      +
    • + +
    • _bridge_write_message(...)

      +
    • +
    +
  • +
+

Security hardening and JSON-RPC protocol correctness (id handling)\

+ +
    +
  • Added guard in bridge to refuse token injection for non-registry URLs

    +
  • + +
  • Added strict ID validation via _is_valid_jsonrpc_id(...).

    +
  • + +
  • Bridge no longer emits error responses for notifications/no-id messages

    +
  • +
+
+
    +
  • 6438c8f1b428d01fa0f296c24810e71f9c96992d

    +
  • +
+

TAA-1608

+

Cloud WS: Add Users to WS and Remove Users from WS fail due to inconsistent way of fetching WS state

+

This fixe corrects a state-validation issue in Cloud Workstation admin pipelines (add user / remove user).

+

Previously, these pipelines validated workstation state from Terraform state (terraform show -json), which can be stale when users start/stop workstations via gcloud (user pipelines).
Now, validation uses live workstation state from GCP API (gcloud workstations describe) to make decisions based on current runtime reality.

+

Key Changes

+ +
    +
  • Renamed and refactored utility function:

    + +
      +
    • validate_workstation_state -> assert_workstation_state

      +
    • +
    +
  • + +
  • assert_workstation_state now:

    + +
      +
    • Accepts: <workstation> <config> <cluster> <region> [expected_state]

      +
    • + +
    • Uses get_current_workstation_state (live gcloud lookup)

      +
    • + +
    • Defaults expected_state to STATE_STOPPED

      +
    • + +
    • Fails fast for transitional states (STATE_STARTING, STATE_STOPPING, STATE_REPAIRING, STATE_RECONCILING) with retry guidance

      +
    • +
    +
  • + +
  • Updated admin scripts to pass full workstation context:

    + +
      +
    • workstation-admin-operations/add-workstation-user/add-workstation-user.sh

      +
    • + +
    • workstation-admin-operations/remove-workstation-user/remove-workstation-user.sh

      +
    • +
    +
  • + +
  • In add/remove scripts:

    + +
      +
    • Workstation config is read from generated workstation map (output.tfvars.json)

      +
    • + +
    • Cluster and region are read from input tfvars

      +
    • + +
    • State check is now: assert_workstation_state ...

      +
    • +
    +
  • +
+
+
    +
  • 0bbeb90f60c9c3b904dae53c2c46c3bc271450ea

    +
  • +
+
+ +

Known Issues:

+

+
+ + + + + + + + + + + + + + + + + + + + +

Platform

+

Horizon SDV

+

Version

+

Release 3.0.0

+

Date

+

19.12.2025

+
+ +

Summary

+

Horizon SDV 3.0.0 extends platform capabilities with support for Android 15 and the latest extensions of OpenBSW. Horizon 3.0.0 also delivers multiple new feature and several improvements over Rel. 2.0.1 along with critical bug fixes.

+

The set of new features in version 3.0.0 includes, among others:

+ +
    +
  • Simplified Deployment Flow: We have overhauled the deployment process to make it more intuitive and efficient. The new flow reduces complexity, minimizing the steps required to get your environment up and running.

    +
  • + +
  • ARM64 Support (Bare Metal): We have expanded our infrastructure support to include ARM64 Bare Metal. This allows you to run your workloads natively on ARM architecture, ensuring higher performance and closer parity with automotive edge hardware.

    +
  • + +
  • Gemini Code Assist: Supercharge your development with the integration of Gemini Code Assist and the Gerrit MCP Server. You can now leverage Google's state-of-the-art AI to generate code, explain complex logic, debug issues faster and make use of agentic code review workflows directly within your development environment.

    +
  • + +
  • Advanced Monitoring with Grafana: Gain deeper insights into your infrastructure with our new Grafana integration. You can now visualize and monitor POD and Instance metrics in real-time, helping you optimize resource usage and diagnose performance bottlenecks quickly.

    +
  • +
+ +

New Features

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

ID

+

Feature

+

Description

+

TAA-924

+

Simplified Horizon Deployment Flow

+

Simplified and more automated deployment flow for Horizon SDV platform without GitHub Actions to let community teams could Horizon platform faster and avoid potential human error issues.

+

TAA-511

+

Gemini Code Assist in R3 – Gerrit MCP Server integration

+

Use company’s codebase as a knowledge base for Gemini Code Assist within the IDE to receive code suggestions & explanations tailored to known codebase, libraries and corporate standards.

+

TAA-365

+

ARM64 GCP VM (Bare Metal) support for Cuttlefish

+

ARM64 GCP VM support for Android builds and testing with Cuttlefish

+

TAA-595

+

Monitoring of POD/Instance metrics with Grafana

+

Access to CPU/Memory/Storage metrics for pods and instances, to more easily investigate and debug container, pod and instance related problems and its impact on platform performance.

+

TAA-944

+

Android pipeline update to Android 16

+

Support for Android16 for AAOS, CF and CTS in Horizon pipelines.

+

TAA-946

+

Extend OpenBSW support with additional features

+

Support for Eclipse Foundation OpenBSW workload features that were not included in Horizon-SDV R2.0.0

+

TAA-889

+

Horizon R3 Security update

+

Selected open-source applications and tools which are part of Horizon SDV platform are updated to the latest stable versions

+

TAA-377

+

Google ASOP Repo Mirroring

+

NFS based mirror of AOSP repos deployed in the K8s cluster.

+

TAA-947

+

ABFS update for R3

+

Corrections and minor ABFS updates delivered from Google in Release 3.0.0 timeframe.

+

TAA-1072

+

Cloud Artefact storage management

+

Android and OpenBSW build jobs have been modified to allow the user to specify metadata to be added to the stored artifacts during the upload process. Implementation is supported for GCP storage option only

+

TAA-1001

+

Kubernetes Dashboard SSO integration

+

Kubernetes Dashboard SSO integration

+

TAA-945

+

Replace depreciated Kaniko tool

+

Replace depreciated Google Kaniko tool for building container images with new Buildkit tool.

+

TAA-941

+

IAA demo case.

+

Support for Partner demo in IAA Messe show. The main technical scope is to apply a binary APK file to the Android code, help building it and flash it to selected targets (Cuttlefish and potentially Pixel) according to Partner specification.

+
+ +

Improved Features

+

See details in horizon-sdv/docs/release-notes-3-0-0.md

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

TAA-1172

+

Create Workloads area in Gitops section

+

TAA-862

+

Improvements Structure of Test pipelines

+

TAA-1111

+

Unified CTS Build process

+

TAA-1265

+

[Gerrit] Support GERRIT_TOPIC with existing gerrit-triggers plugin

+

TAA-1271

+

Support custom machine types for Cuttlefish

+

TAA-1269

+

Adjust CTS/CVD options

+
+ +

Bug Fixes

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

TAA-993

+

[ABFS] Missing permission for jenkins-sa for ABFS server

+

TAA-1063

+

[Security] Axios Security update 1.12.0 (dependabot)

+

TAA-904

+

ABFS unmount doesn't work

+

TAA-1090

+

[Android 16] Cuttlefish builds fail (x86/arm)

+

TAA-1080

+

[OpenBSW] Builds no longer functional (main)

+

TAA-1110

+

[OpenBSW] pyTest failure

+

TAA-1103

+

[Android 16] CTS 16_r2 reports 15_r5

+

TAA-1145

+

Update filter (gcloud compute instance-templates list)

+

TAA-1161

+

[ARM64] Subnet working utils too quiet 

+

TAA-1113

+

[ABFS] COS Images no longer available

+

TAA-1118

+

[ABFS] CASFS kernel module update required (6.8.0-1029-gke)

+

TAA-1176

+

[CF] CTS CtsDeqpTestCases execution on main not completing in reasonable time (x86) 

+

TAA-1186

+

Incorrect Headlamp Token Injector Argo CD App Project

+

TAA-1196

+

AOSP Mirror changes break standard builds

+

TAA-1201

+

AOSP Mirror sync failures

+

TAA-1200

+

AOSP Mirror URLs and branches incorrect

+

TAA-1203

+

AOSP Mirror repo sync failing on HTTP 429 (rate limits)

+

TAA-1205

+

AOSP Mirror - no support for dev build instance 

+

TAA-1198

+

AOSP Mirror does not support Warm nor Gerrit Builds

+

TAA-1204

+

AOSP Mirror repo sync failing - SyncFailFastError

+

TAA-1214

+

AOSP Mirror ab is an

+

TAA-1219

+

[Cuttlefish] Host installer failures masked

+

TAA-1202

+

AOSP Mirror blocking concurrent jobs incorrectly configured

+

TAA-1238

+

[Cuttlefish] Update to v1.31.0 - v1.30.0 has changed from stable to unstable. 

+

TAA-1241

+

[Android] Mirror should not be using OpenBSW nodes for jobs AM

+

TAA-1247

+

[Workloads] Remove chmod and use git executable bit 

+

TAA-1249

+

[GCP] Client Secret now masked (security clarification)

+

TAA-1264

+

[CVD] Logs are no longer being archived 

+

TAA-1261

+

[Cuttlefish] gnu.org down blocking builds

+

TAA-1266

+

Pipeline does not fail when IMAGE_TAG is empty and NO_PUSH=true

+

TAA-1267

+

[CWS] OSS Workstation blocking regex incorrect (non-blocking)

+

TAA-1258

+

[Cuttlefish] VM instance template default disk too small.

+

TAA-1233

+

[Jenkins] Plugin updates for  fixes

+

TAA-1278

+

[Cuttlefish] SSH/SCP errors on VM instance creation

+

TAA-1283

+

Mismatch in githubApp secrets (TAA-1054)  

+

TAA-1277

+

[Jenkins] Plugin updates for  fixes

+

TAA-1279

+

[RPI] Android 16 RPI builds now failing

+

TAA-1282

+

[GCP] Cluster deletion not removing load balancers

+

TAA-1257

+

[Cuttlefish] android-cuttlefish build failure (regression)

+

TAA-1273

+

[Cuttlefish] android-cuttlefish CVD device issues (regression)  

+

TAA-1149

+

[K8S] Reduce parallel jobs to reduce costs 

+

TAA-1162

+

[K8S] Revert parallel jobs change to reduce costs

+

TAA-1191

+

Monitoring deployment related hotfixes

+

TAA-1114

+

[ABFS] Update env/dev license (Oct'25)

+

TAA-1116

+

[Android] Android 15 and 16 AVD missing SPDX BOM

+

TAA-1192

+

[MTKC] Support additional hosts for dev and test instances

+

TAA-1207

+

Mirror/Create-Mirror: Add parameter for size of the mirror NFS PVC

+

TAA-1208

+

Mirror/Sync-Mirror: Sync all mirrors when `SYNC_ALL_EXISTING_MIRRORS` is selected 

+

TAA-1211

+

[Android] Simplify Dev Build instance job

+

TAA-1218

+

[Grafana] ArgoCD on Dev shows 'Out Of Sync'

+

TAA-1231

+

R2 - GitHub Actions workflow fails

+

TAA-1038

+

[Jenkins] CF scripts - update to retain color

+

TAA-907

+

Multibranch is not supported in ABFS

+

TAA-862

+

Improvement to structure of Test pipelines

+

TAA-788

+

Jenkins AAOS Build failure - Gerrit secrets/tokens mismatch

+

TAA-1088

+

[NPM] Move wait-on post node install

+

TAA-1115

+

[STORAGE] Override default paths

+

TAA-1160

+

[ARM64] Lack of available instances on us-central1-b/f zone

+

TAA-1274

+

[Cuttlefish] CTS hangs - android-cuttlefish issues

+

TAA-1290

+

[Cuttlefish] ARM64 builds broken on f2fs-tools (missing)

+

TAA-1253

+

[MTK Connect] ERROR: script returned exit code 92/1

+
+
+ + + + + + + + + + + + + + + + + + + + +

Platform

+

Horizon SDV

+

Version

+

Release 2.0.1

+

Date

+

24.09.2025

+
+ +

Summary

+

Hot fix release for Rel.2.0.1 with emergency fix for Helm repo endpoint issues, and minor documentation updates.

+ +

New Features

+

N/A

+ +

Improved Features

+

New simplified Release Notes format.

+ +

Bug Fixes

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

Issue ID

+

Summary

+

TAA-1002

+

[Jenkins] Install ansicolor plugin for CWS

+

TAA-1005

+

Horizon provisioning failure - Due to outdated Helm install steps

+

TAA-1007

+

Cloud WS - Workstation Image builds fail due to Helm Debian repo (OSS) migration

+

TAA-1040

+

Remove references to private repo in Horizon files

+

TAA-1045

+

OSS Bitnami helm charts EOL

+
+
+ + + + + + + + + + + + + + + + + + + + +

Platform

+

Horizon SDV

+

Version

+

Release 2.0.0

+

Date

+

01.09.2025

+
+ +

Summary

+

Horizon SDV 2.0.0 extends Android build capabilities with the integration of Google ABFS and introduces support for Android 15. This release also adds support for OpenBSW, the first non-Android automotive software platform in Horizon. Other major enhancements include Google Cloud Workstations with access to browser based IDEs Code-OSS, Android Studio (AS), and Android Studio for Platforms (ASfP). In addition, Horizon 2.0.0 delivers multiple feature improvements over Rel. 1.1.0 along with critical bug fixes.

+ +

New Features

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

ID

+

Feature

+

Description

+

TAA-8

+

ABFS for Build Workloads

+

The Horizon-SDV platform now integrates Google's Android Build Filesystem (ABFS), a filesystem and caching solution designed to accelerate AOSP source code checkouts and builds.

+

TAA-9

+

Cloud Workstation integration

+

The Horizon-SDV platform now includes GCP Cloud Workstations, enabling users to launch pre-configured, and ready-to-use development environments directly in browser.

+

TAA-375

+

Android 15 Support

+

Horizon previously supported Android 15 in Horizon-SDV but by default Android 14 was selected. In this release, Android 15 android-15.0.0_r36is now the default revision.TAA-381

+

TAA-381

+

Add OpenBSW build targets

+

Eclipse Foundation OpenBSW Workload As part of the R2.0.0 delivery, a new workload has been introduced to support the Eclipse Foundation OpenBSW within the Horizon SDV platform. This workload enables users to work on the OpenBSW stack for build and testing.

+

TAA-915

+

Cloud Android Orchestration - Pt. 1

+

In R2.0.0 Horizon platform introduces significant improvements to Cuttlefish Virtual Devices (CVD). These enhancements include increased support for a larger number of devices, optimized device startup processes, a more robust recovery mechanism and updated the Compatibility Test Suite (CTS) Test Plans and Modules to ensure seamless integration and compatibility with CVD.

+

TAA-623

+

Management of Jenkins Jobs using CasC

+

The CasC configuration has been updated to include a single job in the jenkins.yaml file, which is automatically started on each Jenkins restart. This job provides the "Build with Parameters" option, allowing users to populate the workload of their choice or all workloads.

+

TAA-462

+

Kubernetes Dashboard

+

The Horizon platform now includes the Headlamp application, a web-based tool to browse Kubernetes resources and diagnose problems.

+

TAA-717

+

Multiple pre-warmed disk pools

+

The Horizon is changing to the persistent volume storage for build caches to improve build times, cost and efficiency. These pools are separated by Android major version, e.g. Android 14 and 15, but also Raspberry Vanilla (RPi) targets now have their own smaller pools rather than sharing the original common pool.

+

TAA-596

+

Jenkins RBAC

+

Jenkins has been configured with RBAC capability using the Role-based Authorization Strategy (ID: role-strategy) plugin.

+

TAA-611

+

Argo CD SSO

+

Argo CD has been configured with SSO capabilities. It is now possible to Login to Argo CD either by using the configured admin credentials or by clicking the “Login via Keycloak” button.

+

TAA-837

+

Access Control tool

+

Additional Access Control functionality provides a Python script tool and classes for managing user and access control on GCP level.

+
+ +

Improved Features

+

N/A

+ +

Bug Fixes

-### Summary + + + + + + + -Horizon SDV 3.1.0 is the minor release which extends platform capabilities with support for Sub-environments and additional MCP server configuration for Android Studio and Android Studio for Platforms IDEs. Horizon 3.1.0 also delivers several critical bug fixes including security fixes for network configurations and vulnerabilities in application containers. + + -Rel.3.1.0 defines rules for Partner Contributions Repository and recommended directory structure for third party modules provided from external Horizon Partners which are documented in **contributing.md** file located in the **/doc** directory of Horizon SDV repository. + + -Horizon SDV 3.1.0 package offers fully verified and documented upgrade patch (from Rel.3.0.0 to Rel.3.1.0). (see details in /docs/guides/upgrade_guide_3_0_0_to_3_1_0.md) + + -*** -### New Features + + -

Issue ID

+

Summary

+

TAA-980

+

Access control issue: Workstation User Operations succeed for non-owned workstations

+

TAA-984

+

[Kaniko] Increase CPU resource limits

+
- - - + + + + - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + -
IDFeatureDescription

TAA-982

+

[ABFS] Uploaders not seeding new branch/tag correctly

+
TAA-1057Support for Sub-Environments in Horizon SDV platformHorizon SDV 3.1.0 introduces sub-environments: multiple isolated copies of the platform that run on the same GKE cluster as the main environment. Each sub-environment has its own namespaces (prefixed by sub-environment name, e.g. sub-jenkins, sub-keycloak), its own Argo CD instance, its own sub-domain (e.g. sub..), and its own GCP Certificate Manager certificate, Secret Manager secrets, and Workload Identity service accounts. Sub-environments are defined entirely in terraform.tfvars via the sdv_sub_env_configs variable; no code changes are required to add or remove them. Typical use cases include giving teams isolated instances without extra clusters, testing platform changes on a branch before merge, and running a stable environment alongside a short-lived experimental one.

Changes

  • Terraform: New variable sdv_sub_env_configs in terraform/env/terraform.tfvars (optional; defaults to empty map). Each key is the sub-environment name; each value supplies required Keycloak passwords and optional branch and manual_secrets.
  • Certificate Manager: DNS Authorization and certificate resources converted to for_each to support one certificate per sub-environment. Upgrade from 3.0.0 uses moved {} blocks and a name-preserving conditional to avoid destroying and recreating existing GCP resources.
  • Argo CD: Argo CD-related Kubernetes resources managed by Terraform converted to for_each. One Argo CD instance per sub-environment (e.g. helm_release.argocd_subenvs["sub"] in sub-argocd namespace). Upgrade from 3.0.0 uses moved {} blocks to migrate state without destroying live resources.
  • GCP: Per sub-environment: Workload Identity service accounts (e.g. gke---sa), Secret Manager secrets (prefixed -), Certificate Manager certificate and DNS authorization for .., and Cloud DNS CNAME for certificate verification.
  • GitOps: Helm values namespacePrefix, isSubEnvironment, and environmentName drive namespace and resource naming. Cluster-scoped components (External Secrets Operator, Node Exporter, Kubescape Operator, Gerrit Operator) are gated with isSubEnvironment and remain single-instance; sub-environments use namespace-scoped resources and the shared operators.
  • Documentation: [Sub-Environment Deployment Guide](guides/sub_environments/sub_environment_deployment_guide.md) (configuration, deploy, access, destroy) and [Sub-Environment Developer Guide](guides/sub_environments/sub_environment_developer_guide.md) (architecture, adding apps, naming). Deployment guide referenced from main [Deployment Guide](deployment_guide.md).


Action Required

  • None for existing 3.0.0 users who do not use sub-environments. Upgrade path is described in [Upgrade Guide: 3.0.0 to 3.1.0](guides/upgrade_guide_3_0_0_to_3_1_0.md); follow post-upgrade steps (e.g. delete/recreate affected resources, sync with prune) as documented.
  • To use sub-environments: Add sdv_sub_env_configs to terraform/env/terraform.tfvars with at least keycloak_admin_password and keycloak_horizon_admin_password per sub-environment. Sub-environment names must be lowercase alphanumeric with hyphens, 1-4 characters. See [Sub-Environment Deployment Guide – Configuring Sub-Environments](guides/sub_environments/sub_environment_deployment_guide.md#configuring-sub-environments).

TAA-981

+

[ABFS] CASFS kernel module update required (6.8.0-1027-gke)

+

TAA-977

+

New Cloud Workstation configuration is created successfully, but user details are not added to the configuration

+

TAA-974

+

kube-state-metrics Service Account missing causes StatefulSet pod creation failure

+

TAA-968

+

[IAA] Elektrobit patches remain in PV and break gerrit0

+

TAA-966

+

[ABFS] Kaniko out of memory

+

TAA-953

+

Android CF/CTS: update revisions

+

TAA-964

+

[Gerrit] Propagate seed values

+

TAA-959

+

Reduce number of GCE CF VMs on startup

+

TAA-932

+

ABFS_LICENSE_B64 not propagated to k8s secrets correctly

+

TAA-958

+

[Gerrit] repo sync - ensure we reset local changes before fetch

+

TAA-781

+

GitHub environment secrets do not update when Terraform workload is executed.

+

TAA-933

+

Failure to access ABFS artifact repository

+

TAA-905

+

AAOS build does not work with ABFS

+

TAA-931

+

Create common storage script

+

TAA-930

+

Investigate build issues when using MTK Connect as HOST

+

TAA-923

+

Cuttlefish limited to 10 devices

+

TAA-921

+

[Cuttlefish] Building android-cuttlefish failing on http://gnu.org

+

TAA-922

+

MTK Connect device creation assumes sequential adb ports

+

TAA-920

+

Android Developer Build and Test instances leave MTK Connect testbenches in place when aborted

+

TAA-563

+

[Jenkins] Replace gsutils with gcloud storage

+

TAA-886

+

Conflict Between Role Strategy Plugin and Authorize Project Plugin

+

TAA-814

+

Android RPi builds failing: requires MESON update

+

TAA-863

+

Workloads Guide: updates for R2.0.0

+
-*** + +

TAA-867

+ -### Improved Features +

Gerrit triggers plugin deprecated

+ + - - - - + + + + - - - + + + + - - - + + + +
IDFeatureDescription

TAA-890

+

Persistent Storage Audit: Internal tool removal

+
TAA-1328MCP server configuration caching by Android Studio and ASfP IDEThis improvement provides the MCP configuration caching by Android Studio and ASfP IDE that makes MCP requests by Gemini Code Assist use expired tokens.

MCP configuration caching in Android Studio and ASfP

The Android Studio and Android Studio for Platform IDEs cache the MCP configuration (mcp.json) for their current session.

  • This means, if we store auth tokens in mcp.json and later update them, the IDE will still use the old tokens from its cache.
  • To fix this, a standard workaround has been implemented in gemini-mcp-agent using the --mcp-client-bridge mode where each MCP server configured in mcp.json spawns its own MCP-client bridge.
  • It transparently forwards requests from the IDE to the MCP server (and vice-versa), injecting a fresh authentication token each time from .gemini/settings.json. This ensures seamless access without needing to restart your IDE.
  • Note that, structure of mcp.json is now slightly different from settings.json as mcp.json now configures servers in a pseduo-stdio mode using command, args and env blocks instead of standard httpUrl block so that the client-bridge can proxy requests with latest token injection.


Key Changes

gemini-mcp-setup.py

  • Renamed gemini-mcp-setup.py to gemini-mcp-agent.py to reflect its upgraded feature set.
  • gemini-mcp-agent now provides an internal-use command option --mcp-client-bridge for IDEs like Android Studio (and ASfP) that cache configurations
  • where each MCP server configured in mcp.json spawns its own MCP-client bridge.
  • The bridge uses stdio to communicate with the IDE, injects updated tokens from .gemini/settings.json, and forwards JSON-RPC requests to the MCP server over HTTPS (and vice-versa).
  • This solves the MCP config caching issue in such IDEs, ensuring seamless access without needing to restart your IDE.
  • Updated mcp_setup.md guide for new features and improved clarity


Cloud-WS images (all 3):

  • added GOOGLE_CLOUD_PROJECT as dockerfile ARG and set as container ENV
  • passing value for GOOGLE_CLOUD_PROJECT from Jenkins env var CLOUD_PROJECT
  • Updated descriptions in jenkinsfile for all 3 cloud-ws groovy files
  • Yarn GPG key fix that caused build failure
  • simplified and optimized image layers


More on gemini-mcp-agent changes

  • new func discover_android_studio_mcp_file_path to find mcp.json if platform is Android Studio or ASFP and set the constant ANDROID_STUDIO_MCP_FILE_PATH
  • agent updates the mcp.json only when ANDROID_STUDIO_MCP_FILE_PATH holds a non-None value.
  • added update_android_studio_mcp_file which has slightly diff logic to update_gemini_cli_settings_file as mcp.json structure is diff from settings.json as mcp.json now defines MCP servers with command as this agent script with args --mcp-client-bridge and --mcp-server name. This option combo calls the new run_mcp_client_bridge function.
  • added new run_mcp_client_bridge function to read MCP JSON-RPC requests from android studio IDE (via stdio) and forward it to remote MCP server (via HTTPs)
  • updated is_managed_server function to accept server_http_url instead of entire block
  • renamed ensure_config_dir to ensure_configs_exist that always creates config files for gemini-cli and optionally for as/asfp only if the environment is as/asfp based
  • renamed update_gemini_config to update_gemini_cli_settings_file
  • added new env var ENV_FILE_PATH to store env file path
  • added new func load_env_config to load env vars from ENV_FILE_PATH or .env file in current dir or global fallback dir of ~/.gemini/.env
  • updated func update_android_studio_mcp_file to store env vars into mcp.json file for mcp-client-bridge processes to use them

TAA-618

+

MTK Connect access control for Cuttlefish Devices

+
TAA-1334Generate GitHub App private key PKCS#8 format via TerraformExtension to the new simplified deployment flow for Horizon SDV introduced in Rel.3.0.0.

  • PKCS#8 format of the GitHub App private key is created automatically by terraform.
  • The variable sdv_github_app_private_key_pkcs8 is removed.
  • PKCS#8 format of the GitHub App private key is stored in the GCP Secret Manager

TAA-711

+

[Qwiklabs][Jenkins] GCE limits - VM instances blocked

+
+
+ + + + + + + + + + -*** + + -## GCP changes [Google] + + -Google has changed [Client Secret Handling and Visibility](https://support.google.com/cloud/answer/15549257#client-secret-hashing "https://support.google.com/cloud/answer/15549257#client-secret-hashing") . This affects redeployments of the Horizon SDV platform if the _Client Secret_ was not securely stored previously. + + + +

Platform

+

Horizon SDV

+

Version

+

Release 1.1.0

+

Date

+

14.04.2025

+
-This secret is required by Keycloak for the Google Identity Provider (Client Secret). If the secrets do not match, OAuth 2.0 authentication will fail and users will lose access. +

Summary

+

Minor improvements in Jenkins configuration, additional pipelines implemented for massive build cache pre-warming simplification required for Hackathon and Gerrit post jobs cleanup.

-### **Solution:** +

New Features

-- Create a new secret in Google Cloud: - - - In Credentials, select the Horizon client secret - - - Disable the old secret and create a new one. - - - Download or copy the new secret and store it securely. - -- Verify login (for apps from Landing Page) fail. - -- Update Keycloak: - - - Go to Identity Provider → Google. - - - Update the Client Secret and save. - -- Verify login works as expected. + + + + -*** + -## Documentation update + + -- Rel.3.1.0 provides with several updates in Horizon documentation including e.g. **Horizon Deployment Guide** (/docs/deployment_guide.md). - -- The new **contributing.md** document (/doc/contributing.md) defines rules for Partner Contributions Repository integration and recommended directory structure for third party modules provided from external Horizon Partners. - -- The new Upgrade Guide (/docs/guides/upgrade_guide_3_0_0_to_3_1_0.md) provide guideline for Rel.3.0.0 -> Rel.3.1.0 upgrade. + + -*** + -### Bug Fixes + + -

ID

+

Feature

+

Description

+

TAA-431

+

Jenkins R1 deployment extensions

+

Jenkins extensions to Platform Foundation deployment in Rel.1.0.0. The new job to pre-warm build volumes.

+
- - - - + + + + + + +
IDBugDescriptionSHA

TAA-346

+

Support Pixel devices

+

Support for Google Pixel tablet hardware, full integration with MTK Connect.

+
+ +

Improved Features

+

N/A

+ +

Bug Fixes

+ + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + +
TAA-1236[Volvo] Google platform failures on jenkins-mtk-connect-apikey
  • mtk-connect-post-key: add create_or_update_jenkins_secret() so the jenkins-mtk-connect-apikey secret is created if absent (CronJob or one-off can now establish the credential; previously only updated existing secret, causing "Could not find credentials entry" when mtk-connect-post-job had not run or had failed).
  • mtk-connect-post configure.sh: make DELETE curls non-fatal (| true) so 404 on first run does not exit; remove if block so any real failure exits the job visibly.
ea84ef88c7236d582707601e368fd1803a3345c4

Issue ID

+

Summary

+
TAA-1260Sync Mirror pipeline hangs after modifying MIRROR_VOLUME_CAPACITY_GB during Infra creation
  • Fixed issue where Filestore expansion (e.g., 4TB → 5TB) caused PVCs to remain stuck in Pending state with 0 capacity
  • Resolved Kubernetes binding conflicts caused by static PV/PVC provisioning without a StorageClass or CSI driver
  • Eliminated race conditions during resize where old PVCs were not released and PVs entered Failed state
  • Removed incompatible ReclaimPolicy=Delete usage on statically‑provisioned NFS volumes
  • Migrated Mirror storage from static PV/PVC management to Filestore CSI driver–based dynamic provisioning
  • Introduced new StorageClass with:
  • filestore.csi.storage.gke.io provisioner
  • allowVolumeExpansion=true for online resize
  • ReclaimPolicy=Retain for data safety
  • Simplified Terraform to manage only the PVC; CSI driver now owns PV lifecycle
  • Added safeguards to prevent volume downsizing, avoiding potential data loss
  • Standardized naming by removing legacy aosp references across configs and scripts
86bee3badf422614629752a19bcf19d8555789ef

TAA-683

+

Change MTK Connect application version to 1.8.0 in helm chart

+
TAA-1326Cloud WS: Create Configuration fails for region other than europe-west1
  • Parameter WS_REPLICA_ZONES as default value was partially hardcoded ({CLOUD_REGION}-b, -d) )For some zones eg “us-central1-d” is not existing ( currently us-central1-a, b, c, f) .
  • Implemented solution: If user will not add any replica_zone values The default value will retrieve all zones in region and automatically select the first two zones in current region
  • 1ea0c42ed4ccc2adcbae0126d34664af9599b79e
  • 71a7316c70873e57da6395ee51a0a87684fe5d08
  • 73d4f09c55f4130e1023df7546b53a37c42118cf
  • b8caa3676843d104b1e4fa7120dc76dbd6c9acfa

TAA-644

+

self-hosted runners

+
TAA-1327Cloud WS: Create Workstation pipeline fails (WS created but IAM user add fails)
  • Fix: Ensure the workstation is fully created and ready before applying IAM bindings.


This helps prevent concurrent IAM policy modification conflicts (409 errors)
  • 818bda3e6d5580c8b339b26dfe4b8dad5f28fdac
  • 18ee772625d5abd7377906c6c9865c7be91dec0f

TAA-641

+

[Jenkins] Horizon Gerrit URL path breaks upstream Gerrit FETCH

+
TAA-1340[Jenkins] ABFS license no longer applied in deployment
  • Simplified Horizon deployment dropped support of creating the ABFS license and as such, this must now be applied via Jenkins ABFS server and uploaders when action is APPLY.
  • Mask the license for security reasons.
290bf5dea46d4f058d3fc96f8b67881c1efbdf9c

TAA-639

+

Keycloak Sign-in Failure: Non-Admin Users Stuck on Loading Screen

+
TAA-1416Remove obsolete ABFS secrets created via Terraform and GitOpsThis PR removes deprecated ABFS license resources that were previously managed through Terraform and GitOps. The ABFS license is now exclusively managed by Jenkins, and all unused license-related resources and references have been cleaned up accordingly.

Details:

  • Removed the Terraform variable and references for sdv_abfs_license_key_b64.
  • Removed the Kubernetes/secret resources and references for jenkins-abfs-license-b64.
  • Cleaned up all dependent configurations and references to ensure no residual usage of the removed license resources.


Verification

  • Deployed the platform after removing the deprecated ABFS license resources.
  • Confirmed no deployment or runtime issues related to ABFS licensing.


Purpose

These changes simplify license management by consolidating ABFS license handling within Jenkins, reduce configuration complexity in Terraform and GitOps, and prevent confusion caused by unused or legacy license resources.
a7c2bbbf6e1189b6a5119c983183bfb7001133e6

TAA-631

+

MTK Connect license file in wrong location

+
TAA-1418Fails on pkcs8_converter (jq missing)TAA-1418: install jq dependency for pkcs8 conversion

  • Resolves deployment failures in TAA-1418
  • Adds missing 'jq' binary required by the external terraform data source
b80c14290470ac483b8d1eb587acc20084b3a422

TAA-628

+

[Jenkins] CF instance creation (connection loss)

+
TAA-1428Password check incorrect (12 should mean 12)TAA-1428: Correct password length check

If it states it should be at least 12 characters, ensure the check is correct, ie >= 12 not > 12!
f29c70246fe52a4f880a2e332660157e1459af2e

TAA-627

+

[Jenkins][Dev] Investigate build nodes not scaling past 13

+
TAA-1429argocd namespace stuck in 'Terminating'Update deployment script with deletion of resources which cause the namespace argocd to be stuck in terminating state indefinitely.

Changes

deploy.sh

File path: tools/scripts/deployment/deploy.sh

  • Added two new functions
  • cleanup_gateways() - Deletes the GKE Gateway which triggers the deletion of backends, load balancers and NEGs.
  • cleanup_argocd() - Deletes all Apps created by horizon-sdv app to prevent it from being stuck in terminating state.
d2d32295bc4580bf77fc6f59cb11301de1451636

TAA-622

+

Workloads documentation - wrong paths

+
TAA-1430Enable 'force_destroy' on bucketsEnable force_destroy for GCS buckets to destroy the buckets on Terraform destroy workflow even if it contains objects.

Changes

main.tf

File path: terraform/modules/sdv-gcs/main.tf

  • Add force_destroy = true to enable force destruction of GCS buckets.
211d4564d0265b38ee789dddca7708a8982502af

TAA-615

+

Improve the Gerrit post job

+
TAA-1432landingpage 'exec format error'landingpage 'exec format error' fix

Ensure docker images are built for the target platform, not the architecture of the platform they are deployed on.
4322698a334d01c2c84ab72967537063b3c557ca

TAA-401

+

[Jenkins] Agent losing connection to instance

+
TAA-1435Cross architecture supportCross architecture support fix.

Explicitly set Docker base image platform to linux/amd64 to ensure cross-architecture deployment consistency.
3ef9eb0b71f45bb920a9d62606118ee130895f76

TAA-309

+

[Jenkins] 'Build Now' post restart

+
+
+ + - - - - + + + + - - - - + + + + - - - - + + + + +
TAA-1438Cuttlefish SSH key incorrectly created (blocks CF jobs)Cuttlefish SSH Key Update: Regenerate VM Templates

This fix updates the SSH key generation algorithm used by Cuttlefish VM instances. To avoid any impact, regenerate the VM instance templates.

In Jenkins:

  • Android Workflow → Environment → Docker Image Template → Build with Parameters
  • Deselect NO_PUSH to ensure image is uploaded to registry.
  • Click Build
  • Android Workflow → Environment → CF Instance Template → Build with Parameters
  • Set ANDROID_CUTTLEFISH_REVISION=main
  • Click Build
  • Repeat for the tagged version of Android Cuttlefish
  • Android Workflow → Environment → CF Instance Template ARM64 → Build with Parameters
  • Repeat for ARM64 if enabled.
  • Set ANDROID_CUTTLEFISH_REVISION=main
  • Click Build
  • Repeat for the tagged version of Android Cuttlefish


If SSH key issues appear in any of the following jobs, regenerate the instance templates to ensure the latest keys are installed:

  • Android Workflow → Environment → Development Test Instance
  • Android Workflow → Builds → Gerrit
  • Android Workflow → Tests → CVD Launcher
  • Android Workflow → Tests → CTS Execution
  • eb61aefb3e86a1e16022708a13b0657eaf5b79f0
  • 03f52993fbf637c084e1db0f61be65f21f5c2853
  • 172781210fba6573434ba8e9b6da2b68b0b206d3
  • 501e12e97e89e26eb74fa7c855ca15b3e03921a0
  • d80ccf7323c22d2b85a2f4a8d09be4b1983c95e9
  • 5442aecc9a0cd98ef7b98699f095b0b9332f3e9e

Platform

+

Horizon SDV

+
TAA-1441Finalize cross architecture support - R31.0Updates in deployment scripts and containers to emulate linux/amd64

Changes

container-deploy.sh

File path: tools/scripts/deployment/container-deploy.sh

  • Update the script to run the deployment container with linux/amd64 emulation pinned.


Dockerfile

File path: tools/scripts/deployment/container/Dockerfile

  • Update the Dockerfile to be built for linux/amd64.
076c2c57434c2596e2db44ffb60e4c435f55b1a6

Version

+

Release 1.0.0

+
TAA-1443Gerrit MCP Server issuesFix syntax error for gerrit-mcp-server-config causing gerrit-mcp-server deployment errors.

Changes

gerrit-mcp-server.yaml

File path: gitops/apps/gerrit-mcp-server/templates/gerrit-mcp-server.yaml

  • Remove - causing syntax issues.
e6e2375372b4b16ce8d78a017818989ee911d954

Date

+

18.03.2025

+
+ +

Summary

+

The main objective for Release 1.0.0 is to achieve Minimal Viable Product level for Horizon SDV platform where orchestration will be done using Terraform on GCP with the intention of deploying the tooling on the platform using a simple provisioner. Horizon SDV platform in Rel.1.0.0 supports:

+ +
    +
  • GCP platform / services.

    +
  • + +
  • Terraform orchestration (IaC).

    +
  • + +
  • IaC stored in GitHub repo and provisioned either via CLI or GitHub actions.

    +
  • + +
  • Platform supports Gerrit to host Android (AAOS) repos and manifests, and allows users to create their own repos.

    + +
      +
    • With some pre-submit checks: eg. voting labels: code review and manual vs automated triggered builds.

      +
    • + +
    • Will mirror and fork AAOSP manifests repo, and one additional code repo for demonstrating the SDV Tooling pipeline. Locally mirrored/forked manifest will be updated to point to the internally mirrored code repo, all other repos will remain using the external OSS AAOS repos hosted by Google.

      +
    • +
    +
  • + +
  • Platform supports Jenkins to allow for concurrent, multiple builds for iterative builds from changes in open review in Gerrit , full builds (manually, when user requests) and CTS testing.

    +
  • + +
  • Platform supports an artefact registry to hold all build artefacts and test results.

    +
  • + +
  • Platform supports a means to run CTS tests and use the Accenture MTK Connect solution for UI/Ux testing.

    +
  • +
+ +

New Features

+ + + - - - - + + + + + + - - - - + + + + + + - - - - + + + + + + - - - - + + + + + + - - - - + + + + + + - - - - + + + + + + - - - - + + + + + + +
TAA-1446TF OpenSSH conversion failingFixed a bug where the OpenSSH key was not being updated after the initial RSA key creation.

Replaced null_resource with terraform_data and added a timestamp trigger to force an idempotent conversion check on every run. This ensures that if an RSA key exists without the OpenSSH format, the conversion logic is triggered, while the grep check protects against unnecessary overwrites.
a1f7ce4beaa59dd9acbd09a5c2571cbb8b5af2b8

ID

+

Feature

+

Description

+
TAA-1447Shell Script Permission DeniedUpdate Dockerfiles for sdv-container-images module which when built with Terraform as a non-root user causes permission denied error for configure.sh

Changes

Resolve permission related issues.

File paths:

  • Grafana Post: terraform/modules/sdv-container-images/images/grafana/grafana-post/Dockerfile
  • Keycloak Post Argo CD: terraform/modules/sdv-container-images/images/keycloak/keycloak-post-argocd/Dockerfile
  • Keycloak Post Gerrit: terraform/modules/sdv-container-images/images/keycloak/keycloak-post-gerrit/Dockerfile
  • Keycloak Post Grafana: terraform/modules/sdv-container-images/images/keycloak/keycloak-post-grafana/Dockerfile
  • Keycloak Post Headlamp: terraform/modules/sdv-container-images/images/keycloak/keycloak-post-headlamp/Dockerfile
  • Keycloak Post Jenkins: terraform/modules/sdv-container-images/images/keycloak/keycloak-post-jenkins/Dockerfile
  • Keycloak Post MCP Gateway Resgistry: terraform/modules/sdv-container-images/images/keycloak/keycloak-post-mcp-gateway-registry/Dockerfile
  • Keycloak Post MTK Connect: terraform/modules/sdv-container-images/images/keycloak/keycloak-post-mtk-connect/Dockerfile
  • Keycloak Post: terraform/modules/sdv-container-images/images/keycloak/keycloak-post/Dockerfile
  • MTK Connect Post Key: terraform/modules/sdv-container-images/images/mtk-connect/mtk-connect-post-key/Dockerfile
  • LandingPage App: terraform/modules/sdv-container-images/images/landingpage/landingpage-app/Dockerfile
1e1532c5ca5a2a41f8a20ceaf9012f868947aed4

TAA-6

+

Platform foundation

+

Platform foundation including support for: GCP, Terraform workflow, Stage 1 and Stage 2 deployment with ArgoCD, Jenkins Orchestration and Authentication support through Keycloak.

+
TAA-1450High severity violation of security rules - "GCP DNS zones DNSSEC disabled" #4DNSSEC support in GCP DNS zones enabled by default.363659c78c41d6a3db7cf6877ec7320eb2b443a0

TAA-12

+

Github Setup

+

Github support for Horizon SDV platform repositories.

+
TAA-1453Vulnerabilities in /horizon-sdv/landingpage-app container
  • CVE-2025-48174 is fixed in 1.3.0 for libavif
  • CVE-2026-22801 is fixed in 1.6.54-r0 for libpng
  • CVE-2026-22695 is fixed in 1.6.54-r0 for libpng
a2b3bbb91091cc3c9e99014c1acacac6855bce3a

TAA-67

+

Tooling for tooling

+

Android build pipelines support

+
TAA-1457Vulnerabilities in /horizon-sdv/keycloak-post-headlamp container32 Vulnerabilities fixed fixed in keycloak-post-headlamp container. Base OS Change - node:22.13.0 → node:22-bookworm

Base Image Changes:

  • debian:12.12debian:12.13
  • node:22.13.0node:22-bookworm (includes Debian 12.13)
  • python:3.9-slimpython:3.9-slim-bookworm (explicit)
a2b3bbb91091cc3c9e99014c1acacac6855bce3a

TAA-5

+

Gerrit

+

Gerrit support

+
TAA-1458Vulnerabilities in /horizon-sdv/keycloak-post-grafana container32 Vulnerabilities fixed in keycloak-post-grafana container. Base OS Change - Node:22.13.0 → node:22-bookworm

Base Image Changes:

  • debian:12.12debian:12.13
  • node:22.13.0node:22-bookworm (includes Debian 12.13)
  • python:3.9-slimpython:3.9-slim-bookworm (explicit)
a2b3bbb91091cc3c9e99014c1acacac6855bce3a

TAA-61

+

MTK Connect

+

Test connections to CVD with MTK Connect support

+
TAA-1459Vulnerabilities in /horizon-sdv/keycloak-post-gerrit container33 Vulnerabilities fixed in keycloak-post-gerrit container. Base OS Change - Node:22.13.0 → node:22-bookworm

Base Image Changes:

  • debian:12.12debian:12.13
  • node:22.13.0node:22-bookworm (includes Debian 12.13)
  • python:3.9-slimpython:3.9-slim-bookworm (explicit)
a2b3bbb91091cc3c9e99014c1acacac6855bce3a

TAA-2

+

Android Virtual Devices

+

Pipelines for Android Virtual devices CVD and AVD.

+
+ +

Improved Features

+

N/A

+ +

Bug Fixes

+ + + - - - - + + + + +
TAA-1460Vulnerabilities in /horizon-sdv/keycloak-post-argocd container33 Vulnerabilities fixed in keycloak-post-argocd container. Base OS Change - Node:22.13.0 → node:22-bookworm

Base Image Changes:

  • debian:12.12debian:12.13
  • node:22.13.0node:22-bookworm (includes Debian 12.13)
  • python:3.9-slimpython:3.9-slim-bookworm (explicit)
a2b3bbb91091cc3c9e99014c1acacac6855bce3a

Issue ID

+

Summary

+
+ + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + +
TAA-1461Vulnerabilities in /horizon-sdv/keycloak-post container33 Vulnerabilities fixed in keycloak-post container. Base OS Change - Node:22.13.0 → node:22-bookworm

Base Image Changes:

  • debian:12.12debian:12.13
  • node:22.13.0node:22-bookworm (includes Debian 12.13)
  • python:3.9-slimpython:3.9-slim-bookworm (explicit)
a2b3bbb91091cc3c9e99014c1acacac6855bce3a

Issue ID

+

Summary

+
TAA-1462Vulnerabilities in /horizon-sdv/grafana-post container33 Vulnerabilities fixed in keycloak-post container. Base OS Change-Node:22.13.0 → node:22-bookworm

Base Image Changes:

  • debian:12.12debian:12.13
  • node:22.13.0node:22-bookworm (includes Debian 12.13)
  • python:3.9-slimpython:3.9-slim-bookworm (explicit)
a2b3bbb91091cc3c9e99014c1acacac6855bce3a

TAA-608

+

MTK Connect - testbench registration failing

+
TAA-1463Vulnerabilities in /horizon-sdv/gerrit-post container7 Vulnerabilities fixed in gerrit-post container. Base OS Change - Debian 12.12 → Debian 12.13

Base Image Changes:

  • debian:12.12debian:12.13
  • node:22.13.0node:22-bookworm (includes Debian 12.13)
  • python:3.9-slimpython:3.9-slim-bookworm (explicit)
a2b3bbb91091cc3c9e99014c1acacac6855bce3a

TAA-593

+

[Jenkins] Jenkins config auto reload affecting builds

+
TAA-1455Vulnerabilities in /horizon-sdv/keycloak-post-mtk-connect container32 Vulnerabilities fixed fixed in keycloak-post-mtk-connect container. Base OS Change - node:22.13.0 → node:22-bookworm

Base Image Changes:

  • debian:12.12debian:12.13
  • node:22.13.0node:22-bookworm (includes Debian 12.13)
  • python:3.9-slimpython:3.9-slim-bookworm (explicit)
a2b3bbb91091cc3c9e99014c1acacac6855bce3a

TAA-590

+

[Jenkins] CTS_DOWNLOAD_URL : strip trailing slashes

+
TAA-1452Vulnerabilities in /horizon-sdv/mtk-connect-post container5 Vulnerabilities fixed in gerrit-post container. Base OS Change - Debian 12.12 → Debian 12.13

Base Image Changes:

  • debian:12.12debian:12.13
  • node:22.13.0node:22-bookworm (includes Debian 12.13)
  • python:3.9-slimpython:3.9-slim-bookworm (explicit)
a2b3bbb91091cc3c9e99014c1acacac6855bce3a

TAA-589

+

[Jenkins] computeEngine: cuttlefish-vm-v110 points to incorrect instance template

+
TAA-1468High severity violation of security rules "GCP GKE Application-layer Secrets encryption disabled " #7KMS can be deployed based on settings in terraform.tfvars - (sdv_enable_kms_encryption = false).

KMS implementation details:

  • It is possible to use KMS to encrypt kubernetes secrets (“Application-layer secrets encryption” option in GKE)
  • If enabled – a KMS keyring is created, then a symmetric key (at version 1) is created inside the keyring
  • Encryption is fully transparent to the cluster
  • Once key is created – it is not easy to destroy it, it is rather that version 2 of the key will be created, and previous version 1 even if marked “destroy” – will be gone after 30 days.
  • Once keyring is created – IT IS NOT POSSIBLE TO DESTROY IT , so it makes trouble in terraform state when created and tried to delete it later on
  • KMS feature is disabled by default.
  • Keyring can easily be deleted only if entire GCP project is deleted.
4ea1c55f90d22d77d74a2206c7c326c3dfeef495

TAA-577

+

[Jenkins] CF CVD launcher fails to boot devices

+
TAA-1475[Cuttlefish] OS Login Cleanup Script Errors - Improper Parsing & Excessive LatencyAvoid issues with using table that can lead to erroneous values leading to us delaying 1m per loop and taking too long.

Make it a function so we can use elsewhere if required.
5442aecc9a0cd98ef7b98699f095b0b9332f3e9e

TAA-562

+

[Jenkins] Warnings from pipeline (Pipeline Groovy)

+
TAA-1481mtk-connect-post-key Post-job container image build failsThe permission issue which causes the container image build to fail has been resolved.

Changes

Dockerfile

File path: terraform/modules/sdv-container-images/images/mtk-connect/mtk-connect-post-key/Dockerfile

  • Add --chown=appuser:appuser to fix permission issues.
ef72216ba232586dea96306431a8860b64b9d5e5

TAA-532

+

[Jenkins] Stage View bug (display pipeline)

+
TAA-1482Terraform destroy fails to delete VPCThis merge fixes the issue which cause terraform destroy to fail due to the failure in deletion of the VPC sdv-network caused due to remaining NEGs (Network Endpoint Groups).

Changes

deploy.sh

File path: tools/scripts/deployment/deploy.sh

  • Update the script's cleanup_gateways() function to also remove http-routes which triggers the deletion of NEGs.
d24100db5874a9591404fe522be1f39617448831

TAA-530

+

[Jenkins] Regression: Exceptions raised on connection/instance loss

+
TAA-1492Refactor Argo CD Application Lifecycle to Terraform-Native Cascading DeleteUpdate the Terraform module sdv-gke-apps module to enable cascading delete for the App of Apps horizon-sdv (argocd_application) and update dependency chain for the module sdv-gke-cluster.

Changes

main.tf

File path: terraform/modules/base/main.tf

  • Update the module sdv-gke-cluster with depency on sdv_certificate_manager and sdv_ssl_policy to enable deletion of GKE cluster before deletion of SSL Policy and Certificate Manager Certificates to avoid issues or errors while running Terraform destroy workflow.


main.tf

File path: terraform/modules/sdv-gke-apps/main.tf

  • Update dependency, add required finalizer to enable cascading delete for the horizon-sdv app.
  • Add wait= true to ensure complete deletion of horizon-sdv app before Terraform destroy workflow proceeds to destroy other resources in the module.


Dockerfile

File path: tools/scripts/deployment/container/Dockerfile

  • Remove kubectl from Dockerfile as it is no longer required.


deploy.sh

File path: tools/scripts/deployment/deploy.sh

  • Remove kubectl operation from deploy.sh as it is no longer required to perform clean-up activities.
  • d438544bd1469a8aec19bf31fa35ecdfbb3648d1
  • 7f1486291a1e81bb4fdd1d55c77c54d05097ec5c
  • f81ba04d48ab5b7b9f8f59cd85b2acc14252116c

TAA-528

+

[MTK Connect] node warnings: MaxListenersExceededWarning

+
TAA-1493Cloud-WS Image Builds: Yarn GPG Key IssueAdded Yarn GPG key refresh before first apt-get update in all Dockerfiles

TAA-520

+

[Jenkins] Reinstate cuttlefish-vm termination

+
TAA-1494Kubernetes NetworkPolicies update breaks deploymentMissing closing brace breaking deployment.c95c4c1cbb6ff7f1e47a296868fbc094aa9b619b

TAA-519

+

TAA-518[Jenkins] Reinstate MTKC Test bench deletion env pipeline

+
TAA-1495Security hardening breaks deploymentAn input variable with the name "sdv_dns_dnssec_enabled" has not been declared. This variable can be declared with a variable "sdv_dns_dnssec_enabled" {} block.781c30d3e9c9f76c52e508cb4da2f0e7cf0fc1eb

TAA-518

+

[Jenkins] CVD / CTS - hudson exceptions reported and jobs fail

+
TAA-1498Terraform local-exec fails because gcloud project is not explicitly set in scriptGcloud project is explicitly set in script4114bbaefb3305216541cce6a21f5874ff647de8

TAA-516

+

[Jenkins] Make test jobs more defensive + improvements

+
TAA-1499Terraform destroy blocks redeployment when KMS is enabled (sdv_enable_kms_encryption = true)Several fixes for KMS deploymentfe8c58c57f440cbebb32d6ad48b567245f3a07e6

TAA-508

+

[MTK Connect] Not terminating

+
TAA-1507[Jenkins] CF instances - Fails to connect via ssh
  • Firewall: allow SSH to Cuttlefish from GKE node range (10.1.0.0/24).
  • Jenkins: allow controller egress SSH to Cuttlefish (22); allow agent


SSH to Cuttlefish.
b65cda8a4af97e788af259396445415c243d0919

TAA-507

+

[Jenkins] CVD/CTS test run : times out on android-14.0.0_r74

+
TAA-1508[Jenkins] Fix Jenkins startup and Gerrit connectivitySet noConnectionOnStartup: true for Gerrit so Jenkins starts and the UI is available without waiting for Gerrit; the plugin connects when Gerrit is reachable.

Add allow-jenkins-controller-egress-to-gerrit NetworkPolicy so the

controller can reach Gerrit on 29418 (SSH) and 8080 (HTTP). Default-deny had limited controller egress to 80/443, so the Gerrit Trigger never connected.
  • b6cb82e5122502f2225d2511d227e6715074e8f2
  • 667a01271394ac723922cc321da365a78f62b915

TAA-502

+

Re-apply pull-request trigger to GitHub workflows

+
TAA-1517[Cloud-WS] terminal monospace rendering & gemini-mcp-agent executable broken entrypointFixes applied

  • move gemini-mcp-agent shebang to line 1 so the binary executes with python
  • install fonts-dejavu-core in android-studio, asfp, and code-oss images
  • set GNOME Terminal dconf defaults (DejaVu Sans Mono 12, cell width/height scale 1.0) for desktop images
  • run dconf update during image setup to apply terminal defaults


Minor changes

  • updated docs/guides/mcp_setup.md for clear info on gemini-mcp-agent and mcp servers settings in android studio IDE
  • 101a10e02cca3979f2d3633f28ccd33fef69e39d
  • 9f8f771b984319b687eb9d0739be2ae725094444

TAA-501

+

Invent a solution for restricting GitHub workflows to a given branch

+
TAA-1528ABFS server and uploader: SSH on port 22 blocked; get_server_details / get_uploader_details and Console SSH fail.Code in this PR fixes port 22 opening.

And deployment issue which fixes "Error: googleapi: Error 400: The network policy addon must be enabled before updating the nodes." in file terraform/modules/sdv-gke-cluster/main.tf
  • f8758c356c6e376c2548340614f6fcdd3fe56232
  • 4e974f84bb0c6ea5c209ec9e14e918ba25260a3c

TAA-498

+

Gerrit-admin password is not created in Keycloak

+
TAA-1529Pin ABFS build node pool to a fixed GKE version so CASFS kernel module stays compatibleThis PR pins the ABFS build node pool to a configurable GKE version to ensure CASFS kernel compatibility and prevent breakage caused by automatic node upgrades.

Details

  • Introduced sdv_abfs_build_node_pool_version variable to configure the ABFS build node pool GKE version.
  • Set the node pool version attribute using this variable to pin the node image and kernel.
  • Replaced release channel usage with an explicit cluster version (sdv_cluster_version) to allow disabling auto-upgrade on the ABFS node pool.
  • Updated terraform.tfvars and terraform.tfvars.sample with pinned values (e.g. 1.32.7-gke.1079000).


Purpose

CASFS is a kernel module and must match the running node kernel. By pinning the ABFS node pool GKE version, we ensure the kernel remains stable and compatible, preventing unexpected failures caused by GKE auto-upgrades.
  • f81bc7a22a434fa578190b2c28b03f5c0a9d23b6
  • be32e04e32df4098ffb8b27bea745008feb44916
  • 5f2625760dabe05e85e3936cef0823161163a4ae
  • f4d7724799bcb36732cfcd2d56ff2468ee1f1900
  • 44ae1d9a659a9d49f8f3dba32c791caa57b52440
  • 30d8eb8d3309306cfb8e37e021f18c44e80b1bcc
  • f04bf569d1d0e08cdb3d5e6040b0e1c3ecdb35d2

TAA-496

+

[Android Studio] Arm builds throw an error due to config

+
TAA-1535GKE deployment fails on first run due to STABLE release channel conflictFix the error Error: error creating NodePool: googleapi: Error 400: Auto_upgrade must be true when release_channel STABLE is set.

GCP requires auto_upgrade = true on node pools when a named release channel (STABLE/REGULAR/RAPID with REGULAR being the default option if release channel is unset) is active.

Setting channel = "UNSPECIFIED" explicitly opts the cluster out of any release channel, removing this constraint and allowing Terraform to pin versions directly.

Also formatted all Terraform files in terraform/ for alignment consistency (no logic changes).

Changes

terraform/modules/sdv-gke-cluster/main.tf

  • Add release_channel { channel = "UNSPECIFIED" } block so the GCP API treats the cluster as unenrolled from any release channel.


tools/scripts/deployment/deploy.sh

  • Remove the unenroll_cluster_release_channel function as the release channel is now managed declaratively by Terraform, making the gcloud workaround obsolete.
4a81e523ede0e405465dbe366148a866f571b624

TAA-490

+

[RPi] RPi4 again broken

+
TAA-1569Gerrit-Operator in ArgoCD application goes into Unknown sync state and the Gerrit application fails to syncUpdate gerrit-operator repoURL from Googlesource to GitHub, avoiding rate limits and fixing issues with gerrit-operator deployment on fresh platforms.

Changes

gerrit-operator.yaml

File path: gitops/templates/gerrit-operator.yaml

  • Update repoURL
37709c24d51326d61cd2da2c833a56af2b0e29b0

TAA-478

+

[Jenkins] CLEAN_ALL: rsync errors

+
TAA-1570Terraform workloads Service Account name mismatch in GCP and k8sService Account sa7 name in terraform/env/main.tf should be gke-tf-wl-sa instead of current value of gke-terraform-workloads-sa to match with other instances of the SA in yaml files.d055ccc982ff4ced993dd99a6a359cda5b6b571d

TAA-477

+

[Gerrit] Branch name revision incorrect for 15 - build failures

+
TAA-1573terraform apply fails with Error 400 when removing a sub-environment due to cert map referenced by TargetHTTPSProxyThis PR resolves two issues affecting the sandbox environment:

  • Fix terraform apply Error 400 on sub-environment removal - Previously, each environment (main + each sub-env) created its own google_certificate_manager_certificate_map via a for_each loop. When a sub-environment was removed, Terraform would attempt to delete its cert map while it was still referenced by the TargetHTTPSProxy, causing a 400 error. All certificates (main env + sub-envs) are now consolidated into a single cert map (horizon-sdv-map), eliminating the per-environment map lifecycle issue.
  • Enable GKE main node pool autoscaling - The sdv_main_node_pool previously had a static node count with no autoscaling. Autoscaling has been enabled to allow the cluster to scale up when resource pressure occurs (e.g. Gerrit pod scheduling failures), with a configurable min/max range (default: 1-6 nodes).


Changes:

Certificate Manager Consolidation

  • terraform/modules/base/locals.tf - Replaced per-environment cert_domains_per_env map with a single flat cert_domains map merging main and sub-env domains.
  • terraform/modules/base/main.tf - Removed for_each from module.sdv_certificate_manager, calling it once with all domains. Updated dns_auth_records reference accordingly.
  • terraform/modules/sdv-certificate-manager/main.tf - Hardcoded cert map name to horizon-sdv-map so it is stable across all environments.
  • gitops/templates/gateway.yaml - Updated networking.gke.io/certmap annotation to reference the fixed name horizon-sdv-map instead of the namespaced name.


Main Node Pool Autoscaling

  • terraform/modules/sdv-gke-cluster/main.tf - Enabled autoscaling block on sdv_main_node_pool using min_node_count / max_node_count variables.
  • terraform/modules/sdv-gke-cluster/variables.tf - Added node_pool_min_node_count (default: 1) and node_pool_max_node_count (default: 6).
  • terraform/modules/base/variables.tf - Added sdv_cluster_node_pool_min_node_count and sdv_cluster_node_pool_max_node_count to expose these as configurable inputs.
  • b010250548f9df5ff7db2afd89da96acfbfa5174
  • 831a59f8e9c4f8f8de5b5b9d525acb3b29426641

TAA-425

+

[Jenkins] Native Linux install of MTKC fails (unattended-upgr)

+
TAA-1579Cloud WS: Create Config pipeline fails due to inconsistent order of resource creation
  • Fixed Terraform apply failures caused by google_workstations_workstation_config_iam_binding executing before the target workstation config was fully created
  • Resolved consistent 404 Resource Not Found errors from GCP IAM API due to premature policy application
  • Identified missing dependency in Terraform graph caused by using each.key (raw input string) for workstation_config_id
  • Corrected implicit dependency handling by replacing hardcoded each.key with a direct reference to the workstation config resource attribute
  • Ensured Terraform now waits for successful workstation config provisioning before applying IAM bindings
  • Eliminated parallel execution race condition between workstation config creation and IAM policy attachment
06bbd1cf74d6e47993c0d394e441ae96ea722c8c

TAA-412

+

[Jenkins] Russian Roulette with cache instance causing build failures

+
TAA-1601AAOS Builder: Build that uses mirror for repo sync fails because of empty variable `MIRROR_DIR_NAME`Fixes AOSP mirror path resolution in Android Jenkins pipelines by using AOSP_MIRROR_DIR_NAME when constructing MIRROR_DIR_FULL_PATH.

Pipeline parameters are defined as AOSP_MIRROR_DIR_NAME, but Jenkinsfiles were reading MIRROR_DIR_NAME.

This mismatch could produce an invalid mirror path when USE_LOCAL_AOSP_MIRROR=true.

Change

Updated Jenkinsfiles to build mirror path with:

.../${AOSP_MIRROR_DIR_NAME} (instead of .../${MIRROR_DIR_NAME}).
952611a5c6e8ee26ff25488e03904bbe5822cc73

TAA-400

+

[Jenkins] SSH issues

+
TAA-1602ExternalDNS does not update apex A record when load balancer IP changesExternalDNS was not updating the apex domain A record (e.g. .horizon-sdv.com) when the Gateway load balancer was recreated, only subdomains such as mcp..horizon-sdv.com were updated. ExternalDNS only updates records it owns, and ownership is stored in TXT records. With the default TXT registry, no valid ownership TXT was created for the zone apex, so the apex A record was never updated. This change sets txtPrefix: "%{record_type}-." so the ownership TXT is created in the same zone and ExternalDNS can own and update the apex A record.

Changes

external-dns.yaml

File path: gitops/templates/external-dns.yaml

  • Add txtPrefix: "%{record_type}-." so ExternalDNS can create the heritage TXT for the apex and update the apex A record when the LB IP changes.
5e585c4f1e9548a7dbc616fc990d6313725a480f

TAA-398

+

[Jenkins] GCE plugin losing connection with VM instance

+
TAA-1605cloud-ws/gemini-cli/gemini-mcp-agent: MCP tool calls fail after some time in gemini-cli due to JWT token cachingThis fix hardens and standardizes how MCP authentication is handled across Gemini clients by using mcp-client-bridge for registry-managed servers, instead of relying on cached config tokens.

It also updates setup documentation to reflect the actual runtime model and adds clearer operational guidance for Android Studio/ASfP cache reload behavior.

Changes

Command-based MCP entries for registry-managed servers

  • Registry-managed servers are now written as command + args + env bridge entries instead of static httpUrl + headers token entries.
  • This is applied in both:
  • update_gemini_cli_settings_file(...)
  • update_android_studio_mcp_file(...)


Unified bridge entry generation

  • Added reusable helpers:
  • build_bridge_env_payload()
  • build_bridge_server_entry(...)
  • get_entry_http_url(...)
  • Added managed-entry marker: MCP_GATEWAY_REGISTRY_MANAGED=1.


Bridge now injects auth from token file, not config headers

  • run_mcp_client_bridge(...) now obtains auth via token file flow (~/.gemini/mcp-gateway-registry-token.json) using non-interactive refresh path.
  • Removed dependency on cached settings.json bearer values for bridge auth.


Transport compatibility for Gemini clients

  • Bridge now supports both:
  • MCP stdio framed protocol (Content-Length headers)
  • NDJSON mode (legacy behavior)
  • Added:
  • _bridge_read_message(...)
  • _bridge_write_message(...)


Security hardening and JSON-RPC protocol correctness (id handling)\

  • Added guard in bridge to refuse token injection for non-registry URLs
  • Added strict ID validation via _is_valid_jsonrpc_id(...).
  • Bridge no longer emits error responses for notifications/no-id messages
6438c8f1b428d01fa0f296c24810e71f9c96992d

TAA-394

+

[Gerrit] Admin password stored in secrets with newline

+
TAA-1608Cloud WS: Add Users to WS and Remove Users from WS fail due to inconsistent way of fetching WS stateThis fixe corrects a state-validation issue in Cloud Workstation admin pipelines (add user / remove user).

Previously, these pipelines validated workstation state from Terraform state (terraform show -json), which can be stale when users start/stop workstations via gcloud (user pipelines).

Now, validation uses live workstation state from GCP API (gcloud workstations describe) to make decisions based on current runtime reality.

Key Changes

  • Renamed and refactored utility function:
  • validate_workstation_state -> assert_workstation_state
  • assert_workstation_state now:
  • Accepts: [expected_state]
  • Uses get_current_workstation_state (live gcloud lookup)
  • Defaults expected_state to STATE_STOPPED
  • Fails fast for transitional states (STATE_STARTING, STATE_STOPPING, STATE_REPAIRING, STATE_RECONCILING) with retry guidance
  • Updated admin scripts to pass full workstation context:
  • workstation-admin-operations/add-workstation-user/add-workstation-user.sh
  • workstation-admin-operations/remove-workstation-user/remove-workstation-user.sh
  • In add/remove scripts:
  • Workstation config is read from generated workstation map (output.tfvars.json)
  • Cluster and region are read from input tfvars
  • State check is now: assert_workstation_state ...
0bbeb90f60c9c3b904dae53c2c46c3bc271450ea

TAA-354

+

[Jenkins] CVD adb devices not always working as expected

+
- -*** - -## Horizon SDV - Release 3.0.0 (2025-12-19) - -### Summary - -Horizon SDV 3.0.0 extends platform capabilities with support for Android 15 and the latest extensions of OpenBSW. Horizon 3.0.0 also delivers multiple new feature and several improvements over Rel. 2.0.1 along with critical bug fixes. - -The set of new features in version 3.0.0 includes, among others: - -- **Simplified Deployment Flow :** We have overhauled the deployment process to make it more intuitive and efficient. The new flow reduces complexity, minimizing the steps required to get your environment up and running. - -- **ARM64 Support (Bare Metal) :** We have expanded our infrastructure support to include ARM64 Bare Metal. This allows you to run your workloads natively on ARM architecture, ensuring higher performance and closer parity with automotive edge hardware. - -- **Gemini Code Assist :** Supercharge your development with the integration of **Gemini Code Assist** and the Gerrit MCP Server. You can now leverage Google's state-of-the-art AI to generate code, explain complex logic, debug issues faster and make use of agentic code review workflows directly within your development environment. - -- **Advanced Monitoring with Grafana :** Gain deeper insights into your infrastructure with our new Grafana integration. You can now visualize and monitor POD and Instance metrics in real-time, helping you optimize resource usage and diagnose performance bottlenecks quickly. - -*** -### New Features - -| ID | Feature | Description | -|----|--------|-------------| -| TAA-924 | Simplified Horizon Deployment Flow | Simplified and automated the Horizon SDV platform deployment by removing GitHub Actions, enabling faster adoption by community teams and reducing human error. | -| TAA-511 | Gemini Code Assist in R3 – Gerrit MCP Server integration | Use company’s codebase as a knowledge base for Gemini Code Assist within the IDE to receive code suggestions & explanations tailored to known codebase, libraries and corporate standards. | -| TAA-365 | ARM64 GCP VM (Bare Metal) support for Cuttlefish | ARM64 GCP VM support for Android builds and testing with Cuttlefish | -| TAA-595 | Monitoring of POD/Instance metrics with Grafana | Access to CPU/Memory/Storage metrics for pods and instances, to more easily investigate and debug container, pod and instance related problems and its impact on platform performance. | -| TAA-944 | Android pipeline update to Android 16 | Support for Android16 for AAOS, CF and CTS in Horizon pipelines. | -| TAA-946 | Extend OpenBSW support with additional features | Support for Eclipse Foundation OpenBSW workload features that were not included in Horizon-SDV R2.0.0 | -| TAA-889 | Horizon R3 Security update | Selected open-source applications and tools which are part of Horizon SDV platform are updated to the latest stable versions | -| TAA-377 | Google AOSP Repo Mirroring | NFS based mirror of AOSP repos deployed in the K8s cluster. | -| TAA-947 | ABFS update for R3 | Corrections and minor ABFS updates delivered from Google in Release 3.0.0 timeframe. | -| TAA-1072 | Cloud Artefact storage management | Android and OpenBSW build jobs have been modified to allow the user to specify metadata to be added to the stored artifacts during the upload process. Implementation is supported for GCP storage option only | -| TAA-1001 | Kubernetes Dashboard SSO integration | Kubernetes Dashboard SSO integration | -| TAA-945 | Replace deprecated Kaniko tool | Replace deprecated Google Kaniko tool for building container images with new Buildkit tool. | -| TAA-941 | IAA demo case. | Support for Partner demo in IAA Messe show. The main technical scope is to apply a binary APK file to the Android code, help building it and flash it to selected targets (Cuttlefish and potentially Pixel) according to Partner specification. | - -*** - -### Improved Features - -See details in `horizon-sdv/docs/release-notes-3-0-0.md` - -| ID | Summary | -|----|-------------| -| TAA-1171 | Create Workloads area in Gitops section | -| TAA-862 | Improvements Structure of Test pipelines | -| TAA-1111 | Unified CTS Build process | -| TAA-1265 | [Gerrit] Support GERRIT_TOPIC with existing gerrit-triggers plugin | -| TAA-1271 | Support custom machine types for Cuttlefish | -| TAA-1269 | Adjust CTS/CVD options | - -*** - -### Bug Fixes - -| ID | Summary | -|-----------|-------------| -| TAA-993 | [ABFS] Missing permission for jenkins-sa for ABFS server | -| TAA-1063 | [Security] Axios Security update 1.12.0 (dependabot) | -| TAA-904 | ABFS unmount doesn't work | -| TAA-1090 | [Android 16] Cuttlefish builds fail (x86/arm) | -| TAA-1080 | [OpenBSW] Builds no longer functional (main) | -| TAA-1110 | [OpenBSW] pyTest failure | -| TAA-1103 | [Android 16] CTS 16_r2 reports 15_r5 | -| TAA-1145 | Update filter (gcloud compute instance-templates list) | -| TAA-1161 | [ARM64] Subnet working utils too quiet | -| TAA-1113 | [ABFS] COS Images no longer available | -| TAA-1118 | [ABFS] CASFS kernel module update required (6.8.0-1029-gke) | -| TAA-1176 | [CF] CTS CtsDeqpTestCases execution on main not completing in reasonable time (x86) | -| TAA-1186 | Incorrect Headlamp Token Injector Argo CD App Project | -| TAA-1196 | AOSP Mirror changes break standard builds | -| TAA-1201 | AOSP Mirror sync failures | -| TAA-1200 | AOSP Mirror URLs and branches incorrect | -| TAA-1203 | AOSP Mirror repo sync failing on HTTP 429 (rate limits) | -| TAA-1205 | AOSP Mirror - no support for dev build instance | -| TAA-1198 | AOSP Mirror does not support Warm nor Gerrit Builds | -| TAA-1204 | AOSP Mirror repo sync failing - SyncFailFastError | -| TAA-1214 | AOSP Mirror ab is an | -| TAA-1219 | [Cuttlefish] Host installer failures masked | -| TAA-1202 | AOSP Mirror blocking concurrent jobs incorrectly configured | -| TAA-1238 | [Cuttlefish] Update to v1.31.0 - v1.30.0 has changed from stable to unstable. | -| TAA-1241 | [Android] Mirror should not be using OpenBSW nodes for jobs AM | -| TAA-1247 | [Workloads] Remove chmod and use git executable bit | -| TAA-1249 | [GCP] Client Secret now masked (security clarification) | -| TAA-1264 | [CVD] Logs are no longer being archived | -| TAA-1261 | [Cuttlefish] gnu.org down blocking builds | -| TAA-1266 | Pipeline does not fail when IMAGE_TAG is empty and NO_PUSH=true | -| TAA-1267 | [CWS] OSS Workstation blocking regex incorrect (non-blocking) | -| TAA-1258 | [Cuttlefish] VM instance template default disk too small. | -| TAA-1233 | [Jenkins] Plugin updates for fixes | -| TAA-1278 | [Cuttlefish] SSH/SCP errors on VM instance creation | -| TAA-1283 | Mismatch in githubApp secrets (TAA-1054) | -| TAA-1277 | [Jenkins] Plugin updates for fixes | -| TAA-1279 | [RPI] Android 16 RPI builds now failing | -| TAA-1282 | [GCP] Cluster deletion not removing load balancers | -| TAA-1257 | [Cuttlefish] android-cuttlefish build failure (regression) | -| TAA-1273 | [Cuttlefish] android-cuttlefish CVD device issues (regression) | -| TAA-1149 | [K8S] Reduce parallel jobs to reduce costs | -| TAA-1162 | [K8S] Revert parallel jobs change to reduce costs | -| TAA-1191 | Monitoring deployment related hotfixes | -| TAA-1114 | [ABFS] Update env/dev license (Oct'25) | -| TAA-1116 | [Android] Android 15 and 16 AVD missing SPDX BOM | -| TAA-1192 | [MTKC] Support additional hosts for dev and test instances | -| TAA-1207 | Mirror/Create-Mirror: Add parameter for size of the mirror NFS PVC | -| TAA-1208 | Mirror/Sync-Mirror: Sync all mirrors when SYNC_ALL_EXISTING_MIRRORS is selected | -| TAA-1211 | [Android] Simplify Dev Build instance job | -| TAA-1218 | [Grafana] ArgoCD on Dev shows 'Out Of Sync' | -| TAA-1231 | R2 - GitHub Actions workflow fails | -| TAA-1038 | [Jenkins] CF scripts - update to retain color | -| TAA-907 | Multibranch is not supported in ABFS | -| TAA-862 | Improvement to structure of Test pipelines | -| TAA-788 | Jenkins AAOS Build failure - Gerrit secrets/tokens mismatch | -| TAA-1088 | [NPM] Move wait-on post node install | -| TAA-1115 | [STORAGE] Override default paths | -| TAA-1160 | [ARM64] Lack of available instances on us-central1-b/f zone | -| TAA-1274 | [Cuttlefish] CTS hangs - android-cuttlefish issues | -| TAA-1290 | [Cuttlefish] ARM64 builds broken on f2fs-tools (missing) | -| TAA-1253 | [MTK Connect] ERROR: script returned exit code 92/1 | - -*** -## Horizon SDV - Release 2.0.1 (2025-09-24) - -### Summary -Hot fix release for Rel.2.0.1 with emergency fix for Helm repo endpoint issues, and minor documentation updates. - -### New Features -N/A - -### Improved Features -- New simplified Release Notes format. - -### Bug Fixes - -| ID | Summary | -|-----------|--------------------------------------------------------------| -| TAA-1002 | [Jenkins] Install ansicolor plugin for CWS | -| TAA-1005 | Horizon provisioning failure - Due to outdated Helm install steps | -| TAA-1007 | Cloud WS - Workstation Image builds fail due to Helm Debian repo (OSS) migration | -| TAA-1040 | Remove references to private repo in Horizon files | -| TAA-1045 | OSS Bitnami helm charts EOL - -*** - -## Horizon SDV - Release 2.0.1 (2025-09-24) - -### Summary -Hot fix release for Rel.2.0.1 with emergency fix for Helm repo endpoint issues, and minor documentation updates. - -### New Features -N/A - -### Improved Features -- New simplified Release Notes format. - -### Bug Fixes - -| ID | Summary | -|-----------|--------------------------------------------------------------| -| TAA-1002 | [Jenkins] Install ansicolor plugin for CWS | -| TAA-1005 | Horizon provisioning failure - Due to outdated Helm install steps | -| TAA-1007 | Cloud WS - Workstation Image builds fail due to Helm Debian repo (OSS) migration | -| TAA-1040 | Remove references to private repo in Horizon files | -| TAA-1045 | OSS Bitnami helm charts EOL - -*** -## Horizon SDV - Release 2.0.0 (2025-09-01) - -### Summary -Horizon SDV 2.0.0 extends Android build capabilities with the integration of Google ABFS and introduces support for Android 15. This release also adds support for OpenBSW, the first non-Android automotive software platform in Horizon. Other major enhancements include Google Cloud Workstations with access to browser based IDEs Code-OSS, Android Studio (AS), and Android Studio for Platforms (ASfP). In addition, Horizon 2.0.0 delivers multiple feature improvements over Rel. 1.1.0 along with critical bug fixes. - -### New Features - -| ID | Feature | Description | -|----------|----------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| TAA-8 | ABFS for Build Workloads | The Horizon-SDV platform now integrates Google's Android Build Filesystem (ABFS), a filesystem and caching solution designed to accelerate AOSP source code checkouts and builds. | -| TAA-9 | Cloud Workstation integration | The Horizon-SDV platform now includes GCP Cloud Workstations, enabling users to launch pre-configured, and ready-to-use development environments directly in browser. | -| TAA-375 | Android 15 Support | Horizon previously supported Android 15 in Horizon-SDV but by default Android 14 was selected. In this release, Android 15 android-15.0.0_r36 is now the default revision. | -| TAA-381 | Add OpenBSW build targets | Eclipse Foundation OpenBSW Workload: As part of the R2.0.0 delivery, a new workload has been introduced to support the Eclipse Foundation OpenBSW within the Horizon SDV platform. This workload enables users to work on the OpenBSW stack for build and testing. | -| TAA-915 | Cloud Android Orchestration - Pt.1| In R2.0.0 Horizon platform introduces significant improvements to Cuttlefish Virtual Devices (CVD). These enhancements include increased support for a larger number of devices, optimized device startup processes, a more robust recovery mechanism, and updated CTS Test Plans and Modules to ensure seamless integration and compatibility with CVD. | -| TAA-623 | Management of Jenkins Jobs using CasC | The CasC configuration has been updated to include a single job in the jenkins.yaml file, automatically started on each Jenkins restart. This job provides the "Build with Parameters" option for users. | -| TAA-462 | Kubernetes Dashboard | The Horizon platform now includes the Headlamp application, a web-based tool to browse Kubernetes resources and diagnose problems. | -| TAA-717 | Multiple pre-warmed disk pools | Horizon is changing to persistent volume storage for build caches to improve build times, cost, and efficiency. Pools are separated by Android major version and Raspberry Vanilla targets now have their own smaller pools. | -| TAA-596 | Jenkins RBAC | Jenkins has been configured with RBAC capability using the Role-based Authorization Strategy plugin. | -| TAA-611 | Argo CD SSO | Argo CD has been configured with SSO capabilities. Users can login either with admin credentials or via Keycloak. | -| TAA-837 | Access Control tool | Additional Access Control functionality provides a Python script tool and classes for managing user and access control on GCP level. - -### Improved Features -N/A - -### Bug Fixes - -| ID | Summary | -|----------|---------| -| TAA-980 | Access control issue: Workstation User Operations succeed for non-owned workstations | -| TAA-984 | [Kaniko] Increase CPU resource limits | -| TAA-982 | [ABFS] Uploaders not seeding new branch/tag correctly | -| TAA-981 | [ABFS] CASFS kernel module update required (6.8.0-1027-gke) | -| TAA-977 | New Cloud Workstation configuration is created successfully, but user details are not added to the configuration | -| TAA-974 | kube-state-metrics Service Account missing causes StatefulSet pod creation failure | -| TAA-968 | [IAA] Elektrobit patches remain in PV and break gerrit0 | -| TAA-966 | [ABFS] Kaniko out of memory | -| TAA-953 | Android CF/CTS: update revisions | -| TAA-964 | [Gerrit] Propagate seed values | -| TAA-959 | Reduce number of GCE CF VMs on startup | -| TAA-932 | ABFS_LICENSE_B64 not propagated to k8s secrets correctly | -| TAA-958 | [Gerrit] repo sync - ensure we reset local changes before fetch | -| TAA-781 | GitHub environment secrets do not update when Terraform workload is executed | -| TAA-933 | Failure to access ABFS artifact repository | -| TAA-905 | AAOS build does not work with ABFS | -| TAA-931 | Create common storage script | -| TAA-930 | Investigate build issues when using MTK Connect as HOST | -| TAA-923 | Cuttlefish limited to 10 devices | -| TAA-921 | [Cuttlefish] Building android-cuttlefish failing on The GNU Operating System and the Free Software Movement | -| TAA-922 | MTK Connect device creation assumes sequential adb ports | -| TAA-920 | Android Developer Build and Test instances leave MTK Connect testbenches in place when aborted | -| TAA-563 | [Jenkins] Replace gsutils with gcloud storage | -| TAA-886 | Conflict Between Role Strategy Plugin and Authorize Project Plugin | -| TAA-814 | Android RPi builds failing: requires MESON update | -| TAA-863 | Workloads Guide: updates for R2.0.0 | -| TAA-867 | Gerrit triggers plugin deprecated | -| TAA-890 | Persistent Storage Audit: Internal tool removal | -| TAA-618 | MTK Connect access control for Cuttlefish Devices | -| TAA-711 | [Qwiklabs][Jenkins] GCE limits - VM instances blocked | - -*** -## Horizon SDV - Release 1.1.0 (2025-04-14) - -### Summary -Minor improvements in Jenkins configuration, additional pipelines implemented for massive build cache pre-warming simplification required for Hackathon and Gerrit post jobs cleanup. - -### New Features - -| ID | Feature | Description | -|----------|---------------------------|-----------------------------------------------------------------------------------------------| -| TAA-431 | Jenkins R1 deployment extensions | Jenkins extensions to Platform Foundation deployment in Rel.1.0.0. Includes new job to pre-warm build volumes. | -| TAA-346 | Support Pixel devices | Support for Google Pixel tablet hardware, full integration with MTK Connect. | - -### Improved Features -N/A - -### Bug Fixes - -| ID | Summary | -|----------|------------------------------------------------------------------------------------------| -| TAA-683 | Change MTK Connect application version to 1.8.0 in helm chart | -| TAA-644 | self-hosted runners | -| TAA-641 | [Jenkins] Horizon Gerrit URL path breaks upstream Gerrit FETCH | -| TAA-639 | Keycloak Sign-in Failure: Non-Admin Users Stuck on Loading Screen | -| TAA-631 | MTK Connect license file in wrong location | -| TAA-628 | [Jenkins] CF instance creation (connection loss) | -| TAA-627 | [Jenkins][Dev] Investigate build nodes not scaling past 13 | -| TAA-622 | Workloads documentation - wrong paths | -| TAA-615 | Improve the Gerrit post job | -| TAA-401 | [Jenkins] Agent losing connection to instance | -| TAA-309 | [Jenkins] 'Build Now' post restart - -*** -## Horizon SDV - Release 1.0.0 (2025-03-18) - -### Summary -The main objective for Release 1.0.0 is to achieve Minimal Viable Product level for Horizon SDV platform where orchestration will be done using Terraform on GCP with the intention of deploying the tooling on the platform using a simple provisioner. Horizon SDV platform in Rel.1.0.0 supports: - -- GCP platform / services. -- Terraform orchestration (IaC). -- IaC stored in GitHub repo and provisioned either via CLI or GitHub actions. -- Platform supports Gerrit to host Android (AAOS) repos and manifests, and allows users to create their own repos. - - With some pre-submit checks: e.g., voting labels: code review and manual vs automated triggered builds. - - Will mirror and fork AAOSP manifests repo, and one additional code repo for demonstrating the SDV Tooling pipeline. Locally mirrored/forked manifest will be updated to point to the internally mirrored code repo, all other repos will remain using the external OSS AAOS repos hosted by Google. -- Platform supports Jenkins to allow for concurrent, multiple builds for iterative builds from changes in open review in Gerrit, full builds (manually, when user requests) and CTS testing. -- Platform supports an artefact registry to hold all build artefacts and test results. -- Platform supports a means to run CTS tests and use the Accenture MTK Connect solution for UI/UX testing. - -### New Features - -| ID | Feature | Description | -|----------|---------------------------|-----------------------------------------------------------------------------------------------| -| TAA-6 | Platform foundation | Platform foundation including support for: GCP, Terraform workflow, Stage 1 and Stage 2 deployment with ArgoCD, Jenkins Orchestration and Authentication support through Keycloak. | -| TAA-12 | Github Setup | Github support for Horizon SDV platform repositories. | -| TAA-67 | Tooling for tooling | Android build pipelines support. | -| TAA-5 | Gerrit | Gerrit support. | -| TAA-61 | MTK Connect | Test connections to CVD with MTK Connect support. | -| TAA-2 | Android Virtual Devices | Pipelines for Android Virtual Devices CVD and AVD. | - -### Improved Features -N/A - -### Bug Fixes - -| ID | Summary | -|----------|------------------------------------------------------------------------------------------| -| TAA-608 | MTK Connect - testbench registration failing | -| TAA-593 | [Jenkins] Jenkins config auto reload affecting builds | -| TAA-590 | [Jenkins] CTS_DOWNLOAD_URL : strip trailing slashes | -| TAA-589 | [Jenkins] computeEngine: cuttlefish-vm-v110 points to incorrect instance template | -| TAA-577 | [Jenkins] CF CVD launcher fails to boot devices | -| TAA-562 | [Jenkins] Warnings from pipeline (Pipeline Groovy) | -| TAA-532 | [Jenkins] Stage View bug (display pipeline) | -| TAA-530 | [Jenkins] Regression: Exceptions raised on connection/instance loss | -| TAA-528 | [MTK Connect] node warnings: MaxListenersExceededWarning | -| TAA-520 | [Jenkins] Reinstate cuttlefish-vm termination | -| TAA-519 | TAA-518[Jenkins] Reinstate MTKC Test bench deletion env pipeline | -| TAA-518 | [Jenkins] Reinstate MTKC Test bench deletion env pipeline | -| TAA-518 | [Jenkins] CVD / CTS - hudson exceptions reported and jobs fail | -| TAA-516 | [Jenkins] Make test jobs more defensive + improvements | -| TAA-508 | [MTK Connect] Not terminating | -| TAA-507 | [Jenkins] CVD/CTS test run: times out on android-14.0.0_r74 | -| TAA-502 | Re-apply pull-request trigger to GitHub workflows | -| TAA-501 | Invent a solution for restricting GitHub workflows to a given branch | -| TAA-498 | Gerrit-admin password is not created in Keycloak | -| TAA-496 | [Android Studio] Arm builds throw an error due to config | -| TAA-490 | [RPi] RPi4 again broken | -| TAA-478 | [Jenkins] CLEAN_ALL: rsync errors | -| TAA-477 | [Gerrit] Branch name revision incorrect for 15 - build failures | -| TAA-425 | [Jenkins] Native Linux install of MTKC fails (unattended-upgr) | -| TAA-412 | [Jenkins] Russian Roulette with cache instance causing build failures | -| TAA-400 | [Jenkins] SSH issues | -| TAA-398 | [Jenkins] GCE plugin losing connection with VM instance | -| TAA-394 | [Gerrit] Admin password stored in secrets with newline | -| TAA-354 | [Jenkins] CVD adb devices not always working as expected | - -*** +
diff --git a/terraform/env/locals.tf b/terraform/env/locals.tf index ebb5586c..e9b7e093 100644 --- a/terraform/env/locals.tf +++ b/terraform/env/locals.tf @@ -13,6 +13,11 @@ # limitations under the License. locals { + # Parse repo URL to extract owner/name + scm_repo_url_without_protocol = replace(var.scm_repo_url, "https://", "") + scm_repo_parts = split("/", local.scm_repo_url_without_protocol) + scm_repo_owner = var.scm_type == "github" ? local.scm_repo_parts[1] : "" + scm_repo_name = length(local.scm_repo_parts) > 2 ? replace(local.scm_repo_parts[2], ".git", "") : "" # Password policies per secret (adjust lengths/policy per need) secret_password_specs = { @@ -211,6 +216,47 @@ EOT "roles/iam.serviceAccountTokenCreator", ]) } + argo_workflows = { + name = "argo-workflows" + prefix_style = "gke" + gke_sas = [ + { ns = "argo-workflows", sa = "argo-workflows-server" }, + { ns = "workflows", sa = "workflow-executor" }, + # Horizon API signs artifact download URLs using this GSA. + { ns = "horizon-api", sa = "horizon-api" } + ] + roles = toset([ + "roles/storage.objectUser", + "roles/secretmanager.secretAccessor", + "roles/artifactregistry.reader", + "roles/artifactregistry.writer", + "roles/iam.serviceAccountTokenCreator", + ]) + } + argo_workflows_elevated = { + name = "argo-workflows-elevated" + prefix_style = "gke" + gke_sas = [ + { ns = "workflows", sa = "workflow-executor-elevated" } + ] + roles = toset([ + "roles/storage.objectUser", + "roles/artifactregistry.writer", + "roles/secretmanager.secretAccessor", + "roles/iam.serviceAccountTokenCreator", + "roles/container.admin", + "roles/iap.tunnelResourceAccessor", + "roles/iam.serviceAccountUser", + "roles/compute.instanceAdmin.v1", + "roles/workstations.admin", + "roles/storage.bucketViewer", + "roles/spanner.admin", + "roles/logging.admin", + "roles/editor", + "roles/iam.serviceAccountAdmin", + "roles/resourcemanager.projectIamAdmin" + ]) + } } # When no sub-envs are defined, avoid merge([]) which is invalid in Terraform @@ -283,14 +329,15 @@ EOT sub_env_git_secrets = length(var.sdv_sub_env_configs) == 0 ? {} : merge([ for env in keys(var.sdv_sub_env_configs) : - var.git_auth_method == "app" ? { + var.scm_auth_method == "app" ? { "s_${env}_git_app_id" = { secret_id = "${env}-github-app-id-b64" value = base64encode(var.sdv_github_app_id) apply_value = true gke_access = [ { ns = "${env}-argocd", sa = "argocd-sa" }, - { ns = "${env}-jenkins", sa = "jenkins-sa" } + { ns = "${env}-jenkins", sa = "jenkins-sa" }, + { ns = "${env}-workflows", sa = "workflow-executor" } ] } "s_${env}_github_app_install" = { @@ -299,7 +346,8 @@ EOT apply_value = true gke_access = [ { ns = "${env}-argocd", sa = "argocd-sa" }, - { ns = "${env}-jenkins", sa = "jenkins-sa" } + { ns = "${env}-jenkins", sa = "jenkins-sa" }, + { ns = "${env}-workflows", sa = "workflow-executor" } ] } "s_${env}_github_app_key" = { @@ -308,7 +356,8 @@ EOT apply_value = true gke_access = [ { ns = "${env}-argocd", sa = "argocd-sa" }, - { ns = "${env}-jenkins", sa = "jenkins-sa" } + { ns = "${env}-jenkins", sa = "jenkins-sa" }, + { ns = "${env}-workflows", sa = "workflow-executor" } ] } "s_${env}_github_app_pkcs8" = { @@ -316,17 +365,19 @@ EOT value = base64encode(data.external.pkcs8_converter.result.result) apply_value = true gke_access = [ - { ns = "${env}-jenkins", sa = "jenkins" } + { ns = "${env}-jenkins", sa = "jenkins-sa" }, + { ns = "${env}-workflows", sa = "workflow-executor" } ] } } : { "s_${env}_git_pat" = { - secret_id = "${env}-git-pat-b64" - value = base64encode(var.sdv_git_pat) + secret_id = "${env}-scm-password-b64" + value = base64encode(var.scm_password) apply_value = true gke_access = [ { ns = "${env}-jenkins", sa = "jenkins-sa" }, - { ns = "${env}-argocd", sa = "argocd-sa" } + { ns = "${env}-argocd", sa = "argocd-sa" }, + { ns = "${env}-workflows", sa = "workflow-executor" } ] } } @@ -477,6 +528,10 @@ EOT { ns = "jenkins" sa = "jenkins-sa" + }, + { + ns = "workflows" + sa = "workflow-executor" } ] } @@ -492,6 +547,10 @@ EOT { ns = "jenkins" sa = "jenkins-sa" + }, + { + ns = "workflows" + sa = "workflow-executor" } ] } @@ -512,20 +571,25 @@ EOT } s8 = { secret_id = "github-app-private-key-pkcs8-b64" - value = base64encode(data.external.pkcs8_converter.result.result) + value = var.scm_auth_method == "app" ? base64encode(data.external.pkcs8_converter.result.result) : "" apply_value = true gke_access = [ { ns = "jenkins" sa = "jenkins" + }, + { + ns = "workflows" + sa = "workflow-executor" } ] } } - sdv_gcp_git_pat_secrets_map = { - s16 = { - secret_id = "git-pat-b64" - value = base64encode(var.sdv_git_pat) + # Username/password secrets (for generic Git auth) + sdv_gcp_userpass_secrets_map = { + s18 = { + secret_id = "scm-username-b64" + value = base64encode(var.scm_username) apply_value = true gke_access = [ { @@ -538,5 +602,45 @@ EOT } ] } + s19 = { + secret_id = "scm-password-b64" + value = base64encode(var.scm_password) + apply_value = true + gke_access = [ + { + ns = "jenkins" + sa = "jenkins-sa" + }, + { + ns = "argocd" + sa = "argocd-sa" + }, + { + ns = "workflows" + sa = "workflow-executor" + } + ] + } } + + # Conditionally select SCM secrets based on auth method + #sdv_gcp_scm_secrets_map = ( + # var.scm_auth_method == "app" ? local.sdv_gcp_github_app_secrets_map : + # var.scm_auth_method == "userpass" ? local.sdv_gcp_userpass_secrets_map : + # {} # Empty map for "none" - no secrets needed for public repos + #) + + # Merge all secrets + #sdv_gcp_secrets_map = merge( + # local.sdv_gcp_common_secrets_map, + # local.sdv_gcp_scm_secrets_map + #) + + # Merge all secrets + #sdv_gcp_secrets_map = merge( + # local.sdv_gcp_common_secrets_map, + # local.sdv_gcp_scm_secrets_map, + # local.sub_env_secrets, + # local.sub_env_git_secrets + #) } diff --git a/terraform/env/main.tf b/terraform/env/main.tf index d96cc8e0..a408be6e 100644 --- a/terraform/env/main.tf +++ b/terraform/env/main.tf @@ -17,8 +17,9 @@ # project ID, region, zone, network etc. Set up service accounts and # the required secrets. -# Convert GitHub App private key to PKCS#8 format +# Convert GitHub App private key to PKCS#8 format (only when using app auth) data "external" "pkcs8_converter" { + program = ["bash", "-c", "jq -r .key | openssl pkcs8 -topk8 -inform PEM -outform PEM -nocrypt | jq -Rs '{result: .}'"] query = { @@ -104,31 +105,13 @@ module "gerrit_admin_key_subenv" { convert_to_openssh = true } -# Validate git auth secrets -resource "terraform_data" "validate_git_auth" { +# Validate SCM configuration +resource "terraform_data" "validate_scm_config" { lifecycle { + # GitHub App requires scm_type = "github" precondition { - condition = var.git_auth_method != "pat" || (var.git_auth_method == "pat" && length(var.sdv_git_pat) > 0 && var.sdv_git_pat != "") - error_message = "Selected 'pat' auth but 'sdv_git_pat' is empty or invalid." - } - - precondition { - condition = var.git_auth_method != "app" || (var.git_auth_method == "app" && length(var.sdv_github_app_id) > 0 && var.sdv_github_app_id != "") - error_message = "Selected 'app' auth but 'sdv_github_app_id' is empty or invalid." - } - - precondition { - condition = var.git_auth_method != "app" || (var.git_auth_method == "app" && length(var.sdv_github_app_install_id) > 0 && var.sdv_github_app_install_id != "") - error_message = "Selected 'app' auth but 'sdv_github_app_install_id' is empty or invalid." - } - - precondition { - condition = var.git_auth_method != "app" || ( - var.git_auth_method == "app" && - length(var.sdv_github_app_private_key) > 50 && - !can(regex("paste content here", var.sdv_github_app_private_key)) - ) - error_message = "Selected 'app' auth but 'sdv_github_app_private_key' set to default sample. Replace it with your actual private key." + condition = var.scm_auth_method != "app" || (var.scm_auth_method == "app" && var.scm_type == "github") + error_message = "GitHub App authentication (scm_auth_method='app') can only be used with scm_type='github'." } } } @@ -136,9 +119,14 @@ resource "terraform_data" "validate_git_auth" { module "base" { source = "../modules/base" - git_repo_owner = var.sdv_git_repo_owner - git_repo_name = var.sdv_git_repo_name - git_auth_method = var.git_auth_method + # SCM configuration + scm_type = var.scm_type + scm_auth_method = var.scm_auth_method + scm_repo_url = var.scm_repo_url + scm_repo_branch = var.scm_repo_branch + scm_repo_owner = local.scm_repo_owner + scm_repo_name = local.scm_repo_name + scm_username = var.scm_username # The project is used by provider.tf to define the GCP project sdv_project = var.sdv_gcp_project_id @@ -171,6 +159,7 @@ module "base" { "networkconnectivity.googleapis.com", "networkmanagement.googleapis.com", "integrations.googleapis.com", + "aiplatform.googleapis.com", "storage.googleapis.com", "workstations.googleapis.com", "spanner.googleapis.com", @@ -193,12 +182,15 @@ module "base" { sdv_openbsw_build_node_pool_machine_type = "c2d-highcpu-8" sdv_openbsw_build_node_pool_max_node_count = 20 + # Gemini AI Assistant Jenkins job / Vertex workloads: 32 CPU / 96Gi limits; n2-standard-32 allocatable CPU is often < 32 cores after kube-reserved, so n2-standard-48 fits reliably. + sdv_utility_node_pool_machine_type = "n2-standard-48" + sdv_utility_node_pool_max_node_count = 10 + sdv_abfs_build_node_pool_version = var.sdv_abfs_build_node_pool_version sdv_cluster_version = var.sdv_cluster_version env_name = var.sdv_env_name domain_name = var.sdv_root_domain - git_repo_branch = var.sdv_git_repo_branch gcp_backend_bucket_name = var.sdv_gcp_backend_bucket sdv_network_egress_router_name = "sdv-egress-internet" @@ -214,11 +206,13 @@ module "base" { sdv_sub_environments = nonsensitive(keys(var.sdv_sub_env_configs)) sdv_sub_env_branches = { for env, config in var.sdv_sub_env_configs : - nonsensitive(env) => coalesce(config.branch, var.sdv_git_repo_branch) + nonsensitive(env) => coalesce(config.branch, var.scm_repo_branch) } # # To create a new SA with access from GKE to GC, add a new saN block. + # external-dns-sa (sa12) is only provisioned when dynamic DNS is used + # (sdv_dns_use_static_a_records = false); with static A records external-dns is not deployed. # sdv_wi_service_accounts = merge( { @@ -253,7 +247,8 @@ module "base" { "roles/logging.admin", "roles/editor", "roles/iam.serviceAccountAdmin", - "roles/resourcemanager.projectIamAdmin" + "roles/resourcemanager.projectIamAdmin", + "roles/aiplatform.user" ]) }, sa2 = { @@ -374,49 +369,149 @@ module "base" { ]) }, sa8 = { - account_id = "external-dns-sa" - display_name = "external-dns-sa" - description = "external-dns-sa/external-dsn-sa in GKE cluster makes use of this account through WI" + account_id = "gke-mcp-gateway-sa" + display_name = "mcp-gateway-registry SA" + description = "mcp-gateway-registry/mcp-gateway-registry-sa in GKE cluster makes use of this account through WI" gke_sas = [ { - gke_ns = "external-dns" - gke_sa = "external-dns-sa" + gke_ns = "mcp-gateway-registry" + gke_sa = "mcp-gateway-registry-sa" } ] roles = toset([ - "roles/dns.admin" + "roles/secretmanager.secretAccessor", + "roles/iam.serviceAccountTokenCreator", ]) }, sa9 = { - account_id = "gke-mcp-gateway-sa" - display_name = "mcp-gateway-registry SA" - description = "mcp-gateway-registry/mcp-gateway-registry-sa in GKE cluster makes use of this account through WI" + account_id = "gke-argo-workflows-sa" + display_name = "Argo Workflows SA" + description = "argo-workflows namespace service accounts use this account through Workload Identity" gke_sas = [ { - gke_ns = "mcp-gateway-registry" - gke_sa = "mcp-gateway-registry-sa" + gke_ns = "argo-workflows" + gke_sa = "argo-workflows-server" + }, + { + gke_ns = "workflows" + gke_sa = "workflow-executor" + }, + # Horizon API uses this GSA for GCS V4 signed URLs (downloadArtifact); namespace matches gitops (prefix + horizon-api). + { + gke_ns = "horizon-api" + gke_sa = "horizon-api" + }, + ] + roles = toset([ + "roles/storage.objectUser", + "roles/secretmanager.secretAccessor", + "roles/artifactregistry.reader", + "roles/artifactregistry.writer", + "roles/iam.serviceAccountTokenCreator", + ]) + }, + sa10 = { + account_id = "gke-argo-workflows-elevated-sa" + display_name = "Argo Workflows Elevated SA" + description = "workflows/workflow-executor-elevated in GKE cluster makes use of this account through WI for migration workloads that need Jenkins-equivalent permissions" + + gke_sas = [ + { + gke_ns = "workflows" + gke_sa = "workflow-executor-elevated" } ] + roles = toset([ + "roles/storage.objectUser", + "roles/artifactregistry.writer", "roles/secretmanager.secretAccessor", "roles/iam.serviceAccountTokenCreator", + "roles/container.admin", + "roles/iap.tunnelResourceAccessor", + "roles/iam.serviceAccountUser", + "roles/compute.instanceAdmin.v1", + "roles/workstations.admin", + "roles/storage.bucketViewer", + "roles/spanner.admin", + "roles/logging.admin", + "roles/editor", + "roles/iam.serviceAccountAdmin", + "roles/resourcemanager.projectIamAdmin", + "roles/aiplatform.user" + ]) + }, + sa11 = { + account_id = "gke-config-connector-sa" + display_name = "Config Connector Service Account" + description = "Service account used by Config Connector to manage GCP resources" + + gke_sas = [ + { + gke_ns = "gcpcc" + gke_sa = "default" + }, + { + gke_ns = "cnrm-system" + gke_sa = "cnrm-controller-manager-gcp" + }, + # Namespaced-mode Config Connector creates one controller ServiceAccount per + # ConfigConnectorContext namespace (see gitops configConnectorNamespaces). Each + # must impersonate gke-config-connector-sa or PubSubTopic and other CNRM + # resources fail with iam.serviceAccounts.getAccessToken / Workload Identity 403. + { + gke_ns = "cnrm-system" + gke_sa = "cnrm-controller-manager-sample-module-hello" + }, + { + gke_ns = "cnrm-system" + gke_sa = "cnrm-controller-manager-sample-hard-module-hello" + }, + { + gke_ns = "cnrm-system" + gke_sa = "cnrm-controller-manager-sample-soft-module-hello" + } + ] + roles = toset([ + "roles/editor", + "roles/iam.serviceAccountTokenCreator" ]) } }, - local.sub_env_service_accounts + local.sub_env_service_accounts, + var.sdv_dns_use_static_a_records ? {} : { + sa12 = { + account_id = "external-dns-sa" + display_name = "external-dns-sa" + description = "external-dns-sa/external-dns-sa in GKE cluster makes use of this account through WI" + + gke_sas = [ + { + gke_ns = "external-dns" + gke_sa = "external-dns-sa" + } + ] + roles = toset([ + "roles/dns.admin" + ]) + } + } ) - # # Define the secrets and values and gke access rules + sdv_gcp_secrets_map = merge( local.sdv_gcp_common_secrets_map, - var.git_auth_method == "app" ? local.sdv_gcp_github_app_secrets_map : local.sdv_gcp_git_pat_secrets_map, + var.scm_auth_method == "app" ? local.sdv_gcp_github_app_secrets_map : local.sdv_gcp_userpass_secrets_map, local.sub_env_secrets, local.sub_env_git_secrets ) + #sdv_gcp_secrets_map = local.sdv_gcp_secrets_map + + sdv_gcp_parameters_map = { p1 = { parameter_id = "sdv_environment" @@ -454,19 +549,19 @@ module "base" { value = base64encode(var.sdv_gcp_backend_bucket) } p8 = { - parameter_id = "sdv_git_repo_name" + parameter_id = "scm_repo_name" parameter_version_id = "v1" - value = base64encode(var.sdv_git_repo_name) + value = base64encode(local.scm_repo_name) } p9 = { - parameter_id = "sdv_git_repo_branch" + parameter_id = "scm_repo_branch" parameter_version_id = "v1" - value = base64encode(var.sdv_git_repo_branch) + value = base64encode(var.scm_repo_branch) } p10 = { parameter_id = "sdv_git_auth_method" parameter_version_id = "v1" - value = base64encode(var.git_auth_method) + value = base64encode(var.scm_auth_method) } p11 = { parameter_id = "sdv_sub_environments" @@ -484,4 +579,6 @@ module "base" { sdv_enable_kms_encryption = var.sdv_enable_kms_encryption # DNSSEC configuration for Cloud DNS sdv_dns_dnssec_enabled = var.sdv_dns_dnssec_enabled + # Static A records: no zone delegation, LB cert auth; add A records to parent zone manually + sdv_dns_use_static_a_records = var.sdv_dns_use_static_a_records } diff --git a/terraform/env/terraform.tfvars.sample b/terraform/env/terraform.tfvars.sample index e06f7860..d3066edf 100644 --- a/terraform/env/terraform.tfvars.sample +++ b/terraform/env/terraform.tfvars.sample @@ -32,21 +32,45 @@ sdv_gcp_backend_bucket = "" sdv_env_name = "" sdv_root_domain = "" -# Git Repository Integration -sdv_git_repo_owner = "" -sdv_git_repo_name = "" -sdv_git_repo_branch = "" -# Git auth type -# Select `app` to use GitHub App credentials or `pat` to use a Personal Access Token -git_auth_method = "" - -# Personal Access Token (PAT) -# Replace with actual value if selected auth type is `pat` -sdv_git_pat = "" - -# GitHub App Credentials -# Replace with actual value if selected auth type is `app` +# SCM Configuration (Source Code Management) +# SCM Type +# - "github": Use when hosting on GitHub (enables GitHub-specific features like PR discovery) +# - "git": Use for any other Git server (Gerrit, GitLab, Gitea, Bitbucket, self-hosted Git) +scm_type = "github" + + +# Authentication Method +# - "app": GitHub App authentication (only available with scm_type="github") +# - "userpass": Username/Password or token (works with any Git server via HTTP basic auth) +# - "none": Public repository (no authentication required) +scm_auth_method = "userpass" + + +# Repository Configuration +# Could contains .git at the end (for GitLab is required) +# GitHub example: https://github.com/owner/repo +# Gerrit example: https://gerrit.example.com/a/project-name +# GitLab example: https://gitlab.example.com/group/project.git +# Gitea example: https://gitea.example.com/owner/repo +scm_repo_url = "https://github.com/owner/repo" +scm_repo_branch = "main" + + +# Username/Password Authentication (only when scm_auth_method = "userpass") +# For GitHub with PAT: +# scm_username = "git" +# scm_password = "ghp_xxxxxxxxxxxxxxxxxxxx" +# For Gerrit: +# scm_username = "your-gerrit-username" +# scm_password = "your-http-password" +# For GitLab: +# scm_username = "your-gitlab-username" (could be any not empty string) +# scm_password = "glpat-xxxxxxxxxxxxxxxxxxxx" +scm_username = "git" +scm_password = "" + +# GitHub App Authentication (only if scm_auth_method = "app") sdv_github_app_id = "" sdv_github_app_install_id = "" @@ -58,8 +82,54 @@ sdv_github_app_private_key = < { - directory = image.directory - version = image.build_version - build_args = try(image.build_args, {}) + directory = image.directory + version = image.build_version + build_args = try(image.build_args, {}) + context_path = try(image.context_path, null) + dockerfile_path = try(image.dockerfile_path, null) + platform = try(image.platform, null) } } } @@ -125,6 +136,7 @@ module "sdv_gke_cluster" { module.sdv_network, module.sdv_gcs, module.sdv_gcs_openbsw, + module.sdv_gcs_argo_workflows, module.sdv_container_images, module.sdv_certificate_manager, module.sdv_ssl_policy, @@ -169,6 +181,13 @@ module "sdv_gke_cluster" { openbsw_build_node_pool_min_node_count = var.sdv_openbsw_build_node_pool_min_node_count openbsw_build_node_pool_max_node_count = var.sdv_openbsw_build_node_pool_max_node_count + # Utility node pool (Gemini/Vertex CLI; workloadLabel utility + taint workloadType=utility) + utility_node_pool_name = var.sdv_utility_node_pool_name + utility_node_pool_node_count = var.sdv_utility_node_pool_node_count + utility_node_pool_machine_type = var.sdv_utility_node_pool_machine_type + utility_node_pool_min_node_count = var.sdv_utility_node_pool_min_node_count + utility_node_pool_max_node_count = var.sdv_utility_node_pool_max_node_count + # KMS encryption for GKE secrets enable_kms_encryption = var.sdv_enable_kms_encryption kms_crypto_key_id = local.kms_crypto_key_id @@ -178,6 +197,7 @@ module "sdv_gke_apps" { source = "../sdv-gke-apps" depends_on = [ module.sdv_gke_cluster, + module.sdv_wi, ] providers = { @@ -193,11 +213,14 @@ module "sdv_gke_apps" { gcp_backend_bucket = var.gcp_backend_bucket_name gcp_registry_id = var.sdv_artifact_registry_repository_id - git_repo_url = "https://github.com/${var.git_repo_owner}/${var.git_repo_name}" - git_auth_method = var.git_auth_method - git_repo_owner = var.git_repo_owner - git_repo_name = var.git_repo_name - git_repo_branch = var.git_repo_branch + # SCM configuration + scm_type = var.scm_type + scm_auth_method = var.scm_auth_method + scm_repo_url = var.scm_repo_url + scm_repo_branch = var.scm_repo_branch + scm_repo_owner = var.scm_repo_owner + scm_repo_name = var.scm_repo_name + scm_username = var.scm_username domain_name = var.domain_name subdomain_name = var.env_name @@ -207,6 +230,8 @@ module "sdv_gke_apps" { # Network policies configuration enable_network_policies = var.sdv_enable_network_policies + use_static_dns_a_records = var.sdv_dns_use_static_a_records + images = { for name, image in local.images : name => { directory = image.directory @@ -216,18 +241,22 @@ module "sdv_gke_apps" { } module "sdv_certificate_manager" { - source = "../sdv-certificate-manager" + source = "../sdv-certificate-manager" name = var.sdv_ssl_certificate_name domains = local.cert_domains + certificate_authorization_type = var.sdv_dns_use_static_a_records ? "load_balancer" : "dns" + depends_on = [ module.sdv_apis, ] } +# Only create Cloud DNS zone when not using static A records (zone delegation flow). module "sdv_dns_zone" { source = "../sdv-dns-zone" + count = var.sdv_dns_use_static_a_records ? 0 : 1 zone_name = "${var.env_name}-${var.sdv_ssl_certificate_name}-com" dns_name = "${var.env_name}.${var.domain_name}." diff --git a/terraform/modules/base/variables.tf b/terraform/modules/base/variables.tf index 32cc7034..29a2df0c 100644 --- a/terraform/modules/base/variables.tf +++ b/terraform/modules/base/variables.tf @@ -27,26 +27,51 @@ variable "sdv_sub_env_branches" { default = {} } -variable "git_auth_method" { - description = "Authentication method for Argo CD: 'app' or 'pat'." +# SCM Configuration +variable "scm_type" { + description = "SCM type: 'github' or 'git'" type = string } -variable "git_repo_owner" { - description = "Git repository owner (user or organization name)" +variable "scm_auth_method" { + description = "SCM auth method: 'app' or 'userpass'" type = string } -variable "git_repo_name" { - description = "Git repository name" +variable "scm_repo_url" { + description = "Full SCM repository URL" type = string } -variable "git_repo_branch" { - description = "Git repository branch" +variable "scm_repo_branch" { + description = "SCM repository branch" type = string } +variable "scm_repo_owner" { + description = "SCM repository owner (parsed from URL for GitHub)" + type = string + default = "" +} + +variable "scm_repo_name" { + description = "SCM repository name (parsed from URL for GitHub)" + type = string + default = "" +} + +variable "scm_username" { + description = "SCM username" + type = string + default = "git" +} + +variable "github_repo_branch" { + description = "[Legacy] Github repo branch" + type = string + default = "" +} + variable "env_name" { description = "Define the environment name" type = string @@ -279,6 +304,36 @@ variable "sdv_openbsw_build_node_pool_max_node_count" { default = 20 } +variable "sdv_utility_node_pool_name" { + description = "Name of the utility node pool (Gemini/Vertex CLI and similar workloads)" + type = string + default = "sdv-utility-node-pool" +} + +variable "sdv_utility_node_pool_node_count" { + description = "Number of nodes for the utility node pool" + type = number + default = 0 +} + +variable "sdv_utility_node_pool_machine_type" { + description = "Machine type for the utility node pool (size for up to 32 CPU / 96Gi pod limits; n2-standard-48+ recommended over n2-standard-32 due to kube-reserved CPU)" + type = string + default = "n2-standard-48" +} + +variable "sdv_utility_node_pool_min_node_count" { + description = "Minimum number of nodes for the utility node pool" + type = number + default = 0 +} + +variable "sdv_utility_node_pool_max_node_count" { + description = "Maximum number of nodes for the utility node pool" + type = number + default = 10 +} + variable "sdv_wi_service_accounts" { description = "A map of service accounts and their configurations for WI" type = map(object({ @@ -372,16 +427,22 @@ variable "arm64_services_range" { default = "10.22.0.0/16" } +variable "sdv_enable_network_policies" { + description = "Enable network policies for all workloads. When disabled, all network policies will be removed. Default is enabled." + type = bool + default = true +} + variable "sdv_dns_dnssec_enabled" { description = "Enable DNSSEC for Cloud DNS zone. Requires domain ownership verification. Enabled by default." type = bool default = true } -variable "sdv_enable_network_policies" { - description = "Enable network policies for all workloads. When disabled, all network policies will be removed. Default is enabled." +variable "sdv_dns_use_static_a_records" { + description = "Use static A records in parent zone instead of zone delegation. When true: no Cloud DNS zone for app domain, certificate uses Load Balancer authorization, DNSSEC off. Add A records (domain and mcp.domain) to parent zone manually; LB IP is visible in GCP Console." type = bool - default = true + default = false } variable "sdv_enable_kms_encryption" { diff --git a/terraform/modules/sdv-certificate-manager/main.tf b/terraform/modules/sdv-certificate-manager/main.tf index 23dff571..6c7f944a 100644 --- a/terraform/modules/sdv-certificate-manager/main.tf +++ b/terraform/modules/sdv-certificate-manager/main.tf @@ -16,7 +16,8 @@ data "google_project" "project" {} locals { # Certificate map hostname matching is entry-based, not SAN-based. - # Create both apex and wildcard entries per environment domain. + # Wildcard map entries (and wildcard cert SANs) require DNS authorization; + # load_balancer auth does not support wildcard domains. certificate_map_host_entries = merge( { for k, d in var.domains : "${k}-apex" => { @@ -25,32 +26,30 @@ locals { hostname = d } }, - { + var.certificate_authorization_type == "dns" ? { for k, d in var.domains : "${k}-wildcard" => { name = "${var.name}-entry-${k}-wildcard" domain_key = k hostname = "*.${d}" } - } + } : {}, + var.certificate_authorization_type == "load_balancer" ? { + for k, d in var.domains : "${k}-mcp" => { + name = "${var.name}-entry-${k}-mcp" + domain_key = k + hostname = "mcp.${d}" + } + } : {} ) } -resource "google_certificate_manager_certificate" "horizon_sdv_cert" { - for_each = var.domains - project = data.google_project.project.project_id - - name = "${var.name}-${each.key}" - scope = "DEFAULT" +# DNS authorization (CNAME) only for certificate_authorization_type = "dns". +# "load_balancer" uses managed domains without DNS authorizations (static A records flow). +resource "google_certificate_manager_dns_authorization" "instance" { + for_each = var.certificate_authorization_type == "dns" ? var.domains : {} - managed { - domains = [ - google_certificate_manager_dns_authorization.instance[each.key].domain, - "*.${google_certificate_manager_dns_authorization.instance[each.key].domain}" - ] - dns_authorizations = [ - google_certificate_manager_dns_authorization.instance[each.key].id - ] - } + name = each.key == "main" ? "${var.name}-dns-auth" : "${var.name}-dns-auth-${each.key}" + domain = each.value } # TAA-1571: State migration block for 3.0.0 -> 3.1.0 upgrade. @@ -60,11 +59,25 @@ moved { to = google_certificate_manager_dns_authorization.instance["main"] } -resource "google_certificate_manager_dns_authorization" "instance" { +resource "google_certificate_manager_certificate" "horizon_sdv_cert" { for_each = var.domains + project = data.google_project.project.project_id - name = each.key == "main" ? "${var.name}-dns-auth" : "${var.name}-dns-auth-${each.key}" - domain = each.value + name = "${var.name}-${each.key}" + scope = "DEFAULT" + + managed { + domains = var.certificate_authorization_type == "load_balancer" ? [ + each.value, + "mcp.${each.value}", + ] : [ + google_certificate_manager_dns_authorization.instance[each.key].domain, + "*.${google_certificate_manager_dns_authorization.instance[each.key].domain}", + ] + dns_authorizations = var.certificate_authorization_type == "dns" ? [ + google_certificate_manager_dns_authorization.instance[each.key].id, + ] : [] + } } resource "google_certificate_manager_certificate_map" "horizon_sdv_map" { diff --git a/terraform/modules/sdv-certificate-manager/variables.tf b/terraform/modules/sdv-certificate-manager/variables.tf index e34ae9af..94db7624 100644 --- a/terraform/modules/sdv-certificate-manager/variables.tf +++ b/terraform/modules/sdv-certificate-manager/variables.tf @@ -21,3 +21,14 @@ variable "domains" { type = map(string) description = "Map of env_name => domain_name" } + +variable "certificate_authorization_type" { + description = "Certificate authorization: 'dns' (CNAME in zone) or 'load_balancer' (no CNAME; A records must point to LB)." + type = string + default = "dns" + + validation { + condition = contains(["dns", "load_balancer"], var.certificate_authorization_type) + error_message = "certificate_authorization_type must be 'dns' or 'load_balancer'." + } +} diff --git a/terraform/modules/sdv-container-images/README.md b/terraform/modules/sdv-container-images/README.md new file mode 100644 index 00000000..2279356e --- /dev/null +++ b/terraform/modules/sdv-container-images/README.md @@ -0,0 +1,13 @@ +# sdv-container-images + +Terraform builds selected container images and pushes them to Google Artifact Registry via the `kreuzwerker/docker` provider. + +## Module inputs + +See [`variables.tf`](variables.tf). Optional per image: + +- `context_path` — absolute path to Docker build context +- `dockerfile_path` — absolute path to Dockerfile (when not `Dockerfile` inside the context) +- `platform` — e.g. `linux/amd64` for GKE-compatible builds from Apple Silicon + +Default images use `images///` as context and `Dockerfile` in that folder. diff --git a/terraform/modules/sdv-container-images/images/gerrit/gerrit-post/configure.sh b/terraform/modules/sdv-container-images/images/gerrit/gerrit-post/configure.sh index c113b607..b9457583 100644 --- a/terraform/modules/sdv-container-images/images/gerrit/gerrit-post/configure.sh +++ b/terraform/modules/sdv-container-images/images/gerrit/gerrit-post/configure.sh @@ -456,8 +456,23 @@ function gerrit-setup-all-projects() { else echo "Verified label is already added" fi + if ! grep -q "label-Ready-for-Build" ./project.config; then + echo "Adding Ready-for-Build label" + sed -i '/label-Verified = -1..+1 group Registered Users/a\ label-Ready-for-Build = -1..+1 group Administrators\n\ label-Ready-for-Build = -1..+1 group Project Owners\n\ label-Ready-for-Build = -1..+1 group Registered Users' ./project.config + sed -i '/copyCondition = changekind:NO_CODE_CHANGE/a\[label "Ready-for-Build"]\n\ function = NoBlock\n\ defaultValue = 0\n\ value = -1 Do not build\n\ value = 0 No score\n\ value = +1 Ready to build\n\ copyCondition = changekind:NO_CODE_CHANGE' ./project.config + printf '%s\n' '/\[access "refs\/meta\/config"\]/,/^\[/{' '/label-Code-Review = -2\.\.+2 group Project Owners/a\' ' label-Ready-for-Build = -1..+1 group Administrators\' ' label-Ready-for-Build = -1..+1 group Project Owners' '}' | sed -i -f - ./project.config + cat <<'EOF' >> ./project.config +[submit-requirement "Ready-for-Build"] + description = Requires Ready-for-Build +1 + applicableIf = is:open + submittableIf = label:Ready-for-Build=+1 + canOverrideInChildProjects = false +EOF + else + echo "Ready-for-Build label is already added" + fi git add . - git commit -m "Disable anonymous access, add Verified label" + git commit -m "Disable anonymous access, add labels" git push origin HEAD:refs/meta/config cd /root diff --git a/terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/Dockerfile b/terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/Dockerfile new file mode 100644 index 00000000..5968d2eb --- /dev/null +++ b/terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/Dockerfile @@ -0,0 +1,26 @@ +# Copyright (c) 2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +FROM --platform=linux/amd64 golang:1.25-alpine AS builder +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /horizon-api . + +FROM --platform=linux/amd64 alpine:3.19 +RUN apk --no-cache add ca-certificates +COPY --from=builder --chown=nobody:nobody /horizon-api /horizon-api +USER nobody +ENTRYPOINT ["/horizon-api"] diff --git a/terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/go.mod b/terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/go.mod new file mode 100644 index 00000000..bd3f5450 --- /dev/null +++ b/terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/go.mod @@ -0,0 +1,104 @@ +module github.com/acn-horizon-sdv/horizon-api + +go 1.25.0 + +require ( + cloud.google.com/go/storage v1.62.0 + github.com/coreos/go-oidc/v3 v3.17.0 + google.golang.org/api v0.275.0 + k8s.io/apimachinery v0.35.3 + k8s.io/client-go v0.35.3 + sigs.k8s.io/controller-runtime v0.23.3 +) + +require ( + cel.dev/expr v0.25.1 // indirect + cloud.google.com/go v0.123.0 // indirect + cloud.google.com/go/auth v0.20.0 // indirect + cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect + cloud.google.com/go/compute/metadata v0.9.0 // indirect + cloud.google.com/go/iam v1.6.0 // indirect + cloud.google.com/go/monitoring v1.24.3 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/emicklei/go-restful/v3 v3.12.2 // indirect + github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect + github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect + github.com/evanphx/json-patch/v5 v5.9.11 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/go-jose/go-jose/v4 v4.1.4 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-logr/zapr v1.3.0 // indirect + github.com/go-openapi/jsonpointer v0.21.0 // indirect + github.com/go-openapi/jsonreference v0.20.2 // indirect + github.com/go-openapi/swag v0.23.0 // indirect + github.com/google/btree v1.1.3 // indirect + github.com/google/gnostic-models v0.7.0 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/google/s2a-go v0.1.9 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect + github.com/googleapis/gax-go/v2 v2.21.0 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/prometheus/client_golang v1.23.2 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.66.1 // indirect + github.com/prometheus/procfs v0.16.1 // indirect + github.com/spf13/pflag v1.0.9 // indirect + github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect + github.com/x448/float16 v0.8.4 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/detectors/gcp v1.42.0 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect + go.opentelemetry.io/otel v1.43.0 // indirect + go.opentelemetry.io/otel/metric v1.43.0 // indirect + go.opentelemetry.io/otel/sdk v1.43.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect + go.opentelemetry.io/otel/trace v1.43.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.27.0 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/crypto v0.50.0 // indirect + golang.org/x/net v0.52.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/term v0.42.0 // indirect + golang.org/x/text v0.36.0 // indirect + golang.org/x/time v0.15.0 // indirect + gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect + google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260401001100-f93e5f3e9f0f // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect + google.golang.org/grpc v1.81.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + k8s.io/api v0.35.3 // indirect + k8s.io/apiextensions-apiserver v0.35.0 // indirect + k8s.io/klog/v2 v2.130.1 // indirect + k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect + k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect + sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect + sigs.k8s.io/randfill v1.0.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482 // indirect + sigs.k8s.io/yaml v1.6.0 // indirect +) diff --git a/terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/go.sum b/terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/go.sum new file mode 100644 index 00000000..af72af40 --- /dev/null +++ b/terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/go.sum @@ -0,0 +1,270 @@ +cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= +cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= +cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= +cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA= +cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q= +cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= +cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +cloud.google.com/go/iam v1.6.0 h1:JiSIcEi38dWBKhB3BtfKCW+dMvCZJEhBA2BsaGJgoxs= +cloud.google.com/go/iam v1.6.0/go.mod h1:ZS6zEy7QHmcNO18mjO2viYv/n+wOUkhJqGNkPPGueGU= +cloud.google.com/go/logging v1.13.2 h1:qqlHCBvieJT9Cdq4QqYx1KPadCQ2noD4FK02eNqHAjA= +cloud.google.com/go/logging v1.13.2/go.mod h1:zaybliM3yun1J8mU2dVQ1/qDzjbOqEijZCn6hSBtKak= +cloud.google.com/go/longrunning v0.8.0 h1:LiKK77J3bx5gDLi4SMViHixjD2ohlkwBi+mKA7EhfW8= +cloud.google.com/go/longrunning v0.8.0/go.mod h1:UmErU2Onzi+fKDg2gR7dusz11Pe26aknR4kHmJJqIfk= +cloud.google.com/go/monitoring v1.24.3 h1:dde+gMNc0UhPZD1Azu6at2e79bfdztVDS5lvhOdsgaE= +cloud.google.com/go/monitoring v1.24.3/go.mod h1:nYP6W0tm3N9H/bOw8am7t62YTzZY+zUeQ+Bi6+2eonI= +cloud.google.com/go/storage v1.62.0 h1:w2pQJhpUqVerMON45vatE2FpCYsNTf7OHjkn6ux5mMU= +cloud.google.com/go/storage v1.62.0/go.mod h1:T5hz3qzcpnxZ5LdKc7y8Tw7lh4v9zeeVyrD/cLJAzZU= +cloud.google.com/go/trace v1.11.7 h1:kDNDX8JkaAG3R2nq1lIdkb7FCSi1rCmsEtKVsty7p+U= +cloud.google.com/go/trace v1.11.7/go.mod h1:TNn9d5V3fQVf6s4SCveVMIBS2LJUqo73GACmq/Tky0s= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 h1:DHa2U07rk8syqvCge0QIGMCE1WxGj9njT44GH7zNJLQ= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 h1:UnDZ/zFfG1JhH/DqxIZYU/1CUAlTUScoXD/LcM2Ykk8= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0/go.mod h1:IA1C1U7jO/ENqm/vhi7V9YYpBsp+IMyqNrEN94N7tVc= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.55.0 h1:7t/qx5Ost0s0wbA/VDrByOooURhp+ikYwv20i9Y07TQ= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.55.0/go.mod h1:vB2GH9GAYYJTO3mEn8oYwzEdhlayZIdQz6zdzgUIRvA= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 h1:0s6TxfCu2KHkkZPnBfsQ2y5qia0jl3MMrmBhu3nCOYk= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc= +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik= +github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4= +github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc= +github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= +github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= +github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU= +github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ= +github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A= +github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI= +github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= +github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds= +github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0= +github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k= +github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ= +github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= +github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= +github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= +github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= +github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= +github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= +github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= +github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= +github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= +github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= +github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= +github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= +github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= +github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc= +github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= +github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA6RlzjJaT4hi3kII+zYw8wmLb8= +github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= +github.com/googleapis/gax-go/v2 v2.21.0 h1:h45NjjzEO3faG9Lg/cFrBh2PgegVVgzqKzuZl/wMbiI= +github.com/googleapis/gax-go/v2 v2.21.0/go.mod h1:But/NJU6TnZsrLai/xBAQLLz+Hc7fHZJt/hsCz3Fih4= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns= +github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= +github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A= +github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= +github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo= +github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/detectors/gcp v1.42.0 h1:kpt2PEJuOuqYkPcktfJqWWDjTEd/FNgrxcniL7kQrXQ= +go.opentelemetry.io/contrib/detectors/gcp v1.42.0/go.mod h1:W9zQ439utxymRrXsUOzZbFX4JhLxXU4+ZnCt8GG7yA8= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.42.0 h1:lSZHgNHfbmQTPfuTmWVkEu8J8qXaQwuV30pjCcAUvP8= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.42.0/go.mod h1:so9ounLcuoRDu033MW/E0AD4hhUjVqswrMF5FoZlBcw= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= +golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= +golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= +golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= +gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= +gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/api v0.275.0 h1:vfY5d9vFVJeWEZT65QDd9hbndr7FyZ2+6mIzGAh71NI= +google.golang.org/api v0.275.0/go.mod h1:Fnag/EWUPIcJXuIkP1pjoTgS5vdxlk3eeemL7Do6bvw= +google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0= +google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I= +google.golang.org/genproto/googleapis/api v0.0.0-20260401001100-f93e5f3e9f0f h1:K3zPU40OFjwD5YKADLMLoiL0L7JJpBgEdLqGuCNPfp0= +google.golang.org/genproto/googleapis/api v0.0.0-20260401001100-f93e5f3e9f0f/go.mod h1:EIQZ5bFCfRQDV4MhRle7+OgjNtZ6P1PiZBgAKuxXu/Y= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.81.0 h1:W3G9N3KQf3BU+YuCtGKJk0CmxQNbAISICD/9AORxLIw= +google.golang.org/grpc v1.81.0/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= +gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/api v0.35.3 h1:pA2fiBc6+N9PDf7SAiluKGEBuScsTzd2uYBkA5RzNWQ= +k8s.io/api v0.35.3/go.mod h1:9Y9tkBcFwKNq2sxwZTQh1Njh9qHl81D0As56tu42GA4= +k8s.io/apiextensions-apiserver v0.35.0 h1:3xHk2rTOdWXXJM+RDQZJvdx0yEOgC0FgQ1PlJatA5T4= +k8s.io/apiextensions-apiserver v0.35.0/go.mod h1:E1Ahk9SADaLQ4qtzYFkwUqusXTcaV2uw3l14aqpL2LU= +k8s.io/apimachinery v0.35.3 h1:MeaUwQCV3tjKP4bcwWGgZ/cp/vpsRnQzqO6J6tJyoF8= +k8s.io/apimachinery v0.35.3/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= +k8s.io/client-go v0.35.3 h1:s1lZbpN4uI6IxeTM2cpdtrwHcSOBML1ODNTCCfsP1pg= +k8s.io/client-go v0.35.3/go.mod h1:RzoXkc0mzpWIDvBrRnD+VlfXP+lRzqQjCmKtiwZ8Q9c= +k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= +k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 h1:Y3gxNAuB0OBLImH611+UDZcmKS3g6CthxToOb37KgwE= +k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= +k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck= +k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +sigs.k8s.io/controller-runtime v0.23.3 h1:VjB/vhoPoA9l1kEKZHBMnQF33tdCLQKJtydy4iqwZ80= +sigs.k8s.io/controller-runtime v0.23.3/go.mod h1:B6COOxKptp+YaUT5q4l6LqUJTRpizbgf9KSRNdQGns0= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= +sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= +sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482 h1:2WOzJpHUBVrrkDjU4KBT8n5LDcj824eX0I5UKcgeRUs= +sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/internal/api/history_filters.go b/terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/internal/api/history_filters.go new file mode 100644 index 00000000..89bedc2e --- /dev/null +++ b/terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/internal/api/history_filters.go @@ -0,0 +1,197 @@ +// Copyright (c) 2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package api + +import ( + "fmt" + "net/url" + "path/filepath" + "regexp" + "strings" + "time" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + + "github.com/acn-horizon-sdv/horizon-api/internal/workflow" +) + +const ( + historyListChunk = int64(100) + historyMaxScan = int64(5000) + nameRegexMaxLen = 256 +) + +type historyFilter struct { + phases map[string]struct{} + + startedAfter *time.Time + startedBefore *time.Time + + finishedAfter *time.Time + finishedBefore *time.Time + + nameGlob string + nameRegex *regexp.Regexp + anyApplied bool +} + +func parseHistoryFilters(q url.Values) (*historyFilter, error) { + f := &historyFilter{} + + if v := strings.TrimSpace(q.Get("phase")); v != "" { + f.phases = make(map[string]struct{}) + for _, p := range strings.Split(v, ",") { + p = strings.TrimSpace(p) + if p == "" { + continue + } + f.phases[strings.ToLower(p)] = struct{}{} + } + if len(f.phases) > 0 { + f.anyApplied = true + } + } + + var err error + if f.startedAfter, err = parseQueryTime(q, "startedAfter"); err != nil { + return nil, err + } + if f.startedAfter != nil { + f.anyApplied = true + } + if f.startedBefore, err = parseQueryTime(q, "startedBefore"); err != nil { + return nil, err + } + if f.startedBefore != nil { + f.anyApplied = true + } + if f.finishedAfter, err = parseQueryTime(q, "finishedAfter"); err != nil { + return nil, err + } + if f.finishedAfter != nil { + f.anyApplied = true + } + if f.finishedBefore, err = parseQueryTime(q, "finishedBefore"); err != nil { + return nil, err + } + if f.finishedBefore != nil { + f.anyApplied = true + } + + if g := strings.TrimSpace(q.Get("nameGlob")); g != "" { + f.nameGlob = g + f.anyApplied = true + } + if rx := strings.TrimSpace(q.Get("nameRegex")); rx != "" { + if len(rx) > nameRegexMaxLen { + return nil, fmt.Errorf("nameRegex exceeds max length %d", nameRegexMaxLen) + } + cr, err := regexp.Compile(rx) + if err != nil { + return nil, fmt.Errorf("nameRegex: %w", err) + } + f.nameRegex = cr + f.anyApplied = true + } + + if f.startedAfter != nil && f.startedBefore != nil && f.startedAfter.After(*f.startedBefore) { + return nil, fmt.Errorf("startedAfter is after startedBefore") + } + if f.finishedAfter != nil && f.finishedBefore != nil && f.finishedAfter.After(*f.finishedBefore) { + return nil, fmt.Errorf("finishedAfter is after finishedBefore") + } + + return f, nil +} + +func parseQueryTime(q url.Values, key string) (*time.Time, error) { + s := strings.TrimSpace(q.Get(key)) + if s == "" { + return nil, nil + } + t, err := time.Parse(time.RFC3339Nano, s) + if err != nil { + t, err = time.Parse(time.RFC3339, s) + } + if err != nil { + return nil, fmt.Errorf("%s: use RFC3339 or RFC3339Nano (%w)", key, err) + } + return &t, nil +} + +func parseStatusTime(s string) (time.Time, bool) { + s = strings.TrimSpace(s) + if s == "" { + return time.Time{}, false + } + t, err := time.Parse(time.RFC3339Nano, s) + if err == nil { + return t, true + } + t, err = time.Parse(time.RFC3339, s) + if err == nil { + return t, true + } + return time.Time{}, false +} + +func (f *historyFilter) matchesTerminal(u *unstructured.Unstructured) bool { + if f == nil || !f.anyApplied { + return true + } + if len(f.phases) > 0 { + disp := workflow.DisplayPhaseForAPI(u) + if _, ok := f.phases[strings.ToLower(disp)]; !ok { + return false + } + } + name := u.GetName() + if f.nameGlob != "" { + ok, _ := filepath.Match(f.nameGlob, name) + if !ok { + return false + } + } + if f.nameRegex != nil && !f.nameRegex.MatchString(name) { + return false + } + startedStr, _, _ := unstructured.NestedString(u.Object, "status", "startedAt") + finishedStr, _, _ := unstructured.NestedString(u.Object, "status", "finishedAt") + if f.startedAfter != nil || f.startedBefore != nil { + t, ok := parseStatusTime(startedStr) + if !ok { + return false + } + if f.startedAfter != nil && t.Before(*f.startedAfter) { + return false + } + if f.startedBefore != nil && t.After(*f.startedBefore) { + return false + } + } + if f.finishedAfter != nil || f.finishedBefore != nil { + t, ok := parseStatusTime(finishedStr) + if !ok { + return false + } + if f.finishedAfter != nil && t.Before(*f.finishedAfter) { + return false + } + if f.finishedBefore != nil && t.After(*f.finishedBefore) { + return false + } + } + return true +} diff --git a/terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/internal/api/logs.go b/terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/internal/api/logs.go new file mode 100644 index 00000000..6e82edf1 --- /dev/null +++ b/terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/internal/api/logs.go @@ -0,0 +1,699 @@ +// Copyright (c) 2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package api + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "sync" + "time" + + "github.com/acn-horizon-sdv/horizon-api/internal/auth" + "github.com/acn-horizon-sdv/horizon-api/internal/workflow" +) + +func (s *Server) handleWorkflowLogs(w http.ResponseWriter, r *http.Request, _ *auth.Principal) { + if r.Method != http.MethodGet { + http.NotFound(w, r) + return + } + if s.opt.Argo == nil || strings.TrimSpace(s.opt.Argo.BaseURL) == "" { + http.Error(w, "workflow log streaming is not configured (argo-base-url)", http.StatusServiceUnavailable) + return + } + ctx := r.Context() + name := r.PathValue("workflowName") + if name == "" { + http.NotFound(w, r) + return + } + q := r.URL.Query() + podName := strings.TrimSpace(q.Get("podName")) + container := q.Get("container") + if container == "" { + container = "main" + } + follow := true + if v := q.Get("follow"); v != "" { + follow, _ = strconv.ParseBool(v) + } + + if s.opt.WorkflowStore != nil { + u, gerr := s.opt.WorkflowStore.Get(ctx, name) + if gerr != nil || !workflow.IsHorizonClientVisible(u) { + http.Error(w, "workflow not found or argo error", http.StatusNotFound) + return + } + } + + phase, _, err := s.opt.Argo.WorkflowPhase(ctx, s.opt.WorkflowsNS, name) + if err != nil { + http.Error(w, "workflow not found or argo error", http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "application/x-ndjson; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + // Hint reverse proxies (nginx, some L7 LBs) not to buffer the streaming body. + w.Header().Set("X-Accel-Buffering", "no") + w.WriteHeader(http.StatusOK) + fl, _ := w.(http.Flusher) + if fl != nil { + fl.Flush() + } + + terminal := logTerminalPhase(phase) + var streamErr error + if podName != "" { + streamErr = s.streamArgoLogs(ctx, w, fl, name, podName, container, follow, terminal) + } else if follow && !terminal && s.opt.WorkflowStore != nil { + // Live follow: Argo's workflow-level combined log often batches until steps finish. + // Poll Workflow CR for new pod nodes and attach per-pod follow streams so lines emit as each step runs. + streamErr = s.streamArgoLogsDynamicPods(ctx, w, fl, name, container, s.opt.WorkflowsNS) + } else { + streamErr = s.streamArgoLogsCombined(ctx, w, fl, name, container, follow, terminal) + } + if streamErr != nil { + _ = writeLogTerminal(w, fl, "upstream_error", "", streamErr.Error()) + return + } + s.finalizeWorkflowLogStream(ctx, w, fl, s.opt.WorkflowsNS, name) +} + +func logTerminalPhase(phase string) bool { + switch strings.TrimSpace(phase) { + case "Succeeded", "Failed", "Error": + return true + default: + return false + } +} + +// streamArgoLogsCombined prefers per-pod streams from Workflow status (ordered by node startedAt) with stage metadata +// on each line. Argo's workflow-level log (empty podName) often returns only the active step or batches oddly. +// When Workflow status has no pod nodes, falls back to Argo's combined stream. +func (s *Server) streamArgoLogsCombined(ctx context.Context, w http.ResponseWriter, fl http.Flusher, workflowName, container string, follow bool, workflowTerminal bool) error { + if s.opt.WorkflowStore != nil { + if u, gerr := s.opt.WorkflowStore.Get(ctx, workflowName); gerr == nil { + podNames, _ := s.opt.WorkflowStore.ListPodNamesForWorkflow(ctx, workflowName) + targets := workflow.PodLogTargetsFromRunningPods(u, podNames) + if len(targets) > 0 { + workflow.SortPodLogTargetsByStartedAt(u, targets) + return s.streamArgoLogsPerPodSequential(ctx, w, fl, workflowName, container, follow, workflowTerminal, targets) + } + } + } + + body, err := s.opt.Argo.OpenLogStream(ctx, s.opt.WorkflowsNS, workflowName, "", container, false) + if err == nil { + rerr := s.readArgoNDJSON(ctx, w, fl, body, nil) + _ = body.Close() + if ctx.Err() != nil { + return ctx.Err() + } + if !(follow && !workflowTerminal) { + if rerr != nil && !errors.Is(rerr, io.EOF) { + return rerr + } + return nil + } + body2, err2 := s.opt.Argo.OpenLogStream(ctx, s.opt.WorkflowsNS, workflowName, "", container, true) + if err2 == nil { + tailErr := s.readArgoNDJSON(ctx, w, fl, body2, nil) + _ = body2.Close() + if ctx.Err() != nil { + return ctx.Err() + } + if tailErr != nil && !errors.Is(tailErr, io.EOF) { + return tailErr + } + return nil + } + return nil + } + if s.opt.WorkflowStore == nil { + if err != nil { + return fmt.Errorf("argo combined log: %w", err) + } + return err + } + u, gerr := s.opt.WorkflowStore.Get(ctx, workflowName) + if gerr != nil { + if err != nil { + return fmt.Errorf("argo combined log: %v; workflow get: %w", err, gerr) + } + return gerr + } + podNames, _ := s.opt.WorkflowStore.ListPodNamesForWorkflow(ctx, workflowName) + targets := workflow.PodLogTargetsFromRunningPods(u, podNames) + if len(targets) == 0 { + if err != nil { + return fmt.Errorf("argo combined log: %w; no pod nodes in workflow status", err) + } + return fmt.Errorf("no pod nodes to stream logs from") + } + workflow.SortPodLogTargetsByStartedAt(u, targets) + return s.streamArgoLogsPerPodSequential(ctx, w, fl, workflowName, container, follow, workflowTerminal, targets) +} + +func (s *Server) streamArgoLogsPerPodSequential(ctx context.Context, w http.ResponseWriter, fl http.Flusher, workflowName, container string, follow bool, workflowTerminal bool, targets []workflow.PodLogTarget) error { + for i := range targets { + t := &targets[i] + if err := s.streamArgoSinglePodLogs(ctx, w, fl, workflowName, t.PodName, container, follow, workflowTerminal, t); err != nil { + return err + } + } + return nil +} + +type multiplexLine struct { + t workflow.PodLogTarget + line []byte +} + +// followPodLogToChannel forwards one pod's Argo NDJSON log lines to ch. Unless skipInitialDrain is set, +// it drains retained logs with follow=false first (so clients see output from container start), then if +// follow is true opens a live tail (follow=true). skipInitialDrain is used when those logs were already +// emitted in a prior snapshot pass. OpenLogStream is retried with backoff while the pod is not yet available (404). +func (s *Server) followPodLogToChannel(ctx context.Context, ch chan<- multiplexLine, t workflow.PodLogTarget, workflowName, container string, follow, skipInitialDrain bool) { + if !skipInitialDrain { + s.drainPodLogStreamToChannel(ctx, ch, t, workflowName, container, false) + } + if !follow || ctx.Err() != nil { + return + } + max := s.opt.LogMaxReconnect + if max <= 0 { + max = 10 + } + backoff := 500 * time.Millisecond + for attempt := 0; attempt < max; attempt++ { + if attempt > 0 { + select { + case <-ctx.Done(): + return + case <-time.After(backoff): + } + if backoff < 30*time.Second { + backoff *= 2 + } + } + body, err := s.opt.Argo.OpenLogStream(ctx, s.opt.WorkflowsNS, workflowName, t.PodName, container, true) + if err != nil { + if attempt < max-1 { + continue + } + return + } + func() { + defer body.Close() + br := bufio.NewReaderSize(body, 32*1024) + for { + line, rerr := br.ReadBytes('\n') + if len(line) > 0 { + cp := append([]byte(nil), line...) + select { + case ch <- multiplexLine{t: t, line: cp}: + case <-ctx.Done(): + return + } + } + if rerr != nil { + return + } + } + }() + return + } +} + +func (s *Server) drainPodLogStreamToChannel(ctx context.Context, ch chan<- multiplexLine, t workflow.PodLogTarget, workflowName, container string, follow bool) { + max := s.opt.LogMaxReconnect + if max <= 0 { + max = 10 + } + backoff := 500 * time.Millisecond + for attempt := 0; attempt < max; attempt++ { + if attempt > 0 { + select { + case <-ctx.Done(): + return + case <-time.After(backoff): + } + if backoff < 30*time.Second { + backoff *= 2 + } + } + body, err := s.opt.Argo.OpenLogStream(ctx, s.opt.WorkflowsNS, workflowName, t.PodName, container, follow) + if err != nil { + if attempt < max-1 { + continue + } + return + } + func() { + defer body.Close() + br := bufio.NewReaderSize(body, 32*1024) + for { + line, rerr := br.ReadBytes('\n') + if len(line) > 0 { + cp := append([]byte(nil), line...) + select { + case ch <- multiplexLine{t: t, line: cp}: + case <-ctx.Done(): + return + } + } + if rerr != nil { + return + } + } + }() + return + } +} + +// streamArgoLogsDynamicPods follows a running workflow by multiplexing per-pod live tails. First it +// emits a snapshot (follow=false) for every known pod in startedAt order so completed steps are visible +// even if the client connected mid-run; then it tails each pod, skipping the duplicate drain for pods +// already snapshotted. New pods discovered later get a full drain+tail. +func (s *Server) streamArgoLogsDynamicPods(ctx context.Context, w http.ResponseWriter, fl http.Flusher, workflowName, container, ns string) error { + if s.opt.WorkflowStore == nil { + return fmt.Errorf("workflow store required for live multi-pod logs") + } + u, gerr := s.opt.WorkflowStore.Get(ctx, workflowName) + if gerr != nil { + return fmt.Errorf("workflow get: %w", gerr) + } + podNames, _ := s.opt.WorkflowStore.ListPodNamesForWorkflow(ctx, workflowName) + targets := workflow.PodLogTargetsFromRunningPods(u, podNames) + workflow.SortPodLogTargetsByStartedAt(u, targets) + + snapshotted := map[string]bool{} + for _, t := range targets { + if t.PodName != "" { + snapshotted[t.PodName] = true + } + } + if len(targets) > 0 { + if err := s.streamArgoLogsPerPodSequential(ctx, w, fl, workflowName, container, false, true, targets); err != nil { + return err + } + } + + ch := make(chan multiplexLine, 256) + var streamWg sync.WaitGroup + var seenMu sync.Mutex + seen := map[string]bool{} + + startStream := func(t workflow.PodLogTarget, skipInitialDrain bool) { + streamWg.Add(1) + go func(t workflow.PodLogTarget, skipInitialDrain bool) { + defer streamWg.Done() + s.followPodLogToChannel(ctx, ch, t, workflowName, container, true, skipInitialDrain) + }(t, skipInitialDrain) + } + + go func() { + defer close(ch) + ticker := time.NewTicker(time.Second) + defer ticker.Stop() + for { + phase, _, err := s.opt.Argo.WorkflowPhase(ctx, ns, workflowName) + if err != nil || logTerminalPhase(phase) { + streamWg.Wait() + return + } + podNames, _ := s.opt.WorkflowStore.ListPodNamesForWorkflow(ctx, workflowName) + u2, gerr2 := s.opt.WorkflowStore.Get(ctx, workflowName) + if gerr2 == nil { + for _, t := range workflow.PodLogTargetsFromRunningPods(u2, podNames) { + if t.PodName == "" { + continue + } + skipDrain := snapshotted[t.PodName] + seenMu.Lock() + already := seen[t.PodName] + if !already { + seen[t.PodName] = true + } + seenMu.Unlock() + if !already { + startStream(t, skipDrain) + } + } + } + select { + case <-ctx.Done(): + streamWg.Wait() + return + case <-ticker.C: + } + } + }() + + return s.mergeMultiplexNDJSON(ctx, w, fl, ch) +} + +func (s *Server) mergeMultiplexNDJSON(ctx context.Context, w http.ResponseWriter, fl http.Flusher, ch <-chan multiplexLine) error { + idle := s.opt.LogReadIdle + if idle <= 0 { + idle = 30 * time.Second + } + t := time.NewTimer(idle) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-t.C: + if err := writeLogHeartbeat(w, fl); err != nil { + return err + } + if !t.Stop() { + select { + case <-t.C: + default: + } + } + t.Reset(idle) + case ml, ok := <-ch: + if !ok { + return nil + } + if !t.Stop() { + select { + case <-t.C: + default: + } + } + t.Reset(idle) + if len(ml.line) == 0 { + continue + } + msg, pod, ok := parseArgoLogLine(ml.line) + if !ok { + msg = strings.TrimSpace(string(ml.line)) + if msg == "" { + continue + } + pod = ml.t.PodName + } else if msg == "" { + continue + } + if pod == "" { + pod = ml.t.PodName + } + if err := writeLogNDJSONLineWithMeta(w, fl, pod, msg, ml.t.NodeID, ml.t.DisplayName, ml.t.TemplateName); err != nil { + return err + } + } + } +} + +func (s *Server) streamArgoLogs(ctx context.Context, w http.ResponseWriter, fl http.Flusher, workflowName, podName, container string, follow, workflowTerminal bool) error { + var meta *workflow.PodLogTarget + if s.opt.WorkflowStore != nil && podName != "" { + if u, gerr := s.opt.WorkflowStore.Get(ctx, workflowName); gerr == nil { + podNames, _ := s.opt.WorkflowStore.ListPodNamesForWorkflow(ctx, workflowName) + for _, t := range workflow.PodLogTargetsFromRunningPods(u, podNames) { + if t.PodName == podName { + tcopy := t + meta = &tcopy + break + } + } + } + } + return s.streamArgoSinglePodLogs(ctx, w, fl, workflowName, podName, container, follow, workflowTerminal, meta) +} + +// streamArgoSinglePodLogs streams one pod's main container logs. meta adds displayName/templateName/nodeId to each NDJSON line when non-nil. +func (s *Server) streamArgoSinglePodLogs(ctx context.Context, w http.ResponseWriter, fl http.Flusher, workflowName, podName, container string, follow, workflowTerminal bool, meta *workflow.PodLogTarget) error { + max := s.opt.LogMaxReconnect + if max <= 0 { + max = 10 + } + backoff := 500 * time.Millisecond + var body io.ReadCloser + var err error + for attempt := 0; attempt < max; attempt++ { + if attempt > 0 { + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(backoff): + } + if backoff < 30*time.Second { + backoff *= 2 + } + } + body, err = s.opt.Argo.OpenLogStream(ctx, s.opt.WorkflowsNS, workflowName, podName, container, false) + if err == nil { + break + } + if attempt < max-1 { + continue + } + return err + } + rerr := s.readArgoNDJSON(ctx, w, fl, body, meta) + _ = body.Close() + if ctx.Err() != nil { + return ctx.Err() + } + if !(follow && !workflowTerminal) { + if rerr != nil && !errors.Is(rerr, io.EOF) { + return rerr + } + return nil + } + backoff = 500 * time.Millisecond + for attempt := 0; attempt < max; attempt++ { + if attempt > 0 { + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(backoff): + } + if backoff < 30*time.Second { + backoff *= 2 + } + } + body, err = s.opt.Argo.OpenLogStream(ctx, s.opt.WorkflowsNS, workflowName, podName, container, true) + if err != nil { + if attempt < max-1 { + continue + } + return nil + } + tailErr := s.readArgoNDJSON(ctx, w, fl, body, meta) + _ = body.Close() + if ctx.Err() != nil { + return ctx.Err() + } + if tailErr != nil && !errors.Is(tailErr, io.EOF) { + return tailErr + } + return nil + } + return fmt.Errorf("exceeded reconnect attempts") +} + +type readLineResult struct { + line []byte + err error +} + +// meta optional: when set, add nodeId/displayName/templateName to each log line. +func (s *Server) readArgoNDJSON(ctx context.Context, w http.ResponseWriter, fl http.Flusher, body io.Reader, meta *workflow.PodLogTarget) error { + br := bufio.NewReaderSize(body, 64*1024) + lines := make(chan readLineResult, 1) + go func() { + for { + line, err := br.ReadBytes('\n') + select { + case lines <- readLineResult{line, err}: + if err != nil { + return + } + case <-ctx.Done(): + return + } + } + }() + idle := s.opt.LogReadIdle + if idle <= 0 { + idle = 30 * time.Second + } + t := time.NewTimer(idle) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-t.C: + if err := writeLogHeartbeat(w, fl); err != nil { + return err + } + if !t.Stop() { + select { + case <-t.C: + default: + } + } + t.Reset(idle) + case r := <-lines: + if !t.Stop() { + select { + case <-t.C: + default: + } + } + t.Reset(idle) + if len(r.line) > 0 { + msg, pod, ok := parseArgoLogLine(r.line) + if ok && msg != "" { + var err error + if meta != nil { + if pod == "" { + pod = meta.PodName + } + err = writeLogNDJSONLineWithMeta(w, fl, pod, msg, meta.NodeID, meta.DisplayName, meta.TemplateName) + } else { + err = writeLogNDJSONLine(w, fl, pod, msg) + } + if err != nil { + return err + } + } + } + if r.err == io.EOF { + return nil + } + if r.err != nil { + return r.err + } + } + } +} + +type argoLogEnvelope struct { + Result *struct { + Content string `json:"content"` + PodName string `json:"podName"` + } `json:"result"` +} + +func parseArgoLogLine(b []byte) (msg, podName string, ok bool) { + var env argoLogEnvelope + if err := json.Unmarshal(b, &env); err != nil || env.Result == nil { + return "", "", false + } + return env.Result.Content, env.Result.PodName, true +} + +func writeLogNDJSONLine(w io.Writer, fl http.Flusher, podName, msg string) error { + o := map[string]string{ + "ts": time.Now().UTC().Format(time.RFC3339Nano), + "msg": msg, + "podName": podName, + } + return writeLogJSONLine(w, fl, o) +} + +func writeLogNDJSONLineWithMeta(w io.Writer, fl http.Flusher, podName, msg, nodeID, displayName, templateName string) error { + o := map[string]string{ + "ts": time.Now().UTC().Format(time.RFC3339Nano), + "msg": msg, + "podName": podName, + } + if nodeID != "" { + o["nodeId"] = nodeID + } + if displayName != "" { + o["displayName"] = displayName + } + if templateName != "" { + o["templateName"] = templateName + } + return writeLogJSONLine(w, fl, o) +} + +func writeLogHeartbeat(w io.Writer, fl http.Flusher) error { + return writeLogJSONLine(w, fl, map[string]bool{"heartbeat": true}) +} + +func writeLogTerminal(w io.Writer, fl http.Flusher, reason, workflowStatus, detail string) error { + m := map[string]string{"result": "done", "reason": reason} + if workflowStatus != "" { + m["workflowStatus"] = workflowStatus + } + if detail != "" { + m["detail"] = detail + } + return writeLogJSONLine(w, fl, m) +} + +func writeLogJSONLine(w io.Writer, fl http.Flusher, v interface{}) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + b = append(b, '\n') + if _, err := w.Write(b); err != nil { + return err + } + if fl != nil { + fl.Flush() + } + return nil +} + +func (s *Server) finalizeWorkflowLogStream(ctx context.Context, w http.ResponseWriter, fl http.Flusher, ns, name string) { + var display string + if s.opt.WorkflowStore != nil { + if u, err := s.opt.WorkflowStore.Get(ctx, name); err == nil { + display = workflow.DisplayPhaseForAPI(u) + } + } + if display == "" && s.opt.Argo != nil { + p, _, err := s.opt.Argo.WorkflowPhase(ctx, ns, name) + if err != nil { + _ = writeLogTerminal(w, fl, "upstream_error", "", err.Error()) + return + } + display = p + } + switch display { + case "Aborted": + _ = writeLogTerminal(w, fl, "workflow_aborted", "Aborted", "") + case "Succeeded": + _ = writeLogTerminal(w, fl, "workflow_completed", "Succeeded", "") + case "Failed": + _ = writeLogTerminal(w, fl, "workflow_failed", "Failed", "") + case "Error": + _ = writeLogTerminal(w, fl, "workflow_failed", "Error", "") + default: + _ = writeLogTerminal(w, fl, "workflow_completed", display, "") + } +} diff --git a/terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/internal/api/server.go b/terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/internal/api/server.go new file mode 100644 index 00000000..9ba8659b --- /dev/null +++ b/terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/internal/api/server.go @@ -0,0 +1,290 @@ +// Copyright (c) 2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package api + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log" + "net/http" + "strings" + "time" + + "github.com/acn-horizon-sdv/horizon-api/internal/argo" + "github.com/acn-horizon-sdv/horizon-api/internal/auth" + "github.com/acn-horizon-sdv/horizon-api/internal/catalog" + "github.com/acn-horizon-sdv/horizon-api/internal/invoke" + "github.com/acn-horizon-sdv/horizon-api/internal/softfeature" + "github.com/acn-horizon-sdv/horizon-api/internal/workflow" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +type Options struct { + Catalog *catalog.Catalog + OIDC *auth.OIDC + WorkflowsNS string + HTTPAddr string + EventsWebhookURL string + // Argo REST client (in-cluster SA token → Argo server with --auth-mode=client). Nil or empty BaseURL disables log streaming. + Argo *argo.Client + LogReadIdle time.Duration + LogMaxReconnect int + WorkflowStore *workflow.Store + // GCSArtifactBucket builds gs:// links in archivedLogs when status uses relative keys (optional). + GCSArtifactBucket string + // GCSSigningServiceAccount is the service account email used to sign V4 GET URLs for artifact download; empty disables signing (501). + GCSSigningServiceAccount string + Retention workflow.Retention + // WorkflowDeleteWaitTimeout bounds how long DELETE /v1/workflows/{name} waits for the CR to disappear (finalizers). Zero means 10 minutes. + WorkflowDeleteWaitTimeout time.Duration + // K8sClient is used for runtime injection (e.g. sampleSoftEnabled). Nil skips injection. + K8sClient client.Client + ModuleManagerNS string + StateCRName string + CatalogCRName string +} + +type Server struct { + opt Options + srv *http.Server +} + +func New(opt Options) *Server { + mux := http.NewServeMux() + s := &Server{opt: opt} + mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + }) + mux.HandleFunc("GET /v1/catalog", s.withAuth(s.handleCatalog)) + mux.HandleFunc("GET /openapi.json", s.handleOpenAPI) + mux.HandleFunc("GET /swagger", s.handleSwagger) + mux.HandleFunc("POST /v1/modules/{module}/workflowTemplates/{template}/submit", s.withAuth(s.handleSubmit)) + mux.HandleFunc("GET /v1/workflows/running", s.withAuth(s.handleWorkflowsRunning)) + mux.HandleFunc("GET /v1/workflows/history", s.withAuth(s.handleWorkflowsHistory)) + mux.HandleFunc("GET /v1/workflows/{workflowName}/log", s.withAuth(s.handleWorkflowLogs)) + mux.HandleFunc("GET /v1/workflows/{workflowName}/downloadArtifact/{artifactName}", s.withAuth(s.handleWorkflowDownloadArtifact)) + mux.HandleFunc("GET /v1/workflows/{workflowName}", s.withAuth(s.handleWorkflowGet)) + mux.HandleFunc("DELETE /v1/workflows/{workflowName}", s.withAuth(s.handleWorkflowDelete)) + mux.HandleFunc("POST /v1/workflows/{workflowName}/abort", s.withAuth(s.handleWorkflowAbort)) + s.srv = &http.Server{Addr: opt.HTTPAddr, Handler: mux} + return s +} + +func (s *Server) Start(ctx context.Context) error { + errCh := make(chan error, 1) + go func() { + log.Printf("http: listening on %s", s.srv.Addr) + if err := s.srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + errCh <- err + return + } + errCh <- nil + }() + select { + case <-ctx.Done(): + shCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + return s.srv.Shutdown(shCtx) + case err := <-errCh: + return err + } +} + +func (s *Server) bearerAuth(r *http.Request) (*auth.Principal, error) { + h := strings.TrimSpace(r.Header.Get("Authorization")) + parts := strings.SplitN(h, " ", 2) + if len(parts) != 2 || !strings.EqualFold(parts[0], "bearer") { + return nil, fmt.Errorf("missing bearer") + } + raw := strings.TrimSpace(parts[1]) + if raw == "" { + return nil, fmt.Errorf("empty token") + } + if strings.Count(raw, ".") != 2 { + return nil, fmt.Errorf("Keycloak access token JWT required") + } + return s.opt.OIDC.Verify(r.Context(), raw) +} + +func (s *Server) withAuth(next func(http.ResponseWriter, *http.Request, *auth.Principal)) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + p, err := s.bearerAuth(r) + if err != nil { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + next(w, r, p) + } +} + +func (s *Server) handleCatalog(w http.ResponseWriter, r *http.Request, _ *auth.Principal) { + if r.Method != http.MethodGet { + http.NotFound(w, r) + return + } + entries := s.opt.Catalog.Snapshot() + _ = json.NewEncoder(w).Encode(map[string]interface{}{"entries": entries}) +} + +func (s *Server) handleOpenAPI(w http.ResponseWriter, r *http.Request) { + meta := catalog.RetentionOpenAPI{ + SecondsAfterSuccess: s.opt.Retention.SecondsAfterSuccess, + SecondsAfterFailure: s.opt.Retention.SecondsAfterFailure, + SecondsAfterCompletion: s.opt.Retention.SecondsAfterCompletion, + Explanation: s.opt.Retention.Explanation, + } + b, err := catalog.OpenAPISpec(s.opt.Catalog.Snapshot(), meta) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + _, _ = w.Write(b) +} + +func (s *Server) handleSwagger(w http.ResponseWriter, r *http.Request) { + const html = `Horizon API + +
+ + +` + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write([]byte(html)) +} + +func (s *Server) handleSubmit(w http.ResponseWriter, r *http.Request, p *auth.Principal) { + if r.Method != http.MethodPost { + http.NotFound(w, r) + return + } + module := r.PathValue("module") + tpl := r.PathValue("template") + if module == "" || tpl == "" { + http.Error(w, "bad path", http.StatusBadRequest) + return + } + ent, ok := s.opt.Catalog.Get(module, tpl) + if !ok { + http.Error(w, "unknown or disabled template", http.StatusNotFound) + return + } + var req struct { + Parameters map[string]string `json:"parameters"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid json", http.StatusBadRequest) + return + } + if err := validateParams(ent, req.Parameters); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if s.opt.EventsWebhookURL == "" { + http.Error(w, "Argo Events webhook URL not configured", http.StatusServiceUnavailable) + return + } + submittedFrom, err := workflow.ParseSubmittedFromHeader(r) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if req.Parameters != nil { + if raw := strings.TrimSpace(req.Parameters[workflow.SubmitParamSubmittedFrom]); raw != "" { + submittedFrom, err = workflow.ParseSubmittedFromValue(raw) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + } + } + body := map[string]interface{}{ + "workflowTemplateName": ent.TemplateName, + "workflowTemplateNamespace": ent.Namespace, + "horizonModule": module, + "horizonSubmittedBy": p.Subject, + "horizonSubmittedFrom": submittedFrom, + } + if req.Parameters != nil { + for k, v := range req.Parameters { + if k == workflow.SubmitParamSubmittedFrom { + // Keep the resolved value from header/default unless an explicit non-empty override was validated above. + continue + } + body[k] = v + } + } + if module == "sample" && ent.TemplateName == "sample-smoke-test" { + val := "false" + if s.opt.K8sClient != nil { + enabled, err := softfeature.SoftModuleEnabledForParent( + r.Context(), + s.opt.K8sClient, + s.opt.ModuleManagerNS, + s.opt.StateCRName, + s.opt.CatalogCRName, + "sample", + "sample-soft", + ) + if err != nil { + log.Printf("sampleSoftEnabled: %v", err) + http.Error(w, "could not resolve soft features: "+err.Error(), http.StatusInternalServerError) + return + } + if enabled { + val = "true" + } + } + // Always set so Argo Events Sensor can map body.sampleSoftEnabled (fifth parameter) on every submit. + body[workflow.SubmitParamSampleSoftEnabled] = val + } + if err := invoke.PostArgoEventsWebhook(r.Context(), s.opt.EventsWebhookURL, body); err != nil { + log.Printf("events webhook: %v", err) + http.Error(w, "could not dispatch to Argo Events: "+err.Error(), http.StatusBadGateway) + return + } + w.WriteHeader(http.StatusAccepted) + _ = json.NewEncoder(w).Encode(map[string]string{"status": "dispatched"}) +} + +func validateParams(ent catalog.Entry, got map[string]string) error { + if got == nil { + got = map[string]string{} + } + allowed := make(map[string]bool) + for _, p := range ent.Parameters { + allowed[p.Name] = true + if p.Default != "" { + continue + } + // Resolved from X-Horizon-Submitted-From (default api) unless body override is non-empty. + if p.Name == workflow.SubmitParamSubmittedFrom { + continue + } + if _, ok := got[p.Name]; !ok { + return fmt.Errorf("missing parameter %q", p.Name) + } + } + for k := range got { + if k == "" || !allowed[k] { + return fmt.Errorf("unknown or empty parameter key %q", k) + } + } + return nil +} diff --git a/terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/internal/api/validate_params_test.go b/terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/internal/api/validate_params_test.go new file mode 100644 index 00000000..aeb89d5e --- /dev/null +++ b/terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/internal/api/validate_params_test.go @@ -0,0 +1,39 @@ +// Copyright (c) 2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +package api + +import ( + "strings" + "testing" + + "github.com/acn-horizon-sdv/horizon-api/internal/catalog" + "github.com/acn-horizon-sdv/horizon-api/internal/workflow" +) + +func TestValidateParamsRejectsInjectedSampleSoftEnabled(t *testing.T) { + t.Parallel() + ent := catalog.Entry{ + Parameters: []catalog.Parameter{ + {Name: "sampleEnv", Default: ""}, + }, + } + err := validateParams(ent, map[string]string{ + "sampleEnv": "x", + workflow.SubmitParamSampleSoftEnabled: "true", + }) + if err == nil || !strings.Contains(err.Error(), "unknown") { + t.Fatalf("expected unknown parameter error, got %v", err) + } +} diff --git a/terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/internal/api/workflows.go b/terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/internal/api/workflows.go new file mode 100644 index 00000000..ca03c064 --- /dev/null +++ b/terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/internal/api/workflows.go @@ -0,0 +1,580 @@ +// Copyright (c) 2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +package api + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "path" + "strconv" + "strings" + "time" + + "github.com/acn-horizon-sdv/horizon-api/internal/auth" + "github.com/acn-horizon-sdv/horizon-api/internal/gcs" + "github.com/acn-horizon-sdv/horizon-api/internal/workflow" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +// workflowListPageSize is each Kubernetes List chunk; workflowListMaxScan caps total workflows +// read across chunks so one page of mostly TTL-retained terminal runs cannot hide active workflows. +const ( + workflowListPageSize int64 = 200 + workflowListMaxScan int64 = 10000 +) + +func workflowRootTemplateName(u *unstructured.Unstructured) string { + rootTpl, _, _ := unstructured.NestedString(u.Object, "spec", "workflowTemplateRef", "name") + if rootTpl == "" { + rootTpl, _, _ = unstructured.NestedString(u.Object, "spec", "workflowRef", "name") + } + if rootTpl == "" { + rootTpl, _, _ = unstructured.NestedString(u.Object, "spec", "clusterWorkflowTemplateRef", "name") + } + return strings.TrimSpace(rootTpl) +} + +func enrichWorkflowDetailModules(ctx context.Context, store *workflow.Store, d *workflow.WorkflowDetail) { + if d == nil || store == nil { + return + } + seen := make(map[string]bool) + var names []string + add := func(name string) { + n := strings.TrimSpace(name) + if n == "" || seen[n] { + return + } + seen[n] = true + names = append(names, n) + } + add(d.WorkflowTemplate) + for i := range d.Nodes { + add(d.Nodes[i].WorkflowTemplate) + } + for i := range d.OutputArtifacts { + add(d.OutputArtifacts[i].WorkflowTemplate) + } + for i := range d.DependentWorkflowTemplates { + add(d.DependentWorkflowTemplates[i].Template) + } + if len(names) == 0 { + return + } + moduleByTemplate := store.ResolveWorkflowTemplateModules(ctx, names) + rootModule := strings.TrimSpace(d.Module) + if rootModule == "" && strings.TrimSpace(d.WorkflowTemplate) != "" { + rootModule = strings.TrimSpace(moduleByTemplate[strings.TrimSpace(d.WorkflowTemplate)]) + if rootModule != "" { + d.Module = rootModule + } + } + resolve := func(template, fallback string) string { + t := strings.TrimSpace(template) + if t != "" { + if m := strings.TrimSpace(moduleByTemplate[t]); m != "" { + return m + } + } + return strings.TrimSpace(fallback) + } + for i := range d.Nodes { + d.Nodes[i].Module = resolve(d.Nodes[i].WorkflowTemplate, d.Nodes[i].Module) + if d.Nodes[i].Module == "" { + d.Nodes[i].Module = rootModule + } + } + for i := range d.OutputArtifacts { + d.OutputArtifacts[i].Module = resolve(d.OutputArtifacts[i].WorkflowTemplate, d.OutputArtifacts[i].Module) + if d.OutputArtifacts[i].Module == "" { + d.OutputArtifacts[i].Module = rootModule + } + } + for i := range d.DependentWorkflowTemplates { + d.DependentWorkflowTemplates[i].Module = resolve(d.DependentWorkflowTemplates[i].Template, d.DependentWorkflowTemplates[i].Module) + } +} + +func (s *Server) retentionPayload() map[string]interface{} { + r := s.opt.Retention + return map[string]interface{}{ + "secondsAfterSuccess": r.SecondsAfterSuccess, + "secondsAfterFailure": r.SecondsAfterFailure, + "secondsAfterCompletion": r.SecondsAfterCompletion, + "explanation": r.Explanation, + } +} + +func (s *Server) handleWorkflowsRunning(w http.ResponseWriter, r *http.Request, _ *auth.Principal) { + if r.Method != http.MethodGet { + http.NotFound(w, r) + return + } + if s.opt.WorkflowStore == nil { + http.Error(w, "workflow store not configured", http.StatusServiceUnavailable) + return + } + limit := parseListLimit(r.URL.Query().Get("limit"), 50, 500) + items, err := s.listRunningSummaries(r.Context(), limit) + if err != nil { + http.Error(w, "list workflows failed", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "items": items, + "continue": "", + "retention": s.retentionPayload(), + }) +} + +func (s *Server) handleWorkflowsHistory(w http.ResponseWriter, r *http.Request, _ *auth.Principal) { + if r.Method != http.MethodGet { + http.NotFound(w, r) + return + } + if s.opt.WorkflowStore == nil { + http.Error(w, "workflow store not configured", http.StatusServiceUnavailable) + return + } + q := r.URL.Query() + limit := parseListLimit(q.Get("limit"), 50, 500) + hf, err := parseHistoryFilters(q) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if hf != nil && hf.anyApplied && strings.TrimSpace(q.Get("continue")) != "" { + http.Error(w, "continue token is not supported together with history filters (phase, time, nameGlob, nameRegex)", http.StatusBadRequest) + return + } + + var items []workflow.WorkflowSummary + var continueToken string + var truncated bool + var scanned int64 + + if hf != nil && hf.anyApplied { + items, continueToken, truncated, scanned, err = s.listHistoryFiltered(r.Context(), limit, hf) + if err != nil { + http.Error(w, "list workflows failed", http.StatusInternalServerError) + return + } + } else { + var lerr error + items, lerr = s.listTerminalSummariesPaged(r.Context(), limit) + if lerr != nil { + http.Error(w, "list workflows failed", http.StatusInternalServerError) + return + } + continueToken = "" + } + + resp := map[string]interface{}{ + "items": items, + "continue": continueToken, + "retention": s.retentionPayload(), + } + if hf != nil && hf.anyApplied { + resp["truncated"] = truncated + resp["scanned"] = scanned + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) +} + +// listRunningSummaries walks workflow List pages until wantLimit non-terminal workflows are collected +// or workflowListMaxScan items have been inspected (avoids empty /running when the first chunk is all terminal). +func (s *Server) listRunningSummaries(ctx context.Context, wantLimit int64) ([]workflow.WorkflowSummary, error) { + var out []workflow.WorkflowSummary + var scanned int64 + continueTok := "" + for int64(len(out)) < wantLimit && scanned < workflowListMaxScan { + list, err := s.opt.WorkflowStore.List(ctx, workflowListPageSize, continueTok) + if err != nil { + return nil, err + } + if len(list.Items) == 0 { + break + } + for i := range list.Items { + scanned++ + u := &list.Items[i] + if workflow.TerminalPhase(workflow.Phase(u)) { + continue + } + out = append(out, workflow.Summary(u, s.opt.WorkflowsNS, s.opt.GCSArtifactBucket)) + if int64(len(out)) >= wantLimit { + break + } + } + continueTok = list.GetContinue() + if continueTok == "" { + break + } + } + return out, nil +} + +// listTerminalSummariesPaged walks List pages until wantLimit terminal workflows are collected or scan cap hit. +func (s *Server) listTerminalSummariesPaged(ctx context.Context, wantLimit int64) ([]workflow.WorkflowSummary, error) { + var out []workflow.WorkflowSummary + var scanned int64 + continueTok := "" + for int64(len(out)) < wantLimit && scanned < workflowListMaxScan { + list, err := s.opt.WorkflowStore.List(ctx, workflowListPageSize, continueTok) + if err != nil { + return nil, err + } + if len(list.Items) == 0 { + break + } + for i := range list.Items { + scanned++ + u := &list.Items[i] + if !workflow.TerminalPhase(workflow.Phase(u)) { + continue + } + out = append(out, workflow.Summary(u, s.opt.WorkflowsNS, s.opt.GCSArtifactBucket)) + if int64(len(out)) >= wantLimit { + break + } + } + continueTok = list.GetContinue() + if continueTok == "" { + break + } + } + return out, nil +} + +// listHistoryFiltered scans up to historyMaxScan cluster workflows (chunked list) and returns up to wantLimit +// terminal rows matching hf. continue is never returned (pagination across filtered scans is not supported). +func (s *Server) listHistoryFiltered(ctx context.Context, wantLimit int64, hf *historyFilter) ([]workflow.WorkflowSummary, string, bool, int64, error) { + var items []workflow.WorkflowSummary + var scanned int64 + truncated := false + continueTok := "" + + for int64(len(items)) < wantLimit && scanned < historyMaxScan { + list, err := s.opt.WorkflowStore.List(ctx, historyListChunk, continueTok) + if err != nil { + return nil, "", false, scanned, err + } + for i := range list.Items { + if scanned >= historyMaxScan { + truncated = true + break + } + u := list.Items[i] + scanned++ + if !workflow.TerminalPhase(workflow.Phase(&u)) { + continue + } + if !hf.matchesTerminal(&u) { + continue + } + items = append(items, workflow.Summary(&u, s.opt.WorkflowsNS, s.opt.GCSArtifactBucket)) + if int64(len(items)) >= wantLimit { + break + } + } + continueTok = list.GetContinue() + if int64(len(items)) >= wantLimit { + if continueTok != "" { + truncated = true + } + break + } + if truncated || continueTok == "" { + break + } + } + return items, "", truncated, scanned, nil +} + +func (s *Server) handleWorkflowGet(w http.ResponseWriter, r *http.Request, _ *auth.Principal) { + if r.Method != http.MethodGet { + http.NotFound(w, r) + return + } + if s.opt.WorkflowStore == nil { + http.Error(w, "workflow store not configured", http.StatusServiceUnavailable) + return + } + name := r.PathValue("workflowName") + if name == "" { + http.NotFound(w, r) + return + } + u, err := s.opt.WorkflowStore.Get(r.Context(), name) + if err != nil { + http.Error(w, "workflow not found", http.StatusNotFound) + return + } + if !workflow.IsHorizonClientVisible(u) { + http.Error(w, "workflow not found", http.StatusNotFound) + return + } + podNames, _ := s.opt.WorkflowStore.ListPodNamesForWorkflow(r.Context(), name) + d := workflow.Detail(u, s.opt.WorkflowsNS, s.opt.GCSArtifactBucket, podNames) + enrichWorkflowDetailModules(r.Context(), s.opt.WorkflowStore, &d) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(d) +} + +func (s *Server) handleWorkflowDownloadArtifact(w http.ResponseWriter, r *http.Request, _ *auth.Principal) { + if r.Method != http.MethodGet { + http.NotFound(w, r) + return + } + if s.opt.WorkflowStore == nil { + http.Error(w, "workflow store not configured", http.StatusServiceUnavailable) + return + } + if strings.TrimSpace(s.opt.GCSSigningServiceAccount) == "" { + http.Error(w, "GCS artifact signing not configured", http.StatusNotImplemented) + return + } + wfName := r.PathValue("workflowName") + rawArt := r.PathValue("artifactName") + if wfName == "" || rawArt == "" { + http.NotFound(w, r) + return + } + artifactName, err := url.PathUnescape(rawArt) + if err != nil { + http.Error(w, "invalid artifactName in path", http.StatusBadRequest) + return + } + nodeID := strings.TrimSpace(r.URL.Query().Get("nodeId")) + templateName := strings.TrimSpace(r.URL.Query().Get("templateName")) + inline := false + switch strings.ToLower(strings.TrimSpace(r.URL.Query().Get("inline"))) { + case "1", "true", "yes": + inline = true + } + ds := strings.TrimSpace(r.URL.Query().Get("durationSeconds")) + var durSec int + if ds != "" { + durSec, err = strconv.Atoi(ds) + if err != nil || durSec < 0 { + http.Error(w, "durationSeconds must be a non-negative integer", http.StatusBadRequest) + return + } + } + durSec = gcs.ClampDurationSeconds(durSec) + + u, err := s.opt.WorkflowStore.Get(r.Context(), wfName) + if err != nil { + http.Error(w, "workflow not found", http.StatusNotFound) + return + } + if !workflow.IsHorizonClientVisible(u) { + http.Error(w, "workflow not found", http.StatusNotFound) + return + } + rootTpl := workflowRootTemplateName(u) + rootModule := workflow.ModuleLabelValue(u) + arts := workflow.BuildOutputArtifacts(u, s.opt.GCSArtifactBucket, rootTpl, rootModule) + matches, err := workflow.MatchOutputArtifacts(arts, artifactName, nodeID, templateName) + var amb *workflow.AmbiguousArtifactsError + if errors.As(err, &amb) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusConflict) + type cand struct { + NodeID string `json:"nodeId,omitempty"` + TemplateName string `json:"templateName,omitempty"` + Name string `json:"name"` + FileName string `json:"fileName,omitempty"` + GcsURI string `json:"gcsUri,omitempty"` + Display string `json:"displayName,omitempty"` + } + list := make([]cand, 0, len(amb.Candidates)) + for _, m := range amb.Candidates { + list = append(list, cand{NodeID: m.NodeID, TemplateName: m.TemplateName, Name: m.Name, FileName: m.FileName, GcsURI: m.GcsURI, Display: m.DisplayName}) + } + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "error": "ambiguous artifact name; pass nodeId or templateName query parameter", + "candidates": list, + "artifactName": artifactName, + }) + return + } + if err != nil || len(matches) == 0 { + http.Error(w, "artifact not found for workflow", http.StatusNotFound) + return + } + one := matches[0] + bucket, object, err := gcs.ParseGCSURI(one.GcsURI) + if err != nil { + http.Error(w, "artifact has no valid gs:// URI", http.StatusNotFound) + return + } + exp := time.Now().UTC().Add(time.Duration(durSec) * time.Second) + downloadName := path.Base(object) + if downloadName == "" || downloadName == "." { + downloadName = artifactName + } + signed, err := gcs.SignedGETURL(r.Context(), s.opt.GCSSigningServiceAccount, bucket, object, exp, downloadName) + if err != nil { + http.Error(w, fmt.Sprintf("sign url: %v", err), http.StatusInternalServerError) + return + } + if inline { + req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, signed, nil) + if err != nil { + http.Error(w, fmt.Sprintf("build gcs request: %v", err), http.StatusInternalServerError) + return + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + http.Error(w, fmt.Sprintf("fetch gcs artifact: %v", err), http.StatusBadGateway) + return + } + defer resp.Body.Close() + if resp.StatusCode < http.StatusOK || resp.StatusCode > 299 { + slurp, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + http.Error(w, fmt.Sprintf("gcs GET %d: %s", resp.StatusCode, strings.TrimSpace(string(slurp))), http.StatusBadGateway) + return + } + ct := strings.TrimSpace(resp.Header.Get("Content-Type")) + if ct == "" { + ct = "text/plain; charset=utf-8" + } + w.Header().Set("Content-Type", ct) + w.WriteHeader(http.StatusOK) + _, _ = io.Copy(w, resp.Body) + return + } + w.Header().Set("Content-Type", "application/json") + jsonResp := map[string]string{ + "url": signed, + "expiresAt": exp.Format(time.RFC3339), + "fileName": downloadName, + } + _ = json.NewEncoder(w).Encode(jsonResp) +} + +func (s *Server) handleWorkflowDelete(w http.ResponseWriter, r *http.Request, _ *auth.Principal) { + if r.Method != http.MethodDelete { + http.NotFound(w, r) + return + } + if s.opt.WorkflowStore == nil { + http.Error(w, "workflow store not configured", http.StatusServiceUnavailable) + return + } + name := r.PathValue("workflowName") + if name == "" { + http.NotFound(w, r) + return + } + u, err := s.opt.WorkflowStore.Get(r.Context(), name) + if err != nil { + http.Error(w, "workflow not found", http.StatusNotFound) + return + } + if !workflow.IsHorizonClientVisible(u) { + http.Error(w, "workflow not found", http.StatusNotFound) + return + } + if !workflow.TerminalPhase(workflow.Phase(u)) { + http.Error(w, "workflow must be in a terminal phase (Succeeded, Failed, Error, or Aborted) before it can be deleted", http.StatusConflict) + return + } + waitDur := s.opt.WorkflowDeleteWaitTimeout + if waitDur <= 0 { + waitDur = 10 * time.Minute + } + poll := 750 * time.Millisecond + + waitCtx, cancel := context.WithTimeout(r.Context(), waitDur) + defer cancel() + + if err := s.opt.WorkflowStore.Delete(waitCtx, name); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if err := s.opt.WorkflowStore.WaitUntilWorkflowDeleted(waitCtx, name, poll); err != nil { + if errors.Is(err, context.DeadlineExceeded) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusGatewayTimeout) + _ = json.NewEncoder(w).Encode(map[string]string{ + "error": "timeout waiting for workflow to be fully removed (finalizers or artifact cleanup may still be running)", + }) + return + } + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{"status": "deleted"}) +} + +func (s *Server) handleWorkflowAbort(w http.ResponseWriter, r *http.Request, _ *auth.Principal) { + if r.Method != http.MethodPost { + http.NotFound(w, r) + return + } + if s.opt.WorkflowStore == nil { + http.Error(w, "workflow store not configured", http.StatusServiceUnavailable) + return + } + name := r.PathValue("workflowName") + if name == "" { + http.NotFound(w, r) + return + } + u, err := s.opt.WorkflowStore.Get(r.Context(), name) + if err != nil { + http.Error(w, "workflow not found", http.StatusNotFound) + return + } + if !workflow.IsHorizonClientVisible(u) { + http.Error(w, "workflow not found", http.StatusNotFound) + return + } + if workflow.TerminalPhase(workflow.Phase(u)) { + http.Error(w, "workflow is not running", http.StatusConflict) + return + } + if err := s.opt.WorkflowStore.PatchShutdown(r.Context(), name); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) + _ = json.NewEncoder(w).Encode(map[string]string{"status": "aborting"}) +} + +func parseListLimit(s string, def, max int64) int64 { + if strings.TrimSpace(s) == "" { + return def + } + n, err := strconv.ParseInt(s, 10, 64) + if err != nil || n < 1 { + return def + } + if n > max { + return max + } + return n +} diff --git a/terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/internal/argo/client.go b/terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/internal/argo/client.go new file mode 100644 index 00000000..a843a8b5 --- /dev/null +++ b/terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/internal/argo/client.go @@ -0,0 +1,148 @@ +// Copyright (c) 2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +package argo + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path" + "strconv" + "strings" + "time" +) + +const defaultTokenPath = "/var/run/secrets/kubernetes.io/serviceaccount/token" + +// Client calls Argo Server REST with the pod's Kubernetes service account bearer token (--auth-mode=client on Argo server). +type Client struct { + BaseURL string + HTTPClient *http.Client + TokenPath string +} + +func (c *Client) token() (string, error) { + p := c.TokenPath + if p == "" { + p = defaultTokenPath + } + b, err := os.ReadFile(p) + if err != nil { + return "", err + } + return strings.TrimSpace(string(b)), nil +} + +func (c *Client) joinURL(relPath string, q url.Values) (string, error) { + base, err := url.Parse(c.BaseURL) + if err != nil { + return "", err + } + bp := strings.TrimSuffix(base.Path, "/") + rp := strings.TrimPrefix(relPath, "/") + base.Path = path.Join(bp, rp) + base.RawQuery = q.Encode() + return base.String(), nil +} + +func (c *Client) do(ctx context.Context, fullURL string) (*http.Response, error) { + tok, err := c.token() + if err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+tok) + hc := c.HTTPClient + if hc == nil { + hc = http.DefaultClient + } + return hc.Do(req) +} + +// WorkflowPhase returns .status.phase and metadata.uid from GET /api/v1/workflows/{ns}/{name}. +func (c *Client) WorkflowPhase(ctx context.Context, namespace, name string) (phase string, uid string, err error) { + rel := path.Join("api/v1/workflows", url.PathEscape(namespace), url.PathEscape(name)) + u, err := c.joinURL(rel, nil) + if err != nil { + return "", "", err + } + resp, err := c.do(ctx, u) + if err != nil { + return "", "", err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + return "", "", fmt.Errorf("argo get workflow: %s: %s", resp.Status, strings.TrimSpace(string(b))) + } + var wf struct { + Metadata struct { + UID string `json:"uid"` + } `json:"metadata"` + Status struct { + Phase string `json:"phase"` + } `json:"status"` + } + if err := json.NewDecoder(resp.Body).Decode(&wf); err != nil { + return "", "", err + } + return wf.Status.Phase, wf.Metadata.UID, nil +} + +// OpenLogStream opens the Argo NDJSON log stream (caller must Close body). +func (c *Client) OpenLogStream(ctx context.Context, namespace, workflowName, podName, container string, follow bool) (io.ReadCloser, error) { + rel := path.Join("api/v1/workflows", url.PathEscape(namespace), url.PathEscape(workflowName), "log") + q := url.Values{} + if strings.TrimSpace(podName) != "" { + q.Set("podName", podName) + } + q.Set("logOptions.container", container) + q.Set("logOptions.follow", strconv.FormatBool(follow)) + u, err := c.joinURL(rel, q) + if err != nil { + return nil, err + } + resp, err := c.do(ctx, u) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + resp.Body.Close() + return nil, fmt.Errorf("argo log: %s: %s", resp.Status, strings.TrimSpace(string(b))) + } + return resp.Body, nil +} + +// NewHTTPClient returns an HTTP client with no global timeout (streaming). +func NewHTTPClient() *http.Client { + return &http.Client{ + Timeout: 0, + Transport: &http.Transport{ + Proxy: http.ProxyFromEnvironment, + MaxIdleConns: 32, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + ExpectContinueTimeout: 1 * time.Second, + }, + } +} diff --git a/terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/internal/auth/oidc.go b/terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/internal/auth/oidc.go new file mode 100644 index 00000000..4601f6e9 --- /dev/null +++ b/terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/internal/auth/oidc.go @@ -0,0 +1,60 @@ +// Copyright (c) 2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package auth + +import ( + "context" + "fmt" + "strings" + + "github.com/coreos/go-oidc/v3/oidc" +) + +// OIDC verifies Keycloak-issued JWT access tokens (e.g. client_credentials and interactive users). +type OIDC struct { + verifier *oidc.IDTokenVerifier +} + +func NewOIDC(ctx context.Context, issuerURL, clientID string, skipClientIDCheck bool) (*OIDC, error) { + provider, err := oidc.NewProvider(ctx, strings.TrimSuffix(issuerURL, "/")) + if err != nil { + return nil, fmt.Errorf("oidc provider: %w", err) + } + cfg := &oidc.Config{SkipClientIDCheck: skipClientIDCheck} + if clientID != "" && !skipClientIDCheck { + cfg.ClientID = clientID + } + return &OIDC{verifier: provider.Verifier(cfg)}, nil +} + +// Principal is set after successful Bearer authentication (Keycloak access token). +type Principal struct { + Subject string + Kind string // "oidc" +} + +func (o *OIDC) Verify(ctx context.Context, raw string) (*Principal, error) { + tok, err := o.verifier.Verify(ctx, raw) + if err != nil { + return nil, err + } + var claims struct { + Sub string `json:"sub"` + } + if err := tok.Claims(&claims); err != nil { + return nil, err + } + return &Principal{Subject: claims.Sub, Kind: "oidc"}, nil +} diff --git a/terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/internal/catalog/catalog.go b/terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/internal/catalog/catalog.go new file mode 100644 index 00000000..019cf606 --- /dev/null +++ b/terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/internal/catalog/catalog.go @@ -0,0 +1,931 @@ +// Copyright (c) 2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package catalog + +import ( + "context" + "encoding/json" + "fmt" + "log" + "net/url" + "regexp" + "strconv" + "strings" + "sync" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/acn-horizon-sdv/horizon-api/internal/workflow" +) + +const ( + labelExpose = "horizon-sdv.io/expose" + labelModule = "horizon-sdv.io/module" + labelExposeVal = "true" +) + +type Entry struct { + Module string `json:"module"` + TemplateName string `json:"templateName"` + Namespace string `json:"namespace"` + Parameters []Parameter `json:"parameters"` +} + +type Parameter struct { + Name string `json:"name"` + Default string `json:"default,omitempty"` + Description string `json:"description,omitempty"` +} + +// RetentionOpenAPI documents workflow TTL values echoed by list endpoints (mirrors Argo workflowDefaults.ttlStrategy). +type RetentionOpenAPI struct { + SecondsAfterSuccess int + SecondsAfterFailure int + SecondsAfterCompletion int + Explanation string +} + +// Catalog holds exposed WorkflowTemplates and ClusterWorkflowTemplates for enabled modules (rebuilt by the catalog controller). +type Catalog struct { + mu sync.RWMutex + // key: module/templateName -> entry (templates unique per module name in MVP) + entries map[string]Entry + + moduleManagerNS string + workflowsNS string + stateName string + catalogName string + cli client.Client +} + +func New(moduleManagerNS, workflowsNS, stateName, catalogName string) *Catalog { + return &Catalog{ + entries: make(map[string]Entry), + moduleManagerNS: moduleManagerNS, + workflowsNS: workflowsNS, + stateName: stateName, + catalogName: catalogName, + } +} + +func (c *Catalog) InjectClient(cl client.Client) { + c.cli = cl +} + +// Rebuild refreshes the in-memory catalog from the API server. +func (c *Catalog) Rebuild(ctx context.Context) error { + if c.cli == nil { + return fmt.Errorf("catalog: client not injected") + } + return c.rebuild(ctx) +} + +func (c *Catalog) Snapshot() []Entry { + c.mu.RLock() + defer c.mu.RUnlock() + out := make([]Entry, 0, len(c.entries)) + for _, e := range c.entries { + out = append(out, e) + } + return out +} + +func (c *Catalog) Get(module, templateName string) (Entry, bool) { + c.mu.RLock() + defer c.mu.RUnlock() + e, ok := c.entries[key(module, templateName)] + return e, ok +} + +func key(module, template string) string { + return module + "/" + template +} + +func (c *Catalog) rebuild(ctx context.Context) error { + enabledModules, err := c.loadEnabledModules(ctx) + if err != nil { + return err + } + next := make(map[string]Entry) + + uList := &unstructured.UnstructuredList{} + uList.SetGroupVersionKind(schema.GroupVersionKind{Group: "argoproj.io", Version: "v1alpha1", Kind: "WorkflowTemplateList"}) + if err := c.cli.List(ctx, uList, client.InNamespace(c.workflowsNS)); err != nil { + return fmt.Errorf("list workflowtemplates: %w", err) + } + for i := range uList.Items { + c.ingestExposedTemplate(next, enabledModules, &uList.Items[i], uList.Items[i].GetNamespace()) + } + + cwList := &unstructured.UnstructuredList{} + cwList.SetGroupVersionKind(schema.GroupVersionKind{Group: "argoproj.io", Version: "v1alpha1", Kind: "ClusterWorkflowTemplateList"}) + if err := c.cli.List(ctx, cwList); err != nil { + return fmt.Errorf("list clusterworkflowtemplates: %w", err) + } + for i := range cwList.Items { + // Cluster-scoped: Entry.Namespace is "" (OpenAPI clients use empty string for cluster WorkflowTemplate refs). + c.ingestExposedTemplate(next, enabledModules, &cwList.Items[i], "") + } + + c.mu.Lock() + c.entries = next + c.mu.Unlock() + return nil +} + +// ingestExposedTemplate adds one catalog entry when labels request exposure and the module is enabled. +// entryNamespace is the WorkflowTemplate namespace; use "" for ClusterWorkflowTemplate (cluster scope). +func (c *Catalog) ingestExposedTemplate(next map[string]Entry, enabledModules map[string]bool, item *unstructured.Unstructured, entryNamespace string) { + labels := item.GetLabels() + if labels[labelExpose] != labelExposeVal { + return + } + mod := labels[labelModule] + if mod == "" || !enabledModules[mod] { + return + } + name := item.GetName() + params, err := extractParameters(item.Object) + if err != nil { + loc := entryNamespace + if loc == "" { + loc = "cluster" + } + log.Printf("catalog: skip %s/%s: %v", loc, name, err) + return + } + e := Entry{ + Module: mod, + TemplateName: name, + Namespace: entryNamespace, + Parameters: params, + } + next[key(mod, name)] = e +} + +// loadEnabledModules returns the set of enabled module names, reading ModuleManagerState +// (runtime enabled IDs + module name-to-ID map) and ModuleCatalog (authoritative list of +// known module names). A module is considered enabled when its ModuleCatalog entry has a +// corresponding ID in ModuleManagerState.status.moduleIds and that ID is listed in +// ModuleManagerState.status.enabledModules. +func (c *Catalog) loadEnabledModules(ctx context.Context) (map[string]bool, error) { + state := &unstructured.Unstructured{} + state.SetGroupVersionKind(schema.GroupVersionKind{ + Group: "horizon-sdv.io", Version: "v1alpha1", Kind: "ModuleManagerState", + }) + if err := c.cli.Get(ctx, client.ObjectKey{Namespace: c.moduleManagerNS, Name: c.stateName}, state); err != nil { + if apierrors.IsNotFound(err) { + return make(map[string]bool), nil + } + return nil, fmt.Errorf("get ModuleManagerState: %w", err) + } + enabledIDs := map[string]bool{} + moduleIDByName := map[string]string{} + if ids, found, _ := unstructured.NestedStringSlice(state.Object, "status", "enabledModules"); found { + for _, id := range ids { + enabledIDs[id] = true + } + } + if m, found, _ := unstructured.NestedStringMap(state.Object, "status", "moduleIds"); found { + for name, id := range m { + moduleIDByName[name] = id + } + } + + catalogCR := &unstructured.Unstructured{} + catalogCR.SetGroupVersionKind(schema.GroupVersionKind{ + Group: "horizon-sdv.io", Version: "v1alpha1", Kind: "ModuleCatalog", + }) + if err := c.cli.Get(ctx, client.ObjectKey{Namespace: c.moduleManagerNS, Name: c.catalogName}, catalogCR); err != nil { + if apierrors.IsNotFound(err) { + return make(map[string]bool), nil + } + return nil, fmt.Errorf("get ModuleCatalog: %w", err) + } + modules, found, err := unstructured.NestedSlice(catalogCR.Object, "spec", "modules") + if err != nil || !found { + return make(map[string]bool), nil + } + out := make(map[string]bool, len(modules)) + for _, mod := range modules { + m, ok := mod.(map[string]interface{}) + if !ok { + continue + } + name, _, _ := unstructured.NestedString(m, "name") + if name == "" { + continue + } + id := moduleIDByName[name] + if id != "" && enabledIDs[id] { + out[name] = true + } + } + return out, nil +} + +func extractParameters(obj map[string]interface{}) ([]Parameter, error) { + params, found, err := unstructured.NestedSlice(obj, "spec", "arguments", "parameters") + if err != nil || !found { + return nil, nil + } + var out []Parameter + for _, p := range params { + m, ok := p.(map[string]interface{}) + if !ok { + continue + } + name, _, _ := unstructured.NestedString(m, "name") + if name == "" { + continue + } + if workflow.IsHorizonInjectedWorkflowParameter(name) { + continue + } + def, _, _ := unstructured.NestedString(m, "value") + if def == "" { + def, _, _ = unstructured.NestedString(m, "default") + } + desc, _, _ := unstructured.NestedString(m, "description") + out = append(out, Parameter{Name: name, Default: def, Description: desc}) + } + return out, nil +} + +var opIDSanitizer = regexp.MustCompile(`[^a-zA-Z0-9]+`) + +func sanitizeOperationIDPart(s string) string { + s = opIDSanitizer.ReplaceAllString(s, "_") + return strings.Trim(s, "_") +} + +func submitPathForEntry(module, templateName string) string { + return "/v1/modules/" + url.PathEscape(module) + "/workflowTemplates/" + url.PathEscape(templateName) + "/submit" +} + +func submitRequestBodySchema(e Entry) map[string]interface{} { + paramProps := make(map[string]interface{}) + var paramRequired []interface{} + for _, p := range e.Parameters { + prop := map[string]interface{}{"type": "string"} + if p.Description != "" { + prop["description"] = p.Description + } + if p.Default != "" { + prop["default"] = p.Default + } + if p.Name == workflow.SubmitParamSubmittedFrom { + prop["maxLength"] = 63 + suffix := " Optional: omit to use X-Horizon-Submitted-From (default api). Built-in values: api, developer-portal, horizon-cli (aliases: rest-api, portal, cli). Any other value must follow Kubernetes label value rules (e.g. my-ci, string). For OpenAPI-only clients, you may set header X-Horizon-Submitted-From to \"custom\" and send the id in X-Horizon-Submitted-From-Detail instead." + if d, _ := prop["description"].(string); d != "" { + prop["description"] = strings.TrimSpace(d) + " " + suffix + } else { + prop["description"] = strings.TrimSpace(suffix) + } + } + paramProps[p.Name] = prop + if p.Default == "" && p.Name != workflow.SubmitParamSubmittedFrom { + paramRequired = append(paramRequired, p.Name) + } + } + inner := map[string]interface{}{ + "type": "object", + "properties": paramProps, + "additionalProperties": false, + } + if len(paramRequired) > 0 { + inner["required"] = paramRequired + } + root := map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "parameters": inner, + }, + } + if len(paramRequired) > 0 { + root["required"] = []interface{}{"parameters"} + } + return root +} + +func submitOperationParameters() []interface{} { + return []interface{}{ + map[string]interface{}{ + "name": "X-Horizon-Submitted-From", + "in": "header", + "required": false, + "description": "Where the submit call originated. Built-in: `api` (default), `developer-portal`, `horizon-cli`. `custom`: set `" + workflow.HeaderSubmittedFromDetail + "` to the integration id. Any other single header value is accepted if it is a valid Kubernetes label value (custom integration id). Stored as label horizon-sdv.io/submitted-from on the Workflow.", + "schema": map[string]interface{}{ + "type": "string", + "maxLength": 63, + "description": "Built-in tokens or a custom integration id; use `custom` with " + workflow.HeaderSubmittedFromDetail + " for arbitrary ids in constrained clients.", + }, + }, + map[string]interface{}{ + "name": workflow.HeaderSubmittedFromDetail, + "in": "header", + "required": false, + "description": "When `X-Horizon-Submitted-From` is `custom`, required: integration id (Kubernetes label value rules). Ignored otherwise.", + "schema": map[string]interface{}{ + "type": "string", + "maxLength": 63, + }, + }, + } +} + +func submitResponsesDoc() map[string]interface{} { + return map[string]interface{}{ + "202": map[string]interface{}{"description": "Dispatched to Argo Events"}, + "400": map[string]interface{}{"description": "Bad request"}, + "401": map[string]interface{}{"description": "Unauthorized"}, + "404": map[string]interface{}{"description": "Unknown module/template"}, + "502": map[string]interface{}{"description": "Argo Events webhook error"}, + "503": map[string]interface{}{"description": "Webhook URL not configured"}, + } +} + +func openapiComponentSchemas() map[string]interface{} { + return map[string]interface{}{ + "CatalogParameter": map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "name": map[string]interface{}{"type": "string"}, + "default": map[string]interface{}{"type": "string"}, + "description": map[string]interface{}{"type": "string"}, + }, + "required": []interface{}{"name"}, + }, + "CatalogEntry": map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "module": map[string]interface{}{"type": "string"}, + "templateName": map[string]interface{}{"type": "string"}, + "namespace": map[string]interface{}{"type": "string"}, + "parameters": map[string]interface{}{ + "type": "array", + "items": map[string]interface{}{ + "$ref": "#/components/schemas/CatalogParameter", + }, + }, + }, + "required": []interface{}{"module", "templateName", "namespace", "parameters"}, + }, + "CatalogResponse": map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "entries": map[string]interface{}{ + "type": "array", + "items": map[string]interface{}{ + "$ref": "#/components/schemas/CatalogEntry", + }, + }, + }, + "required": []interface{}{"entries"}, + }, + } +} + +func workflowOpenAPIComponentSchemas() map[string]interface{} { + return map[string]interface{}{ + "WorkflowRetention": map[string]interface{}{ + "type": "object", + "description": "Configured Argo workflow TTL (cluster); workflows disappear after pruning — expect 404 for pruned names.", + "properties": map[string]interface{}{ + "secondsAfterSuccess": map[string]interface{}{"type": "integer", "format": "int32"}, + "secondsAfterFailure": map[string]interface{}{"type": "integer", "format": "int32"}, + "secondsAfterCompletion": map[string]interface{}{"type": "integer", "format": "int32"}, + "explanation": map[string]interface{}{"type": "string"}, + }, + "required": []interface{}{"secondsAfterSuccess", "secondsAfterFailure", "secondsAfterCompletion", "explanation"}, + }, + "LogURIRef": map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "gcsUri": map[string]interface{}{"type": "string", "description": "gs:// bucket/object for archived logs when present"}, + }, + }, + "StepLogLink": map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "nodeId": map[string]interface{}{"type": "string"}, + "displayName": map[string]interface{}{"type": "string"}, + "templateName": map[string]interface{}{"type": "string"}, + "phase": map[string]interface{}{"type": "string"}, + "podName": map[string]interface{}{"type": "string"}, + "gcsUri": map[string]interface{}{"type": "string"}, + "artifactName": map[string]interface{}{"type": "string", "description": "Workflow status artifact name for downloadArtifact (e.g. main-logs)."}, + }, + "required": []interface{}{"nodeId"}, + }, + "ArchivedLogLinks": map[string]interface{}{ + "type": "object", + "description": "Historical archived log locations from Workflow status (artifact outputs); null or omitted while running or when no log artifacts.", + "properties": map[string]interface{}{ + "combined": map[string]interface{}{"$ref": "#/components/schemas/LogURIRef"}, + "steps": map[string]interface{}{ + "type": "array", + "items": map[string]interface{}{ + "$ref": "#/components/schemas/StepLogLink", + }, + }, + }, + }, + "WorkflowSummary": map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "name": map[string]interface{}{"type": "string"}, + "namespace": map[string]interface{}{"type": "string"}, + "phase": map[string]interface{}{ + "type": "string", + "description": "API phase: Running, Succeeded, Failed, Error, or Aborted (graceful stop via spec.shutdown / Stop or Terminate — Argo may still store Failed for the same run).", + }, + "startedAt": map[string]interface{}{"type": "string"}, + "finishedAt": map[string]interface{}{"type": "string"}, + "workflowTemplate": map[string]interface{}{"type": "string"}, + "startedBy": map[string]interface{}{ + "type": "string", + "description": "Horizon portal subject (annotation horizon-sdv.io/submitted-by) or Argo creator label when present.", + }, + "submittedFrom": map[string]interface{}{ + "type": "string", + "maxLength": 63, + "description": "How the workflow was submitted into the cluster (label horizon-sdv.io/submitted-from): built-in api, developer-portal, horizon-cli, or a custom integration id.", + }, + "message": map[string]interface{}{"type": "string"}, + "archivedLogs": map[string]interface{}{"$ref": "#/components/schemas/ArchivedLogLinks"}, + }, + "required": []interface{}{"name", "namespace", "phase"}, + }, + "NodeBrief": map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "id": map[string]interface{}{"type": "string"}, + "displayName": map[string]interface{}{"type": "string"}, + "templateName": map[string]interface{}{"type": "string"}, + "type": map[string]interface{}{"type": "string"}, + "phase": map[string]interface{}{"type": "string"}, + "podName": map[string]interface{}{"type": "string"}, + "startedAt": map[string]interface{}{"type": "string", "description": "RFC3339 from Argo status.nodes[id].startedAt (stable ordering in UIs)."}, + }, + "required": []interface{}{"id"}, + }, + "OutputArtifact": map[string]interface{}{ + "type": "object", + "description": "Workflow status output artifact with a gs:// URI when the cluster records GCS (or default bucket) on the artifact.", + "properties": map[string]interface{}{ + "nodeId": map[string]interface{}{"type": "string"}, + "name": map[string]interface{}{"type": "string"}, + "fileName": map[string]interface{}{"type": "string", "description": "Basename of the GCS object key when it differs from the workflow artifact name (e.g. smoke-result.tgz vs smoke-result)."}, + "displayName": map[string]interface{}{"type": "string"}, + "templateName": map[string]interface{}{"type": "string"}, + "gcsUri": map[string]interface{}{"type": "string"}, + }, + "required": []interface{}{"name"}, + }, + "WorkflowDetail": map[string]interface{}{ + "type": "object", + "description": "Workflow status detail; archivedLogs populated for terminal workflows when log artifacts exist in status.", + "allOf": []interface{}{ + map[string]interface{}{"$ref": "#/components/schemas/WorkflowSummary"}, + map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "uid": map[string]interface{}{"type": "string"}, + "nodes": map[string]interface{}{"type": "array", "items": map[string]interface{}{"$ref": "#/components/schemas/NodeBrief"}}, + "outputArtifacts": map[string]interface{}{ + "type": "array", + "items": map[string]interface{}{"$ref": "#/components/schemas/OutputArtifact"}, + "description": "All node output artifacts with a GCS URI (not limited to log-named artifacts).", + }, + }, + }, + }, + }, + "WorkflowListResponse": map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "items": map[string]interface{}{ + "type": "array", + "items": map[string]interface{}{ + "$ref": "#/components/schemas/WorkflowSummary", + }, + }, + "continue": map[string]interface{}{ + "type": "string", + "description": "Reserved. GET /v1/workflows/running and GET /v1/workflows/history (without filters) walk cluster list pages server-side and always return an empty string here. For filtered history, omit continue (server returns 400 if combined with filters).", + }, + "retention": map[string]interface{}{"$ref": "#/components/schemas/WorkflowRetention"}, + "truncated": map[string]interface{}{ + "type": "boolean", + "description": "Only on filtered history: true if the matching set may extend beyond this response (scan limit or unfetched cluster pages).", + }, + "scanned": map[string]interface{}{ + "type": "integer", + "format": "int64", + "description": "Only on filtered history: number of workflow objects examined (including non-terminal) while collecting items.", + }, + }, + "required": []interface{}{"items", "retention"}, + }, + "WorkflowAbortResponse": map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "status": map[string]interface{}{"type": "string", "example": "aborting"}, + }, + "required": []interface{}{"status"}, + }, + "WorkflowDeleteResponse": map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "status": map[string]interface{}{"type": "string", "example": "deleted"}, + }, + "required": []interface{}{"status"}, + }, + "DownloadArtifactResponse": map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "url": map[string]interface{}{"type": "string", "description": "HTTPS GET URL; use without Horizon Authorization header."}, + "expiresAt": map[string]interface{}{"type": "string", "format": "date-time", "description": "RFC3339 UTC expiry of the signature."}, + "fileName": map[string]interface{}{"type": "string", "description": "Suggested local filename (GCS object basename); matches Content-Disposition on the signed URL."}, + }, + "required": []interface{}{"url", "expiresAt"}, + }, + "DownloadArtifactCandidate": map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "nodeId": map[string]interface{}{"type": "string"}, + "templateName": map[string]interface{}{"type": "string"}, + "name": map[string]interface{}{"type": "string"}, + "fileName": map[string]interface{}{"type": "string"}, + "gcsUri": map[string]interface{}{"type": "string"}, + "displayName": map[string]interface{}{"type": "string"}, + }, + }, + "DownloadArtifactConflict": map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "error": map[string]interface{}{"type": "string"}, + "candidates": map[string]interface{}{"type": "array", "items": map[string]interface{}{"$ref": "#/components/schemas/DownloadArtifactCandidate"}}, + "artifactName": map[string]interface{}{"type": "string"}, + }, + }, + } +} + +func mergeSchemaMaps(a, b map[string]interface{}) map[string]interface{} { + out := make(map[string]interface{}, len(a)+len(b)) + for k, v := range a { + out[k] = v + } + for k, v := range b { + out[k] = v + } + return out +} + +func openapiWorkflowPaths() map[string]interface{} { + listParams := []interface{}{ + map[string]interface{}{"name": "limit", "in": "query", "schema": map[string]interface{}{"type": "integer", "default": 50, "maximum": 500}}, + map[string]interface{}{"name": "continue", "in": "query", "schema": map[string]interface{}{"type": "string"}}, + } + listOK := map[string]interface{}{ + "description": "Workflow rows (cluster-local; pruned workflows are absent)", + "content": map[string]interface{}{ + "application/json": map[string]interface{}{ + "schema": map[string]interface{}{"$ref": "#/components/schemas/WorkflowListResponse"}, + }, + }, + } + runningParams := []interface{}{ + map[string]interface{}{"name": "limit", "in": "query", "schema": map[string]interface{}{"type": "integer", "default": 50, "maximum": 500}, "description": "Max non-terminal workflows returned; the server may scan up to 10000 cluster workflows (200 per page) so active runs are not hidden behind terminal-only pages."}, + } + historyParams := append(append([]interface{}(nil), listParams...), []interface{}{ + map[string]interface{}{"name": "phase", "in": "query", "schema": map[string]interface{}{"type": "string"}, "description": "Comma-separated display phases (case-insensitive), e.g. succeeded,failed,aborted. Matches WorkflowSummary.phase."}, + map[string]interface{}{"name": "startedAfter", "in": "query", "schema": map[string]interface{}{"type": "string", "format": "date-time"}, "description": "RFC3339 or RFC3339Nano; workflow status.startedAt must be >= this time."}, + map[string]interface{}{"name": "startedBefore", "in": "query", "schema": map[string]interface{}{"type": "string", "format": "date-time"}, "description": "RFC3339 or RFC3339Nano; status.startedAt must be <= this time."}, + map[string]interface{}{"name": "finishedAfter", "in": "query", "schema": map[string]interface{}{"type": "string", "format": "date-time"}, "description": "RFC3339 or RFC3339Nano; status.finishedAt must be >= this time."}, + map[string]interface{}{"name": "finishedBefore", "in": "query", "schema": map[string]interface{}{"type": "string", "format": "date-time"}, "description": "RFC3339 or RFC3339Nano; status.finishedAt must be <= this time."}, + map[string]interface{}{"name": "nameGlob", "in": "query", "schema": map[string]interface{}{"type": "string"}, "description": "Shell-style glob on metadata.name (Go path/filepath Match), e.g. my-wf-*."}, + map[string]interface{}{"name": "nameRegex", "in": "query", "schema": map[string]interface{}{"type": "string", "maxLength": 256}, "description": "Regular expression on metadata.name (Go regexp syntax; max 256 chars)."}, + }...) + return map[string]interface{}{ + "/v1/workflows/running": map[string]interface{}{ + "get": map[string]interface{}{ + "tags": []interface{}{"Workflows"}, + "summary": "List non-terminal workflows in the workflows namespace", + "description": "Lists workflows labelled as Horizon-originated (portal, CLI, or API). Paginates cluster List internally so the first page is not only completed runs (which would yield an empty running list).", + "operationId": "listWorkflowsRunning", + "parameters": runningParams, + "responses": map[string]interface{}{"200": listOK, "401": map[string]interface{}{"description": "Unauthorized"}, "503": map[string]interface{}{"description": "Workflow store not configured"}}, + }, + }, + "/v1/workflows/history": map[string]interface{}{ + "get": map[string]interface{}{ + "tags": []interface{}{"Workflows"}, + "summary": "List terminal workflows (API phases include Aborted) with optional archivedLogs per item", + "operationId": "listWorkflowsHistory", + "description": "Without filters, the server walks cluster List pages (200 per request, up to 10000 workflows) until enough terminal rows are collected (same problem as running: a single page can be all non-terminal). With phase / started* / finished* / nameGlob / nameRegex, scans up to 5000 workflows in pages of 100; continue is not supported with filters and truncated+scanned indicate completeness.", + "parameters": historyParams, + "responses": map[string]interface{}{ + "200": listOK, + "400": map[string]interface{}{"description": "Invalid filter parameters or continue combined with filters"}, + "401": map[string]interface{}{"description": "Unauthorized"}, + "503": map[string]interface{}{"description": "Workflow store not configured"}, + }, + }, + }, + "/v1/workflows/{workflowName}": map[string]interface{}{ + "get": map[string]interface{}{ + "tags": []interface{}{"Workflows"}, + "summary": "Get workflow status by name (metadata.name in workflows namespace)", + "parameters": []interface{}{ + map[string]interface{}{"name": "workflowName", "in": "path", "required": true, "schema": map[string]interface{}{"type": "string"}}, + }, + "responses": map[string]interface{}{ + "200": map[string]interface{}{ + "description": "Workflow detail", + "content": map[string]interface{}{ + "application/json": map[string]interface{}{ + "schema": map[string]interface{}{"$ref": "#/components/schemas/WorkflowDetail"}, + }, + }, + }, + "401": map[string]interface{}{"description": "Unauthorized"}, + "404": map[string]interface{}{"description": "Workflow not found"}, + "503": map[string]interface{}{"description": "Workflow store not configured"}, + }, + }, + "delete": map[string]interface{}{ + "tags": []interface{}{"Workflows"}, + "operationId": "deleteWorkflow", + "summary": "Delete a terminal workflow CR (must be Succeeded, Failed, Error, or Aborted)", + "description": "Deletes the Workflow and **blocks** until the object is no longer returned by the API (finalizers and artifact cleanup may take minutes). Configure max wait via server flag `--workflow-delete-wait-timeout` or `WORKFLOW_DELETE_WAIT_TIMEOUT`.", + "parameters": []interface{}{ + map[string]interface{}{"name": "workflowName", "in": "path", "required": true, "schema": map[string]interface{}{"type": "string"}}, + }, + "responses": map[string]interface{}{ + "200": map[string]interface{}{ + "description": "Workflow fully removed from the cluster", + "content": map[string]interface{}{ + "application/json": map[string]interface{}{ + "schema": map[string]interface{}{"$ref": "#/components/schemas/WorkflowDeleteResponse"}, + }, + }, + }, + "401": map[string]interface{}{"description": "Unauthorized"}, + "404": map[string]interface{}{"description": "Workflow not found"}, + "409": map[string]interface{}{"description": "Workflow is not in a terminal phase"}, + "504": map[string]interface{}{ + "description": "Workflow delete admission succeeded but the CR was still present when the server wait budget expired (finalizers may still be running)", + "content": map[string]interface{}{ + "application/json": map[string]interface{}{ + "schema": map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "error": map[string]interface{}{"type": "string"}, + }, + }, + }, + }, + }, + "503": map[string]interface{}{"description": "Workflow store not configured"}, + }, + }, + }, + "/v1/workflows/{workflowName}/abort": map[string]interface{}{ + "post": map[string]interface{}{ + "tags": []interface{}{"Workflows"}, + "summary": "Request graceful shutdown (spec.shutdown Stop) for a running workflow", + "parameters": []interface{}{ + map[string]interface{}{"name": "workflowName", "in": "path", "required": true, "schema": map[string]interface{}{"type": "string"}}, + }, + "responses": map[string]interface{}{ + "202": map[string]interface{}{ + "description": "Shutdown requested (Accepted)", + "content": map[string]interface{}{ + "application/json": map[string]interface{}{ + "schema": map[string]interface{}{"$ref": "#/components/schemas/WorkflowAbortResponse"}, + }, + }, + }, + "401": map[string]interface{}{"description": "Unauthorized"}, + "404": map[string]interface{}{"description": "Workflow not found"}, + "409": map[string]interface{}{"description": "Workflow is not running (terminal phase)"}, + "503": map[string]interface{}{"description": "Workflow store not configured"}, + }, + }, + }, + "/v1/workflows/{workflowName}/log": map[string]interface{}{ + "get": map[string]interface{}{ + "tags": []interface{}{"Workflows"}, + "operationId": "streamWorkflowLogs", + "summary": "Stream live workflow logs as one NDJSON stream (combined across steps when podName is omitted)", + "description": "Without `podName`, the server prefers Argo workflow-level logs; if unavailable, it multiplexes pod streams. " + + "Log lines may include `nodeId`, `displayName`, and `templateName` when multiplexed. Ordering is best-effort when multiplexed. " + + "With `podName`, streams that pod only (debug).", + "parameters": []interface{}{ + map[string]interface{}{"name": "workflowName", "in": "path", "required": true, "schema": map[string]interface{}{"type": "string"}}, + map[string]interface{}{ + "name": "podName", "in": "query", "required": false, + "description": "Omit for combined workflow log stream; set to restrict to one pod.", + "schema": map[string]interface{}{"type": "string"}, + }, + map[string]interface{}{"name": "container", "in": "query", "schema": map[string]interface{}{"type": "string", "default": "main"}}, + map[string]interface{}{"name": "follow", "in": "query", "schema": map[string]interface{}{"type": "boolean", "default": true}}, + }, + "responses": map[string]interface{}{ + "200": map[string]interface{}{ + "description": "Chunked application/x-ndjson; log lines {\"ts\",\"msg\",\"podName\",...}; heartbeat {\"heartbeat\":true}; terminal {\"result\":\"done\",...}", + "content": map[string]interface{}{ + "application/x-ndjson": map[string]interface{}{"schema": map[string]interface{}{"type": "string"}}, + }, + }, + "400": map[string]interface{}{"description": "Bad request"}, + "401": map[string]interface{}{"description": "Unauthorized"}, + "404": map[string]interface{}{"description": "Workflow not found"}, + "503": map[string]interface{}{"description": "Log streaming not configured"}, + }, + }, + }, + "/v1/workflows/{workflowName}/downloadArtifact/{artifactName}": map[string]interface{}{ + "get": map[string]interface{}{ + "tags": []interface{}{"Workflows"}, + "operationId": "downloadWorkflowArtifact", + "summary": "Signed URL or inlined artifact bytes for a workflow output artifact from GCS", + "description": "Without `inline`: returns 200 JSON with `url` and `expiresAt` (no HTTP redirect); perform a second GET on `url` without a bearer token. With `inline=true` (`1` / `yes`): returns the artifact body with `Content-Type` from GCS (same auth as JSON mode) — use this from browser apps that cannot follow cross-origin signed URLs due to bucket CORS. When multiple `outputArtifacts` share the same `name`, pass `templateName` and/or `nodeId` from `GET /v1/workflows/{workflowName}`.", + "parameters": []interface{}{ + map[string]interface{}{"name": "workflowName", "in": "path", "required": true, "schema": map[string]interface{}{"type": "string"}}, + map[string]interface{}{ + "name": "artifactName", "in": "path", "required": true, "schema": map[string]interface{}{"type": "string"}, + "description": "Path segment — use percent-encoding for special characters; matches `outputArtifacts[].name`.", + }, + map[string]interface{}{ + "name": "durationSeconds", "in": "query", "required": false, + "description": "Optional signed-URL lifetime in seconds; default 600; server clamps to [60, 43199] (<12h).", + "schema": map[string]interface{}{"type": "integer", "minimum": 0}, + }, + map[string]interface{}{ + "name": "templateName", "in": "query", "required": false, + "description": "When several artifacts share `artifactName`, pass `templateName` from `outputArtifacts[]` (often the friendlier disambiguator).", + "schema": map[string]interface{}{"type": "string"}, + }, + map[string]interface{}{ + "name": "nodeId", "in": "query", "required": false, + "description": "When several artifacts share `artifactName`, pass `nodeId` from `outputArtifacts[]`. May be combined with `templateName` to narrow further.", + "schema": map[string]interface{}{"type": "string"}, + }, + map[string]interface{}{ + "name": "inline", "in": "query", "required": false, + "description": "When `true`/`1`/`yes`, stream the artifact bytes in the response body instead of returning a JSON signed URL.", + "schema": map[string]interface{}{"type": "boolean"}, + }, + }, + "responses": map[string]interface{}{ + "200": map[string]interface{}{ + "description": "JSON (`DownloadArtifactResponse`) unless `inline` is set; then raw artifact bytes.", + "content": map[string]interface{}{ + "application/json": map[string]interface{}{ + "schema": map[string]interface{}{"$ref": "#/components/schemas/DownloadArtifactResponse"}, + }, + }, + }, + "400": map[string]interface{}{"description": "Invalid query or path"}, + "401": map[string]interface{}{"description": "Unauthorized"}, + "404": map[string]interface{}{"description": "Workflow or artifact not found, or artifact has no gs:// URI"}, + "409": map[string]interface{}{ + "description": "Ambiguous artifact name", + "content": map[string]interface{}{ + "application/json": map[string]interface{}{ + "schema": map[string]interface{}{"$ref": "#/components/schemas/DownloadArtifactConflict"}, + }, + }, + }, + "501": map[string]interface{}{"description": "GCS artifact signing not configured"}, + "503": map[string]interface{}{"description": "Workflow store not configured"}, + }, + }, + }, + } +} + +func retentionOpenAPIDescription(meta RetentionOpenAPI) string { + ss, sf, sc := meta.SecondsAfterSuccess, meta.SecondsAfterFailure, meta.SecondsAfterCompletion + ex := strings.TrimSpace(meta.Explanation) + if ss <= 0 { + ss = 86400 + } + if sf <= 0 { + sf = 259200 + } + if sc <= 0 { + sc = 86400 + } + if ex == "" { + ex = "Workflow CRs are removed from the cluster after Argo ttlStrategy; expect 404 when pruned." + } + return " Workflow list APIs include a `retention` object: secondsAfterSuccess=" + strconv.Itoa(ss) + + ", secondsAfterFailure=" + strconv.Itoa(sf) + ", secondsAfterCompletion=" + strconv.Itoa(sc) + ". " + ex +} + +// OpenAPISpec builds OpenAPI 3: catalog, workflow lifecycle, live logs, and one POST submit path per catalog entry. +func OpenAPISpec(entries []Entry, meta RetentionOpenAPI) ([]byte, error) { + paths := map[string]interface{}{ + "/v1/catalog": map[string]interface{}{ + "get": map[string]interface{}{ + "summary": "List exposed workflow templates", + "responses": map[string]interface{}{ + "200": map[string]interface{}{ + "description": "Catalog entries", + "content": map[string]interface{}{ + "application/json": map[string]interface{}{ + "schema": map[string]interface{}{"$ref": "#/components/schemas/CatalogResponse"}, + }, + }, + }, + }, + }, + }, + } + for k, v := range openapiWorkflowPaths() { + paths[k] = v + } + responses := submitResponsesDoc() + for _, e := range entries { + pathKey := submitPathForEntry(e.Module, e.TemplateName) + opID := "submitWorkflow_" + sanitizeOperationIDPart(e.Module) + "_" + sanitizeOperationIDPart(e.TemplateName) + if opID == "submitWorkflow__" { + opID = "submitWorkflow" + } + paths[pathKey] = map[string]interface{}{ + "post": map[string]interface{}{ + "tags": []interface{}{e.Module}, + "summary": fmt.Sprintf("Submit workflow template %s/%s (Argo Events webhook)", e.Module, e.TemplateName), + "operationId": opID, + "parameters": submitOperationParameters(), + "requestBody": map[string]interface{}{ + "required": true, + "content": map[string]interface{}{ + "application/json": map[string]interface{}{ + "schema": submitRequestBodySchema(e), + }, + }, + }, + "responses": responses, + }, + } + } + doc := map[string]interface{}{ + "openapi": "3.0.3", + "info": map[string]interface{}{ + "title": "Horizon API", + "version": "0.3.5", + "description": "Invoke exposed WorkflowTemplates (and ClusterWorkflowTemplates when labeled exposed) for enabled modules. Authentication: Keycloak realm `horizon` access token (JWT) only. " + + "Clients: `horizon-api` (public — Authorization Code+PKCE and OAuth 2.0 device authorization grant for CLI), `horizon-api-ci` (confidential — client_credentials with client_id and client_secret from Keycloak Admin). " + + "Endpoints: `{issuer}/protocol/openid-connect/auth/device` (device), `{issuer}/protocol/openid-connect/token` (code + device token + client_credentials). " + + "Submit runs a template via `POST /v1/modules/{module}/workflowTemplates/{template}/submit` with body `{\"parameters\":{...}}` and optional header `X-Horizon-Submitted-From` (`api` default, `developer-portal`, `horizon-cli`); OpenAPI lists one such operation per catalog entry with named parameters. " + + "Workflow lifecycle: `GET /v1/workflows/running`, `GET /v1/workflows/history`, `GET /v1/workflows/{workflowName}`, `DELETE /v1/workflows/{workflowName}` (terminal only), `GET /v1/workflows/{workflowName}/log`, `POST /v1/workflows/{workflowName}/abort`, `GET /v1/workflows/{workflowName}/downloadArtifact/{artifactName}` (JSON signed URL by default; `inline` streams bytes for browsers). " + + "History and terminal status may include `archivedLogs` with `gs://` URIs derived from Workflow status artifacts. " + + "Live logs: `GET /v1/workflows/{workflowName}/log` without `podName` for a combined NDJSON stream (Argo workflow log or multiplexed pods); optional `podName` for a single pod. " + + "Argo Server uses `--auth-mode=client` for the service account." + retentionOpenAPIDescription(meta), + }, + // Required when the API is exposed behind a path prefix (e.g. GKE Gateway strips /horizon-api/ before the pod). Without this, Swagger UI calls /v1/catalog on the site origin and hits the wrong backend (404 nginx). + "servers": []interface{}{ + map[string]interface{}{ + "url": "/horizon-api", + "description": "External gateway prefix; backend receives paths without this segment.", + }, + }, + "paths": paths, + "components": map[string]interface{}{ + "securitySchemes": map[string]interface{}{ + "bearerAuth": map[string]interface{}{ + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT", + "description": "Keycloak access_token for realm horizon (see info.description).", + }, + }, + "schemas": mergeSchemaMaps(openapiComponentSchemas(), workflowOpenAPIComponentSchemas()), + }, + "security": []interface{}{ + map[string]interface{}{"bearerAuth": []interface{}{}}, + }, + } + return json.MarshalIndent(doc, "", " ") +} diff --git a/terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/internal/catalog/extract_parameters_test.go b/terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/internal/catalog/extract_parameters_test.go new file mode 100644 index 00000000..ea064669 --- /dev/null +++ b/terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/internal/catalog/extract_parameters_test.go @@ -0,0 +1,42 @@ +// Copyright (c) 2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +package catalog + +import ( + "testing" + + "github.com/acn-horizon-sdv/horizon-api/internal/workflow" +) + +func TestExtractParametersOmitsHorizonInjected(t *testing.T) { + t.Parallel() + obj := map[string]interface{}{ + "spec": map[string]interface{}{ + "arguments": map[string]interface{}{ + "parameters": []interface{}{ + map[string]interface{}{"name": "sampleEnv", "value": ""}, + map[string]interface{}{"name": workflow.SubmitParamSampleSoftEnabled, "value": "false"}, + }, + }, + }, + } + params, err := extractParameters(obj) + if err != nil { + t.Fatal(err) + } + if len(params) != 1 || params[0].Name != "sampleEnv" { + t.Fatalf("params: %+v", params) + } +} diff --git a/terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/internal/catalog/openapi_workflow_test.go b/terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/internal/catalog/openapi_workflow_test.go new file mode 100644 index 00000000..03a2e8bf --- /dev/null +++ b/terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/internal/catalog/openapi_workflow_test.go @@ -0,0 +1,125 @@ +// Copyright (c) 2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +package catalog + +import ( + "encoding/json" + "testing" +) + +func TestOpenAPIWorkflowsHistoryParameters(t *testing.T) { + t.Parallel() + b, err := OpenAPISpec(nil, RetentionOpenAPI{}) + if err != nil { + t.Fatal(err) + } + var doc struct { + Paths map[string]struct { + Get *struct { + OperationID string `json:"operationId"` + Parameters []interface{} `json:"parameters"` + } `json:"get"` + } `json:"paths"` + Info struct { + Version string `json:"version"` + } `json:"info"` + } + if err := json.Unmarshal(b, &doc); err != nil { + t.Fatal(err) + } + h := doc.Paths["/v1/workflows/history"].Get + if h == nil { + t.Fatal("missing GET /v1/workflows/history") + } + if h.OperationID != "listWorkflowsHistory" { + t.Fatalf("operationId: got %q", h.OperationID) + } + want := []string{"limit", "continue", "phase", "startedAfter", "startedBefore", "finishedAfter", "finishedBefore", "nameGlob", "nameRegex"} + if len(h.Parameters) != len(want) { + t.Fatalf("parameters: got %d want %d", len(h.Parameters), len(want)) + } + for i, name := range want { + m, ok := h.Parameters[i].(map[string]interface{}) + if !ok { + t.Fatalf("param %d: not an object", i) + } + if m["name"] != name { + t.Fatalf("param %d name: got %v want %q", i, m["name"], name) + } + } + r := doc.Paths["/v1/workflows/running"].Get + if r == nil || r.OperationID != "listWorkflowsRunning" { + t.Fatalf("running operationId: %+v", r) + } + if len(r.Parameters) != 1 { + t.Fatalf("running should have only limit, got %d", len(r.Parameters)) + } + if m, ok := r.Parameters[0].(map[string]interface{}); !ok || m["name"] != "limit" { + t.Fatalf("running first param: %+v", r.Parameters[0]) + } +} + +func TestOpenAPIWorkflowLogPath(t *testing.T) { + t.Parallel() + b, err := OpenAPISpec(nil, RetentionOpenAPI{}) + if err != nil { + t.Fatal(err) + } + var doc struct { + Paths map[string]interface{} `json:"paths"` + } + if err := json.Unmarshal(b, &doc); err != nil { + t.Fatal(err) + } + if _, ok := doc.Paths["/v1/workflows/{workflowName}/log"]; !ok { + t.Fatal("missing GET /v1/workflows/{workflowName}/log") + } + if _, bad := doc.Paths["/v1/logs/{workflowName}"]; bad { + t.Fatal("legacy /v1/logs/{workflowName} must not appear in OpenAPI") + } + lg := doc.Paths["/v1/workflows/{workflowName}/log"].(map[string]interface{}) + op := lg["get"].(map[string]interface{}) + if op["operationId"] != "streamWorkflowLogs" { + t.Fatalf("log operationId: %v", op["operationId"]) + } +} + +func TestOpenAPIDownloadArtifactPath(t *testing.T) { + t.Parallel() + b, err := OpenAPISpec(nil, RetentionOpenAPI{}) + if err != nil { + t.Fatal(err) + } + var doc struct { + Paths map[string]interface{} `json:"paths"` + Info struct { + Version string `json:"version"` + } `json:"info"` + } + if err := json.Unmarshal(b, &doc); err != nil { + t.Fatal(err) + } + const p = "/v1/workflows/{workflowName}/downloadArtifact/{artifactName}" + if _, ok := doc.Paths[p]; !ok { + t.Fatalf("missing path %s", p) + } + op := doc.Paths[p].(map[string]interface{})["get"].(map[string]interface{}) + if op["operationId"] != "downloadWorkflowArtifact" { + t.Fatalf("operationId: %v", op["operationId"]) + } + if doc.Info.Version != "0.3.5" { + t.Fatalf("info.version: %q", doc.Info.Version) + } +} diff --git a/terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/internal/controller/catalog_controller.go b/terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/internal/controller/catalog_controller.go new file mode 100644 index 00000000..000d21d4 --- /dev/null +++ b/terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/internal/controller/catalog_controller.go @@ -0,0 +1,93 @@ +// Copyright (c) 2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package controller + +import ( + "context" + "fmt" + + "github.com/acn-horizon-sdv/horizon-api/internal/catalog" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/predicate" + "sigs.k8s.io/controller-runtime/pkg/reconcile" +) + +// CatalogReconciler rebuilds the in-memory catalog on Module Manager / WorkflowTemplate / ClusterWorkflowTemplate changes. +type CatalogReconciler struct { + Catalog *catalog.Catalog + + ModuleManagerNS string + WorkflowsNS string + StateName string + CatalogName string +} + +func enqueueCatalog(_ context.Context, _ client.Object) []reconcile.Request { + return []reconcile.Request{{NamespacedName: types.NamespacedName{Name: "catalog"}}} +} + +// SetupWithManager registers watches for ModuleManagerState, ModuleCatalog, WorkflowTemplates in the workflows namespace, and cluster-scoped ClusterWorkflowTemplates. +// WorkflowTemplate watches must not filter on horizon-sdv.io/expose: when expose flips true→false, the object would no longer match +// the predicate and no reconcile would run, leaving stale entries in the catalog/OpenAPI until another unrelated event occurred. +func (r *CatalogReconciler) SetupWithManager(mgr ctrl.Manager) error { + mm := &unstructured.Unstructured{} + mm.SetGroupVersionKind(schema.GroupVersionKind{Group: "horizon-sdv.io", Version: "v1alpha1", Kind: "ModuleManagerState"}) + + mc := &unstructured.Unstructured{} + mc.SetGroupVersionKind(schema.GroupVersionKind{Group: "horizon-sdv.io", Version: "v1alpha1", Kind: "ModuleCatalog"}) + + wt := &unstructured.Unstructured{} + wt.SetGroupVersionKind(schema.GroupVersionKind{Group: "argoproj.io", Version: "v1alpha1", Kind: "WorkflowTemplate"}) + + cwt := &unstructured.Unstructured{} + cwt.SetGroupVersionKind(schema.GroupVersionKind{Group: "argoproj.io", Version: "v1alpha1", Kind: "ClusterWorkflowTemplate"}) + + return builder.ControllerManagedBy(mgr). + Named("horizon-api-catalog"). + For(mm, builder.WithPredicates(predicate.NewPredicateFuncs(func(o client.Object) bool { + return o.GetNamespace() == r.ModuleManagerNS && o.GetName() == r.StateName + }))). + Watches( + mc, + handler.EnqueueRequestsFromMapFunc(enqueueCatalog), + builder.WithPredicates(predicate.NewPredicateFuncs(func(o client.Object) bool { + return o.GetNamespace() == r.ModuleManagerNS && o.GetName() == r.CatalogName + }))). + Watches( + wt, + handler.EnqueueRequestsFromMapFunc(enqueueCatalog), + builder.WithPredicates(predicate.NewPredicateFuncs(func(o client.Object) bool { + return o.GetNamespace() == r.WorkflowsNS + }))). + Watches( + cwt, + handler.EnqueueRequestsFromMapFunc(enqueueCatalog)). + Complete(r) +} + +// Reconcile implements reconcile.Reconciler. +func (r *CatalogReconciler) Reconcile(ctx context.Context, req reconcile.Request) (reconcile.Result, error) { + _ = req + if err := r.Catalog.Rebuild(ctx); err != nil { + return reconcile.Result{}, fmt.Errorf("catalog rebuild: %w", err) + } + return reconcile.Result{}, nil +} diff --git a/terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/internal/gcs/duration.go b/terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/internal/gcs/duration.go new file mode 100644 index 00000000..30a29969 --- /dev/null +++ b/terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/internal/gcs/duration.go @@ -0,0 +1,37 @@ +// Copyright (c) 2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +package gcs + +const ( + defaultSignedURLSeconds = 600 + minSignedURLSeconds = 60 + // maxSignedURLSeconds caps lifetime below 12h (security / org policy; IAM SignBlob still needs no exported keys). + maxSignedURLSeconds = 12*3600 - 1 +) + +// ClampDurationSeconds applies signed-URL TTL rules: 0 or negative → default (600); +// otherwise clamp to [60, maxSignedURLSeconds] (< 12h). +func ClampDurationSeconds(seconds int) int { + if seconds <= 0 { + return defaultSignedURLSeconds + } + if seconds < minSignedURLSeconds { + return minSignedURLSeconds + } + if seconds > maxSignedURLSeconds { + return maxSignedURLSeconds + } + return seconds +} diff --git a/terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/internal/gcs/duration_test.go b/terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/internal/gcs/duration_test.go new file mode 100644 index 00000000..178b395c --- /dev/null +++ b/terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/internal/gcs/duration_test.go @@ -0,0 +1,32 @@ +// Copyright (c) 2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +package gcs + +import "testing" + +func TestClampDurationSeconds(t *testing.T) { + if g := ClampDurationSeconds(0); g != 600 { + t.Fatalf("default: %d", g) + } + if g := ClampDurationSeconds(30); g != 60 { + t.Fatalf("min: %d", g) + } + if g := ClampDurationSeconds(999999); g != 12*3600-1 { + t.Fatalf("max: %d", g) + } + if g := ClampDurationSeconds(120); g != 120 { + t.Fatalf("mid: %d", g) + } +} diff --git a/terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/internal/gcs/sign.go b/terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/internal/gcs/sign.go new file mode 100644 index 00000000..9599653a --- /dev/null +++ b/terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/internal/gcs/sign.go @@ -0,0 +1,75 @@ +// Copyright (c) 2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +package gcs + +import ( + "context" + "encoding/base64" + "fmt" + "mime" + "net/url" + "strings" + "time" + + "cloud.google.com/go/storage" + iamcredentials "google.golang.org/api/iamcredentials/v1" + "google.golang.org/api/option" +) + +// SignedGETURL returns a V4 signed GET URL for object. serviceAccountEmail is the IAM identity +// whose key signs the URL (Workload Identity / GSA with roles/iam.serviceAccountTokenCreator as needed). +// downloadName sets response-content-disposition when non-empty (safe filename for browsers). +func SignedGETURL(ctx context.Context, serviceAccountEmail, bucket, object string, expires time.Time, downloadName string) (string, error) { + serviceAccountEmail = strings.TrimSpace(serviceAccountEmail) + if serviceAccountEmail == "" { + return "", fmt.Errorf("signing service account email is empty") + } + svc, err := iamcredentials.NewService(ctx, option.WithScopes(iamcredentials.CloudPlatformScope)) + if err != nil { + return "", fmt.Errorf("iamcredentials client: %w", err) + } + saResource := fmt.Sprintf("projects/-/serviceAccounts/%s", serviceAccountEmail) + signBytes := func(b []byte) ([]byte, error) { + req := &iamcredentials.SignBlobRequest{ + Payload: base64.StdEncoding.EncodeToString(b), + } + resp, err := svc.Projects.ServiceAccounts.SignBlob(saResource, req).Context(ctx).Do() + if err != nil { + return nil, err + } + sig, err := base64.StdEncoding.DecodeString(resp.SignedBlob) + if err != nil { + return nil, fmt.Errorf("decode SignBlob response: %w", err) + } + return sig, nil + } + + opts := &storage.SignedURLOptions{ + Scheme: storage.SigningSchemeV4, + Method: "GET", + GoogleAccessID: serviceAccountEmail, + SignBytes: signBytes, + Expires: expires, + } + if dn := strings.TrimSpace(downloadName); dn != "" { + cd := mime.FormatMediaType("attachment", map[string]string{"filename": dn}) + if cd != "" { + q := url.Values{} + q.Set("response-content-disposition", cd) + opts.QueryParameters = q + } + } + return storage.SignedURL(bucket, object, opts) +} diff --git a/terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/internal/gcs/uri.go b/terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/internal/gcs/uri.go new file mode 100644 index 00000000..724615c1 --- /dev/null +++ b/terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/internal/gcs/uri.go @@ -0,0 +1,56 @@ +// Copyright (c) 2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +package gcs + +import ( + "fmt" + "path" + "strings" +) + +// ParseGCSURI splits gs://bucket/object into bucket and object key (no leading slash on key). +func ParseGCSURI(raw string) (bucket, object string, err error) { + s := strings.TrimSpace(raw) + const p = "gs://" + if !strings.HasPrefix(s, p) { + return "", "", fmt.Errorf("not a gs:// URI") + } + rest := strings.TrimPrefix(s, p) + if rest == "" { + return "", "", fmt.Errorf("empty gs:// URI") + } + i := strings.IndexByte(rest, '/') + if i < 0 { + return "", "", fmt.Errorf("gs:// URI missing object path") + } + bucket, key := rest[:i], rest[i+1:] + if bucket == "" || key == "" { + return "", "", fmt.Errorf("invalid gs:// bucket or object") + } + return bucket, key, nil +} + +// ObjectBaseName returns the last path segment of the object key in a gs:// URI, or empty if invalid. +func ObjectBaseName(raw string) string { + _, object, err := ParseGCSURI(raw) + if err != nil { + return "" + } + b := path.Base(object) + if b == "" || b == "." { + return "" + } + return b +} diff --git a/terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/internal/gcs/uri_test.go b/terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/internal/gcs/uri_test.go new file mode 100644 index 00000000..142ff911 --- /dev/null +++ b/terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/internal/gcs/uri_test.go @@ -0,0 +1,45 @@ +// Copyright (c) 2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +package gcs + +import ( + "fmt" + "testing" +) + +func TestObjectBaseName(t *testing.T) { + t.Parallel() + tests := []struct { + in string + want string + }{ + {"gs://my-bucket/workflows/abc/nodes/node-0/smoke-result.tgz", "smoke-result.tgz"}, + {"gs://b/single", "single"}, + {"gs://b/prefix/main-logs.gz", "main-logs.gz"}, + {"not gs://", ""}, + {"", ""}, + {"gs://onlybucket", ""}, + } + for i, tc := range tests { + tc := tc + t.Run(fmt.Sprintf("%d", i), func(t *testing.T) { + t.Parallel() + got := ObjectBaseName(tc.in) + if got != tc.want { + t.Fatalf("ObjectBaseName(%q) = %q, want %q", tc.in, got, tc.want) + } + }) + } +} diff --git a/terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/internal/invoke/webhook.go b/terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/internal/invoke/webhook.go new file mode 100644 index 00000000..6c0f8707 --- /dev/null +++ b/terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/internal/invoke/webhook.go @@ -0,0 +1,59 @@ +// Copyright (c) 2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package invoke + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// PostArgoEventsWebhook posts the workflow-dispatch JSON body to the Argo Events EventSource. +func PostArgoEventsWebhook(ctx context.Context, url string, body map[string]interface{}) error { + payload, err := json.Marshal(body) + if err != nil { + return err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + // Keep below common gateway / browser timeouts so submit callers get a clear failure instead of hanging. + client := &http.Client{Timeout: 25 * time.Second} + res, err := client.Do(req) + if err != nil { + return err + } + defer res.Body.Close() + if res.StatusCode < 200 || res.StatusCode >= 300 { + snippet, _ := io.ReadAll(io.LimitReader(res.Body, 4096)) + msg := strings.TrimSpace(string(snippet)) + if len(msg) > 512 { + msg = msg[:512] + "…" + } + if msg == "" { + return fmt.Errorf("events webhook: HTTP %d", res.StatusCode) + } + return fmt.Errorf("events webhook: HTTP %d: %s", res.StatusCode, msg) + } + _, _ = io.Copy(io.Discard, res.Body) + return nil +} diff --git a/terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/internal/softfeature/softfeature.go b/terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/internal/softfeature/softfeature.go new file mode 100644 index 00000000..527880a5 --- /dev/null +++ b/terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/internal/softfeature/softfeature.go @@ -0,0 +1,101 @@ +// Copyright (c) 2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Package softfeature resolves whether a soft-dependency module is enabled for a parent module, +// using the same sources as Module Manager (ModuleCatalog for desired state and +// ModuleManagerState for runtime state). +package softfeature + +import ( + "context" + "fmt" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// SoftModuleEnabledForParent reports whether softModuleName (e.g. sample-soft) is enabled for the +// cluster, when parentModuleName (e.g. sample) lists it as a soft dependency in ModuleCatalog. +// If the soft module is not among the parent's soft dependencies, returns (false, nil). +func SoftModuleEnabledForParent(ctx context.Context, c client.Client, mmNamespace, stateCRName, catalogCRName, parentModuleName, softModuleName string) (bool, error) { + deps, err := softDepsForParent(ctx, c, mmNamespace, catalogCRName, parentModuleName) + if err != nil { + return false, err + } + found := false + for _, d := range deps { + if d == softModuleName { + found = true + break + } + } + if !found { + return false, nil + } + + u := &unstructured.Unstructured{} + u.SetGroupVersionKind(schema.GroupVersionKind{Group: "horizon-sdv.io", Version: "v1alpha1", Kind: "ModuleManagerState"}) + if err := c.Get(ctx, client.ObjectKey{Namespace: mmNamespace, Name: stateCRName}, u); err != nil { + if apierrors.IsNotFound(err) { + return false, nil + } + return false, fmt.Errorf("get ModuleManagerState: %w", err) + } + + enabledIDs := map[string]bool{} + if ids, found, _ := unstructured.NestedStringSlice(u.Object, "status", "enabledModules"); found { + for _, id := range ids { + enabledIDs[id] = true + } + } + moduleIDByName := map[string]string{} + if m, found, _ := unstructured.NestedStringMap(u.Object, "status", "moduleIds"); found { + for name, id := range m { + moduleIDByName[name] = id + } + } + + id := moduleIDByName[softModuleName] + return id != "" && enabledIDs[id], nil +} + +func softDepsForParent(ctx context.Context, c client.Client, mmNamespace, catalogCRName, parentModuleName string) ([]string, error) { + u := &unstructured.Unstructured{} + u.SetGroupVersionKind(schema.GroupVersionKind{Group: "horizon-sdv.io", Version: "v1alpha1", Kind: "ModuleCatalog"}) + if err := c.Get(ctx, client.ObjectKey{Namespace: mmNamespace, Name: catalogCRName}, u); err != nil { + if apierrors.IsNotFound(err) { + return nil, nil + } + return nil, fmt.Errorf("get ModuleCatalog: %w", err) + } + modules, found, err := unstructured.NestedSlice(u.Object, "spec", "modules") + if err != nil || !found { + return nil, nil + } + for _, mod := range modules { + m, ok := mod.(map[string]interface{}) + if !ok { + continue + } + name, _, _ := unstructured.NestedString(m, "name") + if name != parentModuleName { + continue + } + deps, _, _ := unstructured.NestedStringSlice(m, "softDependencies") + return append([]string(nil), deps...), nil + } + return nil, nil +} diff --git a/terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/internal/softfeature/softfeature_test.go b/terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/internal/softfeature/softfeature_test.go new file mode 100644 index 00000000..5bcf9ea8 --- /dev/null +++ b/terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/internal/softfeature/softfeature_test.go @@ -0,0 +1,83 @@ +// Copyright (c) 2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +package softfeature + +import ( + "context" + "testing" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func TestSoftModuleEnabledForParent(t *testing.T) { + t.Parallel() + ctx := context.Background() + const mmNS = "module-manager" + + state := &unstructured.Unstructured{} + state.SetAPIVersion("horizon-sdv.io/v1alpha1") + state.SetKind("ModuleManagerState") + state.SetNamespace(mmNS) + state.SetName("cluster") + if err := unstructured.SetNestedStringSlice(state.Object, []string{"id-soft"}, "status", "enabledModules"); err != nil { + t.Fatal(err) + } + if err := unstructured.SetNestedStringMap(state.Object, map[string]string{"sample-soft": "id-soft"}, "status", "moduleIds"); err != nil { + t.Fatal(err) + } + + cat := &unstructured.Unstructured{} + cat.SetAPIVersion("horizon-sdv.io/v1alpha1") + cat.SetKind("ModuleCatalog") + cat.SetNamespace(mmNS) + cat.SetName("cluster") + if err := unstructured.SetNestedSlice(cat.Object, []interface{}{ + map[string]interface{}{ + "name": "sample", + "softDependencies": []interface{}{"sample-soft"}, + }, + }, "spec", "modules"); err != nil { + t.Fatal(err) + } + + cl := fake.NewClientBuilder().WithObjects(state, cat).Build() + + got, err := SoftModuleEnabledForParent(ctx, cl, mmNS, "cluster", "cluster", "sample", "sample-soft") + if err != nil { + t.Fatal(err) + } + if !got { + t.Fatalf("got enabled=false want true") + } + + got, err = SoftModuleEnabledForParent(ctx, cl, mmNS, "cluster", "cluster", "sample", "other-soft") + if err != nil { + t.Fatal(err) + } + if got { + t.Fatalf("got enabled=true want false for unrelated soft dep") + } +} + +func TestSoftModuleEnabledForParent_catalogNotFound(t *testing.T) { + t.Parallel() + ctx := context.Background() + cl := fake.NewClientBuilder().Build() + _, err := SoftModuleEnabledForParent(ctx, cl, "module-manager", "cluster", "cluster", "sample", "sample-soft") + if err != nil { + t.Fatal(err) + } +} diff --git a/terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/internal/workflow/archive.go b/terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/internal/workflow/archive.go new file mode 100644 index 00000000..b23bf5c8 --- /dev/null +++ b/terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/internal/workflow/archive.go @@ -0,0 +1,272 @@ +// Copyright (c) 2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +package workflow + +import ( + "sort" + "strings" + "time" + + "github.com/acn-horizon-sdv/horizon-api/internal/gcs" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +// BuildArchivedLogLinks extracts gs:// URIs from Workflow status (outputs.artifacts with gcs keys). +// k8sPodNames may be nil (list endpoints); when set (e.g. GET /workflow/{name}), step PodName is filled via node-id suffix even if status omits podName. +func BuildArchivedLogLinks(u *unstructured.Unstructured, ns, defaultBucket string, k8sPodNames []string) *ArchivedLogLinks { + name := u.GetName() + out := &ArchivedLogLinks{Steps: nil} + + nodes, _, _ := unstructured.NestedMap(u.Object, "status", "nodes") + for nodeID, raw := range nodes { + m, ok := raw.(map[string]interface{}) + if !ok { + continue + } + typ, _, _ := unstructured.NestedString(m, "type") + disp, _, _ := unstructured.NestedString(m, "displayName") + tpl, _, _ := unstructured.NestedString(m, "templateName") + ph, _, _ := unstructured.NestedString(m, "phase") + pod, _, _ := unstructured.NestedString(m, "podName") + + arts, _, _ := unstructured.NestedSlice(m, "outputs", "artifacts") + var stepURI, stepArtName string + for _, a := range arts { + am, ok := a.(map[string]interface{}) + if !ok { + continue + } + aname, _, _ := unstructured.NestedString(am, "name") + if !isLogArtifactName(aname) { + continue + } + uri := artifactGCSURI(am, defaultBucket) + if uri != "" && stepURI == "" { + stepURI = uri + stepArtName = aname + } + } + if typ == "Pod" && stepURI != "" { + pn := pod + if pn == "" && len(k8sPodNames) > 0 { + pn = matchPodNameForNodeID(nodeID, k8sPodNames) + } + out.Steps = append(out.Steps, StepLogLink{ + NodeID: nodeID, + DisplayName: disp, + TemplateName: tpl, + Phase: ph, + PodName: pn, + GcsURI: stepURI, + ArtifactName: stepArtName, + }) + } + // Entry / workflow root node often has id == workflow name — use for combined if it carries a main log. + if nodeID == name && typ != "Pod" { + arts2, _, _ := unstructured.NestedSlice(m, "outputs", "artifacts") + for _, a := range arts2 { + am, ok := a.(map[string]interface{}) + if !ok { + continue + } + if uri := artifactGCSURI(am, defaultBucket); uri != "" && isLogArtifactNameFromMap(am) { + out.Combined = &LogURIRef{GcsURI: uri} + break + } + } + } + } + + // Heuristic combined: reuse the only step's archived log when there is exactly one pod with logs. + if out.Combined == nil && len(out.Steps) == 1 && out.Steps[0].GcsURI != "" { + out.Combined = &LogURIRef{GcsURI: out.Steps[0].GcsURI} + } + + if len(out.Steps) > 1 { + sort.SliceStable(out.Steps, func(i, j int) bool { + ti := stepStartedAt(nodes, out.Steps[i].NodeID) + tj := stepStartedAt(nodes, out.Steps[j].NodeID) + if ti.Equal(tj) || (ti.IsZero() && tj.IsZero()) { + return out.Steps[i].NodeID < out.Steps[j].NodeID + } + if ti.IsZero() { + return false + } + if tj.IsZero() { + return true + } + return ti.Before(tj) + }) + } + + // Multi-step: Argo archives one main.log per pod; there is no single merged object. Expose a shared GCS + // path prefix (folder) so UIs can open the bucket path containing every step's log for this run. + if out.Combined == nil && len(out.Steps) >= 2 { + uris := make([]string, 0, len(out.Steps)) + for _, st := range out.Steps { + if st.GcsURI != "" { + uris = append(uris, st.GcsURI) + } + } + if len(uris) >= 2 { + if p := gcsURIsLongestCommonDirectoryPrefix(uris); p != "" { + out.Combined = &LogURIRef{GcsURI: p} + } + } + } + + if len(out.Steps) == 0 && (out.Combined == nil || out.Combined.GcsURI == "") { + return nil + } + return out +} + +func stepStartedAt(nodes map[string]interface{}, nodeID string) time.Time { + raw, ok := nodes[nodeID] + if !ok { + return time.Time{} + } + m, ok := raw.(map[string]interface{}) + if !ok { + return time.Time{} + } + s, _, _ := unstructured.NestedString(m, "startedAt") + if s == "" { + return time.Time{} + } + t, err := time.Parse(time.RFC3339, s) + if err != nil { + return time.Time{} + } + return t +} + +func gcsURIsLongestCommonDirectoryPrefix(uris []string) string { + if len(uris) < 2 { + return "" + } + p := strings.TrimSpace(uris[0]) + for _, u := range uris[1:] { + p = commonStringPrefix(p, strings.TrimSpace(u)) + if p == "" { + return "" + } + } + if !strings.HasPrefix(p, "gs://") { + return "" + } + i := strings.LastIndex(p, "/") + if i <= len("gs://") { + return "" + } + out := p[:i+1] + rest := strings.TrimPrefix(out, "gs://") + rest = strings.TrimSuffix(rest, "/") + if !strings.Contains(rest, "/") { + return "" + } + return out +} + +func commonStringPrefix(a, b string) string { + n := len(a) + if len(b) < n { + n = len(b) + } + i := 0 + for i < n && a[i] == b[i] { + i++ + } + return a[:i] +} + +// BuildOutputArtifacts lists all node output artifacts that map to a GCS URI (any name). +func BuildOutputArtifacts(u *unstructured.Unstructured, defaultBucket, rootWorkflowTemplate, rootModule string) []OutputArtifact { + nodes, _, _ := unstructured.NestedMap(u.Object, "status", "nodes") + parentOf := buildNodeParentMap(nodes) + rootTpl := strings.TrimSpace(rootWorkflowTemplate) + var out []OutputArtifact + for nodeID, raw := range nodes { + m, ok := raw.(map[string]interface{}) + if !ok { + continue + } + disp, _, _ := unstructured.NestedString(m, "displayName") + tpl, _, _ := unstructured.NestedString(m, "templateName") + originTpl := effectiveWorkflowTemplateForNode(nodeID, m, nodes, parentOf, rootTpl) + arts, _, _ := unstructured.NestedSlice(m, "outputs", "artifacts") + for _, a := range arts { + am, ok := a.(map[string]interface{}) + if !ok { + continue + } + aname, _, _ := unstructured.NestedString(am, "name") + uri := artifactGCSURI(am, defaultBucket) + if uri == "" { + continue + } + oa := OutputArtifact{ + NodeID: nodeID, + Name: aname, + DisplayName: disp, + Module: rootModule, + TemplateName: tpl, + WorkflowTemplate: originTpl, + GcsURI: uri, + } + if fn := gcs.ObjectBaseName(uri); fn != "" { + oa.FileName = fn + } + out = append(out, oa) + } + } + return out +} + +func isLogArtifactName(name string) bool { + n := strings.ToLower(strings.TrimSpace(name)) + // Default Argo container / stdout archive name; no "log" substring. + if n == "main" { + return true + } + return strings.Contains(n, "log") || n == "main-logs" || n == "mainlogs" +} + +func isLogArtifactNameFromMap(am map[string]interface{}) bool { + n, _, _ := unstructured.NestedString(am, "name") + return isLogArtifactName(n) +} + +func artifactGCSURI(am map[string]interface{}, defaultBucket string) string { + gcs, found, err := unstructured.NestedMap(am, "gcs") + if err == nil && found && len(gcs) > 0 { + b, _, _ := unstructured.NestedString(gcs, "bucket") + k, _, _ := unstructured.NestedString(gcs, "key") + if b == "" { + b = defaultBucket + } + if b != "" && k != "" { + return "gs://" + b + "/" + strings.TrimPrefix(k, "/") + } + } + s3, s3Found, s3Err := unstructured.NestedMap(am, "s3") + if s3Err == nil && s3Found && len(s3) > 0 && defaultBucket != "" { + k, _, _ := unstructured.NestedString(s3, "key") + if k != "" { + return "gs://" + defaultBucket + "/" + strings.TrimPrefix(k, "/") + } + } + return "" +} diff --git a/terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/internal/workflow/artifacts_match.go b/terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/internal/workflow/artifacts_match.go new file mode 100644 index 00000000..3c01b8a7 --- /dev/null +++ b/terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/internal/workflow/artifacts_match.go @@ -0,0 +1,64 @@ +// Copyright (c) 2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +package workflow + +import ( + "fmt" + "strings" +) + +// AmbiguousArtifactsError is returned when several output artifacts share the same name +// and the caller did not narrow with nodeId or templateName (or filters still match more than one). +type AmbiguousArtifactsError struct { + ArtifactName string + Candidates []OutputArtifact +} + +func (e *AmbiguousArtifactsError) Error() string { + return fmt.Sprintf("ambiguous: %d artifacts named %q; pass nodeId or templateName", len(e.Candidates), e.ArtifactName) +} + +// MatchOutputArtifacts returns artifacts whose Name equals artifactName. +// If nodeID is non-empty, only artifacts with that NodeID are considered. +// If templateName is non-empty, only artifacts with that TemplateName are considered. +// When both are set, both must match. Returns *AmbiguousArtifactsError when multiple matches +// remain after filters (or when none are set and multiple artifacts share the name). +func MatchOutputArtifacts(arts []OutputArtifact, artifactName, nodeID, templateName string) ([]OutputArtifact, error) { + wantNode := strings.TrimSpace(nodeID) + wantTpl := strings.TrimSpace(templateName) + var matches []OutputArtifact + for _, a := range arts { + if a.Name != artifactName { + continue + } + if wantNode != "" && a.NodeID != wantNode { + continue + } + if wantTpl != "" && a.TemplateName != wantTpl { + continue + } + matches = append(matches, a) + } + if len(matches) == 0 { + if wantNode != "" || wantTpl != "" { + return nil, fmt.Errorf("no artifact named %q matching filters", artifactName) + } + return nil, fmt.Errorf("no artifact named %q", artifactName) + } + if len(matches) > 1 { + return nil, &AmbiguousArtifactsError{ArtifactName: artifactName, Candidates: matches} + } + return matches, nil +} diff --git a/terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/internal/workflow/artifacts_match_test.go b/terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/internal/workflow/artifacts_match_test.go new file mode 100644 index 00000000..6046d313 --- /dev/null +++ b/terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/internal/workflow/artifacts_match_test.go @@ -0,0 +1,49 @@ +// Copyright (c) 2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +package workflow + +import ( + "errors" + "testing" +) + +func TestMatchOutputArtifacts(t *testing.T) { + arts := []OutputArtifact{ + {NodeID: "n1", Name: "a", TemplateName: "t1", GcsURI: "gs://b/o1"}, + {NodeID: "n2", Name: "a", TemplateName: "t2", GcsURI: "gs://b/o2"}, + {NodeID: "n3", Name: "b", GcsURI: "gs://b/o3"}, + } + _, err := MatchOutputArtifacts(arts, "a", "", "") + var amb *AmbiguousArtifactsError + if !errors.As(err, &amb) || len(amb.Candidates) != 2 { + t.Fatalf("expected ambiguous, got %v", err) + } + got, err := MatchOutputArtifacts(arts, "a", "n2", "") + if err != nil || len(got) != 1 || got[0].GcsURI != "gs://b/o2" { + t.Fatalf("got %v %v", got, err) + } + got, err = MatchOutputArtifacts(arts, "a", "", "t2") + if err != nil || len(got) != 1 || got[0].NodeID != "n2" { + t.Fatalf("templateName filter: got %v %v", got, err) + } + _, err = MatchOutputArtifacts(arts, "a", "n1", "t2") + if err == nil { + t.Fatal("expected no match for conflicting nodeId+templateName") + } + _, err = MatchOutputArtifacts(arts, "missing", "", "") + if err == nil { + t.Fatal("expected no match") + } +} diff --git a/terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/internal/workflow/parse.go b/terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/internal/workflow/parse.go new file mode 100644 index 00000000..2a2ff393 --- /dev/null +++ b/terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/internal/workflow/parse.go @@ -0,0 +1,321 @@ +// Copyright (c) 2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +package workflow + +import ( + "sort" + "strings" + "time" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +// StartedBy returns the Horizon portal subject (sensor annotation), else Argo UI creator labels when set. +func StartedBy(u *unstructured.Unstructured) string { + if u == nil { + return "" + } + if ann := u.GetAnnotations(); ann != nil { + if v := strings.TrimSpace(ann["horizon-sdv.io/submitted-by"]); v != "" { + return v + } + } + if labels := u.GetLabels(); labels != nil { + for _, k := range []string{ + "workflows.argoproj.io/creator", + "workflows.argoproj.io/creator-email", + "workflows.argoproj.io/creator-preferred-username", + } { + if v := strings.TrimSpace(labels[k]); v != "" { + return v + } + } + } + return "" +} + +// TerminalPhase reports whether the workflow phase is finished. +func TerminalPhase(phase string) bool { + switch strings.TrimSpace(phase) { + case "Succeeded", "Failed", "Error": + return true + default: + return false + } +} + +// Phase returns status.phase (Argo's raw value). +func Phase(u *unstructured.Unstructured) string { + p, _, _ := unstructured.NestedString(u.Object, "status", "phase") + return p +} + +// DisplayPhaseForAPI returns the phase shown in Horizon JSON APIs. When Argo stops a workflow via +// spec.shutdown (Stop/Terminate), status.phase is often still "Failed"; we surface that as Aborted. +// Some Argo versions use phase "Stopped" — also mapped to Aborted for a stable contract. +func DisplayPhaseForAPI(u *unstructured.Unstructured) string { + p := strings.TrimSpace(Phase(u)) + if strings.EqualFold(p, "Stopped") { + return "Aborted" + } + if shutdownAbortDetected(u) { + return "Aborted" + } + return p +} + +func shutdownAbortDetected(u *unstructured.Unstructured) bool { + if !TerminalPhase(Phase(u)) { + return false + } + shut, _, _ := unstructured.NestedString(u.Object, "spec", "shutdown") + switch strings.TrimSpace(shut) { + case "Stop", "Terminate": + return true + } + msg, _, _ := unstructured.NestedString(u.Object, "status", "message") + lm := strings.ToLower(msg) + // Argo: "Stopped with strategy 'Stop': ..." / Terminate + if strings.Contains(lm, "stopped with strategy") { + return true + } + if strings.Contains(lm, "workflow shutdown with strategy") { + return true + } + return false +} + +// Summary builds a WorkflowSummary from an unstructured Workflow. +func Summary(u *unstructured.Unstructured, ns, defaultBucket string) WorkflowSummary { + return summaryWithArchivePods(u, ns, defaultBucket, nil) +} + +func summaryWithArchivePods(u *unstructured.Unstructured, ns, defaultBucket string, k8sPodNames []string) WorkflowSummary { + name := u.GetName() + started, _, _ := unstructured.NestedString(u.Object, "status", "startedAt") + finished, _, _ := unstructured.NestedString(u.Object, "status", "finishedAt") + msg, _, _ := unstructured.NestedString(u.Object, "status", "message") + tplRef, _, _ := unstructured.NestedString(u.Object, "spec", "workflowTemplateRef", "name") + if tplRef == "" { + tplRef, _, _ = unstructured.NestedString(u.Object, "spec", "workflowRef", "name") + } + if tplRef == "" { + tplRef, _, _ = unstructured.NestedString(u.Object, "spec", "clusterWorkflowTemplateRef", "name") + } + s := WorkflowSummary{ + Name: name, + Namespace: ns, + Module: ModuleLabelValue(u), + Phase: DisplayPhaseForAPI(u), + StartedAt: started, + FinishedAt: finished, + WorkflowTemplate: tplRef, + StartedBy: StartedBy(u), + SubmittedFrom: SubmittedFromLabelValue(u), + Message: msg, + } + // Expose archived log URIs whenever they appear in status (including running workflows as steps finish). + // Previously we only set this after terminal phase, which hid GCS / combined links until completion. + al := BuildArchivedLogLinks(u, ns, defaultBucket, k8sPodNames) + if al != nil && (al.Combined != nil && al.Combined.GcsURI != "" || len(al.Steps) > 0) { + s.ArchivedLogs = al + } + return s +} + +// Detail builds WorkflowDetail. Pass k8sPodNames from label workflows.argoproj.io/workflow when available so empty status.podName is back-filled in nodes and archivedLogs. +func Detail(u *unstructured.Unstructured, ns, defaultBucket string, k8sPodNames []string) WorkflowDetail { + sum := summaryWithArchivePods(u, ns, defaultBucket, k8sPodNames) + d := WorkflowDetail{WorkflowSummary: sum, UID: string(u.GetUID())} + nodes, _, _ := unstructured.NestedMap(u.Object, "status", "nodes") + parentOf := buildNodeParentMap(nodes) + dependentTplSet := make(map[string]bool) + rootTpl := strings.TrimSpace(sum.WorkflowTemplate) + rootModule := strings.TrimSpace(sum.Module) + for id, raw := range nodes { + m, ok := raw.(map[string]interface{}) + if !ok { + continue + } + typ, _, _ := unstructured.NestedString(m, "type") + disp, _, _ := unstructured.NestedString(m, "displayName") + tpl, _, _ := unstructured.NestedString(m, "templateName") + originTpl := effectiveWorkflowTemplateForNode(id, m, nodes, parentOf, rootTpl) + if originTpl != "" && originTpl != rootTpl { + dependentTplSet[originTpl] = true + } + ph, _, _ := unstructured.NestedString(m, "phase") + pod, _, _ := unstructured.NestedString(m, "podName") + if pod == "" && len(k8sPodNames) > 0 && typ == "Pod" { + pod = matchPodNameForNodeID(id, k8sPodNames) + } + nStarted, _, _ := unstructured.NestedString(m, "startedAt") + d.Nodes = append(d.Nodes, NodeBrief{ + ID: id, + DisplayName: disp, + Module: rootModule, + TemplateName: tpl, + WorkflowTemplate: originTpl, + Type: typ, + Phase: ph, + PodName: pod, + StartedAt: nStarted, + }) + } + sort.SliceStable(d.Nodes, func(i, j int) bool { + ti := nodeStartedAt(nodes, d.Nodes[i].ID) + tj := nodeStartedAt(nodes, d.Nodes[j].ID) + if ti.Equal(tj) || (ti.IsZero() && tj.IsZero()) { + return d.Nodes[i].ID < d.Nodes[j].ID + } + if ti.IsZero() { + return false + } + if tj.IsZero() { + return true + } + return ti.Before(tj) + }) + if len(dependentTplSet) > 0 { + d.DependentWorkflowTemplates = make([]DependentWorkflowTemplate, 0, len(dependentTplSet)) + for name := range dependentTplSet { + d.DependentWorkflowTemplates = append(d.DependentWorkflowTemplates, DependentWorkflowTemplate{ + Template: name, + Module: rootModule, + }) + } + sort.SliceStable(d.DependentWorkflowTemplates, func(i, j int) bool { + return d.DependentWorkflowTemplates[i].Template < d.DependentWorkflowTemplates[j].Template + }) + } + if oa := BuildOutputArtifacts(u, defaultBucket, rootTpl, rootModule); len(oa) > 0 { + d.OutputArtifacts = oa + } + return d +} + +// PodLogTargets returns pod targets suitable for multiplexed log streaming (Pod-type nodes with a pod name). +func PodLogTargets(u *unstructured.Unstructured) []PodLogTarget { + return PodLogTargetsFromRunningPods(u, nil) +} + +// PodLogTargetsFromRunningPods returns Pod-type log targets. +// When status leaves podName empty (common in some Argo versions), pass k8s Pod names listed with +// label workflows.argoproj.io/workflow=; targets are matched by the node's numeric suffix +// (node id …-3141758486 ↔ pod …-echo-and-artifact-3141758486). +// SortPodLogTargetsByStartedAt orders pod log targets by status.nodes[id].startedAt (workflow order). +// Nodes without a parseable time sort after those with times; ties break on pod name. +func SortPodLogTargetsByStartedAt(u *unstructured.Unstructured, targets []PodLogTarget) { + nodes, _, _ := unstructured.NestedMap(u.Object, "status", "nodes") + sort.SliceStable(targets, func(i, j int) bool { + ti := nodeStartedAt(nodes, targets[i].NodeID) + tj := nodeStartedAt(nodes, targets[j].NodeID) + if ti.IsZero() && tj.IsZero() { + return targets[i].PodName < targets[j].PodName + } + if ti.IsZero() { + return false + } + if tj.IsZero() { + return true + } + if !ti.Equal(tj) { + return ti.Before(tj) + } + return targets[i].PodName < targets[j].PodName + }) +} + +func nodeStartedAt(nodes map[string]interface{}, nodeID string) time.Time { + raw, ok := nodes[nodeID] + if !ok { + return time.Time{} + } + m, ok := raw.(map[string]interface{}) + if !ok { + return time.Time{} + } + s, _, _ := unstructured.NestedString(m, "startedAt") + if s == "" { + return time.Time{} + } + t, err := time.Parse(time.RFC3339, s) + if err != nil { + return time.Time{} + } + return t +} + +func PodLogTargetsFromRunningPods(u *unstructured.Unstructured, k8sPodNames []string) []PodLogTarget { + nodes, _, _ := unstructured.NestedMap(u.Object, "status", "nodes") + var out []PodLogTarget + for id, raw := range nodes { + m, ok := raw.(map[string]interface{}) + if !ok { + continue + } + typ, _, _ := unstructured.NestedString(m, "type") + if typ != "Pod" { + continue + } + pod, _, _ := unstructured.NestedString(m, "podName") + if pod == "" && len(k8sPodNames) > 0 { + pod = matchPodNameForNodeID(id, k8sPodNames) + } + if pod == "" { + continue + } + disp, _, _ := unstructured.NestedString(m, "displayName") + tpl, _, _ := unstructured.NestedString(m, "templateName") + out = append(out, PodLogTarget{ + PodName: pod, + NodeID: id, + DisplayName: disp, + TemplateName: tpl, + }) + } + return out +} + +func matchPodNameForNodeID(nodeID string, k8sPodNames []string) string { + suf := nodeIDNumericSuffix(nodeID) + if suf == "" { + return "" + } + want := "-" + suf + for _, name := range k8sPodNames { + if strings.HasSuffix(name, want) { + return name + } + } + return "" +} + +func nodeIDNumericSuffix(nodeID string) string { + i := strings.LastIndex(nodeID, "-") + if i < 0 { + return "" + } + s := nodeID[i+1:] + if s == "" { + return "" + } + for _, r := range s { + if r < '0' || r > '9' { + return "" + } + } + return s +} diff --git a/terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/internal/workflow/provenance.go b/terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/internal/workflow/provenance.go new file mode 100644 index 00000000..30f68ed2 --- /dev/null +++ b/terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/internal/workflow/provenance.go @@ -0,0 +1,110 @@ +// Copyright (c) 2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +package workflow + +import ( + "strings" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +// workflowTemplateFromTemplateScope extracts workflow template name from Argo node templateScope. +// Examples: +// - namespaced/workflowtemplate/sample-soft-smoke-test -> sample-soft-smoke-test +// - clusterworkflowtemplate/my-cwt -> my-cwt +func workflowTemplateFromTemplateScope(scope string) string { + s := strings.TrimSpace(scope) + if s == "" { + return "" + } + parts := strings.Split(s, "/") + if len(parts) < 2 { + return "" + } + kindIdx := len(parts) - 2 + kind := strings.ToLower(strings.TrimSpace(parts[kindIdx])) + if kind != "workflowtemplate" && kind != "clusterworkflowtemplate" { + return "" + } + return strings.TrimSpace(parts[len(parts)-1]) +} + +// workflowTemplateResourceNameFromNode returns the WorkflowTemplate (or ClusterWorkflowTemplate) +// resource name for module provenance: Argo sets templateRef.name on nodes that invoke another +// template; inner Pods often omit both templateRef and templateScope on ancestors only have templateRef. +func workflowTemplateResourceNameFromNode(m map[string]interface{}) string { + if n, _, _ := unstructured.NestedString(m, "templateRef", "name"); strings.TrimSpace(n) != "" { + return strings.TrimSpace(n) + } + scopeStr, _, _ := unstructured.NestedString(m, "templateScope") + return workflowTemplateFromTemplateScope(scopeStr) +} + +// buildNodeParentMap maps each child node id to its parent id using status.nodes[*].children. +func buildNodeParentMap(nodes map[string]interface{}) map[string]string { + parentOf := make(map[string]string) + for parentID, raw := range nodes { + mm, ok := raw.(map[string]interface{}) + if !ok { + continue + } + children, _, _ := unstructured.NestedStringSlice(mm, "children") + for _, cid := range children { + if strings.TrimSpace(cid) != "" { + parentOf[cid] = parentID + } + } + } + return parentOf +} + +// effectiveWorkflowTemplateForNode returns the WorkflowTemplate name for provenance when a node's own +// templateScope/templateRef is empty — common for Pod steps inside a nested templateRef DAG. We read +// templateRef.name and templateScope on self, then ancestors (closest first), then the boundary node, +// so module labels are not all attributed to the root workflow template (e.g. sample-smoke-test vs sample-soft-smoke-test). +func effectiveWorkflowTemplateForNode( + nodeID string, + self map[string]interface{}, + nodes map[string]interface{}, + parentOf map[string]string, + rootWorkflowTemplate string, +) string { + if name := workflowTemplateResourceNameFromNode(self); name != "" { + return name + } + for cur := parentOf[nodeID]; cur != ""; cur = parentOf[cur] { + raw, ok := nodes[cur] + if !ok { + break + } + mm, ok := raw.(map[string]interface{}) + if !ok { + break + } + if name := workflowTemplateResourceNameFromNode(mm); name != "" { + return name + } + } + if bid, _, _ := unstructured.NestedString(self, "boundaryID"); bid != "" && bid != nodeID { + if raw, ok := nodes[bid]; ok { + if bm, ok := raw.(map[string]interface{}); ok { + if name := workflowTemplateResourceNameFromNode(bm); name != "" { + return name + } + } + } + } + return strings.TrimSpace(rootWorkflowTemplate) +} diff --git a/terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/internal/workflow/provenance_test.go b/terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/internal/workflow/provenance_test.go new file mode 100644 index 00000000..c225bb5b --- /dev/null +++ b/terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/internal/workflow/provenance_test.go @@ -0,0 +1,169 @@ +// Copyright (c) 2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +package workflow + +import ( + "testing" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +func TestBuildNodeParentMap(t *testing.T) { + t.Parallel() + nodes := map[string]interface{}{ + "root": map[string]interface{}{ + "children": []interface{}{"mid"}, + }, + "mid": map[string]interface{}{ + "children": []interface{}{"leaf"}, + }, + "leaf": map[string]interface{}{}, + } + p := buildNodeParentMap(nodes) + if p["mid"] != "root" || p["leaf"] != "mid" { + t.Fatalf("parent map: %#v", p) + } +} + +func TestEffectiveWorkflowTemplateForNode_inheritsFromAncestor(t *testing.T) { + t.Parallel() + rootTpl := "sample-smoke-test" + nodes := map[string]interface{}{ + "boundary-1": map[string]interface{}{ + "templateScope": "workflows/workflowtemplate/sample-soft-smoke-test", + "children": []interface{}{"inner-pod"}, + }, + "inner-pod": map[string]interface{}{ + "type": "Pod", + // Many Argo versions omit templateScope on inner Pod nodes under nested templateRef. + }, + } + parentOf := buildNodeParentMap(nodes) + pod, _ := nodes["inner-pod"].(map[string]interface{}) + got := effectiveWorkflowTemplateForNode("inner-pod", pod, nodes, parentOf, rootTpl) + if got != "sample-soft-smoke-test" { + t.Fatalf("got %q want sample-soft-smoke-test", got) + } +} + +func TestEffectiveWorkflowTemplateForNode_inheritsTemplateRefFromAncestor(t *testing.T) { + t.Parallel() + rootTpl := "sample-smoke-test" + nodes := map[string]interface{}{ + "invoke-step": map[string]interface{}{ + "type": "Steps", + "templateRef": map[string]interface{}{ + "name": "sample-soft-smoke-test", + "template": "run-smoke-test", + }, + "children": []interface{}{"inner-pod"}, + }, + "inner-pod": map[string]interface{}{ + "type": "Pod", + }, + } + parentOf := buildNodeParentMap(nodes) + pod, _ := nodes["inner-pod"].(map[string]interface{}) + got := effectiveWorkflowTemplateForNode("inner-pod", pod, nodes, parentOf, rootTpl) + if got != "sample-soft-smoke-test" { + t.Fatalf("got %q want sample-soft-smoke-test", got) + } +} + +func TestEffectiveWorkflowTemplateForNode_selfTemplateRefPrecedence(t *testing.T) { + t.Parallel() + nodes := map[string]interface{}{ + "n": map[string]interface{}{ + "templateRef": map[string]interface{}{ + "name": "external-wt", + "template": "main", + }, + "templateScope": "ns/workflowtemplate/other-wt", + }, + } + parentOf := buildNodeParentMap(nodes) + nm, _ := nodes["n"].(map[string]interface{}) + got := effectiveWorkflowTemplateForNode("n", nm, nodes, parentOf, "root-wt") + if got != "external-wt" { + t.Fatalf("templateRef.name should win; got %q", got) + } +} + +func TestEffectiveWorkflowTemplateForNode_boundaryFallback(t *testing.T) { + t.Parallel() + rootTpl := "sample-smoke-test" + nodes := map[string]interface{}{ + "boundary-root": map[string]interface{}{ + "templateRef": map[string]interface{}{ + "name": "sample-soft-smoke-test", + "template": "run-smoke-test", + }, + }, + "leaf": map[string]interface{}{ + "type": "Pod", + "boundaryID": "boundary-root", + "children": nil, + }, + } + parentOf := buildNodeParentMap(nodes) + leaf, _ := nodes["leaf"].(map[string]interface{}) + got := effectiveWorkflowTemplateForNode("leaf", leaf, nodes, parentOf, rootTpl) + if got != "sample-soft-smoke-test" { + t.Fatalf("boundary fallback: got %q want sample-soft-smoke-test", got) + } +} + +func TestEffectiveWorkflowTemplateForNode_ownScopeWins(t *testing.T) { + t.Parallel() + nodes := map[string]interface{}{ + "p": map[string]interface{}{ + "children": []interface{}{"c"}, + }, + "c": map[string]interface{}{ + "templateScope": "ns/workflowtemplate/other-wt", + }, + } + parentOf := buildNodeParentMap(nodes) + cm, _ := nodes["c"].(map[string]interface{}) + got := effectiveWorkflowTemplateForNode("c", cm, nodes, parentOf, "root-wt") + if got != "other-wt" { + t.Fatalf("got %q", got) + } +} + +func TestWorkflowTemplateFromTemplateScope(t *testing.T) { + t.Parallel() + cases := map[string]string{ + "workflows/workflowtemplate/sample-soft-smoke-test": "sample-soft-smoke-test", + "clusterworkflowtemplate/foo": "foo", + "": "", + } + for in, want := range cases { + if got := workflowTemplateFromTemplateScope(in); got != want { + t.Fatalf("%q: got %q want %q", in, got, want) + } + } +} + +func TestNestedStringSlice_children(t *testing.T) { + t.Parallel() + m := map[string]interface{}{ + "children": []interface{}{"a", "b"}, + } + sl, found, err := unstructured.NestedStringSlice(m, "children") + if err != nil || !found || len(sl) != 2 { + t.Fatalf("NestedStringSlice: %v found=%v err=%v", sl, found, err) + } +} diff --git a/terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/internal/workflow/store.go b/terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/internal/workflow/store.go new file mode 100644 index 00000000..e9bffaa8 --- /dev/null +++ b/terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/internal/workflow/store.go @@ -0,0 +1,170 @@ +// Copyright (c) 2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +package workflow + +import ( + "context" + "fmt" + "time" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/kubernetes" + corev1client "k8s.io/client-go/kubernetes/typed/core/v1" + "k8s.io/client-go/rest" +) + +// GVR is the Kubernetes API resource for Argo Workflows. +var GVR = schema.GroupVersionResource{Group: "argoproj.io", Version: "v1alpha1", Resource: "workflows"} +var workflowTemplateGVR = schema.GroupVersionResource{Group: "argoproj.io", Version: "v1alpha1", Resource: "workflowtemplates"} +var clusterWorkflowTemplateGVR = schema.GroupVersionResource{Group: "argoproj.io", Version: "v1alpha1", Resource: "clusterworkflowtemplates"} + +// Store performs Workflow CR operations in a single namespace. +type Store struct { + ri dynamic.ResourceInterface + wtRi dynamic.ResourceInterface + cwtRi dynamic.NamespaceableResourceInterface + pods corev1client.PodInterface +} + +// NewStore builds a dynamic client scoped to namespace ns. +func NewStore(cfg *rest.Config, ns string) (*Store, error) { + d, err := dynamic.NewForConfig(cfg) + if err != nil { + return nil, err + } + cs, err := kubernetes.NewForConfig(cfg) + if err != nil { + return nil, err + } + return &Store{ + ri: d.Resource(GVR).Namespace(ns), + wtRi: d.Resource(workflowTemplateGVR).Namespace(ns), + cwtRi: d.Resource(clusterWorkflowTemplateGVR), + pods: cs.CoreV1().Pods(ns), + }, nil +} + +// ResolveWorkflowTemplateModules resolves horizon-sdv.io/module for each template name. +// It tries namespaced WorkflowTemplate first, then ClusterWorkflowTemplate. +func (s *Store) ResolveWorkflowTemplateModules(ctx context.Context, templateNames []string) map[string]string { + out := make(map[string]string, len(templateNames)) + for _, name := range templateNames { + if name == "" { + continue + } + if _, seen := out[name]; seen { + continue + } + if s.wtRi != nil { + if u, err := s.wtRi.Get(ctx, name, metav1.GetOptions{}); err == nil { + out[name] = ModuleLabelValue(u) + continue + } else if err != nil && !apierrors.IsNotFound(err) { + out[name] = "" + continue + } + } + if s.cwtRi != nil { + if u, err := s.cwtRi.Get(ctx, name, metav1.GetOptions{}); err == nil { + out[name] = ModuleLabelValue(u) + continue + } + } + out[name] = "" + } + return out +} + +// ListPodNamesForWorkflow returns Pod object names for workflow pods (Argo sets this label on step pods). +func (s *Store) ListPodNamesForWorkflow(ctx context.Context, workflowName string) ([]string, error) { + if s.pods == nil { + return nil, nil + } + list, err := s.pods.List(ctx, metav1.ListOptions{ + LabelSelector: "workflows.argoproj.io/workflow=" + workflowName, + }) + if err != nil { + return nil, err + } + out := make([]string, 0, len(list.Items)) + for i := range list.Items { + out = append(out, list.Items[i].Name) + } + return out, nil +} + +// Get returns a Workflow by name. +func (s *Store) Get(ctx context.Context, name string) (*unstructured.Unstructured, error) { + return s.ri.Get(ctx, name, metav1.GetOptions{}) +} + +// List lists workflows with optional limit and continue token. +func (s *Store) List(ctx context.Context, limit int64, continueToken string) (*unstructured.UnstructuredList, error) { + opts := metav1.ListOptions{ + LabelSelector: HorizonWorkflowListLabelSelector(), + } + if limit > 0 { + opts.Limit = limit + } + if continueToken != "" { + opts.Continue = continueToken + } + return s.ri.List(ctx, opts) +} + +// PatchShutdown sets spec.shutdown to Stop (graceful stop for running workflows). +func (s *Store) PatchShutdown(ctx context.Context, name string) error { + patch := []byte(`{"spec":{"shutdown":"Stop"}}`) + _, err := s.ri.Patch(ctx, name, types.MergePatchType, patch, metav1.PatchOptions{}) + if err != nil { + return fmt.Errorf("patch workflow shutdown: %w", err) + } + return nil +} + +// Delete removes the Workflow CR by name. +func (s *Store) Delete(ctx context.Context, name string) error { + err := s.ri.Delete(ctx, name, metav1.DeleteOptions{}) + if err != nil { + return fmt.Errorf("delete workflow: %w", err) + } + return nil +} + +// WaitUntilWorkflowDeleted polls Get until the object returns NotFound (finalizers cleared and CR removed). +func (s *Store) WaitUntilWorkflowDeleted(ctx context.Context, name string, pollInterval time.Duration) error { + if pollInterval < 100*time.Millisecond { + pollInterval = 500 * time.Millisecond + } + for { + _, err := s.ri.Get(ctx, name, metav1.GetOptions{}) + if apierrors.IsNotFound(err) { + return nil + } + if err != nil { + return fmt.Errorf("wait workflow deleted: %w", err) + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(pollInterval): + } + } +} diff --git a/terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/internal/workflow/summary_test.go b/terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/internal/workflow/summary_test.go new file mode 100644 index 00000000..84d43593 --- /dev/null +++ b/terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/internal/workflow/summary_test.go @@ -0,0 +1,39 @@ +// Copyright (c) 2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +package workflow + +import ( + "testing" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +func TestSummaryWorkflowTemplateRef(t *testing.T) { + u := &unstructured.Unstructured{Object: map[string]interface{}{}} + _ = unstructured.SetNestedField(u.Object, "my-wft", "spec", "workflowTemplateRef", "name") + s := Summary(u, "workflows", "") + if s.WorkflowTemplate != "my-wft" { + t.Fatalf("workflowTemplateRef: got %q", s.WorkflowTemplate) + } +} + +func TestSummaryClusterWorkflowTemplateRef(t *testing.T) { + u := &unstructured.Unstructured{Object: map[string]interface{}{}} + _ = unstructured.SetNestedField(u.Object, "my-cwft", "spec", "clusterWorkflowTemplateRef", "name") + s := Summary(u, "workflows", "") + if s.WorkflowTemplate != "my-cwft" { + t.Fatalf("clusterWorkflowTemplateRef: got %q", s.WorkflowTemplate) + } +} diff --git a/terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/internal/workflow/types.go b/terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/internal/workflow/types.go new file mode 100644 index 00000000..0b7f7c66 --- /dev/null +++ b/terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/internal/workflow/types.go @@ -0,0 +1,110 @@ +// Copyright (c) 2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +package workflow + +// Retention describes cluster Workflow TTL (mirrors Argo workflowDefaults.ttlStrategy). +type Retention struct { + SecondsAfterSuccess int `json:"secondsAfterSuccess"` + SecondsAfterFailure int `json:"secondsAfterFailure"` + SecondsAfterCompletion int `json:"secondsAfterCompletion"` + Explanation string `json:"explanation"` +} + +// LogURIRef holds a single GCS URI for archived logs. +type LogURIRef struct { + GcsURI string `json:"gcsUri,omitempty"` +} + +// StepLogLink is one step/node archived log location. +type StepLogLink struct { + NodeID string `json:"nodeId"` + DisplayName string `json:"displayName,omitempty"` + TemplateName string `json:"templateName,omitempty"` + Phase string `json:"phase,omitempty"` + PodName string `json:"podName,omitempty"` + GcsURI string `json:"gcsUri,omitempty"` + // ArtifactName is the Workflow status outputs.artifacts name (e.g. main-logs) for GET .../downloadArtifact. + ArtifactName string `json:"artifactName,omitempty"` +} + +// ArchivedLogLinks combines workflow-level and per-step archived log URIs (gs://), when present in status. +type ArchivedLogLinks struct { + Combined *LogURIRef `json:"combined,omitempty"` + Steps []StepLogLink `json:"steps,omitempty"` +} + +// PodLogTarget identifies a pod for Argo log streaming with optional display metadata. +type PodLogTarget struct { + PodName string + NodeID string + DisplayName string + TemplateName string +} + +// WorkflowSummary is a compact workflow row for list endpoints. +type WorkflowSummary struct { + Name string `json:"name"` + Namespace string `json:"namespace"` + Module string `json:"module,omitempty"` + Phase string `json:"phase"` + StartedAt string `json:"startedAt,omitempty"` + FinishedAt string `json:"finishedAt,omitempty"` + WorkflowTemplate string `json:"workflowTemplate,omitempty"` + StartedBy string `json:"startedBy,omitempty"` + SubmittedFrom string `json:"submittedFrom,omitempty"` + Message string `json:"message,omitempty"` + ArchivedLogs *ArchivedLogLinks `json:"archivedLogs,omitempty"` +} + +// OutputArtifact is a workflow output artifact with a resolvable GCS URI (when present in status). +type OutputArtifact struct { + NodeID string `json:"nodeId,omitempty"` + Name string `json:"name"` + FileName string `json:"fileName,omitempty"` + DisplayName string `json:"displayName,omitempty"` + Module string `json:"module,omitempty"` + TemplateName string `json:"templateName,omitempty"` + // WorkflowTemplate is the originating WorkflowTemplate for this artifact-producing node. + WorkflowTemplate string `json:"workflowTemplate,omitempty"` + GcsURI string `json:"gcsUri,omitempty"` +} + +type DependentWorkflowTemplate struct { + Template string `json:"template"` + Module string `json:"module,omitempty"` +} + +// WorkflowDetail extends summary with node overview. +type WorkflowDetail struct { + WorkflowSummary + UID string `json:"uid,omitempty"` + DependentWorkflowTemplates []DependentWorkflowTemplate `json:"dependentWorkflowTemplates,omitempty"` + Nodes []NodeBrief `json:"nodes,omitempty"` + OutputArtifacts []OutputArtifact `json:"outputArtifacts,omitempty"` +} + +// NodeBrief is a minimal node summary for status responses. +type NodeBrief struct { + ID string `json:"id"` + DisplayName string `json:"displayName,omitempty"` + Module string `json:"module,omitempty"` + TemplateName string `json:"templateName,omitempty"` + // WorkflowTemplate is the originating WorkflowTemplate for this node. + WorkflowTemplate string `json:"workflowTemplate,omitempty"` + Type string `json:"type,omitempty"` + Phase string `json:"phase,omitempty"` + PodName string `json:"podName,omitempty"` + StartedAt string `json:"startedAt,omitempty"` +} diff --git a/terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/internal/workflow/visibility.go b/terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/internal/workflow/visibility.go new file mode 100644 index 00000000..042d2be6 --- /dev/null +++ b/terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/internal/workflow/visibility.go @@ -0,0 +1,148 @@ +// Copyright (c) 2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +package workflow + +import ( + "fmt" + "net/http" + "strings" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/selection" + "k8s.io/apimachinery/pkg/util/validation" +) + +// LabelSubmittedFrom is set on Workflow metadata when the run is dispatched via Horizon (sensor maps webhook body). +// Values match Argo-style lowercase tokens (compare workflows.argoproj.io/submit-from-ui). +const LabelSubmittedFrom = "horizon-sdv.io/submitted-from" +const LabelModule = "horizon-sdv.io/module" + +const ( + SubmittedFromAPI = "api" + SubmittedFromDeveloperPortal = "developer-portal" + SubmittedFromHorizonCLI = "horizon-cli" + SubmitParamSubmittedFrom = "horizonSubmittedFrom" + // SubmitParamSampleSoftEnabled is set only by Horizon API (runtime injection), not by clients. + SubmitParamSampleSoftEnabled = "sampleSoftEnabled" + // SubmittedFromCustomToken is a reserved header value: the real id is read from HeaderSubmittedFromDetail. + SubmittedFromCustomToken = "custom" + // HeaderSubmittedFromDetail carries the integration id when X-Horizon-Submitted-From is "custom". + HeaderSubmittedFromDetail = "X-Horizon-Submitted-From-Detail" +) + +// IsHorizonInjectedWorkflowParameter is true for workflow parameters that must not appear in the +// Horizon catalog or OpenAPI submit schema; the API injects them when dispatching to Argo Events. +func IsHorizonInjectedWorkflowParameter(name string) bool { + return name == SubmitParamSampleSoftEnabled +} + +var allowedSubmittedFrom = map[string]bool{ + SubmittedFromAPI: true, + SubmittedFromDeveloperPortal: true, + SubmittedFromHorizonCLI: true, +} + +var horizonWorkflowListLabelSelector string + +func init() { + req, err := labels.NewRequirement(LabelSubmittedFrom, selection.Exists, nil) + if err != nil { + panic("workflow.HorizonWorkflowListLabelSelector: " + err.Error()) + } + horizonWorkflowListLabelSelector = labels.NewSelector().Add(*req).String() +} + +// HorizonWorkflowListLabelSelector limits workflow List to runs that carry horizon-sdv.io/submitted-from +// (Horizon-dispatched), including built-in and custom integration ids. +func HorizonWorkflowListLabelSelector() string { + return horizonWorkflowListLabelSelector +} + +// SubmittedFromLabelValue returns the workflow label value (may be empty). +func SubmittedFromLabelValue(u *unstructured.Unstructured) string { + if u == nil { + return "" + } + labels := u.GetLabels() + if labels == nil { + return "" + } + return strings.TrimSpace(labels[LabelSubmittedFrom]) +} + +// ModuleLabelValue returns horizon-sdv.io/module label value (may be empty). +func ModuleLabelValue(u *unstructured.Unstructured) string { + if u == nil { + return "" + } + labels := u.GetLabels() + if labels == nil { + return "" + } + return strings.TrimSpace(labels[LabelModule]) +} + +// IsHorizonClientVisible reports whether the workflow may be listed or read via Horizon API / portal / CLI. +func IsHorizonClientVisible(u *unstructured.Unstructured) bool { + v := SubmittedFromLabelValue(u) + if allowedSubmittedFrom[v] { + return true + } + return v != "" && len(validation.IsValidLabelValue(v)) == 0 +} + +// ParseSubmittedFromHeader reads X-Horizon-Submitted-From; missing header means generic REST (api). +// If the header is "custom" (case-insensitive), the integration id is taken from HeaderSubmittedFromDetail. +func ParseSubmittedFromHeader(r *http.Request) (string, error) { + raw := strings.TrimSpace(r.Header.Get("X-Horizon-Submitted-From")) + if raw == "" { + return SubmittedFromAPI, nil + } + if strings.EqualFold(raw, SubmittedFromCustomToken) { + detail := strings.TrimSpace(r.Header.Get(HeaderSubmittedFromDetail)) + if detail == "" { + return "", fmt.Errorf("%s is %q: set non-empty %s to the integration id (Kubernetes label rules)", + "X-Horizon-Submitted-From", SubmittedFromCustomToken, HeaderSubmittedFromDetail) + } + return ParseSubmittedFromValue(detail) + } + return ParseSubmittedFromValue(raw) +} + +// ParseSubmittedFromValue validates and normalizes submitted-from values to canonical built-in ids, +// or returns any other value that is a valid Kubernetes label value (custom integration id). +func ParseSubmittedFromValue(raw string) (string, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", fmt.Errorf("submitted-from value cannot be empty") + } + k := strings.ToLower(raw) + switch k { + case SubmittedFromAPI, "rest", "rest-api": + return SubmittedFromAPI, nil + case SubmittedFromDeveloperPortal, "developer_portal", "portal": + return SubmittedFromDeveloperPortal, nil + case SubmittedFromHorizonCLI, "horizon_cli", "cli": + return SubmittedFromHorizonCLI, nil + default: + if errs := validation.IsValidLabelValue(raw); len(errs) == 0 { + return raw, nil + } + return "", fmt.Errorf( + "invalid submitted-from value %q: use built-in %q, %q, or %q, header %q with %q, or any valid Kubernetes label value (max 63 chars; see label rules)", + raw, SubmittedFromAPI, SubmittedFromDeveloperPortal, SubmittedFromHorizonCLI, SubmittedFromCustomToken, HeaderSubmittedFromDetail) + } +} diff --git a/terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/internal/workflow/visibility_test.go b/terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/internal/workflow/visibility_test.go new file mode 100644 index 00000000..b2ea2f98 --- /dev/null +++ b/terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/internal/workflow/visibility_test.go @@ -0,0 +1,111 @@ +// Copyright (c) 2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +package workflow + +import ( + "net/http/httptest" + "testing" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +func TestIsHorizonClientVisible(t *testing.T) { + u1 := &unstructured.Unstructured{} + u1.SetLabels(map[string]string{LabelSubmittedFrom: SubmittedFromAPI}) + if !IsHorizonClientVisible(u1) { + t.Fatal("api label should be visible") + } + u2 := &unstructured.Unstructured{} + u2.SetLabels(map[string]string{"workflows.argoproj.io/submit-from-ui": "true"}) + if IsHorizonClientVisible(u2) { + t.Fatal("Argo UI label alone must not be visible") + } + u3 := &unstructured.Unstructured{} + if IsHorizonClientVisible(u3) { + t.Fatal("missing label must not be visible") + } +} + +func TestParseSubmittedFromHeader(t *testing.T) { + r := httptest.NewRequest("POST", "/", nil) + v, err := ParseSubmittedFromHeader(r) + if err != nil || v != SubmittedFromAPI { + t.Fatalf("default: got %q err %v", v, err) + } + r.Header.Set("X-Horizon-Submitted-From", "developer-portal") + v, err = ParseSubmittedFromHeader(r) + if err != nil || v != SubmittedFromDeveloperPortal { + t.Fatalf("portal: got %q err %v", v, err) + } + r.Header.Set("X-Horizon-Submitted-From", "not a valid label!!!") + if _, err := ParseSubmittedFromHeader(r); err == nil { + t.Fatal("expected error for invalid label header") + } +} + +func TestParseSubmittedFromValue(t *testing.T) { + v, err := ParseSubmittedFromValue("rest-api") + if err != nil || v != SubmittedFromAPI { + t.Fatalf("rest-api: got %q err %v", v, err) + } + v, err = ParseSubmittedFromValue("portal") + if err != nil || v != SubmittedFromDeveloperPortal { + t.Fatalf("portal: got %q err %v", v, err) + } + v, err = ParseSubmittedFromValue("HORIZON_CLI") + if err != nil || v != SubmittedFromHorizonCLI { + t.Fatalf("horizon_cli: got %q err %v", v, err) + } + v, err = ParseSubmittedFromValue("workloads-android") + if err != nil || v != "workloads-android" { + t.Fatalf("custom label: got %q err %v", v, err) + } + v, err = ParseSubmittedFromValue("string") + if err != nil || v != "string" { + t.Fatalf("custom label string: got %q err %v", v, err) + } + if _, err := ParseSubmittedFromValue("not a label!!!"); err == nil { + t.Fatal("expected error for invalid Kubernetes label value") + } + if _, err := ParseSubmittedFromValue(""); err == nil { + t.Fatal("expected error for empty submitted-from value") + } +} + +func TestParseSubmittedFromHeaderCustom(t *testing.T) { + r := httptest.NewRequest("POST", "/", nil) + r.Header.Set("X-Horizon-Submitted-From", "custom") + if _, err := ParseSubmittedFromHeader(r); err == nil { + t.Fatal("expected error when custom without detail") + } + r.Header.Set("X-Horizon-Submitted-From-Detail", "my-integration") + v, err := ParseSubmittedFromHeader(r) + if err != nil || v != "my-integration" { + t.Fatalf("custom+detail: got %q err %v", v, err) + } +} + +func TestIsHorizonClientVisibleCustomLabel(t *testing.T) { + u := &unstructured.Unstructured{} + u.SetLabels(map[string]string{LabelSubmittedFrom: "my-ci"}) + if !IsHorizonClientVisible(u) { + t.Fatal("custom valid label should be visible") + } + u2 := &unstructured.Unstructured{} + u2.SetLabels(map[string]string{LabelSubmittedFrom: "bad!!!"}) + if IsHorizonClientVisible(u2) { + t.Fatal("invalid label value must not be visible") + } +} diff --git a/terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/main.go b/terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/main.go new file mode 100644 index 00000000..e3dd82f7 --- /dev/null +++ b/terraform/modules/sdv-container-images/images/horizon-api/horizon-api-app/main.go @@ -0,0 +1,241 @@ +// Copyright (c) 2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "flag" + "log" + "os" + "strconv" + "strings" + "time" + + "k8s.io/apimachinery/pkg/runtime" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/healthz" + crlog "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" + + "github.com/acn-horizon-sdv/horizon-api/internal/api" + "github.com/acn-horizon-sdv/horizon-api/internal/argo" + "github.com/acn-horizon-sdv/horizon-api/internal/auth" + "github.com/acn-horizon-sdv/horizon-api/internal/catalog" + "github.com/acn-horizon-sdv/horizon-api/internal/controller" + "github.com/acn-horizon-sdv/horizon-api/internal/workflow" +) + +var scheme = runtime.NewScheme() + +func init() { + utilruntime.Must(clientgoscheme.AddToScheme(scheme)) +} + +func main() { + var ( + metricsAddr string + probeAddr string + httpAddr string + horizonNamespace string + moduleManagerNS string + workflowsNS string + stateCRName string + catalogCRName string + oidcIssuerURL string + oidcClientID string + skipClientIDCheck bool + eventsWebhookURL string + argoBaseURL string + logReadIdle time.Duration + logMaxReconnect int + gcsArtifactBucket string + ttlAfterSuccess int + ttlAfterFailure int + ttlAfterCompletion int + gcsSigningSA string + workflowDeleteWait time.Duration + ) + flag.StringVar(&metricsAddr, "metrics-bind-address", ":8080", "Prometheus metrics") + flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "Health probes") + flag.StringVar(&httpAddr, "http-bind-address", ":8082", "HTTP API") + flag.StringVar(&horizonNamespace, "namespace", "horizon-api", "Namespace for this deployment") + flag.StringVar(&moduleManagerNS, "module-manager-namespace", "module-manager", "Namespace for Module Manager CRs") + flag.StringVar(&workflowsNS, "workflows-namespace", "", "Namespace for WorkflowTemplate / Workflow (defaults to {prefix}workflows from WORKFLOWS_NAMESPACE env)") + flag.StringVar(&stateCRName, "module-manager-state-name", "cluster", "ModuleManagerState resource name") + flag.StringVar(&catalogCRName, "module-manager-catalog-name", "cluster", "ModuleCatalog resource name in module-manager namespace") + flag.StringVar(&oidcIssuerURL, "oidc-issuer-url", "", "Keycloak realm issuer, e.g. https://domain/auth/realms/horizon") + flag.StringVar(&oidcClientID, "oidc-client-id", "", "Expected access-token audience / client (optional)") + flag.BoolVar(&skipClientIDCheck, "oidc-skip-client-id-check", true, "Skip OIDC azp/aud client check (needed for some Keycloak access tokens)") + flag.StringVar(&eventsWebhookURL, "events-webhook-url", "", "Argo Events EventSource POST URL (required, e.g. .../events/workflow)") + flag.StringVar(&argoBaseURL, "argo-base-url", "", "Argo Server base URL with base href (e.g. http://argo-workflows-server.NS.svc:2746/workflows); ARGO_BASE_URL") + flag.DurationVar(&logReadIdle, "log-read-idle-deadline", 30*time.Second, "Emit heartbeat if Argo sends no log line within this duration") + flag.IntVar(&logMaxReconnect, "log-max-reconnect", 25, "Max reconnect attempts when opening per-pod log streams (pods often 404 until the container is ready)") + flag.StringVar(&gcsArtifactBucket, "gcs-artifact-bucket", "", "Default GCS bucket for gs:// archived log links when artifact keys omit bucket; GCS_ARTIFACT_BUCKET") + flag.IntVar(&ttlAfterSuccess, "workflow-ttl-seconds-after-success", 86400, "Echoed on workflow list APIs; mirror Argo ttlStrategy") + flag.IntVar(&ttlAfterFailure, "workflow-ttl-seconds-after-failure", 259200, "Echoed on workflow list APIs; mirror Argo ttlStrategy") + flag.IntVar(&ttlAfterCompletion, "workflow-ttl-seconds-after-completion", 86400, "Echoed on workflow list APIs; mirror Argo ttlStrategy") + flag.StringVar(&gcsSigningSA, "gcs-signing-service-account", "", "Service account email for V4 signed GET URLs (artifact download); GCS_SIGNING_SERVICE_ACCOUNT") + flag.DurationVar(&workflowDeleteWait, "workflow-delete-wait-timeout", 10*time.Minute, "max time DELETE /v1/workflows/{name} waits for the Workflow CR to disappear (finalizers); WORKFLOW_DELETE_WAIT_TIMEOUT env overrides") + flag.Parse() + + crlog.SetLogger(zap.New(zap.UseDevMode(false))) + + if workflowsNS == "" { + workflowsNS = os.Getenv("WORKFLOWS_NAMESPACE") + } + if workflowsNS == "" { + log.Fatal("workflows-namespace or WORKFLOWS_NAMESPACE must be set") + } + if oidcIssuerURL == "" { + oidcIssuerURL = os.Getenv("OIDC_ISSUER_URL") + } + if oidcIssuerURL == "" { + log.Fatal("oidc-issuer-url or OIDC_ISSUER_URL must be set") + } + if eventsWebhookURL == "" { + eventsWebhookURL = os.Getenv("EVENTS_WEBHOOK_URL") + } + if eventsWebhookURL == "" { + log.Fatal("events-webhook-url or EVENTS_WEBHOOK_URL is required (Argo Events dispatch only)") + } + if argoBaseURL == "" { + argoBaseURL = os.Getenv("ARGO_BASE_URL") + } + if strings.TrimSpace(gcsArtifactBucket) == "" { + gcsArtifactBucket = strings.TrimSpace(os.Getenv("GCS_ARTIFACT_BUCKET")) + } + if strings.TrimSpace(gcsSigningSA) == "" { + gcsSigningSA = strings.TrimSpace(os.Getenv("GCS_SIGNING_SERVICE_ACCOUNT")) + } + if v := strings.TrimSpace(os.Getenv("WORKFLOW_TTL_SECONDS_AFTER_SUCCESS")); v != "" { + if n, err := strconv.Atoi(v); err == nil { + ttlAfterSuccess = n + } + } + if v := strings.TrimSpace(os.Getenv("WORKFLOW_TTL_SECONDS_AFTER_FAILURE")); v != "" { + if n, err := strconv.Atoi(v); err == nil { + ttlAfterFailure = n + } + } + if v := strings.TrimSpace(os.Getenv("WORKFLOW_TTL_SECONDS_AFTER_COMPLETION")); v != "" { + if n, err := strconv.Atoi(v); err == nil { + ttlAfterCompletion = n + } + } + if v := strings.TrimSpace(os.Getenv("LOG_MAX_RECONNECT")); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + logMaxReconnect = n + } + } + if v := strings.TrimSpace(os.Getenv("WORKFLOW_DELETE_WAIT_TIMEOUT")); v != "" { + if d, err := time.ParseDuration(v); err == nil && d > 0 { + workflowDeleteWait = d + } + } + + var argoClient *argo.Client + if strings.TrimSpace(argoBaseURL) != "" { + argoClient = &argo.Client{ + BaseURL: strings.TrimSpace(argoBaseURL), + HTTPClient: argo.NewHTTPClient(), + } + } + + ctx := ctrl.SetupSignalHandler() + authz, err := auth.NewOIDC(ctx, oidcIssuerURL, oidcClientID, skipClientIDCheck) + if err != nil { + log.Fatalf("oidc: %v", err) + } + + cfg := ctrl.GetConfigOrDie() + wfStore, err := workflow.NewStore(cfg, workflowsNS) + if err != nil { + log.Fatalf("workflow store: %v", err) + } + + mgr, err := ctrl.NewManager(cfg, ctrl.Options{ + Scheme: scheme, + Metrics: metricsserver.Options{ + BindAddress: metricsAddr, + }, + HealthProbeBindAddress: probeAddr, + }) + if err != nil { + log.Fatalf("manager: %v", err) + } + if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { + log.Fatal(err) + } + if err := mgr.AddReadyzCheck("readyz", healthz.Ping); err != nil { + log.Fatal(err) + } + + k8s, err := client.New(cfg, client.Options{Scheme: scheme}) + if err != nil { + log.Fatalf("client: %v", err) + } + + cat := catalog.New(moduleManagerNS, workflowsNS, stateCRName, catalogCRName) + + cat.InjectClient(k8s) + rec := &controller.CatalogReconciler{ + Catalog: cat, + ModuleManagerNS: moduleManagerNS, + WorkflowsNS: workflowsNS, + StateName: stateCRName, + CatalogName: catalogCRName, + } + if err := rec.SetupWithManager(mgr); err != nil { + log.Fatalf("catalog controller: %v", err) + } + + retention := workflow.Retention{ + SecondsAfterSuccess: ttlAfterSuccess, + SecondsAfterFailure: ttlAfterFailure, + SecondsAfterCompletion: ttlAfterCompletion, + Explanation: "Workflow CRs are removed from the cluster after Argo ttlStrategy; expect 404 when pruned.", + } + + handler := api.New(api.Options{ + Catalog: cat, + OIDC: authz, + WorkflowsNS: workflowsNS, + HTTPAddr: httpAddr, + EventsWebhookURL: eventsWebhookURL, + Argo: argoClient, + LogReadIdle: logReadIdle, + LogMaxReconnect: logMaxReconnect, + WorkflowStore: wfStore, + GCSArtifactBucket: gcsArtifactBucket, + GCSSigningServiceAccount: gcsSigningSA, + Retention: retention, + WorkflowDeleteWaitTimeout: workflowDeleteWait, + K8sClient: k8s, + ModuleManagerNS: moduleManagerNS, + StateCRName: stateCRName, + CatalogCRName: catalogCRName, + }) + if err := mgr.Add(handler); err != nil { + log.Fatalf("http: %v", err) + } + + log.Printf("Horizon API listening on %s (workflows=%s moduleManager=%s)", httpAddr, workflowsNS, moduleManagerNS) + if err := mgr.Start(ctx); err != nil { + log.Fatalf("start: %v", err) + } +} diff --git a/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/.dockerignore b/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/.dockerignore new file mode 100644 index 00000000..cfc00f6d --- /dev/null +++ b/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/.dockerignore @@ -0,0 +1,5 @@ +node_modules +dist +proxy/dist +.git +*.md diff --git a/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/Dockerfile b/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/Dockerfile new file mode 100644 index 00000000..5af3cb3f --- /dev/null +++ b/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/Dockerfile @@ -0,0 +1,36 @@ +# Copyright (c) 2024-2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# SPDX-License-Identifier: Apache-2.0 + +FROM node:20-alpine AS frontend +WORKDIR /app +COPY package.json package-lock.json* ./ +RUN npm ci +COPY . . +RUN npm run build + +FROM golang:1.25-alpine AS backend +WORKDIR /src +COPY proxy/go.mod proxy/go.sum* ./ +RUN go mod download +COPY proxy/*.go ./ +COPY --from=frontend /app/dist ./dist +RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /horizon-dev-portal . + +FROM gcr.io/distroless/static-debian12:nonroot +COPY --from=backend /horizon-dev-portal /horizon-dev-portal +USER nonroot:nonroot +EXPOSE 8080 +ENTRYPOINT ["/horizon-dev-portal"] diff --git a/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/eslint.config.js b/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/eslint.config.js new file mode 100644 index 00000000..b886b0ef --- /dev/null +++ b/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/eslint.config.js @@ -0,0 +1,37 @@ +// Copyright (c) 2024-2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +import js from '@eslint/js'; +import globals from 'globals'; +import reactHooks from 'eslint-plugin-react-hooks'; +import reactRefresh from 'eslint-plugin-react-refresh'; +import tseslint from 'typescript-eslint'; +import { defineConfig, globalIgnores } from 'eslint/config'; + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + ...tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + }, +]); diff --git a/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/index.html b/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/index.html new file mode 100644 index 00000000..cac3fd45 --- /dev/null +++ b/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/index.html @@ -0,0 +1,30 @@ + + + + + + + + + Horizon Developer Portal + + + +
+ + + diff --git a/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/package-lock.json b/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/package-lock.json new file mode 100644 index 00000000..2026588a --- /dev/null +++ b/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/package-lock.json @@ -0,0 +1,4056 @@ +{ + "name": "horizon-dev-portal", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "horizon-dev-portal", + "version": "0.0.0", + "dependencies": { + "@emotion/react": "^11.14.0", + "@emotion/styled": "^11.14.1", + "@mui/icons-material": "^7.3.5", + "@mui/material": "^7.3.5", + "keycloak-js": "^26.2.3", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "react-router-dom": "^7.9.6" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@types/node": "^24.10.1", + "@types/react": "^19.2.5", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.1", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "typescript": "~5.9.3", + "typescript-eslint": "^8.46.4", + "vite": "^7.2.4" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emotion/babel-plugin": { + "version": "11.13.5", + "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", + "integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.16.7", + "@babel/runtime": "^7.18.3", + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/serialize": "^1.3.3", + "babel-plugin-macros": "^3.1.0", + "convert-source-map": "^1.5.0", + "escape-string-regexp": "^4.0.0", + "find-root": "^1.1.0", + "source-map": "^0.5.7", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/cache": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz", + "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==", + "license": "MIT", + "dependencies": { + "@emotion/memoize": "^0.9.0", + "@emotion/sheet": "^1.4.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/hash": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", + "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==", + "license": "MIT" + }, + "node_modules/@emotion/is-prop-valid": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz", + "integrity": "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==", + "license": "MIT", + "dependencies": { + "@emotion/memoize": "^0.9.0" + } + }, + "node_modules/@emotion/memoize": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", + "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==", + "license": "MIT" + }, + "node_modules/@emotion/react": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", + "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/cache": "^11.14.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "hoist-non-react-statics": "^3.3.1" + }, + "peerDependencies": { + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/serialize": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz", + "integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==", + "license": "MIT", + "dependencies": { + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/unitless": "^0.10.0", + "@emotion/utils": "^1.4.2", + "csstype": "^3.0.2" + } + }, + "node_modules/@emotion/sheet": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz", + "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==", + "license": "MIT" + }, + "node_modules/@emotion/styled": { + "version": "11.14.1", + "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz", + "integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/is-prop-valid": "^1.3.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2" + }, + "peerDependencies": { + "@emotion/react": "^11.0.0-rc.0", + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/unitless": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz", + "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==", + "license": "MIT" + }, + "node_modules/@emotion/use-insertion-effect-with-fallbacks": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz", + "integrity": "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@emotion/utils": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz", + "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==", + "license": "MIT" + }, + "node_modules/@emotion/weak-memoize": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", + "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", + "license": "MIT" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mui/core-downloads-tracker": { + "version": "7.3.10", + "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-7.3.10.tgz", + "integrity": "sha512-vrOpWRmPJSuwLo23J62wggEm/jvGdzqctej+UOCtgDUz6nZJQuj3ByPccVyaa7eQmwAzUwKN56FQPMKkqbj1GA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + } + }, + "node_modules/@mui/icons-material": { + "version": "7.3.10", + "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-7.3.10.tgz", + "integrity": "sha512-Au0ma4NSKGKNiimukj8UT/W1x2Qx6Qwn2RvFGykiSqVLYBNlIOPbjnIMvrwLGLu89EEpTVdu/ys/OduZR+tWqw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@mui/material": "^7.3.10", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/material": { + "version": "7.3.10", + "resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.10.tgz", + "integrity": "sha512-cHvGOk2ZEfbQt3LnGe0ZKd/ETs9gsUpkW66DCO+GSjMZhpdKU4XsuIr7zJ/B/2XaN8ihxuzHfYAR4zPtCN4RYg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "@mui/core-downloads-tracker": "^7.3.10", + "@mui/system": "^7.3.10", + "@mui/types": "^7.4.12", + "@mui/utils": "^7.3.10", + "@popperjs/core": "^2.11.8", + "@types/react-transition-group": "^4.4.12", + "clsx": "^2.1.1", + "csstype": "^3.2.3", + "prop-types": "^15.8.1", + "react-is": "^19.2.3", + "react-transition-group": "^4.4.5" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@mui/material-pigment-css": "^7.3.10", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@mui/material-pigment-css": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/private-theming": { + "version": "7.3.10", + "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-7.3.10.tgz", + "integrity": "sha512-j3EZN+zOctxUISvJSmsEPo5o2F8zse4l5vRkBY+ps6UtnL6J7o14kUaI4w7gwo73id9e3cDNMVQK/9BVaMHVBw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "@mui/utils": "^7.3.10", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/styled-engine": { + "version": "7.3.10", + "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-7.3.10.tgz", + "integrity": "sha512-WxE9SiF8xskAQqGjsp0poXCkCqsoXFEsSr0HBXfApmGHR+DBnXRp+z46Vsltg4gpPM4Z96DeAQRpeAOnhNg7Ng==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "@emotion/cache": "^11.14.0", + "@emotion/serialize": "^1.3.3", + "@emotion/sheet": "^1.4.0", + "csstype": "^3.2.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.4.1", + "@emotion/styled": "^11.3.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + } + } + }, + "node_modules/@mui/system": { + "version": "7.3.10", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-7.3.10.tgz", + "integrity": "sha512-/sfPpdpJaQn7BSF+avjIdHSYmxHp0UOBYNxSG9QGKfMOD6sLANCpRPCnanq1Pe0lFf0NHkO2iUk0TNzdWC1USQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "@mui/private-theming": "^7.3.10", + "@mui/styled-engine": "^7.3.10", + "@mui/types": "^7.4.12", + "@mui/utils": "^7.3.10", + "clsx": "^2.1.1", + "csstype": "^3.2.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/types": { + "version": "7.4.12", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.4.12.tgz", + "integrity": "sha512-iKNAF2u9PzSIj40CjvKJWxFXJo122jXVdrmdh0hMYd+FR+NuJMkr/L88XwWLCRiJ5P1j+uyac25+Kp6YC4hu6w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/utils": { + "version": "7.3.10", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.3.10.tgz", + "integrity": "sha512-7y2eIfy0h7JPz+Yy4pS+wgV68d46PuuxDqKBN4Q8VlPQSsCAGwroMCV6xWyc7g9dvEp8ZNFsknc59GHWO+r6Ow==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "@mui/types": "^7.4.12", + "@types/prop-types": "^15.7.15", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-is": "^19.2.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", + "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", + "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.12.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz", + "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/parse-json": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", + "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==", + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/react-transition-group": { + "version": "4.4.12", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", + "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.1.tgz", + "integrity": "sha512-eSkwoemjo76bdXl2MYqtxg51HNwUSkWfODUOQ3PaTLZGh9uIWWFZIjyjaJnex7wXDu+TRx+ATsnSxdN9YWfRTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.58.1", + "@typescript-eslint/type-utils": "8.58.1", + "@typescript-eslint/utils": "8.58.1", + "@typescript-eslint/visitor-keys": "8.58.1", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.58.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.1.tgz", + "integrity": "sha512-gGkiNMPqerb2cJSVcruigx9eHBlLG14fSdPdqMoOcBfh+vvn4iCq2C8MzUB89PrxOXk0y3GZ1yIWb9aOzL93bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.58.1", + "@typescript-eslint/types": "8.58.1", + "@typescript-eslint/typescript-estree": "8.58.1", + "@typescript-eslint/visitor-keys": "8.58.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.1.tgz", + "integrity": "sha512-gfQ8fk6cxhtptek+/8ZIqw8YrRW5048Gug8Ts5IYcMLCw18iUgrZAEY/D7s4hkI0FxEfGakKuPK/XUMPzPxi5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.58.1", + "@typescript-eslint/types": "^8.58.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.1.tgz", + "integrity": "sha512-TPYUEqJK6avLcEjumWsIuTpuYODTTDAtoMdt8ZZa93uWMTX13Nb8L5leSje1NluammvU+oI3QRr5lLXPgihX3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.1", + "@typescript-eslint/visitor-keys": "8.58.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.1.tgz", + "integrity": "sha512-JAr2hOIct2Q+qk3G+8YFfqkqi7sC86uNryT+2i5HzMa2MPjw4qNFvtjnw1IiA1rP7QhNKVe21mSSLaSjwA1Olw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.58.1.tgz", + "integrity": "sha512-HUFxvTJVroT+0rXVJC7eD5zol6ID+Sn5npVPWoFuHGg9Ncq5Q4EYstqR+UOqaNRFXi5TYkpXXkLhoCHe3G0+7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.1", + "@typescript-eslint/typescript-estree": "8.58.1", + "@typescript-eslint/utils": "8.58.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.1.tgz", + "integrity": "sha512-io/dV5Aw5ezwzfPBBWLoT+5QfVtP8O7q4Kftjn5azJ88bYyp/ZMCsyW1lpKK46EXJcaYMZ1JtYj+s/7TdzmQMw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.1.tgz", + "integrity": "sha512-w4w7WR7GHOjqqPnvAYbazq+Y5oS68b9CzasGtnd6jIeOIeKUzYzupGTB2T4LTPSv4d+WPeccbxuneTFHYgAAWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.58.1", + "@typescript-eslint/tsconfig-utils": "8.58.1", + "@typescript-eslint/types": "8.58.1", + "@typescript-eslint/visitor-keys": "8.58.1", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.1.tgz", + "integrity": "sha512-Ln8R0tmWC7pTtLOzgJzYTXSCjJ9rDNHAqTaVONF4FEi2qwce8mD9iSOxOpLFFvWp/wBFlew0mjM1L1ihYWfBdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.58.1", + "@typescript-eslint/types": "8.58.1", + "@typescript-eslint/typescript-estree": "8.58.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.1.tgz", + "integrity": "sha512-y+vH7QE8ycjoa0bWciFg7OpFcipUuem1ujhrdLtq1gByKwfbC7bPeKsiny9e0urg93DqwGcHey+bGRKCnF1nZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.1", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", + "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.29.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-rc.3", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/babel-plugin-macros": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", + "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5", + "cosmiconfig": "^7.0.0", + "resolve": "^1.19.0" + }, + "engines": { + "node": ">=10", + "npm": ">=6" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.17", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.17.tgz", + "integrity": "sha512-HdrkN8eVG2CXxeifv/VdJ4A4RSra1DTW8dc/hdxzhGHN8QePs6gKaWM9pHPcpCoxYZJuOZ8drHmbdpLHjCYjLA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001787", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001787.tgz", + "integrity": "sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cosmiconfig": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", + "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", + "license": "MIT", + "dependencies": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.2.1", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.10.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/cosmiconfig/node_modules/yaml": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", + "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.334", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.334.tgz", + "integrity": "sha512-mgjZAz7Jyx1SRCwEpy9wefDS7GvNPazLthHg8eQMJ76wBdGQQDW33TCrUTvQ4wzpmOrv2zrFoD3oNufMdyMpog==", + "dev": true, + "license": "ISC" + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", + "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.26", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.26.tgz", + "integrity": "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": ">=8.40" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-root": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", + "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==", + "license": "MIT" + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "license": "BSD-3-Clause", + "dependencies": { + "react-is": "^16.7.0" + } + }, + "node_modules/hoist-non-react-statics/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "license": "MIT" + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keycloak-js": { + "version": "26.2.3", + "resolved": "https://registry.npmjs.org/keycloak-js/-/keycloak-js-26.2.3.tgz", + "integrity": "sha512-widjzw/9T6bHRgEp6H/Se3NCCarU7u5CwFKBcwtu7xfA1IfdZb+7Q7/KGusAnBo34Vtls8Oz9vzSqkQvQ7+b4Q==", + "license": "Apache-2.0", + "workspaces": [ + "test" + ] + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.37", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", + "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", + "dev": true, + "license": "MIT" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT" + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.9", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz", + "integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", + "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", + "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.5" + } + }, + "node_modules/react-is": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.5.tgz", + "integrity": "sha512-Dn0t8IQhCmeIT3wu+Apm1/YVsJXsGWi6k4sPdnBIdqMVtHtv0IGi6dcpNpNkNac0zB2uUAqNX3MHzN8c+z2rwQ==", + "license": "MIT" + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "7.14.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.0.tgz", + "integrity": "sha512-m/xR9N4LQLmAS0ZhkY2nkPA1N7gQ5TUVa5n8TgANuDTARbn1gt+zLPXEm7W0XDTbrQ2AJSJKhoa6yx1D8BcpxQ==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.14.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.14.0.tgz", + "integrity": "sha512-2G3ajSVSZMEtmTjIklRWlNvo8wICEpLihfD/0YMDxbWK2UyP5EGfnoIn9AIQGnF3G/FX0MRbHXdFcD+rL1ZreQ==", + "license": "MIT", + "dependencies": { + "react-router": "7.14.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rollup": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", + "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/stylis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", + "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==", + "license": "MIT" + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.58.1.tgz", + "integrity": "sha512-gf6/oHChByg9HJvhMO1iBexJh12AqqTfnuxscMDOVqfJW3htsdRJI/GfPpHTTcyeB8cSTUY2JcZmVgoyPqcrDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.58.1", + "@typescript-eslint/parser": "8.58.1", + "@typescript-eslint/typescript-estree": "8.58.1", + "@typescript-eslint/utils": "8.58.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", + "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yaml": { + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", + "extraneous": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + } + } +} diff --git a/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/package.json b/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/package.json new file mode 100644 index 00000000..049c4a1b --- /dev/null +++ b/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/package.json @@ -0,0 +1,36 @@ +{ + "name": "horizon-dev-portal", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@emotion/react": "^11.14.0", + "@emotion/styled": "^11.14.1", + "@mui/icons-material": "^7.3.5", + "@mui/material": "^7.3.5", + "keycloak-js": "^26.2.3", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "react-router-dom": "^7.9.6" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@types/node": "^24.10.1", + "@types/react": "^19.2.5", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.1", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "typescript": "~5.9.3", + "typescript-eslint": "^8.46.4", + "vite": "^7.2.4" + } +} diff --git a/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/proxy/go.mod b/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/proxy/go.mod new file mode 100644 index 00000000..627ffc83 --- /dev/null +++ b/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/proxy/go.mod @@ -0,0 +1,25 @@ +// Copyright (c) 2024-2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +module local.dev/horizon-dev-portal/proxy + +go 1.25.0 + +require github.com/coreos/go-oidc/v3 v3.11.0 + +require ( + github.com/go-jose/go-jose/v4 v4.0.2 // indirect + golang.org/x/crypto v0.50.0 // indirect + golang.org/x/oauth2 v0.21.0 // indirect +) diff --git a/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/proxy/go.sum b/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/proxy/go.sum new file mode 100644 index 00000000..5dce50be --- /dev/null +++ b/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/proxy/go.sum @@ -0,0 +1,18 @@ +github.com/coreos/go-oidc/v3 v3.11.0 h1:Ia3MxdwpSw702YW0xgfmP1GVCMA9aEFWu12XUZ3/OtI= +github.com/coreos/go-oidc/v3 v3.11.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-jose/go-jose/v4 v4.0.2 h1:R3l3kkBds16bO7ZFAEEcofK0MkrAJt3jlJznWZG0nvk= +github.com/go-jose/go-jose/v4 v4.0.2/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= +github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs= +golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/proxy/main.go b/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/proxy/main.go new file mode 100644 index 00000000..c4cbf521 --- /dev/null +++ b/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/proxy/main.go @@ -0,0 +1,470 @@ +// Copyright (c) 2024-2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "context" + "embed" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "net/http/httputil" + "net/url" + "os" + "strings" + "sync" + "time" + + "github.com/coreos/go-oidc/v3/oidc" +) + +//go:embed all:dist +var staticFS embed.FS + +// Renew access token this long before JWT expiry (proactive refresh / refresh_token exchange). +const ciTokenRefreshLead = 90 * time.Second + +// If Keycloak omits refresh_expires_in, we still try refresh until the server rejects it. +const ciRefreshMinRemaining = 30 * time.Second + +// parseJWTExpiry returns access token exp (wall clock). Used to cap cache lifetime so we never +// send a JWT past exp when expires_in and cluster clocks disagree with Horizon's validation. +func parseJWTExpiry(accessToken string) (time.Time, bool) { + parts := strings.Split(accessToken, ".") + if len(parts) < 2 { + return time.Time{}, false + } + payload := parts[1] + raw, err := base64.RawURLEncoding.DecodeString(payload) + if err != nil { + l := len(payload) % 4 + if l > 0 { + payload += strings.Repeat("=", 4-l) + } + raw, err = base64.URLEncoding.DecodeString(payload) + if err != nil { + return time.Time{}, false + } + } + var claims struct { + Exp int64 `json:"exp"` + } + if err := json.Unmarshal(raw, &claims); err != nil || claims.Exp == 0 { + return time.Time{}, false + } + return time.Unix(claims.Exp, 0), true +} + +func main() { + addr := env("LISTEN_ADDR", ":8080") + issuer := mustEnv("OIDC_ISSUER_URL") + tokenURL := env("KEYCLOAK_TOKEN_URL", "") + if tokenURL == "" { + tokenURL = strings.TrimSuffix(issuer, "/") + "/protocol/openid-connect/token" + } + mmBase := mustEnv("MODULE_MANAGER_BASE_URL") + haBase := mustEnv("HORIZON_API_BASE_URL") + ciID := mustEnv("HORIZON_API_CI_CLIENT_ID") + ciSecret := mustEnv("HORIZON_API_CI_CLIENT_SECRET") + + ctx := context.Background() + provider, err := oidc.NewProvider(ctx, issuer) + if err != nil { + log.Fatalf("oidc provider: %v", err) + } + verifier := provider.Verifier(&oidc.Config{SkipClientIDCheck: true}) + + mmURL := mustParseURL(mmBase) + haURL := mustParseURL(haBase) + ci := newCITokenSource(tokenURL, ciID, ciSecret) + + mux := http.NewServeMux() + mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + }) + mux.HandleFunc("GET /config.js", func(w http.ResponseWriter, r *http.Request) { + serveConfigJS(w, env("PUBLIC_BASE_URL", ""), normalizePublicPath(env("PUBLIC_PATH", "")), env("KEYCLOAK_TOKEN_PATH", "/auth/realms/horizon/protocol/openid-connect/token"), env("KEYCLOAK_CLIENT_ID", "horizon-dev-portal"), ciID) + }) + mux.Handle("/api/mm/", newMMProxy(mmURL, verifier)) + mux.Handle("/api/horizon/", newHorizonProxy(haURL, ci)) + mux.Handle("/", spaHandler(htmlBaseTagHref(normalizePublicPath(env("PUBLIC_PATH", ""))))) + + log.Printf("listening on %s", addr) + log.Fatal(http.ListenAndServe(addr, mux)) +} + +func env(k, def string) string { + if v := strings.TrimSpace(os.Getenv(k)); v != "" { + return v + } + return def +} + +func mustEnv(k string) string { + v := strings.TrimSpace(os.Getenv(k)) + if v == "" { + log.Fatalf("missing env %s", k) + } + return v +} + +func mustParseURL(raw string) *url.URL { + u, err := url.Parse(raw) + if err != nil || u.Scheme == "" || u.Host == "" { + log.Fatalf("bad URL: %s", raw) + } + return u +} + +func normalizePublicPath(p string) string { + p = strings.TrimSpace(p) + if p == "" { + return "" + } + if !strings.HasPrefix(p, "/") { + p = "/" + p + } + p = strings.TrimSuffix(p, "/") + if p == "/" { + return "" + } + return p +} + +// htmlBaseTagHref is the value for when the SPA is mounted under a subpath. +// It must end with "/" so relative URLs in index.html resolve under the mount (Vite uses base: './'). +func htmlBaseTagHref(normalizedPublicPath string) string { + if normalizedPublicPath == "" { + return "" + } + return normalizedPublicPath + "/" +} + +func injectHTMLBaseTag(html []byte, baseHref string) []byte { + if baseHref == "" { + return html + } + s := string(html) + lower := strings.ToLower(s) + idx := strings.Index(lower, "") + if idx < 0 { + return html + } + insertAt := idx + len("") + inject := "\n " + return []byte(s[:insertAt] + inject + s[insertAt:]) +} + +func htmlEscapeAttr(s string) string { + s = strings.ReplaceAll(s, `&`, `&`) + s = strings.ReplaceAll(s, `"`, `"`) + return s +} + +func serveConfigJS(w http.ResponseWriter, publicBase, publicPath, keycloakTokenPath, clientID, horizonApiOAuthClientID string) { + w.Header().Set("Content-Type", "application/javascript; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + cfg := map[string]string{ + "baseUrl": publicBase, + "publicPath": publicPath, + "keycloakUrl": keycloakTokenPath, + "keycloakClientId": clientID, + } + if strings.TrimSpace(horizonApiOAuthClientID) != "" { + cfg["horizonApiOAuthClientId"] = strings.TrimSpace(horizonApiOAuthClientID) + } + payload, err := json.Marshal(cfg) + if err != nil { + http.Error(w, "config", http.StatusInternalServerError) + return + } + _, _ = fmt.Fprintf(w, "window.APP_CONFIG = window.APP_CONFIG || %s;\n", string(payload)) +} + +func bearerToken(r *http.Request) (string, error) { + h := strings.TrimSpace(r.Header.Get("Authorization")) + parts := strings.SplitN(h, " ", 2) + if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") { + return "", fmt.Errorf("missing bearer") + } + t := strings.TrimSpace(parts[1]) + if t == "" { + return "", fmt.Errorf("empty bearer") + } + return t, nil +} + +func newMMProxy(upstream *url.URL, verifier *oidc.IDTokenVerifier) http.Handler { + rp := httputil.NewSingleHostReverseProxy(upstream) + rp.Director = func(req *http.Request) { + req.URL.Scheme = upstream.Scheme + req.URL.Host = upstream.Host + orig := req.URL.Path + req.URL.Path = strings.TrimPrefix(orig, "/api/mm") + if req.URL.Path == "" { + req.URL.Path = "/" + } + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + raw, err := bearerToken(r) + if err != nil { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + if _, err := verifier.Verify(r.Context(), raw); err != nil { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + rp.ServeHTTP(w, r) + }) +} + +// retryCI401Transport retries GET/HEAD after upstream 401: ci.invalidate() then ci.get() prefers +// refresh_token (if Keycloak issued one), else client_credentials. +type retryCI401Transport struct { + rt http.RoundTripper + ci *ciTokenSource +} + +func (t *retryCI401Transport) RoundTrip(req *http.Request) (*http.Response, error) { + base := t.rt + if base == nil { + base = http.DefaultTransport + } + if req.Method != http.MethodGet && req.Method != http.MethodHead { + return base.RoundTrip(req) + } + const max401Attempts = 2 + cur := req + for attempt := 0; attempt < max401Attempts; attempt++ { + resp, err := base.RoundTrip(cur) + if err != nil || resp == nil { + return resp, err + } + if resp.StatusCode != http.StatusUnauthorized { + return resp, err + } + t.ci.invalidate() + _, _ = io.Copy(io.Discard, resp.Body) + _ = resp.Body.Close() + if attempt == max401Attempts-1 { + return resp, err + } + tok, err := t.ci.get(req.Context()) + if err != nil { + return nil, err + } + r2 := req.Clone(req.Context()) + if r2.Header != nil { + r2.Header = r2.Header.Clone() + } + r2.Header.Set("Authorization", "Bearer "+tok) + cur = r2 + } + return nil, fmt.Errorf("retryCI401Transport: internal") +} + +// newHorizonProxy forwards to Horizon API using only the confidential CI client (K8s secret). +// The browser does not send a user Bearer; ingress same-origin still limits who reaches this service. +func newHorizonProxy(upstream *url.URL, ci *ciTokenSource) http.Handler { + rp := httputil.NewSingleHostReverseProxy(upstream) + rp.Transport = &retryCI401Transport{rt: http.DefaultTransport, ci: ci} + // Chunked NDJSON log streams: periodic flush so the browser sees lines without buffering the whole body. + rp.FlushInterval = 100 * time.Millisecond + rp.Director = func(req *http.Request) { + req.URL.Scheme = upstream.Scheme + req.URL.Host = upstream.Host + orig := req.URL.Path + req.URL.Path = strings.TrimPrefix(orig, "/api/horizon") + if req.URL.Path == "" { + req.URL.Path = "/" + } + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + tok, err := ci.get(r.Context()) + if err != nil { + http.Error(w, "token exchange failed", http.StatusBadGateway) + return + } + r2 := r.Clone(r.Context()) + r2.Header.Del("Authorization") + r2.Header.Set("Authorization", "Bearer "+tok) + rp.ServeHTTP(w, r2) + }) +} + +type ciTokenSource struct { + mu sync.Mutex + token string + expiresAt time.Time + + refreshTok string + refreshExp time.Time // zero if Keycloak did not send refresh_expires_in + + tokenURL string + id string + secret string +} + +func newCITokenSource(tokenURL, id, secret string) *ciTokenSource { + return &ciTokenSource{tokenURL: tokenURL, id: id, secret: secret} +} + +// invalidate clears only the access token so the next get() can try refresh_token before client_credentials. +func (c *ciTokenSource) invalidate() { + c.mu.Lock() + defer c.mu.Unlock() + c.token = "" + c.expiresAt = time.Time{} +} + +type tokenEndpointJSON struct { + AccessToken string `json:"access_token"` + ExpiresIn int `json:"expires_in"` + RefreshToken string `json:"refresh_token"` + RefreshExpiresIn int `json:"refresh_expires_in"` +} + +// clearRefreshWhenMissing: for client_credentials, drop stored refresh if IdP omits refresh_token. +// For refresh_token grant, false preserves the previous refresh when the response does not rotate it. +func (c *ciTokenSource) applyTokenResponse(tr *tokenEndpointJSON, clearRefreshWhenMissing bool) { + c.token = tr.AccessToken + sec := tr.ExpiresIn + if sec <= 0 { + sec = 300 + } + deadline := time.Now().Add(time.Duration(sec) * time.Second) + if jwtExp, ok := parseJWTExpiry(tr.AccessToken); ok && jwtExp.Before(deadline) { + deadline = jwtExp + } + c.expiresAt = deadline + if tr.RefreshToken != "" { + c.refreshTok = tr.RefreshToken + if tr.RefreshExpiresIn > 0 { + c.refreshExp = time.Now().Add(time.Duration(tr.RefreshExpiresIn) * time.Second) + } else { + c.refreshExp = time.Time{} + } + return + } + if clearRefreshWhenMissing { + c.refreshTok = "" + c.refreshExp = time.Time{} + } +} + +func (c *ciTokenSource) postTokenForm(ctx context.Context, form url.Values, clearRefreshWhenMissing bool) error { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.tokenURL, strings.NewReader(form.Encode())) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("token %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + var tr tokenEndpointJSON + if err := json.Unmarshal(body, &tr); err != nil { + return err + } + if tr.AccessToken == "" { + return fmt.Errorf("no access_token") + } + c.applyTokenResponse(&tr, clearRefreshWhenMissing) + return nil +} + +func (c *ciTokenSource) refreshAllowedLocked() bool { + if c.refreshTok == "" { + return false + } + if c.refreshExp.IsZero() { + return true + } + return time.Until(c.refreshExp) > ciRefreshMinRemaining +} + +func (c *ciTokenSource) get(ctx context.Context) (string, error) { + c.mu.Lock() + defer c.mu.Unlock() + if c.token != "" && time.Until(c.expiresAt) > ciTokenRefreshLead { + return c.token, nil + } + if c.refreshAllowedLocked() { + form := url.Values{} + form.Set("grant_type", "refresh_token") + form.Set("refresh_token", c.refreshTok) + form.Set("client_id", c.id) + form.Set("client_secret", c.secret) + if err := c.postTokenForm(ctx, form, false); err == nil { + return c.token, nil + } + c.refreshTok = "" + c.refreshExp = time.Time{} + } + form := url.Values{} + form.Set("grant_type", "client_credentials") + form.Set("client_id", c.id) + form.Set("client_secret", c.secret) + if err := c.postTokenForm(ctx, form, true); err != nil { + return "", err + } + return c.token, nil +} + +func spaHandler(baseHref string) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + p := strings.TrimPrefix(r.URL.Path, "/") + if p == "" { + p = "index.html" + } + b, err := staticFS.ReadFile("dist/" + p) + if err != nil { + b, err = staticFS.ReadFile("dist/index.html") + if err != nil { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write(injectHTMLBaseTag(b, baseHref)) + return + } + switch { + case strings.HasSuffix(p, ".js"): + w.Header().Set("Content-Type", "application/javascript") + case strings.HasSuffix(p, ".svg"): + w.Header().Set("Content-Type", "image/svg+xml") + case strings.HasSuffix(p, ".css"): + w.Header().Set("Content-Type", "text/css") + case strings.HasSuffix(p, ".html"): + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if p == "index.html" { + b = injectHTMLBaseTag(b, baseHref) + } + } + _, _ = w.Write(b) + }) +} diff --git a/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/public/config.js b/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/public/config.js new file mode 100644 index 00000000..260d090b --- /dev/null +++ b/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/public/config.js @@ -0,0 +1,23 @@ +// Copyright (c) 2024-2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Local dev defaults; production is served by the portal binary from env. +window.APP_CONFIG = window.APP_CONFIG || { + baseUrl: typeof window !== 'undefined' ? window.location.origin : '', + /** Match vite dev server if you use `npm run dev` with a subpath; `''` when opening the app at `/`. */ + publicPath: '', + keycloakUrl: '/auth/realms/horizon/protocol/openid-connect/token', + keycloakClientId: 'horizon-dev-portal', + horizonApiOAuthClientId: 'horizon-api-ci', +}; diff --git a/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/public/favicon.svg b/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/public/favicon.svg new file mode 100644 index 00000000..ef5e7a35 --- /dev/null +++ b/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/public/favicon.svg @@ -0,0 +1,3 @@ + + \ No newline at end of file diff --git a/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/src/App.tsx b/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/src/App.tsx new file mode 100644 index 00000000..c8be7da9 --- /dev/null +++ b/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/src/App.tsx @@ -0,0 +1,312 @@ +// Copyright (c) 2024-2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +import { useEffect, useState } from 'react'; +import { + Box, + Divider, + Drawer, + IconButton, + List, + ListItemButton, + ListItemIcon, + ListItemText, + Toolbar, + AppBar, + Typography, + useMediaQuery, + CircularProgress, +} from '@mui/material'; +import { useTheme } from '@mui/material/styles'; +import Tooltip from '@mui/material/Tooltip'; +import MenuIcon from '@mui/icons-material/Menu'; +import Brightness4Icon from '@mui/icons-material/Brightness4'; +import Brightness7Icon from '@mui/icons-material/Brightness7'; +import AdminPanelSettingsIcon from '@mui/icons-material/AdminPanelSettings'; +import HomeIcon from '@mui/icons-material/Home'; +import LogoutIcon from '@mui/icons-material/Logout'; +import ViewModuleIcon from '@mui/icons-material/ViewModule'; +import { + BrowserRouter, + Routes, + Route, + Navigate, + Link, + Outlet, + useLocation, +} from 'react-router-dom'; +import { ThemeModeProvider, useThemeMode } from './ThemeModeProvider'; +import { authService } from './utils/auth'; +import { LoginPage } from './pages/LoginPage'; +import { WelcomePage } from './pages/WelcomePage'; +import { AdminLayout } from './pages/admin/AdminLayout'; +import { ModulesTab } from './pages/admin/ModulesTab'; +import { SettingsTab } from './pages/admin/SettingsTab'; +import { ModulePage } from './pages/ModulePage'; +import { apiMm } from './utils/api'; +import type { ModuleResponse, StatusResponse } from './types'; +import { isReady } from './moduleStatus'; +import { HORIZON_LOGO_SRC } from './constants'; +import { getRouterBasename } from './utils/publicPath'; + +const drawerWidth = 260; + +async function fetchReadyModuleNames(): Promise { + const r = await apiMm('/modules'); + if (!r.ok) { + return []; + } + const list = (await r.json()) as ModuleResponse[]; + const ready: string[] = []; + for (const m of list) { + if (!m.enabled) { + continue; + } + const sr = await apiMm(`/modules/${encodeURIComponent(m.name)}/status`); + if (!sr.ok) { + continue; + } + const st = (await sr.json()) as StatusResponse; + if (isReady(m.enabled, st)) { + ready.push(m.name); + } + } + return ready.sort((a, b) => a.localeCompare(b)); +} + +function useReadyModules(): string[] { + const [mods, setMods] = useState([]); + useEffect(() => { + let cancelled = false; + async function tick() { + const names = await fetchReadyModuleNames(); + if (!cancelled) { + setMods(names); + } + } + void tick(); + const id = window.setInterval(() => void tick(), 12000); + return () => { + cancelled = true; + window.clearInterval(id); + }; + }, []); + return mods; +} + +function ShellLayout() { + const [mobileOpen, setMobileOpen] = useState(false); + const { darkMode, toggleTheme } = useThemeMode(); + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down('sm')); + const location = useLocation(); + const readyMods = useReadyModules(); + + const drawer = ( + + + + + Developer Portal + + + + + isMobile && setMobileOpen(false)} + > + + + + + + isMobile && setMobileOpen(false)} + > + + + + + + {readyMods.map((name) => ( + isMobile && setMobileOpen(false)} + > + + + + + + ))} + + + ); + + return ( + + t.zIndex.drawer + 1 }} + > + + setMobileOpen(!mobileOpen)} + sx={{ mr: 2, display: { sm: 'none' } }} + > + + + + + + Horizon Developer Portal + + + + {authService.getUsername()} + + + + {darkMode ? : } + + + authService.logout()} aria-label="logout"> + + + + + + setMobileOpen(false)} + ModalProps={{ keepMounted: true }} + sx={{ + display: { xs: 'block', sm: 'none' }, + '& .MuiDrawer-paper': { boxSizing: 'border-box', width: drawerWidth }, + }} + > + {drawer} + + + {drawer} + + + + + + + ); +} + +function RequireAuth({ children }: { children: React.ReactNode }) { + const [ok, setOk] = useState(null); + useEffect(() => { + authService + .init() + .then(setOk) + .catch(() => setOk(false)); + }, []); + if (ok === null) { + return ( + + + + ); + } + if (!ok) { + return ; + } + return <>{children}; +} + +export default function App() { + return ( + + + + } /> + + + + } + > + } /> + }> + } /> + } /> + } /> + + } /> + + } /> + + + + ); +} diff --git a/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/src/ThemeModeProvider.tsx b/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/src/ThemeModeProvider.tsx new file mode 100644 index 00000000..3c6a30ec --- /dev/null +++ b/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/src/ThemeModeProvider.tsx @@ -0,0 +1,96 @@ +// Copyright (c) 2024-2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from 'react'; +import { CssBaseline, ThemeProvider } from '@mui/material'; +import { getTheme } from './theme'; + +const STORAGE_KEY = 'theme'; + +type ThemeModeContextValue = { + darkMode: boolean; + toggleTheme: () => void; +}; + +const ThemeModeContext = createContext(null); + +function readInitialDark(): boolean { + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored !== null) { + return stored === 'dark'; + } + } catch { + /* ignore */ + } + return window.APP_CONFIG?.theme === 'dark'; +} + +export function ThemeModeProvider({ children }: { children: React.ReactNode }) { + const [darkMode, setDarkMode] = useState(readInitialDark); + + useEffect(() => { + const onStorage = (e: StorageEvent) => { + if (e.key === STORAGE_KEY && e.newValue !== null) { + setDarkMode(e.newValue === 'dark'); + } + }; + window.addEventListener('storage', onStorage); + return () => window.removeEventListener('storage', onStorage); + }, []); + + const toggleTheme = useCallback(() => { + setDarkMode((d) => { + const next = !d; + try { + localStorage.setItem(STORAGE_KEY, next ? 'dark' : 'light'); + } catch { + /* ignore */ + } + return next; + }); + }, []); + + const theme = useMemo(() => getTheme(darkMode ? 'dark' : 'light'), [darkMode]); + + const value = useMemo( + () => ({ darkMode, toggleTheme }), + [darkMode, toggleTheme] + ); + + return ( + + + + {children} + + + ); +} + +// eslint-disable-next-line react-refresh/only-export-components -- hook is tied to this provider module +export function useThemeMode(): ThemeModeContextValue { + const ctx = useContext(ThemeModeContext); + if (!ctx) { + throw new Error('useThemeMode must be used within ThemeModeProvider'); + } + return ctx; +} diff --git a/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/src/assets/Vehicle.svg b/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/src/assets/Vehicle.svg new file mode 100644 index 00000000..ef5e7a35 --- /dev/null +++ b/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/src/assets/Vehicle.svg @@ -0,0 +1,3 @@ + + \ No newline at end of file diff --git a/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/src/constants.ts b/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/src/constants.ts new file mode 100644 index 00000000..47499ea7 --- /dev/null +++ b/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/src/constants.ts @@ -0,0 +1,17 @@ +// Copyright (c) 2024-2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +import vehicleLogoUrl from './assets/Vehicle.svg?url'; + +export const HORIZON_LOGO_SRC = vehicleLogoUrl; diff --git a/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/src/index.css b/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/src/index.css new file mode 100644 index 00000000..5b158abb --- /dev/null +++ b/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/src/index.css @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2024-2026 Accenture, All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +* { + box-sizing: border-box; +} +html, +body, +#root { + margin: 0; + min-height: 100%; +} diff --git a/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/src/main.tsx b/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/src/main.tsx new file mode 100644 index 00000000..6d11b482 --- /dev/null +++ b/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/src/main.tsx @@ -0,0 +1,24 @@ +// Copyright (c) 2024-2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import './index.css'; +import App from './App'; + +createRoot(document.getElementById('root')!).render( + + + +); diff --git a/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/src/moduleStatus.ts b/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/src/moduleStatus.ts new file mode 100644 index 00000000..4712c77e --- /dev/null +++ b/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/src/moduleStatus.ts @@ -0,0 +1,80 @@ +// Copyright (c) 2024-2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +import type { StatusResponse } from './types'; + +export type DeploymentStatus = + | 'NOT INSTALLED' + | 'UNINSTALL IN PROGRESS' + | 'INSTALLATION IN PROGRESS' + | 'UPDATE IN PROGRESS' + | 'READY'; + +function argoAppStatusPresent(st?: StatusResponse): boolean { + if (!st) { + return false; + } + return !!( + (st.syncStatus ?? '').trim() || + (st.healthStatus ?? '').trim() || + (st.operationPhase ?? '').trim() || + (st.desiredRevision ?? '').trim() || + (st.syncRevision ?? '').trim() || + (st.applicationDeletionTimestamp ?? '').trim() + ); +} + +/** + * Maps Argo CD Application status to a coarse portal label. + * UPDATE IN PROGRESS: drift on a previously healthy deploy (OutOfSync+Healthy), or an + * operation in flight while the app was already synced/healthy (typical in-place update). + * First-time install usually has health Progressing or sync Unknown, so it stays INSTALLATION. + */ +export function deploymentStatus( + enabled: boolean, + st?: StatusResponse +): DeploymentStatus { + if (!enabled) { + const rem = st?.remainingManagedApplications; + if (rem != null && rem > 0) { + return 'UNINSTALL IN PROGRESS'; + } + if (rem === 0 && !argoAppStatusPresent(st)) { + return 'NOT INSTALLED'; + } + return argoAppStatusPresent(st) ? 'UNINSTALL IN PROGRESS' : 'NOT INSTALLED'; + } + const sync = (st?.syncStatus ?? '').trim(); + const health = (st?.healthStatus ?? '').trim(); + const op = (st?.operationPhase ?? '').trim(); + const opBusy = op === 'Running' || op === 'Pending'; + + if (sync === 'Synced' && health === 'Healthy' && !opBusy) { + return 'READY'; + } + if (sync === 'OutOfSync' && health === 'Healthy') { + return 'UPDATE IN PROGRESS'; + } + if (opBusy && (health === 'Healthy' || sync === 'Synced')) { + return 'UPDATE IN PROGRESS'; + } + return 'INSTALLATION IN PROGRESS'; +} + +export function isReady( + enabled: boolean, + st?: StatusResponse +): boolean { + return deploymentStatus(enabled, st) === 'READY'; +} diff --git a/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/src/pages/LoginPage.tsx b/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/src/pages/LoginPage.tsx new file mode 100644 index 00000000..e0b9a1e1 --- /dev/null +++ b/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/src/pages/LoginPage.tsx @@ -0,0 +1,167 @@ +// Copyright (c) 2024-2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +import { useEffect, useState } from 'react'; +import { Navigate } from 'react-router-dom'; +import { + Box, + Button, + Card, + CardContent, + CircularProgress, + Divider, + TextField, + Typography, +} from '@mui/material'; +import LockOutlinedIcon from '@mui/icons-material/LockOutlined'; +import { authService } from '../utils/auth'; +import { HORIZON_LOGO_SRC } from '../constants'; + +export function LoginPage() { + const [user, setUser] = useState(''); + const [password, setPassword] = useState(''); + const [err, setErr] = useState(null); + const [loading, setLoading] = useState(false); + const [authed, setAuthed] = useState(null); + + useEffect(() => { + authService + .init() + .then(setAuthed) + .catch(() => setAuthed(false)); + }, []); + + if (authed === true) { + return ; + } + if (authed === null) { + return ( + + + + ); + } + + const onPasswordSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setErr(null); + setLoading(true); + try { + await authService.loginWithPassword(user, password); + } catch (e: unknown) { + setErr(e instanceof Error ? e.message : 'Login failed'); + setLoading(false); + } + }; + + return ( + + + + + + + Horizon Developer Portal + + + + + + + + + + + + Or use username and password (if enabled on the client) + + + + setUser(e.target.value)} + autoComplete="username" + /> + setPassword(e.target.value)} + autoComplete="current-password" + /> + {err && ( + + {err} + + )} + + + + + + ); +} diff --git a/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/src/pages/ModulePage.tsx b/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/src/pages/ModulePage.tsx new file mode 100644 index 00000000..c7fceca5 --- /dev/null +++ b/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/src/pages/ModulePage.tsx @@ -0,0 +1,2015 @@ +// Copyright (c) 2024-2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import CloudOutlinedIcon from '@mui/icons-material/CloudOutlined'; +import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'; +import DownloadOutlinedIcon from '@mui/icons-material/DownloadOutlined'; +import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined'; +import OpenInNewOutlinedIcon from '@mui/icons-material/OpenInNewOutlined'; +import StopOutlinedIcon from '@mui/icons-material/StopOutlined'; +import TerminalOutlinedIcon from '@mui/icons-material/TerminalOutlined'; +import { + Alert, + Box, + Button, + Card, + CardContent, + Chip, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Stack, + Tab, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Tabs, + TextField, + Tooltip, + Typography, + Paper, +} from '@mui/material'; +import { useTheme } from '@mui/material/styles'; +import { useParams, useSearchParams } from 'react-router-dom'; +import { apiHorizon, apiMm } from '../utils/api'; +import { config } from '../utils/config'; +import { + DIALOG_LAYOUT_COOKIE_WORKFLOW_DETAIL, + DIALOG_LAYOUT_COOKIE_WORKFLOW_LOGS, + useResizableDialogSize, +} from '../utils/dialogLayoutCookie'; +import { useMaxWorkflowLogLines } from '../utils/logBufferSettings'; +import { injectOverviewPortalTheme } from '../utils/overviewPortalTheme'; +import { workflowPassesSubmittedFromFilter } from '../utils/workflowVisibilityDiscovery'; +import type { + CatalogEntry, + CatalogResponse, + ModuleApplication, + ModuleResponse, + WorkflowDetail, + WorkflowListResponse, + WorkflowSummary, + OutputArtifact, + WorkflowsVisibilityDTO, +} from '../types'; + +type TabKey = 'overview' | 'applications' | 'templates' | 'running' | 'history'; + +const TAB_KEYS: TabKey[] = ['overview', 'applications', 'templates', 'running', 'history']; + +function parseTabKey(raw: string | null): TabKey { + if (!raw || !TAB_KEYS.includes(raw as TabKey)) { + return 'overview'; + } + return raw as TabKey; +} + +function resolveApplicationHref(url: string): string { + if (url.startsWith('http://') || url.startsWith('https://')) { + return url; + } + try { + return new URL(url, window.location.origin).href; + } catch { + return url; + } +} + +function useCatalogEntries(module: string | undefined) { + const [entries, setEntries] = useState([]); + const [err, setErr] = useState(null); + const load = useCallback(async () => { + if (!module) { + return; + } + try { + const r = await apiHorizon('/v1/catalog'); + if (!r.ok) { + throw new Error(`catalog ${r.status}`); + } + const j = (await r.json()) as CatalogResponse; + setEntries((j.entries || []).filter((e) => e.module === module)); + setErr(null); + } catch (e: unknown) { + setErr(e instanceof Error ? e.message : 'catalog'); + } + }, [module]); + useEffect(() => { + void load(); + const id = window.setInterval(load, 15000); + return () => window.clearInterval(id); + }, [load]); + return { entries, err, reload: load }; +} + +function templateNames(entries: CatalogEntry[]): Set { + return new Set(entries.map((e) => e.templateName)); +} + +export function ModulePage() { + const { name: moduleName = '' } = useParams<{ name: string }>(); + const [searchParams, setSearchParams] = useSearchParams(); + const tabFromUrl = parseTabKey(searchParams.get('tab')); + const setTab = (k: TabKey) => { + const n = new URLSearchParams(searchParams); + n.set('tab', k); + setSearchParams(n); + }; + + const { entries, err: catErr, reload: reloadCatalog } = useCatalogEntries(moduleName); + const names = useMemo(() => templateNames(entries), [entries]); + const hasTemplates = entries.length > 0; + + const [moduleApplications, setModuleApplications] = useState([]); + const [moduleMetaLoaded, setModuleMetaLoaded] = useState(false); + const hasApplications = moduleApplications.length > 0; + + // Must match a rendered Tab — MUI Tabs breaks when value has no matching Tab. + const activeTab: TabKey = useMemo(() => { + if (tabFromUrl === 'overview') { + return 'overview'; + } + if (tabFromUrl === 'applications') { + if (!moduleMetaLoaded) { + return 'overview'; + } + if (hasApplications) { + return 'applications'; + } + return 'overview'; + } + if ( + hasTemplates && + (tabFromUrl === 'templates' || tabFromUrl === 'running' || tabFromUrl === 'history') + ) { + return tabFromUrl; + } + return 'overview'; + }, [tabFromUrl, hasApplications, hasTemplates, moduleMetaLoaded]); + + useEffect(() => { + if (!moduleMetaLoaded) { + return; + } + if (activeTab !== tabFromUrl) { + setTab(activeTab); + } + }, [moduleMetaLoaded, activeTab, tabFromUrl, setTab]); + + const [runList, setRunList] = useState([]); + const [histList, setHistList] = useState([]); + /** Workflow names currently undergoing DELETE (blocking); History Result shows "Deletion in progress". */ + const [deletingWorkflows, setDeletingWorkflows] = useState>({}); + const [listErr, setListErr] = useState(null); + /** True when ModuleCatalog lists an in-cluster overview Service (Module Manager proxies GET /modules/{name}/overview). */ + const [overviewInCluster, setOverviewInCluster] = useState(false); + /** Raw HTML from Module Manager (theme applied in useMemo from MUI mode). */ + const [overviewHtmlRaw, setOverviewHtmlRaw] = useState(null); + const [overviewErr, setOverviewErr] = useState(null); + const [overviewLoading, setOverviewLoading] = useState(false); + const theme = useTheme(); + const overviewSrcDoc = useMemo(() => { + if (!overviewHtmlRaw) { + return null; + } + return injectOverviewPortalTheme(overviewHtmlRaw, theme.palette.mode); + }, [overviewHtmlRaw, theme.palette.mode]); + /** null = no filter (show all sources). */ + const [submittedFromAllowlist, setSubmittedFromAllowlist] = useState(null); + + useEffect(() => { + let cancelled = false; + (async () => { + try { + const r = await apiMm('/settings/workflows-visibility'); + if (!r.ok || cancelled) { + return; + } + const j = (await r.json()) as WorkflowsVisibilityDTO; + if (j.allowedSubmittedFrom === undefined) { + setSubmittedFromAllowlist(null); + } else { + setSubmittedFromAllowlist(j.allowedSubmittedFrom ?? []); + } + } catch { + if (!cancelled) { + setSubmittedFromAllowlist(null); + } + } + })(); + return () => { + cancelled = true; + }; + }, []); + + useEffect(() => { + if (!moduleName) { + setOverviewInCluster(false); + setOverviewHtmlRaw(null); + setModuleApplications([]); + setModuleMetaLoaded(true); + return; + } + let cancelled = false; + setModuleMetaLoaded(false); + setOverviewInCluster(false); + setOverviewErr(null); + (async () => { + try { + const r = await apiMm(`/modules/${encodeURIComponent(moduleName)}`); + if (!r.ok || cancelled) { + return; + } + const m = (await r.json()) as ModuleResponse; + if (!cancelled) { + const svc = (m.overviewService ?? '').trim(); + const ns = (m.overviewServiceNamespace ?? '').trim(); + setOverviewInCluster(!!(svc && ns)); + setModuleApplications(m.applications ?? []); + } + } catch { + if (!cancelled) { + setOverviewInCluster(false); + setModuleApplications([]); + } + } finally { + if (!cancelled) { + setModuleMetaLoaded(true); + } + } + })(); + return () => { + cancelled = true; + }; + }, [moduleName]); + + useEffect(() => { + if (!moduleName || !overviewInCluster) { + setOverviewHtmlRaw(null); + setOverviewErr(null); + setOverviewLoading(false); + return; + } + let cancelled = false; + setOverviewLoading(true); + setOverviewErr(null); + setOverviewHtmlRaw(null); + (async () => { + try { + const r = await apiMm(`/modules/${encodeURIComponent(moduleName)}/overview`); + if (cancelled) { + return; + } + if (!r.ok) { + const t = await r.text().catch(() => ''); + setOverviewErr(t.trim() || `HTTP ${r.status}`); + return; + } + const text = await r.text(); + if (!cancelled) { + setOverviewHtmlRaw(text); + } + } catch (e: unknown) { + if (!cancelled) { + setOverviewErr(e instanceof Error ? e.message : 'overview fetch failed'); + } + } finally { + if (!cancelled) { + setOverviewLoading(false); + } + } + })(); + return () => { + cancelled = true; + }; + }, [moduleName, overviewInCluster]); + + const loadRunning = useCallback(async () => { + try { + const r = await apiHorizon('/v1/workflows/running?limit=200'); + if (!r.ok) { + throw new Error(`running ${r.status}`); + } + const j = (await r.json()) as WorkflowListResponse; + const items = (j.items || []) + .filter((w) => (w.workflowTemplate ? names.has(w.workflowTemplate) : false)) + .filter((w) => workflowPassesSubmittedFromFilter(w, submittedFromAllowlist)); + setRunList(items); + setListErr(null); + } catch (e: unknown) { + setListErr(e instanceof Error ? e.message : 'running'); + } + }, [names, submittedFromAllowlist]); + + const loadHistory = useCallback(async () => { + try { + const r = await apiHorizon('/v1/workflows/history?limit=200'); + if (!r.ok) { + throw new Error(`history ${r.status}`); + } + const j = (await r.json()) as WorkflowListResponse; + const items = (j.items || []) + .filter((w) => (w.workflowTemplate ? names.has(w.workflowTemplate) : false)) + .filter((w) => workflowPassesSubmittedFromFilter(w, submittedFromAllowlist)); + setHistList(items); + setListErr(null); + } catch (e: unknown) { + setListErr(e instanceof Error ? e.message : 'history'); + } + }, [names, submittedFromAllowlist]); + + useEffect(() => { + if (activeTab === 'running') { + if (names.size === 0) { + setRunList([]); + return; + } + void loadRunning(); + // Short interval so phase (Running → Pending → Running, etc.) feels live while workflows execute. + const id = window.setInterval(loadRunning, 3000); + return () => window.clearInterval(id); + } + if (activeTab === 'history') { + if (names.size === 0) { + setHistList([]); + return; + } + void loadHistory(); + const id = window.setInterval(loadHistory, 4000); + return () => window.clearInterval(id); + } + }, [activeTab, names, loadRunning, loadHistory]); + + // After long idle / background tab, timers are throttled and the CI token may have rotated; + // refresh when the user comes back so we clear stale 401s without a full page reload. + useEffect(() => { + const onVisible = () => { + if (document.visibilityState !== 'visible') { + return; + } + void reloadCatalog(); + if (names.size === 0) { + return; + } + if (activeTab === 'running') { + void loadRunning(); + } else if (activeTab === 'history') { + void loadHistory(); + } + }; + document.addEventListener('visibilitychange', onVisible); + return () => document.removeEventListener('visibilitychange', onVisible); + }, [reloadCatalog, loadRunning, loadHistory, activeTab, names.size]); + + const [selTpl, setSelTpl] = useState(null); + const [detailName, setDetailName] = useState(null); + const [detailOpen, setDetailOpen] = useState(false); + + return ( + + + Module: {moduleName} + + {/* MUI Tabs must not wrap in Fragment — cloneElement only hits direct children. */} + setTab(v as TabKey)} sx={{ mb: 2 }}> + + {hasApplications ? : null} + {hasTemplates ? : null} + {hasTemplates ? : null} + {hasTemplates ? : null} + + + {catErr && ( + + {catErr} + + )} + {listErr && ( + + {listErr} + + )} + + {activeTab === 'overview' && ( + + + + About this module + + {!moduleMetaLoaded && moduleName ? ( + + + + ) : !overviewInCluster ? ( + + No overview is configured: add overviewService and{' '} + overviewServiceNamespace for this module in the ModuleCatalog so Module Manager can fetch + HTML from the in-cluster overview workload (see module Helm chart portal/overview.html). + + ) : overviewLoading ? ( + + + + ) : overviewErr ? ( + {overviewErr} + ) : overviewSrcDoc ? ( + + ) : null} + + + )} + {activeTab === 'applications' && hasApplications && ( + + {moduleApplications.map((app) => { + const href = resolveApplicationHref(app.url); + const label = (app.title ?? app.id).trim() || app.id; + return ( + + + + {label} + + + + + ); + })} + + )} + {activeTab === 'templates' && hasTemplates && ( + setSelTpl(e)} /> + )} + {activeTab === 'running' && hasTemplates && ( + { + setDetailName(n); + setDetailOpen(true); + }} + /> + )} + {activeTab === 'history' && hasTemplates && ( + { + setDetailName(n); + setDetailOpen(true); + }} + /> + )} + + {selTpl && ( + setSelTpl(null)} + onSubmitted={() => { + setSelTpl(null); + setTab('running'); + void loadRunning(); + }} + /> + )} + + {detailOpen && detailName && ( + setDetailOpen(false)} + onMutated={() => { + void loadRunning(); + void loadHistory(); + }} + onDeleteStarted={(n) => setDeletingWorkflows((p) => ({ ...p, [n]: true }))} + onDeleteFinished={(n) => + setDeletingWorkflows((p) => { + const q = { ...p }; + delete q[n]; + return q; + }) + } + /> + )} + + ); +} + +function TemplatesTab({ + entries, + onOpenSubmit, +}: { + entries: CatalogEntry[]; + onOpenSubmit: (e: CatalogEntry) => void; +}) { + if (entries.length === 0) { + return ( + + No exposed workflow templates for this module (see Horizon catalog). + + ); + } + return ( + + {entries.map((e) => ( + + + {e.templateName} + + {e.namespace} + + + + + ))} + + ); +} + +function parseIsoMs(iso: string | undefined): number { + if (!iso?.trim()) { + return NaN; + } + const t = Date.parse(iso); + return Number.isFinite(t) ? t : NaN; +} + +function formatLocaleDateTime(iso: string | undefined): string { + if (!iso?.trim()) { + return '—'; + } + const d = new Date(iso); + return Number.isFinite(d.getTime()) ? d.toLocaleString() : iso; +} + +/** Human-readable duration; when `liveEnd` is true and `finished` is missing, uses `Date.now()` for running workflows. */ +function formatDurationBetween(started?: string, finished?: string, liveEnd = false): string { + const a = parseIsoMs(started); + if (!Number.isFinite(a)) { + return '—'; + } + const b = parseIsoMs(finished); + const end = Number.isFinite(b) ? b : liveEnd ? Date.now() : NaN; + if (!Number.isFinite(end)) { + return '—'; + } + let sec = Math.max(0, Math.floor((end - a) / 1000)); + if (sec < 60) { + return `${sec}s`; + } + const m = Math.floor(sec / 60); + sec %= 60; + if (m < 60) { + return sec > 0 ? `${m}m ${sec}s` : `${m}m`; + } + const h = Math.floor(m / 60); + const mm = m % 60; + return mm > 0 ? `${h}h ${mm}m` : `${h}h`; +} + +function sortWorkflowsForHistory(items: WorkflowSummary[]): WorkflowSummary[] { + return [...items].sort((a, b) => { + const fb = parseIsoMs(b.finishedAt); + const fa = parseIsoMs(a.finishedAt); + if (Number.isFinite(fb) && Number.isFinite(fa) && fb !== fa) { + return fb - fa; + } + const sb = parseIsoMs(b.startedAt); + const sa = parseIsoMs(a.startedAt); + if (Number.isFinite(sb) && Number.isFinite(sa) && sb !== sa) { + return sb - sa; + } + return a.name.localeCompare(b.name); + }); +} + +/** Maps API label horizon-sdv.io/submitted-from to a short UI label. */ +function formatSubmittedFromLabel(v: string | undefined): string { + const raw = (v ?? '').trim(); + if (!raw) { + return '—'; + } + const s = raw.toLowerCase(); + if (s === 'developer-portal') { + return 'Developer portal'; + } + if (s === 'horizon-cli') { + return 'Horizon CLI'; + } + if (s === 'api') { + return 'REST API'; + } + return raw; +} + +function sortWorkflowsForRunning(items: WorkflowSummary[]): WorkflowSummary[] { + return [...items].sort((a, b) => { + const sb = parseIsoMs(b.startedAt); + const sa = parseIsoMs(a.startedAt); + if (Number.isFinite(sb) && Number.isFinite(sa) && sb !== sa) { + return sb - sa; + } + return a.name.localeCompare(b.name); + }); +} + +function runningPhaseChipColor(phase: string | undefined): 'default' | 'primary' | 'secondary' | 'error' | 'warning' | 'info' | 'success' { + const p = (phase ?? '').trim().toLowerCase(); + if (p === 'running') { + return 'info'; + } + if (p === 'pending') { + return 'warning'; + } + if (p === 'succeeded') { + return 'success'; + } + if (p === 'failed' || p === 'error' || p === 'aborted') { + return 'error'; + } + return 'primary'; +} + +function RunningTab({ + items, + onOpen, +}: { + items: WorkflowSummary[]; + onOpen: (name: string) => void; +}) { + const rows = useMemo(() => sortWorkflowsForRunning(items), [items]); + if (rows.length === 0) { + return No running workflows for this module.; + } + return ( + + + + + Template + Started + Duration + Triggered from + Phase + + + + {rows.map((w) => ( + onOpen(w.name)} + title={w.name} + > + + {w.workflowTemplate ?? '—'} + + {formatLocaleDateTime(w.startedAt)} + {formatDurationBetween(w.startedAt, w.finishedAt, true)} + {formatSubmittedFromLabel(w.submittedFrom)} + + + + + ))} + +
+
+ ); +} + +function HistoryTab({ + items, + onOpen, + deletingNames, +}: { + items: WorkflowSummary[]; + onOpen: (name: string) => void; + deletingNames?: Record; +}) { + const rows = useMemo(() => sortWorkflowsForHistory(items), [items]); + if (rows.length === 0) { + return No history for this module yet.; + } + return ( + + + + + Template + Started + Finished + Duration + Triggered from + Result + + + + {rows.map((w) => ( + onOpen(w.name)} + title={`Instance: ${w.name}`} + > + + {w.workflowTemplate ?? '—'} + + {formatLocaleDateTime(w.startedAt)} + {formatLocaleDateTime(w.finishedAt)} + {formatDurationBetween(w.startedAt, w.finishedAt, false)} + {formatSubmittedFromLabel(w.submittedFrom)} + + {deletingNames?.[w.name] ? ( + + ) : ( + + )} + + + ))} + +
+
+ ); +} + +function SubmitDialog({ + entry, + moduleName, + onClose, + onSubmitted, +}: { + entry: CatalogEntry; + moduleName: string; + onClose: () => void; + onSubmitted: () => void; +}) { + const [params, setParams] = useState>(() => { + const o: Record = {}; + for (const p of entry.parameters) { + o[p.name] = p.default ?? ''; + } + return o; + }); + const [err, setErr] = useState(null); + const [loading, setLoading] = useState(false); + + const submit = async () => { + setLoading(true); + setErr(null); + const ctrl = new AbortController(); + const tid = window.setTimeout(() => ctrl.abort(), 45000); + try { + const path = `/v1/modules/${encodeURIComponent(moduleName)}/workflowTemplates/${encodeURIComponent(entry.templateName)}/submit`; + const r = await apiHorizon(path, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Horizon-Submitted-From': 'developer-portal', + }, + body: JSON.stringify({ parameters: params }), + signal: ctrl.signal, + }); + if (!r.ok) { + const t = await r.text(); + throw new Error(t || `submit ${r.status}`); + } + onSubmitted(); + } catch (e: unknown) { + if (e instanceof DOMException && e.name === 'AbortError') { + setErr('Submit timed out waiting for Horizon API (check Argo Events webhook and cluster connectivity).'); + } else { + setErr(e instanceof Error ? e.message : 'submit failed'); + } + } finally { + window.clearTimeout(tid); + setLoading(false); + } + }; + + return ( + + Submit: {entry.templateName} + + {entry.parameters.map((p) => ( + + setParams((prev) => ({ ...prev, [p.name]: e.target.value })) + } + /> + ))} + {err && ( + + {err} + + )} + + + + + + + ); +} + +/** Mirrors Horizon API / workflow archive heuristics for log-type outputs. */ +function isLogArtifactName(name: string): boolean { + const n = name.toLowerCase().trim(); + /** Default Argo container / archived stdout artifact name (does not contain "log"). */ + if (n === 'main') { + return true; + } + return n.includes('log') || n === 'main-logs' || n === 'mainlogs'; +} + +/** + * Pick the log-type output artifact for this DAG row. Prefer exact node id; avoid `find` by templateName alone + * when multiple steps share a template name across nested workflows. + */ +function pickLogArtifactForNode( + logOutputs: OutputArtifact[], + n: { id: string; displayName?: string; templateName?: string }, +): OutputArtifact | undefined { + const sameNode = logOutputs.filter((a) => a.nodeId === n.id); + if (sameNode.length === 1) { + return sameNode[0]; + } + if (sameNode.length > 1) { + const namedLogs = sameNode.filter((a) => isLogArtifactName(a.name)); + return namedLogs.length === 1 ? namedLogs[0] : sameNode[0]; + } + const tpl = n.templateName?.trim(); + const disp = n.displayName?.trim(); + if (tpl && disp) { + const td = logOutputs.filter((a) => a.templateName === tpl && a.displayName === disp); + if (td.length === 1) { + return td[0]; + } + } + if (tpl) { + const tOnly = logOutputs.filter((a) => a.templateName === tpl); + if (tOnly.length === 1) { + return tOnly[0]; + } + } + if (disp) { + const dOnly = logOutputs.filter((a) => a.displayName === disp); + if (dOnly.length === 1) { + return dOnly[0]; + } + } + return undefined; +} + +/** Resolve archived GCS URI and Horizon download artifact for a workflow node. */ +function nodeArchivedLogLinks( + n: { id: string; displayName?: string; templateName?: string }, + archived: WorkflowSummary['archivedLogs'] | undefined, + logOutputs: OutputArtifact[], +): { gcsUri?: string; download?: OutputArtifact; artifactName?: string } { + let gcsUri: string | undefined; + let artifactName: string | undefined; + + const step = archived?.steps?.find((s) => { + if (!s.gcsUri) { + return false; + } + if (s.nodeId === n.id) { + return true; + } + const tpl = n.templateName?.trim(); + const disp = n.displayName?.trim(); + if (tpl && disp && s.templateName === tpl && s.displayName === disp) { + return true; + } + if (!s.nodeId && disp && s.displayName === disp) { + return true; + } + return false; + }); + if (step?.gcsUri) { + gcsUri = step.gcsUri; + } + if (step?.artifactName) { + artifactName = step.artifactName; + } + + const logArt = pickLogArtifactForNode(logOutputs, n); + + if (!gcsUri && logArt?.gcsUri) { + gcsUri = logArt.gcsUri; + } + + return { + gcsUri, + download: logArt, + artifactName, + }; +} + +/** Site origin for opening cluster UIs; strips a trailing `/workflows` if PUBLIC_BASE_URL mistakenly includes the Argo UI mount. */ +function argoBrowserBaseUrl(): string { + let base = config + .getString('baseUrl', typeof window !== 'undefined' ? window.location.origin : '') + .replace(/\/$/, ''); + while (/\/workflows$/i.test(base)) { + base = base.replace(/\/workflows$/i, '').replace(/\/$/, ''); + } + return base; +} + +/** Public Argo Workflows UI path (gateway serves UI under /workflows on the cluster domain). */ +function argoWorkflowUiUrl(workflowName: string, namespace: string): string { + const base = argoBrowserBaseUrl(); + return `${base}/workflows/${encodeURIComponent(namespace)}/${encodeURIComponent(workflowName)}`; +} + +/** Poll workflow GET so phase, DAG nodes, and archived log links stay current; slower interval when terminal (TTL detection). */ +const WORKFLOW_DETAIL_POLL_MS = 4000; + +function WorkflowDetailDialog({ + workflowName, + listDeletionPending, + open, + onClose, + onMutated, + onDeleteStarted, + onDeleteFinished, +}: { + workflowName: string; + /** Parent still has this workflow in deleting set (blocking DELETE may continue after dialog closes). */ + listDeletionPending: boolean; + open: boolean; + onClose: () => void; + /** Refresh running/history tables after delete or abort. */ + onMutated?: () => void; + /** Called after the user confirms delete, before the blocking DELETE request. */ + onDeleteStarted?: (name: string) => void; + /** Always called when the delete attempt finishes (success, HTTP error, or thrown). */ + onDeleteFinished?: (name: string) => void; +}) { + const [detail, setDetail] = useState(null); + const [err, setErr] = useState(null); + /** Abort request in flight only — does not block closing the dialog during a long DELETE. */ + const [abortBusy, setAbortBusy] = useState(false); + /** This dialog instance is awaiting the blocking DELETE response. */ + const [deleteBusy, setDeleteBusy] = useState(false); + const deletionPendingUi = deleteBusy || listDeletionPending; + /** null = unknown; true = Workflow CR still in cluster; false = deleted/TTL (Argo UI has nothing to open). */ + const [argoReachable, setArgoReachable] = useState(null); + /** `null` = closed; `{}` = whole workflow; otherwise pod logs or archived step log fetch. */ + const [logDialog, setLogDialog] = useState< + null | { + podName?: string; + stageLabel?: string; + /** When pods are gone (e.g. podGC), load main archived log via signed GCS URL. */ + archivedArtifact?: { artifactName: string; nodeId: string; templateName?: string }; + } + >(null); + + const dlgLayout = useResizableDialogSize({ + storageKey: DIALOG_LAYOUT_COOKIE_WORKFLOW_DETAIL, + defaultWidth: () => + typeof window !== 'undefined' ? Math.min(1200, Math.floor(window.innerWidth * 0.96)) : 1200, + defaultHeight: () => + typeof window !== 'undefined' ? Math.min(720, Math.floor(window.innerHeight * 0.88)) : 720, + minWidth: 720, + minHeight: 400, + }); + + useEffect(() => { + if (!open) { + return; + } + let cancelled = false; + setDetail(null); + setErr(null); + setArgoReachable(null); + (async () => { + try { + const r = await apiHorizon(`/v1/workflows/${encodeURIComponent(workflowName)}`); + if (!r.ok) { + if (r.status === 404) { + setArgoReachable(false); + } + throw new Error(`get workflow ${r.status}`); + } + if (cancelled) { + return; + } + setArgoReachable(true); + setDetail((await r.json()) as WorkflowDetail); + setErr(null); + } catch (e: unknown) { + if (!cancelled) { + setErr(e instanceof Error ? e.message : 'load'); + } + } + })(); + return () => { + cancelled = true; + }; + }, [open, workflowName]); + + useEffect(() => { + if (!open || !detail) { + return; + } + const terminal = isTerminalWorkflowPhase(detail.phase); + const period = terminal ? 20000 : WORKFLOW_DETAIL_POLL_MS; + + const tick = async () => { + try { + const r = await apiHorizon(`/v1/workflows/${encodeURIComponent(workflowName)}`); + if (!r.ok) { + if (r.status === 404) { + setArgoReachable(false); + } + return; + } + setArgoReachable(true); + const next = (await r.json()) as WorkflowDetail; + setDetail(next); + setErr(null); + } catch { + /* keep last successful detail on transient errors */ + } + }; + + const id = window.setInterval(() => { + void tick(); + }, period); + + return () => { + clearInterval(id); + }; + }, [open, workflowName, detail?.phase]); + + const argoUrl = + detail?.namespace && argoReachable !== false + ? argoWorkflowUiUrl(workflowName, detail.namespace) + : undefined; + + const terminal = detail ? isTerminalWorkflowPhase(detail.phase) : false; + const detailTitle = detail + ? (() => { + const root = detail.workflowTemplate?.trim() || 'Workflow'; + const deps = (detail.dependentWorkflowTemplates || []) + .map((v) => v.template.trim()) + .filter((v) => v.length > 0); + if (deps.length === 0) { + return root; + } + return `${root} (${deps.join(', ')})`; + })() + : 'Workflow'; + + const doAbort = async () => { + if ( + !window.confirm( + `Abort workflow "${workflowName}"? The run will stop gracefully (shutdown Stop) and may show as Aborted when finished.`, + ) + ) { + return; + } + setAbortBusy(true); + setErr(null); + try { + const r = await apiHorizon(`/v1/workflows/${encodeURIComponent(workflowName)}/abort`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: '{}', + }); + if (!r.ok) { + const t = await r.text(); + throw new Error(t || `abort ${r.status}`); + } + onMutated?.(); + const rr = await apiHorizon(`/v1/workflows/${encodeURIComponent(workflowName)}`); + if (rr.ok) { + setDetail((await rr.json()) as WorkflowDetail); + } + } catch (e: unknown) { + setErr(e instanceof Error ? e.message : 'abort failed'); + } finally { + setAbortBusy(false); + } + }; + + const doDelete = async () => { + if ( + !window.confirm( + `Permanently delete workflow "${workflowName}" from the cluster? This cannot be undone.`, + ) + ) { + return; + } + onDeleteStarted?.(workflowName); + setDeleteBusy(true); + setErr(null); + try { + const r = await apiHorizon(`/v1/workflows/${encodeURIComponent(workflowName)}`, { method: 'DELETE' }); + if (!r.ok) { + const t = await r.text(); + throw new Error(t || `delete ${r.status}`); + } + onMutated?.(); + onClose(); + } catch (e: unknown) { + setErr(e instanceof Error ? e.message : 'delete failed'); + } finally { + setDeleteBusy(false); + onDeleteFinished?.(workflowName); + } + }; + + const handleDialogClose = () => { + if (abortBusy) { + return; + } + onClose(); + }; + + return ( + <> + + + + + {detail?.namespace ? ( + + + + + + ) : null} + + {detailTitle} + + {detail ? ( + + {detail.module ? ( + + ) : null} + {detail.workflowTemplate ? ( + + ) : null} + {(detail.dependentWorkflowTemplates || []).map((dep) => ( + + {dep.module ? : null} + + + ))} + + ) : null} + + {workflowName} + + {deletionPendingUi ? ( + + Deletion in progress (waiting for cluster and artifact cleanup)… + + ) : null} + + {detail && ( + + {!terminal && ( + + + + + + )} + {terminal && ( + + + + + + )} + + )} + + + + {err && ( + + {err} + + )} + {!detail && !err && ( + + + + )} + {detail && ( + setLogDialog({})} + onShowNodeLogs={(opts) => setLogDialog(opts)} + /> + )} + + + + + {dlgLayout.ResizeHandle} + + {logDialog !== null && ( + setLogDialog(null)} + /> + )} + + ); +} + +function WorkflowDetailSections({ + wf, + detail, + onOpenClusterLogs, + onShowNodeLogs, +}: { + wf: string; + detail: WorkflowDetail; + onOpenClusterLogs: () => void; + onShowNodeLogs: (opts: { + podName?: string; + stageLabel: string; + archivedArtifact?: { artifactName: string; nodeId: string; templateName?: string }; + }) => void; +}) { + const arts = detail.outputArtifacts || []; + const fileArtifacts = arts.filter((a) => !isLogArtifactName(a.name)); + const logOutputArtifacts = arts.filter((a) => isLogArtifactName(a.name)); + const archived = detail.archivedLogs; + const terminal = isTerminalWorkflowPhase(detail.phase); + // Pod rows only for large DAGs; sort by startedAt + id so polling phase updates do not reshuffle rows. + const dagNodesForLogs = useMemo(() => { + const all = detail.nodes || []; + const pods = all.filter((n) => n.type === 'Pod'); + const rows = pods.length > 0 ? pods : all; + return [...rows].sort((a, b) => { + const ta = parseIsoMs(a.startedAt); + const tb = parseIsoMs(b.startedAt); + if (Number.isFinite(ta) && Number.isFinite(tb) && ta !== tb) { + return ta - tb; + } + if (Number.isFinite(ta) !== Number.isFinite(tb)) { + return Number.isFinite(ta) ? -1 : 1; + } + return (a.id || '').localeCompare(b.id || ''); + }); + }, [detail.nodes]); + + return ( + + + + + {detail.workflowTemplate ?? '—'} + + + + + + + Horizon API OAuth client:{' '} + + {config.getString('horizonApiOAuthClientId', 'horizon-api-ci')} + + + + + + + + + + Artifacts (files) + + + Non-log outputs from all steps — GCS location and signed download. + + {fileArtifacts.length === 0 ? ( + + None + + ) : ( + + {fileArtifacts.map((a) => ( + + + {a.fileName ?? a.name} + + {(a.displayName || a.templateName) && ( + + )} + {a.module && ( + + )} + {a.workflowTemplate && ( + + )} + {a.gcsUri && ( + + + + )} + + + ))} + + )} + + + + + + + + Cluster log stream + + + Live NDJSON from Horizon (all stages; tagged lines). Distinct from GCS log archives in the DAG nodes table. + + + + {terminal && } + + + + + + + DAG nodes + + + Show uses the live cluster log stream while the workflow is running. For finished workflows, Show loads + archived main logs from GCS in the same dialog when available. GCS / Download are the same archive objects + (not file/build artifacts). Module and Template list per-node fields from the API only — no workflow-level + fallback — so blanks mean the backend did not set them for that row. + + + + + + Module + Template + Stage + Phase + Pod + Show + GCS + Download + + + + {dagNodesForLogs.map((n) => { + const { gcsUri, download, artifactName } = nodeArchivedLogLinks(n, archived, logOutputArtifacts); + const stageLabel = n.displayName || n.id; + const archivedArtifactResolved = download + ? { + artifactName: download.name, + nodeId: download.nodeId ?? n.id, + templateName: download.templateName ?? n.templateName, + } + : artifactName + ? { artifactName, nodeId: n.id, templateName: n.templateName } + : undefined; + const archivedArtifactWhenNoPod = + !n.podName && archivedArtifactResolved ? archivedArtifactResolved : undefined; + const canShow = Boolean(n.podName) || Boolean(archivedArtifactResolved); + const showTitle = + terminal && archivedArtifactResolved + ? 'Archived main container log from GCS (finished workflow)' + : n.podName + ? 'Cluster logs for this pod (Horizon NDJSON stream)' + : archivedArtifactResolved + ? 'Archived main container log from GCS (pod may have been removed by podGC)' + : 'No pod yet — logs open when the step schedules a pod, or when archived logs appear in status'; + return ( + + {n.module?.trim() || '—'} + {n.workflowTemplate?.trim() || '—'} + + {stageLabel} + + {n.phase ?? '—'} + {n.podName ?? '—'} + + {canShow ? ( + + + + ) : ( + + + + + + )} + + + {gcsUri ? ( + + + + ) : ( + '—' + )} + + + {download ? ( + + ) : ( + '—' + )} + + + ); + })} + +
+
+
+
+ ); +} + +async function downloadArtifact(wf: string, a: OutputArtifact) { + const q = new URLSearchParams(); + if (a.nodeId) { + q.set('nodeId', a.nodeId); + } + if (a.templateName) { + q.set('templateName', a.templateName); + } + const path = `/v1/workflows/${encodeURIComponent(wf)}/downloadArtifact/${encodeURIComponent(a.name)}?${q.toString()}`; + const r = await apiHorizon(path); + if (r.status === 409) { + alert('Ambiguous artifact name — pick node/template in Horizon API.'); + return; + } + if (!r.ok) { + const t = await r.text(); + alert(t || `download ${r.status}`); + return; + } + const j = (await r.json()) as { url?: string }; + if (j.url) { + window.open(j.url, '_blank', 'noopener,noreferrer'); + } +} + +/** Matches Horizon API terminal phases (finished workflows — History tab). */ +function isTerminalWorkflowPhase(phase: string | undefined): boolean { + if (!phase) { + return false; + } + const p = phase.trim().toLowerCase(); + return ['succeeded', 'failed', 'error', 'aborted'].includes(p); +} + +function hasArchivedLogLinks(archived?: WorkflowSummary['archivedLogs']): boolean { + if (!archived) { + return false; + } + return (archived.steps || []).some((s) => Boolean(s.gcsUri)); +} + +/** Parsed `[stage]` prefix from our formatted log lines; empty if unlabeled. */ +function stageLabelFromDisplayLine(line: string): string { + const m = line.match(/^\[([^\]]+)\]\s+/); + return m ? m[1].trim() : ''; +} + +/** + * When over the cap, repeatedly remove the chronologically oldest line from the + * stage that has the most lines (tie: most total characters). That way a noisy + * "build" step sheds its own lines first instead of wiping out short earlier stages. + */ +function trimBySheddingBusiestStage(lines: string[], max: number): string[] { + if (lines.length <= max) { + return lines; + } + const out = lines.slice(); + while (out.length > max) { + const counts = new Map(); + for (const ln of out) { + const s = stageLabelFromDisplayLine(ln) || '_'; + counts.set(s, (counts.get(s) ?? 0) + 1); + } + let maxN = -1; + for (const n of counts.values()) { + if (n > maxN) { + maxN = n; + } + } + const tied = [...counts.entries()].filter(([, n]) => n === maxN).map(([s]) => s); + let victimStage = tied[0] ?? ''; + if (tied.length > 1) { + let bestChars = -1; + for (const s of tied) { + let chars = 0; + for (const ln of out) { + if ((stageLabelFromDisplayLine(ln) || '_') === s) { + chars += ln.length; + } + } + if (chars > bestChars) { + bestChars = chars; + victimStage = s; + } + } + } + const idx = out.findIndex((ln) => (stageLabelFromDisplayLine(ln) || '_') === victimStage); + if (idx < 0) { + out.shift(); + continue; + } + out.splice(idx, 1); + } + return out; +} + +function appendWorkflowLogLine(prev: string[], line: string, maxLines: number): string[] { + return trimBySheddingBusiestStage([...prev, line], maxLines); +} + +function buildWorkflowLogUrl(workflowName: string, follow: boolean, podName?: string): string { + const q = new URLSearchParams(); + q.set('follow', follow ? 'true' : 'false'); + if (podName) { + q.set('podName', podName); + } + return `/v1/workflows/${encodeURIComponent(workflowName)}/log?${q.toString()}`; +} + +/** + * Loads archived artifact text via Horizon (`inline=1`), which proxies GCS server-side — avoids browser CORS on + * direct `storage.googleapis.com` signed URLs. + */ +async function horizonArtifactInlineText( + wf: string, + sel: { artifactName: string; nodeId: string; templateName?: string }, + signal?: AbortSignal, +): Promise { + const q = new URLSearchParams(); + q.set('inline', '1'); + if (sel.nodeId) { + q.set('nodeId', sel.nodeId); + } + if (sel.templateName) { + q.set('templateName', sel.templateName); + } + const path = `/v1/workflows/${encodeURIComponent(wf)}/downloadArtifact/${encodeURIComponent(sel.artifactName)}?${q.toString()}`; + const r = await apiHorizon(path, { signal }); + if (r.status === 409) { + throw new Error('Ambiguous artifact name — add templateName or use Download in the table.'); + } + if (!r.ok) { + const t = await r.text(); + throw new Error(t || `artifact ${r.status}`); + } + return r.text(); +} + +function LogStreamDialog({ + workflowName, + phase, + archivedLogs, + podName, + stageLabel, + archivedArtifact, + onClose, +}: { + workflowName: string; + phase?: string; + archivedLogs?: WorkflowSummary['archivedLogs']; + /** When set, stream only this pod’s logs (Horizon `podName` query). */ + podName?: string; + /** Shown under the title for context (stage / node label). */ + stageLabel?: string; + /** When pods are gone, fetch archived main log text from GCS via Horizon-signed URL. */ + archivedArtifact?: { artifactName: string; nodeId: string; templateName?: string }; + onClose: () => void; +}) { + const [lines, setLines] = useState([]); + const [err, setErr] = useState(null); + const [emptyHint, setEmptyHint] = useState(false); + const [fromArchive, setFromArchive] = useState(false); + const maxLines = useMaxWorkflowLogLines(); + const maxLinesRef = useRef(maxLines); + maxLinesRef.current = maxLines; + + const logDlgLayout = useResizableDialogSize({ + storageKey: DIALOG_LAYOUT_COOKIE_WORKFLOW_LOGS, + defaultWidth: () => + typeof window !== 'undefined' ? Math.min(1280, Math.floor(window.innerWidth * 0.96)) : 1280, + defaultHeight: () => + typeof window !== 'undefined' ? Math.min(820, Math.floor(window.innerHeight * 0.9)) : 820, + minWidth: 720, + minHeight: 400, + }); + + const terminal = isTerminalWorkflowPhase(phase); + + useEffect(() => { + const ac = new AbortController(); + let cancelled = false; + setLines([]); + setErr(null); + setEmptyHint(false); + setFromArchive(false); + + if (!podName && archivedArtifact) { + (async () => { + try { + const text = await horizonArtifactInlineText(workflowName, archivedArtifact, ac.signal); + if (cancelled) { + return; + } + const raw = text.split('\n'); + const cap = maxLinesRef.current; + setLines(raw.length > cap ? raw.slice(-cap) : raw); + setFromArchive(true); + if (!text.trim()) { + setEmptyHint(true); + } + } catch (e: unknown) { + if ((e as Error).name === 'AbortError') { + return; + } + setErr(e instanceof Error ? e.message : 'archived log load'); + } + })(); + return () => { + cancelled = true; + ac.abort(); + }; + } + + void (async () => { + let sawLogLine = false; + let reportedUpstream = false; + try { + const url = buildWorkflowLogUrl(workflowName, !terminal, podName); + const r = await apiHorizon(url, { signal: ac.signal }); + if (!r.ok) { + throw new Error(`logs ${r.status}`); + } + const reader = r.body?.getReader(); + if (!reader) { + throw new Error('no stream'); + } + const dec = new TextDecoder(); + let buf = ''; + while (!cancelled) { + const { done, value } = await reader.read(); + if (done) { + break; + } + buf += dec.decode(value, { stream: true }); + const parts = buf.split('\n'); + buf = parts.pop() ?? ''; + for (const line of parts) { + const t = line.trim(); + if (!t) { + continue; + } + try { + const o = JSON.parse(t) as { + heartbeat?: boolean; + result?: string; + reason?: string; + detail?: string; + msg?: string; + line?: string; + message?: string; + displayName?: string; + templateName?: string; + podName?: string; + nodeId?: string; + }; + if (o.heartbeat) { + continue; + } + if (o.result === 'done') { + if (o.reason === 'upstream_error' && o.detail) { + reportedUpstream = true; + setErr(o.detail); + } + continue; + } + const label = + o.displayName?.trim() || + o.templateName?.trim() || + o.podName?.trim() || + o.nodeId?.trim() || + ''; + const body = o.msg ?? o.line ?? o.message ?? t; + const text = label ? `[${label}] ${body}` : body; + sawLogLine = true; + setLines((prev) => appendWorkflowLogLine(prev, text, maxLinesRef.current)); + } catch { + sawLogLine = true; + setLines((prev) => appendWorkflowLogLine(prev, t, maxLinesRef.current)); + } + } + } + if (!cancelled && terminal && !sawLogLine && !reportedUpstream) { + setEmptyHint(true); + } + } catch (e: unknown) { + if ((e as Error).name === 'AbortError') { + return; + } + setErr(e instanceof Error ? e.message : 'log stream'); + } + })(); + return () => { + cancelled = true; + ac.abort(); + }; + }, [workflowName, phase, podName, archivedArtifact?.artifactName, archivedArtifact?.nodeId, archivedArtifact?.templateName]); + + return ( + + + Logs — {workflowName} + {podName ? ( + + {stageLabel ? `${stageLabel} · ` : ''} + + {podName} + + + ) : archivedArtifact ? ( + + {stageLabel ? `${stageLabel} · ` : ''} + archived ({archivedArtifact.artifactName}) + + ) : null} + + + {terminal && !fromArchive && ( + + Finished workflows often have no live cluster logs (pods removed). Prefer{' '} + Archived log links (GCS / Download in the DAG nodes table) on the workflow details panel when available. + + )} + {fromArchive && ( + + Loaded from Argo archived main container log in GCS (step pod may have been removed after the step finished). + + )} + {err && {err}} + {emptyHint && !err && ( + + No log lines were returned from the cluster stream. + {hasArchivedLogLinks(archivedLogs) + ? ' Open the previous dialog and use GCS or Download in the DAG nodes table.' + : ' If logging was configured to archive, check the workflow in Argo or GCS.'} + + )} + + Buffer up to {maxLines.toLocaleString()} lines (configure under Administration → Settings). When full, lines + drop from the busiest step first so short stages (e.g. checks) are kept longer than a very chatty build. + + + {lines.length > 0 ? lines.join('\n') : emptyHint || err ? '' : 'Loading…'} + + + + + + {logDlgLayout.ResizeHandle} + + ); +} diff --git a/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/src/pages/WelcomePage.tsx b/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/src/pages/WelcomePage.tsx new file mode 100644 index 00000000..055b2953 --- /dev/null +++ b/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/src/pages/WelcomePage.tsx @@ -0,0 +1,203 @@ +// Copyright (c) 2024-2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +import type { ReactNode } from 'react'; +import { + Box, + Button, + Card, + CardContent, + Container, + Link as MuiLink, + Paper, + Stack, + Typography, +} from '@mui/material'; +import { alpha, useTheme } from '@mui/material/styles'; +import AccountTreeIcon from '@mui/icons-material/AccountTree'; +import AdminPanelSettingsIcon from '@mui/icons-material/AdminPanelSettings'; +import CloudQueueIcon from '@mui/icons-material/CloudQueue'; +import RocketLaunchIcon from '@mui/icons-material/RocketLaunch'; +import ViewModuleIcon from '@mui/icons-material/ViewModule'; +import { Link } from 'react-router-dom'; +import { HORIZON_LOGO_SRC } from '../constants'; +import { authService } from '../utils/auth'; + +const HORIZON_SDV_REPO = 'https://github.com/GoogleCloudPlatform/horizon-sdv'; +const DEPLOYMENT_GUIDE_URL = + 'https://github.com/GoogleCloudPlatform/horizon-sdv/blob/main/docs/deployment_guide.md'; + +type ConceptCard = { + icon: ReactNode; + title: string; + body: string; +}; + +const CONCEPT_CARDS: ConceptCard[] = [ + { + icon: , + title: 'Horizon & SDV', + body: + 'Horizon is a cloud-hosted toolchain for building, testing, and releasing complex embedded software in the automotive SDV space. The goal is simple: the platform should not be your differentiator—your product should.', + }, + { + icon: , + title: 'This developer portal', + body: + 'You are signed in to a small SPA that talks to Module Manager and the Horizon API. When a module is enabled and healthy, it reaches READY and appears in the sidebar for day-to-day use.', + }, + { + icon: , + title: 'Modules & workflows', + body: + 'Under Administration → Modules, turn workloads on or off. Administration → Settings holds global options such as workflow visibility by submit source. Each READY module exposes overview, workflow templates, running workflows, and history.', + }, + { + icon: , + title: 'Platform delivery', + body: + 'Horizon environments are typically stood up with Terraform and kept in sync with Argo CD. That pattern keeps clusters, services, and GitOps changes traceable and repeatable.', + }, +]; + +export function WelcomePage() { + const theme = useTheme(); + const username = authService.getUsername(); + const isDark = theme.palette.mode === 'dark'; + + const heroGradient = isDark + ? `linear-gradient(135deg, ${alpha(theme.palette.primary.dark, 0.42)} 0%, ${alpha( + theme.palette.background.paper, + 0.92 + )} 52%, ${theme.palette.background.paper} 100%)` + : `linear-gradient(135deg, ${alpha(theme.palette.primary.light, 0.28)} 0%, ${alpha( + theme.palette.primary.main, + 0.1 + )} 48%, ${theme.palette.background.paper} 100%)`; + + return ( + + + + + + + + Software-defined vehicle toolchain + + + Welcome{username ? `, ${username}` : ''} + + + Explore Horizon modules, kick off workflows, and tune how work appears in this + cluster—all from one place after sign-in. + + + + + + + + + + + + {CONCEPT_CARDS.map((card) => ( + + + + {card.icon} + + {card.title} + + + {card.body} + + + + + ))} + + + + + + Learn more + + + New to the program? The public repo has the full story, contribution guide, and + deployment walkthrough. + + + + Horizon SDV on GitHub + + + Deployment guide + + + + + + + ); +} diff --git a/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/src/pages/admin/AdminLayout.tsx b/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/src/pages/admin/AdminLayout.tsx new file mode 100644 index 00000000..6d1ddf4d --- /dev/null +++ b/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/src/pages/admin/AdminLayout.tsx @@ -0,0 +1,41 @@ +// Copyright (c) 2024-2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +import { Box, Tab, Tabs, Typography } from '@mui/material'; +import { Outlet, useLocation, useNavigate } from 'react-router-dom'; + +export function AdminLayout() { + const location = useLocation(); + const navigate = useNavigate(); + const tab = location.pathname.includes('/admin/settings') ? 'settings' : 'modules'; + + return ( + + + Administration + + { + navigate(v === 'settings' ? '/admin/settings' : '/admin/modules'); + }} + sx={{ mb: 2 }} + > + + + + + + ); +} diff --git a/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/src/pages/admin/ModulesTab.tsx b/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/src/pages/admin/ModulesTab.tsx new file mode 100644 index 00000000..3a958ed4 --- /dev/null +++ b/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/src/pages/admin/ModulesTab.tsx @@ -0,0 +1,296 @@ +// Copyright (c) 2024-2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +import { useCallback, useEffect, useState } from 'react'; +import { + Alert, + Box, + Button, + Card, + CardContent, + CircularProgress, + FormControlLabel, + Grid, + Switch, + TextField, + Typography, + Chip, +} from '@mui/material'; +import { apiMm } from '../../utils/api'; +import type { ModuleResponse, StatusResponse } from '../../types'; +import { deploymentStatus } from '../../moduleStatus'; + +async function fetchModules(): Promise { + const r = await apiMm('/modules'); + if (!r.ok) { + throw new Error(`modules: ${r.status}`); + } + return r.json() as Promise; +} + +async function fetchStatus(idOrName: string): Promise { + const r = await apiMm(`/modules/${encodeURIComponent(idOrName)}/status`); + if (!r.ok) { + throw new Error(`status: ${r.status}`); + } + return r.json() as Promise; +} + +export function ModulesTab() { + const [mods, setMods] = useState([]); + const [statuses, setStatuses] = useState>({}); + const [refDraft, setRefDraft] = useState>({}); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + const [busy, setBusy] = useState(null); + + const refresh = useCallback(async (): Promise => { + try { + const list = await fetchModules(); + setRefDraft((prev) => { + const next = { ...prev }; + for (const m of list) { + const serverRef = + (m.targetRevision ?? m.clusterTargetRevision ?? '').trim() || ''; + if (next[m.name] === undefined) { + next[m.name] = serverRef; + } + } + return next; + }); + const st: Record = {}; + await Promise.all( + list.map(async (m) => { + try { + st[m.name] = await fetchStatus(m.name); + } catch { + st[m.name] = {}; + } + }) + ); + setMods(list); + setStatuses(st); + setError(null); + const allReady = list.every((m) => { + const label = deploymentStatus(m.enabled, st[m.name]); + if (!m.enabled) { + return label === 'NOT INSTALLED'; + } + return label === 'READY'; + }); + return allReady; + } catch (e: unknown) { + setError(e instanceof Error ? e.message : 'Load failed'); + return true; + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + let cancelled = false; + let timeoutId = 0; + + const schedule = (delay: number) => { + timeoutId = window.setTimeout(async () => { + if (cancelled) { + return; + } + const allReady = await refresh(); + if (cancelled) { + return; + } + schedule(allReady ? 8000 : 3000); + }, delay); + }; + + schedule(0); + return () => { + cancelled = true; + window.clearTimeout(timeoutId); + }; + }, [refresh]); + + const applyRef = async (m: ModuleResponse) => { + const ref = (refDraft[m.name] ?? '').trim(); + if (!ref) { + setError('Git ref cannot be empty'); + return; + } + setBusy(m.name); + setError(null); + try { + const r = await apiMm(`/modules/${encodeURIComponent(m.name)}/target-revision`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ targetRevision: ref }), + }); + if (!r.ok) { + const t = await r.text(); + throw new Error(t || `set ref ${r.status}`); + } + await refresh(); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : 'Request failed'); + } finally { + setBusy(null); + } + }; + + const toggle = async (m: ModuleResponse, enable: boolean) => { + setBusy(m.name); + setError(null); + try { + if (enable) { + const refTrim = (refDraft[m.name] ?? '').trim(); + const init: RequestInit = { + method: 'POST', + headers: refTrim ? { 'Content-Type': 'application/json' } : undefined, + body: refTrim ? JSON.stringify({ targetRevision: refTrim }) : undefined, + }; + const r = await apiMm(`/modules/${encodeURIComponent(m.name)}/enable`, init); + if (!r.ok) { + const t = await r.text(); + throw new Error(t || `enable ${r.status}`); + } + } else { + const r = await apiMm(`/modules/${encodeURIComponent(m.name)}/disable`, { + method: 'DELETE', + }); + if (r.status === 409) { + const j = (await r.json()) as { hardDependents?: string[] }; + throw new Error( + `Cannot disable: required by: ${(j.hardDependents ?? []).join(', ') || 'other modules'}` + ); + } + if (!r.ok) { + const t = await r.text(); + throw new Error(t || `disable ${r.status}`); + } + } + await refresh(); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : 'Request failed'); + } finally { + setBusy(null); + } + }; + + if (loading && mods.length === 0) { + return ( + + + + ); + } + + return ( + + + Enable or disable modules. Hard dependencies are enabled automatically. Set each + module's Git ref (branch, tag, or commit) before enabling or use Apply on an + enabled module to switch refs. Dependent modules are shown on each card. + + {error && ( + setError(null)}> + {error} + + )} + + {mods.map((m) => { + const st = statuses[m.name]; + const label = deploymentStatus(m.enabled, st); + const color = + label === 'READY' + ? 'success' + : label === 'NOT INSTALLED' + ? 'default' + : label === 'UPDATE IN PROGRESS' + ? 'info' + : 'warning'; + return ( + + + + {m.name} + + + + + Effective ref: {m.targetRevision || m.clusterTargetRevision || '—'} + + + setRefDraft((prev) => ({ ...prev, [m.name]: e.target.value })) + } + sx={{ mt: 1 }} + /> + + {m.enabled ? ( + + ) : null} + + void toggle(m, v)} + /> + } + label={m.enabled ? 'Enabled' : 'Disabled'} + /> + {(m.hardDependencies?.length || m.softDependencies?.length) ? ( + + {m.hardDependencies?.length ? ( + <>Hard deps: {m.hardDependencies.join(', ')}. + ) : null} + {m.softDependencies?.length ? ( + <>Soft deps: {m.softDependencies.join(', ')} + ) : null} + + ) : null} + {m.enabled && (m.hardDependents?.length || m.softDependents?.length) ? ( + + {m.hardDependents?.length ? ( + <>Hard dependents: {m.hardDependents.join(', ')}. + ) : null} + {m.softDependents?.length ? ( + <>Soft dependents: {m.softDependents.join(', ')} + ) : null} + + ) : null} + + + + ); + })} + + + ); +} diff --git a/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/src/pages/admin/SettingsTab.tsx b/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/src/pages/admin/SettingsTab.tsx new file mode 100644 index 00000000..ea293cdd --- /dev/null +++ b/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/src/pages/admin/SettingsTab.tsx @@ -0,0 +1,294 @@ +// Copyright (c) 2024-2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +import { useCallback, useEffect, useState } from 'react'; +import { + Alert, + Box, + Button, + Card, + CardContent, + Checkbox, + CircularProgress, + FormControlLabel, + FormGroup, + Stack, + TextField, + Typography, +} from '@mui/material'; +import { apiHorizon, apiMm } from '../../utils/api'; +import type { WorkflowListResponse, WorkflowSummary, WorkflowsVisibilityDTO } from '../../types'; +import { + collectSubmittedFromFromWorkflows, + extractSubmittedFromEnumsFromOpenAPI, + mergeSourceOptions, +} from '../../utils/workflowVisibilityDiscovery'; +import { + DEFAULT_MAX_WORKFLOW_LOG_LINES, + MAX_MAX_WORKFLOW_LOG_LINES, + MIN_MAX_WORKFLOW_LOG_LINES, + readMaxWorkflowLogLines, + writeMaxWorkflowLogLines, +} from '../../utils/logBufferSettings'; + +export function SettingsTab() { + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [err, setErr] = useState(null); + const [savedMsg, setSavedMsg] = useState(null); + + const [options, setOptions] = useState([]); + /** null = no restriction (show all). Empty = hide all. */ + const [selected, setSelected] = useState(null); + + const [logBufferLines, setLogBufferLines] = useState(String(DEFAULT_MAX_WORKFLOW_LOG_LINES)); + const [logBufferErr, setLogBufferErr] = useState(null); + const [logBufferSaved, setLogBufferSaved] = useState(null); + + const load = useCallback(async () => { + setLoading(true); + setErr(null); + setSavedMsg(null); + try { + const [mmRes, openRes, runRes, histRes] = await Promise.all([ + apiMm('/settings/workflows-visibility'), + apiHorizon('/openapi.json'), + apiHorizon('/v1/workflows/running?limit=200'), + apiHorizon('/v1/workflows/history?limit=200'), + ]); + + let openapiEnums: string[] = []; + if (openRes.ok) { + try { + const doc = (await openRes.json()) as unknown; + openapiEnums = extractSubmittedFromEnumsFromOpenAPI(doc); + } catch { + openapiEnums = extractSubmittedFromEnumsFromOpenAPI({}); + } + } else { + openapiEnums = extractSubmittedFromEnumsFromOpenAPI({}); + } + + const wfItems: WorkflowSummary[] = []; + if (runRes.ok) { + const jr = (await runRes.json()) as WorkflowListResponse; + wfItems.push(...(jr.items || [])); + } + if (histRes.ok) { + const jh = (await histRes.json()) as WorkflowListResponse; + wfItems.push(...(jh.items || [])); + } + const observed = collectSubmittedFromFromWorkflows(wfItems); + const merged = mergeSourceOptions(openapiEnums, observed); + setOptions(merged); + + if (!mmRes.ok) { + throw new Error(`settings: ${mmRes.status}`); + } + const cur = (await mmRes.json()) as WorkflowsVisibilityDTO; + if (cur.allowedSubmittedFrom === undefined) { + setSelected(null); + } else { + setSelected([...(cur.allowedSubmittedFrom ?? [])]); + } + + setLogBufferLines(String(readMaxWorkflowLogLines())); + setLogBufferErr(null); + setLogBufferSaved(null); + } catch (e: unknown) { + setErr(e instanceof Error ? e.message : 'load failed'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + const save = async () => { + setSaving(true); + setErr(null); + setSavedMsg(null); + try { + const r = await apiMm('/settings/workflows-visibility', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify( + selected === null ? { allowedSubmittedFrom: null } : { allowedSubmittedFrom: selected } + ), + }); + if (!r.ok) { + const t = await r.text(); + throw new Error(t || `save ${r.status}`); + } + setSavedMsg('Saved.'); + void load(); + } catch (e: unknown) { + setErr(e instanceof Error ? e.message : 'save failed'); + } finally { + setSaving(false); + } + }; + + const saveLogBuffer = () => { + setLogBufferErr(null); + setLogBufferSaved(null); + const n = Number.parseInt(logBufferLines.trim(), 10); + if (!Number.isFinite(n)) { + setLogBufferErr('Enter a whole number.'); + return; + } + if (n < MIN_MAX_WORKFLOW_LOG_LINES || n > MAX_MAX_WORKFLOW_LOG_LINES) { + setLogBufferErr( + `Must be between ${MIN_MAX_WORKFLOW_LOG_LINES.toLocaleString()} and ${MAX_MAX_WORKFLOW_LOG_LINES.toLocaleString()}.` + ); + return; + } + const v = writeMaxWorkflowLogLines(n); + setLogBufferLines(String(v)); + setLogBufferSaved(`Saved. Log dialogs will buffer up to ${v.toLocaleString()} lines (browser memory scales with line count and line length).`); + }; + + if (loading) { + return ( + + + + ); + } + + const noRestriction = selected === null; + const checked = (v: string) => (selected === null ? true : selected.includes(v)); + + const toggleOne = (v: string) => { + if (selected === null) { + setSelected(options.length ? [...options] : []); + } + setSelected((prev) => { + const base = prev === null ? [...options] : [...prev]; + const i = base.indexOf(v); + if (i >= 0) { + base.splice(i, 1); + } else { + base.push(v); + } + return base; + }); + }; + + return ( + + + Control which workflow runs appear under Running Workflows and{' '} + History in each module, based on the{' '} + horizon-sdv.io/submitted-from label (REST API, Developer Portal, Horizon CLI, or + overrides). Options combine Horizon OpenAPI enums with values seen in recent workflows. + + {err && ( + setErr(null)}> + {err} + + )} + {savedMsg && ( + setSavedMsg(null)}> + {savedMsg} + + )} + {logBufferErr && ( + setLogBufferErr(null)}> + {logBufferErr} + + )} + {logBufferSaved && ( + setLogBufferSaved(null)}> + {logBufferSaved} + + )} + + + + Workflows visibility + + { + if (c) { + setSelected(null); + } else { + setSelected([...options]); + } + }} + /> + } + label="Show all sources (no filter)" + /> + + Uncheck to restrict to specific sources below. An empty selection hides all workflows. + + + {options.map((v) => ( + toggleOne(v)} + /> + } + label={{v}} + /> + ))} + + + + + + + + + Workflow log buffer (browser) + + + Maximum number of lines kept in memory for the live / archived log viewer on module workflow pages. Higher + values use more RAM in this browser tab (roughly proportional to total text size). Allowed range:{' '} + {MIN_MAX_WORKFLOW_LOG_LINES.toLocaleString()}–{MAX_MAX_WORKFLOW_LOG_LINES.toLocaleString()} (default{' '} + {DEFAULT_MAX_WORKFLOW_LOG_LINES.toLocaleString()}). Stored only in this browser (localStorage). + + setLogBufferLines(e.target.value)} + sx={{ maxWidth: 280 }} + /> + + + + + ); +} diff --git a/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/src/theme.ts b/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/src/theme.ts new file mode 100644 index 00000000..81091050 --- /dev/null +++ b/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/src/theme.ts @@ -0,0 +1,115 @@ +// Copyright (c) 2024-2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +import { createTheme } from '@mui/material/styles'; +import type { PaletteMode } from '@mui/material/styles'; + +export const getTheme = (mode: PaletteMode) => + createTheme({ + palette: { + mode, + primary: { + main: '#1a73e8', + light: '#4285f4', + dark: '#1557b0', + contrastText: '#fff', + }, + secondary: { + main: '#34a853', + light: '#81c995', + dark: '#188038', + contrastText: '#fff', + }, + error: { + main: '#ea4335', + light: '#ee675c', + dark: '#c5221f', + }, + warning: { + main: '#fbbc04', + light: '#fdd663', + dark: '#f29900', + }, + info: { + main: '#4285f4', + light: '#669df6', + dark: '#1967d2', + }, + success: { + main: '#34a853', + light: '#81c995', + dark: '#188038', + }, + ...(mode === 'light' + ? { + background: { + default: '#f8f9fa', + paper: '#ffffff', + }, + text: { + primary: '#202124', + secondary: '#5f6368', + }, + } + : { + background: { + default: '#202124', + paper: '#292a2d', + }, + text: { + primary: '#e8eaed', + secondary: '#9aa0a6', + }, + }), + }, + typography: { + fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif', + button: { + textTransform: 'none', + fontWeight: 500, + }, + }, + shape: { + borderRadius: 8, + }, + components: { + MuiButton: { + styleOverrides: { + root: { + borderRadius: 4, + textTransform: 'none', + fontWeight: 500, + padding: '8px 24px', + }, + }, + }, + MuiCard: { + styleOverrides: { + root: { + boxShadow: + '0 1px 2px 0 rgba(60,64,67,.3), 0 1px 3px 1px rgba(60,64,67,.15)', + borderRadius: 8, + }, + }, + }, + MuiDrawer: { + styleOverrides: { + paper: { + borderRight: '1px solid', + borderColor: mode === 'light' ? '#e8eaed' : '#3c4043', + }, + }, + }, + }, + }); diff --git a/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/src/types.ts b/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/src/types.ts new file mode 100644 index 00000000..2fd36cb3 --- /dev/null +++ b/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/src/types.ts @@ -0,0 +1,161 @@ +// Copyright (c) 2024-2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +export interface AppConfig { + baseUrl?: string; + /** + * URL path prefix for this SPA (leading slash, no trailing slash), e.g. `/developer-portal`. + * Empty or omitted when served at site root. Must match Gateway path prefix and Keycloak redirect URIs. + */ + publicPath?: string; + keycloakUrl?: string; + keycloakClientId?: string; + /** Keycloak OAuth client id used for client-credentials calls to Horizon API via this app's proxy (not the SPA login client). */ + horizonApiOAuthClientId?: string; + /** Optional default from config.js when localStorage has no `theme` key */ + theme?: 'light' | 'dark'; +} + +/** Module child application link from Module Manager catalog (GET /modules). */ +export interface ModuleApplication { + id: string; + title?: string; + /** Site-relative path (/) or absolute URL. */ + url: string; +} + +export interface ModuleResponse { + id: string; + name: string; + enabled: boolean; + /** Repo-relative path under the catalog module path; used for Helm packaging (portal/overview.html), not a runtime Git path. */ + overviewPath?: string; + /** In-cluster Service name; with overviewServiceNamespace, Module Manager serves GET /modules/{name}/overview via HTTP inside the cluster (port 80, path /). */ + overviewService?: string; + overviewServiceNamespace?: string; + hardDependencies?: string[]; + softDependencies?: string[]; + hardDependents?: string[]; + softDependents?: string[]; + applicationName?: string; + applicationNamespace?: string; + /** Catalog-defined links (e.g. public Gateway paths for child apps). */ + applications?: ModuleApplication[]; + /** Effective Git ref (branch, tag, commit) for the module Argo CD Application. */ + targetRevision?: string; + /** Module Manager cluster default ref (--target-revision). */ + clusterTargetRevision?: string; +} + +/** GET/PUT /settings/workflows-visibility (Module Manager). */ +export interface WorkflowsVisibilityDTO { + /** Omitted after GET means no filter. Empty array means hide all. */ + allowedSubmittedFrom?: string[] | null; +} + +export interface StatusResponse { + syncStatus?: string; + healthStatus?: string; + operationPhase?: string; + desiredRevision?: string; + syncRevision?: string; + /** RFC3339; set when the Argo CD Application has metadata.deletionTimestamp */ + applicationDeletionTimestamp?: string; + /** Module Manager: when module is disabled, parent+child Argo CD Applications still present */ + remainingManagedApplications?: number; +} + +export interface CatalogEntry { + module: string; + templateName: string; + namespace: string; + parameters: { name: string; default?: string; description?: string }[]; +} + +export interface CatalogResponse { + entries: CatalogEntry[]; +} + +export interface WorkflowSummary { + name: string; + namespace: string; + phase: string; + startedAt?: string; + finishedAt?: string; + workflowTemplate?: string; + /** Horizon portal user (annotation) or Argo creator label when present */ + startedBy?: string; + /** Label horizon-sdv.io/submitted-from: api | developer-portal | horizon-cli */ + submittedFrom?: string; + message?: string; + archivedLogs?: { + combined?: { gcsUri?: string }; + steps?: { + nodeId?: string; + displayName?: string; + templateName?: string; + gcsUri?: string; + /** Matches Horizon API StepLogLink.artifactName (e.g. main-logs) for downloadArtifact. */ + artifactName?: string; + }[]; + }; +} + +export interface WorkflowListResponse { + items: WorkflowSummary[]; + continue?: string; +} + +export interface OutputArtifact { + nodeId?: string; + name: string; + /** Basename of the GCS object when it differs from the workflow artifact name (e.g. smoke-result.tgz). */ + fileName?: string; + displayName?: string; + module?: string; + templateName?: string; + workflowTemplate?: string; + gcsUri?: string; +} + +export interface DependentWorkflowTemplate { + template: string; + module?: string; +} + +export interface WorkflowDetail { + name: string; + namespace: string; + module?: string; + phase: string; + workflowTemplate?: string; + startedAt?: string; + finishedAt?: string; + message?: string; + uid?: string; + dependentWorkflowTemplates?: DependentWorkflowTemplate[]; + nodes?: { + id: string; + displayName?: string; + module?: string; + templateName?: string; + workflowTemplate?: string; + type?: string; + phase?: string; + podName?: string; + startedAt?: string; + }[]; + outputArtifacts?: OutputArtifact[]; + archivedLogs?: WorkflowSummary['archivedLogs']; +} diff --git a/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/src/utils/api.ts b/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/src/utils/api.ts new file mode 100644 index 00000000..12355491 --- /dev/null +++ b/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/src/utils/api.ts @@ -0,0 +1,75 @@ +// Copyright (c) 2024-2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +import { authService } from './auth'; +import { getAppOriginBase } from './publicPath'; + +function authHeaders(): HeadersInit { + const t = authService.getToken(); + if (!t) { + return {}; + } + return { Authorization: `Bearer ${t}` }; +} + +async function fetchWithAuth(url: string, init: RequestInit): Promise { + await authService.ensureFreshToken(); + + const doFetch = () => + fetch(url, { + ...init, + headers: { + ...authHeaders(), + ...init.headers, + }, + }); + + let resp = await doFetch(); + + // 401 may be stale user JWT (proxy OIDC) or stale Horizon CI token (proxy invalidates CI on upstream 401). + // Always attempt refresh + retry; allow two refresh cycles for stubborn failures. + for (let attempt = 0; resp.status === 401 && attempt < 2; attempt++) { + await authService.refreshAccessTokenBestEffort(); + resp = await doFetch(); + } + + if (resp.status === 401) { + authService.sessionExpiredRedirectToLogin(); + } + + return resp; +} + +/** Module Manager via proxy (user JWT required). */ +export async function apiMm(path: string, init: RequestInit = {}): Promise { + const originBase = getAppOriginBase(); + const url = `${originBase}/api/mm${path.startsWith('/') ? path : `/${path}`}`; + return fetchWithAuth(url, init); +} + +/** + * Horizon API via same-origin proxy: only the confidential CI client (K8s secret) talks to Horizon. + * No browser Bearer — avoids user JWT / refresh issues. On 401 the proxy drops stale CI; retry a few times. + */ +export async function apiHorizon(path: string, init: RequestInit = {}): Promise { + const originBase = getAppOriginBase(); + const url = `${originBase}/api/horizon${path.startsWith('/') ? path : `/${path}`}`; + let resp = await fetch(url, init); + for (let i = 0; i < 3 && resp.status === 401; i++) { + // Brief pause so the proxy can finish invalidating the CI token before the next fetch. + await new Promise((r) => setTimeout(r, 120)); + resp = await fetch(url, init); + } + return resp; +} diff --git a/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/src/utils/auth.ts b/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/src/utils/auth.ts new file mode 100644 index 00000000..a72bc023 --- /dev/null +++ b/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/src/utils/auth.ts @@ -0,0 +1,378 @@ +// Copyright (c) 2024-2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +import Keycloak from 'keycloak-js'; +import type { KeycloakTokenParsed } from 'keycloak-js'; +import { config } from './config'; +import { getRouterBasename } from './publicPath'; + +const SK_TOKEN = 'kc-devportal-token'; +const SK_REFRESH = 'kc-devportal-refresh'; +const SK_ID = 'kc-devportal-id-token'; + +function parseKeycloakConfig(): { url: string; realm: string } { + const keycloakUrl = config.getString( + 'keycloakUrl', + '/auth/realms/horizon/protocol/openid-connect/token' + ); + const absolute = keycloakUrl.startsWith('/') + ? new URL(keycloakUrl, window.location.origin).href + : keycloakUrl; + const realmMatch = absolute.match(/\/realms\/([^/]+)/); + const realm = realmMatch ? realmMatch[1] : 'horizon'; + const url = absolute.split('/realms/')[0]; + return { url, realm }; +} + +function redirectUri(): string { + const base = getRouterBasename(); + return `${window.location.origin}${base}/`; +} + +function tokenEndpoint(): string { + const { url, realm } = parseKeycloakConfig(); + return `${url.replace(/\/$/, '')}/realms/${realm}/protocol/openid-connect/token`; +} + +function loginPath(): string { + const base = getRouterBasename(); + return `${base}/login`; +} + +/** Decode JWT payload (no signature verification — display/session use only). */ +function decodeJwtPayload(token: string): KeycloakTokenParsed | null { + try { + const parts = token.split('.'); + if (parts.length !== 3) { + return null; + } + let b64 = parts[1].replace(/-/g, '+').replace(/_/g, '/'); + while (b64.length % 4) { + b64 += '='; + } + const json = atob(b64); + return JSON.parse(json) as KeycloakTokenParsed; + } catch { + return null; + } +} + +class AuthService { + private static instance: AuthService; + private keycloak: Keycloak; + private initInFlight: Promise | null = null; + private keycloakInitFinished = false; + + private constructor() { + const { url, realm } = parseKeycloakConfig(); + const clientId = config.getString('keycloakClientId', 'horizon-dev-portal'); + this.keycloak = new Keycloak({ url, realm, clientId }); + } + + static getInstance(): AuthService { + if (!AuthService.instance) { + AuthService.instance = new AuthService(); + } + return AuthService.instance; + } + + private clearStoredSession(): void { + sessionStorage.removeItem(SK_TOKEN); + sessionStorage.removeItem(SK_REFRESH); + sessionStorage.removeItem(SK_ID); + } + + async init(): Promise { + if (this.initInFlight !== null) { + return this.initInFlight; + } + if (this.keycloakInitFinished) { + return this.keycloak.authenticated === true && !!this.keycloak.token; + } + this.initInFlight = (async () => { + try { + return await this.runInit(); + } finally { + this.keycloakInitFinished = true; + this.initInFlight = null; + } + })(); + return this.initInFlight; + } + + private async runInit(): Promise { + const hasCallback = /[?&#](code|error)=/.test( + window.location.search + window.location.hash + ); + const storedToken = sessionStorage.getItem(SK_TOKEN) ?? undefined; + const storedRefresh = sessionStorage.getItem(SK_REFRESH) ?? undefined; + const storedId = sessionStorage.getItem(SK_ID) ?? undefined; + + const initOptions: Parameters[0] = { + pkceMethod: 'S256', + checkLoginIframe: false, + redirectUri: redirectUri(), + responseMode: 'query', + }; + + if (!hasCallback && storedToken && storedRefresh) { + initOptions.token = storedToken; + initOptions.refreshToken = storedRefresh; + initOptions.idToken = storedId; + } + + let authenticated: boolean; + try { + authenticated = await this.keycloak.init(initOptions); + } catch { + this.clearStoredSession(); + return false; + } + + if (authenticated && this.keycloak.token) { + sessionStorage.setItem(SK_TOKEN, this.keycloak.token); + if (this.keycloak.refreshToken) { + sessionStorage.setItem(SK_REFRESH, this.keycloak.refreshToken); + } + if (this.keycloak.idToken) { + sessionStorage.setItem(SK_ID, this.keycloak.idToken); + } + } else if (!hasCallback) { + this.clearStoredSession(); + } + + return authenticated; + } + + login(): void { + this.keycloak.login({ redirectUri: redirectUri() }); + } + + logout(): void { + this.clearStoredSession(); + this.keycloak.logout({ redirectUri: redirectUri() }); + } + + getToken(): string | undefined { + return this.keycloak.token; + } + + private persistTokensFromKeycloak(): void { + if (!this.keycloak.token) { + return; + } + sessionStorage.setItem(SK_TOKEN, this.keycloak.token); + if (this.keycloak.refreshToken) { + sessionStorage.setItem(SK_REFRESH, this.keycloak.refreshToken); + } + if (this.keycloak.idToken) { + sessionStorage.setItem(SK_ID, this.keycloak.idToken); + } + } + + /** + * Pushes tokens into the Keycloak adapter after a manual refresh_token grant. + */ + private applyTokensFromRefreshResponse(data: { + access_token: string; + refresh_token?: string; + id_token?: string; + }): boolean { + const access = data.access_token; + const parsed = decodeJwtPayload(access); + if (!parsed) { + return false; + } + const kc = this.keycloak as Keycloak & { + token?: string; + refreshToken?: string; + idToken?: string; + tokenParsed?: KeycloakTokenParsed; + refreshTokenParsed?: KeycloakTokenParsed; + idTokenParsed?: KeycloakTokenParsed; + authenticated?: boolean; + subject?: string; + sessionId?: string; + realmAccess?: KeycloakTokenParsed['realm_access']; + resourceAccess?: KeycloakTokenParsed['resource_access']; + timeSkew?: number; + }; + kc.token = access; + kc.tokenParsed = parsed; + kc.authenticated = true; + kc.subject = typeof parsed.sub === 'string' ? parsed.sub : undefined; + kc.sessionId = typeof parsed.sid === 'string' ? parsed.sid : undefined; + kc.realmAccess = parsed.realm_access; + kc.resourceAccess = parsed.resource_access; + if (typeof parsed.iat === 'number') { + kc.timeSkew = Math.floor(Date.now() / 1000) - parsed.iat; + } + if (data.refresh_token) { + kc.refreshToken = data.refresh_token; + kc.refreshTokenParsed = decodeJwtPayload(data.refresh_token) ?? undefined; + } + if (data.id_token) { + kc.idToken = data.id_token; + kc.idTokenParsed = decodeJwtPayload(data.id_token) ?? undefined; + } + this.persistTokensFromKeycloak(); + return true; + } + + /** + * Keycloak-js refresh, then direct refresh_token POST if the adapter fails (network/CORS quirks). + */ + async refreshAccessTokenBestEffort(): Promise { + const rtStored = sessionStorage.getItem(SK_REFRESH); + if (!this.keycloak.refreshToken && rtStored) { + const kc = this.keycloak as Keycloak & { refreshToken?: string }; + kc.refreshToken = rtStored; + } + const rt = this.keycloak.refreshToken ?? rtStored; + if (!rt) { + return false; + } + try { + await this.keycloak.updateToken(-1); + this.persistTokensFromKeycloak(); + return !!this.keycloak.token; + } catch { + // fall through to manual grant + } + const clientId = config.getString('keycloakClientId', 'horizon-dev-portal'); + try { + const resp = await fetch(tokenEndpoint(), { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: rt, + client_id: clientId, + }), + }); + if (!resp.ok) { + return false; + } + const data = (await resp.json()) as { + access_token: string; + refresh_token?: string; + id_token?: string; + }; + if (!data.access_token) { + return false; + } + this.applyTokensFromRefreshResponse(data); + return !!this.keycloak.token; + } catch { + return false; + } + } + + /** + * Refreshes the access token via Keycloak if it expires within minValiditySeconds. + * Call before Horizon / Module Manager API requests so long sessions keep working. + */ + async ensureFreshToken(minValiditySeconds = 70): Promise { + if (!this.keycloak.token && sessionStorage.getItem(SK_TOKEN)) { + this.applyTokensFromRefreshResponse({ + access_token: sessionStorage.getItem(SK_TOKEN)!, + refresh_token: sessionStorage.getItem(SK_REFRESH) ?? undefined, + id_token: sessionStorage.getItem(SK_ID) ?? undefined, + }); // rehydrate adapter if init left tokens only in sessionStorage + } + if (!this.keycloak.token) { + return; + } + try { + await this.keycloak.updateToken(minValiditySeconds); + this.persistTokensFromKeycloak(); + } catch { + await this.refreshAccessTokenBestEffort(); + } + } + + /** + * Session is no longer valid for API calls — clear storage and send user to login. + */ + sessionExpiredRedirectToLogin(): void { + this.clearStoredSession(); + window.location.assign(`${window.location.origin}${loginPath()}`); + } + + getUsername(): string | null { + const tp = this.keycloak.tokenParsed as Record | undefined; + return tp?.preferred_username ?? tp?.name ?? tp?.sub ?? null; + } + + /** + * OAuth/OIDC client id for the current access token (`azp`), or configured public client id as fallback. + * Used for display in the Developer Portal only (not sent to Horizon API). + */ + getOAuthClientId(): string | null { + const tp = this.keycloak.tokenParsed as + | { azp?: string; aud?: string | string[] } + | undefined; + if (tp?.azp && typeof tp.azp === 'string') { + return tp.azp; + } + if (typeof tp?.aud === 'string') { + return tp.aud; + } + if (Array.isArray(tp?.aud) && typeof tp.aud[0] === 'string') { + return tp.aud[0]; + } + return config.getString('keycloakClientId', 'horizon-dev-portal'); + } + + /** + * Resource-owner password grant (requires Direct Access Grants on the client). + */ + async loginWithPassword(username: string, password: string): Promise { + const clientId = config.getString('keycloakClientId', 'horizon-dev-portal'); + const body = new URLSearchParams({ + grant_type: 'password', + client_id: clientId, + username, + password, + scope: 'openid', + }); + const resp = await fetch(tokenEndpoint(), { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body, + }); + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + throw new Error( + (err as { error_description?: string }).error_description || + `Login failed (${resp.status})` + ); + } + const data = (await resp.json()) as { + access_token: string; + refresh_token?: string; + id_token?: string; + }; + sessionStorage.setItem(SK_TOKEN, data.access_token); + if (data.refresh_token) { + sessionStorage.setItem(SK_REFRESH, data.refresh_token); + } + if (data.id_token) { + sessionStorage.setItem(SK_ID, data.id_token); + } + window.location.assign(redirectUri()); + } +} + +export const authService = AuthService.getInstance(); diff --git a/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/src/utils/config.ts b/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/src/utils/config.ts new file mode 100644 index 00000000..0f6bb9c3 --- /dev/null +++ b/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/src/utils/config.ts @@ -0,0 +1,29 @@ +// Copyright (c) 2024-2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +import type { AppConfig } from '../types'; + +function getWindowConfig(): AppConfig { + if (typeof window !== 'undefined' && window.APP_CONFIG) { + return window.APP_CONFIG; + } + return {}; +} + +export const config = { + getString(key: keyof AppConfig, fallback: string): string { + const v = getWindowConfig()[key]; + return typeof v === 'string' && v !== '' ? v : fallback; + }, +}; diff --git a/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/src/utils/dialogLayoutCookie.tsx b/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/src/utils/dialogLayoutCookie.tsx new file mode 100644 index 00000000..92e9121b --- /dev/null +++ b/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/src/utils/dialogLayoutCookie.tsx @@ -0,0 +1,189 @@ +// Copyright (c) 2024-2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +import Box from '@mui/material/Box'; +import type { SxProps, Theme } from '@mui/material/styles'; +import { useCallback, useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from 'react'; +import { getRouterBasename } from './publicPath'; + +const ONE_YEAR_SEC = 60 * 60 * 24 * 365; + +export const DIALOG_LAYOUT_COOKIE_WORKFLOW_DETAIL = 'hdp_dialog_workflow_detail'; +export const DIALOG_LAYOUT_COOKIE_WORKFLOW_LOGS = 'hdp_dialog_workflow_logs'; + +export function readDialogLayoutCookie(name: string): { width: number; height: number } | null { + if (typeof document === 'undefined') { + return null; + } + const prefix = `${name}=`; + for (const part of document.cookie.split(';')) { + const c = part.trim(); + if (!c.startsWith(prefix)) { + continue; + } + try { + const j = JSON.parse(decodeURIComponent(c.slice(prefix.length))) as { width?: unknown; height?: unknown }; + if (typeof j.width === 'number' && typeof j.height === 'number') { + return { width: j.width, height: j.height }; + } + } catch { + /* ignore */ + } + } + return null; +} + +export function writeDialogLayoutCookie(name: string, width: number, height: number): void { + if (typeof document === 'undefined') { + return; + } + const path = getRouterBasename() || '/'; + const val = encodeURIComponent(JSON.stringify({ width, height })); + document.cookie = `${name}=${val};path=${path};max-age=${ONE_YEAR_SEC};SameSite=Lax`; +} + +type ActiveDrag = { + move: (e: PointerEvent) => void; + up: () => void; +}; + +export function useResizableDialogSize(options: { + storageKey: string; + defaultWidth: () => number; + defaultHeight: () => number; + minWidth: number; + minHeight: number; +}) { + const { storageKey, defaultWidth, defaultHeight, minWidth, minHeight } = options; + const clamp = useCallback( + (w: number, h: number) => { + if (typeof window === 'undefined') { + return { width: w, height: h }; + } + const maxW = Math.floor(window.innerWidth * 0.96); + const maxH = Math.floor(window.innerHeight * 0.9); + return { + width: Math.min(maxW, Math.max(minWidth, w)), + height: Math.min(maxH, Math.max(minHeight, h)), + }; + }, + [minWidth, minHeight], + ); + + const [size, setSize] = useState(() => { + const d = clamp(defaultWidth(), defaultHeight()); + const saved = readDialogLayoutCookie(storageKey); + if (!saved) { + return d; + } + return clamp(saved.width, saved.height); + }); + + const drag = useRef<{ sx: number; sy: number; w: number; h: number } | null>(null); + const sizeRef = useRef(size); + + const activeDrag = useRef(null); + + useEffect(() => { + sizeRef.current = size; + }, [size]); + + useEffect( + () => () => { + const a = activeDrag.current; + if (a) { + window.removeEventListener('pointermove', a.move); + window.removeEventListener('pointerup', a.up); + window.removeEventListener('pointercancel', a.up); + activeDrag.current = null; + } + }, + [], + ); + + const onResizeHandleDown = useCallback( + (e: ReactPointerEvent) => { + e.preventDefault(); + e.stopPropagation(); + (e.target as HTMLElement).setPointerCapture(e.pointerId); + drag.current = { sx: e.clientX, sy: e.clientY, w: sizeRef.current.width, h: sizeRef.current.height }; + + const prev = activeDrag.current; + if (prev) { + window.removeEventListener('pointermove', prev.move); + window.removeEventListener('pointerup', prev.up); + window.removeEventListener('pointercancel', prev.up); + } + + const move = (ev: PointerEvent) => { + if (!drag.current) { + return; + } + const dw = ev.clientX - drag.current.sx; + const dh = ev.clientY - drag.current.sy; + const next = clamp(drag.current.w + dw, drag.current.h + dh); + sizeRef.current = next; + setSize(next); + }; + + const up = () => { + window.removeEventListener('pointermove', move); + window.removeEventListener('pointerup', up); + window.removeEventListener('pointercancel', up); + drag.current = null; + activeDrag.current = null; + const { width, height } = sizeRef.current; + writeDialogLayoutCookie(storageKey, width, height); + }; + + activeDrag.current = { move, up }; + window.addEventListener('pointermove', move); + window.addEventListener('pointerup', up); + window.addEventListener('pointercancel', up); + }, + [clamp, storageKey], + ); + + const paperSx: SxProps = { + position: 'relative', + width: size.width, + height: size.height, + maxWidth: '96vw', + maxHeight: '90vh', + m: 2, + display: 'flex', + flexDirection: 'column', + overflow: 'hidden', + }; + + const ResizeHandle = ( + + ); + + return { paperSx, ResizeHandle }; +} diff --git a/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/src/utils/logBufferSettings.ts b/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/src/utils/logBufferSettings.ts new file mode 100644 index 00000000..b19c7f1e --- /dev/null +++ b/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/src/utils/logBufferSettings.ts @@ -0,0 +1,60 @@ +// Copyright (c) 2024-2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +import { useEffect, useState } from 'react'; + +const STORAGE_KEY = 'devportal.maxWorkflowLogLines'; + +export const DEFAULT_MAX_WORKFLOW_LOG_LINES = 50_000; +export const MIN_MAX_WORKFLOW_LOG_LINES = 5_000; +/** Upper bound keeps in-browser log buffers from growing without a hard cap (~hundreds of MB at long lines). */ +export const MAX_MAX_WORKFLOW_LOG_LINES = 200_000; + +export function readMaxWorkflowLogLines(): number { + if (typeof window === 'undefined') { + return DEFAULT_MAX_WORKFLOW_LOG_LINES; + } + const raw = localStorage.getItem(STORAGE_KEY); + const n = raw === null ? NaN : Number.parseInt(raw, 10); + if (!Number.isFinite(n)) { + return DEFAULT_MAX_WORKFLOW_LOG_LINES; + } + return Math.min(MAX_MAX_WORKFLOW_LOG_LINES, Math.max(MIN_MAX_WORKFLOW_LOG_LINES, n)); +} + +export function writeMaxWorkflowLogLines(n: number): number { + const v = Math.min( + MAX_MAX_WORKFLOW_LOG_LINES, + Math.max(MIN_MAX_WORKFLOW_LOG_LINES, Math.round(n)), + ); + if (typeof window !== 'undefined') { + localStorage.setItem(STORAGE_KEY, String(v)); + window.dispatchEvent(new Event('devportal-max-log-lines')); + } + return v; +} + +export function useMaxWorkflowLogLines(): number { + const [v, setV] = useState(readMaxWorkflowLogLines); + useEffect(() => { + const sync = () => setV(readMaxWorkflowLogLines()); + window.addEventListener('storage', sync); + window.addEventListener('devportal-max-log-lines', sync); + return () => { + window.removeEventListener('storage', sync); + window.removeEventListener('devportal-max-log-lines', sync); + }; + }, []); + return v; +} diff --git a/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/src/utils/overviewPortalTheme.ts b/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/src/utils/overviewPortalTheme.ts new file mode 100644 index 00000000..07ee4dde --- /dev/null +++ b/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/src/utils/overviewPortalTheme.ts @@ -0,0 +1,57 @@ +// Copyright (c) 2024-2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +/** + * Module overview HTML uses prefers-color-scheme; the portal iframe follows MUI palette instead. + * Inject theme CSS + data-hp-theme on so CSS variables match the Developer Portal light/dark toggle. + */ +const OVERVIEW_THEME_BRIDGE = ` +html[data-hp-theme="dark"] { + color-scheme: dark; + --bg: #202124; + --paper: #292a2d; + --text: #e8eaed; + --muted: #9aa0a6; + --primary: #8ab4f8; + --border: #3c4043; + --hero-end: rgba(26, 115, 232, 0.12); + --hero-start: rgba(66, 133, 244, 0.28); +} +html[data-hp-theme="light"] { + color-scheme: light; + --bg: #f8f9fa; + --paper: #ffffff; + --text: #202124; + --muted: #5f6368; + --primary: #1a73e8; + --border: #e8eaed; + --hero-end: rgba(26, 115, 232, 0.08); + --hero-start: rgba(66, 133, 244, 0.22); +} +`.trim(); + +export function injectOverviewPortalTheme(html: string, mode: 'light' | 'dark'): string { + const bridge = ``; + const withHtml = html.replace(/]*)?>/i, (full, g1: string | undefined) => { + const rest = g1 ?? ''; + if (/\sdata-hp-theme=/i.test(rest)) { + return full.replace(/\sdata-hp-theme="[^"]*"/i, ` data-hp-theme="${mode}"`); + } + return ``; + }); + if (/<\/head>/i.test(withHtml)) { + return withHtml.replace(/<\/head>/i, `${bridge}`); + } + return bridge + withHtml; +} diff --git a/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/src/utils/publicPath.ts b/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/src/utils/publicPath.ts new file mode 100644 index 00000000..dc9f0ab6 --- /dev/null +++ b/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/src/utils/publicPath.ts @@ -0,0 +1,49 @@ +// Copyright (c) 2024-2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +import type { AppConfig } from '../types'; + +function cfg(): AppConfig { + if (typeof window !== 'undefined' && window.APP_CONFIG) { + return window.APP_CONFIG; + } + return {}; +} + +/** + * Browser URL path prefix where this SPA is mounted (leading slash, no trailing slash). + * Empty string when the app is served at the site root. + * Populated from `publicPath` in config.js / `PUBLIC_PATH` in the Go proxy (Helm `config.publicPath`). + */ +export function getRouterBasename(): string { + const raw = cfg().publicPath; + if (raw === undefined || raw === '') { + return ''; + } + let p = String(raw).trim(); + if (!p.startsWith('/')) { + p = `/${p}`; + } + p = p.replace(/\/+$/, ''); + return p === '/' ? '' : p; +} + +/** Same-origin base for API proxy URLs (`/api/mm`, `/api/horizon`). */ +export function getAppOriginBase(): string { + if (typeof window === 'undefined') { + return ''; + } + const p = getRouterBasename(); + return `${window.location.origin}${p}`; +} diff --git a/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/src/utils/workflowVisibilityDiscovery.ts b/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/src/utils/workflowVisibilityDiscovery.ts new file mode 100644 index 00000000..8b86b8c5 --- /dev/null +++ b/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/src/utils/workflowVisibilityDiscovery.ts @@ -0,0 +1,104 @@ +// Copyright (c) 2024-2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +import type { WorkflowSummary } from '../types'; + +const CANONICAL_SOURCES = ['api', 'developer-portal', 'horizon-cli'] as const; + +/** Walk OpenAPI 3 JSON and collect string enums relevant to workflow submitted-from. */ +export function extractSubmittedFromEnumsFromOpenAPI(doc: unknown): string[] { + const out = new Set(); + const visit = (node: unknown) => { + if (node === null || node === undefined) { + return; + } + if (Array.isArray(node)) { + node.forEach(visit); + return; + } + if (typeof node !== 'object') { + return; + } + const o = node as Record; + if (o.name === 'X-Horizon-Submitted-From' && o.schema && typeof o.schema === 'object') { + const sch = o.schema as Record; + if (Array.isArray(sch.enum)) { + for (const v of sch.enum) { + if (typeof v === 'string' && v.trim()) { + out.add(v.trim()); + } + } + } + } + const props = o.properties; + if (props && typeof props === 'object' && !Array.isArray(props)) { + const p = props as Record; + const sf = p.submittedFrom; + if (sf && typeof sf === 'object') { + const sch = sf as Record; + if (Array.isArray(sch.enum)) { + for (const v of sch.enum) { + if (typeof v === 'string' && v.trim()) { + out.add(v.trim()); + } + } + } + } + } + for (const v of Object.values(o)) { + visit(v); + } + }; + visit(doc); + for (const c of CANONICAL_SOURCES) { + out.add(c); + } + return [...out].sort((a, b) => a.localeCompare(b)); +} + +export function collectSubmittedFromFromWorkflows(items: WorkflowSummary[]): string[] { + const out = new Set(); + for (const w of items) { + const s = (w.submittedFrom ?? '').trim(); + if (s) { + out.add(s); + } + } + return [...out].sort((a, b) => a.localeCompare(b)); +} + +export function mergeSourceOptions(openapiEnums: string[], fromWorkflows: string[]): string[] { + const out = new Set(); + openapiEnums.forEach((x) => out.add(x)); + fromWorkflows.forEach((x) => out.add(x)); + return [...out].sort((a, b) => a.localeCompare(b)); +} + +/** No restriction when allowed is null/undefined. Empty array = hide all. */ +export function workflowPassesSubmittedFromFilter( + w: WorkflowSummary, + allowed: string[] | null | undefined +): boolean { + if (allowed == null) { + return true; + } + if (allowed.length === 0) { + return false; + } + const sf = (w.submittedFrom ?? '').trim(); + if (!sf) { + return false; + } + return allowed.includes(sf); +} diff --git a/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/src/vite-env.d.ts b/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/src/vite-env.d.ts new file mode 100644 index 00000000..d79b8b31 --- /dev/null +++ b/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/src/vite-env.d.ts @@ -0,0 +1,19 @@ +// Copyright (c) 2024-2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +/// + +interface Window { + APP_CONFIG?: import('./types').AppConfig; +} diff --git a/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/tsconfig.app.json b/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/tsconfig.app.json new file mode 100644 index 00000000..ea440874 --- /dev/null +++ b/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/tsconfig.app.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true, + "resolveJsonModule": true, + "isolatedModules": true + }, + "include": ["src"] +} diff --git a/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/tsconfig.json b/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/tsconfig.json new file mode 100644 index 00000000..82a8007e --- /dev/null +++ b/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/tsconfig.json @@ -0,0 +1,4 @@ +{ + "files": [], + "references": [{ "path": "./tsconfig.app.json" }] +} diff --git a/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/vite.config.ts b/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/vite.config.ts new file mode 100644 index 00000000..560727e0 --- /dev/null +++ b/terraform/modules/sdv-container-images/images/horizon-dev-portal/horizon-dev-portal/vite.config.ts @@ -0,0 +1,34 @@ +// Copyright (c) 2024-2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +/** Relative base so the same build can be mounted at any HTTP path (set at runtime via config.js `publicPath`). */ +export default defineConfig({ + base: './', + server: { + proxy: { + '/auth': { + target: process.env.VITE_KEYCLOAK_ORIGIN || 'http://localhost:32080', + changeOrigin: true, + }, + '/api': { + target: process.env.VITE_DEV_PROXY_TARGET || 'http://127.0.0.1:7090', + changeOrigin: true, + }, + }, + }, + plugins: [react()], +}); diff --git a/terraform/modules/sdv-container-images/images/kcc-webhook-cert-monitor/kcc-webhook-cert-monitor/Dockerfile b/terraform/modules/sdv-container-images/images/kcc-webhook-cert-monitor/kcc-webhook-cert-monitor/Dockerfile new file mode 100644 index 00000000..95a11218 --- /dev/null +++ b/terraform/modules/sdv-container-images/images/kcc-webhook-cert-monitor/kcc-webhook-cert-monitor/Dockerfile @@ -0,0 +1,23 @@ +# Copyright (c) 2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# Minimal image for kcc-webhook-cert-monitor CronJob: openssl, curl, jq (no runtime apk). +# Matches Kubernetes pod securityContext runAsUser 65534 (nobody). +FROM --platform=linux/amd64 alpine:3.20 + +RUN apk add --no-cache openssl curl jq \ + && rm -rf /var/cache/apk/* + +USER nobody +WORKDIR /tmp diff --git a/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-argo-workflows/Dockerfile b/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-argo-workflows/Dockerfile new file mode 100644 index 00000000..bf5054ee --- /dev/null +++ b/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-argo-workflows/Dockerfile @@ -0,0 +1,25 @@ +# Copyright (c) 2024-2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +FROM --platform=linux/amd64 node:22-alpine +RUN apk update && apk upgrade && apk add --no-cache bash vim curl jq +WORKDIR /home/node +ADD --chown=node:node package.json ./ +USER node +RUN npm install --ignore-scripts +ADD --chown=node:node keycloak.mjs ./ +ADD --chown=node:node configure.sh ./ +ADD --chown=node:node secret.json ./ +RUN chmod +x configure.sh +CMD ["/bin/bash", "-c", "/home/node/configure.sh"] diff --git a/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-argo-workflows/configure.sh b/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-argo-workflows/configure.sh new file mode 100644 index 00000000..9b029382 --- /dev/null +++ b/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-argo-workflows/configure.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash + +# Copyright (c) 2024-2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +set -euo pipefail + +APISERVER=https://kubernetes.default.svc +SERVICEACCOUNT=/var/run/secrets/kubernetes.io/serviceaccount +TOKEN=$(cat ${SERVICEACCOUNT}/token) +CACERT=${SERVICEACCOUNT}/ca.crt +TARGET_NAMESPACE=${NAMESPACE_PREFIX}argo-workflows + +# @keycloak/keycloak-admin-client 26+ runs a postinstall that invokes pnpm (not in image). +# Published tarballs are complete; lifecycle scripts are not needed at runtime. +npm install --ignore-scripts +node keycloak.mjs + +if [[ ! -s client-argo-workflows.json ]]; then + echo "client-argo-workflows.json was not generated or is empty" >&2 + exit 1 +fi + +CLIENT_SECRET=$(jq -er '.secret' client-argo-workflows.json) +sed -i "s/##SECRET##/${CLIENT_SECRET}/g" ./secret.json +sed -i "s/##NAMESPACE##/${TARGET_NAMESPACE}/g" ./secret.json + +# Recreate Argo Workflows SSO secret with current client credentials +curl --silent --show-error --cacert ${CACERT} --header "Authorization: Bearer ${TOKEN}" -X DELETE ${APISERVER}/api/v1/namespaces/${TARGET_NAMESPACE}/secrets/argo-workflows-sso || true +curl --fail --silent --show-error --cacert ${CACERT} --header "Authorization: Bearer ${TOKEN}" -H 'Accept: application/json' -H 'Content-Type: application/json' -X POST ${APISERVER}/api/v1/namespaces/${TARGET_NAMESPACE}/secrets -d @secret.json + +echo "Argo Workflows Keycloak configuration completed successfully" diff --git a/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-argo-workflows/keycloak.mjs b/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-argo-workflows/keycloak.mjs new file mode 100644 index 00000000..3c9ad91e --- /dev/null +++ b/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-argo-workflows/keycloak.mjs @@ -0,0 +1,306 @@ +// Copyright (c) 2024-2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import fs from 'fs/promises'; +import _ from 'lodash'; +import KcAdminClient from '@keycloak/keycloak-admin-client'; +import retry from 'async-retry'; + +const clientDumpFileName = 'client-argo-workflows.json'; + +const config = { + keycloak: { + baseUrl: process.env.PLATFORM_URL + '/auth', + username: process.env.KEYCLOAK_USERNAME, + password: process.env.KEYCLOAK_PASSWORD, + realm: { + realm: 'horizon' + }, + // Argo Server uses this client to authenticate users via Keycloak. + client: { + clientId: 'argo-workflows-oauth', + adminUrl: process.env.DOMAIN + '/workflows', + redirectUris: [ + process.env.DOMAIN + '/workflows/oauth2/callback', + process.env.DOMAIN + '/workflows/*' + ], + webOrigins: [process.env.DOMAIN], + protocol: 'openid-connect', + publicClient: false, + standardFlowEnabled: true, + directAccessGrantsEnabled: false, + serviceAccountsEnabled: false + }, + // Client roles created inside the argo-workflows-oauth client. + // The built-in 'roles' client scope emits these into the 'roles' token claim. + clientRoles: [ + 'administrators', + 'developers' + ] + } +}; + +// Maps each client role to one or more realm groups. +// Realm groups (administrators, viewers, developers) are created centrally +// by the base keycloak-post job +const clientRoleToGroup = { + administrators: ['administrators'], + developers: ['developers'] +}; + +const keycloakAdmin = new KcAdminClient({ + baseUrl: config.keycloak.baseUrl +}); + +async function waitForKeycloak(baseUrl, username, password) { + const opts = { + retries: 100, + minTimeout: 2000, + factor: 1, + onRetry: (err) => { + console.info(`waiting for ${baseUrl}...`, err.message); + } + }; + await retry( + () => login(username, password), + opts + ); +} + +async function login(username, password) { + try { + await keycloakAdmin.auth({ + 'username': username, + 'password': password, + 'grantType': 'password', + 'clientId': 'admin-cli' + }); + } catch (err) { + throw err; + } +} + +async function getRealm(realmName) { + try { + let realm = await keycloakAdmin.realms.findOne({ + realm: realmName, + }); + keycloakAdmin.setConfig({ + realmName: realm.realm, + }); + realm.keys = await keycloakAdmin.realms.getKeys({realm: realm.realm}); + return realm; + } catch (err) { + throw err; + } +} + +async function createOrUpdateClient(clientConfig, realmName) { + try { + let clients = await keycloakAdmin.clients.find(); + let client = _.find(clients, {clientId: clientConfig.clientId}); + if (client) { + console.info('updating %s client', clientConfig.clientId); + const targetClient = _.merge({}, client, clientConfig); + await keycloakAdmin.clients.update({id: client.id, realm: realmName}, targetClient); + } else { + console.info('creating %s client', clientConfig.clientId); + await keycloakAdmin.clients.create(clientConfig); + } + clients = await keycloakAdmin.clients.find(); + return _.find(clients, {clientId: clientConfig.clientId}); + } catch (err) { + throw err; + } +} + +// Keycloak does not return the client secret in clients.find() / ClientRepresentation +// for confidential clients after creation. Use the credentials API (same pattern as +// keycloak-post-horizon-api/keycloak.mjs). +async function generateClientSecretFile(clientId, fileName) { + try { + const client = (await keycloakAdmin.clients.find({ clientId }))[0]; + if (!client) { + throw new Error(`client ${clientId} not found, cannot dump secret file`); + } + let cred = await keycloakAdmin.clients.getClientSecret({ id: client.id }); + if (!cred?.value) { + cred = await keycloakAdmin.clients.generateNewClientSecret({ + id: client.id, + }); + } + if (!cred?.value) { + throw new Error( + `client ${clientId} has no client secret after get/regenerate`, + ); + } + const payload = { ...client, secret: cred.value }; + console.info('dumping %s client data into json file', clientId); + await fs.writeFile(fileName, JSON.stringify(payload)); + } catch (err) { + throw err; + } +} + +// Disable "Full Scope Allowed" so the token only contains roles explicitly +// assigned to this client, not roles from every client in the realm. +async function DisableFullScopeIfRequired() { + const clientId = config.keycloak.client.clientId; + + try { + const clients = await keycloakAdmin.clients.find(); + const argoClient = clients.find(client => client.clientId === clientId); + + if (!argoClient) { + console.error(`client "${clientId}" does not exist.`); + return; + } + + if (argoClient.fullScopeAllowed === false) { + console.info(`"Full scope allowed" is already disabled for client "${clientId}".`); + } else { + console.log(`disabling "Full scope allowed" for client "${clientId}".`); + await keycloakAdmin.clients.update( + { id: argoClient.id, realm: config.keycloak.realm.realm }, + { ...argoClient, fullScopeAllowed: false } + ); + } + } catch (err) { + throw err; + } +} + +// Create client roles that appear in the 'roles' token claim. +async function createClientRolesIfRequired() { + const clientId = config.keycloak.client.clientId; + const clientRoleNames = config.keycloak.clientRoles; + + try { + const clients = await keycloakAdmin.clients.find(); + const argoClient = clients.find(client => client.clientId === clientId); + + if (!argoClient) { + console.error(`client "${clientId}" does not exist.`); + return; + } + + const existingRoles = await keycloakAdmin.clients.listRoles({ id: argoClient.id }); + + for (const roleName of clientRoleNames) { + const roleExists = existingRoles.some(role => role.name === roleName); + if (roleExists) { + console.info(`client role "${roleName}" already exists for "${clientId}".`); + continue; + } + + await keycloakAdmin.clients.createRole({id: argoClient.id, name: roleName}); + console.log(`client role "${roleName}" created for client "${clientId}".`); + } + } catch (err) { + throw err; + } +} + +// Map each client role to the corresponding realm group(s) using clientRoleToGroup. +// When a user belongs to a realm group, they automatically receive the +// mapped client roles in their token for this client. +async function mapClientRolesToGroupsIfRequired() { + const clientId = config.keycloak.client.clientId; + const clientRoleNames = config.keycloak.clientRoles; + + try { + const clients = await keycloakAdmin.clients.find(); + const argoClient = clients.find(client => client.clientId === clientId); + + if (!argoClient) { + console.error(`client "${clientId}" does not exist.`); + return; + } + + const allGroups = await keycloakAdmin.groups.find(); + + for (const clientRoleName of clientRoleNames) { + const clientRole = await keycloakAdmin.clients.findRole({id: argoClient.id, roleName: clientRoleName}); + + if (!clientRole) { + console.warn(`client role "${clientRoleName}" does not exist in "${clientId}".`); + continue; + } + + const groupNames = clientRoleToGroup[clientRoleName]; + if (!groupNames) { + console.warn(`no group mapping defined for client role "${clientRoleName}".`); + continue; + } + + const names = Array.isArray(groupNames) ? groupNames : [groupNames]; + for (const groupName of names) { + const group = allGroups.find(g => g.name === groupName); + + if (!group) { + console.warn(`group "${groupName}" does not exist.`); + continue; + } + + const mappedRoles = await keycloakAdmin.groups.listClientRoleMappings({id: group.id, clientUniqueId: argoClient.id}); + const alreadyMapped = mappedRoles.some(role => role.name === clientRole.name); + + if (alreadyMapped) { + console.info(`client role "${clientRoleName}" is already mapped to group "${groupName}".`); + continue; + } + + await keycloakAdmin.groups.addClientRoleMappings({ + id: group.id, + clientUniqueId: argoClient.id, + roles: [{ + id: clientRole.id, + name: clientRole.name + }] + }); + console.log(`client role "${clientRoleName}" mapped to group "${groupName}".`); + } + } + } catch (err) { + throw err; + } +} + +// Orchestrates the full Keycloak setup for Argo Workflows: +// 1. Create/update the OIDC client +// 2. Restrict token scope to this client's own roles only +// 3. Create client roles (administrators, developers) +// 4. Map client roles to centrally-managed realm groups +// 5. Dump the client secret for configure.sh to create the K8s secret +async function configureKeycloak() { + await waitForKeycloak( + config.keycloak.baseUrl, + config.keycloak.username, + config.keycloak.password + ); + const realm = await getRealm(config.keycloak.realm.realm); + config.keycloak.client = await createOrUpdateClient(config.keycloak.client, realm.realm); + await DisableFullScopeIfRequired(); + await createClientRolesIfRequired(); + await mapClientRolesToGroupsIfRequired(); + await generateClientSecretFile( + config.keycloak.client.clientId, + clientDumpFileName + ); +} + +configureKeycloak().catch((err) => { + console.error(err.message); + process.exit(1); +}); diff --git a/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-argo-workflows/package.json b/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-argo-workflows/package.json new file mode 100644 index 00000000..e8600573 --- /dev/null +++ b/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-argo-workflows/package.json @@ -0,0 +1,10 @@ +{ + "name": "keycloak-post-argo-workflows", + "version": "1.0.0", + "type": "module", + "dependencies": { + "@keycloak/keycloak-admin-client": "25.0.5", + "async-retry": "^1.3.3", + "lodash": "^4.18.1" + } +} diff --git a/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-argo-workflows/secret.json b/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-argo-workflows/secret.json new file mode 100644 index 00000000..ad043011 --- /dev/null +++ b/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-argo-workflows/secret.json @@ -0,0 +1,13 @@ +{ + "apiVersion": "v1", + "kind": "Secret", + "metadata": { + "name": "argo-workflows-sso", + "namespace": "##NAMESPACE##" + }, + "type": "Opaque", + "stringData": { + "client-id": "argo-workflows-oauth", + "client-secret": "##SECRET##" + } +} diff --git a/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-argocd/configure.sh b/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-argocd/configure.sh index da28e135..df628b87 100644 --- a/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-argocd/configure.sh +++ b/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-argocd/configure.sh @@ -20,7 +20,7 @@ NAMESPACE=$(cat ${SERVICEACCOUNT}/namespace) TOKEN=$(cat ${SERVICEACCOUNT}/token) CACERT=${SERVICEACCOUNT}/ca.crt -npm install +npm install --ignore-scripts node keycloak.mjs SECRET=$(cat client-argocd.json | jq -r ".secret") @@ -32,15 +32,60 @@ kubectl -n ${NAMESPACE_PREFIX}argocd patch configmap argocd-cm --patch=" { \"data\": { \"url\": \"${DOMAIN}/argocd\", - \"oidc.config\": \"name: Keycloak\nissuer: ${DOMAIN}/auth/realms/horizon\nclientID: argocd\nclientSecret: \$oidc.keycloak.clientSecret\nrequestedScopes: [\\\"openid\\\", \\\"profile\\\", \\\"email\\\", \\\"groups\\\"]\" + \"oidc.groupsClaim\": \"roles\", + \"oidc.config\": \"name: Keycloak\nissuer: ${DOMAIN}/auth/realms/horizon\nclientID: argocd\nclientSecret: \$oidc.keycloak.clientSecret\nrequestedScopes: [\\\"openid\\\", \\\"profile\\\", \\\"email\\\", \\\"roles\\\"]\" } }" -kubectl -n ${NAMESPACE_PREFIX}argocd patch configmap argocd-rbac-cm --patch=' -{ - "data": { - "policy.csv": "g, horizon-argocd-administrators, role:admin" - } -}' +read -r -d '' POLICY_CSV << 'POLICY_END' || true +p, role:readonly, applications, get, */*, allow +p, role:readonly, applicationsets, get, */*, allow +p, role:readonly, certificates, get, *, allow +p, role:readonly, clusters, get, *, allow +p, role:readonly, repositories, get, *, allow +p, role:readonly, write-repositories, get, *, allow +p, role:readonly, projects, get, *, allow +p, role:readonly, accounts, get, *, allow +p, role:readonly, gpgkeys, get, *, allow +p, role:readonly, logs, get, */*, allow +p, role:admin, applications, create, */*, allow +p, role:admin, applications, update, */*, allow +p, role:admin, applications, update/*, */*, allow +p, role:admin, applications, delete, */*, allow +p, role:admin, applications, delete/*, */*, allow +p, role:admin, applications, sync, */*, allow +p, role:admin, applications, override, */*, allow +p, role:admin, applications, action/*, */*, allow +p, role:admin, applicationsets, get, */*, allow +p, role:admin, applicationsets, create, */*, allow +p, role:admin, applicationsets, update, */*, allow +p, role:admin, applicationsets, delete, */*, allow +p, role:admin, certificates, create, *, allow +p, role:admin, certificates, update, *, allow +p, role:admin, certificates, delete, *, allow +p, role:admin, clusters, create, *, allow +p, role:admin, clusters, update, *, allow +p, role:admin, clusters, delete, *, allow +p, role:admin, repositories, create, *, allow +p, role:admin, repositories, update, *, allow +p, role:admin, repositories, delete, *, allow +p, role:admin, write-repositories, create, *, allow +p, role:admin, write-repositories, update, *, allow +p, role:admin, write-repositories, delete, *, allow +p, role:admin, projects, create, *, allow +p, role:admin, projects, update, *, allow +p, role:admin, projects, delete, *, allow +p, role:admin, accounts, update, *, allow +p, role:admin, gpgkeys, create, *, allow +p, role:admin, gpgkeys, delete, *, allow +p, role:admin, exec, create, */*, allow +g, role:admin, role:readonly +g, administrators, role:admin +g, viewers, role:readonly +POLICY_END + +POLICY_CSV_JSON=$(echo "$POLICY_CSV" | jq -Rs .) + +kubectl -n ${NAMESPACE_PREFIX}argocd patch configmap argocd-rbac-cm --patch="{\"data\": {\"policy.csv\": $POLICY_CSV_JSON, \"scopes\": \"[roles]\"}}" kubectl rollout restart deployment/${NAMESPACE_PREFIX}argocd-server -n ${NAMESPACE_PREFIX}argocd diff --git a/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-argocd/keycloak.mjs b/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-argocd/keycloak.mjs index a6661e61..06a33fa9 100644 --- a/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-argocd/keycloak.mjs +++ b/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-argocd/keycloak.mjs @@ -17,6 +17,26 @@ import _ from 'lodash'; import KcAdminClient from '@keycloak/keycloak-admin-client'; import retry from 'async-retry'; +const argocdRedirectUris = () => { + const d = process.env.DOMAIN; + return [ + `${d}/argocd/auth/callback`, + `${d}/argocd/api/dex/callback`, + `${d}/argocd/pkce/verify`, + `${d}/argocd/*`, + // If argocd-cm url is missing the /argocd suffix, Dex/OIDC uses host-root paths. + `${d}/api/dex/callback`, + // Workaround when rootPath is omitted from generated OAuth URLs (argoproj/argo-cd#26233, #18045). + `${d}/auth/callback`, + `${d}/pkce/verify`, + // Argo CD CLI: `argocd login --sso` (default --sso-port 8085). + 'http://127.0.0.1:8085/auth/callback', + 'http://localhost:8085/auth/callback', + 'http://127.0.0.1:8085/pkce/verify', + 'http://localhost:8085/pkce/verify', + ]; +}; + const config = { keycloak: { baseUrl: process.env.PLATFORM_URL + '/auth', @@ -28,18 +48,33 @@ const config = { client: { clientId: 'argocd', adminUrl: process.env.DOMAIN + '/argocd', - redirectUris: [process.env.DOMAIN + '/argocd/*'], + redirectUris: argocdRedirectUris(), + webOrigins: [process.env.DOMAIN, `${process.env.DOMAIN}/argocd`], protocol: 'openid-connect', publicClient: false }, - clientScope:{ - clientScopeName: 'groups' + adminUser: { + username: process.env.ARGOCD_ADMIN_USERNAME, + password: process.env.ARGOCD_ADMIN_PASSWORD, + firstName: 'Argocd', + lastName: 'Argocd', + email: 'argocd@argocd' }, - rolesAndGroups: [ - 'horizon-argocd-administrators' + ClientRoles: [ + 'administrators', + 'viewers' ] } -} +}; + +const clientRoleToGroup = { + administrators: ['administrators'], + viewers: ['viewers'] +}; + +// Argo CD uses `oidc.groupsClaim: roles` (see configure.sh). Keycloak does not put client +// roles in a top-level `roles` JWT claim by default — add an explicit mapper. +const ARGOCD_ROLES_CLAIM_MAPPER = 'argocd-rbac-client-roles-claim'; const keycloakAdmin = new KcAdminClient({ baseUrl: config.keycloak.baseUrl @@ -89,7 +124,10 @@ async function createClientIfRequired() { let client = _.find(clients, {clientId: config.keycloak.client.clientId}); if (client) { console.info('updating %s client', config.keycloak.client.clientId); - await keycloakAdmin.clients.update({id: client.id, realm: config.keycloak.realm.realm}, _.merge(client, config.keycloak.client)); + const merged = _.merge({}, client, config.keycloak.client); + merged.redirectUris = argocdRedirectUris(); + merged.webOrigins = [process.env.DOMAIN, `${process.env.DOMAIN}/argocd`]; + await keycloakAdmin.clients.update({id: client.id, realm: config.keycloak.realm.realm}, merged); } else { console.info('creating %s client', config.keycloak.client.clientId); await keycloakAdmin.clients.create(config.keycloak.client); @@ -102,6 +140,39 @@ async function createClientIfRequired() { } } +async function createUserIfRequired() { + try { + let users = await keycloakAdmin.users.find(); + let user = _.find(users, {username: config.keycloak.adminUser.username}); + + if (user) { + console.info('user "%s" already exists, skipping create and password reset', config.keycloak.adminUser.username); + return; + } + + console.info('creating %s user', config.keycloak.adminUser.username); + const new_user = await keycloakAdmin.users.create({ + username: config.keycloak.adminUser.username, + enabled: true, + requiredActions: [], + realm: config.keycloak.realm.realm, + firstName: config.keycloak.adminUser.firstName, + lastName: config.keycloak.adminUser.lastName, + email: config.keycloak.adminUser.email, + emailVerified: true + }); + + await keycloakAdmin.users.resetPassword({ + id: new_user.id, + realm: config.keycloak.realm.realm, + credential: {temporary: false, type: 'password', value: config.keycloak.adminUser.password} + }); + + } catch (err) { + throw err + } +} + async function generateSecretFiles() { try { let clients = await keycloakAdmin.clients.find(); @@ -111,15 +182,40 @@ async function generateSecretFiles() { console.info('dumping %s client data into json file', config.keycloak.client.clientId); fs.writeFile('client-argocd.json', JSON.stringify(client)); } - } catch (err) { throw err } } -async function addGroupsClientScopeToArgocdClientIfRequired() { +async function DisableFullScopeIfRequired() { + const clientId = config.keycloak.client.clientId; + + try { + const clients = await keycloakAdmin.clients.find(); + const argocdClient = clients.find(client => client.clientId === clientId); + + if (!argocdClient) { + console.error(`client "${clientId}" does not exist.`); + return; + } + + if (argocdClient.fullScopeAllowed === false) { + console.info(`"Full scope allowed" is already disabled for client "${clientId}".`); + } else { + console.log(`disabling "Full scope allowed" for client "${clientId}".`); + await keycloakAdmin.clients.update( + { id: argocdClient.id, realm: config.keycloak.realm.realm }, + { ...argocdClient, fullScopeAllowed: false } + ); + } + } catch (err) { + throw err; + } +} + +async function createArgocdClientRolesIfRequired() { const clientId = config.keycloak.client.clientId; - const clientScopeName = config.keycloak.clientScope.clientScopeName; + const clientRoleNames = config.keycloak.ClientRoles; try { const clients = await keycloakAdmin.clients.find(); @@ -130,45 +226,201 @@ async function addGroupsClientScopeToArgocdClientIfRequired() { return; } - const clientScopes = await keycloakAdmin.clientScopes.find(); - const groupsScope = clientScopes.find(scope => scope.name === clientScopeName); - - if (!groupsScope) { - console.error(`client scope "${clientScopeName}" does not exist.`); + const existingRoles = await keycloakAdmin.clients.listRoles({ id: argocdClient.id }); + + for (const roleName of clientRoleNames) { + const roleExists = existingRoles.some(role => role.name === roleName); + if (roleExists) { + console.info(`client role "${roleName}" already exists for "${clientId}".`); + continue; + } + + await keycloakAdmin.clients.createRole({id: argocdClient.id, name: roleName}); + console.log(`client role "${roleName}" created for client "${clientId}".`); + } + } catch (err) { + throw err; + } +} + +async function ensureArgocdRolesJwtClaimMapper() { + const clientId = config.keycloak.client.clientId; + + try { + const clients = await keycloakAdmin.clients.find(); + const argocdClient = clients.find(c => c.clientId === clientId); + + if (!argocdClient) { + console.error(`client "${clientId}" does not exist; skip roles JWT mapper.`); return; } - const defaultScopes = await keycloakAdmin.clients.listDefaultClientScopes({ id: argocdClient.id }); - const isGroupsScopeAssigned = defaultScopes.some(scope => scope.id === groupsScope.id); - - if (isGroupsScopeAssigned) { - console.info('"groups" client scope already exists in "argocd" client.'); - } else { - console.log('adding "groups" client scope to "argocd" client.'); - await keycloakAdmin.clients.addDefaultClientScope({id: argocdClient.id, clientScopeId: groupsScope.id,}); + const mapperPayload = { + name: ARGOCD_ROLES_CLAIM_MAPPER, + protocol: 'openid-connect', + protocolMapper: 'oidc-usermodel-client-role-mapper', + consentRequired: false, + config: { + multivalued: 'true', + 'userinfo.token.claim': 'true', + 'id.token.claim': 'true', + 'access.token.claim': 'true', + 'claim.name': 'roles', + 'jsonType.label': 'String', + 'usermodel.clientRoleMapping.clientId': clientId + } + }; + + const mappers = await keycloakAdmin.clients.listProtocolMappers({ id: argocdClient.id }); + const existing = mappers.find(m => m.name === ARGOCD_ROLES_CLAIM_MAPPER); + + if (existing) { + if (existing.protocolMapper !== mapperPayload.protocolMapper) { + await keycloakAdmin.clients.delProtocolMapper({ + id: argocdClient.id, + mapperId: existing.id + }); + await keycloakAdmin.clients.addProtocolMapper({ id: argocdClient.id }, mapperPayload); + console.log('replaced protocol mapper %s on client %s', ARGOCD_ROLES_CLAIM_MAPPER, clientId); + return; + } + await keycloakAdmin.clients.updateProtocolMapper( + { id: argocdClient.id, mapperId: existing.id }, + { ...mapperPayload, id: existing.id } + ); + console.info('updated protocol mapper %s on client %s', ARGOCD_ROLES_CLAIM_MAPPER, clientId); + return; + } + + await keycloakAdmin.clients.addProtocolMapper({ id: argocdClient.id }, mapperPayload); + console.log('created protocol mapper %s on client %s', ARGOCD_ROLES_CLAIM_MAPPER, clientId); + } catch (err) { + throw err; + } +} + +async function mapArgocdClientRolesToGroupsIfRequired() { + const clientId = config.keycloak.client.clientId; + const clientRoleNames = config.keycloak.ClientRoles; + + try { + const clients = await keycloakAdmin.clients.find(); + const argocdClient = clients.find(client => client.clientId === clientId); + + if (!argocdClient) { + console.error(`client "${clientId}" does not exist.`); + return; + } + + const allGroups = await keycloakAdmin.groups.find(); + + for (const clientRoleName of clientRoleNames) { + const clientRole = await keycloakAdmin.clients.findRole({id: argocdClient.id, roleName: clientRoleName}); + + if (!clientRole) { + console.warn(`client role "${clientRoleName}" does not exist in "${clientId}".`); + continue; + } + + const groupNames = clientRoleToGroup[clientRoleName]; + if (!groupNames) { + console.warn(`no group mapping defined for client role "${clientRoleName}".`); + continue; + } + + const names = Array.isArray(groupNames) ? groupNames : [groupNames]; + for (const groupName of names) { + const group = allGroups.find(g => g.name === groupName); + + if (!group) { + console.warn(`group "${groupName}" does not exist.`); + continue; + } + + const mappedRoles = await keycloakAdmin.groups.listClientRoleMappings({id: group.id, clientUniqueId: argocdClient.id}); + const alreadyMapped = mappedRoles.some(role => role.name === clientRole.name); + + if (alreadyMapped) { + console.info(`client role "${clientRoleName}" is already mapped to group "${groupName}".`); + continue; + } + + await keycloakAdmin.groups.addClientRoleMappings({ + id: group.id, + clientUniqueId: argocdClient.id, + roles: [{ + id: clientRole.id, + name: clientRole.name + }] + }); + console.log(`client role "${clientRoleName}" mapped to group "${groupName}".`); + } } } catch (err) { throw err; } } -async function createArgocdRealmGroupsIfRequired() { - const realmGroupNames = config.keycloak.rolesAndGroups; +async function mapUsersToClientRoleIfRequired() { + const searchGroup = 'admin'; + const clientId = config.keycloak.client.clientId; + + const clientRoleNamesToAssign = []; + for (const [roleName, mappedGroupNames] of Object.entries(clientRoleToGroup)) { + const names = Array.isArray(mappedGroupNames) ? mappedGroupNames : [mappedGroupNames]; + if (names.some(gn => gn.includes(searchGroup))) { + clientRoleNamesToAssign.push(roleName); + } + } + + try { + const users = await keycloakAdmin.users.find(); + const user = _.find(users, { username: config.keycloak.adminUser.username }); + + if (!user) { + console.error(`user "${config.keycloak.adminUser.username}" does not exist.`); + return; + } + + const clients = await keycloakAdmin.clients.find(); + const argocdClient = clients.find(c => c.clientId === clientId); + + if (!argocdClient) { + console.error(`client "${clientId}" does not exist.`); + return; + } + + for (const clientRoleName of clientRoleNamesToAssign) { + const clientRole = await keycloakAdmin.clients.findRole({ + id: argocdClient.id, + roleName: clientRoleName + }); + + if (!clientRole) { + console.warn(`client role "${clientRoleName}" does not exist for "${clientId}".`); + continue; + } - for (const realmGroupName of realmGroupNames) { - try { - const existingGroups = await keycloakAdmin.groups.find({ search: realmGroupName }); - const matchedGroup = existingGroups.find(group => group.name === realmGroupName); + const mappedRoles = await keycloakAdmin.users.listClientRoleMappings({ + id: user.id, + clientUniqueId: argocdClient.id + }); + const alreadyMapped = mappedRoles.some(role => role.name === clientRole.name); - if (matchedGroup) { - console.info(`group "${realmGroupName}" already exists.`); - } else { - console.log(`creating group "${realmGroupName}".`); - await keycloakAdmin.groups.create({ name: realmGroupName }); + if (alreadyMapped) { + console.info(`user "${user.username}" already has client role "${clientRoleName}" on "${clientId}".`); + continue; } - } catch (err) { - throw err; + + await keycloakAdmin.users.addClientRoleMappings({ + id: user.id, + clientUniqueId: argocdClient.id, + roles: [{ id: clientRole.id, name: clientRole.name }] + }); + console.log(`user "${user.username}" assigned client role "${clientRoleName}" on "${clientId}".`); } + } catch (err) { + throw err; } } @@ -177,9 +429,13 @@ async function configureKeycloak() { await waitForKeycloak(); await getRealm(); await createClientIfRequired(); + await createUserIfRequired(); await generateSecretFiles(); - await addGroupsClientScopeToArgocdClientIfRequired(); - await createArgocdRealmGroupsIfRequired(); + await DisableFullScopeIfRequired(); + await createArgocdClientRolesIfRequired(); + await ensureArgocdRolesJwtClaimMapper(); + await mapArgocdClientRolesToGroupsIfRequired(); + await mapUsersToClientRoleIfRequired(); } catch (err) { throw err } diff --git a/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-argocd/package.json b/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-argocd/package.json index 9ed941a4..2f26aef3 100644 --- a/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-argocd/package.json +++ b/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-argocd/package.json @@ -4,6 +4,6 @@ "dependencies": { "async-retry": "1.3.3", "@keycloak/keycloak-admin-client": "25.0.5", - "lodash": "4.17.21" + "lodash": "4.18.1" } } diff --git a/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-gerrit/configure.sh b/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-gerrit/configure.sh index c00ec6d3..b13ae85c 100644 --- a/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-gerrit/configure.sh +++ b/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-gerrit/configure.sh @@ -22,7 +22,7 @@ CACERT=${SERVICEACCOUNT}/ca.crt UPDATE_NEEDED=false -npm install +npm install --ignore-scripts node keycloak.mjs SECRET=$(cat client-gerrit.json | jq -r ".secret") diff --git a/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-gerrit/keycloak.mjs b/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-gerrit/keycloak.mjs index 3780fd79..2c405fa2 100644 --- a/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-gerrit/keycloak.mjs +++ b/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-gerrit/keycloak.mjs @@ -109,8 +109,8 @@ async function createUserIfRequired() { let user = _.find(users, {username: config.keycloak.adminUser.username}); if (user) { - console.info('deleting old instance of %s user', config.keycloak.adminUser.username); - await keycloakAdmin.users.del({id: user.id}); + console.info('user "%s" already exists, skipping create and password reset', config.keycloak.adminUser.username); + return; } console.info('creating %s user', config.keycloak.adminUser.username); @@ -121,7 +121,8 @@ async function createUserIfRequired() { realm: config.keycloak.realm.realm, firstName: config.keycloak.adminUser.firstName, lastName: config.keycloak.adminUser.lastName, - email: config.keycloak.adminUser.email + email: config.keycloak.adminUser.email, + emailVerified: true }); await keycloakAdmin.users.resetPassword({ diff --git a/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-gerrit/package.json b/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-gerrit/package.json index 9ed941a4..2f26aef3 100644 --- a/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-gerrit/package.json +++ b/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-gerrit/package.json @@ -4,6 +4,6 @@ "dependencies": { "async-retry": "1.3.3", "@keycloak/keycloak-admin-client": "25.0.5", - "lodash": "4.17.21" + "lodash": "4.18.1" } } diff --git a/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-grafana/configure.sh b/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-grafana/configure.sh index cd50c7be..be33d2ca 100644 --- a/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-grafana/configure.sh +++ b/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-grafana/configure.sh @@ -20,7 +20,7 @@ NAMESPACE=$(cat ${SERVICEACCOUNT}/namespace) TOKEN=$(cat ${SERVICEACCOUNT}/token) CACERT=${SERVICEACCOUNT}/ca.crt -npm install +npm install --ignore-scripts node keycloak.mjs SECRET=$(cat client-grafana.json | jq -r ".secret") sed -i "s/##SECRET##/${SECRET}/g" ./secret.json diff --git a/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-grafana/keycloak.mjs b/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-grafana/keycloak.mjs index ce2be283..d1c593a2 100644 --- a/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-grafana/keycloak.mjs +++ b/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-grafana/keycloak.mjs @@ -39,16 +39,18 @@ const config = { lastName: 'Grafana', email: 'grafana@grafana' }, - clientScope:{ - clientScopeName: 'groups' - }, - rolesAndGroups: [ - 'horizon-grafana-administrators', - 'horizon-grafana-viewers' + ClientRoles: [ + 'administrators', + 'viewers' ] } }; +const clientRoleToGroup = { + administrators: ['administrators'], + viewers: ['viewers'] +}; + const keycloakAdmin = new KcAdminClient({ baseUrl: config.keycloak.baseUrl }); @@ -116,8 +118,8 @@ async function createUserIfRequired() { let user = _.find(users, {username: config.keycloak.adminUser.username}); if (user) { - console.info('deleting old instance of %s user', config.keycloak.adminUser.username); - await keycloakAdmin.users.del({id: user.id}); + console.info('user "%s" already exists, skipping create and password reset', config.keycloak.adminUser.username); + return; } console.info('creating %s user', config.keycloak.adminUser.username); @@ -128,13 +130,19 @@ async function createUserIfRequired() { realm: config.keycloak.realm.realm, firstName: config.keycloak.adminUser.firstName, lastName: config.keycloak.adminUser.lastName, - email: config.keycloak.adminUser.email + email: config.keycloak.adminUser.email, + emailVerified: true + }); + + await keycloakAdmin.users.resetPassword({ + id: new_user.id, + realm: config.keycloak.realm.realm, + credential: {temporary: false, type: 'password', value: config.keycloak.adminUser.password} }); } catch (err) { throw err } - } async function generateSecretFiles() { @@ -151,9 +159,8 @@ async function generateSecretFiles() { } } -async function addGroupsClientScopeToGrafanaClientIfRequired() { +async function DisableFullScopeIfRequired() { const clientId = config.keycloak.client.clientId; - const clientScopeName = config.keycloak.clientScope.clientScopeName; try { const clients = await keycloakAdmin.clients.find(); @@ -164,49 +171,23 @@ async function addGroupsClientScopeToGrafanaClientIfRequired() { return; } - const clientScopes = await keycloakAdmin.clientScopes.find(); - const groupsScope = clientScopes.find(scope => scope.name === clientScopeName); - - if (!groupsScope) { - console.error(`client scope "${clientScopeName}" does not exist.`); - return; - } - - const defaultScopes = await keycloakAdmin.clients.listDefaultClientScopes({ id: grafanaClient.id }); - const isGroupsScopeAssigned = defaultScopes.some(scope => scope.id === groupsScope.id); - - if (isGroupsScopeAssigned) { - console.info('"groups" client scope already exists in "grafana" client.'); + if (grafanaClient.fullScopeAllowed === false) { + console.info(`"Full scope allowed" is already disabled for client "${clientId}".`); } else { - console.log('adding "groups" client scope to "grafana" client.'); - await keycloakAdmin.clients.addDefaultClientScope({id: grafanaClient.id, clientScopeId: groupsScope.id,}); + console.log(`disabling "Full scope allowed" for client "${clientId}".`); + await keycloakAdmin.clients.update( + { id: grafanaClient.id, realm: config.keycloak.realm.realm }, + { ...grafanaClient, fullScopeAllowed: false } + ); } } catch (err) { throw err; } } -async function createGrafanaRealmRolesIfRequired() { - const realmRoleNames = config.keycloak.rolesAndGroups; - - for (const realmRoleName of realmRoleNames) { - try { - let realmRole = await keycloakAdmin.roles.findOneByName({name: realmRoleName}); - if (realmRole) { - console.info(`role ${realmRoleName} exists`); - } else { - console.log(`creating ${realmRoleName} role`); - await keycloakAdmin.roles.create({name: realmRoleName}); - } - } catch (err) { - throw err; - } - } -} - async function createGrafanaClientRolesIfRequired() { const clientId = config.keycloak.client.clientId; - const clientRoleNames = config.keycloak.rolesAndGroups; + const clientRoleNames = config.keycloak.ClientRoles; try { const clients = await keycloakAdmin.clients.find(); @@ -234,29 +215,9 @@ async function createGrafanaClientRolesIfRequired() { } } -async function createGrafanaRealmGroupsIfRequired() { - const realmGroupNames = config.keycloak.rolesAndGroups; - - for (const realmGroupName of realmGroupNames) { - try { - const existingGroups = await keycloakAdmin.groups.find({ search: realmGroupName }); - const matchedGroup = existingGroups.find(group => group.name === realmGroupName); - - if (matchedGroup) { - console.info(`group "${realmGroupName}" already exists.`); - } else { - console.log(`creating group "${realmGroupName}".`); - await keycloakAdmin.groups.create({ name: realmGroupName }); - } - } catch (err) { - throw err; - } - } -} - -async function mapGrafanaRealmRolesIntoClientRolesIfRequired() { +async function mapGrafanaClientRolesToGroupsIfRequired() { const clientId = config.keycloak.client.clientId; - const roleNames = config.keycloak.rolesAndGroups; + const clientRoleNames = config.keycloak.ClientRoles; try { const clients = await keycloakAdmin.clients.find(); @@ -267,76 +228,112 @@ async function mapGrafanaRealmRolesIntoClientRolesIfRequired() { return; } - for (const roleName of roleNames) { - const clientRole = await keycloakAdmin.clients.findRole({id: grafanaClient.id, roleName}); + const allGroups = await keycloakAdmin.groups.find(); + + for (const clientRoleName of clientRoleNames) { + const clientRole = await keycloakAdmin.clients.findRole({id: grafanaClient.id, roleName: clientRoleName}); if (!clientRole) { - console.warn(`client role "${roleName}" does not exist under client "${clientId}".`); + console.warn(`client role "${clientRoleName}" does not exist in "${clientId}".`); continue; } - const realmRole = await keycloakAdmin.roles.findOneByName({ name: roleName }); - if (!realmRole) { - console.warn(`realm role "${roleName}" does not exist.`); + const groupNames = clientRoleToGroup[clientRoleName]; + if (!groupNames) { + console.warn(`no group mapping defined for client role "${clientRoleName}".`); continue; } - let parentRole = await keycloakAdmin.clients.findRole({id: grafanaClient.id, roleName: roleName}); - let childRole = await keycloakAdmin.roles.findOneByName({name: roleName}); - await keycloakAdmin.roles.createComposite({roleId: parentRole.id}, [childRole]); - console.log(`realm role "${roleName}" mapped into client role "${roleName}".`); + const names = Array.isArray(groupNames) ? groupNames : [groupNames]; + for (const groupName of names) { + const group = allGroups.find(g => g.name === groupName); + + if (!group) { + console.warn(`group "${groupName}" does not exist.`); + continue; + } + + const mappedRoles = await keycloakAdmin.groups.listClientRoleMappings({id: group.id, clientUniqueId: grafanaClient.id}); + const alreadyMapped = mappedRoles.some(role => role.name === clientRole.name); + + if (alreadyMapped) { + console.info(`client role "${clientRoleName}" is already mapped to group "${groupName}".`); + continue; + } + + await keycloakAdmin.groups.addClientRoleMappings({ + id: group.id, + clientUniqueId: grafanaClient.id, + roles: [{ + id: clientRole.id, + name: clientRole.name + }] + }); + console.log(`client role "${clientRoleName}" mapped to group "${groupName}".`); + } } } catch (err) { throw err; } } -async function mapGrafanaClientRolesToGroupsIfRequired() { +async function mapUsersToClientRoleIfRequired() { + const searchGroup = 'admin'; const clientId = config.keycloak.client.clientId; - const roleGroupNames = config.keycloak.rolesAndGroups; + + const clientRoleNamesToAssign = []; + for (const [roleName, mappedGroupNames] of Object.entries(clientRoleToGroup)) { + const names = Array.isArray(mappedGroupNames) ? mappedGroupNames : [mappedGroupNames]; + if (names.some(gn => gn.includes(searchGroup))) { + clientRoleNamesToAssign.push(roleName); + } + } try { + const users = await keycloakAdmin.users.find(); + const user = _.find(users, { username: config.keycloak.adminUser.username }); + + if (!user) { + console.error(`user "${config.keycloak.adminUser.username}" does not exist.`); + return; + } + const clients = await keycloakAdmin.clients.find(); - const grafanaClient = clients.find(client => client.clientId === clientId); + const grafanaClient = clients.find(c => c.clientId === clientId); if (!grafanaClient) { console.error(`client "${clientId}" does not exist.`); return; } - for (const roleGroupName of roleGroupNames) { - const clientRole = await keycloakAdmin.clients.findRole({id: grafanaClient.id, roleName: roleGroupName}); + for (const clientRoleName of clientRoleNamesToAssign) { + const clientRole = await keycloakAdmin.clients.findRole({ + id: grafanaClient.id, + roleName: clientRoleName + }); if (!clientRole) { - console.warn(`client role "${roleGroupName}" does not exist in "${clientId}".`); + console.warn(`client role "${clientRoleName}" does not exist for "${clientId}".`); continue; } - const allGroups = await keycloakAdmin.groups.find(); - const group = allGroups.find(g => g.name === roleGroupName); - - if (!group) { - console.warn(`group "${roleGroupName}" does not exist.`); - continue; - } - - const mappedRoles = await keycloakAdmin.groups.listClientRoleMappings({id: group.id, clientUniqueId: grafanaClient.id}); + const mappedRoles = await keycloakAdmin.users.listClientRoleMappings({ + id: user.id, + clientUniqueId: grafanaClient.id + }); const alreadyMapped = mappedRoles.some(role => role.name === clientRole.name); if (alreadyMapped) { - console.info(`client role "${roleGroupName}" is already mapped to group "${roleGroupName}".`); + console.info(`user "${user.username}" already has client role "${clientRoleName}" on "${clientId}".`); continue; } - await keycloakAdmin.groups.addClientRoleMappings({ - id: group.id, + await keycloakAdmin.users.addClientRoleMappings({ + id: user.id, clientUniqueId: grafanaClient.id, - roles: [{ - id: clientRole.id, - name: clientRole.name - }] + roles: [{ id: clientRole.id, name: clientRole.name }] }); - console.log(`client role "${roleGroupName}" mapped to group "${roleGroupName}".`); + console.log(`user "${user.username}" assigned client role "${clientRoleName}" on "${clientId}".`); } } catch (err) { throw err; @@ -350,12 +347,10 @@ async function configureKeycloak() { await createClientIfRequired(); await createUserIfRequired(); await generateSecretFiles(); - await addGroupsClientScopeToGrafanaClientIfRequired(); - await createGrafanaRealmRolesIfRequired(); - await createGrafanaRealmGroupsIfRequired(); + await DisableFullScopeIfRequired(); await createGrafanaClientRolesIfRequired(); - await mapGrafanaRealmRolesIntoClientRolesIfRequired(); await mapGrafanaClientRolesToGroupsIfRequired(); + await mapUsersToClientRoleIfRequired(); } catch (err) { throw err } @@ -364,4 +359,4 @@ async function configureKeycloak() { configureKeycloak() .catch((err) => { console.error(err.message); - }); + }); \ No newline at end of file diff --git a/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-grafana/package.json b/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-grafana/package.json index 9ed941a4..2f26aef3 100644 --- a/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-grafana/package.json +++ b/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-grafana/package.json @@ -4,6 +4,6 @@ "dependencies": { "async-retry": "1.3.3", "@keycloak/keycloak-admin-client": "25.0.5", - "lodash": "4.17.21" + "lodash": "4.18.1" } } diff --git a/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-headlamp/configure.sh b/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-headlamp/configure.sh index 7926de6f..7806d3c2 100644 --- a/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-headlamp/configure.sh +++ b/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-headlamp/configure.sh @@ -66,7 +66,7 @@ debug_log "kubectl connectivity OK" # Run node script debug_log "Installing npm packages" -npm install --silent +npm install --silent --ignore-scripts debug_log "====================" debug_log "Running keycloak.mjs" @@ -150,5 +150,4 @@ debug_log "Post-job script Finish" debug_log "======================" echo "All steps finished successfully" -exit 0 - +exit 0 \ No newline at end of file diff --git a/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-headlamp/keycloak.mjs b/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-headlamp/keycloak.mjs index f999e016..dc2affaa 100644 --- a/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-headlamp/keycloak.mjs +++ b/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-headlamp/keycloak.mjs @@ -32,14 +32,15 @@ const config = { protocol: 'openid-connect', publicClient: false }, - clientScope:{ - clientScopeName: 'groups' - }, - rolesAndGroups: [ - 'horizon-headlamp-administrators' + ClientRoles: [ + 'administrators' ] } -} +}; + +const clientRoleToGroup = { + administrators: ['administrators'] +}; const keycloakAdmin = new KcAdminClient({ baseUrl: config.keycloak.baseUrl @@ -77,7 +78,7 @@ async function login() { debugLog(`Keycloak authentication failed!`); debugLog(`Error message:`, err.message); debugLog(`Stack trace:`, err.stack); - throw err; + throw err } } @@ -105,7 +106,7 @@ async function getRealm() { debugLog(`Error while fetching realm "${config.keycloak.realm.realm}!"`); debugLog(`Error Message:`, err.message) debugLog(`Stack Trace:`, err.stack); - throw err; + throw err } } @@ -135,7 +136,7 @@ async function createClientIfRequired() { debugLog(`Error while updataing/creating Client ${config.keycloak.client.clientId}!`); debugLog(`Error Message:`, err.message); debugLog(`Error Stack:`, err.stack); - throw err; + throw err } } @@ -160,14 +161,11 @@ async function generateSecretFiles() { } } -async function addGroupsClientScopeToHeadlampClientIfRequired() { - debugLog(`Adding "${config.keycloak.clientScope.clientScopeName}" client scope to "${config.keycloak.client.clientId}" client if not existing already...`); +async function DisableFullScopeIfRequired() { const clientId = config.keycloak.client.clientId; - const clientScopeName = config.keycloak.clientScope.clientScopeName; try { const clients = await keycloakAdmin.clients.find(); - debugLog(`Clients fetched: ${clients.map(c => c.clientId).join(', ')}`); const headlampClient = clients.find(client => client.clientId === clientId); if (!headlampClient) { @@ -175,55 +173,109 @@ async function addGroupsClientScopeToHeadlampClientIfRequired() { return; } - const clientScopes = await keycloakAdmin.clientScopes.find(); - debugLog(`Client scopes fetched: ${clientScopes.map(cs => cs.name).join(', ')}`); - const groupsScope = clientScopes.find(scope => scope.name === clientScopeName); - - if (!groupsScope) { - console.error(`client scope "${clientScopeName}" does not exist.`); + if (headlampClient.fullScopeAllowed === false) { + console.info(`"Full scope allowed" is already disabled for client "${clientId}".`); + } else { + console.log(`disabling "Full scope allowed" for client "${clientId}".`); + await keycloakAdmin.clients.update( + { id: headlampClient.id, realm: config.keycloak.realm.realm }, + { ...headlampClient, fullScopeAllowed: false } + ); + } + } catch (err) { + throw err; + } +} + +async function createHeadlampClientRolesIfRequired() { + const clientId = config.keycloak.client.clientId; + const clientRoleNames = config.keycloak.ClientRoles; + + try { + const clients = await keycloakAdmin.clients.find(); + const headlampClient = clients.find(client => client.clientId === clientId); + + if (!headlampClient) { + console.error(`client "${clientId}" does not exist.`); return; } - const defaultScopes = await keycloakAdmin.clients.listDefaultClientScopes({ id: headlampClient.id }); - debugLog(`Existing Client scopes for Client ${clientId}: ${defaultScopes.map(ds => ds.name).join(', ')}`); - const isGroupsScopeAssigned = defaultScopes.some(scope => scope.id === groupsScope.id); - - if (isGroupsScopeAssigned) { - console.info(`"${config.keycloak.clientScope.clientScopeName}" client scope already exists in "${config.keycloak.client.clientId}" client.`); - } else { - console.log(`adding "${config.keycloak.clientScope.clientScopeName}" client scope to "${config.keycloak.client.clientId}" client.`); - await keycloakAdmin.clients.addDefaultClientScope({id: headlampClient.id, clientScopeId: groupsScope.id,}); - debugLog(`"${config.keycloak.clientScope.clientScopeName}" client scope successfully added to "${clientId}" client`); + const existingRoles = await keycloakAdmin.clients.listRoles({ id: headlampClient.id }); + + for (const roleName of clientRoleNames) { + const roleExists = existingRoles.some(role => role.name === roleName); + if (roleExists) { + console.info(`client role "${roleName}" already exists for "${clientId}".`); + continue; + } + + await keycloakAdmin.clients.createRole({id: headlampClient.id, name: roleName}); + console.log(`client role "${roleName}" created for client "${clientId}".`); } } catch (err) { - debugLog(`Error while adding "${config.keycloak.clientScope.clientScopeName}" client scope to "${clientId}" client`); - debugLog(`Error Message:`, err.message); - debugLog(`Stack Trace:`, err.stack); throw err; } } -async function createHeadlampRealmGroupsIfRequired() { - debugLog(`Creating Keycloak realm group "${config.keycloak.rolesAndGroups}" if not existing already...`); - const realmGroupNames = config.keycloak.rolesAndGroups; +async function mapHeadlampClientRolesToGroupsIfRequired() { + const clientId = config.keycloak.client.clientId; + const clientRoleNames = config.keycloak.ClientRoles; - for (const realmGroupName of realmGroupNames) { - try { - const existingGroups = await keycloakAdmin.groups.find({ search: realmGroupName }); - const matchedGroup = existingGroups.find(group => group.name === realmGroupName); + try { + const clients = await keycloakAdmin.clients.find(); + const headlampClient = clients.find(client => client.clientId === clientId); + + if (!headlampClient) { + console.error(`client "${clientId}" does not exist.`); + return; + } + + const allGroups = await keycloakAdmin.groups.find(); - if (matchedGroup) { - console.info(`group "${realmGroupName}" already exists.`); - } else { - console.log(`creating group "${realmGroupName}".`); - await keycloakAdmin.groups.create({ name: realmGroupName }); + for (const clientRoleName of clientRoleNames) { + const clientRole = await keycloakAdmin.clients.findRole({id: headlampClient.id, roleName: clientRoleName}); + + if (!clientRole) { + console.warn(`client role "${clientRoleName}" does not exist in "${clientId}".`); + continue; + } + + const groupNames = clientRoleToGroup[clientRoleName]; + if (!groupNames) { + console.warn(`no group mapping defined for client role "${clientRoleName}".`); + continue; + } + + const names = Array.isArray(groupNames) ? groupNames : [groupNames]; + for (const groupName of names) { + const group = allGroups.find(g => g.name === groupName); + + if (!group) { + console.warn(`group "${groupName}" does not exist.`); + continue; + } + + const mappedRoles = await keycloakAdmin.groups.listClientRoleMappings({id: group.id, clientUniqueId: headlampClient.id}); + const alreadyMapped = mappedRoles.some(role => role.name === clientRole.name); + + if (alreadyMapped) { + console.info(`client role "${clientRoleName}" is already mapped to group "${groupName}".`); + continue; + } + + await keycloakAdmin.groups.addClientRoleMappings({ + id: group.id, + clientUniqueId: headlampClient.id, + roles: [{ + id: clientRole.id, + name: clientRole.name + }] + }); + console.log(`client role "${clientRoleName}" mapped to group "${groupName}".`); } - } catch (err) { - debugLog(`Error while creating group ${config.keycloak.rolesAndGroups}`); - debugLog(`Error Message:`, err.message); - debugLog(`Stack Trace: `, err.stack); - throw err; } + } catch (err) { + throw err; } } @@ -233,8 +285,9 @@ async function configureKeycloak() { await getRealm(); await createClientIfRequired(); await generateSecretFiles(); - await addGroupsClientScopeToHeadlampClientIfRequired(); - await createHeadlampRealmGroupsIfRequired(); + await DisableFullScopeIfRequired(); + await createHeadlampClientRolesIfRequired(); + await mapHeadlampClientRolesToGroupsIfRequired(); } catch (err) { debugLog(`Keycloak configuration failed.`); debugLog(`Error Message:`, err.message); diff --git a/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-headlamp/package.json b/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-headlamp/package.json index 9ed941a4..2f26aef3 100644 --- a/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-headlamp/package.json +++ b/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-headlamp/package.json @@ -4,6 +4,6 @@ "dependencies": { "async-retry": "1.3.3", "@keycloak/keycloak-admin-client": "25.0.5", - "lodash": "4.17.21" + "lodash": "4.18.1" } } diff --git a/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-horizon-api/.dockerignore b/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-horizon-api/.dockerignore new file mode 100644 index 00000000..3c3629e6 --- /dev/null +++ b/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-horizon-api/.dockerignore @@ -0,0 +1 @@ +node_modules diff --git a/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-horizon-api/Dockerfile b/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-horizon-api/Dockerfile new file mode 100644 index 00000000..6163171f --- /dev/null +++ b/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-horizon-api/Dockerfile @@ -0,0 +1,23 @@ +# Copyright (c) 2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +FROM --platform=linux/amd64 node:22-alpine +RUN apk update && apk upgrade && apk add --no-cache bash vim curl jq openssl +WORKDIR /home/node +ADD --chown=node:node package.json ./ +ADD --chown=node:node keycloak.mjs ./ +ADD --chown=node:node configure.sh ./ +RUN chmod +x configure.sh +USER node +CMD ["/bin/bash", "-c", "/home/node/configure.sh"] diff --git a/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-horizon-api/configure.sh b/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-horizon-api/configure.sh new file mode 100644 index 00000000..1d7f73fa --- /dev/null +++ b/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-horizon-api/configure.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash + +# Copyright (c) 2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +set -euo pipefail + +APISERVER=https://kubernetes.default.svc +SERVICEACCOUNT=/var/run/secrets/kubernetes.io/serviceaccount +TOKEN=$(cat "${SERVICEACCOUNT}/token") +CACERT=${SERVICEACCOUNT}/ca.crt + +# Wait until the namespace exists (Argo sync can run this Job before the child Application creates it). +wait_for_namespace() { + local ns=$1 + local deadline=$(( $(date +%s) + 300 )) + while true; do + code=$(curl --cacert "${CACERT}" --header "Authorization: Bearer ${TOKEN}" \ + -o /dev/null -w '%{http_code}' -sS "${APISERVER}/api/v1/namespaces/${ns}" || true) + if [[ "${code}" == "200" ]]; then + return 0 + fi + if [[ "$(date +%s)" -ge "${deadline}" ]]; then + echo "timeout waiting for namespace ${ns} (last HTTP ${code})" >&2 + exit 1 + fi + sleep 2 + done +} + +npm install +node keycloak.mjs + +SECRET=$(tr -d '\n\r' { + console.info( + `waiting for ${process.env.PLATFORM_URL}/auth...`, + err.message, + ); + }, + }; + await retry(login, opts); +} + +async function login() { + await keycloakAdmin.auth({ + username: process.env.KEYCLOAK_USERNAME, + password: process.env.KEYCLOAK_PASSWORD, + grantType: 'password', + clientId: 'admin-cli', + }); +} + +async function getRealm() { + const realm = await keycloakAdmin.realms.findOne({ realm: REALM }); + if (!realm) { + throw new Error(`realm "${REALM}" not found`); + } + keycloakAdmin.setConfig({ realmName: realm.realm }); +} + +function horizonPublicClient(domain) { + return { + clientId: 'horizon-api', + name: 'Horizon API (CLI / humans)', + protocol: 'openid-connect', + publicClient: true, + standardFlowEnabled: true, + directAccessGrantsEnabled: false, + attributes: { + 'pkce.code.challenge.method': 'S256', + 'oauth2.device.authorization.grant.enabled': 'true', + }, + redirectUris: [ + `${domain}/horizon-api/oauth2/*`, + 'http://127.0.0.1:8080/*', + 'http://127.0.0.1:8400/*', + 'http://127.0.0.1:9250/*', + 'http://localhost:8080/*', + 'http://localhost:8400/*', + 'http://localhost:9250/*', + ], + webOrigins: [ + domain, + 'http://127.0.0.1:8080', + 'http://127.0.0.1:8400', + 'http://127.0.0.1:9250', + 'http://localhost:8080', + 'http://localhost:8400', + 'http://localhost:9250', + ], + }; +} + +/** Confidential client: client_credentials via service account; use client_id + client_secret from Keycloak Admin. */ +function horizonCiClient() { + return { + clientId: 'horizon-api-ci', + name: 'Horizon API CI/CD', + protocol: 'openid-connect', + publicClient: false, + standardFlowEnabled: false, + directAccessGrantsEnabled: false, + serviceAccountsEnabled: true, + // Lets Keycloak return refresh_token + refresh_expires_in on client_credentials (renew via refresh_token grant). + attributes: { + 'use.refresh.tokens': 'true', + }, + }; +} + +/** Browser SPA at /developer-portal (PKCE + optional password grant). */ +function horizonDevPortalClient(domain) { + return { + clientId: 'horizon-dev-portal', + name: 'Horizon Developer Portal', + protocol: 'openid-connect', + publicClient: true, + standardFlowEnabled: true, + directAccessGrantsEnabled: true, + attributes: { + 'pkce.code.challenge.method': 'S256', + }, + redirectUris: [`${domain}/developer-portal/*`], + webOrigins: [domain], + }; +} + +async function upsertClient(desired) { + const clients = await keycloakAdmin.clients.find(); + const found = _.find(clients, { clientId: desired.clientId }); + const merged = _.merge({}, found, desired); + if (merged.clientId === 'horizon-api-ci' && merged.attributes) { + merged.attributes = _.omit(merged.attributes, 'oauth2.token.exchange.grant.enabled'); + } + if (found) { + console.info('updating client %s', desired.clientId); + await keycloakAdmin.clients.update( + { id: found.id, realm: REALM }, + merged, + ); + } else { + console.info('creating client %s', desired.clientId); + await keycloakAdmin.clients.create(desired); + } +} + +async function writeHorizonCiClientSecretForJenkins() { + const clients = await keycloakAdmin.clients.find(); + const client = _.find(clients, { clientId: 'horizon-api-ci' }); + if (!client?.id) { + throw new Error('horizon-api-ci client not found'); + } + let cred = await keycloakAdmin.clients.getClientSecret({ id: client.id }); + if (!cred?.value) { + cred = await keycloakAdmin.clients.generateNewClientSecret({ + id: client.id, + }); + } + if (!cred?.value) { + throw new Error('horizon-api-ci client has no client secret'); + } + await fs.writeFile('horizon-api-ci-client-secret', cred.value, 'utf8'); +} + +async function configureKeycloak() { + const domain = (process.env.DOMAIN || '').replace(/\/$/, ''); + if (!domain) { + throw new Error('DOMAIN is required'); + } + + await waitForKeycloak(); + await getRealm(); + await upsertClient(horizonPublicClient(domain)); + await upsertClient(horizonDevPortalClient(domain)); + await upsertClient(horizonCiClient()); + await writeHorizonCiClientSecretForJenkins(); + console.info('Horizon API Keycloak post-job finished.'); +} + +configureKeycloak().catch((err) => { + console.error(err.message || err); + process.exit(1); +}); diff --git a/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-horizon-api/package-lock.json b/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-horizon-api/package-lock.json new file mode 100644 index 00000000..bf86ff82 --- /dev/null +++ b/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-horizon-api/package-lock.json @@ -0,0 +1,82 @@ +{ + "name": "keycloak-post-horizon-api", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "keycloak-post-horizon-api", + "version": "1.0.0", + "dependencies": { + "@keycloak/keycloak-admin-client": "25.0.5", + "async-retry": "1.3.3", + "lodash": "4.18.1" + } + }, + "node_modules/@keycloak/keycloak-admin-client": { + "version": "25.0.5", + "resolved": "https://registry.npmjs.org/@keycloak/keycloak-admin-client/-/keycloak-admin-client-25.0.5.tgz", + "integrity": "sha512-IpCKVyBbDm3oIEImszXRQFA3IefrdIxCaPykwNjlFlvfZ6E+Ma7GyNXlT3oukPlAeCgPG+zM/sHcooWZ6ZUl/Q==", + "license": "Apache-2.0", + "dependencies": { + "camelize-ts": "^3.0.0", + "url-join": "^5.0.0", + "url-template": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/async-retry": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/async-retry/-/async-retry-1.3.3.tgz", + "integrity": "sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==", + "license": "MIT", + "dependencies": { + "retry": "0.13.1" + } + }, + "node_modules/camelize-ts": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/camelize-ts/-/camelize-ts-3.0.0.tgz", + "integrity": "sha512-cgRwKKavoDKLTjO4FQTs3dRBePZp/2Y9Xpud0FhuCOTE86M2cniKN4CCXgRnsyXNMmQMifVHcv6SPaMtTx6ofQ==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/url-join": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/url-join/-/url-join-5.0.0.tgz", + "integrity": "sha512-n2huDr9h9yzd6exQVnH/jU5mr+Pfx08LRXXZhkLLetAMESRj+anQsTAh940iMrIetKAmry9coFuZQ2jY8/p3WA==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/url-template": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/url-template/-/url-template-3.1.1.tgz", + "integrity": "sha512-4oszoaEKE/mQOtAmdMWqIRHmkxWkUZMnXFnjQ5i01CuRSK3uluxcH1MRVVVWmhlnzT1SCDfKxxficm2G37qzCA==", + "license": "BSD-3-Clause", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + } + } +} diff --git a/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-horizon-api/package.json b/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-horizon-api/package.json new file mode 100644 index 00000000..c6c8971f --- /dev/null +++ b/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-horizon-api/package.json @@ -0,0 +1,9 @@ +{ + "name": "keycloak-post-horizon-api", + "version": "1.0.0", + "dependencies": { + "async-retry": "1.3.3", + "@keycloak/keycloak-admin-client": "25.0.5", + "lodash": "4.18.1" + } +} diff --git a/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-jenkins/configure.sh b/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-jenkins/configure.sh index e19c13bc..4377b601 100644 --- a/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-jenkins/configure.sh +++ b/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-jenkins/configure.sh @@ -20,7 +20,7 @@ NAMESPACE=$(cat ${SERVICEACCOUNT}/namespace) TOKEN=$(cat ${SERVICEACCOUNT}/token) CACERT=${SERVICEACCOUNT}/ca.crt -npm install +npm install --ignore-scripts node keycloak.mjs SECRET=$(cat client-jenkins.json | jq -r ".secret") DOMAIN_BS=$(echo $DOMAIN | sed 's:/:\\/:g') diff --git a/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-jenkins/keycloak.mjs b/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-jenkins/keycloak.mjs index 71d41d5e..8eff9cc3 100644 --- a/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-jenkins/keycloak.mjs +++ b/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-jenkins/keycloak.mjs @@ -39,17 +39,20 @@ const config = { lastName: 'Jenkins', email: 'jenkins@jenkins' }, - clientScope:{ - clientScopeName: 'groups' - }, - rolesAndGroups: [ - 'horizon-jenkins-administrators', - 'horizon-jenkins-workloads-developers', - 'horizon-jenkins-workloads-users' + ClientRoles: [ + 'administrators', + 'developers', + 'viewers' ] } }; +const clientRoleToGroup = { + administrators: ['administrators'], + developers: ['developers'], + viewers: ['viewers'] +}; + const keycloakAdmin = new KcAdminClient({ baseUrl: config.keycloak.baseUrl }); @@ -117,8 +120,8 @@ async function createUserIfRequired() { let user = _.find(users, {username: config.keycloak.adminUser.username}); if (user) { - console.info('deleting old instance of %s user', config.keycloak.adminUser.username); - await keycloakAdmin.users.del({id: user.id}); + console.info('user "%s" already exists, skipping create and password reset', config.keycloak.adminUser.username); + return; } console.info('creating %s user', config.keycloak.adminUser.username); @@ -129,7 +132,8 @@ async function createUserIfRequired() { realm: config.keycloak.realm.realm, firstName: config.keycloak.adminUser.firstName, lastName: config.keycloak.adminUser.lastName, - email: config.keycloak.adminUser.email + email: config.keycloak.adminUser.email, + emailVerified: true }); await keycloakAdmin.users.resetPassword({ @@ -152,15 +156,13 @@ async function generateSecretFiles() { console.info('dumping %s client data into json file', config.keycloak.client.clientId); fs.writeFile('client-jenkins.json', JSON.stringify(client)); } - } catch (err) { throw err } } -async function addGroupsClientScopeToJenkinsClientIfRequired() { +async function DisableFullScopeIfRequired() { const clientId = config.keycloak.client.clientId; - const clientScopeName = config.keycloak.clientScope.clientScopeName; try { const clients = await keycloakAdmin.clients.find(); @@ -171,49 +173,23 @@ async function addGroupsClientScopeToJenkinsClientIfRequired() { return; } - const clientScopes = await keycloakAdmin.clientScopes.find(); - const groupsScope = clientScopes.find(scope => scope.name === clientScopeName); - - if (!groupsScope) { - console.error(`client scope "${clientScopeName}" does not exist.`); - return; - } - - const defaultScopes = await keycloakAdmin.clients.listDefaultClientScopes({ id: jenkinsClient.id }); - const isGroupsScopeAssigned = defaultScopes.some(scope => scope.id === groupsScope.id); - - if (isGroupsScopeAssigned) { - console.info('"groups" client scope already exists in "jenkins" client.'); + if (jenkinsClient.fullScopeAllowed === false) { + console.info(`"Full scope allowed" is already disabled for client "${clientId}".`); } else { - console.log('adding "groups" client scope to "jenkins" client.'); - await keycloakAdmin.clients.addDefaultClientScope({id: jenkinsClient.id, clientScopeId: groupsScope.id,}); + console.log(`disabling "Full scope allowed" for client "${clientId}".`); + await keycloakAdmin.clients.update( + { id: jenkinsClient.id, realm: config.keycloak.realm.realm }, + { ...jenkinsClient, fullScopeAllowed: false } + ); } } catch (err) { throw err; } } -async function createJenkinsRealmRolesIfRequired() { - const realmRoleNames = config.keycloak.rolesAndGroups; - - for (const realmRoleName of realmRoleNames) { - try { - let realmRole = await keycloakAdmin.roles.findOneByName({name: realmRoleName}); - if (realmRole) { - console.info(`role ${realmRoleName} exists`); - } else { - console.log(`creating ${realmRoleName} role`); - await keycloakAdmin.roles.create({name: realmRoleName}); - } - } catch (err) { - throw err; - } - } -} - async function createJenkinsClientRolesIfRequired() { const clientId = config.keycloak.client.clientId; - const clientRoleNames = config.keycloak.rolesAndGroups; + const clientRoleNames = config.keycloak.ClientRoles; try { const clients = await keycloakAdmin.clients.find(); @@ -241,29 +217,9 @@ async function createJenkinsClientRolesIfRequired() { } } -async function createJenkinsRealmGroupsIfRequired() { - const realmGroupNames = config.keycloak.rolesAndGroups; - - for (const realmGroupName of realmGroupNames) { - try { - const existingGroups = await keycloakAdmin.groups.find({ search: realmGroupName }); - const matchedGroup = existingGroups.find(group => group.name === realmGroupName); - - if (matchedGroup) { - console.info(`group "${realmGroupName}" already exists.`); - } else { - console.log(`creating group "${realmGroupName}".`); - await keycloakAdmin.groups.create({ name: realmGroupName }); - } - } catch (err) { - throw err; - } - } -} - -async function mapJenkinsRealmRolesIntoClientRolesIfRequired() { +async function mapJenkinsClientRolesToGroupsIfRequired() { const clientId = config.keycloak.client.clientId; - const roleNames = config.keycloak.rolesAndGroups; + const clientRoleNames = config.keycloak.ClientRoles; try { const clients = await keycloakAdmin.clients.find(); @@ -274,76 +230,112 @@ async function mapJenkinsRealmRolesIntoClientRolesIfRequired() { return; } - for (const roleName of roleNames) { - const clientRole = await keycloakAdmin.clients.findRole({id: jenkinsClient.id, roleName}); + const allGroups = await keycloakAdmin.groups.find(); + + for (const clientRoleName of clientRoleNames) { + const clientRole = await keycloakAdmin.clients.findRole({id: jenkinsClient.id, roleName: clientRoleName}); if (!clientRole) { - console.warn(`client role "${roleName}" does not exist under client "${clientId}".`); + console.warn(`client role "${clientRoleName}" does not exist in "${clientId}".`); continue; } - const realmRole = await keycloakAdmin.roles.findOneByName({ name: roleName }); - if (!realmRole) { - console.warn(`realm role "${roleName}" does not exist.`); + const groupNames = clientRoleToGroup[clientRoleName]; + if (!groupNames) { + console.warn(`no group mapping defined for client role "${clientRoleName}".`); continue; } - let parentRole = await keycloakAdmin.clients.findRole({id: jenkinsClient.id, roleName: roleName}); - let childRole = await keycloakAdmin.roles.findOneByName({name: roleName}); - await keycloakAdmin.roles.createComposite({roleId: parentRole.id}, [childRole]); - console.log(`realm role "${roleName}" mapped into client role "${roleName}".`); + const names = Array.isArray(groupNames) ? groupNames : [groupNames]; + for (const groupName of names) { + const group = allGroups.find(g => g.name === groupName); + + if (!group) { + console.warn(`group "${groupName}" does not exist.`); + continue; + } + + const mappedRoles = await keycloakAdmin.groups.listClientRoleMappings({id: group.id, clientUniqueId: jenkinsClient.id}); + const alreadyMapped = mappedRoles.some(role => role.name === clientRole.name); + + if (alreadyMapped) { + console.info(`client role "${clientRoleName}" is already mapped to group "${groupName}".`); + continue; + } + + await keycloakAdmin.groups.addClientRoleMappings({ + id: group.id, + clientUniqueId: jenkinsClient.id, + roles: [{ + id: clientRole.id, + name: clientRole.name + }] + }); + console.log(`client role "${clientRoleName}" mapped to group "${groupName}".`); + } } } catch (err) { throw err; } } -async function mapJenkinsClientRolesToGroupsIfRequired() { +async function mapUsersToClientRoleIfRequired() { + const searchGroup = 'admin'; const clientId = config.keycloak.client.clientId; - const roleGroupNames = config.keycloak.rolesAndGroups; + + const clientRoleNamesToAssign = []; + for (const [roleName, mappedGroupNames] of Object.entries(clientRoleToGroup)) { + const names = Array.isArray(mappedGroupNames) ? mappedGroupNames : [mappedGroupNames]; + if (names.some(gn => gn.includes(searchGroup))) { + clientRoleNamesToAssign.push(roleName); + } + } try { + const users = await keycloakAdmin.users.find(); + const user = _.find(users, { username: config.keycloak.adminUser.username }); + + if (!user) { + console.error(`user "${config.keycloak.adminUser.username}" does not exist.`); + return; + } + const clients = await keycloakAdmin.clients.find(); - const jenkinsClient = clients.find(client => client.clientId === clientId); - + const jenkinsClient = clients.find(c => c.clientId === clientId); + if (!jenkinsClient) { console.error(`client "${clientId}" does not exist.`); return; } - for (const roleGroupName of roleGroupNames) { - const clientRole = await keycloakAdmin.clients.findRole({id: jenkinsClient.id, roleName: roleGroupName}); + for (const clientRoleName of clientRoleNamesToAssign) { + const clientRole = await keycloakAdmin.clients.findRole({ + id: jenkinsClient.id, + roleName: clientRoleName + }); if (!clientRole) { - console.warn(`client role "${roleGroupName}" does not exist in "${clientId}".`); + console.warn(`client role "${clientRoleName}" does not exist for "${clientId}".`); continue; } - const allGroups = await keycloakAdmin.groups.find(); - const group = allGroups.find(g => g.name === roleGroupName); - - if (!group) { - console.warn(`group "${roleGroupName}" does not exist.`); - continue; - } - - const mappedRoles = await keycloakAdmin.groups.listClientRoleMappings({id: group.id, clientUniqueId: jenkinsClient.id}); + const mappedRoles = await keycloakAdmin.users.listClientRoleMappings({ + id: user.id, + clientUniqueId: jenkinsClient.id + }); const alreadyMapped = mappedRoles.some(role => role.name === clientRole.name); if (alreadyMapped) { - console.info(`client role "${roleGroupName}" is already mapped to group "${roleGroupName}".`); + console.info(`user "${user.username}" already has client role "${clientRoleName}" on "${clientId}".`); continue; } - await keycloakAdmin.groups.addClientRoleMappings({ - id: group.id, + await keycloakAdmin.users.addClientRoleMappings({ + id: user.id, clientUniqueId: jenkinsClient.id, - roles: [{ - id: clientRole.id, - name: clientRole.name - }] + roles: [{ id: clientRole.id, name: clientRole.name }] }); - console.log(`client role "${roleGroupName}" mapped to group "${roleGroupName}".`); + console.log(`user "${user.username}" assigned client role "${clientRoleName}" on "${clientId}".`); } } catch (err) { throw err; @@ -357,12 +349,10 @@ async function configureKeycloak() { await createClientIfRequired(); await createUserIfRequired(); await generateSecretFiles(); - await addGroupsClientScopeToJenkinsClientIfRequired(); - await createJenkinsRealmRolesIfRequired(); - await createJenkinsRealmGroupsIfRequired(); + await DisableFullScopeIfRequired(); await createJenkinsClientRolesIfRequired(); - await mapJenkinsRealmRolesIntoClientRolesIfRequired(); await mapJenkinsClientRolesToGroupsIfRequired(); + await mapUsersToClientRoleIfRequired(); } catch (err) { throw err } @@ -371,4 +361,4 @@ async function configureKeycloak() { configureKeycloak() .catch((err) => { console.error(err.message); - }); + }); \ No newline at end of file diff --git a/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-jenkins/package.json b/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-jenkins/package.json index 9ed941a4..2f26aef3 100644 --- a/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-jenkins/package.json +++ b/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-jenkins/package.json @@ -4,6 +4,6 @@ "dependencies": { "async-retry": "1.3.3", "@keycloak/keycloak-admin-client": "25.0.5", - "lodash": "4.17.21" + "lodash": "4.18.1" } } diff --git a/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-mcp-gateway-registry/configure.sh b/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-mcp-gateway-registry/configure.sh index 81850032..a4a6a379 100644 --- a/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-mcp-gateway-registry/configure.sh +++ b/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-mcp-gateway-registry/configure.sh @@ -20,7 +20,7 @@ NAMESPACE=$(cat ${SERVICEACCOUNT}/namespace) TOKEN=$(cat ${SERVICEACCOUNT}/token) CACERT=${SERVICEACCOUNT}/ca.crt -npm install +npm install --ignore-scripts node keycloak.mjs WEB_CLIENT_SECRET=$(cat client-mcp-gateway-registry-web.json | jq -r ".secret") sed -i "s/##SECRET##/${WEB_CLIENT_SECRET}/g" ./web-client-secret.json diff --git a/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-mcp-gateway-registry/keycloak.mjs b/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-mcp-gateway-registry/keycloak.mjs index e43d2508..c63b7508 100644 --- a/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-mcp-gateway-registry/keycloak.mjs +++ b/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-mcp-gateway-registry/keycloak.mjs @@ -72,11 +72,7 @@ const config = { firstName: 'MCP Gateway Registry', lastName: 'MCP Gateway Registry', email: 'mcp-gateway-registry@mcp-gateway-registry' - }, - realmGroups: [ - 'horizon-mcp-gateway-registry-admins', // admin group - 'horizon-mcp-gateway-registry-users' - ] + } } }; @@ -170,7 +166,8 @@ async function createAdminUserIfRequired() { realm: config.keycloak.realm.realm, firstName: config.keycloak.adminUser.firstName, lastName: config.keycloak.adminUser.lastName, - email: config.keycloak.adminUser.email + email: config.keycloak.adminUser.email, + emailVerified: true }); await keycloakAdmin.users.resetPassword({ @@ -195,7 +192,7 @@ async function addAdminUsertoAdminGroupIfRequired(groupName) { } const groups = await keycloakAdmin.groups.find({ search: groupName }); - const group = groups[0]; + const group = groups.find(g => g.name === groupName); if (!group) { console.error(`group "${groupName}" does not exist.`); @@ -240,26 +237,6 @@ async function generateSecretFiles() { } } -async function createRealmGroupsIfRequired() { - const realmGroupNames = config.keycloak.realmGroups || []; - - for (const realmGroupName of realmGroupNames) { - try { - const existingGroups = await keycloakAdmin.groups.find({ search: realmGroupName }); - const matchedGroup = existingGroups.find(group => group.name === realmGroupName); - - if (matchedGroup) { - console.info(`group "${realmGroupName}" already exists.`); - } else { - console.log(`creating group "${realmGroupName}".`); - await keycloakAdmin.groups.create({ name: realmGroupName }); - } - } catch (err) { - throw err; - } - } -} - async function createProtocolMappersForClientIfRequired(clientId) { try { const allClients = await keycloakAdmin.clients.find(); @@ -299,9 +276,8 @@ async function configureKeycloak() { await getRealm(); await createOrUpdateClients(); await createAdminUserIfRequired(); - await addAdminUsertoAdminGroupIfRequired(config.keycloak.realmGroups[0]); + await addAdminUsertoAdminGroupIfRequired('administrators'); await generateSecretFiles(); - await createRealmGroupsIfRequired(); await createProtocolMappersForClients(); } catch (err) { throw err @@ -311,4 +287,4 @@ async function configureKeycloak() { configureKeycloak() .catch((err) => { console.error(err.message); - }); + }); \ No newline at end of file diff --git a/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-mcp-gateway-registry/package.json b/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-mcp-gateway-registry/package.json index 9ed941a4..2f26aef3 100644 --- a/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-mcp-gateway-registry/package.json +++ b/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-mcp-gateway-registry/package.json @@ -4,6 +4,6 @@ "dependencies": { "async-retry": "1.3.3", "@keycloak/keycloak-admin-client": "25.0.5", - "lodash": "4.17.21" + "lodash": "4.18.1" } } diff --git a/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-mtk-connect/configure.sh b/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-mtk-connect/configure.sh index 3b61906b..353a7ae9 100644 --- a/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-mtk-connect/configure.sh +++ b/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-mtk-connect/configure.sh @@ -20,7 +20,7 @@ NAMESPACE=$(cat ${SERVICEACCOUNT}/namespace) TOKEN=$(cat ${SERVICEACCOUNT}/token) CACERT=${SERVICEACCOUNT}/ca.crt -npm install +npm install --ignore-scripts node keycloak.mjs OLD_KEY=$(kubectl get secret -n ${NAMESPACE_PREFIX}mtk-connect mtk-connect-keycloak -o jsonpath='{.data.privateKey}' | base64 -d) diff --git a/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-mtk-connect/package.json b/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-mtk-connect/package.json index 9ed941a4..2f26aef3 100644 --- a/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-mtk-connect/package.json +++ b/terraform/modules/sdv-container-images/images/keycloak/keycloak-post-mtk-connect/package.json @@ -4,6 +4,6 @@ "dependencies": { "async-retry": "1.3.3", "@keycloak/keycloak-admin-client": "25.0.5", - "lodash": "4.17.21" + "lodash": "4.18.1" } } diff --git a/terraform/modules/sdv-container-images/images/keycloak/keycloak-post/Dockerfile b/terraform/modules/sdv-container-images/images/keycloak/keycloak-post/Dockerfile index 17240faf..5b82f0e4 100644 --- a/terraform/modules/sdv-container-images/images/keycloak/keycloak-post/Dockerfile +++ b/terraform/modules/sdv-container-images/images/keycloak/keycloak-post/Dockerfile @@ -18,5 +18,5 @@ RUN chown node:node ./ USER node COPY --chown=node:node package.json ./ COPY --chown=node:node keycloak.mjs ./ -RUN npm install +RUN npm install --ignore-scripts CMD ["node", "./keycloak.mjs"] diff --git a/terraform/modules/sdv-container-images/images/keycloak/keycloak-post/keycloak.mjs b/terraform/modules/sdv-container-images/images/keycloak/keycloak-post/keycloak.mjs index abd383ae..7128ff24 100644 --- a/terraform/modules/sdv-container-images/images/keycloak/keycloak-post/keycloak.mjs +++ b/terraform/modules/sdv-container-images/images/keycloak/keycloak-post/keycloak.mjs @@ -134,7 +134,8 @@ async function createAdminUserIfRequired() { username: config.keycloak.adminUser.username, enabled: true, requiredActions: [], - realm: config.keycloak.realm.realm + realm: config.keycloak.realm.realm, + emailVerified: true }); const role = await keycloakAdmin.roles.findOneByName({name: 'realm_admin'}); await keycloakAdmin.users.addRealmRoleMappings({ @@ -172,6 +173,63 @@ async function createRealmAdminRoleIfRequired() { } } +const ADMINISTRATORS_GROUP_NAME = 'administrators'; + +async function createAdministratorsGroupIfRequired() { + try { + const groups = await keycloakAdmin.groups.find({ search: ADMINISTRATORS_GROUP_NAME }); + const exists = groups.some((g) => g.name === ADMINISTRATORS_GROUP_NAME); + if (exists) { + console.info(`group ${ADMINISTRATORS_GROUP_NAME} exists`); + } else { + console.info(`creating group ${ADMINISTRATORS_GROUP_NAME}`); + await keycloakAdmin.groups.create({ + name: ADMINISTRATORS_GROUP_NAME, + }); + } + } catch (err) { + throw err; + } +} + +const VIEWERS_GROUP_NAME = 'viewers'; + +async function createViewersGroupIfRequired() { + try { + const groups = await keycloakAdmin.groups.find({ search: VIEWERS_GROUP_NAME }); + const exists = groups.some((g) => g.name === VIEWERS_GROUP_NAME); + if (exists) { + console.info(`group ${VIEWERS_GROUP_NAME} exists`); + } else { + console.info(`creating group ${VIEWERS_GROUP_NAME}`); + await keycloakAdmin.groups.create({ + name: VIEWERS_GROUP_NAME, + }); + } + } catch (err) { + throw err; + } +} + +const DEVELOPERS_GROUP_NAME = 'developers'; + +async function createDevelopersGroupIfRequired() { + try { + const groups = await keycloakAdmin.groups.find({ search: DEVELOPERS_GROUP_NAME }); + const exists = groups.some((g) => g.name === DEVELOPERS_GROUP_NAME); + if (exists) { + console.info(`group ${DEVELOPERS_GROUP_NAME} exists`); + } else { + console.info(`creating group ${DEVELOPERS_GROUP_NAME}`); + await keycloakAdmin.groups.create({ + name: DEVELOPERS_GROUP_NAME, + }); + } + } catch (err) { + throw err; + } +} + async function createGroupsClientScopeIfRequired() { const clientScopeName = config.keycloak.clientScope.clientScopeName; @@ -221,14 +279,98 @@ async function createGroupsMapperIfRequired() { } } +const ROLES_CLIENT_SCOPE_NAME = 'roles'; +const INCLUDE_IN_TOKEN_SCOPE_ATTR = 'include.in.token.scope'; + +async function updateRolesClientScopeIncludeInTokenScope() { + try { + const clientScope = await keycloakAdmin.clientScopes.findOneByName({ name: ROLES_CLIENT_SCOPE_NAME }); + if (!clientScope) { + console.warn(`client scope "${ROLES_CLIENT_SCOPE_NAME}" not found.`); + return; + } + + if (clientScope.attributes?.[INCLUDE_IN_TOKEN_SCOPE_ATTR] === 'true') { + console.info(`client scope "${ROLES_CLIENT_SCOPE_NAME}" already has "Include in token scope" enabled.`); + return; + } + + const attributes = { ...(clientScope.attributes || {}), [INCLUDE_IN_TOKEN_SCOPE_ATTR]: 'true' }; + await keycloakAdmin.clientScopes.update( + { id: clientScope.id }, + { ...clientScope, attributes } + ); + console.info(`client scope "${ROLES_CLIENT_SCOPE_NAME}": enabled "Include in token scope".`); + } catch (err) { + throw err; + } +} + +const CLIENT_ROLES_MAPPER_NAME = 'client roles'; +const ROLES_CLAIM_NAME = 'roles'; + +async function updateRolesClientScopeClientRolesMapperClaimName() { + try { + const clientScope = await keycloakAdmin.clientScopes.findOneByName({ name: ROLES_CLIENT_SCOPE_NAME }); + if (!clientScope) { + console.warn(`client scope "${ROLES_CLIENT_SCOPE_NAME}" not found.`); + return; + } + + const mappers = await keycloakAdmin.clientScopes.listProtocolMappers({ id: clientScope.id }); + const clientRolesMapper = mappers.find(m => m.name === CLIENT_ROLES_MAPPER_NAME); + if (!clientRolesMapper) { + console.warn(`"${CLIENT_ROLES_MAPPER_NAME}" mapper not found in client scope "${ROLES_CLIENT_SCOPE_NAME}".`); + return; + } + + const config = clientRolesMapper.config ?? {}; + const currentClaimName = config['claim.name']; + const desiredConfig = { + ...config, + 'claim.name': ROLES_CLAIM_NAME, + 'id.token.claim': 'true', + 'access.token.claim': 'true', + 'userinfo.token.claim': 'true', + 'introspection.token.claim': 'true' + }; + const claimNameOk = currentClaimName === ROLES_CLAIM_NAME; + const tokenClaimsOk = + config['id.token.claim'] === 'true' && + config['access.token.claim'] === 'true' && + config['userinfo.token.claim'] === 'true' && + config['introspection.token.claim'] === 'true'; + if (claimNameOk && tokenClaimsOk) { + console.info(`"${CLIENT_ROLES_MAPPER_NAME}" mapper in "${ROLES_CLIENT_SCOPE_NAME}" already has Token Claim Name "${ROLES_CLAIM_NAME}" and all token claims (ID, access, userinfo, introspection) enabled.`); + return; + } + + await keycloakAdmin.clientScopes.updateProtocolMapper( + { id: clientScope.id, mapperId: clientRolesMapper.id }, + { ...clientRolesMapper, config: desiredConfig } + ); + const updates = []; + if (!claimNameOk) updates.push(`Token Claim Name "${currentClaimName ?? 'resource_access.${client_id}.roles'}" → "${ROLES_CLAIM_NAME}"`); + if (!tokenClaimsOk) updates.push('Add to ID token, access token, userinfo, and introspection enabled'); + console.info(`"${CLIENT_ROLES_MAPPER_NAME}" mapper in "${ROLES_CLIENT_SCOPE_NAME}": ${updates.join('; ')}.`); + } catch (err) { + throw err; + } +} + async function configureKeycloak() { try { await waitForKeycloak(); await createRealmIfRequired(); await createRealmAdminRoleIfRequired(); await createAdminUserIfRequired(); + await createAdministratorsGroupIfRequired(); + await createViewersGroupIfRequired(); + await createDevelopersGroupIfRequired(); await createGroupsClientScopeIfRequired(); await createGroupsMapperIfRequired(); + await updateRolesClientScopeIncludeInTokenScope(); + await updateRolesClientScopeClientRolesMapperClaimName(); } catch (err) { throw err } diff --git a/terraform/modules/sdv-container-images/images/keycloak/keycloak-post/package.json b/terraform/modules/sdv-container-images/images/keycloak/keycloak-post/package.json index 9ed941a4..2f26aef3 100644 --- a/terraform/modules/sdv-container-images/images/keycloak/keycloak-post/package.json +++ b/terraform/modules/sdv-container-images/images/keycloak/keycloak-post/package.json @@ -4,6 +4,6 @@ "dependencies": { "async-retry": "1.3.3", "@keycloak/keycloak-admin-client": "25.0.5", - "lodash": "4.17.21" + "lodash": "4.18.1" } } diff --git a/terraform/modules/sdv-container-images/images/landingpage/landingpage-app/html/images/mcp-gateway-registry-logo.png b/terraform/modules/sdv-container-images/images/landingpage/landingpage-app/html/images/mcp-gateway-registry-logo.png new file mode 100644 index 00000000..52ebda68 Binary files /dev/null and b/terraform/modules/sdv-container-images/images/landingpage/landingpage-app/html/images/mcp-gateway-registry-logo.png differ diff --git a/terraform/modules/sdv-container-images/images/landingpage/landingpage-app/html/index.html b/terraform/modules/sdv-container-images/images/landingpage/landingpage-app/html/index.html index ca79655b..775443f4 100644 --- a/terraform/modules/sdv-container-images/images/landingpage/landingpage-app/html/index.html +++ b/terraform/modules/sdv-container-images/images/landingpage/landingpage-app/html/index.html @@ -92,6 +92,20 @@

MTK Connect

Launch + +
+
+
+ MCP Gateway Registry logo +
+

MCP Gateway Registry

+
+

Centralized registry and gateway for Model Context Protocol services

+ +
diff --git a/terraform/modules/sdv-container-images/images/landingpage/landingpage-app/html/script.js b/terraform/modules/sdv-container-images/images/landingpage/landingpage-app/html/script.js index b7b619e8..28725477 100644 --- a/terraform/modules/sdv-container-images/images/landingpage/landingpage-app/html/script.js +++ b/terraform/modules/sdv-container-images/images/landingpage/landingpage-app/html/script.js @@ -33,4 +33,9 @@ document.addEventListener('DOMContentLoaded', () => { const isDark = body.classList.contains('dark'); icon.className = isDark ? 'bx bx-sun' : 'bx bx-moon'; } + + const mcpGatewayLink = document.getElementById('mcp-gateway-link'); + if (mcpGatewayLink) { + mcpGatewayLink.href = location.protocol + '//mcp.' + location.hostname; + } }); diff --git a/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/Dockerfile b/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/Dockerfile new file mode 100644 index 00000000..6d3cc986 --- /dev/null +++ b/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/Dockerfile @@ -0,0 +1,27 @@ +# Copyright (c) 2024-2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# Build the Module Manager controller binary. +FROM --platform=linux/amd64 golang:1.25-alpine AS builder +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /module-manager . + +FROM --platform=linux/amd64 alpine:3.19 +RUN apk --no-cache add ca-certificates +COPY --from=builder --chown=nobody:nobody /module-manager /module-manager +USER nobody +ENTRYPOINT ["/module-manager"] diff --git a/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/api/v1alpha1/modulecatalog_types.go b/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/api/v1alpha1/modulecatalog_types.go new file mode 100644 index 00000000..29febf18 --- /dev/null +++ b/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/api/v1alpha1/modulecatalog_types.go @@ -0,0 +1,163 @@ +// Copyright (c) 2024-2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" +) + +// Soft-features propagation modes (ModuleCatalogEntry.softFeaturesPropagation). +const ( + SoftFeaturesPropagationHelmValues = "HelmValues" + SoftFeaturesPropagationConfigMap = "ConfigMap" + SoftFeaturesPropagationHelmValuesAndConfigMap = "HelmValuesAndConfigMap" +) + +// ModuleCatalogApplication is a link to a module-deployed application (e.g. public HTTPRoute path or absolute URL). +type ModuleCatalogApplication struct { + ID string `json:"id"` + Title string `json:"title,omitempty"` + URL string `json:"url"` +} + +// ModuleCatalogEntry is one module in the catalog. +type ModuleCatalogEntry struct { + Name string `json:"name"` + Path string `json:"path"` + OverviewPath string `json:"overviewPath,omitempty"` + // OverviewService is the in-cluster Service name that serves overview HTML (with OverviewServiceNamespace). + OverviewService string `json:"overviewService,omitempty"` + // OverviewServiceNamespace is the namespace containing OverviewService. + OverviewServiceNamespace string `json:"overviewServiceNamespace,omitempty"` + HardDependencies []string `json:"hardDependencies,omitempty"` + SoftDependencies []string `json:"softDependencies,omitempty"` + Applications []ModuleCatalogApplication `json:"applications,omitempty"` + // SoftFeaturesPropagation controls how Module Manager exposes soft-dependency enablement to workloads. + // Omitted or empty means HelmValues (merge softFeaturesEnabled into the parent Argo CD Application helm values). + // +kubebuilder:validation:Enum=HelmValues;ConfigMap;HelmValuesAndConfigMap + SoftFeaturesPropagation string `json:"softFeaturesPropagation,omitempty"` + // SoftFeaturesConfigMapNamespaces lists namespaces where Module Manager upserts the soft-features ConfigMap + // when SoftFeaturesPropagation is ConfigMap or HelmValuesAndConfigMap (required for those modes). + SoftFeaturesConfigMapNamespaces []string `json:"softFeaturesConfigMapNamespaces,omitempty"` + // AutoDisableWhenUnused, when true, allows Module Manager to automatically disable this module + // once both hard and soft dependent counts transition from greater than zero to zero. Default false. + AutoDisableWhenUnused bool `json:"autoDisableWhenUnused,omitempty"` +} + +// ModuleCatalogSpec defines the catalog of known modules. +type ModuleCatalogSpec struct { + Modules []ModuleCatalogEntry `json:"modules,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:resource:scope=Namespaced + +// ModuleCatalog is the schema for the module catalog (singleton per namespace). +type ModuleCatalog struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec ModuleCatalogSpec `json:"spec,omitempty"` +} + +// DeepCopyObject implements runtime.Object. +func (in *ModuleCatalog) DeepCopyObject() runtime.Object { + if in == nil { + return nil + } + out := &ModuleCatalog{} + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto copies the receiver into out. +func (in *ModuleCatalog) DeepCopyInto(out *ModuleCatalog) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) +} + +// DeepCopyInto copies Spec. +func (in *ModuleCatalogSpec) DeepCopyInto(out *ModuleCatalogSpec) { + *out = *in + if in.Modules != nil { + out.Modules = make([]ModuleCatalogEntry, len(in.Modules)) + for i := range in.Modules { + in.Modules[i].DeepCopyInto(&out.Modules[i]) + } + } +} + +// DeepCopyInto copies ModuleCatalogEntry. +func (in *ModuleCatalogEntry) DeepCopyInto(out *ModuleCatalogEntry) { + *out = *in + if in.HardDependencies != nil { + out.HardDependencies = make([]string, len(in.HardDependencies)) + copy(out.HardDependencies, in.HardDependencies) + } + if in.SoftDependencies != nil { + out.SoftDependencies = make([]string, len(in.SoftDependencies)) + copy(out.SoftDependencies, in.SoftDependencies) + } + if in.Applications != nil { + out.Applications = make([]ModuleCatalogApplication, len(in.Applications)) + for i := range in.Applications { + in.Applications[i].DeepCopyInto(&out.Applications[i]) + } + } + if in.SoftFeaturesConfigMapNamespaces != nil { + out.SoftFeaturesConfigMapNamespaces = make([]string, len(in.SoftFeaturesConfigMapNamespaces)) + copy(out.SoftFeaturesConfigMapNamespaces, in.SoftFeaturesConfigMapNamespaces) + } +} + +// DeepCopyInto copies ModuleCatalogApplication. +func (in *ModuleCatalogApplication) DeepCopyInto(out *ModuleCatalogApplication) { + *out = *in +} + +// +kubebuilder:object:root=true + +// ModuleCatalogList contains a list of ModuleCatalog. +type ModuleCatalogList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []ModuleCatalog `json:"items"` +} + +// DeepCopyObject implements runtime.Object. +func (in *ModuleCatalogList) DeepCopyObject() runtime.Object { + if in == nil { + return nil + } + out := &ModuleCatalogList{} + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto copies the list. +func (in *ModuleCatalogList) DeepCopyInto(out *ModuleCatalogList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + out.Items = make([]ModuleCatalog, len(in.Items)) + for i := range in.Items { + in.Items[i].DeepCopyInto(&out.Items[i]) + } + } +} diff --git a/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/api/v1alpha1/modulemanagerstate_types.go b/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/api/v1alpha1/modulemanagerstate_types.go new file mode 100644 index 00000000..df700806 --- /dev/null +++ b/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/api/v1alpha1/modulemanagerstate_types.go @@ -0,0 +1,147 @@ +// Copyright (c) 2024-2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" +) + +// ModuleManagerStateSpec is reserved for future use (e.g. desired enabled list). +type ModuleManagerStateSpec struct{} + +// ModuleManagerStateStatus holds the current module state. +type ModuleManagerStateStatus struct { + // EnabledModules is the list of module IDs that are currently enabled. + EnabledModules []string `json:"enabledModules,omitempty"` + // ModuleIDs maps module name to the assigned stable ID. + ModuleIDs map[string]string `json:"moduleIds,omitempty"` + // ModuleTargetRevisions maps module name to Git ref (branch, tag, commit) for the module's Argo CD Application. + ModuleTargetRevisions map[string]string `json:"moduleTargetRevisions,omitempty"` + // WorkflowsVisibility stores Developer Portal workflow list filters (optional). + WorkflowsVisibility *WorkflowsVisibilitySettings `json:"workflowsVisibility,omitempty"` +} + +// WorkflowsVisibilitySettings is persisted alongside module enablement state. +type WorkflowsVisibilitySettings struct { + AllowedSubmittedFrom []string `json:"allowedSubmittedFrom,omitempty"` +} + +// DeepCopyInto copies WorkflowsVisibilitySettings. +func (in *WorkflowsVisibilitySettings) DeepCopyInto(out *WorkflowsVisibilitySettings) { + *out = *in + if in.AllowedSubmittedFrom != nil { + out.AllowedSubmittedFrom = make([]string, len(in.AllowedSubmittedFrom)) + copy(out.AllowedSubmittedFrom, in.AllowedSubmittedFrom) + } +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:scope=Namespaced + +// ModuleManagerState is the schema for the module manager state (singleton per namespace). +type ModuleManagerState struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec ModuleManagerStateSpec `json:"spec,omitempty"` + Status ModuleManagerStateStatus `json:"status,omitempty"` +} + +// DeepCopyObject implements runtime.Object. +func (in *ModuleManagerState) DeepCopyObject() runtime.Object { + if in == nil { + return nil + } + out := &ModuleManagerState{} + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto copies the receiver into out. +func (in *ModuleManagerState) DeepCopyInto(out *ModuleManagerState) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopyInto copies Spec. +func (in *ModuleManagerStateSpec) DeepCopyInto(out *ModuleManagerStateSpec) { + *out = *in +} + +// DeepCopyInto copies Status. +func (in *ModuleManagerStateStatus) DeepCopyInto(out *ModuleManagerStateStatus) { + *out = *in + if in.EnabledModules != nil { + out.EnabledModules = make([]string, len(in.EnabledModules)) + copy(out.EnabledModules, in.EnabledModules) + } + if in.ModuleIDs != nil { + out.ModuleIDs = make(map[string]string, len(in.ModuleIDs)) + for k, v := range in.ModuleIDs { + out.ModuleIDs[k] = v + } + } + if in.ModuleTargetRevisions != nil { + out.ModuleTargetRevisions = make(map[string]string, len(in.ModuleTargetRevisions)) + for k, v := range in.ModuleTargetRevisions { + out.ModuleTargetRevisions[k] = v + } + } else { + out.ModuleTargetRevisions = nil + } + if in.WorkflowsVisibility != nil { + out.WorkflowsVisibility = new(WorkflowsVisibilitySettings) + in.WorkflowsVisibility.DeepCopyInto(out.WorkflowsVisibility) + } else { + out.WorkflowsVisibility = nil + } +} + +// +kubebuilder:object:root=true + +// ModuleManagerStateList contains a list of ModuleManagerState. +type ModuleManagerStateList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []ModuleManagerState `json:"items"` +} + +// DeepCopyObject implements runtime.Object. +func (in *ModuleManagerStateList) DeepCopyObject() runtime.Object { + if in == nil { + return nil + } + out := &ModuleManagerStateList{} + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto copies the list. +func (in *ModuleManagerStateList) DeepCopyInto(out *ModuleManagerStateList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + out.Items = make([]ModuleManagerState, len(in.Items)) + for i := range in.Items { + in.Items[i].DeepCopyInto(&out.Items[i]) + } + } +} diff --git a/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/api/v1alpha1/register.go b/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/api/v1alpha1/register.go new file mode 100644 index 00000000..27554169 --- /dev/null +++ b/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/api/v1alpha1/register.go @@ -0,0 +1,40 @@ +// Copyright (c) 2024-2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +// GroupVersion is the group version for horizon-sdv.io types. +var SchemeGroupVersion = schema.GroupVersion{Group: "horizon-sdv.io", Version: "v1alpha1"} + +var ( + // SchemeBuilder is used to add types to the scheme. + SchemeBuilder = runtime.NewSchemeBuilder(addKnownTypes) + // AddToScheme adds the types in this group-version to the given scheme. + AddToScheme = SchemeBuilder.AddToScheme +) + +func addKnownTypes(scheme *runtime.Scheme) error { + scheme.AddKnownTypes(SchemeGroupVersion, + &ModuleManagerState{}, &ModuleManagerStateList{}, + &ModuleCatalog{}, &ModuleCatalogList{}, + ) + metav1.AddToGroupVersion(scheme, SchemeGroupVersion) + return nil +} diff --git a/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/go.mod b/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/go.mod new file mode 100644 index 00000000..74476a76 --- /dev/null +++ b/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/go.mod @@ -0,0 +1,70 @@ +module github.com/acn-horizon-sdv/module-manager + +go 1.25.0 +replace golang.org/x/crypto => golang.org/x/crypto v0.50.0 + +require ( + gopkg.in/yaml.v3 v3.0.1 + k8s.io/api v0.29.3 + k8s.io/apimachinery v0.29.3 + k8s.io/client-go v0.29.3 + sigs.k8s.io/controller-runtime v0.17.3 +) + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/emicklei/go-restful/v3 v3.11.0 // indirect + github.com/evanphx/json-patch v4.12.0+incompatible // indirect + github.com/evanphx/json-patch/v5 v5.8.0 // indirect + github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/go-logr/logr v1.4.1 // indirect + github.com/go-logr/zapr v1.3.0 // indirect + github.com/go-openapi/jsonpointer v0.19.6 // indirect + github.com/go-openapi/jsonreference v0.20.2 // indirect + github.com/go-openapi/swag v0.22.3 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/google/gnostic-models v0.6.8 // indirect + github.com/google/go-cmp v0.6.0 // indirect + github.com/google/gofuzz v1.2.0 // indirect + github.com/google/uuid v1.3.0 // indirect + github.com/imdario/mergo v0.3.6 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/prometheus/client_golang v1.18.0 // indirect + github.com/prometheus/client_model v0.5.0 // indirect + github.com/prometheus/common v0.45.0 // indirect + github.com/prometheus/procfs v0.12.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.26.0 // indirect + golang.org/x/exp v0.0.0-20220722155223-a9213eeb770e // indirect + golang.org/x/net v0.52.0 // indirect + golang.org/x/oauth2 v0.12.0 // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/term v0.42.0 // indirect + golang.org/x/text v0.36.0 // indirect + golang.org/x/time v0.3.0 // indirect + gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect + google.golang.org/appengine v1.6.7 // indirect + google.golang.org/protobuf v1.33.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + k8s.io/apiextensions-apiserver v0.29.2 // indirect + k8s.io/component-base v0.29.2 // indirect + k8s.io/klog/v2 v2.110.1 // indirect + k8s.io/kube-openapi v0.0.0-20231010175941-2dd684a91f00 // indirect + k8s.io/utils v0.0.0-20230726121419-3b25d923346b // indirect + sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect + sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect + sigs.k8s.io/yaml v1.4.0 // indirect +) diff --git a/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/go.sum b/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/go.sum new file mode 100644 index 00000000..a3779c73 --- /dev/null +++ b/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/go.sum @@ -0,0 +1,305 @@ +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= +github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g= +github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/evanphx/json-patch v4.12.0+incompatible h1:4onqiflcdA9EOZ4RxV643DvftH5pOlLGNtQ5lPWQu84= +github.com/evanphx/json-patch v4.12.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= +github.com/evanphx/json-patch/v5 v5.8.0 h1:lRj6N9Nci7MvzrXuX6HFzU8XjmhPiXPlsKEy1u0KQro= +github.com/evanphx/json-patch/v5 v5.8.0/go.mod h1:VNkHZ/282BpEyt/tObQO8s5CMPmYYq14uClGH4abBuQ= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/go-logr/logr v1.3.0/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= +github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= +github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= +github.com/go-openapi/jsonpointer v0.19.6 h1:eCs3fxoIi3Wh6vtgmLTOjdhSpiqphQ+DaPn38N2ZdrE= +github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= +github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= +github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= +github.com/go-openapi/swag v0.22.3 h1:yMBqmnQ0gyZvEb/+KzuWZOXgllrXT4SADYbvDaXHv/g= +github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= +github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= +github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/gnostic-models v0.6.8 h1:yo/ABAfM5IMRsS1VnXjTBvUb61tFIHozhlYvRgGre9I= +github.com/google/gnostic-models v0.6.8/go.mod h1:5n7qKqH0f5wFt+aWF8CW6pZLLNOfYuF5OpfBSENuI8U= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1 h1:K6RDEckDVWvDI9JAJYCmNdQXq6neHJOYx3V6jnqNEec= +github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/imdario/mergo v0.3.6 h1:xTNEAn+kxVO7dTZGu0CegyqKZmoWFI0rF8UxjlB2d28= +github.com/imdario/mergo v0.3.6/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg= +github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/onsi/ginkgo/v2 v2.14.0 h1:vSmGj2Z5YPb9JwCWT6z6ihcUvDhuXLc3sJiqd3jMKAY= +github.com/onsi/ginkgo/v2 v2.14.0/go.mod h1:JkUdW7JkN0V6rFvsHcJ478egV3XH9NxpD27Hal/PhZw= +github.com/onsi/gomega v1.30.0 h1:hvMK7xYz4D3HapigLTeGdId/NcfQx1VHMJc60ew99+8= +github.com/onsi/gomega v1.30.0/go.mod h1:9sxs+SwGrKI0+PWe4Fxa9tFQQBG5xSsSbMXOI8PPpoQ= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.18.0 h1:HzFfmkOzH5Q8L8G+kSJKUx5dtG87sewO+FoDDqP5Tbk= +github.com/prometheus/client_golang v1.18.0/go.mod h1:T+GXkCk5wSJyOqMIzVgvvjFDlkOQntgjkJWKrN5txjA= +github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw= +github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI= +github.com/prometheus/common v0.45.0 h1:2BGz0eBc2hdMDLnO/8n0jeB3oPrt2D08CekT0lneoxM= +github.com/prometheus/common v0.45.0/go.mod h1:YJmSTw9BoKxJplESWWxlbyttQR4uaEcGyv9MZjVOJsY= +github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo= +github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo= +go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/exp v0.0.0-20220722155223-a9213eeb770e h1:+WEEuIdZHnUeJJmEUjyYC2gfUMj69yZXw17EnHg/otA= +golang.org/x/exp v0.0.0-20220722155223-a9213eeb770e/go.mod h1:Kr81I6Kryrl9sr8s2FK3vxD90NdsKWRuOIl2O4CvYbA= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ= +golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= +golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI= +golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= +golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= +golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= +golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= +golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= +golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= +golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= +golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/oauth2 v0.12.0 h1:smVPGxink+n1ZI5pkQa8y6fZT0RW0MgCO5bFpepy4B4= +golang.org/x/oauth2 v0.12.0/go.mod h1:A74bZ3aGXgCY0qaIC9Ahg6Lglin4AMAco8cIv9baba4= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= +golang.org/x/telemetry v0.0.0-20240521205824-bda55230c457/go.mod h1:pRgIJT+bRLFKnoM1ldnzKoxTIn14Yxz928LQRYYgIN0= +golang.org/x/telemetry v0.0.0-20250710130107-8d8967aff50b/go.mod h1:4ZwOYna0/zsOKwuR5X/m0QFOJpSZvAxFfkQT+Erd9D4= +golang.org/x/telemetry v0.0.0-20250807160809-1a19826ec488/go.mod h1:fGb/2+tgXXjhjHsTNdVEEMZNWA0quBnfrO+AfoDSAKw= +golang.org/x/telemetry v0.0.0-20250908211612-aef8a434d053/go.mod h1:+nZKN+XVh4LCiA9DV3ywrzN4gumyCnKjau3NGb9SGoE= +golang.org/x/telemetry v0.0.0-20251008203120-078029d740a8/go.mod h1:Pi4ztBfryZoJEkyFTI5/Ocsu2jXyDr6iSdgJiYE/uwE= +golang.org/x/telemetry v0.0.0-20251111182119-bc8e575c7b54/go.mod h1:hKdjCMrbv9skySur+Nek8Hd0uJ0GuxJIoIX2payrIdQ= +golang.org/x/telemetry v0.0.0-20251203150158-8fff8a5912fc/go.mod h1:hKdjCMrbv9skySur+Nek8Hd0uJ0GuxJIoIX2payrIdQ= +golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2/go.mod h1:b7fPSJ0pKZ3ccUh8gnTONJxhn3c/PS6tyzQvyqw4iA8= +golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4/go.mod h1:g5NllXBEermZrmR51cJDQxmJUHUOfRAaNyWBM+R+548= +golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c/go.mod h1:TpUTTEp9frx7rTdLpC9gFG9kdI7zVLFTFFlqaH2Cncw= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= +golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0= +golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= +golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA= +golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= +golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= +golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= +golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= +golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= +golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= +golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= +golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= +golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= +golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= +golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI= +golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= +golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw= +golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= +golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= +golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= +golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= +golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= +gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= +google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= +google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= +google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/api v0.29.3 h1:2ORfZ7+bGC3YJqGpV0KSDDEVf8hdGQ6A03/50vj8pmw= +k8s.io/api v0.29.3/go.mod h1:y2yg2NTyHUUkIoTC+phinTnEa3KFM6RZ3szxt014a80= +k8s.io/apiextensions-apiserver v0.29.2 h1:UK3xB5lOWSnhaCk0RFZ0LUacPZz9RY4wi/yt2Iu+btg= +k8s.io/apiextensions-apiserver v0.29.2/go.mod h1:aLfYjpA5p3OwtqNXQFkhJ56TB+spV8Gc4wfMhUA3/b8= +k8s.io/apimachinery v0.29.3 h1:2tbx+5L7RNvqJjn7RIuIKu9XTsIZ9Z5wX2G22XAa5EU= +k8s.io/apimachinery v0.29.3/go.mod h1:hx/S4V2PNW4OMg3WizRrHutyB5la0iCUbZym+W0EQIU= +k8s.io/client-go v0.29.3 h1:R/zaZbEAxqComZ9FHeQwOh3Y1ZUs7FaHKZdQtIc2WZg= +k8s.io/client-go v0.29.3/go.mod h1:tkDisCvgPfiRpxGnOORfkljmS+UrW+WtXAy2fTvXJB0= +k8s.io/component-base v0.29.2 h1:lpiLyuvPA9yV1aQwGLENYyK7n/8t6l3nn3zAtFTJYe8= +k8s.io/component-base v0.29.2/go.mod h1:BfB3SLrefbZXiBfbM+2H1dlat21Uewg/5qtKOl8degM= +k8s.io/klog/v2 v2.110.1 h1:U/Af64HJf7FcwMcXyKm2RPM22WZzyR7OSpYj5tg3cL0= +k8s.io/klog/v2 v2.110.1/go.mod h1:YGtd1984u+GgbuZ7e08/yBuAfKLSO0+uR1Fhi6ExXjo= +k8s.io/kube-openapi v0.0.0-20231010175941-2dd684a91f00 h1:aVUu9fTY98ivBPKR9Y5w/AuzbMm96cd3YHRTU83I780= +k8s.io/kube-openapi v0.0.0-20231010175941-2dd684a91f00/go.mod h1:AsvuZPBlUDVuCdzJ87iajxtXuR9oktsTctW/R9wwouA= +k8s.io/utils v0.0.0-20230726121419-3b25d923346b h1:sgn3ZU783SCgtaSJjpcVVlRqd6GSnlTLKgpAAttJvpI= +k8s.io/utils v0.0.0-20230726121419-3b25d923346b/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +sigs.k8s.io/controller-runtime v0.17.3 h1:65QmN7r3FWgTxDMz9fvGnO1kbf2nu+acg9p2R9oYYYk= +sigs.k8s.io/controller-runtime v0.17.3/go.mod h1:N0jpP5Lo7lMTF9aL56Z/B2oWBJjey6StQM0jRbKQXtY= +sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo= +sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0= +sigs.k8s.io/structured-merge-diff/v4 v4.4.1 h1:150L+0vs/8DA78h1u02ooW1/fFq/Lwr+sGiqlzvrtq4= +sigs.k8s.io/structured-merge-diff/v4 v4.4.1/go.mod h1:N8hJocpFajUSSeSJ9bOZ77VzejKZaXsTtZo4/u7Io08= +sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= +sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= diff --git a/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/api/argo_app.go b/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/api/argo_app.go new file mode 100644 index 00000000..236ef3bb --- /dev/null +++ b/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/api/argo_app.go @@ -0,0 +1,190 @@ +// Copyright (c) 2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package api + +import ( + "context" + "fmt" + "strings" + "time" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/wait" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +var createApplicationBackoff = wait.Backoff{ + Duration: 500 * time.Millisecond, + Factor: 2.0, + Steps: 7, + Jitter: 0.1, +} + +const moduleManagerManagedLabelKey = "horizon-sdv.io/module-manager-managed" + +func escapeYAMLString(s string) string { + s = strings.ReplaceAll(s, "\\", "\\\\") + return strings.ReplaceAll(s, `"`, `\"`) +} + +// BuildHelmValuesYAML returns the inline Helm values string used for module parent Applications. +// When parentOverviewNamespace is non-empty (same as catalog overviewServiceNamespace when overview is configured), +// appends top-level overviewNamespace for charts that render the in-cluster overview workload. +func BuildHelmValuesYAML(moduleName, repoURL, revision, moduleConfig, parentOverviewNamespace string) string { + helmValues := "moduleName: \"" + escapeYAMLString(moduleName) + "\"\n" + if moduleConfig != "" { + helmValues += "config:\n" + for _, line := range strings.Split(moduleConfig, "\n") { + if line != "" { + helmValues += " " + line + "\n" + } + } + } + if strings.TrimSpace(parentOverviewNamespace) != "" { + helmValues += "overviewNamespace: \"" + escapeYAMLString(strings.TrimSpace(parentOverviewNamespace)) + "\"\n" + } + helmValues += "repo:\n url: \"" + escapeYAMLString(repoURL) + "\"\n revision: \"" + escapeYAMLString(revision) + "\"\n" + return helmValues +} + +// BuildArgoCDApplication builds an Argo CD Application for a module chart at the given Git revision. +func BuildArgoCDApplication(name, moduleName, argocdNamespace, project, destinationNamespace, repoURL, revision, path, moduleConfig, parentOverviewNamespace string) *unstructured.Unstructured { + app := &unstructured.Unstructured{} + app.SetGroupVersionKind(schema.GroupVersionKind{Group: "argoproj.io", Version: "v1alpha1", Kind: "Application"}) + app.SetNamespace(argocdNamespace) + app.SetName(name) + app.SetLabels(map[string]string{ + "horizon-sdv.io/module": moduleName, + "horizon-sdv.io/app-role": "parent", + moduleManagerManagedLabelKey: "true", + }) + app.SetFinalizers([]string{"resources-finalizer.argocd.argoproj.io"}) + helmValues := BuildHelmValuesYAML(moduleName, repoURL, revision, moduleConfig, parentOverviewNamespace) + source := map[string]interface{}{ + "repoURL": repoURL, + "targetRevision": revision, + "path": path, + "helm": map[string]interface{}{ + "values": helmValues, + }, + } + app.Object["spec"] = map[string]interface{}{ + "project": project, + "source": source, + "destination": map[string]interface{}{ + "server": "https://kubernetes.default.svc", + "namespace": destinationNamespace, + }, + "syncPolicy": map[string]interface{}{ + "syncOptions": []interface{}{"CreateNamespace=true"}, + "automated": map[string]interface{}{}, + }, + } + return app +} + +func createApplicationIdempotent(ctx context.Context, c client.Client, app *unstructured.Unstructured) error { + return createApplicationIdempotentWithBackoff(ctx, c, app, createApplicationBackoff) +} + +func createApplicationIdempotentWithBackoff(ctx context.Context, c client.Client, app *unstructured.Unstructured, backoff wait.Backoff) error { + if err := c.Create(ctx, app); err == nil { + return nil + } else if !apierrors.IsAlreadyExists(err) { + return err + } + + existing := &unstructured.Unstructured{} + existing.SetGroupVersionKind(app.GroupVersionKind()) + key := client.ObjectKeyFromObject(app) + if err := c.Get(ctx, key, existing); err != nil { + if apierrors.IsNotFound(err) { + return c.Create(ctx, app) + } + return err + } + if existing.GetDeletionTimestamp().IsZero() { + if !applicationManagedByModuleManager(existing) { + return fmt.Errorf("application %s/%s already exists and is not managed by module-manager", key.Namespace, key.Name) + } + return nil + } + + var lastErr error + waitErr := wait.ExponentialBackoffWithContext(ctx, backoff, func(ctx context.Context) (bool, error) { + current := &unstructured.Unstructured{} + current.SetGroupVersionKind(app.GroupVersionKind()) + err := c.Get(ctx, key, current) + if err == nil { + if current.GetDeletionTimestamp().IsZero() { + lastErr = fmt.Errorf("application %s/%s still exists while waiting for terminating object to be removed", key.Namespace, key.Name) + return false, nil + } + lastErr = fmt.Errorf("application %s/%s is still terminating", key.Namespace, key.Name) + return false, nil + } + if !apierrors.IsNotFound(err) { + return false, err + } + createErr := c.Create(ctx, app) + if createErr == nil { + return true, nil + } + if apierrors.IsAlreadyExists(createErr) { + lastErr = createErr + return false, nil + } + return false, createErr + }) + if waitErr == nil { + return nil + } + if lastErr == nil { + lastErr = waitErr + } + return fmt.Errorf("wait for deleting application %s/%s before re-create: %w", key.Namespace, key.Name, lastErr) +} + +func applicationManagedByModuleManager(app *unstructured.Unstructured) bool { + return app.GetLabels()[moduleManagerManagedLabelKey] == "true" +} + +// PatchApplicationTargetRevision updates spec.source.targetRevision and embedded Helm repo.revision values. +func PatchApplicationTargetRevision(ctx context.Context, c client.Client, argoNS, appName, repoURL, revision, moduleName, moduleConfig, parentOverviewNamespace string) error { + app := &unstructured.Unstructured{} + app.SetGroupVersionKind(schema.GroupVersionKind{Group: "argoproj.io", Version: "v1alpha1", Kind: "Application"}) + if err := c.Get(ctx, client.ObjectKey{Namespace: argoNS, Name: appName}, app); err != nil { + return err + } + if err := unstructured.SetNestedField(app.Object, revision, "spec", "source", "targetRevision"); err != nil { + return fmt.Errorf("set targetRevision: %w", err) + } + src, found, err := unstructured.NestedMap(app.Object, "spec", "source") + if err != nil || !found || src == nil { + return fmt.Errorf("application %s/%s has no spec.source", argoNS, appName) + } + helm, _ := src["helm"].(map[string]interface{}) + if helm == nil { + helm = map[string]interface{}{} + } + helm["values"] = BuildHelmValuesYAML(moduleName, repoURL, revision, moduleConfig, parentOverviewNamespace) + src["helm"] = helm + if err := unstructured.SetNestedMap(app.Object, src, "spec", "source"); err != nil { + return fmt.Errorf("set helm values: %w", err) + } + return c.Update(ctx, app) +} diff --git a/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/api/argo_app_test.go b/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/api/argo_app_test.go new file mode 100644 index 00000000..eef1b82d --- /dev/null +++ b/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/api/argo_app_test.go @@ -0,0 +1,151 @@ +// Copyright (c) 2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +package api + +import ( + "context" + "strings" + "testing" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/wait" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func TestBuildHelmValuesYAML_escapesQuotes(t *testing.T) { + got := BuildHelmValuesYAML(`mod"name`, `https://example.com/a"b`, `feat/foo"bar`, "", "") + for _, want := range []string{`mod\"name`, `https://example.com/a\"b`, `feat/foo\"bar`} { + if !strings.Contains(got, want) { + t.Fatalf("expected substring %q in:\n%s", want, got) + } + } +} + +func TestBuildHelmValuesYAML_includesModuleConfig(t *testing.T) { + got := BuildHelmValuesYAML("m", "https://r", "main", "foo: bar\nbaz: 1", "") + for _, want := range []string{"config:", " foo: bar", " baz: 1", "repo:", " revision: \"main\""} { + if !strings.Contains(got, want) { + t.Fatalf("expected substring %q in:\n%s", want, got) + } + } +} + +func TestBuildHelmValuesYAML_parentOverviewNamespace(t *testing.T) { + cfg := "namespacePrefix: pfx-\nprojectID: x\n" + got := BuildHelmValuesYAML("any-module", "https://r", "main", cfg, "pfx-workflows") + want := "overviewNamespace: \"pfx-workflows\"" + if !strings.Contains(got, want) { + t.Fatalf("expected substring %q in:\n%s", want, got) + } + gotNo := BuildHelmValuesYAML("any-module", "https://r", "main", cfg, "") + if strings.Contains(gotNo, "overviewNamespace:") { + t.Fatalf("did not expect overviewNamespace when parent namespace empty:\n%s", gotNo) + } +} + +func TestCreateApplicationIdempotent_FreshCreate(t *testing.T) { + t.Parallel() + + c := fake.NewClientBuilder().Build() + app := testApplication("mod-sample") + if err := createApplicationIdempotentWithBackoff(context.Background(), c, app, wait.Backoff{Duration: time.Millisecond, Factor: 1, Steps: 2}); err != nil { + t.Fatalf("createApplicationIdempotentWithBackoff() error = %v", err) + } + got := &unstructured.Unstructured{} + got.SetGroupVersionKind(app.GroupVersionKind()) + if err := c.Get(context.Background(), client.ObjectKeyFromObject(app), got); err != nil { + t.Fatalf("get created app: %v", err) + } +} + +func TestCreateApplicationIdempotent_AlreadyExistsManaged(t *testing.T) { + t.Parallel() + + existing := testApplication("mod-sample") + c := fake.NewClientBuilder().WithObjects(existing.DeepCopy()).Build() + app := testApplication("mod-sample") + if err := createApplicationIdempotentWithBackoff(context.Background(), c, app, wait.Backoff{Duration: time.Millisecond, Factor: 1, Steps: 2}); err != nil { + t.Fatalf("createApplicationIdempotentWithBackoff() error = %v", err) + } +} + +func TestCreateApplicationIdempotent_WaitsForDeletingThenCreates(t *testing.T) { + t.Parallel() + + ctx := context.Background() + app := testApplication("mod-sample") + c := fake.NewClientBuilder().WithObjects(app.DeepCopy()).Build() + + existing := &unstructured.Unstructured{} + existing.SetGroupVersionKind(app.GroupVersionKind()) + if err := c.Get(ctx, client.ObjectKeyFromObject(app), existing); err != nil { + t.Fatalf("seed get: %v", err) + } + ts := metav1.Now() + existing.SetDeletionTimestamp(&ts) + if err := c.Update(ctx, existing); err != nil { + t.Fatalf("seed update deletion timestamp: %v", err) + } + go func() { + time.Sleep(20 * time.Millisecond) + _ = c.Delete(context.Background(), existing.DeepCopy()) + }() + + if err := createApplicationIdempotentWithBackoff(ctx, c, testApplication("mod-sample"), wait.Backoff{Duration: 5 * time.Millisecond, Factor: 1, Steps: 20}); err != nil { + t.Fatalf("createApplicationIdempotentWithBackoff() error = %v", err) + } + got := &unstructured.Unstructured{} + got.SetGroupVersionKind(app.GroupVersionKind()) + if err := c.Get(ctx, client.ObjectKeyFromObject(app), got); err != nil { + t.Fatalf("get recreated app: %v", err) + } +} + +func TestCreateApplicationIdempotent_TimesOutForDeletingObject(t *testing.T) { + t.Parallel() + + ctx := context.Background() + app := testApplication("mod-sample") + c := fake.NewClientBuilder().WithObjects(app.DeepCopy()).Build() + + existing := &unstructured.Unstructured{} + existing.SetGroupVersionKind(app.GroupVersionKind()) + if err := c.Get(ctx, client.ObjectKeyFromObject(app), existing); err != nil { + t.Fatalf("seed get: %v", err) + } + ts := metav1.Now() + existing.SetDeletionTimestamp(&ts) + if err := c.Update(ctx, existing); err != nil { + t.Fatalf("seed update deletion timestamp: %v", err) + } + + err := createApplicationIdempotentWithBackoff(ctx, c, testApplication("mod-sample"), wait.Backoff{Duration: time.Millisecond, Factor: 1, Steps: 3}) + if err == nil { + t.Fatal("expected timeout-like error, got nil") + } + if !strings.Contains(err.Error(), "wait for deleting application") { + t.Fatalf("expected wait error, got %v", err) + } +} + +func testApplication(name string) *unstructured.Unstructured { + app := BuildArgoCDApplication(name, "sample", "argocd", "default", "module-manager", "https://example.com/repo.git", "main", "gitops/modules/sample-module", "", "") + app.SetGroupVersionKind(schema.GroupVersionKind{Group: "argoproj.io", Version: "v1alpha1", Kind: "Application"}) + return app +} diff --git a/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/api/handler.go b/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/api/handler.go new file mode 100644 index 00000000..b1cfb00b --- /dev/null +++ b/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/api/handler.go @@ -0,0 +1,956 @@ +// Copyright (c) 2024-2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package api + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net/http" + "strings" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/acn-horizon-sdv/module-manager/internal/controller" + "github.com/acn-horizon-sdv/module-manager/internal/overviewfetch" +) + +// ModuleApplication is catalog metadata for a module child application (public URL or path). +type ModuleApplication struct { + ID string `json:"id"` + Title string `json:"title,omitempty"` + URL string `json:"url"` +} + +// ModuleResponse is the JSON shape for a single module. +type ModuleResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Enabled bool `json:"enabled"` + HardDependencies []string `json:"hardDependencies,omitempty"` + SoftDependencies []string `json:"softDependencies,omitempty"` + ApplicationName string `json:"applicationName,omitempty"` + ApplicationNamespace string `json:"applicationNamespace,omitempty"` + // Applications lists Developer Portal links: ModuleCatalog entries plus Argo child Applications + // labeled horizon-sdv.io/expose=true with horizon-sdv.io/portal-url (see module_applications_discover.go). + Applications []ModuleApplication `json:"applications,omitempty"` + // AutoDisableWhenUnused mirrors ModuleCatalog.spec.modules[].autoDisableWhenUnused; when unset in JSON it is false. + AutoDisableWhenUnused bool `json:"autoDisableWhenUnused,omitempty"` + // HardDependentCount is the number of enabled modules that list this module as a hard dependency. + HardDependentCount *int `json:"hardDependentCount,omitempty"` + // HardDependents are those module names (sorted). + HardDependents []string `json:"hardDependents,omitempty"` + // SoftDependentCount is the number of enabled modules that list this module as a soft dependency. + SoftDependentCount *int `json:"softDependentCount,omitempty"` + // SoftDependents are those soft-dependent module names (sorted). + SoftDependents []string `json:"softDependents,omitempty"` + // OverviewPath is a path relative to the catalog module path (e.g. portal/overview.html) for chart packaging only. + OverviewPath string `json:"overviewPath,omitempty"` + // OverviewService is the in-cluster Kubernetes Service that serves overview HTML. + OverviewService string `json:"overviewService,omitempty"` + // OverviewServiceNamespace is the namespace containing OverviewService. + OverviewServiceNamespace string `json:"overviewServiceNamespace,omitempty"` + // TargetRevision is the effective Git ref for this module's Argo CD Application (per-module or cluster default). + TargetRevision string `json:"targetRevision,omitempty"` + // ClusterTargetRevision is the Module Manager process default (--target-revision). + ClusterTargetRevision string `json:"clusterTargetRevision,omitempty"` +} + +// WorkflowsVisibilityDTO is the JSON body for GET/PUT /settings/workflows-visibility. +type WorkflowsVisibilityDTO struct { + // AllowedSubmittedFrom lists horizon-sdv.io/submitted-from values shown in the Developer Portal. + // JSON null or omitted field means show all sources (no restriction). Present empty array hides every workflow. + AllowedSubmittedFrom *[]string `json:"allowedSubmittedFrom,omitempty"` +} + +// StatusResponse is the JSON shape for module status (ArgoCD sync/health). +type StatusResponse struct { + SyncStatus string `json:"syncStatus,omitempty"` + HealthStatus string `json:"healthStatus,omitempty"` + OperationPhase string `json:"operationPhase,omitempty"` + DesiredRevision string `json:"desiredRevision,omitempty"` + SyncRevision string `json:"syncRevision,omitempty"` + // ApplicationDeletionTimestamp is set when the Argo CD Application has metadata.deletionTimestamp (uninstall in progress). + ApplicationDeletionTimestamp string `json:"applicationDeletionTimestamp,omitempty"` + // RemainingManagedApplications is set only when the module is disabled: count of parent+child Argo CD Applications still present for this module. + RemainingManagedApplications *int `json:"remainingManagedApplications,omitempty"` +} + +func fillArgoAppStatus(app *unstructured.Unstructured, status *StatusResponse) { + if s, _, _ := unstructured.NestedString(app.Object, "status", "sync", "status"); s != "" { + status.SyncStatus = s + } + if s, _, _ := unstructured.NestedString(app.Object, "status", "health", "status"); s != "" { + status.HealthStatus = s + } + if s, _, _ := unstructured.NestedString(app.Object, "status", "operationState", "phase"); s != "" { + status.OperationPhase = s + } + if s, _, _ := unstructured.NestedString(app.Object, "spec", "source", "targetRevision"); s != "" { + status.DesiredRevision = s + } + if s, _, _ := unstructured.NestedString(app.Object, "status", "sync", "revision"); s != "" { + status.SyncRevision = s + } + if ts, _, _ := unstructured.NestedString(app.Object, "metadata", "deletionTimestamp"); ts != "" { + status.ApplicationDeletionTimestamp = ts + } +} + +// Handler implements the REST API for the Module Manager. +type Handler struct { + client client.Client + apiReader client.Reader + stateStore controller.StateStoreInterface + catalogStore controller.CatalogStoreInterface + namespace string + argocdNamespace string + argocdProject string + repoURL string + targetRevision string + moduleConfig string +} + +// NewHandler returns a new API handler. +func NewHandler( + c client.Client, + apiReader client.Reader, + stateStore controller.StateStoreInterface, + catalogStore controller.CatalogStoreInterface, + namespace, argocdNamespace, argocdProject, repoURL, targetRevision, moduleConfig string, +) *Handler { + return &Handler{ + client: c, + apiReader: apiReader, + stateStore: stateStore, + catalogStore: catalogStore, + namespace: namespace, + argocdNamespace: argocdNamespace, + argocdProject: argocdProject, + repoURL: repoURL, + targetRevision: targetRevision, + moduleConfig: moduleConfig, + } +} + +// Routes returns the http.ServeMux for the API. +// Register more specific paths first so /modules/foo/status matches before /modules/foo. +func (h *Handler) Routes() *http.ServeMux { + mux := http.NewServeMux() + mux.HandleFunc("GET /openapi.json", h.serveOpenAPI) + mux.HandleFunc("GET /swagger", h.serveSwaggerUI) + mux.HandleFunc("GET /settings/workflows-visibility", h.getWorkflowsVisibility) + mux.HandleFunc("PUT /settings/workflows-visibility", h.putWorkflowsVisibility) + mux.HandleFunc("GET /modules", h.listModules) + mux.HandleFunc("GET /modules/{idOrName}/status", h.getModuleStatus) + mux.HandleFunc("GET /modules/{idOrName}/overview", h.getModuleOverview) + mux.HandleFunc("PUT /modules/{idOrName}/target-revision", h.putModuleTargetRevision) + mux.HandleFunc("POST /modules/{idOrName}/enable", h.enableModule) + mux.HandleFunc("DELETE /modules/{idOrName}/disable", h.disableModule) + mux.HandleFunc("GET /modules/{idOrName}", h.getModule) + return mux +} + +func (h *Handler) serveOpenAPI(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + w.Header().Set("Content-Type", "application/json") + w.Write(OpenAPISpec()) +} + +func (h *Handler) serveSwaggerUI(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + // Serve a minimal HTML page that loads Swagger UI and points to /openapi.json + w.Header().Set("Content-Type", "text/html; charset=utf-8") + const swaggerHTML = ` + + + Module Manager API - Swagger UI + + + +
+ + + + +` + w.Write([]byte(swaggerHTML)) +} + +func (h *Handler) getWorkflowsVisibility(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + ctx := r.Context() + state, err := h.stateStore.Get(ctx) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + var dto WorkflowsVisibilityDTO + if state != nil && state.WorkflowsVisibility != nil { + s := append([]string(nil), state.WorkflowsVisibility.AllowedSubmittedFrom...) + dto.AllowedSubmittedFrom = &s + } + writeJSON(w, dto) +} + +func (h *Handler) putWorkflowsVisibility(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + ctx := r.Context() + var raw map[string]json.RawMessage + if err := json.NewDecoder(r.Body).Decode(&raw); err != nil { + http.Error(w, "invalid json", http.StatusBadRequest) + return + } + state, err := h.stateStore.Get(ctx) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if v, ok := raw["allowedSubmittedFrom"]; !ok || string(v) == "null" { + state.WorkflowsVisibility = nil + } else { + var list []string + if err := json.Unmarshal(v, &list); err != nil { + http.Error(w, "allowedSubmittedFrom must be a JSON array of strings", http.StatusBadRequest) + return + } + out := make([]string, 0, len(list)) + for _, s := range list { + s = strings.TrimSpace(s) + if s != "" { + out = append(out, s) + } + } + state.WorkflowsVisibility = &controller.WorkflowsVisibilitySettings{AllowedSubmittedFrom: out} + } + if err := h.stateStore.Update(ctx, state); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + h.stateStore.InvalidateCache() + writeJSON(w, map[string]string{"status": "ok"}) +} + +func (h *Handler) listModules(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + ctx := r.Context() + + nameFilter := r.URL.Query().Get("name") + + catalogEntries, err := h.catalogStore.List(ctx) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + state, err := h.stateStore.Get(ctx) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + enabledSet := make(map[string]bool) + for _, id := range state.EnabledModules { + enabledSet[id] = true + } + + var list []ModuleResponse + for i := range catalogEntries { + entry := &catalogEntries[i] + if nameFilter != "" && entry.Name != nameFilter { + continue + } + id := state.ModuleIDs[entry.Name] + enabled := id != "" && enabledSet[id] + mod := ModuleResponse{ + Name: entry.Name, + ID: id, + Enabled: enabled, + HardDependencies: append([]string(nil), entry.HardDependencies...), + SoftDependencies: append([]string(nil), entry.SoftDependencies...), + AutoDisableWhenUnused: entry.AutoDisableWhenUnused, + OverviewPath: strings.TrimSpace(entry.OverviewPath), + } + if enabled { + mod.ApplicationName = controller.ApplicationName(entry.Name) + mod.ApplicationNamespace = h.argocdNamespace + } + h.fillOverviewFromCatalog(&mod, entry) + mod.Applications = h.mergedApplicationsForModule(ctx, entry.Name, entry.Applications) + if err := h.attachDependents(ctx, &mod, state); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + h.attachRevisionInfo(&mod, state) + list = append(list, mod) + } + + writeJSON(w, list) +} + +func (h *Handler) attachDependents(ctx context.Context, mod *ModuleResponse, state *controller.State) error { + if !mod.Enabled { + mod.HardDependents = nil + mod.HardDependentCount = nil + mod.SoftDependents = nil + mod.SoftDependentCount = nil + return nil + } + edges, err := controller.ListEnabledReverseDependents(ctx, h.client, h.catalogStore, h.namespace, state, mod.Name) + if err != nil { + return err + } + mod.HardDependents = edges.Hard + hardCount := len(edges.Hard) + mod.HardDependentCount = &hardCount + mod.SoftDependents = edges.Soft + softCount := len(edges.Soft) + mod.SoftDependentCount = &softCount + return nil +} + +func (h *Handler) getModule(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + idOrName := r.PathValue("idOrName") + if idOrName == "" { + http.Error(w, "idOrName required", http.StatusBadRequest) + return + } + ctx := r.Context() + + mod, err := h.resolveModule(ctx, idOrName) + if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + if mod == nil { + http.Error(w, "module not found", http.StatusNotFound) + return + } + writeJSON(w, mod) +} + +func (h *Handler) getModuleStatus(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + idOrName := r.PathValue("idOrName") + if idOrName == "" { + http.Error(w, "idOrName required", http.StatusBadRequest) + return + } + ctx := r.Context() + + mod, err := h.resolveModule(ctx, idOrName) + if err != nil || mod == nil { + http.Error(w, "module not found", http.StatusNotFound) + return + } + + status := StatusResponse{} + argoNS := h.argocdNamespace + argoName := controller.ApplicationName(mod.Name) + if mod.ApplicationName != "" && mod.ApplicationNamespace != "" { + argoNS = mod.ApplicationNamespace + argoName = mod.ApplicationName + } + parentApp := &unstructured.Unstructured{} + parentApp.SetGroupVersionKind(schema.GroupVersionKind{ + Group: "argoproj.io", + Version: "v1alpha1", + Kind: "Application", + }) + parentOnly := StatusResponse{} + parentErr := h.apiReader.Get(ctx, client.ObjectKey{Namespace: argoNS, Name: argoName}, parentApp) + if parentErr == nil { + fillArgoAppStatus(parentApp, &parentOnly) + } + + managed, listErr := h.listModuleManagedArgoApplications(ctx, mod.Name) + if listErr != nil { + log.Printf("module status: list managed Argo Applications for %q: %v", mod.Name, listErr) + if parentErr == nil { + status = parentOnly + } + } else { + agg := make([]StatusResponse, 0, len(managed)) + for i := range managed { + var st StatusResponse + fillArgoAppStatus(&managed[i], &st) + agg = append(agg, st) + } + if len(agg) == 0 { + if parentErr == nil { + status = parentOnly + } + } else { + sync, health, op := mergeManagedApplicationStatuses(agg) + status.SyncStatus = sync + status.HealthStatus = health + status.OperationPhase = op + status.DesiredRevision = parentOnly.DesiredRevision + status.SyncRevision = parentOnly.SyncRevision + status.ApplicationDeletionTimestamp = parentOnly.ApplicationDeletionTimestamp + } + } + + if !mod.Enabled && listErr == nil { + n := len(managed) + status.RemainingManagedApplications = &n + } + + writeJSON(w, status) +} + +func (h *Handler) getModuleOverview(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + idOrName := r.PathValue("idOrName") + if idOrName == "" { + http.Error(w, "idOrName required", http.StatusBadRequest) + return + } + ctx := r.Context() + + mod, err := h.resolveModule(ctx, idOrName) + if err != nil || mod == nil { + http.Error(w, "module not found", http.StatusNotFound) + return + } + entry := h.getCatalogEntry(ctx, mod.Name) + if entry == nil { + http.Error(w, "module not found", http.StatusNotFound) + return + } + svc := strings.TrimSpace(entry.OverviewService) + ns := h.resolvedOverviewServiceNamespace(entry) + if svc == "" || ns == "" { + http.Error(w, "configure overview in-cluster service (overviewService and overviewServiceNamespace) in the module catalog", http.StatusNotFound) + return + } + pageURL, err := overviewfetch.BuildInClusterOverviewURL(svc, ns) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + html, err := overviewfetch.FetchHTTPOverview(ctx, nil, pageURL) + if err != nil { + if errors.Is(err, overviewfetch.ErrOverviewNotFound) { + http.Error(w, "module overview not found", http.StatusNotFound) + return + } + log.Printf("getModuleOverview %q from %s: %v", mod.Name, pageURL, err) + http.Error(w, "failed to load overview from in-cluster service: "+err.Error(), http.StatusBadGateway) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + _, _ = w.Write(html) +} + +func (h *Handler) putModuleTargetRevision(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + idOrName := r.PathValue("idOrName") + if idOrName == "" { + http.Error(w, "idOrName required", http.StatusBadRequest) + return + } + var body struct { + TargetRevision string `json:"targetRevision"` + } + if r.Body == nil { + http.Error(w, `body required: {"targetRevision":"..."}`, http.StatusBadRequest) + return + } + defer r.Body.Close() + if err := json.NewDecoder(io.LimitReader(r.Body, 64<<10)).Decode(&body); err != nil { + http.Error(w, "invalid json body", http.StatusBadRequest) + return + } + rev := strings.TrimSpace(body.TargetRevision) + if rev == "" { + http.Error(w, "targetRevision must be non-empty", http.StatusBadRequest) + return + } + ctx := r.Context() + mod, err := h.resolveModule(ctx, idOrName) + if err != nil || mod == nil { + http.Error(w, "module not found", http.StatusNotFound) + return + } + if !mod.Enabled || mod.ApplicationName == "" || mod.ApplicationNamespace == "" { + http.Error(w, "module is not enabled", http.StatusConflict) + return + } + + state, err := h.stateStore.Get(ctx) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + oldRev := "" + if state.ModuleTargetRevisions != nil { + oldRev = state.ModuleTargetRevisions[mod.Name] + } + if state.ModuleTargetRevisions == nil { + state.ModuleTargetRevisions = make(map[string]string) + } + state.ModuleTargetRevisions[mod.Name] = rev + if err := h.stateStore.Update(ctx, state); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + parentONS := "" + if e := h.getCatalogEntry(ctx, mod.Name); e != nil { + parentONS = h.parentHelmOverviewNamespace(e) + } + if err := PatchApplicationTargetRevision(ctx, h.client, mod.ApplicationNamespace, mod.ApplicationName, h.repoURL, rev, mod.Name, h.moduleConfig, parentONS); err != nil { + state2, gerr := h.stateStore.Get(ctx) + if gerr == nil { + if oldRev == "" { + delete(state2.ModuleTargetRevisions, mod.Name) + } else { + if state2.ModuleTargetRevisions == nil { + state2.ModuleTargetRevisions = make(map[string]string) + } + state2.ModuleTargetRevisions[mod.Name] = oldRev + } + _ = h.stateStore.Update(ctx, state2) + } + h.stateStore.InvalidateCache() + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + h.stateStore.InvalidateCache() + writeJSON(w, map[string]string{"status": "ok"}) +} + +func (h *Handler) enableModule(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + idOrName := r.PathValue("idOrName") + if idOrName == "" { + http.Error(w, "idOrName required", http.StatusBadRequest) + return + } + ctx := r.Context() + controller.ModuleOpsMutex.Lock() + defer controller.ModuleOpsMutex.Unlock() + + mod, err := h.resolveModule(ctx, idOrName) + if err != nil || mod == nil { + http.Error(w, "module not found", http.StatusNotFound) + return + } + if mod.Enabled { + writeJSON(w, map[string]string{"status": "already enabled"}) + return + } + + defaultRev := strings.TrimSpace(h.targetRevision) + var enableBody struct { + TargetRevision string `json:"targetRevision"` + } + if r.Body != nil { + defer r.Body.Close() + dec := json.NewDecoder(io.LimitReader(r.Body, 64<<10)) + if err := dec.Decode(&enableBody); err != nil && !errors.Is(err, io.EOF) { + http.Error(w, "invalid json body", http.StatusBadRequest) + return + } + } + rev := strings.TrimSpace(enableBody.TargetRevision) + if rev == "" { + rev = defaultRev + } + if rev == "" { + http.Error(w, "targetRevision cannot be empty", http.StatusBadRequest) + return + } + + if err := h.enableOneModule(ctx, mod.Name, rev); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + // After enabling, recompute softFeaturesEnabled on parents that list this module as a soft dependency. + h.patchDependentsSoftFeature(ctx, mod.Name) + + if err := controller.RunAutoDisableSweep(ctx, h.apiReader, h.client, h.namespace, h.argocdNamespace, h.stateStore, h.catalogStore); err != nil { + log.Printf("auto-disable sweep after enable %q: %v", mod.Name, err) + } + + w.WriteHeader(http.StatusOK) + writeJSON(w, map[string]string{"status": "enabled"}) +} + +// enableOneModule enables a single module by name, recursively enabling its hard dependencies first. +// targetRevision is the Git ref for this module's Argo CD Application (branch, tag, or commit). +func (h *Handler) enableOneModule(ctx context.Context, moduleName, targetRevision string) error { + targetRevision = strings.TrimSpace(targetRevision) + if targetRevision == "" { + return fmt.Errorf("targetRevision cannot be empty") + } + state, err := h.stateStore.Get(ctx) + if err != nil { + return err + } + mod, err := h.moduleFromName(ctx, moduleName, state) + if err != nil { + return err + } + if mod == nil { + return fmt.Errorf("module not found: %s", moduleName) + } + if mod.Enabled { + return nil + } + + path, err := h.catalogStore.GetPath(ctx, moduleName) + if err != nil { + return err + } + if path == "" { + return fmt.Errorf("module path not in catalog: %s", moduleName) + } + + hardDeps := mod.HardDependencies + if len(hardDeps) == 0 { + if entry := h.getCatalogEntry(ctx, moduleName); entry != nil { + hardDeps = entry.HardDependencies + } + } + + depDefaultRev := strings.TrimSpace(h.targetRevision) + enabledSet := make(map[string]bool) + for _, id := range state.EnabledModules { + enabledSet[id] = true + } + for _, dep := range hardDeps { + depID := state.ModuleIDs[dep] + if depID != "" && enabledSet[depID] { + continue + } + if err := h.enableOneModule(ctx, dep, depDefaultRev); err != nil { + return err + } + state, err = h.stateStore.Get(ctx) + if err != nil { + return err + } + enabledSet = make(map[string]bool) + for _, id := range state.EnabledModules { + enabledSet[id] = true + } + } + + appName := controller.ApplicationName(moduleName) + parentONS := "" + if e := h.getCatalogEntry(ctx, moduleName); e != nil { + parentONS = h.parentHelmOverviewNamespace(e) + } + app := BuildArgoCDApplication(appName, moduleName, h.argocdNamespace, h.argocdProject, h.namespace, h.repoURL, targetRevision, path, h.moduleConfig, parentONS) + if err := createApplicationIdempotent(ctx, h.client, app); err != nil { + return err + } + + // Refresh state and ensure we have an ID (generate if first enable). + state, err = h.stateStore.Get(ctx) + if err != nil { + return err + } + mod, _ = h.moduleFromName(ctx, moduleName, state) + id := mod.ID + if id == "" { + b := make([]byte, 4) + if _, err := rand.Read(b); err != nil { + return err + } + id = "mod-" + hex.EncodeToString(b) + } + if state.ModuleIDs == nil { + state.ModuleIDs = make(map[string]string) + } + state.ModuleIDs[moduleName] = id + if state.ModuleTargetRevisions == nil { + state.ModuleTargetRevisions = make(map[string]string) + } + state.ModuleTargetRevisions[moduleName] = targetRevision + found := false + for _, eid := range state.EnabledModules { + if eid == id { + found = true + break + } + } + if !found { + state.EnabledModules = append(state.EnabledModules, id) + } + if err := h.stateStore.Update(ctx, state); err != nil { + return err + } + + if err := controller.SyncSoftFeaturesForModule(ctx, h.apiReader, h.client, h.argocdNamespace, h.namespace, h.stateStore, h.catalogStore, moduleName); err != nil { + return err + } + return nil +} + +func (h *Handler) resolvedOverviewServiceNamespace(entry *controller.CatalogEntry) string { + if entry == nil { + return "" + } + svc := strings.TrimSpace(entry.OverviewService) + if svc == "" { + return "" + } + ns := ResolveOverviewServiceNamespace(entry.Name, entry.OverviewServiceNamespace, h.moduleConfig, h.namespace) + if ns == "" { + return "" + } + return ns +} + +func (h *Handler) parentHelmOverviewNamespace(entry *controller.CatalogEntry) string { + if entry == nil { + return "" + } + svc := strings.TrimSpace(entry.OverviewService) + if svc == "" { + return "" + } + ns := h.resolvedOverviewServiceNamespace(entry) + if ns == "" { + return "" + } + return ns +} + +func (h *Handler) fillOverviewFromCatalog(mod *ModuleResponse, entry *controller.CatalogEntry) { + mod.OverviewService = strings.TrimSpace(entry.OverviewService) + mod.OverviewServiceNamespace = h.resolvedOverviewServiceNamespace(entry) + if mod.OverviewService == "" || mod.OverviewServiceNamespace == "" { + mod.OverviewService = "" + mod.OverviewServiceNamespace = "" + } +} + +func (h *Handler) getCatalogEntry(ctx context.Context, name string) *controller.CatalogEntry { + entries, err := h.catalogStore.List(ctx) + if err != nil { + return nil + } + for i := range entries { + if entries[i].Name == name { + return &entries[i] + } + } + return nil +} + +// patchDependentsSoftFeature recomputes softFeaturesEnabled on each enabled parent that lists enabledModuleName as a soft dependency. +func (h *Handler) patchDependentsSoftFeature(ctx context.Context, enabledModuleName string) { + if err := controller.ResyncSoftFeaturesForParentsOfSoftDep(ctx, h.apiReader, h.client, h.argocdNamespace, h.namespace, h.stateStore, h.catalogStore, enabledModuleName); err != nil { + log.Printf("patchDependentsSoftFeature(%q): %v", enabledModuleName, err) + } +} + +func (h *Handler) disableModule(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + idOrName := r.PathValue("idOrName") + if idOrName == "" { + http.Error(w, "idOrName required", http.StatusBadRequest) + return + } + ctx := r.Context() + controller.ModuleOpsMutex.Lock() + defer controller.ModuleOpsMutex.Unlock() + + mod, err := h.resolveModule(ctx, idOrName) + if err != nil || mod == nil { + http.Error(w, "module not found", http.StatusNotFound) + return + } + if !mod.Enabled { + writeJSON(w, map[string]string{"status": "already disabled"}) + return + } + + // Check no enabled module has a hard dependency on this one. + state, err := h.stateStore.Get(ctx) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + deps, err := controller.ListHardDependents(ctx, h.client, h.catalogStore, h.namespace, state, mod.Name) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if len(deps) > 0 { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusConflict) + writeJSON(w, map[string]interface{}{ + "error": "cannot disable: module is a hard dependency", + "hardDependents": deps, + }) + return + } + + if err := controller.DisableModuleAndRefresh(ctx, h.apiReader, h.client, h.stateStore, h.catalogStore, h.argocdNamespace, h.namespace, mod.Name, mod.ID, true); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) + writeJSON(w, map[string]string{"status": "disabled"}) +} + +func (h *Handler) resolveModule(ctx context.Context, idOrName string) (*ModuleResponse, error) { + state, err := h.stateStore.Get(ctx) + if err != nil { + return nil, err + } + // By ID + for name, id := range state.ModuleIDs { + if id == idOrName { + mod, err := h.moduleFromName(ctx, name, state) + if err != nil || mod == nil { + return mod, err + } + if err := h.attachDependents(ctx, mod, state); err != nil { + return nil, err + } + h.attachRevisionInfo(mod, state) + return mod, nil + } + } + // By name (catalog or registration) + mod, err := h.moduleFromName(ctx, idOrName, state) + if err != nil || mod == nil { + return mod, err + } + if err := h.attachDependents(ctx, mod, state); err != nil { + return nil, err + } + h.attachRevisionInfo(mod, state) + return mod, nil +} + +func (h *Handler) attachRevisionInfo(mod *ModuleResponse, state *controller.State) { + def := strings.TrimSpace(h.targetRevision) + mod.ClusterTargetRevision = def + mod.TargetRevision = controller.EffectiveTargetRevision(state, mod.Name, def) +} + +func (h *Handler) moduleFromName(ctx context.Context, name string, state *controller.State) (*ModuleResponse, error) { + enabledSet := make(map[string]bool) + for _, id := range state.EnabledModules { + enabledSet[id] = true + } + id := state.ModuleIDs[name] + enabled := id != "" && enabledSet[id] + mod := &ModuleResponse{Name: name, ID: id, Enabled: enabled} + entry := h.getCatalogEntry(ctx, name) + if entry == nil && mod.ID == "" { + return nil, nil + } + if entry != nil { + mod.HardDependencies = append([]string(nil), entry.HardDependencies...) + mod.SoftDependencies = append([]string(nil), entry.SoftDependencies...) + mod.AutoDisableWhenUnused = entry.AutoDisableWhenUnused + mod.OverviewPath = strings.TrimSpace(entry.OverviewPath) + h.fillOverviewFromCatalog(mod, entry) + } + if enabled { + mod.ApplicationName = controller.ApplicationName(name) + mod.ApplicationNamespace = h.argocdNamespace + } + var catApps []controller.CatalogApplication + if entry != nil { + catApps = entry.Applications + } + mod.Applications = h.mergedApplicationsForModule(ctx, name, catApps) + return mod, nil +} + +func catalogApplicationsToResponse(in []controller.CatalogApplication) []ModuleApplication { + if len(in) == 0 { + return nil + } + out := make([]ModuleApplication, 0, len(in)) + for _, a := range in { + if a.ID == "" || a.URL == "" { + continue + } + out = append(out, ModuleApplication{ID: a.ID, Title: a.Title, URL: a.URL}) + } + if len(out) == 0 { + return nil + } + return out +} + +func writeJSON(w http.ResponseWriter, v interface{}) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(v) +} diff --git a/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/api/module_applications_discover.go b/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/api/module_applications_discover.go new file mode 100644 index 00000000..a91a7121 --- /dev/null +++ b/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/api/module_applications_discover.go @@ -0,0 +1,168 @@ +// Copyright (c) 2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package api + +import ( + "context" + "log" + "sort" + "strings" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/acn-horizon-sdv/module-manager/internal/controller" +) + +// Labels / annotations for Developer Portal "Applications" discovered from Argo CD child Applications. +// Aligns with WorkflowTemplate exposure (horizon-sdv.io/expose) but uses annotations for URL/title (label length limits). +const ( + labelModule = "horizon-sdv.io/module" + labelAppRole = "horizon-sdv.io/app-role" + labelAppRoleParent = "parent" + labelAppRoleChild = "child" + labelExpose = "horizon-sdv.io/expose" + labelExposeVal = "true" + + annPortalURL = "horizon-sdv.io/portal-url" + annPortalTitle = "horizon-sdv.io/portal-title" + annPortalID = "horizon-sdv.io/portal-id" +) + +// discoverApplicationsFromArgoChildApps lists Argo CD Applications labeled as child apps of the module +// with horizon-sdv.io/expose=true and portal-url set. +func (h *Handler) discoverApplicationsFromArgoChildApps(ctx context.Context, moduleName string) ([]ModuleApplication, error) { + if strings.TrimSpace(moduleName) == "" || strings.TrimSpace(h.argocdNamespace) == "" { + return nil, nil + } + list := &unstructured.UnstructuredList{} + list.SetGroupVersionKind(schema.GroupVersionKind{Group: "argoproj.io", Version: "v1alpha1", Kind: "ApplicationList"}) + err := h.apiReader.List(ctx, list, client.InNamespace(h.argocdNamespace), client.MatchingLabels{ + labelModule: moduleName, + labelAppRole: labelAppRoleChild, + labelExpose: labelExposeVal, + }) + if err != nil { + return nil, err + } + var out []ModuleApplication + for i := range list.Items { + item := &list.Items[i] + labels := item.GetLabels() + if labels == nil || labels[labelExpose] != labelExposeVal { + continue + } + ann := item.GetAnnotations() + if ann == nil { + continue + } + url := strings.TrimSpace(ann[annPortalURL]) + if url == "" { + continue + } + id := strings.TrimSpace(ann[annPortalID]) + if id == "" { + id = item.GetName() + } + title := strings.TrimSpace(ann[annPortalTitle]) + if title == "" { + title = displayTitleFromArgoAppName(item.GetName(), moduleName) + } + out = append(out, ModuleApplication{ID: id, Title: title, URL: url}) + } + sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) + return out, nil +} + +func displayTitleFromArgoAppName(appName, moduleName string) string { + prefix := "mod-" + moduleName + "-" + if strings.HasPrefix(appName, prefix) { + return appName[len(prefix):] + } + return appName +} + +// mergeModuleApplications merges catalog-derived apps first, then Argo-discovered rows, deduping by URL and id. +func mergeModuleApplications(catalog []ModuleApplication, fromArgo []ModuleApplication) []ModuleApplication { + seenURL := make(map[string]struct{}) + seenID := make(map[string]struct{}) + var out []ModuleApplication + add := func(a ModuleApplication) { + u := strings.TrimSpace(a.URL) + id := strings.TrimSpace(a.ID) + if u == "" || id == "" { + return + } + if _, ok := seenID[id]; ok { + return + } + if _, ok := seenURL[u]; ok { + return + } + seenID[id] = struct{}{} + seenURL[u] = struct{}{} + out = append(out, ModuleApplication{ID: id, Title: a.Title, URL: u}) + } + for _, a := range catalog { + add(a) + } + for _, a := range fromArgo { + add(a) + } + if len(out) == 0 { + return nil + } + return out +} + +// listModuleManagedArgoApplications returns Argo CD Applications labeled as parent or child for the module catalog name. +func (h *Handler) listModuleManagedArgoApplications(ctx context.Context, moduleName string) ([]unstructured.Unstructured, error) { + if strings.TrimSpace(moduleName) == "" || strings.TrimSpace(h.argocdNamespace) == "" { + return nil, nil + } + list := &unstructured.UnstructuredList{} + list.SetGroupVersionKind(schema.GroupVersionKind{Group: "argoproj.io", Version: "v1alpha1", Kind: "ApplicationList"}) + if err := h.apiReader.List(ctx, list, client.InNamespace(h.argocdNamespace), client.MatchingLabels{ + labelModule: moduleName, + }); err != nil { + return nil, err + } + out := make([]unstructured.Unstructured, 0, len(list.Items)) + for i := range list.Items { + item := &list.Items[i] + labels := item.GetLabels() + if labels == nil { + continue + } + switch strings.TrimSpace(labels[labelAppRole]) { + case labelAppRoleParent, labelAppRoleChild: + out = append(out, *item) + default: + continue + } + } + return out, nil +} + +func (h *Handler) mergedApplicationsForModule(ctx context.Context, moduleName string, catalogApps []controller.CatalogApplication) []ModuleApplication { + cat := catalogApplicationsToResponse(catalogApps) + argo, err := h.discoverApplicationsFromArgoChildApps(ctx, moduleName) + if err != nil { + log.Printf("module applications: list Argo child Applications for %q: %v", moduleName, err) + return cat + } + return mergeModuleApplications(cat, argo) +} diff --git a/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/api/module_applications_discover_test.go b/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/api/module_applications_discover_test.go new file mode 100644 index 00000000..578d3060 --- /dev/null +++ b/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/api/module_applications_discover_test.go @@ -0,0 +1,64 @@ +// Copyright (c) 2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package api + +import ( + "testing" +) + +func TestMergeModuleApplications_dedupeURL(t *testing.T) { + cat := []ModuleApplication{{ID: "a", Title: "A", URL: "/a"}} + argo := []ModuleApplication{{ID: "b", Title: "B", URL: "/a"}} + got := mergeModuleApplications(cat, argo) + if len(got) != 1 || got[0].ID != "a" { + t.Fatalf("got %#v, want single catalog entry when URL duplicates", got) + } +} + +func TestMergeModuleApplications_dedupeID(t *testing.T) { + cat := []ModuleApplication{{ID: "same", Title: "C", URL: "/c"}} + argo := []ModuleApplication{{ID: "same", Title: "D", URL: "/d"}} + got := mergeModuleApplications(cat, argo) + if len(got) != 1 || got[0].URL != "/c" { + t.Fatalf("got %#v, want catalog wins on same id", got) + } +} + +func TestMergeModuleApplications_orderAndAppend(t *testing.T) { + cat := []ModuleApplication{{ID: "z", Title: "", URL: "/z"}} + argo := []ModuleApplication{{ID: "m", Title: "M", URL: "/m"}} + got := mergeModuleApplications(cat, argo) + if len(got) != 2 { + t.Fatalf("got len %d", len(got)) + } + if got[0].ID != "z" || got[1].ID != "m" { + t.Fatalf("order: got %#v", got) + } +} + +func TestMergeModuleApplications_empty(t *testing.T) { + if mergeModuleApplications(nil, nil) != nil { + t.Fatal("expected nil") + } +} + +func TestDisplayTitleFromArgoAppName(t *testing.T) { + if got := displayTitleFromArgoAppName("mod-sample-module-hello-world", "sample-module"); got != "hello-world" { + t.Fatalf("got %q", got) + } + if got := displayTitleFromArgoAppName("other", "sample-module"); got != "other" { + t.Fatalf("got %q", got) + } +} diff --git a/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/api/module_status_aggregate.go b/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/api/module_status_aggregate.go new file mode 100644 index 00000000..c9360df6 --- /dev/null +++ b/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/api/module_status_aggregate.go @@ -0,0 +1,105 @@ +// Copyright (c) 2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package api + +import ( + "strings" +) + +// mergeManagedApplicationStatuses combines sync/health/operation from parent + child Argo CD Applications. +func mergeManagedApplicationStatuses(statuses []StatusResponse) (syncStatus, healthStatus, operationPhase string) { + if len(statuses) == 0 { + return "", "", "" + } + syncs := make([]string, 0, len(statuses)) + healths := make([]string, 0, len(statuses)) + ops := make([]string, 0, len(statuses)) + for _, s := range statuses { + if t := strings.TrimSpace(s.SyncStatus); t != "" { + syncs = append(syncs, t) + } + if t := strings.TrimSpace(s.HealthStatus); t != "" { + healths = append(healths, t) + } + if t := strings.TrimSpace(s.OperationPhase); t != "" { + ops = append(ops, t) + } + } + return mergeSyncStatuses(syncs), mergeHealthStatuses(healths), mergeOperationPhases(ops) +} + +func mergeSyncStatuses(syncs []string) string { + if len(syncs) == 0 { + return "" + } + allSynced := true + for _, s := range syncs { + if s != "Synced" { + allSynced = false + break + } + } + if allSynced { + return "Synced" + } + for _, s := range syncs { + if s == "OutOfSync" { + return "OutOfSync" + } + } + return "Unknown" +} + +// healthRank maps Argo CD health.status to severity (higher = worse for readiness). +var healthRank = map[string]int{ + "Degraded": 5, + "Missing": 4, + "Progressing": 3, + "Unknown": 2, + "Suspended": 3, + "Healthy": 1, +} + +func mergeHealthStatuses(healths []string) string { + if len(healths) == 0 { + return "" + } + best := "" + bestR := -1 + for _, h := range healths { + r, ok := healthRank[h] + if !ok { + r = 2 // unknown string → treat like Unknown + } + if r > bestR { + bestR, best = r, h + } + } + return best +} + +func mergeOperationPhases(ops []string) string { + for _, p := range ops { + if p == "Running" { + return "Running" + } + } + for _, p := range ops { + if p == "Pending" { + return "Pending" + } + } + return "" +} diff --git a/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/api/module_status_aggregate_test.go b/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/api/module_status_aggregate_test.go new file mode 100644 index 00000000..f410f3a8 --- /dev/null +++ b/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/api/module_status_aggregate_test.go @@ -0,0 +1,120 @@ +// Copyright (c) 2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package api + +import "testing" + +func TestMergeManagedApplicationStatuses(t *testing.T) { + t.Parallel() + cases := []struct { + name string + in []StatusResponse + wantSync string + wantHlth string + wantOp string + }{ + { + name: "empty", + in: nil, + wantSync: "", + wantHlth: "", + wantOp: "", + }, + { + name: "all synced healthy", + in: []StatusResponse{ + {SyncStatus: "Synced", HealthStatus: "Healthy", OperationPhase: "Succeeded"}, + {SyncStatus: "Synced", HealthStatus: "Healthy"}, + }, + wantSync: "Synced", + wantHlth: "Healthy", + wantOp: "", + }, + { + name: "child progressing blocks ready", + in: []StatusResponse{ + {SyncStatus: "Synced", HealthStatus: "Healthy"}, + {SyncStatus: "Synced", HealthStatus: "Progressing"}, + }, + wantSync: "Synced", + wantHlth: "Progressing", + wantOp: "", + }, + { + name: "out of sync wins", + in: []StatusResponse{ + {SyncStatus: "Synced", HealthStatus: "Healthy"}, + {SyncStatus: "OutOfSync", HealthStatus: "Healthy"}, + }, + wantSync: "OutOfSync", + wantHlth: "Healthy", + wantOp: "", + }, + { + name: "running op", + in: []StatusResponse{ + {SyncStatus: "Synced", HealthStatus: "Healthy", OperationPhase: "Succeeded"}, + {SyncStatus: "Synced", HealthStatus: "Healthy", OperationPhase: "Running"}, + }, + wantSync: "Synced", + wantHlth: "Healthy", + wantOp: "Running", + }, + { + name: "pending after running check", + in: []StatusResponse{ + {SyncStatus: "Synced", HealthStatus: "Healthy", OperationPhase: "Pending"}, + }, + wantSync: "Synced", + wantHlth: "Healthy", + wantOp: "Pending", + }, + { + name: "degraded over progressing", + in: []StatusResponse{ + {SyncStatus: "Synced", HealthStatus: "Progressing"}, + {SyncStatus: "Synced", HealthStatus: "Degraded"}, + }, + wantSync: "Synced", + wantHlth: "Degraded", + wantOp: "", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + s, h, o := mergeManagedApplicationStatuses(tc.in) + if s != tc.wantSync { + t.Fatalf("syncStatus: got %q want %q", s, tc.wantSync) + } + if h != tc.wantHlth { + t.Fatalf("healthStatus: got %q want %q", h, tc.wantHlth) + } + if o != tc.wantOp { + t.Fatalf("operationPhase: got %q want %q", o, tc.wantOp) + } + }) + } +} + +func TestMergeSyncStatuses(t *testing.T) { + t.Parallel() + if got := mergeSyncStatuses([]string{"Synced", "Unknown"}); got != "Unknown" { + t.Fatalf("got %q want Unknown", got) + } + if got := mergeSyncStatuses([]string{"Synced", "OutOfSync"}); got != "OutOfSync" { + t.Fatalf("got %q want OutOfSync", got) + } +} diff --git a/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/api/openapi.go b/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/api/openapi.go new file mode 100644 index 00000000..09583429 --- /dev/null +++ b/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/api/openapi.go @@ -0,0 +1,387 @@ +// Copyright (c) 2024-2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package api + +// OpenAPISpec returns the OpenAPI 3.0 JSON specification for the Module Manager REST API. +func OpenAPISpec() []byte { + return []byte(`{ + "openapi": "3.0.3", + "info": { + "title": "Horizon SDV Module Manager API", + "description": "REST API for enabling and disabling Horizon SDV modules. Used by the Developer Portal. Discoverable via GET /openapi.json; interactive docs at GET /swagger.", + "version": "0.5.0" + }, + "paths": { + "/settings/workflows-visibility": { + "get": { + "summary": "Get workflows visibility settings", + "description": "Returns which workflow submitted-from sources are shown in the Developer Portal. Omitted allowedSubmittedFrom means no filter (show all).", + "operationId": "getWorkflowsVisibility", + "responses": { + "200": { + "description": "Current settings", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/WorkflowsVisibility" } + } + } + } + } + }, + "put": { + "summary": "Update workflows visibility settings", + "description": "Persist Developer Portal workflow source filter. Send null or omit allowedSubmittedFrom to clear the filter (show all). Empty array hides all workflows.", + "operationId": "putWorkflowsVisibility", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/WorkflowsVisibility" } + } + } + }, + "responses": { + "200": { + "description": "Updated", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { "status": { "type": "string", "example": "ok" } } + } + } + } + }, + "400": { "description": "Invalid JSON body" } + } + } + }, + "/modules": { + "get": { + "summary": "List modules", + "description": "Returns all modules (from catalog and registrations). Use query parameter 'name' to filter by module name.", + "operationId": "listModules", + "parameters": [ + { + "name": "name", + "in": "query", + "description": "Filter by module name", + "required": false, + "schema": { "type": "string" } + } + ], + "responses": { + "200": { + "description": "List of modules", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { "$ref": "#/components/schemas/Module" } + } + } + } + } + } + } + }, + "/modules/{idOrName}": { + "get": { + "summary": "Get module", + "description": "Returns a single module by assigned ID or by name.", + "operationId": "getModule", + "parameters": [ + { + "name": "idOrName", + "in": "path", + "required": true, + "schema": { "type": "string" } + } + ], + "responses": { + "200": { + "description": "Module details", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Module" } + } + } + }, + "404": { "description": "Module not found" } + } + } + }, + "/modules/{idOrName}/target-revision": { + "put": { + "summary": "Set module Git target revision", + "description": "Updates the persisted ref and patches the module's Argo CD Application (spec.source.targetRevision and Helm values). Module must be enabled.", + "operationId": "putModuleTargetRevision", + "parameters": [ + { + "name": "idOrName", + "in": "path", + "required": true, + "schema": { "type": "string" } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["targetRevision"], + "properties": { + "targetRevision": { "type": "string", "description": "Git branch, tag, or commit SHA" } + } + } + } + } + }, + "responses": { + "200": { + "description": "Updated", + "content": { + "application/json": { + "schema": { "type": "object", "properties": { "status": { "type": "string", "example": "ok" } } } + } + } + }, + "400": { "description": "Invalid body or empty targetRevision" }, + "404": { "description": "Module not found" }, + "409": { "description": "Module not enabled" }, + "500": { "description": "Cluster update failed" } + } + } + }, + "/modules/{idOrName}/status": { + "get": { + "summary": "Get module status", + "description": "Returns Argo CD sync, health, and operation state aggregated across the module parent Application and child Applications (labels horizon-sdv.io/module and horizon-sdv.io/app-role parent or child). desiredRevision, syncRevision, and applicationDeletionTimestamp reflect the parent Application only. When the module is disabled, remainingManagedApplications counts how many of those Applications still exist (Developer Portal uses this so uninstall stays in progress until all are gone).", + "operationId": "getModuleStatus", + "parameters": [ + { + "name": "idOrName", + "in": "path", + "required": true, + "schema": { "type": "string" } + } + ], + "responses": { + "200": { + "description": "Sync and health status", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/ModuleStatus" } + } + } + }, + "404": { "description": "Module not found" } + } + } + }, + "/modules/{idOrName}/overview": { + "get": { + "summary": "Get module overview HTML", + "description": "Returns self-contained HTML for the Developer Portal Overview tab. Module Manager performs an in-cluster HTTP GET to http://{overviewService}.{overviewServiceNamespace}.svc.cluster.local:80/ (Cluster DNS; port and path are fixed). For catalog modules whose name starts with workloads-, overview Services are deployed with the parent workload chart in Module Manager's own namespace (--namespace); the catalog should list that namespace. If the catalog still has the bare name \"workflows\" or omits the namespace, Module Manager falls back to its configured namespace. Other modules use overviewServiceNamespace from the catalog, with a legacy correction when the catalog has the bare name \"workflows\" but MODULE_CONFIG declares a non-empty namespacePrefix. If overviewService is unset or the resolved namespace is empty, this endpoint returns 404.", + "operationId": "getModuleOverview", + "parameters": [ + { + "name": "idOrName", + "in": "path", + "required": true, + "schema": { "type": "string" } + } + ], + "responses": { + "200": { + "description": "text/html document", + "content": { + "text/html": { + "schema": { "type": "string", "format": "binary" } + } + } + }, + "400": { "description": "Invalid overview HTTP target" }, + "404": { "description": "Module not found, overview not configured in catalog, or upstream overview returned 404" }, + "502": { "description": "In-cluster overview HTTP error or overview pod not ready" } + } + } + }, + "/modules/{idOrName}/enable": { + "post": { + "summary": "Enable module", + "description": "Creates the ArgoCD Application for the module so it is deployed. Fails if hard dependencies are not enabled.", + "operationId": "enableModule", + "parameters": [ + { + "name": "idOrName", + "in": "path", + "required": true, + "schema": { "type": "string" } + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "targetRevision": { + "type": "string", + "description": "Optional Git ref for this module; defaults to Module Manager cluster target revision" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Module enabled", + "content": { + "application/json": { + "schema": { "type": "object", "properties": { "status": { "type": "string", "example": "enabled" } } } + } + } + }, + "400": { "description": "Bad request (e.g. module path not in catalog)" }, + "404": { "description": "Module not found" }, + "409": { "description": "Conflict (e.g. hard dependency not enabled)" } + } + } + }, + "/modules/{idOrName}/disable": { + "delete": { + "summary": "Disable module", + "description": "Deletes the ArgoCD Application for the module. Fails with 409 if any enabled module has a hard dependency on this module.", + "operationId": "disableModule", + "parameters": [ + { + "name": "idOrName", + "in": "path", + "required": true, + "schema": { "type": "string" } + } + ], + "responses": { + "200": { + "description": "Module disabled", + "content": { + "application/json": { + "schema": { "type": "object", "properties": { "status": { "type": "string", "example": "disabled" } } } + } + } + }, + "404": { "description": "Module not found" }, + "409": { + "description": "Conflict - module is a hard dependency of other enabled modules", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { "type": "string" }, + "hardDependents": { "type": "array", "items": { "type": "string" } } + } + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "ModuleApplication": { + "type": "object", + "required": ["id", "url"], + "properties": { + "id": { "type": "string", "description": "Stable id (ModuleCatalog entry, or Argo Application metadata.name / horizon-sdv.io/portal-id)" }, + "title": { "type": "string", "description": "Human-readable label" }, + "url": { + "type": "string", + "description": "Public URL: site-relative path (starts with /) resolved against the Developer Portal origin, or an absolute http(s) URL" + } + } + }, + "Module": { + "type": "object", + "properties": { + "id": { "type": "string", "description": "Stable ID assigned by the controller" }, + "name": { "type": "string", "description": "Logical module name" }, + "overviewPath": { + "type": "string", + "description": "Path to overview HTML under the catalog module path (e.g. portal/overview.html) for Helm chart packaging only; not used as a runtime Git path" + }, + "overviewService": { + "type": "string", + "description": "Kubernetes Service name that serves overview HTML in overviewServiceNamespace" + }, + "overviewServiceNamespace": { + "type": "string", + "description": "Namespace for overviewService (in-cluster HTTP from Module Manager; always port 80 and path /)" + }, + "enabled": { "type": "boolean" }, + "hardDependencies": { "type": "array", "items": { "type": "string" } }, + "softDependencies": { "type": "array", "items": { "type": "string" } }, + "applicationName": { "type": "string" }, + "applicationNamespace": { "type": "string" }, + "applications": { + "type": "array", + "description": "Developer Portal links: union of ModuleCatalog.spec.modules[].applications and Argo CD child Applications (labels horizon-sdv.io/module, horizon-sdv.io/app-role=child, horizon-sdv.io/expose=true) with annotation horizon-sdv.io/portal-url (required), optional horizon-sdv.io/portal-title and horizon-sdv.io/portal-id. Catalog rows are listed first; duplicates by id or url are dropped.", + "items": { "$ref": "#/components/schemas/ModuleApplication" } + }, + "hardDependentCount": { "type": "integer", "description": "Number of enabled modules that list this module as a hard dependency" }, + "hardDependents": { "type": "array", "items": { "type": "string" }, "description": "Names of those hard dependent modules (sorted)" }, + "softDependentCount": { "type": "integer", "description": "Number of enabled modules that list this module as a soft dependency" }, + "softDependents": { "type": "array", "items": { "type": "string" }, "description": "Names of those soft dependent modules (sorted)" }, + "autoDisableWhenUnused": { "type": "boolean", "description": "When true, ModuleCatalog entry allows auto-disable when both hard and soft dependent counts drop to zero" }, + "targetRevision": { "type": "string", "description": "Effective Git ref for the module Application" }, + "clusterTargetRevision": { "type": "string", "description": "Module Manager default Git ref (--target-revision)" } + } + }, + "ModuleStatus": { + "type": "object", + "properties": { + "syncStatus": { "type": "string", "description": "Argo CD sync status (worst of parent + child Applications)" }, + "healthStatus": { "type": "string", "description": "Argo CD health status (worst of parent + child Applications)" }, + "operationPhase": { "type": "string", "description": "Argo CD operation phase (e.g. Running, Pending); aggregated across parent + children" }, + "desiredRevision": { "type": "string", "description": "spec.source.targetRevision (parent Application only)" }, + "syncRevision": { "type": "string", "description": "Resolved revision from status.sync.revision (parent Application only)" }, + "applicationDeletionTimestamp": { "type": "string", "description": "RFC3339 time when the parent Argo CD Application has metadata.deletionTimestamp (uninstall in progress)" }, + "remainingManagedApplications": { + "type": "integer", + "minimum": 0, + "description": "Present only when the module is disabled: number of parent+child Argo CD Applications still on the cluster for this module" + } + } + }, + "WorkflowsVisibility": { + "type": "object", + "properties": { + "allowedSubmittedFrom": { + "type": "array", + "items": { "type": "string" }, + "description": "If set, only workflows whose horizon-sdv.io/submitted-from label is in this list are shown in the Developer Portal. Omit or null for no restriction; empty array hides all." + } + } + } + } + } +}`) +} diff --git a/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/api/overview_namespace.go b/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/api/overview_namespace.go new file mode 100644 index 00000000..f820fdf6 --- /dev/null +++ b/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/api/overview_namespace.go @@ -0,0 +1,93 @@ +// Copyright (c) 2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +package api + +import ( + "encoding/json" + "fmt" + "strings" + + "gopkg.in/yaml.v3" +) + +// namespacePrefixFromModuleConfig reads MODULE_CONFIG (Helm toYaml of config) as YAML or JSON. +func namespacePrefixFromModuleConfig(moduleConfig string) string { + moduleConfig = strings.TrimSpace(moduleConfig) + if moduleConfig == "" { + return "" + } + var m map[string]interface{} + switch { + case yaml.Unmarshal([]byte(moduleConfig), &m) == nil: + case json.Unmarshal([]byte(moduleConfig), &m) == nil: + default: + return "" + } + if m == nil { + return "" + } + v, ok := m["namespacePrefix"] + if !ok || v == nil { + return "" + } + switch t := v.(type) { + case string: + return t + default: + return strings.TrimSpace(fmt.Sprint(t)) + } +} + +// workflowsOverviewNamespaceFromModuleConfig returns namespacePrefix + "workflows" from MODULE_CONFIG. +func workflowsOverviewNamespaceFromModuleConfig(moduleConfig string) string { + return namespacePrefixFromModuleConfig(moduleConfig) + "workflows" +} + +// ResolveOverviewServiceNamespace picks the namespace Module Manager uses for in-cluster overview HTTP. +// +// Workloads parent charts (mod-workloads-*) deploy overview Services in the same namespace as Module Manager +// (--namespace / chart .Values.namespace). The catalog should list that namespace; if it still has the bare +// name "workflows" or is empty, we fall back to mmNamespace. When MODULE_CONFIG has a namespacePrefix but the +// catalog was never updated, we still support the legacy {prefix}workflows guess only if mmNamespace is empty. +// +// Other modules use the catalog value, with a legacy fix when the catalog has the bare name "workflows" but +// MODULE_CONFIG declares a non-empty namespacePrefix (sample modules in prefixed workflows namespaces). +func ResolveOverviewServiceNamespace(moduleName, catalogOverviewNamespace, moduleConfig, mmNamespace string) string { + moduleName = strings.TrimSpace(moduleName) + ns := strings.TrimSpace(catalogOverviewNamespace) + mmNamespace = strings.TrimSpace(mmNamespace) + prefix := namespacePrefixFromModuleConfig(moduleConfig) + wnsFromPrefix := prefix + "workflows" + + if strings.HasPrefix(moduleName, "workloads-") { + if ns != "" && ns != "workflows" { + return ns + } + if mmNamespace != "" { + return mmNamespace + } + if prefix != "" { + return wnsFromPrefix + } + if ns != "" { + return ns + } + return "workflows" + } + if ns == "workflows" && prefix != "" && wnsFromPrefix != ns { + return wnsFromPrefix + } + return ns +} diff --git a/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/api/overview_namespace_test.go b/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/api/overview_namespace_test.go new file mode 100644 index 00000000..9cc24587 --- /dev/null +++ b/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/api/overview_namespace_test.go @@ -0,0 +1,64 @@ +// Copyright (c) 2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +package api + +import "testing" + +func TestWorkflowsOverviewNamespaceFromModuleConfig(t *testing.T) { + t.Parallel() + if got := workflowsOverviewNamespaceFromModuleConfig(""); got != "workflows" { + t.Fatalf("empty config: got %q want workflows", got) + } + if got := workflowsOverviewNamespaceFromModuleConfig("namespacePrefix: pfx-\n"); got != "pfx-workflows" { + t.Fatalf("got %q want pfx-workflows", got) + } + if got := workflowsOverviewNamespaceFromModuleConfig(`{"namespacePrefix":"env-"}`); got != "env-workflows" { + t.Fatalf("json: got %q want env-workflows", got) + } +} + +func TestResolveOverviewServiceNamespace_workloadsCoLocatedWithMM(t *testing.T) { + t.Parallel() + // Legacy catalog still says "workflows"; Services are in module-manager. + if got := ResolveOverviewServiceNamespace("workloads-android", "workflows", "domain: x\n", "module-manager"); got != "module-manager" { + t.Fatalf("got %q want module-manager", got) + } + if got := ResolveOverviewServiceNamespace("workloads-android", "", "domain: x\n", "module-manager"); got != "module-manager" { + t.Fatalf("got %q want module-manager", got) + } + // Catalog already correct. + if got := ResolveOverviewServiceNamespace("workloads-android", "module-manager", "namespacePrefix: sbx-\n", "module-manager"); got != "module-manager" { + t.Fatalf("got %q want module-manager", got) + } +} + +func TestResolveOverviewServiceNamespace_workloadsFallbackPrefixedWorkflows(t *testing.T) { + t.Parallel() + // No mmNamespace (tests); prefix present — legacy {prefix}workflows. + if got := ResolveOverviewServiceNamespace("workloads-common", "workflows", "namespacePrefix: sbx-\n", ""); got != "sbx-workflows" { + t.Fatalf("got %q want sbx-workflows", got) + } +} + +func TestResolveOverviewServiceNamespace_nonWorkloadsUsesCatalog(t *testing.T) { + t.Parallel() + cfg := "namespacePrefix: sbx-\n" + if got := ResolveOverviewServiceNamespace("sample", "sample-module-hello", cfg, "module-manager"); got != "sample-module-hello" { + t.Fatalf("got %q", got) + } + if got := ResolveOverviewServiceNamespace("sample", "workflows", cfg, "module-manager"); got != "sbx-workflows" { + t.Fatalf("bare workflows for non-workloads: got %q want sbx-workflows", got) + } +} diff --git a/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/controller/application_helm_config.go b/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/controller/application_helm_config.go new file mode 100644 index 00000000..8a2e4eac --- /dev/null +++ b/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/controller/application_helm_config.go @@ -0,0 +1,100 @@ +// Copyright (c) 2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package controller + +import ( + "context" + "fmt" + "reflect" + "strings" + + "gopkg.in/yaml.v3" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +func mergeModuleConfigIntoHelmValuesYAML(valuesStr, moduleConfig string) (newValues string, changed bool, err error) { + moduleConfig = strings.TrimSpace(moduleConfig) + if moduleConfig == "" { + return "", false, nil + } + var cfg map[string]interface{} + if err := yaml.Unmarshal([]byte(moduleConfig), &cfg); err != nil { + return "", false, fmt.Errorf("parse MODULE_CONFIG: %w", err) + } + var root map[string]interface{} + if strings.TrimSpace(valuesStr) != "" { + if err := yaml.Unmarshal([]byte(valuesStr), &root); err != nil { + return "", false, fmt.Errorf("parse helm values: %w", err) + } + } + if root == nil { + root = make(map[string]interface{}) + } + if reflect.DeepEqual(root["config"], cfg) { + return valuesStr, false, nil + } + root["config"] = cfg + out, err := yaml.Marshal(root) + if err != nil { + return "", false, fmt.Errorf("marshal helm values: %w", err) + } + return string(out), true, nil +} + +// SyncApplicationHelmValuesConfig replaces spec.source.helm.values.config with MODULE_CONFIG (YAML), preserving +// moduleName, repo, overviewNamespace, and softFeaturesEnabled. Module Manager injects MODULE_CONFIG from the +// Deployment env; parent Applications snapshot Helm values at enable time, so this keeps config (including scm) +// aligned after GitOps upgrades without chart-side defaults or re-enabling modules. +func SyncApplicationHelmValuesConfig(ctx context.Context, writer client.Client, reader client.Reader, argoNS, appName, moduleConfig string) error { + moduleConfig = strings.TrimSpace(moduleConfig) + if moduleConfig == "" { + return nil + } + + app := &unstructured.Unstructured{} + app.SetGroupVersionKind(schema.GroupVersionKind{Group: "argoproj.io", Version: "v1alpha1", Kind: "Application"}) + key := client.ObjectKey{Namespace: argoNS, Name: appName} + if err := reader.Get(ctx, key, app); err != nil { + if errors.IsNotFound(err) { + return nil + } + return err + } + src, ok, err := unstructured.NestedMap(app.Object, "spec", "source") + if err != nil || !ok || src == nil { + return fmt.Errorf("application %s/%s: no spec.source", argoNS, appName) + } + helm, _ := src["helm"].(map[string]interface{}) + if helm == nil { + return nil + } + valuesStr, _ := helm["values"].(string) + newVals, changed, err := mergeModuleConfigIntoHelmValuesYAML(valuesStr, moduleConfig) + if err != nil { + return fmt.Errorf("merge MODULE_CONFIG into Application %s/%s: %w", argoNS, appName, err) + } + if !changed { + return nil + } + helm["values"] = newVals + src["helm"] = helm + if err := unstructured.SetNestedMap(app.Object, src, "spec", "source"); err != nil { + return err + } + return writer.Update(ctx, app) +} diff --git a/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/controller/application_helm_config_test.go b/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/controller/application_helm_config_test.go new file mode 100644 index 00000000..9818ff95 --- /dev/null +++ b/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/controller/application_helm_config_test.go @@ -0,0 +1,86 @@ +// Copyright (c) 2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package controller + +import ( + "testing" + + "gopkg.in/yaml.v3" +) + +func TestMergeModuleConfigIntoHelmValuesYAML_preservesSoftFeaturesAndRepo(t *testing.T) { + values := `moduleName: "workloads-android" +config: + namespacePrefix: "pfx-" + scm: + authMethod: userpass +repo: + url: "https://repo" + revision: "HEAD" +softFeaturesEnabled: + sample-soft: true +` + moduleCfg := `namespacePrefix: "pfx-" +scm: + authMethod: app +domain: example.com +` + got, changed, err := mergeModuleConfigIntoHelmValuesYAML(values, moduleCfg) + if err != nil { + t.Fatal(err) + } + if !changed { + t.Fatal("expected merge to change values") + } + var root map[string]interface{} + if err := yaml.Unmarshal([]byte(got), &root); err != nil { + t.Fatal(err) + } + cfg, ok := root["config"].(map[string]interface{}) + if !ok { + t.Fatalf("missing config: %s", got) + } + if cfg["domain"] != "example.com" { + t.Fatalf("expected domain in config, got %+v", cfg) + } + m, ok := cfg["scm"].(map[string]interface{}) + if !ok || m["authMethod"] != "app" { + t.Fatalf("expected scm.authMethod app, got %+v", cfg) + } + if _, ok := root["softFeaturesEnabled"]; !ok { + t.Fatal("lost softFeaturesEnabled") + } + if root["repo"] == nil { + t.Fatal("lost repo") + } +} + +func TestMergeModuleConfigIntoHelmValuesYAML_noOpWhenEqual(t *testing.T) { + y := `config: + scm: + authMethod: pat +moduleName: m +` + got, changed, err := mergeModuleConfigIntoHelmValuesYAML(y, "scm:\n authMethod: pat\n") + if err != nil { + t.Fatal(err) + } + if changed { + t.Fatalf("unexpected change, got %q", got) + } + if got != y { + t.Fatalf("expected unchanged body, got %q", got) + } +} diff --git a/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/controller/argo_naming.go b/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/controller/argo_naming.go new file mode 100644 index 00000000..b70da060 --- /dev/null +++ b/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/controller/argo_naming.go @@ -0,0 +1,32 @@ +// Copyright (c) 2024-2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + +package controller + +import "strings" + +// ArgoCDConfig holds ArgoCD connection and naming config used by the REST handler and by +// module enable/disable paths to build Applications deterministically from catalog module +// names. +type ArgoCDConfig struct { + Namespace string + Project string + RepoURL string + Revision string +} + +// ApplicationName returns the ArgoCD Application name for a module (e.g. mod-sample-module). +// The mapping is deterministic: module names with underscores are normalised to hyphens to +// match Kubernetes object naming rules. +func ApplicationName(moduleName string) string { + return "mod-" + strings.ReplaceAll(moduleName, "_", "-") +} diff --git a/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/controller/catalog.go b/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/controller/catalog.go new file mode 100644 index 00000000..b5300283 --- /dev/null +++ b/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/controller/catalog.go @@ -0,0 +1,105 @@ +// Copyright (c) 2024-2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package controller + +import ( + "context" + "encoding/json" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +const ( + catalogKey = "modules" +) + +// CatalogApplication is metadata for a module child application (from ModuleCatalog). +type CatalogApplication struct { + ID string `json:"id"` + Title string `json:"title,omitempty"` + URL string `json:"url"` +} + +// CatalogEntry describes a module in the catalog (name, path, optional dependencies). +type CatalogEntry struct { + Name string `json:"name"` + Path string `json:"path"` + OverviewPath string `json:"overviewPath,omitempty"` + OverviewService string `json:"overviewService,omitempty"` + OverviewServiceNamespace string `json:"overviewServiceNamespace,omitempty"` + HardDependencies []string `json:"hardDependencies,omitempty"` + SoftDependencies []string `json:"softDependencies,omitempty"` + Applications []CatalogApplication `json:"applications,omitempty"` + SoftFeaturesPropagation string `json:"softFeaturesPropagation,omitempty"` + SoftFeaturesConfigMapNamespaces []string `json:"softFeaturesConfigMapNamespaces,omitempty"` + // AutoDisableWhenUnused allows Module Manager to automatically disable this module + // when both hard and soft dependents drop to zero. + AutoDisableWhenUnused bool `json:"autoDisableWhenUnused,omitempty"` +} + +// CatalogStoreInterface is implemented by CatalogStore (ConfigMap) and CatalogStoreCR (CRD). +type CatalogStoreInterface interface { + List(ctx context.Context) ([]CatalogEntry, error) + GetPath(ctx context.Context, moduleName string) (string, error) +} + +// CatalogStore reads the module catalog from a ConfigMap (deprecated: use CatalogStoreCR). +type CatalogStore struct { + client client.Client + ns string + name string +} + +// NewCatalogStore returns a CatalogStore that reads from the given ConfigMap. +func NewCatalogStore(c client.Client, namespace, configMapName string) *CatalogStore { + return &CatalogStore{client: c, ns: namespace, name: configMapName} +} + +// List returns all catalog entries (name -> path). +func (c *CatalogStore) List(ctx context.Context) ([]CatalogEntry, error) { + cm := &corev1.ConfigMap{} + err := c.client.Get(ctx, client.ObjectKey{Namespace: c.ns, Name: c.name}, cm) + if err != nil { + if apierrors.IsNotFound(err) { + return nil, nil + } + return nil, err + } + raw, ok := cm.Data[catalogKey] + if !ok || raw == "" { + return nil, nil + } + var entries []CatalogEntry + if err := json.Unmarshal([]byte(raw), &entries); err != nil { + return nil, err + } + return entries, nil +} + +// GetPath returns the repo path for a module name, or empty string if not in catalog. +func (c *CatalogStore) GetPath(ctx context.Context, moduleName string) (string, error) { + entries, err := c.List(ctx) + if err != nil { + return "", err + } + for _, e := range entries { + if e.Name == moduleName { + return e.Path, nil + } + } + return "", nil +} diff --git a/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/controller/catalog_cr.go b/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/controller/catalog_cr.go new file mode 100644 index 00000000..8b253c90 --- /dev/null +++ b/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/controller/catalog_cr.go @@ -0,0 +1,105 @@ +// Copyright (c) 2024-2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package controller + +import ( + "context" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + "sigs.k8s.io/controller-runtime/pkg/client" + + horizonv1alpha1 "github.com/acn-horizon-sdv/module-manager/api/v1alpha1" +) + +// DefaultModuleCatalogName is the name of the singleton ModuleCatalog CR. +const DefaultModuleCatalogName = "cluster" + +// CatalogStoreCR reads the module catalog from a ModuleCatalog custom resource. +type CatalogStoreCR struct { + client client.Client + ns string + name string +} + +// NewCatalogStoreCR returns a CatalogStore that reads from the given ModuleCatalog CR (singleton per namespace). +func NewCatalogStoreCR(c client.Client, namespace, catalogCRName string) *CatalogStoreCR { + if catalogCRName == "" { + catalogCRName = DefaultModuleCatalogName + } + return &CatalogStoreCR{client: c, ns: namespace, name: catalogCRName} +} + +// List returns all catalog entries from the ModuleCatalog CR. +func (c *CatalogStoreCR) List(ctx context.Context) ([]CatalogEntry, error) { + obj := &horizonv1alpha1.ModuleCatalog{} + err := c.client.Get(ctx, client.ObjectKey{Namespace: c.ns, Name: c.name}, obj) + if err != nil { + if apierrors.IsNotFound(err) { + return nil, nil + } + return nil, err + } + entries := make([]CatalogEntry, 0, len(obj.Spec.Modules)) + for _, m := range obj.Spec.Modules { + e := CatalogEntry{ + Name: m.Name, + Path: m.Path, + OverviewPath: m.OverviewPath, + OverviewService: m.OverviewService, + OverviewServiceNamespace: m.OverviewServiceNamespace, + } + if len(m.HardDependencies) > 0 { + e.HardDependencies = make([]string, len(m.HardDependencies)) + copy(e.HardDependencies, m.HardDependencies) + } + if len(m.SoftDependencies) > 0 { + e.SoftDependencies = make([]string, len(m.SoftDependencies)) + copy(e.SoftDependencies, m.SoftDependencies) + } + if len(m.Applications) > 0 { + e.Applications = make([]CatalogApplication, 0, len(m.Applications)) + for _, a := range m.Applications { + if a.ID == "" || a.URL == "" { + continue + } + e.Applications = append(e.Applications, CatalogApplication{ID: a.ID, Title: a.Title, URL: a.URL}) + } + if len(e.Applications) == 0 { + e.Applications = nil + } + } + e.SoftFeaturesPropagation = m.SoftFeaturesPropagation + if len(m.SoftFeaturesConfigMapNamespaces) > 0 { + e.SoftFeaturesConfigMapNamespaces = append([]string(nil), m.SoftFeaturesConfigMapNamespaces...) + } + e.AutoDisableWhenUnused = m.AutoDisableWhenUnused + entries = append(entries, e) + } + return entries, nil +} + +// GetPath returns the repo path for a module name, or empty string if not in catalog. +func (c *CatalogStoreCR) GetPath(ctx context.Context, moduleName string) (string, error) { + entries, err := c.List(ctx) + if err != nil { + return "", err + } + for _, e := range entries { + if e.Name == moduleName { + return e.Path, nil + } + } + return "", nil +} diff --git a/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/controller/catalog_reconciler.go b/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/controller/catalog_reconciler.go new file mode 100644 index 00000000..f38f5cdb --- /dev/null +++ b/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/controller/catalog_reconciler.go @@ -0,0 +1,58 @@ +// Copyright (c) 2024-2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package controller + +import ( + "context" + + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log" + + horizonv1alpha1 "github.com/acn-horizon-sdv/module-manager/api/v1alpha1" +) + +// ModuleCatalogReconciler runs the auto-disable sweep and soft-feature sync whenever the +// ModuleCatalog spec changes (dependency wiring or autoDisableWhenUnused edits). +type ModuleCatalogReconciler struct { + client.Client + APIReader client.Reader + Namespace string + ArgoCDNamespace string + StateStore StateStoreInterface + CatalogStore CatalogStoreInterface +} + +// Reconcile runs after ModuleCatalog spec changes. +func (r *ModuleCatalogReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + logger := log.FromContext(ctx) + ModuleOpsMutex.Lock() + defer ModuleOpsMutex.Unlock() + if err := RunAutoDisableSweep(ctx, r.APIReader, r.Client, r.Namespace, r.ArgoCDNamespace, r.StateStore, r.CatalogStore); err != nil { + return ctrl.Result{}, err + } + if err := SyncSoftFeaturesForAllEnabledModulesWithSoftDeps(ctx, r.APIReader, r.Client, r.ArgoCDNamespace, r.Namespace, r.StateStore, r.CatalogStore); err != nil { + logger.Error(err, "sync soft features after ModuleCatalog change") + return ctrl.Result{}, err + } + return ctrl.Result{}, nil +} + +// SetupWithManager registers the reconciler. +func (r *ModuleCatalogReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&horizonv1alpha1.ModuleCatalog{}). + Complete(r) +} diff --git a/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/controller/dependents.go b/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/controller/dependents.go new file mode 100644 index 00000000..c2715633 --- /dev/null +++ b/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/controller/dependents.go @@ -0,0 +1,137 @@ +// Copyright (c) 2024-2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package controller + +import ( + "context" + "errors" + "sort" + + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log" +) + +// autoDisableMaxWaves caps cascade sweeps to avoid pathological loops; practical cascades are <=2. +const autoDisableMaxWaves = 8 + +// RunAutoDisableSweep performs a catalog-driven auto-disable sweep: any enabled catalog +// entry with autoDisableWhenUnused=true and zero enabled hard+soft dependents is disabled. +// The sweep cascades until steady state (no more candidates disable in a wave). +func RunAutoDisableSweep(ctx context.Context, apiReader client.Reader, c client.Client, mmNamespace, argocdNamespace string, stateStore StateStoreInterface, catalogStore CatalogStoreInterface) error { + _, err := runAutoDisableSweep(ctx, apiReader, c, mmNamespace, argocdNamespace, stateStore, catalogStore) + return err +} + +// runAutoDisableSweep iteratively disables unused opted-in modules until none qualify. +// Returns the list of modules disabled during the sweep (across all waves). +func runAutoDisableSweep(ctx context.Context, apiReader client.Reader, c client.Client, mmNamespace, argocdNamespace string, stateStore StateStoreInterface, catalogStore CatalogStoreInterface) ([]string, error) { + logger := log.FromContext(ctx) + var allDisabled []string + var waveErrs []error + for wave := 0; wave < autoDisableMaxWaves; wave++ { + disabled, err := autoDisableOneWave(ctx, apiReader, c, mmNamespace, argocdNamespace, stateStore, catalogStore) + if err != nil { + waveErrs = append(waveErrs, err) + } + if len(disabled) == 0 { + break + } + allDisabled = append(allDisabled, disabled...) + } + if len(allDisabled) > 0 { + logger.Info("auto-disable sweep complete", "disabledModules", allDisabled) + } + if len(waveErrs) > 0 { + return allDisabled, errors.Join(waveErrs...) + } + return allDisabled, nil +} + +// autoDisableOneWave runs a single sweep over the catalog, disabling each eligible module. +// Eligibility: entry.AutoDisableWhenUnused=true, module currently enabled, zero enabled +// hard and soft dependents. Returns names disabled this wave, sorted. +func autoDisableOneWave(ctx context.Context, apiReader client.Reader, c client.Client, mmNamespace, argocdNamespace string, stateStore StateStoreInterface, catalogStore CatalogStoreInterface) ([]string, error) { + logger := log.FromContext(ctx) + state, err := stateStore.Get(ctx) + if err != nil { + return nil, err + } + entries, err := catalogStore.List(ctx) + if err != nil { + return nil, err + } + enabledSet := make(map[string]bool, len(state.EnabledModules)) + for _, id := range state.EnabledModules { + enabledSet[id] = true + } + index := ComputeDependentsFromCatalog(entries, state) + + type candidate struct { + name string + moduleID string + } + var candidates []candidate + for i := range entries { + e := &entries[i] + if !e.AutoDisableWhenUnused { + continue + } + id := state.ModuleIDs[e.Name] + if id == "" || !enabledSet[id] { + continue + } + rd := index[e.Name] + if rd != nil && (len(rd.Hard) > 0 || len(rd.Soft) > 0) { + continue + } + candidates = append(candidates, candidate{name: e.Name, moduleID: id}) + } + if len(candidates) == 0 { + return nil, nil + } + sort.Slice(candidates, func(i, j int) bool { return candidates[i].name < candidates[j].name }) + + var disabled []string + var candErrs []error + for _, cand := range candidates { + state, err := stateStore.Get(ctx) + if err != nil { + return disabled, err + } + edges, err := ListEnabledReverseDependents(ctx, c, catalogStore, mmNamespace, state, cand.name) + if err != nil { + return disabled, err + } + if len(edges.Hard) > 0 || len(edges.Soft) > 0 { + logger.Info("skipping auto-disable because dependents reappeared", "module", cand.name, "hardDependents", edges.Hard, "softDependents", edges.Soft) + continue + } + logger.Info("attempting auto-disable", "module", cand.name, "moduleID", cand.moduleID) + if err := PerformModuleDisable(ctx, c, stateStore, catalogStore, argocdNamespace, mmNamespace, cand.name, cand.moduleID, false); err != nil { + logger.Error(err, "auto-disable failed", "module", cand.name, "moduleID", cand.moduleID) + candErrs = append(candErrs, err) + continue + } + disabled = append(disabled, cand.name) + if err := ResyncSoftFeaturesForParentsOfSoftDep(ctx, apiReader, c, argocdNamespace, mmNamespace, stateStore, catalogStore, cand.name); err != nil { + logger.Error(err, "resync soft features after auto-disable", "module", cand.name) + candErrs = append(candErrs, err) + } + } + if len(candErrs) > 0 { + return disabled, errors.Join(candErrs...) + } + return disabled, nil +} diff --git a/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/controller/kcc_drain.go b/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/controller/kcc_drain.go new file mode 100644 index 00000000..a3e387a1 --- /dev/null +++ b/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/controller/kcc_drain.go @@ -0,0 +1,129 @@ +// Copyright (c) 2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package controller + +import ( + "context" + "strings" + + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/discovery" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log" +) + +// Ensure the real discovery.DiscoveryClient satisfies our narrow APIGroupLister interface. +var _ APIGroupLister = (*discovery.DiscoveryClient)(nil) + +const kccFinalizer = "cnrm.cloud.google.com/finalizer" + +// StripKCCFinalizersInNamespaces discovers every API group whose name ends in +// ".cnrm.cloud.google.com", lists all objects of each resource in the given namespaces, +// and removes cnrm.cloud.google.com/finalizer from any object that has a non-zero +// deletionTimestamp. All other finalizers are preserved. +// +// This is used as a defensive fallback during platform drain: once KCC has lost its +// GCP workload-identity token (because Terraform is tearing down IAM bindings in +// parallel), KCC's delete reconcile loop can never succeed. The underlying GCP resource +// (PubSub topic, etc.) is owned by the GCP project and is cleaned up when the project +// itself is destroyed. Removing the Kubernetes finalizer unblocks namespace termination +// and allows ArgoCD's cascade to complete. +// +// Errors on individual GVKs are logged and skipped so that one unlistable CRD does not +// abort the whole sweep. +func (p *PlatformDrainer) StripKCCFinalizersInNamespaces(ctx context.Context, namespaces []string) error { + if len(namespaces) == 0 || p.DiscoveryClient == nil { + return nil + } + logger := log.FromContext(ctx).WithName("kcc-drain") + + _, apiResourceLists, err := p.DiscoveryClient.ServerGroupsAndResources() + if err != nil && !discovery.IsGroupDiscoveryFailedError(err) { + return err + } + + for _, rl := range apiResourceLists { + gv, parseErr := schema.ParseGroupVersion(rl.GroupVersion) + if parseErr != nil { + continue + } + if !strings.HasSuffix(gv.Group, ".cnrm.cloud.google.com") { + continue + } + + for i := range rl.APIResources { + res := &rl.APIResources[i] + // Skip sub-resources (e.g. "pubsubtopics/status") and non-namespaced resources. + if strings.Contains(res.Name, "/") || !res.Namespaced { + continue + } + // Only process resources that support list and patch. + if !resourceSupportsVerb(res.Verbs, "list") || !resourceSupportsVerb(res.Verbs, "patch") { + continue + } + + gvk := schema.GroupVersionKind{Group: gv.Group, Version: gv.Version, Kind: res.Kind} + + for _, ns := range namespaces { + ul := &unstructured.UnstructuredList{} + ul.SetGroupVersionKind(schema.GroupVersionKind{ + Group: gv.Group, + Version: gv.Version, + Kind: res.Kind + "List", + }) + if listErr := p.APIReader.List(ctx, ul, client.InNamespace(ns)); listErr != nil { + if !errors.IsNotFound(listErr) { + logger.V(1).Info("list KCC resource (skipping)", "gvk", gvk, "namespace", ns, "err", listErr) + } + continue + } + for j := range ul.Items { + item := &ul.Items[j] + if item.GetDeletionTimestamp().IsZero() { + continue + } + finalizers := item.GetFinalizers() + var kept []string + for _, f := range finalizers { + if f != kccFinalizer { + kept = append(kept, f) + } + } + if len(kept) == len(finalizers) { + continue + } + item.SetFinalizers(kept) + if patchErr := p.Client.Update(ctx, item); patchErr != nil && !errors.IsNotFound(patchErr) { + logger.Error(patchErr, "strip KCC finalizer", "gvk", gvk, "namespace", ns, "name", item.GetName()) + } else { + logger.Info("stripped KCC finalizer", "gvk", gvk, "namespace", ns, "name", item.GetName()) + } + } + } + } + } + return nil +} + +func resourceSupportsVerb(verbs []string, verb string) bool { + for _, v := range verbs { + if v == verb { + return true + } + } + return false +} diff --git a/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/controller/kcc_drain_test.go b/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/controller/kcc_drain_test.go new file mode 100644 index 00000000..b2900b18 --- /dev/null +++ b/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/controller/kcc_drain_test.go @@ -0,0 +1,170 @@ +// Copyright (c) 2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package controller + +import ( + "context" + "testing" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +// stubAPIGroupLister implements the narrow APIGroupLister interface. +type stubAPIGroupLister struct { + resources []*metav1.APIResourceList +} + +func (s *stubAPIGroupLister) ServerGroupsAndResources() ([]*metav1.APIGroup, []*metav1.APIResourceList, error) { + return nil, s.resources, nil +} + +// newKCCFakeClient builds a fake client registered for a KCC-like GVK. +func newKCCFakeClient(gvk schema.GroupVersionKind, objs ...client.Object) client.Client { + s := runtime.NewScheme() + s.AddKnownTypeWithName(gvk, &unstructured.Unstructured{}) + s.AddKnownTypeWithName( + schema.GroupVersionKind{Group: gvk.Group, Version: gvk.Version, Kind: gvk.Kind + "List"}, + &unstructured.UnstructuredList{}) + return fake.NewClientBuilder().WithScheme(s).WithObjects(objs...).Build() +} + +func kccObj(gvk schema.GroupVersionKind, ns, name string, finalizers []string, deleting bool) *unstructured.Unstructured { + u := &unstructured.Unstructured{} + u.SetGroupVersionKind(gvk) + u.SetNamespace(ns) + u.SetName(name) + u.SetFinalizers(finalizers) + if deleting { + t := metav1.NewTime(time.Now()) + u.SetDeletionTimestamp(&t) + } + return u +} + +func TestStripKCCFinalizers_RemovesKCCFinalizerOnDeletingObject(t *testing.T) { + pubsubGVK := schema.GroupVersionKind{Group: "pubsub.cnrm.cloud.google.com", Version: "v1beta1", Kind: "PubSubTopic"} + obj := kccObj(pubsubGVK, "sample-module-hello", "sample-events", + []string{kccFinalizer, "some.other/finalizer"}, true) + + c := newKCCFakeClient(pubsubGVK, obj) + disc := &stubAPIGroupLister{ + resources: []*metav1.APIResourceList{ + { + GroupVersion: "pubsub.cnrm.cloud.google.com/v1beta1", + APIResources: []metav1.APIResource{ + {Name: "pubsubtopics", Kind: "PubSubTopic", Namespaced: true, Verbs: []string{"list", "patch", "get"}}, + }, + }, + }, + } + + d := &PlatformDrainer{Client: c, APIReader: c, DiscoveryClient: disc} + if err := d.StripKCCFinalizersInNamespaces(context.Background(), []string{"sample-module-hello"}); err != nil { + t.Fatalf("StripKCCFinalizersInNamespaces: %v", err) + } + + got := &unstructured.Unstructured{} + got.SetGroupVersionKind(pubsubGVK) + if err := c.Get(context.Background(), client.ObjectKey{Namespace: "sample-module-hello", Name: "sample-events"}, got); err != nil { + // Object was garbage-collected (last finalizer removed with deletionTimestamp set). Acceptable. + return + } + for _, f := range got.GetFinalizers() { + if f == kccFinalizer { + t.Fatalf("kcc finalizer should have been removed, got %v", got.GetFinalizers()) + } + } +} + +func TestStripKCCFinalizers_DoesNothingForNonDeletingObject(t *testing.T) { + pubsubGVK := schema.GroupVersionKind{Group: "pubsub.cnrm.cloud.google.com", Version: "v1beta1", Kind: "PubSubTopic"} + obj := kccObj(pubsubGVK, "sample-module-hello", "sample-events", + []string{kccFinalizer}, false) // no deletionTimestamp + + c := newKCCFakeClient(pubsubGVK, obj) + disc := &stubAPIGroupLister{ + resources: []*metav1.APIResourceList{ + { + GroupVersion: "pubsub.cnrm.cloud.google.com/v1beta1", + APIResources: []metav1.APIResource{ + {Name: "pubsubtopics", Kind: "PubSubTopic", Namespaced: true, Verbs: []string{"list", "patch", "get"}}, + }, + }, + }, + } + + d := &PlatformDrainer{Client: c, APIReader: c, DiscoveryClient: disc} + if err := d.StripKCCFinalizersInNamespaces(context.Background(), []string{"sample-module-hello"}); err != nil { + t.Fatalf("StripKCCFinalizersInNamespaces: %v", err) + } + + got := &unstructured.Unstructured{} + got.SetGroupVersionKind(pubsubGVK) + if err := c.Get(context.Background(), client.ObjectKey{Namespace: "sample-module-hello", Name: "sample-events"}, got); err != nil { + t.Fatalf("get object: %v", err) + } + finalizers := got.GetFinalizers() + if len(finalizers) != 1 || finalizers[0] != kccFinalizer { + t.Fatalf("finalizers should be untouched for non-deleting object, got %v", finalizers) + } +} + +func TestStripKCCFinalizers_SkipsNonKCCGroups(t *testing.T) { + appGVK := schema.GroupVersionKind{Group: "argoproj.io", Version: "v1alpha1", Kind: "Application"} + s := runtime.NewScheme() + s.AddKnownTypeWithName(appGVK, &unstructured.Unstructured{}) + s.AddKnownTypeWithName(schema.GroupVersionKind{Group: "argoproj.io", Version: "v1alpha1", Kind: "ApplicationList"}, &unstructured.UnstructuredList{}) + obj := kccObj(appGVK, "argocd", "mod-sample", []string{kccFinalizer}, true) + c := fake.NewClientBuilder().WithScheme(s).WithObjects(obj).Build() + + disc := &stubAPIGroupLister{ + resources: []*metav1.APIResourceList{ + { + GroupVersion: "argoproj.io/v1alpha1", + APIResources: []metav1.APIResource{ + {Name: "applications", Kind: "Application", Namespaced: true, Verbs: []string{"list", "patch", "get"}}, + }, + }, + }, + } + + d := &PlatformDrainer{Client: c, APIReader: c, DiscoveryClient: disc} + if err := d.StripKCCFinalizersInNamespaces(context.Background(), []string{"argocd"}); err != nil { + t.Fatalf("StripKCCFinalizersInNamespaces: %v", err) + } + + got := &unstructured.Unstructured{} + got.SetGroupVersionKind(appGVK) + if err := c.Get(context.Background(), client.ObjectKey{Namespace: "argocd", Name: "mod-sample"}, got); err != nil { + return // garbage collected + } + finalizers := got.GetFinalizers() + if len(finalizers) != 1 || finalizers[0] != kccFinalizer { + t.Fatalf("non-KCC group should not be touched, got %v", finalizers) + } +} + +func TestStripKCCFinalizers_NoopWhenDiscoveryIsNil(t *testing.T) { + d := &PlatformDrainer{DiscoveryClient: nil} + if err := d.StripKCCFinalizersInNamespaces(context.Background(), []string{"any-ns"}); err != nil { + t.Fatalf("should be a noop when DiscoveryClient is nil, got: %v", err) + } +} diff --git a/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/controller/module_config_helm_startup.go b/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/controller/module_config_helm_startup.go new file mode 100644 index 00000000..852b9bc1 --- /dev/null +++ b/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/controller/module_config_helm_startup.go @@ -0,0 +1,64 @@ +// Copyright (c) 2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package controller + +import ( + "context" + "fmt" + "strings" + "time" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// ModuleConfigHelmStartupSync runs once after cache warm-up and patches every parent module Argo CD Application so +// spec.source.helm.values.config matches the current MODULE_CONFIG env. Rolling the module-manager Deployment thus +// updates enabled modules without re-enable or target-revision API calls. +type ModuleConfigHelmStartupSync struct { + Client client.Client + APIReader client.Reader + ArgoNS string + ModuleConfig string +} + +func (s *ModuleConfigHelmStartupSync) Start(ctx context.Context) error { + cfg := strings.TrimSpace(s.ModuleConfig) + if cfg == "" { + return nil + } + select { + case <-ctx.Done(): + return nil + case <-time.After(5 * time.Second): + } + + ul := &unstructured.UnstructuredList{} + ul.SetGroupVersionKind(schema.GroupVersionKind{Group: "argoproj.io", Version: "v1alpha1", Kind: "ApplicationList"}) + if err := s.APIReader.List(ctx, ul, + client.InNamespace(s.ArgoNS), + client.MatchingLabels{"horizon-sdv.io/module-manager-managed": "true"}, + ); err != nil { + return fmt.Errorf("list module-manager-managed Applications: %w", err) + } + for i := range ul.Items { + name := ul.Items[i].GetName() + if err := SyncApplicationHelmValuesConfig(ctx, s.Client, s.APIReader, s.ArgoNS, name, cfg); err != nil { + return fmt.Errorf("sync MODULE_CONFIG into Application %q: %w", name, err) + } + } + return nil +} diff --git a/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/controller/module_disable.go b/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/controller/module_disable.go new file mode 100644 index 00000000..5d676153 --- /dev/null +++ b/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/controller/module_disable.go @@ -0,0 +1,96 @@ +// Copyright (c) 2024-2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package controller + +import ( + "context" + "fmt" + + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log" +) + +// PerformModuleDisable deletes the Argo CD Application and updates ModuleManagerState to +// reflect a disabled module. It does not refresh dependents or resync soft features; +// callers should invoke RunAutoDisableSweep and ResyncSoftFeaturesForParentsOfSoftDep +// after disable side effects as appropriate. +// +// When enforceNoHardDependents is true, ListHardDependents must be empty or the call fails. +// REST disable sets this to true. Auto-disable sets it to false because eligibility is +// determined by hard- and soft-dependent checks before this runs. +func PerformModuleDisable(ctx context.Context, c client.Client, stateStore StateStoreInterface, catalogStore CatalogStoreInterface, argocdNamespace, mmNamespace string, moduleName, moduleID string, enforceNoHardDependents bool) error { + logger := log.FromContext(ctx) + if moduleName == "" || moduleID == "" { + return fmt.Errorf("perform module disable: module name and module id are required") + } + + state, err := stateStore.Get(ctx) + if err != nil { + return err + } + if enforceNoHardDependents { + deps, err := ListHardDependents(ctx, c, catalogStore, mmNamespace, state, moduleName) + if err != nil { + return err + } + if len(deps) > 0 { + return fmt.Errorf("perform module disable: module %q still has hard dependents %v", moduleName, deps) + } + } + + appName := ApplicationName(moduleName) + app := &unstructured.Unstructured{} + app.SetGroupVersionKind(schema.GroupVersionKind{Group: "argoproj.io", Version: "v1alpha1", Kind: "Application"}) + app.SetNamespace(argocdNamespace) + app.SetName(appName) + logger.Info("disabling module", "module", moduleName, "moduleID", moduleID, "application", appName, "enforceNoHardDependents", enforceNoHardDependents) + if err := c.Delete(ctx, app); err != nil && !errors.IsNotFound(err) { + return err + } + + var newEnabled []string + for _, id := range state.EnabledModules { + if id != moduleID { + newEnabled = append(newEnabled, id) + } + } + state.EnabledModules = newEnabled + if state.ModuleTargetRevisions != nil { + delete(state.ModuleTargetRevisions, moduleName) + } + if err := stateStore.Update(ctx, state); err != nil { + return err + } + _ = mmNamespace + return nil +} + +// DisableModuleAndRefresh performs the shared disable workflow used by the REST API: +// disable the module, refresh dependents, then resync soft-feature parents. +func DisableModuleAndRefresh(ctx context.Context, apiReader client.Reader, c client.Client, stateStore StateStoreInterface, catalogStore CatalogStoreInterface, argocdNamespace, mmNamespace, moduleName, moduleID string, enforceNoHardDependents bool) error { + if err := PerformModuleDisable(ctx, c, stateStore, catalogStore, argocdNamespace, mmNamespace, moduleName, moduleID, enforceNoHardDependents); err != nil { + return err + } + if err := RunAutoDisableSweep(ctx, apiReader, c, mmNamespace, argocdNamespace, stateStore, catalogStore); err != nil { + return fmt.Errorf("refresh dependents after disabling %q: %w", moduleName, err) + } + if err := ResyncSoftFeaturesForParentsOfSoftDep(ctx, apiReader, c, argocdNamespace, mmNamespace, stateStore, catalogStore, moduleName); err != nil { + return fmt.Errorf("resync soft features after disabling %q: %w", moduleName, err) + } + return nil +} diff --git a/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/controller/platform_drain.go b/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/controller/platform_drain.go new file mode 100644 index 00000000..6b857d1c --- /dev/null +++ b/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/controller/platform_drain.go @@ -0,0 +1,218 @@ +// Copyright (c) 2024-2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package controller + +import ( + "context" + "fmt" + "sort" + + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// PlatformDrainFinalizer blocks root horizon-sdv Application deletion until module apps are drained. +const PlatformDrainFinalizer = "horizon-sdv.io/module-manager-platform-drain" + +// APIGroupLister is the narrow interface from k8s.io/client-go/discovery used by the KCC +// finalizer stripper. Using a narrow interface here keeps the production code testable +// without a full discovery.DiscoveryInterface mock. +type APIGroupLister interface { + ServerGroupsAndResources() ([]*metav1.APIGroup, []*metav1.APIResourceList, error) +} + +// ModuleManagerManagedLabelKey identifies Applications created by Module Manager for teardown. +const ModuleManagerManagedLabelKey = "horizon-sdv.io/module-manager-managed" + +// PlatformDrainer performs ordered disable of all modules (root Application finalizer path). +type PlatformDrainer struct { + Client client.Client + APIReader client.Reader + DiscoveryClient APIGroupLister + StateStore StateStoreInterface + CatalogStore CatalogStoreInterface + Namespace string + ArgoCDNamespace string + ArgoCDProject string +} + +// DrainAllEnabledModules deletes module Applications in safe order (dependents before dependencies), +// then strips KCC finalizers in destination namespaces so that KCC-managed resources do not block +// namespace termination during platform destroy. +func (p *PlatformDrainer) DrainAllEnabledModules(ctx context.Context) error { + for { + state, err := p.StateStore.Get(ctx) + if err != nil { + return err + } + enabledSet := make(map[string]bool) + for _, id := range state.EnabledModules { + enabledSet[id] = true + } + if len(state.EnabledModules) == 0 { + break + } + idToName := make(map[string]string) + for n, id := range state.ModuleIDs { + idToName[id] = n + } + var candidates []string + for _, id := range state.EnabledModules { + if n := idToName[id]; n != "" { + candidates = append(candidates, n) + } + } + sort.Strings(candidates) + + var next string + for _, name := range candidates { + if p.allHardDepsDisabledOrAbsent(ctx, name, enabledSet, state) { + next = name + break + } + } + if next == "" { + return fmt.Errorf("platform drain: no module eligible to disable (cycle or state mismatch)") + } + if err := p.disableOne(ctx, next); err != nil { + return err + } + } + + // After all parent Applications have been deleted, proactively strip KCC finalizers in + // module destination namespaces so that KCC-managed resources (e.g. PubSubTopic) do not + // block namespace termination when KCC has lost GCP authentication. + nss := p.ManagedDestinationNamespaces(ctx, p.ArgoCDNamespace) + return p.StripKCCFinalizersInNamespaces(ctx, nss) +} + +func (p *PlatformDrainer) allHardDepsDisabledOrAbsent(ctx context.Context, moduleName string, enabledSet map[string]bool, state *State) bool { + hard := p.hardDepsForModule(ctx, moduleName) + for _, dep := range hard { + depID := state.ModuleIDs[dep] + if depID != "" && enabledSet[depID] { + return false + } + } + return true +} + +func (p *PlatformDrainer) hardDepsForModule(ctx context.Context, moduleName string) []string { + entries, _ := p.CatalogStore.List(ctx) + for i := range entries { + if entries[i].Name == moduleName { + return append([]string(nil), entries[i].HardDependencies...) + } + } + return nil +} + +func (p *PlatformDrainer) disableOne(ctx context.Context, moduleName string) error { + state, err := p.StateStore.Get(ctx) + if err != nil { + return err + } + modID := state.ModuleIDs[moduleName] + enabledSet := make(map[string]bool) + for _, id := range state.EnabledModules { + enabledSet[id] = true + } + if modID == "" || !enabledSet[modID] { + return nil + } + + appName := ApplicationName(moduleName) + app := &unstructured.Unstructured{} + app.SetGroupVersionKind(schema.GroupVersionKind{Group: "argoproj.io", Version: "v1alpha1", Kind: "Application"}) + app.SetNamespace(p.ArgoCDNamespace) + app.SetName(appName) + if err := p.Client.Delete(ctx, app); err != nil && !errors.IsNotFound(err) { + return err + } + var newEnabled []string + for _, id := range state.EnabledModules { + if id != modID { + newEnabled = append(newEnabled, id) + } + } + state.EnabledModules = newEnabled + if err := p.StateStore.Update(ctx, state); err != nil { + return err + } + _ = ResyncSoftFeaturesForParentsOfSoftDep(ctx, p.APIReader, p.Client, p.ArgoCDNamespace, p.Namespace, p.StateStore, p.CatalogStore, moduleName) + return nil +} + +// ManagedModuleApplicationsRemaining returns true if any labeled module Application still exists. +func (p *PlatformDrainer) ManagedModuleApplicationsRemaining(ctx context.Context, argoCDNamespace string) (bool, error) { + ul := &unstructured.UnstructuredList{} + ul.SetGroupVersionKind(schema.GroupVersionKind{Group: "argoproj.io", Version: "v1alpha1", Kind: "ApplicationList"}) + if err := p.APIReader.List(ctx, ul, + client.InNamespace(argoCDNamespace), + client.MatchingLabels{ModuleManagerManagedLabelKey: "true"}); err != nil { + return false, err + } + return len(ul.Items) > 0, nil +} + +// ManagedDestinationNamespaces returns the unique set of spec.destination.namespace values +// across all module-manager-managed Applications in argoCDNamespace. Used to scope the KCC +// finalizer stripper to only namespaces owned by module-manager. +func (p *PlatformDrainer) ManagedDestinationNamespaces(ctx context.Context, argoCDNamespace string) []string { + ul := &unstructured.UnstructuredList{} + ul.SetGroupVersionKind(schema.GroupVersionKind{Group: "argoproj.io", Version: "v1alpha1", Kind: "ApplicationList"}) + if err := p.APIReader.List(ctx, ul, + client.InNamespace(argoCDNamespace), + client.MatchingLabels{ModuleManagerManagedLabelKey: "true"}); err != nil { + return nil + } + seen := map[string]struct{}{} + for i := range ul.Items { + ns, _, _ := unstructured.NestedString(ul.Items[i].Object, "spec", "destination", "namespace") + if ns != "" { + seen[ns] = struct{}{} + } + } + out := make([]string, 0, len(seen)) + for ns := range seen { + out = append(out, ns) + } + return out +} + +// RemovePlatformDrainFinalizer removes only Module Manager's finalizer from the root Application. +func (p *PlatformDrainer) RemovePlatformDrainFinalizer(ctx context.Context, ns, name string) error { + app := &unstructured.Unstructured{} + app.SetGroupVersionKind(schema.GroupVersionKind{Group: "argoproj.io", Version: "v1alpha1", Kind: "Application"}) + key := client.ObjectKey{Namespace: ns, Name: name} + if err := p.Client.Get(ctx, key, app); err != nil { + return client.IgnoreNotFound(err) + } + finalizers := app.GetFinalizers() + var kept []string + for _, f := range finalizers { + if f != PlatformDrainFinalizer { + kept = append(kept, f) + } + } + if len(kept) == len(finalizers) { + return nil + } + app.SetFinalizers(kept) + return p.Client.Update(ctx, app) +} diff --git a/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/controller/platform_drain_test.go b/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/controller/platform_drain_test.go new file mode 100644 index 00000000..8177f1ef --- /dev/null +++ b/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/controller/platform_drain_test.go @@ -0,0 +1,249 @@ +// Copyright (c) 2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package controller + +import ( + "context" + "sort" + "testing" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +const testArgoNS = "argocd" + +func newApp(name string, labels map[string]string, finalizers []string, deletionTime *time.Time) *unstructured.Unstructured { + u := &unstructured.Unstructured{} + u.SetGroupVersionKind(argoApplicationGVK) + u.SetName(name) + u.SetNamespace(testArgoNS) + if labels != nil { + u.SetLabels(labels) + } + if finalizers != nil { + u.SetFinalizers(finalizers) + } + if deletionTime != nil { + t := metav1.NewTime(*deletionTime) + u.SetDeletionTimestamp(&t) + } + return u +} + +// newFakeClient builds a fake client that knows about the Argo Application GVK so +// list-by-label queries on unstructured objects work correctly. +func newFakeClient(objs ...client.Object) client.Client { + scheme := runtime.NewScheme() + scheme.AddKnownTypeWithName(argoApplicationGVK, &unstructured.Unstructured{}) + scheme.AddKnownTypeWithName( + schema.GroupVersionKind{Group: "argoproj.io", Version: "v1alpha1", Kind: "ApplicationList"}, + &unstructured.UnstructuredList{}) + return fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(objs...). + Build() +} + +func getFinalizers(t *testing.T, c client.Client, name string) []string { + t.Helper() + u := &unstructured.Unstructured{} + u.SetGroupVersionKind(argoApplicationGVK) + if err := c.Get(context.Background(), client.ObjectKey{Namespace: testArgoNS, Name: name}, u); err != nil { + t.Fatalf("get %s: %v", name, err) + } + out := append([]string(nil), u.GetFinalizers()...) + sort.Strings(out) + return out +} + +// --------------------------------------------------------------------------- +// PlatformDrainer unit tests +// --------------------------------------------------------------------------- + +func TestRemovePlatformDrainFinalizer_RemovesOnlyPlatformFinalizer(t *testing.T) { + app := newApp("horizon-sdv", nil, + []string{"resources-finalizer.argocd.argoproj.io", PlatformDrainFinalizer}, nil) + c := newFakeClient(app) + d := &PlatformDrainer{Client: c, APIReader: c, ArgoCDNamespace: testArgoNS} + + if err := d.RemovePlatformDrainFinalizer(context.Background(), testArgoNS, "horizon-sdv"); err != nil { + t.Fatalf("RemovePlatformDrainFinalizer: %v", err) + } + got := getFinalizers(t, c, "horizon-sdv") + if len(got) != 1 || got[0] != "resources-finalizer.argocd.argoproj.io" { + t.Fatalf("finalizers = %v, want [resources-finalizer.argocd.argoproj.io]", got) + } +} + +func TestRemovePlatformDrainFinalizer_NoopWhenAbsent(t *testing.T) { + app := newApp("horizon-sdv", nil, []string{"resources-finalizer.argocd.argoproj.io"}, nil) + c := newFakeClient(app) + d := &PlatformDrainer{Client: c, APIReader: c, ArgoCDNamespace: testArgoNS} + + if err := d.RemovePlatformDrainFinalizer(context.Background(), testArgoNS, "horizon-sdv"); err != nil { + t.Fatalf("RemovePlatformDrainFinalizer: %v", err) + } + got := getFinalizers(t, c, "horizon-sdv") + if len(got) != 1 || got[0] != "resources-finalizer.argocd.argoproj.io" { + t.Fatalf("finalizers should be unchanged, got %v", got) + } +} + +func TestManagedModuleApplicationsRemaining_TrueWhenLabeled(t *testing.T) { + now := time.Now() + managed := newApp("mod-sample", + map[string]string{ModuleManagerManagedLabelKey: "true"}, + []string{"resources-finalizer.argocd.argoproj.io"}, &now) + unmanaged := newApp("mod-other", nil, nil, nil) + + c := newFakeClient(managed, unmanaged) + d := &PlatformDrainer{Client: c, APIReader: c, ArgoCDNamespace: testArgoNS} + + remaining, err := d.ManagedModuleApplicationsRemaining(context.Background(), testArgoNS) + if err != nil { + t.Fatalf("ManagedModuleApplicationsRemaining: %v", err) + } + if !remaining { + t.Fatal("expected remaining=true when a managed app exists") + } +} + +func TestManagedModuleApplicationsRemaining_FalseWhenNoneLabeled(t *testing.T) { + unmanaged := newApp("mod-other", nil, nil, nil) + c := newFakeClient(unmanaged) + d := &PlatformDrainer{Client: c, APIReader: c, ArgoCDNamespace: testArgoNS} + + remaining, err := d.ManagedModuleApplicationsRemaining(context.Background(), testArgoNS) + if err != nil { + t.Fatalf("ManagedModuleApplicationsRemaining: %v", err) + } + if remaining { + t.Fatal("expected remaining=false when no managed apps exist") + } +} + +// --------------------------------------------------------------------------- +// RootGitOpsApplicationReconciler unit tests +// --------------------------------------------------------------------------- + +// stubDrainer satisfies the platformDrainer interface for reconciler tests. +type stubDrainer struct { + drainErr error + managedRemaining bool + managedRemainingErr error + stripKCCCalled int + platformFinRemoved bool + destNamespaces []string +} + +func (s *stubDrainer) DrainAllEnabledModules(ctx context.Context) error { + return s.drainErr +} +func (s *stubDrainer) ManagedModuleApplicationsRemaining(ctx context.Context, ns string) (bool, error) { + return s.managedRemaining, s.managedRemainingErr +} +func (s *stubDrainer) ManagedDestinationNamespaces(ctx context.Context, ns string) []string { + return s.destNamespaces +} +func (s *stubDrainer) StripKCCFinalizersInNamespaces(ctx context.Context, namespaces []string) error { + s.stripKCCCalled++ + return nil +} +func (s *stubDrainer) RemovePlatformDrainFinalizer(ctx context.Context, ns, name string) error { + s.platformFinRemoved = true + return nil +} + +func TestReconcileRoot_RemovesPlatformFinalizerWhenNoAppsRemain(t *testing.T) { + delTime := time.Now() + root := newApp("horizon-sdv", nil, []string{PlatformDrainFinalizer}, &delTime) + c := newFakeClient(root) + stub := &stubDrainer{managedRemaining: false} + + r := &RootGitOpsApplicationReconciler{ + Client: c, + Drainer: stub, + ArgoCDNamespace: testArgoNS, + GitOpsRootAppNames: []string{"horizon-sdv"}, + } + if _, err := r.Reconcile(context.Background(), ctrl.Request{ + NamespacedName: client.ObjectKey{Namespace: testArgoNS, Name: "horizon-sdv"}, + }); err != nil { + t.Fatalf("reconcile: %v", err) + } + if !stub.platformFinRemoved { + t.Fatal("platform-drain finalizer should be removed when no managed apps remain") + } + if stub.stripKCCCalled != 0 { + t.Fatal("KCC stripper should not be called when no apps remain") + } +} + +func TestReconcileRoot_StripsKCCAndRequeuesWhileAppsRemain(t *testing.T) { + delTime := time.Now() + root := newApp("horizon-sdv", nil, []string{PlatformDrainFinalizer}, &delTime) + c := newFakeClient(root) + stub := &stubDrainer{managedRemaining: true, destNamespaces: []string{"sample-module-hello"}} + + r := &RootGitOpsApplicationReconciler{ + Client: c, + Drainer: stub, + ArgoCDNamespace: testArgoNS, + GitOpsRootAppNames: []string{"horizon-sdv"}, + } + res, err := r.Reconcile(context.Background(), ctrl.Request{ + NamespacedName: client.ObjectKey{Namespace: testArgoNS, Name: "horizon-sdv"}, + }) + if err != nil { + t.Fatalf("reconcile: %v", err) + } + if res.RequeueAfter == 0 { + t.Fatal("expected non-zero RequeueAfter while apps remain") + } + if stub.stripKCCCalled != 1 { + t.Fatalf("KCC stripper should be called once while apps remain, got %d", stub.stripKCCCalled) + } + if stub.platformFinRemoved { + t.Fatal("platform-drain finalizer must not be removed while apps remain") + } +} + +func TestReconcileRoot_NoopWhenNotDeleting(t *testing.T) { + root := newApp("horizon-sdv", nil, []string{PlatformDrainFinalizer}, nil) // no deletion timestamp + c := newFakeClient(root) + stub := &stubDrainer{} + + r := &RootGitOpsApplicationReconciler{ + Client: c, + Drainer: stub, + ArgoCDNamespace: testArgoNS, + GitOpsRootAppNames: []string{"horizon-sdv"}, + } + if _, err := r.Reconcile(context.Background(), ctrl.Request{ + NamespacedName: client.ObjectKey{Namespace: testArgoNS, Name: "horizon-sdv"}, + }); err != nil { + t.Fatalf("reconcile: %v", err) + } + if stub.platformFinRemoved { + t.Fatal("should not remove finalizer when app is not being deleted") + } +} diff --git a/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/controller/platform_root_finalize_reconciler.go b/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/controller/platform_root_finalize_reconciler.go new file mode 100644 index 00000000..c7d75dff --- /dev/null +++ b/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/controller/platform_root_finalize_reconciler.go @@ -0,0 +1,169 @@ +// Copyright (c) 2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package controller + +import ( + "context" + "time" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/predicate" +) + +var argoApplicationGVK = schema.GroupVersionKind{Group: "argoproj.io", Version: "v1alpha1", Kind: "Application"} + +// platformDrainer is the subset of *PlatformDrainer the reconciler uses. Defined as an +// interface to keep the reconciler unit-testable without a real state/catalog store. +type platformDrainer interface { + DrainAllEnabledModules(ctx context.Context) error + ManagedModuleApplicationsRemaining(ctx context.Context, argoCDNamespace string) (bool, error) + ManagedDestinationNamespaces(ctx context.Context, argoCDNamespace string) []string + StripKCCFinalizersInNamespaces(ctx context.Context, namespaces []string) error + RemovePlatformDrainFinalizer(ctx context.Context, ns, name string) error +} + +// RootGitOpsApplicationReconciler drains module Applications when the root horizon-sdv +// Application is deleted, then removes PlatformDrainFinalizer to unblock Terraform destroy. +type RootGitOpsApplicationReconciler struct { + Client client.Client + Drainer platformDrainer + ArgoCDNamespace string + GitOpsRootAppNames []string +} + +func (r *RootGitOpsApplicationReconciler) rootNameSet() map[string]struct{} { + m := make(map[string]struct{}, len(r.GitOpsRootAppNames)) + for _, n := range r.GitOpsRootAppNames { + if n != "" { + m[n] = struct{}{} + } + } + return m +} + +// Reconcile implements reconcile.Reconciler. +func (r *RootGitOpsApplicationReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + if r.ArgoCDNamespace == "" || req.Namespace != r.ArgoCDNamespace { + return ctrl.Result{}, nil + } + if _, ok := r.rootNameSet()[req.Name]; ok { + return r.reconcileRoot(ctx, req) + } + return ctrl.Result{}, nil +} + +// reconcileRoot drains modules and removes PlatformDrainFinalizer from the root Application. +func (r *RootGitOpsApplicationReconciler) reconcileRoot(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + logger := log.FromContext(ctx) + + u := &unstructured.Unstructured{} + u.SetGroupVersionKind(argoApplicationGVK) + if err := r.Client.Get(ctx, req.NamespacedName, u); err != nil { + return ctrl.Result{}, client.IgnoreNotFound(err) + } + if ts := u.GetDeletionTimestamp(); ts == nil || ts.IsZero() { + return ctrl.Result{}, nil + } + if !hasFinalizerUnstructured(u, PlatformDrainFinalizer) { + return ctrl.Result{}, nil + } + + if err := r.Drainer.DrainAllEnabledModules(ctx); err != nil { + logger.Error(err, "drain all modules for platform teardown") + return ctrl.Result{RequeueAfter: 10 * time.Second}, nil + } + + remaining, err := r.Drainer.ManagedModuleApplicationsRemaining(ctx, r.ArgoCDNamespace) + if err != nil { + return ctrl.Result{RequeueAfter: 5 * time.Second}, err + } + if remaining { + // Child Applications are still present (waiting on KCC or other finalizers). + // Re-run the KCC stripper on every reconcile iteration so stuck KCC objects + // unblock as soon as their token expires or auth is revoked. + nss := r.Drainer.ManagedDestinationNamespaces(ctx, r.ArgoCDNamespace) + if err := r.Drainer.StripKCCFinalizersInNamespaces(ctx, nss); err != nil { + logger.Error(err, "strip KCC finalizers during platform drain wait") + } + return ctrl.Result{RequeueAfter: 10 * time.Second}, nil + } + + if err := r.Drainer.RemovePlatformDrainFinalizer(ctx, req.Namespace, req.Name); err != nil { + logger.Error(err, "remove platform drain finalizer from root Application") + return ctrl.Result{RequeueAfter: 5 * time.Second}, err + } + return ctrl.Result{}, nil +} + +func hasFinalizerUnstructured(u *unstructured.Unstructured, want string) bool { + for _, f := range u.GetFinalizers() { + if f == want { + return true + } + } + return false +} + +// SetupWithManager registers the reconciler for Argo CD Applications. +// It watches only the configured root Applications during deletion. +func (r *RootGitOpsApplicationReconciler) SetupWithManager(mgr ctrl.Manager) error { + rootNames := r.rootNameSet() + if len(rootNames) == 0 { + return nil + } + ns := r.ArgoCDNamespace + argo := &unstructured.Unstructured{} + argo.SetGroupVersionKind(argoApplicationGVK) + + matchWatched := func(u *unstructured.Unstructured) bool { + if u.GetNamespace() != ns { + return false + } + _, ok := rootNames[u.GetName()] + return ok + } + + return ctrl.NewControllerManagedBy(mgr). + For(argo). + WithEventFilter(predicate.Funcs{ + CreateFunc: func(e event.CreateEvent) bool { + u, ok := e.Object.(*unstructured.Unstructured) + if !ok || !matchWatched(u) { + return false + } + ts := u.GetDeletionTimestamp() + if ts == nil || ts.IsZero() { + return false + } + return hasFinalizerUnstructured(u, PlatformDrainFinalizer) + }, + DeleteFunc: func(e event.DeleteEvent) bool { return false }, + UpdateFunc: func(e event.UpdateEvent) bool { + u, ok := e.ObjectNew.(*unstructured.Unstructured) + if !ok || !matchWatched(u) { + return false + } + ts := u.GetDeletionTimestamp() + return ts != nil && !ts.IsZero() + }, + GenericFunc: func(e event.GenericEvent) bool { return false }, + }). + Complete(r) +} diff --git a/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/controller/reverse_dependents.go b/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/controller/reverse_dependents.go new file mode 100644 index 00000000..17566fab --- /dev/null +++ b/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/controller/reverse_dependents.go @@ -0,0 +1,120 @@ +// Copyright (c) 2024-2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package controller + +import ( + "context" + "sort" + + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// ReverseDependents contains enabled reverse edges for a target module. +type ReverseDependents struct { + Hard []string + Soft []string +} + +// DependentsIndex maps module name to its enabled reverse dependents. +type DependentsIndex map[string]*ReverseDependents + +// ComputeDependentsFromCatalog builds a reverse-edge index from catalog entries and enabled state. +// A module entry E contributes to Dependents[D].Hard iff E is enabled and D in E.HardDependencies, +// and analogously for Soft. Returned slices are sorted and never contain E==D self-edges. +func ComputeDependentsFromCatalog(entries []CatalogEntry, state *State) DependentsIndex { + out := make(DependentsIndex, len(entries)) + for _, e := range entries { + out[e.Name] = &ReverseDependents{} + } + if state == nil { + return out + } + enabledSet := make(map[string]bool, len(state.EnabledModules)) + for _, id := range state.EnabledModules { + enabledSet[id] = true + } + for _, e := range entries { + id := state.ModuleIDs[e.Name] + if id == "" || !enabledSet[id] { + continue + } + for _, dep := range e.HardDependencies { + if dep == e.Name { + continue + } + if rd, ok := out[dep]; ok { + rd.Hard = append(rd.Hard, e.Name) + } else { + out[dep] = &ReverseDependents{Hard: []string{e.Name}} + } + } + for _, dep := range e.SoftDependencies { + if dep == e.Name { + continue + } + if rd, ok := out[dep]; ok { + rd.Soft = append(rd.Soft, e.Name) + } else { + out[dep] = &ReverseDependents{Soft: []string{e.Name}} + } + } + } + for _, rd := range out { + sort.Strings(rd.Hard) + sort.Strings(rd.Soft) + } + return out +} + +// ListEnabledReverseDependents returns enabled module names that reference targetModule +// as either a hard dependency or a soft dependency. Data is sourced from ModuleCatalog +// (desired) and ModuleManagerState (runtime); c and mmNamespace are retained for call-site +// compatibility but no longer used. +func ListEnabledReverseDependents(ctx context.Context, c client.Client, catalogStore CatalogStoreInterface, mmNamespace string, state *State, targetModule string) (*ReverseDependents, error) { + _ = c + _ = mmNamespace + out := &ReverseDependents{} + if state == nil { + return out, nil + } + entries, err := catalogStore.List(ctx) + if err != nil { + return nil, err + } + index := ComputeDependentsFromCatalog(entries, state) + if rd, ok := index[targetModule]; ok && rd != nil { + out.Hard = append([]string(nil), rd.Hard...) + out.Soft = append([]string(nil), rd.Soft...) + } + return out, nil +} + +// ListHardDependents returns enabled module names that declare targetModule as a hard dependency. +func ListHardDependents(ctx context.Context, c client.Client, catalogStore CatalogStoreInterface, mmNamespace string, state *State, targetModule string) ([]string, error) { + edges, err := ListEnabledReverseDependents(ctx, c, catalogStore, mmNamespace, state, targetModule) + if err != nil { + return nil, err + } + return edges.Hard, nil +} + +// ListSoftDependents returns enabled module names that declare targetModule as a soft dependency. +func ListSoftDependents(ctx context.Context, c client.Client, catalogStore CatalogStoreInterface, mmNamespace string, state *State, targetModule string) ([]string, error) { + edges, err := ListEnabledReverseDependents(ctx, c, catalogStore, mmNamespace, state, targetModule) + if err != nil { + return nil, err + } + return edges.Soft, nil +} diff --git a/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/controller/reverse_dependents_test.go b/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/controller/reverse_dependents_test.go new file mode 100644 index 00000000..8c42020a --- /dev/null +++ b/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/controller/reverse_dependents_test.go @@ -0,0 +1,115 @@ +// Copyright (c) 2024-2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package controller + +import ( + "reflect" + "testing" +) + +// TestComputeDependentsFromCatalog verifies the in-memory reverse-dependency index: +// - only enabled modules (ID assigned and present in state.EnabledModules) contribute edges +// - hard/soft edges are recorded separately +// - self-edges are skipped +// - output slices are sorted and deterministic +func TestComputeDependentsFromCatalog(t *testing.T) { + entries := []CatalogEntry{ + {Name: "sample-hard"}, + {Name: "sample-soft"}, + { + Name: "sample", + HardDependencies: []string{"sample-hard"}, + SoftDependencies: []string{"sample-soft"}, + }, + { + Name: "other", + SoftDependencies: []string{"sample-soft"}, + }, + { + Name: "orphan-sample", + HardDependencies: []string{"sample-hard"}, + }, + } + + state := &State{ + EnabledModules: []string{"id-sample", "id-other", "id-sample-hard", "id-sample-soft"}, + ModuleIDs: map[string]string{ + "sample": "id-sample", + "other": "id-other", + "sample-hard": "id-sample-hard", + "sample-soft": "id-sample-soft", + "orphan-sample": "id-orphan", + }, + } + + index := ComputeDependentsFromCatalog(entries, state) + + if rd := index["sample-hard"]; rd == nil || !reflect.DeepEqual(rd.Hard, []string{"sample"}) { + t.Fatalf("sample-hard hard dependents: got %+v want [sample]", rd) + } + if rd := index["sample-hard"]; rd == nil || len(rd.Soft) != 0 { + t.Fatalf("sample-hard soft dependents: got %+v want []", rd) + } + + if rd := index["sample-soft"]; rd == nil || !reflect.DeepEqual(rd.Soft, []string{"other", "sample"}) { + t.Fatalf("sample-soft soft dependents: got %+v want [other sample]", rd) + } + + if rd, ok := index["sample"]; !ok || rd == nil || len(rd.Hard) != 0 || len(rd.Soft) != 0 { + t.Fatalf("sample should exist with empty dependents, got %+v ok=%v", rd, ok) + } +} + +func TestComputeDependentsFromCatalog_SkipsDisabled(t *testing.T) { + entries := []CatalogEntry{ + {Name: "sample-hard"}, + {Name: "sample", HardDependencies: []string{"sample-hard"}}, + } + + state := &State{ + EnabledModules: []string{"id-sample-hard"}, + ModuleIDs: map[string]string{ + "sample": "id-sample", + "sample-hard": "id-sample-hard", + }, + } + + index := ComputeDependentsFromCatalog(entries, state) + if rd := index["sample-hard"]; rd == nil || len(rd.Hard) != 0 { + t.Fatalf("disabled parent must not register as dependent, got %+v", rd) + } +} + +func TestComputeDependentsFromCatalog_SkipsSelfEdges(t *testing.T) { + entries := []CatalogEntry{ + {Name: "a", HardDependencies: []string{"a"}, SoftDependencies: []string{"a"}}, + } + state := &State{ + EnabledModules: []string{"id-a"}, + ModuleIDs: map[string]string{"a": "id-a"}, + } + index := ComputeDependentsFromCatalog(entries, state) + if rd := index["a"]; rd == nil || len(rd.Hard) != 0 || len(rd.Soft) != 0 { + t.Fatalf("self-edges must be skipped, got %+v", rd) + } +} + +func TestComputeDependentsFromCatalog_NilState(t *testing.T) { + entries := []CatalogEntry{{Name: "a"}, {Name: "b", HardDependencies: []string{"a"}}} + index := ComputeDependentsFromCatalog(entries, nil) + if rd, ok := index["a"]; !ok || rd == nil || len(rd.Hard) != 0 { + t.Fatalf("nil state: expected empty dependents slot for a, got %+v ok=%v", rd, ok) + } +} diff --git a/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/controller/soft_features.go b/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/controller/soft_features.go new file mode 100644 index 00000000..f96178d1 --- /dev/null +++ b/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/controller/soft_features.go @@ -0,0 +1,285 @@ +// Copyright (c) 2024-2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package controller + +import ( + "context" + "fmt" + "sort" + "strings" + + horizonv1alpha1 "github.com/acn-horizon-sdv/module-manager/api/v1alpha1" + "gopkg.in/yaml.v3" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +const ( + // SoftFeaturesHelmKey is the Helm values key for per-soft-dependency flags (map moduleName -> bool). + SoftFeaturesHelmKey = "softFeaturesEnabled" + legacySoftFeatureHelmKey = "softFeatureEnabled" +) + +// ComputeSoftFeaturesEnabledMap returns, for parent module P, a map from each soft dependency name to whether that module is enabled. +func ComputeSoftFeaturesEnabledMap(ctx context.Context, c client.Client, mmNamespace string, state *State, catalogStore CatalogStoreInterface, parentModuleName string) (map[string]bool, error) { + softDeps, err := softDepsForParent(ctx, c, catalogStore, mmNamespace, parentModuleName) + if err != nil { + return nil, err + } + out := make(map[string]bool, len(softDeps)) + enabledSet := make(map[string]bool) + for _, id := range state.EnabledModules { + enabledSet[id] = true + } + for _, s := range softDeps { + out[s] = moduleNameEnabledInState(ctx, c, mmNamespace, state, enabledSet, s) + } + return out, nil +} + +func softDepsForParent(ctx context.Context, c client.Client, catalogStore CatalogStoreInterface, mmNamespace, parentModuleName string) ([]string, error) { + _ = c + _ = mmNamespace + entries, err := catalogStore.List(ctx) + if err != nil { + return nil, err + } + for i := range entries { + if entries[i].Name == parentModuleName { + return append([]string(nil), entries[i].SoftDependencies...), nil + } + } + return nil, nil +} + +func moduleNameEnabledInState(ctx context.Context, c client.Client, mmNamespace string, state *State, enabledSet map[string]bool, moduleName string) bool { + _ = ctx + _ = c + _ = mmNamespace + if state == nil { + return false + } + id := state.ModuleIDs[moduleName] + return id != "" && enabledSet[id] +} + +// CollectEnabledParentsWithSoftDependency returns enabled module names that list softDepName as a soft dependency (registration and catalog). +func CollectEnabledParentsWithSoftDependency(ctx context.Context, c client.Client, catalogStore CatalogStoreInterface, mmNamespace string, state *State, softDepName string) ([]string, error) { + return ListSoftDependents(ctx, c, catalogStore, mmNamespace, state, softDepName) +} + +// PatchApplicationHelmSoftFeaturesMap merges softFeaturesEnabled into spec.source.helm.values YAML and removes legacy softFeatureEnabled. +func PatchApplicationHelmSoftFeaturesMap(ctx context.Context, apiReader client.Reader, writer client.Client, argoNamespace, appName string, features map[string]bool) error { + app := &unstructured.Unstructured{} + app.SetGroupVersionKind(schema.GroupVersionKind{Group: "argoproj.io", Version: "v1alpha1", Kind: "Application"}) + key := client.ObjectKey{Namespace: argoNamespace, Name: appName} + if err := apiReader.Get(ctx, key, app); err != nil { + if errors.IsNotFound(err) { + return nil + } + return err + } + spec, ok, _ := unstructured.NestedMap(app.Object, "spec") + if !ok || spec == nil { + return fmt.Errorf("application %s/%s: no spec", argoNamespace, appName) + } + source, _ := spec["source"].(map[string]interface{}) + if source == nil { + source = make(map[string]interface{}) + spec["source"] = source + } + helm, _ := source["helm"].(map[string]interface{}) + if helm == nil { + helm = make(map[string]interface{}) + source["helm"] = helm + } + valuesStr, _ := helm["values"].(string) + + var root map[string]interface{} + if strings.TrimSpace(valuesStr) != "" { + if err := yaml.Unmarshal([]byte(valuesStr), &root); err != nil { + return fmt.Errorf("parse helm values for %s: %w", appName, err) + } + } + if root == nil { + root = make(map[string]interface{}) + } + delete(root, legacySoftFeatureHelmKey) + nested := make(map[string]interface{}, len(features)) + keys := make([]string, 0, len(features)) + for k := range features { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + nested[k] = features[k] + } + root[SoftFeaturesHelmKey] = nested + + out, err := yaml.Marshal(root) + if err != nil { + return fmt.Errorf("marshal helm values for %s: %w", appName, err) + } + helm["values"] = string(out) + source["helm"] = helm + spec["source"] = source + if err := unstructured.SetNestedMap(app.Object, spec, "spec"); err != nil { + return err + } + return writer.Update(ctx, app) +} + +// RemoveSoftFeaturesFromApplicationHelm deletes softFeaturesEnabled (and legacy softFeatureEnabled) from spec.source.helm.values. +func RemoveSoftFeaturesFromApplicationHelm(ctx context.Context, apiReader client.Reader, writer client.Client, argoNamespace, appName string) error { + app := &unstructured.Unstructured{} + app.SetGroupVersionKind(schema.GroupVersionKind{Group: "argoproj.io", Version: "v1alpha1", Kind: "Application"}) + key := client.ObjectKey{Namespace: argoNamespace, Name: appName} + if err := apiReader.Get(ctx, key, app); err != nil { + if errors.IsNotFound(err) { + return nil + } + return err + } + spec, ok, _ := unstructured.NestedMap(app.Object, "spec") + if !ok || spec == nil { + return fmt.Errorf("application %s/%s: no spec", argoNamespace, appName) + } + source, _ := spec["source"].(map[string]interface{}) + if source == nil { + return nil + } + helm, _ := source["helm"].(map[string]interface{}) + if helm == nil { + return nil + } + valuesStr, _ := helm["values"].(string) + var root map[string]interface{} + if strings.TrimSpace(valuesStr) != "" { + if err := yaml.Unmarshal([]byte(valuesStr), &root); err != nil { + return fmt.Errorf("parse helm values for %s: %w", appName, err) + } + } + if root == nil { + return nil + } + delete(root, legacySoftFeatureHelmKey) + delete(root, SoftFeaturesHelmKey) + out, err := yaml.Marshal(root) + if err != nil { + return fmt.Errorf("marshal helm values for %s: %w", appName, err) + } + helm["values"] = string(out) + source["helm"] = helm + spec["source"] = source + if err := unstructured.SetNestedMap(app.Object, spec, "spec"); err != nil { + return err + } + return writer.Update(ctx, app) +} + +// SyncSoftFeaturesForModule updates soft-feature state for a parent module per ModuleCatalog (Helm values, ConfigMap, or both). +func SyncSoftFeaturesForModule(ctx context.Context, apiReader client.Reader, writer client.Client, argoNS, mmNS string, stateStore StateStoreInterface, catalogStore CatalogStoreInterface, parentModuleName string) error { + state, err := stateStore.Get(ctx) + if err != nil { + return err + } + m, err := ComputeSoftFeaturesEnabledMap(ctx, writer, mmNS, state, catalogStore, parentModuleName) + if err != nil { + return err + } + if m == nil { + m = map[string]bool{} + } + entry, err := catalogEntryForModule(ctx, catalogStore, parentModuleName) + if err != nil { + return err + } + mode := effectiveSoftFeaturesPropagation(entry) + appName := ApplicationName(parentModuleName) + + switch mode { + case horizonv1alpha1.SoftFeaturesPropagationConfigMap: + if len(entry.SoftFeaturesConfigMapNamespaces) == 0 { + return fmt.Errorf("catalog module %q: softFeaturesConfigMapNamespaces is required when softFeaturesPropagation is ConfigMap", parentModuleName) + } + if err := RemoveSoftFeaturesFromApplicationHelm(ctx, apiReader, writer, argoNS, appName); err != nil { + return err + } + return EnsureSoftFeaturesConfigMaps(ctx, apiReader, writer, entry.SoftFeaturesConfigMapNamespaces, parentModuleName, m) + case horizonv1alpha1.SoftFeaturesPropagationHelmValuesAndConfigMap: + if len(entry.SoftFeaturesConfigMapNamespaces) == 0 { + return fmt.Errorf("catalog module %q: softFeaturesConfigMapNamespaces is required when softFeaturesPropagation is HelmValuesAndConfigMap", parentModuleName) + } + if err := PatchApplicationHelmSoftFeaturesMap(ctx, apiReader, writer, argoNS, appName, m); err != nil { + return err + } + return EnsureSoftFeaturesConfigMaps(ctx, apiReader, writer, entry.SoftFeaturesConfigMapNamespaces, parentModuleName, m) + default: + return PatchApplicationHelmSoftFeaturesMap(ctx, apiReader, writer, argoNS, appName, m) + } +} + +// ResyncSoftFeaturesForParentsOfSoftDep recomputes softFeaturesEnabled for every enabled parent that lists toggledModule as a soft dependency. +func ResyncSoftFeaturesForParentsOfSoftDep(ctx context.Context, apiReader client.Reader, writer client.Client, argoNS, mmNS string, stateStore StateStoreInterface, catalogStore CatalogStoreInterface, toggledModule string) error { + state, err := stateStore.Get(ctx) + if err != nil { + return err + } + parents, err := CollectEnabledParentsWithSoftDependency(ctx, writer, catalogStore, mmNS, state, toggledModule) + if err != nil { + return err + } + var firstErr error + for _, p := range parents { + if err := SyncSoftFeaturesForModule(ctx, apiReader, writer, argoNS, mmNS, stateStore, catalogStore, p); err != nil && firstErr == nil { + firstErr = err + } + } + return firstErr +} + +// SyncSoftFeaturesForAllEnabledModulesWithSoftDeps runs SyncSoftFeaturesForModule for every enabled catalog +// module that declares softDependencies (parents). Call after ModuleCatalog changes so ConfigMap / Helm +// propagation matches the catalog without requiring a soft-dependency toggle. +func SyncSoftFeaturesForAllEnabledModulesWithSoftDeps(ctx context.Context, apiReader client.Reader, writer client.Client, argoNS, mmNS string, stateStore StateStoreInterface, catalogStore CatalogStoreInterface) error { + state, err := stateStore.Get(ctx) + if err != nil { + return err + } + entries, err := catalogStore.List(ctx) + if err != nil { + return err + } + enabledID := make(map[string]bool, len(state.EnabledModules)) + for _, id := range state.EnabledModules { + enabledID[id] = true + } + var firstErr error + for _, e := range entries { + if len(e.SoftDependencies) == 0 { + continue + } + id := state.ModuleIDs[e.Name] + if id == "" || !enabledID[id] { + continue + } + if err := SyncSoftFeaturesForModule(ctx, apiReader, writer, argoNS, mmNS, stateStore, catalogStore, e.Name); err != nil && firstErr == nil { + firstErr = err + } + } + return firstErr +} diff --git a/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/controller/soft_features_configmap.go b/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/controller/soft_features_configmap.go new file mode 100644 index 00000000..6492a7da --- /dev/null +++ b/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/controller/soft_features_configmap.go @@ -0,0 +1,273 @@ +// Copyright (c) 2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package controller + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "sort" + "strings" + "time" + + horizonv1alpha1 "github.com/acn-horizon-sdv/module-manager/api/v1alpha1" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/wait" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log" +) + +const ( + // SoftFeaturesConfigMapNamePrefix is the prefix for ConfigMaps that hold soft-feature flags (suffix: sanitized module name). + SoftFeaturesConfigMapNamePrefix = "horizon-sdv-soft-features-" + // SoftFeaturesConfigMapKeyEnv is a newline-separated KEY=value file (SOFT_FEATURE_ENABLED_*), for shell-friendly consumers. + SoftFeaturesConfigMapKeyEnv = "features.env" + // SoftFeaturesConfigMapKeyJSON is JSON object moduleName -> bool. + SoftFeaturesConfigMapKeyJSON = "soft-features.json" + + managedByLabelKey = "app.kubernetes.io/managed-by" + managedByLabelValue = "module-manager" + moduleLabelKey = "horizon-sdv.io/module" +) + +var softFeaturesConfigMapRetryBackoff = wait.Backoff{ + Duration: 500 * time.Millisecond, + Factor: 2.0, + Steps: 7, + Jitter: 0.1, +} + +type namespaceTerminatingError struct { + namespace string +} + +func (e namespaceTerminatingError) Error() string { + return fmt.Sprintf("namespace %s is being terminated", e.namespace) +} + +// SoftFeaturesConfigMapName returns the ConfigMap name for a parent module (e.g. horizon-sdv-soft-features-sample-module). +func SoftFeaturesConfigMapName(moduleName string) string { + return SoftFeaturesConfigMapNamePrefix + strings.ReplaceAll(moduleName, "_", "-") +} + +func catalogEntryForModule(ctx context.Context, catalogStore CatalogStoreInterface, parentModuleName string) (CatalogEntry, error) { + entries, err := catalogStore.List(ctx) + if err != nil { + return CatalogEntry{}, err + } + for i := range entries { + if entries[i].Name == parentModuleName { + return entries[i], nil + } + } + return CatalogEntry{}, nil +} + +func effectiveSoftFeaturesPropagation(e CatalogEntry) string { + if e.SoftFeaturesPropagation == "" { + return horizonv1alpha1.SoftFeaturesPropagationHelmValues + } + return e.SoftFeaturesPropagation +} + +// softDepEnvLine returns one line like SOFT_FEATURE_ENABLED_SAMPLE_SOFT_MODULE=true matching Helm template naming. +func softDepEnvLine(moduleName string, enabled bool) string { + s := strings.ReplaceAll(moduleName, "-", "_") + s = strings.ReplaceAll(s, ".", "_") + key := "SOFT_FEATURE_ENABLED_" + strings.ToUpper(s) + val := "false" + if enabled { + val = "true" + } + return key + "=" + val +} + +func buildSoftFeaturesConfigMapData(features map[string]bool) map[string]string { + keys := make([]string, 0, len(features)) + for k := range features { + keys = append(keys, k) + } + sort.Strings(keys) + var envLines strings.Builder + for _, k := range keys { + envLines.WriteString(softDepEnvLine(k, features[k])) + envLines.WriteByte('\n') + } + j, err := json.Marshal(features) + if err != nil { + j = []byte("{}") + } + return map[string]string{ + SoftFeaturesConfigMapKeyEnv: strings.TrimSuffix(envLines.String(), "\n"), + SoftFeaturesConfigMapKeyJSON: string(j), + } +} + +// EnsureSoftFeaturesConfigMaps creates or updates the soft-features ConfigMap in each namespace. +// apiReader must be uncached (e.g. mgr.GetAPIReader()): workload namespaces are not in the manager's +// DefaultNamespaces cache, so a cached client Get fails with "unknown namespace for the cache". +func EnsureSoftFeaturesConfigMaps(ctx context.Context, apiReader client.Reader, writer client.Client, namespaces []string, parentModuleName string, features map[string]bool) error { + logger := log.FromContext(ctx) + var nsList []string + for _, ns := range namespaces { + if strings.TrimSpace(ns) != "" { + nsList = append(nsList, strings.TrimSpace(ns)) + } + } + if len(nsList) == 0 { + return fmt.Errorf("soft-features ConfigMap: no non-empty namespaces for module %s", parentModuleName) + } + name := SoftFeaturesConfigMapName(parentModuleName) + data := buildSoftFeaturesConfigMapData(features) + for _, ns := range nsList { + key := client.ObjectKey{Namespace: ns, Name: name} + attempt := 0 + var lastRetryErr error + err := wait.ExponentialBackoffWithContext(ctx, softFeaturesConfigMapRetryBackoff, func(ctx context.Context) (bool, error) { + attempt++ + if err := ensureNamespaceReady(ctx, apiReader, ns); err != nil { + if isSoftFeaturesRetryableError(err) { + lastRetryErr = err + logger.Info("namespace not ready for soft-features ConfigMap write; retrying", "module", parentModuleName, "namespace", ns, "attempt", attempt, "error", err.Error()) + return false, nil + } + return false, fmt.Errorf("get namespace %s: %w", ns, err) + } + cm := &corev1.ConfigMap{} + getErr := apiReader.Get(ctx, key, cm) + if apierrors.IsNotFound(getErr) { + cm = &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: ns, + Name: name, + Labels: map[string]string{ + managedByLabelKey: managedByLabelValue, + moduleLabelKey: parentModuleName, + }, + }, + Data: data, + } + createErr := writer.Create(ctx, cm) + if createErr == nil { + return true, nil + } + if apierrors.IsAlreadyExists(createErr) || isSoftFeaturesRetryableError(createErr) { + lastRetryErr = createErr + logger.Info("create soft-features ConfigMap retried", "module", parentModuleName, "namespace", ns, "attempt", attempt, "error", createErr.Error()) + return false, nil + } + return false, fmt.Errorf("create soft-features ConfigMap %s/%s: %w", ns, name, createErr) + } + if getErr != nil { + if isSoftFeaturesRetryableError(getErr) { + lastRetryErr = getErr + logger.Info("get soft-features ConfigMap retried", "module", parentModuleName, "namespace", ns, "attempt", attempt, "error", getErr.Error()) + return false, nil + } + return false, fmt.Errorf("get soft-features ConfigMap %s/%s: %w", ns, name, getErr) + } + if cm.Data == nil { + cm.Data = map[string]string{} + } + for k, v := range data { + cm.Data[k] = v + } + if cm.Labels == nil { + cm.Labels = map[string]string{} + } + cm.Labels[managedByLabelKey] = managedByLabelValue + cm.Labels[moduleLabelKey] = parentModuleName + updateErr := writer.Update(ctx, cm) + if updateErr == nil { + return true, nil + } + if apierrors.IsConflict(updateErr) || isSoftFeaturesRetryableError(updateErr) { + lastRetryErr = updateErr + logger.Info("update soft-features ConfigMap retried", "module", parentModuleName, "namespace", ns, "attempt", attempt, "error", updateErr.Error()) + return false, nil + } + return false, fmt.Errorf("update soft-features ConfigMap %s/%s: %w", ns, name, updateErr) + }) + if err == nil { + continue + } + if lastRetryErr == nil { + lastRetryErr = err + } + return fmt.Errorf("sync soft-features ConfigMap %s/%s: %w", ns, name, lastRetryErr) + } + return nil +} + +func ensureNamespaceReady(ctx context.Context, apiReader client.Reader, namespace string) error { + ns := &corev1.Namespace{} + if err := apiReader.Get(ctx, client.ObjectKey{Name: namespace}, ns); err != nil { + return err + } + if !ns.GetDeletionTimestamp().IsZero() { + return namespaceTerminatingError{namespace: namespace} + } + return nil +} + +func isSoftFeaturesRetryableError(err error) bool { + var terminating namespaceTerminatingError + if errors.As(err, &terminating) { + return true + } + return isNamespaceNotFoundError(err) || isNamespaceTerminatingStatusError(err) +} + +func isNamespaceNotFoundError(err error) bool { + if !apierrors.IsNotFound(err) { + return false + } + if statusErr, ok := err.(*apierrors.StatusError); ok { + details := statusErr.Status().Details + if details != nil && strings.EqualFold(details.Kind, "namespaces") { + return true + } + msg := strings.ToLower(statusErr.Status().Message) + if strings.Contains(msg, "namespaces") && strings.Contains(msg, "not found") { + return true + } + } + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "namespaces") && strings.Contains(msg, "not found") +} + +func isNamespaceTerminatingStatusError(err error) bool { + if !apierrors.IsForbidden(err) { + return false + } + if statusErr, ok := err.(*apierrors.StatusError); ok { + msg := strings.ToLower(statusErr.Status().Message) + if strings.Contains(msg, "is being terminated") { + return true + } + details := statusErr.Status().Details + if details != nil { + for _, cause := range details.Causes { + if cause.Type == metav1.CauseTypeForbidden && strings.Contains(strings.ToLower(cause.Message), "is being terminated") { + return true + } + } + } + } + return strings.Contains(strings.ToLower(err.Error()), "is being terminated") +} diff --git a/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/controller/soft_features_configmap_test.go b/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/controller/soft_features_configmap_test.go new file mode 100644 index 00000000..c1877a16 --- /dev/null +++ b/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/controller/soft_features_configmap_test.go @@ -0,0 +1,105 @@ +// Copyright (c) 2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package controller + +import ( + "context" + "fmt" + "testing" + + horizonv1alpha1 "github.com/acn-horizon-sdv/module-manager/api/v1alpha1" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func TestSoftFeaturesConfigMapName(t *testing.T) { + if got := SoftFeaturesConfigMapName("sample-module"); got != "horizon-sdv-soft-features-sample-module" { + t.Fatalf("got %q", got) + } + if got := SoftFeaturesConfigMapName("foo_bar"); got != "horizon-sdv-soft-features-foo-bar" { + t.Fatalf("got %q", got) + } +} + +func TestBuildSoftFeaturesConfigMapData(t *testing.T) { + data := buildSoftFeaturesConfigMapData(map[string]bool{"sample-soft-module": true, "a": false}) + wantEnv := "SOFT_FEATURE_ENABLED_A=false\nSOFT_FEATURE_ENABLED_SAMPLE_SOFT_MODULE=true" + if data[SoftFeaturesConfigMapKeyEnv] != wantEnv { + t.Fatalf("env payload:\n%s\nwant:\n%s", data[SoftFeaturesConfigMapKeyEnv], wantEnv) + } + wantJSON := `{"a":false,"sample-soft-module":true}` + if data[SoftFeaturesConfigMapKeyJSON] != wantJSON { + t.Fatalf("json: %s want %s", data[SoftFeaturesConfigMapKeyJSON], wantJSON) + } +} + +func TestEffectiveSoftFeaturesPropagation(t *testing.T) { + if g := effectiveSoftFeaturesPropagation(CatalogEntry{}); g != horizonv1alpha1.SoftFeaturesPropagationHelmValues { + t.Fatalf("default: %s", g) + } + if g := effectiveSoftFeaturesPropagation(CatalogEntry{SoftFeaturesPropagation: horizonv1alpha1.SoftFeaturesPropagationConfigMap}); g != horizonv1alpha1.SoftFeaturesPropagationConfigMap { + t.Fatalf("got %s", g) + } +} + +func TestEnsureSoftFeaturesConfigMaps_RetriesOnTerminatingNamespaceForbidden(t *testing.T) { + t.Parallel() + + ctx := context.Background() + ns := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "sample-module-hello"}} + baseClient := fake.NewClientBuilder().WithObjects(ns).Build() + writer := &forbiddenOnceOnCreateClient{ + Client: baseClient, + err: apierrors.NewForbidden( + schema.GroupResource{Resource: "configmaps"}, + "horizon-sdv-soft-features-sample", + fmt.Errorf("unable to create new content in namespace %s because it is being terminated", ns.Name), + ), + } + + err := EnsureSoftFeaturesConfigMaps(ctx, baseClient, writer, []string{ns.Name}, "sample", map[string]bool{"sample-soft": true}) + if err != nil { + t.Fatalf("EnsureSoftFeaturesConfigMaps() error = %v", err) + } + if writer.createCalls != 2 { + t.Fatalf("expected 2 create attempts, got %d", writer.createCalls) + } + + cm := &corev1.ConfigMap{} + if err := baseClient.Get(ctx, client.ObjectKey{Namespace: ns.Name, Name: "horizon-sdv-soft-features-sample"}, cm); err != nil { + t.Fatalf("get configmap: %v", err) + } + if got := cm.Data[SoftFeaturesConfigMapKeyJSON]; got != `{"sample-soft":true}` { + t.Fatalf("unexpected soft-features.json payload: %s", got) + } +} + +type forbiddenOnceOnCreateClient struct { + client.Client + err error + createCalls int +} + +func (c *forbiddenOnceOnCreateClient) Create(ctx context.Context, obj client.Object, opts ...client.CreateOption) error { + c.createCalls++ + if c.createCalls == 1 { + return c.err + } + return c.Client.Create(ctx, obj, opts...) +} diff --git a/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/controller/state.go b/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/controller/state.go new file mode 100644 index 00000000..234f2473 --- /dev/null +++ b/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/controller/state.go @@ -0,0 +1,159 @@ +// Copyright (c) 2024-2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package controller + +import ( + "context" + "encoding/json" + "strings" + "sync" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +const ( + stateKey = "state" +) + +// WorkflowsVisibilitySettings is persisted Developer Portal preference (stored in module manager state). +type WorkflowsVisibilitySettings struct { + // AllowedSubmittedFrom lists horizon-sdv.io/submitted-from label values that may appear in the portal. + // Empty or nil means no restriction (show all sources the API returns). + AllowedSubmittedFrom []string `json:"allowedSubmittedFrom,omitempty"` +} + +// State holds enabled modules and name-to-ID mapping. +type State struct { + EnabledModules []string `json:"enabledModules"` + ModuleIDs map[string]string `json:"moduleIds"` // name -> assigned ID + // ModuleTargetRevisions maps module name to Argo CD spec.source.targetRevision (Git ref). + // Missing key means use the Module Manager process default (same as --target-revision). + ModuleTargetRevisions map[string]string `json:"moduleTargetRevisions,omitempty"` + // WorkflowsVisibility is optional portal configuration (ignored by module enable/disable logic). + WorkflowsVisibility *WorkflowsVisibilitySettings `json:"workflowsVisibility,omitempty"` +} + +// EffectiveTargetRevision returns the Git ref for moduleName from persisted state, or defaultRev when unset. +func EffectiveTargetRevision(state *State, moduleName, defaultRev string) string { + defaultRev = strings.TrimSpace(defaultRev) + if state != nil && state.ModuleTargetRevisions != nil { + if r, ok := state.ModuleTargetRevisions[moduleName]; ok && strings.TrimSpace(r) != "" { + return strings.TrimSpace(r) + } + } + return defaultRev +} + +// StateStoreInterface is implemented by both StateStore (ConfigMap) and StateStoreCR (CRD). +type StateStoreInterface interface { + Get(ctx context.Context) (*State, error) + Update(ctx context.Context, st *State) error + InvalidateCache() +} + +// StateStore reads and writes module state from a ConfigMap (deprecated: use StateStoreCR). +type StateStore struct { + client client.Client + ns string + name string + mu sync.RWMutex + cache *State +} + +// NewStateStore returns a StateStore that uses the given ConfigMap. +func NewStateStore(c client.Client, namespace, configMapName string) *StateStore { + return &StateStore{client: c, ns: namespace, name: configMapName} +} + +// Get returns the current state, reading from the cluster if cache is nil. +func (s *StateStore) Get(ctx context.Context) (*State, error) { + s.mu.RLock() + if s.cache != nil { + st := *s.cache + s.mu.RUnlock() + return &st, nil + } + s.mu.RUnlock() + + cm := &corev1.ConfigMap{} + err := s.client.Get(ctx, client.ObjectKey{Namespace: s.ns, Name: s.name}, cm) + if err != nil { + if apierrors.IsNotFound(err) { + st := &State{ModuleIDs: make(map[string]string)} + s.mu.Lock() + s.cache = st + s.mu.Unlock() + return st, nil + } + return nil, err + } + + var st State + if raw, ok := cm.Data[stateKey]; ok && raw != "" { + if err := json.Unmarshal([]byte(raw), &st); err != nil { + return nil, err + } + } + if st.ModuleIDs == nil { + st.ModuleIDs = make(map[string]string) + } + + s.mu.Lock() + s.cache = &st + s.mu.Unlock() + return &st, nil +} + +// Update persists state to the ConfigMap. +func (s *StateStore) Update(ctx context.Context, st *State) error { + data, err := json.Marshal(st) + if err != nil { + return err + } + + cm := &corev1.ConfigMap{} + err = s.client.Get(ctx, client.ObjectKey{Namespace: s.ns, Name: s.name}, cm) + if err != nil { + if apierrors.IsNotFound(err) { + cm = &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Namespace: s.ns, Name: s.name}, + Data: map[string]string{stateKey: string(data)}, + } + return s.client.Create(ctx, cm) + } + return err + } + if cm.Data == nil { + cm.Data = make(map[string]string) + } + cm.Data[stateKey] = string(data) + if err := s.client.Update(ctx, cm); err != nil { + return err + } + s.mu.Lock() + s.cache = st + s.mu.Unlock() + return nil +} + +// InvalidateCache clears the in-memory cache so the next Get reads from the cluster. +func (s *StateStore) InvalidateCache() { + s.mu.Lock() + s.cache = nil + s.mu.Unlock() +} diff --git a/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/controller/state_test.go b/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/controller/state_test.go new file mode 100644 index 00000000..06897a41 --- /dev/null +++ b/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/controller/state_test.go @@ -0,0 +1,36 @@ +// Copyright (c) 2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package controller + +import "testing" + +func TestEffectiveTargetRevision(t *testing.T) { + def := "HEAD" + st := &State{ + ModuleTargetRevisions: map[string]string{"a": "main", "b": " v1 "}, + } + if got := EffectiveTargetRevision(st, "a", def); got != "main" { + t.Fatalf("a: got %q", got) + } + if got := EffectiveTargetRevision(st, "b", def); got != "v1" { + t.Fatalf("b: got %q", got) + } + if got := EffectiveTargetRevision(st, "missing", def); got != "HEAD" { + t.Fatalf("missing: got %q", got) + } + if got := EffectiveTargetRevision(nil, "x", " develop "); got != "develop" { + t.Fatalf("nil state: got %q", got) + } +} diff --git a/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/controller/statestore_cr.go b/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/controller/statestore_cr.go new file mode 100644 index 00000000..2f108acc --- /dev/null +++ b/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/controller/statestore_cr.go @@ -0,0 +1,181 @@ +// Copyright (c) 2024-2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package controller + +import ( + "context" + "sync" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + horizonv1alpha1 "github.com/acn-horizon-sdv/module-manager/api/v1alpha1" +) + +// DefaultModuleManagerStateName is the name of the singleton ModuleManagerState CR. +const DefaultModuleManagerStateName = "cluster" + +// StateStoreCR reads and writes module state from a ModuleManagerState custom resource. +type StateStoreCR struct { + client client.Client + ns string + name string + mu sync.RWMutex + cache *State +} + +// NewStateStoreCR returns a StateStore that uses the given ModuleManagerState CR (singleton per namespace). +func NewStateStoreCR(c client.Client, namespace, stateCRName string) *StateStoreCR { + if stateCRName == "" { + stateCRName = DefaultModuleManagerStateName + } + return &StateStoreCR{client: c, ns: namespace, name: stateCRName} +} + +// Get returns the current state from the ModuleManagerState CR. +func (s *StateStoreCR) Get(ctx context.Context) (*State, error) { + s.mu.RLock() + if s.cache != nil { + st := *s.cache + s.mu.RUnlock() + return &st, nil + } + s.mu.RUnlock() + + obj := &horizonv1alpha1.ModuleManagerState{} + err := s.client.Get(ctx, client.ObjectKey{Namespace: s.ns, Name: s.name}, obj) + if err != nil { + if apierrors.IsNotFound(err) { + st := &State{ModuleIDs: make(map[string]string)} + s.mu.Lock() + s.cache = st + s.mu.Unlock() + return st, nil + } + return nil, err + } + + st := &State{ + EnabledModules: append([]string(nil), obj.Status.EnabledModules...), + ModuleIDs: make(map[string]string), + } + if obj.Status.ModuleIDs != nil { + for k, v := range obj.Status.ModuleIDs { + st.ModuleIDs[k] = v + } + } + if obj.Status.ModuleTargetRevisions != nil { + st.ModuleTargetRevisions = make(map[string]string, len(obj.Status.ModuleTargetRevisions)) + for k, v := range obj.Status.ModuleTargetRevisions { + st.ModuleTargetRevisions[k] = v + } + } + if wv := obj.Status.WorkflowsVisibility; wv != nil { + st.WorkflowsVisibility = &WorkflowsVisibilitySettings{ + AllowedSubmittedFrom: append([]string(nil), wv.AllowedSubmittedFrom...), + } + } + if st.ModuleIDs == nil { + st.ModuleIDs = make(map[string]string) + } + + s.mu.Lock() + s.cache = st + s.mu.Unlock() + return st, nil +} + +// Update persists state to the ModuleManagerState CR. +func (s *StateStoreCR) Update(ctx context.Context, st *State) error { + obj := &horizonv1alpha1.ModuleManagerState{} + err := s.client.Get(ctx, client.ObjectKey{Namespace: s.ns, Name: s.name}, obj) + if err != nil { + if apierrors.IsNotFound(err) { + obj = &horizonv1alpha1.ModuleManagerState{ + ObjectMeta: metav1.ObjectMeta{Namespace: s.ns, Name: s.name}, + } + if err := s.client.Create(ctx, obj); err != nil { + return err + } + // Status is often ignored on Create when using subresources; set it explicitly. + obj.Status.EnabledModules = append([]string(nil), st.EnabledModules...) + obj.Status.ModuleIDs = make(map[string]string) + if st.ModuleIDs != nil { + for k, v := range st.ModuleIDs { + obj.Status.ModuleIDs[k] = v + } + } + if st.WorkflowsVisibility != nil { + obj.Status.WorkflowsVisibility = &horizonv1alpha1.WorkflowsVisibilitySettings{ + AllowedSubmittedFrom: append([]string(nil), st.WorkflowsVisibility.AllowedSubmittedFrom...), + } + } + if st.ModuleTargetRevisions != nil { + obj.Status.ModuleTargetRevisions = make(map[string]string, len(st.ModuleTargetRevisions)) + for k, v := range st.ModuleTargetRevisions { + obj.Status.ModuleTargetRevisions[k] = v + } + } else { + obj.Status.ModuleTargetRevisions = nil + } + if err := s.client.Status().Update(ctx, obj); err != nil { + return err + } + s.mu.Lock() + s.cache = st + s.mu.Unlock() + return nil + } + return err + } + + obj.Status.EnabledModules = append([]string(nil), st.EnabledModules...) + obj.Status.ModuleIDs = make(map[string]string) + if st.ModuleIDs != nil { + for k, v := range st.ModuleIDs { + obj.Status.ModuleIDs[k] = v + } + } + if st.ModuleTargetRevisions != nil { + obj.Status.ModuleTargetRevisions = make(map[string]string, len(st.ModuleTargetRevisions)) + for k, v := range st.ModuleTargetRevisions { + obj.Status.ModuleTargetRevisions[k] = v + } + } else { + obj.Status.ModuleTargetRevisions = nil + } + if st.WorkflowsVisibility != nil { + obj.Status.WorkflowsVisibility = &horizonv1alpha1.WorkflowsVisibilitySettings{ + AllowedSubmittedFrom: append([]string(nil), st.WorkflowsVisibility.AllowedSubmittedFrom...), + } + } else { + obj.Status.WorkflowsVisibility = nil + } + if err := s.client.Status().Update(ctx, obj); err != nil { + return err + } + s.mu.Lock() + s.cache = st + s.mu.Unlock() + return nil +} + +// InvalidateCache clears the in-memory cache so the next Get reads from the cluster. +func (s *StateStoreCR) InvalidateCache() { + s.mu.Lock() + s.cache = nil + s.mu.Unlock() +} diff --git a/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/controller/transaction.go b/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/controller/transaction.go new file mode 100644 index 00000000..d975f803 --- /dev/null +++ b/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/controller/transaction.go @@ -0,0 +1,14 @@ +// Copyright (c) 2024-2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. + +package controller + +import "sync" + +// ModuleOpsMutex serializes module lifecycle transactions that mutate +// ModuleManagerState, Argo CD Applications, and dependent soft-feature state. +// Hold this mutex for the full duration of top-level enable/disable/auto-disable +// operations to prevent race conditions between API handlers and reconcilers. +var ModuleOpsMutex sync.Mutex diff --git a/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/controller/transaction_test.go b/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/controller/transaction_test.go new file mode 100644 index 00000000..93c4edc4 --- /dev/null +++ b/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/controller/transaction_test.go @@ -0,0 +1,41 @@ +// Copyright (c) 2024-2026 Accenture, All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 + +package controller + +import ( + "sync/atomic" + "testing" + "time" +) + +func TestModuleOpsMutexSerializesConcurrentCriticalSections(t *testing.T) { + t.Parallel() + + start := make(chan struct{}) + done := make(chan struct{}, 2) + var inCritical atomic.Int32 + var overlap atomic.Bool + + worker := func() { + <-start + ModuleOpsMutex.Lock() + if inCritical.Add(1) > 1 { + overlap.Store(true) + } + time.Sleep(25 * time.Millisecond) + inCritical.Add(-1) + ModuleOpsMutex.Unlock() + done <- struct{}{} + } + + go worker() + go worker() + close(start) + <-done + <-done + if overlap.Load() { + t.Fatal("expected ModuleOpsMutex to serialize critical sections") + } +} diff --git a/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/overviewfetch/fetch.go b/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/overviewfetch/fetch.go new file mode 100644 index 00000000..a4a0a008 --- /dev/null +++ b/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/overviewfetch/fetch.go @@ -0,0 +1,83 @@ +// Copyright (c) 2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Package overviewfetch loads module overview HTML from an in-cluster HTTP endpoint. +package overviewfetch + +import ( + "context" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "strings" +) + +// MaxOverviewBytes caps HTML size returned to clients. +const MaxOverviewBytes = 2 << 20 + +// ErrOverviewNotFound means the HTTP server returned 404 for the overview URL. +var ErrOverviewNotFound = errors.New("overview not found") + +// BuildInClusterOverviewURL returns http://..svc.cluster.local:80/ (fixed port and path). +func BuildInClusterOverviewURL(service, namespace string) (string, error) { + service = strings.TrimSpace(service) + namespace = strings.TrimSpace(namespace) + if service == "" || namespace == "" { + return "", errors.New("overviewService and overviewServiceNamespace are required") + } + host := service + "." + namespace + ".svc.cluster.local" + u := url.URL{ + Scheme: "http", + Host: net.JoinHostPort(host, "80"), + Path: "/", + } + return u.String(), nil +} + +// FetchHTTPOverview performs GET url and returns the body for status 200. +func FetchHTTPOverview(ctx context.Context, client *http.Client, pageURL string) ([]byte, error) { + if strings.TrimSpace(pageURL) == "" { + return nil, errors.New("empty overview URL") + } + if client == nil { + client = http.DefaultClient + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, pageURL, nil) + if err != nil { + return nil, err + } + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + body, err := io.ReadAll(io.LimitReader(resp.Body, MaxOverviewBytes+1)) + if err != nil { + return nil, err + } + if len(body) > MaxOverviewBytes { + return nil, fmt.Errorf("overview response exceeds %d bytes", MaxOverviewBytes) + } + switch resp.StatusCode { + case http.StatusOK: + return body, nil + case http.StatusNotFound: + return nil, ErrOverviewNotFound + default: + return nil, fmt.Errorf("overview HTTP %s", resp.Status) + } +} diff --git a/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/overviewfetch/fetch_test.go b/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/overviewfetch/fetch_test.go new file mode 100644 index 00000000..7c3e885c --- /dev/null +++ b/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/internal/overviewfetch/fetch_test.go @@ -0,0 +1,74 @@ +// Copyright (c) 2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package overviewfetch + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestBuildInClusterOverviewURL(t *testing.T) { + got, err := BuildInClusterOverviewURL("mod-sample-overview", "sample-module-hello") + if err != nil { + t.Fatal(err) + } + want := "http://mod-sample-overview.sample-module-hello.svc.cluster.local:80/" + if got != want { + t.Fatalf("got %q want %q", got, want) + } + if _, err := BuildInClusterOverviewURL("", "ns"); err == nil { + t.Fatal("expected error for empty service") + } +} + +func TestFetchHTTPOverview_ok(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("")) + })) + t.Cleanup(srv.Close) + b, err := FetchHTTPOverview(context.Background(), srv.Client(), srv.URL) + if err != nil { + t.Fatal(err) + } + if string(b) != "" { + t.Fatalf("got %q", b) + } +} + +func TestFetchHTTPOverview_notFound(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.NotFound(w, nil) + })) + t.Cleanup(srv.Close) + _, err := FetchHTTPOverview(context.Background(), srv.Client(), srv.URL) + if !errors.Is(err, ErrOverviewNotFound) { + t.Fatalf("got %v want ErrOverviewNotFound", err) + } +} + +func TestFetchHTTPOverview_upstreamError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "no", http.StatusBadGateway) + })) + t.Cleanup(srv.Close) + _, err := FetchHTTPOverview(context.Background(), srv.Client(), srv.URL) + if err == nil || !strings.Contains(err.Error(), "502") { + t.Fatalf("expected 502 in error, got %v", err) + } +} diff --git a/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/main.go b/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/main.go new file mode 100644 index 00000000..99a037aa --- /dev/null +++ b/terraform/modules/sdv-container-images/images/module-manager/module-manager-app/main.go @@ -0,0 +1,227 @@ +// Copyright (c) 2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "context" + "flag" + "log" + "net/http" + "os" + "strings" + "time" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + "k8s.io/client-go/discovery" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/cache" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/healthz" + crlog "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" + + horizonv1alpha1 "github.com/acn-horizon-sdv/module-manager/api/v1alpha1" + "github.com/acn-horizon-sdv/module-manager/internal/api" + "github.com/acn-horizon-sdv/module-manager/internal/controller" +) + +var ( + scheme = runtime.NewScheme() +) + +func init() { + utilruntime.Must(clientgoscheme.AddToScheme(scheme)) + utilruntime.Must(horizonv1alpha1.AddToScheme(scheme)) +} + +func main() { + var ( + metricsAddr string + probeAddr string + apiAddr string + namespace string + argocdNamespace string + stateCRName string + catalogCRName string + argocdProject string + repoURL string + targetRevision string + gitopsRootAppNames string + ) + flag.StringVar(&metricsAddr, "metrics-bind-address", ":8080", "Address for metrics.") + flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "Address for health probes.") + flag.StringVar(&apiAddr, "api-bind-address", ":8082", "Address for REST API.") + flag.StringVar(&namespace, "namespace", "module-manager", "Namespace for Module Manager and state.") + flag.StringVar(&argocdNamespace, "argocd-namespace", "argocd", "Namespace where ArgoCD Applications are created.") + flag.StringVar(&stateCRName, "state-cr-name", "cluster", "Name of the singleton ModuleManagerState CR for persisted state.") + flag.StringVar(&catalogCRName, "catalog-cr-name", "cluster", "Name of the singleton ModuleCatalog CR for the module catalog.") + flag.StringVar(&argocdProject, "argocd-project", "horizon-sdv", "ArgoCD AppProject for module Applications.") + flag.StringVar(&repoURL, "repo-url", "", "Git repo URL for module charts (override from catalog).") + flag.StringVar(&targetRevision, "target-revision", "HEAD", "Git target revision (e.g. HEAD, main).") + flag.StringVar(&gitopsRootAppNames, "gitops-root-app-name", "", + "Comma-separated names of root Argo CD app-of-apps Applications (e.g. prefix+horizon-sdv). Falls back to GITOPS_ROOT_APP_NAME when empty.") + flag.Parse() + + crlog.SetLogger(zap.New(zap.UseDevMode(false))) + + if repoURL == "" { + repoURL = os.Getenv("REPO_URL") + } + if repoURL == "" { + log.Fatal("repo-url or REPO_URL must be set") + } + + moduleConfig := os.Getenv("MODULE_CONFIG") + + rootAppNameList := splitCommaTrim(gitopsRootAppNames) + if len(rootAppNameList) == 0 { + rootAppNameList = splitCommaTrim(os.Getenv("GITOPS_ROOT_APP_NAME")) + } + + ctx := ctrl.SetupSignalHandler() + + cfg := ctrl.GetConfigOrDie() + + // Restrict the informer cache per resource type so that each resource type is + // only listed/watched in the namespace where it actually lives. Using a blanket + // DefaultNamespaces that covers both namespaces causes controller-runtime to try + // to list every registered type in every namespace, which triggers RBAC denials + // (e.g. Applications in module-manager) and causes the cache to time out and the + // manager to crash. + argoAppObj := &unstructured.Unstructured{} + argoAppObj.SetGroupVersionKind(schema.GroupVersionKind{Group: "argoproj.io", Version: "v1alpha1", Kind: "Application"}) + + mgr, err := ctrl.NewManager(cfg, ctrl.Options{ + Scheme: scheme, + Metrics: metricsserver.Options{BindAddress: metricsAddr}, + HealthProbeBindAddress: probeAddr, + LeaderElection: false, + Cache: cache.Options{ + // Default: custom CRDs (ModuleManagerState, ModuleCatalog) live only in module-manager namespace. + DefaultNamespaces: map[string]cache.Config{ + namespace: {}, + }, + // Override for ArgoCD Applications: watched only in the argocd namespace. + ByObject: map[client.Object]cache.ByObject{ + argoAppObj: { + Namespaces: map[string]cache.Config{ + argocdNamespace: {}, + }, + }, + }, + }, + }) + if err != nil { + log.Fatalf("creating manager: %v", err) + } + + // State and catalog live in the same namespace as the controller. + stateStore := controller.NewStateStoreCR(mgr.GetClient(), namespace, stateCRName) + catalogStore := controller.NewCatalogStoreCR(mgr.GetClient(), namespace, catalogCRName) + + apiReader := mgr.GetAPIReader() + + // ModuleCatalog is the authoritative desired-state source; ModuleManagerState holds + // runtime state. The REST API handler and ModuleCatalogReconciler build Argo + // Applications deterministically from catalog entries (see controller.ApplicationName). + + if err := mgr.Add(&controller.ModuleConfigHelmStartupSync{ + Client: mgr.GetClient(), + APIReader: apiReader, + ArgoNS: argocdNamespace, + ModuleConfig: moduleConfig, + }); err != nil { + log.Fatalf("module-config helm startup sync: %v", err) + } + + catalogReconciler := &controller.ModuleCatalogReconciler{ + Client: mgr.GetClient(), + APIReader: apiReader, + Namespace: namespace, + ArgoCDNamespace: argocdNamespace, + StateStore: stateStore, + CatalogStore: catalogStore, + } + if err = catalogReconciler.SetupWithManager(mgr); err != nil { + log.Fatalf("setting up ModuleCatalog reconciler: %v", err) + } + + discoveryClient, err := discovery.NewDiscoveryClientForConfig(cfg) + if err != nil { + log.Fatalf("creating discovery client: %v", err) + } + + drainer := &controller.PlatformDrainer{ + Client: mgr.GetClient(), + APIReader: apiReader, + DiscoveryClient: discoveryClient, + StateStore: stateStore, + CatalogStore: catalogStore, + Namespace: namespace, + ArgoCDNamespace: argocdNamespace, + ArgoCDProject: argocdProject, + } + + rootFin := &controller.RootGitOpsApplicationReconciler{ + Client: mgr.GetClient(), + Drainer: drainer, + ArgoCDNamespace: argocdNamespace, + GitOpsRootAppNames: rootAppNameList, + } + if err := rootFin.SetupWithManager(mgr); err != nil { + log.Fatalf("setting up root GitOps Application finalizer reconciler: %v", err) + } + + if err := mgr.AddHealthzCheck("health", healthz.Ping); err != nil { + log.Fatalf("adding health check: %v", err) + } + if err := mgr.AddReadyzCheck("ready", healthz.Ping); err != nil { + log.Fatalf("adding ready check: %v", err) + } + + // REST API server (runs in goroutine, uses the same client and stores). + apiHandler := api.NewHandler(mgr.GetClient(), apiReader, stateStore, catalogStore, namespace, argocdNamespace, argocdProject, repoURL, targetRevision, moduleConfig) + srv := &http.Server{Addr: apiAddr, Handler: apiHandler.Routes(), ReadHeaderTimeout: 10 * time.Second} + + go func() { + log.Printf("REST API listening on %s", apiAddr) + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Printf("API server error: %v", err) + } + }() + + if err := mgr.Start(ctx); err != nil { + log.Printf("manager stopped: %v", err) + } + _ = srv.Shutdown(context.Background()) +} + +func splitCommaTrim(s string) []string { + if s == "" { + return nil + } + var out []string + for _, p := range strings.Split(s, ",") { + if t := strings.TrimSpace(p); t != "" { + out = append(out, t) + } + } + return out +} diff --git a/terraform/modules/sdv-container-images/images/mtk-connect/mtk-connect-post-key/Dockerfile b/terraform/modules/sdv-container-images/images/mtk-connect/mtk-connect-post-key/Dockerfile index 594ae76a..cef84316 100644 --- a/terraform/modules/sdv-container-images/images/mtk-connect/mtk-connect-post-key/Dockerfile +++ b/terraform/modules/sdv-container-images/images/mtk-connect/mtk-connect-post-key/Dockerfile @@ -12,7 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. -FROM --platform=linux/amd64 python:3.9-slim-bookworm +# requests>=2.33.0 requires Python >=3.10 (see PyPI Requires-Python). +FROM --platform=linux/amd64 python:3.12-slim-bookworm # Add security repository and upgrade all packages to fix CVEs RUN echo "deb http://security.debian.org/debian-security bookworm-security main" > /etc/apt/sources.list.d/security.list && \ @@ -20,13 +21,15 @@ RUN echo "deb http://security.debian.org/debian-security bookworm-security main" apt clean && rm -rf /var/lib/apt/lists/* RUN pip install --upgrade pip -RUN useradd -m appuser -USER appuser WORKDIR /app -COPY --chown=appuser:appuser requirements.txt /app/ +# Install dependencies as root (system site-packages is not writable for non-root). +COPY requirements.txt /app/ RUN pip install --no-cache-dir -r requirements.txt -COPY --chown=appuser:appuser configure-key.py /app/ +COPY configure-key.py /app/ + +RUN useradd -m appuser && chown -R appuser:appuser /app +USER appuser ENTRYPOINT ["python", "/app/configure-key.py"] diff --git a/terraform/modules/sdv-container-images/images/mtk-connect/mtk-connect-post-key/requirements.txt b/terraform/modules/sdv-container-images/images/mtk-connect/mtk-connect-post-key/requirements.txt index 358a5ae0..aa9d2a83 100644 --- a/terraform/modules/sdv-container-images/images/mtk-connect/mtk-connect-post-key/requirements.txt +++ b/terraform/modules/sdv-container-images/images/mtk-connect/mtk-connect-post-key/requirements.txt @@ -12,6 +12,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -requests==2.32.5 +requests==2.33.1 python-dateutil==2.9.0.post0 kubernetes==32.0.1 diff --git a/terraform/modules/sdv-container-images/images/workflow-namespace-drain/workflow-namespace-drain-app/Dockerfile b/terraform/modules/sdv-container-images/images/workflow-namespace-drain/workflow-namespace-drain-app/Dockerfile new file mode 100644 index 00000000..a58869e0 --- /dev/null +++ b/terraform/modules/sdv-container-images/images/workflow-namespace-drain/workflow-namespace-drain-app/Dockerfile @@ -0,0 +1,27 @@ +# Copyright (c) 2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# Build the Workflow Namespace Drain controller binary. +FROM --platform=linux/amd64 golang:1.25-alpine AS builder +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /workflow-namespace-drain . + +FROM --platform=linux/amd64 alpine:3.19 +RUN apk --no-cache add ca-certificates +COPY --from=builder --chown=nobody:nobody /workflow-namespace-drain /workflow-namespace-drain +USER nobody +ENTRYPOINT ["/workflow-namespace-drain"] diff --git a/terraform/modules/sdv-container-images/images/workflow-namespace-drain/workflow-namespace-drain-app/go.mod b/terraform/modules/sdv-container-images/images/workflow-namespace-drain/workflow-namespace-drain-app/go.mod new file mode 100644 index 00000000..6068d6b4 --- /dev/null +++ b/terraform/modules/sdv-container-images/images/workflow-namespace-drain/workflow-namespace-drain-app/go.mod @@ -0,0 +1,71 @@ +module github.com/acn-horizon-sdv/workflow-namespace-drain + +go 1.25.0 + +replace golang.org/x/crypto => golang.org/x/crypto v0.50.0 + +require ( + k8s.io/apimachinery v0.29.3 + k8s.io/client-go v0.29.3 + sigs.k8s.io/controller-runtime v0.17.3 +) + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/emicklei/go-restful/v3 v3.11.0 // indirect + github.com/evanphx/json-patch v4.12.0+incompatible // indirect + github.com/evanphx/json-patch/v5 v5.8.0 // indirect + github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/go-logr/logr v1.4.1 // indirect + github.com/go-logr/zapr v1.3.0 // indirect + github.com/go-openapi/jsonpointer v0.19.6 // indirect + github.com/go-openapi/jsonreference v0.20.2 // indirect + github.com/go-openapi/swag v0.22.3 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/google/gnostic-models v0.6.8 // indirect + github.com/google/go-cmp v0.6.0 // indirect + github.com/google/gofuzz v1.2.0 // indirect + github.com/google/uuid v1.3.0 // indirect + github.com/imdario/mergo v0.3.6 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/prometheus/client_golang v1.18.0 // indirect + github.com/prometheus/client_model v0.5.0 // indirect + github.com/prometheus/common v0.45.0 // indirect + github.com/prometheus/procfs v0.12.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.26.0 // indirect + golang.org/x/exp v0.0.0-20220722155223-a9213eeb770e // indirect + golang.org/x/net v0.52.0 // indirect + golang.org/x/oauth2 v0.12.0 // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/term v0.42.0 // indirect + golang.org/x/text v0.36.0 // indirect + golang.org/x/time v0.3.0 // indirect + gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect + google.golang.org/appengine v1.6.7 // indirect + google.golang.org/protobuf v1.33.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + k8s.io/api v0.29.3 // indirect + k8s.io/apiextensions-apiserver v0.29.2 // indirect + k8s.io/component-base v0.29.2 // indirect + k8s.io/klog/v2 v2.110.1 // indirect + k8s.io/kube-openapi v0.0.0-20231010175941-2dd684a91f00 // indirect + k8s.io/utils v0.0.0-20230726121419-3b25d923346b // indirect + sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect + sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect + sigs.k8s.io/yaml v1.4.0 // indirect +) diff --git a/terraform/modules/sdv-container-images/images/workflow-namespace-drain/workflow-namespace-drain-app/go.sum b/terraform/modules/sdv-container-images/images/workflow-namespace-drain/workflow-namespace-drain-app/go.sum new file mode 100644 index 00000000..a3779c73 --- /dev/null +++ b/terraform/modules/sdv-container-images/images/workflow-namespace-drain/workflow-namespace-drain-app/go.sum @@ -0,0 +1,305 @@ +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= +github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g= +github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/evanphx/json-patch v4.12.0+incompatible h1:4onqiflcdA9EOZ4RxV643DvftH5pOlLGNtQ5lPWQu84= +github.com/evanphx/json-patch v4.12.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= +github.com/evanphx/json-patch/v5 v5.8.0 h1:lRj6N9Nci7MvzrXuX6HFzU8XjmhPiXPlsKEy1u0KQro= +github.com/evanphx/json-patch/v5 v5.8.0/go.mod h1:VNkHZ/282BpEyt/tObQO8s5CMPmYYq14uClGH4abBuQ= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/go-logr/logr v1.3.0/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= +github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= +github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= +github.com/go-openapi/jsonpointer v0.19.6 h1:eCs3fxoIi3Wh6vtgmLTOjdhSpiqphQ+DaPn38N2ZdrE= +github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= +github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= +github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= +github.com/go-openapi/swag v0.22.3 h1:yMBqmnQ0gyZvEb/+KzuWZOXgllrXT4SADYbvDaXHv/g= +github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= +github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= +github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/gnostic-models v0.6.8 h1:yo/ABAfM5IMRsS1VnXjTBvUb61tFIHozhlYvRgGre9I= +github.com/google/gnostic-models v0.6.8/go.mod h1:5n7qKqH0f5wFt+aWF8CW6pZLLNOfYuF5OpfBSENuI8U= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1 h1:K6RDEckDVWvDI9JAJYCmNdQXq6neHJOYx3V6jnqNEec= +github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/imdario/mergo v0.3.6 h1:xTNEAn+kxVO7dTZGu0CegyqKZmoWFI0rF8UxjlB2d28= +github.com/imdario/mergo v0.3.6/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg= +github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/onsi/ginkgo/v2 v2.14.0 h1:vSmGj2Z5YPb9JwCWT6z6ihcUvDhuXLc3sJiqd3jMKAY= +github.com/onsi/ginkgo/v2 v2.14.0/go.mod h1:JkUdW7JkN0V6rFvsHcJ478egV3XH9NxpD27Hal/PhZw= +github.com/onsi/gomega v1.30.0 h1:hvMK7xYz4D3HapigLTeGdId/NcfQx1VHMJc60ew99+8= +github.com/onsi/gomega v1.30.0/go.mod h1:9sxs+SwGrKI0+PWe4Fxa9tFQQBG5xSsSbMXOI8PPpoQ= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.18.0 h1:HzFfmkOzH5Q8L8G+kSJKUx5dtG87sewO+FoDDqP5Tbk= +github.com/prometheus/client_golang v1.18.0/go.mod h1:T+GXkCk5wSJyOqMIzVgvvjFDlkOQntgjkJWKrN5txjA= +github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw= +github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI= +github.com/prometheus/common v0.45.0 h1:2BGz0eBc2hdMDLnO/8n0jeB3oPrt2D08CekT0lneoxM= +github.com/prometheus/common v0.45.0/go.mod h1:YJmSTw9BoKxJplESWWxlbyttQR4uaEcGyv9MZjVOJsY= +github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo= +github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo= +go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/exp v0.0.0-20220722155223-a9213eeb770e h1:+WEEuIdZHnUeJJmEUjyYC2gfUMj69yZXw17EnHg/otA= +golang.org/x/exp v0.0.0-20220722155223-a9213eeb770e/go.mod h1:Kr81I6Kryrl9sr8s2FK3vxD90NdsKWRuOIl2O4CvYbA= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ= +golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= +golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI= +golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= +golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= +golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= +golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= +golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= +golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= +golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= +golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/oauth2 v0.12.0 h1:smVPGxink+n1ZI5pkQa8y6fZT0RW0MgCO5bFpepy4B4= +golang.org/x/oauth2 v0.12.0/go.mod h1:A74bZ3aGXgCY0qaIC9Ahg6Lglin4AMAco8cIv9baba4= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= +golang.org/x/telemetry v0.0.0-20240521205824-bda55230c457/go.mod h1:pRgIJT+bRLFKnoM1ldnzKoxTIn14Yxz928LQRYYgIN0= +golang.org/x/telemetry v0.0.0-20250710130107-8d8967aff50b/go.mod h1:4ZwOYna0/zsOKwuR5X/m0QFOJpSZvAxFfkQT+Erd9D4= +golang.org/x/telemetry v0.0.0-20250807160809-1a19826ec488/go.mod h1:fGb/2+tgXXjhjHsTNdVEEMZNWA0quBnfrO+AfoDSAKw= +golang.org/x/telemetry v0.0.0-20250908211612-aef8a434d053/go.mod h1:+nZKN+XVh4LCiA9DV3ywrzN4gumyCnKjau3NGb9SGoE= +golang.org/x/telemetry v0.0.0-20251008203120-078029d740a8/go.mod h1:Pi4ztBfryZoJEkyFTI5/Ocsu2jXyDr6iSdgJiYE/uwE= +golang.org/x/telemetry v0.0.0-20251111182119-bc8e575c7b54/go.mod h1:hKdjCMrbv9skySur+Nek8Hd0uJ0GuxJIoIX2payrIdQ= +golang.org/x/telemetry v0.0.0-20251203150158-8fff8a5912fc/go.mod h1:hKdjCMrbv9skySur+Nek8Hd0uJ0GuxJIoIX2payrIdQ= +golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2/go.mod h1:b7fPSJ0pKZ3ccUh8gnTONJxhn3c/PS6tyzQvyqw4iA8= +golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4/go.mod h1:g5NllXBEermZrmR51cJDQxmJUHUOfRAaNyWBM+R+548= +golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c/go.mod h1:TpUTTEp9frx7rTdLpC9gFG9kdI7zVLFTFFlqaH2Cncw= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= +golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0= +golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= +golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA= +golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= +golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= +golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= +golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= +golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= +golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= +golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= +golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= +golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= +golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= +golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI= +golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= +golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw= +golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= +golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= +golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= +golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= +golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= +gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= +google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= +google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= +google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/api v0.29.3 h1:2ORfZ7+bGC3YJqGpV0KSDDEVf8hdGQ6A03/50vj8pmw= +k8s.io/api v0.29.3/go.mod h1:y2yg2NTyHUUkIoTC+phinTnEa3KFM6RZ3szxt014a80= +k8s.io/apiextensions-apiserver v0.29.2 h1:UK3xB5lOWSnhaCk0RFZ0LUacPZz9RY4wi/yt2Iu+btg= +k8s.io/apiextensions-apiserver v0.29.2/go.mod h1:aLfYjpA5p3OwtqNXQFkhJ56TB+spV8Gc4wfMhUA3/b8= +k8s.io/apimachinery v0.29.3 h1:2tbx+5L7RNvqJjn7RIuIKu9XTsIZ9Z5wX2G22XAa5EU= +k8s.io/apimachinery v0.29.3/go.mod h1:hx/S4V2PNW4OMg3WizRrHutyB5la0iCUbZym+W0EQIU= +k8s.io/client-go v0.29.3 h1:R/zaZbEAxqComZ9FHeQwOh3Y1ZUs7FaHKZdQtIc2WZg= +k8s.io/client-go v0.29.3/go.mod h1:tkDisCvgPfiRpxGnOORfkljmS+UrW+WtXAy2fTvXJB0= +k8s.io/component-base v0.29.2 h1:lpiLyuvPA9yV1aQwGLENYyK7n/8t6l3nn3zAtFTJYe8= +k8s.io/component-base v0.29.2/go.mod h1:BfB3SLrefbZXiBfbM+2H1dlat21Uewg/5qtKOl8degM= +k8s.io/klog/v2 v2.110.1 h1:U/Af64HJf7FcwMcXyKm2RPM22WZzyR7OSpYj5tg3cL0= +k8s.io/klog/v2 v2.110.1/go.mod h1:YGtd1984u+GgbuZ7e08/yBuAfKLSO0+uR1Fhi6ExXjo= +k8s.io/kube-openapi v0.0.0-20231010175941-2dd684a91f00 h1:aVUu9fTY98ivBPKR9Y5w/AuzbMm96cd3YHRTU83I780= +k8s.io/kube-openapi v0.0.0-20231010175941-2dd684a91f00/go.mod h1:AsvuZPBlUDVuCdzJ87iajxtXuR9oktsTctW/R9wwouA= +k8s.io/utils v0.0.0-20230726121419-3b25d923346b h1:sgn3ZU783SCgtaSJjpcVVlRqd6GSnlTLKgpAAttJvpI= +k8s.io/utils v0.0.0-20230726121419-3b25d923346b/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +sigs.k8s.io/controller-runtime v0.17.3 h1:65QmN7r3FWgTxDMz9fvGnO1kbf2nu+acg9p2R9oYYYk= +sigs.k8s.io/controller-runtime v0.17.3/go.mod h1:N0jpP5Lo7lMTF9aL56Z/B2oWBJjey6StQM0jRbKQXtY= +sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo= +sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0= +sigs.k8s.io/structured-merge-diff/v4 v4.4.1 h1:150L+0vs/8DA78h1u02ooW1/fFq/Lwr+sGiqlzvrtq4= +sigs.k8s.io/structured-merge-diff/v4 v4.4.1/go.mod h1:N8hJocpFajUSSeSJ9bOZ77VzejKZaXsTtZo4/u7Io08= +sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= +sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= diff --git a/terraform/modules/sdv-container-images/images/workflow-namespace-drain/workflow-namespace-drain-app/internal/controller/root_finalizer.go b/terraform/modules/sdv-container-images/images/workflow-namespace-drain/workflow-namespace-drain-app/internal/controller/root_finalizer.go new file mode 100644 index 00000000..12b7d1b5 --- /dev/null +++ b/terraform/modules/sdv-container-images/images/workflow-namespace-drain/workflow-namespace-drain-app/internal/controller/root_finalizer.go @@ -0,0 +1,187 @@ +// Copyright (c) 2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package controller + +import ( + "context" + "time" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/predicate" +) + +const WorkflowDrainFinalizer = "horizon-sdv.io/workflow-namespace-drain" + +var argoApplicationGVK = schema.GroupVersionKind{Group: "argoproj.io", Version: "v1alpha1", Kind: "Application"} + +type workflowDrainer interface { + Drain(ctx context.Context) (bool, error) +} + +// RootFinalizerReconciler drains Argo Workflows when the root horizon-sdv Application is deleted. +type RootFinalizerReconciler struct { + Client client.Client + Drainer workflowDrainer + ArgoCDNamespace string + GitOpsRootAppNames []string + Finalizer string +} + +func (r *RootFinalizerReconciler) finalizer() string { + if r.Finalizer != "" { + return r.Finalizer + } + return WorkflowDrainFinalizer +} + +func (r *RootFinalizerReconciler) rootNameSet() map[string]struct{} { + m := make(map[string]struct{}, len(r.GitOpsRootAppNames)) + for _, n := range r.GitOpsRootAppNames { + if n != "" { + m[n] = struct{}{} + } + } + return m +} + +// Reconcile implements reconcile.Reconciler. +func (r *RootFinalizerReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + logger := log.FromContext(ctx) + names := r.rootNameSet() + if len(names) == 0 || r.ArgoCDNamespace == "" { + return ctrl.Result{}, nil + } + if req.Namespace != r.ArgoCDNamespace { + return ctrl.Result{}, nil + } + if _, ok := names[req.Name]; !ok { + return ctrl.Result{}, nil + } + + u := &unstructured.Unstructured{} + u.SetGroupVersionKind(argoApplicationGVK) + if err := r.Client.Get(ctx, req.NamespacedName, u); err != nil { + return ctrl.Result{}, client.IgnoreNotFound(err) + } + + finalizer := r.finalizer() + if ts := u.GetDeletionTimestamp(); ts == nil || ts.IsZero() { + return ctrl.Result{}, nil + } + if !hasFinalizerUnstructured(u, finalizer) { + return ctrl.Result{}, nil + } + + done, err := r.Drainer.Drain(ctx) + if err != nil { + logger.Error(err, "drain Argo Workflows for platform teardown") + return ctrl.Result{RequeueAfter: 10 * time.Second}, nil + } + if !done { + return ctrl.Result{RequeueAfter: 5 * time.Second}, nil + } + + if err := r.RemoveRootFinalizer(ctx, req.Namespace, req.Name); err != nil { + logger.Error(err, "remove workflow drain finalizer from root Application") + return ctrl.Result{RequeueAfter: 5 * time.Second}, err + } + return ctrl.Result{}, nil +} + +func hasFinalizerUnstructured(u *unstructured.Unstructured, want string) bool { + for _, f := range u.GetFinalizers() { + if f == want { + return true + } + } + return false +} + +// RemoveRootFinalizer removes only this controller's finalizer from the root Application. +func (r *RootFinalizerReconciler) RemoveRootFinalizer(ctx context.Context, ns, name string) error { + app := &unstructured.Unstructured{} + app.SetGroupVersionKind(argoApplicationGVK) + key := client.ObjectKey{Namespace: ns, Name: name} + if err := r.Client.Get(ctx, key, app); err != nil { + return client.IgnoreNotFound(err) + } + finalizers := app.GetFinalizers() + var kept []string + for _, f := range finalizers { + if f != r.finalizer() { + kept = append(kept, f) + } + } + if len(kept) == len(finalizers) { + return nil + } + app.SetFinalizers(kept) + if err := r.Client.Update(ctx, app); err != nil { + return err + } + return nil +} + +// SetupWithManager registers the reconciler for Argo CD Applications. +func (r *RootFinalizerReconciler) SetupWithManager(mgr ctrl.Manager) error { + names := r.rootNameSet() + if len(names) == 0 { + return nil + } + ns := r.ArgoCDNamespace + finalizer := r.finalizer() + argo := &unstructured.Unstructured{} + argo.SetGroupVersionKind(argoApplicationGVK) + + matchRoot := func(u *unstructured.Unstructured) bool { + if u.GetNamespace() != ns { + return false + } + _, ok := names[u.GetName()] + return ok + } + + return ctrl.NewControllerManagedBy(mgr). + For(argo). + WithEventFilter(predicate.Funcs{ + CreateFunc: func(e event.CreateEvent) bool { + u, ok := e.Object.(*unstructured.Unstructured) + if !ok || !matchRoot(u) { + return false + } + ts := u.GetDeletionTimestamp() + if ts == nil || ts.IsZero() { + return false + } + return hasFinalizerUnstructured(u, finalizer) + }, + DeleteFunc: func(e event.DeleteEvent) bool { return false }, + UpdateFunc: func(e event.UpdateEvent) bool { + u, ok := e.ObjectNew.(*unstructured.Unstructured) + if !ok || !matchRoot(u) { + return false + } + ts := u.GetDeletionTimestamp() + return ts != nil && !ts.IsZero() && hasFinalizerUnstructured(u, finalizer) + }, + GenericFunc: func(e event.GenericEvent) bool { return false }, + }). + Complete(r) +} diff --git a/terraform/modules/sdv-container-images/images/workflow-namespace-drain/workflow-namespace-drain-app/internal/controller/root_finalizer_test.go b/terraform/modules/sdv-container-images/images/workflow-namespace-drain/workflow-namespace-drain-app/internal/controller/root_finalizer_test.go new file mode 100644 index 00000000..c6384413 --- /dev/null +++ b/terraform/modules/sdv-container-images/images/workflow-namespace-drain/workflow-namespace-drain-app/internal/controller/root_finalizer_test.go @@ -0,0 +1,198 @@ +// Copyright (c) 2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package controller + +import ( + "context" + "errors" + "testing" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +type fakeWorkflowDrainer struct { + done bool + err error + calls int +} + +func (f *fakeWorkflowDrainer) Drain(context.Context) (bool, error) { + f.calls++ + return f.done, f.err +} + +func TestRootFinalizerReconcilerNoDeletionTimestamp(t *testing.T) { + t.Parallel() + + app := rootApp("argocd", "horizon-sdv", []string{WorkflowDrainFinalizer}, nil) + drainer := &fakeWorkflowDrainer{done: true} + reconciler := rootReconciler(t, app, drainer) + + result, err := reconciler.Reconcile(context.Background(), rootRequest()) + if err != nil { + t.Fatalf("Reconcile returned error: %v", err) + } + if result != (ctrl.Result{}) { + t.Fatalf("expected empty result, got %#v", result) + } + if drainer.calls != 0 { + t.Fatalf("expected drainer not to be called, got %d calls", drainer.calls) + } +} + +func TestRootFinalizerReconcilerFinalizerAbsent(t *testing.T) { + t.Parallel() + + now := metav1.Now() + app := rootApp("argocd", "horizon-sdv", []string{"resources-finalizer.argocd.argoproj.io"}, &now) + drainer := &fakeWorkflowDrainer{done: true} + reconciler := rootReconciler(t, app, drainer) + + result, err := reconciler.Reconcile(context.Background(), rootRequest()) + if err != nil { + t.Fatalf("Reconcile returned error: %v", err) + } + if result != (ctrl.Result{}) { + t.Fatalf("expected empty result, got %#v", result) + } + if drainer.calls != 0 { + t.Fatalf("expected drainer not to be called, got %d calls", drainer.calls) + } +} + +func TestRootFinalizerReconcilerDrainNotDoneKeepsFinalizer(t *testing.T) { + t.Parallel() + + now := metav1.Now() + app := rootApp("argocd", "horizon-sdv", []string{"resources-finalizer.argocd.argoproj.io", WorkflowDrainFinalizer}, &now) + drainer := &fakeWorkflowDrainer{done: false} + reconciler := rootReconciler(t, app, drainer) + + result, err := reconciler.Reconcile(context.Background(), rootRequest()) + if err != nil { + t.Fatalf("Reconcile returned error: %v", err) + } + if result.RequeueAfter != 5*time.Second { + t.Fatalf("expected 5s requeue, got %#v", result) + } + if drainer.calls != 1 { + t.Fatalf("expected drainer to be called once, got %d calls", drainer.calls) + } + + got := &unstructured.Unstructured{} + got.SetGroupVersionKind(argoApplicationGVK) + if err := reconciler.Client.Get(context.Background(), client.ObjectKey{Namespace: "argocd", Name: "horizon-sdv"}, got); err != nil { + t.Fatalf("get root app: %v", err) + } + if !hasFinalizerUnstructured(got, WorkflowDrainFinalizer) { + t.Fatal("expected workflow drain finalizer to remain") + } +} + +func TestRootFinalizerReconcilerDrainErrorRequeuesAndKeepsFinalizer(t *testing.T) { + t.Parallel() + + now := metav1.Now() + app := rootApp("argocd", "horizon-sdv", []string{WorkflowDrainFinalizer}, &now) + drainer := &fakeWorkflowDrainer{err: errors.New("temporary API error")} + reconciler := rootReconciler(t, app, drainer) + + result, err := reconciler.Reconcile(context.Background(), rootRequest()) + if err != nil { + t.Fatalf("Reconcile returned error: %v", err) + } + if result.RequeueAfter != 10*time.Second { + t.Fatalf("expected 10s requeue, got %#v", result) + } + if drainer.calls != 1 { + t.Fatalf("expected drainer to be called once, got %d calls", drainer.calls) + } +} + +func TestRootFinalizerReconcilerDrainDoneRemovesOnlyOwnFinalizer(t *testing.T) { + t.Parallel() + + now := metav1.Now() + otherFinalizers := []string{ + "resources-finalizer.argocd.argoproj.io", + "horizon-sdv.io/module-manager-platform-drain", + WorkflowDrainFinalizer, + } + app := rootApp("argocd", "horizon-sdv", otherFinalizers, &now) + drainer := &fakeWorkflowDrainer{done: true} + reconciler := rootReconciler(t, app, drainer) + + result, err := reconciler.Reconcile(context.Background(), rootRequest()) + if err != nil { + t.Fatalf("Reconcile returned error: %v", err) + } + if result != (ctrl.Result{}) { + t.Fatalf("expected empty result, got %#v", result) + } + if drainer.calls != 1 { + t.Fatalf("expected drainer to be called once, got %d calls", drainer.calls) + } + + got := &unstructured.Unstructured{} + got.SetGroupVersionKind(argoApplicationGVK) + if err := reconciler.Client.Get(context.Background(), client.ObjectKey{Namespace: "argocd", Name: "horizon-sdv"}, got); err != nil { + t.Fatalf("get root app: %v", err) + } + if hasFinalizerUnstructured(got, WorkflowDrainFinalizer) { + t.Fatal("expected workflow drain finalizer to be removed") + } + if !hasFinalizerUnstructured(got, "resources-finalizer.argocd.argoproj.io") { + t.Fatal("expected ArgoCD resources finalizer to be preserved") + } + if !hasFinalizerUnstructured(got, "horizon-sdv.io/module-manager-platform-drain") { + t.Fatal("expected module-manager finalizer to be preserved") + } +} + +func rootRequest() ctrl.Request { + return ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "argocd", Name: "horizon-sdv"}} +} + +func rootReconciler(t *testing.T, app *unstructured.Unstructured, drainer workflowDrainer) *RootFinalizerReconciler { + t.Helper() + scheme := runtime.NewScheme() + scheme.AddKnownTypeWithName(argoApplicationGVK, &unstructured.Unstructured{}) + scheme.AddKnownTypeWithName(schema.GroupVersionKind{Group: "argoproj.io", Version: "v1alpha1", Kind: "ApplicationList"}, &unstructured.UnstructuredList{}) + return &RootFinalizerReconciler{ + Client: fake.NewClientBuilder().WithScheme(scheme).WithObjects(app).Build(), + Drainer: drainer, + ArgoCDNamespace: "argocd", + GitOpsRootAppNames: []string{"horizon-sdv"}, + Finalizer: WorkflowDrainFinalizer, + } +} + +func rootApp(namespace, name string, finalizers []string, deletionTimestamp *metav1.Time) *unstructured.Unstructured { + u := &unstructured.Unstructured{} + u.SetGroupVersionKind(argoApplicationGVK) + u.SetNamespace(namespace) + u.SetName(name) + u.SetFinalizers(finalizers) + u.SetDeletionTimestamp(deletionTimestamp) + return u +} diff --git a/terraform/modules/sdv-container-images/images/workflow-namespace-drain/workflow-namespace-drain-app/internal/controller/workflow_drain.go b/terraform/modules/sdv-container-images/images/workflow-namespace-drain/workflow-namespace-drain-app/internal/controller/workflow_drain.go new file mode 100644 index 00000000..30cd2639 --- /dev/null +++ b/terraform/modules/sdv-container-images/images/workflow-namespace-drain/workflow-namespace-drain-app/internal/controller/workflow_drain.go @@ -0,0 +1,113 @@ +// Copyright (c) 2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package controller + +import ( + "context" + "time" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log" +) + +var ( + workflowGVK = schema.GroupVersionKind{Group: "argoproj.io", Version: "v1alpha1", Kind: "Workflow"} + workflowListGVK = schema.GroupVersionKind{Group: "argoproj.io", Version: "v1alpha1", Kind: "WorkflowList"} +) + +// WorkflowDrainer deletes Argo Workflow CRs and force-clears their finalizers after a grace window. +type WorkflowDrainer struct { + Client client.Client + APIReader client.Reader + WorkflowsNS string + GracefulTimeout time.Duration + PageSize int64 +} + +// Drain returns true when the workflows namespace has no Workflow CRs left. +func (d *WorkflowDrainer) Drain(ctx context.Context) (bool, error) { + logger := log.FromContext(ctx) + workflows, err := d.listWorkflows(ctx) + if err != nil { + if meta.IsNoMatchError(err) { + logger.Info("Workflow CRD is not available, considering workflow drain complete") + return true, nil + } + return false, err + } + if len(workflows) == 0 { + return true, nil + } + + now := time.Now() + for i := range workflows { + wf := workflows[i].DeepCopy() + wf.SetGroupVersionKind(workflowGVK) + if wf.GetDeletionTimestamp() == nil || wf.GetDeletionTimestamp().IsZero() { + if err := d.Client.Delete(ctx, wf, client.PropagationPolicy(metav1.DeletePropagationBackground)); err != nil && !apierrors.IsNotFound(err) { + return false, err + } + logger.Info("deleted Workflow", "namespace", wf.GetNamespace(), "name", wf.GetName()) + continue + } + if finalizers := wf.GetFinalizers(); len(finalizers) > 0 && now.Sub(wf.GetDeletionTimestamp().Time) >= d.GracefulTimeout { + patch := []byte(`{"metadata":{"finalizers":[]}}`) + if err := d.Client.Patch(ctx, wf, client.RawPatch(types.MergePatchType, patch)); err != nil && !apierrors.IsNotFound(err) { + return false, err + } + logger.Info("force-cleared Workflow finalizers", "namespace", wf.GetNamespace(), "name", wf.GetName()) + } + } + + remaining, err := d.listWorkflows(ctx) + if err != nil { + if meta.IsNoMatchError(err) { + return true, nil + } + return false, err + } + return len(remaining) == 0, nil +} + +func (d *WorkflowDrainer) listWorkflows(ctx context.Context) ([]unstructured.Unstructured, error) { + var items []unstructured.Unstructured + continueToken := "" + for { + ul := &unstructured.UnstructuredList{} + ul.SetGroupVersionKind(workflowListGVK) + opts := []client.ListOption{client.InNamespace(d.WorkflowsNS)} + if d.PageSize > 0 { + opts = append(opts, client.Limit(d.PageSize)) + } + if continueToken != "" { + opts = append(opts, client.Continue(continueToken)) + } + if err := d.APIReader.List(ctx, ul, opts...); err != nil { + return nil, err + } + items = append(items, ul.Items...) + continueToken = ul.GetContinue() + if continueToken == "" { + break + } + } + return items, nil +} diff --git a/terraform/modules/sdv-container-images/images/workflow-namespace-drain/workflow-namespace-drain-app/internal/controller/workflow_drain_test.go b/terraform/modules/sdv-container-images/images/workflow-namespace-drain/workflow-namespace-drain-app/internal/controller/workflow_drain_test.go new file mode 100644 index 00000000..d41d389f --- /dev/null +++ b/terraform/modules/sdv-container-images/images/workflow-namespace-drain/workflow-namespace-drain-app/internal/controller/workflow_drain_test.go @@ -0,0 +1,180 @@ +// Copyright (c) 2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package controller + +import ( + "context" + "testing" + "time" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func TestWorkflowDrainerEmptyNamespaceDone(t *testing.T) { + t.Parallel() + + c := workflowFakeClient(t) + drainer := newWorkflowDrainer(c, c) + + done, err := drainer.Drain(context.Background()) + if err != nil { + t.Fatalf("Drain returned error: %v", err) + } + if !done { + t.Fatal("expected empty workflow namespace to be done") + } +} + +func TestWorkflowDrainerFreshWorkflowDeleted(t *testing.T) { + t.Parallel() + + wf := workflow("workflows", "sample", []string{"workflows.argoproj.io/artifact-gc"}, nil) + c := workflowFakeClient(t, wf) + drainer := newWorkflowDrainer(c, c) + + _, err := drainer.Drain(context.Background()) + if err != nil { + t.Fatalf("Drain returned error: %v", err) + } + + got := &unstructured.Unstructured{} + got.SetGroupVersionKind(workflowGVK) + err = c.Get(context.Background(), client.ObjectKey{Namespace: "workflows", Name: "sample"}, got) + if apierrors.IsNotFound(err) { + return + } + if err != nil { + t.Fatalf("get workflow: %v", err) + } + if got.GetDeletionTimestamp() == nil || got.GetDeletionTimestamp().IsZero() { + t.Fatal("expected fresh Workflow to be deleted or marked for deletion") + } +} + +func TestWorkflowDrainerYoungDeletingWorkflowKeepsFinalizer(t *testing.T) { + t.Parallel() + + now := metav1.Now() + wf := workflow("workflows", "sample", []string{"workflows.argoproj.io/artifact-gc"}, &now) + c := workflowFakeClient(t, wf) + drainer := newWorkflowDrainer(c, c) + drainer.GracefulTimeout = time.Hour + + done, err := drainer.Drain(context.Background()) + if err != nil { + t.Fatalf("Drain returned error: %v", err) + } + if done { + t.Fatal("expected deleting Workflow to require another reconcile") + } + + got := getWorkflow(t, c, "sample") + if !hasFinalizerUnstructured(got, "workflows.argoproj.io/artifact-gc") { + t.Fatal("expected young deleting Workflow finalizer to remain") + } +} + +func TestWorkflowDrainerOldDeletingWorkflowClearsFinalizer(t *testing.T) { + t.Parallel() + + old := metav1.NewTime(time.Now().Add(-10 * time.Minute)) + wf := workflow("workflows", "sample", []string{"workflows.argoproj.io/artifact-gc"}, &old) + c := workflowFakeClient(t, wf) + drainer := newWorkflowDrainer(c, c) + drainer.GracefulTimeout = time.Minute + + done, err := drainer.Drain(context.Background()) + if err != nil { + t.Fatalf("Drain returned error: %v", err) + } + if done { + t.Fatal("expected Workflow removal to require another reconcile after finalizers are cleared") + } + + got := getWorkflow(t, c, "sample") + if len(got.GetFinalizers()) != 0 { + t.Fatalf("expected Workflow finalizers to be cleared, got %#v", got.GetFinalizers()) + } +} + +func TestWorkflowDrainerMissingCRDDone(t *testing.T) { + t.Parallel() + + c := workflowFakeClient(t) + drainer := newWorkflowDrainer(c, noMatchReader{}) + + done, err := drainer.Drain(context.Background()) + if err != nil { + t.Fatalf("Drain returned error: %v", err) + } + if !done { + t.Fatal("expected missing Workflow CRD to be considered done") + } +} + +type noMatchReader struct{} + +func (noMatchReader) Get(context.Context, client.ObjectKey, client.Object, ...client.GetOption) error { + return &meta.NoKindMatchError{GroupKind: workflowGVK.GroupKind(), SearchedVersions: []string{workflowGVK.Version}} +} + +func (noMatchReader) List(context.Context, client.ObjectList, ...client.ListOption) error { + return &meta.NoKindMatchError{GroupKind: workflowGVK.GroupKind(), SearchedVersions: []string{workflowGVK.Version}} +} + +func newWorkflowDrainer(c client.Client, reader client.Reader) *WorkflowDrainer { + return &WorkflowDrainer{ + Client: c, + APIReader: reader, + WorkflowsNS: "workflows", + GracefulTimeout: time.Minute, + PageSize: 200, + } +} + +func workflowFakeClient(t *testing.T, objs ...client.Object) client.Client { + t.Helper() + scheme := runtime.NewScheme() + scheme.AddKnownTypeWithName(workflowGVK, &unstructured.Unstructured{}) + scheme.AddKnownTypeWithName(workflowListGVK, &unstructured.UnstructuredList{}) + return fake.NewClientBuilder().WithScheme(scheme).WithObjects(objs...).Build() +} + +func workflow(namespace, name string, finalizers []string, deletionTimestamp *metav1.Time) *unstructured.Unstructured { + u := &unstructured.Unstructured{} + u.SetGroupVersionKind(workflowGVK) + u.SetNamespace(namespace) + u.SetName(name) + u.SetFinalizers(finalizers) + u.SetDeletionTimestamp(deletionTimestamp) + return u +} + +func getWorkflow(t *testing.T, c client.Client, name string) *unstructured.Unstructured { + t.Helper() + got := &unstructured.Unstructured{} + got.SetGroupVersionKind(workflowGVK) + if err := c.Get(context.Background(), types.NamespacedName{Namespace: "workflows", Name: name}, got); err != nil { + t.Fatalf("get workflow: %v", err) + } + return got +} diff --git a/terraform/modules/sdv-container-images/images/workflow-namespace-drain/workflow-namespace-drain-app/main.go b/terraform/modules/sdv-container-images/images/workflow-namespace-drain/workflow-namespace-drain-app/main.go new file mode 100644 index 00000000..49a6e38b --- /dev/null +++ b/terraform/modules/sdv-container-images/images/workflow-namespace-drain/workflow-namespace-drain-app/main.go @@ -0,0 +1,142 @@ +// Copyright (c) 2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "flag" + "log" + "os" + "strings" + "time" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/cache" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/healthz" + crlog "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" + + "github.com/acn-horizon-sdv/workflow-namespace-drain/internal/controller" +) + +var scheme = runtime.NewScheme() + +func init() { + utilruntime.Must(clientgoscheme.AddToScheme(scheme)) +} + +func main() { + var ( + metricsAddr string + probeAddr string + argocdNamespace string + gitopsRootAppNames string + rootFinalizer string + workflowsNamespace string + gracefulTimeout time.Duration + workflowListPageSize int64 + ) + flag.StringVar(&metricsAddr, "metrics-bind-address", ":8080", "Address for metrics.") + flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "Address for health probes.") + flag.StringVar(&argocdNamespace, "argocd-namespace", "argocd", "Namespace where the root ArgoCD Application exists.") + flag.StringVar(&gitopsRootAppNames, "gitops-root-app-name", "", + "Comma-separated names of root Argo CD app-of-apps Applications (e.g. prefix+horizon-sdv). Falls back to GITOPS_ROOT_APP_NAME when empty.") + flag.StringVar(&rootFinalizer, "root-finalizer", controller.WorkflowDrainFinalizer, "Finalizer owned by this controller on the root Application.") + flag.StringVar(&workflowsNamespace, "workflows-namespace", "workflows", "Namespace containing Argo Workflow CRs to drain during platform destroy.") + flag.DurationVar(&gracefulTimeout, "graceful-timeout", 60*time.Second, "How long to wait before force-clearing finalizers on deleting Workflows.") + flag.Int64Var(&workflowListPageSize, "workflow-list-page-size", 200, "Page size used when listing Workflow CRs.") + flag.Parse() + + crlog.SetLogger(zap.New(zap.UseDevMode(false))) + + rootAppNameList := splitCommaTrim(gitopsRootAppNames) + if len(rootAppNameList) == 0 { + rootAppNameList = splitCommaTrim(os.Getenv("GITOPS_ROOT_APP_NAME")) + } + + ctx := ctrl.SetupSignalHandler() + cfg := ctrl.GetConfigOrDie() + + argoAppObj := &unstructured.Unstructured{} + argoAppObj.SetGroupVersionKind(schema.GroupVersionKind{Group: "argoproj.io", Version: "v1alpha1", Kind: "Application"}) + + mgr, err := ctrl.NewManager(cfg, ctrl.Options{ + Scheme: scheme, + Metrics: metricsserver.Options{BindAddress: metricsAddr}, + HealthProbeBindAddress: probeAddr, + LeaderElection: false, + Cache: cache.Options{ + ByObject: map[client.Object]cache.ByObject{ + argoAppObj: { + Namespaces: map[string]cache.Config{ + argocdNamespace: {}, + }, + }, + }, + }, + }) + if err != nil { + log.Fatalf("creating manager: %v", err) + } + + drainer := &controller.WorkflowDrainer{ + Client: mgr.GetClient(), + APIReader: mgr.GetAPIReader(), + WorkflowsNS: workflowsNamespace, + GracefulTimeout: gracefulTimeout, + PageSize: workflowListPageSize, + } + + rootFin := &controller.RootFinalizerReconciler{ + Client: mgr.GetClient(), + Drainer: drainer, + ArgoCDNamespace: argocdNamespace, + GitOpsRootAppNames: rootAppNameList, + Finalizer: rootFinalizer, + } + if err := rootFin.SetupWithManager(mgr); err != nil { + log.Fatalf("setting up root GitOps Application finalizer reconciler: %v", err) + } + + if err := mgr.AddHealthzCheck("health", healthz.Ping); err != nil { + log.Fatalf("adding health check: %v", err) + } + if err := mgr.AddReadyzCheck("ready", healthz.Ping); err != nil { + log.Fatalf("adding ready check: %v", err) + } + + if err := mgr.Start(ctx); err != nil { + log.Printf("manager stopped: %v", err) + } +} + +func splitCommaTrim(s string) []string { + if s == "" { + return nil + } + var out []string + for _, p := range strings.Split(s, ",") { + if t := strings.TrimSpace(p); t != "" { + out = append(out, t) + } + } + return out +} diff --git a/terraform/modules/sdv-container-images/main.tf b/terraform/modules/sdv-container-images/main.tf index de0590d5..a37a473a 100644 --- a/terraform/modules/sdv-container-images/main.tf +++ b/terraform/modules/sdv-container-images/main.tf @@ -12,6 +12,23 @@ # See the License for the specific language governing permissions and # limitations under the License. +locals { + # Effective Docker build context per image (default: folder under images/). + docker_context = { + for name, img in var.images : name => coalesce(try(img.context_path, null), "${path.module}/images/${img.directory}/${name}") + } + + # When context_path is set, skip hashing paths that match local npm/build artifacts (like .dockerignore). + docker_context_files = { + for name, img in var.images : name => sort([ + for f in fileset(local.docker_context[name], "**") : f + if try(img.context_path, null) == null ? true : !( + startswith(f, "node_modules/") || startswith(f, "dist/") || startswith(f, ".git/") + ) + ]) + } +} + resource "docker_image" "sdv-container-images" { for_each = var.images @@ -19,19 +36,26 @@ resource "docker_image" "sdv-container-images" { build { no_cache = true - context = "${path.module}/images/${each.value.directory}/${each.key}" - tag = ["${var.gcp_region}-docker.pkg.dev/${var.gcp_project_id}/${var.gcp_registry_id}/${each.key}:${each.value.version}"] - + context = local.docker_context[each.key] + tag = ["${var.gcp_region}-docker.pkg.dev/${var.gcp_project_id}/${var.gcp_registry_id}/${each.key}:${each.value.version}"] build_args = each.value.build_args + + # Default "Dockerfile" in context; override with absolute path when context is external. + dockerfile = coalesce(try(each.value.dockerfile_path, null), "Dockerfile") + platform = try(each.value.platform, null) } triggers = { dir_sha1 = sha1(join("", [ - for f in fileset("${path.module}/images/${each.value.directory}/${each.key}", "**") : - filesha1("${path.module}/images/${each.value.directory}/${each.key}/${f}") + for f in local.docker_context_files[each.key] : + filesha1("${local.docker_context[each.key]}/${f}") ])) build_args_sha = sha1(jsonencode(each.value.build_args)) + + dockerfile_sha = try(each.value.dockerfile_path, null) != null ? filesha1(each.value.dockerfile_path) : "" + + platform_sha = try(each.value.platform, null) != null ? each.value.platform : "" } } diff --git a/terraform/modules/sdv-container-images/variables.tf b/terraform/modules/sdv-container-images/variables.tf index 5a063c56..85e3371d 100644 --- a/terraform/modules/sdv-container-images/variables.tf +++ b/terraform/modules/sdv-container-images/variables.tf @@ -28,10 +28,17 @@ variable "gcp_registry_id" { } variable "images" { - description = "A map of images to build. The key is the image name and the value is an object containing its build directory and version." + description = <<-EOT + Map of images to build. Key = image name in Artifact Registry. + Default context: images//. Optional context_path + dockerfile_path + allow overriding context_path and dockerfile_path when they differ from images//. + EOT type = map(object({ - directory = string - version = string - build_args = optional(map(string), {}) + directory = string + version = string + build_args = optional(map(string), {}) + context_path = optional(string) + dockerfile_path = optional(string) + platform = optional(string) })) } diff --git a/terraform/modules/sdv-gcs/main.tf b/terraform/modules/sdv-gcs/main.tf index afcfd6ea..e6b95a86 100644 --- a/terraform/modules/sdv-gcs/main.tf +++ b/terraform/modules/sdv-gcs/main.tf @@ -19,4 +19,16 @@ resource "google_storage_bucket" "bucket" { location = var.location force_destroy = true uniform_bucket_level_access = true + + dynamic "lifecycle_rule" { + for_each = var.lifecycle_delete_age_days != null ? [var.lifecycle_delete_age_days] : [] + content { + action { + type = "Delete" + } + condition { + age = lifecycle_rule.value + } + } + } } diff --git a/terraform/modules/sdv-gcs/variables.tf b/terraform/modules/sdv-gcs/variables.tf index 1a2023ee..9ac62d5e 100644 --- a/terraform/modules/sdv-gcs/variables.tf +++ b/terraform/modules/sdv-gcs/variables.tf @@ -21,3 +21,9 @@ variable "location" { description = "Define the loation of the storage" type = string } + +variable "lifecycle_delete_age_days" { + description = "If set, delete all bucket objects older than this many days (creation time). Omit for no age-based lifecycle rule." + type = number + default = null +} diff --git a/terraform/modules/sdv-gke-apps/argocd-values.yaml.tpl b/terraform/modules/sdv-gke-apps/argocd-values.yaml.tpl index 06f3bc8a..3643158f 100644 --- a/terraform/modules/sdv-gke-apps/argocd-values.yaml.tpl +++ b/terraform/modules/sdv-gke-apps/argocd-values.yaml.tpl @@ -20,9 +20,18 @@ configs: params: server.insecure: true server.rootpath: /argocd + # GitHub HTTPS from the cluster can be slow or stall; defaults (15s git, 60s + # server/controller → repo-server) surface as UI "Connection Failed" and + # `context deadline exceeded` in repo-server while apps may still use cache. + reposerver.git.request.timeout: "120s" + server.repo.server.timeout.seconds: "180" + controller.repo.server.timeout.seconds: "180" secret: createSecret: false cm: + # Must match server.rootpath (/argocd) and the public Gateway prefix or Keycloak rejects + # redirect_uri (OAuth callback must be under this URL). + url: https://${subdomain_name}.${domain_name}/argocd resource.customizations: | Secret: ignoreDifferences: | diff --git a/terraform/modules/sdv-gke-apps/main.tf b/terraform/modules/sdv-gke-apps/main.tf index 7f168024..d9b998a1 100644 --- a/terraform/modules/sdv-gke-apps/main.tf +++ b/terraform/modules/sdv-gke-apps/main.tf @@ -21,7 +21,7 @@ locals { subdomain = var.subdomain_name is_main = true env_name = "main" - branch = var.git_repo_branch + branch = var.scm_repo_branch } }, { @@ -31,7 +31,7 @@ locals { subdomain = "${env}.${var.subdomain_name}" is_main = false env_name = env - branch = lookup(var.sub_env_branches, env, var.git_repo_branch) + branch = lookup(var.sub_env_branches, env, var.scm_repo_branch) } } ) @@ -113,12 +113,12 @@ resource "kubernetes_service_account" "argocd_sa" { ] } -# Create empty Git creds secret for each environment -resource "kubernetes_secret" "argocd_git_creds" { +# Create the SCM credentials secret for each environment +resource "kubernetes_secret" "argocd_scm_creds" { for_each = local.all_environments metadata { - name = "argocd-git-creds" + name = "argocd-scm-creds" namespace = kubernetes_namespace.argocd[each.key].metadata[0].name labels = { "argocd.argoproj.io/secret-type" = "repository" @@ -126,9 +126,9 @@ resource "kubernetes_secret" "argocd_git_creds" { } data = { - "url" = var.git_repo_url + "url" = var.scm_repo_url "type" = "git" - "username" = var.git_auth_method == "pat" ? "git" : null + "username" = var.scm_auth_method == "userpass" ? var.scm_username : null } depends_on = [ @@ -185,7 +185,7 @@ resource "helm_release" "argocd_main" { depends_on = [ helm_release.external_secrets, kubernetes_service_account.argocd_sa, - kubernetes_secret.argocd_git_creds, + kubernetes_secret.argocd_scm_creds, kubernetes_secret.argocd_secret ] } @@ -224,11 +224,37 @@ resource "helm_release" "argocd_subenvs" { depends_on = [ helm_release.argocd_main, kubernetes_service_account.argocd_sa, - kubernetes_secret.argocd_git_creds, + kubernetes_secret.argocd_scm_creds, kubernetes_secret.argocd_secret ] } +# Deploy workflow namespace drain outside the ArgoCD cascade so it survives platform destroy. +resource "helm_release" "workflow_namespace_drain" { + for_each = local.all_environments + + name = "${each.value.namespace_prefix}workflow-namespace-drain" + chart = "${path.module}/../../../gitops/apps/workflow-namespace-drain" + namespace = "${each.value.namespace_prefix}workflow-namespace-drain" + create_namespace = true + wait = true + + values = [ + yamlencode({ + image = "${var.gcp_cloud_region}-docker.pkg.dev/${var.gcp_project_id}/${var.gcp_registry_id}/workflow-namespace-drain-app:${var.images["workflow-namespace-drain-app"].version}" + namespace = "${each.value.namespace_prefix}workflow-namespace-drain" + argocd = { namespace = each.value.argocd_namespace } + workflowsNamespace = "${each.value.namespace_prefix}workflows" + gitopsRootAppName = "${each.value.namespace_prefix}${var.argocd_application_name}" + }) + ] + + depends_on = [ + helm_release.argocd_main, + helm_release.argocd_subenvs + ] +} + # Create SecretStore for each environment resource "kubectl_manifest" "argocd_secret_store" { for_each = local.all_environments @@ -262,8 +288,8 @@ resource "kubectl_manifest" "argocd_secret_store" { } # Create ExternalSecret for Git creds for each environment -resource "kubectl_manifest" "es_git_creds" { - for_each = local.all_environments +resource "kubectl_manifest" "es_scm_creds" { + for_each = var.scm_auth_method != "none" ? local.all_environments : {} validate_schema = false @@ -271,7 +297,7 @@ resource "kubectl_manifest" "es_git_creds" { apiVersion: external-secrets.io/v1beta1 kind: ExternalSecret metadata: - name: argocd-git-creds + name: argocd-scm-creds namespace: ${kubernetes_namespace.argocd[each.key].metadata[0].name} spec: refreshInterval: 10s @@ -279,10 +305,10 @@ resource "kubectl_manifest" "es_git_creds" { kind: SecretStore name: argocd-secret-store target: - name: ${kubernetes_secret.argocd_git_creds[each.key].metadata[0].name} + name: ${kubernetes_secret.argocd_scm_creds[each.key].metadata[0].name} creationPolicy: Merge data: - %{if var.git_auth_method == "app"} + %{if var.scm_auth_method == "app"} - secretKey: githubAppID remoteRef: key: ${each.value.namespace_prefix}github-app-id-b64 @@ -298,14 +324,14 @@ resource "kubectl_manifest" "es_git_creds" { %{else} - secretKey: password remoteRef: - key: ${each.value.namespace_prefix}git-pat-b64 + key: ${each.value.namespace_prefix}scm-password-b64 decodingStrategy: Base64 %{endif} EOT depends_on = [ kubectl_manifest.argocd_secret_store, - kubernetes_secret.argocd_git_creds + kubernetes_secret.argocd_scm_creds ] } @@ -382,6 +408,9 @@ resource "kubectl_manifest" "argocd_application" { validate_schema = false wait = true + # Avoid "metadata.resourceVersion: Invalid value: 0x0: must be specified for an update" on + # Application CRD updates (e.g. after out-of-band kubectl apply) by using server-side apply. + server_side_apply = true yaml_body = <<-EOT apiVersion: argoproj.io/v1alpha1 @@ -391,19 +420,25 @@ resource "kubectl_manifest" "argocd_application" { namespace: ${kubernetes_namespace.argocd[each.key].metadata[0].name} finalizers: - resources-finalizer.argocd.argoproj.io + - horizon-sdv.io/module-manager-platform-drain + - horizon-sdv.io/workflow-namespace-drain spec: project: "${each.value.namespace_prefix}${var.argocd_application_name}" source: - repoURL: ${var.git_repo_url} + repoURL: ${var.scm_repo_url} path: gitops targetRevision: ${each.value.branch} helm: values: | - git: - authMethod: ${var.git_auth_method} - username: "git" - repoOwner: ${var.git_repo_owner} - repoName: ${var.git_repo_name} + scm: + type: ${var.scm_type} + authMethod: ${var.scm_auth_method} + username: ${var.scm_username} + repoUrl: ${var.scm_repo_url} + branch: ${var.scm_repo_branch} + %{if var.scm_type == "github"}repoOwner: ${var.scm_repo_owner} + repoName: ${var.scm_repo_name} + %{endif} config: domain: ${each.value.subdomain}.${var.domain_name} projectID: ${var.gcp_project_id} @@ -414,9 +449,14 @@ resource "kubectl_manifest" "argocd_application" { isSubEnvironment: ${!each.value.is_main} environmentName: "${each.value.env_name}" enableNetworkPolicies: ${var.enable_network_policies} - apps: + useStaticDnsARecords: ${var.use_static_dns_a_records} + containerImages: landingpage: ${var.gcp_cloud_region}-docker.pkg.dev/${var.gcp_project_id}/${var.gcp_registry_id}/landingpage-app:${var.images["landingpage-app"].version} + kccWebhookCertMonitor: ${var.gcp_cloud_region}-docker.pkg.dev/${var.gcp_project_id}/${var.gcp_registry_id}/kcc-webhook-cert-monitor:${var.images["kcc-webhook-cert-monitor"].version} + horizondevelopmentportal: ${var.gcp_cloud_region}-docker.pkg.dev/${var.gcp_project_id}/${var.gcp_registry_id}/horizon-dev-portal:${var.images["horizon-dev-portal"].version} gerritMcpServer: ${var.gcp_cloud_region}-docker.pkg.dev/${var.gcp_project_id}/${var.gcp_registry_id}/gerrit-mcp-server-app:${var.images["gerrit-mcp-server-app"].version} + moduleManager: ${var.gcp_cloud_region}-docker.pkg.dev/${var.gcp_project_id}/${var.gcp_registry_id}/module-manager-app:${var.images["module-manager-app"].version} + horizonApi: ${var.gcp_cloud_region}-docker.pkg.dev/${var.gcp_project_id}/${var.gcp_registry_id}/horizon-api-app:${var.images["horizon-api-app"].version} postjobs: keycloak: ${var.gcp_cloud_region}-docker.pkg.dev/${var.gcp_project_id}/${var.gcp_registry_id}/keycloak-post:${var.images["keycloak-post"].version} keycloakmtkconnect: ${var.gcp_cloud_region}-docker.pkg.dev/${var.gcp_project_id}/${var.gcp_registry_id}/keycloak-post-mtk-connect:${var.images["keycloak-post-mtk-connect"].version} @@ -426,18 +466,20 @@ resource "kubectl_manifest" "argocd_application" { keycloakgerrit: ${var.gcp_cloud_region}-docker.pkg.dev/${var.gcp_project_id}/${var.gcp_registry_id}/keycloak-post-gerrit:${var.images["keycloak-post-gerrit"].version} keycloakgrafana: ${var.gcp_cloud_region}-docker.pkg.dev/${var.gcp_project_id}/${var.gcp_registry_id}/keycloak-post-grafana:${var.images["keycloak-post-grafana"].version} keycloakMcpGatewayRegistry: ${var.gcp_cloud_region}-docker.pkg.dev/${var.gcp_project_id}/${var.gcp_registry_id}/keycloak-post-mcp-gateway-registry:${var.images["keycloak-post-mcp-gateway-registry"].version} + keycloakArgoWorkflows: ${var.gcp_cloud_region}-docker.pkg.dev/${var.gcp_project_id}/${var.gcp_registry_id}/keycloak-post-argo-workflows:${var.images["keycloak-post-argo-workflows"].version} + keycloakhorizonapi: ${var.gcp_cloud_region}-docker.pkg.dev/${var.gcp_project_id}/${var.gcp_registry_id}/keycloak-post-horizon-api:${var.images["keycloak-post-horizon-api"].version} mtkconnect: ${var.gcp_cloud_region}-docker.pkg.dev/${var.gcp_project_id}/${var.gcp_registry_id}/mtk-connect-post:${var.images["mtk-connect-post"].version} mtkconnectkey: ${var.gcp_cloud_region}-docker.pkg.dev/${var.gcp_project_id}/${var.gcp_registry_id}/mtk-connect-post-key:${var.images["mtk-connect-post-key"].version} grafana: ${var.gcp_cloud_region}-docker.pkg.dev/${var.gcp_project_id}/${var.gcp_registry_id}/grafana-post:${var.images["grafana-post"].version} gerrit: ${var.gcp_cloud_region}-docker.pkg.dev/${var.gcp_project_id}/${var.gcp_registry_id}/gerrit-post:${var.images["gerrit-post"].version} workloads: android: - url: ${var.git_repo_url} - branch: ${each.value.branch} + url: ${var.scm_repo_url} + branch: ${var.scm_repo_branch} spec: source: - repoURL: ${var.git_repo_url} - targetRevision: ${each.value.branch} + repoURL: ${var.scm_repo_url} + targetRevision: ${var.scm_repo_branch} destination: server: https://kubernetes.default.svc revisionHistoryLimit: 1 @@ -445,6 +487,7 @@ resource "kubectl_manifest" "argocd_application" { syncOptions: - CreateNamespace=true automated: + enabled: true prune: true selfHeal: false retry: @@ -458,6 +501,7 @@ resource "kubectl_manifest" "argocd_application" { depends_on = [ kubectl_manifest.argocd_appproject, helm_release.argocd_main, - helm_release.argocd_subenvs + helm_release.argocd_subenvs, + helm_release.workflow_namespace_drain ] } diff --git a/terraform/modules/sdv-gke-apps/variables.tf b/terraform/modules/sdv-gke-apps/variables.tf index 21163124..650f1859 100644 --- a/terraform/modules/sdv-gke-apps/variables.tf +++ b/terraform/modules/sdv-gke-apps/variables.tf @@ -26,11 +26,44 @@ variable "sub_env_branches" { default = {} } -variable "git_auth_method" { - description = "Authentication method for Argo CD: 'app' or 'pat'." +variable "scm_type" { + description = "SCM type: 'github' or 'git'" type = string } +variable "scm_auth_method" { + description = "SCM auth method: 'app' or 'userpass'" + type = string +} + +variable "scm_repo_url" { + description = "Full SCM repository URL" + type = string +} + +variable "scm_repo_branch" { + description = "SCM repository branch" + type = string +} + +variable "scm_repo_owner" { + description = "SCM repository owner (for GitHub only)" + type = string + default = "" +} + +variable "scm_repo_name" { + description = "SCM repository name (for GitHub only)" + type = string + default = "" +} + +variable "scm_username" { + description = "SCM username" + type = string + default = "git" +} + variable "es_namespace" { description = "Namespace for External Secrets" type = string @@ -76,26 +109,6 @@ variable "argocd_application_name" { default = "horizon-sdv" } -variable "git_repo_url" { - description = "The URL of the git repository." - type = string -} - -variable "git_repo_branch" { - description = "The target branch for Argo CD." - type = string -} - -variable "git_repo_owner" { - description = "Git repository owner (user or organization name)" - type = string -} - -variable "git_repo_name" { - description = "Git repository name" - type = string -} - variable "domain_name" { description = "The base domain name." type = string @@ -133,3 +146,9 @@ variable "enable_network_policies" { type = bool default = true } + +variable "use_static_dns_a_records" { + description = "When true, no Cloud DNS zone and no external-dns; use static A records in parent zone instead." + type = bool + default = false +} diff --git a/terraform/modules/sdv-gke-cluster/main.tf b/terraform/modules/sdv-gke-cluster/main.tf index 5520e5f8..b2fb83c2 100644 --- a/terraform/modules/sdv-gke-cluster/main.tf +++ b/terraform/modules/sdv-gke-cluster/main.tf @@ -86,6 +86,9 @@ resource "google_container_cluster" "sdv_cluster" { gcp_filestore_csi_driver_config { enabled = true } + config_connector_config { + enabled = true + } } # Enable network policy enforcement for pod-to-pod traffic restriction @@ -356,3 +359,46 @@ resource "google_container_node_pool" "sdv_openbsw_build_node_pool" { } +resource "google_container_node_pool" "sdv_utility_node_pool" { + name = var.utility_node_pool_name + location = var.location + cluster = google_container_cluster.sdv_cluster.name + node_count = var.utility_node_pool_node_count + node_locations = var.node_locations + node_config { + preemptible = false + machine_type = var.utility_node_pool_machine_type + disk_size_gb = 500 + image_type = "UBUNTU_CONTAINERD" + + service_account = var.service_account + oauth_scopes = [ + "https://www.googleapis.com/auth/cloud-platform" + ] + + labels = { + workloadLabel = "utility" + } + + taint { + key = "workloadType" + value = "utility" + effect = "NO_SCHEDULE" + } + + metadata = { + disable-legacy-endpoints = "true" + } + + workload_metadata_config { + mode = "GKE_METADATA" + } + } + + autoscaling { + min_node_count = var.utility_node_pool_min_node_count + max_node_count = var.utility_node_pool_max_node_count + } + +} + diff --git a/terraform/modules/sdv-gke-cluster/variables.tf b/terraform/modules/sdv-gke-cluster/variables.tf index 3f46b54f..a4f8e493 100644 --- a/terraform/modules/sdv-gke-cluster/variables.tf +++ b/terraform/modules/sdv-gke-cluster/variables.tf @@ -118,6 +118,33 @@ variable "openbsw_build_node_pool_max_node_count" { default = 3 } +variable "utility_node_pool_name" { + description = "Name of the utility node pool (Vertex/Gemini CLI and similar; not Android-specific)" + type = string +} + +variable "utility_node_pool_node_count" { + description = "Number of nodes for the utility node pool" + type = number +} + +variable "utility_node_pool_machine_type" { + description = "Machine type for the utility node pool. Size for pods with limits up to 32 CPU / 96Gi (e.g. workloads/common/agentic-ai/gemini/helm/values.yaml); n2-standard-32 allocatable CPU is often slightly under 32 cores after kube-reserved, so n2-standard-48 or larger is safer unless CPU limits are reduced." + type = string +} + +variable "utility_node_pool_min_node_count" { + description = "Minimum number of nodes for the utility node pool" + type = number + default = 0 +} + +variable "utility_node_pool_max_node_count" { + description = "Maximum number of nodes for the utility node pool" + type = number + default = 5 +} + variable "network" { description = "Name of the network" type = string diff --git a/terraform/modules/sdv-wi/main.tf b/terraform/modules/sdv-wi/main.tf index 043077ca..580b7337 100644 --- a/terraform/modules/sdv-wi/main.tf +++ b/terraform/modules/sdv-wi/main.tf @@ -78,12 +78,15 @@ resource "google_project_iam_member" "sdv_wi_sa_iam_2" { ] } -resource "google_project_iam_member" "sdv_wi_sa_wi_users_gke_ns_sa" { +# GKE Workload Identity: the Kubernetes SA must have roles/iam.workloadIdentityUser on the +# *Google* service account (not project IAM). Otherwise token exchange fails with +# Permission 'iam.serviceAccounts.getAccessToken' denied (e.g. External Secrets + GSM). +resource "google_service_account_iam_member" "sdv_wi_sa_workload_identity_user" { for_each = local.gke_sas_with_sa_map - project = data.google_project.project.id - role = "roles/iam.workloadIdentityUser" - member = "serviceAccount:${var.project_id}.svc.id.goog[${each.value.gke_ns}/${each.value.gke_sa}]" + service_account_id = google_service_account.sdv_wi_sa[each.value.sa_id].name + role = "roles/iam.workloadIdentityUser" + member = "serviceAccount:${var.project_id}.svc.id.goog[${each.value.gke_ns}/${each.value.gke_sa}]" depends_on = [ google_service_account.sdv_wi_sa diff --git a/tools/horizon/auth_cmds.go b/tools/horizon/auth_cmds.go new file mode 100644 index 00000000..766532e7 --- /dev/null +++ b/tools/horizon/auth_cmds.go @@ -0,0 +1,385 @@ +// Copyright (c) 2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "bufio" + "context" + "flag" + "fmt" + "os" + "os/exec" + "runtime" + "strings" + "time" + + "golang.org/x/term" +) + +func promptLine(in *bufio.Reader, label, def string) string { + if def != "" { + fmt.Fprintf(os.Stderr, "%s [%s]: ", label, def) + } else { + fmt.Fprintf(os.Stderr, "%s: ", label) + } + line, _ := in.ReadString('\n') + line = strings.TrimSpace(line) + if line == "" { + return def + } + return line +} + +func promptSecret(label string) (string, error) { + fmt.Fprintf(os.Stderr, "%s: ", label) + b, err := term.ReadPassword(int(os.Stdin.Fd())) + fmt.Fprintln(os.Stderr) + if err != nil { + return "", err + } + return string(b), nil +} + +func browserOpener() func(string) error { + return func(url string) error { + switch runtime.GOOS { + case "linux": + return exec.Command("xdg-open", url).Start() + case "darwin": + return exec.Command("open", url).Start() + case "windows": + return exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start() + default: + return fmt.Errorf("unsupported OS for opening browser") + } + } +} + +func runAuthLogin(args []string) error { + fs := flag.NewFlagSet("auth login", flag.ExitOnError) + device := fs.Bool("device", false, "use OAuth 2.0 device flow (public client)") + clientCreds := fs.Bool("client-credentials", false, "use client_credentials grant") + openBrowser := fs.Bool("open-browser", false, "open verification URL in a browser") + noBrowser := fs.Bool("no-browser", false, "never open a browser") + scope := fs.String("scope", envOr("KEYCLOAK_DEVICE_SCOPE", "openid"), "OAuth scope (device flow)") + api := fs.String("api", "", "Horizon API base URL (stored with --write-config)") + domain := fs.String("domain", "", "Horizon domain (derives API URL; stored with --write-config)") + clientIDFlag := fs.String("client-id", "", "Keycloak client id (overrides KEYCLOAK_CLIENT_ID)") + clientSecretFlag := fs.String("client-secret", "", "Keycloak client secret (overrides KEYCLOAK_CLIENT_SECRET)") + writeConfig := fs.Bool("write-config", false, "write domain/keycloak defaults to ~/.config/horizon/config.yaml") + _ = fs.Parse(args) + + cfg, err := LoadConfigRelaxed() + if err != nil { + return err + } + ApplyFlagOverrides(cfg, &FlagOverrides{APIBaseURL: *api, Domain: *domain}) + if cfg.KeycloakBase == "" && cfg.Domain != "" { + cfg.KeycloakBase = "https://" + cfg.Domain + "/auth" + } + if cfg.KeycloakBase == "" { + return fmt.Errorf("Keycloak URL unknown: set KEYCLOAK_BASE or HORIZON_DOMAIN, or put domain in ~/.config/horizon/config.yaml, or pass --domain (e.g. %s auth login --device --domain horizon.example.com)", progName()) + } + kb := cfg.KeycloakBase + realm := cfg.KeycloakRealm + + in := bufio.NewReader(os.Stdin) + useTTY := term.IsTerminal(int(os.Stdin.Fd())) + autoOpen := useTTY && !*noBrowser && !*openBrowser + if *openBrowser { + autoOpen = true + } + + defaultClientCI := "horizon-api-ci" + defaultClientPub := "horizon-api" + mode := "auto" + if *device { + mode = "device" + } + if *clientCreds { + mode = "client_credentials" + } + + secret := strings.TrimSpace(os.Getenv("KEYCLOAK_CLIENT_SECRET")) + clientID := strings.TrimSpace(os.Getenv("KEYCLOAK_CLIENT_ID")) + if s := strings.TrimSpace(*clientSecretFlag); s != "" { + secret = s + } + if s := strings.TrimSpace(*clientIDFlag); s != "" { + clientID = s + } + + if mode == "auto" { + if secret != "" { + mode = "client_credentials" + } else { + mode = "device" + } + } + + if clientID == "" { + def := defaultClientPub + if mode == "client_credentials" { + def = defaultClientCI + } + // Device flow: use KEYCLOAK_CLIENT_ID or default (horizon-api); never prompt. + if useTTY && mode != "device" { + clientID = promptLine(in, "Keycloak client ID", def) + } else { + clientID = def + } + } + if clientID == "" { + return fmt.Errorf("client ID required") + } + + oauth := &OAuthClient{HTTP: defaultHTTPClient()} + ctx := context.Background() + + var tc *TokenCache + switch mode { + case "client_credentials": + if secret == "" { + if !useTTY { + return fmt.Errorf("KEYCLOAK_CLIENT_SECRET required non-interactively") + } + var err error + secret, err = promptSecret("Keycloak client secret") + if err != nil { + return err + } + } + tc, err = oauth.ClientCredentials(ctx, kb, realm, clientID, secret) + if err != nil { + return err + } + case "device": + var opener func(string) error + if autoOpen { + op := browserOpener() + opener = func(u string) error { return op(u) } + } + tc, err = oauth.DeviceFlow(ctx, kb, realm, clientID, *scope, opener) + if err != nil { + return err + } + default: + return fmt.Errorf("unknown auth mode %q", mode) + } + + tc.KeycloakBase = strings.TrimSuffix(kb, "/") + tc.Realm = realm + tc.ClientID = clientID + if err := saveTokenCache(tc); err != nil { + return err + } + fmt.Fprintln(os.Stderr, "Logged in. Token saved to ~/.config/horizon/token.json") + if *writeConfig { + apiBase := cfg.BaseURL + if err := mergeLoginIntoConfigFile(cfg.Domain, apiBase, kb, realm); err != nil { + return fmt.Errorf("write config: %w", err) + } + p, _ := HorizonConfigFilePath() + fmt.Fprintf(os.Stderr, "Wrote defaults to %s\n", p) + } + return nil +} + +func runAuthRefresh(args []string) error { + fs := flag.NewFlagSet("auth refresh", flag.ExitOnError) + api := fs.String("api", "", "Horizon API base URL (optional; same as login --api)") + domain := fs.String("domain", "", "Horizon domain (optional; sets Keycloak URL if token cache lacks keycloak_base)") + clientIDFlag := fs.String("client-id", "", "Keycloak client id (overrides token cache / KEYCLOAK_CLIENT_ID)") + clientSecretFlag := fs.String("client-secret", "", "Keycloak client secret (confidential client; or KEYCLOAK_CLIENT_SECRET)") + _ = fs.Parse(args) + + tc, err := loadTokenCache() + if err != nil { + return err + } + if tc == nil || strings.TrimSpace(tc.RefreshToken) == "" { + return fmt.Errorf("no refresh token in token cache: run `%s auth login`", progName()) + } + + kb := strings.TrimSuffix(strings.TrimSpace(tc.KeycloakBase), "/") + realm := strings.TrimSpace(tc.Realm) + clientID := strings.TrimSpace(tc.ClientID) + + cfg, cfgErr := LoadConfigRelaxed() + if cfgErr == nil { + ApplyFlagOverrides(cfg, &FlagOverrides{APIBaseURL: *api, Domain: *domain}) + if cfg.KeycloakBase == "" && cfg.Domain != "" { + cfg.KeycloakBase = "https://" + cfg.Domain + "/auth" + } + if kb == "" { + kb = strings.TrimSuffix(strings.TrimSpace(cfg.KeycloakBase), "/") + } + if realm == "" { + realm = cfg.KeycloakRealm + } + if clientID == "" { + clientID = envOr("KEYCLOAK_CLIENT_ID", "horizon-api") + } + } else { + if d := strings.TrimSpace(*domain); d != "" { + kb = "https://" + d + "/auth" + } + if realm == "" { + realm = envOr("KEYCLOAK_REALM", "horizon") + } + if clientID == "" { + clientID = envOr("KEYCLOAK_CLIENT_ID", "horizon-api") + } + if kb == "" { + return fmt.Errorf("Keycloak URL unknown (%v); set KEYCLOAK_BASE or HORIZON_DOMAIN, pass --domain, or run `%s auth login`", cfgErr, progName()) + } + } + + if s := strings.TrimSpace(*clientIDFlag); s != "" { + clientID = s + } + secret := strings.TrimSpace(os.Getenv("KEYCLOAK_CLIENT_SECRET")) + if s := strings.TrimSpace(*clientSecretFlag); s != "" { + secret = s + } + if clientID == "" { + return fmt.Errorf("Keycloak client id unknown (token cache empty and not set)") + } + + oauth := &OAuthClient{HTTP: defaultHTTPClient()} + ctx := context.Background() + nt, err := oauth.RefreshToken(ctx, kb, realm, clientID, secret, strings.TrimSpace(tc.RefreshToken)) + if err != nil { + return err + } + nt.KeycloakBase = kb + nt.Realm = realm + nt.ClientID = clientID + if err := saveTokenCache(nt); err != nil { + return err + } + fmt.Fprintln(os.Stderr, "Access token refreshed. Saved to ~/.config/horizon/token.json") + return nil +} + +func runAuthLogout() error { + if err := clearTokenCache(); err != nil { + return err + } + fmt.Fprintln(os.Stderr, "Removed ~/.config/horizon/token.json (if present).") + return nil +} + +// jwtClaimExpUnix returns JWT numeric exp (seconds since epoch) if present and valid. +func jwtClaimExpUnix(claims map[string]any) (int64, bool) { + v, ok := claims["exp"] + if !ok { + return 0, false + } + switch x := v.(type) { + case float64: + if x <= 0 { + return 0, false + } + return int64(x), true + case int64: + if x <= 0 { + return 0, false + } + return x, true + case int: + if x <= 0 { + return 0, false + } + return int64(x), true + default: + return 0, false + } +} + +func formatJWTExpHuman(expUnix int64) string { + expAt := time.Unix(expUnix, 0).UTC() + abs := expAt.Format(time.RFC3339) + skew := time.Until(expAt).Round(time.Second) + if skew > 0 { + return fmt.Sprintf("%s (valid for %s)", abs, skew) + } + if skew < 0 { + return fmt.Sprintf("%s (expired %s ago)", abs, (-skew).Round(time.Second).String()) + } + return fmt.Sprintf("%s (expires now)", abs) +} + +func runAuthWhoami() error { + tok := strings.TrimSpace(os.Getenv("HORIZON_ACCESS_TOKEN")) + if tok == "" { + tc, err := loadTokenCache() + if err != nil { + return err + } + if tc == nil || tc.AccessToken == "" { + return fmt.Errorf("no token: run `%s auth login` or set HORIZON_ACCESS_TOKEN", progName()) + } + tok = tc.AccessToken + } + claims, err := decodeJWTPayload(tok) + if err == nil { + fmt.Println("JWT claims (unverified):") + if s, ok := claims["sub"].(string); ok { + fmt.Println(" sub:", s) + } + if s, ok := claims["preferred_username"].(string); ok { + fmt.Println(" preferred_username:", s) + } + if expUnix, ok := jwtClaimExpUnix(claims); ok { + fmt.Println(" exp:", formatJWTExpHuman(expUnix)) + } + } else { + fmt.Println("Token is not a JWT or could not be decoded:", err) + } + + cfg, err := LoadConfig() + if err != nil { + fmt.Fprintln(os.Stderr, "(Skipping API check:", err, ")") + return nil + } + c, err := newClient(cfg) + if err != nil { + return err + } + b, err := c.GetJSON(context.Background(), "/v1/catalog") + if err != nil { + return fmt.Errorf("catalog GET: %w", err) + } + fmt.Printf("Horizon API catalog: OK (%d bytes)\n", len(b)) + return nil +} + +func runAuth(args []string) error { + if len(args) < 1 { + return fmt.Errorf("usage: %s auth [flags]", progName()) + } + switch args[0] { + case "login": + return runAuthLogin(args[1:]) + case "logout": + return runAuthLogout() + case "refresh": + return runAuthRefresh(args[1:]) + case "whoami": + return runAuthWhoami() + default: + return fmt.Errorf("unknown auth subcommand %q", args[0]) + } +} diff --git a/tools/horizon/client.go b/tools/horizon/client.go new file mode 100644 index 00000000..ce379f3a --- /dev/null +++ b/tools/horizon/client.go @@ -0,0 +1,341 @@ +// Copyright (c) 2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "bytes" + "context" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strings" + "sync" + "time" +) + +// Client calls Horizon API with bearer token refresh on 401. +type Client struct { + cfg *Config + httpClient *http.Client + mu sync.Mutex + bearer string + // tokenCache is non-nil when using ~/.config/horizon/token.json (refresh can update file). + tokenCache *TokenCache + envToken bool // HORIZON_ACCESS_TOKEN set; never refresh from Keycloak in-process for 401 +} + +func defaultHTTPClient() *http.Client { + // Use DefaultTransport clone with HTTP/2 enabled (ALPN h2). An empty TLSNextProto map + // disables h2 and breaks Keycloak/API calls behind modern ingress that speaks HTTP/2. + return &http.Client{Transport: http.DefaultTransport.(*http.Transport).Clone()} +} + +func newClient(cfg *Config) (*Client, error) { + c := &Client{ + cfg: cfg, + httpClient: defaultHTTPClient(), + } + + if t := strings.TrimSpace(os.Getenv("HORIZON_ACCESS_TOKEN")); t != "" { + c.bearer = t + c.envToken = true + return c, nil + } + + tc, err := loadTokenCache() + if err != nil { + return nil, err + } + if tc != nil && tc.AccessToken != "" && tc.KeycloakBase != "" { + if !tc.IsExpired(90 * time.Second) { + c.bearer = tc.AccessToken + c.tokenCache = tc + return c, nil + } + if tc.RefreshToken != "" { + oauth := &OAuthClient{HTTP: c.httpClient} + nt, err := oauth.RefreshToken(context.Background(), tc.KeycloakBase, tc.Realm, tc.ClientID, strings.TrimSpace(os.Getenv("KEYCLOAK_CLIENT_SECRET")), tc.RefreshToken) + if err == nil { + c.bearer = nt.AccessToken + c.tokenCache = nt + _ = saveTokenCache(nt) + return c, nil + } + } + } + + if sec := strings.TrimSpace(os.Getenv("KEYCLOAK_CLIENT_SECRET")); sec != "" { + if cfg.KeycloakBase == "" { + return nil, fmt.Errorf("KEYCLOAK_CLIENT_SECRET set but KEYCLOAK_BASE or HORIZON_DOMAIN is required") + } + clientID := envOr("KEYCLOAK_CLIENT_ID", "horizon-api-ci") + oauth := &OAuthClient{HTTP: c.httpClient} + nt, err := oauth.ClientCredentials(context.Background(), cfg.KeycloakBase, cfg.KeycloakRealm, clientID, sec) + if err != nil { + return nil, fmt.Errorf("client_credentials: %w", err) + } + c.bearer = nt.AccessToken + _ = os.Setenv("HORIZON_ACCESS_TOKEN", nt.AccessToken) + return c, nil + } + + if tc != nil && tc.AccessToken != "" { + c.bearer = tc.AccessToken + c.tokenCache = tc + return c, nil + } + + return nil, fmt.Errorf("not authenticated: set HORIZON_ACCESS_TOKEN, KEYCLOAK_CLIENT_SECRET, or run `%s auth login`", progName()) +} + +func (c *Client) refreshBearer(ctx context.Context) error { + if c.envToken { + return fmt.Errorf("HORIZON_ACCESS_TOKEN rejected by server (401)") + } + if c.tokenCache != nil && c.tokenCache.RefreshToken != "" && c.tokenCache.KeycloakBase != "" { + oauth := &OAuthClient{HTTP: c.httpClient} + nt, err := oauth.RefreshToken(ctx, c.tokenCache.KeycloakBase, c.tokenCache.Realm, c.tokenCache.ClientID, strings.TrimSpace(os.Getenv("KEYCLOAK_CLIENT_SECRET")), c.tokenCache.RefreshToken) + if err == nil { + c.bearer = nt.AccessToken + c.tokenCache = nt + _ = saveTokenCache(nt) + _ = os.Setenv("HORIZON_ACCESS_TOKEN", nt.AccessToken) + return nil + } + } + if sec := strings.TrimSpace(os.Getenv("KEYCLOAK_CLIENT_SECRET")); sec != "" && c.cfg.KeycloakBase != "" { + clientID := envOr("KEYCLOAK_CLIENT_ID", "horizon-api-ci") + oauth := &OAuthClient{HTTP: c.httpClient} + nt, err := oauth.ClientCredentials(ctx, c.cfg.KeycloakBase, c.cfg.KeycloakRealm, clientID, sec) + if err != nil { + return err + } + c.bearer = nt.AccessToken + _ = os.Setenv("HORIZON_ACCESS_TOKEN", nt.AccessToken) + return nil + } + return fmt.Errorf("cannot refresh token: run `%s auth login` or set KEYCLOAK_CLIENT_SECRET", progName()) +} + +func (c *Client) doOnce(ctx context.Context, method, path string, body io.Reader, contentType string) ([]byte, int, error) { + u, err := url.Parse(c.cfg.BaseURL + path) + if err != nil { + return nil, 0, err + } + req, err := http.NewRequestWithContext(ctx, method, u.String(), body) + if err != nil { + return nil, 0, err + } + if contentType != "" { + req.Header.Set("Content-Type", contentType) + } + req.Header.Set("Accept", "application/json") + if c.bearer != "" { + req.Header.Set("Authorization", "Bearer "+c.bearer) + } + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, 0, err + } + defer resp.Body.Close() + b, err := io.ReadAll(resp.Body) + if err != nil { + return nil, resp.StatusCode, err + } + return b, resp.StatusCode, nil +} + +// DoJSON performs a request and retries once on 401 after refreshing the bearer token. +func (c *Client) DoJSON(ctx context.Context, method, path string, jsonBody []byte) ([]byte, error) { + c.mu.Lock() + defer c.mu.Unlock() + + var body io.Reader + ct := "" + if jsonBody != nil { + body = bytes.NewReader(jsonBody) + ct = "application/json" + } + b, code, err := c.doOnce(ctx, method, path, body, ct) + if err != nil { + return nil, err + } + if code == http.StatusUnauthorized && !c.envToken { + if err := c.refreshBearer(ctx); err != nil { + return nil, err + } + if jsonBody != nil { + body = bytes.NewReader(jsonBody) + } else { + body = nil + } + b, code, err = c.doOnce(ctx, method, path, body, ct) + if err != nil { + return nil, err + } + } + if code < 200 || code > 299 { + return nil, fmt.Errorf("%s %s: HTTP %d %s", method, path, code, truncate(string(b), 400)) + } + return b, nil +} + +// DoJSONWithHeaders is like DoJSON but merges extra HTTP headers (e.g. X-Horizon-Submitted-From). +func (c *Client) DoJSONWithHeaders(ctx context.Context, method, path string, jsonBody []byte, headers map[string]string) ([]byte, error) { + c.mu.Lock() + defer c.mu.Unlock() + + var body io.Reader + ct := "" + if jsonBody != nil { + body = bytes.NewReader(jsonBody) + ct = "application/json" + } + b, code, err := c.doOnceWithHeaders(ctx, method, path, body, ct, headers) + if err != nil { + return nil, err + } + if code == http.StatusUnauthorized && !c.envToken { + if err := c.refreshBearer(ctx); err != nil { + return nil, err + } + if jsonBody != nil { + body = bytes.NewReader(jsonBody) + } else { + body = nil + } + b, code, err = c.doOnceWithHeaders(ctx, method, path, body, ct, headers) + if err != nil { + return nil, err + } + } + if code < 200 || code > 299 { + return nil, fmt.Errorf("%s %s: HTTP %d %s", method, path, code, truncate(string(b), 400)) + } + return b, nil +} + +func (c *Client) doOnceWithHeaders(ctx context.Context, method, path string, body io.Reader, contentType string, headers map[string]string) ([]byte, int, error) { + u, err := url.Parse(c.cfg.BaseURL + path) + if err != nil { + return nil, 0, err + } + req, err := http.NewRequestWithContext(ctx, method, u.String(), body) + if err != nil { + return nil, 0, err + } + if contentType != "" { + req.Header.Set("Content-Type", contentType) + } + req.Header.Set("Accept", "application/json") + for k, v := range headers { + if strings.TrimSpace(k) != "" { + req.Header.Set(k, v) + } + } + if c.bearer != "" { + req.Header.Set("Authorization", "Bearer "+c.bearer) + } + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, 0, err + } + defer resp.Body.Close() + b, err := io.ReadAll(resp.Body) + if err != nil { + return nil, resp.StatusCode, err + } + return b, resp.StatusCode, nil +} + +func truncate(s string, n int) string { + s = strings.ReplaceAll(s, "\n", " ") + if len(s) <= n { + return s + } + return s[:n] + "…" +} + +// GetJSON GETs a JSON resource. +func (c *Client) GetJSON(ctx context.Context, path string) ([]byte, error) { + c.mu.Lock() + defer c.mu.Unlock() + b, code, err := c.doOnce(ctx, http.MethodGet, path, nil, "") + if err != nil { + return nil, err + } + if code == http.StatusUnauthorized && !c.envToken { + if err := c.refreshBearer(ctx); err != nil { + return nil, err + } + b, code, err = c.doOnce(ctx, http.MethodGet, path, nil, "") + if err != nil { + return nil, err + } + } + if code < 200 || code > 299 { + return nil, fmt.Errorf("GET %s: HTTP %d %s", path, code, truncate(string(b), 400)) + } + return b, nil +} + +// StreamGET opens a GET response for streaming (caller must close body). +func (c *Client) StreamGET(ctx context.Context, rawURL string) (*http.Response, error) { + c.mu.Lock() + defer c.mu.Unlock() + + do := func() (*http.Response, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/x-ndjson") + req.Header.Set("Accept-Encoding", "identity") + if c.bearer != "" { + req.Header.Set("Authorization", "Bearer "+c.bearer) + } + return c.httpClient.Do(req) + } + resp, err := do() + if err != nil { + return nil, err + } + if resp.StatusCode == http.StatusUnauthorized && !c.envToken { + resp.Body.Close() + if err := c.refreshBearer(ctx); err != nil { + return nil, err + } + resp, err = do() + if err != nil { + return nil, err + } + } + if resp.StatusCode < 200 || resp.StatusCode > 299 { + b, _ := io.ReadAll(resp.Body) + resp.Body.Close() + return nil, fmt.Errorf("stream GET: HTTP %d %s", resp.StatusCode, truncate(string(b), 400)) + } + return resp, nil +} + +// GetURLPlain performs an HTTP GET without adding Authorization (e.g. GCS V4 signed URL). +func GetURLPlain(ctx context.Context, rawURL string) (*http.Response, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + if err != nil { + return nil, err + } + return defaultHTTPClient().Do(req) +} diff --git a/tools/horizon/commands.go b/tools/horizon/commands.go new file mode 100644 index 00000000..a17c0b41 --- /dev/null +++ b/tools/horizon/commands.go @@ -0,0 +1,1202 @@ +// Copyright (c) 2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" + + "golang.org/x/term" +) + +const submittedFromParamName = "horizonSubmittedFrom" +const submittedFromCLIValue = "horizon-cli" + +func stringParamEmpty(v any) bool { + if v == nil { + return true + } + s, ok := v.(string) + return ok && s == "" +} + +// catalogParameter matches GET /v1/catalog entries[].parameters[]. +type catalogParameter struct { + Name string `json:"name"` + Default string `json:"default,omitempty"` + Description string `json:"description,omitempty"` +} + +// catalogEntry matches one GET /v1/catalog entries[] item. +type catalogEntry struct { + Module string `json:"module"` + TemplateName string `json:"templateName"` + Parameters []catalogParameter `json:"parameters"` +} + +func findCatalogEntry(ctx context.Context, c *Client, module, template string) (*catalogEntry, error) { + b, err := c.GetJSON(ctx, "/v1/catalog") + if err != nil { + return nil, err + } + var resp struct { + Entries []catalogEntry `json:"entries"` + } + if err := json.Unmarshal(b, &resp); err != nil { + return nil, fmt.Errorf("catalog JSON: %w", err) + } + for i := range resp.Entries { + e := &resp.Entries[i] + if e.Module == module && e.TemplateName == template { + return e, nil + } + } + return nil, fmt.Errorf("catalog missing module=%s template=%s", module, template) +} + +// mergeSubmitParamsFromCatalog builds the submit "parameters" object from catalog-declared +// names and defaults, then overlays user JSON. Rejects keys not declared for this template. +func mergeSubmitParamsFromCatalog(user map[string]any, entry *catalogEntry) (map[string]any, error) { + byName := make(map[string]catalogParameter, len(entry.Parameters)) + for _, p := range entry.Parameters { + byName[p.Name] = p + } + for k := range user { + if _, ok := byName[k]; !ok { + return nil, fmt.Errorf("unknown workflow parameter %q (not in catalog for %s/%s); see GET /v1/catalog", + k, entry.Module, entry.TemplateName) + } + } + out := make(map[string]any, len(entry.Parameters)) + for _, p := range entry.Parameters { + uv, userSet := user[p.Name] + switch { + case !userSet: + out[p.Name] = p.Default + case stringParamEmpty(uv): + if p.Default != "" { + out[p.Name] = p.Default + } else { + out[p.Name] = "" + } + default: + out[p.Name] = uv + } + } + return out, nil +} + +func readParamsJSON(paramsFile, paramsEnv string) (map[string]any, error) { + var raw []byte + switch { + case strings.TrimSpace(paramsFile) == "-": + b, err := io.ReadAll(os.Stdin) + if err != nil { + return nil, fmt.Errorf("read params stdin: %w", err) + } + raw = b + case strings.TrimSpace(paramsFile) != "": + b, err := os.ReadFile(paramsFile) + if err != nil { + return nil, fmt.Errorf("read params file: %w", err) + } + raw = b + case strings.TrimSpace(paramsEnv) != "": + raw = []byte(paramsEnv) + default: + raw = []byte("{}") + } + raw = bytesTrimSpace(raw) + var m map[string]any + if err := json.Unmarshal(raw, &m); err != nil { + return nil, fmt.Errorf("workflow parameters JSON: %w", err) + } + if m == nil { + m = map[string]any{} + } + return m, nil +} + +func bytesTrimSpace(b []byte) []byte { + return []byte(strings.TrimSpace(string(b))) +} + +func runningNames(ctx context.Context, c *Client, cfg *Config) ([]string, error) { + path := fmt.Sprintf("/v1/workflows/running?limit=%d", cfg.RunningPollLimit) + b, err := c.GetJSON(ctx, path) + if err != nil { + return nil, err + } + var resp struct { + Items []struct { + Name string `json:"name"` + } `json:"items"` + } + if err := json.Unmarshal(b, &resp); err != nil { + return nil, err + } + names := make([]string, 0, len(resp.Items)) + for _, it := range resp.Items { + if it.Name != "" { + names = append(names, it.Name) + } + } + return names, nil +} + +func pickNewName(before, after []string) string { + bset := make(map[string]struct{}, len(before)) + for _, n := range before { + bset[n] = struct{}{} + } + for _, n := range after { + if _, ok := bset[n]; !ok { + return n + } + } + return "" +} + +func waitForNewRunning(ctx context.Context, c *Client, cfg *Config, before []string) (string, error) { + for i := 0; i < cfg.WaitNewAttempts; i++ { + after, err := runningNames(ctx, c, cfg) + if err != nil { + return "", err + } + if w := pickNewName(before, after); w != "" { + return w, nil + } + select { + case <-ctx.Done(): + return "", ctx.Err() + case <-time.After(time.Duration(cfg.WaitNewSleepSec) * time.Second): + } + } + return "", fmt.Errorf("no new workflow in time (check Argo Events)") +} + +func workflowPhase(ctx context.Context, c *Client, wf string) (string, error) { + b, err := c.GetJSON(ctx, "/v1/workflows/"+url.PathEscape(wf)) + if err != nil { + return "", err + } + var detail struct { + Phase string `json:"phase"` + } + if err := json.Unmarshal(b, &detail); err != nil { + return "", err + } + return detail.Phase, nil +} + +func hasPodHint(ctx context.Context, c *Client, wf string) (bool, error) { + b, err := c.GetJSON(ctx, "/v1/workflows/"+url.PathEscape(wf)) + if err != nil { + return false, err + } + var detail struct { + Nodes []struct { + PodName *string `json:"podName"` + } `json:"nodes"` + } + if err := json.Unmarshal(b, &detail); err != nil { + return false, err + } + for _, n := range detail.Nodes { + if n.PodName != nil && strings.TrimSpace(*n.PodName) != "" { + return true, nil + } + } + return false, nil +} + +func waitForPodHint(ctx context.Context, c *Client, cfg *Config, wf string) error { + if cfg.LogWaitPodSecs <= 0 { + return nil + } + deadline := time.Now().Add(time.Duration(cfg.LogWaitPodSecs) * time.Second) + for time.Now().Before(deadline) { + ok, err := hasPodHint(ctx, c, wf) + if err != nil { + return err + } + if ok { + return nil + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(2 * time.Second): + } + } + return nil +} + +// waitForPodHintVerbose prints progress on stderr until pods exist or wait budget ends (for --logs). +func waitForPodHintVerbose(ctx context.Context, c *Client, cfg *Config, wf string) error { + if cfg.LogWaitPodSecs <= 0 { + fmt.Fprintf(os.Stderr, "Logs: opening live stream for workflow %s (pod wait disabled).\n", wf) + return nil + } + fmt.Fprintf(os.Stderr, "Logs: waiting for workflow pods (workflow %s, up to %ds) …\n", wf, cfg.LogWaitPodSecs) + deadline := time.Now().Add(time.Duration(cfg.LogWaitPodSecs) * time.Second) + for time.Now().Before(deadline) { + ok, err := hasPodHint(ctx, c, wf) + if err != nil { + return err + } + if ok { + fmt.Fprintf(os.Stderr, "Logs: pods are visible; streaming output below.\n") + return nil + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(2 * time.Second): + } + fmt.Fprintf(os.Stderr, "Logs: still waiting for pod assignment …\n") + } + fmt.Fprintf(os.Stderr, "Logs: opening stream anyway (pods may appear shortly).\n") + return nil +} + +func terminalPhase(ph string) bool { + switch ph { + case "Succeeded", "Failed", "Error", "Aborted": + return true + default: + return false + } +} + +func phaseToExitCode(ph string) int { + switch ph { + case "Succeeded": + return 0 + case "Aborted": + return 2 + case "Failed", "Error": + return 1 + default: + return 1 + } +} + +// stringFromAny turns a JSON-decoded value into a display string. nil and absent fields → "" (not ""). +func stringFromAny(v any) string { + if v == nil { + return "" + } + switch x := v.(type) { + case string: + return strings.TrimSpace(x) + default: + return strings.TrimSpace(fmt.Sprint(x)) + } +} + +func formatNDJSONLine(trimmed, originalLine []byte, formatted bool) ([]byte, bool) { + if len(bytes.TrimSpace(trimmed)) == 0 { + return nil, false + } + if !formatted { + out := originalLine + if !bytes.HasSuffix(out, []byte("\n")) { + out = append(append([]byte{}, out...), '\n') + } + return out, true + } + var o map[string]any + if err := json.Unmarshal(trimmed, &o); err != nil { + out := originalLine + if !bytes.HasSuffix(out, []byte("\n")) { + out = append(append([]byte{}, out...), '\n') + } + return out, true + } + if hb, ok := o["heartbeat"].(bool); ok && hb { + return nil, false + } + if res, _ := o["result"].(string); res == "done" { + reason, _ := o["reason"].(string) + ws, _ := o["workflowStatus"].(string) + detail, _ := o["detail"].(string) + stage := ws + if stage == "" { + stage = reason + } + if stage == "" { + stage = "done" + } + msg := detail + if msg == "" { + msg = reason + } + if msg == "" { + msg = "-" + } + return []byte(fmt.Sprintf("[%s] [-] [%s]\n", stage, singleLine(msg))), true + } + ts := stringFromAny(o["ts"]) + if ts == "" { + ts = "-" + } + msg := stringFromAny(o["msg"]) + stage := stringFromAny(o["displayName"]) + if stage == "" { + stage = stringFromAny(o["templateName"]) + } + if stage == "" { + stage = stringFromAny(o["podName"]) + } + if stage == "" { + stage = "log" + } + return []byte(fmt.Sprintf("[%s] [%s] [%s]\n", stage, ts, singleLine(msg))), true +} + +func singleLine(s string) string { + s = strings.ReplaceAll(s, "\r", " ") + s = strings.ReplaceAll(s, "\n", " ") + return strings.TrimSpace(s) +} + +func followWorkflowLogsDuringWait(ctx context.Context, c *Client, cfg *Config, wf string) error { + if err := waitForPodHintVerbose(ctx, c, cfg, wf); err != nil { + return err + } + if ctx.Err() != nil { + return ctx.Err() + } + return streamLogs(ctx, c, cfg, wf) +} + +// waitWithOptionalLogs runs pollUntilTerminal and, when followLogs is true, log streaming in parallel. +// Cancels the shared context when the workflow reaches a terminal phase (or poll errors). +func waitWithOptionalLogs(ctx context.Context, c *Client, cfg *Config, wf string, followLogs bool) (int, error) { + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + var ph string + var pollErr error + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + ph, pollErr = pollUntilTerminal(ctx, c, cfg, wf) + cancel() + }() + + var logErr error + if followLogs { + wg.Add(1) + go func() { + defer wg.Done() + logErr = followWorkflowLogsDuringWait(ctx, c, cfg, wf) + }() + } + wg.Wait() + + if pollErr != nil { + return 1, pollErr + } + if followLogs && logErr != nil && !errors.Is(logErr, context.Canceled) { + fmt.Fprintf(os.Stderr, "logs: %v\n", logErr) + } + return phaseToExitCode(ph), nil +} + +func streamLogs(ctx context.Context, c *Client, cfg *Config, wf string) error { + ph, err := workflowPhase(ctx, c, wf) + if err != nil { + return err + } + follow := "true" + if terminalPhase(ph) { + follow = "false" + } + logURL := fmt.Sprintf("%s/v1/workflows/%s/log?follow=%s&container=main", + cfg.BaseURL, url.PathEscape(wf), follow) + fmt.Printf("━━ Horizon log stream ━━ %s (max-time=%ds follow=%s format=%s)\n", + logURL, cfg.LogStreamMaxSecs, follow, cfg.LogStreamFormat) + + sub, cancel := context.WithTimeout(ctx, time.Duration(cfg.LogStreamMaxSecs)*time.Second) + defer cancel() + + resp, err := c.StreamGET(sub, logURL) + if err != nil { + return err + } + defer resp.Body.Close() + + formatted := cfg.LogStreamFormat != "RAW" + br := bufio.NewReader(resp.Body) + for { + line, err := br.ReadBytes('\n') + if len(line) > 0 { + trim := trimCRLFLine(line) + out, ok := formatNDJSONLine(trim, line, formatted) + if ok { + os.Stdout.Write(out) + } + } + if err == io.EOF { + break + } + if err != nil { + return err + } + } + fmt.Println("━━ log stream end ━━") + return nil +} + +func trimCRLFLine(b []byte) []byte { + b = bytes.TrimSuffix(b, []byte("\n")) + b = bytes.TrimSuffix(b, []byte("\r")) + return b +} + +// pollUntilTerminal blocks until workflow reaches a terminal phase or timeout. +func pollUntilTerminal(ctx context.Context, c *Client, cfg *Config, wf string) (string, error) { + deadline := time.Now().Add(time.Duration(cfg.WaitTerminalSecs) * time.Second) + for time.Now().Before(deadline) { + ph, err := workflowPhase(ctx, c, wf) + if err != nil { + return "", err + } + if terminalPhase(ph) { + fmt.Fprintf(os.Stderr, "Terminal phase: %s\n", ph) + return ph, nil + } + select { + case <-ctx.Done(): + return "", ctx.Err() + case <-time.After(time.Duration(cfg.TerminalPollInterval) * time.Second): + } + } + return "", fmt.Errorf("timeout waiting for terminal phase on %s", wf) +} + +type submitOutputMode string + +const ( + submitOutText submitOutputMode = "text" + submitOutJSON submitOutputMode = "json" +) + +func printSubmitAckHuman(resp []byte, outMode submitOutputMode, quiet bool) { + if quiet || outMode != submitOutText { + return + } + var m map[string]any + if err := json.Unmarshal(resp, &m); err != nil { + fmt.Println(string(resp)) + return + } + st := strings.ToLower(stringFromAny(m["status"])) + if st == "dispatched" { + fmt.Println("Submit accepted: Horizon handed the run off to the workflow event dispatcher; it should show up in /v1/workflows/running momentarily.") + return + } + var pretty bytes.Buffer + if err := json.Indent(&pretty, resp, "", " "); err != nil { + fmt.Println(string(resp)) + } else { + fmt.Println(pretty.String()) + } +} + +func catalogHasParam(entry *catalogEntry, paramName string) bool { + for _, p := range entry.Parameters { + if p.Name == paramName { + return true + } + } + return false +} + +// cmdSubmit posts a workflow; prints workflow name; optional --wait returns phase exit code. +func cmdSubmit(c *Client, cfg *Config, paramsFile, paramsEnv string, wait, followLogs bool, outMode submitOutputMode, quiet bool) (int, error) { + ctx := context.Background() + entry, err := findCatalogEntry(ctx, c, cfg.Module, cfg.Template) + if err != nil { + return 1, err + } + inner, err := readParamsJSON(paramsFile, paramsEnv) + if err != nil { + return 1, err + } + params, err := mergeSubmitParamsFromCatalog(inner, entry) + if err != nil { + return 1, err + } + if catalogHasParam(entry, submittedFromParamName) && stringParamEmpty(params[submittedFromParamName]) { + // Keep source explicit in submit payload for templates that expose this parameter. + params[submittedFromParamName] = submittedFromCLIValue + } + bodyMap := map[string]any{ + "parameters": params, + } + body, err := json.Marshal(bodyMap) + if err != nil { + return 1, err + } + before, err := runningNames(ctx, c, cfg) + if err != nil { + return 1, err + } + path := fmt.Sprintf("/v1/modules/%s/workflowTemplates/%s/submit", + url.PathEscape(cfg.Module), url.PathEscape(cfg.Template)) + if !quiet { + fmt.Fprintf(os.Stderr, "Submitting %s/%s …\n", cfg.Module, cfg.Template) + } + resp, err := c.DoJSONWithHeaders(ctx, "POST", path, body, map[string]string{ + "X-Horizon-Submitted-From": submittedFromCLIValue, + }) + if err != nil { + return 1, err + } + if !quiet { + if outMode == submitOutJSON { + // Response body printed after we know workflowName (below). + } else { + printSubmitAckHuman(resp, outMode, quiet) + fmt.Fprintln(os.Stderr, "Waiting for new workflow in /v1/workflows/running …") + } + } + + wf, err := waitForNewRunning(ctx, c, cfg, before) + if err != nil { + return 1, err + } + + if outMode == submitOutJSON { + var submitObj map[string]any + _ = json.Unmarshal(resp, &submitObj) + wrap := map[string]any{ + "module": cfg.Module, + "template": cfg.Template, + "workflowName": wf, + "submitResponse": submitObj, + } + b, _ := json.MarshalIndent(wrap, "", " ") + fmt.Println(string(b)) + } else { + fmt.Println("module=" + cfg.Module) + fmt.Println("template=" + cfg.Template) + fmt.Println("workflowName=" + wf) + } + + if !wait { + return 0, nil + } + if followLogs { + return waitWithOptionalLogs(ctx, c, cfg, wf, true) + } + ph, err := pollUntilTerminal(ctx, c, cfg, wf) + if err != nil { + return 1, err + } + return phaseToExitCode(ph), nil +} + +// cmdWait polls until terminal; exit code matches finalize semantics. +func cmdWait(c *Client, cfg *Config, wf string, followLogs bool) (int, error) { + ctx := context.Background() + if !followLogs { + ph, err := pollUntilTerminal(ctx, c, cfg, wf) + if err != nil { + return 1, err + } + return phaseToExitCode(ph), nil + } + return waitWithOptionalLogs(ctx, c, cfg, wf, true) +} + +// cmdLogsForWorkflow streams logs for a workflow name. +func cmdLogsForWorkflow(c *Client, cfg *Config, wf string) error { + ctx := context.Background() + if err := waitForPodHint(ctx, c, cfg, wf); err != nil { + return err + } + err := streamLogs(ctx, c, cfg, wf) + if err != nil { + fmt.Fprintf(os.Stderr, "log stream: %v\n", err) + } + return nil +} + +type archivedLogLinks struct { + Combined *struct { + GcsURI string `json:"gcsUri"` + } `json:"combined"` + Steps []struct { + GcsURI string `json:"gcsUri"` + } `json:"steps"` +} + +type outputArtifact struct { + Name string `json:"name"` + GcsURI string `json:"gcsUri"` +} + +func printWorkflowShowText(b []byte) error { + var root map[string]json.RawMessage + if err := json.Unmarshal(b, &root); err != nil { + return err + } + summary := make(map[string]json.RawMessage) + for _, k := range []string{"name", "phase", "workflowTemplate", "submittedFrom", "archivedLogs", "outputArtifacts"} { + if v, ok := root[k]; ok { + summary[k] = v + } + } + sum, _ := json.MarshalIndent(summary, "", " ") + fmt.Println("━━ Workflow detail ━━") + fmt.Println(string(sum)) + + var ph string + if raw, ok := root["phase"]; ok { + _ = json.Unmarshal(raw, &ph) + } + + fmt.Println("━━ GCS / artifact URIs ━━") + var al archivedLogLinks + if raw, ok := root["archivedLogs"]; ok && string(raw) != "null" { + _ = json.Unmarshal(raw, &al) + } + if al.Combined != nil && al.Combined.GcsURI != "" { + fmt.Println("archivedLogs.combined:", al.Combined.GcsURI) + } + for _, s := range al.Steps { + if s.GcsURI != "" { + fmt.Println("archivedLogs.step:", s.GcsURI) + } + } + var arts []outputArtifact + if raw, ok := root["outputArtifacts"]; ok { + _ = json.Unmarshal(raw, &arts) + } + for _, a := range arts { + if a.GcsURI != "" { + fmt.Printf("outputArtifact: %s %s\n", a.Name, a.GcsURI) + } + } + return nil +} + +// cmdWorkflowShow prints workflow detail (--output json for raw body). +func cmdWorkflowShow(ctx context.Context, c *Client, wf, output string) error { + b, err := c.GetJSON(ctx, "/v1/workflows/"+url.PathEscape(wf)) + if err != nil { + return err + } + if output == "json" { + var pretty bytes.Buffer + if err := json.Indent(&pretty, b, "", " "); err != nil { + fmt.Println(string(b)) + } else { + fmt.Println(pretty.String()) + } + return nil + } + return printWorkflowShowText(b) +} + +func cmdAbortWorkflowName(c *Client, wf string) error { + ctx := context.Background() + ph, err := workflowPhase(ctx, c, wf) + if err != nil { + ph = "" + } + if terminalPhase(ph) { + return nil + } + fmt.Fprintf(os.Stderr, "Aborting workflow %s via Horizon API …\n", wf) + path := "/v1/workflows/" + url.PathEscape(wf) + "/abort" + _, _ = c.DoJSON(ctx, "POST", path, []byte("{}")) + return nil +} + +func cmdDeleteWorkflowName(ctx context.Context, c *Client, wf string) error { + fmt.Fprintf(os.Stderr, "Deleting workflow %s via Horizon API (server waits until the Workflow CR and finalizers are gone; may take several minutes)…\n", wf) + path := "/v1/workflows/" + url.PathEscape(wf) + b, err := c.DoJSON(ctx, "DELETE", path, nil) + if err != nil { + return err + } + if len(bytes.TrimSpace(b)) > 0 { + fmt.Println(string(b)) + } + return nil +} + +func cmdCatalogGet(ctx context.Context, c *Client, output string, raw bool) error { + b, err := c.GetJSON(ctx, "/v1/catalog") + if err != nil { + return err + } + switch strings.ToLower(strings.TrimSpace(output)) { + case "text": + if raw { + return fmt.Errorf("-raw applies only to -output json") + } + return printCatalogText(b) + case "json": + if raw { + fmt.Println(string(b)) + return nil + } + var pretty bytes.Buffer + if err := json.Indent(&pretty, b, "", " "); err != nil { + fmt.Println(string(b)) + } else { + fmt.Println(pretty.String()) + } + return nil + default: + return fmt.Errorf("catalog get: -output must be text or json") + } +} + +func printCatalogText(b []byte) error { + var resp struct { + Entries []struct { + Module string `json:"module"` + TemplateName string `json:"templateName"` + Namespace string `json:"namespace"` + Parameters []struct { + Name string `json:"name"` + Default string `json:"default,omitempty"` + Description string `json:"description,omitempty"` + } `json:"parameters"` + } `json:"entries"` + } + if err := json.Unmarshal(b, &resp); err != nil { + return fmt.Errorf("catalog JSON: %w", err) + } + n := len(resp.Entries) + if n == 0 { + fmt.Println("Catalog: (no entries)") + return nil + } + pl := "ies" + if n == 1 { + pl = "y" + } + fmt.Printf("Catalog (%d entr%s)\n", n, pl) + for _, e := range resp.Entries { + fmt.Printf("\n %s / %s\n", e.Module, e.TemplateName) + if strings.TrimSpace(e.Namespace) != "" { + fmt.Printf(" namespace: %s\n", e.Namespace) + } + if len(e.Parameters) == 0 { + fmt.Println(" parameters: (none)") + continue + } + fmt.Println(" parameters:") + for _, p := range e.Parameters { + line := fmt.Sprintf(" - %s", p.Name) + if p.Default != "" { + line += fmt.Sprintf(" (default: %q)", p.Default) + } + fmt.Println(line) + if strings.TrimSpace(p.Description) != "" { + fmt.Printf(" %s\n", strings.TrimSpace(p.Description)) + } + } + } + return nil +} + +type runningListItem struct { + Name string `json:"name"` + Phase string `json:"phase"` + SubmittedFrom string `json:"submittedFrom,omitempty"` +} + +type historyListItem struct { + Name string `json:"name"` + Phase string `json:"phase"` + SubmittedFrom string `json:"submittedFrom,omitempty"` +} + +func cmdWorkflowList(ctx context.Context, c *Client, runningOnly bool, limit int, continueToken, phaseFilter, output string) error { + if limit <= 0 { + limit = 50 + } + autoOut := output == "" + if autoOut { + if term.IsTerminal(int(os.Stdout.Fd())) { + output = "text" + } else { + output = "json" + } + } + + var runJSON, histJSON []byte + var err error + runJSON, err = c.GetJSON(ctx, fmt.Sprintf("/v1/workflows/running?limit=%d", limit)) + if err != nil { + return err + } + if !runningOnly { + q := url.Values{} + q.Set("limit", fmt.Sprintf("%d", limit)) + if continueToken != "" { + q.Set("continue", continueToken) + } + if phaseFilter != "" { + q.Set("phase", phaseFilter) + } + histJSON, err = c.GetJSON(ctx, "/v1/workflows/history?"+q.Encode()) + if err != nil { + return err + } + } + + if output == "json" || output == "wide" { + combined := map[string]json.RawMessage{"running": runJSON} + if !runningOnly { + combined["history"] = histJSON + } + b, err := json.MarshalIndent(combined, "", " ") + if err != nil { + return err + } + fmt.Println(string(b)) + return nil + } + + // text table + var runWrap struct { + Items []runningListItem `json:"items"` + } + _ = json.Unmarshal(runJSON, &runWrap) + fmt.Println("━━ Running ━━") + fmt.Println("workflow\tphase\tsubmitted-from") + for _, it := range runWrap.Items { + src := it.SubmittedFrom + if src == "" { + src = "—" + } + fmt.Printf("%s\t%s\t%s\n", it.Name, it.Phase, src) + } + if runningOnly { + return nil + } + var histWrap struct { + Items []historyListItem `json:"items"` + } + _ = json.Unmarshal(histJSON, &histWrap) + fmt.Println("━━ History ━━") + fmt.Println("workflow\tphase\tsubmitted-from") + for _, it := range histWrap.Items { + src := it.SubmittedFrom + if src == "" { + src = "—" + } + fmt.Printf("%s\t%s\t%s\n", it.Name, it.Phase, src) + } + return nil +} + +// cmdWorkflowRunning prints running workflows (compat). +func cmdWorkflowRunning(ctx context.Context, c *Client, limit int) error { + return cmdWorkflowList(ctx, c, true, limit, "", "", "json") +} + +// cmdWorkflowHistory prints history (compat). +func cmdWorkflowHistory(ctx context.Context, c *Client, limit int, continueToken, phase string) error { + if limit <= 0 { + limit = 50 + } + q := url.Values{} + q.Set("limit", fmt.Sprintf("%d", limit)) + if continueToken != "" { + q.Set("continue", continueToken) + } + if phase != "" { + q.Set("phase", phase) + } + path := "/v1/workflows/history?" + q.Encode() + b, err := c.GetJSON(ctx, path) + if err != nil { + return err + } + var pretty bytes.Buffer + if err := json.Indent(&pretty, b, "", " "); err != nil { + fmt.Println(string(b)) + } else { + fmt.Println(pretty.String()) + } + return nil +} + +func cmdWorkflowGet(ctx context.Context, c *Client, name string) error { + return cmdWorkflowShow(ctx, c, name, "json") +} + +func formatIECBytes(n int64) string { + switch { + case n < 0: + return "?" + case n < 1024: + return fmt.Sprintf("%d B", n) + } + x := float64(n) + u := 0 + for x >= 1024 && u < 3 { + x /= 1024 + u++ + } + suf := []string{"KiB", "MiB", "GiB"}[u] + if x >= 100 { + return fmt.Sprintf("%.0f %s", x, suf) + } + return fmt.Sprintf("%.2f %s", x, suf) +} + +func shortenPath(s string, max int) string { + if len(s) <= max { + return s + } + if max < 12 { + return s[:max] + } + head := max/2 - 2 + tail := max - head - 3 + return s[:head] + "..." + s[len(s)-tail:] +} + +type countingWriter struct { + w io.Writer + n *int64 +} + +func (c *countingWriter) Write(p []byte) (int, error) { + nn, err := c.w.Write(p) + if nn > 0 { + atomic.AddInt64(c.n, int64(nn)) + } + return nn, err +} + +// copyDownloadWithProgress copies r to w while printing a live progress line to progOut (typically stderr). +func copyDownloadWithProgress(r io.Reader, w io.Writer, total int64, destPath string, progOut *os.File) (written int64, err error) { + var n int64 + cw := &countingWriter{w: w, n: &n} + fancy := term.IsTerminal(int(progOut.Fd())) + barW := 28 + stop := make(chan struct{}) + var wg sync.WaitGroup + wg.Add(1) + start := time.Now() + go func() { + defer wg.Done() + ticker := time.NewTicker(200 * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-stop: + return + case <-ticker.C: + cur := atomic.LoadInt64(&n) + elapsed := time.Since(start) + label := shortenPath(destPath, 56) + if !fancy { + var etaStr string + if total > 0 && cur > 0 && elapsed >= time.Millisecond { + rate := float64(cur) / elapsed.Seconds() + if rate >= 1 { + remSec := float64(total-cur) / rate + if remSec > 0 { + etaStr = fmt.Sprintf(" ETA~%s", time.Duration(remSec*float64(time.Second)).Round(time.Second)) + } + } + } + sizePart := formatIECBytes(cur) + if total > 0 { + sizePart = formatIECBytes(cur) + " / " + formatIECBytes(total) + } else { + sizePart += " (unknown total)" + } + fmt.Fprintf(progOut, "%s %s elapsed %s%s\n", label, sizePart, elapsed.Round(time.Second), etaStr) + continue + } + var etaStr string + if total > 0 && cur > 0 && elapsed >= time.Millisecond { + rate := float64(cur) / elapsed.Seconds() + if rate >= 1 { + remSec := float64(total-cur) / rate + if remSec > 0 { + etaStr = fmt.Sprintf(" ETA ~%s", time.Duration(remSec*float64(time.Second)).Round(time.Second)) + } + } + } + pct := 0.0 + filled := 0 + if total > 0 { + pct = 100 * float64(cur) / float64(total) + if pct > 100 { + pct = 100 + } + filled = int(pct / 100 * float64(barW)) + } + bar := strings.Repeat("#", filled) + strings.Repeat("-", barW-filled) + sizePart := formatIECBytes(cur) + if total > 0 { + sizePart = formatIECBytes(cur) + " / " + formatIECBytes(total) + } else { + sizePart += " (unknown total)" + } + fmt.Fprintf(progOut, "\r%s [%s] %5.1f%% %s elapsed %s%s", + label, bar, pct, sizePart, elapsed.Round(time.Second), etaStr) + } + } + }() + _, err = io.Copy(cw, r) + close(stop) + wg.Wait() + nn := atomic.LoadInt64(&n) + if fancy { + fmt.Fprintf(progOut, "\r%s\r", strings.Repeat(" ", 120)) + } + if err != nil { + fmt.Fprintf(progOut, "download failed after %s: %v (%s transferred)\n", + time.Since(start).Round(time.Second), err, formatIECBytes(nn)) + return nn, err + } + elapsed := time.Since(start) + if total >= 0 { + fmt.Fprintf(progOut, "download complete: %s (%s) in %s -> %s\n", + formatIECBytes(nn), formatIECBytes(total), elapsed.Round(time.Second), destPath) + } else { + fmt.Fprintf(progOut, "download complete: %s in %s -> %s\n", + formatIECBytes(nn), elapsed.Round(time.Second), destPath) + } + return nn, err +} + +func cmdWorkflowDownloadArtifact(ctx context.Context, c *Client, wfName, artName string, genURL bool, durationSec int, templateName, outFile string, toStdout bool, outFormat string, quiet bool) error { + if genURL && (toStdout || outFile != "") { + return fmt.Errorf("--generate-signed-url cannot be combined with -o or -stdout") + } + if toStdout && outFile != "" { + return fmt.Errorf("-stdout and -o are mutually exclusive") + } + + apiPath := "/v1/workflows/" + url.PathEscape(wfName) + "/downloadArtifact/" + url.PathEscape(artName) + q := url.Values{} + if templateName != "" { + q.Set("templateName", templateName) + } + if durationSec > 0 { + q.Set("durationSeconds", strconv.Itoa(durationSec)) + } + if enc := q.Encode(); enc != "" { + apiPath += "?" + enc + } + + raw, err := c.GetJSON(ctx, apiPath) + if err != nil { + return err + } + var meta struct { + URL string `json:"url"` + ExpiresAt string `json:"expiresAt"` + FileName string `json:"fileName"` + } + if err := json.Unmarshal(raw, &meta); err != nil { + return fmt.Errorf("decode downloadArtifact JSON: %w", err) + } + if meta.URL == "" { + return fmt.Errorf("empty url in API response") + } + + if genURL { + switch outFormat { + case "json": + var buf bytes.Buffer + if err := json.Indent(&buf, raw, "", " "); err != nil { + return err + } + fmt.Println(buf.String()) + default: + fmt.Printf("signed-url: %s\n", meta.URL) + } + return nil + } + + resp, err := GetURLPlain(ctx, meta.URL) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode < http.StatusOK || resp.StatusCode > 299 { + slurp, _ := io.ReadAll(resp.Body) + return fmt.Errorf("GET signed URL: HTTP %d %s", resp.StatusCode, truncate(string(slurp), 400)) + } + + outName := outFile + if outName == "" { + outName = strings.TrimSpace(meta.FileName) + if outName == "" { + outName = filepath.Base(artName) + if outName == "" || outName == "." { + outName = "artifact" + } + } + } + + if toStdout { + if quiet { + _, err = io.Copy(os.Stdout, resp.Body) + return err + } + _, err = copyDownloadWithProgress(resp.Body, os.Stdout, resp.ContentLength, "(stdout)", os.Stderr) + return err + } + + f, err := os.Create(outName) + if err != nil { + return err + } + defer f.Close() + absDest, err := filepath.Abs(outName) + if err != nil { + return err + } + if quiet { + _, err = io.Copy(f, resp.Body) + return err + } + _, err = copyDownloadWithProgress(resp.Body, f, resp.ContentLength, absDest, os.Stderr) + return err +} diff --git a/tools/horizon/config.go b/tools/horizon/config.go new file mode 100644 index 00000000..7d6e0f53 --- /dev/null +++ b/tools/horizon/config.go @@ -0,0 +1,57 @@ +// Copyright (c) 2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "os" + "strconv" + "strings" +) + +// Config holds merged CLI defaults: optional file, then environment, then flags. +type Config struct { + Domain string + Module string + Template string + BaseURL string + KeycloakBase string + KeycloakRealm string + RunningPollLimit int + WaitNewAttempts int + WaitNewSleepSec int + WaitTerminalSecs int + TerminalPollInterval int + LogStreamMaxSecs int + LogWaitPodSecs int + LogStreamFormat string +} + +func envOr(key, def string) string { + if v := strings.TrimSpace(os.Getenv(key)); v != "" { + return v + } + return def +} + +func envInt(key string, def int) int { + v := strings.TrimSpace(os.Getenv(key)) + if v == "" { + return def + } + n, err := strconv.Atoi(v) + if err != nil { + return def + } + return n +} diff --git a/tools/horizon/config_cmd.go b/tools/horizon/config_cmd.go new file mode 100644 index 00000000..89cb348c --- /dev/null +++ b/tools/horizon/config_cmd.go @@ -0,0 +1,226 @@ +// Copyright (c) 2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "encoding/json" + "flag" + "fmt" + "os" + "path/filepath" + "strings" + + "gopkg.in/yaml.v3" +) + +const configInitHelp = `# Horizon CLI defaults (optional). Precedence: CLI flags > environment > this file. +# domain: example.com +# api_base_url: https://example.com/horizon-api +# keycloak_base: https://example.com/auth +# keycloak_realm: horizon +# module: sample +# template: sample-smoke-test +` + +func runConfig(args []string) error { + if len(args) < 1 { + return fmt.Errorf("usage: %s config ...", progName()) + } + switch args[0] { + case "init": + return runConfigInit(args[1:]) + case "get": + return runConfigGet(args[1:]) + case "set": + return runConfigSet(args[1:]) + default: + return fmt.Errorf("unknown config subcommand %q", args[0]) + } +} + +func runConfigInit(args []string) error { + fs := flag.NewFlagSet("config init", flag.ExitOnError) + force := fs.Bool("force", false, "overwrite existing config.yaml") + _ = fs.Parse(args) + + dir, err := horizonConfigDir() + if err != nil { + return err + } + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + path := filepath.Join(dir, "config.yaml") + if _, err := os.Stat(path); err == nil && !*force { + fmt.Fprintf(os.Stderr, "%s exists (use --force to overwrite)\n", path) + return nil + } + if err := os.WriteFile(path, []byte(configInitHelp), 0o644); err != nil { + return err + } + fmt.Println(path) + return nil +} + +func runConfigGet(args []string) error { + fs := flag.NewFlagSet("config get", flag.ExitOnError) + merged := fs.Bool("merged", false, "print effective values (file+env), secrets redacted") + _ = fs.Parse(args) + key := "" + if rem := fs.Args(); len(rem) > 0 { + key = strings.TrimSpace(rem[0]) + } + + if *merged { + cfg, err := LoadConfig() + if err != nil { + return err + } + m := map[string]any{ + "domain": cfg.Domain, + "api_base_url": cfg.BaseURL, + "keycloak_base": cfg.KeycloakBase, + "keycloak_realm": cfg.KeycloakRealm, + "module": cfg.Module, + "template": cfg.Template, + "running_poll_limit": cfg.RunningPollLimit, + "wait_terminal_secs": cfg.WaitTerminalSecs, + "log_stream_max_secs": cfg.LogStreamMaxSecs, + "log_stream_format": cfg.LogStreamFormat, + } + if key != "" { + if v, ok := m[key]; ok { + fmt.Println(v) + return nil + } + return fmt.Errorf("unknown key %q", key) + } + b, err := json.MarshalIndent(m, "", " ") + if err != nil { + return err + } + fmt.Println(string(b)) + return nil + } + + path, err := HorizonConfigFilePath() + if err != nil { + return err + } + b, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + fmt.Fprintf(os.Stderr, "no file at %s (run `%s config init`)\n", path, progName()) + return nil + } + return err + } + if key != "" { + var root map[string]any + if err := yaml.Unmarshal(b, &root); err != nil { + return err + } + v, ok := root[key] + if !ok { + return fmt.Errorf("key %q not in %s", key, path) + } + fmt.Println(v) + return nil + } + fmt.Print(string(b)) + return nil +} + +func runConfigSet(args []string) error { + if len(args) < 2 { + return fmt.Errorf("usage: %s config set ", progName()) + } + k, v := args[0], args[1] + allowed := map[string]bool{ + "domain": true, "api_base_url": true, "keycloak_base": true, "keycloak_realm": true, + "module": true, "template": true, + } + if !allowed[k] { + return fmt.Errorf("unsupported key %q (allowed: %v)", k, keysOf(allowed)) + } + + dir, err := horizonConfigDir() + if err != nil { + return err + } + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + path := filepath.Join(dir, "config.yaml") + + var root map[string]any + if b, err := os.ReadFile(path); err == nil { + _ = yaml.Unmarshal(b, &root) + } + if root == nil { + root = map[string]any{} + } + root[k] = v + out, err := yaml.Marshal(root) + if err != nil { + return err + } + if err := os.WriteFile(path, out, 0o644); err != nil { + return err + } + fmt.Println(path) + return nil +} + +func keysOf(m map[string]bool) []string { + s := make([]string, 0, len(m)) + for k := range m { + s = append(s, k) + } + return s +} + +// mergeLoginIntoConfigFile writes domain/keycloak fields into config.yaml (for auth login --write-config). +func mergeLoginIntoConfigFile(domain, apiBase, keycloakBase, realm string) error { + dir, err := horizonConfigDir() + if err != nil { + return err + } + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + path := filepath.Join(dir, "config.yaml") + + var fc fileConfig + if b, err := os.ReadFile(path); err == nil { + _ = yaml.Unmarshal(b, &fc) + } + if d := strings.TrimSpace(domain); d != "" { + fc.Domain = d + } + if s := strings.TrimSpace(apiBase); s != "" { + fc.APIBaseURL = strings.TrimSuffix(s, "/") + } + if s := strings.TrimSpace(keycloakBase); s != "" { + fc.KeycloakBase = strings.TrimSuffix(s, "/") + } + if s := strings.TrimSpace(realm); s != "" { + fc.KeycloakRealm = s + } + out, err := yaml.Marshal(&fc) + if err != nil { + return err + } + return os.WriteFile(path, out, 0o644) +} diff --git a/tools/horizon/configfile.go b/tools/horizon/configfile.go new file mode 100644 index 00000000..833dcba9 --- /dev/null +++ b/tools/horizon/configfile.go @@ -0,0 +1,282 @@ +// Copyright (c) 2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "fmt" + "net/url" + "os" + "path/filepath" + "strings" + + "gopkg.in/yaml.v3" +) + +// horizonConfigDir returns ~/.config/horizon (or XDG_CONFIG_HOME/horizon). +func horizonConfigDir() (string, error) { + base, err := os.UserConfigDir() + if err != nil { + home, e := os.UserHomeDir() + if e != nil { + return "", fmt.Errorf("config dir: %w", err) + } + base = filepath.Join(home, ".config") + } + return filepath.Join(base, "horizon"), nil +} + +// HorizonConfigFilePath is the default path for optional CLI defaults. +func HorizonConfigFilePath() (string, error) { + dir, err := horizonConfigDir() + if err != nil { + return "", err + } + return filepath.Join(dir, "config.yaml"), nil +} + +// fileConfig is the on-disk YAML shape (snake_case). Empty strings are ignored when merging. +type fileConfig struct { + Domain string `yaml:"domain"` + APIBaseURL string `yaml:"api_base_url"` + KeycloakBase string `yaml:"keycloak_base"` + KeycloakRealm string `yaml:"keycloak_realm"` + Module string `yaml:"module"` + Template string `yaml:"template"` + RunningPollLimit *int `yaml:"running_poll_limit"` + WaitNewAttempts *int `yaml:"wait_new_attempts"` + WaitNewSleepSec *int `yaml:"wait_new_sleep_sec"` + WaitTerminalSecs *int `yaml:"wait_terminal_secs"` + TerminalPollInterval *int `yaml:"terminal_poll_interval"` + LogStreamMaxSecs *int `yaml:"log_stream_max_secs"` + LogWaitPodSecs *int `yaml:"log_wait_pod_secs"` + LogStreamFormat string `yaml:"log_stream_format"` +} + +func readFileConfig(path string) (*fileConfig, error) { + b, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + var fc fileConfig + if err := yaml.Unmarshal(b, &fc); err != nil { + return nil, fmt.Errorf("parse %s: %w", path, err) + } + return &fc, nil +} + +func mergeFileIntoConfig(cfg *Config, fc *fileConfig) { + if fc == nil { + return + } + if s := strings.TrimSpace(fc.Domain); s != "" { + cfg.Domain = s + } + if s := strings.TrimSpace(fc.APIBaseURL); s != "" { + cfg.BaseURL = strings.TrimSuffix(s, "/") + } + if s := strings.TrimSpace(fc.KeycloakBase); s != "" { + cfg.KeycloakBase = strings.TrimSuffix(s, "/") + } + if s := strings.TrimSpace(fc.KeycloakRealm); s != "" { + cfg.KeycloakRealm = s + } + if s := strings.TrimSpace(fc.Module); s != "" { + cfg.Module = s + } + if s := strings.TrimSpace(fc.Template); s != "" { + cfg.Template = s + } + if fc.RunningPollLimit != nil { + cfg.RunningPollLimit = *fc.RunningPollLimit + } + if fc.WaitNewAttempts != nil { + cfg.WaitNewAttempts = *fc.WaitNewAttempts + } + if fc.WaitNewSleepSec != nil { + cfg.WaitNewSleepSec = *fc.WaitNewSleepSec + } + if fc.WaitTerminalSecs != nil { + cfg.WaitTerminalSecs = *fc.WaitTerminalSecs + } + if fc.TerminalPollInterval != nil { + cfg.TerminalPollInterval = *fc.TerminalPollInterval + } + if fc.LogStreamMaxSecs != nil { + cfg.LogStreamMaxSecs = *fc.LogStreamMaxSecs + } + if fc.LogWaitPodSecs != nil { + cfg.LogWaitPodSecs = *fc.LogWaitPodSecs + } + if s := strings.TrimSpace(fc.LogStreamFormat); s != "" { + cfg.LogStreamFormat = strings.ToUpper(s) + } +} + +// LoadConfig reads defaults, optional ~/.config/horizon/config.yaml, then environment (env overrides file). +func LoadConfig() (*Config, error) { + path, _ := HorizonConfigFilePath() + var fc *fileConfig + if path != "" { + var err error + fc, err = readFileConfig(path) + if err != nil { + return nil, err + } + } + return buildConfig(fc) +} + +// LoadConfigRelaxed is for auth login: Keycloak URL is required; Horizon API base URL is optional. +func LoadConfigRelaxed() (*Config, error) { + path, _ := HorizonConfigFilePath() + var fc *fileConfig + if path != "" { + var err error + fc, err = readFileConfig(path) + if err != nil { + return nil, err + } + } + cfg := defaultConfig() + mergeFileIntoConfig(cfg, fc) + mergeEnvIntoConfig(cfg) + return finalizeConfigRelaxed(cfg) +} + +func finalizeConfigRelaxed(cfg *Config) (*Config, error) { + if cfg.BaseURL == "" && cfg.Domain != "" { + cfg.BaseURL = "https://" + cfg.Domain + "/horizon-api" + } + if cfg.KeycloakBase == "" && cfg.Domain != "" { + cfg.KeycloakBase = "https://" + cfg.Domain + "/auth" + } + if cfg.KeycloakBase == "" { + d := strings.TrimSpace(os.Getenv("HORIZON_DOMAIN")) + if d != "" { + cfg.KeycloakBase = "https://" + d + "/auth" + } + } + if cfg.KeycloakBase == "" { + return nil, fmt.Errorf("Keycloak URL unknown: set KEYCLOAK_BASE or HORIZON_DOMAIN, or domain in ~/.config/horizon/config.yaml, or pass --domain (e.g. %s auth login --device --domain horizon.example.com)", progName()) + } + return cfg, nil +} + +func buildConfig(fc *fileConfig) (*Config, error) { + cfg := defaultConfig() + mergeFileIntoConfig(cfg, fc) + mergeEnvIntoConfig(cfg) + return finalizeConfig(cfg) +} + +func defaultConfig() *Config { + return &Config{ + Module: "sample", + Template: "sample-smoke-test", + KeycloakRealm: "horizon", + RunningPollLimit: 500, + WaitNewAttempts: 120, + WaitNewSleepSec: 2, + WaitTerminalSecs: 3600, + TerminalPollInterval: 5, + LogStreamMaxSecs: 7200, + LogWaitPodSecs: 60, + LogStreamFormat: "FORMATTED", + } +} + +func mergeEnvIntoConfig(cfg *Config) { + if s := strings.TrimSpace(os.Getenv("MODULE")); s != "" { + cfg.Module = s + } + if s := strings.TrimSpace(os.Getenv("TEMPLATE")); s != "" { + cfg.Template = s + } + if s := strings.TrimSpace(os.Getenv("KEYCLOAK_REALM")); s != "" { + cfg.KeycloakRealm = s + } + cfg.RunningPollLimit = envInt("RUNNING_POLL_LIMIT", cfg.RunningPollLimit) + cfg.WaitNewAttempts = envInt("WAIT_NEW_ATTEMPTS", cfg.WaitNewAttempts) + cfg.WaitNewSleepSec = envInt("WAIT_NEW_SLEEP", cfg.WaitNewSleepSec) + cfg.WaitTerminalSecs = envInt("WAIT_TERMINAL_SECS", cfg.WaitTerminalSecs) + cfg.TerminalPollInterval = envInt("TERMINAL_POLL_INTERVAL", cfg.TerminalPollInterval) + cfg.LogStreamMaxSecs = envInt("LOG_STREAM_MAX_SECS", cfg.LogStreamMaxSecs) + cfg.LogWaitPodSecs = envInt("LOG_WAIT_POD_SECS", cfg.LogWaitPodSecs) + if s := strings.TrimSpace(os.Getenv("HORIZON_LOG_STREAM_FORMAT")); s != "" { + cfg.LogStreamFormat = strings.ToUpper(s) + } + + base := strings.TrimSpace(os.Getenv("HORIZON_API_BASE_URL")) + base = strings.TrimSuffix(base, "/") + if base != "" { + cfg.BaseURL = base + cfg.Domain = "" + } else if d := strings.TrimSpace(os.Getenv("HORIZON_DOMAIN")); d != "" { + cfg.Domain = d + cfg.BaseURL = "https://" + d + "/horizon-api" + } + + kb := strings.TrimSpace(os.Getenv("KEYCLOAK_BASE")) + kb = strings.TrimSuffix(kb, "/") + if kb != "" { + cfg.KeycloakBase = kb + } +} + +func finalizeConfig(cfg *Config) (*Config, error) { + if cfg.BaseURL == "" && cfg.Domain != "" { + cfg.BaseURL = "https://" + cfg.Domain + "/horizon-api" + } + if cfg.BaseURL == "" { + return nil, fmt.Errorf("set HORIZON_API_BASE_URL, HORIZON_DOMAIN, or api_base_url in ~/.config/horizon/config.yaml (or use --api / --domain)") + } + if cfg.KeycloakBase == "" && cfg.Domain != "" { + cfg.KeycloakBase = "https://" + cfg.Domain + "/auth" + } + if _, err := url.Parse(cfg.BaseURL); err != nil { + return nil, fmt.Errorf("Horizon API base URL: %w", err) + } + return cfg, nil +} + +// FlagOverrides are applied after file+env (CLI wins). +type FlagOverrides struct { + APIBaseURL string + Domain string + Module string + Template string +} + +func ApplyFlagOverrides(cfg *Config, fo *FlagOverrides) { + if fo == nil { + return + } + if s := strings.TrimSpace(fo.APIBaseURL); s != "" { + cfg.BaseURL = strings.TrimSuffix(s, "/") + cfg.Domain = "" + } else if d := strings.TrimSpace(fo.Domain); d != "" { + cfg.Domain = d + cfg.BaseURL = "https://" + d + "/horizon-api" + cfg.KeycloakBase = "https://" + d + "/auth" + } + if s := strings.TrimSpace(fo.Module); s != "" { + cfg.Module = s + } + if s := strings.TrimSpace(fo.Template); s != "" { + cfg.Template = s + } +} diff --git a/tools/horizon/go.mod b/tools/horizon/go.mod new file mode 100644 index 00000000..a8e5fea0 --- /dev/null +++ b/tools/horizon/go.mod @@ -0,0 +1,10 @@ +module github.com/horizon-sdv/horizon + +go 1.22 + +require golang.org/x/term v0.28.0 + +require ( + golang.org/x/sys v0.29.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/tools/horizon/go.sum b/tools/horizon/go.sum new file mode 100644 index 00000000..3316e039 --- /dev/null +++ b/tools/horizon/go.sum @@ -0,0 +1,7 @@ +golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= +golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg= +golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/tools/horizon/jwt.go b/tools/horizon/jwt.go new file mode 100644 index 00000000..0a5c749d --- /dev/null +++ b/tools/horizon/jwt.go @@ -0,0 +1,45 @@ +// Copyright (c) 2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "strings" +) + +// decodeJWTPayload returns the middle segment of a JWT as JSON (display-only, not verified). +func decodeJWTPayload(token string) (map[string]any, error) { + parts := strings.Split(strings.TrimSpace(token), ".") + if len(parts) != 3 { + return nil, fmt.Errorf("not a JWT") + } + payload := parts[1] + switch len(payload) % 4 { + case 2: + payload += "==" + case 3: + payload += "=" + } + b, err := base64.RawURLEncoding.DecodeString(payload) + if err != nil { + return nil, err + } + var o map[string]any + if err := json.Unmarshal(b, &o); err != nil { + return nil, err + } + return o, nil +} diff --git a/tools/horizon/main.go b/tools/horizon/main.go new file mode 100644 index 00000000..701e2907 --- /dev/null +++ b/tools/horizon/main.go @@ -0,0 +1,463 @@ +// Copyright (c) 2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Command horizon is the Horizon API CLI (CI workflows, catalog, auth). +package main + +import ( + "context" + "flag" + "fmt" + "net/url" + "os" + "strings" +) + +func printUsage() { + p := progName() + fmt.Fprintf(os.Stderr, `%s — Horizon API CLI + +Usage: + %s config ... + %s auth [flags] + %s catalog get [--output text|json] [flags] (default output: text) + %s workflow ... [flags] + + %s ci ... (alias for %s workflow) + +Environment (common): + HORIZON_DOMAIN or HORIZON_API_BASE_URL + HORIZON_ACCESS_TOKEN or KEYCLOAK_CLIENT_SECRET (CI) + KEYCLOAK_BASE, KEYCLOAK_REALM, KEYCLOAK_CLIENT_ID, KEYCLOAK_CLIENT_SECRET + Optional defaults file: ~/.config/horizon/config.yaml (see %s config init) + MODULE, TEMPLATE or --module / --template on submit (parameters must match GET /v1/catalog for that template) + +Auth: + %s auth login [--device|--client-credentials] [--domain HOST] [--write-config] + %s auth logout + %s auth refresh [--domain HOST] (OAuth refresh_token from token cache; no device flow) + %s auth whoami + +Workflow (stateless — pass workflow name from submit output; no WORKFLOW_STATE_DIR): + %s workflow submit [--module NAME] [--template NAME] [--params-file|-] [--params-json] [--wait] [--logs] [--output text|json] ... + %s workflow wait [--logs] + %s workflow logs + %s workflow abort + %s workflow delete + %s workflow show [--output text|json] + %s workflow list [--running-only] [--output text|json|wide] + %s workflow get + %s workflow running [--limit N] [--api URL] [--domain HOST] + %s workflow history [--limit N] [--continue TOKEN] [--phase P] [--api URL] [--domain HOST] + %s workflow download-artifact [--generate-signed-url] [--duration SECONDS] [--template-name NAME] [-o PATH] [-stdout] [-q] [--output text|json] [--api URL] [--domain HOST] + +Examples (submit + wait): + # Block until terminal phase (exit 0=Succeeded, 1=Failed/Error, 2=Aborted): + %s workflow submit --module sample --template sample-smoke-test --params-json '{"sampleEnv":"jenkins","sampleBuildId":"build-1","sampleNote":"note"}' --wait --logs + + # Async submit, then wait by name (parse workflowName from JSON; -q is quieter stderr): + %s workflow submit --module sample --template sample-smoke-test --params-json '{"sampleEnv":"jenkins"}' --output json -q + %s workflow wait + +`, p, p, p, p, p, p, p, p, p, p, p, p, p, p, p, p, p, p, p, p, p, p, p, p, p) +} + +func main() { + args := os.Args[1:] + if len(args) == 0 { + printUsage() + os.Exit(2) + } + + switch args[0] { + case "-h", "--help", "help": + printUsage() + os.Exit(0) + case "ci": + args = append([]string{"workflow"}, args[1:]...) + } + + if len(args) == 0 { + printUsage() + os.Exit(2) + } + + switch args[0] { + case "config": + if err := runConfig(args[1:]); err != nil { + fmt.Fprintf(os.Stderr, "config: %v\n", err) + os.Exit(1) + } + case "auth": + if err := runAuth(args[1:]); err != nil { + fmt.Fprintf(os.Stderr, "auth: %v\n", err) + os.Exit(1) + } + case "catalog": + if err := runCatalog(args[1:]); err != nil { + fmt.Fprintf(os.Stderr, "catalog: %v\n", err) + os.Exit(1) + } + case "workflow": + code, err := runWorkflow(args[1:]) + if err != nil { + fmt.Fprintf(os.Stderr, "workflow: %v\n", err) + os.Exit(1) + } + os.Exit(code) + default: + fmt.Fprintf(os.Stderr, "unknown command %q\n", args[0]) + printUsage() + os.Exit(2) + } +} + +func runCatalog(args []string) error { + if len(args) < 1 || args[0] != "get" { + return fmt.Errorf("usage: %s catalog get [--output text|json] [-raw] [--api URL] [--domain HOST]", progName()) + } + fs := flag.NewFlagSet("catalog get", flag.ExitOnError) + out := fs.String("output", "text", "text | json (default text; with json, -raw is compact one line)") + raw := fs.Bool("raw", false, "with -output json: single-line compact JSON") + api := fs.String("api", "", "Horizon API base URL (overrides env / config)") + domain := fs.String("domain", "", "Horizon domain; derives API URL") + _ = fs.Parse(args[1:]) + + cfg, err := LoadConfig() + if err != nil { + return err + } + ApplyFlagOverrides(cfg, &FlagOverrides{APIBaseURL: *api, Domain: *domain}) + if err := revalidateConfig(cfg); err != nil { + return err + } + c, err := newClient(cfg) + if err != nil { + return err + } + mode := strings.TrimSpace(*out) + if mode == "" { + mode = "text" + } + return cmdCatalogGet(context.Background(), c, mode, *raw) +} + +func revalidateConfig(cfg *Config) error { + if cfg.BaseURL == "" && cfg.Domain != "" { + cfg.BaseURL = "https://" + cfg.Domain + "/horizon-api" + } + if cfg.BaseURL == "" { + return fmt.Errorf("set HORIZON_API_BASE_URL, HORIZON_DOMAIN, or use --api / --domain") + } + if cfg.KeycloakBase == "" && cfg.Domain != "" { + cfg.KeycloakBase = "https://" + cfg.Domain + "/auth" + } + if _, err := url.Parse(cfg.BaseURL); err != nil { + return fmt.Errorf("Horizon API base URL: %w", err) + } + return nil +} + +func runWorkflow(args []string) (int, error) { + if len(args) < 1 { + return 2, fmt.Errorf("usage: %s workflow ...", progName()) + } + + ctx := context.Background() + + switch args[0] { + case "submit": + fs := flag.NewFlagSet("workflow submit", flag.ExitOnError) + paramsFile := fs.String("params-file", os.Getenv("WORKFLOW_PARAMETERS_JSON_FILE"), "JSON parameters object (file, or - for stdin)") + paramsEnv := fs.String("params-json", os.Getenv("WORKFLOW_PARAMETERS_JSON"), "JSON parameters object (inline)") + tpl := fs.String("template", "", "workflow template (default: env / config)") + mod := fs.String("module", "", "module name (default: env / config)") + wait := fs.Bool("wait", false, "after submit, poll until terminal (exit 0/1/2 by phase)") + logs := fs.Bool("logs", false, "with --wait: stream workflow logs concurrently until done (stdout; progress on stderr)") + out := fs.String("output", "text", "text | json") + quiet := fs.Bool("q", false, "less stderr noise (still prints workflow name / JSON)") + api := fs.String("api", "", "Horizon API base URL") + domain := fs.String("domain", "", "Horizon domain") + _ = fs.Parse(args[1:]) + if *logs && !*wait { + return 2, fmt.Errorf("--logs requires --wait") + } + + cfg, err := LoadConfig() + if err != nil { + return 1, err + } + ApplyFlagOverrides(cfg, &FlagOverrides{APIBaseURL: *api, Domain: *domain, Module: *mod, Template: *tpl}) + if err := revalidateConfig(cfg); err != nil { + return 1, err + } + c, err := newClient(cfg) + if err != nil { + return 1, err + } + mode := submitOutText + if *out == "json" { + mode = submitOutJSON + } + return cmdSubmit(c, cfg, *paramsFile, *paramsEnv, *wait, *logs, mode, *quiet) + + case "wait": + fs := flag.NewFlagSet("workflow wait", flag.ExitOnError) + logs := fs.Bool("logs", false, "stream workflow logs concurrently until terminal (stdout; progress on stderr)") + api := fs.String("api", "", "Horizon API base URL") + domain := fs.String("domain", "", "Horizon domain") + _ = fs.Parse(args[1:]) + rem := fs.Args() + if len(rem) < 1 { + return 2, fmt.Errorf("usage: %s workflow wait [--logs] ", progName()) + } + cfg, err := LoadConfig() + if err != nil { + return 1, err + } + ApplyFlagOverrides(cfg, &FlagOverrides{APIBaseURL: *api, Domain: *domain}) + if err := revalidateConfig(cfg); err != nil { + return 1, err + } + c, err := newClient(cfg) + if err != nil { + return 1, err + } + return cmdWait(c, cfg, rem[0], *logs) + + case "logs": + fs := flag.NewFlagSet("workflow logs", flag.ExitOnError) + api := fs.String("api", "", "Horizon API base URL") + domain := fs.String("domain", "", "Horizon domain") + _ = fs.Parse(args[1:]) + rem := fs.Args() + if len(rem) < 1 { + return 2, fmt.Errorf("usage: %s workflow logs ", progName()) + } + cfg, err := LoadConfig() + if err != nil { + return 1, err + } + ApplyFlagOverrides(cfg, &FlagOverrides{APIBaseURL: *api, Domain: *domain}) + if err := revalidateConfig(cfg); err != nil { + return 1, err + } + c, err := newClient(cfg) + if err != nil { + return 1, err + } + return 0, cmdLogsForWorkflow(c, cfg, rem[0]) + + case "abort": + fs := flag.NewFlagSet("workflow abort", flag.ExitOnError) + api := fs.String("api", "", "Horizon API base URL") + domain := fs.String("domain", "", "Horizon domain") + _ = fs.Parse(args[1:]) + rem := fs.Args() + if len(rem) < 1 { + return 2, fmt.Errorf("usage: %s workflow abort ", progName()) + } + cfg, err := LoadConfig() + if err != nil { + return 1, err + } + ApplyFlagOverrides(cfg, &FlagOverrides{APIBaseURL: *api, Domain: *domain}) + if err := revalidateConfig(cfg); err != nil { + return 1, err + } + c, err := newClient(cfg) + if err != nil { + return 1, err + } + return 0, cmdAbortWorkflowName(c, rem[0]) + + case "delete": + fs := flag.NewFlagSet("workflow delete", flag.ExitOnError) + api := fs.String("api", "", "Horizon API base URL") + domain := fs.String("domain", "", "Horizon domain") + _ = fs.Parse(args[1:]) + rem := fs.Args() + if len(rem) < 1 { + return 2, fmt.Errorf("usage: %s workflow delete ", progName()) + } + cfg, err := LoadConfig() + if err != nil { + return 1, err + } + ApplyFlagOverrides(cfg, &FlagOverrides{APIBaseURL: *api, Domain: *domain}) + if err := revalidateConfig(cfg); err != nil { + return 1, err + } + c, err := newClient(cfg) + if err != nil { + return 1, err + } + return 0, cmdDeleteWorkflowName(ctx, c, rem[0]) + + case "show": + fs := flag.NewFlagSet("workflow show", flag.ExitOnError) + out := fs.String("output", "text", "text | json") + api := fs.String("api", "", "Horizon API base URL") + domain := fs.String("domain", "", "Horizon domain") + _ = fs.Parse(args[1:]) + rem := fs.Args() + if len(rem) < 1 { + return 2, fmt.Errorf("usage: %s workflow show ", progName()) + } + cfg, err := LoadConfig() + if err != nil { + return 1, err + } + ApplyFlagOverrides(cfg, &FlagOverrides{APIBaseURL: *api, Domain: *domain}) + if err := revalidateConfig(cfg); err != nil { + return 1, err + } + c, err := newClient(cfg) + if err != nil { + return 1, err + } + return 0, cmdWorkflowShow(ctx, c, rem[0], *out) + + case "list": + fs := flag.NewFlagSet("workflow list", flag.ExitOnError) + runningOnly := fs.Bool("running-only", false, "only GET /workflows/running") + limit := fs.Int("limit", 50, "list limit") + cont := fs.String("continue", "", "history continue token") + phase := fs.String("phase", "", "history phase filter (comma-separated)") + out := fs.String("output", "", "text | json | wide (default: TTY=text else json)") + api := fs.String("api", "", "Horizon API base URL") + domain := fs.String("domain", "", "Horizon domain") + _ = fs.Parse(args[1:]) + cfg, err := LoadConfig() + if err != nil { + return 1, err + } + ApplyFlagOverrides(cfg, &FlagOverrides{APIBaseURL: *api, Domain: *domain}) + if err := revalidateConfig(cfg); err != nil { + return 1, err + } + c, err := newClient(cfg) + if err != nil { + return 1, err + } + return 0, cmdWorkflowList(ctx, c, *runningOnly, *limit, *cont, *phase, *out) + + case "get": + fs := flag.NewFlagSet("workflow get", flag.ExitOnError) + api := fs.String("api", "", "Horizon API base URL") + domain := fs.String("domain", "", "Horizon domain") + _ = fs.Parse(args[1:]) + rem := fs.Args() + if len(rem) < 1 { + return 2, fmt.Errorf("usage: %s workflow get (same as show --output json)", progName()) + } + cfg, err := LoadConfig() + if err != nil { + return 1, err + } + ApplyFlagOverrides(cfg, &FlagOverrides{APIBaseURL: *api, Domain: *domain}) + if err := revalidateConfig(cfg); err != nil { + return 1, err + } + c, err := newClient(cfg) + if err != nil { + return 1, err + } + return 0, cmdWorkflowGet(ctx, c, rem[0]) + + case "running": + fs := flag.NewFlagSet("workflow running", flag.ExitOnError) + limit := fs.Int("limit", 50, "list limit") + api := fs.String("api", "", "Horizon API base URL") + domain := fs.String("domain", "", "Horizon domain") + _ = fs.Parse(args[1:]) + cfg, err := LoadConfig() + if err != nil { + return 1, err + } + ApplyFlagOverrides(cfg, &FlagOverrides{APIBaseURL: *api, Domain: *domain}) + if err := revalidateConfig(cfg); err != nil { + return 1, err + } + c, err := newClient(cfg) + if err != nil { + return 1, err + } + return 0, cmdWorkflowRunning(ctx, c, *limit) + + case "history": + fs := flag.NewFlagSet("workflow history", flag.ExitOnError) + limit := fs.Int("limit", 50, "list limit") + cont := fs.String("continue", "", "continue token") + phase := fs.String("phase", "", "phase filter (comma-separated)") + api := fs.String("api", "", "Horizon API base URL") + domain := fs.String("domain", "", "Horizon domain") + _ = fs.Parse(args[1:]) + cfg, err := LoadConfig() + if err != nil { + return 1, err + } + ApplyFlagOverrides(cfg, &FlagOverrides{APIBaseURL: *api, Domain: *domain}) + if err := revalidateConfig(cfg); err != nil { + return 1, err + } + c, err := newClient(cfg) + if err != nil { + return 1, err + } + return 0, cmdWorkflowHistory(ctx, c, *limit, *cont, *phase) + + case "download-artifact": + fs := flag.NewFlagSet("workflow download-artifact", flag.ExitOnError) + genURL := fs.Bool("generate-signed-url", false, "print signed-url: on stdout (or --output json); do not download bytes") + dur := fs.Int("duration", 0, "optional signed URL lifetime in seconds (maps to durationSeconds query; server clamps)") + templateName := fs.String("template-name", "", "disambiguate artifact (maps to templateName query; see workflow show outputArtifacts)") + outPath := fs.String("o", "", "write downloaded bytes to this path") + toStdout := fs.Bool("stdout", false, "write downloaded bytes to stdout") + quiet := fs.Bool("q", false, "when downloading: no progress on stderr") + outFmt := fs.String("output", "text", "with --generate-signed-url: text (signed-url line) or json") + api := fs.String("api", "", "Horizon API base URL") + domain := fs.String("domain", "", "Horizon domain") + _ = fs.Parse(args[1:]) + rem := fs.Args() + if len(rem) < 2 { + return 2, fmt.Errorf("usage: %s workflow download-artifact [flags] ", progName()) + } + cfg, err := LoadConfig() + if err != nil { + return 1, err + } + ApplyFlagOverrides(cfg, &FlagOverrides{APIBaseURL: *api, Domain: *domain}) + if err := revalidateConfig(cfg); err != nil { + return 1, err + } + c, err := newClient(cfg) + if err != nil { + return 1, err + } + err = cmdWorkflowDownloadArtifact(ctx, c, rem[0], rem[1], *genURL, *dur, *templateName, *outPath, *toStdout, *outFmt, *quiet) + if err != nil { + return 1, err + } + return 0, nil + + case "poll": + return 2, fmt.Errorf("workflow poll was removed; use: %s workflow wait (name is printed by submit)", progName()) + + case "finalize": + return 2, fmt.Errorf("workflow finalize was removed; use: %s workflow wait and/or %s workflow show ", progName(), progName()) + + default: + return 2, fmt.Errorf("unknown workflow subcommand %q", args[0]) + } +} diff --git a/tools/horizon/oauth.go b/tools/horizon/oauth.go new file mode 100644 index 00000000..889951ae --- /dev/null +++ b/tools/horizon/oauth.go @@ -0,0 +1,252 @@ +// Copyright (c) 2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strings" + "time" +) + +type tokenEndpointResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresIn int `json:"expires_in"` + Error string `json:"error"` + ErrorDescription string `json:"error_description"` +} + +func tokenURL(keycloakBase, realm string) string { + return strings.TrimSuffix(keycloakBase, "/") + "/realms/" + realm + "/protocol/openid-connect/token" +} + +func authBase(keycloakBase, realm string) string { + return strings.TrimSuffix(keycloakBase, "/") + "/realms/" + realm + "/protocol/openid-connect" +} + +// OAuthClient performs Keycloak OAuth2 calls (stdlib only). +type OAuthClient struct { + HTTP *http.Client +} + +func (o *OAuthClient) postForm(ctx context.Context, u string, data url.Values) ([]byte, int, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, strings.NewReader(data.Encode())) + if err != nil { + return nil, 0, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + resp, err := o.HTTP.Do(req) + if err != nil { + return nil, 0, err + } + defer resp.Body.Close() + b, err := io.ReadAll(resp.Body) + if err != nil { + return nil, resp.StatusCode, err + } + return b, resp.StatusCode, nil +} + +func (o *OAuthClient) ClientCredentials(ctx context.Context, keycloakBase, realm, clientID, clientSecret string) (*TokenCache, error) { + u := tokenURL(keycloakBase, realm) + data := url.Values{} + data.Set("grant_type", "client_credentials") + data.Set("client_id", clientID) + data.Set("client_secret", clientSecret) + b, code, err := o.postForm(ctx, u, data) + if err != nil { + return nil, err + } + if code < 200 || code > 299 { + return nil, fmt.Errorf("oauth: HTTP %d %s", code, truncate(string(b), 300)) + } + var tr tokenEndpointResponse + if err := json.Unmarshal(b, &tr); err != nil { + return nil, err + } + if tr.AccessToken == "" { + return nil, fmt.Errorf("oauth: %s %s", tr.Error, tr.ErrorDescription) + } + return tokenFromResponse(&tr, keycloakBase, realm, clientID), nil +} + +func (o *OAuthClient) RefreshToken(ctx context.Context, keycloakBase, realm, clientID, clientSecret, refresh string) (*TokenCache, error) { + u := tokenURL(keycloakBase, realm) + data := url.Values{} + data.Set("grant_type", "refresh_token") + data.Set("client_id", clientID) + data.Set("refresh_token", refresh) + if clientSecret != "" { + data.Set("client_secret", clientSecret) + } + b, code, err := o.postForm(ctx, u, data) + if err != nil { + return nil, err + } + if code < 200 || code > 299 { + return nil, fmt.Errorf("oauth refresh: HTTP %d %s", code, truncate(string(b), 300)) + } + var tr tokenEndpointResponse + if err := json.Unmarshal(b, &tr); err != nil { + return nil, err + } + if tr.AccessToken == "" { + return nil, fmt.Errorf("oauth refresh: %s %s", tr.Error, tr.ErrorDescription) + } + t := tokenFromResponse(&tr, keycloakBase, realm, clientID) + if tr.RefreshToken != "" { + t.RefreshToken = tr.RefreshToken + } else { + t.RefreshToken = refresh + } + return t, nil +} + +func tokenFromResponse(tr *tokenEndpointResponse, keycloakBase, realm, clientID string) *TokenCache { + t := &TokenCache{ + AccessToken: tr.AccessToken, + RefreshToken: tr.RefreshToken, + KeycloakBase: strings.TrimSuffix(keycloakBase, "/"), + Realm: realm, + ClientID: clientID, + } + if tr.ExpiresIn > 0 { + t.ExpiresAt = time.Now().Unix() + int64(tr.ExpiresIn) + } + return t +} + +func newPKCE() (verifier string, challenge string, err error) { + raw := make([]byte, 32) + if _, err := rand.Read(raw); err != nil { + return "", "", err + } + verifier = base64.RawURLEncoding.EncodeToString(raw) + h := sha256.Sum256([]byte(verifier)) + challenge = base64.RawURLEncoding.EncodeToString(h[:]) + return verifier, challenge, nil +} + +type deviceAuthStart struct { + DeviceCode string `json:"device_code"` + UserCode string `json:"user_code"` + VerificationURI string `json:"verification_uri"` + VerificationURIComplete string `json:"verification_uri_complete"` + Interval int `json:"interval"` + ExpiresIn int `json:"expires_in"` + Error string `json:"error"` + ErrorDescription string `json:"error_description"` +} + +// DeviceFlow runs OAuth 2.0 device authorization with PKCE (S256). +func (o *OAuthClient) DeviceFlow(ctx context.Context, keycloakBase, realm, clientID, scope string, openBrowser func(url string) error) (*TokenCache, error) { + verifier, challenge, err := newPKCE() + if err != nil { + return nil, err + } + ab := authBase(keycloakBase, realm) + "/auth/device" + data := url.Values{} + data.Set("client_id", clientID) + if scope == "" { + scope = "openid" + } + data.Set("scope", scope) + data.Set("code_challenge_method", "S256") + data.Set("code_challenge", challenge) + b, code, err := o.postForm(ctx, ab, data) + if err != nil { + return nil, err + } + if code < 200 || code > 299 { + return nil, fmt.Errorf("device auth start: HTTP %d %s", code, truncate(string(b), 300)) + } + var da deviceAuthStart + if err := json.Unmarshal(b, &da); err != nil { + return nil, err + } + if da.DeviceCode == "" { + return nil, fmt.Errorf("device auth: %s %s", da.Error, da.ErrorDescription) + } + fmt.Fprintln(os.Stderr, "") + fmt.Fprintln(os.Stderr, "Open this URL in a browser (sign in with your Horizon user):") + if da.VerificationURIComplete != "" { + fmt.Fprintln(os.Stderr, " ", da.VerificationURIComplete) + if openBrowser != nil { + _ = openBrowser(da.VerificationURIComplete) + } + } else { + fmt.Fprintln(os.Stderr, " ", da.VerificationURI) + fmt.Fprintln(os.Stderr, " Enter code:", da.UserCode) + if openBrowser != nil && da.VerificationURI != "" { + _ = openBrowser(da.VerificationURI) + } + } + fmt.Fprintln(os.Stderr, "") + interval := time.Duration(da.Interval) * time.Second + if interval <= 0 { + interval = 5 * time.Second + } + expires := time.Duration(da.ExpiresIn) * time.Second + if expires <= 0 { + expires = 10 * time.Minute + } + deadline := time.Now().Add(expires) + tokURL := tokenURL(keycloakBase, realm) + for time.Now().Before(deadline) { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(interval): + } + poll := url.Values{} + poll.Set("grant_type", "urn:ietf:params:oauth:grant-type:device_code") + poll.Set("client_id", clientID) + poll.Set("device_code", da.DeviceCode) + poll.Set("code_verifier", verifier) + rb, _, err := o.postForm(ctx, tokURL, poll) + if err != nil { + continue + } + var tr tokenEndpointResponse + if err := json.Unmarshal(rb, &tr); err != nil { + continue + } + if tr.AccessToken != "" { + return tokenFromResponse(&tr, keycloakBase, realm, clientID), nil + } + switch tr.Error { + case "authorization_pending", "": + continue + case "slow_down": + interval += 5 * time.Second + continue + case "access_denied", "expired_token": + return nil, fmt.Errorf("device authorization %s: %s", tr.Error, tr.ErrorDescription) + default: + if tr.Error != "" { + return nil, fmt.Errorf("device token: %s %s", tr.Error, tr.ErrorDescription) + } + } + } + return nil, fmt.Errorf("device authorization timed out") +} diff --git a/tools/horizon/progname.go b/tools/horizon/progname.go new file mode 100644 index 00000000..e15577cc --- /dev/null +++ b/tools/horizon/progname.go @@ -0,0 +1,29 @@ +// Copyright (c) 2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "os" + "path/filepath" +) + +// progName is the executable basename (e.g. horizon) for help text and errors. +func progName() string { + if len(os.Args) > 0 { + if b := filepath.Base(os.Args[0]); b != "" && b != "." { + return b + } + } + return "horizon" +} diff --git a/tools/horizon/tokenstore.go b/tools/horizon/tokenstore.go new file mode 100644 index 00000000..8b376a32 --- /dev/null +++ b/tools/horizon/tokenstore.go @@ -0,0 +1,108 @@ +// Copyright (c) 2026 Accenture, All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "time" +) + +// TokenCache is persisted after interactive login (0600). +type TokenCache struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token,omitempty"` + ExpiresAt int64 `json:"expires_at_unix,omitempty"` + ExpiresAtHuman string `json:"expires_at,omitempty"` // RFC3339 UTC; set on save from ExpiresAt + KeycloakBase string `json:"keycloak_base,omitempty"` + Realm string `json:"realm,omitempty"` + ClientID string `json:"client_id,omitempty"` +} + +func tokenCachePath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + dir := filepath.Join(home, ".config", "horizon") + if err := os.MkdirAll(dir, 0o700); err != nil { + return "", err + } + return filepath.Join(dir, "token.json"), nil +} + +func loadTokenCache() (*TokenCache, error) { + p, err := tokenCachePath() + if err != nil { + return nil, err + } + b, err := os.ReadFile(p) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + return nil, err + } + var t TokenCache + if err := json.Unmarshal(b, &t); err != nil { + return nil, fmt.Errorf("token cache: %w", err) + } + return &t, nil +} + +func saveTokenCache(t *TokenCache) error { + if t != nil { + if t.ExpiresAt > 0 { + t.ExpiresAtHuman = time.Unix(t.ExpiresAt, 0).UTC().Format(time.RFC3339) + } else { + t.ExpiresAtHuman = "" + } + } + p, err := tokenCachePath() + if err != nil { + return err + } + b, err := json.MarshalIndent(t, "", " ") + if err != nil { + return err + } + if err := os.WriteFile(p, b, 0o600); err != nil { + return err + } + return nil +} + +func clearTokenCache() error { + p, err := tokenCachePath() + if err != nil { + return err + } + if err := os.Remove(p); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + return nil +} + +func (t *TokenCache) IsExpired(skew time.Duration) bool { + if t == nil || t.AccessToken == "" { + return true + } + if t.ExpiresAt <= 0 { + return false + } + return time.Now().Unix()+int64(skew.Seconds()) >= t.ExpiresAt +} diff --git a/tools/scripts/argowf/repo-sync-pvc.sh b/tools/scripts/argowf/repo-sync-pvc.sh new file mode 100755 index 00000000..d5f099b5 --- /dev/null +++ b/tools/scripts/argowf/repo-sync-pvc.sh @@ -0,0 +1,218 @@ +#!/usr/bin/env bash + +# Copyright (c) 2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# Description: +# Sync a local repo directory into a PVC using a helper pod. +# On GKE, RWO volume attach to the node can take many minutes; the default wait +# for the pod to become Ready is 30m (override with --pod-ready-timeout or +# REPO_SYNC_POD_READY_TIMEOUT). +# +# StorageClasses with volumeBindingMode: WaitForFirstConsumer do not bind the PVC +# until a pod that uses the claim is scheduled. This script creates the helper pod +# first, then waits for the PVC to become Bound, then for the pod to be Ready. + +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: + repo-sync-pvc.sh -n -p -s [-m ] [--kubectl-timeout ] [--pod-ready-timeout ] [--clean] [--exclude-git] [--exclude-terraform] + +Options: + -n Kubernetes namespace (e.g., jenkins) + -p PVC name (e.g., workloads-repo-pvc) + -s Local path to repo (absolute path) + -m Mount path in pod (default: /repo) + --kubectl-timeout kubectl request timeout (default: 0, no timeout) + --pod-ready-timeout Max time to wait for the helper pod to be Ready (default: 30m). RWO attach on GKE can exceed 2m. + --clean Delete existing contents in the mount path before copy + --exclude-git Skip .git directory + --exclude-terraform Skip terraform directory + +Environment: + REPO_SYNC_POD_READY_TIMEOUT Same as --pod-ready-timeout (default: 30m) + +Example: + tools/scripts/repo-sync-pvc.sh \ + -n jenkins \ + -p workloads-repo-pvc \ + -s "/Users/dave.m.smith/Horizon/source/sdv/acn-horizon-sdv" +EOF +} + +NAMESPACE="" +PVC_NAME="" +LOCAL_PATH="" +MOUNT_PATH="/repo" +KUBECTL_TIMEOUT="0" +POD_READY_TIMEOUT="${REPO_SYNC_POD_READY_TIMEOUT:-30m}" +EXCLUDE_ARGS=() +CLEAN_MOUNT="false" +ARGS=() +while [[ $# -gt 0 ]]; do + case "$1" in + --clean) + CLEAN_MOUNT="true" + shift + ;; + --exclude-git) + EXCLUDE_ARGS+=("--exclude=.git") + shift + ;; + --exclude-terraform) + EXCLUDE_ARGS+=("--exclude=terraform") + shift + ;; + --kubectl-timeout) + KUBECTL_TIMEOUT="${2:-}" + if [ -z "${KUBECTL_TIMEOUT}" ]; then + echo "Missing value for --kubectl-timeout" >&2 + usage + exit 1 + fi + shift 2 + ;; + --pod-ready-timeout) + POD_READY_TIMEOUT="${2:-}" + if [ -z "${POD_READY_TIMEOUT}" ]; then + echo "Missing value for --pod-ready-timeout" >&2 + usage + exit 1 + fi + shift 2 + ;; + *) + ARGS+=("$1") + shift + ;; + esac +done + +set -- "${ARGS[@]}" +OPTIND=1 +while getopts ":n:p:s:m:h" opt; do + case "${opt}" in + n) NAMESPACE="${OPTARG}" ;; + p) PVC_NAME="${OPTARG}" ;; + s) LOCAL_PATH="${OPTARG}" ;; + m) MOUNT_PATH="${OPTARG}" ;; + h) + usage + exit 0 + ;; + \?) + echo "Unknown option: -${OPTARG}" >&2 + usage + exit 1 + ;; + :) + echo "Missing argument for -${OPTARG}" >&2 + usage + exit 1 + ;; + esac +done + +if [ -z "${NAMESPACE}" ] || [ -z "${LOCAL_PATH}" ]; then + echo "Missing required arguments." >&2 + usage + exit 1 +fi +if [ -z "${PVC_NAME}" ]; then + PVC_NAME="workloads-repo-pvc" +fi + +if ! kubectl -n "${NAMESPACE}" get pvc "${PVC_NAME}" >/dev/null 2>&1; then + kubectl -n "${NAMESPACE}" apply -f - <&2 + exit 1 +fi +LOCAL_PATH="${LOCAL_PATH%/}" + +POD_NAME="repo-sync-${PVC_NAME}" + +kubectl -n "${NAMESPACE}" apply -f - <&2 + kubectl -n "${NAMESPACE}" describe pvc "${PVC_NAME}" >&2 + kubectl -n "${NAMESPACE}" describe pod "${POD_NAME}" >&2 + exit 1 +fi + +if ! kubectl -n "${NAMESPACE}" wait --for=condition=Ready "pod/${POD_NAME}" --timeout="${POD_READY_TIMEOUT}"; then + echo "Timed out waiting for pod/${POD_NAME} to be Ready after ${POD_READY_TIMEOUT} (volume attach on GKE can take many minutes)." >&2 + kubectl -n "${NAMESPACE}" describe pod "${POD_NAME}" >&2 + exit 1 +fi + +if [ "${CLEAN_MOUNT}" = "true" ]; then + kubectl -n "${NAMESPACE}" exec "${POD_NAME}" -- sh -c "rm -rf ${MOUNT_PATH}/* ${MOUNT_PATH}/.[!.]* ${MOUNT_PATH}/..?* || true" +fi + +# Copy repo contents into the mount root so /workspace-local/workloads/... exists. +kubectl -n "${NAMESPACE}" exec "${POD_NAME}" -- sh -c "mkdir -p ${MOUNT_PATH} && touch ${MOUNT_PATH}/.sync-test && rm -f ${MOUNT_PATH}/.sync-test" +TOTAL_KB=$(du -sk "${LOCAL_PATH}" | awk '{print $1}') +TOTAL_BYTES=$((TOTAL_KB * 1024)) +echo "Syncing ${TOTAL_KB} KB from ${LOCAL_PATH} to ${NAMESPACE}/${PVC_NAME}:${MOUNT_PATH}" + +TAR_CMD=(tar "${EXCLUDE_ARGS[@]}" -C "${LOCAL_PATH}" -cf - .) +KUBECTL_EXEC=(kubectl -n "${NAMESPACE}" exec -i --request-timeout="${KUBECTL_TIMEOUT}" "${POD_NAME}" -- tar -C "${MOUNT_PATH}" --no-same-owner --no-same-permissions -xf -) + +if command -v pv >/dev/null 2>&1; then + "${TAR_CMD[@]}" | pv -pterb -s "${TOTAL_BYTES}" | "${KUBECTL_EXEC[@]}" +else + echo "pv not found; streaming without progress (install pv for progress output)." + "${TAR_CMD[@]}" | "${KUBECTL_EXEC[@]}" +fi +kubectl -n "${NAMESPACE}" exec "${POD_NAME}" -- sh -c "ls -la ${MOUNT_PATH} | head -50" + +kubectl -n "${NAMESPACE}" delete pod "${POD_NAME}" --ignore-not-found diff --git a/tools/scripts/container-images/build.sh b/tools/scripts/container-images/build.sh new file mode 100755 index 00000000..c02a599d --- /dev/null +++ b/tools/scripts/container-images/build.sh @@ -0,0 +1,731 @@ +#!/usr/bin/env bash + +# Copyright (c) 2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# Description: +# Build and push container images to Google Cloud Artifact Registry. +# Parses image definitions from Terraform configuration (locals.tf) and +# GCP configuration from terraform.tfvars. Supports building all images +# or a targeted subset, with optional cache control. +# +# This script is a development convenience tool for fast local iteration. +# It does NOT update Terraform state. For official/production builds, use +# Terraform (terraform apply), which tracks image state and triggers. + +set -euo pipefail + +# --------------------------------------------------------------------------- +# Constants & Paths +# --------------------------------------------------------------------------- +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)" +IMAGES_DIR="${REPO_ROOT}/terraform/modules/sdv-container-images/images" +TFVARS_FILE="${REPO_ROOT}/terraform/env/terraform.tfvars" +LOCALS_FILE="${REPO_ROOT}/terraform/modules/base/locals.tf" +ENV_MAIN_FILE="${REPO_ROOT}/terraform/env/main.tf" + +# --------------------------------------------------------------------------- +# Defaults +# --------------------------------------------------------------------------- +NO_CACHE=false +DRY_RUN=false +SKIP_PUSH=false +BUILD_ALL=false +QUIET=false +TARGET_IMAGES=() +GCP_PROJECT="" +GCP_REGION="" +REGISTRY_ID="" +LIST_MODE=false + +# --------------------------------------------------------------------------- +# Output Formatting +# --------------------------------------------------------------------------- +# Respect NO_COLOR (https://no-color.org/) and non-interactive terminals. +# Unicode symbols are always used; only ANSI color codes are conditional. +if [[ -t 1 ]] && [[ -z "${NO_COLOR:-}" ]]; then + GREEN='\033[0;32m' + YELLOW='\033[1;33m' + RED='\033[0;31m' + CYAN='\033[0;36m' + BOLD='\033[1m' + DIM='\033[2m' + NC='\033[0m' +else + GREEN='' YELLOW='' RED='' CYAN='' BOLD='' DIM='' NC='' +fi + +log_info() { echo -e " ${DIM}·${NC} $1"; } +log_ok() { echo -e " ${GREEN}✓${NC} $1"; } +log_warn() { echo -e " ${YELLOW}⚠ $1${NC}"; } +log_err() { echo -e " ${RED}✗ $1${NC}" >&2; } +log_step() { echo -e " ${CYAN}▶${NC} ${BOLD}$1${NC}"; } + +print_banner() { + local rule + rule="$(printf '━%.0s' {1..52})" + echo "" + echo -e " ${CYAN}${rule}${NC}" + echo -e " ${BOLD}Horizon SDV${NC} ${DIM}·${NC} Container Image Builder" + echo -e " ${CYAN}${rule}${NC}" +} + +format_duration() { + local secs=$1 + if (( secs >= 3600 )); then + printf '%dh %dm %ds' $((secs/3600)) $((secs%3600/60)) $((secs%60)) + elif (( secs >= 60 )); then + printf '%dm %ds' $((secs/60)) $((secs%60)) + else + printf '%ds' "$secs" + fi +} + +# --------------------------------------------------------------------------- +# Usage +# --------------------------------------------------------------------------- +usage() { + local self + self="$(basename "$0")" + + cat <&2 + exit 1 + ;; + esac + done +} + +# --------------------------------------------------------------------------- +# Load GCP Configuration from terraform.tfvars +# --------------------------------------------------------------------------- +load_gcp_config() { + if [[ ! -f "$TFVARS_FILE" ]]; then + if [[ -z "$GCP_PROJECT" || -z "$GCP_REGION" ]]; then + log_err "terraform.tfvars not found at: ${TFVARS_FILE}" + log_err "Provide GCP configuration via flags: --project --region " + exit 1 + fi + log_warn "terraform.tfvars not found; using values from command-line flags." + return + fi + + if [[ -z "$GCP_PROJECT" ]]; then + GCP_PROJECT=$(awk -F'"' '/sdv_gcp_project_id[[:space:]]*=/ {print $2}' "$TFVARS_FILE") + fi + if [[ -z "$GCP_REGION" ]]; then + GCP_REGION=$(awk -F'"' '/sdv_gcp_region[[:space:]]*=/ {print $2}' "$TFVARS_FILE") + fi + + if [[ -z "$GCP_PROJECT" ]]; then + log_err "Could not determine GCP project ID." + log_err "Set 'sdv_gcp_project_id' in terraform.tfvars or pass --project ." + exit 1 + fi + if [[ -z "$GCP_REGION" ]]; then + log_err "Could not determine GCP region." + log_err "Set 'sdv_gcp_region' in terraform.tfvars or pass --region ." + exit 1 + fi +} + +# --------------------------------------------------------------------------- +# Load Artifact Registry Repository ID +# --------------------------------------------------------------------------- +load_registry_id() { + [[ -n "$REGISTRY_ID" ]] && return + + if [[ -f "$ENV_MAIN_FILE" ]]; then + REGISTRY_ID=$(awk -F'"' '/sdv_artifact_registry_repository_id[[:space:]]*=/ {print $2}' "$ENV_MAIN_FILE") + fi + + if [[ -z "$REGISTRY_ID" ]]; then + log_err "Could not determine Artifact Registry repository ID." + log_err "Set it in terraform/env/main.tf or pass --registry ." + exit 1 + fi +} + +# --------------------------------------------------------------------------- +# Parse Image Catalog from locals.tf +# +# Populates globals: +# IMAGE_NAMES - ordered array of image names +# IMAGE_DIRS[] - associative: image name -> build directory +# IMAGE_VERSIONS[] - associative: image name -> version tag +# IMAGE_BUILD_ARGS[] - associative: image name -> comma-separated KEY=VAL pairs +# --------------------------------------------------------------------------- +declare -A IMAGE_DIRS=() +declare -A IMAGE_VERSIONS=() +declare -A IMAGE_BUILD_ARGS=() +IMAGE_NAMES=() + +parse_image_catalog() { + if [[ ! -f "$LOCALS_FILE" ]]; then + log_err "locals.tf not found at: ${LOCALS_FILE}" + log_err "Cannot determine image definitions. Ensure the Terraform codebase is intact." + exit 1 + fi + + # Pass 1: extract simple local variable assignments for resolving build_arg references + # (e.g. common_nginx_version = "1.28.1-alpine3.23") + declare -A local_vars + while IFS= read -r line; do + [[ "$line" =~ ^[[:space:]]*# ]] && continue + if [[ "$line" =~ ^[[:space:]]+([a-z_][a-z0-9_]*)[[:space:]]*=[[:space:]]*\"([^\"]*)\" ]]; then + local_vars["${BASH_REMATCH[1]}"]="${BASH_REMATCH[2]}" + fi + done < "$LOCALS_FILE" + + # Pass 2: parse the `images = { ... }` block using a brace-depth state machine + local in_images=0 brace_depth=0 in_build_args=0 + local cur_image="" cur_dir="" cur_ver="" cur_ba="" + + while IFS= read -r line; do + [[ "$line" =~ ^[[:space:]]*# ]] && continue + + # Detect start of the images block + if [[ $in_images -eq 0 ]]; then + if [[ "$line" =~ images[[:space:]]*=[[:space:]]*\{ ]]; then + in_images=1 + brace_depth=1 + fi + continue + fi + + # Count braces on this line + local opens="${line//[^\{]/}" + local closes="${line//[^\}]/}" + brace_depth=$(( brace_depth + ${#opens} - ${#closes} )) + + # End of the entire images block + if [[ $brace_depth -le 0 ]]; then + if [[ -n "$cur_image" ]]; then + IMAGE_NAMES+=("$cur_image") + IMAGE_DIRS["$cur_image"]="$cur_dir" + IMAGE_VERSIONS["$cur_image"]="$cur_ver" + IMAGE_BUILD_ARGS["$cur_image"]="$cur_ba" + fi + break + fi + + # New image entry: "image-name" = { + if [[ $in_build_args -eq 0 && "$line" =~ \"([^\"]+)\"[[:space:]]*=[[:space:]]*\{ ]]; then + # Save previous entry + if [[ -n "$cur_image" ]]; then + IMAGE_NAMES+=("$cur_image") + IMAGE_DIRS["$cur_image"]="$cur_dir" + IMAGE_VERSIONS["$cur_image"]="$cur_ver" + IMAGE_BUILD_ARGS["$cur_image"]="$cur_ba" + fi + cur_image="${BASH_REMATCH[1]}" + cur_dir="" + cur_ver="" + cur_ba="" + continue + fi + + # build_args block start + if [[ $in_build_args -eq 0 && "$line" =~ build_args[[:space:]]*=[[:space:]]*\{ ]]; then + in_build_args=1 + continue + fi + + # Inside build_args block + if [[ $in_build_args -eq 1 ]]; then + if [[ "$line" =~ \} ]]; then + in_build_args=0 + continue + fi + if [[ "$line" =~ ([A-Z_][A-Z0-9_]*)[[:space:]]*=[[:space:]]*(.*) ]]; then + local ba_key="${BASH_REMATCH[1]}" + local ba_val + ba_val="$(echo "${BASH_REMATCH[2]}" | xargs)" + # Resolve local.* references + if [[ "$ba_val" =~ ^local\.(.+)$ ]]; then + local ref="${BASH_REMATCH[1]}" + ba_val="${local_vars[$ref]:-}" + if [[ -z "$ba_val" ]]; then + log_warn "Could not resolve local.${ref} for build_arg ${ba_key} in image '${cur_image}'" + fi + else + ba_val="${ba_val//\"/}" + fi + [[ -n "$cur_ba" ]] && cur_ba+="," + cur_ba+="${ba_key}=${ba_val}" + fi + continue + fi + + # Parse directory + if [[ "$line" =~ directory[[:space:]]*=[[:space:]]*\"([^\"]*)\" ]]; then + cur_dir="${BASH_REMATCH[1]}" + fi + + # Parse build_version + if [[ "$line" =~ build_version[[:space:]]*=[[:space:]]*\"([^\"]*)\" ]]; then + cur_ver="${BASH_REMATCH[1]}" + fi + done < "$LOCALS_FILE" + + if [[ ${#IMAGE_NAMES[@]} -eq 0 ]]; then + log_err "No images found in the 'images' block of ${LOCALS_FILE}." + exit 1 + fi + + log_ok "Parsed ${#IMAGE_NAMES[@]} image(s) from catalog." + echo "" +} + +# --------------------------------------------------------------------------- +# List Images +# --------------------------------------------------------------------------- +list_images() { + local hrule + hrule="$(printf '─%.0s' {1..68})" + + echo "" + echo -e " ${CYAN}┌${NC} ${BOLD}Image Catalog${NC} ${DIM}(source: terraform/modules/base/locals.tf)${NC}" + echo -e " ${CYAN}│${NC}" + printf " ${CYAN}│${NC} ${BOLD}%-4s %-38s %-10s %s${NC}\n" "#" "IMAGE NAME" "VERSION" "DIRECTORY" + echo -e " ${CYAN}│${NC} ${DIM}${hrule}${NC}" + + local idx=0 + for name in "${IMAGE_NAMES[@]}"; do + idx=$(( idx + 1 )) + printf " ${CYAN}│${NC} ${DIM}%-4s${NC} %-38s ${GREEN}%-10s${NC} ${DIM}%s${NC}\n" \ + "${idx}" "$name" "${IMAGE_VERSIONS[$name]}" "${IMAGE_DIRS[$name]}" + done + + echo -e " ${CYAN}│${NC}" + echo -e " ${CYAN}└${NC} ${BOLD}${#IMAGE_NAMES[@]}${NC} image(s) defined in catalog" + echo "" +} + +# --------------------------------------------------------------------------- +# Prerequisite Checks +# --------------------------------------------------------------------------- +check_prerequisites() { + log_step "Checking prerequisites..." + + if ! command -v docker &>/dev/null; then + log_err "Docker is not installed or not in PATH." + exit 1 + fi + + if ! docker info &>/dev/null 2>&1; then + log_err "Docker daemon is not running or current user lacks permission. Start Docker and retry." + exit 1 + fi + log_ok "Docker is available and running." + + if ! command -v gcloud &>/dev/null; then + log_err "Google Cloud SDK (gcloud) is not installed or not in PATH." + exit 1 + fi + log_ok "Google Cloud SDK is available." + + if ! gcloud auth print-access-token &>/dev/null 2>&1; then + log_err "Not authenticated with gcloud. Run 'gcloud auth login' first." + exit 1 + fi + log_ok "gcloud authentication verified." +} + +# --------------------------------------------------------------------------- +# Configure Docker for Artifact Registry +# --------------------------------------------------------------------------- +configure_docker_auth() { + local registry_host="${GCP_REGION}-docker.pkg.dev" + log_step "Configuring Docker credential helper for ${registry_host}..." + + if ! gcloud auth configure-docker "${registry_host}" --quiet 2>/dev/null; then + log_err "Failed to configure Docker authentication for ${registry_host}." + log_err "Ensure gcloud is authenticated and has Artifact Registry permissions." + exit 1 + fi + log_ok "Docker credential helper configured for ${registry_host}." +} + +# --------------------------------------------------------------------------- +# Build a Single Image +# --------------------------------------------------------------------------- +build_image() { + local name="$1" + local counter="$2" + local directory="${IMAGE_DIRS[$name]}" + local version="${IMAGE_VERSIONS[$name]}" + local build_args_raw="${IMAGE_BUILD_ARGS[$name]:-}" + local registry_host="${GCP_REGION}-docker.pkg.dev" + local full_image="${registry_host}/${GCP_PROJECT}/${REGISTRY_ID}/${name}:${version}" + local context_dir="${IMAGES_DIR}/${directory}/${name}" + local dockerfile_path="" + local platform_flag="" + + if [[ ! -d "$context_dir" ]]; then + log_err "Build context directory not found: ${context_dir}" + return 1 + fi + + if [[ -n "$dockerfile_path" ]]; then + if [[ ! -f "$dockerfile_path" ]]; then + log_err "Dockerfile not found: ${dockerfile_path}" + return 1 + fi + elif [[ ! -f "${context_dir}/Dockerfile" ]]; then + log_err "Dockerfile not found in: ${context_dir}" + return 1 + fi + + log_step "${counter} ${CYAN}Building${NC} ${BOLD}${name}:${version}${NC}" + log_info " Context: ${DIM}${context_dir}${NC}" + [[ -n "$dockerfile_path" ]] && log_info " Dockerfile: ${DIM}${dockerfile_path}${NC}" + log_info " Tag: ${full_image}" + + local -a cmd=(docker build) + local cache_label="enabled" + + if [[ "$NO_CACHE" == "true" ]]; then + cmd+=(--no-cache) + cache_label="disabled" + fi + + if [[ "$QUIET" == "true" ]]; then + cmd+=(--quiet) + fi + + if [[ -n "$platform_flag" ]]; then + cmd+=(--platform "$platform_flag") + fi + + local details="${DIM}Cache: ${cache_label}${NC}" + [[ -n "$platform_flag" ]] && details+="${DIM} | platform=${platform_flag}${NC}" + + if [[ -n "$build_args_raw" ]]; then + IFS=',' read -ra ba_pairs <<< "$build_args_raw" + for pair in "${ba_pairs[@]}"; do + cmd+=(--build-arg "$pair") + details+="${DIM} | ${pair}${NC}" + done + fi + + log_info " ${details}" + if [[ -n "$dockerfile_path" ]]; then + cmd+=(-f "$dockerfile_path" -t "$full_image" "$context_dir") + else + cmd+=(-t "$full_image" "$context_dir") + fi + + if [[ "$DRY_RUN" == "true" ]]; then + log_info " ${DIM}[DRY RUN] ${cmd[*]}${NC}" + return 0 + fi + + local build_start=$SECONDS + if "${cmd[@]}"; then + local elapsed=$(( SECONDS - build_start )) + log_ok "Built ${GREEN}${name}:${version}${NC} ${DIM}in $(format_duration $elapsed)${NC}" + else + log_err "Build failed: ${name}:${version}" + return 1 + fi +} + +# --------------------------------------------------------------------------- +# Push a Single Image +# --------------------------------------------------------------------------- +push_image() { + local name="$1" + local version="${IMAGE_VERSIONS[$name]}" + local registry_host="${GCP_REGION}-docker.pkg.dev" + local full_image="${registry_host}/${GCP_PROJECT}/${REGISTRY_ID}/${name}:${version}" + + if [[ "$DRY_RUN" == "true" ]]; then + log_info " ${DIM}[DRY RUN] docker push ${full_image}${NC}" + return 0 + fi + + local -a push_cmd=(docker push) + if [[ "$QUIET" == "true" ]]; then + push_cmd+=(--quiet) + fi + push_cmd+=("$full_image") + + local push_start=$SECONDS + if "${push_cmd[@]}"; then + local elapsed=$(( SECONDS - push_start )) + log_ok "Pushed ${DIM}in $(format_duration $elapsed)${NC}" + else + log_err "Push failed: ${full_image}" + return 1 + fi +} + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- +main() { + parse_args "$@" + + print_banner + + load_gcp_config + load_registry_id + parse_image_catalog + + # --list: print catalog and exit + if [[ "$LIST_MODE" == "true" ]]; then + list_images + exit 0 + fi + + # Require explicit build target: -a (all) or -i (one or more images) + if [[ "$BUILD_ALL" != "true" && ${#TARGET_IMAGES[@]} -eq 0 ]]; then + usage + fi + + # Mutual exclusion: -a and -i cannot be combined + if [[ "$BUILD_ALL" == "true" && ${#TARGET_IMAGES[@]} -gt 0 ]]; then + log_err "Flags -a/--all and -i/--image are mutually exclusive." + log_err "Use -a to build everything, or -i to pick specific images." + exit 1 + fi + + # Validate each --image target exists in catalog + if [[ ${#TARGET_IMAGES[@]} -gt 0 ]]; then + local -a invalid_images=() + for target in "${TARGET_IMAGES[@]}"; do + local found=false + for name in "${IMAGE_NAMES[@]}"; do + [[ "$name" == "$target" ]] && { found=true; break; } + done + [[ "$found" != "true" ]] && invalid_images+=("$target") + done + if [[ ${#invalid_images[@]} -gt 0 ]]; then + for bad in "${invalid_images[@]}"; do + log_err "Image '${bad}' not found in catalog." + done + echo "" >&2 + echo " Available images:" >&2 + for name in "${IMAGE_NAMES[@]}"; do + echo -e " ${DIM}-${NC} ${name}" >&2 + done + exit 1 + fi + fi + + # Determine build list + local -a build_list + if [[ ${#TARGET_IMAGES[@]} -gt 0 ]]; then + build_list=("${TARGET_IMAGES[@]}") + else + build_list=("${IMAGE_NAMES[@]}") + fi + + # Configuration summary (tree-style) + local registry_host="${GCP_REGION}-docker.pkg.dev" + local cache_label push_label + cache_label="$( [[ "$NO_CACHE" == "true" ]] && echo "disabled" || echo "enabled" )" + push_label="$( [[ "$SKIP_PUSH" == "true" ]] && echo "skip" || echo "enabled" )" + local tree_rule + tree_rule="$(printf '─%.0s' {1..52})" + + echo "" + echo -e " ${CYAN}┌${NC} ${BOLD}Build Configuration${NC}" + echo -e " ${CYAN}│${NC}" + echo -e " ${CYAN}│${NC} GCP Project ${BOLD}${GCP_PROJECT}${NC}" + echo -e " ${CYAN}│${NC} GCP Region ${GCP_REGION}" + echo -e " ${CYAN}│${NC} Registry ${DIM}${registry_host}/${GCP_PROJECT}/${REGISTRY_ID}${NC}" + echo -e " ${CYAN}│${NC} Images ${BOLD}${#build_list[@]}${NC} of ${#IMAGE_NAMES[@]}" + local quiet_label + quiet_label="$( [[ "$QUIET" == "true" ]] && echo "on" || echo "off" )" + + echo -e " ${CYAN}│${NC} Cache ${cache_label}" + echo -e " ${CYAN}│${NC} Push ${push_label}" + echo -e " ${CYAN}│${NC} Quiet ${quiet_label}" + if [[ "$DRY_RUN" == "true" ]]; then + echo -e " ${CYAN}│${NC}" + echo -e " ${CYAN}│${NC} ${YELLOW}⚠ DRY RUN - no changes will be made${NC}" + fi + echo -e " ${CYAN}│${NC}" + echo -e " ${CYAN}└${tree_rule}${NC}" + echo "" + + # Pre-flight checks (skip for dry-run to allow offline previews) + if [[ "$DRY_RUN" != "true" ]]; then + check_prerequisites + configure_docker_auth + echo "" + fi + + # Build and push loop + local total=${#build_list[@]} + local build_ok=0 build_fail=0 push_ok=0 push_fail=0 push_skip=0 + local -a failed_images=() + local run_start=$SECONDS + local idx=0 + + for name in "${build_list[@]}"; do + idx=$(( idx + 1 )) + local counter="${DIM}[${idx}/${total}]${NC}" + echo "" + + if build_image "$name" "$counter"; then + build_ok=$(( build_ok + 1 )) + + if [[ "$SKIP_PUSH" == "true" || "$DRY_RUN" == "true" ]]; then + push_skip=$(( push_skip + 1 )) + else + if push_image "$name"; then + push_ok=$(( push_ok + 1 )) + else + push_fail=$(( push_fail + 1 )) + failed_images+=("$name") + fi + fi + else + build_fail=$(( build_fail + 1 )) + failed_images+=("$name") + fi + done + + local total_elapsed=$(( SECONDS - run_start )) + + # Summary (tree-style) + local build_sym push_sym + if [[ $build_fail -eq 0 ]]; then + build_sym="${GREEN}✓${NC}" + else + build_sym="${RED}✗${NC}" + fi + + echo "" + echo -e " ${CYAN}┌${NC} ${BOLD}Summary${NC}" + echo -e " ${CYAN}│${NC}" + echo -e " ${CYAN}│${NC} ${build_sym} Build ${GREEN}${build_ok} succeeded${NC}, ${RED}${build_fail} failed${NC}" + + if [[ "$SKIP_PUSH" != "true" && "$DRY_RUN" != "true" ]]; then + if [[ $push_fail -eq 0 ]]; then + push_sym="${GREEN}✓${NC}" + else + push_sym="${RED}✗${NC}" + fi + echo -e " ${CYAN}│${NC} ${push_sym} Push ${GREEN}${push_ok} succeeded${NC}, ${RED}${push_fail} failed${NC}" + elif [[ "$DRY_RUN" == "true" ]]; then + echo -e " ${CYAN}│${NC} ${DIM}·${NC} Push ${DIM}skipped (dry run)${NC}" + else + echo -e " ${CYAN}│${NC} ${DIM}·${NC} Push ${DIM}skipped (--skip-push)${NC}" + fi + + echo -e " ${CYAN}│${NC} ⏱ Elapsed $(format_duration $total_elapsed)" + + if [[ ${#failed_images[@]} -gt 0 ]]; then + echo -e " ${CYAN}│${NC}" + echo -e " ${CYAN}│${NC} ${RED}Failed:${NC}" + for fname in "${failed_images[@]}"; do + echo -e " ${CYAN}│${NC} ${RED}✗${NC} ${fname}" + done + fi + + echo -e " ${CYAN}│${NC}" + echo -e " ${CYAN}└${tree_rule}${NC}" + echo "" + + if [[ $build_fail -gt 0 || $push_fail -gt 0 ]]; then + exit 1 + fi + + log_ok "All operations completed successfully." +} + +main "$@" diff --git a/tools/scripts/deployment/deploy.sh b/tools/scripts/deployment/deploy.sh index 628fba09..5634b29c 100755 --- a/tools/scripts/deployment/deploy.sh +++ b/tools/scripts/deployment/deploy.sh @@ -41,52 +41,105 @@ version_ge() { [ "$2" = "$(echo -e "$1\n$2" | sort -V | head -n1)" ] } -# Workspace Setup -setup_workspace() { - if [[ -f "$CONTAINER_CONFIG" ]]; then - # Clone repository if running within container - log_info "Container environment detected. Setting up workspace..." - - # Extract Repo Details from terraform.tfvars - REPO_OWNER=$(grep '^\s*sdv_git_repo_owner\s*=' "$CONTAINER_CONFIG" | cut -d'"' -f2) - REPO_NAME=$(grep '^\s*sdv_git_repo_name\s*=' "$CONTAINER_CONFIG" | cut -d'"' -f2) - REPO_BRANCH=$(grep '^\s*sdv_git_repo_branch\s*=' "$CONTAINER_CONFIG" | cut -d'"' -f2) - GIT_PAT=$(grep '^\s*sdv_git_pat\s*=' "$CONTAINER_CONFIG" | cut -d'"' -f2) - - # Clone repository - if [[ "$GIT_PAT" == "" || "$GIT_PAT" == "" ]]; then GIT_PAT=""; fi - - log_info "Cloning ${REPO_OWNER}/${REPO_NAME} (Branch: ${REPO_BRANCH})..." - if [[ -n "$GIT_PAT" ]]; then - git clone -q -b "$REPO_BRANCH" "https://${GIT_PAT}@github.com/${REPO_OWNER}/${REPO_NAME}.git" . - else - if ! git clone -q -b "$REPO_BRANCH" "https://github.com/${REPO_OWNER}/${REPO_NAME}.git" . 2>/dev/null; then - log_warn "Public clone failed. Repository might be private." - read -s -p "Enter Git PAT: " MANUAL_PAT; echo "" - if [[ -z "$MANUAL_PAT" ]]; then log_err "No token provided."; exit 1; fi - git clone -q -b "$REPO_BRANCH" "https://${MANUAL_PAT}@github.com/${REPO_OWNER}/${REPO_NAME}.git" . - fi - fi - - # Copy the terraform.tfvars file - DEST_TFVARS="terraform/env/terraform.tfvars" - mkdir -p "$(dirname "$DEST_TFVARS")" - cp "$CONTAINER_CONFIG" "$DEST_TFVARS" - - # Set TF_DIR to the newly cloned location - TF_DIR="$(pwd)/terraform/env" +# Help command +usage() { + echo "" + echo "Please use one of the valid commands below:" + echo " ./deploy.sh [OPTION] for e.g. ./deploy.sh -p or --plan (recommended for native runs on Linux distributions)" + echo " ./container-deploy.sh [OPTION] for e.g. ./container-deploy.sh -p or --plan (for containerized execution)" + echo "" + echo "Options:" + echo " -p, --plan Run Terraform plan" + echo " -a, --apply Run Terraform apply" + echo " -d, --destroy Run Terraform destroy" + echo " -h, --help Help message" + echo "" + exit 0 +} +# Argument validation before any terraform logic +if [[ $# -eq 0 ]]; then + usage +fi + +case "$1" in + -h|--help) + usage + ;; + -p|--plan|-a|--apply|-d|--destroy) + ;; + *) + echo "Invalid option: $1" + usage + ;; +esac + + +# Workspace Setup +setup_workspace() { + if [[ -f "$CONTAINER_CONFIG" ]]; then + # Clone repository if running within container + log_info "Container environment detected. Setting up workspace..." + + # Extract SCM configuration from terraform.tfvars + SCM_TYPE=$(grep '^\s*scm_type\s*=' "$CONTAINER_CONFIG" | cut -d'"' -f2) + SCM_AUTH_METHOD=$(grep '^\s*scm_auth_method\s*=' "$CONTAINER_CONFIG" | cut -d'"' -f2) + SCM_REPO_URL=$(grep '^\s*scm_repo_url\s*=' "$CONTAINER_CONFIG" | cut -d'"' -f2) + SCM_REPO_BRANCH=$(grep '^\s*scm_repo_branch\s*=' "$CONTAINER_CONFIG" | cut -d'"' -f2) + SCM_USERNAME=$(grep '^\s*scm_username\s*=' "$CONTAINER_CONFIG" | cut -d'"' -f2) + SCM_USERNAME=$(echo -n "$SCM_USERNAME" | jq -sRr @uri) + SCM_PASSWORD=$(grep '^\s*scm_password\s*=' "$CONTAINER_CONFIG" | cut -d'"' -f2) + SCM_PASSWORD=$(echo -n "$SCM_PASSWORD" | jq -sRr @uri) + + log_info "Cloning from ${SCM_REPO_URL} (Branch: ${SCM_REPO_BRANCH})..." + + # Handle authentication based on method + if [[ "$SCM_AUTH_METHOD" == "userpass" ]]; then + # Build authenticated URL for username/password + SCM_HOST=$(echo "$SCM_REPO_URL" | sed -e 's|^https://||' -e 's|/.*||') + SCM_PATH=$(echo "$SCM_REPO_URL" | sed -e 's|^https://[^/]*/||') + AUTH_URL="https://${SCM_USERNAME}:${SCM_PASSWORD}@${SCM_HOST}/${SCM_PATH}" + git clone -q -b "$SCM_REPO_BRANCH" "$AUTH_URL" . + elif [[ "$SCM_AUTH_METHOD" == "none" ]]; then + # Public repository - no authentication + git clone -q -b "$SCM_REPO_BRANCH" "$SCM_REPO_URL" . else - # Skip cloning repository if running on local/native machine - log_info "Local environment detected. Skipping clone." - TF_DIR="${SCRIPT_DIR}/../../../terraform/env" - - if [[ ! -f "${TF_DIR}/terraform.tfvars" ]]; then - log_err "Config file not found at ${TF_DIR}/terraform.tfvars" - exit 1 + # Try public clone first, then prompt if needed + if ! git clone -q -b "$SCM_REPO_BRANCH" "$SCM_REPO_URL" . 2>/dev/null; then + log_warn "Public clone failed. Repository might be private." + read -s -p "Enter password/token: " MANUAL_PASSWORD + echo "" + if [[ -z "$MANUAL_PASSWORD" ]]; then + log_err "No password provided." + exit 1 fi + + SCM_HOST=$(echo "$SCM_REPO_URL" | sed -e 's|^https://||' -e 's|/.*||') + SCM_PATH=$(echo "$SCM_REPO_URL" | sed -e 's|^https://[^/]*/||') + AUTH_URL="https://${SCM_USERNAME:-git}:${MANUAL_PASSWORD}@${SCM_HOST}/${SCM_PATH}" + git clone -q -b "$SCM_REPO_BRANCH" "$AUTH_URL" . + fi fi -} + + # Copy the terraform.tfvars file + DEST_TFVARS="terraform/env/terraform.tfvars" + mkdir -p "$(dirname "$DEST_TFVARS")" + cp "$CONTAINER_CONFIG" "$DEST_TFVARS" + + # Set TF_DIR to the newly cloned location + TF_DIR="$(pwd)/terraform/env" + + else + # Skip cloning repository if running on local/native machine + log_info "Local environment detected. Skipping clone." + TF_DIR="${SCRIPT_DIR}/../../../terraform/env" + + if [[ ! -f "${TF_DIR}/terraform.tfvars" ]]; then + log_err "Config file not found at ${TF_DIR}/terraform.tfvars" + exit 1 + fi + fi +} check_requirements() { log_info "Checking requirements..." @@ -170,6 +223,20 @@ check_auth() { # Terraform Execution run_terraform() { + MODE="apply" + + case "$1" in + -p|--plan) + MODE="plan" + ;; + -a|--apply) + MODE="apply" + ;; + -d|--destroy) + MODE="destroy" + ;; + esac + cd "$TF_DIR" TFVARS_FILE="terraform.tfvars" @@ -194,7 +261,7 @@ run_terraform() { # Handle KMS infrastructure if encryption is enabled KMS_DIR="$(dirname "$TF_DIR")/kms" - if [[ "$KMS_ENABLED" == "true" ]] && [[ -d "$KMS_DIR" ]] && [[ ! "$SKIP_KMS" == "true" ]]; then + if [[ "$KMS_ENABLED" == "true" ]] && [[ -d "$KMS_DIR" ]] && [[ "$MODE" == "apply" ]] && [[ ! "$SKIP_KMS" == "true" ]]; then log_info "KMS encryption enabled - checking KMS infrastructure..." # Check if KMS resources exist in GCP (using explicit project/location flags) @@ -227,23 +294,21 @@ run_terraform() { terraform init -upgrade -reconfigure \ -backend-config="bucket=$BACKEND_BUCKET" - # Check for Destroy Mode - DESTROY_MODE=false - for arg in "$@"; do - if [[ "$arg" == "--destroy" || "$arg" == "-d" ]]; then DESTROY_MODE=true; fi - done - - if [[ "$DESTROY_MODE" == "true" ]]; then - log_warn "!!! DESTRUCTION MODE ENABLED !!!" - - # Run Terraform destroy - log_info "Running Terraform destroy..." - log_info "Note: KMS resources (if any) will persist in GCP (separate state)" - terraform destroy -auto-approve - else - log_info "Running Terraform Apply..." - terraform apply -auto-approve - fi +# Execute based on selected mode +if [[ "$MODE" == "plan" ]]; then + log_info "Running Terraform plan..." + terraform plan + +elif [[ "$MODE" == "destroy" ]]; then + log_warn "!!! DESTRUCTION MODE ENABLED !!!" + log_info "Running Terraform destroy..." + log_info "Note: KMS resources (if any) will persist in GCP (separate state)" + terraform destroy -auto-approve + +else + log_info "Running Terraform Apply..." + terraform apply -auto-approve +fi } setup_workspace diff --git a/tools/scripts/horizon-api/horizon-api-build-push-restart.sh b/tools/scripts/horizon-api/horizon-api-build-push-restart.sh new file mode 100755 index 00000000..e5c833b6 --- /dev/null +++ b/tools/scripts/horizon-api/horizon-api-build-push-restart.sh @@ -0,0 +1,112 @@ +#!/usr/bin/env bash + +# Copyright (c) 2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# Description: +# Build horizon-api-app (Docker), push to Artifact Registry, restart the Deployment. +# +# Prerequisites: docker, gcloud (authenticated), kubectl (context set), jq optional for version parse. +# +# Usage: +# export HORIZON_API_NAMESPACE="sbx-horizon-api" # Kubernetes namespace (required) +# ./tools/scripts/horizon-api/horizon-api-build-push-restart.sh +# +# Optional: +# DEPLOYMENT_NAME=horizon-api +# CONTAINER_NAME=horizon-api +# SKIP_BUILD=true # only restart (set FULL_IMAGE_URI) +# SKIP_PUSH=true # passed to build.sh +# NO_CACHE=true # passed to build.sh +# FULL_IMAGE_URI=... # override image for kubectl set image (default: computed from tfvars + locals) +# +# Terraform-derived defaults (same as tools/scripts/container-images/build.sh): +# terraform/env/terraform.tfvars → project, region +# terraform/env/main.tf → registry id +# terraform/modules/base/locals.tf → horizon-api-app build_version + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)" +BUILD_SH="${REPO_ROOT}/tools/scripts/container-images/build.sh" +LOCALS_FILE="${REPO_ROOT}/terraform/modules/base/locals.tf" +TFVARS_FILE="${REPO_ROOT}/terraform/env/terraform.tfvars" +ENV_MAIN_FILE="${REPO_ROOT}/terraform/env/main.tf" + +DEPLOYMENT_NAME="${DEPLOYMENT_NAME:-horizon-api}" +CONTAINER_NAME="${CONTAINER_NAME:-horizon-api}" +SKIP_BUILD="${SKIP_BUILD:-false}" + +die() { echo "error: $*" >&2; exit 1; } + +[[ -n "${HORIZON_API_NAMESPACE:-}" ]] || die "set HORIZON_API_NAMESPACE (e.g. sbx-horizon-api)" + +load_tfvars() { + if [[ -f "$TFVARS_FILE" ]]; then + GCP_PROJECT="${GCP_PROJECT:-$(awk -F'"' '/sdv_gcp_project_id[[:space:]]*=/ {print $2; exit}' "$TFVARS_FILE")}" + GCP_REGION="${GCP_REGION:-$(awk -F'"' '/sdv_gcp_region[[:space:]]*=/ {print $2; exit}' "$TFVARS_FILE")}" + fi + if [[ -f "$ENV_MAIN_FILE" ]]; then + REGISTRY_ID="${REGISTRY_ID:-$(awk -F'"' '/sdv_artifact_registry_repository_id[[:space:]]*=/ {print $2; exit}' "$ENV_MAIN_FILE")}" + fi + if [[ -z "${GCP_PROJECT:-}" ]] && command -v gcloud >/dev/null 2>&1; then + GCP_PROJECT="$(gcloud config get-value project 2>/dev/null || true)" + fi + if [[ -z "${GCP_REGION:-}" ]] && command -v gcloud >/dev/null 2>&1; then + GCP_REGION="$(gcloud config get-value compute/region 2>/dev/null || true)" + fi + [[ -n "${GCP_PROJECT:-}" ]] || die "set GCP_PROJECT or sdv_gcp_project_id in ${TFVARS_FILE} (file missing?)" + [[ -n "${GCP_REGION:-}" ]] || die "set GCP_REGION or sdv_gcp_region in ${TFVARS_FILE} (file missing?)" + [[ -n "${REGISTRY_ID:-}" ]] || die "set REGISTRY_ID or sdv_artifact_registry_repository_id in ${ENV_MAIN_FILE}" +} + +horizon_api_version() { + if [[ ! -f "$LOCALS_FILE" ]]; then + die "missing ${LOCALS_FILE}" + fi + python3 - "$LOCALS_FILE" <<'PY' +import re, sys +text = open(sys.argv[1], encoding="utf-8").read() +# First block for "horizon-api-app" = { ... build_version = "x" ... } +m = re.search( + r'"horizon-api-app"\s*=\s*\{[^}]*?build_version\s*=\s*"([^"]+)"', + text, + re.DOTALL, +) +print(m.group(1) if m else "1.0.0") +PY +} + +load_tfvars +VERSION="$(horizon_api_version)" +REGISTRY_HOST="${GCP_REGION}-docker.pkg.dev" +DEFAULT_IMAGE="${REGISTRY_HOST}/${GCP_PROJECT}/${REGISTRY_ID}/horizon-api-app:${VERSION}" +FULL_IMAGE_URI="${FULL_IMAGE_URI:-$DEFAULT_IMAGE}" + +if [[ "$SKIP_BUILD" != "true" ]]; then + [[ -x "$BUILD_SH" || -f "$BUILD_SH" ]] || die "missing build script: $BUILD_SH" + build_args=(-i horizon-api-app -p "$GCP_PROJECT" -r "$GCP_REGION" --registry "$REGISTRY_ID") + [[ "${SKIP_PUSH:-false}" == "true" ]] && build_args+=(--skip-push) + [[ "${NO_CACHE:-false}" == "true" ]] && build_args+=(-n) + bash "$BUILD_SH" "${build_args[@]}" +else + echo "SKIP_BUILD=true — skipping docker build/push" +fi + +echo "Restarting Deployment/${DEPLOYMENT_NAME} in namespace ${HORIZON_API_NAMESPACE} with image:" +echo " ${FULL_IMAGE_URI}" +kubectl set image "deployment/${DEPLOYMENT_NAME}" "${CONTAINER_NAME}=${FULL_IMAGE_URI}" -n "${HORIZON_API_NAMESPACE}" +kubectl rollout status "deployment/${DEPLOYMENT_NAME}" -n "${HORIZON_API_NAMESPACE}" --timeout=300s +echo "Rollout complete." diff --git a/tools/scripts/horizon-api/horizon-api-e2e-smoke.sh b/tools/scripts/horizon-api/horizon-api-e2e-smoke.sh new file mode 100755 index 00000000..1313854a --- /dev/null +++ b/tools/scripts/horizon-api/horizon-api-e2e-smoke.sh @@ -0,0 +1,678 @@ +#!/usr/bin/env bash + +# Copyright (c) 2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# Description: +# Smoke-test Horizon API: catalog → submit sample workflow → running/status/logs → abort path → +# more runs → history with archived log links (gs://) when the cluster exposes them. +# +# Prerequisites: curl, jq, python3 (strongly recommended for true live GET /v1/workflows/{name}/log streaming), +# a valid Bearer token (Keycloak). +# +# Usage — authentication (pick one): +# +# A) CI / automation: confidential client horizon-api-ci (token refreshed before each HTTP call / log stream): +# export KEYCLOAK_CLIENT_SECRET='…' +# export HORIZON_API_BASE_URL="https://YOUR_DOMAIN/horizon-api" +# ./tools/scripts/horizon-api/horizon-api-e2e-smoke.sh +# +# B) Human: public client horizon-api (device flow — browser login, no client secret); export token once: +# export HORIZON_API_BASE_URL="https://YOUR_DOMAIN/horizon-api" +# export HORIZON_ACCESS_TOKEN="$(./tools/scripts/horizon-api/horizon-api-get-token.sh --device)" +# ./tools/scripts/horizon-api/horizon-api-e2e-smoke.sh +# Long runs may need a fresh token (reuse --device or Swagger Authorize). +# +# C) Static JWT from anywhere (may expire on long runs / log streams): +# export HORIZON_ACCESS_TOKEN="…" +# +# Optional: +# MODULE=sample +# TEMPLATE=sample-smoke-test +# LOG_SAMPLE_SECS=90 # max time per GET /v1/workflows/{name}/log stream (seconds) +# LOG_MAX_LINES=0 # stop after N ndjson lines (0 = no cap, only time) +# LOG_WAIT_POD_SECS=0 # default 0: open workflow log stream immediately (best for live follow; increase if debugging) +# LOG_FOLLOW_MODE=auto # auto: follow=false if workflow already Succeeded/Failed/Error (Argo often sends no lines with follow=true on finished WFs); true|false to force +# LOG_USE_PYTHON_STREAM=1 # 1 = stream logs via python3+subprocess (default if python3 exists); 0 = bash+curl only +# WAIT_TERMINAL_SECS=600 # max wait for workflow to finish (history test) +# RUNNING_POLL_LIMIT=500 # GET /v1/workflows/running?limit=… when discovering new runs (max 500; see script header comment) +# WAIT_NEW_RUNNING_ATTEMPTS=120 # polls (× WAIT_NEW_RUNNING_SLEEP_SECS) after submit until a new name appears in running +# WAIT_NEW_RUNNING_SLEEP_SECS=2 +# WAIT_AFTER_HIST_SUBMIT_SECS=2 # sleep after POST submit in step 8 (Argo Events / CR creation delay) +# GLOB_PREFIX_LEN=20 # step 9c nameGlob prefix length (narrower pattern when cluster has many webhook-sm* terminals) +# HISTORY_GLOB_TEST_LIMIT=500 # step 9c filtered history limit (must be high enough to reach WF_A in scan order) +# SKIP_ABORT_TEST=true +# SKIP_LONG_HISTORY=true # skip extra runs + history archive inspection +# REQUIRE_ARCHIVED_GCS=true # fail if GET /v1/workflows/{name} has no gs:// links after terminal (needs gcs-artifact-bucket + template log artifacts) +# +# Why RUNNING_POLL_LIMIT matters: the API lists up to `limit` Workflow CRs from Kubernetes, then keeps only +# non-terminal phases. If the first page is mostly finished workflows, the new run may not be in the response +# at all when limit=50, and wait_for_new_running will time out ("no workflow for run …"). + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +GET_TOKEN_SH="${GET_TOKEN_SH:-${SCRIPT_DIR}/horizon-api-get-token.sh}" + +MODULE="${MODULE:-sample}" +TEMPLATE="${TEMPLATE:-sample-smoke-test}" +HORIZON_API_BASE_URL="${HORIZON_API_BASE_URL:-}" +HORIZON_ACCESS_TOKEN="${HORIZON_ACCESS_TOKEN:-}" +KEYCLOAK_CLIENT_SECRET="${KEYCLOAK_CLIENT_SECRET:-}" +LOG_SAMPLE_SECS="${LOG_SAMPLE_SECS:-90}" +LOG_MAX_LINES="${LOG_MAX_LINES:-0}" +LOG_WAIT_POD_SECS="${LOG_WAIT_POD_SECS:-0}" +LOG_FOLLOW_MODE="${LOG_FOLLOW_MODE:-auto}" +WAIT_TERMINAL_SECS="${WAIT_TERMINAL_SECS:-600}" +RUNNING_POLL_LIMIT="${RUNNING_POLL_LIMIT:-500}" +WAIT_NEW_RUNNING_ATTEMPTS="${WAIT_NEW_RUNNING_ATTEMPTS:-120}" +WAIT_NEW_RUNNING_SLEEP_SECS="${WAIT_NEW_RUNNING_SLEEP_SECS:-2}" +WAIT_AFTER_HIST_SUBMIT_SECS="${WAIT_AFTER_HIST_SUBMIT_SECS:-2}" +SKIP_ABORT_TEST="${SKIP_ABORT_TEST:-false}" +SKIP_LONG_HISTORY="${SKIP_LONG_HISTORY:-false}" +REQUIRE_ARCHIVED_GCS="${REQUIRE_ARCHIVED_GCS:-false}" + +die() { echo "error: $*" >&2; exit 1; } + +# Print archivedLogs JSON and a flat list of gs:// URIs from GET /v1/workflows/{name}. +show_archived_gcs_for_workflow() { + local wf="$1" + local json + json="$(api_get "/v1/workflows/${wf}")" + echo "$json" | jq '{name, phase, workflowTemplate, archivedLogs}' + echo "— gs:// URLs (from archivedLogs) —" + echo "$json" | jq -r ' + .archivedLogs + | if . == null then + " (no archivedLogs yet — workflow must be terminal; ensure horizon-api has --gcs-artifact-bucket when Argo omits bucket on artifacts; template should expose log-style GCS outputs e.g. main-logs)" + else + ((.combined // {}).gcsUri // empty | select(length > 0) | " combined: \(.)"), + (.steps // [])[] | select((.gcsUri // "") | length > 0) | " step (\(.displayName // .templateName // "?")): \(.gcsUri)" + end + ' +} + +# Count gs:// lines extractable from archivedLogs object. +count_gs_urls_in_archived_payload() { + local json="$1" + echo "$json" | jq -r ' + .archivedLogs + | if . == null then empty + else + ((.combined // {}).gcsUri // empty), + (.steps // [])[] | .gcsUri // empty + end + | select(test("^gs://")) + ' | wc -l | tr -d ' ' +} + +[[ -n "$HORIZON_API_BASE_URL" ]] || die "set HORIZON_API_BASE_URL (no trailing slash), e.g. https://env.example.com/horizon-api" +if [[ -n "${KEYCLOAK_CLIENT_SECRET}" ]]; then + [[ -f "$GET_TOKEN_SH" ]] || die "missing get-token script: $GET_TOKEN_SH" +elif [[ -z "${HORIZON_ACCESS_TOKEN}" ]]; then + die "set KEYCLOAK_CLIENT_SECRET (CI; token refresh), or HORIZON_ACCESS_TOKEN (human: run ${GET_TOKEN_SH} --device, or paste JWT from Swagger)" +fi + +command -v curl >/dev/null || die "curl required" +command -v jq >/dev/null || die "jq required" +command -v python3 >/dev/null || die "python3 required (for line-buffered live workflow logs; install or use a host with python3)" + +BASE="${HORIZON_API_BASE_URL%/}" + +# When KEYCLOAK_CLIENT_SECRET is set, fetch a new access_token before each HTTP call / log stream (short-lived JWTs). +refresh_bearer_if_configured() { + if [[ -n "${KEYCLOAK_CLIENT_SECRET}" ]]; then + HORIZON_ACCESS_TOKEN="$(bash "$GET_TOKEN_SH")" || die "could not refresh access token (check KEYCLOAK_* and $GET_TOKEN_SH)" + fi +} + +# GET: refresh token, then GET; on 401 refresh once and retry. +api_get() { + local path="$1" + local attempt tmp code + for attempt in 1 2; do + refresh_bearer_if_configured + tmp="$(mktemp)" + code="$(curl -sS -o "$tmp" -w "%{http_code}" \ + -H "Authorization: Bearer ${HORIZON_ACCESS_TOKEN}" \ + -H "Accept: application/json" \ + "${BASE}${path}")" || true + if [[ "$code" == "401" && "$attempt" == "1" && -n "${KEYCLOAK_CLIENT_SECRET}" ]]; then + rm -f "$tmp" + continue + fi + if [[ "$code" != "2"* ]]; then + echo "error: GET ${path} HTTP ${code} $(head -c 400 "$tmp" | tr '\n' ' ')" >&2 + rm -f "$tmp" + return 1 + fi + cat "$tmp" + rm -f "$tmp" + return 0 + done + return 1 +} + +# GET with HTTP code as last line of output (newline + code), for non-2xx assertions. +api_get_raw() { + local path="$1" + local attempt raw + for attempt in 1 2; do + refresh_bearer_if_configured + raw="$(curl -sS -w "\n%{http_code}" \ + -H "Authorization: Bearer ${HORIZON_ACCESS_TOKEN}" \ + -H "Accept: application/json" \ + "${BASE}${path}")" || true + local code + code="$(printf '%s' "$raw" | tail -n 1)" + if [[ "$code" == "401" && "$attempt" == "1" && -n "${KEYCLOAK_CLIENT_SECRET}" ]]; then + continue + fi + printf '%s' "$raw" + return 0 + done +} + +# POST JSON: refresh, post, 401 retry once. +api_post() { + local path="$1" + local body="$2" + local attempt tmp code + for attempt in 1 2; do + refresh_bearer_if_configured + tmp="$(mktemp)" + code="$(curl -sS -o "$tmp" -w "%{http_code}" -X POST \ + -H "Authorization: Bearer ${HORIZON_ACCESS_TOKEN}" \ + -H "Accept: application/json" \ + -H "Content-Type: application/json" \ + -d "${body}" \ + "${BASE}${path}")" || true + if [[ "$code" == "401" && "$attempt" == "1" && -n "${KEYCLOAK_CLIENT_SECRET}" ]]; then + rm -f "$tmp" + continue + fi + if [[ "$code" != "2"* ]]; then + echo "error: POST ${path} HTTP ${code} $(head -c 400 "$tmp" | tr '\n' ' ')" >&2 + rm -f "$tmp" + return 1 + fi + cat "$tmp" + rm -f "$tmp" + return 0 + done + return 1 +} + +# POST with empty JSON body (abort); returns body + trailing line with http code (same as prior script). +api_post_raw() { + local path="$1" + local attempt raw + for attempt in 1 2; do + refresh_bearer_if_configured + raw="$(curl -sS -w "\n%{http_code}" -X POST \ + -H "Authorization: Bearer ${HORIZON_ACCESS_TOKEN}" \ + -H "Accept: application/json" \ + -H "Content-Type: application/json" \ + -d '{}' \ + "${BASE}${path}")" || true + local code + code="$(printf '%s' "$raw" | tail -n 1)" + if [[ "$code" == "401" && "$attempt" == "1" && -n "${KEYCLOAK_CLIENT_SECRET}" ]]; then + continue + fi + printf '%s' "$raw" + return 0 + done +} + +running_names_json() { + api_get "/v1/workflows/running?limit=${RUNNING_POLL_LIMIT}" | jq '[(.items // [])[] | .name]' +} + +# Return names in $running that were not in $before_json (jq array of strings). +pick_new_workflow_name() { + local before_json="$1" + api_get "/v1/workflows/running?limit=${RUNNING_POLL_LIMIT}" | jq -r --argjson before "$before_json" ' + [(.items // [])[] | .name] as $now | + $before as $b | + $now | map(select(. as $n | $b | index($n) | not)) | .[0] // empty + ' +} + +wait_for_new_running() { + local before_json="$1" + local attempts="${2:-$WAIT_NEW_RUNNING_ATTEMPTS}" + local sleep_s="${3:-$WAIT_NEW_RUNNING_SLEEP_SECS}" + local wf="" + for ((i = 0; i < attempts; i++)); do + wf="$(pick_new_workflow_name "$before_json")" + if [[ -n "$wf" ]]; then + echo "$wf" + return 0 + fi + sleep "$sleep_s" + done + return 1 +} + +wait_terminal_phase() { + local wf="$1" + local max_wait="${2:-$WAIT_TERMINAL_SECS}" + local elapsed=0 + while (( elapsed < max_wait )); do + local phase + phase="$(api_get "/v1/workflows/${wf}" | jq -r '.phase // empty')" + case "$phase" in + Succeeded|Failed|Error|Aborted) echo "$phase"; return 0 ;; + esac + sleep 5 + elapsed=$((elapsed + 5)) + done + die "timeout waiting for terminal phase on ${wf}" +} + +# Poll workflow detail until at least one node has a podName (helps Argo log stream produce lines). +wait_for_workflow_pod_hint() { + local wf="$1" + local max_wait="${2:-$LOG_WAIT_POD_SECS}" + local elapsed=0 + if (( max_wait <= 0 )); then + echo "LOG_WAIT_POD_SECS=${max_wait:-0} — opening log stream immediately (live follow; horizon-api uses per-pod streams when workflow is non-terminal)." + return 0 + fi + echo "Waiting up to ${max_wait}s for pod-backed nodes on ${wf} …" + while (( elapsed < max_wait )); do + if api_get "/v1/workflows/${wf}" | jq -e '([(.nodes // [])[] | select(.podName != null and .podName != "")] | length) > 0' >/dev/null 2>&1; then + echo "Pod node(s) present; starting log stream." + return 0 + fi + sleep 2 + elapsed=$((elapsed + 2)) + done + echo "No pod nodes yet; attempting log stream anyway." +} + +# Stream GET /v1/workflows/{wf}/log to stdout (NDJSON lines as they arrive). +# Bash `curl | while read` often buffers; `stdbuf` does not reliably affect static curl binaries. +# Python reads curl stdout with bufsize=0 and flushes every line so you see live logs. +stream_ndjson_logs() { + local wf_name="$1" + local max_secs="${2:-$LOG_SAMPLE_SECS}" + local banner="${3:-}" + local max_lines="${4:-${LOG_MAX_LINES:-0}}" + max_lines="${max_lines:-0}" + + local follow_q="true" + case "${LOG_FOLLOW_MODE}" in + true|1|yes) follow_q="true" ;; + false|0|no) follow_q="false" ;; + auto|"") + local ph + ph="$(api_get "/v1/workflows/${wf_name}" | jq -r '.phase // empty')" + case "$ph" in + Succeeded|Failed|Error|Aborted) + follow_q="false" + echo "log stream: workflow phase=${ph} → follow=false (snapshot; Argo often returns no body with follow=true after completion)" + ;; + *) + follow_q="true" + echo "log stream: workflow phase=${ph:-'(pending)'} → follow=true (live)" + ;; + esac + ;; + *) + die "LOG_FOLLOW_MODE must be auto, true, or false (got: ${LOG_FOLLOW_MODE})" + ;; + esac + + local url="${BASE}/v1/workflows/${wf_name}/log?follow=${follow_q}&container=main" + echo "" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo " LIVE GET /v1/workflows/${wf_name}/log ${banner}" + echo " ${url}" + echo " max-time=${max_secs}s follow=${follow_q} container=main (line-buffered via python3+curl)" + echo " (NDJSON may include {\"heartbeat\":true} every ~30s while waiting on Argo — that means the connection is open.)" + if (( max_lines > 0 )); then + echo " line cap=${max_lines} (then stop reading)" + fi + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + + # Fresh token immediately before a long-lived stream (wait_for_pod may have consumed most of JWT TTL). + refresh_bearer_if_configured + + local rc=0 + set +e + if [[ "${LOG_USE_PYTHON_STREAM:-1}" != "0" ]]; then + _E2E_LOG_URL="${url}" \ + _E2E_LOG_MAX_SECS="${max_secs}" \ + _E2E_LOG_MAX_LINES="${max_lines}" \ + _E2E_LOG_TOKEN="${HORIZON_ACCESS_TOKEN}" \ + python3 - <<'PY' +import os, subprocess, sys + +url = os.environ["_E2E_LOG_URL"] +max_secs = os.environ["_E2E_LOG_MAX_SECS"] +max_lines = int(os.environ.get("_E2E_LOG_MAX_LINES") or "0") +token = os.environ["_E2E_LOG_TOKEN"] + +cmd = [ + "curl", + "-sS", + "-N", + "--no-buffer", + "--http1.1", + "--max-time", + max_secs, + "-H", + "Authorization: Bearer " + token, + "-H", + "Accept: application/x-ndjson", + # Avoid gzip on the stream so intermediaries less often buffer full chunks before decode. + "-H", + "Accept-Encoding: identity", + url, +] +# stderr inherited so curl errors print live; stdout read line-by-line with flush +p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=None, bufsize=0) +if p.stdout is None: + sys.exit(1) +n = 0 +try: + while True: + line = p.stdout.readline() + if not line: + break + sys.stdout.buffer.write(line) + sys.stdout.buffer.flush() + n += 1 + if max_lines > 0 and n >= max_lines: + p.terminate() + break +finally: + p.stdout.close() +rc = p.wait() +sys.exit(rc if isinstance(rc, int) else 1) +PY + rc=$? + unset _E2E_LOG_URL _E2E_LOG_MAX_SECS _E2E_LOG_MAX_LINES _E2E_LOG_TOKEN + else + # Fallback: no python path (should not hit: python3 is required above) + curl -sS -N --http1.1 --max-time "${max_secs}" \ + -H "Authorization: Bearer ${HORIZON_ACCESS_TOKEN}" \ + -H "Accept: application/x-ndjson" \ + "${url}" || rc=$? + fi + set -e + + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo " END GET /v1/workflows/${wf_name}/log (stream helper exit: ${rc})" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "" +} + +echo "== 1) GET /v1/catalog" +echo " Test: Catalog lists modules and workflow templates the API advertises." +echo " Expect: HTTP 200; response includes entry for MODULE=${MODULE} and TEMPLATE=${TEMPLATE} (else script exits)." +CATALOG="$(api_get "/v1/catalog")" +echo "$CATALOG" | jq '{count: (.entries|length), modules: [.entries[].module]|unique}' +echo "$CATALOG" | jq -e --arg m "$MODULE" --arg t "$TEMPLATE" \ + '.entries[] | select(.module==$m and .templateName==$t)' >/dev/null \ + || die "catalog missing module=${MODULE} template=${TEMPLATE} (enable module in Module Manager / sync sample-module)" + +echo "== 2) GET /v1/workflows/running (retention echo)" +echo " Test: Running-workflow list shape and retention hints from the API." +echo " Expect: HTTP 200; JSON has items[] and retention (TTL / explanation echo)." +api_get "/v1/workflows/running" | jq '{item_count: ((.items // [])|length), retention}' + +echo "== 3) Submit ${MODULE}/${TEMPLATE} with parameters" +echo " Test: POST submit creates a new workflow instance (Argo CR) in the cluster." +echo " Expect: HTTP 2xx from submit; a new name appears under GET /v1/workflows/running within the poll window." +STAMP="$(date -u +%Y%m%dT%H%M%SZ)" +NOTE="e2e-smoke-${STAMP}" +SUBMIT_BODY="$(jq -n \ + --arg env "e2e" \ + --arg bid "build-${STAMP}" \ + --arg note "$NOTE" \ + '{parameters: {sampleEnv: $env, sampleBuildId: $bid, sampleNote: $note}}')" +BEFORE_JSON="$(running_names_json)" +echo "Submit body: $SUBMIT_BODY" +api_post "/v1/modules/${MODULE}/workflowTemplates/${TEMPLATE}/submit" "$SUBMIT_BODY" | jq . +echo "Waiting for new workflow in /v1/workflows/running …" +WF="$(wait_for_new_running "$BEFORE_JSON")" || die "no new running workflow (check Argo Events webhook + Sensor)" +echo "Picked workflow name: ${WF}" + +echo "== 4) GET /v1/workflows/{name}/log — live NDJSON (opens early; build+deploy horizon-api with dynamic per-pod follow)" +echo " Test: Combined log stream for the submitted workflow (NDJSON; optional heartbeats while waiting)." +echo " Expect: Stream opens (python3+curl); no hard failure — lines may be sparse depending on phase and Argo." +wait_for_workflow_pod_hint "${WF}" +stream_ndjson_logs "${WF}" "${LOG_SAMPLE_SECS}" "(abort path — before abort)" + +echo "== 5) GET /v1/workflows/{name} (snapshot after log sample)" +echo " Test: Workflow detail DTO after the log sample (phase, nodes, timestamps)." +echo " Expect: HTTP 200; JSON includes name, phase, workflowTemplate, and nodes count." +api_get "/v1/workflows/${WF}" | jq '{name, phase, workflowTemplate, startedAt, finishedAt, node_count: (.nodes|length)}' + +echo "== 5b) Wait for terminal phase (archivedLogs with gs:// appears after workflow finishes)" +echo " Test: Workflow reaches a terminal API phase (Succeeded / Failed / Error / Aborted)." +echo " Expect: Phase becomes terminal within WAIT_TERMINAL_SECS, or script was already terminal." +CUR_PHASE="$(api_get "/v1/workflows/${WF}" | jq -r '.phase // empty')" +if [[ "$CUR_PHASE" != "Succeeded" && "$CUR_PHASE" != "Failed" && "$CUR_PHASE" != "Error" && "$CUR_PHASE" != "Aborted" ]]; then + PH="$(wait_terminal_phase "$WF")" + echo "Terminal phase: ${PH}" +else + echo "Already terminal: ${CUR_PHASE}" +fi + +echo "== 5c) GET /v1/workflows/{name} — archivedLogs + gs:// list" +echo " Test: Detail payload includes archivedLogs when the cluster/template exposes GCS log artifacts." +echo " Expect: HTTP 200; print archivedLogs structure; if REQUIRE_ARCHIVED_GCS=true at least one gs:// URI or script dies." +show_archived_gcs_for_workflow "${WF}" +if [[ "$REQUIRE_ARCHIVED_GCS" == "true" ]]; then + DET_JSON="$(api_get "/v1/workflows/${WF}")" + n="$(count_gs_urls_in_archived_payload "$DET_JSON")" + if [[ "${n:-0}" -lt 1 ]]; then + die "REQUIRE_ARCHIVED_GCS=true but no gs:// URLs in archivedLogs for ${WF} (set gitops horizon-api gcsArtifactBucket; confirm template writes log artifacts to GCS)" + fi + echo "REQUIRE_ARCHIVED_GCS: OK (${n} gs:// URI(s))" +fi + +if [[ "$SKIP_ABORT_TEST" == "true" ]]; then + echo "== 6) SKIP_ABORT_TEST=true — waiting for workflow to finish instead of abort" + echo " Test: Abort path skipped; workflow may still be running until it finishes naturally." + echo " Expect: wait_terminal_phase returns a terminal phase (same timeout semantics as elsewhere)." + PH="$(wait_terminal_phase "$WF")" + echo "Terminal phase: ${PH}" +else + echo "== 6) POST /v1/workflows/{name}/abort" + echo " Test: Graceful shutdown request for a non-terminal workflow (horizon-api patches spec.shutdown)." + echo " Expect: HTTP 202 Accepted; JSON {\"status\":\"aborting\"} (then brief sleep before second call)." + raw="$(api_post_raw "/v1/workflows/${WF}/abort")" + code="$(printf '%s' "$raw" | tail -n 1)" + body="$(printf '%s' "$raw" | sed '$d')" + echo "HTTP ${code}" + echo "$body" | jq . 2>/dev/null || echo "$body" + sleep 5 + echo "== 7) Second abort (expect 409 if already terminal)" + echo " Test: Idempotence — second abort when workflow is already terminal." + echo " Expect: HTTP 409 Conflict (or still success if cluster lags); code printed for inspection." + raw="$(api_post_raw "/v1/workflows/${WF}/abort")" + code2="$(printf '%s' "$raw" | tail -n 1)" + echo "HTTP ${code2}" +fi + +if [[ "$SKIP_LONG_HISTORY" == "true" ]]; then + echo "== 8–10) SKIP_LONG_HISTORY=true — done" + echo " Test: Long-running history block (extra submits, filtered history, gs:// sweep) skipped." + echo " Expect: Clean exit 0 after steps 1–7 (or 1–6 when SKIP_ABORT_TEST)." + exit 0 +fi + +# Populated in run_and_wait (step 8) for history filter assertions. +E2E_HIST_WORKFLOWS=() + +echo "== 8) Submit two more workflows; wait until terminal (for history + archivedLogs)" +echo " Test: Two independent submit→run→terminal sequences (names recorded for step 9 filters)." +echo " Expect: Two new workflows reach terminal phase; E2E_HIST_WORKFLOWS has two names." +run_and_wait() { + local suffix="$1" + local before + before="$(running_names_json)" + local body + body="$(jq -n \ + --arg env "e2e" \ + --arg bid "hist-${suffix}" \ + --arg note "e2e-history-${suffix}" \ + '{parameters: {sampleEnv: $env, sampleBuildId: $bid, sampleNote: $note}}')" + api_post "/v1/modules/${MODULE}/workflowTemplates/${TEMPLATE}/submit" "$body" >/dev/null + if [[ "${WAIT_AFTER_HIST_SUBMIT_SECS}" != "0" ]]; then + sleep "${WAIT_AFTER_HIST_SUBMIT_SECS}" + fi + local wname + wname="$(wait_for_new_running "$before")" || die "no workflow for run ${suffix} (check RUNNING_POLL_LIMIT, Argo Events, cluster workflows namespace; see script header)" + E2E_HIST_WORKFLOWS+=("$wname") + echo "Started ${wname}." + wait_for_workflow_pod_hint "${wname}" + stream_ndjson_logs "${wname}" "${LOG_SAMPLE_SECS}" "(history run ${suffix} — while still running)" + echo " ${wname}: waiting for terminal…" + local ph + ph="$(wait_terminal_phase "$wname")" + echo " ${wname} → ${ph}" +} + +run_and_wait "a-$(date +%s)" +run_and_wait "b-$(date +%s)" + +echo "== 9) GET /v1/workflows/history (pagination + retention)" +echo " Test: Unfiltered history list uses Kubernetes-style limit + optional continue token." +echo " Expect: HTTP 200; items are terminal rows; retention echoed; continue may be non-empty if more pages exist." +api_get "/v1/workflows/history?limit=15" | jq '{retention, continue, names: [(.items // [])[].name], phases: [(.items // [])[].phase]}' + +echo "== 9b) Filtered history includes truncated + scanned; comma-separated phase" +echo " Test: Filtered history scan (phase query); response must include scan metadata." +echo " Expect: truncated (bool) and scanned (number); items length ≤ limit; jq assertions pass." +api_get "/v1/workflows/history?limit=8&phase=succeeded,failed,error,aborted" \ + | jq -e ' + (.truncated | type) == "boolean" + and ((.scanned | type) == "number") + and ((.items // []) | length) <= 8 + ' >/dev/null \ + || die "filtered history must expose truncated, scanned, and respect limit" +api_get "/v1/workflows/history?limit=8&phase=succeeded" \ + | jq '{truncated, scanned, count: ((.items // []) | length), sample: [(.items // [])[:3][].name]}' + +WF_A="${E2E_HIST_WORKFLOWS[0]:-}" +WF_B="${E2E_HIST_WORKFLOWS[1]:-}" +[[ -n "$WF_A" && -n "$WF_B" ]] || die "expected two workflow names from step 8 for filter checks" + +echo "== 9c) History filters — nameRegex (exact) and nameGlob (prefix*)" +echo " Test: nameRegex=^wf$ per known workflow; nameGlob=prefix* must still return WF_A." +echo " Expect: Each exact regex returns its workflow. Glob uses a long prefix + high limit so a broad pattern (e.g. webhook-sm*) cannot fill the result cap before the scan reaches WF_A." +history_exact_regex_q() { + local name="$1" + local esc + esc="$(printf '%s' "$name" | sed 's/[.^$*+?()[\]{}|]/\\&/g')" + jq -nr --arg r "^${esc}\$" '$r|@uri' +} +for w in "$WF_A" "$WF_B"; do + RX_Q="$(history_exact_regex_q "$w")" + api_get "/v1/workflows/history?limit=10&nameRegex=${RX_Q}" \ + | jq -e --arg n "$w" '[(.items // [])[].name] | index($n) != null' >/dev/null \ + || die "nameRegex exact match should return workflow ${w}" +done + +# Filtered history stops after `limit` matches; a short prefix + busy cluster can return 20 other +# terminal workflows matching e.g. webhook-sm* before WF_A appears in list order — use a longer prefix +# and API max limit for this assertion. +GLOB_PREFIX_LEN="${GLOB_PREFIX_LEN:-20}" +HISTORY_GLOB_TEST_LIMIT="${HISTORY_GLOB_TEST_LIMIT:-500}" +if [[ ${#WF_A} -gt "$GLOB_PREFIX_LEN" ]]; then + GLOB_PREFIX="${WF_A:0:GLOB_PREFIX_LEN}" +else + # Keep at least one character for '*' so we still exercise glob (not exact equality only). + GLOB_PREFIX="${WF_A:0:$((${#WF_A} > 1 ? ${#WF_A} - 1 : 1))}" +fi +GLOB_Q="$(jq -nr --arg g "${GLOB_PREFIX}*" '$g|@uri')" +api_get "/v1/workflows/history?limit=${HISTORY_GLOB_TEST_LIMIT}&nameGlob=${GLOB_Q}" \ + | jq -e --arg n "$WF_A" '[(.items // [])[].name] | index($n) != null' >/dev/null \ + || die "nameGlob ${GLOB_PREFIX}* should include ${WF_A} (increase HISTORY_GLOB_TEST_LIMIT or GLOB_PREFIX_LEN if cluster had many matches before this workflow in scan order)" + +echo "== 9d) History filters — startedAt / finishedAt bounds" +echo " Test: startedAfter / startedBefore / finishedAfter / finishedBefore (RFC3339) with nameRegex pin." +echo " Expect: Boundary times still match WF_A; impossible future bounds yield zero items for that name." +STARTED="$(api_get "/v1/workflows/${WF_A}" | jq -r '.startedAt // empty')" +FINISHED="$(api_get "/v1/workflows/${WF_A}" | jq -r '.finishedAt // empty')" +[[ -n "$STARTED" && -n "$FINISHED" ]] || die "terminal workflow ${WF_A} should have startedAt and finishedAt" +SA="$(jq -nr --arg s "$STARTED" '$s|@uri')" +SB="$(jq -nr --arg s "$FINISHED" '$s|@uri')" +RX_A_Q="$(history_exact_regex_q "$WF_A")" +api_get "/v1/workflows/history?limit=5&nameRegex=${RX_A_Q}&startedAfter=${SA}" \ + | jq -e --arg n "$WF_A" '[(.items // [])[].name] | index($n) != null' >/dev/null \ + || die "startedAfter=workflow startedAt should still match (>=)" +api_get "/v1/workflows/history?limit=5&nameRegex=${RX_A_Q}&finishedBefore=${SB}" \ + | jq -e --arg n "$WF_A" '[(.items // [])[].name] | index($n) != null' >/dev/null \ + || die "finishedBefore=workflow finishedAt should still match (<=)" +api_get "/v1/workflows/history?limit=5&nameRegex=${RX_A_Q}&startedAfter=2099-12-31T23:59:59Z" \ + | jq -e '((.items // []) | length) == 0' >/dev/null \ + || die "startedAfter in the future should yield no rows for this workflow" +api_get "/v1/workflows/history?limit=5&nameRegex=${RX_A_Q}&finishedAfter=2099-12-31T23:59:59Z" \ + | jq -e '((.items // []) | length) == 0' >/dev/null \ + || die "finishedAfter in the future should yield no rows for this workflow" + +echo "== 9e) History filters — validation errors (expect HTTP 400)" +echo " Test: Bad regex, inverted started/finished windows, continue+filter combination." +echo " Expect: HTTP 400 for invalid nameRegex and inconsistent time bounds; 400 when continue paired with phase if continue token exists." +raw_bad_rx="$(api_get_raw '/v1/workflows/history?limit=3&nameRegex=(unclosed')" +code_bad_rx="$(printf '%s' "$raw_bad_rx" | tail -n 1)" +[[ "$code_bad_rx" == "400" ]] || die "invalid nameRegex should return 400 (got ${code_bad_rx})" + +raw_range="$(api_get_raw '/v1/workflows/history?limit=3&startedAfter=2099-01-01T00:00:00Z&startedBefore=2000-01-01T00:00:00Z')" +code_range="$(printf '%s' "$raw_range" | tail -n 1)" +[[ "$code_range" == "400" ]] || die "startedAfter > startedBefore should return 400 (got ${code_range})" + +raw_frange="$(api_get_raw '/v1/workflows/history?limit=3&finishedAfter=2099-01-01T00:00:00Z&finishedBefore=2000-01-01T00:00:00Z')" +code_frange="$(printf '%s' "$raw_frange" | tail -n 1)" +[[ "$code_frange" == "400" ]] || die "finishedAfter > finishedBefore should return 400 (got ${code_frange})" + +CONT="$(api_get "/v1/workflows/history?limit=1" | jq -r '.continue // empty')" +if [[ -n "$CONT" ]]; then + CONT_Q="$(jq -nr --arg c "$CONT" '$c|@uri')" + raw_cont="$(api_get_raw "/v1/workflows/history?limit=5&phase=succeeded&continue=${CONT_Q}")" + code_cont="$(printf '%s' "$raw_cont" | tail -n 1)" + [[ "$code_cont" == "400" ]] || die "continue + filter should return 400 (got ${code_cont})" +else + echo " (skip continue+filter: empty continue from history?limit=1 — not enough pages)" +fi + +echo "== 10) Recent history — archivedLogs summary + flat gs:// lines" +echo " Test: Unfiltered history page shows archivedLogs and gs:// URIs when templates write log artifacts." +echo " Expect: HTTP 200; jq prints per-item archive summary and a flat list of gs:// lines (may be empty)." +HIST_JSON="$(api_get "/v1/workflows/history?limit=20")" +echo "$HIST_JSON" | jq '.items[:8] | map({ + name, + phase, + workflowTemplate, + combined: (.archivedLogs.combined.gcsUri // null), + stepLogUris: [(.archivedLogs.steps // [])[] | select((.gcsUri // "") | test("^gs://")) | {template: .templateName, display: .displayName, gcsUri: .gcsUri}] +})' +echo "— All gs:// in this history page —" +echo "$HIST_JSON" | jq -r ' + (.items // [])[] | . as $i | $i.archivedLogs + | if . == null then empty + else + ((.combined // {}).gcsUri // empty | "\($i.name) combined: \(.)"), + (.steps // [])[] | (.gcsUri // empty) | select(test("^gs://")) | "\($i.name) step: \(.)" + end +' + +echo "Done." diff --git a/tools/scripts/horizon-api/horizon-api-get-token.sh b/tools/scripts/horizon-api/horizon-api-get-token.sh new file mode 100755 index 00000000..e988c65b --- /dev/null +++ b/tools/scripts/horizon-api/horizon-api-get-token.sh @@ -0,0 +1,230 @@ +#!/usr/bin/env bash + +# Copyright (c) 2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# Description: +# Print a Keycloak access token for Horizon API (Swagger "Authorize" / curl Bearer). +# +# Two modes: +# +# 1) CI / automation — confidential client `horizon-api-ci` + client_credentials +# export KEYCLOAK_CLIENT_SECRET='…' +# ./tools/scripts/horizon-api/horizon-api-get-token.sh +# +# 2) Humans — public client `horizon-api` + OAuth 2.0 device authorization (no secret; +# open the verification URL in a browser and approve with your user account) +# ./tools/scripts/horizon-api/horizon-api-get-token.sh --device +# # or: KEYCLOAK_AUTH_MODE=device ./tools/scripts/horizon-api/horizon-api-get-token.sh +# +# Optional env: +# KEYCLOAK_BASE default https://sbx.horizon-sdv.com/auth +# KEYCLOAK_REALM default horizon +# KEYCLOAK_CLIENT_ID default horizon-api-ci (credentials) or horizon-api (device) +# KEYCLOAK_AUTH_MODE client_credentials | device (overrides auto if set) +# KEYCLOAK_CLIENT_SECRET required for client_credentials (default mode when set and not --device) +# KEYCLOAK_DEVICE_SCOPE default "openid" (space-separated scopes for device flow) +set -euo pipefail + +KEYCLOAK_BASE="${KEYCLOAK_BASE:-https://sbx.horizon-sdv.com/auth}" +KEYCLOAK_REALM="${KEYCLOAK_REALM:-horizon}" +KEYCLOAK_DEVICE_SCOPE="${KEYCLOAK_DEVICE_SCOPE:-openid}" +KEYCLOAK_AUTH_MODE="${KEYCLOAK_AUTH_MODE:-}" + +AUTH_MODE_CLI="" +for arg in "$@"; do + case "$arg" in + --device | -d) AUTH_MODE_CLI="device" ;; + --client-credentials | --ci) AUTH_MODE_CLI="client_credentials" ;; + -h | --help) + echo "Usage: $0 [--device|-d] [--client-credentials|--ci]" + echo " Default: client_credentials if KEYCLOAK_CLIENT_SECRET is set, else device (public client horizon-api)." + echo " Env: KEYCLOAK_AUTH_MODE=client_credentials|device, KEYCLOAK_CLIENT_ID, KEYCLOAK_BASE, KEYCLOAK_REALM" + exit 0 + ;; + esac +done + +if [[ -n "${AUTH_MODE_CLI}" ]]; then + AUTH_MODE="${AUTH_MODE_CLI}" +elif [[ -n "${KEYCLOAK_AUTH_MODE}" ]]; then + AUTH_MODE="${KEYCLOAK_AUTH_MODE}" +elif [[ -n "${KEYCLOAK_CLIENT_SECRET:-}" ]]; then + AUTH_MODE="client_credentials" +else + AUTH_MODE="device" +fi + +if [[ "$AUTH_MODE" == "client_credentials" ]]; then + KEYCLOAK_CLIENT_ID="${KEYCLOAK_CLIENT_ID:-horizon-api-ci}" + KEYCLOAK_CLIENT_SECRET="${KEYCLOAK_CLIENT_SECRET:-}" + if [[ -z "${KEYCLOAK_CLIENT_SECRET}" ]]; then + echo "error: client_credentials requires KEYCLOAK_CLIENT_SECRET (Keycloak → Clients → ${KEYCLOAK_CLIENT_ID} → Credentials)." >&2 + echo " For human login without a secret, run: $0 --device" >&2 + exit 1 + fi +else + KEYCLOAK_CLIENT_ID="${KEYCLOAK_CLIENT_ID:-horizon-api}" +fi + +TOKEN_URL="${KEYCLOAK_BASE%/}/realms/${KEYCLOAK_REALM}/protocol/openid-connect/token" +AUTH_BASE="${KEYCLOAK_BASE%/}/realms/${KEYCLOAK_REALM}/protocol/openid-connect" + +# Public client horizon-api sets pkce.code.challenge.method=S256 in Keycloak — device authorization +# must include PKCE (RFC 7636) on /auth/device and send code_verifier on the token request. +pkce_verifier_and_challenge() { + if command -v openssl >/dev/null 2>&1; then + local v + v="$(openssl rand -base64 96 | tr -d '\n' | tr '+/' '-_' | tr -d '=')" + v="${v:0:96}" + [[ ${#v} -ge 43 ]] || { + echo "error: could not build PKCE verifier (openssl)." >&2 + return 1 + } + PKCE_VERIFIER="$v" + PKCE_CHALLENGE="$(printf '%s' "${PKCE_VERIFIER}" | openssl dgst -binary -sha256 | openssl base64 | tr -d '\n' | tr '+/' '-_' | tr -d '=')" + return 0 + fi + if command -v python3 >/dev/null 2>&1; then + local _line1 _line2 + IFS=$'\n' read -r _line1 _line2 < <(python3 - <<'PY' +import base64, hashlib, secrets +v = base64.urlsafe_b64encode(secrets.token_bytes(32)).decode("ascii").rstrip("=") +c = base64.urlsafe_b64encode(hashlib.sha256(v.encode("ascii")).digest()).decode("ascii").rstrip("=") +print(v) +print(c) +PY +) + PKCE_VERIFIER="${_line1}" + PKCE_CHALLENGE="${_line2}" + return 0 + fi + echo "error: need openssl or python3 for PKCE (device flow with S256 client setting)." >&2 + return 1 +} + +device_flow() { + pkce_verifier_and_challenge || exit 1 + local dev_resp device_code interval expires_in verification_uri user_code verification_uri_complete + dev_resp="$(curl -sS -X POST "${AUTH_BASE}/auth/device" \ + -H 'Content-Type: application/x-www-form-urlencoded' \ + --data-urlencode "client_id=${KEYCLOAK_CLIENT_ID}" \ + --data-urlencode "scope=${KEYCLOAK_DEVICE_SCOPE}" \ + --data-urlencode 'code_challenge_method=S256' \ + --data-urlencode "code_challenge=${PKCE_CHALLENGE}")" + + if command -v jq >/dev/null 2>&1; then + device_code="$(printf '%s' "${dev_resp}" | jq -r '.device_code // empty')" + verification_uri="$(printf '%s' "${dev_resp}" | jq -r '.verification_uri // empty')" + user_code="$(printf '%s' "${dev_resp}" | jq -r '.user_code // empty')" + verification_uri_complete="$(printf '%s' "${dev_resp}" | jq -r '.verification_uri_complete // empty')" + interval="$(printf '%s' "${dev_resp}" | jq -r '.interval // 5')" + expires_in="$(printf '%s' "${dev_resp}" | jq -r '.expires_in // 600')" + err="$(printf '%s' "${dev_resp}" | jq -r '.error_description // .error // empty')" + else + echo "error: jq required for device flow (install jq)." >&2 + exit 1 + fi + + if [[ -z "${device_code}" ]]; then + echo "error: device authorization failed: ${err:-${dev_resp}}" >&2 + exit 1 + fi + + echo "" >&2 + echo "Open this URL in a browser (sign in with your Horizon user):" >&2 + if [[ -n "${verification_uri_complete}" ]]; then + echo " ${verification_uri_complete}" >&2 + else + echo " ${verification_uri}" >&2 + echo " Enter code: ${user_code}" >&2 + fi + echo "" >&2 + echo "Waiting for authorization (expires in ${expires_in}s)…" >&2 + + local end=$((SECONDS + expires_in)) + while (( SECONDS < end )); do + sleep "${interval}" + local tok_resp + tok_resp="$(curl -sS -X POST "${TOKEN_URL}" \ + -H 'Content-Type: application/x-www-form-urlencoded' \ + --data-urlencode 'grant_type=urn:ietf:params:oauth:grant-type:device_code' \ + --data-urlencode "client_id=${KEYCLOAK_CLIENT_ID}" \ + --data-urlencode "device_code=${device_code}" \ + --data-urlencode "code_verifier=${PKCE_VERIFIER}")" + + local terr ttoken + terr="$(printf '%s' "${tok_resp}" | jq -r '.error // empty')" + ttoken="$(printf '%s' "${tok_resp}" | jq -r '.access_token // empty')" + + case "${terr}" in + authorization_pending) continue ;; + slow_down) + interval=$((interval + 5)) + continue + ;; + access_denied | expired_token) + echo "error: device authorization ${terr}: $(printf '%s' "${tok_resp}" | jq -r '.error_description // empty')" >&2 + exit 1 + ;; + "") + if [[ -n "${ttoken}" ]]; then + printf '%s\n' "${ttoken}" + return 0 + fi + continue + ;; + esac + + if [[ -n "${terr}" && "${terr}" != "authorization_pending" && "${terr}" != "slow_down" ]]; then + local msg + msg="$(printf '%s' "${tok_resp}" | jq -r '.error_description // .error')" + echo "error: token poll failed: ${msg}" >&2 + exit 1 + fi + done + echo "error: device authorization timed out (re-run $0 --device)." >&2 + exit 1 +} + +client_credentials_flow() { + local resp token err + resp="$(curl -sS -X POST "${TOKEN_URL}" \ + -d 'grant_type=client_credentials' \ + -d "client_id=${KEYCLOAK_CLIENT_ID}" \ + -d "client_secret=${KEYCLOAK_CLIENT_SECRET}")" + + if command -v jq >/dev/null 2>&1; then + token="$(printf '%s' "${resp}" | jq -r '.access_token // empty')" + err="$(printf '%s' "${resp}" | jq -r '(.error_description // .error) // empty')" + else + token="$(printf '%s' "${resp}" | python3 -c 'import sys, json; d=json.load(sys.stdin); print(d.get("access_token") or "")')" + err="$(printf '%s' "${resp}" | python3 -c 'import sys, json; d=json.load(sys.stdin); print(d.get("error_description") or d.get("error") or "")')" + fi + + if [[ -z "${token}" ]]; then + echo "error: no access_token in response: ${err:-${resp}}" >&2 + exit 1 + fi + printf '%s\n' "${token}" +} + +case "${AUTH_MODE}" in + device) device_flow ;; + client_credentials) client_credentials_flow ;; + *) + echo "error: unknown KEYCLOAK_AUTH_MODE / mode: ${AUTH_MODE} (use client_credentials or device)" >&2 + exit 1 + ;; +esac diff --git a/workloads/android/pipelines/builds/aaos_abfs_builder/Jenkinsfile b/workloads/android/pipelines/builds/aaos_abfs_builder/Jenkinsfile index 35f5d747..6760d4a0 100644 --- a/workloads/android/pipelines/builds/aaos_abfs_builder/Jenkinsfile +++ b/workloads/android/pipelines/builds/aaos_abfs_builder/Jenkinsfile @@ -1,4 +1,4 @@ -// Copyright (c) 2025 Accenture, All Rights Reserved. +// Copyright (c) 2025-2026 Accenture, All Rights Reserved. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -30,7 +30,7 @@ pipeline { kind: Pod metadata: annotations: - cluster-autoscaler.kubernetes.io/safe-to-evict: "true" + cluster-autoscaler.kubernetes.io/safe-to-evict: "false" labels: aaos_abfs_pod: "true" spec: @@ -77,7 +77,7 @@ pipeline { kind: Pod metadata: annotations: - cluster-autoscaler.kubernetes.io/safe-to-evict: "true" + cluster-autoscaler.kubernetes.io/safe-to-evict: "false" labels: aaos_abfs_pod: "true" spec: @@ -136,7 +136,10 @@ pipeline { stages { stage ('Start VM Instance') { - agent { kubernetes { yaml "${POD_TEMPLATE}" } } + agent { kubernetes { + yamlMergeStrategy merge() + yaml "${POD_TEMPLATE}" + } } stages { stage ('Initialise') { when { @@ -208,6 +211,76 @@ pipeline { ./workloads/android/pipelines/builds/aaos_builder/aaos_build.sh ''' } + archiveArtifacts artifacts: 'aaos-build*.log', followSymlinks: false, onlyIfSuccessful: false, allowEmptyArchive: true + } + } + } + + stage ('AI Review'){ + when { + allOf { + expression { env.AAOS_LUNCH_TARGET } + expression { env.ENABLE_GEMINI_AI_ASSISTANT == 'true' } + expression { currentBuild.currentResult == 'FAILURE' } + } + } + steps { + container(name: 'builder') { + catchError(buildResult: 'FAILURE', stageResult: 'FAILURE') { + sh ''' + set +ex + if [ "${ABFS_CACHED_BUILD}" = "true" ]; then export GEMINI_ANALYSIS_PATH="/aaos-cache/abfs"; else export GEMINI_ANALYSIS_PATH="/abfs"; fi + echo "Analysis path: ${GEMINI_ANALYSIS_PATH}" + # Copy artifacts so Gemini can analyse the source in situ (avoid workspace security issues). + cp -f aaos-build*.* "${GEMINI_ANALYSIS_PATH%/}/" || true + cd "${GEMINI_ANALYSIS_PATH%/}" || true + # AI Review: reuse the same artifact name as AAOS triage (aaos-build.log.tail). ABFS uses a full + # copy of aaos-build.log here so early failures (e.g. #error) are not dropped above a short tail. + # skills.yaml: grep-first on huge logs; AAOS jobs still use bounded tail / prepare script. + [ -f aaos-build.log ] && cp -f aaos-build.log aaos-build.log.tail || true + export GEMINI_PROMPT_FILE="${WORKSPACE}/workloads/android/pipelines/builds/aaos_builder/prompt/sequenced/step1_triage.txt" + export GEMINI_PROMPT_FILE_2="${WORKSPACE}/workloads/android/pipelines/builds/aaos_builder/prompt/sequenced/step2_rca.txt" + export GEMINI_PROMPT_FILE_3="${WORKSPACE}/workloads/android/pipelines/builds/aaos_builder/prompt/sequenced/step3_fixes.txt" + export GEMINI_STEP2_PRIOR_CONTEXT_BYTES="${GEMINI_STEP2_PRIOR_CONTEXT_BYTES:-131072}" + export GEMINI_PREVIEW_FEATURES="${GEMINI_PREVIEW_FEATURES}" + [ "$GEMINI_LOCATION_GLOBAL" = "true" ] && export GOOGLE_CLOUD_LOCATION="global" || export GOOGLE_CLOUD_LOCATION="${CLOUD_REGION}" + export GOOGLE_CLOUD_PROJECT="${CLOUD_PROJECT}" + export GEMINI_COMMAND_LINE="${GEMINI_COMMAND_LINE}" + "${WORKSPACE}"/workloads/common/agentic-ai/gemini/gemini_initialise.sh + timeout "${GEMINI_AI_EXECUTION_TIMEOUT}"h "${WORKSPACE}"/workloads/common/agentic-ai/gemini/gemini_analysis.sh + GEMINI_EXIT_CODE=$? + + echo "Check artifacts create in gemini-assist" + ls -la ${GEMINI_ANALYSIS_PATH}/gemini-assist || true + + # Restore order to cache/PV and workspace. + # Avoid gemini deafness and it applying changes, cleanup! + repo forall -c 'git checkout -- .; git clean -xfd;' >/dev/null 2>&1 + rm -f aaos-build*.* + + cd - || true + bash -c 'source \"${WORKSPACE}/workloads/common/agentic-ai/gemini/gemini_environment.sh\" && \ + move_gemini_artifacts \"${GEMINI_ANALYSIS_PATH%/}\" \"${WORKSPACE}\"' || true + + find . -type f -name "headless*.json" -size 0 -delete + + # Fail stage if gemini-client-error.zip exists (CLI error even when exit code was 0) + if [ -f "${GEMINI_ANALYSIS_PATH}/gemini-client-error.zip" ] || [ -f "${WORKSPACE}/gemini-client-error.zip" ]; then + echo "ERROR: Gemini AI assistant reported errors (gemini-client-error.zip present)" + GEMINI_EXIT_CODE=1 + fi + + set -e + exit ${GEMINI_EXIT_CODE} + ''' + } + archiveArtifacts artifacts: 'gemini-assist/*', followSymlinks: false, onlyIfSuccessful: false, allowEmptyArchive: true + archiveArtifacts artifacts: 'headless_output*.json', followSymlinks: false, onlyIfSuccessful: false, allowEmptyArchive: false + script { + if (fileExists('gemini-client-error.zip')) { + archiveArtifacts artifacts: 'gemini-client-error.zip', followSymlinks: false, onlyIfSuccessful: false, allowEmptyArchive: true + } + } } } } @@ -217,24 +290,35 @@ pipeline { allOf { expression { env.AAOS_LUNCH_TARGET } expression { env.AAOS_ARTIFACT_STORAGE_SOLUTION } - expression { currentBuild.currentResult == 'SUCCESS' } + anyOf { + expression { currentBuild.currentResult == 'SUCCESS' } + expression { env.ENABLE_GEMINI_AI_ASSISTANT == 'true' } + } } } steps { container(name: 'builder') { catchError(buildResult: 'FAILURE', stageResult: 'FAILURE') { + script { + env.AAOS_BUILD_STAGE_FAILED = (currentBuild.currentResult != 'SUCCESS') ? 'true' : 'false' + } sh ''' - AAOS_LUNCH_TARGET="${AAOS_LUNCH_TARGET}" \ - AAOS_REVISION="${AAOS_REVISION}" \ - ANDROID_VERSION="${ANDROID_VERSION}" \ - ABFS_BUILDER="true" \ - ./workloads/android/pipelines/builds/aaos_builder/aaos_avd_sdk.sh || true + if [ "${AAOS_BUILD_STAGE_FAILED}" != "true" ]; then + AAOS_LUNCH_TARGET="${AAOS_LUNCH_TARGET}" \ + AAOS_REVISION="${AAOS_REVISION}" \ + ANDROID_VERSION="${ANDROID_VERSION}" \ + ABFS_BUILDER="true" \ + ./workloads/android/pipelines/builds/aaos_builder/aaos_avd_sdk.sh || true + fi + AAOS_LUNCH_TARGET="${AAOS_LUNCH_TARGET}" \ AAOS_ARTIFACT_ROOT_NAME="${ANDROID_BUILD_BUCKET_ROOT_NAME}" \ AAOS_ARTIFACT_STORAGE_SOLUTION="${AAOS_ARTIFACT_STORAGE_SOLUTION}" \ ABFS_BUILDER="true" \ STORAGE_BUCKET_DESTINATION="${STORAGE_BUCKET_DESTINATION}" \ STORAGE_LABELS="${STORAGE_LABELS}" \ + ENABLE_GEMINI_AI_ASSISTANT="${ENABLE_GEMINI_AI_ASSISTANT}" \ + AAOS_BUILD_STAGE_FAILED="${AAOS_BUILD_STAGE_FAILED}" \ ./workloads/android/pipelines/builds/aaos_builder/aaos_storage.sh ''' archiveArtifacts artifacts: '*artifacts*.txt', followSymlinks: false, onlyIfSuccessful: false, allowEmptyArchive: true diff --git a/workloads/android/pipelines/builds/aaos_abfs_builder/groovy/job.groovy b/workloads/android/pipelines/builds/aaos_abfs_builder/groovy/job.groovy index b25ad253..a120b3d5 100644 --- a/workloads/android/pipelines/builds/aaos_abfs_builder/groovy/job.groovy +++ b/workloads/android/pipelines/builds/aaos_abfs_builder/groovy/job.groovy @@ -1,4 +1,4 @@ -// Copyright (c) 2025 Accenture, All Rights Reserved. +// Copyright (c) 2025-2026 Accenture, All Rights Reserved. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -11,6 +11,9 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. + +def model = ('${GEMINI_MODEL}' && '${GEMINI_MODEL}' != 'null' && !'${GEMINI_MODEL}'.contains('${')) ? "--model ${'${GEMINI_MODEL}'} " : "" + pipelineJob('Android/Builds/AAOS Builder ABFS') { description("""

Android Build Filesystem Builder

@@ -23,7 +26,8 @@ pipelineJob('Android/Builds/AAOS Builder ABFS') {
  • CTS development, reference Compatibility Test Suite and CTS Trade Federataion.
  • Users have the ability to retain the ABFS cache and ABFS source mount point in persistent storage, this may improve build times. Simply enable ABFS_CACHED_BUILD and a persistent volume will be created to store the cache and source mount path.

    -

    For CTS development builds, select a cuttlefish variety of AAOS_LUNCH_TARGET and enable AAOS_BUILD_CTS to build and create android-cts.zip for use in the CTS Execution test job.

    +

    For CTS development builds, select a cuttlefish variety of AAOS_LUNCH_TARGET and enable AAOS_BUILD_CTS to build and create android-cts.zip for use in the CTS Execution test job. +
    Note: only the test suite will be built when this option is selected (no other target images will be built).

    Build Outputs

    Build outputs are stored in a Google Cloud Storage bucket (refer to build artifact for location).

    Viewing Artifacts on Google Cloud

    @@ -37,6 +41,12 @@ crucial for correlating ABFS_VERSION and ABFS_CASFS_VERSIONRefer to abfs.md for setting up ABFS for the GCP project.



    """) + environmentVariables { + env('GEMINI_PREVIEW_FEATURES', ${GEMINI_PREVIEW_FEATURES}) + env('GEMINI_LOCATION_GLOBAL', ${GEMINI_LOCATION_GLOBAL}) + env('GEMINI_MODEL', '${GEMINI_MODEL}') + } + parameters { stringParam { name('AAOS_REVISION') @@ -58,7 +68,9 @@ crucial for correlating ABFS_VERSION and ABFS_CASFS_VERSIONBuild the Android Automotive Compatibility Test Suite.
    - Only applicable for CF lunch targets, i.e aosp_cf.

    ''') + Only applicable for CF lunch targets, i.e aosp_cf.
    + Note: only the test suite will be built when this option is selected (no other target images will be built). +

    ''') } choiceParam { @@ -69,7 +81,7 @@ crucial for correlating ABFS_VERSION and ABFS_CASFS_VERSIONThe ABFS cache and source mount path will be stored in a persistent volume for other builds to use.
    Used in conjunction with ABFS_CACHEMAN_TIMEOUT and may improve future build times.

    ''') } @@ -183,6 +195,36 @@ git fetch https://android.googlesource.com/platform/build/soong refs/changes/92/ trim(true) } + separator { + name('Agentic AI: Configuration (Experimental)') + sectionHeader('Agentic AI: Configuration (Experimental)') + sectionHeaderStyle("${HEADER_STYLE}") + separatorStyle("${SEPARATOR_STYLE}") + } + + booleanParam { + name('ENABLE_GEMINI_AI_ASSISTANT') + defaultValue(true) + description('''

    Enable Gemini AI assistant to provide potential solutions to any build failures.

    ''') + } + + stringParam { + name('GEMINI_COMMAND_LINE') + defaultValue("gemini ${model} --yolo --output-format json") + description('''

    The headless gemini cli.

    +

    Use this to define such things as Gemini model to use.

    +

    Note: We use stdin to pipe the prompt to gemini-cli and redirect output to json file.

    ''') + trim(true) + } + + choiceParam { + name('GEMINI_AI_EXECUTION_TIMEOUT') + description(''' +

    Maximum duration allowed for the Gemini assistant to run before automatic termination.

    +

    Note: This safety limit prevents hung processes and optimizes resource usage.

    ''') + choices(['1', '2', '4', '8']) + } + separator { name('Gerrit Changeset Options') sectionHeader('Gerrit Changeset Options') @@ -198,6 +240,13 @@ git fetch https://android.googlesource.com/platform/build/soong refs/changes/92/ trim(true) } + stringParam { + name('GERRIT_TOPIC') + defaultValue('') + description('''

    Optional, define the Gerrit Topic to build multiple changes.

    ''') + trim(true) + } + stringParam { name('GERRIT_PROJECT') defaultValue('') @@ -221,8 +270,8 @@ git fetch https://android.googlesource.com/platform/build/soong refs/changes/92/ } logRotator { - daysToKeep(60) - numToKeep(200) + daysToKeep(7) + numToKeep(50) } definition { @@ -231,10 +280,10 @@ git fetch https://android.googlesource.com/platform/build/soong refs/changes/92/ scm { git { remote { - url("${HORIZON_GIT_URL}") - credentials('jenkins-git-creds') + url("${HORIZON_SCM_URL}") + credentials('jenkins-scm-creds') } - branch("*/${HORIZON_GIT_BRANCH}") + branch("*/${HORIZON_SCM_BRANCH}") } } scriptPath('workloads/android/pipelines/builds/aaos_abfs_builder/Jenkinsfile') diff --git a/workloads/android/pipelines/builds/aaos_builder/Jenkinsfile b/workloads/android/pipelines/builds/aaos_builder/Jenkinsfile index 269e69f1..5e35baba 100644 --- a/workloads/android/pipelines/builds/aaos_builder/Jenkinsfile +++ b/workloads/android/pipelines/builds/aaos_builder/Jenkinsfile @@ -1,4 +1,4 @@ -// Copyright (c) 2024-2025 Accenture, All Rights Reserved. +// Copyright (c) 2024-2026 Accenture, All Rights Reserved. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -52,7 +52,7 @@ pipeline { kind: Pod metadata: annotations: - cluster-autoscaler.kubernetes.io/safe-to-evict: "true" + cluster-autoscaler.kubernetes.io/safe-to-evict: "false" labels: aaos_pod: "true" spec: @@ -74,7 +74,7 @@ pipeline { containers: - name: builder image: ${CLOUD_REGION}-docker.pkg.dev/${CLOUD_PROJECT}/${ANDROID_BUILD_DOCKER_ARTIFACT_PATH_NAME}:latest - imagePullPolicy: IfNotPresent + imagePullPolicy: Always securityContext: privileged: true appArmorProfile: @@ -114,7 +114,7 @@ pipeline { kind: Pod metadata: annotations: - cluster-autoscaler.kubernetes.io/safe-to-evict: "true" + cluster-autoscaler.kubernetes.io/safe-to-evict: "false" labels: aaos_pod: "true" spec: @@ -179,7 +179,10 @@ pipeline { stages { stage ('Start VM Instance') { - agent { kubernetes { yaml "${POD_TEMPLATE}" } } + agent { kubernetes { + yamlMergeStrategy merge() + yaml "${POD_TEMPLATE}" + } } stages { stage ('Clean') { when { @@ -222,6 +225,7 @@ pipeline { set +x echo "AAOS CACHE Persistent Volume Claim: ${NODE_NAME}-aaos-cache" | tee -a build_cache_volume.txt /usr/bin/kubectl get pod ${NODE_NAME} -n jenkins -o=jsonpath='{.spec.nodeName}' | xargs -I {} gcloud compute instances describe {} --zone=${CLOUD_ZONE} | grep 'deviceName: pvc' | awk '{print "AAOS CACHE Persistent Volume: " $2}' | tee -a build_cache_volume.txt || true + ''' sh ''' git config --global credential.helper store @@ -265,6 +269,73 @@ pipeline { ./workloads/android/pipelines/builds/aaos_builder/aaos_build.sh ''' } + archiveArtifacts artifacts: 'aaos-build*.log', followSymlinks: false, onlyIfSuccessful: false, allowEmptyArchive: true + } + } + } + + stage ('AI Review'){ + when { + allOf { + expression { env.AAOS_LUNCH_TARGET } + expression { env.ENABLE_GEMINI_AI_ASSISTANT == 'true' } + expression { currentBuild.currentResult == 'FAILURE' } + } + } + steps { + container(name: 'builder') { + catchError(buildResult: 'FAILURE', stageResult: 'FAILURE') { + sh ''' + set +ex + export GEMINI_ANALYSIS_PATH="/aaos-cache/aaos_builds" + echo "Analysis path: ${GEMINI_ANALYSIS_PATH}" + # Copy artifacts so Gemini can analyse the source in situ (avoid workspace security issues). + cp -f aaos-build*.* "${GEMINI_ANALYSIS_PATH%/}/" || true + cd "${GEMINI_ANALYSIS_PATH%/}" || true + [ -f aaos-build.log ] && tail -n 2500 aaos-build.log > aaos-build.log.tail || true + export GEMINI_PROMPT_FILE="${WORKSPACE}/workloads/android/pipelines/builds/aaos_builder/prompt/sequenced/step1_triage.txt" + export GEMINI_PROMPT_FILE_2="${WORKSPACE}/workloads/android/pipelines/builds/aaos_builder/prompt/sequenced/step2_rca.txt" + export GEMINI_PROMPT_FILE_3="${WORKSPACE}/workloads/android/pipelines/builds/aaos_builder/prompt/sequenced/step3_fixes.txt" + export GEMINI_STEP2_PRIOR_CONTEXT_BYTES="${GEMINI_STEP2_PRIOR_CONTEXT_BYTES:-131072}" + export GEMINI_PREVIEW_FEATURES="${GEMINI_PREVIEW_FEATURES}" + [ "$GEMINI_LOCATION_GLOBAL" = "true" ] && export GOOGLE_CLOUD_LOCATION="global" || export GOOGLE_CLOUD_LOCATION="${CLOUD_REGION}" + export GOOGLE_CLOUD_PROJECT="${CLOUD_PROJECT}" + export GEMINI_COMMAND_LINE="${GEMINI_COMMAND_LINE}" + "${WORKSPACE}"/workloads/common/agentic-ai/gemini/gemini_initialise.sh + timeout "${GEMINI_AI_EXECUTION_TIMEOUT}"h "${WORKSPACE}"/workloads/common/agentic-ai/gemini/gemini_analysis.sh + GEMINI_EXIT_CODE=$? + + echo "Check artifacts create in gemini-assist" + ls -la ${GEMINI_ANALYSIS_PATH}/gemini-assist || true + + # Restore order to cache/PV and workspace. + # Avoid gemini deafness and it applying changes, cleanup! + repo forall -c 'git checkout -- .; git clean -xfd;' >/dev/null 2>&1 + rm -f aaos-build*.* + + cd - || true + bash -c 'source \"${WORKSPACE}/workloads/common/agentic-ai/gemini/gemini_environment.sh\" && \ + move_gemini_artifacts \"${GEMINI_ANALYSIS_PATH%/}\" \"${WORKSPACE}\"' || true + + find . -type f -name "headless*.json" -size 0 -delete + + # Fail stage if gemini-client-error.zip exists (CLI error even when exit code was 0) + if [ -f "${GEMINI_ANALYSIS_PATH}/gemini-client-error.zip" ] || [ -f "${WORKSPACE}/gemini-client-error.zip" ]; then + echo "ERROR: Gemini AI assistant reported errors (gemini-client-error.zip present)" + GEMINI_EXIT_CODE=1 + fi + + set -e + exit ${GEMINI_EXIT_CODE} + ''' + } + archiveArtifacts artifacts: 'gemini-assist/*', followSymlinks: false, onlyIfSuccessful: false, allowEmptyArchive: true + archiveArtifacts artifacts: 'headless_output*.json', followSymlinks: false, onlyIfSuccessful: false, allowEmptyArchive: false + script { + if (fileExists('gemini-client-error.zip')) { + archiveArtifacts artifacts: 'gemini-client-error.zip', followSymlinks: false, onlyIfSuccessful: false, allowEmptyArchive: true + } + } } } } @@ -274,23 +345,33 @@ pipeline { allOf { expression { env.AAOS_LUNCH_TARGET } expression { env.AAOS_ARTIFACT_STORAGE_SOLUTION } - expression { currentBuild.currentResult == 'SUCCESS' } + anyOf { + expression { currentBuild.currentResult == 'SUCCESS' } + expression { env.ENABLE_GEMINI_AI_ASSISTANT == 'true' } + } } } steps { container(name: 'builder') { catchError(buildResult: 'FAILURE', stageResult: 'FAILURE') { + script { + env.AAOS_BUILD_STAGE_FAILED = (currentBuild.currentResult != 'SUCCESS') ? 'true' : 'false' + } sh ''' - AAOS_LUNCH_TARGET="${AAOS_LUNCH_TARGET}" \ - AAOS_REVISION="${AAOS_REVISION}" \ - ANDROID_VERSION="${SDK_ANDROID_VERSION}" \ - ./workloads/android/pipelines/builds/aaos_builder/aaos_avd_sdk.sh || true + if [ "${AAOS_BUILD_STAGE_FAILED}" != "true" ]; then + AAOS_LUNCH_TARGET="${AAOS_LUNCH_TARGET}" \ + AAOS_REVISION="${AAOS_REVISION}" \ + ANDROID_VERSION="${SDK_ANDROID_VERSION}" \ + ./workloads/android/pipelines/builds/aaos_builder/aaos_avd_sdk.sh || true + fi AAOS_LUNCH_TARGET="${AAOS_LUNCH_TARGET}" \ AAOS_ARTIFACT_ROOT_NAME="${ANDROID_BUILD_BUCKET_ROOT_NAME}" \ AAOS_ARTIFACT_STORAGE_SOLUTION="${AAOS_ARTIFACT_STORAGE_SOLUTION}" \ STORAGE_BUCKET_DESTINATION="${STORAGE_BUCKET_DESTINATION}" \ STORAGE_LABELS="${STORAGE_LABELS}" \ + ENABLE_GEMINI_AI_ASSISTANT="${ENABLE_GEMINI_AI_ASSISTANT}" \ + AAOS_BUILD_STAGE_FAILED="${AAOS_BUILD_STAGE_FAILED}" \ ./workloads/android/pipelines/builds/aaos_builder/aaos_storage.sh ''' archiveArtifacts artifacts: '*artifacts*.txt', followSymlinks: false, onlyIfSuccessful: false, allowEmptyArchive: true diff --git a/workloads/android/pipelines/builds/aaos_builder/aaos_avd_sdk.sh b/workloads/android/pipelines/builds/aaos_builder/aaos_avd_sdk.sh index 9e864d90..85b41ba5 100755 --- a/workloads/android/pipelines/builds/aaos_builder/aaos_avd_sdk.sh +++ b/workloads/android/pipelines/builds/aaos_builder/aaos_avd_sdk.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash -# Copyright (c) 2024-2025 Accenture, All Rights Reserved. +# Copyright (c) 2024-2026 Accenture, All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -125,6 +125,8 @@ function create_devices_xml() { function update_archives() { for artifact in "${AAOS_ARTIFACT_LIST[@]}"; do for file in ${artifact}; do + # Skip if file does not exist (e.g. glob did not expand; zip -u would create it) + [ -f "${file}" ] || continue if [[ $(basename "${file}") =~ ^"${AAOS_SDK_SYSTEM_IMAGE_PREFIX}" ]]; then zip -u "${file}" devices.xml diff --git a/workloads/android/pipelines/builds/aaos_builder/aaos_build.sh b/workloads/android/pipelines/builds/aaos_builder/aaos_build.sh index 1955c1ba..49c304c8 100755 --- a/workloads/android/pipelines/builds/aaos_builder/aaos_build.sh +++ b/workloads/android/pipelines/builds/aaos_builder/aaos_build.sh @@ -47,8 +47,8 @@ if [ -n "${AAOS_MAKE_CMDLINE}" ]; then echo "Building: $AAOS_MAKE_CMDLINE" # Run the build. - eval "${AAOS_MAKE_CMDLINE}" - RESULT=$? + eval "${AAOS_MAKE_CMDLINE}" | tee -a "${AAOS_BUILD_LOG_FILE}" + RESULT="${PIPESTATUS[0]}" else echo -e "\033[1;31mERROR: make command line undefined!\033[0m" exit 1 @@ -58,7 +58,7 @@ if (( RESULT == 0 )); then echo "Post build commands:" for command in "${POST_BUILD_COMMANDS[@]}"; do echo "${command}" - eval "${command}" + eval "${command}" | tee -a "${AAOS_BUILD_LOG_FILE}" done fi diff --git a/workloads/android/pipelines/builds/aaos_builder/aaos_environment.sh b/workloads/android/pipelines/builds/aaos_builder/aaos_environment.sh index 187cc6a3..3c959e63 100755 --- a/workloads/android/pipelines/builds/aaos_builder/aaos_environment.sh +++ b/workloads/android/pipelines/builds/aaos_builder/aaos_environment.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash -# Copyright (c) 2024-2025 Accenture, All Rights Reserved. +# Copyright (c) 2024-2026 Accenture, All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -29,6 +29,8 @@ # - AAOS_ARTIFACT_STORAGE_SOLUTION: the persistent storage location for # artifacts (GCS_BUCKET default). # - AAOS_ARTIFACT_ROOT_NAME: the name of the bucket to store artifacts. +# - AAOS_BUILD_STAGE_FAILED: when "true", OUT_DIR artifacts are not added to +# AAOS_ARTIFACT_LIST (used by storage when build failed but AI Review ran). # - ANDROID_VERSION: the Android version (default: 14). # - REPO_SYNC_JOBS: the number of parallel repo sync jobs to use Default: 3). # - MAX_REPO_SYNC_JOBS: the maximum number of parallel repo sync jobs @@ -157,7 +159,7 @@ else fi # Disk space ceiling, remove older build targets if insufficient space. -DISK_SPACE_WATERMARK=${DISK_SPACE_WATERMARK:-88} +DISK_SPACE_WATERMARK=${DISK_SPACE_WATERMARK:-85} if [[ "${AAOS_LUNCH_TARGET}" =~ "rpi" ]]; then DISK_SPACE_WATERMARK=78 fi @@ -169,7 +171,7 @@ if [ -d "${AAOS_CACHE_DIRECTORY}" ]; then sudo chmod g+s /"${AAOS_CACHE_DIRECTORY}" if [[ "${ABFS_BUILDER}" == "true" ]]; then - if [[ "${ABFS_CACHED_BUILD}" = "true" ]]; then + if [[ "${ABFS_CACHED_BUILD}" == "true" ]]; then ABFS_CMD_FLAGS="--cache-dir ${AAOS_CACHE_DIRECTORY}/cache" mkdir -p "${AAOS_CACHE_DIRECTORY}/cache" mkdir -p "${AAOS_CACHE_DIRECTORY}/${ABFS_MOUNT_POINT}" @@ -178,7 +180,7 @@ if [ -d "${AAOS_CACHE_DIRECTORY}" ]; then case "$0" in *initialise.sh | *build.sh) if [[ "${ABFS_BUILDER}" == "true" ]]; then - if [[ "${ABFS_CACHED_BUILD}" = "true" ]]; then + if [[ "${ABFS_CACHED_BUILD}" == "true" ]]; then USAGE=$(df -h "${AAOS_CACHE_DIRECTORY}" | tail -1 | awk '{print "Used " $3 " of " $2}') USED_PERCENTAGE=$(df "${AAOS_CACHE_DIRECTORY}" | tail -1 | awk '{print ($3/$2)*100}' | cut -d '.' -f 1) if [ "${USED_PERCENTAGE}" -lt "${DISK_SPACE_WATERMARK}" ]; then @@ -204,8 +206,8 @@ if [ -d "${AAOS_CACHE_DIRECTORY}" ]; then USAGE=$(df -h "${AAOS_CACHE_DIRECTORY}" | tail -1 | awk '{print "Used " $3 " of " $2}') echo "WARNING: Insufficient disk space - ${USED_PERCENTAGE}% (${USAGE})" - # List the oldest target directory - OLDEST_DIR=$(find "${AAOS_CACHE_DIRECTORY}"/aaos_builds* -mindepth 1 -maxdepth 1 -type d -name 'out_sdv*' -exec ls -drt {} + | head -1) + # List the oldest target directory (exclude current lunch target) + OLDEST_DIR=$(find "${AAOS_CACHE_DIRECTORY}"/aaos_builds* -mindepth 1 -maxdepth 1 -type d -name 'out_sdv*' ! -name "out_sdv-${AAOS_LUNCH_TARGET}" -exec ls -drt {} + | head -1) if [ -z "${OLDEST_DIR}" ]; then echo "No further target directories to clean up." break @@ -231,7 +233,7 @@ if [[ "${ABFS_BUILDER}" == "false" ]]; then "${AAOS_CACHE_DIRECTORY}"/"${AAOS_BUILDS_DIRECTORY}" ) else - if [[ "${ABFS_CACHED_BUILD}" = "true" ]]; then + if [[ "${ABFS_CACHED_BUILD}" == "true" ]]; then DIRECTORY_LIST+=( "${AAOS_CACHE_DIRECTORY}/cache" ) @@ -241,6 +243,23 @@ else fi fi +# Build records directory: aaos-build-info.txt + aaos-build.log (after WORKSPACE is finalized). +# Jenkins: WORKSPACE is reassigned to the AAOS cache tree, but ORIG_WORKSPACE is still the job workspace — +# archiveArtifacts / storage expecting workspace paths need records there. Argo keeps them on the PVC +# build WORKSPACE. Set AAOS_BUILD_LOGS_USE_ORIG_WORKSPACE=true|false to force either behaviour. +if [[ "${AAOS_BUILD_LOGS_USE_ORIG_WORKSPACE:-}" == "true" ]]; then + AAOS_BUILD_RECORDS_DIR="${ORIG_WORKSPACE}" +elif [[ "${AAOS_BUILD_LOGS_USE_ORIG_WORKSPACE:-}" == "false" ]]; then + AAOS_BUILD_RECORDS_DIR="${WORKSPACE}" +elif [[ -n "${JENKINS_URL:-}" ]] || [[ -n "${HUDSON_URL:-}" ]]; then + AAOS_BUILD_RECORDS_DIR="${ORIG_WORKSPACE}" +else + AAOS_BUILD_RECORDS_DIR="${WORKSPACE}" +fi +AAOS_BUILD_INFO_FILE="${AAOS_BUILD_RECORDS_DIR}/aaos-build-info.txt" +AAOS_BUILD_LOG_FILE="${AAOS_BUILD_RECORDS_DIR}/aaos-build.log" +export AAOS_BUILD_RECORDS_DIR AAOS_BUILD_INFO_FILE AAOS_BUILD_LOG_FILE + # Clean commands AAOS_CLEAN=${AAOS_CLEAN:-NO_CLEAN} @@ -250,9 +269,6 @@ if [[ "${ABFS_CLEAN_CACHE}" == "true" ]]; then AAOS_CLEAN=CLEAN_ALL fi -# Build info file name -BUILD_INFO_FILE="${WORKSPACE}/build_info.txt" - # Override build output directory to keep builds # separate from each other. if [[ "${ABFS_BUILDER}" == "false" ]]; then @@ -293,11 +309,27 @@ declare -a POST_BUILD_COMMANDS # Declare artifact array. declare -a AAOS_ARTIFACT_LIST=( - "${BUILD_INFO_FILE}" + "${AAOS_BUILD_INFO_FILE}" + "${AAOS_BUILD_LOG_FILE}" ) + +# Gemini AI assistant: only upload Gemini artifacts when build failed (AI Review ran) +ENABLE_GEMINI_AI_ASSISTANT=${ENABLE_GEMINI_AI_ASSISTANT:-false} + +if [[ "${ENABLE_GEMINI_AI_ASSISTANT}" == "true" ]] && [[ "${AAOS_BUILD_STAGE_FAILED}" == "true" ]]; then + AAOS_ARTIFACT_LIST+=( + "${ORIG_WORKSPACE}/gemini-assist/" + "${ORIG_WORKSPACE}/*.json" + "/aaos-cache/aaos_builds/gemini-assist/" + "/aaos-cache/aaos_builds/headless_output*.json" + "/aaos-cache/aaos_builds/gemini-client-error.zip" + ) +fi + # Post storage commands declare -a POST_STORAGE_COMMANDS=( - "rm -f ${BUILD_INFO_FILE}" + "rm -f ${AAOS_BUILD_INFO_FILE}" + "rm -f ${AAOS_BUILD_LOG_FILE}" "rm -rf vendor" ) @@ -310,11 +342,13 @@ case "${AAOS_LUNCH_TARGET}" in # FIXME: we can build full flashable image but may require special # permissions, for now host the individual parts. # ${VERSION}-${DATE}-rpi5.img # rpi5-mkimg.sh - AAOS_ARTIFACT_LIST+=( - "${OUT_DIR}/target/product/${AAOS_ARCH}/boot.img" - "${OUT_DIR}/target/product/${AAOS_ARCH}/system.img" - "${OUT_DIR}/target/product/${AAOS_ARCH}/vendor.img" - ) + if [[ "${AAOS_BUILD_STAGE_FAILED}" != "true" ]]; then + AAOS_ARTIFACT_LIST+=( + "${OUT_DIR}/target/product/${AAOS_ARCH}/boot.img" + "${OUT_DIR}/target/product/${AAOS_ARCH}/system.img" + "${OUT_DIR}/target/product/${AAOS_ARCH}/vendor.img" + ) + fi case "${AAOS_LUNCH_TARGET}" in # Download the RPi manifest if we are building for an RPi device. @@ -363,12 +397,14 @@ case "${AAOS_LUNCH_TARGET}" in AAOS_BUILD_CTS="false" AAOS_MAKE_CMDLINE="m -j${AAOS_PARALLEL_BUILD_JOBS}&& m emu_img_zip -j${AAOS_PARALLEL_BUILD_JOBS}&& m sbom -j${AAOS_PARALLEL_BUILD_JOBS}" # Newer versions, sbom is under SOONG - AAOS_ARTIFACT_LIST+=( - "${OUT_DIR}/soong/sbom/sdk_car_${AAOS_ARCH}/sbom.spdx.json" - "${OUT_DIR}/target/product/emulator_car64_${AAOS_ARCH}/sbom.spdx.json" - "${OUT_DIR}/target/product/emulator_car64_${AAOS_ARCH}/${AAOS_SDK_SYSTEM_IMAGE_PREFIX}*.zip" - "${OUT_DIR}/target/product/emulator_car64_${AAOS_ARCH}/${AAOS_SDK_ADDON_FILE}" - ) + if [[ "${AAOS_BUILD_STAGE_FAILED}" != "true" ]]; then + AAOS_ARTIFACT_LIST+=( + "${OUT_DIR}/soong/sbom/sdk_car_${AAOS_ARCH}/sbom.spdx.json" + "${OUT_DIR}/target/product/emulator_car64_${AAOS_ARCH}/sbom.spdx.json" + "${OUT_DIR}/target/product/emulator_car64_${AAOS_ARCH}/${AAOS_SDK_SYSTEM_IMAGE_PREFIX}*.zip" + "${OUT_DIR}/target/product/emulator_car64_${AAOS_ARCH}/${AAOS_SDK_ADDON_FILE}" + ) + fi POST_STORAGE_COMMANDS+=( "rm -f devices.xml" "rm -f ${AAOS_SDK_ADDON_FILE}" @@ -395,26 +431,33 @@ case "${AAOS_LUNCH_TARGET}" in threads=$(( $(nproc) / 2 )) threads=$(( threads < 1 ? 1 : threads )) - # Always build aosp_cf and then CTS. + # Build only the CTS test suite. AAOS_MAKE_CMDLINE="m cts -j ${threads}" - AAOS_ARTIFACT_LIST+=("${OUT_DIR}/host/linux-x86/cts/android-cts.zip") + if [[ "${AAOS_BUILD_STAGE_FAILED}" != "true" ]]; then + AAOS_ARTIFACT_LIST+=("${OUT_DIR}/host/linux-x86/cts/android-cts.zip") + fi else - AAOS_ARTIFACT_LIST+=( - "${OUT_DIR}/dist/cvd-host_package.tar.gz" - "${OUT_DIR}/dist/sbom/sbom.spdx.json" - "${OUT_DIR}/dist/aosp_cf_${AAOS_ARCH}_auto-img*.zip" - "${WIFI_APK_NAME}" - ) + if [[ "${AAOS_BUILD_STAGE_FAILED}" != "true" ]]; then + AAOS_ARTIFACT_LIST+=( + "${OUT_DIR}/dist/cvd-host_package.tar.gz" + "${OUT_DIR}/dist/sbom/sbom.spdx.json" + "${OUT_DIR}/dist/aosp_cf_${AAOS_ARCH}_auto-img*.zip" + "${WIFI_APK_NAME}" + ) + fi fi POST_STORAGE_COMMANDS+=( "rm -f ${WIFI_APK_NAME}" + "rm -f ${OUT_DIR}/dist/aosp_cf_${AAOS_ARCH}_auto-img*.zip" ) ;; *tangorpro_car*) AAOS_BUILD_CTS="false" - AAOS_ARTIFACT_LIST+=( - "${OUT_DIR}.tgz" - ) + if [[ "${AAOS_BUILD_STAGE_FAILED}" != "true" ]]; then + AAOS_ARTIFACT_LIST+=( + "${OUT_DIR}.tgz" + ) + fi AAOS_MAKE_CMDLINE="m -j${AAOS_PARALLEL_BUILD_JOBS} && m android.hardware.automotive.vehicle@2.0-default-service android.hardware.automotive.audiocontrol-service.example -j${AAOS_PARALLEL_BUILD_JOBS}" # Pixel Tablet binaries for Android ap1a/ap2a/ap3a/ap4a/bp1a case "${AAOS_LUNCH_TARGET}" in @@ -624,6 +667,8 @@ case "$0" in AAOS_ARTIFACT_ROOT_NAME=${AAOS_ARTIFACT_ROOT_NAME} AAOS_BUILD_CTS=${AAOS_BUILD_CTS} + + ENABLE_GEMINI_AI_ASSISTANT=${ENABLE_GEMINI_AI_ASSISTANT} " ;; *) @@ -631,15 +676,19 @@ case "$0" in esac VARIABLES+=" + ORIG_WORKSPACE=${ORIG_WORKSPACE} WORKSPACE=${WORKSPACE} + AAOS_BUILD_RECORDS_DIR=${AAOS_BUILD_RECORDS_DIR} + AAOS_BUILD_INFO_FILE=${AAOS_BUILD_INFO_FILE} + AAOS_BUILD_LOG_FILE=${AAOS_BUILD_LOG_FILE} hostname=$(hostname) Storage Usage (${AAOS_CACHE_DIRECTORY}): $(df -h "${AAOS_CACHE_DIRECTORY}" | tail -1 | awk '{print "Used " $3 " of " $2}') Kernel Revision: $(uname -r) " # Add to build info for storage. -echo "$0 Build Info:" | tee -a "${BUILD_INFO_FILE}" -echo "${VARIABLES}" | tee -a "${BUILD_INFO_FILE}" +echo "$0 Build Info:" | tee -a "${AAOS_BUILD_INFO_FILE}" +echo "${VARIABLES}" | tee -a "${AAOS_BUILD_INFO_FILE}" # Remove directories if requested. RSYNC_DELETE=${RSYNC_DELETE:-false} @@ -682,7 +731,7 @@ function create_workspace() { if [[ "${ABFS_BUILDER}" == "false" ]]; then mkdir -p "${WORKSPACE}" > /dev/null 2>&1 else - if [[ "${ABFS_CACHED_BUILD}" = "false" ]]; then + if [[ "${ABFS_CACHED_BUILD}" == "false" ]]; then sudo mkdir -p "/${ABFS_MOUNT_POINT}" sudo chown builder:builder "/${ABFS_MOUNT_POINT}" fi diff --git a/workloads/android/pipelines/builds/aaos_builder/aaos_initialise.sh b/workloads/android/pipelines/builds/aaos_builder/aaos_initialise.sh index ffaebef8..1ce4eb96 100755 --- a/workloads/android/pipelines/builds/aaos_builder/aaos_initialise.sh +++ b/workloads/android/pipelines/builds/aaos_builder/aaos_initialise.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash -# Copyright (c) 2024-2025 Accenture, All Rights Reserved. +# Copyright (c) 2024-2026 Accenture, All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -63,6 +63,7 @@ # Include common functions and variables. # shellcheck disable=SC1091 source "$(dirname "${BASH_SOURCE[0]}")"/aaos_environment.sh "$0" +declare PROJECT_PATH="" # Initialise the repository function initialise_repo() { @@ -127,6 +128,105 @@ function initialise_repo() { echo "SUCCESS: repo sync complete." } +# Determine the true project path +function set_repo_path() { + local project=$1 + + if [[ "${ABFS_BUILDER}" == "false" ]]; then + # Use standard git fetch to retrieve the change. + # Find the project name from the manifest. + if [[ -n "${GERRIT_TOPIC}" ]]; then + PROJECT_PATH=$(repo list -p "${project}") + else + PROJECT_PATH=$(grep "name=\"${project}\"" .repo/manifests/default.xml | sed -r 's/.*path="([^"]+)".*/\1/') + fi + else + + # Make this common to topic. + # Find the path from manifest + mkdir -p "${HOME}"/manifest + cd "${HOME}"/manifest || exit + + # FIXME: fix branch (demo only) + if [[ "${AAOS_GERRIT_MANIFEST_URL}" =~ "horizon" ]]; then + if [[ ! "${AAOS_REVISION}" =~ "horizon" ]]; then + AAOS_REVISION=horizon/"${AAOS_REVISION}" + fi + fi + + # FIXME: will use clone in future but for now this is just convenience for commonality. + if ! repo init -u "${AAOS_GERRIT_MANIFEST_URL}" -b "${AAOS_REVISION}" --depth=1 + then + echo -e "\033[1;31mERROR: repo init failed, exit!\033[0m" + exit 1 + fi + + PROJECT_PATH=$(grep "name=\"${project}\"" .repo/manifests/default.xml | sed -r 's/.*path="([^"]+)".*/\1/') + rm -rf "${HOME}"/manifest + cd - || exit + fi + + echo "Repo path: $PROJECT_PATH" +} + +# Configure Git for more reliable fetches from Gerrit (avoids HTTP/2 INTERNAL_ERROR, +# early EOF, GnuTLS recv error, unpack-objects failed). Call once before any Gerrit fetch. +function configure_git_gerrit_fetch() { + # Larger buffer for big fetches; reduces "RPC failed" / "body still expected" errors. + git config --global http.postBuffer 524288000 + if [[ -n "${GERRIT_SERVER_URL:-}" ]]; then + local host + host=$(echo "${GERRIT_SERVER_URL}" | sed -e 's|^https\?://||' -e 's|/.*||') + if [[ -n "${host}" ]]; then + git config --global "http.https://${host}/.extraHeader" "Connection: keep-alive" + # Force HTTP/1.1 for this host to avoid HTTP/2 stream errors (INTERNAL_ERROR, stream not closed cleanly). + git config --global "http.https://${host}/.version" "HTTP/1.1" + # Force TLS 1.2 for this host to avoid GnuTLS "Error decoding the received TLS packet" (curl 56). + git config --global "http.https://${host}/.sslVersion" "tlsv1.2" + echo "Git: configured postBuffer, keep-alive, HTTP/1.1 and TLS 1.2 for ${host}" + fi + fi +} + +# Fetch ref from URL and cherry-pick with retries (transient HTTP/2, GnuTLS, network errors). +# Uses a timeout so a stuck fetch fails and retries instead of hanging. Set GERRIT_FETCH_TIMEOUT_SEC to override (default 300). +# Returns 0 on success, 1 after all retries failed. +function fetch_and_cherry_pick_with_retry() { + local project_path="$1" + local fetch_url="$2" + local ref="$3" + local max_attempts=5 + local retry_delay_sec=20 + local fetch_timeout_sec="${GERRIT_FETCH_TIMEOUT_SEC:-300}" + local attempt=1 + local saved_pwd + saved_pwd=$(pwd) + + while (( attempt <= max_attempts )); do + echo "Attempt ${attempt}/${max_attempts}: git fetch ${fetch_url} ${ref} (timeout ${fetch_timeout_sec}s) && git cherry-pick FETCH_HEAD" + local fetch_exit=0 + ( cd "${project_path}" && timeout "${fetch_timeout_sec}" git fetch "${fetch_url}" "${ref}" && git cherry-pick FETCH_HEAD ) || fetch_exit=$? + if (( fetch_exit == 0 )); then + cd "${saved_pwd}" || true + return 0 + fi + if (( fetch_exit == 124 )); then + echo "WARNING: git fetch timed out after ${fetch_timeout_sec}s (attempt ${attempt}/${max_attempts})." + else + echo "WARNING: fetch or cherry-pick failed with exit ${fetch_exit} (attempt ${attempt}/${max_attempts})." + fi + cd "${saved_pwd}" || true + git -C "${project_path}" cherry-pick --abort 2>/dev/null || true + git -C "${project_path}" reset --hard HEAD 2>/dev/null || true + if (( attempt < max_attempts )); then + echo "Retrying in ${retry_delay_sec} seconds..." + sleep "${retry_delay_sec}" + fi + (( attempt++ )) || true + done + return 1 +} + # Fetch and apply all changes based on GERRIT_TOPIC function fetch_from_topic() { echo "Fetching ${GERRIT_TOPIC}" @@ -144,21 +244,31 @@ function fetch_from_topic() { echo "Current Revision | $rev" # Derive project path from manifest - PROJECT_PATH=$(grep "name=\"${project}\"" .repo/manifests/default.xml | sed -r 's/.*path="([^"]+)".*/\1/') - # Create the command to apply the patchset from topic. - REPO_CMD="cd ${PROJECT_PATH} && git fetch ${url} ${ref} && git cherry-pick FETCH_HEAD && cd -" - echo "Running: ${REPO_CMD}" - if ! eval "${REPO_CMD}" - then - echo -e "\033[1;31mERROR: git fetch failed, exit!\033[0m" - # Clean up so pv is not left in limbo (and thus removed) - git cherry-pick --abort || true && git reset --hard HEAD || true - exit 1 + set_repo_path "${project}" + + if [[ "${ABFS_BUILDER}" != "false" ]]; then + # ABFS: fetch and cherry-pick with retries (HTTP/2, GnuTLS, network). + if ! fetch_and_cherry_pick_with_retry "${PROJECT_PATH}" "${url}" "${ref}" + then + echo -e "\033[1;31mERROR: git fetch failed after retries, exit!\033[0m" + git -C "${PROJECT_PATH}" cherry-pick --abort 2>/dev/null || true + git -C "${PROJECT_PATH}" reset --hard HEAD 2>/dev/null || true + exit 1 + fi else - if (( createChangesFile == 1 )); then - echo "$rev" | tee -a "${GERRIT_CHANGES_FILE}" + REPO_CMD="cd ${PROJECT_PATH} && git fetch ${url} ${ref} && git cherry-pick FETCH_HEAD && cd -" + echo "Running: ${REPO_CMD}" + if ! eval "${REPO_CMD}" + then + echo -e "\033[1;31mERROR: git fetch failed, exit!\033[0m" + git -C "${PROJECT_PATH}" cherry-pick --abort 2>/dev/null || true + git -C "${PROJECT_PATH}" reset --hard HEAD 2>/dev/null || true + exit 1 fi fi + if (( createChangesFile == 1 )); then + echo "$rev" | tee -a "${GERRIT_CHANGES_FILE}" + fi done < <(curl -sS -u "${GERRIT_USERNAME}:${GERRIT_PASSWORD}" \ "${GERRIT_SERVER_URL}/a/changes/?q=topic:${GERRIT_TOPIC}+status:open&o=CURRENT_REVISION" \ | sed '1d' | jq -r ' .[] | @@ -175,36 +285,15 @@ function fetch_from_topic() { # Pull in change set from Gerrit. function fetch_patchset() { + # ABFS only: configure Git for Gerrit (postBuffer, HTTP/1.1, TLS 1.2, retry on transient errors). + if [[ "${ABFS_BUILDER}" != "false" ]]; then + configure_git_gerrit_fetch + fi + if [[ -n "${GERRIT_TOPIC}" ]]; then fetch_from_topic elif [[ -n "${GERRIT_PROJECT}" && -n "${GERRIT_CHANGE_NUMBER}" && -n "${GERRIT_PATCHSET_NUMBER}" ]]; then - if [[ "${ABFS_BUILDER}" == "false" ]]; then - # Use standard git fetch to retrieve the change. - # Find the project name from the manifest. - PROJECT_PATH=$(repo list -p "${GERRIT_PROJECT}") - else - # Find the path from manifest - mkdir -p "${HOME}"/manifest - cd "${HOME}"/manifest || exit - - # FIXME: fix branch (demo only) - if [[ "${AAOS_GERRIT_MANIFEST_URL}" =~ "horizon" ]]; then - if [[ ! "${AAOS_REVISION}" =~ "horizon" ]]; then - AAOS_REVISION=horizon/"${AAOS_REVISION}" - fi - fi - - # FIXME: will use clone in future but for now this is just convenience for commonality. - if ! repo init -u "${AAOS_GERRIT_MANIFEST_URL}" -b "${AAOS_REVISION}" --depth=1 - then - echo -e "\033[1;31mERROR: repo init failed, exit!\033[0m" - exit 1 - fi - - PROJECT_PATH=$(grep "name=\"${GERRIT_PROJECT}\"" .repo/manifests/default.xml | sed -r 's/.*path="([^"]+)".*/\1/') - rm -rf "${HOME}"/manifest - cd - || exit - fi + set_repo_path "${GERRIT_PROJECT}" # Derive the Gerrit URL from the manifest URL. # Horizon SDV uses path based URL whereas Google Android does not. @@ -226,14 +315,21 @@ function fetch_patchset() { fi FETCHED_REFS="refs/changes/${LAST_TWO_DIGITS}"/"${GERRIT_CHANGE_NUMBER}"/"${GERRIT_PATCHSET_NUMBER}" - # shellcheck disable=SC2164 - REPO_CMD="cd ${PROJECT_PATH} && git fetch ${PROJECT_URL} ${FETCHED_REFS} && git cherry-pick FETCH_HEAD && cd -" - - echo "Running: ${REPO_CMD}" - if ! eval "${REPO_CMD}" - then - echo -e "\033[1;31mERROR: git fetch failed, exit!\033[0m" - exit 1 + if [[ "${ABFS_BUILDER}" != "false" ]]; then + echo "Running: cd ${PROJECT_PATH} && git fetch ${PROJECT_URL} ${FETCHED_REFS} && git cherry-pick FETCH_HEAD" + if ! fetch_and_cherry_pick_with_retry "${PROJECT_PATH}" "${PROJECT_URL}" "${FETCHED_REFS}" + then + echo -e "\033[1;31mERROR: git fetch failed after retries, exit!\033[0m" + exit 1 + fi + else + REPO_CMD="cd ${PROJECT_PATH} && git fetch ${PROJECT_URL} ${FETCHED_REFS} && git cherry-pick FETCH_HEAD && cd -" + echo "Running: ${REPO_CMD}" + if ! eval "${REPO_CMD}" + then + echo -e "\033[1;31mERROR: git fetch failed, exit!\033[0m" + exit 1 + fi fi if [ -f "${GERRIT_CHANGES_FILE}" ]; then diff --git a/workloads/android/pipelines/builds/aaos_builder/aaos_storage.sh b/workloads/android/pipelines/builds/aaos_builder/aaos_storage.sh index a5165730..777111ab 100755 --- a/workloads/android/pipelines/builds/aaos_builder/aaos_storage.sh +++ b/workloads/android/pipelines/builds/aaos_builder/aaos_storage.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash -# Copyright (c) 2024-2025 Accenture, All Rights Reserved. +# Copyright (c) 2024-2026 Accenture, All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -37,6 +37,7 @@ # shellcheck disable=SC1091 source "$(dirname "${BASH_SOURCE[0]}")"/aaos_environment.sh "$0" +RESULT=0 # Format STORAGE_PATH as a zero-padded two-digit string (e.g. 7/aaa -> 07/aaa, 7 -> 07) # shellcheck disable=SC2329 function pad_first_number_if_numeric() { @@ -58,25 +59,31 @@ export STORAGE_BUCKET_DESTINATION=${STORAGE_BUCKET_DESTINATION:-gs://${AAOS_ARTI export BUCKET_RELATIVE_DESTINATION="${STORAGE_BUCKET_DESTINATION#gs://}" export STORAGE_CLOUD_URL=${STORAGE_CLOUD_URL:-https://console.cloud.google.com/storage/browser/${BUCKET_RELATIVE_DESTINATION}} -export ARTIFACT_LIST="${AAOS_ARTIFACT_LIST[*]}" -export ARTIFACT_SUMMARY="${ORIG_WORKSPACE}/${AAOS_LUNCH_TARGET}-artifacts.txt" +ARTIFACT_LIST=$(printf '%s\n' "${AAOS_ARTIFACT_LIST[@]}") +export ARTIFACT_LIST +export ARTIFACT_SUMMARY="${AAOS_BUILD_RECORDS_DIR}/${AAOS_LUNCH_TARGET}-artifacts.txt" POST_CLEANUP_STRING="" export POST_CLEANUP_STRING POST_CLEANUP_STRING="$(printf "%s\n" "${POST_STORAGE_COMMANDS[@]}")" export ARTIFACT_STORAGE_SOLUTION="${AAOS_ARTIFACT_STORAGE_SOLUTION}" -export ARTIFACT_STORAGE_SOLUTION_FUNCTION="${AAOS_ARTIFACT_STORAGE_SOLUTION_FUNCTION}" export WORKSPACE="${ORIG_WORKSPACE}" "${ORIG_WORKSPACE}"/workloads/common/storage/storage.sh +RESULT="$?" export STORAGE_LABELS="${STORAGE_LABELS}" -case "${ARTIFACT_STORAGE_SOLUTION}" in - GCS_BUCKET) - export URL_PATH="${STORAGE_BUCKET_DESTINATION}/" - export KEYVALUE_PAIRS="${STORAGE_LABELS}" - "${ORIG_WORKSPACE}"/workloads/common/storage/gcs_utilities.sh ADD_OBJECT_METADATA || true - ;; - *) - echo "Utility to add metadata using $ARTIFACT_STORAGE_SOLUTION not available" - ;; -esac -exit "$?" +if [ -n "${STORAGE_LABELS}" ]; then + case "${ARTIFACT_STORAGE_SOLUTION}" in + GCS_BUCKET) + export URL_PATH="${STORAGE_BUCKET_DESTINATION}/" + export KEYVALUE_PAIRS="${STORAGE_LABELS}" + "${ORIG_WORKSPACE}"/workloads/common/storage/gcs_utilities.sh ADD_OBJECT_METADATA || true + RESULT="$?" + ;; + *) + echo "Utility to add metadata using $ARTIFACT_STORAGE_SOLUTION not available" + ;; + esac +else + echo "STORAGE_LABELS empty, ignoring" +fi +exit "${RESULT}" diff --git a/workloads/android/pipelines/builds/aaos_builder/groovy/job.groovy b/workloads/android/pipelines/builds/aaos_builder/groovy/job.groovy index 151a57f2..38b5759c 100644 --- a/workloads/android/pipelines/builds/aaos_builder/groovy/job.groovy +++ b/workloads/android/pipelines/builds/aaos_builder/groovy/job.groovy @@ -1,4 +1,4 @@ -// Copyright (c) 2025 Accenture, All Rights Reserved. +// Copyright (c) 2025-2026 Accenture, All Rights Reserved. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -11,6 +11,9 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. + +def model = ('${GEMINI_MODEL}' && '${GEMINI_MODEL}' != 'null' && !'${GEMINI_MODEL}'.contains('${')) ? "--model ${'${GEMINI_MODEL}'} " : "" + pipelineJob('Android/Builds/AAOS Builder') { description("""

    Android Automotive Virtual Devices and Platform Targets Builder

    @@ -22,13 +25,21 @@ pipelineJob('Android/Builds/AAOS Builder') {
  • Reference hardware platforms such as RPi and Pixel Tablets
  • CTS development, reference Compatibility Test Suite and CTS Trade Federataion.
  • -

    For CTS development builds, select a cuttlefish variety of AAOS_LUNCH_TARGET and enable AAOS_BUILD_CTS to build and create android-cts.zip for use in the CTS Execution test job.

    +

    For CTS development builds, select a cuttlefish variety of AAOS_LUNCH_TARGET and enable AAOS_BUILD_CTS to build and create android-cts.zip for use in the CTS Execution test job. +
    Note: only the test suite will be built when this option is selected (no other target images will be built).

    +

    Build Outputs

    Build outputs are stored in a Google Cloud Storage bucket (refer to build artifact for location).

    Viewing Artifacts on Google Cloud

    Sign in to Google Cloud and run the following command:
    gcloud storage ls gs://${ANDROID_BUILD_BUCKET_ROOT_NAME}/Android/Builds/AAOS_Builder/<BUILD_NUMBER>



    """) + environmentVariables { + env('GEMINI_PREVIEW_FEATURES', ${GEMINI_PREVIEW_FEATURES}) + env('GEMINI_LOCATION_GLOBAL', ${GEMINI_LOCATION_GLOBAL}) + env('GEMINI_MODEL', '${GEMINI_MODEL}') + } + parameters { stringParam { name('AAOS_GERRIT_MANIFEST_URL') @@ -55,7 +66,9 @@ pipelineJob('Android/Builds/AAOS Builder') { name('AAOS_BUILD_CTS') defaultValue(false) description('''

    Build the Android Automotive Compatibility Test Suite.
    - Only applicable for CF lunch targets, i.e aosp_cf.

    ''') + Only applicable for CF lunch targets, i.e aosp_cf.
    + Note: only the test suite will be built when this option is selected (no other target images will be built). +

    ''') } choiceParam { @@ -121,6 +134,36 @@ pipelineJob('Android/Builds/AAOS Builder') { choices(['0', '15', '30', '45', '60', '120', '180']) } + separator { + name('Agentic AI: Configuration (Experimental)') + sectionHeader('Agentic AI: Configuration (Experimental)') + sectionHeaderStyle("${HEADER_STYLE}") + separatorStyle("${SEPARATOR_STYLE}") + } + + booleanParam { + name('ENABLE_GEMINI_AI_ASSISTANT') + defaultValue(true) + description('''

    Enable Gemini AI assistant to provide potential solutions to any build failures.

    ''') + } + + stringParam { + name('GEMINI_COMMAND_LINE') + defaultValue("gemini ${model} --yolo --output-format json") + description('''

    The headless gemini cli.

    +

    Use this to define such things as Gemini model to use.

    +

    Note: We use stdin to pipe the prompt to gemini-cli and redirect output to json file.

    ''') + trim(true) + } + + choiceParam { + name('GEMINI_AI_EXECUTION_TIMEOUT') + description(''' +

    Maximum duration allowed for the Gemini assistant to run before automatic termination.

    +

    Note: This safety limit prevents hung processes and optimizes resource usage.

    ''') + choices(['1', '2', '4', '8']) + } + separator { name('AOSP Mirror Parameters') sectionHeader('AOSP Mirror Parameters') @@ -214,8 +257,8 @@ pipelineJob('Android/Builds/AAOS Builder') { } logRotator { - daysToKeep(60) - numToKeep(200) + daysToKeep(7) + numToKeep(50) } definition { @@ -224,10 +267,10 @@ pipelineJob('Android/Builds/AAOS Builder') { scm { git { remote { - url("${HORIZON_GIT_URL}") - credentials('jenkins-git-creds') + url("${HORIZON_SCM_URL}") + credentials('jenkins-scm-creds') } - branch("*/${HORIZON_GIT_BRANCH}") + branch("*/${HORIZON_SCM_BRANCH}") } } scriptPath('workloads/android/pipelines/builds/aaos_builder/Jenkinsfile') diff --git a/workloads/android/pipelines/builds/aaos_builder/helm/Chart.yaml b/workloads/android/pipelines/builds/aaos_builder/helm/Chart.yaml new file mode 100644 index 00000000..01d8e75b --- /dev/null +++ b/workloads/android/pipelines/builds/aaos_builder/helm/Chart.yaml @@ -0,0 +1,22 @@ +# Copyright (c) 2026 Accenture, All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# Description: +# Helm chart metadata for the AAOS builder workflow. +apiVersion: v2 +name: aaos-builder +description: Helm chart for AAOS builder workflow + sample CR +type: application +version: 0.1.0 +appVersion: "1.0" diff --git a/workloads/android/pipelines/builds/aaos_builder/helm/README.md b/workloads/android/pipelines/builds/aaos_builder/helm/README.md new file mode 100644 index 00000000..e88cc811 --- /dev/null +++ b/workloads/android/pipelines/builds/aaos_builder/helm/README.md @@ -0,0 +1,398 @@ +# AAOS Builder Helm Chart + +This chart installs the AAOS builder Argo WorkflowTemplate. Run workflows with +`argo submit --from workflowtemplate/aaos-builder -n ` or via Argo CD. + +## What’s in this chart + +- `Chart.yaml`: Helm chart metadata. +- `values.yaml`: Default values; update this for configuration. +- `templates/workflow/workflowtemplates.yaml`: Argo WorkflowTemplate wrapper for the AAOS build pipeline. +- `templates/workflow/_templates.tpl`: Aggregates all split workflow templates. +- `templates/workflow/_main.tpl`: Main DAG task graph. +- `templates/workflow/_compute-vars.tpl`: Computes derived values (e.g., SDK version). +- `templates/workflow/_check-aaos-image.tpl`: Checks for the builder image in Artifact Registry. +- `templates/workflow/_clean.tpl`: Clean step template. +- `templates/workflow/_init.tpl`: Init template. +- `templates/workflow/_build.tpl`: Build template. +- `templates/workflow/_main.tpl`: Includes the ai-review step (no separate _ai-review.tpl); ai-review invokes the `ai-review` ClusterWorkflowTemplate. +- `templates/workflow/_storage.tpl`: Storage/artifact template. +- `templates/_env.tpl`: Shared Helm env helpers used in the WorkflowTemplate. +- `README.md`: This document. + +## Why split templates + +The workflow templates are split into per-step files to keep each task easy to find, +review, and maintain. The `templates/workflow/_templates.tpl` file aggregates them +in a stable order. + +## Prerequisites + +- Argo Workflows installed in the cluster +- `kubectl` and `helm` available locally (or Argo CD to sync this chart) +- The `aaos-builder-runtime-image` chart applied (WorkflowTemplate); runtime image tag must match `spec.imageBuild.imageTag` (default `argowf-latest`, distinct from a shared `:latest` tag in Artifact Registry) +- ClusterWorkflowTemplate `common-docker-image-build` from Module Manager module **`workloads-common`** (Argo child Application, wave **2**). WorkflowTemplate **`ai-review`** from **`workloads/common/agentic-ai/gemini/helm`** is a source on **`workloads-android`** (wave **3**). **WorkflowTemplates** use resource sync wave **8**. +- GKE **Workload Identity** bound for the workflow Kubernetes service account (charts do not mount a GCP JSON key) + +## Platform env (gitops ConfigMap) + +GitOps installs a ConfigMap `horizon-workflow-cloud-env` in the workflows namespace (see `gitops/templates/argo-workflows-init.yaml`) with **`CLOUD_PROJECT`**, **`CLOUD_REGION`**, **`CLOUD_ZONE`**, and **`HORIZON_DOMAIN`** (from gitops `config`, e.g. `config.domain`). With **`cloudEnvConfigMapName: "horizon-workflow-cloud-env"`** (default in `values.yaml`), each workflow step’s `container` / `script` includes **`envFrom`** that ConfigMap and **does not** duplicate those variables in per-step `env` blocks (see `templates/_env.tpl` define `aaos-builder.cloudEnvFrom`). **`STORAGE_LABELS`** comes only from **`workflow.parameters.storageLabels`** / **`spec.storageLabels`**, not the ConfigMap. + +For local clusters without that ConfigMap, set **`cloudEnvConfigMapName: ""`** (see `values-local.yaml`) and keep **`spec.cloudProject`**, **`spec.cloudRegion`**, **`spec.cloudZone`**, and **`spec.horizonDomain`** set. Helm-templated fields such as **`STORAGE_BUCKET_BASE`** and **`aaos-builder.builderImage`** still use `spec.cloud*`; keep them aligned with the cluster ConfigMap when both are used. + +## Pipeline repo (git workspace, non-local) + +On **GKE** (no **`localRepoHostPath`** / **`localRepoPvcName`**), init/clean/build/storage/ai-review use **Argo `git` artifacts** so **`/workspace`** is populated from Git. Repo URL and revision are **workflow parameters** **`pipelineRepoUrl`** and **`pipelineRepoRevision`**, with defaults from **`spec.pipelineRepoUrl`** and **`spec.pipelineRepoRevision`** in `values.yaml`. + +**Platform GitOps** deploys this chart (and **aaos-builder-runtime-image**) as **Helm sources** on **`workloads-android`** (`gitops/modules/workloads-android` via Module Manager), setting **`spec.pipelineRepoUrl`** and **`spec.pipelineRepoRevision`** from **`config.workloads.android.url`** and **`config.workloads.android.branch`** (passed through **`MODULE_CONFIG`**). Enable module **`workloads-android`** in Module Manager so that Application exists. + +For **manual** installs or overrides, set **`spec.*`** to match those gitops keys. Example: + +```bash +helm template aaos-builder workloads/android/pipelines/builds/aaos_builder/helm \ + --set spec.pipelineRepoUrl='https://github.com/your-org/your-horizon-fork.git' \ + --set spec.pipelineRepoRevision='your-branch' \ + | kubectl apply -f - +``` + +For a **per-run** override: `argo submit --from workflowtemplate/aaos-builder -n -p pipelineRepoUrl=... -p pipelineRepoRevision=...`. + +**Private HTTPS repos:** Argo **`git` artifacts** use HTTPS username/password on the template. GitOps (**`gitops/templates/argo-workflows-init.yaml`**) wires credentials from GSM: + +- **`scm.authMethod: userpass`** — GSM backs **`workflow-pipeline-git-creds`** (see **`{{ namespacePrefix }}scm-password-b64`**). For a **remote** pipeline repo, the DAG runs **ClusterWorkflowTemplate** **`prepare-pipeline-git-creds`**, which copies **`workflow.parameters.pipelineStaticGitSecretName`** (default **`workflow-pipeline-git-creds`**, or **`spec.pipelineRepoSecret`** from **`workloads-android`**) into **`{{workflow.uid}}-pipeline-git-creds`** so git artifacts use the same per-run Secret shape as **app**. +- **`scm.authMethod: app`** — ExternalSecret **`workflow-github-app`** (keys **`github-app-id-b64`**, **`github-app-private-key-pkcs8-b64`**, **`github-app-installation-id-b64`**) and **`prepare-pipeline-git-creds`**, which delegates to **`prepare-github-app-git-creds`** to mint a short-lived installation token into **`{{workflow.uid}}-pipeline-git-creds`**. With a **remote** repo, **`refresh-pipeline-git-creds-after-build`** runs the same umbrella after **`build`** so **`storage`** and **`ai-review`** clones use a fresh token. + +**Local development** (`values-local.yaml` with **hostPath** or **PVC**): **git** artifacts are **omitted**; the pipeline tree is expected from the **mounted** path (`localRepoMountPath`). **`spec.pipelineRepoUrl`** / **`spec.pipelineRepoRevision`** are not used for those steps. + +## Same cluster as GitOps: pause sync, local `helm apply`, resync + +If Argo CD manages this WorkflowTemplate (via **`workloads-android`**), a manual `helm template | kubectl apply` (for example with **`-f values-local.yaml`**) can be **reverted** when Argo syncs from Git. To test local manifests without losing them immediately: + +1. **Find the Application** (with GitOps **`namespacePrefix`**, e.g. **`dev-workloads-android`** in **`argocd`**): + + ```bash + kubectl get applications -A | grep workloads-android + ``` + +2. **Pause automated sync** (replace `APP_NAME` and `ARGOCD_NS` with values from step 1): + + ```bash + kubectl patch application APP_NAME \ + -n ARGOCD_NS \ + --type=json \ + -p='[{"op":"remove","path":"/spec/syncPolicy/automated"}]' + ``` + +3. **Apply** your chart (example with local overrides): + + ```bash + helm template aaos-builder \ + workloads/android/pipelines/builds/aaos_builder/helm \ + -f workloads/android/pipelines/builds/aaos_builder/helm/values-local.yaml \ + | kubectl apply -n workflows -f - + ``` + +4. **Resync from Git** when you want the cluster to match the repo again (one-off): + + ```bash + argocd app sync APP_NAME + ``` + + Or use the Argo CD UI **Sync** button for that Application. + +5. **Re-enable continuous sync** (optional): + + ```bash + kubectl patch application APP_NAME \ + -n ARGOCD_NS \ + --type=merge \ + -p '{"spec":{"syncPolicy":{"automated":{}}}}' + ``` + +While automated sync is off, avoid running a **Sync** on **`workloads-android`** if you still want to keep hand-applied YAML; a sync reapplies Git and overwrites **all** sources for that Application. + +## Workload Identity (recommended) + +1) Bind your Kubernetes service account to a Google service account. +2) Grant the GSA `roles/artifactregistry.reader` (and writer if you push images). +3) Set **`spec.useElevatedWorkflowIam: true`** (or **`spec.serviceAccountName`**) to the bound KSA per your environment. + +**Terraform / Horizon SDV defaults:** by default `spec.serviceAccountName: workflow-executor` is bound to `gke-argo-workflows-sa` (`terraform/env/main.tf`, `sdv_wi_service_accounts` entry `argo_workflows`). For elevated workload IAM, set **`spec.useElevatedWorkflowIam: true`** so pods use `workflow-executor-elevated`, bound via **`argo_workflows_elevated`** to `gke-argo-workflows-elevated-sa`. Example: `helm upgrade --install ... --set spec.useElevatedWorkflowIam=true`. The Argo Workflows controller runs in namespace `argo-workflows` with `argo-workflows-server` (not used by these WorkflowTemplates). + +**Sub-environment namespaces (GitOps `namespacePrefix`):** leave `namespace` empty and set `namespacePrefix` to match GitOps (e.g. `dev-`) so the WorkflowTemplate is created in `dev-workflows`. Example: `helm upgrade --install ... --set namespacePrefix=dev-`. To pin an exact name, set `namespace` (overrides the prefix rule). + +## Deploy + +```bash +helm template aaos-builder \ + workloads/android/pipelines/builds/aaos_builder/helm \ + | kubectl apply -f - +``` + +## Run (from WorkflowTemplate) + +```bash +argo submit --from workflowtemplate/aaos-builder -n +``` + +## Archived step logs (`main.log`) in GCS + +When the cluster’s Argo Workflows controller is configured with **`artifactRepository.archiveLogs: true`** and a **GCS artifact repository** (Horizon SDV: **`gitops/templates/argo-workflows.yaml`**, bucket pattern **`-argo-workflows`**), archived workflow logs—including **`main.log`** per step—are stored in that bucket under **per-workflow prefixes** (for example `aaos-builder-/...` for nested steps such as **ai-review**). + +The WorkflowTemplate also sets **`spec.archiveLogs: true`** so each submitted workflow explicitly opts in to log archival (same behavior as relying on the controller default alone). + +**``** is the GCP project ID from gitops **`config.projectID`** (same project as the cluster; e.g. `sdva-2108202401-argo-workflows`). + +For details, timestamps, and an example Cloud Console link, see **`docs/guides/argo_workflows_log_timestamps.md`** (section: *Archived logs in GCS (`main.log`)*). + +## Update after changes + +Re-apply the chart: + +```bash +helm template aaos-builder \ + workloads/android/pipelines/builds/aaos_builder/helm \ + | kubectl apply -f - +``` + +If a WorkflowTemplate name changed, re-submit workflows afterward. + +## Local development (hostPath) + +For local clusters (Docker Desktop/Kind), you can mount the pipeline repo +from your workstation using `hostPath`: + +```yaml +# values-local.yaml +localRepoHostPath: "/absolute/path/to/your/repo" +localRepoMountPath: "/workspace-local" +``` + +Apply with the override: + +```bash +helm template aaos-builder \ + workloads/android/pipelines/builds/aaos_builder/helm \ + -f values-local.yaml | kubectl apply -f - +``` + +Note: leave `localRepoHostPath` empty on GKE or other remote clusters. + +## Local changes on GKE (PVC-backed repo) + +On GKE you cannot mount your macOS filesystem directly. Instead, sync your +local repo into a PVC, then mount that PVC into the workflow as the repo root. + +## Warning + +When using local, there is only one PVC for `workspace-local`, so only run a +single workflow at a time. This workspace cannot be shared across workflows and +is only intended for local development. + +Diagram: + +![AAOS Builder Local Repo Diagram](assets/aaos-builder-local-repo-architecture.png) + +1) Sync your local repo into a PVC: + +```bash +tools/scripts/repo-sync-pvc.sh \ + -n workflows \ + -p workloads-repo-pvc \ + -s "/absolute/path/to/your/repo" +``` + +Note: the script will **create the PVC if it does not exist** (default: `ReadWriteOnce`, `20Gi`). If you need a different size / storageClass / access mode, create the PVC yourself before running the sync. + +Note: the script copies repo contents into the mount root so +`/workspace-local/workloads/...` resolves correctly. + +To skip large or unnecessary directories during copy: + +```bash +tools/scripts/repo-sync-pvc.sh \ + -n workflows \ + -p workloads-repo-pvc \ + -s "/absolute/path/to/your/repo" + --exclude-git \ + --exclude-terraform \ + --clean +``` + +2) Set values-local.yaml: + +```yaml +# values-local.yaml +localRepoPvcName: "workloads-repo-pvc" +localRepoMountPath: "/workspace-local" +``` + +Apply with the override: + +```bash +helm template aaos-builder \ + workloads/android/pipelines/builds/aaos_builder/helm \ + -f values-local.yaml | kubectl apply -f - + +# Create a workflow +argo submit --from workflowtemplate/aaos-builder -n workflows +``` + +## Verify mounts and paths + +```bash +kubectl -n workflows exec -- sh -c 'ls -la /workspace-local | head' +kubectl -n workflows exec -- sh -c 'ls -la /workspace-local/workloads/android/pipelines/builds/aaos_builder | head' +``` + +## Release a stuck PV (Retain policy) + +If a PV stays `Bound` to an old per‑workflow PVC: + +1) Delete the PVC: + +```bash +kubectl -n workflows delete pvc +``` + +2) If deletion hangs, remove the finalizer: + +```bash +kubectl -n workflows patch pvc --type=json \ + -p '[{"op":"remove","path":"/metadata/finalizers"}]' +``` + +3) Clear the PV claimRef to make it `Available`: + +```bash +kubectl patch pv -p '{"spec":{"claimRef":null}}' +``` + +## Workspace paths + +`/workspace` is pod-local and not shared between tasks. Only paths on the +shared PVC (e.g., `/aaos-cache/aaos_builds`) persist across workflow steps. + +## Local repo vs shared workspace + +Use a local repo mount (`localRepoHostPath` or `localRepoPvcName`) when you want +the workflow to use a pre-synced repo. In that case, `PIPELINE_REPO_ROOT` points +to `/workspace-local` and the shared `/workspace` PVC can be used for +pod-to-pod artifacts. + +Local repo PVCs are typically **RWO**, so only **one workflow at a time** should +use the local repo mount. External repo checkouts (`pipelineRepoUrl`) do not +share a PVC, so **multiple workflows can run in parallel**. + +## Common Options (values.yaml) + +- `namespace`: Namespace where WorkflowTemplate is installed +- `workflowTemplateName`: WorkflowTemplate name +- `localRepoHostPath`: hostPath for local clusters (empty on GKE) +- `localRepoPvcName`: PVC for local repo on GKE +- `localRepoMountPath`: Mount path for local repo +- `spec.lunchTarget`: AAOS lunch target +- `spec.androidRevision`: AAOS branch/tag +- `spec.cleanBuild`: `NO_CLEAN`, `CLEAN_BUILD`, `CLEAN_ALL` +- `spec.buildCtsOnly`: `true` to build CTS only +- `spec.forceImageBuild`: `true` to always rebuild the builder image +- `spec.parallelSyncJobs`: Repo sync parallelism +- `spec.enableGeminiAiAssistant`: `"true"` / `"false"` — enables ai-review on build failure (WorkflowTemplate default parameter) +- `spec.geminiSkillsYaml`: **required** path to `skills.yaml` for the ai-review step (`GEMINI_SKILLS_YAML`) +- `spec.manifestUrl`: Manifest URL for repo init +- `spec.pipelineRepoUrl`: Pipeline repo URL +- `spec.pipelineRepoRevision`: Pipeline repo revision +- `spec.pipelineRepoSecret`: Optional git credentials secret +- `spec.serviceAccountName`: Service account for workflow pods +- `spec.volumeClaimGcStrategy`: `OnWorkflowCompletion` to release per-workflow PVCs +- `spec.workflowTtlSecondsAfterCompletion`: Set seconds to auto-delete workflows +- `podGcStrategy`: Pod cleanup strategy + +### Gerrit credentials (repo sync) + +The chart sets **`GERRIT_CREDENTIALS_SECRET`** to **`jenkins-gerrit-http-password`** and **`GERRIT_USERNAME_KEY`** / **`GERRIT_PASSWORD_KEY`** to **`username`** / **`password`** (see `templates/_env.tpl`). GitOps defines that Secret in the **`jenkins`** namespace (`gitops/templates/jenkins-init.yaml`). Workflow pods run in **`workflows`**; if a step resolves it as a **namespaced** Kubernetes Secret, mirror the same object (same name and keys) into the workflow namespace or adjust consumption. The **`jenkins-gerrit-http-password`** id is shared across the platform—do not change it in Helm without renaming it everywhere it is created or referenced. + +## Logs, pod status on error, and GC + +Workflow step pods are labeled with the workflow name. Use that to list what is still running or recently failed: + +```bash +kubectl get pods -n -l workflows.argoproj.io/workflow= -o wide +``` + +**Logs** + +- Follow all logs for a workflow (while pods exist): `argo logs -n --follow` +- One step: `argo logs -n ` (see `argo get` for node IDs) +- Direct from Kubernetes: `kubectl logs -n -c main` (Argo’s user container is usually `main`; init uses `init`, sidecar `wait`) + +If the main container restarted, inspect the previous run: `kubectl logs -n -c main --previous`. + +**Pod status / exit reason** + +- `kubectl describe pod -n ` — **State**, **Exit Code**, **Reason** (e.g. `Error`, `OOMKilled`), **Events**, and **Restart Count** +- Match **``** to the failing step (e.g. `…-build-…`, `…-storage-…`, `…-ai-review-…`). The **build** task’s pod reflects a real compile failure; use its logs first when the build breaks. + +**How this interacts with `podGC` (`podGcStrategy`)** + +This chart sets **`podGcStrategy`** on the WorkflowTemplate (default in `values.yaml`: **`OnPodSuccess`**). Argo deletes **succeeded** task pods according to that strategy (often soon after the step succeeds), so **storage** or **init** pods may disappear from `kubectl get pods` quickly even while the workflow is still running. **Failed** pods are usually kept longer so you can inspect them, but behavior is controlled by the Argo version and full **`podGC`** spec—if you only see one pod (e.g. a late failing step), others may already have been garbage-collected. + +- To retain completed pods longer for debugging, consider a looser strategy (e.g. **`OnWorkflowCompletion`**) or adjust Argo’s **`podGC`** options if your controller version supports **`deleteDelayDuration`**—see [Argo pod garbage collection](https://argo-workflows.readthedocs.io/en/latest/fields/#podgc). +- Do not confuse **pod GC** with **`spec.workflowTtlSecondsAfterCompletion`**: TTL removes the **Workflow** custom resource after completion. After TTL, **`kubectl get wf`** and the Argo UI may no longer show the run unless you use **workflow archiving** (see above). Collect logs or export **`kubectl get workflow -o yaml`** before TTL if you need a permanent record. + +**Summary:** For errors, use **`argo logs`** / **`kubectl logs`** and **`kubectl describe pod`** while pods exist; expect **successful** step pods to vanish early under **`OnPodSuccess`**; use **workflow YAML**, **archive**, or central logging if pods or the Workflow CR are already gone. + +## AI review (Gemini) + +When a build fails and `spec.enableGeminiAiAssistant` is enabled, the workflow runs the **ai-review** ClusterWorkflowTemplate (Helm chart `workloads/common/agentic-ai/gemini/helm/`). You can control behaviour with these options in `values.yaml` (under `spec`): + +- **`spec.geminiPromptFile`** / **`spec.geminiPromptFile2`** / **`spec.geminiPromptFile3`** – Sequenced prompts (defaults: `prompt/sequenced/step1_triage.txt`, `step2_rca.txt`, `step3_fixes.txt` under `/workspace/.../aaos_builder/`). +- **`spec.geminiSkillsYaml`** – **Required.** Path to `skills.yaml` for `gemini_initialise` (default: same folder as the sequenced prompts). The ai-review ClusterWorkflowTemplate passes this as `GEMINI_SKILLS_YAML`; with a local repo mount, `/workspace/...` is rewritten to `localRepoMountPath` like the prompt paths. +- **`spec.geminiModel`** – Optional model pin. When set, the workflow passes `gemini --model --yolo --output-format json` to ai-review; when empty, **`spec.geminiCommandLine`** is used as-is. +- **`spec.geminiPreviewFeatures`** / **`spec.geminiLocationGlobal`** – Preview and location settings for Vertex AI. With preview disabled and a non-global location, set **`spec.geminiModel`** or add **`--model `** to **`spec.geminiCommandLine`** so the CLI does not auto-select preview models. +- **`spec.geminiCommandLine`** – Full Gemini CLI invocation when **`spec.geminiModel`** is empty (e.g. `gemini --yolo --output-format json`). + +## Test / Verify + +```bash +kubectl get workflowtemplate -n aaos-builder +kubectl get wf -n +kubectl get pod -n +``` + +## Optional: Archive Workflow parameters in Argo UI + +To retain Workflow parameters for completed runs (so they remain visible in the +Argo UI after GC/TTL), enable Argo Workflow archiving in the controller config: + +```yaml +# workflow-controller-configmap +data: + persistence: | + archive: true +``` + +This requires Argo persistence to be configured (Postgres/MySQL). Once enabled, +completed workflows appear under “Archived” in the Argo UI and include the +original parameters. + +## Artifacts (optional) + +To enable Argo artifact downloads (UI/CLI), configure an artifact repository +in the Argo controller config (e.g., GCS/S3/MinIO). Example (GCS): + +```yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: argo-workflows-config + namespace: argo +data: + artifactRepository: | + gcs: + bucket: + keyFormat: "{{workflow.name}}/{{pod.name}}/{{artifact.name}}" +``` + +This workflow exports `build-info` and `build-log` artifacts from the `storage` step. + diff --git a/workloads/android/pipelines/builds/aaos_builder/helm/assets/aaos-builder-local-repo-architecture.png b/workloads/android/pipelines/builds/aaos_builder/helm/assets/aaos-builder-local-repo-architecture.png new file mode 100644 index 00000000..99c48cfe Binary files /dev/null and b/workloads/android/pipelines/builds/aaos_builder/helm/assets/aaos-builder-local-repo-architecture.png differ diff --git a/workloads/android/pipelines/builds/aaos_builder/helm/templates/_env.tpl b/workloads/android/pipelines/builds/aaos_builder/helm/templates/_env.tpl new file mode 100644 index 00000000..a82bc723 --- /dev/null +++ b/workloads/android/pipelines/builds/aaos_builder/helm/templates/_env.tpl @@ -0,0 +1,92 @@ +{{- /* +Copyright (c) 2026 Accenture, All Rights Reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +Description: +Helm helpers for common env blocks in AAOS workflows. +*/ -}} + +{{- define "aaos-builder.cloudEnvFrom" -}} +{{- if .Values.cloudEnvConfigMapName }} +envFrom: + - configMapRef: + name: {{ .Values.cloudEnvConfigMapName | quote }} +{{- end }} +{{- end }} + +{{- define "aaos-builder.commonEnv" -}} +- name: WORKSPACE +{{- if or .Values.localRepoHostPath .Values.localRepoPvcName }} + value: /workspace +{{- else }} + value: {{ include "aaos-builder.pipelineMonorepoPath" . | quote }} +{{- end }} +- name: PIPELINE_REPO_ROOT +{{- if or .Values.localRepoHostPath .Values.localRepoPvcName }} + value: {{ .Values.localRepoMountPath | quote }} +{{- else }} + value: {{ include "aaos-builder.pipelineMonorepoPath" . | quote }} +{{- end }} +- name: AAOS_LUNCH_TARGET + value: '{{ "{{" }}workflow.parameters.lunchTarget{{ "}}" }}' +- name: AAOS_REVISION + value: '{{ "{{" }}workflow.parameters.androidRevision{{ "}}" }}' +- name: AAOS_CLEAN + value: '{{ "{{" }}workflow.parameters.cleanBuild{{ "}}" }}' +- name: AAOS_BUILD_CTS + value: '{{ "{{" }}workflow.parameters.buildCtsOnly{{ "}}" }}' +- name: AAOS_ARTIFACT_STORAGE_SOLUTION + value: "GCS_BUCKET" +- name: STORAGE_BUCKET_BASE + value: 'gs://{{ .Values.spec.cloudProject }}-aaos/Android/Builds/AAOS_Builder' +- name: WORKFLOW_NAME + # Argo generateName (e.g., aaos-builder-6v79q). Storage step composes -