feature: every write of an instance's variable files is kept as a revision with the schema version of their layout and the application that wrote them.
Release / release (push) Successful in 1m8s
Release / release (push) Successful in 1m8s
This commit is contained in:
1 parent
7c335c7ed2
commit
5e62e143b6
11 files changed
+286
-37
No files matched your search
@@ -135,3 +135,25 @@ exporting them too.
|
||||
|
||||
Consequences: An application's shared API types import from `/shared` without the code of either
|
||||
side. A type added to `shared/` is exported from `/shared` as well as from the side that uses it.
|
||||
|
||||
## ADR-7: Every write of an instance's variable files is a revision with its schema and producer
|
||||
|
||||
Status: Accepted
|
||||
|
||||
Context: A configurator changes the layout of the variable files it writes from one version to
|
||||
the next, and Cloud Platform has to tell whether an instance's files are of the layout its
|
||||
module's configurator writes before it runs Terraform with them. The files written earlier have
|
||||
to stay available, to restore or compare them.
|
||||
|
||||
Decision: `tf_variable_revisions` holds every write of an instance's files: all files after the
|
||||
write, the schema version of their layout as the writer gives it, null when it does not know it,
|
||||
the producer, the writing application with its version, and the time. `write` and `remove` take
|
||||
the schema and the producer and record the revision in the transaction of the change, under the
|
||||
lock of the files; a write of no file records none. The current files stay in `tf_variable_files`,
|
||||
so reading them and their version is unchanged. Revisions are kept until removed by hand with
|
||||
SQL. Files without revisions are recorded as revision 1 of an unknown producer and schema when the
|
||||
store is first used.
|
||||
|
||||
Consequences: The schema of an instance's files is the schema of its newest revision; files
|
||||
imported without a schema, or written before revisions, have none until a configurator writes
|
||||
them. Revisions grow with every write; the README describes the SQL that removes them.
|
||||
@@ -1,5 +1,22 @@
|
||||
# Changelog
|
||||
|
||||
## 0.7.0
|
||||
|
||||
feature: every write of an instance's variable files is kept as a revision with the schema version
|
||||
of their layout and the application that wrote them.
|
||||
|
||||
- `tf_variable_revisions` keeps all files of an instance after each write, with `schema`, null when
|
||||
not known, `producer` and `saved`; revisions are numbered from 1 for each instance and kept until
|
||||
removed by hand.
|
||||
- `write(version, contents, meta)` and `remove(version, name, meta)` take `meta`, `{ schema,
|
||||
producer }`, and record the revision in the same transaction; a write of no file records none.
|
||||
- `read()` answers the newest revision as `revision`; `revisions()` lists them, newest first, and
|
||||
`revision(n)` answers one with its files. `TfvarsRevision` is exported from `/server` and
|
||||
`/shared`.
|
||||
- The files of an instance without revisions become its revision 1, of the producer `unknown` and
|
||||
no schema.
|
||||
- The README describes the SQL that purges some or all revisions of a module's instance.
|
||||
|
||||
## 0.6.0-2
|
||||
|
||||
fix: release.mjs reads a summary wrapped over several lines.
|
||||
|
||||
@@ -180,6 +180,7 @@ which edit their variables. `createTfvarsStore(pool)` takes a `pg` pool and appl
|
||||
| `tf_modules` | `name`, `root`, the folder of the Terraform code, `description` and `configurator_url` |
|
||||
| `tf_instances` | `module`, `name` and `state_schema`, the schema of the instance's state in the pg backend, unique, and `created` |
|
||||
| `tf_variable_files` | `module`, `instance`, `name` (`*.tfvars.json`), `content` and `previous`, the object it replaced, as `jsonb`, and `updated` |
|
||||
| `tf_variable_revisions` | `module`, `instance`, `revision`, numbered from 1 for each instance, `files`, all its files after a write as a `jsonb` object by name, `schema`, the schema version of their layout or null, `producer` and `saved` |
|
||||
|
||||
Names of modules and instances are `TF_NAME_PATTERN`, of variable files `TFVARS_FILE_PATTERN`,
|
||||
of state schemas `STATE_SCHEMA_PATTERN`.
|
||||
@@ -187,11 +188,42 @@ of state schemas `STATE_SCHEMA_PATTERN`.
|
||||
- `modules.list()`, `modules.put(module)`, which creates or changes, and `modules.delete(name)`.
|
||||
- `instances.list(module)`, `instances.create(module, name, stateSchema)` and
|
||||
`instances.delete(module, name)`, which deletes its variable files too.
|
||||
- `variables(module, instance)`: `read()` answers the files, their sizes and times and the
|
||||
version, `versionOf` the files, the name and the SHA-256 of each in name order. `write(version,
|
||||
contents)` writes files given by name as JSON text and `remove(version, name)` deletes one; both
|
||||
lock the table, compare the version and change in one transaction, and answer `false`, changing
|
||||
nothing, when the files changed since the version.
|
||||
- `variables(module, instance)`: `read()` answers the files, their sizes and times, the version,
|
||||
`versionOf` the files, the name and the SHA-256 of each in name order, and the newest revision.
|
||||
`write(version, contents, meta)` writes files given by name as JSON text and `remove(version,
|
||||
name, meta)` deletes one; both lock the table, compare the version, change and record all files
|
||||
as the next revision with `meta`, `{ schema, producer }`, in one transaction, and answer
|
||||
`false`, changing nothing, when the files changed since the version. A write of no file records
|
||||
no revision. `revisions()` lists the revisions, newest first, without their files, and
|
||||
`revision(n)` answers one with its files, or `null`.
|
||||
|
||||
Every revision is kept. The files of an instance without any revision, as those written before
|
||||
revisions existed, are recorded as its revision 1, of the producer `unknown` and no schema, when
|
||||
the store is first used.
|
||||
|
||||
### Purging revisions
|
||||
|
||||
Revisions are removed by hand, with `psql` connected to the database of the store as the owner of
|
||||
its tables, in the schema the applications use (`SET search_path TO <schema>;` when it is not the
|
||||
default). Each statement removes the revisions of one instance, here `router`/`platform`:
|
||||
|
||||
```sql
|
||||
-- All revisions but the newest.
|
||||
DELETE FROM tf_variable_revisions
|
||||
WHERE module = 'router' AND instance = 'platform'
|
||||
AND revision < (SELECT max(revision) FROM tf_variable_revisions WHERE module = 'router' AND instance = 'platform');
|
||||
|
||||
-- The revisions before revision 10.
|
||||
DELETE FROM tf_variable_revisions WHERE module = 'router' AND instance = 'platform' AND revision < 10;
|
||||
|
||||
-- All revisions of the instance.
|
||||
DELETE FROM tf_variable_revisions WHERE module = 'router' AND instance = 'platform';
|
||||
```
|
||||
|
||||
Leaving out `AND instance = ...` applies a statement to every instance of the module. The next
|
||||
write numbers its revision after the newest one left. An instance whose revisions are all removed
|
||||
gets its current files back as revision 1, of the producer `unknown` and no schema, when the store
|
||||
is next first used, so its schema is unknown until the next write by its configurator.
|
||||
|
||||
A change the tables refuse throws `HttpError`: 409 for a name or a state schema in use and for a
|
||||
module that has instances, 404 for a module or an instance that does not exist, 400 for a name or
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@koszewscy/sk-react-toolkit",
|
||||
"version": "0.6.0",
|
||||
"version": "0.7.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@koszewscy/sk-react-toolkit",
|
||||
"version": "0.6.0",
|
||||
"version": "0.7.0",
|
||||
"devDependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@koszewscy/sk-react-toolkit",
|
||||
"version": "0.6.0",
|
||||
"version": "0.7.0",
|
||||
"description": "Shared code of the React and Fastify web applications: web components, server helpers and the Vite font plugin.",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
+12
-2
@@ -4,6 +4,16 @@ export { HttpError, errorHandler, notFoundHandler, strict } from './http.ts'
|
||||
export { QUIET, createLog, fail, timestampsOn, type Log } from './log.ts'
|
||||
export { exitOnSignals, gitVersion, readJsonObject } from './process.ts'
|
||||
export { decrypt, encrypt, encryptionKey } from './secrets.ts'
|
||||
export type { TfInstance, TfModule, TfvarsFileStatus } from '../shared/tfvars.ts'
|
||||
export type { TfInstance, TfModule, TfvarsFileStatus, TfvarsRevision } from '../shared/tfvars.ts'
|
||||
export { STATE_SCHEMA_PATTERN, TFVARS_FILE_PATTERN, TF_NAME_PATTERN } from '../shared/tfvars.ts'
|
||||
export { createTfvarsStore, tfvarsText, versionOf, type InstanceVariables, type TfvarsDocument, type TfvarsFile, type TfvarsStore } from './tfvarsStore.ts'
|
||||
export {
|
||||
createTfvarsStore,
|
||||
tfvarsText,
|
||||
versionOf,
|
||||
type InstanceVariables,
|
||||
type RevisionMeta,
|
||||
type TfvarsDocument,
|
||||
type TfvarsFile,
|
||||
type TfvarsRevisionFiles,
|
||||
type TfvarsStore,
|
||||
} from './tfvarsStore.ts'
|
||||
@@ -1,6 +1,7 @@
|
||||
-- The store of Terraform modules, their instances and the instances' variable files: the tables tf_modules,
|
||||
-- tf_instances and tf_variable_files, and the functions and procedures the store calls. The store applies the file on
|
||||
-- its first use in the schema its connection's search path names; every statement can run again.
|
||||
-- tf_instances, tf_variable_files and tf_variable_revisions, and the functions and procedures the store calls. The
|
||||
-- store applies the file on its first use in the schema its connection's search path names; every statement can run
|
||||
-- again.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tf_modules (
|
||||
name text PRIMARY KEY CHECK (name ~ '^[a-z][a-z0-9-]*$'),
|
||||
@@ -28,6 +29,28 @@ CREATE TABLE IF NOT EXISTS tf_variable_files (
|
||||
FOREIGN KEY (module, instance) REFERENCES tf_instances (module, name) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Every write of an instance's variable files: all its files after the write, by name, the schema version of their
|
||||
-- layout, null when not known, and the application that wrote them.
|
||||
CREATE TABLE IF NOT EXISTS tf_variable_revisions (
|
||||
module text NOT NULL,
|
||||
instance text NOT NULL,
|
||||
revision integer NOT NULL CHECK (revision > 0),
|
||||
files jsonb NOT NULL CHECK (jsonb_typeof(files) = 'object'),
|
||||
schema integer CHECK (schema > 0),
|
||||
producer text NOT NULL CHECK (producer <> ''),
|
||||
saved timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (module, instance, revision),
|
||||
FOREIGN KEY (module, instance) REFERENCES tf_instances (module, name) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- The files of an instance that has no revision are its revision 1, of an unknown schema and producer.
|
||||
INSERT INTO tf_variable_revisions (module, instance, revision, files, schema, producer, saved)
|
||||
SELECT f.module, f.instance, 1, jsonb_object_agg(f.name, f.content), NULL, 'unknown', max(f.updated)
|
||||
FROM tf_variable_files f
|
||||
WHERE NOT EXISTS (SELECT 1 FROM tf_variable_revisions r WHERE r.module = f.module AND r.instance = f.instance)
|
||||
GROUP BY f.module, f.instance
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- The modules, by name.
|
||||
CREATE OR REPLACE FUNCTION tf_modules_list()
|
||||
RETURNS TABLE (name text, root text, description text, configurator_url text)
|
||||
@@ -115,3 +138,36 @@ SET search_path FROM CURRENT
|
||||
AS $$
|
||||
DELETE FROM tf_variable_files WHERE module = module_name AND instance = instance_name AND name = file_name
|
||||
$$;
|
||||
|
||||
-- Records the variable files of an instance as its next revision, with the schema of their layout and their producer;
|
||||
-- called with the lock of tf_variable_files_lock held.
|
||||
CREATE OR REPLACE PROCEDURE tf_variable_revision_add(module_name text, instance_name text, revision_schema integer, revision_producer text)
|
||||
LANGUAGE sql
|
||||
SET search_path FROM CURRENT
|
||||
AS $$
|
||||
INSERT INTO tf_variable_revisions (module, instance, revision, files, schema, producer)
|
||||
SELECT module_name, instance_name,
|
||||
(SELECT coalesce(max(r.revision), 0) + 1 FROM tf_variable_revisions r WHERE r.module = module_name AND r.instance = instance_name),
|
||||
(SELECT coalesce(jsonb_object_agg(f.name, f.content), '{}'::jsonb) FROM tf_variable_files f WHERE f.module = module_name AND f.instance = instance_name),
|
||||
revision_schema, revision_producer
|
||||
$$;
|
||||
|
||||
-- The revisions of an instance, newest first, with the names of their files.
|
||||
CREATE OR REPLACE FUNCTION tf_variable_revisions_list(module_name text, instance_name text)
|
||||
RETURNS TABLE (revision integer, schema integer, producer text, saved timestamptz, names text[])
|
||||
LANGUAGE sql STABLE
|
||||
SET search_path FROM CURRENT
|
||||
AS $$
|
||||
SELECT r.revision, r.schema, r.producer, r.saved, ARRAY(SELECT jsonb_object_keys(r.files) ORDER BY 1)
|
||||
FROM tf_variable_revisions r WHERE r.module = module_name AND r.instance = instance_name ORDER BY r.revision DESC
|
||||
$$;
|
||||
|
||||
-- A revision of an instance with its files.
|
||||
CREATE OR REPLACE FUNCTION tf_variable_revision_get(module_name text, instance_name text, revision_number integer)
|
||||
RETURNS TABLE (revision integer, schema integer, producer text, saved timestamptz, files jsonb)
|
||||
LANGUAGE sql STABLE
|
||||
SET search_path FROM CURRENT
|
||||
AS $$
|
||||
SELECT r.revision, r.schema, r.producer, r.saved, r.files
|
||||
FROM tf_variable_revisions r WHERE r.module = module_name AND r.instance = instance_name AND r.revision = revision_number
|
||||
$$;
|
||||
@@ -60,29 +60,62 @@ test('instances are created and refused for a missing module or a name in use',
|
||||
assert.equal(await status(store.modules.delete('router')), 409)
|
||||
})
|
||||
|
||||
const META = { schema: 1, producer: 'Test Configurator 1.0.0' }
|
||||
|
||||
test('variable files are written with their version and refused against another version', async () => {
|
||||
const variables = store.variables('router', 'test')
|
||||
const empty = await variables.read()
|
||||
assert.deepEqual(empty.files, [])
|
||||
assert.equal(empty.version, '')
|
||||
assert.equal(await variables.write(empty.version, { 'platform.tfvars.json': tfvarsText({ a: 1 }), 'secrets.tfvars.json': tfvarsText({ s: 'x' }) }), true)
|
||||
assert.equal(empty.revision, null)
|
||||
assert.equal(await variables.write(empty.version, { 'platform.tfvars.json': tfvarsText({ a: 1 }), 'secrets.tfvars.json': tfvarsText({ s: 'x' }) }, META), true)
|
||||
const written = await variables.read()
|
||||
assert.deepEqual(written.files.map((file) => [file.name, file.content.toString('utf8')]), [
|
||||
['platform.tfvars.json', tfvarsText({ a: 1 })],
|
||||
['secrets.tfvars.json', tfvarsText({ s: 'x' })],
|
||||
])
|
||||
assert.deepEqual(written.status.map((file) => file.previous), [false, false])
|
||||
assert.equal(await variables.write(empty.version, { 'platform.tfvars.json': tfvarsText({ a: 2 }) }), false)
|
||||
assert.equal(await variables.write(written.version, { 'platform.tfvars.json': tfvarsText({ a: 2 }) }), true)
|
||||
assert.equal(await variables.write(empty.version, { 'platform.tfvars.json': tfvarsText({ a: 2 }) }, META), false)
|
||||
assert.equal(await variables.write(written.version, { 'platform.tfvars.json': tfvarsText({ a: 2 }) }, META), true)
|
||||
const changed = await variables.read()
|
||||
assert.deepEqual(changed.status.map((file) => file.previous), [true, false])
|
||||
assert.notEqual(changed.version, written.version)
|
||||
assert.equal(await variables.remove(written.version, 'secrets.tfvars.json'), false)
|
||||
assert.equal(await variables.remove(changed.version, 'secrets.tfvars.json'), true)
|
||||
assert.equal(await variables.remove(written.version, 'secrets.tfvars.json', META), false)
|
||||
assert.equal(await variables.remove(changed.version, 'secrets.tfvars.json', META), true)
|
||||
assert.deepEqual((await variables.read()).files.map((file) => file.name), ['platform.tfvars.json'])
|
||||
assert.deepEqual((await store.variables('router', 'platform').read()).files, [])
|
||||
assert.equal(await status(variables.write((await variables.read()).version, { 'notes.json': tfvarsText({}) })), 400)
|
||||
assert.equal(await status(store.variables('router', 'missing').write('', { 'a.tfvars.json': tfvarsText({}) })), 404)
|
||||
assert.equal(await status(variables.write((await variables.read()).version, { 'notes.json': tfvarsText({}) }, META)), 400)
|
||||
assert.equal(await status(store.variables('router', 'missing').write('', { 'a.tfvars.json': tfvarsText({}) }, META)), 404)
|
||||
})
|
||||
|
||||
test('every write is a revision with its files, schema and producer', async () => {
|
||||
const variables = store.variables('router', 'test')
|
||||
assert.deepEqual((await variables.revisions()).map((revision) => [revision.revision, revision.schema, revision.producer, revision.names]), [
|
||||
[3, 1, META.producer, ['platform.tfvars.json']],
|
||||
[2, 1, META.producer, ['platform.tfvars.json', 'secrets.tfvars.json']],
|
||||
[1, 1, META.producer, ['platform.tfvars.json', 'secrets.tfvars.json']],
|
||||
])
|
||||
const current = await variables.read()
|
||||
assert.equal(await variables.write(current.version, {}, META), true)
|
||||
assert.equal(await variables.write(current.version, { 'platform.tfvars.json': tfvarsText({ a: 3 }) }, { schema: null, producer: 'manage.mjs import' }), true)
|
||||
const newest = (await variables.read()).revision
|
||||
assert.deepEqual(newest && [newest.revision, newest.schema, newest.producer], [4, null, 'manage.mjs import'])
|
||||
const first = await variables.revision(1)
|
||||
assert.deepEqual(first?.files.map((file) => [file.name, file.content.toString('utf8')]), [
|
||||
['platform.tfvars.json', tfvarsText({ a: 1 })],
|
||||
['secrets.tfvars.json', tfvarsText({ s: 'x' })],
|
||||
])
|
||||
assert.equal(await variables.revision(9), null)
|
||||
assert.deepEqual(await store.variables('router', 'platform').revisions(), [])
|
||||
})
|
||||
|
||||
test('the files of an instance without revisions become its revision 1 of an unknown producer', async () => {
|
||||
await pool.query(`INSERT INTO tf_variable_files (module, instance, name, content) VALUES ('router', 'platform', 'a.tfvars.json', '{"b": 1}')`)
|
||||
await pool.query('DELETE FROM tf_variable_revisions')
|
||||
const again = createTfvarsStore(pool)
|
||||
const revisions = await again.variables('router', 'platform').revisions()
|
||||
assert.deepEqual(revisions.map((revision) => [revision.revision, revision.schema, revision.producer, revision.names]), [[1, null, 'unknown', ['a.tfvars.json']]])
|
||||
assert.deepEqual((await again.variables('router', 'test').revisions()).map((revision) => revision.revision), [1])
|
||||
})
|
||||
|
||||
test('an instance is deleted with its variable files, then its module', async () => {
|
||||
@@ -90,6 +123,7 @@ test('an instance is deleted with its variable files, then its module', async ()
|
||||
await store.instances.delete('router', 'platform')
|
||||
assert.deepEqual(await store.instances.list('router'), [])
|
||||
assert.equal((await pool.query('SELECT count(*)::int AS n FROM tf_variable_files')).rows[0].n, 0)
|
||||
assert.equal((await pool.query('SELECT count(*)::int AS n FROM tf_variable_revisions')).rows[0].n, 0)
|
||||
await store.modules.delete('router')
|
||||
assert.deepEqual(await store.modules.list(), [])
|
||||
})
|
||||
+81
-17
@@ -1,25 +1,42 @@
|
||||
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 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. */
|
||||
export type TfvarsDocument = { files: TfvarsFile[]; status: TfvarsFileStatus[]; version: string }
|
||||
/**
|
||||
* 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; answers false, writing nothing, when they changed since.
|
||||
* 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>): Promise<boolean>
|
||||
/** Deletes the file name when the files are still those of version; answers false, deleting nothing, otherwise. */
|
||||
remove(version: string, name: string): Promise<boolean>
|
||||
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. */
|
||||
@@ -56,6 +73,15 @@ 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,
|
||||
@@ -72,27 +98,49 @@ export function createTfvarsStore(pool: Pool): TfvarsStore {
|
||||
throw refused(error)
|
||||
}
|
||||
}
|
||||
const document = (rows: FileRow[]): TfvarsDocument => {
|
||||
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),
|
||||
}
|
||||
}
|
||||
/** 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<void>) => {
|
||||
/** 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).version !== version) {
|
||||
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) {
|
||||
@@ -124,15 +172,31 @@ export function createTfvarsStore(pool: Pool): TfvarsStore {
|
||||
},
|
||||
},
|
||||
variables: (module, instance) => ({
|
||||
read: async () => document(await query<FileRow>('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])
|
||||
read: () => read(module, instance),
|
||||
write: async (version, contents, meta) => {
|
||||
if (Object.keys(contents).length === 0) {
|
||||
return (await read(module, instance)).version === version
|
||||
}
|
||||
}),
|
||||
remove: (version, name) => locked(module, instance, version, async (client) => {
|
||||
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 }
|
||||
},
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
export type { CredentialField, CredentialKind, CredentialProfile, CredentialsDocument } from './credentials.ts'
|
||||
export type { TfInstance, TfModule, TfvarsFileStatus } from './tfvars.ts'
|
||||
export type { TfInstance, TfModule, TfvarsFileStatus, TfvarsRevision } from './tfvars.ts'
|
||||
export { STATE_SCHEMA_PATTERN, TFVARS_FILE_PATTERN, TF_NAME_PATTERN } from './tfvars.ts'
|
||||
@@ -26,6 +26,20 @@ export type TfvarsFileStatus = {
|
||||
previous: boolean
|
||||
}
|
||||
|
||||
/** A write of an instance's variable files, without their content. */
|
||||
export type TfvarsRevision = {
|
||||
/** The number of the write, from 1 for each instance. */
|
||||
revision: number
|
||||
/** The schema version of the files' layout, as their producer gave it; null when not known. */
|
||||
schema: number | null
|
||||
/** The application that wrote the files, with its version. */
|
||||
producer: string
|
||||
/** ISO 8601. */
|
||||
saved: string
|
||||
/** The names of the files of the revision. */
|
||||
names: string[]
|
||||
}
|
||||
|
||||
/** The names of modules and instances: a lower-case letter, then lower-case letters, digits and hyphens. */
|
||||
export const TF_NAME_PATTERN = /^[a-z][a-z0-9-]*$/
|
||||
|
||||
|
||||
Reference in new issue
Block a user