@mittwald/cli 1.13.1 → 1.13.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/container/update.js +1 -1
- package/dist/commands/cronjob/create.d.ts +1 -0
- package/dist/commands/cronjob/create.js +3 -1
- package/dist/commands/cronjob/list.js +4 -0
- package/dist/commands/cronjob/update.d.ts +1 -0
- package/dist/commands/cronjob/update.js +3 -1
- package/dist/commands/org/invite/list-own.d.ts +1 -0
- package/dist/commands/org/membership/list-own.d.ts +1 -0
- package/dist/commands/stack/deploy.js +2 -2
- package/dist/lib/resources/container/containerconfig.d.ts +0 -7
- package/dist/lib/resources/container/containerconfig.js +3 -19
- package/dist/lib/resources/cronjob/flags.d.ts +4 -0
- package/dist/lib/resources/cronjob/flags.js +4 -0
- package/dist/lib/resources/stack/enrich.d.ts +2 -1
- package/dist/lib/resources/stack/enrich.js +6 -2
- package/dist/lib/resources/stack/enrich.test.js +184 -0
- package/dist/lib/resources/stack/env.js +2 -2
- package/dist/lib/resources/stack/env.test.js +24 -0
- package/dist/lib/resources/stack/loader.d.ts +3 -5
- package/dist/lib/resources/stack/sanitize.d.ts +2 -4
- package/dist/lib/resources/stack/types.d.ts +22 -0
- package/dist/lib/util/parser.d.ts +15 -0
- package/dist/lib/util/parser.js +41 -0
- package/dist/lib/util/parser.test.d.ts +1 -0
- package/dist/lib/util/parser.test.js +103 -0
- package/dist/rendering/react/components/CronJob/CronJobDetails.js +1 -0
- package/package.json +4 -5
- package/dist/lib/resources/container/containerconfig.test.js +0 -25
- /package/dist/lib/resources/{container/containerconfig.test.d.ts → stack/env.test.d.ts} +0 -0
|
@@ -65,7 +65,7 @@ export class Update extends ExecRenderBaseCommand {
|
|
|
65
65
|
}),
|
|
66
66
|
volume: Flags.string({
|
|
67
67
|
summary: "update volume mounts for the container",
|
|
68
|
-
description: "This flag can be used to
|
|
68
|
+
description: "This flag can be used to replace volume mounts of the container. It can be used multiple times to mount multiple volumes." +
|
|
69
69
|
"" +
|
|
70
70
|
"Needs to be in the format <host-path>:<container-path>. " +
|
|
71
71
|
"" +
|
|
@@ -15,6 +15,7 @@ export declare class Create extends ExecRenderBaseCommand<typeof Create, Result>
|
|
|
15
15
|
interpreter: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
16
16
|
disable: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
17
17
|
timeout: import("@oclif/core/interfaces").OptionFlag<Duration, import("@oclif/core/interfaces").CustomOptions>;
|
|
18
|
+
timezone: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
18
19
|
quiet: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
19
20
|
"installation-id": import("@oclif/core/interfaces").OptionFlag<string>;
|
|
20
21
|
};
|
|
@@ -41,11 +41,12 @@ export class Create extends ExecRenderBaseCommand {
|
|
|
41
41
|
default: Duration.fromString("1h"),
|
|
42
42
|
required: false,
|
|
43
43
|
}),
|
|
44
|
+
timezone: cronjobFlagDefinitions.timezone(),
|
|
44
45
|
};
|
|
45
46
|
async exec() {
|
|
46
47
|
const p = makeProcessRenderer(this.flags, "Creating a new cron job");
|
|
47
48
|
const appInstallationId = await this.withAppInstallationId(Create);
|
|
48
|
-
const { description, interval, disable, email, url, interpreter, command, timeout, } = this.flags;
|
|
49
|
+
const { description, interval, disable, email, url, interpreter, command, timeout, timezone, } = this.flags;
|
|
49
50
|
const { projectId } = await p.runStep("fetching project", async () => {
|
|
50
51
|
const r = await this.apiClient.app.getAppinstallation({
|
|
51
52
|
appInstallationId,
|
|
@@ -67,6 +68,7 @@ export class Create extends ExecRenderBaseCommand {
|
|
|
67
68
|
email,
|
|
68
69
|
destination: buildCronjobDestination(url, command, interpreter),
|
|
69
70
|
timeout: timeout.seconds,
|
|
71
|
+
timeZone: timezone,
|
|
70
72
|
},
|
|
71
73
|
});
|
|
72
74
|
assertStatus(r, 201);
|
|
@@ -17,6 +17,7 @@ export default class Update extends ExecRenderBaseCommand<typeof Update, UpdateR
|
|
|
17
17
|
enable: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
18
18
|
disable: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
19
19
|
timeout: import("@oclif/core/interfaces").OptionFlag<Duration | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
20
|
+
timezone: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
20
21
|
quiet: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
21
22
|
};
|
|
22
23
|
protected exec(): Promise<void>;
|
|
@@ -47,6 +47,7 @@ export default class Update extends ExecRenderBaseCommand {
|
|
|
47
47
|
"If an email address is defined, an error message will be sent.",
|
|
48
48
|
required: false,
|
|
49
49
|
}),
|
|
50
|
+
timezone: cronjobFlagDefinitions.timezone(),
|
|
50
51
|
};
|
|
51
52
|
async exec() {
|
|
52
53
|
const process = makeProcessRenderer(this.flags, "Updating cron job");
|
|
@@ -55,7 +56,7 @@ export default class Update extends ExecRenderBaseCommand {
|
|
|
55
56
|
cronjobId,
|
|
56
57
|
});
|
|
57
58
|
assertSuccess(currentCronjob);
|
|
58
|
-
const { description, interval, email, timeout, url, interpreter, command, enable, disable, } = this.flags;
|
|
59
|
+
const { description, interval, email, timeout, url, interpreter, command, enable, disable, timezone, } = this.flags;
|
|
59
60
|
if (Object.keys(this.flags).length == 0) {
|
|
60
61
|
await process.complete(_jsx(Success, { children: "Nothing to change. Have a good day!" }));
|
|
61
62
|
return;
|
|
@@ -72,6 +73,7 @@ export default class Update extends ExecRenderBaseCommand {
|
|
|
72
73
|
timeout: timeout?.seconds,
|
|
73
74
|
email,
|
|
74
75
|
interval,
|
|
76
|
+
timeZone: timezone,
|
|
75
77
|
},
|
|
76
78
|
});
|
|
77
79
|
assertSuccess(response);
|
|
@@ -39,6 +39,7 @@ export declare class List extends ListBaseCommand<typeof List, ResponseItem, Res
|
|
|
39
39
|
isAllowedToPlaceOrders?: boolean | undefined;
|
|
40
40
|
isBanned?: boolean | undefined;
|
|
41
41
|
isInDefaultOfPayment?: boolean | undefined;
|
|
42
|
+
isMailAddressInvalid?: boolean | undefined;
|
|
42
43
|
levelOfUndeliverableDunningNotice?: "first" | "second" | undefined;
|
|
43
44
|
memberCount: number;
|
|
44
45
|
name: string;
|
|
@@ -39,6 +39,7 @@ export declare class ListOwn extends ListBaseCommand<typeof ListOwn, ResponseIte
|
|
|
39
39
|
isAllowedToPlaceOrders?: boolean | undefined;
|
|
40
40
|
isBanned?: boolean | undefined;
|
|
41
41
|
isInDefaultOfPayment?: boolean | undefined;
|
|
42
|
+
isMailAddressInvalid?: boolean | undefined;
|
|
42
43
|
levelOfUndeliverableDunningNotice?: "first" | "second" | undefined;
|
|
43
44
|
memberCount: number;
|
|
44
45
|
name: string;
|
|
@@ -12,7 +12,7 @@ import { enrichStackDefinition } from "../../lib/resources/stack/enrich.js";
|
|
|
12
12
|
import { Success } from "../../rendering/react/components/Success.js";
|
|
13
13
|
import { Value } from "../../rendering/react/components/Value.js";
|
|
14
14
|
import { loadStackFromTemplate } from "../../lib/resources/stack/template-loader.js";
|
|
15
|
-
import {
|
|
15
|
+
import { parseEnvironmentVariablesFromStr } from "../../lib/util/parser.js";
|
|
16
16
|
export class Deploy extends ExecRenderBaseCommand {
|
|
17
17
|
static description = "Deploys a docker-compose compatible file to a mittwald container stack";
|
|
18
18
|
static aliases = ["stack:up"];
|
|
@@ -56,7 +56,7 @@ This flag is mutually exclusive with --compose-file.`,
|
|
|
56
56
|
// Load from GitHub template
|
|
57
57
|
const { composeYaml, envContent } = await renderer.runStep("fetching template from GitHub", () => loadStackFromTemplate(source.template));
|
|
58
58
|
if (envContent) {
|
|
59
|
-
const templateEnv =
|
|
59
|
+
const templateEnv = parseEnvironmentVariablesFromStr(envContent);
|
|
60
60
|
env = { ...env, ...templateEnv };
|
|
61
61
|
}
|
|
62
62
|
env = await collectEnvironment(env, envFile);
|
|
@@ -15,13 +15,6 @@ export declare function parseEnvironmentVariables(envFlags?: string[], envFiles?
|
|
|
15
15
|
* @returns An object containing environment variable key-value pairs
|
|
16
16
|
*/
|
|
17
17
|
export declare function parseEnvironmentVariablesFromFile(envFiles?: string[]): Promise<Record<string, string>>;
|
|
18
|
-
/**
|
|
19
|
-
* Parses environment variables from command line flags
|
|
20
|
-
*
|
|
21
|
-
* @param envFlags Array of environment variable strings in KEY=VALUE format
|
|
22
|
-
* @returns An object containing environment variable key-value pairs
|
|
23
|
-
*/
|
|
24
|
-
export declare function parseEnvironmentVariablesFromEnvFlags(envFlags?: string[]): Record<string, string>;
|
|
25
18
|
/**
|
|
26
19
|
* Determines which ports to expose based on image metadata
|
|
27
20
|
*
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { assertStatus, } from "@mittwald/api-client";
|
|
2
2
|
import * as fs from "fs/promises";
|
|
3
|
-
import { parse } from "envfile";
|
|
4
3
|
import { pathExists } from "../../util/fs/pathExists.js";
|
|
4
|
+
import { parseEnvironmentVariablesFromArray, parseEnvironmentVariablesFromStr, } from "../../util/parser.js";
|
|
5
5
|
/**
|
|
6
6
|
* Parses environment variables from command line flags and env files
|
|
7
7
|
*
|
|
@@ -11,7 +11,7 @@ import { pathExists } from "../../util/fs/pathExists.js";
|
|
|
11
11
|
*/
|
|
12
12
|
export async function parseEnvironmentVariables(envFlags = [], envFiles = []) {
|
|
13
13
|
return {
|
|
14
|
-
...
|
|
14
|
+
...parseEnvironmentVariablesFromArray(envFlags),
|
|
15
15
|
...(await parseEnvironmentVariablesFromFile(envFiles)),
|
|
16
16
|
};
|
|
17
17
|
}
|
|
@@ -28,27 +28,11 @@ export async function parseEnvironmentVariablesFromFile(envFiles = []) {
|
|
|
28
28
|
throw new Error(`Env file not found: ${envFile}`);
|
|
29
29
|
}
|
|
30
30
|
const fileContent = await fs.readFile(envFile, { encoding: "utf-8" });
|
|
31
|
-
const parsed =
|
|
31
|
+
const parsed = parseEnvironmentVariablesFromStr(fileContent);
|
|
32
32
|
Object.assign(result, parsed);
|
|
33
33
|
}
|
|
34
34
|
return result;
|
|
35
35
|
}
|
|
36
|
-
/**
|
|
37
|
-
* Parses environment variables from command line flags
|
|
38
|
-
*
|
|
39
|
-
* @param envFlags Array of environment variable strings in KEY=VALUE format
|
|
40
|
-
* @returns An object containing environment variable key-value pairs
|
|
41
|
-
*/
|
|
42
|
-
export function parseEnvironmentVariablesFromEnvFlags(envFlags = []) {
|
|
43
|
-
const splitIntoKeyAndValue = (e) => {
|
|
44
|
-
const index = e.indexOf("=");
|
|
45
|
-
if (index < 0) {
|
|
46
|
-
throw new Error(`Invalid environment variable format: ${e}`);
|
|
47
|
-
}
|
|
48
|
-
return [e.slice(0, index), e.slice(index + 1)];
|
|
49
|
-
};
|
|
50
|
-
return Object.fromEntries(envFlags.map(splitIntoKeyAndValue));
|
|
51
|
-
}
|
|
52
36
|
/**
|
|
53
37
|
* Determines which ports to expose based on image metadata
|
|
54
38
|
*
|
|
@@ -23,4 +23,8 @@ export declare const cronjobFlagDefinitions: {
|
|
|
23
23
|
multiple: false;
|
|
24
24
|
requiredOrDefaulted: false;
|
|
25
25
|
}>;
|
|
26
|
+
timezone: import("@oclif/core/interfaces").FlagDefinition<string, import("@oclif/core/interfaces").CustomOptions, {
|
|
27
|
+
multiple: false;
|
|
28
|
+
requiredOrDefaulted: false;
|
|
29
|
+
}>;
|
|
26
30
|
};
|
|
@@ -25,4 +25,8 @@ export const cronjobFlagDefinitions = {
|
|
|
25
25
|
summary: "Set the interpreter to be used for execution.",
|
|
26
26
|
description: "Must be either 'bash' or 'php'. Define the interpreter to be used to execute the previously defined command. The interpreter should match the corresponding command or script.",
|
|
27
27
|
}),
|
|
28
|
+
timezone: Flags.custom({
|
|
29
|
+
summary: "Set the timezone for the cron job.",
|
|
30
|
+
description: "Specify the timezone in which the cron job should be executed. Use standard timezone identifiers (e.g., 'Europe/Berlin', 'America/New_York'). Defaults to UTC if not specified.",
|
|
31
|
+
}),
|
|
28
32
|
};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type MittwaldAPIV2 } from "@mittwald/api-client";
|
|
2
|
+
import { RawStackInput } from "./types.js";
|
|
2
3
|
type StackRequest = MittwaldAPIV2.Paths.V2StacksStackId.Put.Parameters.RequestBody;
|
|
3
|
-
export declare function enrichStackDefinition(input:
|
|
4
|
+
export declare function enrichStackDefinition(input: RawStackInput): Promise<StackRequest>;
|
|
4
5
|
export {};
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import { readFile } from "fs/promises";
|
|
2
|
-
import {
|
|
2
|
+
import { parseEnvironmentVariablesFromArray, parseEnvironmentVariablesFromStr, } from "../../util/parser.js";
|
|
3
3
|
export async function enrichStackDefinition(input) {
|
|
4
4
|
const enriched = structuredClone(input);
|
|
5
5
|
for (const serviceName of Object.keys(input.services ?? {})) {
|
|
6
6
|
let service = enriched.services[serviceName];
|
|
7
|
+
// resolve array into object before enriching
|
|
8
|
+
if (service.envs && Array.isArray(service.envs)) {
|
|
9
|
+
service.envs = parseEnvironmentVariablesFromArray(service.envs);
|
|
10
|
+
}
|
|
7
11
|
service = await setEnvironmentFromEnvFile(service);
|
|
8
12
|
enriched.services[serviceName] = service;
|
|
9
13
|
}
|
|
@@ -20,7 +24,7 @@ async function setEnvironmentFromEnvFile(service) {
|
|
|
20
24
|
let envVars = {};
|
|
21
25
|
for (const envFile of envFiles) {
|
|
22
26
|
const envFileContent = await readFile(envFile, "utf-8");
|
|
23
|
-
const fileEnvVars =
|
|
27
|
+
const fileEnvVars = parseEnvironmentVariablesFromStr(envFileContent);
|
|
24
28
|
envVars = { ...envVars, ...fileEnvVars };
|
|
25
29
|
}
|
|
26
30
|
delete enriched.env_file;
|
|
@@ -4,10 +4,36 @@ jest.unstable_mockModule("fs/promises", () => ({
|
|
|
4
4
|
readFile: mockReadFile,
|
|
5
5
|
}));
|
|
6
6
|
const { enrichStackDefinition } = await import("./enrich.js");
|
|
7
|
+
const { loadStackFromStr } = await import("./loader.js");
|
|
8
|
+
const { sanitizeStackDefinition } = await import("./sanitize.js");
|
|
7
9
|
describe("enrichStackDefinition", () => {
|
|
8
10
|
beforeEach(() => {
|
|
9
11
|
jest.clearAllMocks();
|
|
10
12
|
});
|
|
13
|
+
it("should resolve environment lists to objects", async () => {
|
|
14
|
+
const input = {
|
|
15
|
+
services: {
|
|
16
|
+
webapp: {
|
|
17
|
+
image: "postgres:latest",
|
|
18
|
+
envs: ["something=little", 'bit="of love"'],
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
const expected = {
|
|
23
|
+
services: {
|
|
24
|
+
webapp: {
|
|
25
|
+
image: "postgres:latest",
|
|
26
|
+
envs: {
|
|
27
|
+
something: "little",
|
|
28
|
+
bit: "of love",
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
const result = await enrichStackDefinition(input);
|
|
34
|
+
expect(result).toEqual(expected);
|
|
35
|
+
expect(mockReadFile).not.toHaveBeenCalled();
|
|
36
|
+
});
|
|
11
37
|
it("should handle service without env_file", async () => {
|
|
12
38
|
const input = {
|
|
13
39
|
services: {
|
|
@@ -165,3 +191,161 @@ describe("enrichStackDefinition", () => {
|
|
|
165
191
|
});
|
|
166
192
|
});
|
|
167
193
|
});
|
|
194
|
+
describe("Integration Test: Docker Compose to Mittwald API Request transformation", () => {
|
|
195
|
+
beforeEach(() => {
|
|
196
|
+
jest.clearAllMocks();
|
|
197
|
+
});
|
|
198
|
+
it("should transform Docker Compose file, environment variables, and CLI args into a valid API request", async () => {
|
|
199
|
+
// Sample Docker Compose file content
|
|
200
|
+
const dockerComposeFile = `
|
|
201
|
+
version: '3'
|
|
202
|
+
services:
|
|
203
|
+
nginx:
|
|
204
|
+
image: 'nginx:latest'
|
|
205
|
+
ports:
|
|
206
|
+
- "80:80"
|
|
207
|
+
environment:
|
|
208
|
+
APP_ENV: "development"
|
|
209
|
+
API_URL: "\${CUSTOM_API_URL:-https://default.api}"
|
|
210
|
+
env_file:
|
|
211
|
+
- ".env"
|
|
212
|
+
- ".env.local"
|
|
213
|
+
`;
|
|
214
|
+
// Mocking environment variables (process.env)
|
|
215
|
+
const cliEnv = {
|
|
216
|
+
CUSTOM_API_URL: "https://cli.api",
|
|
217
|
+
};
|
|
218
|
+
// Mocking .env files using fs/promises
|
|
219
|
+
mockReadFile
|
|
220
|
+
.mockResolvedValueOnce("DB_HOST=db.local\nDB_PORT=5432") // Content of .env
|
|
221
|
+
.mockResolvedValueOnce("DB_HOST=db.override.local\nDB_USER=user123"); // Content of .env.local
|
|
222
|
+
// Step 1: Load and parse Docker Compose file
|
|
223
|
+
const parsedStack = await loadStackFromStr(dockerComposeFile, cliEnv);
|
|
224
|
+
// Step 2: Sanitize stack definition ( compat layer )
|
|
225
|
+
const sanitizedStack = sanitizeStackDefinition(parsedStack);
|
|
226
|
+
// Step 3: Enrich stack definition (process environment variables)
|
|
227
|
+
const enrichedStack = await enrichStackDefinition(sanitizedStack);
|
|
228
|
+
// Expected API request format after the transformation
|
|
229
|
+
const expectedApiRequest = {
|
|
230
|
+
services: {
|
|
231
|
+
nginx: {
|
|
232
|
+
image: "nginx:latest",
|
|
233
|
+
ports: ["80:80"],
|
|
234
|
+
envs: {
|
|
235
|
+
APP_ENV: "development", // From compose file
|
|
236
|
+
API_URL: "https://cli.api", // Default value overridden by CLI env
|
|
237
|
+
DB_HOST: "db.override.local", // Overridden by .env.local
|
|
238
|
+
DB_PORT: "5432", // Value from first .env file
|
|
239
|
+
DB_USER: "user123", // Value from .env.local
|
|
240
|
+
},
|
|
241
|
+
},
|
|
242
|
+
},
|
|
243
|
+
};
|
|
244
|
+
// Assertions
|
|
245
|
+
expect(enrichedStack).toMatchObject(expectedApiRequest);
|
|
246
|
+
// Type Assertions
|
|
247
|
+
const typeCheck = enrichedStack;
|
|
248
|
+
expect(typeCheck).toBeTruthy();
|
|
249
|
+
// Ensure env files are read in order
|
|
250
|
+
expect(mockReadFile).toHaveBeenCalledWith(".env", "utf-8");
|
|
251
|
+
expect(mockReadFile).toHaveBeenCalledWith(".env.local", "utf-8");
|
|
252
|
+
});
|
|
253
|
+
it("should transform Docker Compose file with list environment, environment variables, and CLI args into a valid API request", async () => {
|
|
254
|
+
// Sample Docker Compose file content
|
|
255
|
+
const dockerComposeFile = `
|
|
256
|
+
version: '3'
|
|
257
|
+
services:
|
|
258
|
+
nginx:
|
|
259
|
+
image: 'nginx:latest'
|
|
260
|
+
ports:
|
|
261
|
+
- "80:80"
|
|
262
|
+
environment:
|
|
263
|
+
- APP_ENV=development
|
|
264
|
+
- API_URL=\${CUSTOM_API_URL:-https://default.api}
|
|
265
|
+
env_file:
|
|
266
|
+
- ".env"
|
|
267
|
+
- ".env.local"
|
|
268
|
+
`;
|
|
269
|
+
// Mocking environment variables (process.env)
|
|
270
|
+
const cliEnv = {
|
|
271
|
+
CUSTOM_API_URL: "https://cli.api",
|
|
272
|
+
};
|
|
273
|
+
// Mocking .env files using fs/promises
|
|
274
|
+
mockReadFile
|
|
275
|
+
.mockResolvedValueOnce("DB_HOST=db.local\nDB_PORT=5432") // Content of .env
|
|
276
|
+
.mockResolvedValueOnce("DB_HOST=db.override.local\nDB_USER=user123"); // Content of .env.local
|
|
277
|
+
// Step 1: Load and parse Docker Compose file
|
|
278
|
+
const parsedStack = await loadStackFromStr(dockerComposeFile, cliEnv);
|
|
279
|
+
// Step 2: Sanitize stack definition ( compat layer )
|
|
280
|
+
const sanitizedStack = sanitizeStackDefinition(parsedStack);
|
|
281
|
+
// Step 3: Enrich stack definition (process environment variables)
|
|
282
|
+
const enrichedStack = await enrichStackDefinition(sanitizedStack);
|
|
283
|
+
// Expected API request format after the transformation
|
|
284
|
+
const expectedApiRequest = {
|
|
285
|
+
services: {
|
|
286
|
+
nginx: {
|
|
287
|
+
image: "nginx:latest",
|
|
288
|
+
ports: ["80:80"],
|
|
289
|
+
envs: {
|
|
290
|
+
APP_ENV: "development", // From compose file
|
|
291
|
+
API_URL: "https://cli.api", // Default value overridden by CLI env
|
|
292
|
+
DB_HOST: "db.override.local", // Overridden by .env.local
|
|
293
|
+
DB_PORT: "5432", // Value from first .env file
|
|
294
|
+
DB_USER: "user123", // Value from .env.local
|
|
295
|
+
},
|
|
296
|
+
},
|
|
297
|
+
},
|
|
298
|
+
};
|
|
299
|
+
// Assertions
|
|
300
|
+
expect(enrichedStack).toMatchObject(expectedApiRequest);
|
|
301
|
+
// Type Assertions
|
|
302
|
+
const typeCheck = enrichedStack;
|
|
303
|
+
expect(typeCheck).toBeTruthy();
|
|
304
|
+
// Ensure env files are read in order
|
|
305
|
+
expect(mockReadFile).toHaveBeenCalledWith(".env", "utf-8");
|
|
306
|
+
expect(mockReadFile).toHaveBeenCalledWith(".env.local", "utf-8");
|
|
307
|
+
});
|
|
308
|
+
it("Issue # 1589: Invalid JSON from env file", async () => {
|
|
309
|
+
// Sample Docker Compose file content
|
|
310
|
+
const dockerComposeFile = `
|
|
311
|
+
version: '3'
|
|
312
|
+
services:
|
|
313
|
+
nginx:
|
|
314
|
+
image: 'nginx:latest'
|
|
315
|
+
ports:
|
|
316
|
+
- "80:80"
|
|
317
|
+
environment:
|
|
318
|
+
EXCLUDES: \${CUSTOM_EXCLUDES:-failed}
|
|
319
|
+
`;
|
|
320
|
+
// simulate env file input, mimes "collectEnvironment" output
|
|
321
|
+
const env = {
|
|
322
|
+
CUSTOM_EXCLUDES: JSON.stringify(["foo", "bar"]),
|
|
323
|
+
};
|
|
324
|
+
// Step 1: Load and parse Docker Compose file
|
|
325
|
+
// stuff from --env-file and/or process environment
|
|
326
|
+
// is already injected here!
|
|
327
|
+
const parsedStack = await loadStackFromStr(dockerComposeFile, env);
|
|
328
|
+
// Step 2: Sanitize stack definition ( compat layer )
|
|
329
|
+
const sanitizedStack = sanitizeStackDefinition(parsedStack);
|
|
330
|
+
// Step 3: Enrich stack definition (process environment variables)
|
|
331
|
+
// this one deals with additional env files *per service*
|
|
332
|
+
const enrichedStack = await enrichStackDefinition(sanitizedStack);
|
|
333
|
+
// Expected API request format after the transformation
|
|
334
|
+
const expectedApiRequest = {
|
|
335
|
+
services: {
|
|
336
|
+
nginx: {
|
|
337
|
+
image: "nginx:latest",
|
|
338
|
+
ports: ["80:80"],
|
|
339
|
+
envs: {
|
|
340
|
+
EXCLUDES: JSON.stringify(["foo", "bar"]),
|
|
341
|
+
},
|
|
342
|
+
},
|
|
343
|
+
},
|
|
344
|
+
};
|
|
345
|
+
// Assertions
|
|
346
|
+
expect(enrichedStack).toMatchObject(expectedApiRequest);
|
|
347
|
+
// Type Assertions
|
|
348
|
+
const typeCheck = enrichedStack;
|
|
349
|
+
expect(typeCheck).toBeTruthy();
|
|
350
|
+
});
|
|
351
|
+
});
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import * as fs from "fs/promises";
|
|
2
|
-
import { parse } from "envfile";
|
|
3
2
|
import { pathExists } from "../../util/fs/pathExists.js";
|
|
4
3
|
import { getRandomValues } from "node:crypto";
|
|
4
|
+
import { parseEnvironmentVariablesFromStr } from "../../util/parser.js";
|
|
5
5
|
export async function collectEnvironment(base, envFile) {
|
|
6
6
|
if (!(await pathExists(envFile))) {
|
|
7
7
|
return base;
|
|
8
8
|
}
|
|
9
9
|
const defs = await fs.readFile(envFile, { encoding: "utf-8" });
|
|
10
|
-
const parsed =
|
|
10
|
+
const parsed = parseEnvironmentVariablesFromStr(defs);
|
|
11
11
|
return { ...base, ...parsed };
|
|
12
12
|
}
|
|
13
13
|
/**
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, jest } from "@jest/globals";
|
|
2
|
+
import { collectEnvironment } from "./env.js";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
describe("collectEnvironment", () => {
|
|
5
|
+
beforeEach(() => {
|
|
6
|
+
jest.clearAllMocks();
|
|
7
|
+
});
|
|
8
|
+
it("should preserve JSON array strings from env file", async () => {
|
|
9
|
+
// Simulate base environment variables
|
|
10
|
+
const baseEnv = {
|
|
11
|
+
NODE_ENV: "production",
|
|
12
|
+
};
|
|
13
|
+
// Mock .env file path and its content
|
|
14
|
+
const envFilePath = path.join(import.meta.dirname, "test_assets", "test.env");
|
|
15
|
+
// Call `collectEnvironment` with the mocks in effect
|
|
16
|
+
const result = await collectEnvironment(baseEnv, envFilePath);
|
|
17
|
+
// Assertions
|
|
18
|
+
expect(result).toEqual({
|
|
19
|
+
NODE_ENV: "production",
|
|
20
|
+
NODES_EXCLUDE: '["node1","node2","node3"]', // JSON array must remain a string
|
|
21
|
+
MY_ENV: "8.8.8.8",
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
});
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
export declare function
|
|
4
|
-
export declare function loadStackFromStr(input: string, environment: Record<string, string | undefined>): Promise<StackRequest>;
|
|
5
|
-
export {};
|
|
1
|
+
import { RawStackInput } from "./types.js";
|
|
2
|
+
export declare function loadStackFromFile(file: string, environment: Record<string, string | undefined>): Promise<RawStackInput>;
|
|
3
|
+
export declare function loadStackFromStr(input: string, environment: Record<string, string | undefined>): Promise<RawStackInput>;
|
|
@@ -1,10 +1,8 @@
|
|
|
1
|
-
import
|
|
2
|
-
type StackRequest = MittwaldAPIV2.Paths.V2StacksStackId.Put.Parameters.RequestBody;
|
|
1
|
+
import { RawStackInput } from "./types.js";
|
|
3
2
|
/**
|
|
4
3
|
* This function is needed to work around the mStudios supposedly docker-compose
|
|
5
4
|
* compatible container stack API.
|
|
6
5
|
*
|
|
7
6
|
* @param stack
|
|
8
7
|
*/
|
|
9
|
-
export declare function sanitizeStackDefinition(stack:
|
|
10
|
-
export {};
|
|
8
|
+
export declare function sanitizeStackDefinition(stack: RawStackInput): RawStackInput;
|
|
@@ -9,4 +9,26 @@ export type ContainerServiceInput = ContainerServiceDeclareRequest & {
|
|
|
9
9
|
};
|
|
10
10
|
ports?: string[];
|
|
11
11
|
};
|
|
12
|
+
export type RawStackInput = {
|
|
13
|
+
services?: {
|
|
14
|
+
[serviceName: string]: {
|
|
15
|
+
environment?: string[] | {
|
|
16
|
+
[k: string]: string;
|
|
17
|
+
};
|
|
18
|
+
env_file?: string | string[];
|
|
19
|
+
image: string;
|
|
20
|
+
ports?: string[];
|
|
21
|
+
command?: string[];
|
|
22
|
+
description?: string;
|
|
23
|
+
envs?: string[] | {
|
|
24
|
+
[k: string]: string;
|
|
25
|
+
};
|
|
26
|
+
};
|
|
27
|
+
};
|
|
28
|
+
volumes?: {
|
|
29
|
+
[key: string]: {
|
|
30
|
+
name: string;
|
|
31
|
+
};
|
|
32
|
+
};
|
|
33
|
+
};
|
|
12
34
|
export {};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parses environment variables from array and strips unnecessary quotes from
|
|
3
|
+
* values.
|
|
4
|
+
*
|
|
5
|
+
* @param envFlags Array of environment variable strings in KEY=VALUE format
|
|
6
|
+
* @returns An object containing environment variable key-value pairs
|
|
7
|
+
*/
|
|
8
|
+
export declare function parseEnvironmentVariablesFromArray(envFlags?: string[]): Record<string, string>;
|
|
9
|
+
/**
|
|
10
|
+
* Parses environment variables from string. Called with .env file content.
|
|
11
|
+
*
|
|
12
|
+
* @param src String describing environment, .env file notation
|
|
13
|
+
* @returns An object containing environment variable key-value pairs
|
|
14
|
+
*/
|
|
15
|
+
export declare function parseEnvironmentVariablesFromStr(src: string): Record<string, string>;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parses environment variables from array and strips unnecessary quotes from
|
|
3
|
+
* values.
|
|
4
|
+
*
|
|
5
|
+
* @param envFlags Array of environment variable strings in KEY=VALUE format
|
|
6
|
+
* @returns An object containing environment variable key-value pairs
|
|
7
|
+
*/
|
|
8
|
+
export function parseEnvironmentVariablesFromArray(envFlags = []) {
|
|
9
|
+
const splitIntoKeyAndValue = (e) => {
|
|
10
|
+
const index = e.indexOf("=");
|
|
11
|
+
if (index < 0) {
|
|
12
|
+
throw new Error(`Invalid environment variable format: ${e}`);
|
|
13
|
+
}
|
|
14
|
+
const key = e.slice(0, index);
|
|
15
|
+
const rawValue = e.slice(index + 1);
|
|
16
|
+
// Remove enclosing quotes (if they exist)
|
|
17
|
+
const isQuoted = rawValue.startsWith('"') && rawValue.endsWith('"');
|
|
18
|
+
const value = isQuoted ? rawValue.slice(1, -1) : rawValue;
|
|
19
|
+
return [key, value];
|
|
20
|
+
};
|
|
21
|
+
return Object.fromEntries(envFlags.map(splitIntoKeyAndValue));
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Parses environment variables from string. Called with .env file content.
|
|
25
|
+
*
|
|
26
|
+
* @param src String describing environment, .env file notation
|
|
27
|
+
* @returns An object containing environment variable key-value pairs
|
|
28
|
+
*/
|
|
29
|
+
export function parseEnvironmentVariablesFromStr(src) {
|
|
30
|
+
const result = {};
|
|
31
|
+
const lines = src.toString().split("\n");
|
|
32
|
+
for (const line of lines) {
|
|
33
|
+
const match = line.match(/^([^=:#]+?)[=:](.*)/);
|
|
34
|
+
if (match) {
|
|
35
|
+
const key = match[1].trim();
|
|
36
|
+
const value = match[2].trim();
|
|
37
|
+
result[key] = value;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return result;
|
|
41
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { describe, expect, test } from "@jest/globals";
|
|
2
|
+
import { parseEnvironmentVariablesFromArray, parseEnvironmentVariablesFromStr, } from "./parser.js";
|
|
3
|
+
describe("Containerconfig handling", () => {
|
|
4
|
+
describe("Config parsing", () => {
|
|
5
|
+
test("call parser with simple flags", () => {
|
|
6
|
+
const args = ["foo=bar", "ham=eggs"];
|
|
7
|
+
const res = parseEnvironmentVariablesFromArray(args);
|
|
8
|
+
expect(res).toStrictEqual({
|
|
9
|
+
foo: "bar",
|
|
10
|
+
ham: "eggs",
|
|
11
|
+
});
|
|
12
|
+
});
|
|
13
|
+
test("call parser with flag containing another '='", () => {
|
|
14
|
+
const args = ["extra_args=first=1 second=2 third=littlebitoflove"];
|
|
15
|
+
const res = parseEnvironmentVariablesFromArray(args);
|
|
16
|
+
expect(res).toStrictEqual({
|
|
17
|
+
extra_args: "first=1 second=2 third=littlebitoflove",
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
test("throw error for invalid flag format", () => {
|
|
21
|
+
const args = ["invalidFlagWithoutEqualsSign"];
|
|
22
|
+
expect(() => parseEnvironmentVariablesFromArray(args)).toThrow("Invalid environment variable format: invalidFlagWithoutEqualsSign");
|
|
23
|
+
});
|
|
24
|
+
test("remove enclosing quotes from values", () => {
|
|
25
|
+
const args = ['foo="bar"', 'ham="eggs and bacon"'];
|
|
26
|
+
const res = parseEnvironmentVariablesFromArray(args);
|
|
27
|
+
expect(res).toStrictEqual({
|
|
28
|
+
foo: "bar",
|
|
29
|
+
ham: "eggs and bacon",
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
test("preserve values with quotes in the middle", () => {
|
|
33
|
+
const args = ['foo=bar"baz', 'ham=eggs"and"bacon'];
|
|
34
|
+
const res = parseEnvironmentVariablesFromArray(args);
|
|
35
|
+
expect(res).toStrictEqual({
|
|
36
|
+
foo: 'bar"baz',
|
|
37
|
+
ham: 'eggs"and"bacon',
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
describe("parseEnvironmentVariablesFromStr", () => {
|
|
42
|
+
test("parse simple .env format", () => {
|
|
43
|
+
const src = "foo=bar\nham=eggs";
|
|
44
|
+
const res = parseEnvironmentVariablesFromStr(src);
|
|
45
|
+
expect(res).toStrictEqual({
|
|
46
|
+
foo: "bar",
|
|
47
|
+
ham: "eggs",
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
test("parse .env format with colon separator", () => {
|
|
51
|
+
const src = "foo:bar\nham:eggs";
|
|
52
|
+
const res = parseEnvironmentVariablesFromStr(src);
|
|
53
|
+
expect(res).toStrictEqual({
|
|
54
|
+
foo: "bar",
|
|
55
|
+
ham: "eggs",
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
test("parse .env format with empty lines", () => {
|
|
59
|
+
const src = "foo=bar\n\nham=eggs\n";
|
|
60
|
+
const res = parseEnvironmentVariablesFromStr(src);
|
|
61
|
+
expect(res).toStrictEqual({
|
|
62
|
+
foo: "bar",
|
|
63
|
+
ham: "eggs",
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
test("parse .env format with comments", () => {
|
|
67
|
+
const src = "foo=bar\n# comment line\nham=eggs";
|
|
68
|
+
const res = parseEnvironmentVariablesFromStr(src);
|
|
69
|
+
expect(res).toStrictEqual({
|
|
70
|
+
foo: "bar",
|
|
71
|
+
ham: "eggs",
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
test("parse .env format with values containing equals signs", () => {
|
|
75
|
+
const src = "extra_args=first=1 second=2 third=littlebitoflove";
|
|
76
|
+
const res = parseEnvironmentVariablesFromStr(src);
|
|
77
|
+
expect(res).toStrictEqual({
|
|
78
|
+
extra_args: "first=1 second=2 third=littlebitoflove",
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
test("trim whitespace from keys and values", () => {
|
|
82
|
+
const src = " foo = bar \n ham : eggs ";
|
|
83
|
+
const res = parseEnvironmentVariablesFromStr(src);
|
|
84
|
+
expect(res).toStrictEqual({
|
|
85
|
+
foo: "bar",
|
|
86
|
+
ham: "eggs",
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
test("handle empty string input", () => {
|
|
90
|
+
const src = "";
|
|
91
|
+
const res = parseEnvironmentVariablesFromStr(src);
|
|
92
|
+
expect(res).toStrictEqual({});
|
|
93
|
+
});
|
|
94
|
+
test("ignore lines without key-value pairs", () => {
|
|
95
|
+
const src = "foo=bar\ninvalid line\nham=eggs";
|
|
96
|
+
const res = parseEnvironmentVariablesFromStr(src);
|
|
97
|
+
expect(res).toStrictEqual({
|
|
98
|
+
foo: "bar",
|
|
99
|
+
ham: "eggs",
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -34,6 +34,7 @@ export const CronJobDetails = ({ cronjob }) => {
|
|
|
34
34
|
Project: project ? _jsx(IDAndShortID, { object: project }) : _jsx(Value, { notSet: true }),
|
|
35
35
|
App: _jsx(IDAndShortID, { object: app }),
|
|
36
36
|
Schedule: (_jsxs(Text, { children: [_jsx(Value, { children: cronjob.interval }), " (next execution:", " ", _jsx(CronJobNextExecution, { cronjob: cronjob }), ")"] })),
|
|
37
|
+
Timezone: _jsx(Value, { children: cronjob.timeZone || "UTC" }),
|
|
37
38
|
};
|
|
38
39
|
const sections = [
|
|
39
40
|
_jsx(SingleResult, { title: _jsxs(_Fragment, { children: ["CRON JOB DETAILS: ", _jsx(Value, { children: cronjob.description })] }), rows: rows }, "primary"),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mittwald/cli",
|
|
3
|
-
"version": "1.13.
|
|
3
|
+
"version": "1.13.3",
|
|
4
4
|
"description": "Hand-crafted CLI for the mittwald API",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -57,7 +57,6 @@
|
|
|
57
57
|
"chalk": "^5.3.0",
|
|
58
58
|
"date-fns": "^4.0.0",
|
|
59
59
|
"docker-names": "^1.2.1",
|
|
60
|
-
"envfile": "^7.1.0",
|
|
61
60
|
"fast-xml-parser": "^5.2.5",
|
|
62
61
|
"ink": "^6.2.3",
|
|
63
62
|
"ink-link": "^5.0.0",
|
|
@@ -72,7 +71,7 @@
|
|
|
72
71
|
"semver": "^7.5.4",
|
|
73
72
|
"semver-parser": "^4.1.6",
|
|
74
73
|
"shell-escape": "^0.2.0",
|
|
75
|
-
"slice-ansi": "^
|
|
74
|
+
"slice-ansi": "^8.0.0",
|
|
76
75
|
"string-width": "^8.0.0",
|
|
77
76
|
"tempfile": "^6.0.1",
|
|
78
77
|
"uuid": "^13.0.0"
|
|
@@ -97,11 +96,11 @@
|
|
|
97
96
|
"license-checker-rseidelsohn": "^4.2.6",
|
|
98
97
|
"nock": "^14.0.0",
|
|
99
98
|
"oclif": "^4.14.31",
|
|
100
|
-
"prettier": "~3.
|
|
99
|
+
"prettier": "~3.8.1",
|
|
101
100
|
"prettier-plugin-jsdoc": "^1.3.2",
|
|
102
101
|
"prettier-plugin-package": "^2.0.0",
|
|
103
102
|
"prettier-plugin-sort-json": "^4.1.1",
|
|
104
|
-
"rimraf": "^
|
|
103
|
+
"rimraf": "^6.1.3",
|
|
105
104
|
"ts-jest": "^29.2.5",
|
|
106
105
|
"ts-node": "^10.9.2",
|
|
107
106
|
"tsx": "^4.7.1",
|
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from "@jest/globals";
|
|
2
|
-
import { parseEnvironmentVariablesFromEnvFlags } from "./containerconfig.js";
|
|
3
|
-
describe("Containerconfig handling", () => {
|
|
4
|
-
describe("Config parsing", () => {
|
|
5
|
-
test("call parser with simple flags", () => {
|
|
6
|
-
const args = ["foo=bar", "ham=eggs"];
|
|
7
|
-
const res = parseEnvironmentVariablesFromEnvFlags(args);
|
|
8
|
-
expect(res).toStrictEqual({
|
|
9
|
-
foo: "bar",
|
|
10
|
-
ham: "eggs",
|
|
11
|
-
});
|
|
12
|
-
});
|
|
13
|
-
test("call parser with flag containing another '='", () => {
|
|
14
|
-
const args = ["extra_args=first=1 second=2 third=littlebitoflove"];
|
|
15
|
-
const res = parseEnvironmentVariablesFromEnvFlags(args);
|
|
16
|
-
expect(res).toStrictEqual({
|
|
17
|
-
extra_args: "first=1 second=2 third=littlebitoflove",
|
|
18
|
-
});
|
|
19
|
-
});
|
|
20
|
-
test("throw error for invalid flag format", () => {
|
|
21
|
-
const args = ["invalidFlagWithoutEqualsSign"];
|
|
22
|
-
expect(() => parseEnvironmentVariablesFromEnvFlags(args)).toThrow("Invalid environment variable format: invalidFlagWithoutEqualsSign");
|
|
23
|
-
});
|
|
24
|
-
});
|
|
25
|
-
});
|
|
File without changes
|