@prairielearn/config 1.0.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.
File without changes
package/README.md ADDED
@@ -0,0 +1,50 @@
1
+ # `@prairielearn/config`
2
+
3
+ Utilities to help load configuration from various sources including a JSON file and AWS Secrets Manager. Config is made type-safe through a [Zod](https://github.com/colinhacks/zod) schema.
4
+
5
+ This package _should not_ be depended upon by other packages directly. Instead, import it into your application, load the config, and then provide any necessary values to other packages.
6
+
7
+ ## Usage
8
+
9
+ ```ts
10
+ import { ConfigLoader, makeFileConfig } from '@prairielearn/config';
11
+ import { z } from 'zod';
12
+
13
+ const ConfigSchema = z.object({
14
+ hello: z.string().default('world'),
15
+ });
16
+
17
+ const configLoader = new ConfigLoader(ConfigSchema);
18
+
19
+ await configLoader.loadAndValidate([makeFileConfig('config.json')]);
20
+
21
+ console.log(configLoader.config);
22
+ // { hello: "world" }
23
+ ```
24
+
25
+ Typically, you'll want to have a `config.ts` file in your own project that encapsulates this. Then, you can import the config elsewhere in the project.
26
+
27
+ ```ts
28
+ import { ConfigLoader, makeFileConfig } from '@prairielearn/config';
29
+ import { z } from 'zod';
30
+
31
+ const configLoader = new ConfigLoader(z.any());
32
+
33
+ export async function loadAndValidate(path: string) {
34
+ await configLoader.loadAndValidate([makeFileConfig(path)]);
35
+ }
36
+
37
+ export default configLoader.config;
38
+ ```
39
+
40
+ ### Loading config from AWS
41
+
42
+ If you're running in AWS, you can use `makeImdsConfig()` and `makeSecretsManagerConfig()` to load config from IMDS and Secrets Manager, respectively:
43
+
44
+ - `makeImdsConfig()` will load `hostname`, `instanceId`, and `region`, which will be available if you config schema contains these values.
45
+ - `makeSecretsManagerConfig()` will look for a `ConfSecret` tag on the instance. If found, the value of that tag will be used treated as a Secrets Manager secret ID, and that secret's value will be parsed as JSON and merged into the config.
46
+
47
+ Note that both of these config sources are no-ops by default. To active them, you must do one of the following:
48
+
49
+ - Set `CONFIG_LOAD_FROM_AWS=1` in the process environment.
50
+ - Chain them after `makeFileConfig()`, and ensure that the config file contains `{"runningInEc2": true}`.
@@ -0,0 +1,20 @@
1
+ import { z } from 'zod';
2
+ declare const AbstractConfigSchema: z.ZodRecord<z.ZodString, z.ZodUnknown>;
3
+ type AbstractConfig = z.infer<typeof AbstractConfigSchema>;
4
+ interface ConfigSource {
5
+ load: (existingConfig: AbstractConfig) => Promise<AbstractConfig>;
6
+ }
7
+ export declare function makeLiteralConfigSource(config: AbstractConfig): {
8
+ load: () => Promise<Record<string, unknown>>;
9
+ };
10
+ export declare function makeFileConfigSource(path: string): ConfigSource;
11
+ export declare function makeSecretsManagerConfigSource(tagKey: string): ConfigSource;
12
+ export declare function makeImdsConfigSource(): ConfigSource;
13
+ export declare class ConfigLoader<Schema extends z.ZodTypeAny> {
14
+ private readonly schema;
15
+ private readonly resolvedConfig;
16
+ constructor(schema: Schema);
17
+ loadAndValidate(sources?: ConfigSource[]): Promise<void>;
18
+ get config(): z.TypeOf<Schema>;
19
+ }
20
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,93 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.ConfigLoader = exports.makeImdsConfigSource = exports.makeSecretsManagerConfigSource = exports.makeFileConfigSource = exports.makeLiteralConfigSource = void 0;
7
+ const lodash_1 = __importDefault(require("lodash"));
8
+ const fs_extra_1 = __importDefault(require("fs-extra"));
9
+ const zod_1 = require("zod");
10
+ const client_ec2_1 = require("@aws-sdk/client-ec2");
11
+ const client_secrets_manager_1 = require("@aws-sdk/client-secrets-manager");
12
+ const aws_imds_1 = require("@prairielearn/aws-imds");
13
+ const AbstractConfigSchema = zod_1.z.record(zod_1.z.string(), zod_1.z.unknown());
14
+ function makeLiteralConfigSource(config) {
15
+ return {
16
+ load: async () => config,
17
+ };
18
+ }
19
+ exports.makeLiteralConfigSource = makeLiteralConfigSource;
20
+ function makeFileConfigSource(path) {
21
+ return {
22
+ load: async () => {
23
+ if (!(await fs_extra_1.default.pathExists(path)))
24
+ return {};
25
+ const config = await fs_extra_1.default.readJson(path);
26
+ return zod_1.z.record(zod_1.z.string(), zod_1.z.any()).parse(config);
27
+ },
28
+ };
29
+ }
30
+ exports.makeFileConfigSource = makeFileConfigSource;
31
+ function makeSecretsManagerConfigSource(tagKey) {
32
+ return {
33
+ load: async (existingConfig) => {
34
+ if (!existingConfig.runningInEc2 && !process.env.CONFIG_LOAD_FROM_AWS) {
35
+ return {};
36
+ }
37
+ const identity = await (0, aws_imds_1.fetchInstanceIdentity)();
38
+ const ec2Client = new client_ec2_1.EC2Client({ region: identity.region });
39
+ const tags = await ec2Client.send(new client_ec2_1.DescribeTagsCommand({
40
+ Filters: [{ Name: 'resource-id', Values: [identity.instanceId] }],
41
+ }));
42
+ const secretId = tags.Tags?.find((tag) => tag.Key === tagKey)?.Value;
43
+ if (!secretId)
44
+ return {};
45
+ const secretsManagerClient = new client_secrets_manager_1.SecretsManagerClient({ region: identity.region });
46
+ const secretValue = await secretsManagerClient.send(new client_secrets_manager_1.GetSecretValueCommand({ SecretId: secretId }));
47
+ if (!secretValue.SecretString)
48
+ return {};
49
+ const config = JSON.parse(secretValue.SecretString);
50
+ return zod_1.z.record(zod_1.z.string(), zod_1.z.any()).parse(config);
51
+ },
52
+ };
53
+ }
54
+ exports.makeSecretsManagerConfigSource = makeSecretsManagerConfigSource;
55
+ function makeImdsConfigSource() {
56
+ return {
57
+ load: async (existingConfig) => {
58
+ if (!existingConfig.runningInEc2 && !process.env.CONFIG_LOAD_FROM_AWS) {
59
+ return {};
60
+ }
61
+ const hostname = await (0, aws_imds_1.fetchInstanceHostname)();
62
+ const identity = await (0, aws_imds_1.fetchInstanceIdentity)();
63
+ return {
64
+ hostname,
65
+ instanceId: identity.instanceId,
66
+ region: identity.region,
67
+ };
68
+ },
69
+ };
70
+ }
71
+ exports.makeImdsConfigSource = makeImdsConfigSource;
72
+ class ConfigLoader {
73
+ constructor(schema) {
74
+ this.schema = schema;
75
+ // Get the default values from the schema. This ensures that all values
76
+ // have defaults, and also allows us to override nested defaults with
77
+ // `_.merge()` in `loadAndValidate()`.
78
+ this.resolvedConfig = schema.parse({});
79
+ }
80
+ async loadAndValidate(sources = []) {
81
+ let config = this.schema.parse({});
82
+ for (const source of sources) {
83
+ config = lodash_1.default.merge(config, await source.load(config));
84
+ }
85
+ const parsedConfig = this.schema.parse(config);
86
+ lodash_1.default.merge(this.resolvedConfig, parsedConfig);
87
+ }
88
+ get config() {
89
+ return this.resolvedConfig;
90
+ }
91
+ }
92
+ exports.ConfigLoader = ConfigLoader;
93
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,62 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const chai_1 = require("chai");
4
+ const promises_1 = require("node:fs/promises");
5
+ const tmp_promise_1 = require("tmp-promise");
6
+ const zod_1 = require("zod");
7
+ const index_1 = require("./index");
8
+ describe('config', () => {
9
+ it('loads config with defaults', async () => {
10
+ const schema = zod_1.z.object({
11
+ foo: zod_1.z.string().nullable().default(null),
12
+ bar: zod_1.z.string().default('bar'),
13
+ });
14
+ const loader = new index_1.ConfigLoader(schema);
15
+ await loader.loadAndValidate();
16
+ chai_1.assert.equal(loader.config.foo, null);
17
+ chai_1.assert.equal(loader.config.bar, 'bar');
18
+ });
19
+ it('loads config from a file', async () => {
20
+ const schema = zod_1.z.object({
21
+ foo: zod_1.z.string().optional().nullable(),
22
+ bar: zod_1.z.string().default('bar'),
23
+ baz: zod_1.z.string().default('baz'),
24
+ });
25
+ const loader = new index_1.ConfigLoader(schema);
26
+ await (0, tmp_promise_1.withFile)(async ({ path }) => {
27
+ await (0, promises_1.writeFile)(path, JSON.stringify({ foo: 'bar', bar: 'bar' }));
28
+ await loader.loadAndValidate([(0, index_1.makeFileConfigSource)(path)]);
29
+ });
30
+ chai_1.assert.equal(loader.config.foo, 'bar');
31
+ chai_1.assert.equal(loader.config.bar, 'bar');
32
+ chai_1.assert.equal(loader.config.baz, 'baz');
33
+ });
34
+ it('overrides deep objects', async () => {
35
+ const schema = zod_1.z.object({
36
+ features: zod_1.z.record(zod_1.z.string(), zod_1.z.boolean()).default({
37
+ foo: true,
38
+ bar: false,
39
+ }),
40
+ });
41
+ const loader = new index_1.ConfigLoader(schema);
42
+ await loader.loadAndValidate([
43
+ (0, index_1.makeLiteralConfigSource)({
44
+ features: {
45
+ foo: false,
46
+ baz: true,
47
+ },
48
+ }),
49
+ ]);
50
+ chai_1.assert.equal(loader.config.features.foo, false);
51
+ chai_1.assert.equal(loader.config.features.bar, false);
52
+ chai_1.assert.equal(loader.config.features.baz, true);
53
+ });
54
+ it('maintains object identity when loading config', async () => {
55
+ const schema = zod_1.z.object({});
56
+ const loader = new index_1.ConfigLoader(schema);
57
+ const config = loader.config;
58
+ await loader.loadAndValidate();
59
+ chai_1.assert.strictEqual(config, loader.config);
60
+ });
61
+ });
62
+ //# sourceMappingURL=index.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.test.js","sourceRoot":"","sources":["../src/index.test.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@prairielearn/config",
3
+ "version": "1.0.0",
4
+ "main": "dist/index.js",
5
+ "scripts": {
6
+ "build": "tsc",
7
+ "dev": "tsc --watch --preserveWatchOutput",
8
+ "test": "mocha --no-config --require ts-node/register src/**/*.test.ts"
9
+ },
10
+ "dependencies": {
11
+ "@aws-sdk/client-ec2": "^3.304.0",
12
+ "@aws-sdk/client-secrets-manager": "^3.303.0",
13
+ "@prairielearn/aws-imds": "workspace:^",
14
+ "fs-extra": "^11.1.1",
15
+ "lodash": "^4.17.21"
16
+ },
17
+ "devDependencies": {
18
+ "@prairielearn/tsconfig": "*",
19
+ "@types/fs-extra": "^11.0.1",
20
+ "@types/lodash": "^4.14.194",
21
+ "@types/mocha": "^10.0.1",
22
+ "@types/node": "^18.14.2",
23
+ "mocha": "^10.2.0",
24
+ "tmp-promise": "^3.0.3",
25
+ "ts-node": "^10.9.1",
26
+ "typescript": "^4.9.5"
27
+ }
28
+ }
@@ -0,0 +1,72 @@
1
+ import { assert } from 'chai';
2
+ import { writeFile } from 'node:fs/promises';
3
+ import { withFile } from 'tmp-promise';
4
+ import { z } from 'zod';
5
+
6
+ import { ConfigLoader, makeLiteralConfigSource, makeFileConfigSource } from './index';
7
+
8
+ describe('config', () => {
9
+ it('loads config with defaults', async () => {
10
+ const schema = z.object({
11
+ foo: z.string().nullable().default(null),
12
+ bar: z.string().default('bar'),
13
+ });
14
+ const loader = new ConfigLoader(schema);
15
+
16
+ await loader.loadAndValidate();
17
+
18
+ assert.equal(loader.config.foo, null);
19
+ assert.equal(loader.config.bar, 'bar');
20
+ });
21
+
22
+ it('loads config from a file', async () => {
23
+ const schema = z.object({
24
+ foo: z.string().optional().nullable(),
25
+ bar: z.string().default('bar'),
26
+ baz: z.string().default('baz'),
27
+ });
28
+ const loader = new ConfigLoader(schema);
29
+
30
+ await withFile(async ({ path }) => {
31
+ await writeFile(path, JSON.stringify({ foo: 'bar', bar: 'bar' }));
32
+ await loader.loadAndValidate([makeFileConfigSource(path)]);
33
+ });
34
+
35
+ assert.equal(loader.config.foo, 'bar');
36
+ assert.equal(loader.config.bar, 'bar');
37
+ assert.equal(loader.config.baz, 'baz');
38
+ });
39
+
40
+ it('overrides deep objects', async () => {
41
+ const schema = z.object({
42
+ features: z.record(z.string(), z.boolean()).default({
43
+ foo: true,
44
+ bar: false,
45
+ }),
46
+ });
47
+ const loader = new ConfigLoader(schema);
48
+
49
+ await loader.loadAndValidate([
50
+ makeLiteralConfigSource({
51
+ features: {
52
+ foo: false,
53
+ baz: true,
54
+ },
55
+ }),
56
+ ]);
57
+
58
+ assert.equal(loader.config.features.foo, false);
59
+ assert.equal(loader.config.features.bar, false);
60
+ assert.equal(loader.config.features.baz, true);
61
+ });
62
+
63
+ it('maintains object identity when loading config', async () => {
64
+ const schema = z.object({});
65
+ const loader = new ConfigLoader(schema);
66
+ const config = loader.config;
67
+
68
+ await loader.loadAndValidate();
69
+
70
+ assert.strictEqual(config, loader.config);
71
+ });
72
+ });
package/src/index.ts ADDED
@@ -0,0 +1,109 @@
1
+ import _ from 'lodash';
2
+ import fs from 'fs-extra';
3
+ import { z } from 'zod';
4
+ import { EC2Client, DescribeTagsCommand } from '@aws-sdk/client-ec2';
5
+ import { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager';
6
+ import { fetchInstanceHostname, fetchInstanceIdentity } from '@prairielearn/aws-imds';
7
+
8
+ const AbstractConfigSchema = z.record(z.string(), z.unknown());
9
+ type AbstractConfig = z.infer<typeof AbstractConfigSchema>;
10
+
11
+ interface ConfigSource {
12
+ load: (existingConfig: AbstractConfig) => Promise<AbstractConfig>;
13
+ }
14
+
15
+ export function makeLiteralConfigSource(config: AbstractConfig) {
16
+ return {
17
+ load: async () => config,
18
+ };
19
+ }
20
+
21
+ export function makeFileConfigSource(path: string): ConfigSource {
22
+ return {
23
+ load: async () => {
24
+ if (!(await fs.pathExists(path))) return {};
25
+
26
+ const config = await fs.readJson(path);
27
+ return z.record(z.string(), z.any()).parse(config);
28
+ },
29
+ };
30
+ }
31
+
32
+ export function makeSecretsManagerConfigSource(tagKey: string): ConfigSource {
33
+ return {
34
+ load: async (existingConfig) => {
35
+ if (!existingConfig.runningInEc2 && !process.env.CONFIG_LOAD_FROM_AWS) {
36
+ return {};
37
+ }
38
+
39
+ const identity = await fetchInstanceIdentity();
40
+
41
+ const ec2Client = new EC2Client({ region: identity.region });
42
+ const tags = await ec2Client.send(
43
+ new DescribeTagsCommand({
44
+ Filters: [{ Name: 'resource-id', Values: [identity.instanceId] }],
45
+ })
46
+ );
47
+
48
+ const secretId = tags.Tags?.find((tag) => tag.Key === tagKey)?.Value;
49
+ if (!secretId) return {};
50
+
51
+ const secretsManagerClient = new SecretsManagerClient({ region: identity.region });
52
+ const secretValue = await secretsManagerClient.send(
53
+ new GetSecretValueCommand({ SecretId: secretId })
54
+ );
55
+ if (!secretValue.SecretString) return {};
56
+
57
+ const config = JSON.parse(secretValue.SecretString);
58
+ return z.record(z.string(), z.any()).parse(config);
59
+ },
60
+ };
61
+ }
62
+
63
+ export function makeImdsConfigSource(): ConfigSource {
64
+ return {
65
+ load: async (existingConfig) => {
66
+ if (!existingConfig.runningInEc2 && !process.env.CONFIG_LOAD_FROM_AWS) {
67
+ return {};
68
+ }
69
+
70
+ const hostname = await fetchInstanceHostname();
71
+ const identity = await fetchInstanceIdentity();
72
+
73
+ return {
74
+ hostname,
75
+ instanceId: identity.instanceId,
76
+ region: identity.region,
77
+ };
78
+ },
79
+ };
80
+ }
81
+
82
+ export class ConfigLoader<Schema extends z.ZodTypeAny> {
83
+ private readonly schema: Schema;
84
+ private readonly resolvedConfig: z.infer<Schema>;
85
+
86
+ constructor(schema: Schema) {
87
+ this.schema = schema;
88
+
89
+ // Get the default values from the schema. This ensures that all values
90
+ // have defaults, and also allows us to override nested defaults with
91
+ // `_.merge()` in `loadAndValidate()`.
92
+ this.resolvedConfig = schema.parse({});
93
+ }
94
+
95
+ async loadAndValidate(sources: ConfigSource[] = []) {
96
+ let config = this.schema.parse({});
97
+
98
+ for (const source of sources) {
99
+ config = _.merge(config, await source.load(config));
100
+ }
101
+
102
+ const parsedConfig = this.schema.parse(config);
103
+ _.merge(this.resolvedConfig, parsedConfig);
104
+ }
105
+
106
+ get config() {
107
+ return this.resolvedConfig;
108
+ }
109
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,7 @@
1
+ {
2
+ "extends": "@prairielearn/tsconfig",
3
+ "compilerOptions": {
4
+ "outDir": "./dist",
5
+ "rootDir": "./src",
6
+ },
7
+ }