Integration as code
An integrator who defrosts one ERP writes its universe once: a directory of YAML manifests, one per connector, entity, view, app, process and workflow. That directory is the installation. It goes in git, it is reviewed in a pull request, and it is replayed against another tenant by a loop.
This page is the operating manual for that. Four habits of lm make the loop possible:
- a manifest arrives through
-f, and-f -reads it from stdin; - listings print
-o json|yaml; - notes and warnings go to stderr, so stdout stays parseable;
- the exit code carries the verdict.
What belongs in the repository
| In git | Never in git | Rebuilt, not stored |
|---|---|---|
kind: Connector, Entity, View, App, Process, Workflow, Export, SearchIndex manifests |
secret values — manifests carry the name (password_env, token_env, key_env), never the credential |
the dynamic tables under schema connector (ingestion writes them) |
Kotlin scripts (.kts) — the operator copies them into the hub's hooks directory; lm hook scripts lists them |
the OIDC token in ~/.lm/config.yaml |
the RAG corpus (re-indexed from what was ingested) |
the apply order (below) — a Makefile or a numbered prefix |
ingested rows, dead letters, run history | discovered schema cards (lm source rediscover) |
Credentials are referenced by name and resolved where the hub runs: in its environment or in its secret store, depending on the connector. Each connector page states which.
- A manifest is therefore safe to commit.
- The same manifest works against dev, staging and production: only the name travels.
Two fields break that, and a repository should ban them
Two manifest fields take a secret inline, both declared dev/test only:
| Field | Kind | What it does |
|---|---|---|
spec.connection.password |
jdbc-generic |
Wins over password_env when both are present — a manifest that looks safe because it names an env var is still overridden by the plain-text line above it. |
spec.auth.token_value |
rest-generic |
A literal bearer token. token_env is read first, so nothing is overridden — but the secret itself is in the file. |
lm validate and lm apply name both — WARN spec.connection.password,
WARN spec.auth.token_value — but a warning exits 0, so it gates nothing. Close them in CI,
before the validator runs:
! grep -rnE '^[[:space:]]*(password|token_value):' 10-connectors/
acme-si/
├── 10-connectors/ # applied first — an entity fuses sources that must exist
│ ├── ad-user.yaml
│ └── c-order.yaml
├── 20-entities/
│ └── customer.yaml
├── 30-apps/ # views, processes, workflows — they read entities
│ ├── clients-view.yaml
│ └── commande-workflow.yaml
└── secrets.list # the NAMES the manifests reference, so an operator can check them
Order matters in one direction only: a manifest that references something applies after it. Connectors, then entities, then the app ladder. Within a layer, order is free.
The three gestures
lm validate -f 10-connectors/ad-user.yaml # dry run, per-field errors, exit 1 on error
lm apply -f 10-connectors/ad-user.yaml # upsert by metadata.name
lm run list --connector ad_user -o json # verify: the ledger, as data
apply is an upsert: replaying the whole directory converges a tenant. There is no separate
"create" and "update":
$ lm apply -f docs/apps/demo/sources/10-erp.yaml
Applied connector "acme-desk-erp" — 1 endpoint
lm validate covers kind: Connector only
Any other kind fails on the envelope (kind must be "Connector"). The other kinds are
checked at apply, which refuses rather than half-writes. A CI gate routes by kind:
validate the connectors, apply the rest against a non-production tenant.
The pipe, both ways
lm reads manifests from stdin and writes results as data:
- In —
-f -reads the manifest from stdin, at every-fdoor (apply,validate,entity apply,view apply,app apply,process apply,workflow apply,export apply,search apply,connector apply). - Out —
-o json(oryaml) on every listing, plus the two discovery surfaces a generator reads:lm source schemaandlm source jdbc list-tables. Notes such as "result hit the limit" go to stderr. lm connector scope lshas no-o: it prints one scope per line, ready to pipe.
That pair turns two hundred tables into a loop: lm source schema hands back the tables and
columns as data, a generator turns each into a manifest, -f - applies it:
lm source schema --scope atelier -o json \
| jq -r '.tables[].name' \
| while read -r table; do
./generate-entity.sh "$table" | lm entity apply -f -
done
On the demo desk:
$ lm source schema --scope desk -o json | jq -c '.tables[] | {name, cols: [.columns[].name]}'
{"name":"connector.t_acme_desk_erp","cols":["code","name","city","segment","credit_limit"]}
{"name":"connector.t_acme_desk_crm","cols":["customer","company","town","email"]}
{"name":"connector.t_acme_desk_orders","cols":["order_no","code","status","amount","ordered_on"]}
{"name":"connector.v_acmecustomer","cols":["code","name","city","segment","credit_limit","email"]}
The generator is yours: it holds your knowledge of that ERP.
The two ends also meet directly. lm view get writes the saved view's manifest to stdout, so
promoting a view from one environment to another is one line with no file in between:
lm view get clients --context staging | lm view apply -f - --context prod
Do not do the same with lm connector get | lm connector apply: the flat fields carry the
connector's id, which belongs to the tenant it came from. Use the kind: Connector manifest in
your repository instead.
A manifest piped in has no file name: refusals name it stdin and point upstream.
$ cat bad-connector.yaml | lm validate -f -
✗ stdin — 2 error(s), 1 warning(s)
ERROR spec.transport
required field missing
ERROR spec.after_process
required field missing
→ archive
→ delete
→ move
→ none
WARN metadata.scopes
no scopes declared — a user bound to a scope will not see this connector, its runs or its dead letters
→ Add: scopes: [compta] (a user must hold the role scope:compta)
Fix the errors above in what produced this manifest.
Error: validation failed: 2 error(s)
$ echo $?
1
In CI
lm validate exits 1 when the manifest carries at least one error, 0 otherwise. It is
a gate with no wrapper.
A fresh runner has no lm context. Create one first: the URL and the token alone are not
enough.
# .github/workflows/manifests.yml
- name: Point lm at staging
run: |
lm config set-context ci --server "${{ secrets.LUMNIK_STAGING_URL }}" \
--token "${{ secrets.LUMNIK_STAGING_TOKEN }}"
lm config use-context ci
- name: Validate every connector manifest
run: |
for f in 10-connectors/*.yaml; do
lm validate -f "$f"
done
# on merge — converge the tenant, then wait for the ledger to settle
- run: |
for f in 10-connectors/*.yaml 20-entities/*.yaml 30-apps/*.yaml; do lm apply -f "$f"; done
lm connector run ad_user
for _ in $(seq 1 60); do
status=$(lm run list --connector ad_user -o json | jq -r '.[0].status')
case "$status" in
Completed|Partial) echo "run ended: $status"; break ;;
Failed|Cancelled) echo "run ended: $status"; exit 1 ;;
esac
sleep 5
done
The wait is not optional. lm connector run exits 0 as soon as the run is scheduled:
$ lm connector run acme-desk-erp
run scheduled: 2cff18e6-6774-44f5-bcad-e3640e776f51
Asserting on the ledger at once would read Queued or Running. Runs are listed newest first,
so .[0] is the one just scheduled.
Statuses are Queued, Running, Completed, Partial, Failed, Cancelled. Decide whether
Partial fails your pipeline. Nothing broke, but either:
- the run read less than its target, or
- it read everything and was not sure it read it right (a guessed encoding).
error: says which.
A pre-flight worth one line — the secrets the manifests reference must exist on the target,
and secret list never returns a value:
lm secret list -o json | jq -r '.[].name' | sort > present.txt
comm -23 <(sort secrets.list) present.txt # prints what is missing, empty means ready
Validation is server-side — CI needs a reachable hub
lm validate posts the manifest to the hub; there is no offline validator. A PR gate needs a
URL and a token for a non-production tenant. Air-gapped CI can only lint the YAML.
Exit codes
| Command | 0 | 1 |
|---|---|---|
lm validate |
no error (warnings still exit 0) | at least one error |
lm apply and every … apply |
the manifest was applied | rejected, unreachable, unauthorised |
lm connector run |
the run was scheduled (run scheduled: <id>, one line per endpoint) |
it could not be scheduled |
| any command | — | any error, printed as Error: … on stderr |
lm connector run returning 0 means the run was accepted, not that it started. The verdict is
in the ledger: wait on lm run list -o json, as above.
What a green pipeline does not prove
- Secrets are not in git, so a fresh tenant has none until an operator sets them.
- Data is not in git. Applying the universe creates the pipes; the rows arrive when the connectors run.
- Scope roles live in the identity provider (
scope:<tag>realm roles), not in a manifest — see Tenants, users & scopes. - The ERP is untouched by lumnik either way. Ingestion is read-only: nothing in this repository makes lumnik write to the source system.
- A replay switches the outputs back on. A
kind: Processis enabled when applied and fires at once: webhooks and mail leave the platform. Apply the app layer to a fresh tenant with that in mind, or runlm process disable NAMEright after the apply.
Reversing all of it is its own page: Reversibility & exit.