@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.
Files changed (54) hide show
  1. package/dist/core/core.cjs +35 -0
  2. package/dist/core/core.cjs.map +1 -1
  3. package/dist/core/core.d.ts +32 -6
  4. package/dist/core/core.d.ts.map +1 -1
  5. package/dist/core/core.js +35 -0
  6. package/dist/core/core.js.map +1 -1
  7. package/dist/core/docker-coverage.cjs +56 -0
  8. package/dist/core/docker-coverage.cjs.map +1 -0
  9. package/dist/core/docker-coverage.d.ts +35 -0
  10. package/dist/core/docker-coverage.d.ts.map +1 -0
  11. package/dist/core/docker-coverage.js +53 -0
  12. package/dist/core/docker-coverage.js.map +1 -0
  13. package/dist/core/envs.d.ts +27 -27
  14. package/dist/core/envs.d.ts.map +1 -1
  15. package/dist/core/package-info.cjs +6 -1
  16. package/dist/core/package-info.cjs.map +1 -1
  17. package/dist/core/package-info.d.ts +6 -6
  18. package/dist/core/package-info.d.ts.map +1 -1
  19. package/dist/core/package-info.js +6 -1
  20. package/dist/core/package-info.js.map +1 -1
  21. package/dist/core/push-guard.cjs +17 -0
  22. package/dist/core/push-guard.cjs.map +1 -0
  23. package/dist/core/push-guard.d.ts +15 -0
  24. package/dist/core/push-guard.d.ts.map +1 -0
  25. package/dist/core/push-guard.js +17 -0
  26. package/dist/core/push-guard.js.map +1 -0
  27. package/dist/core/schemas/artifacts.d.ts +236 -71
  28. package/dist/core/schemas/artifacts.d.ts.map +1 -1
  29. package/dist/core/schemas/entrypoints.d.ts +357 -10
  30. package/dist/core/schemas/entrypoints.d.ts.map +1 -1
  31. package/dist/core/sw-json-render.cjs +18 -0
  32. package/dist/core/sw-json-render.cjs.map +1 -1
  33. package/dist/core/sw-json-render.js +18 -0
  34. package/dist/core/sw-json-render.js.map +1 -1
  35. package/dist/core/util.d.ts +42 -44
  36. package/dist/core/util.d.ts.map +1 -1
  37. package/dist/defaults.d.ts +22 -22
  38. package/dist/defaults.d.ts.map +1 -1
  39. package/dist/index.cjs +6 -0
  40. package/dist/index.cjs.map +1 -1
  41. package/dist/index.d.ts +6 -5
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +3 -1
  44. package/dist/index.js.map +1 -1
  45. package/package.json +6 -6
  46. package/src/core/__tests__/docker-coverage.test.ts +319 -0
  47. package/src/core/__tests__/publish-docker-images.test.ts +63 -0
  48. package/src/core/__tests__/push-guard.test.ts +62 -0
  49. package/src/core/core.ts +51 -0
  50. package/src/core/docker-coverage.ts +112 -0
  51. package/src/core/package-info.ts +11 -0
  52. package/src/core/push-guard.ts +42 -0
  53. package/src/core/sw-json-render.ts +25 -0
  54. 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
+ }
@@ -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
  }