diff --git a/README.md b/README.md index 3dc4709..9704c63 100644 --- a/README.md +++ b/README.md @@ -2,35 +2,90 @@ [![Release](https://github.com/skyhook-io/create-deployment-matrix/actions/workflows/release.yml/badge.svg)](https://github.com/skyhook-io/create-deployment-matrix/actions/workflows/release.yml) -A GitHub Action that creates a deployment matrix for services based on Koala monorepo configuration files (`.koala-monorepo.json` and `.koala.toml`). This action intelligently reads your monorepo structure and generates a GitHub Actions matrix for multi-service, multi-environment deployments. +A GitHub Action that generates a deployment matrix for multi-service, multi-environment deployments. It reads your repository's configuration to produce a GitHub Actions `strategy.matrix` JSON object, enabling parallel deployments across services and environments. -## Why This Action? +## Configuration Formats -1. **Monorepo-aware**: Automatically discovers services from `.koala-monorepo.json` -2. **Environment-based deployment**: Uses `.koala.toml` to determine deployment configurations per environment -3. **Flexible filtering**: Deploy to specific environments or all environments at once -4. **Matrix optimization**: Generates optimized GitHub Actions matrices for parallel deployments -5. **Tag management**: Handles image tag configuration for deployments -6. **GitOps-ready**: Perfect for GitOps workflows with Kustomize overlays +The action supports two configuration formats. Both can coexist in the same repository — their matrices are merged with deduplication by `service_name + overlay`. -## Use Cases +### Skyhook (`.skyhook/skyhook.yaml`) -- **Multi-service deployments**: Deploy multiple services from a monorepo in parallel -- **Environment-specific rollouts**: Deploy to dev, staging, production with different configurations -- **Feature branch deployments**: Dynamically create deployment matrices for feature branches -- **Automated releases**: Integrate with CI/CD pipelines for automated service deployments -- **GitOps workflows**: Generate deployment configurations for ArgoCD, Flux, or manual Kustomize +The Skyhook format defines services and environments in a single YAML file at `.skyhook/skyhook.yaml`. -## Prerequisites +**Environment discovery** works in two ways depending on whether a service has a `deploymentRepo`: -Your repository should have: -- `.koala-monorepo.json` file at the root defining your services -- `.koala.toml` files in service directories with deployment configuration -- `workflow-utils` npm package available (installed automatically via npx) +- **Without `deploymentRepo`**: environments are read from the `environments[]` array in `skyhook.yaml` (local path). +- **With `deploymentRepo`**: environments are discovered from the remote deployment repository — overlay directories are listed from `{deploymentRepoPath}/overlays/`, and environment details (cluster, cloud provider, account, location, namespace) are read from `skyhook/environments/{name}.yaml` files in that repo. + +This means different services can have different sets of environments — one service might deploy to `dev` and `staging` (from its deployment repo), while another deploys to `dev`, `staging`, and `prod` (from the local config or a different deployment repo). + +#### `skyhook.yaml` + +```yaml +services: + - name: api-gateway + path: apps/api-gateway + deploymentRepo: my-org/deployment-repo # environments discovered from remote repo + deploymentRepoPath: api-gateway # path within deployment repo (defaults to service name) + - name: worker + path: apps/worker + # no deploymentRepo — uses local environments[] below + +environments: # used by services without deploymentRepo + - name: dev + clusterName: nonprod-cluster + cloudProvider: gcp + account: my-project-nonprod + location: us-east1-b + namespace: dev + - name: prod + clusterName: prod-cluster + cloudProvider: gcp + account: my-project-prod + location: us-east1-b + namespace: prod +``` + +#### Remote deployment repo structure + +For services with `deploymentRepo`, the action clones the repo (shallow, `--depth 1`) and reads: + +``` +deployment-repo/ +├── api-gateway/ +│ └── overlays/ +│ ├── dev/ # each directory = one environment +│ ├── staging/ +│ └── prod/ +└── skyhook/ + └── environments/ + ├── dev.yaml # environment details + ├── staging.yaml + └── prod.yaml +``` + +Each environment file (`skyhook/environments/{name}.yaml`): + +```yaml +clusterName: my-cluster +cloudProvider: gcp +account: my-project-id +location: us-central1 +namespace: default +autoDeploy: true +``` + +The environment `name` comes from the filename, not from inside the file. If an environment YAML file is missing, the overlay is still included with only its name populated. + +Multiple services can reference the same deployment repo — it is cloned once and shared. Clone and environment config caches are keyed by `repo:branch` and `repo:branch:envName` respectively, so different deployment repos with same-named environments never collide. + +### Koala (legacy) + +The Koala format uses `.koala-monorepo.json` at the repository root to list services and `.koala.toml` files per service for environment configuration. Processing is handled by the external `workflow-utils` CLI (installed automatically via `npx`). ## Usage -### Basic Example - All Environments +### Basic — all environments ```yaml - name: Create deployment matrix @@ -43,126 +98,34 @@ Your repository should have: - name: Deploy services strategy: matrix: ${{ fromJson(steps.matrix.outputs.matrix) }} + fail-fast: false runs-on: ubuntu-latest steps: - - name: Deploy ${{ matrix.service }} to ${{ matrix.environment }} - run: | - echo "Deploying ${{ matrix.service }} version ${{ matrix.tag }} to ${{ matrix.environment }}" + - run: echo "Deploying ${{ matrix.service_name }} (${{ matrix.service_tag }}) to ${{ matrix.overlay }}" ``` -### Filter by Environment +### Filter by environment ```yaml -- name: Create production deployment matrix +- name: Deploy to production only id: matrix uses: skyhook-io/create-deployment-matrix@v1 with: - overlay: production + overlay: prod tag: ${{ github.ref_name }} - branch: main github-token: ${{ secrets.GITHUB_TOKEN }} - -- name: Deploy to production - needs: matrix - strategy: - matrix: ${{ fromJson(steps.matrix.outputs.matrix) }} - runs-on: ubuntu-latest - steps: - - name: Deploy ${{ matrix.service }} - uses: skyhook-io/kustomize-deploy@v1 - with: - service: ${{ matrix.service }} - environment: ${{ matrix.environment }} - tag: ${{ matrix.tag }} -``` - -### Multi-Environment Deployment Pipeline - -```yaml -name: Deploy Services - -on: - push: - tags: - - 'v*' - -jobs: - create-matrix: - runs-on: ubuntu-latest - outputs: - matrix: ${{ steps.matrix.outputs.matrix }} - steps: - - uses: actions/checkout@v4 - - - name: Create deployment matrix - id: matrix - uses: skyhook-io/create-deployment-matrix@v1 - with: - tag: ${{ github.ref_name }} - github-token: ${{ secrets.GITHUB_TOKEN }} - - deploy: - needs: create-matrix - strategy: - matrix: ${{ fromJson(needs.create-matrix.outputs.matrix) }} - fail-fast: false - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Deploy ${{ matrix.service }} to ${{ matrix.environment }} - run: | - echo "Deploying service: ${{ matrix.service }}" - echo "Environment: ${{ matrix.environment }}" - echo "Tag: ${{ matrix.tag }}" - # Add your deployment logic here -``` - -### Feature Branch Deployments - -```yaml -name: Deploy Feature Branch - -on: - pull_request: - types: [opened, synchronize] - -jobs: - deploy-preview: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Create dev deployment matrix - id: matrix - uses: skyhook-io/create-deployment-matrix@v1 - with: - overlay: dev - branch: ${{ github.head_ref }} - tag: pr-${{ github.event.pull_request.number }} - github-token: ${{ secrets.GITHUB_TOKEN }} - - - name: Deploy preview environments - strategy: - matrix: ${{ fromJson(steps.matrix.outputs.matrix) }} - run: | - echo "Deploying preview for ${{ matrix.service }}" ``` -### Custom Repository Path +### Feature branch deployment ```yaml -- name: Checkout monorepo - uses: actions/checkout@v4 - with: - path: my-monorepo - -- name: Create matrix from custom path +- name: Deploy preview id: matrix uses: skyhook-io/create-deployment-matrix@v1 with: - repo-path: my-monorepo - tag: ${{ github.sha }} + overlay: dev + branch: ${{ github.head_ref }} + tag: pr-${{ github.event.pull_request.number }} github-token: ${{ secrets.GITHUB_TOKEN }} ``` @@ -171,284 +134,73 @@ jobs: | Input | Description | Required | Default | |-------|-------------|----------|---------| | `tag` | The image tag to deploy | Yes | - | -| `github-token` | GitHub token for API access | Yes | - | -| `overlay` | Environment/overlay filter (e.g., dev, staging, production) | No | (all) | -| `branch` | Branch to use for deployment context | No | `main` | +| `github-token` | GitHub token for API access and deployment repo cloning | Yes | - | +| `overlay` | Environment filter (e.g., `dev`, `staging`, `prod`). If omitted, all environments are included. | No | (all) | +| `branch` | Branch for deployment context and deployment repo cloning. If omitted, uses the remote's default branch (HEAD). | No | (HEAD) | | `repo-path` | Path to the repository root | No | `.` | ## Outputs | Output | Description | |--------|-------------| -| `matrix` | Parsed JSON matrix object ready for GitHub Actions `strategy.matrix` | -| `matrix-json` | Raw JSON string of the matrix for debugging or custom parsing | +| `matrix` | JSON string of the deployment matrix, ready for `strategy.matrix` via `fromJson()` | ## Matrix Output Format -The action generates a matrix with the following structure: - ```json { "include": [ { - "service": "api-service", - "environment": "dev", - "tag": "v1.2.3", - "overlay": "overlays/dev" - }, - { - "service": "api-service", - "environment": "production", - "tag": "v1.2.3", - "overlay": "overlays/production" - }, - { - "service": "web-service", - "environment": "dev", - "tag": "v1.2.3", - "overlay": "overlays/dev" - } - ] -} -``` - -Each matrix entry includes: -- `service`: Service name from `.koala-monorepo.json` -- `environment`: Target environment (dev, staging, production, etc.) -- `tag`: Image tag to deploy -- `overlay`: Kustomize overlay path (if applicable) - -## Configuration Files - -### .koala-monorepo.json - -Define your services at the repository root: - -```json -{ - "services": [ - { - "name": "api-service", - "path": "services/api" - }, - { - "name": "web-service", - "path": "services/web" - }, - { - "name": "worker-service", - "path": "services/worker" + "service_name": "api-gateway", + "service_dir": "apps/api-gateway", + "service_repo": "my-org/my-app", + "service_tag": "api-gateway_v1.2.3_01", + "deployment_repo": "my-org/deployment-repo", + "deployment_folder_path": "api-gateway", + "overlay": "dev", + "cluster": "nonprod-cluster", + "cluster_location": "us-east1-b", + "cloud_provider": "gcp", + "namespace": "dev", + "account": "my-project-nonprod", + "auto_deploy": "false" } ] } ``` -### .koala.toml - -Configure deployment settings per service: - -```toml -[deployment] -environments = ["dev", "staging", "production"] - -[deployment.dev] -replicas = 1 -resources = "small" - -[deployment.staging] -replicas = 2 -resources = "medium" - -[deployment.production] -replicas = 5 -resources = "large" -``` - -## How It Works - -1. **Validation**: Validates all required inputs and checks repository path -2. **Service Discovery**: Reads `.koala-monorepo.json` to identify services -3. **Configuration Parsing**: Extracts deployment settings from each service's `.koala.toml` -4. **Matrix Generation**: Uses `workflow-utils` to generate an optimized GitHub Actions matrix -5. **Filtering**: Applies environment filters if specified -6. **Output**: Returns both parsed and raw JSON formats - -## Examples - -### Complete CI/CD Pipeline - -```yaml -name: Build and Deploy - -on: - push: - branches: [main] - tags: ['v*'] - -jobs: - build: - runs-on: ubuntu-latest - strategy: - matrix: - service: [api-service, web-service, worker-service] - steps: - - uses: actions/checkout@v4 - - - name: Build and push ${{ matrix.service }} - uses: skyhook-io/docker-build-push-action@v1 - with: - context: services/${{ matrix.service }} - tags: | - ghcr.io/my-org/${{ matrix.service }}:${{ github.sha }} - ghcr.io/my-org/${{ matrix.service }}:latest - - deploy-staging: - needs: build - runs-on: ubuntu-latest - if: github.ref == 'refs/heads/main' - steps: - - uses: actions/checkout@v4 - - - name: Create staging matrix - id: matrix - uses: skyhook-io/create-deployment-matrix@v1 - with: - overlay: staging - tag: ${{ github.sha }} - github-token: ${{ secrets.GITHUB_TOKEN }} - - - name: Deploy to staging - strategy: - matrix: ${{ fromJson(steps.matrix.outputs.matrix) }} - run: | - kubectl set image deployment/${{ matrix.service }} \ - ${{ matrix.service }}=ghcr.io/my-org/${{ matrix.service }}:${{ matrix.tag }} - - deploy-production: - needs: build - runs-on: ubuntu-latest - if: startsWith(github.ref, 'refs/tags/v') - steps: - - uses: actions/checkout@v4 - - - name: Create production matrix - id: matrix - uses: skyhook-io/create-deployment-matrix@v1 - with: - overlay: production - tag: ${{ github.ref_name }} - github-token: ${{ secrets.GITHUB_TOKEN }} - - - name: Deploy to production - strategy: - matrix: ${{ fromJson(steps.matrix.outputs.matrix) }} - run: | - kubectl set image deployment/${{ matrix.service }} \ - ${{ matrix.service }}=ghcr.io/my-org/${{ matrix.service }}:${{ matrix.tag }} -``` - -### Progressive Rollout - -```yaml -name: Progressive Deployment - -on: - workflow_dispatch: - inputs: - version: - description: 'Version to deploy' - required: true - -jobs: - deploy-dev: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Deploy to dev - uses: skyhook-io/create-deployment-matrix@v1 - with: - overlay: dev - tag: ${{ inputs.version }} - github-token: ${{ secrets.GITHUB_TOKEN }} - - deploy-staging: - needs: deploy-dev - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Deploy to staging - uses: skyhook-io/create-deployment-matrix@v1 - with: - overlay: staging - tag: ${{ inputs.version }} - github-token: ${{ secrets.GITHUB_TOKEN }} - - deploy-production: - needs: deploy-staging - runs-on: ubuntu-latest - environment: production - steps: - - uses: actions/checkout@v4 - - name: Deploy to production - uses: skyhook-io/create-deployment-matrix@v1 - with: - overlay: production - tag: ${{ inputs.version }} - github-token: ${{ secrets.GITHUB_TOKEN }} -``` - -## Troubleshooting - -### Matrix is empty - -Check that: -1. `.koala-monorepo.json` exists at repository root -2. Services have `.koala.toml` files with deployment configuration -3. If using `overlay`, the environment exists in your `.koala.toml` files - -### Invalid JSON error - -This usually means: -1. The `workflow-utils` package failed to execute -2. Configuration files have syntax errors -3. Check the action logs for detailed error messages - -### Service not in matrix - -Verify: -1. Service is listed in `.koala-monorepo.json` -2. Service has a `.koala.toml` file -3. If using environment filter, the service is configured for that environment +| Field | Source | +|-------|--------| +| `service_name` | `skyhook.yaml` `services[].name` | +| `service_dir` | `skyhook.yaml` `services[].path` | +| `service_repo` | `GITHUB_REPOSITORY` env var | +| `service_tag` | Computed: `{service_name}_{tag}_{counter}` | +| `deployment_repo` | `skyhook.yaml` `services[].deploymentRepo` | +| `deployment_folder_path` | `skyhook.yaml` `services[].deploymentRepoPath` | +| `overlay` | Environment name | +| `cluster` | `environments[].clusterName` (local or remote) | +| `cluster_location` | `environments[].location` | +| `cloud_provider` | `environments[].cloudProvider` | +| `namespace` | `environments[].namespace` | +| `account` | `environments[].account` | +| `auto_deploy` | `environments[].autoDeploy` (default `false`) | + +## Service Tag Counters + +Each matrix entry gets a unique `service_tag` in the format `{service_name}_{tag}_{counter}` (e.g., `api-gateway_v1.2.3_01`). Counters are **per-service** and are seeded from two sources to prevent duplicate tags across multiple runs: + +1. **Existing git tags** — the action queries `git ls-remote --tags origin` for tags matching `{service_name}_{tag}_NN` and starts after the highest existing counter. +2. **Koala matrix output** — if both Koala and Skyhook configs are present, counters from the Koala matrix carry forward into the Skyhook matrix. ## Permissions -This action requires the following permissions: - ```yaml permissions: - contents: read # Read repository contents + contents: read ``` -## Dependencies - -- **workflow-utils**: Automatically installed via npx (no setup required) -- **Node.js**: Available in GitHub Actions runners by default -- **jq**: Used for JSON validation (pre-installed in GitHub runners) - -## Related Actions - -- [skyhook-io/git-sync-commit](https://github.com/skyhook-io/git-sync-commit) - Commit and push deployment changes -- [skyhook-io/kustomize-deploy](https://github.com/skyhook-io/kustomize-deploy) - Deploy with Kustomize -- [skyhook-io/docker-build-push-action](https://github.com/skyhook-io/docker-build-push-action) - Build and push Docker images - -## Contributing - -Contributions are welcome! Please feel free to submit a Pull Request. +The `github-token` must have read access to any deployment repos referenced by `services[].deploymentRepo`. ## License -MIT License - see [LICENSE](LICENSE) for details - -## Support - -For issues, questions, or contributions, please visit the [GitHub repository](https://github.com/skyhook-io/create-deployment-matrix). +MIT diff --git a/action.yml b/action.yml index b0ea9db..97d5046 100644 --- a/action.yml +++ b/action.yml @@ -1,5 +1,5 @@ name: 'Create Deployment Matrix' -description: 'Creates a matrix for service deployment based on .koala-monorepo.json and .koala.toml configuration' +description: 'Creates a matrix for service deployment based on .skyhook/skyhook.yaml (with optional remote deployment repo discovery) or .koala-monorepo.json/.koala.toml configuration' author: 'Skyhook' branding: @@ -11,9 +11,8 @@ inputs: description: 'The overlay/environment to use for kustomize (e.g., dev, staging, production). If not provided, all environments will be included.' required: false branch: - description: 'The branch to use for deployment' + description: 'The branch to use for deployment. If not provided, uses the remote default branch (HEAD).' required: false - default: 'main' tag: description: 'The image tag to deploy' required: true diff --git a/src/config/SkyhookConfig.js b/src/config/SkyhookConfig.js index 6c503bc..7c1ba8a 100644 --- a/src/config/SkyhookConfig.js +++ b/src/config/SkyhookConfig.js @@ -61,13 +61,14 @@ class SkyhookEnvironment { * @param {string} [params.location] - Cluster location/zone * @param {string} [params.namespace] - Kubernetes namespace */ - constructor({ name, clusterName, cloudProvider, account, location, namespace }) { + constructor({ name, clusterName, cloudProvider, account, location, namespace, autoDeploy }) { this.name = name; this.clusterName = clusterName; this.cloudProvider = cloudProvider; this.account = account; this.location = location; this.namespace = namespace; + this.autoDeploy = autoDeploy === true || autoDeploy === 'true'; } } diff --git a/src/deployment/repo-fetcher.js b/src/deployment/repo-fetcher.js new file mode 100644 index 0000000..992cc65 --- /dev/null +++ b/src/deployment/repo-fetcher.js @@ -0,0 +1,169 @@ +const core = require('@actions/core'); +const exec = require('@actions/exec'); +const fs = require('fs'); +const path = require('path'); +const yaml = require('js-yaml'); +const os = require('os'); +const { SkyhookEnvironment } = require('../config/SkyhookConfig'); + +/** + * Clone a deployment repo (shallow, cached by repo:branch). + * Tries each token in order until one succeeds. + * @param {string} repoFullName - e.g. "org/deploy-repo" + * @param {string} branch - branch to clone (empty string = remote HEAD) + * @param {string[]} githubTokens - GitHub tokens to try in priority order + * @param {Map} cloneCache - cache of repo:branch -> cloned path + * @returns {Promise} - path to cloned repo + */ +async function cloneDeploymentRepo(repoFullName, branch, githubTokens, cloneCache) { + const cacheKey = `${repoFullName}:${branch || 'HEAD'}`; + const branchLabel = branch || 'HEAD'; + + if (cloneCache.has(cacheKey)) { + core.info(`Using cached clone for ${cacheKey}`); + return cloneCache.get(cacheKey); + } + + const sanitized = repoFullName.replace(/[^a-zA-Z0-9_-]/g, '-'); + let lastError; + + // Build clone args: omit --branch to use remote HEAD when no branch specified + const baseArgs = ['clone', '--depth', '1']; + if (branch) { + baseArgs.push('--single-branch', '--branch', branch); + } + + for (let i = 0; i < githubTokens.length; i++) { + const token = githubTokens[i]; + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), `skyhook-${sanitized}-`)); + const repoUrl = `https://x-access-token:${token}@github.com/${repoFullName}.git`; + + core.info(`Cloning ${repoFullName}@${branchLabel} (token ${i + 1}/${githubTokens.length})`); + + let stderr = ''; + try { + await exec.exec('git', [...baseArgs, repoUrl, tmpDir], { + silent: true, + listeners: { + stderr: (data) => { stderr += data.toString(); } + } + }); + + cloneCache.set(cacheKey, tmpDir); + core.info(`Cloned ${repoFullName}@${branchLabel} successfully`); + return tmpDir; + } catch (err) { + // Clean up failed clone attempt + fs.rmSync(tmpDir, { recursive: true, force: true }); + lastError = err; + + if (i < githubTokens.length - 1) { + core.info(`Token ${i + 1} failed for ${repoFullName}, trying next token`); + } + } + } + + throw new Error(`Failed to clone ${repoFullName}@${branchLabel}: ${lastError.message}`); +} + +/** + * List overlay directories for a service in a cloned deployment repo. + * @param {string} clonedRepoPath - path to cloned repo root + * @param {string} deploymentRepoPath - service path within the deployment repo (e.g. "vcs") + * @returns {string[]} - array of environment/overlay names + */ +function listServiceOverlays(clonedRepoPath, deploymentRepoPath) { + const overlaysDir = path.join(clonedRepoPath, deploymentRepoPath, 'overlays'); + + if (!fs.existsSync(overlaysDir)) { + core.warning(`Overlays directory not found: ${overlaysDir}`); + return []; + } + + const entries = fs.readdirSync(overlaysDir, { withFileTypes: true }); + return entries.filter(e => e.isDirectory()).map(e => e.name); +} + +/** + * Read environment config from skyhook/environments/{name}.yaml in a cloned deployment repo. + * @param {string} clonedRepoPath - path to cloned repo root + * @param {string} repoFullName - repo identifier for cache key + * @param {string} branch - branch for cache key + * @param {string} envName - environment name + * @param {Map} envConfigCache - cache of parsed env configs + * @returns {SkyhookEnvironment} + */ +function readEnvironmentConfig(clonedRepoPath, repoFullName, branch, envName, envConfigCache) { + const cacheKey = `${repoFullName}:${branch}:${envName}`; + + if (envConfigCache.has(cacheKey)) { + return envConfigCache.get(cacheKey); + } + + const envFilePath = path.join(clonedRepoPath, 'skyhook', 'environments', `${envName}.yaml`); + + if (!fs.existsSync(envFilePath)) { + core.warning(`Environment config not found: ${envFilePath}, using name-only environment`); + const env = new SkyhookEnvironment({ name: envName }); + envConfigCache.set(cacheKey, env); + return env; + } + + const content = fs.readFileSync(envFilePath, 'utf8'); + const parsed = yaml.load(content); + + const env = new SkyhookEnvironment({ + name: envName, + clusterName: parsed.clusterName, + cloudProvider: parsed.cloudProvider, + account: parsed.account, + location: parsed.location, + namespace: parsed.namespace, + autoDeploy: parsed.autoDeploy + }); + + envConfigCache.set(cacheKey, env); + return env; +} + +/** + * Resolve environments for a service that has a deploymentRepo. + * Clones the repo, lists overlays, reads env configs. + * @param {Object} service - SkyhookService instance + * @param {string} branch - branch to clone + * @param {string[]} githubTokens - GitHub tokens to try in priority order + * @param {Map} cloneCache - clone cache + * @param {Map} envConfigCache - env config cache + * @returns {Promise} + */ +async function resolveServiceEnvironments(service, branch, githubTokens, cloneCache, envConfigCache) { + const clonedPath = await cloneDeploymentRepo( + service.deploymentRepo, branch, githubTokens, cloneCache + ); + + const overlayNames = listServiceOverlays(clonedPath, service.deploymentRepoPath || service.name); + + if (overlayNames.length === 0) { + core.warning(`No overlays found for service ${service.name} in ${service.deploymentRepo}`); + return []; + } + + core.info(`Found ${overlayNames.length} overlays for ${service.name}: ${overlayNames.join(', ')}`); + + const environments = []; + for (const envName of overlayNames) { + const env = readEnvironmentConfig( + clonedPath, service.deploymentRepo, branch, envName, envConfigCache + ); + environments.push(env); + } + + return environments; +} + +module.exports = { + cloneDeploymentRepo, + listServiceOverlays, + readEnvironmentConfig, + resolveServiceEnvironments +}; diff --git a/src/index.js b/src/index.js index 59b588d..9ffd785 100644 --- a/src/index.js +++ b/src/index.js @@ -5,15 +5,18 @@ const { DeploymentMatrix } = require('./DeploymentMatrix'); const { detectConfigFormats } = require('./config/config-detector'); const { parseSkyhookConfig } = require('./config/skyhook-parser'); const { buildMatrixFromSkyhook, mergeMatrices } = require('./matrix/matrix-builder'); +const { resolveServiceEnvironments } = require('./deployment/repo-fetcher'); async function run() { try { const overlay = core.getInput('overlay'); - const branch = core.getInput('branch') || 'main'; + const branch = core.getInput('branch') || ''; const tag = core.getInput('tag'); - const githubToken = core.getInput('github-token'); const repoPath = core.getInput('repo-path') || '.'; + // Collect all available tokens (deduplicated, ordered by priority) + const githubTokens = resolveTokens(core.getInput('github-token'), process.env.GITHUB_TOKEN); + // Validate inputs if (!fs.existsSync(repoPath)) { throw new Error(`Repository path not found: ${repoPath}`); @@ -23,10 +26,13 @@ async function run() { throw new Error('tag input is required'); } - if (!githubToken) { - throw new Error('github-token input is required'); + if (githubTokens.length === 0) { + throw new Error('github-token input is required, or GITHUB_TOKEN environment variable must be set'); } + // Primary token used for most operations + const githubToken = githubTokens[0]; + // Detect which config format(s) are present const configFormats = detectConfigFormats(repoPath); core.info(`Config detection: hasSkyhook=${configFormats.hasSkyhook}, hasKoala=${configFormats.hasKoala}`); @@ -49,7 +55,7 @@ async function run() { const serviceCounters = koalaMatrix ? getServiceCounters(koalaMatrix) : new Map(); if (configFormats.hasSkyhook) { core.info('📋 Processing Skyhook configuration (.skyhook/skyhook.yaml)'); - skyhookMatrix = await processSkyhookConfig(configFormats.skyhookPath, tag, overlay, repoPath, serviceCounters); + skyhookMatrix = await processSkyhookConfig(configFormats.skyhookPath, tag, overlay, repoPath, serviceCounters, branch, githubTokens); } // Determine final matrix @@ -114,7 +120,10 @@ async function processKoalaConfig(repoPath, branch, tag, githubToken, overlay) { core.info('📋 Extracting deployment configuration from .koala.toml files for different environments'); // Build the command - let cmd = `npx --yes workflow-utils get-services-env-config -dir . -outputFormat github-matrix -branch ${branch} -actionTag ${tag} -token ${githubToken}`; + let cmd = `npx --yes workflow-utils get-services-env-config -dir . -outputFormat github-matrix -actionTag ${tag} -token ${githubToken}`; + if (branch) { + cmd += ` -branch ${branch}`; + } if (overlay) { core.info(`🎯 Filtering for environment: ${overlay}`); @@ -173,8 +182,10 @@ async function processKoalaConfig(repoPath, branch, tag, githubToken, overlay) { * @param {string} overlay - Environment filter * @param {string} repoPath - Path to the git repository * @param {Map} serviceCounters - Per-service counters from Koala + * @param {string} branch - Branch for deployment repo cloning + * @param {string[]} githubTokens - GitHub tokens to try for deployment repo access (in priority order) */ -async function processSkyhookConfig(skyhookPath, tag, overlay, repoPath, serviceCounters) { +async function processSkyhookConfig(skyhookPath, tag, overlay, repoPath, serviceCounters, branch, githubTokens) { const config = parseSkyhookConfig(skyhookPath); core.info(`Found ${config.services.length} services and ${config.environments.length} environments in Skyhook config`); @@ -195,11 +206,27 @@ async function processSkyhookConfig(skyhookPath, tag, overlay, repoPath, service } } + // Resolve per-service environments from deployment repos + const perServiceEnvs = new Map(); + const cloneCache = new Map(); + const envConfigCache = new Map(); + + for (const service of config.services) { + if (service.deploymentRepo) { + core.info(`🔍 Resolving environments for ${service.name} from deployment repo ${service.deploymentRepo}`); + const envs = await resolveServiceEnvironments( + service, branch, githubTokens, cloneCache, envConfigCache + ); + perServiceEnvs.set(service.name, envs); + } + } + const matrix = buildMatrixFromSkyhook(config.services, config.environments, { tag, serviceRepo, envFilter: overlay, - serviceCounters: mergedCounters + serviceCounters: mergedCounters, + perServiceEnvs }); return matrix; @@ -258,6 +285,23 @@ async function getExistingTagCounters(services, tag, repoPath) { return counters; } +/** + * Collect all distinct, non-empty tokens in priority order. + * @param {...string} sources - Token values (may be empty/undefined) + * @returns {string[]} - Deduplicated non-empty tokens + */ +function resolveTokens(...sources) { + const seen = new Set(); + const tokens = []; + for (const token of sources) { + if (token && !seen.has(token)) { + seen.add(token); + tokens.push(token); + } + } + return tokens; +} + /** * Escape special regex characters in a string. */ diff --git a/src/matrix/matrix-builder.js b/src/matrix/matrix-builder.js index ccdfa67..731fcd6 100644 --- a/src/matrix/matrix-builder.js +++ b/src/matrix/matrix-builder.js @@ -4,16 +4,17 @@ const { DeploymentMatrix, DeploymentEntry } = require('../DeploymentMatrix'); /** * Build a DeploymentMatrix from Skyhook services and environments * @param {Array} services - Array of service configurations from skyhook.yaml - * @param {Array} environments - Array of environment configurations from skyhook.yaml + * @param {Array} environments - Array of environment configurations from skyhook.yaml (used for services without deploymentRepo) * @param {Object} options - Build options * @param {string} options.tag - Image tag to inject * @param {string} options.serviceRepo - Source repository (e.g., "KoalaOps/orbit") * @param {string} [options.envFilter] - Environment filter (optional) * @param {Map} [options.serviceCounters] - Per-service counters from Koala + * @param {Map} [options.perServiceEnvs] - Per-service environments from deployment repos * @returns {DeploymentMatrix} */ function buildMatrixFromSkyhook(services, environments, options = {}) { - const { tag, serviceRepo, envFilter, serviceCounters = new Map() } = options; + const { tag, serviceRepo, envFilter, serviceCounters = new Map(), perServiceEnvs = new Map() } = options; const matrix = new DeploymentMatrix(); // Clone the counters map so we can modify it @@ -24,19 +25,21 @@ function buildMatrixFromSkyhook(services, environments, options = {}) { core.info(` - Service repo (from GITHUB_REPOSITORY): ${serviceRepo}`); core.info(` - Existing service counters: ${JSON.stringify(Object.fromEntries(counters))}`); - // Apply environment filter if provided - let filteredEnvs = environments; - if (envFilter) { - core.info(` - Environment filter: ${envFilter}`); - filteredEnvs = environments.filter(env => env.name === envFilter); - } - core.info(` - Services count: ${services.length}`); - core.info(` - Environments count: ${filteredEnvs.length}`); // Build matrix entries for each service x environment combination for (const service of services) { - for (const env of filteredEnvs) { + // Use per-service environments if available (from deployment repo), otherwise fall back to global + let serviceEnvs = perServiceEnvs.has(service.name) ? perServiceEnvs.get(service.name) : environments; + + // Apply environment filter if provided + if (envFilter) { + serviceEnvs = serviceEnvs.filter(env => env.name === envFilter); + } + + core.info(` - ${service.name}: ${serviceEnvs.length} environments${perServiceEnvs.has(service.name) ? ' (from deployment repo)' : ' (from local config)'}`); + + for (const env of serviceEnvs) { // Get next counter for this service (per-service counter) const currentCounter = counters.get(service.name) || 0; const nextCounter = currentCounter + 1; @@ -76,7 +79,7 @@ function createDeploymentEntry(service, env, tag, serviceRepo, counter) { core.info(` cloud_provider: "${env.cloudProvider || ''}" (from skyhook.yaml environments[].cloudProvider)`); core.info(` namespace: "${env.namespace || ''}" (from skyhook.yaml environments[].namespace)`); core.info(` account: "${env.account || ''}" (from skyhook.yaml environments[].account)`); - core.info(` auto_deploy: "true" (default value)`); + core.info(` auto_deploy: "${!!env.autoDeploy}" (from environment config, default false)`); core.info(` service_tag: "${serviceTag}" (computed: {service_name}_{tag}_{counter})`); return new DeploymentEntry({ @@ -91,7 +94,7 @@ function createDeploymentEntry(service, env, tag, serviceRepo, counter) { cloud_provider: env.cloudProvider || '', namespace: env.namespace, account: env.account, - auto_deploy: 'true', + auto_deploy: String(!!env.autoDeploy), service_tag: serviceTag }); } diff --git a/tests/skyhook.test.js b/tests/skyhook.test.js index d194b91..159189f 100644 --- a/tests/skyhook.test.js +++ b/tests/skyhook.test.js @@ -3,8 +3,12 @@ const { parseSkyhookConfig, validateSkyhookConfig } = require('../src/config/sky const { detectConfigFormats } = require('../src/config/config-detector'); const { buildMatrixFromSkyhook } = require('../src/matrix/matrix-builder'); const { DeploymentMatrix, DeploymentEntry } = require('../src/DeploymentMatrix'); +const { cloneDeploymentRepo, listServiceOverlays, readEnvironmentConfig, resolveServiceEnvironments } = require('../src/deployment/repo-fetcher'); +const exec = require('@actions/exec'); const fs = require('fs'); const path = require('path'); +const os = require('os'); +const yaml = require('js-yaml'); // Test fixtures const validSkyhookYaml = ` @@ -55,6 +59,28 @@ describe('SkyhookConfig', () => { expect(config.services[0]).toBeInstanceOf(SkyhookService); expect(config.environments[0]).toBeInstanceOf(SkyhookEnvironment); }); + + describe('SkyhookEnvironment.autoDeploy', () => { + test('defaults to false when not specified', () => { + const env = new SkyhookEnvironment({ name: 'dev' }); + expect(env.autoDeploy).toBe(false); + }); + + test('accepts boolean true', () => { + const env = new SkyhookEnvironment({ name: 'dev', autoDeploy: true }); + expect(env.autoDeploy).toBe(true); + }); + + test('accepts string "true"', () => { + const env = new SkyhookEnvironment({ name: 'dev', autoDeploy: 'true' }); + expect(env.autoDeploy).toBe(true); + }); + + test('false when explicitly false', () => { + const env = new SkyhookEnvironment({ name: 'dev', autoDeploy: false }); + expect(env.autoDeploy).toBe(false); + }); + }); }); describe('validateSkyhookConfig', () => { @@ -93,6 +119,14 @@ describe('validateSkyhookConfig', () => { expect(result.valid).toBe(false); expect(result.errors.some(e => e.includes('path is required'))).toBe(true); }); + + test('config without environments is valid (services use deploymentRepo)', () => { + const config = { + services: [{ name: 'test', path: 'apps/test', deploymentRepo: 'org/deploy' }] + }; + const result = validateSkyhookConfig(config); + expect(result.valid).toBe(true); + }); }); describe('buildMatrixFromSkyhook', () => { @@ -177,7 +211,433 @@ describe('buildMatrixFromSkyhook', () => { expect(entry.cloud_provider).toBe('gcp'); expect(entry.namespace).toBe('dev'); expect(entry.account).toBe('koalabackend'); - expect(entry.auto_deploy).toBe('true'); + expect(entry.auto_deploy).toBe('false'); + }); + + test('auto_deploy reflects environment autoDeploy setting', () => { + const envsWithAutoDeploy = [ + { name: 'dev', clusterName: 'c1', autoDeploy: true }, + { name: 'prod', clusterName: 'c2', autoDeploy: false } + ]; + + const matrix = buildMatrixFromSkyhook( + [{ name: 'svc', path: 'apps/svc' }], + envsWithAutoDeploy, + { tag: 'v1.0.0', serviceRepo: 'org/repo' } + ); + + const devEntry = matrix.include.find(e => e.overlay === 'dev'); + const prodEntry = matrix.include.find(e => e.overlay === 'prod'); + expect(devEntry.auto_deploy).toBe('true'); + expect(prodEntry.auto_deploy).toBe('false'); + }); +}); + +describe('buildMatrixFromSkyhook with perServiceEnvs', () => { + const services = [ + { name: 'svc-remote', path: 'apps/svc-remote', deploymentRepo: 'org/deploy', deploymentRepoPath: 'svc-remote' }, + { name: 'svc-local', path: 'apps/svc-local' } + ]; + + const globalEnvs = [ + { name: 'dev', clusterName: 'global-cluster', cloudProvider: 'gcp', location: 'us-east1', namespace: 'dev', account: 'global-acct' } + ]; + + const remoteEnvs = [ + { name: 'staging', clusterName: 'remote-cluster', cloudProvider: 'aws', location: 'us-west-2', namespace: 'staging', account: 'remote-acct' }, + { name: 'prod', clusterName: 'remote-prod', cloudProvider: 'aws', location: 'us-west-2', namespace: 'prod', account: 'remote-acct' } + ]; + + test('uses per-service envs for services with deploymentRepo, global for others', () => { + const perServiceEnvs = new Map(); + perServiceEnvs.set('svc-remote', remoteEnvs); + + const matrix = buildMatrixFromSkyhook(services, globalEnvs, { + tag: 'v1.0.0', + serviceRepo: 'org/source', + perServiceEnvs + }); + + // svc-remote gets 2 envs (staging, prod) from deployment repo + // svc-local gets 1 env (dev) from global config + expect(matrix.count).toBe(3); + + const remoteEntries = matrix.include.filter(e => e.service_name === 'svc-remote'); + expect(remoteEntries).toHaveLength(2); + expect(remoteEntries.map(e => e.overlay).sort()).toEqual(['prod', 'staging']); + expect(remoteEntries[0].cluster).toBe('remote-cluster'); + + const localEntries = matrix.include.filter(e => e.service_name === 'svc-local'); + expect(localEntries).toHaveLength(1); + expect(localEntries[0].overlay).toBe('dev'); + expect(localEntries[0].cluster).toBe('global-cluster'); + }); + + test('applies envFilter to per-service envs', () => { + const perServiceEnvs = new Map(); + perServiceEnvs.set('svc-remote', remoteEnvs); + + const matrix = buildMatrixFromSkyhook(services, globalEnvs, { + tag: 'v1.0.0', + serviceRepo: 'org/source', + perServiceEnvs, + envFilter: 'staging' + }); + + // Only svc-remote has staging, svc-local has no staging env + expect(matrix.count).toBe(1); + expect(matrix.include[0].service_name).toBe('svc-remote'); + expect(matrix.include[0].overlay).toBe('staging'); + }); + + test('services with empty remote envs produce no entries', () => { + const perServiceEnvs = new Map(); + perServiceEnvs.set('svc-remote', []); + + const matrix = buildMatrixFromSkyhook(services, globalEnvs, { + tag: 'v1.0.0', + serviceRepo: 'org/source', + perServiceEnvs + }); + + // Only svc-local gets entries from global envs + expect(matrix.count).toBe(1); + expect(matrix.include[0].service_name).toBe('svc-local'); + }); +}); + +describe('cloneDeploymentRepo', () => { + let originalExec; + + beforeEach(() => { + originalExec = exec.exec; + }); + + afterEach(() => { + exec.exec = originalExec; + }); + + test('clones repo and caches result', async () => { + let capturedArgs; + exec.exec = jest.fn(async (cmd, args, opts) => { + capturedArgs = args; + const targetDir = args[args.length - 1]; + fs.mkdirSync(targetDir, { recursive: true }); + return 0; + }); + + const cache = new Map(); + const result = await cloneDeploymentRepo('org/deploy-repo', 'main', ['fake-token'], cache); + + // Verify git clone was called with correct args + expect(exec.exec).toHaveBeenCalledTimes(1); + expect(capturedArgs).toContain('clone'); + expect(capturedArgs).toContain('--depth'); + expect(capturedArgs).toContain('1'); + expect(capturedArgs).toContain('--single-branch'); + expect(capturedArgs).toContain('--branch'); + expect(capturedArgs).toContain('main'); + expect(capturedArgs).toContain('https://x-access-token:fake-token@github.com/org/deploy-repo.git'); + + // Verify cache was populated + expect(cache.has('org/deploy-repo:main')).toBe(true); + expect(cache.get('org/deploy-repo:main')).toBe(result); + + // Second call should use cache, not clone again + const result2 = await cloneDeploymentRepo('org/deploy-repo', 'main', ['fake-token'], cache); + expect(result2).toBe(result); + expect(exec.exec).toHaveBeenCalledTimes(1); // still 1, no second clone + + // Clean up + fs.rmSync(result, { recursive: true, force: true }); + }); + + test('different branch gets separate cache entry', async () => { + exec.exec = jest.fn(async (cmd, args) => { + const targetDir = args[args.length - 1]; + fs.mkdirSync(targetDir, { recursive: true }); + return 0; + }); + + const cache = new Map(); + const r1 = await cloneDeploymentRepo('org/repo', 'main', ['token'], cache); + const r2 = await cloneDeploymentRepo('org/repo', 'develop', ['token'], cache); + + expect(r1).not.toBe(r2); + expect(cache.size).toBe(2); + expect(exec.exec).toHaveBeenCalledTimes(2); + + fs.rmSync(r1, { recursive: true, force: true }); + fs.rmSync(r2, { recursive: true, force: true }); + }); + + test('falls back to second token when first fails', async () => { + let lastUrl; + exec.exec = jest.fn(async (cmd, args) => { + const url = args.find(a => a.startsWith('https://')); + lastUrl = url; + if (url.includes('bad-token')) { + throw new Error('Authentication failed'); + } + const targetDir = args[args.length - 1]; + fs.mkdirSync(targetDir, { recursive: true }); + return 0; + }); + + const cache = new Map(); + const result = await cloneDeploymentRepo('org/repo', 'main', ['bad-token', 'good-token'], cache); + + expect(exec.exec).toHaveBeenCalledTimes(2); + expect(lastUrl).toContain('good-token'); + expect(cache.has('org/repo:main')).toBe(true); + + fs.rmSync(result, { recursive: true, force: true }); + }); + + test('throws when all tokens fail', async () => { + exec.exec = jest.fn(async () => { + throw new Error('Authentication failed'); + }); + + const cache = new Map(); + await expect( + cloneDeploymentRepo('org/repo', 'main', ['token-a', 'token-b'], cache) + ).rejects.toThrow('Failed to clone org/repo@main'); + + expect(exec.exec).toHaveBeenCalledTimes(2); + expect(cache.size).toBe(0); + }); +}); + +describe('resolveServiceEnvironments', () => { + let tmpDir; + let originalExec; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'resolve-envs-test-')); + originalExec = exec.exec; + }); + + afterEach(() => { + exec.exec = originalExec; + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + function setupDeploymentRepoFixture(repoDir, servicePath, overlays, envConfigs) { + // Create overlay dirs + for (const overlay of overlays) { + fs.mkdirSync(path.join(repoDir, servicePath, 'overlays', overlay), { recursive: true }); + } + // Create env config files + if (envConfigs) { + const envDir = path.join(repoDir, 'skyhook', 'environments'); + fs.mkdirSync(envDir, { recursive: true }); + for (const [name, config] of Object.entries(envConfigs)) { + fs.writeFileSync(path.join(envDir, `${name}.yaml`), yaml.dump(config)); + } + } + } + + test('resolves environments from deployment repo overlays and env configs', async () => { + // Pre-populate the fixture directory to simulate a cloned repo + setupDeploymentRepoFixture(tmpDir, 'my-svc', ['dev', 'prod'], { + dev: { clusterName: 'dev-cluster', cloudProvider: 'gcp', account: 'dev-acct', location: 'us-east1', namespace: 'dev' }, + prod: { clusterName: 'prod-cluster', cloudProvider: 'gcp', account: 'prod-acct', location: 'us-east1', namespace: 'prod' } + }); + + // Mock exec to return our fixture dir instead of actually cloning + const cloneCache = new Map(); + cloneCache.set('org/deploy:main', tmpDir); // pre-seed cache so no clone happens + + const envConfigCache = new Map(); + const service = { name: 'my-svc', deploymentRepo: 'org/deploy', deploymentRepoPath: 'my-svc' }; + + const envs = await resolveServiceEnvironments(service, 'main', ['token'], cloneCache, envConfigCache); + + expect(envs).toHaveLength(2); + const names = envs.map(e => e.name).sort(); + expect(names).toEqual(['dev', 'prod']); + + const devEnv = envs.find(e => e.name === 'dev'); + expect(devEnv.clusterName).toBe('dev-cluster'); + expect(devEnv.cloudProvider).toBe('gcp'); + expect(devEnv.account).toBe('dev-acct'); + + // Verify env configs were cached + expect(envConfigCache.has('org/deploy:main:dev')).toBe(true); + expect(envConfigCache.has('org/deploy:main:prod')).toBe(true); + }); + + test('falls back to service.name when deploymentRepoPath is not set', async () => { + setupDeploymentRepoFixture(tmpDir, 'api-service', ['staging'], { + staging: { clusterName: 'stg-cluster', cloudProvider: 'aws', location: 'us-west-2', namespace: 'staging' } + }); + + const cloneCache = new Map(); + cloneCache.set('org/deploy:main', tmpDir); + + const service = { name: 'api-service', deploymentRepo: 'org/deploy' }; // no deploymentRepoPath + + const envs = await resolveServiceEnvironments(service, 'main', ['token'], cloneCache, new Map()); + + expect(envs).toHaveLength(1); + expect(envs[0].name).toBe('staging'); + expect(envs[0].clusterName).toBe('stg-cluster'); + }); + + test('returns empty array when no overlays found', async () => { + // tmpDir exists but has no overlays directory + const cloneCache = new Map(); + cloneCache.set('org/deploy:main', tmpDir); + + const service = { name: 'missing-svc', deploymentRepo: 'org/deploy', deploymentRepoPath: 'missing-svc' }; + + const envs = await resolveServiceEnvironments(service, 'main', ['token'], cloneCache, new Map()); + + expect(envs).toEqual([]); + }); + + test('two services sharing the same deployment repo reuse one clone', async () => { + // Set up both services in the same fixture dir + setupDeploymentRepoFixture(tmpDir, 'svc-a', ['dev'], { + dev: { clusterName: 'cluster-a', cloudProvider: 'gcp', namespace: 'dev' } + }); + setupDeploymentRepoFixture(tmpDir, 'svc-b', ['dev', 'prod'], {}); + // dev env already created above, add prod + const envDir = path.join(tmpDir, 'skyhook', 'environments'); + fs.writeFileSync(path.join(envDir, 'prod.yaml'), yaml.dump({ clusterName: 'cluster-b', namespace: 'prod' })); + + const cloneCache = new Map(); + cloneCache.set('org/deploy:main', tmpDir); + const envConfigCache = new Map(); + + const svcA = { name: 'svc-a', deploymentRepo: 'org/deploy', deploymentRepoPath: 'svc-a' }; + const svcB = { name: 'svc-b', deploymentRepo: 'org/deploy', deploymentRepoPath: 'svc-b' }; + + const envsA = await resolveServiceEnvironments(svcA, 'main', ['token'], cloneCache, envConfigCache); + const envsB = await resolveServiceEnvironments(svcB, 'main', ['token'], cloneCache, envConfigCache); + + expect(envsA).toHaveLength(1); + expect(envsB).toHaveLength(2); + + // Both read the shared dev env config - should be same cached object + const devFromA = envsA.find(e => e.name === 'dev'); + const devFromB = envsB.find(e => e.name === 'dev'); + expect(devFromA).toBe(devFromB); // same reference from cache + }); +}); + +describe('repo-fetcher', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'repo-fetcher-test-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + describe('listServiceOverlays', () => { + test('lists overlay directories', () => { + const overlaysDir = path.join(tmpDir, 'my-service', 'overlays'); + fs.mkdirSync(path.join(overlaysDir, 'dev'), { recursive: true }); + fs.mkdirSync(path.join(overlaysDir, 'staging'), { recursive: true }); + fs.mkdirSync(path.join(overlaysDir, 'prod'), { recursive: true }); + // Add a file that should be ignored + fs.writeFileSync(path.join(overlaysDir, 'kustomization.yaml'), 'resources: []'); + + const overlays = listServiceOverlays(tmpDir, 'my-service'); + expect(overlays.sort()).toEqual(['dev', 'prod', 'staging']); + }); + + test('returns empty array when overlays dir does not exist', () => { + const overlays = listServiceOverlays(tmpDir, 'nonexistent'); + expect(overlays).toEqual([]); + }); + }); + + describe('readEnvironmentConfig', () => { + test('reads and parses environment yaml', () => { + const envDir = path.join(tmpDir, 'skyhook', 'environments'); + fs.mkdirSync(envDir, { recursive: true }); + fs.writeFileSync(path.join(envDir, 'dev.yaml'), [ + 'clusterName: my-cluster', + 'cloudProvider: gcp', + 'account: my-project', + 'location: us-central1', + 'namespace: dev-ns' + ].join('\n')); + + const cache = new Map(); + const env = readEnvironmentConfig(tmpDir, 'org/repo', 'main', 'dev', cache); + + expect(env.name).toBe('dev'); + expect(env.clusterName).toBe('my-cluster'); + expect(env.cloudProvider).toBe('gcp'); + expect(env.account).toBe('my-project'); + expect(env.location).toBe('us-central1'); + expect(env.namespace).toBe('dev-ns'); + }); + + test('caches parsed environment configs', () => { + const envDir = path.join(tmpDir, 'skyhook', 'environments'); + fs.mkdirSync(envDir, { recursive: true }); + fs.writeFileSync(path.join(envDir, 'dev.yaml'), 'clusterName: cached-cluster'); + + const cache = new Map(); + const env1 = readEnvironmentConfig(tmpDir, 'org/repo', 'main', 'dev', cache); + const env2 = readEnvironmentConfig(tmpDir, 'org/repo', 'main', 'dev', cache); + + expect(env1).toBe(env2); // Same object reference (cached) + expect(cache.size).toBe(1); + }); + + test('different repos with same env name get separate cache entries', () => { + const envDir = path.join(tmpDir, 'skyhook', 'environments'); + fs.mkdirSync(envDir, { recursive: true }); + fs.writeFileSync(path.join(envDir, 'dev.yaml'), 'clusterName: cluster-a'); + + const cache = new Map(); + readEnvironmentConfig(tmpDir, 'org/repo-a', 'main', 'dev', cache); + readEnvironmentConfig(tmpDir, 'org/repo-b', 'main', 'dev', cache); + + expect(cache.size).toBe(2); + expect(cache.has('org/repo-a:main:dev')).toBe(true); + expect(cache.has('org/repo-b:main:dev')).toBe(true); + }); + + test('returns name-only env when yaml file is missing', () => { + const cache = new Map(); + const env = readEnvironmentConfig(tmpDir, 'org/repo', 'main', 'missing', cache); + + expect(env.name).toBe('missing'); + expect(env.clusterName).toBeUndefined(); + expect(env.autoDeploy).toBe(false); + }); + + test('reads autoDeploy true from environment yaml', () => { + const envDir = path.join(tmpDir, 'skyhook', 'environments'); + fs.mkdirSync(envDir, { recursive: true }); + fs.writeFileSync(path.join(envDir, 'prod.yaml'), [ + 'clusterName: prod-cluster', + 'autoDeploy: true' + ].join('\n')); + + const cache = new Map(); + const env = readEnvironmentConfig(tmpDir, 'org/repo', 'main', 'prod', cache); + expect(env.autoDeploy).toBe(true); + }); + + test('autoDeploy defaults to false when not in remote yaml', () => { + const envDir = path.join(tmpDir, 'skyhook', 'environments'); + fs.mkdirSync(envDir, { recursive: true }); + fs.writeFileSync(path.join(envDir, 'staging.yaml'), 'clusterName: stg-cluster'); + + const cache = new Map(); + const env = readEnvironmentConfig(tmpDir, 'org/repo', 'main', 'staging', cache); + expect(env.autoDeploy).toBe(false); + }); }); });