@faable/faable 1.35.0 → 1.36.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/dist/commands/deploy/secrets/list.js +19 -2
- package/dist/commands/deploy/secrets/managed_names.js +68 -0
- package/dist/commands/deploy/secrets/parse_env.js +101 -0
- package/dist/commands/deploy/secrets/parse_pairs.js +20 -10
- package/dist/commands/deploy/secrets/set.js +81 -10
- package/package.json +1 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { requireApi } from '../../../api/context.js';
|
|
2
2
|
import { log } from '../../../log.js';
|
|
3
3
|
import { resolve_app_id } from '../resolve_app_id.js';
|
|
4
|
+
import { managed_name, managed_warning } from './managed_names.js';
|
|
4
5
|
import { mask_value } from './mask.js';
|
|
5
6
|
|
|
6
7
|
const secrets_list = {
|
|
@@ -33,12 +34,28 @@ const secrets_list = {
|
|
|
33
34
|
const sorted = [...secrets].sort((a, b) => a.name.localeCompare(b.name));
|
|
34
35
|
for (const secret of sorted) {
|
|
35
36
|
const value = args.show ? secret.value : mask_value(secret.value);
|
|
36
|
-
const origin = secret.related_model === 'profile'
|
|
37
|
-
|
|
37
|
+
const origin = secret.related_model === 'profile'
|
|
38
|
+
? ' (inherited from team profile)'
|
|
39
|
+
: '';
|
|
40
|
+
// Flag names the platform manages: a stored PORT looks perfectly set
|
|
41
|
+
// here while being dropped at deploy time.
|
|
42
|
+
const managed = managed_name(secret.name);
|
|
43
|
+
const note = !managed
|
|
44
|
+
? ''
|
|
45
|
+
: managed.kind === 'reserved'
|
|
46
|
+
? ' ⚠️ reserved, ignored at deploy'
|
|
47
|
+
: ' ⚠️ overrides a platform default';
|
|
48
|
+
log.info(` ${secret.name.padEnd(width)} ${value}${origin}${note}`);
|
|
38
49
|
}
|
|
39
50
|
if (!args.show) {
|
|
40
51
|
log.info(`Use --show to reveal full values.`);
|
|
41
52
|
}
|
|
53
|
+
// The per-row marker says WHICH; this says why, once, with the docs link.
|
|
54
|
+
for (const secret of sorted) {
|
|
55
|
+
const warning = managed_warning(secret.name);
|
|
56
|
+
if (warning)
|
|
57
|
+
log.warn(`⚠️ ${warning}`);
|
|
58
|
+
}
|
|
42
59
|
}
|
|
43
60
|
};
|
|
44
61
|
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Environment variables the platform already provides.
|
|
3
|
+
*
|
|
4
|
+
* Two very different failure modes, so they are warned about differently:
|
|
5
|
+
*
|
|
6
|
+
* - `reserved`: the controller DROPS a user secret with this name before
|
|
7
|
+
* building the Deployment's env list (controller-deploy
|
|
8
|
+
* src/k8s/deployment.actions.ts, RESERVED). Setting it is a silent no-op —
|
|
9
|
+
* the platform value always wins — which is exactly the kind of thing
|
|
10
|
+
* people spend an afternoon debugging.
|
|
11
|
+
* - `runtime_default`: the runtime image already exports it
|
|
12
|
+
* (buildpacks/images/runtime-{node,python}/Dockerfile). A user value DOES
|
|
13
|
+
* win here, so it is legal — just usually redundant, and occasionally the
|
|
14
|
+
* reason a production build behaves like a dev one.
|
|
15
|
+
*
|
|
16
|
+
* Mirrored in the dashboard (dashboard components/apps/deploy/secrets/
|
|
17
|
+
* managed_names.ts) so both surfaces say the same thing. Keep in sync.
|
|
18
|
+
*/
|
|
19
|
+
const DOCS_BASE = 'https://faable.com/docs/deploy/environment';
|
|
20
|
+
const MANAGED_DOCS_URL = {
|
|
21
|
+
reserved: `${DOCS_BASE}#reserved-names`,
|
|
22
|
+
runtime_default: `${DOCS_BASE}#runtime-defaults`
|
|
23
|
+
};
|
|
24
|
+
const MANAGED_ENV_NAMES = {
|
|
25
|
+
PORT: {
|
|
26
|
+
kind: 'reserved',
|
|
27
|
+
detail: 'to 80 — the port your app must listen on'
|
|
28
|
+
},
|
|
29
|
+
FAABLE_HOST: { kind: 'reserved', detail: "to your app's public host" },
|
|
30
|
+
FAABLE_APP_ID: { kind: 'reserved', detail: 'to this app id' },
|
|
31
|
+
FAABLE_DEPLOY_ID: { kind: 'reserved', detail: "to each deployment's id" },
|
|
32
|
+
FAABLE_RELEASE: {
|
|
33
|
+
kind: 'reserved',
|
|
34
|
+
detail: 'from --release or your latest git tag'
|
|
35
|
+
},
|
|
36
|
+
FAABLE_GIT_COMMIT: {
|
|
37
|
+
kind: 'reserved',
|
|
38
|
+
detail: 'to the commit the deployment was built from'
|
|
39
|
+
},
|
|
40
|
+
FAABLE_GIT_REF: { kind: 'reserved', detail: 'to the deployed git ref' },
|
|
41
|
+
START_COMMAND: {
|
|
42
|
+
kind: 'reserved',
|
|
43
|
+
detail: "to the artifact's start command"
|
|
44
|
+
},
|
|
45
|
+
NODE_ENV: {
|
|
46
|
+
kind: 'runtime_default',
|
|
47
|
+
detail: 'to "production" on Node runtimes'
|
|
48
|
+
},
|
|
49
|
+
PYTHONUNBUFFERED: {
|
|
50
|
+
kind: 'runtime_default',
|
|
51
|
+
detail: 'to "1" on Python runtimes'
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
const managed_name = (name) => MANAGED_ENV_NAMES[name.trim()];
|
|
55
|
+
// One line, ready for a warning log. Undefined when the name is the user's
|
|
56
|
+
// own business.
|
|
57
|
+
const managed_warning = (name) => {
|
|
58
|
+
const managed = managed_name(name);
|
|
59
|
+
if (!managed)
|
|
60
|
+
return undefined;
|
|
61
|
+
const key = name.trim();
|
|
62
|
+
const url = MANAGED_DOCS_URL[managed.kind];
|
|
63
|
+
return managed.kind === 'reserved'
|
|
64
|
+
? `${key} is reserved: your value is ignored. Faable sets it ${managed.detail}. ${url}`
|
|
65
|
+
: `${key} is already set ${managed.detail}; override it only if you mean to. ${url}`;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export { MANAGED_DOCS_URL, MANAGED_ENV_NAMES, managed_name, managed_warning };
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { validate_pair } from './parse_pairs.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Parser for `.env` files, the format every runtime already agrees on
|
|
5
|
+
* (dotenv, docker, `source`). Deliberately a strict subset:
|
|
6
|
+
*
|
|
7
|
+
* - `#` comments and blank lines are skipped; an inline `#` only starts a
|
|
8
|
+
* comment when it follows whitespace in an UNQUOTED value.
|
|
9
|
+
* - a leading `export ` is tolerated, so a file meant for `source` works.
|
|
10
|
+
* - values may be single- or double-quoted; double quotes expand `\n`, `\r`,
|
|
11
|
+
* `\t`, `\\` and `\"`, single quotes are literal. Quoted values may span
|
|
12
|
+
* lines, which is how PEM keys survive.
|
|
13
|
+
* - a line that is not a comment and has no `=` is an ERROR, not a silently
|
|
14
|
+
* ignored line: a typo must not quietly drop a variable from the deploy.
|
|
15
|
+
*
|
|
16
|
+
* Repeated names keep the last occurrence, matching `source`.
|
|
17
|
+
*/
|
|
18
|
+
// Closing quote for `quote`, honouring backslash escapes inside double
|
|
19
|
+
// quotes. Single quotes have no escapes, so the first one closes.
|
|
20
|
+
const closing_quote = (text, quote) => {
|
|
21
|
+
if (quote === "'")
|
|
22
|
+
return text.indexOf(quote);
|
|
23
|
+
for (let i = 0; i < text.length; i++) {
|
|
24
|
+
if (text[i] === '\\') {
|
|
25
|
+
i++;
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
if (text[i] === quote)
|
|
29
|
+
return i;
|
|
30
|
+
}
|
|
31
|
+
return -1;
|
|
32
|
+
};
|
|
33
|
+
const ESCAPES = {
|
|
34
|
+
n: '\n',
|
|
35
|
+
r: '\r',
|
|
36
|
+
t: '\t',
|
|
37
|
+
'\\': '\\',
|
|
38
|
+
'"': '"',
|
|
39
|
+
"'": "'"
|
|
40
|
+
};
|
|
41
|
+
const unescape_double = (value) => value.replace(/\\(.)/g, (match, char) => ESCAPES[char] ?? match);
|
|
42
|
+
// `VALUE # comment` → `VALUE`. Requires the whitespace, so `pa#ss` (no space)
|
|
43
|
+
// stays whole; a value that really needs " #" has to be quoted.
|
|
44
|
+
const strip_inline_comment = (value) => value.replace(/\s+#.*$/, '');
|
|
45
|
+
const preview = (line) => line.length > 40 ? `${line.slice(0, 40)}…` : line;
|
|
46
|
+
const parse_env = (content, source = '.env') => {
|
|
47
|
+
// A BOM would otherwise become part of the first variable's name.
|
|
48
|
+
const lines = content.replace(/^\uFEFF/, '').split(/\r?\n/);
|
|
49
|
+
const pairs = new Map();
|
|
50
|
+
for (let i = 0; i < lines.length; i++) {
|
|
51
|
+
const line_no = i + 1;
|
|
52
|
+
const trimmed = lines[i].trim();
|
|
53
|
+
if (!trimmed || trimmed.startsWith('#'))
|
|
54
|
+
continue;
|
|
55
|
+
const declaration = trimmed.replace(/^export\s+/, '');
|
|
56
|
+
const eq = declaration.indexOf('=');
|
|
57
|
+
if (eq < 0) {
|
|
58
|
+
throw new Error(`${source}:${line_no}: expected KEY=VALUE, got "${preview(declaration)}".`);
|
|
59
|
+
}
|
|
60
|
+
const name = declaration.slice(0, eq).trim();
|
|
61
|
+
if (!name) {
|
|
62
|
+
throw new Error(`${source}:${line_no}: missing name before '='.`);
|
|
63
|
+
}
|
|
64
|
+
const raw = declaration.slice(eq + 1).replace(/^[ \t]*/, '');
|
|
65
|
+
const quote = raw[0] === '"' || raw[0] === "'" ? raw[0] : undefined;
|
|
66
|
+
let value;
|
|
67
|
+
if (quote) {
|
|
68
|
+
let body = raw.slice(1);
|
|
69
|
+
let end = closing_quote(body, quote);
|
|
70
|
+
// Unterminated on this line: keep pulling raw lines in until the quote
|
|
71
|
+
// closes (multi-line values), reporting the line the value STARTED on.
|
|
72
|
+
while (end < 0) {
|
|
73
|
+
i++;
|
|
74
|
+
if (i >= lines.length) {
|
|
75
|
+
throw new Error(`${source}:${line_no}: unterminated ${quote} quote for "${name}".`);
|
|
76
|
+
}
|
|
77
|
+
body += `\n${lines[i]}`;
|
|
78
|
+
end = closing_quote(body, quote);
|
|
79
|
+
}
|
|
80
|
+
const quoted = body.slice(0, end);
|
|
81
|
+
value = quote === '"' ? unescape_double(quoted) : quoted;
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
value = strip_inline_comment(raw).trim();
|
|
85
|
+
}
|
|
86
|
+
// Re-throw with the file position: "exceeds 255 characters" is useless
|
|
87
|
+
// when the file has eighty lines.
|
|
88
|
+
try {
|
|
89
|
+
const pair = validate_pair(name, value);
|
|
90
|
+
pairs.set(pair.name, pair.value);
|
|
91
|
+
}
|
|
92
|
+
catch (err) {
|
|
93
|
+
throw new Error(`${source}:${line_no}: ${err.message}`, {
|
|
94
|
+
cause: err
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return [...pairs.entries()].map(([name, value]) => ({ name, value }));
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
export { parse_env };
|
|
@@ -1,6 +1,24 @@
|
|
|
1
1
|
// API limits for a secret (mirrors the server-side schema).
|
|
2
2
|
const NAME_MAX = 255;
|
|
3
3
|
const VALUE_MAX = 50000;
|
|
4
|
+
// Mirror of the API's secret-name rule (api/src/deploy/secrets/SecretsManager.ts):
|
|
5
|
+
// printable ASCII (0x20-0x7E) except '=' (0x3D). Checked here so a bad name
|
|
6
|
+
// fails before the request instead of 400-ing the whole batch.
|
|
7
|
+
const PRINTABLE_ASCII_NO_EQUALS = /^[\x20-\x3C\x3E-\x7E]+$/;
|
|
8
|
+
// Shared by the KEY=VALUE arguments and the --env-file parser, so both reject
|
|
9
|
+
// the same names and sizes with the same wording.
|
|
10
|
+
const validate_pair = (name, value) => {
|
|
11
|
+
if (name.length > NAME_MAX) {
|
|
12
|
+
throw new Error(`Secret name "${name.slice(0, 32)}…" exceeds ${NAME_MAX} characters.`);
|
|
13
|
+
}
|
|
14
|
+
if (!PRINTABLE_ASCII_NO_EQUALS.test(name)) {
|
|
15
|
+
throw new Error(`Invalid secret name "${name}". Names must be printable ASCII and cannot contain '='.`);
|
|
16
|
+
}
|
|
17
|
+
if (value.length > VALUE_MAX) {
|
|
18
|
+
throw new Error(`Value for "${name}" exceeds ${VALUE_MAX} characters.`);
|
|
19
|
+
}
|
|
20
|
+
return { name, value };
|
|
21
|
+
};
|
|
4
22
|
// Split each "KEY=VALUE" on the FIRST '=' only, so values may contain '='.
|
|
5
23
|
// An empty value ("KEY=") is allowed — it is a legitimate way to blank a
|
|
6
24
|
// secret. Throws on the first invalid pair so callers can validate the whole
|
|
@@ -11,16 +29,8 @@ const parse_pairs = (inputs) => {
|
|
|
11
29
|
if (idx <= 0) {
|
|
12
30
|
throw new Error(`Invalid secret "${raw}". Expected KEY=VALUE (e.g. DATABASE_URL=postgres://...).`);
|
|
13
31
|
}
|
|
14
|
-
|
|
15
|
-
const value = raw.slice(idx + 1);
|
|
16
|
-
if (name.length > NAME_MAX) {
|
|
17
|
-
throw new Error(`Secret name "${name.slice(0, 32)}…" exceeds ${NAME_MAX} characters.`);
|
|
18
|
-
}
|
|
19
|
-
if (value.length > VALUE_MAX) {
|
|
20
|
-
throw new Error(`Value for "${name}" exceeds ${VALUE_MAX} characters.`);
|
|
21
|
-
}
|
|
22
|
-
return { name, value };
|
|
32
|
+
return validate_pair(raw.slice(0, idx), raw.slice(idx + 1));
|
|
23
33
|
});
|
|
24
34
|
};
|
|
25
35
|
|
|
26
|
-
export { NAME_MAX, VALUE_MAX, parse_pairs };
|
|
36
|
+
export { NAME_MAX, VALUE_MAX, parse_pairs, validate_pair };
|
|
@@ -1,31 +1,96 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
1
3
|
import { requireApi } from '../../../api/context.js';
|
|
2
4
|
import { log } from '../../../log.js';
|
|
3
5
|
import { resolve_app_id } from '../resolve_app_id.js';
|
|
6
|
+
import { managed_warning } from './managed_names.js';
|
|
4
7
|
import { merge_app_secrets } from './merge.js';
|
|
8
|
+
import { parse_env } from './parse_env.js';
|
|
5
9
|
import { parse_pairs } from './parse_pairs.js';
|
|
6
10
|
|
|
11
|
+
// `--env-file` with no value means "the .env in this directory", the case
|
|
12
|
+
// people actually have.
|
|
13
|
+
const DEFAULT_ENV_FILE = '.env';
|
|
14
|
+
const read_env_file = (raw_path) => {
|
|
15
|
+
const path = raw_path || DEFAULT_ENV_FILE;
|
|
16
|
+
let content;
|
|
17
|
+
try {
|
|
18
|
+
content = readFileSync(resolve(path), 'utf8');
|
|
19
|
+
}
|
|
20
|
+
catch (err) {
|
|
21
|
+
const code = err.code;
|
|
22
|
+
if (code === 'ENOENT') {
|
|
23
|
+
throw new Error(`No such file: ${path}`, { cause: err });
|
|
24
|
+
}
|
|
25
|
+
if (code === 'EISDIR') {
|
|
26
|
+
throw new Error(`${path} is a directory, expected a .env file.`, {
|
|
27
|
+
cause: err
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
throw err;
|
|
31
|
+
}
|
|
32
|
+
return parse_env(content, path);
|
|
33
|
+
};
|
|
34
|
+
// Both sources upserted into one list, arguments last so an explicit
|
|
35
|
+
// KEY=VALUE on the command line overrides the same name in the file.
|
|
36
|
+
const combine = (from_file, from_args) => {
|
|
37
|
+
const merged = new Map(from_file.map(p => [p.name, p.value]));
|
|
38
|
+
for (const { name, value } of from_args)
|
|
39
|
+
merged.set(name, value);
|
|
40
|
+
return [...merged.entries()].map(([name, value]) => ({ name, value }));
|
|
41
|
+
};
|
|
42
|
+
// One line per secret reads well for a handful; a whole .env would bury the
|
|
43
|
+
// summary under eighty lines.
|
|
44
|
+
const MAX_DETAILED = 10;
|
|
7
45
|
const secrets_set = {
|
|
8
|
-
command: 'set
|
|
9
|
-
describe: 'Set
|
|
46
|
+
command: 'set [pairs...]',
|
|
47
|
+
describe: 'Set secrets as KEY=VALUE pairs or from a .env file',
|
|
10
48
|
builder: yargs => yargs
|
|
11
49
|
.positional('pairs', {
|
|
12
50
|
type: 'string',
|
|
13
51
|
array: true,
|
|
14
|
-
demandOption: true,
|
|
15
52
|
description: 'KEY=VALUE pairs (quote values containing spaces)'
|
|
16
53
|
})
|
|
17
54
|
.option('app', {
|
|
18
55
|
alias: 'a',
|
|
19
56
|
type: 'string',
|
|
20
57
|
description: 'App Identifier (defaults to the linked app)'
|
|
58
|
+
})
|
|
59
|
+
.option('env-file', {
|
|
60
|
+
alias: 'f',
|
|
61
|
+
type: 'string',
|
|
62
|
+
description: 'Load variables from a .env file (defaults to ./.env)'
|
|
21
63
|
})
|
|
22
64
|
.example('$0 deploy secrets set API_KEY=abc123', 'Set a single secret')
|
|
23
65
|
.example('$0 deploy secrets set A=1 DB_URL=postgres://u:p@host/db', 'Set several at once (values may contain "=")')
|
|
66
|
+
.example('$0 deploy secrets set --env-file', 'Upload every variable in ./.env')
|
|
67
|
+
.example('$0 deploy secrets set -f .env.production', 'Upload another env file')
|
|
24
68
|
.showHelpOnFail(false),
|
|
25
69
|
handler: async (args) => {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
70
|
+
const env_file = args['env-file'];
|
|
71
|
+
const pairs = args.pairs ?? [];
|
|
72
|
+
if (env_file === undefined && pairs.length === 0) {
|
|
73
|
+
throw new Error('Nothing to set. Pass KEY=VALUE pairs or --env-file [path] to load a .env file.');
|
|
74
|
+
}
|
|
75
|
+
// Validate EVERYTHING before writing ANY, so one malformed line or pair
|
|
76
|
+
// aborts the whole command with no partial writes.
|
|
77
|
+
const from_file = env_file === undefined ? [] : read_env_file(env_file);
|
|
78
|
+
const parsed = combine(from_file, parse_pairs(pairs));
|
|
79
|
+
if (env_file !== undefined) {
|
|
80
|
+
const path = env_file || DEFAULT_ENV_FILE;
|
|
81
|
+
if (from_file.length === 0) {
|
|
82
|
+
throw new Error(`${path} has no variables to set.`);
|
|
83
|
+
}
|
|
84
|
+
log.info(`📄 Read ${from_file.length} variable(s) from ${path}`);
|
|
85
|
+
}
|
|
86
|
+
// Warn BEFORE the write: a reserved name is accepted and stored, but the
|
|
87
|
+
// controller drops it when it builds the pod — without this line the
|
|
88
|
+
// command looks like it worked.
|
|
89
|
+
for (const { name } of parsed) {
|
|
90
|
+
const warning = managed_warning(name);
|
|
91
|
+
if (warning)
|
|
92
|
+
log.warn(`⚠️ ${warning}`);
|
|
93
|
+
}
|
|
29
94
|
const ctx = await requireApi();
|
|
30
95
|
const app_id = await resolve_app_id(args.app, ctx.appId, ctx.api);
|
|
31
96
|
// getApp also validates access to the app and provides the team the
|
|
@@ -35,10 +100,16 @@ const secrets_set = {
|
|
|
35
100
|
const merged = merge_app_secrets(existing, parsed);
|
|
36
101
|
await ctx.api.createSecretsBatch(app.id, app.team, merged);
|
|
37
102
|
const current = new Set(existing.filter(s => s.related_model === 'app').map(s => s.name));
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
103
|
+
const updated = parsed.filter(({ name }) => current.has(name)).length;
|
|
104
|
+
if (parsed.length <= MAX_DETAILED) {
|
|
105
|
+
for (const { name } of parsed) {
|
|
106
|
+
log.info(current.has(name)
|
|
107
|
+
? `🔑 Updated secret ${name} on ${app_id}`
|
|
108
|
+
: `🔑 Added secret ${name} to ${app_id}`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
log.info(`🔑 ${parsed.length - updated} added, ${updated} updated`);
|
|
42
113
|
}
|
|
43
114
|
log.info(`✅ ${parsed.length} secret(s) saved to ${app_id}.`);
|
|
44
115
|
log.info(`ℹ️ The app is restarting to apply the changes.`);
|