Merge pull request #2741 from spotify/rugvip/short

config-loader: add support for new short-form secret syntax
This commit is contained in:
Patrik Oldsberg
2020-10-05 13:30:04 +02:00
committed by GitHub
19 changed files with 188 additions and 179 deletions
+42 -1
View File
@@ -84,6 +84,47 @@ describe('readConfigFile', () => {
});
it('should read secrets', async () => {
const readFile = memoryFiles({
'./app-config.yaml': 'app: { $file: "./my-secret" }',
});
const readSecret = jest.fn().mockResolvedValue('secret');
const config = readConfigFile('./app-config.yaml', {
...mockContext,
readFile,
readSecret: readSecret as ReadSecretFunc,
});
await expect(config).resolves.toEqual({
data: {
app: 'secret',
},
context: 'app-config.yaml',
});
expect(readSecret).toHaveBeenCalledWith('.app', {
file: './my-secret',
});
});
it('should not allow keys adjacent to secrets', async () => {
const readFile = memoryFiles({
'./app-config.yaml': 'app: { extraKey: 3, $file: "./my-secret" }',
});
const readSecret = jest.fn().mockResolvedValue('secret');
const config = readConfigFile('./app-config.yaml', {
...mockContext,
readFile,
readSecret: readSecret as ReadSecretFunc,
});
await expect(config).rejects.toThrow(
"Secret key '$file' has adjacent keys at .app",
);
expect(readSecret).not.toHaveBeenCalled();
});
it('should read deprecated secrets', async () => {
const readFile = memoryFiles({
'./app-config.yaml': 'app: { $secret: { file: "./my-secret" } }',
});
@@ -106,7 +147,7 @@ describe('readConfigFile', () => {
});
});
it('should require secrets to be objects', async () => {
it('should require deprecated secrets to be objects', async () => {
const readFile = memoryFiles({
'./app-config.yaml': 'app: { $secret: ["wrong-type"] }',
});
+25 -1
View File
@@ -31,6 +31,8 @@ export async function readConfigFile(
const configYaml = await ctx.readFile(filePath);
const config = yaml.parse(configYaml);
const context = basename(filePath);
async function transform(
obj: JsonValue,
path: string,
@@ -56,7 +58,11 @@ export async function readConfigFile(
return arr;
}
// TODO(Rugvip): This form of declaring secrets is deprecated, warn and remove in the future
if ('$secret' in obj) {
console.warn(
`Deprecated secret declaration at '${path}' in '${context}', use $env, $file, etc. instead`,
);
if (!isObject(obj.$secret)) {
throw TypeError(`Expected object at secret ${path}.$secret`);
}
@@ -68,6 +74,24 @@ export async function readConfigFile(
}
}
// Check if there's any key that starts with a '$', in that case we treat
// this entire object as a secret.
const [secretKey] = Object.keys(obj).filter(key => key.startsWith('$'));
if (secretKey) {
if (Object.keys(obj).length !== 1) {
throw new Error(
`Secret key '${secretKey}' has adjacent keys at ${path}`,
);
}
try {
return await ctx.readSecret(path, {
[secretKey.slice(1)]: obj[secretKey],
});
} catch (error) {
throw new Error(`Invalid secret at ${path}: ${error.message}`);
}
}
const out: JsonObject = {};
for (const [key, value] of Object.entries(obj)) {
@@ -87,5 +111,5 @@ export async function readConfigFile(
if (!isObject(finalConfig)) {
throw new TypeError('Expected object at config root');
}
return { data: finalConfig, context: basename(filePath) };
return { data: finalConfig, context };
}
+16 -4
View File
@@ -56,21 +56,33 @@ describe('readSecret', () => {
});
it('should read data secrets', async () => {
// Deprecated object form
await expect(
readSecret({ data: 'my-data.json', path: 'a.b.c' }, ctx),
).resolves.toBe('42');
await expect(
readSecret({ data: 'my-data.yaml', path: 'some.yaml.key' }, ctx),
).resolves.toBe('7');
await expect(
readSecret({ data: 'my-data.yml', path: 'different.key' }, ctx),
).resolves.toBe('hello');
await expect(
readSecret({ data: 'no-data.yml', path: 'different.key' }, ctx),
).rejects.toThrow('File not found!');
// New format with path in fragment
await expect(readSecret({ data: 'my-data.json#a.b.c' }, ctx)).resolves.toBe(
'42',
);
await expect(
readSecret({ data: 'my-data.yaml#some.yaml.key' }, ctx),
).resolves.toBe('7');
await expect(
readSecret({ data: 'my-data.yml#different.key' }, ctx),
).resolves.toBe('hello');
await expect(
readSecret({ data: 'no-data.yml#different.key' }, ctx),
).rejects.toThrow('File not found!');
});
it('should reject invalid secrets', async () => {
@@ -84,7 +96,7 @@ describe('readSecret', () => {
"Secret must contain one of 'file', 'env', 'data'",
);
await expect(readSecret({ data: 'no-data.yml' }, ctx)).rejects.toThrow(
'path is a required field',
"Invalid format for data secret value, must be of the form <filepath>#<datapath>, got 'no-data.yml'",
);
await expect(
readSecret({ data: 'no-parser.js', path: '.' }, ctx),
+15 -16
View File
@@ -38,9 +38,8 @@ type EnvSecret = {
type DataSecret = {
// Path to the data secret file, relative to the config file.
data: string;
// The path to the value inside the data file.
// Either a '.' separated list, or an array of path segments.
path: string | string[];
// The path to the value inside the data file, each element separated by '.'.
path?: string;
};
type Secret = FileSecret | EnvSecret | DataSecret;
@@ -55,12 +54,6 @@ const secretLoaderSchemas = {
}),
data: yup.object({
data: yup.string().required(),
path: yup.lazy(value => {
if (typeof value === 'string') {
return yup.string().required();
}
return yup.array().of(yup.string().required()).required();
}),
}),
};
@@ -111,24 +104,30 @@ export async function readSecret(
return ctx.env[secret.env];
}
if ('data' in secret) {
const ext = extname(secret.data);
const url =
'path' in secret ? `${secret.data}#${secret.path}` : secret.data;
const [filePath, dataPath] = url.split(/#(.*)/);
if (!dataPath) {
throw new Error(
`Invalid format for data secret value, must be of the form <filepath>#<datapath>, got '${url}'`,
);
}
const ext = extname(filePath);
const parser = dataSecretParser[ext];
if (!parser) {
throw new Error(`No data secret parser available for extension ${ext}`);
}
const content = await ctx.readFile(secret.data);
const content = await ctx.readFile(filePath);
const { path } = secret;
const parts = typeof path === 'string' ? path.split('.') : path;
const parts = dataPath.split('.');
let value: JsonValue | undefined = await parser(content);
for (const [index, part] of parts.entries()) {
if (!isObject(value)) {
const errPath = parts.slice(0, index).join('.');
throw new Error(
`Value is not an object at ${errPath} in ${secret.data}`,
);
throw new Error(`Value is not an object at ${errPath} in ${filePath}`);
}
value = value[part];
}
+1 -2
View File
@@ -24,8 +24,7 @@ describe('loadConfig', () => {
app:
title: Example App
sessionKey:
$secret:
file: secrets/session-key.txt
$file: secrets/session-key.txt
`,
'/root/app-config.development.yaml': `
app:
@@ -22,22 +22,17 @@ backend:
client: pg
connection:
host:
$secret:
env: POSTGRES_HOST
$env: POSTGRES_HOST
port:
$secret:
env: POSTGRES_PORT
$env: POSTGRES_PORT
user:
$secret:
env: POSTGRES_USER
$env: POSTGRES_USER
password:
$secret:
env: POSTGRES_PASSWORD
$env: POSTGRES_PASSWORD
# https://node-postgres.com/features/ssl
#ssl: require # see https://www.postgresql.org/docs/current/libpq-ssl.html Table 33.1. SSL Mode Descriptions (e.g. require)
#ca: # if you have a CA file and want to verify it you can uncomment this section
# $secret:
# file: <file-path>/ca/server.crt
# $file: <file-path>/ca/server.crt
{{/if}}
proxy:
@@ -60,8 +55,7 @@ auth:
scaffolder:
github:
token:
$secret:
env: GITHUB_ACCESS_TOKEN
$env: GITHUB_ACCESS_TOKEN
visibility: public # or 'internal' or 'private'
catalog:
@@ -72,14 +66,12 @@ catalog:
providers:
- target: https://github.com
token:
$secret:
env: GITHUB_PRIVATE_TOKEN
$env: GITHUB_PRIVATE_TOKEN
# Example for how to add your GitHub Enterprise instance:
# - target: https://ghe.example.net
# apiBaseUrl: https://ghe.example.net/api/v3
# token:
# $secret:
# env: GHE_PRIVATE_TOKEN
# $env: GHE_PRIVATE_TOKEN
locations:
# Backstage example components
- type: url