3 Commits
14 changed files with 558 additions and 63 deletions

No files matched your search

+58
View File
@@ -135,3 +135,61 @@ 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.
## ADR-8: A Vite plugin sets the color scheme before the first paint
Status: Accepted
Context: `appTheme` puts the class of the color scheme on `<html>` from React, after the scripts
of the application have loaded. Until then a page in the dark scheme is white, which shows on
every load, and on every opening of a page that embeds an application in an iframe.
Decision: The `colorScheme()` plugin adds an inline script and style to `index.html`, so the class
is set and the dark background painted before the application loads. The script reads MUI's
storage key `mui-mode` and follows `prefers-color-scheme` for the system mode, as MUI does, and
the style uses MUI's default dark background, which `appTheme` keeps. The plugin is part of the
Vite helpers, which every application already uses, so the applications need no code of their own
in `index.html`.
Consequences: The storage key and the dark background are repeated in the plugin; a theme that
changes either has to change the plugin too. An application of the toolkit adds `colorScheme()`
to the plugins of its `vite.config.ts`.
## ADR-9: The checks run in a container built from the files git knows, with a scratch PostgreSQL
Status: Accepted
Context: The checks are faster on a remote Docker engine than on the developer's machine. The
store's tests need a PostgreSQL server whose role creates databases, and a remote engine sees
neither the checkout nor its `node_modules`.
Decision: `test-container.mjs` sends a tar archive of the files git tracks and the untracked files
it does not ignore as the build context of `test.Dockerfile`, whose build runs the checks that need
no server, and runs the tests in a container next to a PostgreSQL container on a network of their
own, both removed afterwards. The image's `package` stage holds the packed toolkit, which the
applications install to check against the checkout.
Consequences: The tests need no PostgreSQL server of the developer's. A file git ignores is not
part of the checks. The checks run on Linux, whatever the developer's system.
+39
View File
@@ -1,5 +1,44 @@
# Changelog
## 0.7.2
fix: a module of the tfvars store says whether it discovers its configurator, as
configuratorDiscovery.
- `TfModule` has `configuratorDiscovery`, and `tf_modules` the column `configurator_discovery`:
whether a module without `configurator_url` finds its configurator by the `config.json` of its
folder or one above it. A module registered before keeps it on; `modules.put` sets it.
- `tf_module_put` takes the new argument; its earlier signature is dropped.
## 0.7.1
fix: a page in the dark color scheme, also one in an iframe, is no longer white while it loads,
and test-container.mjs runs the checks with Docker.
- The `colorScheme()` plugin of `/vite` sets the color scheme of `appTheme` and the dark background
on the page before its first paint; an application adds it to the plugins of its
`vite.config.ts`.
- `./test-container.mjs` runs the typecheck, the lint, the package build and the tests in a
container, on the engine of the docker CLI's context, with a scratch PostgreSQL;
`--package <folder>` writes the packed toolkit to the folder.
## 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.
+50 -10
View File
@@ -19,15 +19,15 @@ published version and imports `@koszewscy/sk-react-toolkit/server`, `/shared`, `
{ "dependencies": { "@koszewscy/sk-react-toolkit": "^0.2.0" } }
```
The web application adds the toolkit's resolve options and font plugin to its `vite.config.ts`, so
React, MUI and Emotion come from its own `node_modules`:
The web application adds the toolkit's resolve options, so React, MUI and Emotion come from its
own `node_modules`, and its font and color scheme plugins to its `vite.config.ts`:
```ts
import { font, fontChoice, TOOLKIT_RESOLVE } from '@koszewscy/sk-react-toolkit/vite'
import { colorScheme, font, fontChoice, TOOLKIT_RESOLVE } from '@koszewscy/sk-react-toolkit/vite'
import { defineConfig, loadEnv } from 'vite'
export default defineConfig(({ mode }) => ({
plugins: [react(), font(fontChoice(loadEnv(mode, import.meta.dirname)))],
plugins: [react(), font(fontChoice(loadEnv(mode, import.meta.dirname))), colorScheme()],
resolve: TOOLKIT_RESOLVE,
}))
```
@@ -94,6 +94,7 @@ API types imported by both, without the code of either: `CredentialField`, `Cred
| Export | Purpose |
| --- | --- |
| `font(choice)`, `fontChoice(env)`, `FONTS` | the font plugin of `VITE_FONT`: `cdn` (Google Fonts links, the default), `bundled` (`@fontsource/roboto`, a dependency of the application) or `none` |
| `colorScheme()` | the plugin that sets the color scheme of `appTheme`, chosen in the theme menu or by the system, on the page before its first paint, so a dark page, also one in an iframe, does not show white while it loads |
| `TOOLKIT_RESOLVE` | the resolve options that keep one copy of React, MUI and Emotion |
## Credential store
@@ -177,9 +178,10 @@ which edit their variables. `createTfvarsStore(pool)` takes a `pg` pool and appl
| Table | Rows |
| --- | --- |
| `tf_modules` | `name`, `root`, the folder of the Terraform code, `description` and `configurator_url` |
| `tf_modules` | `name`, `root`, the folder of the Terraform code, `description`, `configurator_url` and `configurator_discovery`, whether a module without `configurator_url` finds its configurator by the `config.json` of its folder or one above it; a module registered before the column keeps it on, a new one has it off unless given |
| `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 +189,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
@@ -207,6 +240,13 @@ database `postgres`. `npm run build` (`build.mjs`) builds the published package
`dist/`: the code compiled to JavaScript with its declarations, the SQL of the server, README.md, CHANGELOG.md and a
`package.json` whose exports name the compiled files.
`./test-container.mjs` runs the same checks with Docker, on the engine of the docker CLI's
context, which may be remote: it sends the files git tracks, and the untracked files it does not
ignore, as the build context of `test.Dockerfile`, whose build runs the typecheck, the lint and
the package build, and runs the tests against a scratch PostgreSQL in a container on a private
network, removed afterwards. `--package <folder>` writes the packed toolkit to the folder instead,
for the tests of an application against the checkout.
## Releases
`./release.mjs` commits, tags the version of the newest CHANGELOG.md entry and pushes `main` with
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@koszewscy/sk-react-toolkit",
"version": "0.6.0",
"version": "0.7.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@koszewscy/sk-react-toolkit",
"version": "0.6.0",
"version": "0.7.2",
"devDependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "@koszewscy/sk-react-toolkit",
"version": "0.6.0",
"description": "Shared code of the React and Fastify web applications: web components, server helpers and the Vite font plugin.",
"version": "0.7.2",
"description": "Shared code of the React and Fastify web applications: web components, server helpers and the Vite font and color scheme plugins.",
"private": true,
"type": "module",
"engines": {
+12 -2
View File
@@ -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'
+94 -13
View File
@@ -1,14 +1,27 @@
-- 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-]*$'),
root text NOT NULL CHECK (root <> ''),
description text NOT NULL DEFAULT '',
configurator_url text
name text PRIMARY KEY CHECK (name ~ '^[a-z][a-z0-9-]*$'),
root text NOT NULL CHECK (root <> ''),
description text NOT NULL DEFAULT '',
configurator_url text,
configurator_discovery boolean NOT NULL DEFAULT false
);
-- A module registered before configurator_discovery keeps finding its configurator.
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_schema = current_schema() AND table_name = 'tf_modules' AND column_name = 'configurator_discovery') THEN
ALTER TABLE tf_modules ADD COLUMN configurator_discovery boolean NOT NULL DEFAULT true;
ALTER TABLE tf_modules ALTER COLUMN configurator_discovery SET DEFAULT false;
END IF;
END
$$;
CREATE TABLE IF NOT EXISTS tf_instances (
module text NOT NULL REFERENCES tf_modules (name),
name text NOT NULL CHECK (name ~ '^[a-z][a-z0-9-]*$'),
@@ -28,23 +41,58 @@ CREATE TABLE IF NOT EXISTS tf_variable_files (
FOREIGN KEY (module, instance) REFERENCES tf_instances (module, name) ON DELETE CASCADE
);
-- The modules, by name.
-- 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; its columns are those of the table, so a table of other columns replaces it.
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
WHERE n.nspname = current_schema() AND p.proname = 'tf_modules_list'
AND NOT 'configurator_discovery' = ANY (coalesce(p.proargnames, '{}'))) THEN
DROP FUNCTION tf_modules_list();
END IF;
END
$$;
CREATE OR REPLACE FUNCTION tf_modules_list()
RETURNS TABLE (name text, root text, description text, configurator_url text)
RETURNS TABLE (name text, root text, description text, configurator_url text, configurator_discovery boolean)
LANGUAGE sql STABLE
SET search_path FROM CURRENT
AS $$
SELECT m.name, m.root, m.description, m.configurator_url FROM tf_modules m ORDER BY m.name
SELECT m.name, m.root, m.description, m.configurator_url, m.configurator_discovery FROM tf_modules m ORDER BY m.name
$$;
DROP PROCEDURE IF EXISTS tf_module_put(text, text, text, text);
-- Creates a module, or changes the one of that name.
CREATE OR REPLACE PROCEDURE tf_module_put(module_name text, module_root text, module_description text, module_configurator_url text)
CREATE OR REPLACE PROCEDURE tf_module_put(module_name text, module_root text, module_description text, module_configurator_url text, module_configurator_discovery boolean)
LANGUAGE sql
SET search_path FROM CURRENT
AS $$
INSERT INTO tf_modules (name, root, description, configurator_url)
VALUES (module_name, module_root, module_description, module_configurator_url)
ON CONFLICT (name) DO UPDATE SET root = excluded.root, description = excluded.description, configurator_url = excluded.configurator_url
INSERT INTO tf_modules (name, root, description, configurator_url, configurator_discovery)
VALUES (module_name, module_root, module_description, module_configurator_url, module_configurator_discovery)
ON CONFLICT (name) DO UPDATE SET root = excluded.root, description = excluded.description,
configurator_url = excluded.configurator_url, configurator_discovery = excluded.configurator_discovery
$$;
-- Deletes a module; the foreign key of tf_instances refuses it while the module has instances.
@@ -115,3 +163,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
$$;
+69 -13
View File
@@ -35,16 +35,38 @@ const status = async (promise: Promise<unknown>) => {
}
test('modules are created, changed, listed and deleted', async () => {
await store.modules.put({ name: 'router', root: 'router/terraform', description: 'VPN router', configuratorUrl: null })
await store.modules.put({ name: 'router', root: 'router/terraform', description: 'VPN router', configuratorUrl: 'http://localhost:7083/' })
await store.modules.put({ name: 'app', root: 'app', description: '', configuratorUrl: null })
await store.modules.put({ name: 'router', root: 'router/terraform', description: 'VPN router', configuratorUrl: null, configuratorDiscovery: true })
await store.modules.put({ name: 'router', root: 'router/terraform', description: 'VPN router', configuratorUrl: 'http://localhost:7083/', configuratorDiscovery: false })
await store.modules.put({ name: 'app', root: 'app', description: '', configuratorUrl: null, configuratorDiscovery: true })
assert.deepEqual(await store.modules.list(), [
{ name: 'app', root: 'app', description: '', configuratorUrl: null },
{ name: 'router', root: 'router/terraform', description: 'VPN router', configuratorUrl: 'http://localhost:7083/' },
{ name: 'app', root: 'app', description: '', configuratorUrl: null, configuratorDiscovery: true },
{ name: 'router', root: 'router/terraform', description: 'VPN router', configuratorUrl: 'http://localhost:7083/', configuratorDiscovery: false },
])
await store.modules.delete('app')
assert.deepEqual((await store.modules.list()).map((module) => module.name), ['router'])
assert.equal(await status(store.modules.put({ name: 'Bad', root: 'x', description: '', configuratorUrl: null })), 400)
assert.equal(await status(store.modules.put({ name: 'Bad', root: 'x', description: '', configuratorUrl: null, configuratorDiscovery: false })), 400)
})
test('a module registered before configurator_discovery keeps discovering its configurator; a new one does not', async () => {
// The table, the function and the procedure as they were before the column, in a schema of their own.
await pool.query(`CREATE SCHEMA earlier;
CREATE TABLE earlier.tf_modules (name text PRIMARY KEY, root text NOT NULL, description text NOT NULL DEFAULT '', configurator_url text);
INSERT INTO earlier.tf_modules VALUES ('router', 'router/terraform', '', NULL);
CREATE FUNCTION earlier.tf_modules_list() RETURNS TABLE (name text, root text, description text, configurator_url text)
LANGUAGE sql AS $$ SELECT m.name, m.root, m.description, m.configurator_url FROM earlier.tf_modules m $$;
CREATE PROCEDURE earlier.tf_module_put(a text, b text, c text, d text) LANGUAGE sql AS $$ SELECT 1 $$;`)
const earlierPool = new pg.Pool({ database, options: '-c search_path=earlier' })
try {
const earlier = createTfvarsStore(earlierPool)
await earlier.modules.put({ name: 'app', root: 'app', description: '', configuratorUrl: null, configuratorDiscovery: false })
assert.deepEqual((await earlier.modules.list()).map((module) => [module.name, module.configuratorDiscovery]), [['app', false], ['router', true]])
const { rows } = await earlierPool.query("SELECT count(*)::int AS count FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace WHERE n.nspname = 'earlier' AND p.proname = 'tf_module_put'")
assert.equal(rows[0].count, 1)
// Another store applies the file again over the migrated schema, which keeps the modules as they are.
assert.deepEqual((await createTfvarsStore(earlierPool).modules.list()).map((module) => [module.name, module.configuratorDiscovery]), [['app', false], ['router', true]])
} finally {
await earlierPool.end()
}
})
test('instances are created and refused for a missing module or a name in use', async () => {
@@ -60,29 +82,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 +145,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(), [])
})
+84 -20
View File
@@ -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) {
@@ -104,10 +152,10 @@ export function createTfvarsStore(pool: Pool): TfvarsStore {
}
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 })),
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)', [module.name, module.root, module.description, module.configuratorUrl])
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])
@@ -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
View File
@@ -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'
+19
View File
@@ -5,6 +5,11 @@ export type TfModule = {
description: string
/** The address of the module's configurator, the application that edits the variables of its instances. */
configuratorUrl: string | null
/**
* Whether a module without configuratorUrl finds its configurator by the config.json of its folder or one above it;
* without either, the module has no configurator.
*/
configuratorDiscovery: boolean
}
/** A deployment of a module, whose state is kept in the schema stateSchema of the pg backend. */
@@ -26,6 +31,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-]*$/
+87
View File
@@ -0,0 +1,87 @@
#!/usr/bin/env node
// Runs the checks of the toolkit with Docker, on the engine of the docker CLI's context: builds test.Dockerfile from
// the files of the checkout git knows, then runs the tests against a scratch PostgreSQL on a private network.
import { spawnSync } from 'node:child_process';
import { resolve } from 'node:path';
import { parseArgs } from 'node:util';
const NAME = 'sk-react-toolkit-test';
const POSTGRES_IMAGE = 'postgres:18';
const usage = `Usage: test-container.mjs [-h|--help] [--package <folder>]
Builds test.Dockerfile, which runs the typecheck, the lint and the package build, and runs the
tests in a container with a scratch PostgreSQL. The build context is the files git tracks and the
untracked files it does not ignore.
--package <folder> write the packed toolkit, the .tgz file npm pack makes, to the folder instead
of running the tests`;
const { values } = parseArgs({
options: {
package: { type: 'string' },
help: { type: 'boolean', short: 'h', default: false },
},
});
if (values.help) {
console.log(usage);
process.exit(0);
}
function fail(message) {
console.error(`test-container: ${message}`);
process.exit(1);
}
// Runs docker, failing with its message when it cannot start; answers the result.
function docker(args, options = {}) {
const result = spawnSync('docker', args, { stdio: 'inherit', ...options });
if (result.error) fail(`docker: ${result.error.message}`);
return result;
}
const files = spawnSync('git', ['ls-files', '-z', '--cached', '--others', '--exclude-standard'], { cwd: import.meta.dirname, maxBuffer: 1 << 26 });
if (files.status !== 0) fail(`git ls-files failed: ${files.stderr}`);
// macOS tar adds extended attributes and AppleDouble files, which a Linux engine refuses.
const macOptions = process.platform === 'darwin' ? ['--no-xattrs', '--no-mac-metadata'] : [];
const context = spawnSync('tar', [...macOptions, '--null', '-T', '-', '-cf', '-'], { cwd: import.meta.dirname, input: files.stdout, maxBuffer: 1 << 30 });
if (context.status !== 0) fail(`tar failed: ${context.stderr}`);
if (values.package !== undefined) {
const output = `type=local,dest=${resolve(values.package)}`;
const built = docker(['build', '-f', 'test.Dockerfile', '--target', 'package', '--output', output, '-'], { input: context.stdout, stdio: ['pipe', 'inherit', 'inherit'] });
process.exit(built.status ?? 1);
}
const built = docker(['build', '-f', 'test.Dockerfile', '--target', 'check', '-t', NAME, '-'], { input: context.stdout, stdio: ['pipe', 'inherit', 'inherit'] });
if (built.status !== 0) process.exit(built.status ?? 1);
// Ctrl-C stops the running docker command; the cleanup below still runs.
process.on('SIGINT', () => {});
const network = `${NAME}-${process.pid}`;
const database = `${NAME}-pg-${process.pid}`;
let status = 1;
try {
if (docker(['network', 'create', network], { stdio: 'ignore' }).status !== 0) throw new Error(`cannot create the network ${network}`);
const started = docker(['run', '-d', '--rm', '--name', database, '--network', network, '-e', 'POSTGRES_PASSWORD=test', POSTGRES_IMAGE], { stdio: ['ignore', 'ignore', 'inherit'] });
if (started.status !== 0) throw new Error(`cannot start ${POSTGRES_IMAGE}`);
// The server answers on TCP once its initialization has finished.
let ready = false;
for (let attempt = 0; attempt < 60 && !ready; attempt++) {
ready = docker(['exec', database, 'pg_isready', '-q', '-h', '127.0.0.1', '-U', 'postgres'], { stdio: 'ignore' }).status === 0;
if (!ready) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 1000);
}
if (!ready) throw new Error('PostgreSQL did not start within 60 seconds');
const environment = ['PGHOST=' + database, 'PGUSER=postgres', 'PGPASSWORD=test', 'PGDATABASE=postgres'].flatMap((variable) => ['-e', variable]);
status = docker(['run', '--rm', '--network', network, ...environment, NAME]).status ?? 1;
} catch (error) {
console.error(`test-container: ${error.message}`);
} finally {
docker(['rm', '-f', database], { stdio: 'ignore' });
docker(['network', 'rm', network], { stdio: 'ignore' });
}
process.exit(status);
+16
View File
@@ -0,0 +1,16 @@
# The toolkit's checks on Linux: the build runs the typecheck, the lint and the package build, the
# container the tests; the package stage holds the packed toolkit for the tests of an application.
ARG NODE_VERSION=26
FROM node:${NODE_VERSION}-trixie-slim AS check
WORKDIR /src
COPY package.json package-lock.json ./
RUN npm ci --no-fund --no-audit
COPY . .
RUN npm run typecheck && npm run lint && npm run build
RUN mkdir /pack && npm pack ./dist --pack-destination /pack
CMD ["npm", "test"]
FROM scratch AS package
COPY --from=check /pack/ /
+25
View File
@@ -38,6 +38,31 @@ export function font(choice: Font): Plugin {
}
}
/**
* Sets the color scheme of appTheme on the page before its first paint, so a dark page, and a page in an iframe of a
* dark page, is not white until the application renders: adds to index.html a script that sets the class dark or
* light on <html>, from MUI's mui-mode in localStorage or, for system, from prefers-color-scheme, and a style that
* gives those classes their color-scheme and the dark scheme MUI's dark background.
*/
export function colorScheme(): Plugin {
const script = `(function () {
try {
var mode = localStorage.getItem('mui-mode') || 'system';
var dark = mode === 'dark' || (mode === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
document.documentElement.classList.add(dark ? 'dark' : 'light');
} catch (e) {}
})();`
return {
name: 'color-scheme',
transformIndexHtml() {
return [
{ tag: 'style', children: 'html.light { color-scheme: light; }\nhtml.dark { color-scheme: dark; background-color: #121212; }', injectTo: 'head' },
{ tag: 'script', children: script, injectTo: 'head' },
]
},
}
}
/** VITE_FONT of env, the variables Vite's loadEnv answers, cdn when it is not set; another value throws. */
export function fontChoice(env: Record<string, string>): Font {
const choice = env.VITE_FONT ?? 'cdn'