import { createHash } from 'node:crypto' import { readFileSync } from 'node:fs' import type { Pool, PoolClient } from 'pg' import type { TfInstance, TfModule, TfvarsFileStatus, TfvarsRevision } from '../shared/tfvars.ts' import { HttpError } from './http.ts' /** A variable file of an instance: its name and its content, the object jsonb keeps written as JSON text. */ export type TfvarsFile = { name: string; content: Buffer } /** * The variable files of an instance, by name, with their version, versionOf the files, and their newest revision, null * for an instance never written. */ export type TfvarsDocument = { files: TfvarsFile[]; status: TfvarsFileStatus[]; version: string; revision: TfvarsRevision | null } /** A revision with its files, by name. */ export type TfvarsRevisionFiles = TfvarsRevision & { files: TfvarsFile[] } /** What a write records with the files: the schema version of their layout, null when not known, and the producer. */ export type RevisionMeta = { schema: number | null; producer: string } /** The variable files of one instance. */ export type InstanceVariables = { read(): Promise /** * Writes the given files, name and JSON text of an object, when the files are still those of version, keeping each * replaced content as its previous one, and records all files as the next revision with meta; answers false, writing * nothing, when they changed since. A write of no file records no revision. */ write(version: string, contents: Record, meta: RevisionMeta): Promise /** * Deletes the file name when the files are still those of version, and records the remaining files as the next * revision with meta; answers false, deleting nothing, otherwise. */ remove(version: string, name: string, meta: RevisionMeta): Promise /** The revisions, newest first. */ revisions(): Promise /** The revision of the number with its files; null when there is none. */ revision(revision: number): Promise } /** The modules, the instances and their variable files in PostgreSQL. */ export type TfvarsStore = { modules: { list(): Promise /** Creates the module, or changes the one of its name. */ put(module: TfModule): Promise /** Deletes the module; HttpError 409 while it has instances. */ delete(name: string): Promise } instances: { list(module: string): Promise /** HttpError 409 for a name or a state schema in use, 404 for a module that does not exist. */ create(module: string, name: string, stateSchema: string): Promise /** Deletes the instance with its variable files. */ delete(module: string, name: string): Promise } variables(module: string, instance: string): InstanceVariables } /** The text of a variable file as both stores write it: the object as JSON with two spaces, and a final newline. */ export function tfvarsText(content: unknown): string { return `${JSON.stringify(content, null, 2)}\n` } /** Identifies the variable files: the name and the SHA-256 of the content of each, in name order. */ export function versionOf(files: TfvarsFile[]): string { return [...files].sort((a, b) => a.name.localeCompare(b.name)) .map((file) => `${file.name}=${createHash('sha256').update(file.content).digest('hex')}`).join(':') } const SQL = new URL('./sql/tfvars_store.sql', import.meta.url) type FileRow = { name: string; content: object; previous: boolean; updated: Date } type RevisionRow = { revision: number; schema: number | null; producer: string; saved: Date; names: string[] } const revisionOf = (row: Omit, names: string[]): TfvarsRevision => ({ revision: row.revision, schema: row.schema, producer: row.producer, saved: row.saved.toISOString(), names }) /** The files of an object of revision files, by name, written as JSON text. */ const filesOf = (files: Record): TfvarsFile[] => Object.keys(files).sort().map((name) => ({ name, content: Buffer.from(tfvarsText(files[name]), 'utf8') })) /** * The store of sql/tfvars_store.sql in the database of pool, applied at the first use in the schema of the * connection's search path. A refused change is an HttpError: 409 for a name in use or a module that has instances, * 404 for a module or an instance that does not exist, 400 for a value the tables do not accept. */ export function createTfvarsStore(pool: Pool): TfvarsStore { let created: Promise | null = null const ready = () => (created ??= pool.query(readFileSync(SQL, 'utf8'))) const query = async (text: string, values: unknown[]): Promise => { await ready() try { return (await pool.query(text, values)).rows } catch (error) { throw refused(error) } } const document = (rows: FileRow[], newest: RevisionRow | undefined): TfvarsDocument => { const files = rows.map((row) => ({ name: row.name, content: Buffer.from(tfvarsText(row.content), 'utf8') })) return { files, status: rows.map((row, index) => ({ name: row.name, bytes: files[index].content.length, modified: row.updated.toISOString(), previous: row.previous })), version: versionOf(files), revision: newest === undefined ? null : revisionOf(newest, newest.names), } } /** The files and the newest revision of an instance, read in one snapshot. */ const read = async (module: string, instance: string): Promise => { await ready() const client = await pool.connect() try { await client.query('BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY') const files = await client.query('SELECT * FROM tf_variable_files_read($1, $2)', [module, instance]) const revisions = await client.query('SELECT * FROM tf_variable_revisions_list($1, $2) LIMIT 1', [module, instance]) await client.query('COMMIT') return document(files.rows, revisions.rows[0]) } catch (error) { await client.query('ROLLBACK') throw refused(error) } finally { client.release() } } /** * Runs change in a transaction that holds the lock of the variable files, when they are still those of version, and * records the files after it as the next revision with meta. */ const locked = async (module: string, instance: string, version: string, meta: RevisionMeta, change: (client: PoolClient) => Promise) => { await ready() const client = await pool.connect() try { await client.query('BEGIN') await client.query('CALL tf_variable_files_lock()') const { rows } = await client.query('SELECT * FROM tf_variable_files_read($1, $2)', [module, instance]) if (document(rows, undefined).version !== version) { await client.query('ROLLBACK') return false } await change(client) await client.query('CALL tf_variable_revision_add($1, $2, $3, $4)', [module, instance, meta.schema, meta.producer]) await client.query('COMMIT') return true } catch (error) { await client.query('ROLLBACK') throw refused(error) } finally { client.release() } } return { modules: { list: async () => (await query<{ name: string; root: string; description: string; configurator_url: string | null }>('SELECT * FROM tf_modules_list()', [])) .map((row) => ({ name: row.name, root: row.root, description: row.description, configuratorUrl: row.configurator_url })), async put(module) { await query('CALL tf_module_put($1, $2, $3, $4)', [module.name, module.root, module.description, module.configuratorUrl]) }, async delete(name) { await query('CALL tf_module_delete($1)', [name]) }, }, instances: { list: async (module) => (await query<{ module: string; name: string; state_schema: string; created: Date }>('SELECT * FROM tf_instances_list($1)', [module])) .map((row) => ({ module: row.module, name: row.name, stateSchema: row.state_schema, created: row.created.toISOString() })), async create(module, name, stateSchema) { await query('CALL tf_instance_create($1, $2, $3)', [module, name, stateSchema]) }, async delete(module, name) { await query('CALL tf_instance_delete($1, $2)', [module, name]) }, }, variables: (module, instance) => ({ read: () => read(module, instance), write: async (version, contents, meta) => { if (Object.keys(contents).length === 0) { return (await read(module, instance)).version === version } return locked(module, instance, version, meta, async (client) => { for (const [name, text] of Object.entries(contents)) { await client.query('CALL tf_variable_file_put($1, $2, $3, $4::jsonb)', [module, instance, name, text]) } }) }, remove: (version, name, meta) => locked(module, instance, version, meta, async (client) => { await client.query('CALL tf_variable_file_delete($1, $2, $3)', [module, instance, name]) }), revisions: async () => (await query('SELECT * FROM tf_variable_revisions_list($1, $2)', [module, instance])) .map((row) => revisionOf(row, row.names)), revision: async (revision) => { const [row] = await query & { files: Record }>( 'SELECT * FROM tf_variable_revision_get($1, $2, $3)', [module, instance, revision]) if (row === undefined) { return null } const files = filesOf(row.files) return { ...revisionOf(row, files.map((file) => file.name)), files } }, }), } } /** The HttpError of an error of PostgreSQL that refuses a change, by its SQLSTATE; any other error as it is. */ function refused(error: unknown): unknown { const { code, constraint, detail } = error as { code?: string; constraint?: string; detail?: string } const reason = detail ?? (error as Error).message switch (code) { case '23505': return new HttpError(409, `The name is in use (${reason}).`) case '23503': if (reason.includes('is still referenced')) { return new HttpError(409, `The module has instances; delete them first (${reason}).`) } return new HttpError(404, `The ${constraint === 'tf_instances_module_fkey' ? 'module' : 'instance'} does not exist (${reason}).`) case '23514': case '22P02': return new HttpError(400, `The value is not allowed (${reason}).`) default: return error } }