diff --git a/README.md b/README.md index 9078fa2..58e4010 100644 --- a/README.md +++ b/README.md @@ -178,10 +178,10 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 # both refs must be in the clone - - uses: SOFTURE/COMPAT@v0.13.0 # base, revision and fail-on come from "check" in compat.config.json + - uses: SOFTURE/COMPAT@v0.14.0 # base, revision and fail-on come from "check" in compat.config.json ``` -Use the tag of a release (`@v0.13.0`); every release has one. The action runs the CLI version released +Use the tag of a release (`@v0.14.0`); every release has one. The action runs the CLI version released from the same commit, so the action and the CLI never drift apart. | Input | Default | Meaning | @@ -1011,7 +1011,7 @@ deploy. It does not prove that the call works. | `flags` | regex flags out of `i`, `m`, `s`, `u` | | `targets[].keyFrom` | `appsettings`: each capture's configuration key is the .NET path of the JSON leaf that holds it (`Shop:BaseUrl`); a named group `key` in `pattern` gives the key instead, for example the variable of `GetEnvironmentVariable("CRM_URL") ?? "https://..."`. Requires `service` | | `targets[].service` | the compose service that runs the app; a target whose key its `environment` sets is reported as that key (see below). Requires `keyFrom` or a `key` group | -| `targets[].composeFiles` | where `service` is looked up, default `**/{docker-compose,compose}{,.*}.{yml,yaml}`; list only the deploy files, so a local-dev compose that sets the same key never counts. The service must be in one of them in the revision | +| `targets[].composeFiles` | where `service` is looked up. Default: the `files` of the [`config`](#config) layer's `compose` sources, so a local-dev compose that sets the same key never counts as the deploy; only without such a source every compose file (`**/{docker-compose,compose}{,.*}.{yml,yaml}`). The service must be in one of them in the revision | | `accept[]` | `{ target, reason }`: accepts `outbound-added` for that target; `target` may be a glob such as `maps.googleapis.com/maps/api/geocode/**` (`*` stays within one path segment), or the key of a target the deploy sets (`Shop:BaseUrl`) | A target is compared as `host/path`: without the scheme, the query string and the fragment, with a lower-case host @@ -1285,7 +1285,7 @@ its message contracts and queues (`test/e2e/message-contracts.test.ts`). Query-s opened by old app versions and behaviour of refactored code are caught only by your own black-box tests through the [`behaviour`](#behaviour) layer; messaging library behaviour beyond the version change is not checked. -Known gaps in 0.13.0: +Known gaps in 0.14.0: - psql's `\copy` meta-command in a seed script is not read; the statement after it is reported as `unreadable-write`. - MSBuild `Condition` attributes are decided only for `'$(Name)' == ''` and `!= ''`; a property that other conditions diff --git a/action.yml b/action.yml index 6c4612c..d0b6fa0 100644 --- a/action.yml +++ b/action.yml @@ -6,7 +6,7 @@ branding: color: blue # A composite action around the published CLI. It installs `@softure-ai/compat` at the version in this repository's -# package.json, so `uses: SOFTURE/COMPAT@v0.13.0` always runs the CLI released from that commit. +# package.json, so `uses: SOFTURE/COMPAT@v0.14.0` always runs the CLI released from that commit. inputs: base: description: Git ref or resolver running in production (`--base`), e.g. `github-deployment:production` or `latest-tag:v*`. Empty uses `check.base` from the config. diff --git a/package-lock.json b/package-lock.json index 093c5e5..df736e8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@softure-ai/compat", - "version": "0.13.0", + "version": "0.14.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@softure-ai/compat", - "version": "0.13.0", + "version": "0.14.0", "license": "MIT", "dependencies": { "zod": "^4.6.5" diff --git a/package.json b/package.json index 8c927ac..a6b04ce 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@softure-ai/compat", - "version": "0.13.0", + "version": "0.14.0", "description": "Backward-compatibility check between two git refs of a server release: OpenAPI, SQL migrations, seed scripts, persisted enums, configuration and message contracts", "keywords": [ "backward-compatibility", diff --git a/src/commands/check.ts b/src/commands/check.ts index c48efc9..7486027 100644 --- a/src/commands/check.ts +++ b/src/commands/check.ts @@ -2,8 +2,11 @@ import { rmSync } from "node:fs"; import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { isAbsolute, join, resolve } from "node:path"; -import { DEFAULT_CONFIG_FILE, loadConfig } from "../config/config.js"; +import { type CompatConfig, DEFAULT_CONFIG_FILE, loadConfig } from "../config/config.js"; import { openRefTree, type RefTree, resolveRepoRoot } from "../git/ref-tree.js"; +import { CONFIG_LAYER } from "../layers/config/classify.js"; +import { configLayerConfigSchema } from "../layers/config/config.js"; +import { listComposeSourceFiles } from "../layers/config/config-layer.js"; import type { ClientRefCalls, Layer } from "../layers/layer.js"; import { getCacheDir } from "../layers/openapi/oasdiff-download.js"; import { LAYERS } from "../layers/registry.js"; @@ -151,6 +154,7 @@ export async function runCheck(options: CheckOptions, io: CheckIo): Promise io.stderr(`[${layer.name}] ${message}\n`), results: [...results], calls, + ...(deployComposeFiles === undefined ? {} : { deployComposeFiles }), }), ); const { result } = output; @@ -311,3 +316,13 @@ function resolvePath(repoDir: string, cwd: string, configPath: string | undefine if (configPath === undefined) return join(repoDir, DEFAULT_CONFIG_FILE); return isAbsolute(configPath) ? configPath : resolve(cwd, configPath); } + +/** The globs of the enabled `config` layer's `compose` sources, which other layers read as the deploy. */ +function getConfiguredDeployComposeFiles(layers: CompatConfig["layers"]): string[] | undefined { + const entry = layers.find(({ layer }) => layer.name === CONFIG_LAYER); + if (entry === undefined) return undefined; + const parsed = configLayerConfigSchema.safeParse(entry.config); + if (!parsed.success) return undefined; + const files = listComposeSourceFiles(parsed.data.sources); + return files.length > 0 ? files : undefined; +} diff --git a/src/layers/config/config-layer.ts b/src/layers/config/config-layer.ts index 93d07c4..5d4eb9a 100644 --- a/src/layers/config/config-layer.ts +++ b/src/layers/config/config-layer.ts @@ -255,13 +255,18 @@ function getScanner( } } +/** The globs of the `compose` sources, which are the deploy; empty without one. */ +export function listComposeSourceFiles(sources: readonly ConfigSource[]): string[] { + return [...new Set(sources.flatMap((source) => (source.kind === "compose" ? source.files : [])))]; +} + /** * The globs an `appsettings` source without `composeFiles` looks its service up in: the files of - * the layer's `compose` sources, which are the deploy, else every compose file. + * the layer's `compose` sources, else every compose file. */ function getDeployComposeFiles(sources: ConfigSource[]): string[] { - const files = sources.flatMap((source) => (source.kind === "compose" ? source.files : [])); - return files.length > 0 ? [...new Set(files)] : DEFAULT_COMPOSE_FILES; + const files = listComposeSourceFiles(sources); + return files.length > 0 ? files : DEFAULT_COMPOSE_FILES; } function withPrefix(declaration: KeyDeclaration, prefix: string | undefined): KeyDeclaration { diff --git a/src/layers/layer.ts b/src/layers/layer.ts index b9fc88a..8abd27d 100644 --- a/src/layers/layer.ts +++ b/src/layers/layer.ts @@ -23,6 +23,11 @@ export type LayerContext = { results?: readonly LayerResult[]; /** What live client refs call, shared by `client-usage` when it ran before this layer. */ calls?: readonly ClientRefCalls[]; + /** + * The globs of the `config` layer's `compose` sources, which are the deploy; absent when that layer is not enabled + * or has no `compose` source. + */ + deployComposeFiles?: readonly string[]; }; /** The operations one live client ref calls: method lower-case or `*` when unknown, path normalized. */ diff --git a/src/layers/outbound/config.ts b/src/layers/outbound/config.ts index 62bab2b..fcd7a61 100644 --- a/src/layers/outbound/config.ts +++ b/src/layers/outbound/config.ts @@ -1,5 +1,4 @@ import { z } from "zod"; -import { DEFAULT_COMPOSE_FILES } from "../config/config.js"; export const OUTBOUND_LAYER = "outbound"; @@ -58,8 +57,11 @@ export const targetSourceSchema = z keyFrom: z.enum(TARGET_KEY_SOURCES).optional(), /** The compose service that runs the app; a target whose key its `environment` sets is reported as the key. */ service: z.string().min(1).optional(), - /** Where `service` is looked up; list only the deploy files, so a local-dev compose never counts. */ - composeFiles: z.array(z.string().min(1)).min(1).default(DEFAULT_COMPOSE_FILES), + /** + * Where `service` is looked up. Defaults to the files of the `config` layer's `compose` sources, so a local-dev + * compose never counts as the deploy; to `DEFAULT_COMPOSE_FILES` only without one. + */ + composeFiles: z.array(z.string().min(1)).min(1).optional(), }) .superRefine((source, context) => { // A flags issue is already reported; compiling with them would only repeat it. diff --git a/src/layers/outbound/outbound-layer.ts b/src/layers/outbound/outbound-layer.ts index f20fd00..d680779 100644 --- a/src/layers/outbound/outbound-layer.ts +++ b/src/layers/outbound/outbound-layer.ts @@ -1,6 +1,7 @@ import type { RefTree } from "../../git/ref-tree.js"; import type { LayerResult } from "../../model/finding.js"; import { err, ok, type Result } from "../../result.js"; +import { DEFAULT_COMPOSE_FILES } from "../config/config.js"; import { readSettingLeaves, type SettingLeaf } from "../config/scan-appsettings.js"; import { defineLayer } from "../layer.js"; import { applyOutboundAccept, classifyOutbound, type TargetDeclaration } from "./classify.js"; @@ -22,7 +23,8 @@ export const outboundLayer = defineLayer({ const revision = new Map(); // Every source is read even when one fails, so its error and the others' counts reach the report. for (const source of context.config.targets) { - const outcome = await readSource(context.base, context.revision, source); + const composeFiles = source.composeFiles ?? context.deployComposeFiles ?? DEFAULT_COMPOSE_FILES; + const outcome = await readSource(context.base, context.revision, { source, composeFiles }); if (!outcome.ok) { errors.push(`target source "${source.name}": ${outcome.error}`); continue; @@ -82,9 +84,11 @@ function getOccurrenceKey( } /** The targets one source captures in the files of `tree`, each with its first capture as evidence. */ +type SourceSettings = { source: TargetSource; composeFiles: readonly string[] }; + async function readTree( tree: RefTree, - source: TargetSource, + { source, composeFiles }: SourceSettings, regex: RegExp, ): Promise }>> { const files = await tree.listFiles(source.files); @@ -93,7 +97,7 @@ async function readTree( if (source.service !== undefined) { const read = await readDeployOverrides(tree, { service: source.service, - composeFiles: source.composeFiles, + composeFiles: [...composeFiles], }); if (!read.ok) return read; overrides = read.value; @@ -134,14 +138,15 @@ async function readTree( async function readSource( base: RefTree, revision: RefTree, - source: TargetSource, + settings: SourceSettings, ): Promise> { + const { source } = settings; const regex = compileTargetPattern(source.pattern, source.flags); // The config schema already rejected a pattern that does not compile. if (!(regex instanceof RegExp)) throw new Error(`pattern ${source.pattern} ${regex.error}`); const [before, after] = await Promise.all([ - readTree(base, source, regex), - readTree(revision, source, regex), + readTree(base, settings, regex), + readTree(revision, settings, regex), ]); if (!before.ok) return before; if (!after.ok) return after; diff --git a/test/e2e/outbound-deploy-keys.test.ts b/test/e2e/outbound-deploy-keys.test.ts index 31903b3..a49bc46 100644 --- a/test/e2e/outbound-deploy-keys.test.ts +++ b/test/e2e/outbound-deploy-keys.test.ts @@ -106,3 +106,72 @@ describe("outbound target whose config key the deploy overrides (issue #127)", ( expect(markdown).not.toContain("outbound call to api-shop-dev.example.com"); }); }); + +describe("outbound service lookup without composeFiles", () => { + const LOCAL_COMPOSE_PATH = "APP/docker-compose.yml"; + let localRepo: TestRepo; + + beforeAll(() => { + localRepo = createRepo([ + { + files: { + [SETTINGS_PATH]: settings(), + [COMPOSE_PATH]: compose(), + [LOCAL_COMPOSE_PATH]: compose("Shop__BaseUrl=${SHOP_BASE_URL:-https://localhost:5001}"), + }, + tag: "2.2.4", + }, + { + files: { [SETTINGS_PATH]: settings('"Shop": { "BaseUrl": "https://api-shop-dev.example.com" }') }, + tag: "2.3.4", + }, + ]); + const target = { + name: "appsettings-urls", + files: ["APP/**/appsettings.json"], + pattern: '"BaseUrl"\\s*:\\s*"(?https?://[^"/]+)', + keyFrom: "appsettings", + service: "api", + }; + writeRepoFile( + localRepo, + "compat.json", + JSON.stringify({ + layers: { + config: { sources: [{ kind: "compose", files: [COMPOSE_PATH] }] }, + outbound: { targets: [target] }, + }, + }), + ); + writeRepoFile( + localRepo, + "outbound-only.json", + JSON.stringify({ layers: { outbound: { targets: [target] } } }), + ); + }); + afterAll(() => localRepo.cleanup()); + + const readOutbound = async (configFile: string) => { + const run = createIo(localRepo.dir); + await main( + ["check", "--base", "2.2.4", "--revision", "2.3.4", "--config", configFile, "--format", "json"], + run.io, + ); + const report = JSON.parse(run.stdout()) as { layers: JsonLayer[] }; + return report.layers.find((layer) => layer.layer === "outbound"); + }; + + it("reads the config layer's compose sources as the deploy, so a local-dev compose does not set the key", async () => { + const outbound = await readOutbound("compat.json"); + expect(outbound?.findings.map((finding) => [finding.id, finding.subject])).toEqual([ + ["outbound-added", "api-shop-dev.example.com"], + ]); + }); + + it("falls back to every compose file without a config compose source", async () => { + const outbound = await readOutbound("outbound-only.json"); + expect(outbound?.findings.map((finding) => [finding.id, finding.subject])).toEqual([ + ["outbound-added", "Shop:BaseUrl"], + ]); + }); +});