Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d3a700f11 | ||
|
|
97c46ff0d8 | ||
|
|
7959c90012 |
No files matched your search
@@ -57,11 +57,15 @@ Context: sql-query-webui kept PostgreSQL connection profiles with encrypted pass
|
|||||||
manage-ui needs a PostgreSQL connection and an Azure service principal without taking them from
|
manage-ui needs a PostgreSQL connection and an Azure service principal without taking them from
|
||||||
its environment. Other systems will need other kinds of credentials.
|
its environment. Other systems will need other kinds of credentials.
|
||||||
|
|
||||||
Decision: A credential store is a list in a key of the application's `config.json`; each entry
|
Decision: A credential store is a list in a key of the application's `config.json`, all of one
|
||||||
has a name, a kind and the fields of its kind. The application gives the kinds as data: each field
|
kind; each entry has a name and the fields of the store's kind, and no kind of its own, as the key
|
||||||
with its key, label, help, type (text, port, GUID or a choice), whether it is required and whether
|
of `config.json`, whose schema the application defines, already says what the entries are. An
|
||||||
it is secret. The toolkit checks the entries, serves read, validate and write routes, and renders
|
application with several kinds of credentials has a store, a key, for each. The application gives
|
||||||
the dialog from the kinds, so a new kind needs no new code. Secret fields are encrypted with
|
the kind as data: each field with its key, label, help, type (text, port, GUID or a choice),
|
||||||
|
whether it is required and whether it is secret. The toolkit checks the entries, serves read,
|
||||||
|
validate and write routes, and renders the dialog from the kind, so a new kind needs no new code;
|
||||||
|
the dialog edits the stores of one `config.json` in tabs, and its Save writes them one after the
|
||||||
|
other, each with the version the previous write answers. Secret fields are encrypted with
|
||||||
AES-256-GCM, a random 12-byte IV each, with the key the SHA-256 of `ENCRYPTION_KEY`, else of the
|
AES-256-GCM, a random 12-byte IV each, with the key the SHA-256 of `ENCRYPTION_KEY`, else of the
|
||||||
content of `ENCRYPTION_KEY_FILE`. A secret is never answered: read gives null and whether it is
|
content of `ENCRYPTION_KEY_FILE`. A secret is never answered: read gives null and whether it is
|
||||||
kept, and a write sends null to keep it, `''` to drop it, or a new secret. The applications decide
|
kept, and a write sends null to keep it, `''` to drop it, or a new secret. The applications decide
|
||||||
@@ -72,3 +76,45 @@ Consequences: Without the key no secret can be saved or used, and a changed key
|
|||||||
secrets unusable until they are entered again. SHA-256 does not slow down guessing a weak key, so
|
secrets unusable until they are entered again. SHA-256 does not slow down guessing a weak key, so
|
||||||
the key has to be long and random. Anyone who reaches the routes can replace credentials, as the
|
the key has to be long and random. Anyone who reaches the routes can replace credentials, as the
|
||||||
applications have no authentication.
|
applications have no authentication.
|
||||||
|
|
||||||
|
## ADR-4: The key of a credential store is a path, so related stores share one object
|
||||||
|
|
||||||
|
Status: Accepted
|
||||||
|
|
||||||
|
Context: sql-query-webui keeps connection profiles of PostgreSQL and of SQL Server. As stores of
|
||||||
|
top-level keys, the keys have to carry the kind in their names, and a key such as `connections`
|
||||||
|
does not say which kind it holds once there are several.
|
||||||
|
|
||||||
|
Decision: The key of a store is a key of `config.json` or a path of keys separated by dots, such
|
||||||
|
as `connections.pg` and `connections.mssql`. The store reads the list at the path, reports its
|
||||||
|
problems by `<path>.<index>.<field>`, and a write sets the list at the path, creating the objects
|
||||||
|
of the path that are missing, or are not objects, and keeping their other keys, so stores of one
|
||||||
|
object can be written one after the other. The kind stays the store's, not a field of an entry
|
||||||
|
(ADR-3).
|
||||||
|
|
||||||
|
Consequences: A key of `config.json` holding a dot cannot be the key of a store. A single key
|
||||||
|
reads and writes as before, so applications of one-key stores need no change. The application
|
||||||
|
checks the object of a path, such as the keys it allows in it, itself.
|
||||||
|
|
||||||
|
## ADR-5: Terraform modules, instances and their variable files are tables shared through the toolkit
|
||||||
|
|
||||||
|
Status: Accepted
|
||||||
|
|
||||||
|
Context: Cloud Platform deploys many instances of many Terraform modules, and each module's
|
||||||
|
configurator, a separate application, edits the variables of its instances. Both have to read
|
||||||
|
and write the same modules, instances and variable files, with the same versions, so that a
|
||||||
|
write from an outdated read is refused whichever application made it.
|
||||||
|
|
||||||
|
Decision: The toolkit holds the tables and their functions and procedures,
|
||||||
|
`server/sql/tfvars_store.sql`, and `createTfvarsStore`, which both applications use with their own
|
||||||
|
`pg` pool. An instance names the schema of its state, unique among instances. A variable file is
|
||||||
|
a row of its instance, its object as `jsonb` with the one it replaced. The version of an
|
||||||
|
instance's files is the name and the SHA-256 of each file's text, `tfvarsText` of its object, in
|
||||||
|
name order; a write locks the table, compares the version and writes in one transaction. A change
|
||||||
|
the tables refuse is an `HttpError` with the status the routes answer. `pg` is an optional peer
|
||||||
|
dependency, as the applications that do not use the store do not need it.
|
||||||
|
|
||||||
|
Consequences: Every application of the store works on the same rows and refuses the same
|
||||||
|
conflicts, and changes of the tables reach them with a release of the toolkit. The objects' keys
|
||||||
|
are in `jsonb`'s order. The store is tested against a real PostgreSQL server, which the release
|
||||||
|
workflow does not have, so `npm test` runs on the developer's machine.
|
||||||
@@ -1,5 +1,39 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 0.4.0
|
||||||
|
|
||||||
|
feature: the tfvars store keeps Terraform modules, their instances and the instances' variable files in PostgreSQL.
|
||||||
|
|
||||||
|
- `createTfvarsStore(pool)`: the tables `tf_modules`, `tf_instances` and `tf_variable_files`,
|
||||||
|
created at the first use, with `modules` (`list`, `put`, `delete`), `instances` (`list`,
|
||||||
|
`create`, `delete`) and `variables(module, instance)` (`read`, `write`, `remove`), whose writes
|
||||||
|
take the version read and answer `false` when the files changed since.
|
||||||
|
- `versionOf`, `tfvarsText`, and the types `TfModule`, `TfInstance` and `TfvarsFileStatus` with
|
||||||
|
the patterns of their names, also exported from `/web`.
|
||||||
|
- `pg` is an optional peer dependency, for the store.
|
||||||
|
- `npm test` tests the store against a scratch database of the server the `PG*` variables name.
|
||||||
|
|
||||||
|
## 0.3.0
|
||||||
|
|
||||||
|
feature: the key of a credential store may be a path of keys, so stores of several kinds share one object of config.json.
|
||||||
|
|
||||||
|
- `CredentialStore.key` is a key of `config.json` or a path of keys separated by dots, such as
|
||||||
|
`connections.pg`; read, validate, write and `credentialValues` use the list at that path, and a
|
||||||
|
write creates the objects of the path that are missing and keeps their other keys.
|
||||||
|
|
||||||
|
## 0.2.1
|
||||||
|
|
||||||
|
fix: a credential store holds credentials of one kind, without a kind field in its entries, and the dialog edits several stores in tabs.
|
||||||
|
|
||||||
|
- A `CredentialStore` has one `kind`, and its entries hold the name and the fields of that kind
|
||||||
|
only; `kind` is no longer a field of an entry, of `CredentialProfile`, or of `CredentialKind`. The
|
||||||
|
read answer has the store's `kind` in place of `kinds`.
|
||||||
|
- `CredentialsButton` takes `stores`, the prefixes and labels of the stores of the same
|
||||||
|
`config.json`, with a tab for each when there are several; Save writes each store that changed.
|
||||||
|
The message of an empty store is centered in the dialog, and the kind's label keeps its case.
|
||||||
|
- The README's Using it shows the `dev-link` script of the applications, `npm link --no-save` in
|
||||||
|
both their packages, in place of the two commands.
|
||||||
|
|
||||||
## 0.2.0
|
## 0.2.0
|
||||||
|
|
||||||
feature: the credential store keeps credentials of any kind in config.json with encrypted secrets, with its routes and its dialog.
|
feature: the credential store keeps credentials of any kind in config.json with encrypted secrets, with its routes and its dialog.
|
||||||
|
|||||||
@@ -33,16 +33,17 @@ export default defineConfig(({ mode }) => ({
|
|||||||
```
|
```
|
||||||
|
|
||||||
To change the toolkit and an application together, `npm link --no-save` replaces the installed
|
To change the toolkit and an application together, `npm link --no-save` replaces the installed
|
||||||
package with the checkout, the TypeScript source, in each package of the application:
|
package with the checkout, the TypeScript source, in each package of the application, whatever
|
||||||
|
version it names. The applications run it for both their packages as `npm run dev-link`:
|
||||||
|
|
||||||
```sh
|
```json
|
||||||
npm link --no-save ../sk-react-toolkit
|
{ "scripts": { "dev-link": "npm link --no-save --no-fund ../sk-react-toolkit && cd web && npm link --no-save --no-fund ../../sk-react-toolkit" } }
|
||||||
npm --prefix web link --no-save ../../sk-react-toolkit
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`package.json` and the lock file keep the published version; `npm ci` or `npm install` replaces
|
`package.json` and the lock files keep the published version; `npm ci` or `npm install` replaces
|
||||||
the link with it again. A change of the toolkit reaches the application through a release of the
|
the link with it again, and `npm run dev-link` links the checkout again. A change of the toolkit
|
||||||
toolkit and an update of the dependency.
|
reaches the released application through a release of the toolkit and an update of the
|
||||||
|
dependency.
|
||||||
|
|
||||||
The peer dependencies are the application's: `fastify` and `@fastify/static` for the server
|
The peer dependencies are the application's: `fastify` and `@fastify/static` for the server
|
||||||
helpers; `react`, `react-dom`, `@mui/material`, `@mui/icons-material` and Emotion for the web
|
helpers; `react`, `react-dom`, `@mui/material`, `@mui/icons-material` and Emotion for the web
|
||||||
@@ -63,6 +64,7 @@ code; `vite` for the Vite helpers.
|
|||||||
| `readJsonObject(path, options)` | a JSON object file and its version, the SHA-256 of its bytes; optionally `{}` for a missing file |
|
| `readJsonObject(path, options)` | a JSON object file and its version, the SHA-256 of its bytes; optionally `{}` for a missing file |
|
||||||
| `encryptionKey()`, `encrypt(text, key)`, `decrypt(encrypted, key)` | the key of the secrets and AES-256-GCM encryption; see [Credential store](#credential-store) |
|
| `encryptionKey()`, `encrypt(text, key)`, `decrypt(encrypted, key)` | the key of the secrets and AES-256-GCM encryption; see [Credential store](#credential-store) |
|
||||||
| `CredentialStore`, `credentialProblems`, `credentialValues`, `registerCredentialRoutes`, `POSTGRESQL_KIND` | the credential store; see [Credential store](#credential-store) |
|
| `CredentialStore`, `credentialProblems`, `credentialValues`, `registerCredentialRoutes`, `POSTGRESQL_KIND` | the credential store; see [Credential store](#credential-store) |
|
||||||
|
| `createTfvarsStore`, `versionOf`, `tfvarsText`, `TfModule`, `TfInstance`, `TfvarsFileStatus`, `TF_NAME_PATTERN`, `TFVARS_FILE_PATTERN`, `STATE_SCHEMA_PATTERN` | the Terraform modules, their instances and their variable files in PostgreSQL; see [Tfvars store](#tfvars-store) |
|
||||||
|
|
||||||
## sk-react-toolkit/web
|
## sk-react-toolkit/web
|
||||||
|
|
||||||
@@ -78,6 +80,7 @@ code; `vite` for the Vite helpers.
|
|||||||
| `RowDialog`, `useValidation` | a dialog that edits a value validated by the server as it changes, and the validation hook |
|
| `RowDialog`, `useValidation` | a dialog that edits a value validated by the server as it changes, and the validation hook |
|
||||||
| `createBrowserSettings(keys, event)` | switches kept in localStorage and followed across tabs |
|
| `createBrowserSettings(keys, event)` | switches kept in localStorage and followed across tabs |
|
||||||
| `CredentialsButton` | the dialog of a credential store; see [Credential store](#credential-store) |
|
| `CredentialsButton` | the dialog of a credential store; see [Credential store](#credential-store) |
|
||||||
|
| `TfModule`, `TfInstance`, `TfvarsFileStatus`, `TF_NAME_PATTERN`, `TFVARS_FILE_PATTERN`, `STATE_SCHEMA_PATTERN` | the types and name patterns of the [Tfvars store](#tfvars-store) |
|
||||||
|
|
||||||
## sk-react-toolkit/vite
|
## sk-react-toolkit/vite
|
||||||
|
|
||||||
@@ -88,16 +91,16 @@ code; `vite` for the Vite helpers.
|
|||||||
|
|
||||||
## Credential store
|
## Credential store
|
||||||
|
|
||||||
A credential store is a list of credentials in a key of the application's `config.json`, of the
|
A credential store is a list of credentials in a key of the application's `config.json`, all of
|
||||||
kinds the application gives, such as a database connection or a service principal. Each entry has
|
the one kind the application gives the store, such as a database connection or a service
|
||||||
a `name`, unique and not one of the store's reserved names, a `kind`, and the fields of its kind:
|
principal; an application with several kinds has a store, a key, for each. Each entry has a
|
||||||
|
`name`, unique and not one of the store's reserved names, and the fields of the store's kind:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"credentials": [
|
"pg_connections": [
|
||||||
{
|
{
|
||||||
"name": "state",
|
"name": "state",
|
||||||
"kind": "postgresql",
|
|
||||||
"host": "db.example.com",
|
"host": "db.example.com",
|
||||||
"port": 5432,
|
"port": 5432,
|
||||||
"database": "platform",
|
"database": "platform",
|
||||||
@@ -109,7 +112,8 @@ a `name`, unique and not one of the store's reserved names, a `kind`, and the fi
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
A kind (`CredentialKind`) names its fields (`CredentialField`), each by its key in `config.json`,
|
A kind (`CredentialKind`) has a label for one credential and names its fields
|
||||||
|
(`CredentialField`), each by its key in `config.json`,
|
||||||
with a label, a help text, a type, `text`, `port`, `guid` or `choice` with its `choices`, whether
|
with a label, a help text, a type, `text`, `port`, `guid` or `choice` with its `choices`, whether
|
||||||
it is required, and whether it is secret. `POSTGRESQL_KIND` is the kind of a PostgreSQL
|
it is required, and whether it is secret. `POSTGRESQL_KIND` is the kind of a PostgreSQL
|
||||||
connection: `host`, `port`, `database`, `user`, `ssl_mode` (`disable`, `require` or
|
connection: `host`, `port`, `database`, `user`, `ssl_mode` (`disable`, `require` or
|
||||||
@@ -121,8 +125,20 @@ file `ENCRYPTION_KEY_FILE` names, without its final newline. Without either, no
|
|||||||
saved or used; a secret encrypted with another key cannot be decrypted and has to be entered
|
saved or used; a secret encrypted with another key cannot be decrypted and has to be entered
|
||||||
again.
|
again.
|
||||||
|
|
||||||
`CredentialStore` describes a store: the path of `config.json`, the key, the kinds, the reserved
|
`CredentialStore` describes a store: the path of `config.json`, the key, the kind, the reserved
|
||||||
names and whether a missing `config.json` reads as `{}`.
|
names and whether a missing `config.json` reads as `{}`. The key is a key of `config.json` or a
|
||||||
|
path of keys separated by dots, such as `connections.pg`, for a list in nested objects; a write
|
||||||
|
creates the objects of the path that are missing and keeps their other keys, so stores of several
|
||||||
|
kinds can share one object:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"connections": {
|
||||||
|
"pg": [{ "name": "state", "host": "db.example.com", "port": 5432, "...": "..." }],
|
||||||
|
"mssql": [{ "name": "warehouse", "host": "sql.example.com", "port": 1433, "...": "..." }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
- `credentialProblems(value, store)`: the problems of the key's value by `<key>.<index>.<field>`,
|
- `credentialProblems(value, store)`: the problems of the key's value by `<key>.<index>.<field>`,
|
||||||
for the application's own check of `config.json`.
|
for the application's own check of `config.json`.
|
||||||
@@ -131,22 +147,57 @@ names and whether a missing `config.json` reads as `{}`.
|
|||||||
- `registerCredentialRoutes(app, store, prefix, log)`: `GET <prefix>/read`, `POST
|
- `registerCredentialRoutes(app, store, prefix, log)`: `GET <prefix>/read`, `POST
|
||||||
<prefix>/validate` and `POST <prefix>/write`. Read answers the credentials, `CredentialProfile`,
|
<prefix>/validate` and `POST <prefix>/write`. Read answers the credentials, `CredentialProfile`,
|
||||||
with `values` (the fields that are not secret, as text), `secrets` (null for each secret, never
|
with `values` (the fields that are not secret, as text), `secrets` (null for each secret, never
|
||||||
answered) and `stored` (which secrets are kept), the kinds, the reserved names, the path of
|
answered) and `stored` (which secrets are kept), the kind, the reserved names, the path of
|
||||||
`config.json`, its version and whether the server has a key. A write takes the version read and
|
`config.json`, its version and whether the server has a key. A write takes the version read and
|
||||||
the credentials; a secret is null to keep the secret of the credential named `original`, `''`
|
the credentials; a secret is null to keep the secret of the credential named `original`, `''`
|
||||||
for none, or the new secret, which is encrypted. A file that changed since the version read is
|
for none, or the new secret, which is encrypted. A file that changed since the version read is
|
||||||
409, credentials with problems 422; the other keys of `config.json` are kept.
|
409, credentials with problems 422; the other keys of `config.json` are kept.
|
||||||
|
|
||||||
`CredentialsButton` (`prefix`, `title`, `icon`, `note`, `onSaved`) opens the store's credentials in
|
`CredentialsButton` (`stores`, `title`, `icon`, `note`, `onSaved`) opens the credentials of the
|
||||||
a dialog: a list at the left with Add for each kind and Remove, and the fields of the credential
|
stores in a dialog. `stores` lists the prefix of each store's routes and the label of its tab; the
|
||||||
selected, as its kind names them. A secret shows only whether it is kept: Set or Change replaces
|
tabs are shown when there are several stores, which share one `config.json`. Each tab has a list at
|
||||||
it and Remove drops it.
|
the left with Add and Remove, and the fields of the credential selected, as the store's kind names
|
||||||
|
them; a tab with a problem has a red label. A secret shows only whether it is kept: Set or Change
|
||||||
|
replaces it and Remove drops it. Save writes each store that changed, each write with the version
|
||||||
|
the previous one answers.
|
||||||
|
|
||||||
|
## Tfvars store
|
||||||
|
|
||||||
|
The tfvars store keeps Terraform modules, their instances and the variable files of each
|
||||||
|
instance in PostgreSQL, for Cloud Platform, which deploys the instances, and the configurators,
|
||||||
|
which edit their variables. `createTfvarsStore(pool)` takes a `pg` pool and applies
|
||||||
|
`server/sql/tfvars_store.sql` at its first use, in the schema of the connection's search path:
|
||||||
|
|
||||||
|
| Table | Rows |
|
||||||
|
| --- | --- |
|
||||||
|
| `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` |
|
||||||
|
|
||||||
|
Names of modules and instances are `TF_NAME_PATTERN`, of variable files `TFVARS_FILE_PATTERN`,
|
||||||
|
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.
|
||||||
|
|
||||||
|
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
|
||||||
|
a content the tables do not accept. The content of a file is the object as `jsonb` keeps it,
|
||||||
|
written by `tfvarsText`, so the order of object keys is `jsonb`'s; arrays keep theirs.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
`npm install` installs the peer dependencies as development dependencies; `npm run typecheck`
|
`npm install` installs the peer dependencies as development dependencies; `npm run typecheck`
|
||||||
and `npm run lint` check the code. `npm run build` (`build.mjs`) builds the published package in
|
and `npm run lint` check the code. `npm test` tests the tfvars store in a scratch database it
|
||||||
`dist/`: the code compiled to JavaScript with its declarations, README.md, CHANGELOG.md and a
|
creates and drops on the PostgreSQL server the `PG*` variables name, connected to through the
|
||||||
|
database `postgres`. `npm run build` (`build.mjs`) builds the published package in
|
||||||
|
`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.
|
`package.json` whose exports name the compiled files.
|
||||||
|
|
||||||
## Releases
|
## Releases
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
|
|
||||||
// Builds the published package in dist/: the toolkit compiled to JavaScript with its declarations, the declaration of
|
// Builds the published package in dist/: the toolkit compiled to JavaScript with its declarations, the declaration of
|
||||||
// the font plugin's virtual module, README.md, CHANGELOG.md and a package.json whose exports name the compiled files.
|
// the font plugin's virtual module, the SQL of the server, README.md, CHANGELOG.md and a package.json whose exports
|
||||||
|
// name the compiled files.
|
||||||
|
|
||||||
import { spawnSync } from 'node:child_process';
|
import { spawnSync } from 'node:child_process';
|
||||||
import { copyFileSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
import { copyFileSync, cpSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
|
|
||||||
const ROOT = import.meta.dirname;
|
const ROOT = import.meta.dirname;
|
||||||
@@ -34,6 +35,9 @@ copyFileSync(join(ROOT, 'web', 'virtual-font.d.ts'), join(DIST, 'web', 'virtual-
|
|||||||
const renderApp = join(DIST, 'web', 'renderApp.d.ts');
|
const renderApp = join(DIST, 'web', 'renderApp.d.ts');
|
||||||
writeFileSync(renderApp, `/// <reference path="./virtual-font.d.ts" />\n${readFileSync(renderApp, 'utf8')}`);
|
writeFileSync(renderApp, `/// <reference path="./virtual-font.d.ts" />\n${readFileSync(renderApp, 'utf8')}`);
|
||||||
|
|
||||||
|
// The tfvars store reads its SQL next to the compiled module.
|
||||||
|
cpSync(join(ROOT, 'server', 'sql'), join(DIST, 'server', 'sql'), { recursive: true });
|
||||||
|
|
||||||
for (const file of ['README.md', 'CHANGELOG.md']) copyFileSync(join(ROOT, file), join(DIST, file));
|
for (const file of ['README.md', 'CHANGELOG.md']) copyFileSync(join(ROOT, file), join(DIST, file));
|
||||||
|
|
||||||
const source = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8'));
|
const source = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8'));
|
||||||
|
|||||||
Generated
+170
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@koszewscy/sk-react-toolkit",
|
"name": "@koszewscy/sk-react-toolkit",
|
||||||
"version": "0.2.0",
|
"version": "0.4.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@koszewscy/sk-react-toolkit",
|
"name": "@koszewscy/sk-react-toolkit",
|
||||||
"version": "0.2.0",
|
"version": "0.4.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@emotion/react": "^11.14.0",
|
"@emotion/react": "^11.14.0",
|
||||||
"@emotion/styled": "^11.14.1",
|
"@emotion/styled": "^11.14.1",
|
||||||
@@ -14,10 +14,12 @@
|
|||||||
"@mui/icons-material": "^9.5.0",
|
"@mui/icons-material": "^9.5.0",
|
||||||
"@mui/material": "^9.5.0",
|
"@mui/material": "^9.5.0",
|
||||||
"@types/node": "^26.6.5",
|
"@types/node": "^26.6.5",
|
||||||
|
"@types/pg": "^8.23.1",
|
||||||
"@types/react": "^19.3.0",
|
"@types/react": "^19.3.0",
|
||||||
"@types/react-dom": "^19.3.0",
|
"@types/react-dom": "^19.3.0",
|
||||||
"fastify": "^5.12.7",
|
"fastify": "^5.12.7",
|
||||||
"oxlint": "^1.89.0",
|
"oxlint": "^1.89.0",
|
||||||
|
"pg": "^8.23.1",
|
||||||
"react": "^19.3.1",
|
"react": "^19.3.1",
|
||||||
"react-dom": "^19.3.1",
|
"react-dom": "^19.3.1",
|
||||||
"typescript": "^7.0.2",
|
"typescript": "^7.0.2",
|
||||||
@@ -33,6 +35,7 @@
|
|||||||
"@mui/icons-material": "^9.5.0",
|
"@mui/icons-material": "^9.5.0",
|
||||||
"@mui/material": "^9.5.0",
|
"@mui/material": "^9.5.0",
|
||||||
"fastify": "^5.12.7",
|
"fastify": "^5.12.7",
|
||||||
|
"pg": "^8.23.1",
|
||||||
"react": "^19.3.1",
|
"react": "^19.3.1",
|
||||||
"react-dom": "^19.3.1",
|
"react-dom": "^19.3.1",
|
||||||
"vite": "^8.3.5"
|
"vite": "^8.3.5"
|
||||||
@@ -56,6 +59,9 @@
|
|||||||
"fastify": {
|
"fastify": {
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
|
"pg": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"react": {
|
"react": {
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
@@ -1539,6 +1545,18 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/pg": {
|
||||||
|
"version": "8.23.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.23.1.tgz",
|
||||||
|
"integrity": "sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*",
|
||||||
|
"pg-protocol": "*",
|
||||||
|
"pg-types": "^2.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/prop-types": {
|
"node_modules/@types/prop-types": {
|
||||||
"version": "15.7.15",
|
"version": "15.7.15",
|
||||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||||
@@ -3131,6 +3149,103 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pg": {
|
||||||
|
"version": "8.23.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg/-/pg-8.23.1.tgz",
|
||||||
|
"integrity": "sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pg-connection-string": "^2.14.1",
|
||||||
|
"pg-pool": "^3.14.0",
|
||||||
|
"pg-protocol": "^1.16.1",
|
||||||
|
"pg-types": "2.2.0",
|
||||||
|
"pgpass": "1.0.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16.0.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"pg-cloudflare": "^1.4.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"pg-native": ">=3.0.1"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"pg-native": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pg-cloudflare": {
|
||||||
|
"version": "1.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.1.tgz",
|
||||||
|
"integrity": "sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"node_modules/pg-connection-string": {
|
||||||
|
"version": "2.14.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.1.tgz",
|
||||||
|
"integrity": "sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/pg-int8": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pg-pool": {
|
||||||
|
"version": "3.14.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
|
||||||
|
"integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"pg": ">=8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pg-protocol": {
|
||||||
|
"version": "1.16.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.1.tgz",
|
||||||
|
"integrity": "sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/pg-types": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pg-int8": "1.0.1",
|
||||||
|
"postgres-array": "~2.0.0",
|
||||||
|
"postgres-bytea": "~1.0.0",
|
||||||
|
"postgres-date": "~1.0.4",
|
||||||
|
"postgres-interval": "^1.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pgpass": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"split2": "^4.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -3220,6 +3335,49 @@
|
|||||||
"node": "^10 || ^12 || >=14"
|
"node": "^10 || ^12 || >=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/postgres-array": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/postgres-bytea": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/postgres-date": {
|
||||||
|
"version": "1.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
|
||||||
|
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/postgres-interval": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"xtend": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/process-warning": {
|
"node_modules/process-warning": {
|
||||||
"version": "5.1.1",
|
"version": "5.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.1.1.tgz",
|
||||||
@@ -3744,6 +3902,16 @@
|
|||||||
"optional": true
|
"optional": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"node_modules/xtend": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.4"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+6
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@koszewscy/sk-react-toolkit",
|
"name": "@koszewscy/sk-react-toolkit",
|
||||||
"version": "0.2.0",
|
"version": "0.4.0",
|
||||||
"description": "Shared code of the React and Fastify web applications: web components, server helpers and the Vite font plugin.",
|
"description": "Shared code of the React and Fastify web applications: web components, server helpers and the Vite font plugin.",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"typecheck": "tsc -p tsconfig.json",
|
"typecheck": "tsc -p tsconfig.json",
|
||||||
"lint": "oxlint",
|
"lint": "oxlint",
|
||||||
|
"test": "node --test server/tfvarsStore.test.ts",
|
||||||
"build": "node build.mjs"
|
"build": "node build.mjs"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
@@ -28,6 +29,7 @@
|
|||||||
"@mui/icons-material": "^9.5.0",
|
"@mui/icons-material": "^9.5.0",
|
||||||
"@mui/material": "^9.5.0",
|
"@mui/material": "^9.5.0",
|
||||||
"fastify": "^5.12.7",
|
"fastify": "^5.12.7",
|
||||||
|
"pg": "^8.23.1",
|
||||||
"react": "^19.3.1",
|
"react": "^19.3.1",
|
||||||
"react-dom": "^19.3.1",
|
"react-dom": "^19.3.1",
|
||||||
"vite": "^8.3.5"
|
"vite": "^8.3.5"
|
||||||
@@ -35,6 +37,7 @@
|
|||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"@fastify/static": { "optional": true },
|
"@fastify/static": { "optional": true },
|
||||||
"fastify": { "optional": true },
|
"fastify": { "optional": true },
|
||||||
|
"pg": { "optional": true },
|
||||||
"vite": { "optional": true },
|
"vite": { "optional": true },
|
||||||
"@emotion/react": { "optional": true },
|
"@emotion/react": { "optional": true },
|
||||||
"@emotion/styled": { "optional": true },
|
"@emotion/styled": { "optional": true },
|
||||||
@@ -50,10 +53,12 @@
|
|||||||
"@mui/icons-material": "^9.5.0",
|
"@mui/icons-material": "^9.5.0",
|
||||||
"@mui/material": "^9.5.0",
|
"@mui/material": "^9.5.0",
|
||||||
"@types/node": "^26.6.5",
|
"@types/node": "^26.6.5",
|
||||||
|
"@types/pg": "^8.23.1",
|
||||||
"@types/react": "^19.3.0",
|
"@types/react": "^19.3.0",
|
||||||
"@types/react-dom": "^19.3.0",
|
"@types/react-dom": "^19.3.0",
|
||||||
"fastify": "^5.12.7",
|
"fastify": "^5.12.7",
|
||||||
"oxlint": "^1.89.0",
|
"oxlint": "^1.89.0",
|
||||||
|
"pg": "^8.23.1",
|
||||||
"react": "^19.3.1",
|
"react": "^19.3.1",
|
||||||
"react-dom": "^19.3.1",
|
"react-dom": "^19.3.1",
|
||||||
"typescript": "^7.0.2",
|
"typescript": "^7.0.2",
|
||||||
|
|||||||
+48
-40
@@ -7,27 +7,27 @@ import { readJsonObject } from './process.ts'
|
|||||||
import { decrypt, encrypt, encryptionKey } from './secrets.ts'
|
import { decrypt, encrypt, encryptionKey } from './secrets.ts'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A credential store: the list under key of config.json at configPath, of the kinds the application gives, with the
|
* A credential store: the list under key of config.json at configPath, every entry of the kind the application gives,
|
||||||
* names reserved for its own credentials. With missingAsEmpty a missing config.json reads as {}, and the first write
|
* with the names reserved for its own credentials. key is a key of config.json or a path of keys separated by dots,
|
||||||
* creates it.
|
* such as connections.pg, for a list in nested objects. With missingAsEmpty a missing config.json reads as {}, and the
|
||||||
|
* first write creates it.
|
||||||
*/
|
*/
|
||||||
export type CredentialStore = {
|
export type CredentialStore = {
|
||||||
configPath: string
|
configPath: string
|
||||||
key: string
|
key: string
|
||||||
kinds: CredentialKind[]
|
kind: CredentialKind
|
||||||
reserved?: string[]
|
reserved?: string[]
|
||||||
missingAsEmpty: boolean
|
missingAsEmpty: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A credential with its secrets decrypted; a secret that is not kept is absent. */
|
/** A credential with its secrets decrypted; a secret that is not kept is absent. */
|
||||||
export type CredentialValues = { name: string; kind: string; values: Record<string, string | number> }
|
export type CredentialValues = { name: string; values: Record<string, string | number> }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The kind of a PostgreSQL connection: host, port, database, user, SSL mode and an encrypted password. The SSL modes
|
* The kind of a PostgreSQL connection: host, port, database, user, SSL mode and an encrypted password. The SSL modes
|
||||||
* are those libpq and node-postgres both take: disable, require (no check of the certificate) and verify-full.
|
* are those libpq and node-postgres both take: disable, require (no check of the certificate) and verify-full.
|
||||||
*/
|
*/
|
||||||
export const POSTGRESQL_KIND: CredentialKind = {
|
export const POSTGRESQL_KIND: CredentialKind = {
|
||||||
kind: 'postgresql',
|
|
||||||
label: 'PostgreSQL connection',
|
label: 'PostgreSQL connection',
|
||||||
fields: [
|
fields: [
|
||||||
{ key: 'host', label: 'Host', help: 'The host name or address of the server.', type: 'text', required: true, secret: false },
|
{ key: 'host', label: 'Host', help: 'The host name or address of the server.', type: 'text', required: true, secret: false },
|
||||||
@@ -55,7 +55,7 @@ const text = (value: unknown) => typeof value === 'string' && value.trim() !== '
|
|||||||
* The problems of the value of the store's key in config.json, by <key>, <key>.<index> or <key>.<index>.<field key>,
|
* The problems of the value of the store's key in config.json, by <key>, <key>.<index> or <key>.<index>.<field key>,
|
||||||
* each with what it must be; empty when every value is allowed.
|
* each with what it must be; empty when every value is allowed.
|
||||||
*/
|
*/
|
||||||
export function credentialProblems(entries: unknown, store: Pick<CredentialStore, 'key' | 'kinds' | 'reserved'>): Record<string, string> {
|
export function credentialProblems(entries: unknown, store: Pick<CredentialStore, 'key' | 'kind' | 'reserved'>): Record<string, string> {
|
||||||
if (entries === undefined) {
|
if (entries === undefined) {
|
||||||
return {}
|
return {}
|
||||||
}
|
}
|
||||||
@@ -64,10 +64,11 @@ export function credentialProblems(entries: unknown, store: Pick<CredentialStore
|
|||||||
}
|
}
|
||||||
const problems: Record<string, string> = {}
|
const problems: Record<string, string> = {}
|
||||||
const reserved = store.reserved ?? []
|
const reserved = store.reserved ?? []
|
||||||
|
const keys = ['name', ...store.kind.fields.map((field) => field.key)]
|
||||||
entries.forEach((entry: unknown, index) => {
|
entries.forEach((entry: unknown, index) => {
|
||||||
const at = `${store.key}.${index}`
|
const at = `${store.key}.${index}`
|
||||||
if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) {
|
if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) {
|
||||||
problems[at] = 'must be an object with name and kind'
|
problems[at] = `must be an object with ${keys.join(', ')}`
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const record = entry as Record<string, unknown>
|
const record = entry as Record<string, unknown>
|
||||||
@@ -79,17 +80,11 @@ export function credentialProblems(entries: unknown, store: Pick<CredentialStore
|
|||||||
} else if (entries.findIndex((other) => (other as Record<string, unknown> | null)?.name === name) !== index) {
|
} else if (entries.findIndex((other) => (other as Record<string, unknown> | null)?.name === name) !== index) {
|
||||||
problems[`${at}.name`] = 'must differ from the names of the other credentials'
|
problems[`${at}.name`] = 'must differ from the names of the other credentials'
|
||||||
}
|
}
|
||||||
const kind = store.kinds.find((item) => item.kind === record.kind)
|
|
||||||
if (kind === undefined) {
|
|
||||||
problems[`${at}.kind`] = `must be one of ${store.kinds.map((item) => item.kind).join(', ')}`
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const keys = ['name', 'kind', ...kind.fields.map((field) => field.key)]
|
|
||||||
const unknown = Object.keys(record).filter((key) => !keys.includes(key))
|
const unknown = Object.keys(record).filter((key) => !keys.includes(key))
|
||||||
if (unknown.length > 0) {
|
if (unknown.length > 0) {
|
||||||
problems[at] = `must hold only ${keys.join(', ')}; ${unknown.join(', ')} is not a field of ${kind.kind}`
|
problems[at] = `must hold only ${keys.join(', ')}; ${unknown.join(', ')} is not one of them`
|
||||||
}
|
}
|
||||||
for (const field of kind.fields) {
|
for (const field of store.kind.fields) {
|
||||||
const value = record[field.key]
|
const value = record[field.key]
|
||||||
const path = `${at}.${field.key}`
|
const path = `${at}.${field.key}`
|
||||||
if (value === undefined) {
|
if (value === undefined) {
|
||||||
@@ -120,9 +115,30 @@ export function credentialProblems(entries: unknown, store: Pick<CredentialStore
|
|||||||
return problems
|
return problems
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isObject = (value: unknown): value is Record<string, unknown> => value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
|
||||||
|
/** The value of data at key, a key or a path of keys separated by dots; undefined when it is not there. */
|
||||||
|
function valueAt(data: Record<string, unknown>, key: string): unknown {
|
||||||
|
return key.split('.').reduce<unknown>((value, part) => (isObject(value) ? value[part] : undefined), data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* data with value at key, a key or a path of keys separated by dots, after the other keys of its object; the objects
|
||||||
|
* of the path that are missing, or are not objects, are created, and their other keys kept.
|
||||||
|
*/
|
||||||
|
function withValue(data: Record<string, unknown>, key: string, value: unknown): Record<string, unknown> {
|
||||||
|
const [first, ...rest] = key.split('.')
|
||||||
|
if (rest.length > 0) {
|
||||||
|
return { ...data, [first]: withValue(isObject(data[first]) ? data[first] : {}, rest.join('.'), value) }
|
||||||
|
}
|
||||||
|
const others = { ...data }
|
||||||
|
delete others[first]
|
||||||
|
return { ...others, [first]: value }
|
||||||
|
}
|
||||||
|
|
||||||
/** The entries of the store's key in config.json; a value that is not a list reads as none. */
|
/** The entries of the store's key in config.json; a value that is not a list reads as none. */
|
||||||
function entriesOf(data: Record<string, unknown>, store: CredentialStore): Record<string, unknown>[] {
|
function entriesOf(data: Record<string, unknown>, store: CredentialStore): Record<string, unknown>[] {
|
||||||
const entries = data[store.key]
|
const entries = valueAt(data, store.key)
|
||||||
return Array.isArray(entries) ? entries.filter((entry) => entry !== null && typeof entry === 'object') : []
|
return Array.isArray(entries) ? entries.filter((entry) => entry !== null && typeof entry === 'object') : []
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,19 +162,15 @@ function read(store: CredentialStore) {
|
|||||||
|
|
||||||
/** The credentials of the entries, without their secrets. */
|
/** The credentials of the entries, without their secrets. */
|
||||||
function toProfiles(data: Record<string, unknown>, store: CredentialStore): CredentialProfile[] {
|
function toProfiles(data: Record<string, unknown>, store: CredentialStore): CredentialProfile[] {
|
||||||
return entriesOf(data, store).map((entry) => {
|
const { fields } = store.kind
|
||||||
const kind = store.kinds.find((item) => item.kind === entry.kind)
|
|
||||||
const fields = kind?.fields ?? []
|
|
||||||
const shown = (value: unknown) => (typeof value === 'string' || typeof value === 'number' ? String(value) : '')
|
const shown = (value: unknown) => (typeof value === 'string' || typeof value === 'number' ? String(value) : '')
|
||||||
return {
|
return entriesOf(data, store).map((entry) => ({
|
||||||
original: typeof entry.name === 'string' ? entry.name : null,
|
original: typeof entry.name === 'string' ? entry.name : null,
|
||||||
name: typeof entry.name === 'string' ? entry.name : '',
|
name: typeof entry.name === 'string' ? entry.name : '',
|
||||||
kind: typeof entry.kind === 'string' ? entry.kind : '',
|
|
||||||
values: Object.fromEntries(fields.filter((field) => !field.secret).map((field) => [field.key, shown(entry[field.key])])),
|
values: Object.fromEntries(fields.filter((field) => !field.secret).map((field) => [field.key, shown(entry[field.key])])),
|
||||||
secrets: Object.fromEntries(fields.filter((field) => field.secret).map((field) => [field.key, null])),
|
secrets: Object.fromEntries(fields.filter((field) => field.secret).map((field) => [field.key, null])),
|
||||||
stored: Object.fromEntries(fields.filter((field) => field.secret).map((field) => [field.key, typeof entry[field.key] === 'string'])),
|
stored: Object.fromEntries(fields.filter((field) => field.secret).map((field) => [field.key, typeof entry[field.key] === 'string'])),
|
||||||
}
|
}))
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -169,13 +181,12 @@ function toProfiles(data: Record<string, unknown>, store: CredentialStore): Cred
|
|||||||
function toEntries(profiles: CredentialProfile[], data: Record<string, unknown>, store: CredentialStore, seal: (secret: string) => string) {
|
function toEntries(profiles: CredentialProfile[], data: Record<string, unknown>, store: CredentialStore, seal: (secret: string) => string) {
|
||||||
const stored = entriesOf(data, store)
|
const stored = entriesOf(data, store)
|
||||||
return profiles.map((profile) => {
|
return profiles.map((profile) => {
|
||||||
const kind = store.kinds.find((item) => item.kind === profile.kind)
|
|
||||||
const previous = profile.original === null ? undefined : stored.find((entry) => entry.name === profile.original)
|
const previous = profile.original === null ? undefined : stored.find((entry) => entry.name === profile.original)
|
||||||
const entry: Record<string, unknown> = { name: profile.name.trim(), kind: profile.kind }
|
const entry: Record<string, unknown> = { name: profile.name.trim() }
|
||||||
for (const field of kind?.fields ?? []) {
|
for (const field of store.kind.fields) {
|
||||||
if (field.secret) {
|
if (field.secret) {
|
||||||
const secret = profile.secrets[field.key] ?? null
|
const secret = profile.secrets[field.key] ?? null
|
||||||
const value = secret === null ? (previous?.kind === profile.kind ? previous?.[field.key] : undefined) : secret === '' ? undefined : seal(secret)
|
const value = secret === null ? previous?.[field.key] : secret === '' ? undefined : seal(secret)
|
||||||
if (value !== undefined) {
|
if (value !== undefined) {
|
||||||
entry[field.key] = value
|
entry[field.key] = value
|
||||||
}
|
}
|
||||||
@@ -196,17 +207,16 @@ function toEntries(profiles: CredentialProfile[], data: Record<string, unknown>,
|
|||||||
*/
|
*/
|
||||||
export function credentialValues(store: CredentialStore, name: string): CredentialValues {
|
export function credentialValues(store: CredentialStore, name: string): CredentialValues {
|
||||||
const { data } = read(store)
|
const { data } = read(store)
|
||||||
const [problem] = Object.entries(credentialProblems(data[store.key], store))
|
const [problem] = Object.entries(credentialProblems(valueAt(data, store.key), store))
|
||||||
if (problem !== undefined) {
|
if (problem !== undefined) {
|
||||||
throw new HttpError(500, `${problem[0]} in ${store.configPath} ${problem[1]}.`)
|
throw new HttpError(500, `${problem[0]} in ${store.configPath} ${problem[1]}.`)
|
||||||
}
|
}
|
||||||
const entry = entriesOf(data, store).find((item) => item.name === name)
|
const entry = entriesOf(data, store).find((item) => item.name === name)
|
||||||
if (entry === undefined) {
|
if (entry === undefined) {
|
||||||
throw new HttpError(404, `The credential ${name} does not exist.`)
|
throw new HttpError(404, `The ${store.kind.label} ${name} does not exist.`)
|
||||||
}
|
}
|
||||||
const kind = store.kinds.find((item) => item.kind === entry.kind)!
|
|
||||||
const values: Record<string, string | number> = {}
|
const values: Record<string, string | number> = {}
|
||||||
for (const field of kind.fields) {
|
for (const field of store.kind.fields) {
|
||||||
const value = entry[field.key]
|
const value = entry[field.key]
|
||||||
if (value === undefined) {
|
if (value === undefined) {
|
||||||
continue
|
continue
|
||||||
@@ -225,7 +235,7 @@ export function credentialValues(store: CredentialStore, name: string): Credenti
|
|||||||
throw new HttpError(400, `The ${field.label.toLowerCase()} of ${name} cannot be decrypted with the server's encryption key; enter it again.`)
|
throw new HttpError(400, `The ${field.label.toLowerCase()} of ${name} cannot be decrypted with the server's encryption key; enter it again.`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { name, kind: kind.kind, values }
|
return { name, values }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -239,15 +249,15 @@ export function registerCredentialRoutes(app: FastifyInstance, store: Credential
|
|||||||
const flags = { type: 'object', additionalProperties: { type: 'boolean' } }
|
const flags = { type: 'object', additionalProperties: { type: 'boolean' } }
|
||||||
const profiles = {
|
const profiles = {
|
||||||
type: 'array',
|
type: 'array',
|
||||||
items: strict({ original: { type: ['string', 'null'] }, name: { type: 'string' }, kind: { type: 'string' }, values: record, secrets, stored: flags }),
|
items: strict({ original: { type: ['string', 'null'] }, name: { type: 'string' }, values: record, secrets, stored: flags }),
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The problems of the credentials by <index>.<field key>, a new secret included when the server has no key. */
|
/** The problems of the credentials by <index>.<field key>, a new secret included when the server has no key. */
|
||||||
function problemsOf(value: CredentialProfile[], data: Record<string, unknown>): Record<string, string> {
|
function problemsOf(value: CredentialProfile[], data: Record<string, unknown>): Record<string, string> {
|
||||||
const problems: Record<string, string> = {}
|
const problems: Record<string, string> = {}
|
||||||
for (const [path, message] of Object.entries(credentialProblems(toEntries(value, data, store, () => 'new'), store))) {
|
for (const [path, message] of Object.entries(credentialProblems(toEntries(value, data, store, () => 'new'), store))) {
|
||||||
const [, index, field] = path.split('.')
|
const [index, field] = path.slice(store.key.length + 1).split('.')
|
||||||
problems[field === undefined ? index ?? store.key : `${index}.${field}`] = `${field ?? 'The credential'} ${message}.`
|
problems[field === undefined ? index || store.key : `${index}.${field}`] = `${field ?? 'The credential'} ${message}.`
|
||||||
}
|
}
|
||||||
if (key() === null) {
|
if (key() === null) {
|
||||||
value.forEach((profile, index) => {
|
value.forEach((profile, index) => {
|
||||||
@@ -267,7 +277,7 @@ export function registerCredentialRoutes(app: FastifyInstance, store: Credential
|
|||||||
value: toProfiles(file.data, store),
|
value: toProfiles(file.data, store),
|
||||||
path: store.configPath,
|
path: store.configPath,
|
||||||
encryption: key() !== null,
|
encryption: key() !== null,
|
||||||
kinds: store.kinds,
|
kind: store.kind,
|
||||||
reserved: store.reserved ?? [],
|
reserved: store.reserved ?? [],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -291,11 +301,9 @@ export function registerCredentialRoutes(app: FastifyInstance, store: Credential
|
|||||||
throw new HttpError(422, 'The credentials are not valid.', problems)
|
throw new HttpError(422, 'The credentials are not valid.', problems)
|
||||||
}
|
}
|
||||||
const sealing = key()!
|
const sealing = key()!
|
||||||
const others = { ...file.data }
|
|
||||||
delete others[store.key]
|
|
||||||
const entries = toEntries(request.body.value, file.data, store, (secret) => encrypt(secret, sealing))
|
const entries = toEntries(request.body.value, file.data, store, (secret) => encrypt(secret, sealing))
|
||||||
try {
|
try {
|
||||||
writeFileSync(store.configPath, `${JSON.stringify({ ...others, [store.key]: entries }, null, 2)}\n`)
|
writeFileSync(store.configPath, `${JSON.stringify(withValue(file.data, store.key, entries), null, 2)}\n`)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new HttpError(500, `${store.configPath} cannot be written: ${(error as Error).message}`)
|
throw new HttpError(500, `${store.configPath} cannot be written: ${(error as Error).message}`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,3 +4,6 @@ export { HttpError, errorHandler, notFoundHandler, strict } from './http.ts'
|
|||||||
export { QUIET, createLog, fail, timestampsOn, type Log } from './log.ts'
|
export { QUIET, createLog, fail, timestampsOn, type Log } from './log.ts'
|
||||||
export { exitOnSignals, gitVersion, readJsonObject } from './process.ts'
|
export { exitOnSignals, gitVersion, readJsonObject } from './process.ts'
|
||||||
export { decrypt, encrypt, encryptionKey } from './secrets.ts'
|
export { decrypt, encrypt, encryptionKey } from './secrets.ts'
|
||||||
|
export type { TfInstance, TfModule, TfvarsFileStatus } 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'
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
-- 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.
|
||||||
|
|
||||||
|
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
|
||||||
|
);
|
||||||
|
|
||||||
|
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-]*$'),
|
||||||
|
state_schema text NOT NULL UNIQUE CHECK (state_schema ~ '^[a-z_][a-z0-9_]*$'),
|
||||||
|
created timestamptz NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (module, name)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS tf_variable_files (
|
||||||
|
module text NOT NULL,
|
||||||
|
instance text NOT NULL,
|
||||||
|
name text NOT NULL CHECK (name ~ '^[A-Za-z0-9_.-]+\.tfvars\.json$'),
|
||||||
|
content jsonb NOT NULL CHECK (jsonb_typeof(content) = 'object'),
|
||||||
|
previous jsonb CHECK (jsonb_typeof(previous) = 'object'),
|
||||||
|
updated timestamptz NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (module, instance, name),
|
||||||
|
FOREIGN KEY (module, instance) REFERENCES tf_instances (module, name) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- The modules, by name.
|
||||||
|
CREATE OR REPLACE FUNCTION tf_modules_list()
|
||||||
|
RETURNS TABLE (name text, root text, description text, configurator_url text)
|
||||||
|
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
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- 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)
|
||||||
|
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
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Deletes a module; the foreign key of tf_instances refuses it while the module has instances.
|
||||||
|
CREATE OR REPLACE PROCEDURE tf_module_delete(module_name text)
|
||||||
|
LANGUAGE sql
|
||||||
|
SET search_path FROM CURRENT
|
||||||
|
AS $$
|
||||||
|
DELETE FROM tf_modules WHERE name = module_name
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- The instances of a module, by name.
|
||||||
|
CREATE OR REPLACE FUNCTION tf_instances_list(module_name text)
|
||||||
|
RETURNS TABLE (module text, name text, state_schema text, created timestamptz)
|
||||||
|
LANGUAGE sql STABLE
|
||||||
|
SET search_path FROM CURRENT
|
||||||
|
AS $$
|
||||||
|
SELECT i.module, i.name, i.state_schema, i.created FROM tf_instances i WHERE i.module = module_name ORDER BY i.name
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Creates an instance of a module with the schema of its state.
|
||||||
|
CREATE OR REPLACE PROCEDURE tf_instance_create(module_name text, instance_name text, instance_state_schema text)
|
||||||
|
LANGUAGE sql
|
||||||
|
SET search_path FROM CURRENT
|
||||||
|
AS $$
|
||||||
|
INSERT INTO tf_instances (module, name, state_schema) VALUES (module_name, instance_name, instance_state_schema)
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Deletes an instance with its variable files.
|
||||||
|
CREATE OR REPLACE PROCEDURE tf_instance_delete(module_name text, instance_name text)
|
||||||
|
LANGUAGE sql
|
||||||
|
SET search_path FROM CURRENT
|
||||||
|
AS $$
|
||||||
|
DELETE FROM tf_instances WHERE module = module_name AND name = instance_name
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- The variable files of an instance, by name: content, whether the content it replaced is kept, and the time of the
|
||||||
|
-- last write.
|
||||||
|
CREATE OR REPLACE FUNCTION tf_variable_files_read(module_name text, instance_name text)
|
||||||
|
RETURNS TABLE (name text, content jsonb, previous boolean, updated timestamptz)
|
||||||
|
LANGUAGE sql STABLE
|
||||||
|
SET search_path FROM CURRENT
|
||||||
|
AS $$
|
||||||
|
SELECT f.name, f.content, f.previous IS NOT NULL, f.updated FROM tf_variable_files f
|
||||||
|
WHERE f.module = module_name AND f.instance = instance_name ORDER BY f.name
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Keeps other writers out until the end of the transaction, from the comparison of the version to the commit.
|
||||||
|
CREATE OR REPLACE PROCEDURE tf_variable_files_lock()
|
||||||
|
LANGUAGE sql
|
||||||
|
SET search_path FROM CURRENT
|
||||||
|
AS $$
|
||||||
|
LOCK TABLE tf_variable_files IN EXCLUSIVE MODE
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Writes a variable file of an instance, keeping the content it replaces as previous.
|
||||||
|
CREATE OR REPLACE PROCEDURE tf_variable_file_put(module_name text, instance_name text, file_name text, file_content jsonb)
|
||||||
|
LANGUAGE sql
|
||||||
|
SET search_path FROM CURRENT
|
||||||
|
AS $$
|
||||||
|
INSERT INTO tf_variable_files (module, instance, name, content) VALUES (module_name, instance_name, file_name, file_content)
|
||||||
|
ON CONFLICT (module, instance, name) DO UPDATE SET previous = tf_variable_files.content, content = excluded.content, updated = now()
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Deletes a variable file of an instance.
|
||||||
|
CREATE OR REPLACE PROCEDURE tf_variable_file_delete(module_name text, instance_name text, file_name text)
|
||||||
|
LANGUAGE sql
|
||||||
|
SET search_path FROM CURRENT
|
||||||
|
AS $$
|
||||||
|
DELETE FROM tf_variable_files WHERE module = module_name AND instance = instance_name AND name = file_name
|
||||||
|
$$;
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { after, before, test } from 'node:test'
|
||||||
|
import pg from 'pg'
|
||||||
|
import { HttpError } from './http.ts'
|
||||||
|
import { createTfvarsStore, tfvarsText, type TfvarsStore } from './tfvarsStore.ts'
|
||||||
|
|
||||||
|
// The tests create a scratch database on the server the PG* variables name, connected to through the database
|
||||||
|
// postgres, and drop it at the end.
|
||||||
|
const database = `tfvars_store_test_${process.pid}`
|
||||||
|
const admin = new pg.Client({ database: 'postgres' })
|
||||||
|
let pool: pg.Pool
|
||||||
|
let store: TfvarsStore
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
await admin.connect()
|
||||||
|
await admin.query(`CREATE DATABASE ${database}`)
|
||||||
|
pool = new pg.Pool({ database })
|
||||||
|
store = createTfvarsStore(pool)
|
||||||
|
})
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
await pool?.end()
|
||||||
|
await admin.query(`DROP DATABASE IF EXISTS ${database}`)
|
||||||
|
await admin.end()
|
||||||
|
})
|
||||||
|
|
||||||
|
const status = async (promise: Promise<unknown>) => {
|
||||||
|
try {
|
||||||
|
await promise
|
||||||
|
} catch (error) {
|
||||||
|
assert.ok(error instanceof HttpError, String(error))
|
||||||
|
return error.statusCode
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
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 })
|
||||||
|
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/' },
|
||||||
|
])
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('instances are created and refused for a missing module or a name in use', async () => {
|
||||||
|
await store.instances.create('router', 'platform', 'terraform_remote_state')
|
||||||
|
await store.instances.create('router', 'test', 'tf_router_test')
|
||||||
|
assert.deepEqual((await store.instances.list('router')).map((instance) => [instance.name, instance.stateSchema]), [
|
||||||
|
['platform', 'terraform_remote_state'],
|
||||||
|
['test', 'tf_router_test'],
|
||||||
|
])
|
||||||
|
assert.equal(await status(store.instances.create('missing', 'x', 'tf_missing_x')), 404)
|
||||||
|
assert.equal(await status(store.instances.create('router', 'test', 'tf_other')), 409)
|
||||||
|
assert.equal(await status(store.instances.create('router', 'other', 'tf_router_test')), 409)
|
||||||
|
assert.equal(await status(store.modules.delete('router')), 409)
|
||||||
|
})
|
||||||
|
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
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.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)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an instance is deleted with its variable files, then its module', async () => {
|
||||||
|
await store.instances.delete('router', 'test')
|
||||||
|
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)
|
||||||
|
await store.modules.delete('router')
|
||||||
|
assert.deepEqual(await store.modules.list(), [])
|
||||||
|
})
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
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<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.
|
||||||
|
*/
|
||||||
|
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>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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[]): 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<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) {
|
||||||
|
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<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])
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,9 +14,11 @@ export type CredentialField = {
|
|||||||
secret: boolean
|
secret: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A kind of credential, such as a database connection or a service principal, with its fields. */
|
/**
|
||||||
|
* The kind of the credentials of a store, such as a database connection or a service principal: a label for one
|
||||||
|
* credential and the fields of each. Every credential of a store is of its kind.
|
||||||
|
*/
|
||||||
export type CredentialKind = {
|
export type CredentialKind = {
|
||||||
kind: string
|
|
||||||
label: string
|
label: string
|
||||||
fields: CredentialField[]
|
fields: CredentialField[]
|
||||||
}
|
}
|
||||||
@@ -29,13 +31,12 @@ export type CredentialKind = {
|
|||||||
export type CredentialProfile = {
|
export type CredentialProfile = {
|
||||||
original: string | null
|
original: string | null
|
||||||
name: string
|
name: string
|
||||||
kind: string
|
|
||||||
values: Record<string, string>
|
values: Record<string, string>
|
||||||
secrets: Record<string, string | null>
|
secrets: Record<string, string | null>
|
||||||
stored: Record<string, boolean>
|
stored: Record<string, boolean>
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The read answer of a credential store and of a write: its credentials, kinds and the names it does not allow. */
|
/** The read answer of a credential store and of a write: its credentials, its kind and the names it does not allow. */
|
||||||
export type CredentialsDocument = {
|
export type CredentialsDocument = {
|
||||||
/** Identifies the content of config.json; a write sends it back and is refused with 409 when the file changed since. */
|
/** Identifies the content of config.json; a write sends it back and is refused with 409 when the file changed since. */
|
||||||
version: string
|
version: string
|
||||||
@@ -44,6 +45,6 @@ export type CredentialsDocument = {
|
|||||||
path: string
|
path: string
|
||||||
/** Whether ENCRYPTION_KEY or ENCRYPTION_KEY_FILE gives the server a key, which a secret needs. */
|
/** Whether ENCRYPTION_KEY or ENCRYPTION_KEY_FILE gives the server a key, which a secret needs. */
|
||||||
encryption: boolean
|
encryption: boolean
|
||||||
kinds: CredentialKind[]
|
kind: CredentialKind
|
||||||
reserved: string[]
|
reserved: string[]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
/** A registered Terraform module: a folder of Terraform code, relative to the modules folder of Cloud Platform. */
|
||||||
|
export type TfModule = {
|
||||||
|
name: string
|
||||||
|
root: string
|
||||||
|
description: string
|
||||||
|
/** The address of the module's configurator, the application that edits the variables of its instances. */
|
||||||
|
configuratorUrl: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A deployment of a module, whose state is kept in the schema stateSchema of the pg backend. */
|
||||||
|
export type TfInstance = {
|
||||||
|
module: string
|
||||||
|
name: string
|
||||||
|
stateSchema: string
|
||||||
|
/** ISO 8601. */
|
||||||
|
created: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A variable file of an instance, without its content. */
|
||||||
|
export type TfvarsFileStatus = {
|
||||||
|
name: string
|
||||||
|
bytes: number
|
||||||
|
/** ISO 8601, the time of the last write. */
|
||||||
|
modified: string
|
||||||
|
/** Whether the content the last write replaced is kept. */
|
||||||
|
previous: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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-]*$/
|
||||||
|
|
||||||
|
/** The names of variable files: letters, digits, '_', '.' and '-', ending in .tfvars.json. */
|
||||||
|
export const TFVARS_FILE_PATTERN = /^[A-Za-z0-9_.-]+\.tfvars\.json$/
|
||||||
|
|
||||||
|
/** The names of pg schemas of a state: a lower-case letter or '_', then lower-case letters, digits and '_'. */
|
||||||
|
export const STATE_SCHEMA_PATTERN = /^[a-z_][a-z0-9_]*$/
|
||||||
+2
-1
@@ -6,5 +6,6 @@
|
|||||||
"outDir": "dist",
|
"outDir": "dist",
|
||||||
"declaration": true,
|
"declaration": true,
|
||||||
"rewriteRelativeImportExtensions": true
|
"rewriteRelativeImportExtensions": true
|
||||||
}
|
},
|
||||||
|
"exclude": ["dist", "**/*.test.ts"]
|
||||||
}
|
}
|
||||||
+157
-73
@@ -1,25 +1,33 @@
|
|||||||
import Add from '@mui/icons-material/Add'
|
import Add from '@mui/icons-material/Add'
|
||||||
import Delete from '@mui/icons-material/Delete'
|
import Delete from '@mui/icons-material/Delete'
|
||||||
import Key from '@mui/icons-material/Key'
|
import Key from '@mui/icons-material/Key'
|
||||||
import { Alert, Box, Button, IconButton, List, ListItemButton, ListItemText, MenuItem, Snackbar, Stack, TextField, Tooltip, Typography } from '@mui/material'
|
import {
|
||||||
import { useState, type ReactNode } from 'react'
|
Alert, Box, Button, Dialog, DialogActions, DialogContent, DialogTitle, IconButton, List, ListItemButton, ListItemText, MenuItem, Snackbar, Stack, Tab, Tabs,
|
||||||
import type { CredentialKind, CredentialProfile, CredentialsDocument } from '../shared/credentials.ts'
|
TextField, Tooltip, Typography,
|
||||||
|
} from '@mui/material'
|
||||||
|
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||||
|
import type { CredentialProfile, CredentialsDocument } from '../shared/credentials.ts'
|
||||||
import { request } from './api.ts'
|
import { request } from './api.ts'
|
||||||
import RowDialog from './RowDialog.tsx'
|
|
||||||
import TextInput from './TextInput.tsx'
|
import TextInput from './TextInput.tsx'
|
||||||
|
import { useValidation } from './useValidation.ts'
|
||||||
|
|
||||||
/** A new credential of the kind, named apart from those of profiles, its choices at their first value. */
|
/** A credential store the dialog edits: the prefix of its routes and the label of its tab. */
|
||||||
function newProfile(kind: CredentialKind, profiles: CredentialProfile[]): CredentialProfile {
|
export type CredentialsStoreTab = { prefix: string; label: string }
|
||||||
|
|
||||||
|
/** The state of the validation of a store's credentials: its problems, and whether the answer is pending. */
|
||||||
|
type Check = { problems: Record<string, string>; pending: boolean; error: string }
|
||||||
|
|
||||||
|
/** A new credential of the kind of document, named apart from those of profiles, its choices at their first value. */
|
||||||
|
function newProfile(document: CredentialsDocument, profiles: CredentialProfile[]): CredentialProfile {
|
||||||
let number = profiles.length + 1
|
let number = profiles.length + 1
|
||||||
while (profiles.some((profile) => profile.name === `${kind.kind}${number}`)) {
|
while (profiles.some((profile) => profile.name === `credential${number}`)) {
|
||||||
number++
|
number++
|
||||||
}
|
}
|
||||||
const plain = kind.fields.filter((field) => !field.secret)
|
const plain = document.kind.fields.filter((field) => !field.secret)
|
||||||
const secret = kind.fields.filter((field) => field.secret)
|
const secret = document.kind.fields.filter((field) => field.secret)
|
||||||
return {
|
return {
|
||||||
original: null,
|
original: null,
|
||||||
name: `${kind.kind}${number}`,
|
name: `credential${number}`,
|
||||||
kind: kind.kind,
|
|
||||||
values: Object.fromEntries(plain.map((field) => [field.key, field.type === 'choice' ? field.choices?.[0] ?? '' : ''])),
|
values: Object.fromEntries(plain.map((field) => [field.key, field.type === 'choice' ? field.choices?.[0] ?? '' : ''])),
|
||||||
secrets: Object.fromEntries(secret.map((field) => [field.key, ''])),
|
secrets: Object.fromEntries(secret.map((field) => [field.key, ''])),
|
||||||
stored: Object.fromEntries(secret.map((field) => [field.key, false])),
|
stored: Object.fromEntries(secret.map((field) => [field.key, false])),
|
||||||
@@ -27,94 +35,70 @@ function newProfile(kind: CredentialKind, profiles: CredentialProfile[]): Creden
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A button opening the credentials of a store, served under prefix, in a dialog: the credentials in a list at the
|
* The editor of one store: the credentials in a list at the left, Add and Remove, and the fields of the one selected
|
||||||
* left, Add for each kind and Remove, and the fields of the one selected at the right, as its kind names them; a
|
* at the right, as the store's kind names them; a credential with a problem is red in the list. A secret shows only
|
||||||
* credential with a problem is red in the list. A secret is kept as it is until it is typed anew or removed, and is
|
* whether it is kept: Set or Change replaces it and Remove drops it. The server validates the credentials as they
|
||||||
* never shown. The server validates the credentials as they change and writes them on save, encrypting new secrets;
|
* change, and onCheck follows the validation.
|
||||||
* onSaved follows a save. note is shown above the list.
|
|
||||||
*/
|
*/
|
||||||
export default function CredentialsButton(props: { prefix: string; title: string; icon?: ReactNode; note?: ReactNode; onSaved?: () => void }) {
|
function StoreEditor(props: { prefix: string; document: CredentialsDocument; profiles: CredentialProfile[]; onChange: (profiles: CredentialProfile[]) => void; onCheck: (check: Check) => void }) {
|
||||||
const { prefix, title } = props
|
const { document, profiles, onChange, onCheck } = props
|
||||||
const [document, setDocument] = useState<CredentialsDocument | null>(null)
|
|
||||||
const [selected, setSelected] = useState(0)
|
const [selected, setSelected] = useState(0)
|
||||||
const [error, setError] = useState('')
|
const validation = useValidation(`${props.prefix}/validate`, profiles)
|
||||||
|
const problems = validation.result?.problems ?? {}
|
||||||
|
// The latest onCheck; the validation's answers, not a new onCheck, report the check.
|
||||||
|
const report = useRef(onCheck)
|
||||||
|
|
||||||
function open() {
|
useEffect(() => {
|
||||||
setSelected(0)
|
report.current = onCheck
|
||||||
request<CredentialsDocument>('GET', `${prefix}/read`).then(setDocument, (caught: Error) => setError(caught.message))
|
})
|
||||||
}
|
|
||||||
|
|
||||||
async function save(value: CredentialProfile[]) {
|
useEffect(() => {
|
||||||
await request<CredentialsDocument>('POST', `${prefix}/write`, { version: document?.version, value })
|
report.current({ problems: validation.result?.problems ?? {}, pending: validation.pending, error: validation.error })
|
||||||
setDocument(null)
|
}, [validation.result, validation.pending, validation.error])
|
||||||
props.onSaved?.()
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Tooltip title={title}>
|
|
||||||
<IconButton aria-label={title.toLowerCase()} onClick={open}>
|
|
||||||
{props.icon ?? <Key />}
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
{document && (
|
|
||||||
<RowDialog<CredentialProfile[]>
|
|
||||||
title={title}
|
|
||||||
initial={document.value}
|
|
||||||
validatePath={`${prefix}/validate`}
|
|
||||||
saveLabel="Save"
|
|
||||||
onSave={save}
|
|
||||||
onCancel={() => setDocument(null)}
|
|
||||||
fields={(profiles, set, problems) => {
|
|
||||||
const index = Math.min(selected, profiles.length - 1)
|
const index = Math.min(selected, profiles.length - 1)
|
||||||
const profile = profiles[index]
|
const profile = profiles[index]
|
||||||
const kind = document.kinds.find((item) => item.kind === profile?.kind)
|
|
||||||
const problem = (field: string) => problems[`${index}.${field}`]
|
const problem = (field: string) => problems[`${index}.${field}`]
|
||||||
const change = (next: Partial<CredentialProfile>) => set(profiles.map((item, at) => (at === index ? { ...item, ...next } : item)))
|
const change = (next: Partial<CredentialProfile>) => onChange(profiles.map((item, at) => (at === index ? { ...item, ...next } : item)))
|
||||||
const add = (item: CredentialKind) => {
|
const { kind } = document
|
||||||
set([...profiles, newProfile(item, profiles)])
|
|
||||||
setSelected(profiles.length)
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Stack sx={{ gap: 1 }}>
|
||||||
<Typography variant="body2" color="text.secondary">
|
{validation.error && <Alert severity="error">{validation.error}</Alert>}
|
||||||
Saved to <b>{document.path}</b>.
|
<Stack direction="row" sx={{ gap: 2, minHeight: 420, position: 'relative' }}>
|
||||||
</Typography>
|
|
||||||
{props.note}
|
|
||||||
{!document.encryption && (
|
|
||||||
<Alert severity="info">The server has no ENCRYPTION_KEY or ENCRYPTION_KEY_FILE, so the credentials cannot have secrets.</Alert>
|
|
||||||
)}
|
|
||||||
<Stack direction="row" sx={{ gap: 2, minHeight: 420 }}>
|
|
||||||
<Stack sx={{ width: 180, flexShrink: 0 }}>
|
<Stack sx={{ width: 180, flexShrink: 0 }}>
|
||||||
<List dense sx={{ flexGrow: 1, overflow: 'auto' }}>
|
<List dense sx={{ flexGrow: 1, overflow: 'auto' }}>
|
||||||
{profiles.map((item, at) => (
|
{profiles.map((item, at) => (
|
||||||
<ListItemButton key={at} selected={at === index} onClick={() => setSelected(at)}>
|
<ListItemButton key={at} selected={at === index} onClick={() => setSelected(at)}>
|
||||||
<ListItemText
|
<ListItemText
|
||||||
primary={item.name || '(no name)'}
|
primary={item.name || '(no name)'}
|
||||||
secondary={document.kinds.length > 1 ? document.kinds.find((entry) => entry.kind === item.kind)?.label ?? item.kind : undefined}
|
|
||||||
slotProps={{ primary: { noWrap: true, color: Object.keys(problems).some((key) => key === `${at}` || key.startsWith(`${at}.`)) ? 'error' : undefined } }}
|
slotProps={{ primary: { noWrap: true, color: Object.keys(problems).some((key) => key === `${at}` || key.startsWith(`${at}.`)) ? 'error' : undefined } }}
|
||||||
/>
|
/>
|
||||||
</ListItemButton>
|
</ListItemButton>
|
||||||
))}
|
))}
|
||||||
</List>
|
</List>
|
||||||
<Stack sx={{ gap: 0.5, alignItems: 'flex-start' }}>
|
<Stack direction="row" sx={{ gap: 1 }}>
|
||||||
{document.kinds.map((item) => (
|
<Button size="small" startIcon={<Add />} onClick={() => {
|
||||||
<Button key={item.kind} size="small" startIcon={<Add />} onClick={() => add(item)}>
|
onChange([...profiles, newProfile(document, profiles)])
|
||||||
{document.kinds.length > 1 ? item.label : 'Add'}
|
setSelected(profiles.length)
|
||||||
|
}}>
|
||||||
|
Add
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
<Button size="small" color="error" startIcon={<Delete />} disabled={profile === undefined} onClick={() => onChange(profiles.filter((_item, at) => at !== index))}>
|
||||||
<Button size="small" color="error" startIcon={<Delete />} disabled={profile === undefined} onClick={() => set(profiles.filter((_item, at) => at !== index))}>
|
|
||||||
Remove
|
Remove
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
{profile === undefined || kind === undefined ? (
|
{profile === undefined ? (
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ flexGrow: 1 }}>
|
// Centered over the list and the fields together.
|
||||||
{profile === undefined ? 'No credentials. Add creates one.' : `The kind ${profile.kind} is not known; remove the credential.`}
|
<Box sx={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', pointerEvents: 'none' }}>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
None yet. Add creates a {kind.label}.
|
||||||
</Typography>
|
</Typography>
|
||||||
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
<Stack sx={{ flexGrow: 1, gap: 1 }}>
|
<Stack sx={{ flexGrow: 1, gap: 1 }}>
|
||||||
<TextInput label="Name" value={profile.name} problem={problem('name')} help={`The name of the ${kind.label.toLowerCase()}.`} onChange={(name) => change({ name })} />
|
<TextInput label="Name" value={profile.name} problem={problem('name')} help={`The name of the ${kind.label}.`} onChange={(name) => change({ name })} />
|
||||||
{kind.fields.map((field) => {
|
{kind.fields.map((field) => {
|
||||||
if (field.type === 'choice' && !field.secret) {
|
if (field.type === 'choice' && !field.secret) {
|
||||||
return (
|
return (
|
||||||
@@ -180,10 +164,110 @@ export default function CredentialsButton(props: { prefix: string; title: string
|
|||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
</>
|
</Stack>
|
||||||
)
|
)
|
||||||
}}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A button opening the credentials of one or more stores of the same config.json, served under their prefixes, in a
|
||||||
|
* dialog, with a tab for each store when there are several; a tab whose store has a problem has a red label. Save
|
||||||
|
* writes each store that changed, encrypting new secrets, after the server found no problem in any; onSaved follows a
|
||||||
|
* save. note is shown above the tabs.
|
||||||
|
*/
|
||||||
|
export default function CredentialsButton(props: { stores: CredentialsStoreTab[]; title: string; icon?: ReactNode; note?: ReactNode; onSaved?: () => void }) {
|
||||||
|
const { stores, title } = props
|
||||||
|
const [documents, setDocuments] = useState<CredentialsDocument[] | null>(null)
|
||||||
|
const [values, setValues] = useState<CredentialProfile[][]>([])
|
||||||
|
const [checks, setChecks] = useState<Check[]>([])
|
||||||
|
const [tab, setTab] = useState(0)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [saveError, setSaveError] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
function open() {
|
||||||
|
setTab(0)
|
||||||
|
setSaveError('')
|
||||||
|
Promise.all(stores.map((store) => request<CredentialsDocument>('GET', `${store.prefix}/read`))).then((answers) => {
|
||||||
|
setValues(answers.map((answer) => answer.value))
|
||||||
|
setChecks(answers.map(() => ({ problems: {}, pending: true, error: '' })))
|
||||||
|
setDocuments(answers)
|
||||||
|
}, (caught: Error) => setError(caught.message))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (documents === null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSaving(true)
|
||||||
|
setSaveError('')
|
||||||
|
try {
|
||||||
|
// The stores share config.json: each write answers the version the next one sends.
|
||||||
|
let version = documents[0].version
|
||||||
|
for (const [index, store] of stores.entries()) {
|
||||||
|
if (JSON.stringify(values[index]) !== JSON.stringify(documents[index].value)) {
|
||||||
|
version = (await request<CredentialsDocument>('POST', `${store.prefix}/write`, { version, value: values[index] })).version
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setDocuments(null)
|
||||||
|
props.onSaved?.()
|
||||||
|
} catch (caught) {
|
||||||
|
setSaveError((caught as Error).message)
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const blocked = checks.some((check) => check.pending || check.error !== '' || Object.keys(check.problems).length > 0)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Tooltip title={title}>
|
||||||
|
<IconButton aria-label={title.toLowerCase()} onClick={open}>
|
||||||
|
{props.icon ?? <Key />}
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
{documents && (
|
||||||
|
<Dialog open onClose={(_event, reason) => reason !== 'backdropClick' && setDocuments(null)} maxWidth="sm" fullWidth>
|
||||||
|
<DialogTitle>{title}</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Stack sx={{ gap: 2, pt: 1 }}>
|
||||||
|
{saveError && <Alert severity="error" sx={{ whiteSpace: 'pre-line' }}>{saveError}</Alert>}
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Saved to <b>{documents[0].path}</b>.
|
||||||
|
</Typography>
|
||||||
|
{props.note}
|
||||||
|
{!documents[0].encryption && (
|
||||||
|
<Alert severity="info">The server has no ENCRYPTION_KEY or ENCRYPTION_KEY_FILE, so the credentials cannot have secrets.</Alert>
|
||||||
|
)}
|
||||||
|
{stores.length > 1 && (
|
||||||
|
<Tabs value={tab} onChange={(_event, next: number) => setTab(next)} sx={{ borderBottom: 1, borderColor: 'divider' }}>
|
||||||
|
{stores.map((store, index) => (
|
||||||
|
<Tab key={store.prefix} value={index} label={store.label} sx={Object.keys(checks[index]?.problems ?? {}).length > 0 ? { color: 'error.main' } : undefined} />
|
||||||
|
))}
|
||||||
|
</Tabs>
|
||||||
|
)}
|
||||||
|
<Box sx={{ display: 'grid' }}>
|
||||||
|
{stores.map((store, index) => (
|
||||||
|
<Box key={store.prefix} sx={{ gridArea: '1 / 1', visibility: tab === index ? 'visible' : 'hidden' }}>
|
||||||
|
<StoreEditor
|
||||||
|
prefix={store.prefix}
|
||||||
|
document={documents[index]}
|
||||||
|
profiles={values[index]}
|
||||||
|
onChange={(profiles) => setValues((current) => current.map((item, at) => (at === index ? profiles : item)))}
|
||||||
|
onCheck={(check) => setChecks((current) => current.map((item, at) => (at === index ? check : item)))}
|
||||||
/>
|
/>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setDocuments(null)}>Cancel</Button>
|
||||||
|
<Button variant="contained" disabled={saving || blocked} onClick={() => void save()}>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
)}
|
)}
|
||||||
<Snackbar open={error !== ''} autoHideDuration={8000} onClose={() => setError('')}>
|
<Snackbar open={error !== ''} autoHideDuration={8000} onClose={() => setError('')}>
|
||||||
<Alert severity="error" onClose={() => setError('')}>
|
<Alert severity="error" onClose={() => setError('')}>
|
||||||
|
|||||||
+3
-1
@@ -1,8 +1,10 @@
|
|||||||
export type { CredentialField, CredentialKind, CredentialProfile, CredentialsDocument } from '../shared/credentials.ts'
|
export type { CredentialField, CredentialKind, CredentialProfile, CredentialsDocument } from '../shared/credentials.ts'
|
||||||
|
export type { TfInstance, TfModule, TfvarsFileStatus } from '../shared/tfvars.ts'
|
||||||
|
export { STATE_SCHEMA_PATTERN, TFVARS_FILE_PATTERN, TF_NAME_PATTERN } from '../shared/tfvars.ts'
|
||||||
export { ApiError, request, type Validation } from './api.ts'
|
export { ApiError, request, type Validation } from './api.ts'
|
||||||
export { createBrowserSettings } from './browserSettings.ts'
|
export { createBrowserSettings } from './browserSettings.ts'
|
||||||
export { default as Card } from './Card.tsx'
|
export { default as Card } from './Card.tsx'
|
||||||
export { default as CredentialsButton } from './CredentialsButton.tsx'
|
export { default as CredentialsButton, type CredentialsStoreTab } from './CredentialsButton.tsx'
|
||||||
export { MONOSPACED, REGULAR, verbatim } from './fonts.ts'
|
export { MONOSPACED, REGULAR, verbatim } from './fonts.ts'
|
||||||
export { appTheme, renderApp } from './renderApp.tsx'
|
export { appTheme, renderApp } from './renderApp.tsx'
|
||||||
export { default as RowDialog } from './RowDialog.tsx'
|
export { default as RowDialog } from './RowDialog.tsx'
|
||||||
|
|||||||
Reference in new issue
Block a user