limitsdb 0.5.0__tar.gz → 0.6.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. {limitsdb-0.5.0 → limitsdb-0.6.0}/PKG-INFO +146 -11
  2. {limitsdb-0.5.0 → limitsdb-0.6.0}/README.md +145 -10
  3. {limitsdb-0.5.0 → limitsdb-0.6.0}/limitsdb/cli/ldb_impl.py +90 -36
  4. limitsdb-0.6.0/limitsdb/core/ldb_ddl.py +132 -0
  5. {limitsdb-0.5.0 → limitsdb-0.6.0}/limitsdb/core/ldb_logger.py +5 -5
  6. {limitsdb-0.5.0 → limitsdb-0.6.0}/limitsdb/core/ldb_params_config.py +23 -1
  7. {limitsdb-0.5.0 → limitsdb-0.6.0}/limitsdb/core/ldb_runner.py +223 -11
  8. {limitsdb-0.5.0 → limitsdb-0.6.0}/limitsdb/db/ldb_engines.py +53 -0
  9. {limitsdb-0.5.0 → limitsdb-0.6.0}/limitsdb/db/oracle/ldb_engine_impl.py +402 -44
  10. {limitsdb-0.5.0 → limitsdb-0.6.0}/pyproject.toml +1 -1
  11. {limitsdb-0.5.0 → limitsdb-0.6.0}/LICENSE +0 -0
  12. {limitsdb-0.5.0 → limitsdb-0.6.0}/LICENSE-DUAL.md +0 -0
  13. {limitsdb-0.5.0 → limitsdb-0.6.0}/NOTICE +0 -0
  14. {limitsdb-0.5.0 → limitsdb-0.6.0}/limitsdb/__init__.py +0 -0
  15. {limitsdb-0.5.0 → limitsdb-0.6.0}/limitsdb/cli/__init__.py +0 -0
  16. {limitsdb-0.5.0 → limitsdb-0.6.0}/limitsdb/cli/ldb_crypt.py +0 -0
  17. {limitsdb-0.5.0 → limitsdb-0.6.0}/limitsdb/cli/ldb_init.py +0 -0
  18. {limitsdb-0.5.0 → limitsdb-0.6.0}/limitsdb/cli/ldb_run.py +0 -0
  19. {limitsdb-0.5.0 → limitsdb-0.6.0}/limitsdb/core/__init__.py +0 -0
  20. {limitsdb-0.5.0 → limitsdb-0.6.0}/limitsdb/core/ldb_config_loader.py +0 -0
  21. {limitsdb-0.5.0 → limitsdb-0.6.0}/limitsdb/core/ldb_crypto.py +0 -0
  22. {limitsdb-0.5.0 → limitsdb-0.6.0}/limitsdb/core/ldb_errors.py +0 -0
  23. {limitsdb-0.5.0 → limitsdb-0.6.0}/limitsdb/core/ldb_ilm_config.py +0 -0
  24. {limitsdb-0.5.0 → limitsdb-0.6.0}/limitsdb/core/ldb_meta.py +0 -0
  25. {limitsdb-0.5.0 → limitsdb-0.6.0}/limitsdb/core/ldb_status.py +0 -0
  26. {limitsdb-0.5.0 → limitsdb-0.6.0}/limitsdb/core/ldb_utils.py +0 -0
  27. {limitsdb-0.5.0 → limitsdb-0.6.0}/limitsdb/db/__init__.py +0 -0
  28. {limitsdb-0.5.0 → limitsdb-0.6.0}/limitsdb/db/ldb_engine_loader.py +0 -0
  29. {limitsdb-0.5.0 → limitsdb-0.6.0}/limitsdb/db/oracle/__init__.py +0 -0
  30. {limitsdb-0.5.0 → limitsdb-0.6.0}/limitsdb/db/oracle/ldb_conf.sql +0 -0
  31. {limitsdb-0.5.0 → limitsdb-0.6.0}/limitsdb/resources/__init__.py +0 -0
  32. {limitsdb-0.5.0 → limitsdb-0.6.0}/limitsdb/resources/ilm.example.yml +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: limitsdb
3
- Version: 0.5.0
3
+ Version: 0.6.0
4
4
  Summary: Configuration-driven ILM (Information Lifecycle Management) for production and history databases
5
5
  License: Apache License
6
6
  Version 2.0, January 2004
@@ -207,6 +207,52 @@ source .venv/bin/activate
207
207
  pip install limitsdb
208
208
  ```
209
209
 
210
+ ### Air-gapped or internet-restricted servers
211
+
212
+ For production database servers without internet access (common in enterprise Oracle
213
+ environments on Linux, AIX, or Solaris), download the packages on a machine that
214
+ does have access and transfer them to the target server.
215
+
216
+ **Step 1 — Download on a connected machine**
217
+
218
+ If the target server is Linux x86_64:
219
+
220
+ ```bash
221
+ pip download limitsdb \
222
+ --dest ./limitsdb-bundle \
223
+ --platform linux_x86_64 \
224
+ --python-version 312 \
225
+ --only-binary=:all:
226
+ ```
227
+
228
+ For the same platform as the current machine (Windows or macOS build host
229
+ copying to an identical Linux target, omit `--platform` and `--python-version`):
230
+
231
+ ```bash
232
+ pip download limitsdb --dest ./limitsdb-bundle
233
+ ```
234
+
235
+ **Step 2 — Transfer to the target server**
236
+
237
+ ```bash
238
+ scp -r ./limitsdb-bundle user@server:/opt/limitsdb-bundle
239
+ ```
240
+
241
+ **Step 3 — Install on the target server (no internet required)**
242
+
243
+ ```bash
244
+ python3 -m venv /opt/limitsdb-venv
245
+ source /opt/limitsdb-venv/bin/activate
246
+ pip install --no-index --find-links /opt/limitsdb-bundle limitsdb
247
+ ldb-run --help
248
+ ```
249
+
250
+ > **Note for AIX and Solaris**: `oracledb` may not have a prebuilt wheel for your
251
+ > platform. If `pip download --only-binary=:all:` finds nothing, the package must
252
+ > be compiled from source on the target, which requires Python development headers
253
+ > and a C compiler (`gcc` or `xlc`). Contact your system administrator before
254
+ > proceeding.
255
+
210
256
  For development setup (cloning the repo, running tests, CI) see [CONTRIBUTING.md](CONTRIBUTING.md).
211
257
 
212
258
  ## Quickstart
@@ -227,7 +273,7 @@ Spin up a minimal end-to-end run with the built-in scaffolding tools:
227
273
  ldb-crypt --schema billing --profile dev
228
274
  ```
229
275
 
230
- 4. **Dry-run the ILM plan** (no data changes):
276
+ 4. **Preview the ILM run** (applies the required DDL and grants, moves no data):
231
277
 
232
278
  ```bash
233
279
  ldb-run --schema billing --profile dev --action SOURCE_ILM --mode PREVIEW
@@ -356,9 +402,32 @@ What it creates in both source and history environments:
356
402
  - **Database link** — bidirectional links between source and history so the ILM
357
403
  engine can reach both environments from either side.
358
404
 
359
- `ldb-impl` requires all four credential pairs to be present and encrypted in
360
- `secrets[.<PROFILE>].json`: `source_password`, `history_password`,
361
- `admin_source_password`, and `admin_history_password`. Run `ldb-crypt` first.
405
+ `ldb-impl` needs the runtime credentials (`source_password`, `history_password`)
406
+ encrypted in `secrets[.<PROFILE>].json`. Run `ldb-crypt` first.
407
+
408
+ ### Without admin credentials
409
+
410
+ Admin credentials (`admin_source_*`, `admin_history_*`) are optional per
411
+ database. When they are absent for a database, `ldb-impl` creates nothing in it
412
+ and writes a SQL*Plus script for a DBA instead, one file per database:
413
+ `bootstrap_<schema>_<source|history>_<YYYYMMDD_HHMMSS>.sql` under
414
+ `ddl_pending_dir` (the same parameter as `ldb-run`: `--ddl-pending-dir`,
415
+ `LDB_DDL_PENDING_DIR`, default `ldb_ddl_pending`). A database whose admin credentials are present is still
416
+ provisioned directly. Generating scripts is not an error (exit code 0, with a
417
+ warning naming each file); if a script cannot be written, `ldb-impl` exits 1.
418
+
419
+ The script:
420
+
421
+ - stops at the first error (`whenever sqlerror exit failure`) and ends with a
422
+ `LimitsDb: OK` banner on success;
423
+ - contains no passwords: it prompts for them (hidden input);
424
+ - can be rerun: each object is created in a PL/SQL block that ignores only its
425
+ own "already exists" error, and an existing database link is skipped;
426
+ - creates the database link after reconnecting as the runtime user, who owns it
427
+ (it asks for that user's password).
428
+
429
+ Run it in SQL*Plus connected as a user with administrative privileges:
430
+ `sqlplus admin@<dsn> @bootstrap_<schema>_<env>_<timestamp>.sql`.
362
431
 
363
432
  ## Run ILM
364
433
 
@@ -372,9 +441,11 @@ ldb-run --schema <SCHEMA> [--profile <PROFILE>] --action SOURCE_ILM|HISTORY_ILM
372
441
  Common overrides:
373
442
 
374
443
  ```
375
- --mode VALIDATE|PLAN|PREVIEW|SCRIPT|EXECUTE # VALIDATE setup, PLAN DAG, PREVIEW simulate, SCRIPT emit SQL, EXECUTE run
444
+ --mode VALIDATE|PLAN|PREVIEW|SCRIPT|EXECUTE # VALIDATE setup, PLAN DAG, PREVIEW run without moving data, SCRIPT emit SQL, EXECUTE run
376
445
  --chunk-size <int> # Rows per chunk when processing large tables
377
446
  --parallel-max <int> # Maximum number of tables processed in parallel
447
+ --ddl-pending-dir <path> # Where failed history DDL is written for a DBA (default: ldb_ddl_pending)
448
+ --history-baseline EMPTY|SOURCE # SCRIPT without a readable history: assume no history objects (default) or the same objects as source
378
449
  --log-level DEBUG|INFO|WARNING|ERROR|CRITICAL # Adjust logger verbosity
379
450
  --ilm-config-file <path> # Point to a specific ILM YAML file
380
451
  --config-dir <root> # Override config root discovery
@@ -396,24 +467,84 @@ ldb-run --schema billing --profile prod --action SOURCE_ILM --mode EXECUTE --set
396
467
 
397
468
  `ldb-run` uses admin credentials (`admin_source_password`,
398
469
  `admin_history_password`) for two operations: granting table privileges to the
399
- application role, and creating or altering history tables before an `EXECUTE`
400
- run. Both operations are idempotent.
470
+ application role, and creating or altering history tables before a `PREVIEW` or
471
+ `EXECUTE` run (`PREVIEW` needs the objects in place to validate and time the
472
+ queries; it skips only the data movement). Both operations are idempotent.
401
473
 
402
474
  If admin credentials are absent from `secrets[.<PROFILE>].json`, the privilege
403
475
  grants are skipped silently — the assumption is that grants are already in
404
- place. History table management in `EXECUTE` mode still requires admin
405
- credentials in the current version.
476
+ place.
477
+
478
+ History table management follows the same principle:
479
+
480
+ - **Admin credentials present, tables already up to date**: the admin connection
481
+ is not opened. No DDL is executed.
482
+ - **Admin credentials present, DDL changes are required**: the admin connection
483
+ is opened and the changes are applied as before.
484
+ - **Admin credentials absent, tables already up to date**: `ldb-run` proceeds
485
+ without an admin connection. This is the normal case for environments where a
486
+ DBA pre-creates all history objects and admin credentials are not stored.
487
+ - **Admin credentials absent, history schema cannot be inspected** (for example
488
+ the runtime connection or dictionary query fails): `ldb-run` exits with code 1
489
+ and logs the cause. It does not run ILM against a history schema whose state is
490
+ unknown.
491
+ - **Admin credentials absent, DDL changes are required**: `ldb-run` applies the
492
+ DDL directly over the runtime history connection, with no prior privilege
493
+ check. It succeeds when the runtime user owns the objects or holds the needed
494
+ privileges (for example `ALTER`). Any DDL statement that fails, for whatever
495
+ reason, is recorded and the remaining statements are still attempted. If at least one
496
+ statement failed, `ldb-run` writes the failed statements, each preceded by its
497
+ error, to `history_ddl_<schema>_<YYYYMMDD_HHMMSS>.sql` under `ddl_pending_dir`
498
+ (default `ldb_ddl_pending`), logs an error naming the file, and exits with
499
+ code 1. The file is plain SQL for SQL*Plus (no PL/SQL): it stops at the first
500
+ failing statement with a non-zero exit code, and only a run that reaches the
501
+ final `LimitsDb: OK` banner applied everything. The recorded errors are
502
+ comments. A DBA reviews and runs the file, then `ldb-run` is rerun. Nothing is
503
+ written to stdout, and the directory is created only when a file is written.
504
+ If the file cannot be written (for example the directory is not writable),
505
+ `ldb-run` logs the error with the failed statements and exits with code 1.
506
+
507
+ ### History DDL in `--mode SCRIPT`
508
+
509
+ `--mode SCRIPT` applies nothing to the databases. The ILM SQL blocks, which
510
+ run many times, go to stdout. The one-time statements go to files under
511
+ `ddl_pending_dir`, so they stay out of the script that is rerun:
512
+
513
+ - `history_ddl_<schema>_<YYYYMMDD_HHMMSS>.sql`: the history table DDL followed by
514
+ the grants on the history tables for the history role. Run it against the
515
+ history database.
516
+ - `source_grants_<schema>_<YYYYMMDD_HHMMSS>.sql` (`SOURCE_ILM` only): the grants
517
+ on the source tables for the source role. Run it against the source database.
518
+
519
+ Both are plain SQL for SQL*Plus that stop at the first failing statement; the
520
+ grants are unconditional and safe to run again. The history DDL is always
521
+ emitted, computed like this:
522
+
523
+ - **A history connection works**: the admin connection is used when admin
524
+ credentials exist, the runtime one otherwise. The current history structure is
525
+ read, and the file holds only the missing changes.
526
+ - **Neither works or there are no credentials**: `history_baseline` states what to
527
+ assume. `EMPTY` (default) assumes history has no objects, so every table, key
528
+ and index is created. `SOURCE` assumes history holds the same objects as
529
+ source, so only the history-specific differences are emitted (for example the
530
+ `LDB_PROCESS_DATE` column and the matching primary key).
531
+
532
+ Without admin credentials, `ldb-run` applies the history grants right after the
533
+ DDL over the runtime connection. Any grant the runtime user cannot apply goes to
534
+ the same pending file as the failed DDL, so a DBA can run it.
406
535
 
407
536
  ### Configuration parameters reference
408
537
 
409
538
  | Parameter | Purpose | Default / values |
410
539
  | ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
411
540
  | `action` | ILM target (`SOURCE_ILM` moves/purges source, `HISTORY_ILM` cleans downstream history). | Default `SOURCE_ILM`; choices `SOURCE_ILM`, `HISTORY_ILM`. |
412
- | `mode` | Execution mode: `VALIDATE` config/credentials, `PLAN` dependency order, `PREVIEW` simulate (a.k.a. legacy `DRY_RUN`), `SCRIPT` emit SQL, `EXECUTE` run changes. | Default `PREVIEW`; choices `VALIDATE`, `PLAN`, `PREVIEW`, `SCRIPT`, `EXECUTE`. |
541
+ | `mode` | Execution mode: `VALIDATE` config/credentials, `PLAN` dependency order, `PREVIEW` run the real queries and apply DDL and grants but do not insert or delete data (a.k.a. legacy `DRY_RUN`), `SCRIPT` emit the history DDL and the ILM SQL without applying anything, `EXECUTE` run changes. | Default `PREVIEW`; choices `VALIDATE`, `PLAN`, `PREVIEW`, `SCRIPT`, `EXECUTE`. |
413
542
  | `chunk_size` | Rows per chunk when processing large tables. | Default `100000`. |
414
543
  | `use_added_columns` | Populate derived columns in history tables. | Default `True` (boolean toggle). |
415
544
  | `add_ldb_columns` | Add LimitsDb execution-date columns in history tables. | Default `True` (boolean toggle). |
416
545
  | `parallel_max` | Maximum number of parallel processes. | Default `10`. |
546
+ | `ddl_pending_dir` | Directory where `ldb-run` writes history DDL it could not apply without admin credentials, and the DDL and grants files of `--mode SCRIPT`. | Default `ldb_ddl_pending`. |
547
+ | `history_baseline` | State assumed for the history tables by `--mode SCRIPT` when it cannot read them over the runtime history connection: `EMPTY` (no objects) or `SOURCE` (same objects as source). | Default `EMPTY`; choices `EMPTY`, `SOURCE`. |
417
548
  | `db_engine` | Database engine. | Default and only current choice: `"oracle"`. |
418
549
  | `log_level` | Logging level. | Default `"INFO"`; choices `"DEBUG"`, `"INFO"`, `"WARNING"`, `"ERROR"`, `"CRITICAL"`. |
419
550
  | `schema` | Configuration set name: identifies a system or application (e.g., `billing`). Not a database schema — it is the folder that groups all configuration files for one target. | Required on CLI; no persisted default. |
@@ -440,6 +571,8 @@ credentials in the current version.
440
571
  | `use_added_columns` | — | — | `config[.<PROFILE>].yml: use_added_columns` |
441
572
  | `add_ldb_columns` | — | — | `config[.<PROFILE>].yml: add_ldb_columns` |
442
573
  | `parallel_max` | `--parallel-max` | `LDB_PARALLEL_MAX` | `config[.<PROFILE>].yml: parallel_max` |
574
+ | `ddl_pending_dir` | `--ddl-pending-dir` | `LDB_DDL_PENDING_DIR` | `config[.<PROFILE>].yml: ddl_pending_dir` |
575
+ | `history_baseline` | `--history-baseline` | `LDB_HISTORY_BASELINE` | `config[.<PROFILE>].yml: history_baseline` |
443
576
  | `db_engine` | `--db-engine` | `LDB_DB_ENGINE` | `config[.<PROFILE>].yml: db_engine` |
444
577
  | `log_level` | `--log-level` | `LDB_LOG_LEVEL` | `config[.<PROFILE>].yml: log_level` |
445
578
  | `schema` | `--schema` | `LDB_SCHEMA` | CLI only (not stored). |
@@ -497,6 +630,8 @@ Every key is commented; the default value and short help appear inline. Example:
497
630
  # use_added_columns: True # Populate derived columns in history tables
498
631
  # add_ldb_columns: True # Add LimitsDb execution-date columns in history tables
499
632
  # parallel_max: 10 # Maximum number of parallel processes
633
+ # ddl_pending_dir: "ldb_ddl_pending" # Where failed history DDL is written for a DBA
634
+ # history_baseline: "EMPTY" # SCRIPT without readable history: EMPTY | SOURCE
500
635
  # db_engine: "oracle" # Database engine
501
636
  # log_level: "INFO" # Logging level
502
637
  ```
@@ -61,6 +61,52 @@ source .venv/bin/activate
61
61
  pip install limitsdb
62
62
  ```
63
63
 
64
+ ### Air-gapped or internet-restricted servers
65
+
66
+ For production database servers without internet access (common in enterprise Oracle
67
+ environments on Linux, AIX, or Solaris), download the packages on a machine that
68
+ does have access and transfer them to the target server.
69
+
70
+ **Step 1 — Download on a connected machine**
71
+
72
+ If the target server is Linux x86_64:
73
+
74
+ ```bash
75
+ pip download limitsdb \
76
+ --dest ./limitsdb-bundle \
77
+ --platform linux_x86_64 \
78
+ --python-version 312 \
79
+ --only-binary=:all:
80
+ ```
81
+
82
+ For the same platform as the current machine (Windows or macOS build host
83
+ copying to an identical Linux target, omit `--platform` and `--python-version`):
84
+
85
+ ```bash
86
+ pip download limitsdb --dest ./limitsdb-bundle
87
+ ```
88
+
89
+ **Step 2 — Transfer to the target server**
90
+
91
+ ```bash
92
+ scp -r ./limitsdb-bundle user@server:/opt/limitsdb-bundle
93
+ ```
94
+
95
+ **Step 3 — Install on the target server (no internet required)**
96
+
97
+ ```bash
98
+ python3 -m venv /opt/limitsdb-venv
99
+ source /opt/limitsdb-venv/bin/activate
100
+ pip install --no-index --find-links /opt/limitsdb-bundle limitsdb
101
+ ldb-run --help
102
+ ```
103
+
104
+ > **Note for AIX and Solaris**: `oracledb` may not have a prebuilt wheel for your
105
+ > platform. If `pip download --only-binary=:all:` finds nothing, the package must
106
+ > be compiled from source on the target, which requires Python development headers
107
+ > and a C compiler (`gcc` or `xlc`). Contact your system administrator before
108
+ > proceeding.
109
+
64
110
  For development setup (cloning the repo, running tests, CI) see [CONTRIBUTING.md](CONTRIBUTING.md).
65
111
 
66
112
  ## Quickstart
@@ -81,7 +127,7 @@ Spin up a minimal end-to-end run with the built-in scaffolding tools:
81
127
  ldb-crypt --schema billing --profile dev
82
128
  ```
83
129
 
84
- 4. **Dry-run the ILM plan** (no data changes):
130
+ 4. **Preview the ILM run** (applies the required DDL and grants, moves no data):
85
131
 
86
132
  ```bash
87
133
  ldb-run --schema billing --profile dev --action SOURCE_ILM --mode PREVIEW
@@ -210,9 +256,32 @@ What it creates in both source and history environments:
210
256
  - **Database link** — bidirectional links between source and history so the ILM
211
257
  engine can reach both environments from either side.
212
258
 
213
- `ldb-impl` requires all four credential pairs to be present and encrypted in
214
- `secrets[.<PROFILE>].json`: `source_password`, `history_password`,
215
- `admin_source_password`, and `admin_history_password`. Run `ldb-crypt` first.
259
+ `ldb-impl` needs the runtime credentials (`source_password`, `history_password`)
260
+ encrypted in `secrets[.<PROFILE>].json`. Run `ldb-crypt` first.
261
+
262
+ ### Without admin credentials
263
+
264
+ Admin credentials (`admin_source_*`, `admin_history_*`) are optional per
265
+ database. When they are absent for a database, `ldb-impl` creates nothing in it
266
+ and writes a SQL*Plus script for a DBA instead, one file per database:
267
+ `bootstrap_<schema>_<source|history>_<YYYYMMDD_HHMMSS>.sql` under
268
+ `ddl_pending_dir` (the same parameter as `ldb-run`: `--ddl-pending-dir`,
269
+ `LDB_DDL_PENDING_DIR`, default `ldb_ddl_pending`). A database whose admin credentials are present is still
270
+ provisioned directly. Generating scripts is not an error (exit code 0, with a
271
+ warning naming each file); if a script cannot be written, `ldb-impl` exits 1.
272
+
273
+ The script:
274
+
275
+ - stops at the first error (`whenever sqlerror exit failure`) and ends with a
276
+ `LimitsDb: OK` banner on success;
277
+ - contains no passwords: it prompts for them (hidden input);
278
+ - can be rerun: each object is created in a PL/SQL block that ignores only its
279
+ own "already exists" error, and an existing database link is skipped;
280
+ - creates the database link after reconnecting as the runtime user, who owns it
281
+ (it asks for that user's password).
282
+
283
+ Run it in SQL*Plus connected as a user with administrative privileges:
284
+ `sqlplus admin@<dsn> @bootstrap_<schema>_<env>_<timestamp>.sql`.
216
285
 
217
286
  ## Run ILM
218
287
 
@@ -226,9 +295,11 @@ ldb-run --schema <SCHEMA> [--profile <PROFILE>] --action SOURCE_ILM|HISTORY_ILM
226
295
  Common overrides:
227
296
 
228
297
  ```
229
- --mode VALIDATE|PLAN|PREVIEW|SCRIPT|EXECUTE # VALIDATE setup, PLAN DAG, PREVIEW simulate, SCRIPT emit SQL, EXECUTE run
298
+ --mode VALIDATE|PLAN|PREVIEW|SCRIPT|EXECUTE # VALIDATE setup, PLAN DAG, PREVIEW run without moving data, SCRIPT emit SQL, EXECUTE run
230
299
  --chunk-size <int> # Rows per chunk when processing large tables
231
300
  --parallel-max <int> # Maximum number of tables processed in parallel
301
+ --ddl-pending-dir <path> # Where failed history DDL is written for a DBA (default: ldb_ddl_pending)
302
+ --history-baseline EMPTY|SOURCE # SCRIPT without a readable history: assume no history objects (default) or the same objects as source
232
303
  --log-level DEBUG|INFO|WARNING|ERROR|CRITICAL # Adjust logger verbosity
233
304
  --ilm-config-file <path> # Point to a specific ILM YAML file
234
305
  --config-dir <root> # Override config root discovery
@@ -250,24 +321,84 @@ ldb-run --schema billing --profile prod --action SOURCE_ILM --mode EXECUTE --set
250
321
 
251
322
  `ldb-run` uses admin credentials (`admin_source_password`,
252
323
  `admin_history_password`) for two operations: granting table privileges to the
253
- application role, and creating or altering history tables before an `EXECUTE`
254
- run. Both operations are idempotent.
324
+ application role, and creating or altering history tables before a `PREVIEW` or
325
+ `EXECUTE` run (`PREVIEW` needs the objects in place to validate and time the
326
+ queries; it skips only the data movement). Both operations are idempotent.
255
327
 
256
328
  If admin credentials are absent from `secrets[.<PROFILE>].json`, the privilege
257
329
  grants are skipped silently — the assumption is that grants are already in
258
- place. History table management in `EXECUTE` mode still requires admin
259
- credentials in the current version.
330
+ place.
331
+
332
+ History table management follows the same principle:
333
+
334
+ - **Admin credentials present, tables already up to date**: the admin connection
335
+ is not opened. No DDL is executed.
336
+ - **Admin credentials present, DDL changes are required**: the admin connection
337
+ is opened and the changes are applied as before.
338
+ - **Admin credentials absent, tables already up to date**: `ldb-run` proceeds
339
+ without an admin connection. This is the normal case for environments where a
340
+ DBA pre-creates all history objects and admin credentials are not stored.
341
+ - **Admin credentials absent, history schema cannot be inspected** (for example
342
+ the runtime connection or dictionary query fails): `ldb-run` exits with code 1
343
+ and logs the cause. It does not run ILM against a history schema whose state is
344
+ unknown.
345
+ - **Admin credentials absent, DDL changes are required**: `ldb-run` applies the
346
+ DDL directly over the runtime history connection, with no prior privilege
347
+ check. It succeeds when the runtime user owns the objects or holds the needed
348
+ privileges (for example `ALTER`). Any DDL statement that fails, for whatever
349
+ reason, is recorded and the remaining statements are still attempted. If at least one
350
+ statement failed, `ldb-run` writes the failed statements, each preceded by its
351
+ error, to `history_ddl_<schema>_<YYYYMMDD_HHMMSS>.sql` under `ddl_pending_dir`
352
+ (default `ldb_ddl_pending`), logs an error naming the file, and exits with
353
+ code 1. The file is plain SQL for SQL*Plus (no PL/SQL): it stops at the first
354
+ failing statement with a non-zero exit code, and only a run that reaches the
355
+ final `LimitsDb: OK` banner applied everything. The recorded errors are
356
+ comments. A DBA reviews and runs the file, then `ldb-run` is rerun. Nothing is
357
+ written to stdout, and the directory is created only when a file is written.
358
+ If the file cannot be written (for example the directory is not writable),
359
+ `ldb-run` logs the error with the failed statements and exits with code 1.
360
+
361
+ ### History DDL in `--mode SCRIPT`
362
+
363
+ `--mode SCRIPT` applies nothing to the databases. The ILM SQL blocks, which
364
+ run many times, go to stdout. The one-time statements go to files under
365
+ `ddl_pending_dir`, so they stay out of the script that is rerun:
366
+
367
+ - `history_ddl_<schema>_<YYYYMMDD_HHMMSS>.sql`: the history table DDL followed by
368
+ the grants on the history tables for the history role. Run it against the
369
+ history database.
370
+ - `source_grants_<schema>_<YYYYMMDD_HHMMSS>.sql` (`SOURCE_ILM` only): the grants
371
+ on the source tables for the source role. Run it against the source database.
372
+
373
+ Both are plain SQL for SQL*Plus that stop at the first failing statement; the
374
+ grants are unconditional and safe to run again. The history DDL is always
375
+ emitted, computed like this:
376
+
377
+ - **A history connection works**: the admin connection is used when admin
378
+ credentials exist, the runtime one otherwise. The current history structure is
379
+ read, and the file holds only the missing changes.
380
+ - **Neither works or there are no credentials**: `history_baseline` states what to
381
+ assume. `EMPTY` (default) assumes history has no objects, so every table, key
382
+ and index is created. `SOURCE` assumes history holds the same objects as
383
+ source, so only the history-specific differences are emitted (for example the
384
+ `LDB_PROCESS_DATE` column and the matching primary key).
385
+
386
+ Without admin credentials, `ldb-run` applies the history grants right after the
387
+ DDL over the runtime connection. Any grant the runtime user cannot apply goes to
388
+ the same pending file as the failed DDL, so a DBA can run it.
260
389
 
261
390
  ### Configuration parameters reference
262
391
 
263
392
  | Parameter | Purpose | Default / values |
264
393
  | ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
265
394
  | `action` | ILM target (`SOURCE_ILM` moves/purges source, `HISTORY_ILM` cleans downstream history). | Default `SOURCE_ILM`; choices `SOURCE_ILM`, `HISTORY_ILM`. |
266
- | `mode` | Execution mode: `VALIDATE` config/credentials, `PLAN` dependency order, `PREVIEW` simulate (a.k.a. legacy `DRY_RUN`), `SCRIPT` emit SQL, `EXECUTE` run changes. | Default `PREVIEW`; choices `VALIDATE`, `PLAN`, `PREVIEW`, `SCRIPT`, `EXECUTE`. |
395
+ | `mode` | Execution mode: `VALIDATE` config/credentials, `PLAN` dependency order, `PREVIEW` run the real queries and apply DDL and grants but do not insert or delete data (a.k.a. legacy `DRY_RUN`), `SCRIPT` emit the history DDL and the ILM SQL without applying anything, `EXECUTE` run changes. | Default `PREVIEW`; choices `VALIDATE`, `PLAN`, `PREVIEW`, `SCRIPT`, `EXECUTE`. |
267
396
  | `chunk_size` | Rows per chunk when processing large tables. | Default `100000`. |
268
397
  | `use_added_columns` | Populate derived columns in history tables. | Default `True` (boolean toggle). |
269
398
  | `add_ldb_columns` | Add LimitsDb execution-date columns in history tables. | Default `True` (boolean toggle). |
270
399
  | `parallel_max` | Maximum number of parallel processes. | Default `10`. |
400
+ | `ddl_pending_dir` | Directory where `ldb-run` writes history DDL it could not apply without admin credentials, and the DDL and grants files of `--mode SCRIPT`. | Default `ldb_ddl_pending`. |
401
+ | `history_baseline` | State assumed for the history tables by `--mode SCRIPT` when it cannot read them over the runtime history connection: `EMPTY` (no objects) or `SOURCE` (same objects as source). | Default `EMPTY`; choices `EMPTY`, `SOURCE`. |
271
402
  | `db_engine` | Database engine. | Default and only current choice: `"oracle"`. |
272
403
  | `log_level` | Logging level. | Default `"INFO"`; choices `"DEBUG"`, `"INFO"`, `"WARNING"`, `"ERROR"`, `"CRITICAL"`. |
273
404
  | `schema` | Configuration set name: identifies a system or application (e.g., `billing`). Not a database schema — it is the folder that groups all configuration files for one target. | Required on CLI; no persisted default. |
@@ -294,6 +425,8 @@ credentials in the current version.
294
425
  | `use_added_columns` | — | — | `config[.<PROFILE>].yml: use_added_columns` |
295
426
  | `add_ldb_columns` | — | — | `config[.<PROFILE>].yml: add_ldb_columns` |
296
427
  | `parallel_max` | `--parallel-max` | `LDB_PARALLEL_MAX` | `config[.<PROFILE>].yml: parallel_max` |
428
+ | `ddl_pending_dir` | `--ddl-pending-dir` | `LDB_DDL_PENDING_DIR` | `config[.<PROFILE>].yml: ddl_pending_dir` |
429
+ | `history_baseline` | `--history-baseline` | `LDB_HISTORY_BASELINE` | `config[.<PROFILE>].yml: history_baseline` |
297
430
  | `db_engine` | `--db-engine` | `LDB_DB_ENGINE` | `config[.<PROFILE>].yml: db_engine` |
298
431
  | `log_level` | `--log-level` | `LDB_LOG_LEVEL` | `config[.<PROFILE>].yml: log_level` |
299
432
  | `schema` | `--schema` | `LDB_SCHEMA` | CLI only (not stored). |
@@ -351,6 +484,8 @@ Every key is commented; the default value and short help appear inline. Example:
351
484
  # use_added_columns: True # Populate derived columns in history tables
352
485
  # add_ldb_columns: True # Add LimitsDb execution-date columns in history tables
353
486
  # parallel_max: 10 # Maximum number of parallel processes
487
+ # ddl_pending_dir: "ldb_ddl_pending" # Where failed history DDL is written for a DBA
488
+ # history_baseline: "EMPTY" # SCRIPT without readable history: EMPTY | SOURCE
354
489
  # db_engine: "oracle" # Database engine
355
490
  # log_level: "INFO" # Logging level
356
491
  ```
@@ -3,6 +3,9 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import argparse
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+ from typing import Any
6
9
 
7
10
  from limitsdb.core.ldb_crypto import load_or_create_key
8
11
  from limitsdb.core.ldb_errors import LimitsDbError
@@ -33,6 +36,9 @@ def _parse_args() -> argparse.Namespace:
33
36
  parser.add_argument(
34
37
  "--set", action="append", default=[], help="Overrides like key=value; supports dotted keys for nesting"
35
38
  )
39
+ parser.add_argument(
40
+ "--ddl-pending-dir", help="Directory for the bootstrap scripts written when admin credentials are absent"
41
+ )
36
42
  parser.add_argument(
37
43
  "--log-level", default="INFO", choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], help="Logging level"
38
44
  )
@@ -101,10 +107,6 @@ def run_cli() -> None:
101
107
  connections = config.connections
102
108
  administration = config.administration
103
109
 
104
- if not administration.source.username or not administration.source.password:
105
- raise ValueError("admin_source_username and admin_source_password are required")
106
- if not administration.history.username or not administration.history.password:
107
- raise ValueError("admin_history_username and admin_history_password are required")
108
110
  if not connections.source.username or not connections.source.password:
109
111
  raise ValueError("source_username and source_password are required")
110
112
  if not connections.history.username or not connections.history.password:
@@ -158,48 +160,100 @@ def run_cli() -> None:
158
160
  )
159
161
 
160
162
  summary: dict[str, list[str]] = {}
163
+ script_files: list[Path] = []
164
+ source_has_admin = bool(administration.source.username and administration.source.password)
165
+ history_has_admin = bool(administration.history.username and administration.history.password)
161
166
 
162
- source_admin_conn = engine.get_connection(config, admin=True, env="SOURCE")
163
- try:
164
- summary["source_roles"] = engine.ensure_roles(source_admin_conn, source_roles)
165
- summary["source_users"] = engine.ensure_users(source_admin_conn, [source_user])
166
- summary["source_tables"] = engine.ensure_tables(source_admin_conn, source_tables)
167
- summary["source_sequences"] = engine.ensure_sequences(source_admin_conn, source_sequences)
168
- summary["source_supporting_objects"] = engine.ensure_supporting_objects(
169
- source_admin_conn, connections.source.username
170
- )
171
- finally:
172
- engine.close_connection(source_admin_conn)
167
+ def write_script(env: str, **definitions: Any) -> None:
168
+ content = engine.generate_bootstrap_script(title=f"{env} database of schema {config.schema}", **definitions)
169
+ stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
170
+ path = Path(config.execution.ddl_pending_dir) / f"bootstrap_{config.schema}_{env.lower()}_{stamp}.sql"
171
+ try:
172
+ path.parent.mkdir(parents=True, exist_ok=True)
173
+ path.write_text(content, encoding="utf-8")
174
+ except OSError as exc:
175
+ logger.error(
176
+ "Admin credentials for %s are absent and the script could not be written to %s: %s", env, path, exc
177
+ )
178
+ raise SystemExit(1) from exc
179
+ script_files.append(path)
173
180
 
174
- history_admin_conn = engine.get_connection(config, admin=True, env="HISTORY")
175
- try:
176
- summary["history_roles"] = engine.ensure_roles(history_admin_conn, history_roles)
177
- summary["history_users"] = engine.ensure_users(history_admin_conn, [history_user])
178
- summary["history_tables"] = engine.ensure_tables(history_admin_conn, history_tables)
179
- summary["history_sequences"] = engine.ensure_sequences(history_admin_conn, history_sequences)
180
- summary["history_supporting_objects"] = engine.ensure_supporting_objects(
181
- history_admin_conn, connections.history.username
181
+ if not source_has_admin:
182
+ write_script(
183
+ "SOURCE",
184
+ roles=source_roles,
185
+ users=[source_user],
186
+ tables=source_tables,
187
+ sequences=source_sequences,
188
+ supporting_owner=connections.source.username,
189
+ links=[source_db_link],
190
+ runtime_username=connections.source.username,
191
+ runtime_dsn=connections.source.dsn,
192
+ )
193
+ if not history_has_admin:
194
+ write_script(
195
+ "HISTORY",
196
+ roles=history_roles,
197
+ users=[history_user],
198
+ tables=history_tables,
199
+ sequences=history_sequences,
200
+ supporting_owner=connections.history.username,
201
+ links=[history_db_link],
202
+ runtime_username=connections.history.username,
203
+ runtime_dsn=connections.history.dsn,
182
204
  )
183
- finally:
184
- engine.close_connection(history_admin_conn)
185
205
 
186
- source_user_conn = engine.get_connection(config, admin=False, env="SOURCE")
187
- try:
188
- summary["source_database_links"] = engine.ensure_database_links(source_user_conn, [source_db_link])
189
- finally:
190
- engine.close_connection(source_user_conn)
206
+ if source_has_admin:
207
+ source_admin_conn = engine.get_connection(config, admin=True, env="SOURCE")
208
+ try:
209
+ summary["source_roles"] = engine.ensure_roles(source_admin_conn, source_roles)
210
+ summary["source_users"] = engine.ensure_users(source_admin_conn, [source_user])
211
+ summary["source_tables"] = engine.ensure_tables(source_admin_conn, source_tables)
212
+ summary["source_sequences"] = engine.ensure_sequences(source_admin_conn, source_sequences)
213
+ summary["source_supporting_objects"] = engine.ensure_supporting_objects(
214
+ source_admin_conn, connections.source.username
215
+ )
216
+ finally:
217
+ engine.close_connection(source_admin_conn)
191
218
 
192
- history_user_conn = engine.get_connection(config, admin=False, env="HISTORY")
193
- try:
194
- summary["history_database_links"] = engine.ensure_database_links(history_user_conn, [history_db_link])
195
- finally:
196
- engine.close_connection(history_user_conn)
219
+ if history_has_admin:
220
+ history_admin_conn = engine.get_connection(config, admin=True, env="HISTORY")
221
+ try:
222
+ summary["history_roles"] = engine.ensure_roles(history_admin_conn, history_roles)
223
+ summary["history_users"] = engine.ensure_users(history_admin_conn, [history_user])
224
+ summary["history_tables"] = engine.ensure_tables(history_admin_conn, history_tables)
225
+ summary["history_sequences"] = engine.ensure_sequences(history_admin_conn, history_sequences)
226
+ summary["history_supporting_objects"] = engine.ensure_supporting_objects(
227
+ history_admin_conn, connections.history.username
228
+ )
229
+ finally:
230
+ engine.close_connection(history_admin_conn)
231
+
232
+ if source_has_admin:
233
+ source_user_conn = engine.get_connection(config, admin=False, env="SOURCE")
234
+ try:
235
+ summary["source_database_links"] = engine.ensure_database_links(source_user_conn, [source_db_link])
236
+ finally:
237
+ engine.close_connection(source_user_conn)
238
+
239
+ if history_has_admin:
240
+ history_user_conn = engine.get_connection(config, admin=False, env="HISTORY")
241
+ try:
242
+ summary["history_database_links"] = engine.ensure_database_links(history_user_conn, [history_db_link])
243
+ finally:
244
+ engine.close_connection(history_user_conn)
197
245
 
198
246
  anything_created = any(summary.values())
199
247
  for key, values in summary.items():
200
248
  if values:
201
249
  logger.info("Created %s: %s", key.replace("_", " "), ", ".join(values))
202
- if not anything_created:
250
+ for script_file in script_files:
251
+ logger.warning(
252
+ "Admin credentials are absent for this database, so nothing was created there. "
253
+ "Ask a DBA to run %s in SQL*Plus.",
254
+ script_file,
255
+ )
256
+ if not anything_created and not script_files:
203
257
  logger.info("All LimitsDb database objects are already present.")
204
258
 
205
259