@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.
- package/assets/conda-dockerfile.template +45 -0
- package/assets/python-dockerfile.template +22 -0
- package/dist/_virtual/_rolldown/runtime.cjs +33 -0
- package/dist/_virtual/_rolldown/runtime.js +13 -0
- package/dist/core/archive.cjs +52 -0
- package/dist/core/archive.cjs.map +1 -0
- package/dist/core/archive.js +47 -0
- package/dist/core/archive.js.map +1 -0
- package/dist/core/conda/builder.cjs +226 -0
- package/dist/core/conda/builder.cjs.map +1 -0
- package/dist/core/conda/builder.js +220 -0
- package/dist/core/conda/builder.js.map +1 -0
- package/dist/core/conda/validate-spec.cjs +76 -0
- package/dist/core/conda/validate-spec.cjs.map +1 -0
- package/dist/core/conda/validate-spec.js +73 -0
- package/dist/core/conda/validate-spec.js.map +1 -0
- package/dist/core/core.cjs +403 -0
- package/dist/core/core.cjs.map +1 -0
- package/dist/core/core.d.ts +82 -0
- package/dist/core/core.d.ts.map +1 -0
- package/dist/core/core.js +397 -0
- package/dist/core/core.js.map +1 -0
- package/dist/core/docker-conda.cjs +75 -0
- package/dist/core/docker-conda.cjs.map +1 -0
- package/dist/core/docker-conda.js +71 -0
- package/dist/core/docker-conda.js.map +1 -0
- package/dist/core/docker-python.cjs +147 -0
- package/dist/core/docker-python.cjs.map +1 -0
- package/dist/core/docker-python.js +144 -0
- package/dist/core/docker-python.js.map +1 -0
- package/dist/core/docker.cjs +223 -0
- package/dist/core/docker.cjs.map +1 -0
- package/dist/core/docker.js +207 -0
- package/dist/core/docker.js.map +1 -0
- package/dist/core/envs.cjs +58 -0
- package/dist/core/envs.cjs.map +1 -0
- package/dist/core/envs.d.ts +26 -0
- package/dist/core/envs.d.ts.map +1 -0
- package/dist/core/envs.js +51 -0
- package/dist/core/envs.js.map +1 -0
- package/dist/core/package-info.cjs +450 -0
- package/dist/core/package-info.cjs.map +1 -0
- package/dist/core/package-info.d.ts +57 -0
- package/dist/core/package-info.d.ts.map +1 -0
- package/dist/core/package-info.js +447 -0
- package/dist/core/package-info.js.map +1 -0
- package/dist/core/paths.cjs +15 -0
- package/dist/core/paths.cjs.map +1 -0
- package/dist/core/paths.js +14 -0
- package/dist/core/paths.js.map +1 -0
- package/dist/core/resolver.cjs +99 -0
- package/dist/core/resolver.cjs.map +1 -0
- package/dist/core/resolver.js +95 -0
- package/dist/core/resolver.js.map +1 -0
- package/dist/core/schemas/artifacts.cjs +136 -0
- package/dist/core/schemas/artifacts.cjs.map +1 -0
- package/dist/core/schemas/artifacts.d.ts +280 -0
- package/dist/core/schemas/artifacts.d.ts.map +1 -0
- package/dist/core/schemas/artifacts.js +122 -0
- package/dist/core/schemas/artifacts.js.map +1 -0
- package/dist/core/schemas/entrypoints.cjs +43 -0
- package/dist/core/schemas/entrypoints.cjs.map +1 -0
- package/dist/core/schemas/entrypoints.d.ts +32 -0
- package/dist/core/schemas/entrypoints.d.ts.map +1 -0
- package/dist/core/schemas/entrypoints.js +41 -0
- package/dist/core/schemas/entrypoints.js.map +1 -0
- package/dist/core/schemas/sw-json.cjs +111 -0
- package/dist/core/schemas/sw-json.cjs.map +1 -0
- package/dist/core/schemas/sw-json.js +110 -0
- package/dist/core/schemas/sw-json.js.map +1 -0
- package/dist/core/storage.cjs +86 -0
- package/dist/core/storage.cjs.map +1 -0
- package/dist/core/storage.js +83 -0
- package/dist/core/storage.js.map +1 -0
- package/dist/core/sw-json-render.cjs +327 -0
- package/dist/core/sw-json-render.cjs.map +1 -0
- package/dist/core/sw-json-render.js +322 -0
- package/dist/core/sw-json-render.js.map +1 -0
- package/dist/core/util.cjs +313 -0
- package/dist/core/util.cjs.map +1 -0
- package/dist/core/util.d.ts +66 -0
- package/dist/core/util.d.ts.map +1 -0
- package/dist/core/util.js +281 -0
- package/dist/core/util.js.map +1 -0
- package/dist/defaults.cjs +54 -0
- package/dist/defaults.cjs.map +1 -0
- package/dist/defaults.d.ts +21 -0
- package/dist/defaults.d.ts.map +1 -0
- package/dist/defaults.js +37 -0
- package/dist/defaults.js.map +1 -0
- package/dist/index.cjs +31 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/package.json +56 -0
- package/src/__test__/Dockerfile +1 -0
- package/src/__test__/docker.test.ts +67 -0
- package/src/__test__/package.json +39 -0
- package/src/__test__/python-example/main.py +1 -0
- package/src/__test__/python-example/requirements.txt +0 -0
- package/src/__test__/vite.config.mts +7 -0
- package/src/core/__tests__/docker-conda.test.ts +145 -0
- package/src/core/__tests__/docker-entrypoint-generation.test.ts +119 -0
- package/src/core/__tests__/docker-python.test.ts +374 -0
- package/src/core/__tests__/publish-docker-images.test.ts +309 -0
- package/src/core/archive.ts +102 -0
- package/src/core/conda/builder.test.ts +71 -0
- package/src/core/conda/builder.ts +384 -0
- package/src/core/conda/validate-spec.test.ts +213 -0
- package/src/core/conda/validate-spec.ts +107 -0
- package/src/core/core.ts +835 -0
- package/src/core/docker-conda.ts +149 -0
- package/src/core/docker-python.ts +283 -0
- package/src/core/docker.ts +300 -0
- package/src/core/envs.ts +31 -0
- package/src/core/package-info.test.ts +82 -0
- package/src/core/package-info.ts +843 -0
- package/src/core/paths.ts +16 -0
- package/src/core/resolver.ts +195 -0
- package/src/core/schemas/artifacts.test.ts +72 -0
- package/src/core/schemas/artifacts.ts +279 -0
- package/src/core/schemas/entrypoints.test.ts +171 -0
- package/src/core/schemas/entrypoints.ts +150 -0
- package/src/core/schemas/sw-json.ts +216 -0
- package/src/core/schemas/test-artifacts.ts +362 -0
- package/src/core/storage.ts +125 -0
- package/src/core/sw-json-render.test.ts +215 -0
- package/src/core/sw-json-render.ts +682 -0
- package/src/core/util.ts +390 -0
- package/src/defaults.ts +38 -0
- package/src/index.ts +31 -0
- 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
|
+
});
|