223 lines
11 KiB
TypeScript
223 lines
11 KiB
TypeScript
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<TfvarsDocument>
|
|
/**
|
|
* 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<string, string>, meta: RevisionMeta): Promise<boolean>
|
|
/**
|
|
* 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<boolean>
|
|
/** The revisions, newest first. */
|
|
revisions(): Promise<TfvarsRevision[]>
|
|
/** The revision of the number with its files; null when there is none. */
|
|
revision(revision: number): Promise<TfvarsRevisionFiles | null>
|
|
}
|
|
|
|
/** The modules, the instances and their variable files in PostgreSQL. */
|
|
export type TfvarsStore = {
|
|
modules: {
|
|
list(): Promise<TfModule[]>
|
|
/** Creates the module, or changes the one of its name. */
|
|
put(module: TfModule): Promise<void>
|
|
/** Deletes the module; HttpError 409 while it has instances. */
|
|
delete(name: string): Promise<void>
|
|
}
|
|
instances: {
|
|
list(module: string): Promise<TfInstance[]>
|
|
/** 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<void>
|
|
/** Deletes the instance with its variable files. */
|
|
delete(module: string, name: string): Promise<void>
|
|
}
|
|
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<RevisionRow, 'names'>, 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<string, object>): 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<unknown> | null = null
|
|
const ready = () => (created ??= pool.query(readFileSync(SQL, 'utf8')))
|
|
const query = async <T extends object>(text: string, values: unknown[]): Promise<T[]> => {
|
|
await ready()
|
|
try {
|
|
return (await pool.query<T>(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<TfvarsDocument> => {
|
|
await ready()
|
|
const client = await pool.connect()
|
|
try {
|
|
await client.query('BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY')
|
|
const files = await client.query<FileRow>('SELECT * FROM tf_variable_files_read($1, $2)', [module, instance])
|
|
const revisions = await client.query<RevisionRow>('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<void>) => {
|
|
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<FileRow>('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; configurator_discovery: boolean }>('SELECT * FROM tf_modules_list()', []))
|
|
.map((row) => ({ name: row.name, root: row.root, description: row.description, configuratorUrl: row.configurator_url, configuratorDiscovery: row.configurator_discovery })),
|
|
async put(module) {
|
|
await query('CALL tf_module_put($1, $2, $3, $4, $5)', [module.name, module.root, module.description, module.configuratorUrl, module.configuratorDiscovery])
|
|
},
|
|
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<RevisionRow>('SELECT * FROM tf_variable_revisions_list($1, $2)', [module, instance]))
|
|
.map((row) => revisionOf(row, row.names)),
|
|
revision: async (revision) => {
|
|
const [row] = await query<Omit<RevisionRow, 'names'> & { files: Record<string, object> }>(
|
|
'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
|
|
}
|
|
}
|