@sdods/core 0.7.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/.tsbuildinfo +1 -1
- package/dist/analyze/index.d.ts +2 -0
- package/dist/analyze/index.js +2 -0
- package/dist/analyze/traceability-render.d.ts +5 -0
- package/dist/analyze/traceability-render.js +254 -0
- package/dist/analyze/traceability.d.ts +87 -0
- package/dist/analyze/traceability.js +559 -0
- package/dist/api/json-path.d.ts +11 -1
- package/dist/api/json-path.js +23 -1
- package/dist/config/emulation.d.ts +74 -0
- package/dist/config/emulation.js +154 -0
- package/dist/config/index.d.ts +1 -0
- package/dist/config/index.js +1 -0
- package/dist/config/runner.js +4 -1
- package/dist/config/tags.d.ts +1 -1
- package/dist/config/tags.js +7 -0
- package/dist/errors.d.ts +1 -1
- package/dist/fixtures/api-context.d.ts +19 -1
- package/dist/fixtures/api-context.js +28 -1
- package/dist/fixtures/test.js +36 -0
- package/dist/fixtures/types.d.ts +3 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/lint/index.js +86 -0
- package/dist/load/index.d.ts +4 -0
- package/dist/load/index.js +4 -0
- package/dist/load/profile.d.ts +22 -0
- package/dist/load/profile.js +109 -0
- package/dist/load/run.d.ts +85 -0
- package/dist/load/run.js +255 -0
- package/dist/load/script.d.ts +27 -0
- package/dist/load/script.js +188 -0
- package/dist/mail/extract.d.ts +38 -0
- package/dist/mail/extract.js +106 -0
- package/dist/mail/index.d.ts +8 -0
- package/dist/mail/index.js +11 -0
- package/dist/mail/mailpit.d.ts +26 -0
- package/dist/mail/mailpit.js +124 -0
- package/dist/mail/types.d.ts +38 -0
- package/dist/mail/types.js +2 -0
- package/dist/matrix/index.d.ts +104 -0
- package/dist/matrix/index.js +486 -0
- package/dist/steps/api.steps.js +5 -7
- package/dist/steps/browser.steps.d.ts +12 -0
- package/dist/steps/browser.steps.js +154 -3
- package/dist/steps/canvas.steps.d.ts +9 -0
- package/dist/steps/canvas.steps.js +331 -0
- package/dist/steps/email.steps.d.ts +2 -0
- package/dist/steps/email.steps.js +182 -0
- package/dist/steps/hybrid.steps.js +2 -2
- package/dist/steps/index.d.ts +2 -0
- package/dist/steps/index.js +2 -0
- package/package.json +4 -3
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { ZodError } from 'zod';
|
|
4
|
+
import { LOAD_READ_METHODS, LoadProfileSchema, } from '@sdods/contracts';
|
|
5
|
+
import { SdodsConfigError } from '../errors.js';
|
|
6
|
+
import { assertNoSecretLiterals } from '../config/interpolate.js';
|
|
7
|
+
import { readYamlFile, zodToConfigError } from '../config/resolve.js';
|
|
8
|
+
export const LOAD_DIR = 'load';
|
|
9
|
+
const PROFILE_NAME_RE = /^[a-z0-9][a-z0-9._-]*$/i;
|
|
10
|
+
/** Profile names under `projects/<slug>/load/`, without the extension. */
|
|
11
|
+
export function listLoadProfiles(projectRoot) {
|
|
12
|
+
const dir = join(projectRoot, LOAD_DIR);
|
|
13
|
+
if (!existsSync(dir))
|
|
14
|
+
return [];
|
|
15
|
+
return readdirSync(dir)
|
|
16
|
+
.filter((f) => /\.ya?ml$/.test(f))
|
|
17
|
+
.map((f) => f.replace(/\.ya?ml$/, ''))
|
|
18
|
+
.sort();
|
|
19
|
+
}
|
|
20
|
+
export function loadProfileFile(projectRoot, name) {
|
|
21
|
+
if (!PROFILE_NAME_RE.test(name))
|
|
22
|
+
return undefined;
|
|
23
|
+
for (const ext of ['yaml', 'yml']) {
|
|
24
|
+
const file = join(projectRoot, LOAD_DIR, `${name}.${ext}`);
|
|
25
|
+
if (existsSync(file))
|
|
26
|
+
return file;
|
|
27
|
+
}
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
export function parseLoadProfile(raw, file = '<profile>') {
|
|
31
|
+
assertNoSecretLiterals(raw, 'profile');
|
|
32
|
+
try {
|
|
33
|
+
return LoadProfileSchema.parse(raw ?? {});
|
|
34
|
+
}
|
|
35
|
+
catch (e) {
|
|
36
|
+
if (e instanceof ZodError)
|
|
37
|
+
throw zodToConfigError(e, file);
|
|
38
|
+
throw e;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
export function readLoadProfile(projectRoot, name) {
|
|
42
|
+
const file = loadProfileFile(projectRoot, name);
|
|
43
|
+
if (!file) {
|
|
44
|
+
const known = listLoadProfiles(projectRoot);
|
|
45
|
+
throw new SdodsConfigError(`No load profile "${name}" in ${join(projectRoot, LOAD_DIR)}.`, {
|
|
46
|
+
code: 'CONFIG_NOT_FOUND',
|
|
47
|
+
hint: known.length
|
|
48
|
+
? `Known profiles: ${known.join(', ')}.`
|
|
49
|
+
: `Create ${LOAD_DIR}/${name}.yaml in the project folder (see the load testing guide).`,
|
|
50
|
+
docsPath: '/docs/guides/load-testing',
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
return parseLoadProfile(readYamlFile(file), file);
|
|
54
|
+
}
|
|
55
|
+
/** The most virtual users the profile reaches: `vus`, or the highest stage target. */
|
|
56
|
+
export function peakVus(profile) {
|
|
57
|
+
if (profile.stages)
|
|
58
|
+
return Math.max(...profile.stages.map((s) => s.target));
|
|
59
|
+
return profile.vus ?? 1;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Why this environment may not take this profile. Empty means it may.
|
|
63
|
+
* Checked before every real run; `--dry-run` only reports it.
|
|
64
|
+
*/
|
|
65
|
+
export function loadGuardProblems(env, profile) {
|
|
66
|
+
const problems = [];
|
|
67
|
+
const load = env.load;
|
|
68
|
+
if (!load?.allowed) {
|
|
69
|
+
problems.push(`environment "${env.name}" has not opted in to load tests (envs/${env.name}.yaml has no \`load: { allowed: true }\`)`);
|
|
70
|
+
}
|
|
71
|
+
const peak = peakVus(profile);
|
|
72
|
+
if (load?.maxVus !== undefined && peak > load.maxVus) {
|
|
73
|
+
problems.push(`profile peaks at ${peak} virtual users, above load.maxVus ${load.maxVus} for "${env.name}"`);
|
|
74
|
+
}
|
|
75
|
+
const writes = profile.requests.filter((r) => !LOAD_READ_METHODS.includes(r.method));
|
|
76
|
+
if (writes.length && !load?.allowWrites) {
|
|
77
|
+
problems.push(`profile sends ${[...new Set(writes.map((r) => r.method))].join(', ')} requests and "${env.name}" does not set load.allowWrites: true`);
|
|
78
|
+
}
|
|
79
|
+
return problems;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* The load goes to `SDODS_API_BASE_URL` when it is set. The opt-in is for the environment file, so
|
|
83
|
+
* an override that points at another host is refused unless that file sets
|
|
84
|
+
* `load.allowBaseUrlOverride: true`. Both URLs are compared without trailing slashes.
|
|
85
|
+
*/
|
|
86
|
+
export function baseUrlOverrideProblems(env, envBaseUrl, override) {
|
|
87
|
+
const norm = (u) => u.trim().replace(/\/+$/, '');
|
|
88
|
+
if (!override || norm(override) === norm(envBaseUrl) || env.load?.allowBaseUrlOverride)
|
|
89
|
+
return [];
|
|
90
|
+
return [
|
|
91
|
+
`SDODS_API_BASE_URL (${norm(override)}) is not the api.baseUrl of "${env.name}" (${norm(envBaseUrl)}), and envs/${env.name}.yaml does not set load.allowBaseUrlOverride: true`,
|
|
92
|
+
];
|
|
93
|
+
}
|
|
94
|
+
export function assertLoadAllowed(env, profile, extraProblems = []) {
|
|
95
|
+
const problems = [...loadGuardProblems(env, profile), ...extraProblems];
|
|
96
|
+
if (!problems.length)
|
|
97
|
+
return;
|
|
98
|
+
throw new SdodsConfigError(`Refusing to run a load test: ${problems.join('; ')}.`, {
|
|
99
|
+
hint: `Only opt in an environment you own and that is sized for load — never production. ` +
|
|
100
|
+
`In envs/${env.name}.yaml: load: { allowed: true, maxVus: 50, allowWrites: false }. ` +
|
|
101
|
+
(extraProblems.length
|
|
102
|
+
? 'Unset SDODS_API_BASE_URL, or set load.allowBaseUrlOverride: true in the environment file if the override is intended. '
|
|
103
|
+
: '') +
|
|
104
|
+
'`--dry-run` writes the script without running it.',
|
|
105
|
+
docsPath: '/docs/guides/load-testing',
|
|
106
|
+
details: { problems },
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
//# sourceMappingURL=profile.js.map
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { SdodsError } from '../errors.js';
|
|
2
|
+
export declare const K6_INSTALL_URL = "https://grafana.com/docs/k6/latest/set-up/install-k6/";
|
|
3
|
+
export declare const K6_DEFAULT_IMAGE = "grafana/k6:latest";
|
|
4
|
+
/** k6 exits with 99 when one or more thresholds failed. */
|
|
5
|
+
export declare const K6_THRESHOLDS_FAILED = 99;
|
|
6
|
+
export type LoadRunner = 'auto' | 'k6' | 'docker';
|
|
7
|
+
export type LoadStdio = 'inherit' | 'pipe' | 'stderr';
|
|
8
|
+
/** Absolute path of an executable on `PATH`, or undefined. */
|
|
9
|
+
export declare function findExecutable(name: string, env?: NodeJS.ProcessEnv): string | undefined;
|
|
10
|
+
export declare function k6MissingError(runner: LoadRunner): SdodsError;
|
|
11
|
+
/** sdods exit code for a k6 exit code: 0 passes, anything else (99 = thresholds) fails with 1. */
|
|
12
|
+
export declare function mapK6ExitCode(code: number): {
|
|
13
|
+
exitCode: number;
|
|
14
|
+
thresholdsFailed: boolean;
|
|
15
|
+
};
|
|
16
|
+
export interface RunK6Options {
|
|
17
|
+
outDir: string;
|
|
18
|
+
scriptFile: string;
|
|
19
|
+
summaryFile: string;
|
|
20
|
+
runner?: LoadRunner;
|
|
21
|
+
image?: string;
|
|
22
|
+
/** Environment for the k6 process; k6 exposes it to the script as `__ENV`. */
|
|
23
|
+
env: NodeJS.ProcessEnv;
|
|
24
|
+
/** Names passed into the container with `-e NAME` (values stay out of the command line). */
|
|
25
|
+
passEnv?: string[];
|
|
26
|
+
/** `stderr` sends k6's output to stderr so stdout stays machine-readable (`--json`). */
|
|
27
|
+
stdio?: LoadStdio;
|
|
28
|
+
}
|
|
29
|
+
export interface RunK6Result {
|
|
30
|
+
runner: 'k6' | 'docker';
|
|
31
|
+
command: string;
|
|
32
|
+
k6ExitCode: number;
|
|
33
|
+
stdout: string;
|
|
34
|
+
stderr: string;
|
|
35
|
+
}
|
|
36
|
+
export declare function runK6(opts: RunK6Options): Promise<RunK6Result>;
|
|
37
|
+
export interface LoadSummary {
|
|
38
|
+
requests?: number;
|
|
39
|
+
p95Ms?: number;
|
|
40
|
+
failedRate?: number;
|
|
41
|
+
checksRate?: number;
|
|
42
|
+
}
|
|
43
|
+
/** A few headline numbers from k6's `--summary-export` JSON; absent metrics stay undefined. */
|
|
44
|
+
export declare function readK6Summary(file: string): LoadSummary | undefined;
|
|
45
|
+
export interface RunLoadOptions {
|
|
46
|
+
rootDir: string;
|
|
47
|
+
projectRoot: string;
|
|
48
|
+
profile: string;
|
|
49
|
+
env?: string;
|
|
50
|
+
/** Where to write script.js, summary.json and load.json. Default `.sdods/runs/<id>/load/<profile>`. */
|
|
51
|
+
outDir?: string;
|
|
52
|
+
dryRun?: boolean;
|
|
53
|
+
runner?: LoadRunner;
|
|
54
|
+
image?: string;
|
|
55
|
+
processEnv?: NodeJS.ProcessEnv;
|
|
56
|
+
stdio?: LoadStdio;
|
|
57
|
+
/** Progress lines (target, peak VUs, warnings), printed before k6 starts. */
|
|
58
|
+
log?: (line: string) => void;
|
|
59
|
+
}
|
|
60
|
+
export interface LoadRunResult {
|
|
61
|
+
project: string;
|
|
62
|
+
env: string;
|
|
63
|
+
profile: string;
|
|
64
|
+
target: string;
|
|
65
|
+
peakVus: number;
|
|
66
|
+
outDir: string;
|
|
67
|
+
scriptFile: string;
|
|
68
|
+
summaryFile?: string;
|
|
69
|
+
dryRun: boolean;
|
|
70
|
+
/** Reasons a real run would be refused (reported by --dry-run; a real run throws instead). */
|
|
71
|
+
guardProblems: string[];
|
|
72
|
+
requiredEnv: string[];
|
|
73
|
+
runner?: 'k6' | 'docker';
|
|
74
|
+
k6ExitCode?: number;
|
|
75
|
+
thresholdsFailed: boolean;
|
|
76
|
+
summary?: LoadSummary;
|
|
77
|
+
exitCode: number;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Generate a k6 script for a load profile and run it against an environment that opted in.
|
|
81
|
+
* The environment is read uninterpolated so `${VAR}` secrets become `__ENV` lookups; their values
|
|
82
|
+
* reach k6 through its process environment only.
|
|
83
|
+
*/
|
|
84
|
+
export declare function runLoad(opts: RunLoadOptions): Promise<LoadRunResult>;
|
|
85
|
+
//# sourceMappingURL=run.d.ts.map
|
package/dist/load/run.js
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { accessSync, constants, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { delimiter, join, resolve as resolvePath } from 'node:path';
|
|
3
|
+
import { execa } from 'execa';
|
|
4
|
+
import { newRunId } from '@sdods/contracts';
|
|
5
|
+
import { SdodsConfigError, SdodsError } from '../errors.js';
|
|
6
|
+
import { DEFAULT_ARTIFACTS_DIR } from '../config/defaults.js';
|
|
7
|
+
import { loadDotEnvLayer } from '../config/env-files.js';
|
|
8
|
+
import { assertNoSecretLiterals, interpolateString } from '../config/interpolate.js';
|
|
9
|
+
import { loadEnvFile, loadProjectFile } from '../config/resolve.js';
|
|
10
|
+
import { assertLoadAllowed, baseUrlOverrideProblems, loadGuardProblems, peakVus, readLoadProfile, } from './profile.js';
|
|
11
|
+
import { generateK6Script } from './script.js';
|
|
12
|
+
export const K6_INSTALL_URL = 'https://grafana.com/docs/k6/latest/set-up/install-k6/';
|
|
13
|
+
export const K6_DEFAULT_IMAGE = 'grafana/k6:latest';
|
|
14
|
+
/** k6 exits with 99 when one or more thresholds failed. */
|
|
15
|
+
export const K6_THRESHOLDS_FAILED = 99;
|
|
16
|
+
/** Absolute path of an executable on `PATH`, or undefined. */
|
|
17
|
+
export function findExecutable(name, env = process.env) {
|
|
18
|
+
const exts = process.platform === 'win32' ? (env.PATHEXT ?? '.EXE;.CMD;.BAT').split(';') : [''];
|
|
19
|
+
for (const dir of (env.PATH ?? env.Path ?? '').split(delimiter)) {
|
|
20
|
+
if (!dir)
|
|
21
|
+
continue;
|
|
22
|
+
for (const ext of exts) {
|
|
23
|
+
const file = join(dir, name + ext);
|
|
24
|
+
try {
|
|
25
|
+
accessSync(file, constants.X_OK);
|
|
26
|
+
return file;
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
// keep looking
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
export function k6MissingError(runner) {
|
|
36
|
+
if (runner === 'docker') {
|
|
37
|
+
return new SdodsError('NOT_SUPPORTED', '`--runner docker` needs docker on PATH.', {
|
|
38
|
+
hint: `Install Docker, or install k6 itself (${K6_INSTALL_URL}) and use --runner k6.`,
|
|
39
|
+
docsPath: '/docs/guides/load-testing',
|
|
40
|
+
exitCode: 2,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
return new SdodsError('NOT_SUPPORTED', 'k6 is not installed (no `k6` on PATH).', {
|
|
44
|
+
hint: `Install k6: ${K6_INSTALL_URL} (macOS: brew install k6), or run it in Docker with ` +
|
|
45
|
+
'`--runner docker`. `--dry-run` writes the script without k6.',
|
|
46
|
+
docsPath: '/docs/guides/load-testing',
|
|
47
|
+
exitCode: 2,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
/** sdods exit code for a k6 exit code: 0 passes, anything else (99 = thresholds) fails with 1. */
|
|
51
|
+
export function mapK6ExitCode(code) {
|
|
52
|
+
if (code === 0)
|
|
53
|
+
return { exitCode: 0, thresholdsFailed: false };
|
|
54
|
+
return { exitCode: 1, thresholdsFailed: code === K6_THRESHOLDS_FAILED };
|
|
55
|
+
}
|
|
56
|
+
export async function runK6(opts) {
|
|
57
|
+
const runner = opts.runner ?? 'auto';
|
|
58
|
+
let file;
|
|
59
|
+
let args;
|
|
60
|
+
let used;
|
|
61
|
+
const k6 = runner === 'docker' ? undefined : findExecutable('k6', opts.env);
|
|
62
|
+
if (k6) {
|
|
63
|
+
used = 'k6';
|
|
64
|
+
file = k6;
|
|
65
|
+
args = ['run', '--summary-export', opts.summaryFile, opts.scriptFile];
|
|
66
|
+
}
|
|
67
|
+
else if (runner === 'docker') {
|
|
68
|
+
const docker = findExecutable('docker', opts.env);
|
|
69
|
+
if (!docker)
|
|
70
|
+
throw k6MissingError('docker');
|
|
71
|
+
used = 'docker';
|
|
72
|
+
file = docker;
|
|
73
|
+
const mount = resolvePath(opts.outDir);
|
|
74
|
+
args = [
|
|
75
|
+
'run',
|
|
76
|
+
'--rm',
|
|
77
|
+
'-i',
|
|
78
|
+
'-v',
|
|
79
|
+
`${mount}:/sdods-load`,
|
|
80
|
+
...(opts.passEnv ?? []).flatMap((name) => ['-e', name]),
|
|
81
|
+
opts.image ?? K6_DEFAULT_IMAGE,
|
|
82
|
+
'run',
|
|
83
|
+
'--summary-export',
|
|
84
|
+
`/sdods-load/${relativeTo(mount, opts.summaryFile)}`,
|
|
85
|
+
`/sdods-load/${relativeTo(mount, opts.scriptFile)}`,
|
|
86
|
+
];
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
throw k6MissingError(runner);
|
|
90
|
+
}
|
|
91
|
+
const res = await execa(file, args, {
|
|
92
|
+
env: opts.env,
|
|
93
|
+
extendEnv: false,
|
|
94
|
+
reject: false,
|
|
95
|
+
stdio: opts.stdio === 'stderr' ? ['ignore', 2, 2] : (opts.stdio ?? 'inherit'),
|
|
96
|
+
});
|
|
97
|
+
if (res.failed && typeof res.exitCode !== 'number') {
|
|
98
|
+
throw new SdodsError('RUN_FAILED', `Could not start ${used}: ${res.message}`, {
|
|
99
|
+
docsPath: '/docs/guides/load-testing',
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
return {
|
|
103
|
+
runner: used,
|
|
104
|
+
command: [used, ...args].join(' '),
|
|
105
|
+
k6ExitCode: res.exitCode ?? 1,
|
|
106
|
+
stdout: typeof res.stdout === 'string' ? res.stdout : '',
|
|
107
|
+
stderr: typeof res.stderr === 'string' ? res.stderr : '',
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
function relativeTo(dir, file) {
|
|
111
|
+
const abs = resolvePath(file);
|
|
112
|
+
if (!abs.startsWith(dir + '/') && !abs.startsWith(dir + '\\')) {
|
|
113
|
+
throw new SdodsError('INTERNAL', `${abs} is not inside ${dir}; docker cannot mount it.`);
|
|
114
|
+
}
|
|
115
|
+
return abs.slice(dir.length + 1).replace(/\\/g, '/');
|
|
116
|
+
}
|
|
117
|
+
/** A few headline numbers from k6's `--summary-export` JSON; absent metrics stay undefined. */
|
|
118
|
+
export function readK6Summary(file) {
|
|
119
|
+
if (!existsSync(file))
|
|
120
|
+
return undefined;
|
|
121
|
+
try {
|
|
122
|
+
const metrics = JSON.parse(readFileSync(file, 'utf8'))
|
|
123
|
+
.metrics;
|
|
124
|
+
if (!metrics)
|
|
125
|
+
return undefined;
|
|
126
|
+
const num = (v) => (typeof v === 'number' ? v : undefined);
|
|
127
|
+
return {
|
|
128
|
+
requests: num(metrics.http_reqs?.count),
|
|
129
|
+
p95Ms: num(metrics.http_req_duration?.['p(95)']),
|
|
130
|
+
failedRate: num(metrics.http_req_failed?.value ?? metrics.http_req_failed?.rate),
|
|
131
|
+
checksRate: num(metrics.checks?.value ?? metrics.checks?.rate),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
return undefined;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Generate a k6 script for a load profile and run it against an environment that opted in.
|
|
140
|
+
* The environment is read uninterpolated so `${VAR}` secrets become `__ENV` lookups; their values
|
|
141
|
+
* reach k6 through its process environment only.
|
|
142
|
+
*/
|
|
143
|
+
export async function runLoad(opts) {
|
|
144
|
+
const processEnv = opts.processEnv ?? process.env;
|
|
145
|
+
const log = opts.log ?? (() => { });
|
|
146
|
+
const project = loadProjectFile(opts.projectRoot);
|
|
147
|
+
const envName = opts.env ?? processEnv.SDODS_ENV ?? project.envs.default;
|
|
148
|
+
if (!project.envs.available.includes(envName)) {
|
|
149
|
+
throw new SdodsConfigError(`Environment "${envName}" is not in envs.available [${project.envs.available.join(', ')}] for project ${project.slug}.`, { code: 'ENV_NOT_FOUND' });
|
|
150
|
+
}
|
|
151
|
+
const env = loadEnvFile(opts.projectRoot, envName);
|
|
152
|
+
assertNoSecretLiterals(env, 'env');
|
|
153
|
+
const profile = readLoadProfile(opts.projectRoot, opts.profile);
|
|
154
|
+
const vars = {
|
|
155
|
+
...loadDotEnvLayer(opts.rootDir, opts.projectRoot, envName).values,
|
|
156
|
+
...processEnv,
|
|
157
|
+
};
|
|
158
|
+
const envBaseUrl = interpolateString(env.api.baseUrl, { vars, onUnresolved: 'throw' }, 'env.api.baseUrl').replace(/\/+$/, '');
|
|
159
|
+
const override = processEnv.SDODS_API_BASE_URL || undefined;
|
|
160
|
+
const overrideProblems = baseUrlOverrideProblems(env, envBaseUrl, override);
|
|
161
|
+
const target = (override ?? envBaseUrl).trim().replace(/\/+$/, '');
|
|
162
|
+
const peak = peakVus(profile);
|
|
163
|
+
const guardProblems = [...loadGuardProblems(env, profile), ...overrideProblems];
|
|
164
|
+
const { script, requiredEnv } = generateK6Script({
|
|
165
|
+
project: project.slug,
|
|
166
|
+
profileName: opts.profile,
|
|
167
|
+
profile,
|
|
168
|
+
env,
|
|
169
|
+
baseUrl: target,
|
|
170
|
+
});
|
|
171
|
+
log(`target ${target}`);
|
|
172
|
+
log(`peak VUs ${peak}${profile.stages ? ` (stages)` : ''}`);
|
|
173
|
+
if (opts.dryRun) {
|
|
174
|
+
for (const p of guardProblems)
|
|
175
|
+
log(`warning: a real run would be refused: ${p}`);
|
|
176
|
+
}
|
|
177
|
+
else {
|
|
178
|
+
// Refuse before writing anything: a refused run leaves no run directory behind.
|
|
179
|
+
assertLoadAllowed(env, profile, overrideProblems);
|
|
180
|
+
const missing = requiredEnv.filter((name) => !vars[name]);
|
|
181
|
+
if (missing.length) {
|
|
182
|
+
throw new SdodsConfigError(`The load script reads ${missing.map((n) => `\${${n}}`).join(', ')} and ${missing.length === 1 ? 'it is' : 'they are'} not set.`, {
|
|
183
|
+
code: 'CONFIG_UNRESOLVED_VAR',
|
|
184
|
+
hint: `Add ${missing[0]}=... to .env.${envName} (repo root or project folder), or export it in the shell.`,
|
|
185
|
+
details: { variables: missing },
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
const outDir = resolvePath(opts.outDir ??
|
|
190
|
+
join(opts.rootDir, processEnv.SDODS_ARTIFACTS_DIR || DEFAULT_ARTIFACTS_DIR, newRunId(), 'load', opts.profile));
|
|
191
|
+
mkdirSync(outDir, { recursive: true });
|
|
192
|
+
const scriptFile = join(outDir, 'script.js');
|
|
193
|
+
const summaryFile = join(outDir, 'summary.json');
|
|
194
|
+
writeFileSync(scriptFile, script);
|
|
195
|
+
const result = {
|
|
196
|
+
project: project.slug,
|
|
197
|
+
env: envName,
|
|
198
|
+
profile: opts.profile,
|
|
199
|
+
target,
|
|
200
|
+
peakVus: peak,
|
|
201
|
+
outDir,
|
|
202
|
+
scriptFile,
|
|
203
|
+
dryRun: Boolean(opts.dryRun),
|
|
204
|
+
guardProblems,
|
|
205
|
+
requiredEnv,
|
|
206
|
+
thresholdsFailed: false,
|
|
207
|
+
exitCode: 0,
|
|
208
|
+
};
|
|
209
|
+
const writeMeta = () => writeFileSync(join(outDir, 'load.json'), JSON.stringify({ ...result, finishedAt: new Date().toISOString() }, null, 2) + '\n');
|
|
210
|
+
if (opts.dryRun) {
|
|
211
|
+
writeMeta();
|
|
212
|
+
return result;
|
|
213
|
+
}
|
|
214
|
+
// Dotenv values reach k6 through its environment, never through the script or its arguments.
|
|
215
|
+
// Every variable the script reads, including `${VAR:-default}` ones set in .env files.
|
|
216
|
+
const referenced = [
|
|
217
|
+
...new Set([...script.matchAll(/__ENV\.([A-Za-z_][A-Za-z0-9_]*)/g)].map((m) => m[1])),
|
|
218
|
+
]
|
|
219
|
+
.filter((name) => vars[name] !== undefined)
|
|
220
|
+
.sort();
|
|
221
|
+
const k6Env = { ...processEnv };
|
|
222
|
+
for (const name of referenced)
|
|
223
|
+
k6Env[name] = vars[name];
|
|
224
|
+
// k6 reads K6_VUS, K6_STAGES, K6_DURATION, K6_ITERATIONS, K6_SCENARIOS and the rest as options that
|
|
225
|
+
// override the script, which would bypass the profile and load.maxVus. None are passed through.
|
|
226
|
+
const stripped = Object.keys(k6Env)
|
|
227
|
+
.filter((name) => /^K6_/i.test(name))
|
|
228
|
+
.sort();
|
|
229
|
+
for (const name of stripped)
|
|
230
|
+
delete k6Env[name];
|
|
231
|
+
if (stripped.length)
|
|
232
|
+
log(`warning: not passing ${stripped.join(', ')} to k6: k6 reads K6_* variables as options that override the profile and load.maxVus`);
|
|
233
|
+
const run = await runK6({
|
|
234
|
+
outDir,
|
|
235
|
+
scriptFile,
|
|
236
|
+
summaryFile,
|
|
237
|
+
runner: opts.runner,
|
|
238
|
+
image: opts.image,
|
|
239
|
+
env: k6Env,
|
|
240
|
+
passEnv: referenced.filter((name) => !stripped.includes(name)),
|
|
241
|
+
stdio: opts.stdio,
|
|
242
|
+
});
|
|
243
|
+
const mapped = mapK6ExitCode(run.k6ExitCode);
|
|
244
|
+
Object.assign(result, {
|
|
245
|
+
runner: run.runner,
|
|
246
|
+
k6ExitCode: run.k6ExitCode,
|
|
247
|
+
thresholdsFailed: mapped.thresholdsFailed,
|
|
248
|
+
exitCode: mapped.exitCode,
|
|
249
|
+
summaryFile: existsSync(summaryFile) ? summaryFile : undefined,
|
|
250
|
+
summary: readK6Summary(summaryFile),
|
|
251
|
+
});
|
|
252
|
+
writeMeta();
|
|
253
|
+
return result;
|
|
254
|
+
}
|
|
255
|
+
//# sourceMappingURL=run.js.map
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { EnvConfig, LoadProfile } from '@sdods/contracts';
|
|
2
|
+
export interface K6ScriptInput {
|
|
3
|
+
project: string;
|
|
4
|
+
profileName: string;
|
|
5
|
+
profile: LoadProfile;
|
|
6
|
+
/**
|
|
7
|
+
* The environment as written in `envs/<env>.yaml`, NOT interpolated: `${VAR}` references in
|
|
8
|
+
* auth and headers must survive so they become `__ENV.VAR` lookups instead of inlined values.
|
|
9
|
+
*/
|
|
10
|
+
env: EnvConfig;
|
|
11
|
+
/** Resolved `api.baseUrl` (not a secret); requests are sent to `baseUrl + path`. */
|
|
12
|
+
baseUrl: string;
|
|
13
|
+
}
|
|
14
|
+
export interface K6Script {
|
|
15
|
+
script: string;
|
|
16
|
+
/** Variables the script reads from `__ENV` with no default: they must be set when k6 runs. */
|
|
17
|
+
requiredEnv: string[];
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* A string that may contain `${VAR}` / `${VAR:-default}` as a JavaScript expression reading k6's
|
|
21
|
+
* `__ENV`, so secret values stay in the environment and never reach the generated file.
|
|
22
|
+
*/
|
|
23
|
+
export declare function envExpr(value: string, required: Set<string>): string;
|
|
24
|
+
/** k6 `options` exactly as the profile declares them, tagged with project, env and profile. */
|
|
25
|
+
export declare function k6Options(input: K6ScriptInput): Record<string, unknown>;
|
|
26
|
+
export declare function generateK6Script(input: K6ScriptInput): K6Script;
|
|
27
|
+
//# sourceMappingURL=script.d.ts.map
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
const VAR_RE = /\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}/g;
|
|
2
|
+
/**
|
|
3
|
+
* A string that may contain `${VAR}` / `${VAR:-default}` as a JavaScript expression reading k6's
|
|
4
|
+
* `__ENV`, so secret values stay in the environment and never reach the generated file.
|
|
5
|
+
*/
|
|
6
|
+
export function envExpr(value, required) {
|
|
7
|
+
const parts = [];
|
|
8
|
+
let last = 0;
|
|
9
|
+
for (const m of value.matchAll(VAR_RE)) {
|
|
10
|
+
if (m.index > last)
|
|
11
|
+
parts.push(JSON.stringify(value.slice(last, m.index)));
|
|
12
|
+
const name = m[1];
|
|
13
|
+
if (m[2] === undefined) {
|
|
14
|
+
required.add(name);
|
|
15
|
+
parts.push(`__ENV.${name}`);
|
|
16
|
+
}
|
|
17
|
+
else {
|
|
18
|
+
parts.push(`(__ENV.${name} || ${JSON.stringify(m[2])})`);
|
|
19
|
+
}
|
|
20
|
+
last = m.index + m[0].length;
|
|
21
|
+
}
|
|
22
|
+
if (last < value.length || parts.length === 0)
|
|
23
|
+
parts.push(JSON.stringify(value.slice(last)));
|
|
24
|
+
return parts.join(' + ');
|
|
25
|
+
}
|
|
26
|
+
function valueExpr(value, required, indent) {
|
|
27
|
+
if (typeof value === 'string')
|
|
28
|
+
return envExpr(value, required);
|
|
29
|
+
if (Array.isArray(value)) {
|
|
30
|
+
if (!value.length)
|
|
31
|
+
return '[]';
|
|
32
|
+
const inner = indent + ' ';
|
|
33
|
+
return `[\n${value.map((v) => inner + valueExpr(v, required, inner)).join(',\n')},\n${indent}]`;
|
|
34
|
+
}
|
|
35
|
+
if (value && typeof value === 'object') {
|
|
36
|
+
const entries = Object.entries(value);
|
|
37
|
+
if (!entries.length)
|
|
38
|
+
return '{}';
|
|
39
|
+
const inner = indent + ' ';
|
|
40
|
+
return `{\n${entries
|
|
41
|
+
.map(([k, v]) => `${inner}${JSON.stringify(k)}: ${valueExpr(v, required, inner)}`)
|
|
42
|
+
.join(',\n')},\n${indent}}`;
|
|
43
|
+
}
|
|
44
|
+
return JSON.stringify(value ?? null);
|
|
45
|
+
}
|
|
46
|
+
function authLines(env, required) {
|
|
47
|
+
const auth = env.api.auth;
|
|
48
|
+
switch (auth.type) {
|
|
49
|
+
case 'none':
|
|
50
|
+
return { imports: [], setup: [], headers: [] };
|
|
51
|
+
case 'bearer':
|
|
52
|
+
return {
|
|
53
|
+
imports: [],
|
|
54
|
+
setup: [],
|
|
55
|
+
headers: [['Authorization', `"Bearer " + ${envExpr(auth.token, required)}`]],
|
|
56
|
+
};
|
|
57
|
+
case 'basic':
|
|
58
|
+
return {
|
|
59
|
+
imports: [`import encoding from 'k6/encoding';`],
|
|
60
|
+
setup: [],
|
|
61
|
+
headers: [
|
|
62
|
+
[
|
|
63
|
+
'Authorization',
|
|
64
|
+
`"Basic " + encoding.b64encode(${envExpr(auth.username, required)} + ":" + ${envExpr(auth.password, required)})`,
|
|
65
|
+
],
|
|
66
|
+
],
|
|
67
|
+
};
|
|
68
|
+
case 'header':
|
|
69
|
+
return { imports: [], setup: [], headers: [[auth.name, envExpr(auth.value, required)]] };
|
|
70
|
+
case 'oauth-client-credentials': {
|
|
71
|
+
const form = [
|
|
72
|
+
['grant_type', '"client_credentials"'],
|
|
73
|
+
['client_id', envExpr(auth.clientId, required)],
|
|
74
|
+
['client_secret', envExpr(auth.clientSecret, required)],
|
|
75
|
+
];
|
|
76
|
+
if (auth.scope)
|
|
77
|
+
form.push(['scope', envExpr(auth.scope, required)]);
|
|
78
|
+
if (auth.audience)
|
|
79
|
+
form.push(['audience', envExpr(auth.audience, required)]);
|
|
80
|
+
return {
|
|
81
|
+
imports: [],
|
|
82
|
+
setup: [
|
|
83
|
+
` // OAuth client-credentials token, minted once. A test longer than the token lifetime`,
|
|
84
|
+
` // will start failing with 401 when it expires.`,
|
|
85
|
+
` const tokenRes = http.post(${envExpr(auth.tokenUrl, required)}, {`,
|
|
86
|
+
...form.map(([k, v]) => ` ${k}: ${v},`),
|
|
87
|
+
` }, { headers: { Accept: "application/json" }, tags: { name: "oauth token" } });`,
|
|
88
|
+
` if (tokenRes.status !== 200) fail("token request failed with status " + tokenRes.status);`,
|
|
89
|
+
` data.token = tokenRes.json("access_token");`,
|
|
90
|
+
],
|
|
91
|
+
headers: [['Authorization', `"Bearer " + data.token`]],
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
function requestBlock(req, required) {
|
|
97
|
+
const name = req.name ?? `${req.method} ${req.path}`;
|
|
98
|
+
const headers = Object.entries(req.headers).map(([k, v]) => [
|
|
99
|
+
k,
|
|
100
|
+
envExpr(v, required),
|
|
101
|
+
]);
|
|
102
|
+
let body = 'null';
|
|
103
|
+
if (typeof req.body === 'string')
|
|
104
|
+
body = envExpr(req.body, required);
|
|
105
|
+
else if (req.body !== undefined) {
|
|
106
|
+
body = `JSON.stringify(${valueExpr(req.body, required, ' ')})`;
|
|
107
|
+
if (!Object.keys(req.headers).some((h) => h.toLowerCase() === 'content-type'))
|
|
108
|
+
headers.unshift(['Content-Type', '"application/json"']);
|
|
109
|
+
}
|
|
110
|
+
const statuses = JSON.stringify(req.expect.status);
|
|
111
|
+
const checks = [
|
|
112
|
+
` ${JSON.stringify(`${name}: status ${req.expect.status.join(' or ')}`)}: (r) => ${statuses}.includes(r.status),`,
|
|
113
|
+
];
|
|
114
|
+
if (req.expect.bodyContains !== undefined)
|
|
115
|
+
checks.push(` ${JSON.stringify(`${name}: body contains`)}: (r) => String(r.body).includes(${envExpr(req.expect.bodyContains, required)}),`);
|
|
116
|
+
if (req.expect.maxDurationMs !== undefined)
|
|
117
|
+
checks.push(` ${JSON.stringify(`${name}: under ${req.expect.maxDurationMs}ms`)}: (r) => r.timings.duration < ${req.expect.maxDurationMs},`);
|
|
118
|
+
return [
|
|
119
|
+
` res = http.request(${JSON.stringify(req.method)}, BASE_URL + ${envExpr(req.path, required)}, ${body}, {`,
|
|
120
|
+
` headers: Object.assign({}, headers, {${headers.map(([k, v]) => ` ${JSON.stringify(k)}: ${v}`).join(',')}${headers.length ? ' ' : ''}}),`,
|
|
121
|
+
` tags: { name: ${JSON.stringify(name)} },`,
|
|
122
|
+
` });`,
|
|
123
|
+
` check(res, {`,
|
|
124
|
+
...checks,
|
|
125
|
+
` });`,
|
|
126
|
+
];
|
|
127
|
+
}
|
|
128
|
+
/** k6 `options` exactly as the profile declares them, tagged with project, env and profile. */
|
|
129
|
+
export function k6Options(input) {
|
|
130
|
+
const p = input.profile;
|
|
131
|
+
const options = {};
|
|
132
|
+
if (p.stages)
|
|
133
|
+
options.stages = p.stages;
|
|
134
|
+
else {
|
|
135
|
+
options.vus = p.vus;
|
|
136
|
+
if (p.duration)
|
|
137
|
+
options.duration = p.duration;
|
|
138
|
+
if (p.iterations)
|
|
139
|
+
options.iterations = p.iterations;
|
|
140
|
+
}
|
|
141
|
+
options.thresholds = p.thresholds;
|
|
142
|
+
options.tags = { project: input.project, env: input.env.name, profile: input.profileName };
|
|
143
|
+
return options;
|
|
144
|
+
}
|
|
145
|
+
export function generateK6Script(input) {
|
|
146
|
+
const required = new Set();
|
|
147
|
+
const { profile, env } = input;
|
|
148
|
+
const useEnvAuth = profile.auth === 'env';
|
|
149
|
+
const auth = useEnvAuth ? authLines(env, required) : { imports: [], setup: [], headers: [] };
|
|
150
|
+
const commonHeaders = useEnvAuth
|
|
151
|
+
? Object.entries(env.api.headers).map(([k, v]) => [k, envExpr(v, required)])
|
|
152
|
+
: [];
|
|
153
|
+
const requests = profile.requests.flatMap((r) => requestBlock(r, required));
|
|
154
|
+
const baseUrl = input.baseUrl.replace(/\/+$/, '');
|
|
155
|
+
const lines = [
|
|
156
|
+
`// Generated by \`sdods load -p ${input.project} -e ${env.name} ${input.profileName}\`. Do not edit: change`,
|
|
157
|
+
`// load/${input.profileName}.yaml and regenerate. Secrets are read from the environment (__ENV)`,
|
|
158
|
+
`// when k6 runs and are never written to this file.`,
|
|
159
|
+
`import http from 'k6/http';`,
|
|
160
|
+
`import { check, fail, sleep } from 'k6';`,
|
|
161
|
+
...auth.imports,
|
|
162
|
+
``,
|
|
163
|
+
`const BASE_URL = ${JSON.stringify(baseUrl)};`,
|
|
164
|
+
`const REQUIRED_ENV = ${JSON.stringify([...required].sort())};`,
|
|
165
|
+
``,
|
|
166
|
+
`export const options = ${JSON.stringify(k6Options(input), null, 2)};`,
|
|
167
|
+
``,
|
|
168
|
+
`export function setup() {`,
|
|
169
|
+
` const missing = REQUIRED_ENV.filter((name) => !__ENV[name]);`,
|
|
170
|
+
` if (missing.length) fail("missing environment variables: " + missing.join(", "));`,
|
|
171
|
+
` const data = {};`,
|
|
172
|
+
...auth.setup,
|
|
173
|
+
` return data;`,
|
|
174
|
+
`}`,
|
|
175
|
+
``,
|
|
176
|
+
`export default function (data) {`,
|
|
177
|
+
` const headers = {${[...commonHeaders, ...auth.headers].map(([k, v]) => `\n ${JSON.stringify(k)}: ${v},`).join('')}${commonHeaders.length || auth.headers.length ? '\n ' : ''}};`,
|
|
178
|
+
` let res;`,
|
|
179
|
+
...requests,
|
|
180
|
+
...(profile.thinkTimeSeconds > 0 ? [` sleep(${profile.thinkTimeSeconds});`] : []),
|
|
181
|
+
`}`,
|
|
182
|
+
``,
|
|
183
|
+
];
|
|
184
|
+
// Every expression was rendered above, so `required` is complete by the time REQUIRED_ENV is.
|
|
185
|
+
const script = lines.join('\n');
|
|
186
|
+
return { script, requiredEnv: [...required].sort() };
|
|
187
|
+
}
|
|
188
|
+
//# sourceMappingURL=script.js.map
|