@tsg-dsp/cli
v0.20.1
Published
TSG deployment CLI: renders Kubernetes manifests and applies them
Readme
TSG CLI
Renders a TSG deployment to Kubernetes manifests and applies them.
Prerequisites are Node.js 22 and kubectl. The CLI does not use Helm.
Before applying anything, tsg deploy checks the requirements of the rendered
bundle. Required CRDs must exist and have a supported version. An older
controller version produces a warning but does not block the deployment.
CloudNativePG 1.30 or newer supplies the DatabaseRole CRD. The static version
table lives in lib/cluster/prerequisites.ts, so deployment does not require an
upstream release feed.
deployment.yaml
↓ validate → one complete in-memory deployment model
typed Kubernetes resources
↓ ordered patches, extra manifests, deletions
final deterministic manifests
├── commit to Git → Argo CD / Flux owns apply and prune
└── tsg deploy → kubectl diff/apply/wait + inventory-backed pruneCommands
| Command | What it does |
| --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
| tsg render <config> [-s <id>...] [--include-infra] [-o <dir>] | Writes a manifest bundle without contacting a cluster or the network. |
| tsg diff <config> [-s <id>...] | Renders to a temporary bundle and shows what applying it would change, including what would be pruned. |
| tsg apply <bundle-dir> | Applies an already-rendered bundle. Never reinterprets the configuration file. |
| tsg deploy <config> [-s <id>...] | render → diff → apply → wait → prune over a temporary bundle. |
| tsg uninstall <config> [-s <id>...] [--delete-data] | Deletes from the recorded inventory. Data is retained unless --delete-data. |
| tsg status <config> | Lists the inventory sets recorded in the cluster. |
| tsg migrate <config> [-w] | Rewrites a configuration file without contacting a cluster or the network. |
| tsg secrets generate <config> [--apply] | Demo helper for the Secrets that rendered output only references. |
| tsg keys generate <stack> <client> | Writes a private_key_jwt pair. Commit the public JWK, not the private JWK. |
Only apply, deploy, diff, uninstall, status, and
secrets generate --apply contact a cluster. render and migrate also skip
the CLI release check.
Examples
examples/ holds six configurations ordered by how much of the system they
use, from one organisation joining an existing dataspace to a full composition
cookbook. See examples/README.md; every one of them renders as-is, and
test/examples.test.ts keeps it that way.
Configuration versioning
The CLI versions three formats independently. This lets the input schema change without invalidating every bundle, and lets the bundle format change without invalidating cluster inventories.
| Format | Lives in | Marker |
| ------------- | ------------------------- | ------------------------------------ |
| Input config | the user's Git repository | apiVersion |
| bundle.json | rendered output | formatVersion (utils/formats.ts) |
| Inventory | the cluster | formatVersion (utils/formats.ts) |
The inventory is the long-lived format. A CLI may read a ConfigMap written months earlier by another version. It rejects an inventory format newer than it understands because a wrong interpretation could prune the wrong objects.
Input versions
tsg --version lists every apiVersion this CLI accepts. Each lives in its own
frozen directory under lib/config/ and converts only to its immediate
successor. Adding a version therefore needs one converter. Only the newest
version has a resolver, so model/ does not handle older versions.
lib/config/
v1alpha1/schema.ts frozen at release, never edited again
current.ts the newest served version
versions.ts the served list, oldest first
load.ts validate at the declared version, then convert upAdding v1alpha2 needs a new directory, v1alpha1/up.ts, one line in
versions.ts, one line in current.ts, and a frozen fixture under
test/fixtures/versions/. Released schemas are not edited. A file that
validated yesterday must validate the same way tomorrow. Tests over the frozen
fixtures enforce that rule.
tsg migrate <config> rewrites a file at the newest version, printing to stdout
unless -w is given. Comments and key order are not preserved, so review the
diff.
When to bump
| Change | Bump? | | -------------------------------------------- | ----- | | New optional field | no | | Removed, renamed, or newly required field | yes | | Changed default that changes rendered output | yes | | Changed semantics of an existing field | yes |
A changed default that produces a different manifest is a breaking change even when existing configuration files still parse.
Unknown fields are hard errors. See forbidNonWhitelisted in
utils/validation.ts. Without that check, a renamed field could be ignored and
produce the wrong manifests without an error.
The bundle
out/
bundle.json # CLI metadata; never applied as a Kubernetes object
manifests/
infra/manifests.yaml
stacks/
authority/manifests.yaml
alfa/manifests.yamlbundle.json records the resolved image references, CLI version, and input
apiVersion. Its layout has a separate formatVersion. Rendering with another
CLI version may select different image tags. Set
spec.images.<component>.digest for committed bundles.
The stack is the unit of deployment and Kubernetes ownership. Shared
infrastructure, the Namespace and in shared database mode the CNPG Cluster,
renders separately. A participant is seed data inside a stack and never gets an
apply set or inventory of its own.
Topology and participant seeding
The configuration has four distinct concepts:
spec.dataspaceis the trust domain the stacks in this file join. It names the dataspace and states the authority's DID explicitly, because one dataspace is normally deployed from several files by several organisations and the issuer is usually not one of the stacks in front of you.TsgDeploymentis the complete desired deployment described by this file.spec.stacks[]are deployable TSG runtimes. A stack owns its wallet, SSO bridge, optional control/data planes, database resources, patches and apply inventory.stack.participantSeeds[]are dataspace identities to onboard into that runtime. They own no Kubernetes resources.
Every participant is a tenant, even when a stack has only one. Its slug is its
ID and its DID is always did:web:<host>:tenants:<id>. This keeps the DID
stable when another participant is added later. A wallet-only stack may also
start without seeds and onboard participants later.
When a file includes the issuer, dataspace.authority.did must match the DID
served by that issuer. The CLI rejects a mismatch.
The renderer writes these records to the wallet's and control plane's
initTenants startup configuration. Each control-plane tenant gets its own
IAM identity, wallet tenant binding, catalog and memberships. A future seed API or job can replace this mapping
without changing stack ownership. Omitting a seed is not an offboarding
operation.
A control plane serves all participant seeds in its stack and requires at least
one. Data planes and server use-case apps each act as one participant: set
participant: <seed-id> on each in a shared stack. With exactly one seed, the
CLI selects it automatically. Discovery and data-plane protocol URLs include
/tenants/<slug>; management clients send the tenant slug explicitly.
See examples/02-ecosystem.yaml and examples/03-managed-service.yaml.
When upgrading an existing control-plane database from before multi-tenancy,
its migrated data belongs to the default tenant. Preserve that slug in the
participant seed or migrate the tenant explicitly; choosing a new seed id does
not move existing data. Tenant bindings select request context; OAuth scopes
still govern machine-client access.
HTTP data plane resources
The HTTP data plane executes dataflows. It no longer self-registers datasets or owns their metadata and private asset configuration. Deploy Dataspace Starter next to it to publish and manage those resources through the control-plane SDK:
components:
controlPlane:
enabled: true
dataPlanes:
http-data-plane: {}
useCaseApps:
dataspace-starter: {}The data plane still stores operational dataflow state and request logs, so its database remains enabled by default.
Use-case apps
A use-case app is an application built on top of a stack, deployed alongside
its components under stack.components.useCaseApps. It is user facing, so it
gets an Ingress or an HTTPRoute of its own, on the stack's host.
The image profile determines whether an app is a server or static app:
| Shape | What it is | Gets |
| -------- | ------------------------------- | -------------------------------------------------------------------- |
| server | an API with a web UI | a database, a confidential OAuth client and a rendered config.yaml |
| static | a built single-page application | a Deployment, a Service and a route |
First-party profiles are analytics-orchestration (server),
dataspace-starter and gaiax-credential-workbench (static). The map key
names the rendered resources and the route; type picks the image, so the same
app can be deployed twice under different keys.
Dataspace Starter also gets a public OAuth client for its browser-based PKCE login. It has no client secret and no credentials are added to the bundle.
useCaseApps:
analytics-orchestration: {}
credentials:
type: gaiax-credential-workbench
subPath: /credentialsA server app acts as one participant, selected with participant: <seed-id>
in a shared stack. Analytics Orchestration uses an analytics data plane bound
to that same participant, when present. A static app has no participant binding
and is allowed anywhere. See
examples/06-use-case-apps.yaml.
Composition
The CLI applies patches and extra manifests after render and before apply. See
examples/05-composition.yaml.
- Targets are Kustomize-compatible:
group,version,kind,name,namespace,labelSelector,annotationSelector. - Operations are
merge(maps merge recursively, arrays are replaced),jsonPatch(RFC 6902, required for list surgery) anddelete: true. - Patches run in declared order; global ones first, then a stack's own.
- Extra manifests load before patches, so a caller can patch their own additions.
- A target matching zero resources fails unless
optional: true; a target matching several fails unlessallowMultiple: true. - A deleted resource is absent from the bundle, and therefore prune-eligible.
The CLI adds identity labels before patches, which makes them available to a
labelSelector. It validates the labels after patching and rejects changes to
them.
The typed workload block covers replicas, resources, nodeSelector,
routing.className, and routing.annotations. Keep this list small and use
patches for less common Kubernetes settings.
Naming
Rendered names are public API, because patches target them. See utils/naming.ts,
frozen and tested in test/naming.test.ts. Component resources are
<stack>-tsg-<component>, suffixed -config, -ingress, -gateway,
-route, -pvc, -role, -binding. Names are namespace-independent, so
namespace-per-stack stays addable.
Databases
Every component owns its database through a CNPG DatabaseRole. The login
role is named <stack>__<component>, and a separate basic-auth Secret holds
its password. Ownership is the isolation boundary, so no component can read
another's tables within a stack or across stacks that share a Cluster in
database.mode: shared.
PostgreSQL still grants CONNECT on a database to PUBLIC, so any role in the
cluster, including CNPG's bootstrap app role, can still connect to any
database and read catalog metadata such as table names. Table contents stay
owner-only. The CLI does not revoke that grant, because CNPG's Database CR
manages schemas and extensions but not privileges, and closing it would mean
bootstrap SQL for a gap that exposes no data.
database.mode is still a deployment boundary rather than a hard security
boundary: stacks in one namespace share the namespace, and in shared mode
they share a PostgreSQL instance and its resources. The default is perStack.
Secrets
Resources generated by the CLI contain no credential values. They only reference existing Secrets for database credentials, OAuth client credentials, private-key JWT keys, the initial administrator password and OID4VCI pre-authorized codes.
Patches, extra manifests and application config are caller-controlled and can
still introduce credentials. Inspect those inputs before committing a bundle;
the CLI cannot truthfully guarantee that arbitrary composed output is safe for
Git.
Deliver Secrets with SOPS, Sealed Secrets, External Secrets, or kubectl.
tsg deploy checks that every referenced Secret and key exists before applying
anything. For a demo, tsg secrets generate <config> --apply creates them. It
does not overwrite material that already exists.
With auth.clientAuthMethod: private_key_jwt, the public JWK is a render input.
The renderer cannot read it from the cluster. tsg keys generate writes the
public half to keys/<stack>-<client>.jwk to commit, and the private half to a
git-ignored directory.
Ownership and pruning
Each apply set has an inventory ConfigMap in the cluster holding the exact identity of every applied object. Labels support status and selection, but the CLI does not use them as deletion authority. A label sweep could delete an unrelated object that received the label through a patch.
The CLI writes the inventory in two phases. Before changing the cluster, it
records the previous and desired objects as phase: applying. After apply and
readiness succeed, it records only the desired objects and prunes the
difference. If readiness fails after apply, fix the cause and rerun the command
without a reset.
Normal prune and uninstall retain CNPG Cluster, Database, and DatabaseRole
resources plus PVCs. Database and DatabaseRole use a retain reclaim
policy. --delete-data changes those policies to delete before removing the
custom resources. The CLI works in reverse apply order so it drops each database
before its owner role.
An object that another set of the same deployment currently claims is never pruned, no matter how stale the inventory naming it is. Two sets can name the same object after a rename, and ownership by a live set always wins over removal by an obsolete one.
A full deployment reconciles removed stacks and retains their data. A stack-selected deployment does not reconcile unselected stacks. Removing a participant seed does not remove Kubernetes resources.
Inventory writes are optimistic, so a stale invocation cannot prune after another run advanced the same set. Concurrent deployment of the same set is unsupported; different sets are fine.
Code layout
Directories follow the order of the render and deployment pipeline.
bin/tsg-cli.ts argument parsing only
lib/
commands/ one file per command group, named after it
config/ input schema, served versions, conversion
model/ configuration -> one resolved, complete deployment model
types.ts the resolved model
resolve.ts topology: stacks, components, images, databases
profiles.ts what workload structure each component needs
appconfig/ each component's application configuration
render/ model -> typed Kubernetes resources
sets.ts apply-set selection and assembly
workload.ts Deployment, Service, ConfigMap
routing.ts Ingress, Gateway, HTTPRoute
database.ts CNPG Cluster, Database, DatabaseRole
compose.ts patches, extra manifests, ownership validation
cluster/ everything that talks to kubectl
kubectl.ts, preflight.ts, diff.ts, apply.ts, waves.ts, prune.ts,
inventory.ts, prerequisites.ts
utils/ generic helpers and frozen contracts
naming.ts formats.ts version.ts keys.ts release.ts
log.ts errors.ts objects.ts validation.ts memory.ts
bundle.ts the rendered artefact: write, read, verify
secrets.ts demo secret material for `tsg secrets` and `tsg keys`
k8s.ts resource identity, labels, ordering, serialisationmodel/ and render/ may not import anything under cluster/. An ESLint rule
enforces the boundary in CI.
Testing
pnpm testtest/examples.test.ts renders every file in examples/, so a patch target
that no longer matches a rendered name fails in CI rather than in a user's
terminal.
Provisioning on redeployment
Wallet issue configurations are generated from the authority credential type.
An issuer can share a wallet with member tenants. An explicit wallet
config.issueConfigurations array replaces the generated default.
SSO startup creates missing configured clients by client ID and users by username. Existing passwords, client secrets, keys, permissions and other manually edited settings remain unchanged. Configured redirect URIs are added to existing clients, including browser clients shared by several components. Removing a redirect from configuration does not revoke it; remove it through the SSO management API as well. Persisted client credentials are also used to restore Kubernetes Secrets. Initialization errors fail SSO startup, allowing a restart after the dependency or configuration has been repaired.
Configured wallet tenants are provisioned again at startup. Missing issue configurations and presentation scopes are added by ID and alias respectively; existing entries remain unchanged. Keys, DID documents and their service endpoints remain stored identity defaults. Changing a deployment's public address does not migrate an existing DID or replace its services; use the wallet management API for that deliberate change. A failed tenant initialization is logged and its database transaction rolls back; restart after repair to retry provisioning.
Holder credential claims run after the wallet starts listening. The current bounded retry waits total 25.5 seconds plus request time. Exhaustion logs the tenant failure and exits the wallet so Kubernetes can restart it. Issuer or ingress delays and successful recovery must be verified against the deployed candidate as part of release qualification.
