@platforma-sdk/package-builder-lib 1.1.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 (134) hide show
  1. package/assets/conda-dockerfile.template +45 -0
  2. package/assets/python-dockerfile.template +22 -0
  3. package/dist/_virtual/_rolldown/runtime.cjs +33 -0
  4. package/dist/_virtual/_rolldown/runtime.js +13 -0
  5. package/dist/core/archive.cjs +52 -0
  6. package/dist/core/archive.cjs.map +1 -0
  7. package/dist/core/archive.js +47 -0
  8. package/dist/core/archive.js.map +1 -0
  9. package/dist/core/conda/builder.cjs +226 -0
  10. package/dist/core/conda/builder.cjs.map +1 -0
  11. package/dist/core/conda/builder.js +220 -0
  12. package/dist/core/conda/builder.js.map +1 -0
  13. package/dist/core/conda/validate-spec.cjs +76 -0
  14. package/dist/core/conda/validate-spec.cjs.map +1 -0
  15. package/dist/core/conda/validate-spec.js +73 -0
  16. package/dist/core/conda/validate-spec.js.map +1 -0
  17. package/dist/core/core.cjs +403 -0
  18. package/dist/core/core.cjs.map +1 -0
  19. package/dist/core/core.d.ts +82 -0
  20. package/dist/core/core.d.ts.map +1 -0
  21. package/dist/core/core.js +397 -0
  22. package/dist/core/core.js.map +1 -0
  23. package/dist/core/docker-conda.cjs +75 -0
  24. package/dist/core/docker-conda.cjs.map +1 -0
  25. package/dist/core/docker-conda.js +71 -0
  26. package/dist/core/docker-conda.js.map +1 -0
  27. package/dist/core/docker-python.cjs +147 -0
  28. package/dist/core/docker-python.cjs.map +1 -0
  29. package/dist/core/docker-python.js +144 -0
  30. package/dist/core/docker-python.js.map +1 -0
  31. package/dist/core/docker.cjs +223 -0
  32. package/dist/core/docker.cjs.map +1 -0
  33. package/dist/core/docker.js +207 -0
  34. package/dist/core/docker.js.map +1 -0
  35. package/dist/core/envs.cjs +58 -0
  36. package/dist/core/envs.cjs.map +1 -0
  37. package/dist/core/envs.d.ts +26 -0
  38. package/dist/core/envs.d.ts.map +1 -0
  39. package/dist/core/envs.js +51 -0
  40. package/dist/core/envs.js.map +1 -0
  41. package/dist/core/package-info.cjs +450 -0
  42. package/dist/core/package-info.cjs.map +1 -0
  43. package/dist/core/package-info.d.ts +57 -0
  44. package/dist/core/package-info.d.ts.map +1 -0
  45. package/dist/core/package-info.js +447 -0
  46. package/dist/core/package-info.js.map +1 -0
  47. package/dist/core/paths.cjs +15 -0
  48. package/dist/core/paths.cjs.map +1 -0
  49. package/dist/core/paths.js +14 -0
  50. package/dist/core/paths.js.map +1 -0
  51. package/dist/core/resolver.cjs +99 -0
  52. package/dist/core/resolver.cjs.map +1 -0
  53. package/dist/core/resolver.js +95 -0
  54. package/dist/core/resolver.js.map +1 -0
  55. package/dist/core/schemas/artifacts.cjs +136 -0
  56. package/dist/core/schemas/artifacts.cjs.map +1 -0
  57. package/dist/core/schemas/artifacts.d.ts +280 -0
  58. package/dist/core/schemas/artifacts.d.ts.map +1 -0
  59. package/dist/core/schemas/artifacts.js +122 -0
  60. package/dist/core/schemas/artifacts.js.map +1 -0
  61. package/dist/core/schemas/entrypoints.cjs +43 -0
  62. package/dist/core/schemas/entrypoints.cjs.map +1 -0
  63. package/dist/core/schemas/entrypoints.d.ts +32 -0
  64. package/dist/core/schemas/entrypoints.d.ts.map +1 -0
  65. package/dist/core/schemas/entrypoints.js +41 -0
  66. package/dist/core/schemas/entrypoints.js.map +1 -0
  67. package/dist/core/schemas/sw-json.cjs +111 -0
  68. package/dist/core/schemas/sw-json.cjs.map +1 -0
  69. package/dist/core/schemas/sw-json.js +110 -0
  70. package/dist/core/schemas/sw-json.js.map +1 -0
  71. package/dist/core/storage.cjs +86 -0
  72. package/dist/core/storage.cjs.map +1 -0
  73. package/dist/core/storage.js +83 -0
  74. package/dist/core/storage.js.map +1 -0
  75. package/dist/core/sw-json-render.cjs +327 -0
  76. package/dist/core/sw-json-render.cjs.map +1 -0
  77. package/dist/core/sw-json-render.js +322 -0
  78. package/dist/core/sw-json-render.js.map +1 -0
  79. package/dist/core/util.cjs +313 -0
  80. package/dist/core/util.cjs.map +1 -0
  81. package/dist/core/util.d.ts +66 -0
  82. package/dist/core/util.d.ts.map +1 -0
  83. package/dist/core/util.js +281 -0
  84. package/dist/core/util.js.map +1 -0
  85. package/dist/defaults.cjs +54 -0
  86. package/dist/defaults.cjs.map +1 -0
  87. package/dist/defaults.d.ts +21 -0
  88. package/dist/defaults.d.ts.map +1 -0
  89. package/dist/defaults.js +37 -0
  90. package/dist/defaults.js.map +1 -0
  91. package/dist/index.cjs +31 -0
  92. package/dist/index.cjs.map +1 -0
  93. package/dist/index.d.ts +20 -0
  94. package/dist/index.d.ts.map +1 -0
  95. package/dist/index.js +12 -0
  96. package/dist/index.js.map +1 -0
  97. package/package.json +56 -0
  98. package/src/__test__/Dockerfile +1 -0
  99. package/src/__test__/docker.test.ts +67 -0
  100. package/src/__test__/package.json +39 -0
  101. package/src/__test__/python-example/main.py +1 -0
  102. package/src/__test__/python-example/requirements.txt +0 -0
  103. package/src/__test__/vite.config.mts +7 -0
  104. package/src/core/__tests__/docker-conda.test.ts +145 -0
  105. package/src/core/__tests__/docker-entrypoint-generation.test.ts +119 -0
  106. package/src/core/__tests__/docker-python.test.ts +374 -0
  107. package/src/core/__tests__/publish-docker-images.test.ts +309 -0
  108. package/src/core/archive.ts +102 -0
  109. package/src/core/conda/builder.test.ts +71 -0
  110. package/src/core/conda/builder.ts +384 -0
  111. package/src/core/conda/validate-spec.test.ts +213 -0
  112. package/src/core/conda/validate-spec.ts +107 -0
  113. package/src/core/core.ts +835 -0
  114. package/src/core/docker-conda.ts +149 -0
  115. package/src/core/docker-python.ts +283 -0
  116. package/src/core/docker.ts +300 -0
  117. package/src/core/envs.ts +31 -0
  118. package/src/core/package-info.test.ts +82 -0
  119. package/src/core/package-info.ts +843 -0
  120. package/src/core/paths.ts +16 -0
  121. package/src/core/resolver.ts +195 -0
  122. package/src/core/schemas/artifacts.test.ts +72 -0
  123. package/src/core/schemas/artifacts.ts +279 -0
  124. package/src/core/schemas/entrypoints.test.ts +171 -0
  125. package/src/core/schemas/entrypoints.ts +150 -0
  126. package/src/core/schemas/sw-json.ts +216 -0
  127. package/src/core/schemas/test-artifacts.ts +362 -0
  128. package/src/core/storage.ts +125 -0
  129. package/src/core/sw-json-render.test.ts +215 -0
  130. package/src/core/sw-json-render.ts +682 -0
  131. package/src/core/util.ts +390 -0
  132. package/src/defaults.ts +38 -0
  133. package/src/index.ts +31 -0
  134. package/src/requirements.txt +1 -0
@@ -0,0 +1,374 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ import * as os from "node:os";
5
+ import { getRunEnvironmentPythonInfo, prepareDockerOptions } from "../docker-python";
6
+ import type * as artifacts from "../schemas/artifacts";
7
+ import type winston from "winston";
8
+ import * as util from "../util";
9
+
10
+ // Mock the os module
11
+ vi.mock("node:os", () => ({
12
+ platform: vi.fn(),
13
+ tmpdir: vi.fn(),
14
+ }));
15
+
16
+ // Mock winston logger
17
+ const mockLogger = {
18
+ info: vi.fn(),
19
+ warn: vi.fn(),
20
+ debug: vi.fn(),
21
+ error: vi.fn(),
22
+ } as unknown as winston.Logger;
23
+
24
+ // Mock Python package for testing - will be set in beforeEach
25
+ let mockPythonPackage: artifacts.pythonType;
26
+
27
+ const createRunEnvironmentSwJson = (
28
+ packageRoot: string,
29
+ runenvArtifactID: string,
30
+ pythonVersion: string,
31
+ envVars: string[],
32
+ ) => {
33
+ const [runenvPackageName, runenvEntrypointName] = util.rSplit(runenvArtifactID, ":", 2);
34
+
35
+ const entrypointPath = path.join(
36
+ packageRoot,
37
+ "node_modules",
38
+ runenvPackageName,
39
+ "dist",
40
+ "tengo",
41
+ "software",
42
+ runenvEntrypointName + ".sw.json",
43
+ );
44
+ fs.mkdirSync(path.dirname(entrypointPath), { recursive: true });
45
+
46
+ fs.writeFileSync(
47
+ entrypointPath,
48
+ JSON.stringify(
49
+ {
50
+ name: `${runenvPackageName}:${runenvEntrypointName}`,
51
+ runEnv: {
52
+ type: "python",
53
+ "python-version": pythonVersion,
54
+ envVars: envVars,
55
+ registry: "example-python-registry",
56
+ package: "software/package-path/py3/1.1.0-{os}-{arch}.tgz",
57
+ binDir: ".",
58
+ },
59
+ },
60
+ null,
61
+ 2,
62
+ ),
63
+ );
64
+ };
65
+
66
+ describe("Docker Python Functions", () => {
67
+ let tempDir: string;
68
+ let testPackageRoot: string;
69
+
70
+ beforeEach(() => {
71
+ // Set up OS mocks
72
+ vi.mocked(os.platform).mockReturnValue("linux");
73
+ vi.mocked(os.tmpdir).mockReturnValue("/tmp");
74
+
75
+ // Create temporary directories for testing
76
+ tempDir = fs.mkdtempSync(path.join("/tmp", "python-docker-test"));
77
+ testPackageRoot = path.join(tempDir, "package");
78
+
79
+ fs.mkdirSync(testPackageRoot, { recursive: true });
80
+
81
+ // Create src directory for the root property
82
+ fs.mkdirSync(path.join(testPackageRoot, "src"), { recursive: true });
83
+
84
+ // Create a mock requirements.txt file
85
+ fs.writeFileSync(path.join(testPackageRoot, "requirements.txt"), "requests>=2.25.0\n");
86
+
87
+ createRunEnvironmentSwJson(
88
+ testPackageRoot,
89
+ "@platforma-open/milaboratories.runenv-python-3:3.12.10",
90
+ "3.12.10",
91
+ [],
92
+ );
93
+
94
+ // Initialize mockPythonPackage with absolute path
95
+ mockPythonPackage = {
96
+ name: "test-python-package",
97
+ version: "1.0.0",
98
+ type: "python",
99
+ environment: "@platforma-open/milaboratories.runenv-python-3:3.12.10",
100
+ registry: { name: "test" },
101
+ "docker-registry": "test-docker-registry",
102
+ root: path.join(testPackageRoot, "src"), // Use absolute path
103
+ pkg: "/app",
104
+ };
105
+
106
+ // Clear all mocks
107
+ vi.clearAllMocks();
108
+ });
109
+
110
+ afterEach(() => {
111
+ // Clean up temporary directories
112
+ try {
113
+ fs.rmSync(tempDir, { recursive: true, force: true });
114
+ } catch {
115
+ // Ignore cleanup errors
116
+ }
117
+ });
118
+
119
+ describe("prepareDockerOptions", () => {
120
+ it("should create dist/docker directories recursively", () => {
121
+ // Verify directories don't exist initially
122
+ expect(fs.existsSync(path.join(testPackageRoot, "dist"))).toBe(false);
123
+ expect(fs.existsSync(path.join(testPackageRoot, "dist", "docker"))).toBe(false);
124
+
125
+ const result = prepareDockerOptions(
126
+ mockLogger,
127
+ testPackageRoot,
128
+ "test-package",
129
+ "artifact-id",
130
+ mockPythonPackage,
131
+ );
132
+
133
+ // Verify directories were created
134
+ expect(fs.existsSync(path.join(testPackageRoot, "dist"))).toBe(true);
135
+ expect(fs.existsSync(path.join(testPackageRoot, "dist", "docker"))).toBe(true);
136
+
137
+ // Verify result structure
138
+ expect(result).toMatchObject({
139
+ context: expect.stringContaining(testPackageRoot) as string,
140
+ dockerfile: expect.stringContaining("Dockerfile") as string,
141
+ entrypoint: [],
142
+ });
143
+
144
+ // Verify Dockerfile was created
145
+ expect(fs.existsSync(result.dockerfile)).toBe(true);
146
+ expect(fs.existsSync(result.context)).toBe(true);
147
+ });
148
+
149
+ it("should prepare Docker options with default Python settings", () => {
150
+ const result = prepareDockerOptions(
151
+ mockLogger,
152
+ testPackageRoot,
153
+ "test-package",
154
+ "artifact-id",
155
+ mockPythonPackage,
156
+ );
157
+
158
+ // Verify result structure
159
+ expect(result).toMatchObject({
160
+ context: expect.stringContaining(testPackageRoot) as string,
161
+ dockerfile: expect.stringContaining("Dockerfile") as string,
162
+ entrypoint: [],
163
+ });
164
+
165
+ // Verify Dockerfile was created
166
+ expect(fs.existsSync(result.dockerfile)).toBe(true);
167
+ expect(fs.existsSync(result.context)).toBe(true);
168
+ });
169
+
170
+ it("should use default Python version when environment has no version", () => {
171
+ createRunEnvironmentSwJson(
172
+ testPackageRoot,
173
+ "@platforma-open/milaboratories.runenv-python-3:aaa",
174
+ "",
175
+ [],
176
+ );
177
+
178
+ const packageWithoutVersion: artifacts.pythonType = {
179
+ ...mockPythonPackage,
180
+ environment: "@platforma-open/milaboratories.runenv-python-3:aaa",
181
+ root: path.join(testPackageRoot, "src"), // Use absolute path
182
+ };
183
+
184
+ const result = prepareDockerOptions(
185
+ mockLogger,
186
+ testPackageRoot,
187
+ "test-package",
188
+ "artifact-id",
189
+ packageWithoutVersion,
190
+ );
191
+
192
+ expect(mockLogger.debug).toHaveBeenCalledWith(
193
+ "No Python version found in environment, using default: python:3.12-slim",
194
+ );
195
+ expect(result).toBeDefined();
196
+ });
197
+
198
+ it("should generate Dockerfile with correct content", () => {
199
+ const result = prepareDockerOptions(
200
+ mockLogger,
201
+ testPackageRoot,
202
+ "test-package",
203
+ "artifact-id",
204
+ mockPythonPackage,
205
+ );
206
+
207
+ const dockerfileContent = fs.readFileSync(result.dockerfile, "utf-8");
208
+
209
+ // Check essential Dockerfile components
210
+ expect(dockerfileContent).toContain("FROM python:3.12-slim");
211
+ expect(dockerfileContent).toContain("WORKDIR /app");
212
+ expect(dockerfileContent).toContain("COPY ");
213
+ expect(dockerfileContent).toContain("RUN pip install");
214
+ expect(dockerfileContent).toContain("ENV PYTHONPATH=/app");
215
+ expect(dockerfileContent).toContain('CMD ["bash"]');
216
+ });
217
+
218
+ it("should handle missing requirements.txt gracefully", () => {
219
+ // Remove requirements.txt
220
+ fs.unlinkSync(path.join(testPackageRoot, "requirements.txt"));
221
+
222
+ const result = prepareDockerOptions(
223
+ mockLogger,
224
+ testPackageRoot,
225
+ "test-package",
226
+ "artifact-id",
227
+ mockPythonPackage,
228
+ );
229
+
230
+ const dockerfileContent = fs.readFileSync(result.dockerfile, "utf-8");
231
+ expect(dockerfileContent).toContain("COPY");
232
+ expect(dockerfileContent).toContain("RUN pip install --no-cache-dir -r requirements.txt");
233
+
234
+ // Check that empty requirements.txt was created in context directory (src)
235
+ const emptyRequirementsPath = path.join(testPackageRoot, "src", "requirements.txt");
236
+ expect(fs.existsSync(emptyRequirementsPath)).toBe(true);
237
+ const emptyRequirementsContent = fs.readFileSync(emptyRequirementsPath, "utf-8");
238
+ expect(emptyRequirementsContent).toContain("# No dependencies specified");
239
+
240
+ // Should still create valid Docker options
241
+ expect(result).toBeDefined();
242
+ expect(result.dockerfile).toBeDefined();
243
+ });
244
+ });
245
+ });
246
+
247
+ describe("getPythonVersionFromEnvironment", () => {
248
+ let tempDir: string;
249
+ let testPackageRoot: string;
250
+
251
+ beforeEach(() => {
252
+ // Set up OS mocks
253
+ vi.mocked(os.platform).mockReturnValue("linux");
254
+ vi.mocked(os.tmpdir).mockReturnValue("/tmp");
255
+
256
+ // Create temporary directories for testing
257
+ tempDir = fs.mkdtempSync(path.join("/tmp", "python-docker-test"));
258
+ testPackageRoot = path.join(tempDir, "package");
259
+
260
+ fs.mkdirSync(testPackageRoot, { recursive: true });
261
+ vi.clearAllMocks();
262
+ });
263
+
264
+ const rawCases: [string, string, string][] = [
265
+ // Explicit version specified in the runenv
266
+ // ['@platforma-open/milaboratories.runenv-python-3:py-default', '3.12.0', '3.12.0'],
267
+ // ['@platforma-open/milaboratories.runenv-python-3:3.10.10', '3.4.5', '3.4.5'],
268
+
269
+ // Standard versions
270
+ ["@platforma-open/milaboratories.runenv-python-3:3.12.6", "", "3.12.6"],
271
+ ["@platforma-open/milaboratories.runenv-python-3:3.11.0", "", "3.11.0"],
272
+
273
+ // Pre-releases
274
+ ["@platforma-open/milaboratories.runenv-python-3:3.12.6-rc1", "", "3.12.6-rc1"],
275
+ ["@platforma-open/milaboratories.runenv-python-3:3.13.0a1", "", "3.13.0a1"],
276
+ ["@platforma-open/milaboratories.runenv-python-3:3.13.0b2", "", "3.13.0b2"],
277
+
278
+ // With tags/suffixes
279
+ ["@platforma-open/milaboratories.runenv-python-3:3.12.6:latest", "", "3.12.6:latest"],
280
+ ["@platforma-open/milaboratories.runenv-python-3:3.12.6+dev", "", "3.12.6+dev"],
281
+ ["@platforma-open/milaboratories.runenv-python-3:3.12.6_dev", "", "3.12.6_dev"],
282
+ ];
283
+
284
+ const dockerSafeCases: [string, string, string][] = [
285
+ // Explicit version specified in the runenv
286
+ ["@platforma-open/milaboratories.runenv-python-3:py-default", "3.12.0", "3.12.0"],
287
+ ["@platforma-open/milaboratories.runenv-python-3:3.10.10", "3.4.5", "3.4.5"],
288
+
289
+ // Second ":" is stripped
290
+ ["@platforma-open/milaboratories.runenv-python-3:3.12.6:latest", "", "3.12.6"],
291
+ // Digest is stripped
292
+ ["@platforma-open/milaboratories.runenv-python-3:3.12.6@sha256:deadbeef", "", "3.12.6"],
293
+ // Pre-release should remain valid
294
+ ["@platforma-open/milaboratories.runenv-python-3:3.13.0b2@sha256:123", "", "3.13.0b2"],
295
+ // With tags/suffixes
296
+ ["@platforma-open/milaboratories.runenv-python-3:3.14.0rc2-bookworm", "", "3.14.0rc2-bookworm"],
297
+ ["@platforma-open/milaboratories.runenv-python-3:3.9.23-alpine3.22", "", "3.9.23-alpine3.22"],
298
+ ["@platforma-open/milaboratories.runenv-python-3:3.12.6-slim", "", "3.12.6-slim"],
299
+ [
300
+ "@platforma-open/milaboratories.runenv-python-3:3.13.7-windowsservercore-ltsc2025",
301
+ "",
302
+ "3.13.7-windowsservercore-ltsc2025",
303
+ ],
304
+ ];
305
+
306
+ const undefinedCases: [string, string[]][] = [
307
+ ["@milaboratories/example-pl-package-runenv-1:python", ["MY_ENV_VAR=1234"]],
308
+ ["@milaboratories/example-pl-package-runenv-1:python", []],
309
+ ];
310
+
311
+ describe("without normalizeForDocker", () => {
312
+ rawCases.forEach(([input, pyVersion, expected]) => {
313
+ it(`should extract "${expected}" from "${input}"`, () => {
314
+ createRunEnvironmentSwJson(testPackageRoot, input, pyVersion, ["MY_ENV_VAR=1234"]);
315
+ expect(
316
+ getRunEnvironmentPythonInfo(mockLogger, testPackageRoot, "test-package", input, {
317
+ normalizeForDocker: false,
318
+ }),
319
+ ).toStrictEqual({
320
+ pythonVersion: expected,
321
+ envVars: ["MY_ENV_VAR=1234"],
322
+ });
323
+ });
324
+ });
325
+
326
+ undefinedCases.forEach(([input, envVars]) => {
327
+ it(`should return undefined for "${input}"`, () => {
328
+ if (input !== "") {
329
+ createRunEnvironmentSwJson(testPackageRoot, input, "", envVars);
330
+ }
331
+ expect(
332
+ getRunEnvironmentPythonInfo(mockLogger, testPackageRoot, "test-package", input, {
333
+ normalizeForDocker: false,
334
+ }),
335
+ ).toStrictEqual({
336
+ pythonVersion: undefined,
337
+ envVars,
338
+ });
339
+ });
340
+ });
341
+ });
342
+
343
+ describe("with normalizeForDocker = true", () => {
344
+ dockerSafeCases.forEach(([input, pyVersion, expected]) => {
345
+ it(`should extract docker-safe "${expected}" from "${input}"`, () => {
346
+ createRunEnvironmentSwJson(testPackageRoot, input, pyVersion, []);
347
+ expect(
348
+ getRunEnvironmentPythonInfo(mockLogger, testPackageRoot, "test-package", input, {
349
+ normalizeForDocker: true,
350
+ }),
351
+ ).toStrictEqual({
352
+ pythonVersion: expected,
353
+ envVars: [],
354
+ });
355
+ });
356
+ });
357
+
358
+ undefinedCases.forEach(([input, envVars]) => {
359
+ it(`should return undefined for "${input}"`, () => {
360
+ if (input !== "") {
361
+ createRunEnvironmentSwJson(testPackageRoot, input, "", envVars);
362
+ }
363
+ expect(
364
+ getRunEnvironmentPythonInfo(mockLogger, testPackageRoot, "test-package", input, {
365
+ normalizeForDocker: true,
366
+ }),
367
+ ).toStrictEqual({
368
+ pythonVersion: undefined,
369
+ envVars,
370
+ });
371
+ });
372
+ });
373
+ });
374
+ });
@@ -0,0 +1,309 @@
1
+ // Regression tests for Core.buildDockerImages + Core.publishDockerImages.
2
+ //
3
+ // Locks in two properties:
4
+ // 1. each docker artifact is built and pushed exactly once across all
5
+ // block-software entrypoint shapes (python/conda autogen, explicit
6
+ // docker, java-only, multi-entrypoint). The autopush path used to
7
+ // iterate over both buildablePackages and dockerPackages, which for
8
+ // python→docker autogen counted the same artifact twice.
9
+ // 2. the strict-platform gate keeps non-linux/x64 hosts out of the
10
+ // build+publish path in CI, so the matrix doesn't fan out duplicate
11
+ // pushes from arm/windows legs.
12
+ //
13
+ // docker.* is module-mocked so the tests don't shell out to the real
14
+ // docker daemon. PackageInfo is constructed against a tmpdir with a
15
+ // minimal package.json + the bits each shape needs (requirements.txt for
16
+ // python, a stub runenv .sw.json for python autogen).
17
+
18
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
19
+ import * as fs from "node:fs";
20
+ import * as path from "node:path";
21
+ import * as os from "node:os";
22
+ import type winston from "winston";
23
+
24
+ import { Core } from "../core";
25
+ import * as docker from "../docker";
26
+ import * as util from "../util";
27
+
28
+ vi.mock("../docker", async (importOriginal) => {
29
+ const real = await importOriginal<typeof docker>();
30
+ return {
31
+ ...real,
32
+ build: vi.fn(),
33
+ push: vi.fn(),
34
+ addTag: vi.fn(),
35
+ removeTag: vi.fn(),
36
+ getImageHash: vi.fn(() => "deadbeef0123"),
37
+ getImageEntrypoint: vi.fn(() => [] as string[]),
38
+ localImageExists: vi.fn(() => true),
39
+ remoteImageExists: vi.fn(() => false),
40
+ };
41
+ });
42
+
43
+ const mockLogger = {
44
+ info: vi.fn(),
45
+ warn: vi.fn(),
46
+ debug: vi.fn(),
47
+ error: vi.fn(),
48
+ log: vi.fn(),
49
+ } as unknown as winston.Logger;
50
+
51
+ // Minimal stub for the python runenv .sw.json that PackageInfo's python
52
+ // autogen path resolves through `node_modules`. Only the runEnv block is
53
+ // inspected by docker-python so we keep this tiny.
54
+ function writeRunenvSwJson(packageRoot: string, refId: string, pythonVersion: string) {
55
+ const [pkgName, ep] = refId.split(":");
56
+ const swPath = path.join(
57
+ packageRoot,
58
+ "node_modules",
59
+ pkgName,
60
+ "dist",
61
+ "tengo",
62
+ "software",
63
+ `${ep}.sw.json`,
64
+ );
65
+ fs.mkdirSync(path.dirname(swPath), { recursive: true });
66
+ fs.writeFileSync(
67
+ swPath,
68
+ JSON.stringify({
69
+ name: `${pkgName}:${ep}`,
70
+ runEnv: {
71
+ type: "python",
72
+ "python-version": pythonVersion,
73
+ envVars: [],
74
+ registry: "test-runenv-registry",
75
+ package: "software/test/py-{os}-{arch}.tgz",
76
+ binDir: ".",
77
+ },
78
+ }),
79
+ );
80
+ }
81
+
82
+ function makePackageRoot(
83
+ tmpDir: string,
84
+ pkgJson: Record<string, unknown>,
85
+ extras?: {
86
+ requirementsTxt?: boolean;
87
+ runenv?: { ref: string; pythonVersion: string };
88
+ dockerContext?: string;
89
+ },
90
+ ): string {
91
+ const root = path.join(tmpDir, "pkg");
92
+ fs.mkdirSync(path.join(root, "src"), { recursive: true });
93
+ fs.writeFileSync(path.join(root, "package.json"), JSON.stringify(pkgJson));
94
+
95
+ if (extras?.requirementsTxt) {
96
+ fs.writeFileSync(path.join(root, "src", "requirements.txt"), "");
97
+ }
98
+ if (extras?.runenv) {
99
+ writeRunenvSwJson(root, extras.runenv.ref, extras.runenv.pythonVersion);
100
+ }
101
+ if (extras?.dockerContext) {
102
+ fs.mkdirSync(path.join(root, extras.dockerContext), { recursive: true });
103
+ // dockerSchema defaults the Dockerfile path to <root>/Dockerfile, separate
104
+ // from the context dir. Provide both.
105
+ fs.writeFileSync(path.join(root, "Dockerfile"), "FROM scratch\n");
106
+ fs.writeFileSync(path.join(root, extras.dockerContext, ".keep"), "");
107
+ }
108
+
109
+ return root;
110
+ }
111
+
112
+ const RUNENV_REF = "@test/runenv-py:py3";
113
+
114
+ const pythonEntrypoint = {
115
+ binary: {
116
+ artifact: {
117
+ type: "python",
118
+ registry: "platforma-open",
119
+ environment: RUNENV_REF,
120
+ root: "./src",
121
+ dependencies: { toolset: "pip", requirements: "requirements.txt" },
122
+ },
123
+ cmd: ["python", "{pkg}/main.py"],
124
+ },
125
+ };
126
+
127
+ const javaEntrypoint = {
128
+ binary: {
129
+ artifact: {
130
+ type: "java",
131
+ registry: "platforma-open",
132
+ environment: RUNENV_REF,
133
+ // 'roots' is omitted for java in the schema; archiveRulesSchema requires root.
134
+ root: "./src",
135
+ },
136
+ cmd: ["java", "-jar", "{pkg}/app.jar"],
137
+ },
138
+ };
139
+
140
+ const explicitDockerEntrypoint = {
141
+ docker: {
142
+ artifact: {
143
+ context: "docker-context",
144
+ },
145
+ cmd: ["echo", "hello"],
146
+ },
147
+ };
148
+
149
+ describe("Core.buildDockerImages + publishDockerImages", () => {
150
+ let tempDir: string;
151
+
152
+ beforeEach(() => {
153
+ vi.clearAllMocks();
154
+ // Restore default return values that the per-test code may rely on.
155
+ vi.mocked(docker.remoteImageExists).mockReturnValue(false);
156
+ vi.mocked(docker.localImageExists).mockReturnValue(true);
157
+ vi.mocked(docker.getImageHash).mockReturnValue("deadbeef0123");
158
+ vi.mocked(docker.getImageEntrypoint).mockReturnValue([]);
159
+
160
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "pub-docker-test-"));
161
+ });
162
+
163
+ afterEach(() => {
164
+ try {
165
+ fs.rmSync(tempDir, { recursive: true, force: true });
166
+ } catch {
167
+ // ignore
168
+ }
169
+ });
170
+
171
+ it("python autogen: one build, one push, no duplicate from virtual entrypoint", () => {
172
+ const root = makePackageRoot(
173
+ tempDir,
174
+ {
175
+ name: "@test/python-autogen",
176
+ version: "1.0.0",
177
+ "block-software": { entrypoints: { main: pythonEntrypoint } },
178
+ },
179
+ { requirementsTxt: true, runenv: { ref: RUNENV_REF, pythonVersion: "3.12.10" } },
180
+ );
181
+
182
+ const core = new Core(mockLogger, { packageRoot: root });
183
+ core.buildDockerImages({ registry: "test.local/repo" });
184
+ core.publishDockerImages();
185
+
186
+ expect(docker.build).toHaveBeenCalledTimes(1);
187
+ expect(docker.push).toHaveBeenCalledTimes(1);
188
+
189
+ const pushed = vi.mocked(docker.push).mock.calls[0][0];
190
+ expect(pushed).toBe("test.local/repo:test.python-autogen.main.deadbeef0123");
191
+ });
192
+
193
+ it("multi-entrypoint python: one build + one push per entrypoint, no overlap", () => {
194
+ const root = makePackageRoot(
195
+ tempDir,
196
+ {
197
+ name: "@test/multi-ep",
198
+ version: "1.0.0",
199
+ "block-software": {
200
+ entrypoints: {
201
+ main: pythonEntrypoint,
202
+ helper: pythonEntrypoint,
203
+ },
204
+ },
205
+ },
206
+ { requirementsTxt: true, runenv: { ref: RUNENV_REF, pythonVersion: "3.12.10" } },
207
+ );
208
+
209
+ const core = new Core(mockLogger, { packageRoot: root });
210
+ core.buildDockerImages({ registry: "test.local/repo" });
211
+ core.publishDockerImages();
212
+
213
+ expect(docker.build).toHaveBeenCalledTimes(2);
214
+ expect(docker.push).toHaveBeenCalledTimes(2);
215
+
216
+ const pushedTags = vi.mocked(docker.push).mock.calls.map((c) => c[0]);
217
+ expect(new Set(pushedTags).size).toBe(2);
218
+ expect(pushedTags).toEqual(
219
+ expect.arrayContaining([
220
+ "test.local/repo:test.multi-ep.main.deadbeef0123",
221
+ "test.local/repo:test.multi-ep.helper.deadbeef0123",
222
+ ]),
223
+ );
224
+ });
225
+
226
+ it("java-only: no docker work — type is not in dockerAutogenTypes", () => {
227
+ const root = makePackageRoot(tempDir, {
228
+ name: "@test/java-only",
229
+ version: "1.0.0",
230
+ "block-software": { entrypoints: { main: javaEntrypoint } },
231
+ });
232
+
233
+ const core = new Core(mockLogger, { packageRoot: root });
234
+ core.buildDockerImages({ registry: "test.local/repo" });
235
+ core.publishDockerImages();
236
+
237
+ expect(docker.build).not.toHaveBeenCalled();
238
+ expect(docker.push).not.toHaveBeenCalled();
239
+ });
240
+
241
+ it("explicit docker entrypoint: one build, one push", () => {
242
+ const root = makePackageRoot(
243
+ tempDir,
244
+ {
245
+ name: "@test/explicit-docker",
246
+ version: "1.0.0",
247
+ "block-software": { entrypoints: { main: explicitDockerEntrypoint } },
248
+ },
249
+ { dockerContext: "docker-context" },
250
+ );
251
+
252
+ const core = new Core(mockLogger, { packageRoot: root });
253
+ core.buildDockerImages({ registry: "test.local/repo" });
254
+ core.publishDockerImages();
255
+
256
+ expect(docker.build).toHaveBeenCalledTimes(1);
257
+ expect(docker.push).toHaveBeenCalledTimes(1);
258
+ });
259
+
260
+ it("remoteImageExists short-circuits push (idempotent rebuild)", () => {
261
+ vi.mocked(docker.remoteImageExists).mockReturnValue(true);
262
+
263
+ const root = makePackageRoot(
264
+ tempDir,
265
+ {
266
+ name: "@test/python-cached",
267
+ version: "1.0.0",
268
+ "block-software": { entrypoints: { main: pythonEntrypoint } },
269
+ },
270
+ { requirementsTxt: true, runenv: { ref: RUNENV_REF, pythonVersion: "3.12.10" } },
271
+ );
272
+
273
+ const core = new Core(mockLogger, { packageRoot: root });
274
+ core.buildDockerImages({ registry: "test.local/repo" });
275
+ core.publishDockerImages();
276
+
277
+ expect(docker.build).toHaveBeenCalledTimes(1);
278
+ expect(docker.push).not.toHaveBeenCalled();
279
+ });
280
+
281
+ it("strictPlatformMatching skips both build and publish on non-linux/x64 host", () => {
282
+ const currentOS = vi.spyOn(util, "currentOS");
283
+ const currentArch = vi.spyOn(util, "currentArch");
284
+ currentOS.mockReturnValue("macosx");
285
+ currentArch.mockReturnValue("aarch64");
286
+
287
+ try {
288
+ const root = makePackageRoot(
289
+ tempDir,
290
+ {
291
+ name: "@test/strict-skipped",
292
+ version: "1.0.0",
293
+ "block-software": { entrypoints: { main: pythonEntrypoint } },
294
+ },
295
+ { requirementsTxt: true, runenv: { ref: RUNENV_REF, pythonVersion: "3.12.10" } },
296
+ );
297
+
298
+ const core = new Core(mockLogger, { packageRoot: root });
299
+ core.buildDockerImages({ registry: "test.local/repo", strictPlatformMatching: true });
300
+ core.publishDockerImages({ strictPlatformMatching: true });
301
+
302
+ expect(docker.build).not.toHaveBeenCalled();
303
+ expect(docker.push).not.toHaveBeenCalled();
304
+ } finally {
305
+ currentOS.mockRestore();
306
+ currentArch.mockRestore();
307
+ }
308
+ });
309
+ });