@platforma-sdk/package-builder-lib 1.3.0 → 1.4.1
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 +23 -0
- package/dist/core/core.cjs.map +1 -1
- package/dist/core/core.d.ts +22 -6
- package/dist/core/core.d.ts.map +1 -1
- package/dist/core/core.js +23 -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/docker.cjs +2 -3
- package/dist/core/docker.cjs.map +1 -1
- package/dist/core/docker.js +2 -3
- package/dist/core/docker.js.map +1 -1
- 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.d.ts +9 -6
- package/dist/core/push-guard.d.ts.map +1 -1
- 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 +4 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +5 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
- package/src/core/__tests__/docker-build.test.ts +51 -0
- package/src/core/__tests__/docker-coverage.test.ts +319 -0
- package/src/core/core.ts +37 -0
- package/src/core/docker-coverage.ts +112 -0
- package/src/core/docker.ts +2 -7
- package/src/core/package-info.ts +11 -0
- package/src/core/sw-json-render.ts +25 -0
- package/src/index.ts +7 -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
|
+
});
|
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 {
|
|
@@ -212,6 +213,31 @@ export class Core {
|
|
|
212
213
|
}
|
|
213
214
|
}
|
|
214
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
|
+
|
|
215
241
|
// Get entrypoints defined in package.json, transform them to local or release (depending on `buildMode`)
|
|
216
242
|
// sw.json entrypoint descriptors, and write them to ./dist/tengo/sw.json or as.json next to the package.json.
|
|
217
243
|
public buildSwJsonFiles(options?: {
|
|
@@ -220,6 +246,7 @@ export class Core {
|
|
|
220
246
|
sources?: util.SoftwareSource[];
|
|
221
247
|
requireAllArtifacts?: boolean;
|
|
222
248
|
noSoftware?: boolean;
|
|
249
|
+
skipDockerCoverage?: boolean;
|
|
223
250
|
}) {
|
|
224
251
|
const index = this.packageEntrypointsIndex;
|
|
225
252
|
|
|
@@ -242,6 +269,16 @@ export class Core {
|
|
|
242
269
|
entrypoints = entrypoints.filter(([epName, _]) => entrypointNames.includes(epName));
|
|
243
270
|
}
|
|
244
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
|
+
|
|
245
282
|
const infos = options?.noSoftware
|
|
246
283
|
? this.renderer.renderPlaceholderEntrypoints(new Map(entrypoints))
|
|
247
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/docker.ts
CHANGED
|
@@ -114,13 +114,6 @@ export function build(
|
|
|
114
114
|
// regardless of host arch (cross-compile via qemu on non-x64 hosts).
|
|
115
115
|
"--platform",
|
|
116
116
|
defaults.DOCKER_BUILD_PLATFORM,
|
|
117
|
-
// Disable buildkit's SLSA provenance + SBOM attestations. They embed
|
|
118
|
-
// build-time timestamps into the manifest list, which produces a fresh
|
|
119
|
-
// image digest on every build of identical source. That breaks the
|
|
120
|
-
// content-addressable tag scheme (every dev rebuild re-pushes a new
|
|
121
|
-
// tag) and defeats publishDockerImage's `remoteImageExists` smart-skip.
|
|
122
|
-
"--provenance=false",
|
|
123
|
-
"--sbom=false",
|
|
124
117
|
"-t",
|
|
125
118
|
tag,
|
|
126
119
|
context,
|
|
@@ -138,6 +131,8 @@ export function build(
|
|
|
138
131
|
env: {
|
|
139
132
|
...process.env, // PATH variable from parent process affects execution
|
|
140
133
|
HOME: process.env.HOME || os.homedir(), // Ensure HOME is set
|
|
134
|
+
// Disables buildx default provenance so the image ID stays stable; podman ignores it.
|
|
135
|
+
BUILDX_NO_DEFAULT_ATTESTATIONS: "1",
|
|
141
136
|
},
|
|
142
137
|
},
|
|
143
138
|
);
|
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
|
}
|
|
@@ -7,6 +7,7 @@ import type * as swJson from "./schemas/sw-json";
|
|
|
7
7
|
import type * as entrypoint from "./schemas/entrypoints";
|
|
8
8
|
import * as util from "./util";
|
|
9
9
|
import * as docker from "./docker";
|
|
10
|
+
import * as dockerCoverage from "./docker-coverage";
|
|
10
11
|
import * as defaults from "../defaults";
|
|
11
12
|
import { descriptorFilePath } from "./resolver";
|
|
12
13
|
import { resolveRunEnvironment } from "./resolver";
|
|
@@ -179,6 +180,30 @@ export class SwJsonRenderer {
|
|
|
179
180
|
const dstSwInfoPath = descriptorFilePath(this.pkgInfo.packageRoot, epType, epName);
|
|
180
181
|
util.ensureDirsExist(path.dirname(dstSwInfoPath));
|
|
181
182
|
fs.copyFileSync(srcFile, dstSwInfoPath);
|
|
183
|
+
|
|
184
|
+
if (epType === "software") this.warnIfNoDockerVariant(epName, srcFile);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* A reference re-exports another package's descriptor verbatim, so the docker-coverage
|
|
189
|
+
* check cannot judge it from this package's declaration — and must not fail on it: the
|
|
190
|
+
* owning package may have declared itself binary-only on purpose, and that waiver does
|
|
191
|
+
* not travel inside the descriptor. Warn, naming what to fix and where.
|
|
192
|
+
*/
|
|
193
|
+
private warnIfNoDockerVariant(epName: string, srcFile: string) {
|
|
194
|
+
let descriptor: unknown;
|
|
195
|
+
try {
|
|
196
|
+
descriptor = JSON.parse(fs.readFileSync(srcFile, "utf-8"));
|
|
197
|
+
} catch {
|
|
198
|
+
return; // an unreadable descriptor is the copy's problem to report, not this warning's
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (!dockerCoverage.descriptorHasNoDockerVariant(descriptor)) return;
|
|
202
|
+
|
|
203
|
+
this.logger.warn(
|
|
204
|
+
`referenced entrypoint '${epName}' carries no docker image, so no kubernetes ` +
|
|
205
|
+
`installation can run it. Fix it in the package that owns ${srcFile}.`,
|
|
206
|
+
);
|
|
182
207
|
}
|
|
183
208
|
|
|
184
209
|
private renderLocalPackage(
|
package/src/index.ts
CHANGED
|
@@ -5,6 +5,12 @@ export * as util from "./core/util";
|
|
|
5
5
|
export * as envs from "./core/envs";
|
|
6
6
|
export * as defaults from "./defaults";
|
|
7
7
|
export { assertDockerPushIntent, type PushIntent } from "./core/push-guard";
|
|
8
|
+
export {
|
|
9
|
+
assertDockerCoverage,
|
|
10
|
+
softwareEntrypointsWithoutDocker,
|
|
11
|
+
descriptorHasNoDockerVariant,
|
|
12
|
+
type DockerCoverage,
|
|
13
|
+
} from "./core/docker-coverage";
|
|
8
14
|
|
|
9
15
|
/** The logger the engine and its callers pass around. */
|
|
10
16
|
export type Logger = winston.Logger;
|
|
@@ -24,6 +30,7 @@ export type Builder = Pick<
|
|
|
24
30
|
| "version"
|
|
25
31
|
| "isPrivate"
|
|
26
32
|
| "selectedDockerPackages"
|
|
33
|
+
| "assertDockerCoverage"
|
|
27
34
|
| "buildDockerImages"
|
|
28
35
|
| "buildSoftwareArchives"
|
|
29
36
|
| "buildSwJsonFiles"
|