import { createHash } from 'node:crypto' import { readFileSync } from 'node:fs' import type { Pool, PoolClient } from 'pg' import type { TfInstance, TfModule, TfvarsFileStatus } 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. */ export type TfvarsDocument = { files: TfvarsFile[]; status: TfvarsFileStatus[]; version: 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; answers false, writing nothing, when they changed since. */ write(version: string, contents: Record): Promise /** Deletes the file name when the files are still those of version; answers false, deleting nothing, otherwise. */ remove(version: string, name: string): 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 } /** * 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[]): 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), } } /** Runs change in a transaction that holds the lock of the variable files, when they are still those of version. */ const locked = async (module: string, instance: string, version: string, 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).version !== version) { await client.query('ROLLBACK') return false } await change(client) 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: async () => document(await query('SELECT * FROM tf_variable_files_read($1, $2)', [module, instance])), write: (version, contents) => locked(module, instance, version, 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) => locked(module, instance, version, async (client) => { await client.query('CALL tf_variable_file_delete($1, $2, $3)', [module, instance, name]) }), }), } } /** 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 } }