From 5b0f450bbf363acf6433bb00bbf89dc973da14a2 Mon Sep 17 00:00:00 2001 From: Nadav Erell Date: Thu, 19 Mar 2026 15:25:01 +0200 Subject: [PATCH] feat: resolve image URL from registry hierarchy in deployment matrix - Add registry, image, and per-env registry fields to SkyhookConfig, SkyhookService, and SkyhookEnvironment - Add standalone resolveImage() in matrix-builder implementing the hierarchy: service.image > env.registry > root registry - buildMatrixFromSkyhook accepts rootRegistry and populates image per entry - processSkyhookConfig passes config.registry as rootRegistry - image field included in DeploymentEntry and toObject() output - Add tests: resolveImage unit tests, buildMatrixFromSkyhook with registry integration suite (20 tests total, all passing) --- dist/index.js | 57 +++++++++++++++++++---- src/DeploymentMatrix.js | 6 ++- src/config/SkyhookConfig.js | 12 +++-- src/index.js | 3 +- src/matrix/matrix-builder.js | 36 +++++++++++++-- tests/skyhook.test.js | 89 +++++++++++++++++++++++++++++++++++- 6 files changed, 182 insertions(+), 21 deletions(-) diff --git a/dist/index.js b/dist/index.js index 2824ef6..1c3e4f4 100644 --- a/dist/index.js +++ b/dist/index.js @@ -29779,6 +29779,7 @@ class DeploymentEntry { * @param {string} params.service_tag - Service image tag * @param {string} [params.namespace] - Kubernetes namespace * @param {string} [params.account] - Cloud account identifier + * @param {string} [params.image] - Full container image URL without tag (e.g. "us-east1-docker.pkg.dev/project/repo/service") */ constructor({ service_dir, @@ -29793,7 +29794,8 @@ class DeploymentEntry { deployment_folder_path, service_tag, namespace, - account + account, + image }) { this.service_dir = service_dir; this.service_name = service_name; @@ -29808,6 +29810,7 @@ class DeploymentEntry { this.service_tag = service_tag; this.namespace = namespace; this.account = account; + this.image = image || ''; } /** @@ -29839,6 +29842,7 @@ class DeploymentEntry { }; if (this.namespace) obj.namespace = this.namespace; if (this.account) obj.account = this.account; + if (this.image) obj.image = this.image; return obj; } } @@ -29985,10 +29989,12 @@ class SkyhookConfig { /** * @param {Object} params + * @param {string} [params.registry] - Repo-level container registry prefix * @param {SkyhookService[]} params.services - Array of service configurations * @param {SkyhookEnvironment[]} params.environments - Array of environment configurations */ - constructor({ services = [], environments = [] }) { + constructor({ registry = '', services = [], environments = [] }) { + this.registry = registry; this.services = services.map(s => new SkyhookService(s)); this.environments = environments.map(e => new SkyhookEnvironment(e)); } @@ -30000,10 +30006,12 @@ class SkyhookConfig { */ static fromObject(obj) { return new SkyhookConfig({ + registry: obj.registry || '', services: obj.services || [], environments: obj.environments || [] }); } + } /** @@ -30018,12 +30026,13 @@ class SkyhookService { * @param {string} [params.deploymentRepoPath] - Path within deployment repo * @param {Object} [params.buildTool] - Build tool configuration */ - constructor({ name, path, deploymentRepo, deploymentRepoPath, buildTool }) { + constructor({ name, path, deploymentRepo, deploymentRepoPath, buildTool, image }) { this.name = name; this.path = path; this.deploymentRepo = deploymentRepo; this.deploymentRepoPath = deploymentRepoPath; this.buildTool = buildTool; + this.image = image || ''; } } @@ -30040,13 +30049,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, registry }) { this.name = name; this.clusterName = clusterName; this.cloudProvider = cloudProvider; this.account = account; this.location = location; this.namespace = namespace; + this.registry = registry || ''; } } @@ -30208,6 +30218,26 @@ module.exports = { const core = __nccwpck_require__(7484); const { DeploymentMatrix, DeploymentEntry } = __nccwpck_require__(9439); +/** + * Resolve the full image URL for a service+environment combination. + * Resolution order (highest to lowest precedence): + * 1. service.image — explicit per-service full URL override + * 2. env.registry/serviceName — per-environment registry + * 3. rootRegistry/serviceName — repo-level registry + * + * @param {string} serviceName + * @param {Object} service - Service config (may have .image) + * @param {Object} env - Environment config (may have .registry) + * @param {string} rootRegistry - Repo-level registry from skyhook.yaml root + * @returns {string} Full image URL without tag, or '' if unresolvable + */ +function resolveImage(serviceName, service, env, rootRegistry) { + if (service.image) return service.image; + if (env.registry) return `${env.registry}/${serviceName}`; + if (rootRegistry) return `${rootRegistry}/${serviceName}`; + return ''; +} + /** * Build a DeploymentMatrix from Skyhook services and environments * @param {Array} services - Array of service configurations from skyhook.yaml @@ -30217,10 +30247,11 @@ const { DeploymentMatrix, DeploymentEntry } = __nccwpck_require__(9439); * @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 {string} [options.rootRegistry] - Repo-level registry prefix from skyhook.yaml * @returns {DeploymentMatrix} */ function buildMatrixFromSkyhook(services, environments, options = {}) { - const { tag, serviceRepo, envFilter, serviceCounters = new Map() } = options; + const { tag, serviceRepo, envFilter, serviceCounters = new Map(), rootRegistry = '' } = options; const matrix = new DeploymentMatrix(); // Clone the counters map so we can modify it @@ -30249,8 +30280,9 @@ function buildMatrixFromSkyhook(services, environments, options = {}) { const nextCounter = currentCounter + 1; counters.set(service.name, nextCounter); + const image = resolveImage(service.name, service, env, rootRegistry); core.info(`\nšŸ”§ Creating entry for ${service.name} (counter: ${nextCounter}):`); - const entry = createDeploymentEntry(service, env, tag, serviceRepo, nextCounter); + const entry = createDeploymentEntry(service, env, tag, serviceRepo, nextCounter, image); matrix.addEntry(entry); } } @@ -30265,9 +30297,10 @@ function buildMatrixFromSkyhook(services, environments, options = {}) { * @param {string} tag - Image tag * @param {string} serviceRepo - Source repository * @param {number} counter - Counter for unique service tag (per-service) + * @param {string} image - Resolved full image URL without tag * @returns {DeploymentEntry} */ -function createDeploymentEntry(service, env, tag, serviceRepo, counter) { +function createDeploymentEntry(service, env, tag, serviceRepo, counter, image) { const counterStr = String(counter).padStart(2, '0'); const serviceTag = `${service.name}_${tag}_${counterStr}`; @@ -30285,6 +30318,7 @@ function createDeploymentEntry(service, env, tag, serviceRepo, counter) { core.info(` account: "${env.account || ''}" (from skyhook.yaml environments[].account)`); core.info(` auto_deploy: "true" (default value)`); core.info(` service_tag: "${serviceTag}" (computed: {service_name}_{tag}_{counter})`); + core.info(` image: "${image}" (resolved from registry hierarchy)`); return new DeploymentEntry({ service_name: service.name, @@ -30299,7 +30333,8 @@ function createDeploymentEntry(service, env, tag, serviceRepo, counter) { namespace: env.namespace, account: env.account, auto_deploy: 'true', - service_tag: serviceTag + service_tag: serviceTag, + image }); } @@ -30325,7 +30360,8 @@ function mergeMatrices(matrix1, matrix2) { module.exports = { buildMatrixFromSkyhook, createDeploymentEntry, - mergeMatrices + mergeMatrices, + resolveImage }; @@ -32444,7 +32480,8 @@ async function processSkyhookConfig(skyhookPath, tag, overlay, repoPath, service tag, serviceRepo, envFilter: overlay, - serviceCounters: mergedCounters + serviceCounters: mergedCounters, + rootRegistry: config.registry }); return matrix; diff --git a/src/DeploymentMatrix.js b/src/DeploymentMatrix.js index ec74ecd..402d4c3 100644 --- a/src/DeploymentMatrix.js +++ b/src/DeploymentMatrix.js @@ -17,6 +17,7 @@ class DeploymentEntry { * @param {string} params.service_tag - Service image tag * @param {string} [params.namespace] - Kubernetes namespace * @param {string} [params.account] - Cloud account identifier + * @param {string} [params.image] - Full container image URL without tag (e.g. "us-east1-docker.pkg.dev/project/repo/service") */ constructor({ service_dir, @@ -31,7 +32,8 @@ class DeploymentEntry { deployment_folder_path, service_tag, namespace, - account + account, + image }) { this.service_dir = service_dir; this.service_name = service_name; @@ -46,6 +48,7 @@ class DeploymentEntry { this.service_tag = service_tag; this.namespace = namespace; this.account = account; + this.image = image || ''; } /** @@ -77,6 +80,7 @@ class DeploymentEntry { }; if (this.namespace) obj.namespace = this.namespace; if (this.account) obj.account = this.account; + if (this.image) obj.image = this.image; return obj; } } diff --git a/src/config/SkyhookConfig.js b/src/config/SkyhookConfig.js index 6c503bc..e1b677f 100644 --- a/src/config/SkyhookConfig.js +++ b/src/config/SkyhookConfig.js @@ -6,10 +6,12 @@ class SkyhookConfig { /** * @param {Object} params + * @param {string} [params.registry] - Repo-level container registry prefix * @param {SkyhookService[]} params.services - Array of service configurations * @param {SkyhookEnvironment[]} params.environments - Array of environment configurations */ - constructor({ services = [], environments = [] }) { + constructor({ registry = '', services = [], environments = [] }) { + this.registry = registry; this.services = services.map(s => new SkyhookService(s)); this.environments = environments.map(e => new SkyhookEnvironment(e)); } @@ -21,10 +23,12 @@ class SkyhookConfig { */ static fromObject(obj) { return new SkyhookConfig({ + registry: obj.registry || '', services: obj.services || [], environments: obj.environments || [] }); } + } /** @@ -39,12 +43,13 @@ class SkyhookService { * @param {string} [params.deploymentRepoPath] - Path within deployment repo * @param {Object} [params.buildTool] - Build tool configuration */ - constructor({ name, path, deploymentRepo, deploymentRepoPath, buildTool }) { + constructor({ name, path, deploymentRepo, deploymentRepoPath, buildTool, image }) { this.name = name; this.path = path; this.deploymentRepo = deploymentRepo; this.deploymentRepoPath = deploymentRepoPath; this.buildTool = buildTool; + this.image = image || ''; } } @@ -61,13 +66,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, registry }) { this.name = name; this.clusterName = clusterName; this.cloudProvider = cloudProvider; this.account = account; this.location = location; this.namespace = namespace; + this.registry = registry || ''; } } diff --git a/src/index.js b/src/index.js index 59b588d..f445f66 100644 --- a/src/index.js +++ b/src/index.js @@ -199,7 +199,8 @@ async function processSkyhookConfig(skyhookPath, tag, overlay, repoPath, service tag, serviceRepo, envFilter: overlay, - serviceCounters: mergedCounters + serviceCounters: mergedCounters, + rootRegistry: config.registry }); return matrix; diff --git a/src/matrix/matrix-builder.js b/src/matrix/matrix-builder.js index ccdfa67..2b61ad4 100644 --- a/src/matrix/matrix-builder.js +++ b/src/matrix/matrix-builder.js @@ -1,6 +1,26 @@ const core = require('@actions/core'); const { DeploymentMatrix, DeploymentEntry } = require('../DeploymentMatrix'); +/** + * Resolve the full image URL for a service+environment combination. + * Resolution order (highest to lowest precedence): + * 1. service.image — explicit per-service full URL override + * 2. env.registry/serviceName — per-environment registry + * 3. rootRegistry/serviceName — repo-level registry + * + * @param {string} serviceName + * @param {Object} service - Service config (may have .image) + * @param {Object} env - Environment config (may have .registry) + * @param {string} rootRegistry - Repo-level registry from skyhook.yaml root + * @returns {string} Full image URL without tag, or '' if unresolvable + */ +function resolveImage(serviceName, service, env, rootRegistry) { + if (service.image) return service.image; + if (env.registry) return `${env.registry}/${serviceName}`; + if (rootRegistry) return `${rootRegistry}/${serviceName}`; + return ''; +} + /** * Build a DeploymentMatrix from Skyhook services and environments * @param {Array} services - Array of service configurations from skyhook.yaml @@ -10,10 +30,11 @@ const { DeploymentMatrix, DeploymentEntry } = require('../DeploymentMatrix'); * @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 {string} [options.rootRegistry] - Repo-level registry prefix from skyhook.yaml * @returns {DeploymentMatrix} */ function buildMatrixFromSkyhook(services, environments, options = {}) { - const { tag, serviceRepo, envFilter, serviceCounters = new Map() } = options; + const { tag, serviceRepo, envFilter, serviceCounters = new Map(), rootRegistry = '' } = options; const matrix = new DeploymentMatrix(); // Clone the counters map so we can modify it @@ -42,8 +63,9 @@ function buildMatrixFromSkyhook(services, environments, options = {}) { const nextCounter = currentCounter + 1; counters.set(service.name, nextCounter); + const image = resolveImage(service.name, service, env, rootRegistry); core.info(`\nšŸ”§ Creating entry for ${service.name} (counter: ${nextCounter}):`); - const entry = createDeploymentEntry(service, env, tag, serviceRepo, nextCounter); + const entry = createDeploymentEntry(service, env, tag, serviceRepo, nextCounter, image); matrix.addEntry(entry); } } @@ -58,9 +80,10 @@ function buildMatrixFromSkyhook(services, environments, options = {}) { * @param {string} tag - Image tag * @param {string} serviceRepo - Source repository * @param {number} counter - Counter for unique service tag (per-service) + * @param {string} image - Resolved full image URL without tag * @returns {DeploymentEntry} */ -function createDeploymentEntry(service, env, tag, serviceRepo, counter) { +function createDeploymentEntry(service, env, tag, serviceRepo, counter, image) { const counterStr = String(counter).padStart(2, '0'); const serviceTag = `${service.name}_${tag}_${counterStr}`; @@ -78,6 +101,7 @@ function createDeploymentEntry(service, env, tag, serviceRepo, counter) { core.info(` account: "${env.account || ''}" (from skyhook.yaml environments[].account)`); core.info(` auto_deploy: "true" (default value)`); core.info(` service_tag: "${serviceTag}" (computed: {service_name}_{tag}_{counter})`); + core.info(` image: "${image}" (resolved from registry hierarchy)`); return new DeploymentEntry({ service_name: service.name, @@ -92,7 +116,8 @@ function createDeploymentEntry(service, env, tag, serviceRepo, counter) { namespace: env.namespace, account: env.account, auto_deploy: 'true', - service_tag: serviceTag + service_tag: serviceTag, + image }); } @@ -118,5 +143,6 @@ function mergeMatrices(matrix1, matrix2) { module.exports = { buildMatrixFromSkyhook, createDeploymentEntry, - mergeMatrices + mergeMatrices, + resolveImage }; diff --git a/tests/skyhook.test.js b/tests/skyhook.test.js index d194b91..fe07eaa 100644 --- a/tests/skyhook.test.js +++ b/tests/skyhook.test.js @@ -1,7 +1,7 @@ const { SkyhookConfig, SkyhookService, SkyhookEnvironment } = require('../src/config/SkyhookConfig'); const { parseSkyhookConfig, validateSkyhookConfig } = require('../src/config/skyhook-parser'); const { detectConfigFormats } = require('../src/config/config-detector'); -const { buildMatrixFromSkyhook } = require('../src/matrix/matrix-builder'); +const { buildMatrixFromSkyhook, resolveImage } = require('../src/matrix/matrix-builder'); const { DeploymentMatrix, DeploymentEntry } = require('../src/DeploymentMatrix'); const fs = require('fs'); const path = require('path'); @@ -181,6 +181,93 @@ describe('buildMatrixFromSkyhook', () => { }); }); +describe('resolveImage', () => { + const service = (name, image = '') => ({ name, image }); + const env = (name, registry = '') => ({ name, registry }); + + test('service.image takes highest precedence', () => { + expect(resolveImage('vcs', service('vcs', 'ghcr.io/org/vcs'), env('dev', 'env-reg/repo'), 'root-reg/repo')) + .toBe('ghcr.io/org/vcs'); + }); + + test('env.registry is second priority', () => { + expect(resolveImage('vcs', service('vcs'), env('dev', 'env-reg/repo'), 'root-reg/repo')) + .toBe('env-reg/repo/vcs'); + }); + + test('root registry is fallback', () => { + expect(resolveImage('vcs', service('vcs'), env('dev'), 'us-east1-docker.pkg.dev/koalabackend/koala-repo')) + .toBe('us-east1-docker.pkg.dev/koalabackend/koala-repo/vcs'); + }); + + test('returns empty string when no registry available', () => { + expect(resolveImage('vcs', service('vcs'), env('dev'), '')) + .toBe(''); + }); +}); + +describe('buildMatrixFromSkyhook with registry', () => { + const services = [ + { name: 'vcs', path: 'apps/vcs', deploymentRepo: 'KoalaOps/deployment', deploymentRepoPath: 'vcs', image: '' }, + { name: 'special', path: 'apps/special', deploymentRepo: 'KoalaOps/deployment', deploymentRepoPath: 'special', image: 'ghcr.io/org/special' } + ]; + + const environments = [ + { name: 'dev', clusterName: 'nonprod-cluster', cloudProvider: 'gcp', location: 'us-east1-b', namespace: 'dev', account: 'koalabackend', registry: '' }, + { name: 'prod', clusterName: 'prod-cluster', cloudProvider: 'gcp', location: 'us-east1-b', namespace: 'prod', account: 'koalabackend', registry: 'prod-reg/repo' } + ]; + + test('populates image from root registry for services without override', () => { + const matrix = buildMatrixFromSkyhook(services, environments, { + tag: 'v1.0.0', + serviceRepo: 'KoalaOps/orbit', + envFilter: 'dev', + rootRegistry: 'us-east1-docker.pkg.dev/koalabackend/koala-repo' + }); + + const vcsEntry = matrix.include.find(e => e.service_name === 'vcs'); + expect(vcsEntry.image).toBe('us-east1-docker.pkg.dev/koalabackend/koala-repo/vcs'); + }); + + test('service.image overrides root registry', () => { + const matrix = buildMatrixFromSkyhook(services, environments, { + tag: 'v1.0.0', + serviceRepo: 'KoalaOps/orbit', + envFilter: 'dev', + rootRegistry: 'us-east1-docker.pkg.dev/koalabackend/koala-repo' + }); + + const specialEntry = matrix.include.find(e => e.service_name === 'special'); + expect(specialEntry.image).toBe('ghcr.io/org/special'); + }); + + test('env.registry overrides root registry', () => { + const matrix = buildMatrixFromSkyhook(services, environments, { + tag: 'v1.0.0', + serviceRepo: 'KoalaOps/orbit', + envFilter: 'prod', + rootRegistry: 'us-east1-docker.pkg.dev/koalabackend/koala-repo' + }); + + const vcsEntry = matrix.include.find(e => e.service_name === 'vcs'); + expect(vcsEntry.image).toBe('prod-reg/repo/vcs'); + }); + + test('image is included in toObject() output', () => { + const matrix = buildMatrixFromSkyhook(services, environments, { + tag: 'v1.0.0', + serviceRepo: 'KoalaOps/orbit', + envFilter: 'dev', + rootRegistry: 'us-east1-docker.pkg.dev/koalabackend/koala-repo' + }); + + const obj = matrix.toObject(); + const vcsEntry = obj.include.find(e => e.service_name === 'vcs'); + expect(vcsEntry.image).toBe('us-east1-docker.pkg.dev/koalabackend/koala-repo/vcs'); + }); +}); + + describe('DeploymentMatrix.merge', () => { test('merges two matrices and deduplicates by service_name + overlay', () => { const matrix1 = new DeploymentMatrix([