@intentius/chant-lexicon-sql 0.116.0 → 0.118.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/clickhouse/access.d.ts +132 -0
- package/dist/clickhouse/access.d.ts.map +1 -0
- package/dist/clickhouse/apply/apply.d.ts.map +1 -1
- package/dist/clickhouse/apply/statements.d.ts +2 -0
- package/dist/clickhouse/apply/statements.d.ts.map +1 -1
- package/dist/clickhouse/entities.d.ts +171 -2
- package/dist/clickhouse/entities.d.ts.map +1 -1
- package/dist/clickhouse/http.d.ts +3 -0
- package/dist/clickhouse/http.d.ts.map +1 -1
- package/dist/clickhouse/import/generator.d.ts +28 -0
- package/dist/clickhouse/import/generator.d.ts.map +1 -1
- package/dist/clickhouse/import/ir.d.ts.map +1 -1
- package/dist/clickhouse/import/parser.d.ts +3 -3
- package/dist/clickhouse/import/parser.d.ts.map +1 -1
- package/dist/clickhouse/live/bind.d.ts.map +1 -1
- package/dist/clickhouse/live/catalog.d.ts +41 -6
- package/dist/clickhouse/live/catalog.d.ts.map +1 -1
- package/dist/clickhouse/live/describe-resources.d.ts +3 -2
- package/dist/clickhouse/live/describe-resources.d.ts.map +1 -1
- package/dist/clickhouse/parser.d.ts +54 -3
- package/dist/clickhouse/parser.d.ts.map +1 -1
- package/dist/clickhouse/plan/commands.d.ts +3 -1
- package/dist/clickhouse/plan/commands.d.ts.map +1 -1
- package/dist/clickhouse/plan/deep.d.ts.map +1 -1
- package/dist/clickhouse/plan/diff.d.ts +12 -0
- package/dist/clickhouse/plan/diff.d.ts.map +1 -1
- package/dist/clickhouse/plan/normalize.d.ts +61 -1
- package/dist/clickhouse/plan/normalize.d.ts.map +1 -1
- package/dist/clickhouse/plan/report.d.ts.map +1 -1
- package/dist/clickhouse/plan/rules.d.ts +7 -0
- package/dist/clickhouse/plan/rules.d.ts.map +1 -1
- package/dist/clickhouse/plan/schema.d.ts +8 -4
- package/dist/clickhouse/plan/schema.d.ts.map +1 -1
- package/dist/clickhouse/rebuild/backfill.d.ts +3 -1
- package/dist/clickhouse/rebuild/backfill.d.ts.map +1 -1
- package/dist/clickhouse/rebuild/observe.d.ts +7 -3
- package/dist/clickhouse/rebuild/observe.d.ts.map +1 -1
- package/dist/clickhouse/rebuild/op.d.ts +8 -0
- package/dist/clickhouse/rebuild/op.d.ts.map +1 -1
- package/dist/clickhouse/rebuild/steps.d.ts +48 -0
- package/dist/clickhouse/rebuild/steps.d.ts.map +1 -1
- package/dist/clickhouse/testing/fake-server.d.ts.map +1 -1
- package/dist/clickhouse/testing/writable-server.d.ts.map +1 -1
- package/dist/clickhouse/topology.d.ts.map +1 -1
- package/dist/clickhouse.d.ts +1 -1
- package/dist/clickhouse.d.ts.map +1 -1
- package/dist/codegen/classifier-page.d.ts.map +1 -1
- package/dist/codegen/registry.d.ts.map +1 -1
- package/dist/config.d.ts +70 -7
- package/dist/config.d.ts.map +1 -1
- package/dist/core/references.d.ts +4 -2
- package/dist/core/references.d.ts.map +1 -1
- package/dist/core/statements.d.ts +11 -0
- package/dist/core/statements.d.ts.map +1 -1
- package/dist/files/build-root.d.ts +26 -0
- package/dist/files/build-root.d.ts.map +1 -0
- package/dist/files/clickhouse.d.ts +19 -0
- package/dist/files/clickhouse.d.ts.map +1 -0
- package/dist/files/common.d.ts +25 -0
- package/dist/files/common.d.ts.map +1 -0
- package/dist/files/postgres.d.ts +36 -0
- package/dist/files/postgres.d.ts.map +1 -0
- package/dist/import-parser.d.ts +25 -0
- package/dist/import-parser.d.ts.map +1 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/integrity.json +8 -8
- package/dist/lint/rules/sqlch001.d.ts +1 -1
- package/dist/lint/rules/sqlch001.d.ts.map +1 -1
- package/dist/lint/rules/sqlch002.d.ts.map +1 -1
- package/dist/lint/rules/sqlpg001.d.ts.map +1 -1
- package/dist/lint/rules/templates.d.ts +1 -1
- package/dist/lint/rules/templates.d.ts.map +1 -1
- package/dist/lsp/postgres-template.d.ts.map +1 -1
- package/dist/manifest.json +1 -1
- package/dist/meta.json +50 -0
- package/dist/okf/index.md +11 -1
- package/dist/okf/rules/SQLCH001.md +2 -2
- package/dist/okf/types/Dictionary.md +9 -0
- package/dist/okf/types/Function.md +9 -0
- package/dist/okf/types/Grant.md +9 -0
- package/dist/okf/types/PostgresDefaultPrivileges.md +9 -0
- package/dist/okf/types/PostgresGrant.md +9 -0
- package/dist/okf/types/PostgresPolicy.md +9 -0
- package/dist/okf/types/PostgresRole.md +9 -0
- package/dist/okf/types/Role.md +9 -0
- package/dist/okf/types/RowPolicy.md +9 -0
- package/dist/okf/types/User.md +9 -0
- package/dist/op/activities/clickhouse-rebuild.d.ts.map +1 -1
- package/dist/op/activities/postgres-migration.d.ts.map +1 -1
- package/dist/op/activity-contracts.d.ts +19 -0
- package/dist/op/activity-contracts.d.ts.map +1 -1
- package/dist/plugin.d.ts.map +1 -1
- package/dist/postgres/access/acl.d.ts +119 -0
- package/dist/postgres/access/acl.d.ts.map +1 -0
- package/dist/postgres/access/import.d.ts +13 -0
- package/dist/postgres/access/import.d.ts.map +1 -0
- package/dist/postgres/access/live.d.ts +24 -0
- package/dist/postgres/access/live.d.ts.map +1 -0
- package/dist/postgres/apply/apply.d.ts.map +1 -1
- package/dist/postgres/apply/statements.d.ts +71 -0
- package/dist/postgres/apply/statements.d.ts.map +1 -1
- package/dist/postgres/entities.d.ts +106 -4
- package/dist/postgres/entities.d.ts.map +1 -1
- package/dist/postgres/entity-types.d.ts +4 -0
- package/dist/postgres/entity-types.d.ts.map +1 -1
- package/dist/postgres/import/generator.d.ts +37 -1
- package/dist/postgres/import/generator.d.ts.map +1 -1
- package/dist/postgres/import/live-export.d.ts +6 -0
- package/dist/postgres/import/live-export.d.ts.map +1 -1
- package/dist/postgres/live/bind.d.ts +2 -0
- package/dist/postgres/live/bind.d.ts.map +1 -1
- package/dist/postgres/live/catalog.d.ts +11 -2
- package/dist/postgres/live/catalog.d.ts.map +1 -1
- package/dist/postgres/live/client.d.ts +3 -0
- package/dist/postgres/live/client.d.ts.map +1 -1
- package/dist/postgres/live/describe-resources.d.ts +9 -0
- package/dist/postgres/live/describe-resources.d.ts.map +1 -1
- package/dist/postgres/migrate/op.d.ts +29 -0
- package/dist/postgres/migrate/op.d.ts.map +1 -1
- package/dist/postgres/parser.d.ts +76 -3
- package/dist/postgres/parser.d.ts.map +1 -1
- package/dist/postgres/plan/commands.d.ts +12 -0
- package/dist/postgres/plan/commands.d.ts.map +1 -1
- package/dist/postgres/plan/deep.d.ts.map +1 -1
- package/dist/postgres/plan/diff.d.ts +10 -1
- package/dist/postgres/plan/diff.d.ts.map +1 -1
- package/dist/postgres/plan/normalize.d.ts +8 -1
- package/dist/postgres/plan/normalize.d.ts.map +1 -1
- package/dist/postgres/plan/rules.d.ts +8 -0
- package/dist/postgres/plan/rules.d.ts.map +1 -1
- package/dist/postgres/plan/schema.d.ts +2 -1
- package/dist/postgres/plan/schema.d.ts.map +1 -1
- package/dist/postgres/plan/server-normalize.d.ts +4 -1
- package/dist/postgres/plan/server-normalize.d.ts.map +1 -1
- package/dist/postgres.d.ts +1 -1
- package/dist/postgres.d.ts.map +1 -1
- package/dist/rules/postgres-templates.ts +2 -2
- package/dist/rules/sqlch001.ts +26 -4
- package/dist/rules/sqlch002.ts +3 -2
- package/dist/rules/sqlpg001.ts +7 -4
- package/dist/rules/templates.ts +2 -2
- package/dist/serializer.d.ts +2 -1
- package/dist/serializer.d.ts.map +1 -1
- package/dist/sql-files.d.ts +79 -0
- package/dist/sql-files.d.ts.map +1 -0
- package/dist/token-source.d.ts +88 -0
- package/dist/token-source.d.ts.map +1 -0
- package/dist/validate.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/clickhouse/access.test.ts +203 -0
- package/src/clickhouse/access.ts +595 -0
- package/src/clickhouse/apply/apply.ts +29 -20
- package/src/clickhouse/apply/statements.ts +105 -8
- package/src/clickhouse/dictionary.test.ts +177 -0
- package/src/clickhouse/entities.ts +356 -6
- package/src/clickhouse/function.test.ts +91 -0
- package/src/clickhouse/http.ts +11 -2
- package/src/clickhouse/import/generator.ts +81 -46
- package/src/clickhouse/import/import.test.ts +9 -3
- package/src/clickhouse/import/ir.ts +6 -2
- package/src/clickhouse/import/parser.ts +10 -18
- package/src/clickhouse/live/access.e2e.test.ts +218 -0
- package/src/clickhouse/live/bind.ts +17 -6
- package/src/clickhouse/live/catalog.ts +106 -10
- package/src/clickhouse/live/describe-resources.ts +23 -10
- package/src/clickhouse/live/dictionaries.e2e.test.ts +186 -0
- package/src/clickhouse/live/functions.e2e.test.ts +155 -0
- package/src/clickhouse/live/live.test.ts +2 -2
- package/src/clickhouse/parser.ts +136 -4
- package/src/clickhouse/plan/commands.ts +18 -7
- package/src/clickhouse/plan/deep.ts +71 -11
- package/src/clickhouse/plan/diff.ts +65 -2
- package/src/clickhouse/plan/normalize.ts +200 -6
- package/src/clickhouse/plan/report.ts +14 -3
- package/src/clickhouse/plan/rules.ts +44 -1
- package/src/clickhouse/plan/schema.ts +40 -17
- package/src/clickhouse/plan/server-format.ts +1 -1
- package/src/clickhouse/rebuild/backfill.ts +5 -25
- package/src/clickhouse/rebuild/observe.ts +7 -3
- package/src/clickhouse/rebuild/op.ts +9 -0
- package/src/clickhouse/rebuild/rebuild.e2e.test.ts +62 -0
- package/src/clickhouse/rebuild/rebuild.test.ts +54 -0
- package/src/clickhouse/rebuild/steps.ts +136 -1
- package/src/clickhouse/testing/fake-server.ts +2 -0
- package/src/clickhouse/testing/writable-server.ts +2 -0
- package/src/clickhouse/topology.ts +17 -2
- package/src/clickhouse.ts +19 -0
- package/src/codegen/classifier-page.ts +2 -0
- package/src/codegen/registry.ts +10 -0
- package/src/config.ts +22 -3
- package/src/core/references.ts +5 -3
- package/src/core/statements.ts +11 -1
- package/src/files/build-root.ts +62 -0
- package/src/files/clickhouse.ts +52 -0
- package/src/files/common.ts +54 -0
- package/src/files/postgres.ts +371 -0
- package/src/generated/lexicon-sql.json +50 -0
- package/src/import-parser.ts +61 -0
- package/src/index.ts +14 -1
- package/src/lint/rules/postgres-templates.ts +2 -2
- package/src/lint/rules/sqlch001.ts +26 -4
- package/src/lint/rules/sqlch002.ts +3 -2
- package/src/lint/rules/sqlpg001.ts +7 -4
- package/src/lint/rules/templates.ts +2 -2
- package/src/lsp/postgres-template.ts +2 -0
- package/src/lsp/template.ts +3 -3
- package/src/mcp/index.ts +6 -6
- package/src/mcp/postgres.ts +1 -1
- package/src/migration-statements.test.ts +25 -0
- package/src/migration-statements.ts +8 -0
- package/src/op/activities/clickhouse-rebuild.ts +1 -0
- package/src/op/activities/postgres-migration.ts +27 -2
- package/src/op/activity-contracts.ts +2 -0
- package/src/plugin.ts +29 -5
- package/src/postgres/access/access.e2e.test.ts +191 -0
- package/src/postgres/access/access.test.ts +147 -0
- package/src/postgres/access/acl.ts +400 -0
- package/src/postgres/access/import.ts +64 -0
- package/src/postgres/access/live.ts +123 -0
- package/src/postgres/apply/apply.ts +117 -4
- package/src/postgres/apply/constraint-sequences.e2e.test.ts +179 -0
- package/src/postgres/apply/safe-sequences.e2e.test.ts +176 -0
- package/src/postgres/apply/statements.test.ts +138 -6
- package/src/postgres/apply/statements.ts +210 -18
- package/src/postgres/entities.ts +287 -11
- package/src/postgres/entity-types.ts +4 -0
- package/src/postgres/import/generator.ts +129 -46
- package/src/postgres/import/live-export.ts +12 -2
- package/src/postgres/live/bind.ts +20 -6
- package/src/postgres/live/catalog.ts +79 -3
- package/src/postgres/live/client.ts +10 -2
- package/src/postgres/live/describe-resources.ts +27 -3
- package/src/postgres/migrate/migrate.test.ts +24 -1
- package/src/postgres/migrate/op.ts +80 -35
- package/src/postgres/migrate/outer.e2e.test.ts +163 -0
- package/src/postgres/parser.ts +247 -8
- package/src/postgres/plan/commands.ts +60 -7
- package/src/postgres/plan/deep.ts +24 -3
- package/src/postgres/plan/diff.ts +71 -6
- package/src/postgres/plan/normalize.ts +115 -2
- package/src/postgres/plan/plan.test.ts +2 -2
- package/src/postgres/plan/rules.ts +49 -5
- package/src/postgres/plan/schema.ts +4 -2
- package/src/postgres/plan/server-normalize.ts +29 -1
- package/src/postgres.ts +11 -0
- package/src/serializer.ts +14 -9
- package/src/skill-defs.ts +1 -1
- package/src/skills/chant-sql-postgres-migration.md +3 -1
- package/src/skills/chant-sql-postgres-plan.md +3 -3
- package/src/skills/chant-sql-postgres.md +9 -2
- package/src/skills/chant-sql-rebuild.md +3 -2
- package/src/skills/chant-sql.md +7 -1
- package/src/sql-files.test.ts +188 -0
- package/src/sql-files.ts +218 -0
- package/src/token-source.e2e.test.ts +142 -0
- package/src/token-source.test.ts +119 -0
- package/src/token-source.ts +189 -0
- package/src/validate.ts +10 -0
package/src/sql-files.ts
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plain `.sql` files as a schema source (#3646): DDL read into the same
|
|
3
|
+
* declarations the tagged templates make. Also at the package root.
|
|
4
|
+
*
|
|
5
|
+
* - {@link readSqlFile}: the objects a file of DDL declares, each with its
|
|
6
|
+
* export name, type, qualified name and DDL.
|
|
7
|
+
* - {@link sqlFileDeclarations}: the same file as TypeScript, one `schema.ts`
|
|
8
|
+
* of `table`, `view`, ... templates, as `chant import <file>.sql` writes it.
|
|
9
|
+
* - {@link sqlFileEntities}: the declarations themselves, with the references
|
|
10
|
+
* between the files' objects, and to objects declared elsewhere, as
|
|
11
|
+
* interpolations, so dependency order comes from the graph. The build uses
|
|
12
|
+
* it for every `.sql` file in the source directory.
|
|
13
|
+
*
|
|
14
|
+
* Postgres reads each `CREATE`, `GRANT`, `REVOKE` and `ALTER DEFAULT
|
|
15
|
+
* PRIVILEGES` as an object, with `COMMENT ON`, `ALTER TABLE ... ROW LEVEL
|
|
16
|
+
* SECURITY` and `ALTER TABLE ... ADD <table constraint>` folded into the
|
|
17
|
+
* object they finish; ClickHouse reads `CREATE DATABASE`, `TABLE`, `VIEW`,
|
|
18
|
+
* `MATERIALIZED VIEW`, `DICTIONARY` and `FUNCTION`. Any other statement, and any
|
|
19
|
+
* statement that does not parse, throws a {@link SqlFileError} naming every
|
|
20
|
+
* such statement: none is left out.
|
|
21
|
+
*
|
|
22
|
+
* A name is a reference where it is qualified (`app.users`), or bare where an
|
|
23
|
+
* object is named (`REFERENCES users`, `FROM users`, an index's table, a
|
|
24
|
+
* column of an enum type) and an object declared without a schema has it.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import type { Declarable } from "@intentius/chant/declarable";
|
|
28
|
+
import type { SqlDialect } from "./dialects";
|
|
29
|
+
import { SqlFileError, type ReadOptions } from "./files/common";
|
|
30
|
+
import { postgresTag, readPostgres, templateStrings } from "./files/postgres";
|
|
31
|
+
import { clickhouseTag, readClickHouse } from "./files/clickhouse";
|
|
32
|
+
import { exportNames as pgExportNames, objectsToIR as pgObjectsToIR, type ImportedPgObject } from "./postgres/import/ir";
|
|
33
|
+
import { exportNames as chExportNames, objectsToIR as chObjectsToIR, type ImportedObject } from "./clickhouse/import/ir";
|
|
34
|
+
import { PostgresGenerator, postgresTemplates, type PgTemplateItem, type TemplateBody } from "./postgres/import/generator";
|
|
35
|
+
import { ClickHouseGenerator, clickhouseTemplates } from "./clickhouse/import/generator";
|
|
36
|
+
import { isPostgresObject } from "./postgres/entities";
|
|
37
|
+
import { isClickHouseObject } from "./clickhouse/entities";
|
|
38
|
+
|
|
39
|
+
export { SqlFileError };
|
|
40
|
+
export type { ReadOptions as SqlFileOptions };
|
|
41
|
+
|
|
42
|
+
/** One object a file of DDL declares. */
|
|
43
|
+
export interface SqlFileObject {
|
|
44
|
+
/** The export it is declared as. */
|
|
45
|
+
export: string;
|
|
46
|
+
/** Its entity type, `Postgres::Table`. */
|
|
47
|
+
type: string;
|
|
48
|
+
/** `schema.name` (Postgres), `database.name` (ClickHouse); the name alone when the DDL does not qualify it. */
|
|
49
|
+
name: string;
|
|
50
|
+
/** Its statements: the CREATE, then what was folded into it. */
|
|
51
|
+
ddl: string;
|
|
52
|
+
/** The file it came from, the `origin` it was read under. */
|
|
53
|
+
origin: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A file to read: its DDL and the name errors give it. */
|
|
57
|
+
export interface SqlFileSource extends ReadOptions {
|
|
58
|
+
ddl: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
type Imported = ImportedPgObject | ImportedObject;
|
|
62
|
+
|
|
63
|
+
const qualifierOf = (o: Imported): string | undefined => ("schema" in o ? o.schema : "database" in o ? o.database : undefined);
|
|
64
|
+
const qualifiedName = (o: Imported): string => (qualifierOf(o) ? `${qualifierOf(o)}.${o.name}` : o.name);
|
|
65
|
+
|
|
66
|
+
function readObjects(dialect: SqlDialect, sources: readonly SqlFileSource[]): Array<Imported & { origin: string }> {
|
|
67
|
+
const out: Array<Imported & { origin: string }> = [];
|
|
68
|
+
for (const source of sources) {
|
|
69
|
+
const problems: string[] = [];
|
|
70
|
+
const objects = dialect === "postgres" ? readPostgres(source.ddl, source, problems) : readClickHouse(source.ddl, source, problems);
|
|
71
|
+
if (problems.length > 0) throw new SqlFileError(source.origin, problems);
|
|
72
|
+
out.push(...objects.map((o) => ({ ...o, origin: source.origin })));
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Export names for objects read from files, unique across them. */
|
|
78
|
+
function namesFor(dialect: SqlDialect, objects: readonly Imported[]): string[] {
|
|
79
|
+
return dialect === "postgres" ? pgExportNames(objects as ImportedPgObject[]) : chExportNames(objects as ImportedObject[]);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The objects a file of DDL declares. Throws a {@link SqlFileError} naming each statement it cannot read. */
|
|
83
|
+
export function readSqlFile(dialect: SqlDialect, ddl: string, options: ReadOptions): SqlFileObject[] {
|
|
84
|
+
const objects = readObjects(dialect, [{ ...options, ddl }]);
|
|
85
|
+
const names = namesFor(dialect, objects);
|
|
86
|
+
return objects.map((o, i) => ({ export: names[i]!, type: o.type, name: qualifiedName(o), ddl: o.ddl, origin: o.origin }));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* A file of DDL as TypeScript declarations: the `schema.ts` `chant import`
|
|
91
|
+
* writes, with `header` as comment lines at its top, and the objects in it.
|
|
92
|
+
*/
|
|
93
|
+
export function sqlFileDeclarations(dialect: SqlDialect, ddl: string, options: ReadOptions & { header?: string }): { content: string; objects: SqlFileObject[] } {
|
|
94
|
+
const objects = readObjects(dialect, [{ ...options, ddl }]);
|
|
95
|
+
if (objects.length === 0) throw new SqlFileError(options.origin, ["the DDL creates no objects"]);
|
|
96
|
+
const ir = dialect === "postgres" ? pgObjectsToIR(objects as ImportedPgObject[]) : chObjectsToIR(objects as ImportedObject[]);
|
|
97
|
+
const files = dialect === "postgres" ? new PostgresGenerator(options.header).generate(ir) : new ClickHouseGenerator(options.header).generate(ir);
|
|
98
|
+
const [file] = files;
|
|
99
|
+
if (!file) throw new SqlFileError(options.origin, ["the DDL creates no objects"]);
|
|
100
|
+
return {
|
|
101
|
+
content: file.content,
|
|
102
|
+
objects: ir.resources.map((r, i) => ({ export: r.logicalId, type: r.type, name: qualifiedName(objects[i]!), ddl: objects[i]!.ddl, origin: options.origin })),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Where an entity read from a `.sql` file came from: its `origin`. Undefined for any other entity. */
|
|
107
|
+
export function sqlFileOf(entity: unknown): string | undefined {
|
|
108
|
+
return typeof entity === "object" && entity !== null ? ((entity as Record<symbol, unknown>)[FROM_FILE] as string | undefined) : undefined;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const FROM_FILE = Symbol.for("chant.sql.fromFile");
|
|
112
|
+
|
|
113
|
+
/** A declared entity as a template target: its export, type and name. */
|
|
114
|
+
function targetOf(dialect: SqlDialect, exportName: string, entity: Declarable): PgTemplateItem | undefined {
|
|
115
|
+
const p = (entity as unknown as { props: { schema?: string; database?: string; name?: string; ddl?: string } }).props;
|
|
116
|
+
if (typeof p.name !== "string") return undefined;
|
|
117
|
+
const qualifier = dialect === "postgres" ? p.schema : p.database;
|
|
118
|
+
return {
|
|
119
|
+
exportName,
|
|
120
|
+
type: entity.entityType,
|
|
121
|
+
...(qualifier ? (dialect === "postgres" ? { schema: qualifier } : { database: qualifier }) : {}),
|
|
122
|
+
name: p.name,
|
|
123
|
+
ddl: typeof p.ddl === "string" ? p.ddl : "",
|
|
124
|
+
} as PgTemplateItem;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function templates(dialect: SqlDialect, items: readonly PgTemplateItem[], targets: readonly PgTemplateItem[]): { order: PgTemplateItem[]; bodies: Map<string, TemplateBody> } {
|
|
128
|
+
return dialect === "postgres" ? postgresTemplates(items, targets) : (clickhouseTemplates(items, targets) as { order: PgTemplateItem[]; bodies: Map<string, TemplateBody> });
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The entities of one dialect in a set, by export name. */
|
|
132
|
+
function ofDialect(dialect: SqlDialect, entities: ReadonlyMap<string, Declarable> | undefined): Map<string, Declarable> {
|
|
133
|
+
const is = dialect === "postgres" ? isPostgresObject : isClickHouseObject;
|
|
134
|
+
return new Map([...(entities ?? new Map<string, Declarable>())].filter(([, e]) => is(e)));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The declarations the files make, by export name. `known` holds the
|
|
139
|
+
* entities declared elsewhere (the project's tagged templates): a file's
|
|
140
|
+
* reference to one of them is an interpolation of it, as an import would
|
|
141
|
+
* write it, and an export name or an object one of them already declares is
|
|
142
|
+
* an error naming both.
|
|
143
|
+
*/
|
|
144
|
+
export function sqlFileEntities(dialect: SqlDialect, sources: readonly SqlFileSource[], options: { known?: ReadonlyMap<string, Declarable> } = {}): Map<string, Declarable> {
|
|
145
|
+
const objects = readObjects(dialect, sources);
|
|
146
|
+
const names = namesFor(dialect, objects);
|
|
147
|
+
const known = ofDialect(dialect, options.known);
|
|
148
|
+
const problems: string[] = [];
|
|
149
|
+
|
|
150
|
+
const items: PgTemplateItem[] = objects.map((o, i) => ({ exportName: names[i]!, type: o.type, ...("schema" in o && o.schema ? { schema: o.schema } : {}), ...("database" in o && o.database ? { database: o.database } : {}), name: o.name, ddl: o.ddl }) as PgTemplateItem);
|
|
151
|
+
const origin = new Map(objects.map((o, i) => [names[i]!, o.origin]));
|
|
152
|
+
const knownItems = [...known].map(([n, e]) => targetOf(dialect, n, e)).filter((t): t is PgTemplateItem => t !== undefined);
|
|
153
|
+
const declared = new Map(knownItems.map((t) => [`${t.type} ${qualifiedName(t as Imported)}`, t.exportName]));
|
|
154
|
+
const seen = new Map<string, string>();
|
|
155
|
+
const where = (at: string | undefined) => (sources.length > 1 && at ? `${at}: ` : "");
|
|
156
|
+
const label = sources.length === 1 ? sources[0]!.origin : "the .sql files";
|
|
157
|
+
for (const [i, it] of items.entries()) {
|
|
158
|
+
const what = `${it.type} ${qualifiedName(it as Imported)}`;
|
|
159
|
+
const at = objects[i]!.origin;
|
|
160
|
+
const q = qualifiedName(it as Imported);
|
|
161
|
+
if (options.known?.has(it.exportName)) problems.push(`${where(at)}${q} is exported as ${it.exportName}, a name another declaration already has; rename that one`);
|
|
162
|
+
const other = declared.get(what);
|
|
163
|
+
if (other) problems.push(`${where(at)}${q} is declared twice, here and as ${other}; leave it to one of them`);
|
|
164
|
+
const again = seen.get(what);
|
|
165
|
+
if (again) problems.push(`${where(at)}${q} is declared twice, here and in ${again}; leave it to one of them`);
|
|
166
|
+
seen.set(what, at);
|
|
167
|
+
}
|
|
168
|
+
if (problems.length > 0) throw new SqlFileError(label, problems);
|
|
169
|
+
|
|
170
|
+
const { order, bodies } = templates(dialect, items, [...items, ...knownItems]);
|
|
171
|
+
const built = new Map<string, Declarable>();
|
|
172
|
+
const failed = new Set<string>();
|
|
173
|
+
const tagOf = dialect === "postgres" ? postgresTag : clickhouseTag;
|
|
174
|
+
for (const it of order) {
|
|
175
|
+
const body = bodies.get(it.exportName)!;
|
|
176
|
+
const values = body.refs.map((r) => built.get(r) ?? known.get(r));
|
|
177
|
+
const missing = body.refs.find((_, i) => values[i] === undefined);
|
|
178
|
+
if (missing !== undefined) {
|
|
179
|
+
failed.add(it.exportName);
|
|
180
|
+
// What references an object that failed is not reported again.
|
|
181
|
+
if (failed.has(missing)) continue;
|
|
182
|
+
problems.push(`${where(origin.get(it.exportName))}${qualifiedName(it as Imported)} and ${missing} reference each other, which no declaration can; break the cycle`);
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
try {
|
|
186
|
+
const entity = tagOf(it.type)!(templateStrings(body.parts), ...values);
|
|
187
|
+
Object.defineProperty(entity, FROM_FILE, { value: origin.get(it.exportName), enumerable: false });
|
|
188
|
+
built.set(it.exportName, entity);
|
|
189
|
+
} catch (e) {
|
|
190
|
+
failed.add(it.exportName);
|
|
191
|
+
problems.push(`${where(origin.get(it.exportName))}${qualifiedName(it as Imported)}: ${e instanceof Error ? e.message : String(e)}`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
if (problems.length > 0) throw new SqlFileError(label, problems);
|
|
195
|
+
// In the files' order, not the dependency order.
|
|
196
|
+
return new Map(items.filter((it) => built.has(it.exportName)).map((it) => [it.exportName, built.get(it.exportName)!]));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* The objects read from `.sql` files that each other object names in its
|
|
201
|
+
* DDL as text, by export name: a tagged template cannot import a file's
|
|
202
|
+
* object, so it names it, and this is the edge the dependency order needs.
|
|
203
|
+
* Empty when no object came from a file.
|
|
204
|
+
*/
|
|
205
|
+
export function sqlFileReferences(dialect: SqlDialect, objects: ReadonlyMap<string, Declarable>): Map<string, string[]> {
|
|
206
|
+
const fromFiles = [...objects].filter(([, e]) => sqlFileOf(e) !== undefined);
|
|
207
|
+
const out = new Map<string, string[]>();
|
|
208
|
+
if (fromFiles.length === 0) return out;
|
|
209
|
+
const targets = fromFiles.map(([n, e]) => targetOf(dialect, n, e)).filter((t): t is PgTemplateItem => t !== undefined);
|
|
210
|
+
for (const [name, entity] of objects) {
|
|
211
|
+
if (sqlFileOf(entity) !== undefined) continue;
|
|
212
|
+
const item = targetOf(dialect, name, entity);
|
|
213
|
+
if (!item || !item.ddl) continue;
|
|
214
|
+
const refs = templates(dialect, [item], targets).bodies.get(name)?.refs ?? [];
|
|
215
|
+
if (refs.length > 0) out.set(name, [...new Set(refs)]);
|
|
216
|
+
}
|
|
217
|
+
return out;
|
|
218
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token sources against a live server (#3685). A `command` source stands in
|
|
3
|
+
* for the token endpoint: it prints a password from a file, and the test
|
|
4
|
+
* rotates the role's password and the file together, as a cloud rotates its
|
|
5
|
+
* tokens. A profile with that source connects; after the token's lifetime a
|
|
6
|
+
* new one is minted and the next connection uses it; a token the server
|
|
7
|
+
* refuses is forgotten, so the next attempt mints again.
|
|
8
|
+
*
|
|
9
|
+
* Postgres: `CHANT_SQL_E2E_POSTGRES_URL` (with
|
|
10
|
+
* `CHANT_SQL_E2E_POSTGRES_PASSWORD`) names a running server, else a
|
|
11
|
+
* throwaway one, skipped when Docker is not available. ClickHouse:
|
|
12
|
+
* `CHANT_SQL_E2E_CLICKHOUSE_URL` (as `default`, with
|
|
13
|
+
* `CHANT_SQL_E2E_CLICKHOUSE_PASSWORD`), skipped when not set. The test makes
|
|
14
|
+
* only the user `chant_e2e_3685` on each, dropped afterwards.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
18
|
+
import { tmpdir } from "node:os";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
|
21
|
+
import { dockerAvailable, startTestPostgres, type TestPostgres } from "./postgres/testing/server";
|
|
22
|
+
import { connectPostgres, PostgresQueryError, type PostgresClient, type PostgresEndpoint } from "./postgres/live/client";
|
|
23
|
+
import { resolveBoundTarget } from "./postgres/live/bind";
|
|
24
|
+
import { bindClickHouse } from "./clickhouse/live/bind";
|
|
25
|
+
import { clickhouseQuery, ClickHouseQueryError, type ClickHouseEndpoint } from "./clickhouse/http";
|
|
26
|
+
|
|
27
|
+
const ROLE = "chant_e2e_3685";
|
|
28
|
+
const dir = mkdtempSync(join(tmpdir(), "chant-token-"));
|
|
29
|
+
const tokenFile = join(dir, "token");
|
|
30
|
+
const mint = (password: string) => writeFileSync(tokenFile, password);
|
|
31
|
+
const command = ["cat", tokenFile];
|
|
32
|
+
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
33
|
+
|
|
34
|
+
const pgGiven = process.env.CHANT_SQL_E2E_POSTGRES_URL;
|
|
35
|
+
const pgEnabled = pgGiven ? true : await dockerAvailable();
|
|
36
|
+
let server: TestPostgres | undefined;
|
|
37
|
+
let admin: PostgresClient;
|
|
38
|
+
let pgUrl: string;
|
|
39
|
+
|
|
40
|
+
describe.skipIf(!pgEnabled)("a Postgres profile with a command token source", () => {
|
|
41
|
+
beforeAll(async () => {
|
|
42
|
+
let endpoint: PostgresEndpoint;
|
|
43
|
+
if (pgGiven) endpoint = { url: pgGiven, password: process.env.CHANT_SQL_E2E_POSTGRES_PASSWORD ?? "" };
|
|
44
|
+
else {
|
|
45
|
+
server = await startTestPostgres();
|
|
46
|
+
endpoint = server.endpoint();
|
|
47
|
+
}
|
|
48
|
+
admin = await connectPostgres(endpoint);
|
|
49
|
+
await admin.query(`DROP ROLE IF EXISTS ${ROLE}`);
|
|
50
|
+
await admin.query(`CREATE ROLE ${ROLE} LOGIN PASSWORD 'first-token'`);
|
|
51
|
+
const url = new URL(endpoint.url);
|
|
52
|
+
url.username = "";
|
|
53
|
+
url.password = "";
|
|
54
|
+
pgUrl = url.toString();
|
|
55
|
+
}, 600_000);
|
|
56
|
+
|
|
57
|
+
afterAll(async () => {
|
|
58
|
+
await admin?.query(`DROP ROLE IF EXISTS ${ROLE}`).catch(() => undefined);
|
|
59
|
+
await admin?.end();
|
|
60
|
+
await server?.stop();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const whoami = async (endpoint: PostgresEndpoint) => {
|
|
64
|
+
const client = await connectPostgres(endpoint);
|
|
65
|
+
try {
|
|
66
|
+
const [row] = await client.query<{ u: string }>("SELECT current_user AS u");
|
|
67
|
+
return row?.u;
|
|
68
|
+
} finally {
|
|
69
|
+
await client.end();
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
test("connects with the minted token, mints again after its lifetime, and forgets a refused one", async () => {
|
|
74
|
+
mint("first-token");
|
|
75
|
+
const config = { sql: { profiles: { e2e: { url: pgUrl, user: { env: "E2E_USER" }, password: { token: "command" as const, command, ttlSeconds: 1 } } } } };
|
|
76
|
+
const target = await resolveBoundTarget({ environment: "e2e", config, env: { ...process.env, E2E_USER: ROLE } });
|
|
77
|
+
expect(await whoami(target.endpoint)).toBe(ROLE);
|
|
78
|
+
|
|
79
|
+
// Rotated: the cached token has expired by the next connect, so the new one is minted.
|
|
80
|
+
await admin.query(`ALTER ROLE ${ROLE} PASSWORD 'second-token'`);
|
|
81
|
+
mint("second-token");
|
|
82
|
+
await sleep(1_000);
|
|
83
|
+
expect(await whoami(target.endpoint)).toBe(ROLE);
|
|
84
|
+
|
|
85
|
+
// Rotated before the cached token's lifetime is up: the server refuses it, and the retry mints.
|
|
86
|
+
const long = await resolveBoundTarget({
|
|
87
|
+
environment: "e2e",
|
|
88
|
+
config: { sql: { profiles: { e2e: { ...config.sql.profiles.e2e, password: { token: "command" as const, command, ttlSeconds: 3600 } } } } },
|
|
89
|
+
env: { ...process.env, E2E_USER: ROLE },
|
|
90
|
+
});
|
|
91
|
+
expect(await whoami(long.endpoint)).toBe(ROLE);
|
|
92
|
+
await admin.query(`ALTER ROLE ${ROLE} PASSWORD 'third-token'`);
|
|
93
|
+
mint("third-token");
|
|
94
|
+
const refused = await whoami(long.endpoint).catch((e: unknown) => e);
|
|
95
|
+
expect(refused).toBeInstanceOf(PostgresQueryError);
|
|
96
|
+
expect((refused as PostgresQueryError).code).toBe("28P01");
|
|
97
|
+
expect(await whoami(long.endpoint)).toBe(ROLE);
|
|
98
|
+
}, 60_000);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const chGiven = process.env.CHANT_SQL_E2E_CLICKHOUSE_URL;
|
|
102
|
+
|
|
103
|
+
describe.skipIf(!chGiven)("a ClickHouse profile with a command token source", () => {
|
|
104
|
+
const admin: ClickHouseEndpoint = { url: chGiven ?? "", user: "default", password: process.env.CHANT_SQL_E2E_CLICKHOUSE_PASSWORD ?? "" };
|
|
105
|
+
|
|
106
|
+
beforeAll(async () => {
|
|
107
|
+
await clickhouseQuery(admin, `DROP USER IF EXISTS ${ROLE}`);
|
|
108
|
+
await clickhouseQuery(admin, `CREATE USER ${ROLE} IDENTIFIED BY 'first-token'`);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
afterAll(async () => {
|
|
112
|
+
await clickhouseQuery(admin, `DROP USER IF EXISTS ${ROLE}`).catch(() => undefined);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("each request carries a valid token; a refused one is minted again", async () => {
|
|
116
|
+
mint("first-token");
|
|
117
|
+
const config = { sql: { profiles: { e2e: { url: chGiven!, user: { env: "E2E_USER" }, password: { token: "command" as const, command, ttlSeconds: 1 } } } } };
|
|
118
|
+
const target = await bindClickHouse({ environment: "e2e", config, env: { ...process.env, E2E_USER: ROLE } });
|
|
119
|
+
const whoami = async () => (await clickhouseQuery<{ u: string }>(target.endpoint, "SELECT currentUser() AS u"))[0]?.u;
|
|
120
|
+
expect(await whoami()).toBe(ROLE);
|
|
121
|
+
|
|
122
|
+
await clickhouseQuery(admin, `ALTER USER ${ROLE} IDENTIFIED BY 'second-token'`);
|
|
123
|
+
mint("second-token");
|
|
124
|
+
await sleep(1_000);
|
|
125
|
+
expect(await whoami()).toBe(ROLE);
|
|
126
|
+
|
|
127
|
+
// Rotated before the cached token's lifetime is up: refused once, then minted again.
|
|
128
|
+
const long = await bindClickHouse({
|
|
129
|
+
environment: "e2e",
|
|
130
|
+
config: { sql: { profiles: { e2e: { ...config.sql.profiles.e2e, password: { token: "command" as const, command, ttlSeconds: 3600 } } } } },
|
|
131
|
+
env: { ...process.env, E2E_USER: ROLE },
|
|
132
|
+
});
|
|
133
|
+
const longWhoami = async () => (await clickhouseQuery<{ u: string }>(long.endpoint, "SELECT currentUser() AS u"))[0]?.u;
|
|
134
|
+
expect(await longWhoami()).toBe(ROLE);
|
|
135
|
+
await clickhouseQuery(admin, `ALTER USER ${ROLE} IDENTIFIED BY 'third-token'`);
|
|
136
|
+
mint("third-token");
|
|
137
|
+
await expect(longWhoami()).rejects.toBeInstanceOf(ClickHouseQueryError);
|
|
138
|
+
expect(await longWhoami()).toBe(ROLE);
|
|
139
|
+
}, 60_000);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
afterAll(() => rmSync(dir, { recursive: true, force: true }));
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { sqlProfileSchema } from "./config";
|
|
3
|
+
import { credentialSource, tokenCommand, TokenSourceError, type RunCommand } from "./token-source";
|
|
4
|
+
import { resolvePostgresTarget, classifyPostgresFailure, isUnresolvedTarget } from "./postgres/live/bind";
|
|
5
|
+
import { resolveClickHouseTarget, classifyClickHouseFailure } from "./clickhouse/live/bind";
|
|
6
|
+
|
|
7
|
+
const RDS_URL = "postgres://db.abc123.eu-west-1.rds.amazonaws.com:5433/shop";
|
|
8
|
+
|
|
9
|
+
describe("tokenCommand", () => {
|
|
10
|
+
test("rds-iam runs aws rds generate-db-auth-token for the URL's host and port and the profile's user", () => {
|
|
11
|
+
expect(tokenCommand({ token: "rds-iam" }, { url: RDS_URL, user: "app", env: {} })).toEqual([
|
|
12
|
+
"aws", "rds", "generate-db-auth-token", "--hostname", "db.abc123.eu-west-1.rds.amazonaws.com", "--port", "5433", "--username", "app", "--region", "eu-west-1",
|
|
13
|
+
]);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("rds-iam takes the region from the config, then AWS_REGION, then the host name", () => {
|
|
17
|
+
const region = (argv: readonly string[]) => argv[argv.indexOf("--region") + 1];
|
|
18
|
+
expect(region(tokenCommand({ token: "rds-iam", region: "us-east-2" }, { url: RDS_URL, user: "app", env: { AWS_REGION: "ap-south-1" } }))).toBe("us-east-2");
|
|
19
|
+
expect(region(tokenCommand({ token: "rds-iam" }, { url: RDS_URL, user: "app", env: { AWS_REGION: "ap-south-1" } }))).toBe("ap-south-1");
|
|
20
|
+
const proxy = tokenCommand({ token: "rds-iam" }, { url: "postgres://db.internal/shop", user: "app", env: {} });
|
|
21
|
+
expect(proxy).not.toContain("--region");
|
|
22
|
+
expect(proxy[proxy.indexOf("--port") + 1]).toBe("5432");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("rds-iam needs a user", () => {
|
|
26
|
+
expect(() => tokenCommand({ token: "rds-iam" }, { url: RDS_URL, env: {} })).toThrow(TokenSourceError);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("cloud-sql-iam, entra and command", () => {
|
|
30
|
+
expect(tokenCommand({ token: "cloud-sql-iam" }, { url: "postgres://10.0.0.3/shop", env: {} })).toEqual(["gcloud", "sql", "generate-login-token"]);
|
|
31
|
+
expect(tokenCommand({ token: "entra" }, { url: "postgres://x.postgres.database.azure.com/shop", env: {} })).toEqual([
|
|
32
|
+
"az", "account", "get-access-token", "--resource-type", "oss-rdbms", "--query", "accessToken", "--output", "tsv",
|
|
33
|
+
]);
|
|
34
|
+
expect(tokenCommand({ token: "command", command: ["./mint.sh", "prod"] }, { url: "http://ch:8123", env: {} })).toEqual(["./mint.sh", "prod"]);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe("credentialSource", () => {
|
|
39
|
+
const counting = (outputs: string[]) => {
|
|
40
|
+
const calls: (readonly string[])[] = [];
|
|
41
|
+
const run: RunCommand = async (argv) => {
|
|
42
|
+
calls.push(argv);
|
|
43
|
+
return outputs[Math.min(calls.length - 1, outputs.length - 1)]!;
|
|
44
|
+
};
|
|
45
|
+
return { calls, run };
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
test("mints once, reuses the token, and mints again after most of its lifetime", async () => {
|
|
49
|
+
let now = 0;
|
|
50
|
+
const { calls, run } = counting(["one\n", "two\n"]);
|
|
51
|
+
const src = credentialSource({ token: "command", command: ["mint"], ttlSeconds: 100 }, { url: "http://ch:8123", env: {}, source: "sql.profiles.ci.password", run, now: () => now });
|
|
52
|
+
expect(src.source).toBe("sql.profiles.ci.password (command)");
|
|
53
|
+
expect(calls).toHaveLength(0);
|
|
54
|
+
expect(await src.get()).toBe("one");
|
|
55
|
+
now = 79_000;
|
|
56
|
+
expect(await src.get()).toBe("one");
|
|
57
|
+
now = 80_000;
|
|
58
|
+
expect(await src.get()).toBe("two");
|
|
59
|
+
expect(calls).toHaveLength(2);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("invalidate forgets the token; requests at once share one mint", async () => {
|
|
63
|
+
const { calls, run } = counting(["a", "b"]);
|
|
64
|
+
const src = credentialSource({ token: "command", command: ["mint"] }, { url: "http://ch:8123", env: {}, source: "s", run });
|
|
65
|
+
expect(await Promise.all([src.get(), src.get(), src.get()])).toEqual(["a", "a", "a"]);
|
|
66
|
+
src.invalidate();
|
|
67
|
+
expect(await src.get()).toBe("b");
|
|
68
|
+
expect(calls).toHaveLength(2);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("a failed or empty mint names the program, never its output", async () => {
|
|
72
|
+
const failing: RunCommand = async () => {
|
|
73
|
+
throw Object.assign(new Error("secret-ish stderr"), { code: 2 });
|
|
74
|
+
};
|
|
75
|
+
const missing: RunCommand = async () => {
|
|
76
|
+
throw Object.assign(new Error("spawn"), { code: "ENOENT" });
|
|
77
|
+
};
|
|
78
|
+
const opts = { url: RDS_URL, user: "app", env: {}, source: "sql.profiles.prod.password" };
|
|
79
|
+
const err = (await credentialSource({ token: "rds-iam" }, { ...opts, run: failing }).get().catch((e: unknown) => e)) as Error;
|
|
80
|
+
expect(err).toBeInstanceOf(TokenSourceError);
|
|
81
|
+
expect(err.message).toBe("sql.profiles.prod.password (rds-iam) could not mint a token: `aws` failed, it exited with 2");
|
|
82
|
+
await expect(credentialSource({ token: "entra" }, { ...opts, run: missing }).get()).rejects.toThrow("`az` failed, az is not installed");
|
|
83
|
+
await expect(credentialSource({ token: "command", command: ["x"] }, { ...opts, run: async () => " \n" }).get()).rejects.toThrow("printed nothing");
|
|
84
|
+
expect(classifyPostgresFailure(err)).toEqual({ reason: "no-credentials", detail: err.message });
|
|
85
|
+
expect(classifyClickHouseFailure(err).reason).toBe("no-credentials");
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe("profiles with a token source", () => {
|
|
90
|
+
test("the schema takes a token source or an env reference, and refuses an unknown one", () => {
|
|
91
|
+
expect(sqlProfileSchema.safeParse({ url: RDS_URL, password: { token: "rds-iam", region: "eu-west-1" } }).success).toBe(true);
|
|
92
|
+
expect(sqlProfileSchema.safeParse({ url: RDS_URL, password: { token: "command", command: ["./mint.sh"], ttlSeconds: 60 } }).success).toBe(true);
|
|
93
|
+
expect(sqlProfileSchema.safeParse({ url: RDS_URL, password: { env: "PW" } }).success).toBe(true);
|
|
94
|
+
expect(sqlProfileSchema.safeParse({ url: RDS_URL, password: { token: "vault" } }).success).toBe(false);
|
|
95
|
+
expect(sqlProfileSchema.safeParse({ url: RDS_URL, password: { token: "command", command: [] } }).success).toBe(false);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("a Postgres profile binds a token source with the resolved user, and mints nothing yet", () => {
|
|
99
|
+
const config = { sql: { profiles: { prod: { url: RDS_URL, user: { env: "PG_USER" }, password: { token: "rds-iam" as const } } } } };
|
|
100
|
+
const target = resolvePostgresTarget({ environment: "prod", config, env: { PG_USER: "app" } });
|
|
101
|
+
if (isUnresolvedTarget(target)) throw new Error(target.detail);
|
|
102
|
+
expect(target.endpoint.user).toBe("app");
|
|
103
|
+
expect(target.endpoint.password).toBeUndefined();
|
|
104
|
+
expect(target.endpoint.token?.source).toBe("sql.profiles.prod.password (rds-iam)");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("rds-iam with no user is no-credentials", () => {
|
|
108
|
+
const config = { sql: { profiles: { prod: { url: RDS_URL, password: { token: "rds-iam" as const } } } } };
|
|
109
|
+
expect(resolvePostgresTarget({ environment: "prod", config, env: {} })).toMatchObject({ reason: "no-credentials" });
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("ClickHouse takes a command source and refuses a cloud one", () => {
|
|
113
|
+
const command = { sql: { profiles: { ci: { url: "http://ch:8123", password: { token: "command" as const, command: ["mint"] } } } } };
|
|
114
|
+
const target = resolveClickHouseTarget({ environment: "ci", config: command, env: {} });
|
|
115
|
+
expect("endpoint" in target && target.endpoint.token?.source).toBe("sql.profiles.ci.password (command)");
|
|
116
|
+
const cloud = { sql: { profiles: { ci: { url: "http://ch:8123", password: { token: "cloud-sql-iam" as const } } } } };
|
|
117
|
+
expect(resolveClickHouseTarget({ environment: "ci", config: cloud, env: {} })).toMatchObject({ reason: "no-credentials" });
|
|
118
|
+
});
|
|
119
|
+
});
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Short-lived database credentials (#3685): a profile's `password` can name a
|
|
3
|
+
* token source instead of an environment variable, and the token is minted
|
|
4
|
+
* when a connection needs it.
|
|
5
|
+
*
|
|
6
|
+
* ```ts
|
|
7
|
+
* profiles: {
|
|
8
|
+
* prod: { url: "postgres://db.abc.us-east-1.rds.amazonaws.com:5432/shop", user: { env: "PG_USER" }, password: { token: "rds-iam" } },
|
|
9
|
+
* ci: { url: "postgres://db.internal:5432/shop", password: { token: "command", command: ["./mint-token.sh"], ttlSeconds: 600 } },
|
|
10
|
+
* }
|
|
11
|
+
* ```
|
|
12
|
+
*
|
|
13
|
+
* Each cloud source runs that cloud's own command line, which reads the job's
|
|
14
|
+
* ambient identity (an OIDC-federated role in CI, a workload identity, a
|
|
15
|
+
* logged-in developer), so chant holds no cloud SDK and no long-lived secret:
|
|
16
|
+
*
|
|
17
|
+
* - `rds-iam`: `aws rds generate-db-auth-token`, for RDS and Aurora. The host
|
|
18
|
+
* and port come from the profile's URL, the user from its `user`, the region
|
|
19
|
+
* from `region`, else `AWS_REGION`, else the RDS host name.
|
|
20
|
+
* - `cloud-sql-iam`: `gcloud sql generate-login-token`.
|
|
21
|
+
* - `entra`: `az account get-access-token` for Azure Database for PostgreSQL.
|
|
22
|
+
* - `command`: any command that prints a password on its standard output.
|
|
23
|
+
*
|
|
24
|
+
* A token is reused until most of its lifetime has passed and minted again
|
|
25
|
+
* after, so a long apply, whose ClickHouse requests each carry the password
|
|
26
|
+
* and whose Postgres connections each authenticate when opened, never sends
|
|
27
|
+
* an expired one. A token is never written to a message, an outcome or a log.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { execFile } from "node:child_process";
|
|
31
|
+
import { z } from "zod";
|
|
32
|
+
|
|
33
|
+
const ttl = z.number().int().positive().optional();
|
|
34
|
+
|
|
35
|
+
/** The token sources a profile's `password` can name. */
|
|
36
|
+
export const tokenSourceSchema = z.discriminatedUnion("token", [
|
|
37
|
+
z.strictObject({
|
|
38
|
+
token: z.literal("rds-iam"),
|
|
39
|
+
/** The AWS region. `AWS_REGION`, `AWS_DEFAULT_REGION`, else the RDS host name's, when omitted. */
|
|
40
|
+
region: z.string().min(1).optional(),
|
|
41
|
+
/** How long a token is valid, in seconds. 900, the RDS lifetime, when omitted. */
|
|
42
|
+
ttlSeconds: ttl,
|
|
43
|
+
}),
|
|
44
|
+
z.strictObject({
|
|
45
|
+
token: z.literal("cloud-sql-iam"),
|
|
46
|
+
/** How long a token is valid, in seconds. 3600 when omitted. */
|
|
47
|
+
ttlSeconds: ttl,
|
|
48
|
+
}),
|
|
49
|
+
z.strictObject({
|
|
50
|
+
token: z.literal("entra"),
|
|
51
|
+
/** How long a token is valid, in seconds. 3600 when omitted. */
|
|
52
|
+
ttlSeconds: ttl,
|
|
53
|
+
}),
|
|
54
|
+
z.strictObject({
|
|
55
|
+
token: z.literal("command"),
|
|
56
|
+
/** The program and its arguments, run without a shell; its standard output, trimmed, is the password. */
|
|
57
|
+
command: z.array(z.string().min(1)).min(1),
|
|
58
|
+
/** How long a token is valid, in seconds. 300 when omitted. */
|
|
59
|
+
ttlSeconds: ttl,
|
|
60
|
+
}),
|
|
61
|
+
]);
|
|
62
|
+
|
|
63
|
+
export type TokenSourceConfig = z.infer<typeof tokenSourceSchema>;
|
|
64
|
+
|
|
65
|
+
/** The cloud sources, which mint for a Postgres server only. */
|
|
66
|
+
export const POSTGRES_ONLY_TOKEN_SOURCES: ReadonlySet<string> = new Set(["rds-iam", "cloud-sql-iam", "entra"]);
|
|
67
|
+
|
|
68
|
+
const DEFAULT_TTL_SECONDS: Record<TokenSourceConfig["token"], number> = {
|
|
69
|
+
"rds-iam": 900,
|
|
70
|
+
"cloud-sql-iam": 3600,
|
|
71
|
+
entra: 3600,
|
|
72
|
+
command: 300,
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/** A token reused past this share of its lifetime is minted again. */
|
|
76
|
+
const REUSE_FRACTION = 0.8;
|
|
77
|
+
|
|
78
|
+
/** A token source that could not mint. The message names the source and the command, never what it printed. */
|
|
79
|
+
export class TokenSourceError extends Error {
|
|
80
|
+
constructor(message: string) {
|
|
81
|
+
super(message);
|
|
82
|
+
this.name = "TokenSourceError";
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Runs a program without a shell and returns its standard output. */
|
|
87
|
+
export type RunCommand = (argv: readonly string[], env: Record<string, string | undefined>) => Promise<string>;
|
|
88
|
+
|
|
89
|
+
const runCommand: RunCommand = (argv, env) =>
|
|
90
|
+
new Promise((resolve, reject) => {
|
|
91
|
+
execFile(argv[0]!, argv.slice(1), { env: env as NodeJS.ProcessEnv, timeout: 60_000, maxBuffer: 1 << 20 }, (err, stdout) => {
|
|
92
|
+
if (err) reject(err);
|
|
93
|
+
else resolve(String(stdout));
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
/** Where a token is minted, and the cached token. */
|
|
98
|
+
export interface CredentialSource {
|
|
99
|
+
/** What the token is minted by, for a message: `sql.profiles.prod.password (rds-iam)`. */
|
|
100
|
+
readonly source: string;
|
|
101
|
+
/** A valid token: the cached one, or a new one when it has expired. */
|
|
102
|
+
get(): Promise<string>;
|
|
103
|
+
/** Forget the cached token, so the next `get()` mints again. */
|
|
104
|
+
invalidate(): void;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The program a source runs. Pure, for tests. */
|
|
108
|
+
export function tokenCommand(
|
|
109
|
+
config: TokenSourceConfig,
|
|
110
|
+
input: { url: string; user?: string; env: Record<string, string | undefined> },
|
|
111
|
+
): readonly string[] {
|
|
112
|
+
switch (config.token) {
|
|
113
|
+
case "command":
|
|
114
|
+
return config.command;
|
|
115
|
+
case "cloud-sql-iam":
|
|
116
|
+
return ["gcloud", "sql", "generate-login-token"];
|
|
117
|
+
case "entra":
|
|
118
|
+
return ["az", "account", "get-access-token", "--resource-type", "oss-rdbms", "--query", "accessToken", "--output", "tsv"];
|
|
119
|
+
case "rds-iam": {
|
|
120
|
+
const url = new URL(input.url);
|
|
121
|
+
if (input.user === undefined) throw new TokenSourceError("rds-iam mints a token for one database user; the profile names no user");
|
|
122
|
+
const region = config.region ?? input.env.AWS_REGION ?? input.env.AWS_DEFAULT_REGION ?? /\.([a-z]{2}(?:-[a-z]+)+-\d+)\.rds\.amazonaws\.com$/.exec(url.hostname)?.[1];
|
|
123
|
+
return [
|
|
124
|
+
"aws",
|
|
125
|
+
"rds",
|
|
126
|
+
"generate-db-auth-token",
|
|
127
|
+
"--hostname",
|
|
128
|
+
url.hostname,
|
|
129
|
+
"--port",
|
|
130
|
+
url.port || "5432",
|
|
131
|
+
"--username",
|
|
132
|
+
input.user,
|
|
133
|
+
...(region !== undefined ? ["--region", region] : []),
|
|
134
|
+
];
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* A source that mints with `config` when asked. Nothing runs until `get()`.
|
|
141
|
+
* `now` and `run` are for tests.
|
|
142
|
+
*/
|
|
143
|
+
export function credentialSource(
|
|
144
|
+
config: TokenSourceConfig,
|
|
145
|
+
input: {
|
|
146
|
+
url: string;
|
|
147
|
+
user?: string;
|
|
148
|
+
env: Record<string, string | undefined>;
|
|
149
|
+
source: string;
|
|
150
|
+
run?: RunCommand;
|
|
151
|
+
now?: () => number;
|
|
152
|
+
},
|
|
153
|
+
): CredentialSource {
|
|
154
|
+
const run = input.run ?? runCommand;
|
|
155
|
+
const now = input.now ?? Date.now;
|
|
156
|
+
const lifetimeMs = (config.ttlSeconds ?? DEFAULT_TTL_SECONDS[config.token]) * 1000 * REUSE_FRACTION;
|
|
157
|
+
const source = `${input.source} (${config.token})`;
|
|
158
|
+
let cached: { token: string; until: number } | undefined;
|
|
159
|
+
let pending: Promise<string> | undefined;
|
|
160
|
+
const mint = async (): Promise<string> => {
|
|
161
|
+
const argv = tokenCommand(config, input);
|
|
162
|
+
let out: string;
|
|
163
|
+
try {
|
|
164
|
+
out = await run(argv, input.env);
|
|
165
|
+
} catch (err) {
|
|
166
|
+
const e = err as { code?: unknown; stderr?: unknown };
|
|
167
|
+
const why = e.code === "ENOENT" ? `${argv[0]} is not installed` : `it exited with ${String(e.code ?? "an error")}`;
|
|
168
|
+
throw new TokenSourceError(`${source} could not mint a token: \`${argv[0]}\` failed, ${why}`);
|
|
169
|
+
}
|
|
170
|
+
const token = out.trim();
|
|
171
|
+
if (token === "") throw new TokenSourceError(`${source} could not mint a token: \`${argv[0]}\` printed nothing`);
|
|
172
|
+
cached = { token, until: now() + lifetimeMs };
|
|
173
|
+
return token;
|
|
174
|
+
};
|
|
175
|
+
return {
|
|
176
|
+
source,
|
|
177
|
+
async get() {
|
|
178
|
+
if (cached && now() < cached.until) return cached.token;
|
|
179
|
+
// Requests that ask at once share one mint.
|
|
180
|
+
pending ??= mint().finally(() => {
|
|
181
|
+
pending = undefined;
|
|
182
|
+
});
|
|
183
|
+
return pending;
|
|
184
|
+
},
|
|
185
|
+
invalidate() {
|
|
186
|
+
cached = undefined;
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
}
|