Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
17 changes: 16 additions & 1 deletion src/commands/check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -151,6 +154,7 @@ export async function runCheck(options: CheckOptions, io: CheckIo): Promise<numb

let results: LayerResult[] = [];
let calls: ClientRefCalls[] | undefined;
const deployComposeFiles = getConfiguredDeployComposeFiles(config.value.layers);
for (const { layer, config: layerConfig } of config.value.layers) {
const tempDir = join(tempRoot, `layer-${layer.name}`);
await mkdir(tempDir, { recursive: true });
Expand All @@ -168,6 +172,7 @@ export async function runCheck(options: CheckOptions, io: CheckIo): Promise<numb
log: (message) => io.stderr(`[${layer.name}] ${message}\n`),
results: [...results],
calls,
...(deployComposeFiles === undefined ? {} : { deployComposeFiles }),
}),
);
const { result } = output;
Expand Down Expand Up @@ -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;
}
11 changes: 8 additions & 3 deletions src/layers/config/config-layer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
5 changes: 5 additions & 0 deletions src/layers/layer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ export type LayerContext<C> = {
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. */
Expand Down
8 changes: 5 additions & 3 deletions src/layers/outbound/config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { z } from "zod";
import { DEFAULT_COMPOSE_FILES } from "../config/config.js";

export const OUTBOUND_LAYER = "outbound";

Expand Down Expand Up @@ -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.
Expand Down
17 changes: 11 additions & 6 deletions src/layers/outbound/outbound-layer.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -22,7 +23,8 @@ export const outboundLayer = defineLayer({
const revision = new Map<string, TargetDeclaration>();
// 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;
Expand Down Expand Up @@ -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<Result<{ files: number; targets: Map<string, TargetDeclaration> }>> {
const files = await tree.listFiles(source.files);
Expand All @@ -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;
Expand Down Expand Up @@ -134,14 +138,15 @@ async function readTree(
async function readSource(
base: RefTree,
revision: RefTree,
source: TargetSource,
settings: SourceSettings,
): Promise<Result<TargetSides>> {
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;
Expand Down
69 changes: 69 additions & 0 deletions test/e2e/outbound-deploy-keys.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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*"(?<host>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"],
]);
});
});
Loading