Skip to content

Commit 084dc8c

Browse files
authored
Merge pull request #12 from getlarge/fix-table-parsing-failure
fix: Heroku CLI table parsing failure
2 parents 6c4ec3c + ef13d8c commit 084dc8c

File tree

9 files changed

+177
-24
lines changed

9 files changed

+177
-24
lines changed
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { parseConfigVarsTable } from './config-vars';
2+
3+
const configVarsTable = [
4+
'CLUSTER_ENABLED: true',
5+
'DISK_STORAGE_TRESHOLD: 161061273600',
6+
'EVENT_LOOP_DELAY_THRESHOLD: 100',
7+
'HOSTNAME: 0.0.0.0',
8+
'LOG_CONCURRENCY: true',
9+
'MAX_PAYLOAD_SIZE: 1',
10+
'MEMORY_RSS_TRESHOLD:',
11+
];
12+
13+
const configVars = {
14+
CLUSTER_ENABLED: 'true',
15+
DISK_STORAGE_TRESHOLD: '161061273600',
16+
EVENT_LOOP_DELAY_THRESHOLD: '100',
17+
HOSTNAME: '0.0.0.0',
18+
LOG_CONCURRENCY: 'true',
19+
MAX_PAYLOAD_SIZE: '1',
20+
MEMORY_RSS_TRESHOLD: '',
21+
};
22+
23+
describe('ConfigVars', () => {
24+
it('parseConfigVarsTable - should return a valid Variables object from a table', () => {
25+
const result = parseConfigVarsTable(configVarsTable);
26+
expect(result).toEqual(configVars);
27+
});
28+
});

packages/nx-heroku/src/executors/common/heroku/config-vars.ts

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,16 @@ export type SerializedConfigVar =
1010

1111
export type Variables = Record<string, string>;
1212

13+
export function parseConfigVarsTable(table: string[]): Variables {
14+
return table.reduce((acc, line) => {
15+
const parts = line.split(':');
16+
const key = parts.shift().trim();
17+
const value = parts.map((el) => el.trim()).join(':');
18+
key && (acc[key] = value);
19+
return acc;
20+
}, {});
21+
}
22+
1323
export async function getConfigVars(options: {
1424
appName: string;
1525
}): Promise<Record<string, string>> {
@@ -18,16 +28,10 @@ export async function getConfigVars(options: {
1828
encoding: 'utf-8',
1929
});
2030
const rawAppEnv = parseTable(configVars) || [];
21-
if (rawAppEnv.includes(`Invalid credentials provided`)) {
31+
if (rawAppEnv[0]?.includes(`Invalid credentials provided`)) {
2232
throw new Error('Invalid credentials provided');
2333
}
24-
return rawAppEnv.reduce((acc, line) => {
25-
const parts = line.split(':');
26-
const key = parts.shift().trim();
27-
const value = parts.map((el) => el.trim()).join(':');
28-
if (key && value) acc[key] = value;
29-
return acc;
30-
}, {});
34+
return parseConfigVarsTable(rawAppEnv);
3135
}
3236

3337
export function serializeConfigVar(
@@ -42,10 +46,9 @@ export function serializeConfigVars(
4246
variables: Variables,
4347
quote: ConfigVarQuote = `'`
4448
): SerializedConfigVar[] {
45-
return Object.entries(variables).reduce((acc, [key, value]) => {
46-
acc.push(serializeConfigVar(key, value, quote));
47-
return acc;
48-
}, []);
49+
return Object.entries(variables).map(([key, value]) =>
50+
serializeConfigVar(key, value, quote)
51+
);
4952
}
5053

5154
export async function setConfigVars(options: {

packages/nx-heroku/src/executors/common/heroku/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { exec } from '../utils';
22
import { AppName } from './apps';
33

44
export async function dynoCommand(options: {
5-
command: 'kill' | 'restart' | 'stop';
5+
command: 'kill' | 'restart' | 'stop' | 'scale';
66
appName: AppName;
77
}) {
88
const { appName, command } = options;
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { parseWebhooksTable, Webhook } from './webhooks';
2+
3+
const webhooksTable = [
4+
'1965e28c-b99f-4c14-a64c-582efc7667ae https://webhook-handler.io/receive api:build,dyno sync',
5+
'682df0e5-67f8-4a3b-a71d-78451470b997 https://webhook-handler.io/receive api:build,api:release,dyno sync',
6+
'5541c980-0835-4063-bc70-9a11d5b8a016 https://webhook-handler.io/receive api:build,api:release,dyno notify',
7+
'bffc659a-d802-4761-86d5-7f343c135eca https://webhook-handler.io/receive api:addon sync',
8+
'9caf5cfb-ceac-4512-b197-fab47b3629f2 https://webhook-handler.io/receive api:formation sync',
9+
];
10+
11+
const webhooks: Webhook[] = [
12+
{
13+
id: '1965e28c-b99f-4c14-a64c-582efc7667ae',
14+
url: 'https://webhook-handler.io/receive',
15+
include: 'api:build,dyno',
16+
level: 'sync',
17+
},
18+
{
19+
id: '682df0e5-67f8-4a3b-a71d-78451470b997',
20+
url: 'https://webhook-handler.io/receive',
21+
include: 'api:build,api:release,dyno',
22+
level: 'sync',
23+
},
24+
{
25+
id: '5541c980-0835-4063-bc70-9a11d5b8a016',
26+
url: 'https://webhook-handler.io/receive',
27+
include: 'api:build,api:release,dyno',
28+
level: 'notify',
29+
},
30+
{
31+
id: 'bffc659a-d802-4761-86d5-7f343c135eca',
32+
url: 'https://webhook-handler.io/receive',
33+
include: 'api:addon',
34+
level: 'sync',
35+
},
36+
{
37+
id: '9caf5cfb-ceac-4512-b197-fab47b3629f2',
38+
url: 'https://webhook-handler.io/receive',
39+
include: 'api:formation',
40+
level: 'sync',
41+
},
42+
];
43+
44+
describe('Webhooks', () => {
45+
it('parseWebhooksTable - should return a valid Webhook objects array from a table', () => {
46+
const result = parseWebhooksTable(webhooksTable);
47+
expect(result).toEqual(webhooks);
48+
});
49+
});

packages/nx-heroku/src/executors/common/heroku/webhooks.ts

Lines changed: 39 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,55 @@
11
import { logger } from '@nrwl/devkit';
22
import isURL from 'validator/lib/isURL';
3+
import isUUID from 'validator/lib/isUUID';
34

45
import { exec, parseTable } from '../utils';
56
import { HerokuError, shouldHandleHerokuError } from './error';
67

7-
export async function getWebhooks(
8-
appName: string
9-
): Promise<{ id: string; url: string; include: string; level: string }[]> {
8+
export type Webhook = {
9+
id: string;
10+
url: string;
11+
include: string;
12+
level: string;
13+
};
14+
15+
export function parseWebhooksTable(table: string[]): Webhook[] {
16+
/** key order matters */
17+
const validators = {
18+
id: isUUID,
19+
url: isURL,
20+
include: () => true,
21+
level: (x?: string) => !x || ['sync', 'notify'].includes(x),
22+
};
23+
return table
24+
.map((webhook) => {
25+
const parts = webhook.split(' ');
26+
const webhookIsValid =
27+
parts.length === 4 &&
28+
Object.entries(validators).every(([key, validator], i) => {
29+
if (validator(parts[i])) {
30+
return true;
31+
}
32+
logger.warn(`Invalid ${key} (${parts[i]}) for webhook ${webhook}`);
33+
return false;
34+
});
35+
if (!webhookIsValid) return null;
36+
const [id, url, include, level] = parts;
37+
return { id, url, include, level };
38+
})
39+
.filter(Boolean);
40+
}
41+
42+
export async function getWebhooks(appName: string): Promise<Webhook[]> {
1043
const { stdout, stderr } = await exec(`heroku webhooks --app ${appName}`, {
1144
encoding: 'utf-8',
1245
});
1346
if (shouldHandleHerokuError(stderr, stdout)) {
1447
logger.warn(HerokuError.cleanMessage(stderr));
1548
return [];
1649
}
17-
const res = parseTable(stdout);
18-
return res.map((webhook) => {
19-
const [id, url, include, level] = webhook.split(' ');
20-
return { id, url, include, level };
21-
});
50+
51+
const table = parseTable(stdout);
52+
return parseWebhooksTable(table);
2253
}
2354

2455
// eslint-disable-next-line max-lines-per-function

packages/nx-heroku/src/executors/common/utils.spec.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,15 @@ const configVarsRawOutput = `
4141
YARN2_SKIP_PRUNING: true
4242
`;
4343

44+
const webhooksRawOutput = `Webhook ID URL Include Level
45+
──────────────────────────────────── ───────────────────────────────────────────────────────────────────────────────────────── ────────────────────────── ─────
46+
1965e28c-b99f-4c14-a64c-582efc7667ae https://webhook-handler/receive api:build,dyno sync
47+
682df0e5-67f8-4a3b-a71d-78451470b997 https://webhook-handler/receive api:build,api:release,dyno sync
48+
5541c980-0835-4063-bc70-9a11d5b8a016 https://webhook-handler/receive api:build,api:release,dyno notify
49+
bffc659a-d802-4761-86d5-7f343c135eca https://webhook-handler/receive api:addon sync
50+
9caf5cfb-ceac-4512-b197-fab47b3629f2 https://webhook-handler/receive api:formation sync
51+
`;
52+
4453
const configVarsClean = `=== s1-auth-service-staging Config Vars
4554
CLUSTER_ENABLED: true
4655
DISK_STORAGE_TRESHOLD: 161061273600
@@ -60,6 +69,7 @@ TAG: v1.9.4
6069
USE_YARN_CACHE: undefined
6170
YARN2_SKIP_PRUNING: true`;
6271

72+
// eslint-disable-next-line max-lines-per-function
6373
describe('Utils', () => {
6474
it.todo('expandOptions - should expand options');
6575

@@ -94,5 +104,23 @@ describe('Utils', () => {
94104
const result = parseTable(configVarsRawOutput);
95105
expect(result).toBeDefined();
96106
expect(result).toHaveLength(17);
107+
expect(
108+
result
109+
.shift()
110+
.split(':')
111+
.map((el) => el.trim())
112+
).toEqual(['CLUSTER_ENABLED', 'true']);
113+
});
114+
115+
it('parseTable - should parse raw webhooks string', () => {
116+
const result = parseTable(webhooksRawOutput);
117+
expect(result).toBeDefined();
118+
expect(result).toHaveLength(5);
119+
expect(result.shift().split(' ')).toEqual([
120+
'1965e28c-b99f-4c14-a64c-582efc7667ae',
121+
'https://webhook-handler/receive',
122+
'api:build,dyno',
123+
'sync',
124+
]);
97125
});
98126
});

packages/nx-heroku/src/executors/common/utils.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,18 @@ export function parseJsonString(stdout: string) {
1616
}
1717

1818
export function parseTable(stdout: string) {
19-
const lines = removeConsoleOutputColors(stdout)?.split('\n');
20-
// remove header from response
19+
const lines = removeConsoleOutputColors(stdout)
20+
?.split('\n')
21+
?.map((line) => line?.trim());
22+
// remove header from response, column names
2123
lines.shift();
24+
// remove separator
25+
if (
26+
lines[0]?.trim()?.length === 0 ||
27+
lines[0]?.includes('──────────────────────────────')
28+
) {
29+
lines.shift();
30+
}
2231
return lines;
2332
}
2433

packages/nx-heroku/src/executors/deploy/executor.spec.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,10 @@ describe('Deploy Executor', () => {
131131
expect(herokuDeployService.close).toBeCalled();
132132
});
133133

134+
// TODO: test
135+
// validateOptions
136+
// setEnvironmentVariables
137+
// setupHeroku
134138
it('should instantiate the heroku app factory', async () => {
135139
options.config = ['development', 'production'];
136140
herokuDeployService['validateOptions'] = jest.fn();

packages/nx-heroku/src/executors/deploy/services/heroku-app.service.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -344,7 +344,7 @@ class HerokuApp {
344344
const push = spawn('git', args, { signal });
345345
push.stdout
346346
.setEncoding('utf-8')
347-
//? if data contains `Everything up-to-date`, should we still restart the app ?
347+
//? stop watch when data matches ^Total\s+(\d+)\s+\(delta\s+(\d+)\),\s+reused\s+(\d+)\s+\(delta\s+(\d+)\),\s+pack-reused\s+(\d+)
348348
.on('data', (data) => this.logger.info(data?.trim()));
349349

350350
push.stderr
@@ -375,6 +375,7 @@ class HerokuApp {
375375
// Wait for [watchDelay] seconds once the build started to ensure it works and kill child process
376376
// if watchDelay === 0, the process will not be aborted
377377
await new Promise<void>((resolve, reject) => {
378+
// TODO: const signal = AbortSignal.timeout(watchDelay);
378379
const controller = new AbortController();
379380
const { signal } = controller;
380381
const push = this.createDeployProcess(signal, useHttps);

0 commit comments

Comments
 (0)