@platforma-sdk/package-builder-lib 1.2.1 → 1.4.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/core/core.cjs +35 -0
- package/dist/core/core.cjs.map +1 -1
- package/dist/core/core.d.ts +32 -6
- package/dist/core/core.d.ts.map +1 -1
- package/dist/core/core.js +35 -0
- package/dist/core/core.js.map +1 -1
- package/dist/core/docker-coverage.cjs +56 -0
- package/dist/core/docker-coverage.cjs.map +1 -0
- package/dist/core/docker-coverage.d.ts +35 -0
- package/dist/core/docker-coverage.d.ts.map +1 -0
- package/dist/core/docker-coverage.js +53 -0
- package/dist/core/docker-coverage.js.map +1 -0
- package/dist/core/envs.d.ts +27 -27
- package/dist/core/envs.d.ts.map +1 -1
- package/dist/core/package-info.cjs +6 -1
- package/dist/core/package-info.cjs.map +1 -1
- package/dist/core/package-info.d.ts +6 -6
- package/dist/core/package-info.d.ts.map +1 -1
- package/dist/core/package-info.js +6 -1
- package/dist/core/package-info.js.map +1 -1
- package/dist/core/push-guard.cjs +17 -0
- package/dist/core/push-guard.cjs.map +1 -0
- package/dist/core/push-guard.d.ts +15 -0
- package/dist/core/push-guard.d.ts.map +1 -0
- package/dist/core/push-guard.js +17 -0
- package/dist/core/push-guard.js.map +1 -0
- package/dist/core/schemas/artifacts.d.ts +236 -71
- package/dist/core/schemas/artifacts.d.ts.map +1 -1
- package/dist/core/schemas/entrypoints.d.ts +357 -10
- package/dist/core/schemas/entrypoints.d.ts.map +1 -1
- package/dist/core/sw-json-render.cjs +18 -0
- package/dist/core/sw-json-render.cjs.map +1 -1
- package/dist/core/sw-json-render.js +18 -0
- package/dist/core/sw-json-render.js.map +1 -1
- package/dist/core/util.d.ts +42 -44
- package/dist/core/util.d.ts.map +1 -1
- package/dist/defaults.d.ts +22 -22
- package/dist/defaults.d.ts.map +1 -1
- package/dist/index.cjs +6 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +6 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/package.json +6 -6
- package/src/core/__tests__/docker-coverage.test.ts +319 -0
- package/src/core/__tests__/publish-docker-images.test.ts +63 -0
- package/src/core/__tests__/push-guard.test.ts +62 -0
- package/src/core/core.ts +51 -0
- package/src/core/docker-coverage.ts +112 -0
- package/src/core/package-info.ts +11 -0
- package/src/core/push-guard.ts +42 -0
- package/src/core/sw-json-render.ts +25 -0
- package/src/index.ts +9 -0
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
// Regression tests for the "released software with no docker image" guard (MILAB-6872).
|
|
2
|
+
//
|
|
3
|
+
// The trap: an image is autogenerated only for python and conda artifacts, so a
|
|
4
|
+
// java / R / binary entrypoint without an explicit `docker:` block publishes a
|
|
5
|
+
// descriptor the k8s runner cannot use, and the command fails at runtime with
|
|
6
|
+
// "docker image is not specified". Every installation is k8s-based.
|
|
7
|
+
|
|
8
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
9
|
+
import * as fs from "node:fs";
|
|
10
|
+
import * as path from "node:path";
|
|
11
|
+
import type winston from "winston";
|
|
12
|
+
import { PackageInfo } from "../package-info";
|
|
13
|
+
import { Core } from "../core";
|
|
14
|
+
import {
|
|
15
|
+
assertDockerCoverage,
|
|
16
|
+
descriptorHasNoDockerVariant,
|
|
17
|
+
softwareEntrypointsWithoutDocker,
|
|
18
|
+
type DockerCoverage,
|
|
19
|
+
} from "../docker-coverage";
|
|
20
|
+
import { NO_SOFTWARE_PLACEHOLDER } from "../../defaults";
|
|
21
|
+
|
|
22
|
+
const mockLogger = {
|
|
23
|
+
info: vi.fn(),
|
|
24
|
+
warn: vi.fn(),
|
|
25
|
+
debug: vi.fn(),
|
|
26
|
+
error: vi.fn(),
|
|
27
|
+
} as unknown as winston.Logger;
|
|
28
|
+
|
|
29
|
+
// The autogenerated python image resolves its run environment through
|
|
30
|
+
// node_modules, which a /tmp fixture does not have. The coverage check only asks
|
|
31
|
+
// whether the docker companion exists, so the options it would carry are stubbed.
|
|
32
|
+
vi.mock("../docker-python", () => ({
|
|
33
|
+
prepareDockerOptions: vi.fn().mockReturnValue({
|
|
34
|
+
context: "/tmp/test-context",
|
|
35
|
+
dockerfile: "/tmp/test-dockerfile",
|
|
36
|
+
entrypoint: [],
|
|
37
|
+
}),
|
|
38
|
+
}));
|
|
39
|
+
|
|
40
|
+
const JAVA_ARTIFACT = {
|
|
41
|
+
type: "java",
|
|
42
|
+
registry: "test-registry",
|
|
43
|
+
environment: "@platforma-open/milaboratories.runenv-java-corretto:21.0.2.13.1",
|
|
44
|
+
root: "./dld",
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const javaEntrypoint = (withDocker: boolean) => ({
|
|
48
|
+
binary: { artifact: JAVA_ARTIFACT, cmd: ["java", "-jar", "{pkg}/tool.jar"] },
|
|
49
|
+
...(withDocker
|
|
50
|
+
? { docker: { artifact: { context: "dld" }, cmd: ["java", "-jar", "/app/tool.jar"] } }
|
|
51
|
+
: {}),
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const pythonEntrypoint = {
|
|
55
|
+
binary: {
|
|
56
|
+
artifact: {
|
|
57
|
+
type: "python",
|
|
58
|
+
registry: "test-registry",
|
|
59
|
+
environment: "@platforma-open/milaboratories.runenv-python-3:3.12.10",
|
|
60
|
+
root: "./src",
|
|
61
|
+
dependencies: { toolset: "pip", requirements: "requirements.txt" },
|
|
62
|
+
},
|
|
63
|
+
cmd: ["python", "{pkg}/main.py"],
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
describe("softwareEntrypointsWithoutDocker", () => {
|
|
68
|
+
let tempDir: string;
|
|
69
|
+
let packageRoot: string;
|
|
70
|
+
|
|
71
|
+
beforeEach(() => {
|
|
72
|
+
tempDir = fs.mkdtempSync(path.join("/tmp", "docker-coverage-test-"));
|
|
73
|
+
packageRoot = path.join(tempDir, "package");
|
|
74
|
+
fs.mkdirSync(packageRoot, { recursive: true });
|
|
75
|
+
vi.clearAllMocks();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
afterEach(() => {
|
|
79
|
+
try {
|
|
80
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
81
|
+
} catch {
|
|
82
|
+
// ignore cleanup errors
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const uncovered = (blockSoftware: Record<string, unknown>): string[] => {
|
|
87
|
+
fs.writeFileSync(
|
|
88
|
+
path.join(packageRoot, "package.json"),
|
|
89
|
+
JSON.stringify(
|
|
90
|
+
{ name: "test-package", version: "1.0.0", "block-software": blockSoftware },
|
|
91
|
+
null,
|
|
92
|
+
2,
|
|
93
|
+
),
|
|
94
|
+
);
|
|
95
|
+
return softwareEntrypointsWithoutDocker(
|
|
96
|
+
new PackageInfo(mockLogger, { packageRoot }).entrypoints,
|
|
97
|
+
);
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
it("flags a java entrypoint with no docker block", () => {
|
|
101
|
+
expect(uncovered({ entrypoints: { main: javaEntrypoint(false) } })).toEqual(["main"]);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("passes a java entrypoint that declares docker explicitly", () => {
|
|
105
|
+
expect(uncovered({ entrypoints: { main: javaEntrypoint(true) } })).toEqual([]);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("passes python, whose image is autogenerated", () => {
|
|
109
|
+
expect(uncovered({ entrypoints: { main: pythonEntrypoint } })).toEqual([]);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("passes a docker-only entrypoint", () => {
|
|
113
|
+
expect(
|
|
114
|
+
uncovered({
|
|
115
|
+
entrypoints: { main: { docker: { artifact: { context: "dld" }, cmd: ["tool"] } } },
|
|
116
|
+
}),
|
|
117
|
+
).toEqual([]);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// The ':docker' suffix marks the companion PackageInfo generates, but entrypoint
|
|
121
|
+
// names are barely constrained, so a package may declare one called 'main:docker'.
|
|
122
|
+
// Reading the suffix as "generated, already covered" would let it hide there.
|
|
123
|
+
it("flags a binary-only entrypoint that is itself named with the companion suffix", () => {
|
|
124
|
+
expect(uncovered({ entrypoints: { "main:docker": javaEntrypoint(false) } })).toEqual([
|
|
125
|
+
"main:docker",
|
|
126
|
+
]);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("passes an entrypoint named with the suffix once it declares an image", () => {
|
|
130
|
+
expect(uncovered({ entrypoints: { "main:docker": javaEntrypoint(true) } })).toEqual([]);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("skips run environments, whose image is baked into the software that uses them", () => {
|
|
134
|
+
expect(
|
|
135
|
+
uncovered({
|
|
136
|
+
entrypoints: {
|
|
137
|
+
jre: {
|
|
138
|
+
environment: {
|
|
139
|
+
artifact: {
|
|
140
|
+
registry: "test-registry",
|
|
141
|
+
runtime: "java",
|
|
142
|
+
"java-version": "21",
|
|
143
|
+
roots: { "linux-x64": "./env" },
|
|
144
|
+
binDir: ".",
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
}),
|
|
150
|
+
).toEqual([]);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("skips assets, which are data and not runnable", () => {
|
|
154
|
+
expect(
|
|
155
|
+
uncovered({
|
|
156
|
+
entrypoints: { data: { asset: { registry: "test-registry", root: "./data" } } },
|
|
157
|
+
}),
|
|
158
|
+
).toEqual([]);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("names only the uncovered entrypoints of a mixed package", () => {
|
|
162
|
+
expect(
|
|
163
|
+
uncovered({
|
|
164
|
+
entrypoints: {
|
|
165
|
+
"java-tool": javaEntrypoint(false),
|
|
166
|
+
"java-tool-dockerized": javaEntrypoint(true),
|
|
167
|
+
"python-tool": pythonEntrypoint,
|
|
168
|
+
},
|
|
169
|
+
}),
|
|
170
|
+
).toEqual(["java-tool"]);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("defaults requireDocker to true and honours an explicit opt-out", () => {
|
|
174
|
+
const write = (blockSoftware: Record<string, unknown>) => {
|
|
175
|
+
fs.writeFileSync(
|
|
176
|
+
path.join(packageRoot, "package.json"),
|
|
177
|
+
JSON.stringify({ name: "p", version: "1.0.0", "block-software": blockSoftware }, null, 2),
|
|
178
|
+
);
|
|
179
|
+
return new PackageInfo(mockLogger, { packageRoot }).requireDocker;
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
expect(write({ entrypoints: { main: javaEntrypoint(false) } })).toBe(true);
|
|
183
|
+
expect(write({ requireDocker: false, entrypoints: { main: javaEntrypoint(false) } })).toBe(
|
|
184
|
+
false,
|
|
185
|
+
);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// A dev-local build describes software by filesystem path, so a missing image cannot
|
|
189
|
+
// hurt anyone on a cluster — and blocking it would fail a block's own tests
|
|
190
|
+
// (`build:dev-local`, and `test`'s binary-only build) on a release rule.
|
|
191
|
+
it("leaves a dev-local build alone, whose descriptors never reach a cluster", () => {
|
|
192
|
+
const ci = process.env.CI;
|
|
193
|
+
process.env.CI = "true";
|
|
194
|
+
|
|
195
|
+
fs.writeFileSync(
|
|
196
|
+
path.join(packageRoot, "package.json"),
|
|
197
|
+
JSON.stringify({
|
|
198
|
+
name: "test-package",
|
|
199
|
+
version: "1.0.0",
|
|
200
|
+
"block-software": { entrypoints: { main: javaEntrypoint(false) } },
|
|
201
|
+
}),
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
const core = new Core(mockLogger, { packageRoot });
|
|
205
|
+
core.buildMode = "dev-local";
|
|
206
|
+
try {
|
|
207
|
+
expect(() => core.assertDockerCoverage()).not.toThrow();
|
|
208
|
+
core.buildMode = "dev-remote";
|
|
209
|
+
expect(() => core.assertDockerCoverage()).toThrow(/produce no docker image/);
|
|
210
|
+
core.buildMode = "release";
|
|
211
|
+
expect(() => core.assertDockerCoverage()).toThrow(/produce no docker image/);
|
|
212
|
+
} finally {
|
|
213
|
+
if (ci === undefined) delete process.env.CI;
|
|
214
|
+
else process.env.CI = ci;
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("stops a CI build before it writes the descriptor", () => {
|
|
219
|
+
const ci = process.env.CI;
|
|
220
|
+
process.env.CI = "true";
|
|
221
|
+
|
|
222
|
+
fs.writeFileSync(
|
|
223
|
+
path.join(packageRoot, "package.json"),
|
|
224
|
+
JSON.stringify({
|
|
225
|
+
name: "test-package",
|
|
226
|
+
version: "1.0.0",
|
|
227
|
+
"block-software": { entrypoints: { main: javaEntrypoint(false) } },
|
|
228
|
+
}),
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
const core = new Core(mockLogger, { packageRoot });
|
|
232
|
+
try {
|
|
233
|
+
expect(() => core.buildSwJsonFiles()).toThrow(/produce no docker image/);
|
|
234
|
+
} finally {
|
|
235
|
+
if (ci === undefined) delete process.env.CI;
|
|
236
|
+
else process.env.CI = ci;
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
describe("descriptorHasNoDockerVariant", () => {
|
|
242
|
+
const binary = { type: "java", registry: "milaboratories", package: "sw/1.0.0.tgz", cmd: ["x"] };
|
|
243
|
+
|
|
244
|
+
it("flags a rendered descriptor with only a binary variant", () => {
|
|
245
|
+
expect(descriptorHasNoDockerVariant({ binary })).toBe(true);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("passes a descriptor that carries an image", () => {
|
|
249
|
+
expect(descriptorHasNoDockerVariant({ binary, docker: { tag: "reg/img:tag" } })).toBe(false);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("passes a run environment, which is not runnable software", () => {
|
|
253
|
+
expect(descriptorHasNoDockerVariant({ runEnv: { type: "java" } })).toBe(false);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("passes a dev-mode descriptor, which points at a directory and never runs on a cluster", () => {
|
|
257
|
+
expect(descriptorHasNoDockerVariant({ local: { path: "/src" }, isDev: true })).toBe(false);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("passes the no-software placeholder", () => {
|
|
261
|
+
expect(
|
|
262
|
+
descriptorHasNoDockerVariant({
|
|
263
|
+
isDev: true,
|
|
264
|
+
binary: { type: "binary", registry: NO_SOFTWARE_PLACEHOLDER, cmd: ["true"] },
|
|
265
|
+
}),
|
|
266
|
+
).toBe(false);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it("ignores anything that is not a descriptor object", () => {
|
|
270
|
+
expect(descriptorHasNoDockerVariant(null)).toBe(false);
|
|
271
|
+
expect(descriptorHasNoDockerVariant("{}")).toBe(false);
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
const UNCOVERED: DockerCoverage = {
|
|
276
|
+
softwareWithoutDocker: ["main"],
|
|
277
|
+
requirementWaived: false,
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
describe("assertDockerCoverage", () => {
|
|
281
|
+
let ci: string | undefined;
|
|
282
|
+
|
|
283
|
+
beforeEach(() => {
|
|
284
|
+
ci = process.env.CI;
|
|
285
|
+
process.env.CI = "true";
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
afterEach(() => {
|
|
289
|
+
if (ci === undefined) delete process.env.CI;
|
|
290
|
+
else process.env.CI = ci;
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it("throws when CI describes software no k8s install can run", () => {
|
|
294
|
+
expect(() => assertDockerCoverage(UNCOVERED)).toThrow(/produce no docker image/);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it("names the uncovered entrypoints", () => {
|
|
298
|
+
expect(() =>
|
|
299
|
+
assertDockerCoverage({ ...UNCOVERED, softwareWithoutDocker: ["main", "low-memory"] }),
|
|
300
|
+
).toThrow(/low-memory/);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it("points at the requireDocker opt-out", () => {
|
|
304
|
+
expect(() => assertDockerCoverage(UNCOVERED)).toThrow(/"requireDocker": false/);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it("accepts a package that declared itself binary-only", () => {
|
|
308
|
+
expect(() => assertDockerCoverage({ ...UNCOVERED, requirementWaived: true })).not.toThrow();
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it("accepts a package whose software is fully covered", () => {
|
|
312
|
+
expect(() => assertDockerCoverage({ ...UNCOVERED, softwareWithoutDocker: [] })).not.toThrow();
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it("stays out of the way outside CI, where a partial package is normal mid-edit", () => {
|
|
316
|
+
delete process.env.CI;
|
|
317
|
+
expect(() => assertDockerCoverage(UNCOVERED)).not.toThrow();
|
|
318
|
+
});
|
|
319
|
+
});
|
|
@@ -307,3 +307,66 @@ describe("Core.buildDockerImages + publishDockerImages", () => {
|
|
|
307
307
|
}
|
|
308
308
|
});
|
|
309
309
|
});
|
|
310
|
+
|
|
311
|
+
// selectedDockerPackages answers "what docker images would this build produce?"
|
|
312
|
+
// for a given --package-id selection. Callers use it to reason about a build
|
|
313
|
+
// before running it, so it must mirror buildDockerImages' own id resolution:
|
|
314
|
+
// a plain intersection with dockerPackages would miss the `:docker` virtual
|
|
315
|
+
// entrypoint that python autogen produces.
|
|
316
|
+
describe("Core.selectedDockerPackages", () => {
|
|
317
|
+
let tempDir: string;
|
|
318
|
+
|
|
319
|
+
beforeEach(() => {
|
|
320
|
+
vi.clearAllMocks();
|
|
321
|
+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "selected-docker-test-"));
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
afterEach(() => {
|
|
325
|
+
try {
|
|
326
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
327
|
+
} catch {
|
|
328
|
+
// ignore
|
|
329
|
+
}
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
function mixedPackageRoot(): string {
|
|
333
|
+
return makePackageRoot(
|
|
334
|
+
tempDir,
|
|
335
|
+
{
|
|
336
|
+
name: "@test/mixed",
|
|
337
|
+
version: "1.0.0",
|
|
338
|
+
"block-software": {
|
|
339
|
+
entrypoints: { pyep: pythonEntrypoint, javaep: javaEntrypoint },
|
|
340
|
+
},
|
|
341
|
+
},
|
|
342
|
+
{ requirementsTxt: true, runenv: { ref: RUNENV_REF, pythonVersion: "3.12.10" } },
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
it("counts every docker artifact when nothing is selected", () => {
|
|
347
|
+
const core = new Core(mockLogger, { packageRoot: mixedPackageRoot() });
|
|
348
|
+
expect(core.selectedDockerPackages()).toHaveLength(1);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it("returns none when the selection has no docker artifact", () => {
|
|
352
|
+
const core = new Core(mockLogger, { packageRoot: mixedPackageRoot() });
|
|
353
|
+
expect(core.selectedDockerPackages(["javaep"])).toHaveLength(0);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it("resolves the virtual :docker entrypoint for a selected python entrypoint", () => {
|
|
357
|
+
const core = new Core(mockLogger, { packageRoot: mixedPackageRoot() });
|
|
358
|
+
expect(core.selectedDockerPackages(["pyep"])).toHaveLength(1);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it("returns none for a package with no docker artifacts at all", () => {
|
|
362
|
+
const root = makePackageRoot(tempDir, {
|
|
363
|
+
name: "@test/java-only-selection",
|
|
364
|
+
version: "1.0.0",
|
|
365
|
+
"block-software": { entrypoints: { main: javaEntrypoint } },
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
const core = new Core(mockLogger, { packageRoot: root });
|
|
369
|
+
expect(core.selectedDockerPackages()).toHaveLength(0);
|
|
370
|
+
expect(core.selectedDockerPackages(["main"])).toHaveLength(0);
|
|
371
|
+
});
|
|
372
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Regression tests for the "built but never pushed" guard (MILAB-6714).
|
|
2
|
+
//
|
|
3
|
+
// The trap: `"private": true` on a software package flips the auto-push default
|
|
4
|
+
// off, the descriptor is still written with the image tag, CI goes green, and
|
|
5
|
+
// the block 404s pulling the image at runtime.
|
|
6
|
+
|
|
7
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
8
|
+
import { assertDockerPushIntent, type PushIntent } from "../push-guard";
|
|
9
|
+
|
|
10
|
+
const PUSH_SUPPRESSED: PushIntent = {
|
|
11
|
+
buildDocker: true,
|
|
12
|
+
pushDocker: false,
|
|
13
|
+
pushDisabledExplicitly: false,
|
|
14
|
+
dockerPackageCount: 1,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
describe("assertDockerPushIntent", () => {
|
|
18
|
+
let ci: string | undefined;
|
|
19
|
+
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
ci = process.env.CI;
|
|
22
|
+
process.env.CI = "true";
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
if (ci === undefined) delete process.env.CI;
|
|
27
|
+
else process.env.CI = ci;
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("throws when CI builds docker images it will not push", () => {
|
|
31
|
+
expect(() => assertDockerPushIntent(PUSH_SUPPRESSED)).toThrow(/without pushing them/);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("names private as the likely cause", () => {
|
|
35
|
+
expect(() => assertDockerPushIntent(PUSH_SUPPRESSED)).toThrow(/"private": true/);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("accepts an explicit --docker-no-autopush", () => {
|
|
39
|
+
expect(() =>
|
|
40
|
+
assertDockerPushIntent({ ...PUSH_SUPPRESSED, pushDisabledExplicitly: true }),
|
|
41
|
+
).not.toThrow();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("accepts a run that does push", () => {
|
|
45
|
+
expect(() => assertDockerPushIntent({ ...PUSH_SUPPRESSED, pushDocker: true })).not.toThrow();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("accepts a run that builds no docker images", () => {
|
|
49
|
+
expect(() => assertDockerPushIntent({ ...PUSH_SUPPRESSED, buildDocker: false })).not.toThrow();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("accepts a package with no docker entrypoints", () => {
|
|
53
|
+
expect(() =>
|
|
54
|
+
assertDockerPushIntent({ ...PUSH_SUPPRESSED, dockerPackageCount: 0 }),
|
|
55
|
+
).not.toThrow();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("stays out of the way outside CI, where dev iteration never pushes", () => {
|
|
59
|
+
delete process.env.CI;
|
|
60
|
+
expect(() => assertDockerPushIntent(PUSH_SUPPRESSED)).not.toThrow();
|
|
61
|
+
});
|
|
62
|
+
});
|
package/src/core/core.ts
CHANGED
|
@@ -15,6 +15,7 @@ import * as util from "./util";
|
|
|
15
15
|
import * as archive from "./archive";
|
|
16
16
|
import * as storage from "./storage";
|
|
17
17
|
import * as docker from "./docker";
|
|
18
|
+
import * as dockerCoverage from "./docker-coverage";
|
|
18
19
|
import { tmpSpecFile } from "./docker-conda";
|
|
19
20
|
|
|
20
21
|
export class Core {
|
|
@@ -148,6 +149,20 @@ export class Core {
|
|
|
148
149
|
);
|
|
149
150
|
}
|
|
150
151
|
|
|
152
|
+
/**
|
|
153
|
+
* The docker artifacts a build would actually produce for this selection.
|
|
154
|
+
*
|
|
155
|
+
* Mirrors the resolution `buildDockerImages` performs, so callers can reason
|
|
156
|
+
* about a build before running it. Note that `ids` are entrypoint names, not
|
|
157
|
+
* docker package keys: `getArtifact(id, "docker")` resolves the `:docker`
|
|
158
|
+
* virtual entrypoint, so a plain set intersection with `dockerPackages`
|
|
159
|
+
* would be wrong.
|
|
160
|
+
*/
|
|
161
|
+
public selectedDockerPackages(ids?: string[]): string[] {
|
|
162
|
+
const selected = ids ?? Array.from(this.dockerPackages.keys());
|
|
163
|
+
return selected.filter((id) => this.getArtifact(id, "docker") !== undefined);
|
|
164
|
+
}
|
|
165
|
+
|
|
151
166
|
public packageHasType(id: string, type: artifacts.artifactType): boolean {
|
|
152
167
|
const pkg = this.packages.get(id);
|
|
153
168
|
if (pkg && pkg.type === type) {
|
|
@@ -198,6 +213,31 @@ export class Core {
|
|
|
198
213
|
}
|
|
199
214
|
}
|
|
200
215
|
|
|
216
|
+
/**
|
|
217
|
+
* Fails a CI build that would describe software no kubernetes installation can run.
|
|
218
|
+
*
|
|
219
|
+
* `buildSwJsonFiles` calls this on the selection it is about to write, so no path
|
|
220
|
+
* can skip it. Callers may also invoke it up front — the answer comes from the
|
|
221
|
+
* package's declaration alone, so a CI run that is going to fail can fail before
|
|
222
|
+
* it spends minutes building archives and images.
|
|
223
|
+
*
|
|
224
|
+
* Only builds that write a registry descriptor answer for coverage. A dev-local
|
|
225
|
+
* build (`build:dev-local`, and the `test` script's binary-only build) describes
|
|
226
|
+
* software by filesystem path: it never reaches a cluster, so a missing image
|
|
227
|
+
* cannot hurt anyone there, and failing those runs would block a block's tests on
|
|
228
|
+
* a release rule. Release and dev-remote builds do publish, and are checked.
|
|
229
|
+
*/
|
|
230
|
+
public assertDockerCoverage(options?: { entrypoints?: Map<string, entrypoint.Entrypoint> }) {
|
|
231
|
+
if (!util.producesRegistryDescriptor(this.buildMode)) return;
|
|
232
|
+
|
|
233
|
+
dockerCoverage.assertDockerCoverage({
|
|
234
|
+
softwareWithoutDocker: dockerCoverage.softwareEntrypointsWithoutDocker(
|
|
235
|
+
options?.entrypoints ?? this.entrypoints,
|
|
236
|
+
),
|
|
237
|
+
requirementWaived: !this.pkgInfo.requireDocker,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
201
241
|
// Get entrypoints defined in package.json, transform them to local or release (depending on `buildMode`)
|
|
202
242
|
// sw.json entrypoint descriptors, and write them to ./dist/tengo/sw.json or as.json next to the package.json.
|
|
203
243
|
public buildSwJsonFiles(options?: {
|
|
@@ -206,6 +246,7 @@ export class Core {
|
|
|
206
246
|
sources?: util.SoftwareSource[];
|
|
207
247
|
requireAllArtifacts?: boolean;
|
|
208
248
|
noSoftware?: boolean;
|
|
249
|
+
skipDockerCoverage?: boolean;
|
|
209
250
|
}) {
|
|
210
251
|
const index = this.packageEntrypointsIndex;
|
|
211
252
|
|
|
@@ -228,6 +269,16 @@ export class Core {
|
|
|
228
269
|
entrypoints = entrypoints.filter(([epName, _]) => entrypointNames.includes(epName));
|
|
229
270
|
}
|
|
230
271
|
|
|
272
|
+
// The descriptor is the contract the backend reads, so this is the last point at
|
|
273
|
+
// which a software entrypoint with no docker image can still be stopped. Judged
|
|
274
|
+
// over the selection being described, so a targeted build answers only for what
|
|
275
|
+
// it writes. Skipped where this build produces no software of its own: the
|
|
276
|
+
// placeholder path describes none, and `skipDockerCoverage` is for a caller
|
|
277
|
+
// pointing the descriptor at an artifact someone else already released.
|
|
278
|
+
if (!options?.noSoftware && !options?.skipDockerCoverage) {
|
|
279
|
+
this.assertDockerCoverage({ entrypoints: new Map(entrypoints) });
|
|
280
|
+
}
|
|
281
|
+
|
|
231
282
|
const infos = options?.noSoftware
|
|
232
283
|
? this.renderer.renderPlaceholderEntrypoints(new Map(entrypoints))
|
|
233
284
|
: this.renderer.renderSoftwareEntrypoints(this.buildMode, new Map(entrypoints), {
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// Guard against releasing software that cannot run where we deploy it.
|
|
2
|
+
//
|
|
3
|
+
// Nothing else requires a docker variant: an image is autogenerated only for
|
|
4
|
+
// `python` and `conda` artifacts (`artifacts.dockerAutogenTypes`), so a `java`,
|
|
5
|
+
// `R` or plain `binary` entrypoint without an explicit `docker:` block writes a
|
|
6
|
+
// descriptor carrying `binary` and nothing else. Every Platforma installation
|
|
7
|
+
// is kubernetes-based, and the k8s runner has no binary fallback — it fails the
|
|
8
|
+
// command with a bare "docker image is not specified". CI stays green, the
|
|
9
|
+
// defect surfaces the first time a user runs the block (MILAB-6872).
|
|
10
|
+
//
|
|
11
|
+
// So: in CI, describing a software entrypoint that produces no image is an
|
|
12
|
+
// error, unless the package declares itself binary-only with
|
|
13
|
+
// `"block-software": { "requireDocker": false }`.
|
|
14
|
+
//
|
|
15
|
+
// The check reads the package's declaration, not what this particular run
|
|
16
|
+
// built: `--variant binary` and other partial builds are legitimate, a
|
|
17
|
+
// misdeclared entrypoint is not.
|
|
18
|
+
|
|
19
|
+
import * as defaults from "../defaults";
|
|
20
|
+
import * as docker from "./docker";
|
|
21
|
+
import * as envs from "./envs";
|
|
22
|
+
import type * as entrypoint from "./schemas/entrypoints";
|
|
23
|
+
import * as util from "./util";
|
|
24
|
+
|
|
25
|
+
export type DockerCoverage = {
|
|
26
|
+
/** Names of software entrypoints that produce no docker image. */
|
|
27
|
+
softwareWithoutDocker: string[];
|
|
28
|
+
/** Package opted out with `"block-software": { "requireDocker": false }`. */
|
|
29
|
+
requirementWaived: boolean;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The software entrypoints among `entrypoints` that yield no docker image.
|
|
34
|
+
*
|
|
35
|
+
* `entrypoints` is expected in the shape `PackageInfo.entrypoints` produces:
|
|
36
|
+
* one entry per declared entrypoint plus a virtual `<name>:docker` companion
|
|
37
|
+
* carrying the image, explicit or autogenerated. So a missing companion is the
|
|
38
|
+
* whole signal.
|
|
39
|
+
*
|
|
40
|
+
* Run environments and assets are not software and never carry an image of
|
|
41
|
+
* their own (a python image bakes its environment in), so they are skipped. So
|
|
42
|
+
* are references, which re-export a descriptor this package neither builds nor
|
|
43
|
+
* can fix — `SwJsonRenderer.copySwJson` warns about those instead.
|
|
44
|
+
*/
|
|
45
|
+
export function softwareEntrypointsWithoutDocker(
|
|
46
|
+
entrypoints: Map<string, entrypoint.Entrypoint>,
|
|
47
|
+
): string[] {
|
|
48
|
+
const uncovered: string[] = [];
|
|
49
|
+
|
|
50
|
+
for (const [name, ep] of entrypoints.entries()) {
|
|
51
|
+
if (ep.type !== "software") continue;
|
|
52
|
+
|
|
53
|
+
// This entry *is* an image: the virtual companion, or a docker-only entrypoint.
|
|
54
|
+
// Told apart by artifact type rather than by the ':docker' name suffix —
|
|
55
|
+
// entrypoint names are barely constrained, so `main:docker` is a legal name for a
|
|
56
|
+
// declared entrypoint, and reading the suffix as "generated companion" would let a
|
|
57
|
+
// binary-only entrypoint hide behind it. Pairing stays consistent either way: a
|
|
58
|
+
// declared `main:docker` is answered by the companion key `main:docker:docker`,
|
|
59
|
+
// which is exactly what PackageInfo generates for it.
|
|
60
|
+
if (ep.artifact.type === "docker") continue;
|
|
61
|
+
|
|
62
|
+
if (entrypoints.has(docker.entrypointName(name))) continue;
|
|
63
|
+
|
|
64
|
+
uncovered.push(name);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return uncovered;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* True when an already-rendered descriptor describes runnable software carrying no
|
|
72
|
+
* image — the shape no kubernetes installation can run.
|
|
73
|
+
*
|
|
74
|
+
* Reads the descriptor rather than a package declaration, for the one case where the
|
|
75
|
+
* declaration is out of reach: a reference entrypoint, whose descriptor is copied
|
|
76
|
+
* verbatim from the package that owns it.
|
|
77
|
+
*/
|
|
78
|
+
export function descriptorHasNoDockerVariant(descriptor: unknown): boolean {
|
|
79
|
+
if (typeof descriptor !== "object" || descriptor === null) return false;
|
|
80
|
+
|
|
81
|
+
const d = descriptor as {
|
|
82
|
+
binary?: { registry?: unknown };
|
|
83
|
+
docker?: unknown;
|
|
84
|
+
local?: unknown;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
// No `binary` means a run environment or an asset; `docker` means it is covered;
|
|
88
|
+
// `local` is a dev-mode descriptor, which points at a directory rather than a
|
|
89
|
+
// registry and never runs on a cluster.
|
|
90
|
+
if (d.binary === undefined || d.docker !== undefined || d.local !== undefined) return false;
|
|
91
|
+
|
|
92
|
+
// The `variant=none` placeholder describes a package built without software at all.
|
|
93
|
+
return d.binary.registry !== defaults.NO_SOFTWARE_PLACEHOLDER;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Throws when CI is about to describe software that no k8s install can run. */
|
|
97
|
+
export function assertDockerCoverage(coverage: DockerCoverage): void {
|
|
98
|
+
if (!envs.isCI()) return;
|
|
99
|
+
if (coverage.requirementWaived) return;
|
|
100
|
+
if (coverage.softwareWithoutDocker.length === 0) return;
|
|
101
|
+
|
|
102
|
+
const list = coverage.softwareWithoutDocker.map((name) => ` - ${name}`).join("\n");
|
|
103
|
+
|
|
104
|
+
throw util.CLIError(
|
|
105
|
+
`These software entrypoints produce no docker image, so no kubernetes installation ` +
|
|
106
|
+
`can run them:\n${list}\n` +
|
|
107
|
+
` An image is autogenerated only for 'python' and 'conda' artifacts. Give each ` +
|
|
108
|
+
`entrypoint above an explicit 'docker' block next to its 'binary' one.\n` +
|
|
109
|
+
` If this package really is meant to be binary-only, declare that with ` +
|
|
110
|
+
`"block-software": { "requireDocker": false }.`,
|
|
111
|
+
);
|
|
112
|
+
}
|
package/src/core/package-info.ts
CHANGED
|
@@ -37,6 +37,12 @@ const packageJsonSchema = z.object({
|
|
|
37
37
|
|
|
38
38
|
artifacts: artifacts.artifactIndexSchema.optional(),
|
|
39
39
|
entrypoints: entrypoints.entrypointListSchema,
|
|
40
|
+
|
|
41
|
+
// Opt out of the "every software entrypoint needs a docker image" rule for a
|
|
42
|
+
// package that is deliberately binary-only. Kept here rather than behind a CLI
|
|
43
|
+
// flag or env var: the exception belongs to the package, where it is reviewable,
|
|
44
|
+
// not to whichever CI run happens to build it. See core/docker-coverage.ts.
|
|
45
|
+
requireDocker: z.boolean().optional(),
|
|
40
46
|
}),
|
|
41
47
|
});
|
|
42
48
|
type packageJson = z.infer<typeof packageJsonSchema>;
|
|
@@ -133,6 +139,11 @@ export class PackageInfo {
|
|
|
133
139
|
return this.pkgJson.private ?? false;
|
|
134
140
|
}
|
|
135
141
|
|
|
142
|
+
/** Whether every software entrypoint of this package must ship a docker image. */
|
|
143
|
+
get requireDocker(): boolean {
|
|
144
|
+
return this.pkgJson["block-software"].requireDocker ?? true;
|
|
145
|
+
}
|
|
146
|
+
|
|
136
147
|
get binaryRegistries(): binaryRegistryPresets {
|
|
137
148
|
return this.pkgJson["block-software"].registries?.binary ?? {};
|
|
138
149
|
}
|