acumatica-cli 0.12.0__tar.gz → 0.13.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.
- {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/PKG-INFO +10 -1
- {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/README.md +9 -0
- {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/pyproject.toml +1 -1
- {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/cli.py +166 -34
- {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/client.py +79 -9
- {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/config.py +24 -9
- acumatica_cli-0.13.0/src/acumatica_cli/snapshot.py +573 -0
- {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/README.md +29 -1
- acumatica_cli-0.13.0/src/acumatica_cli/templates/distribution/snapshot/10-trial-balance.yaml +20 -0
- acumatica_cli-0.13.0/src/acumatica_cli/templates/finance/snapshot/10-trial-balance.yaml +20 -0
- {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/__init__.py +0 -0
- {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/bootstrap.py +0 -0
- {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/bootstrap_plugin.cs +0 -0
- {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/bootstrap_project.xml +0 -0
- {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/extract.py +0 -0
- {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/extract_manifest.yaml +0 -0
- {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/firstlogin.py +0 -0
- {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/models.py +0 -0
- {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/output.py +0 -0
- {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/run.py +0 -0
- {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/seed.py +0 -0
- {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/target.py +0 -0
- {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/baseline/20-accounts.yaml +0 -0
- {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/baseline/60-ledger-company.yaml +0 -0
- {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/baseline/90-uoms.yaml +0 -0
- {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/bootstrap/company.yaml +0 -0
- {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/bootstrap/features.yaml +0 -0
- {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/master/10-reason-codes.yaml +0 -0
- {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/master/20-in-preferences.yaml +0 -0
- {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/master/30-availability-rules.yaml +0 -0
- {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/master/40-posting-classes.yaml +0 -0
- {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/master/50-warehouse.yaml +0 -0
- {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/master/51-warehouse-locations.yaml +0 -0
- {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/master/52-warehouse-defaults.yaml +0 -0
- {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/master/53-tax-categories.yaml +0 -0
- {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/master/54-item-classes.yaml +0 -0
- {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/master/56-so-preferences.yaml +0 -0
- {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/master/57-po-preferences.yaml +0 -0
- {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/master/58-order-types.yaml +0 -0
- {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/master/60-ar-preferences.yaml +0 -0
- {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/master/61-ap-preferences.yaml +0 -0
- {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/master/62-ca-preferences.yaml +0 -0
- {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/master/63-cash-account.yaml +0 -0
- {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/master/64-payment-methods.yaml +0 -0
- {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/master/65-statement-cycles.yaml +0 -0
- {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/master/70-vendor-classes.yaml +0 -0
- {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/master/71-customer-classes.yaml +0 -0
- {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/master/75-vendors.yaml +0 -0
- {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/master/76-customers.yaml +0 -0
- {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/master/80-stock-items-parts.yaml +0 -0
- {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/master/82-stock-items-kits.yaml +0 -0
- {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/master/85-kit-specifications.yaml +0 -0
- {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/scenario/10-seed-capital.yaml +0 -0
- {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/scenario/20-buy-gateways.yaml +0 -0
- {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/scenario/30-build.yaml +0 -0
- {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/scenario/40-sell.yaml +0 -0
- {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/distribution/setup/30-open-periods.yaml +0 -0
- {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/finance/baseline/10-subaccounts.yaml +0 -0
- {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/finance/baseline/20-accounts.yaml +0 -0
- {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/finance/baseline/40-ledger.yaml +0 -0
- {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/finance/baseline/50-gl-preferences.yaml +0 -0
- {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/finance/baseline/60-ledger-company.yaml +0 -0
- {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/finance/baseline/90-uoms.yaml +0 -0
- {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/finance/bootstrap/company.yaml +0 -0
- {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/finance/bootstrap/credit-terms.yaml +0 -0
- {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/finance/bootstrap/features.yaml +0 -0
- {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/finance/env +0 -0
- {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/finance/gitignore +0 -0
- {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/finance/setup/10-financial-year.yaml +0 -0
- {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/finance/setup/20-master-calendar.yaml +0 -0
- {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/finance/setup/30-open-periods.yaml +0 -0
- {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/templates/finance/target +0 -0
- {acumatica_cli-0.12.0 → acumatica_cli-0.13.0}/src/acumatica_cli/tenant.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: acumatica-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.13.0
|
|
4
4
|
Summary: Acumatica ERP Config-as-Code: tenant provisioning, baseline config, and reference data
|
|
5
5
|
Author: Konstantin Borovik
|
|
6
6
|
Author-email: Konstantin Borovik <kb@lab5.ca>
|
|
@@ -89,6 +89,8 @@ acu [--tenant NAME] [--url URL] [--ssh USER@HOST] [--api-version V]
|
|
|
89
89
|
├── apply [--dry-run] [FILES...] push YAML via REST (idempotent PUT upserts)
|
|
90
90
|
├── diff [FILES...] drift check vs the live tenant (exit 2 on drift)
|
|
91
91
|
├── run [--dry-run] [FILES...] execute transaction scenario YAML (exit 1 on any miss)
|
|
92
|
+
├── snapshot [--out DIR] [--diff] [--assert-unchanged] [--dry-run] [FILES...]
|
|
93
|
+
│ capture derived state into state/ (not seed)
|
|
92
94
|
├── extract [--out DIR] [--only NAME]... [--force] [--dry-run]
|
|
93
95
|
│ dump live tenant state as seed YAML (inverse of apply)
|
|
94
96
|
├── schema [--out DIR] dump the endpoint's OpenAPI schema (swagger.json)
|
|
@@ -103,6 +105,7 @@ acu [--tenant NAME] [--url URL] [--ssh USER@HOST] [--api-version V]
|
|
|
103
105
|
`apply` and `diff` without FILES prefer `config/<name>/` when any seed child exists under `config/`; otherwise root `bootstrap/`, `baseline/`, `setup/`, then `master/` when present.
|
|
104
106
|
A path like `config/` expands nested seed dirs in that fixed order.
|
|
105
107
|
`run` without FILES defaults to `scenario/`.
|
|
108
|
+
`snapshot` without FILES defaults to `config/snapshot/`; writes go to `state/` (`--out`).
|
|
106
109
|
`acu --completion` emits a completion script for bash, zsh, or fish — source it from your shell profile.
|
|
107
110
|
Run `acu <command> --help` for details on any command.
|
|
108
111
|
|
|
@@ -119,6 +122,8 @@ Your configuration lives in its own git repo.
|
|
|
119
122
|
| `setup/` / `config/setup/` | one-time actions: financial year, master calendar, open periods |
|
|
120
123
|
| `config/master/` | distribution masters (prefs, warehouse, items, parties); flavor only |
|
|
121
124
|
| `scenario/` | lifecycle txns for `acu run`: once capital, then buy, build stub, sell |
|
|
125
|
+
| `config/snapshot/` | observer views for `acu snapshot` (`inquire:` / `entity:` / `gi:`; not SEED_DIRS) |
|
|
126
|
+
| `state/` | committed derived-state observations (evidence, not seed; money/qty fixed-point) |
|
|
122
127
|
| `target.yaml` | committed verified matrix: `erp` + `default_api` (what, not where) |
|
|
123
128
|
| `.env` | where to apply and who signs in, every key an `ACU_*` variable |
|
|
124
129
|
|
|
@@ -131,6 +136,10 @@ Scenario YAML is different — it describes transactions that flow forward.
|
|
|
131
136
|
`acu run` executes each step in order (`put`, `action`, `wait`, `get`), captures server-assigned document numbers into `${var}` references for later steps, and checks `expect:` assertions as deltas against a pre-run snapshot, so additive scenarios re-run safely on a warm tenant.
|
|
132
137
|
`once: true` scenarios declare a `present` inquire-absolute gate; when the probe already holds, the CLI prints `skip <path> (once: already present)` and runs neither steps nor expects (Owner Capital does not restack).
|
|
133
138
|
|
|
139
|
+
`acu snapshot` is the third observation path: it captures live derived state (balances) into `state/` for git review. It is not `extract` (config seed) and not `diff` (desired vs actual config). Packaged golden is trial-balance via contract `inquire:` (`EndingBalance` fixed-point); inventory-summary is not golden this pass. `gi:` stays optional when a GI is V12-verified and **Expose via OData** is on (`params` fail-closed vs `$metadata`). After a cold `acu run scenario/ && acu snapshot`, warm `acu run scenario/10-seed-capital.yaml && acu snapshot --assert-unchanged` is the once-class gate (full scenario re-run is additive and moves cash observations on the TB).
|
|
140
|
+
|
|
141
|
+
**Migration (path hard-cut):** bare defaults are `config/snapshot/` (views) and `state/` (observations). Root `snapshot/` and `snapshots/` are no longer defaulted — move files or pass explicit path args.
|
|
142
|
+
|
|
134
143
|
### Seed `endpoint:` symbols
|
|
135
144
|
|
|
136
145
|
Dual-served entities (on both Bootstrap and Default) need an explicit `endpoint:` line.
|
|
@@ -71,6 +71,8 @@ acu [--tenant NAME] [--url URL] [--ssh USER@HOST] [--api-version V]
|
|
|
71
71
|
├── apply [--dry-run] [FILES...] push YAML via REST (idempotent PUT upserts)
|
|
72
72
|
├── diff [FILES...] drift check vs the live tenant (exit 2 on drift)
|
|
73
73
|
├── run [--dry-run] [FILES...] execute transaction scenario YAML (exit 1 on any miss)
|
|
74
|
+
├── snapshot [--out DIR] [--diff] [--assert-unchanged] [--dry-run] [FILES...]
|
|
75
|
+
│ capture derived state into state/ (not seed)
|
|
74
76
|
├── extract [--out DIR] [--only NAME]... [--force] [--dry-run]
|
|
75
77
|
│ dump live tenant state as seed YAML (inverse of apply)
|
|
76
78
|
├── schema [--out DIR] dump the endpoint's OpenAPI schema (swagger.json)
|
|
@@ -85,6 +87,7 @@ acu [--tenant NAME] [--url URL] [--ssh USER@HOST] [--api-version V]
|
|
|
85
87
|
`apply` and `diff` without FILES prefer `config/<name>/` when any seed child exists under `config/`; otherwise root `bootstrap/`, `baseline/`, `setup/`, then `master/` when present.
|
|
86
88
|
A path like `config/` expands nested seed dirs in that fixed order.
|
|
87
89
|
`run` without FILES defaults to `scenario/`.
|
|
90
|
+
`snapshot` without FILES defaults to `config/snapshot/`; writes go to `state/` (`--out`).
|
|
88
91
|
`acu --completion` emits a completion script for bash, zsh, or fish — source it from your shell profile.
|
|
89
92
|
Run `acu <command> --help` for details on any command.
|
|
90
93
|
|
|
@@ -101,6 +104,8 @@ Your configuration lives in its own git repo.
|
|
|
101
104
|
| `setup/` / `config/setup/` | one-time actions: financial year, master calendar, open periods |
|
|
102
105
|
| `config/master/` | distribution masters (prefs, warehouse, items, parties); flavor only |
|
|
103
106
|
| `scenario/` | lifecycle txns for `acu run`: once capital, then buy, build stub, sell |
|
|
107
|
+
| `config/snapshot/` | observer views for `acu snapshot` (`inquire:` / `entity:` / `gi:`; not SEED_DIRS) |
|
|
108
|
+
| `state/` | committed derived-state observations (evidence, not seed; money/qty fixed-point) |
|
|
104
109
|
| `target.yaml` | committed verified matrix: `erp` + `default_api` (what, not where) |
|
|
105
110
|
| `.env` | where to apply and who signs in, every key an `ACU_*` variable |
|
|
106
111
|
|
|
@@ -113,6 +118,10 @@ Scenario YAML is different — it describes transactions that flow forward.
|
|
|
113
118
|
`acu run` executes each step in order (`put`, `action`, `wait`, `get`), captures server-assigned document numbers into `${var}` references for later steps, and checks `expect:` assertions as deltas against a pre-run snapshot, so additive scenarios re-run safely on a warm tenant.
|
|
114
119
|
`once: true` scenarios declare a `present` inquire-absolute gate; when the probe already holds, the CLI prints `skip <path> (once: already present)` and runs neither steps nor expects (Owner Capital does not restack).
|
|
115
120
|
|
|
121
|
+
`acu snapshot` is the third observation path: it captures live derived state (balances) into `state/` for git review. It is not `extract` (config seed) and not `diff` (desired vs actual config). Packaged golden is trial-balance via contract `inquire:` (`EndingBalance` fixed-point); inventory-summary is not golden this pass. `gi:` stays optional when a GI is V12-verified and **Expose via OData** is on (`params` fail-closed vs `$metadata`). After a cold `acu run scenario/ && acu snapshot`, warm `acu run scenario/10-seed-capital.yaml && acu snapshot --assert-unchanged` is the once-class gate (full scenario re-run is additive and moves cash observations on the TB).
|
|
122
|
+
|
|
123
|
+
**Migration (path hard-cut):** bare defaults are `config/snapshot/` (views) and `state/` (observations). Root `snapshot/` and `snapshots/` are no longer defaulted — move files or pass explicit path args.
|
|
124
|
+
|
|
116
125
|
### Seed `endpoint:` symbols
|
|
117
126
|
|
|
118
127
|
Dual-served entities (on both Bootstrap and Default) need an explicit `endpoint:` line.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "acumatica-cli"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.13.0"
|
|
4
4
|
description = "Acumatica ERP Config-as-Code: tenant provisioning, baseline config, and reference data"
|
|
5
5
|
authors = [{ name = "Konstantin Borovik", email = "kb@lab5.ca" }]
|
|
6
6
|
license = "PolyForm-Noncommercial-1.0.0"
|
|
@@ -12,7 +12,7 @@ import click
|
|
|
12
12
|
import httpx
|
|
13
13
|
from click.shell_completion import get_completion_class
|
|
14
14
|
|
|
15
|
-
from . import bootstrap, extract, firstlogin, output, run, seed
|
|
15
|
+
from . import bootstrap, extract, firstlogin, output, run, seed, snapshot
|
|
16
16
|
from .client import AcumaticaClient
|
|
17
17
|
from .config import (
|
|
18
18
|
INIT_FLAVORS,
|
|
@@ -537,37 +537,73 @@ def config_check(ctx: click.Context, strict: bool) -> None:
|
|
|
537
537
|
# both live probes run through the exact objects live commands use, so
|
|
538
538
|
# a pass here proves the real code path, not a parallel one
|
|
539
539
|
inst = _resolve_instance(ctx)
|
|
540
|
-
failed = _probe_target(root, inst, strict=strict)
|
|
540
|
+
failed, claimed_erp = _probe_target(root, inst, strict=strict)
|
|
541
|
+
if not _probe_rest(inst, claimed_erp):
|
|
542
|
+
failed = True
|
|
543
|
+
if not _probe_ssh(inst):
|
|
544
|
+
failed = True
|
|
545
|
+
if failed:
|
|
546
|
+
raise SystemExit(1)
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def _probe_rest(inst: Instance, claimed_erp: str | None) -> bool:
|
|
550
|
+
"""REST login + endpoints + optional ERP build; True = all passed.
|
|
551
|
+
|
|
552
|
+
Entering the client is the probe: login + landed-tenant verify (V5);
|
|
553
|
+
context manager guarantees logout (V6). One GET /entity feeds endpoints
|
|
554
|
+
(T74/T90) and optional ERP build (T92).
|
|
555
|
+
"""
|
|
541
556
|
try:
|
|
542
|
-
# entering the client is the whole probe: login + landed-tenant
|
|
543
|
-
# verify (V5), and the context manager guarantees logout (V6);
|
|
544
|
-
# endpoints probe (T74) reuses the same session when login succeeds
|
|
545
557
|
with AcumaticaClient(inst) as client:
|
|
546
558
|
output.data(f"ok rest ({inst.base_url}, tenant {inst.tenant})")
|
|
547
|
-
|
|
548
|
-
failed = True
|
|
559
|
+
return _probe_entity_root(client, inst, claimed_erp)
|
|
549
560
|
except (RuntimeError, httpx.HTTPError) as exc:
|
|
550
561
|
output.data(f"fail rest: {exc}")
|
|
551
|
-
|
|
562
|
+
return False
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def _probe_entity_root(
|
|
566
|
+
client: AcumaticaClient, inst: Instance, claimed_erp: str | None
|
|
567
|
+
) -> bool:
|
|
568
|
+
"""Endpoints (+ ERP when claimed); True = all passed."""
|
|
569
|
+
ok = True
|
|
570
|
+
try:
|
|
571
|
+
endpoints, live_build = client.entity_root()
|
|
572
|
+
except (RuntimeError, httpx.HTTPError) as exc:
|
|
573
|
+
output.data(f"fail endpoints: {exc}")
|
|
574
|
+
endpoints, live_build = [], None
|
|
575
|
+
ok = False
|
|
576
|
+
else:
|
|
577
|
+
if not _probe_endpoints(endpoints, inst):
|
|
578
|
+
ok = False
|
|
579
|
+
if claimed_erp is not None and not _probe_erp(claimed_erp, live_build):
|
|
580
|
+
ok = False
|
|
581
|
+
return ok
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def _probe_ssh(inst: Instance) -> bool:
|
|
585
|
+
"""SSH ping or skip when unset; True = pass/skip, False = fail."""
|
|
552
586
|
if not inst.ssh:
|
|
553
587
|
# V3/I.cmd: ACU_SSH optional — hosted data-plane path skips, never fails
|
|
554
588
|
output.data("skip ssh (ACU_SSH not set)")
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
raise SystemExit(1)
|
|
589
|
+
return True
|
|
590
|
+
try:
|
|
591
|
+
TenantManager(inst).ping()
|
|
592
|
+
output.data(f"ok ssh ({inst.ssh})")
|
|
593
|
+
return True
|
|
594
|
+
except RuntimeError as exc:
|
|
595
|
+
output.data(f"fail ssh: {exc}")
|
|
596
|
+
return False
|
|
564
597
|
|
|
565
598
|
|
|
566
|
-
def _probe_target(
|
|
567
|
-
|
|
599
|
+
def _probe_target(
|
|
600
|
+
root: Path | None, inst: Instance, *, strict: bool
|
|
601
|
+
) -> tuple[bool, str | None]:
|
|
602
|
+
"""Emit local target probe; return ``(failed, claimed_erp?)`` (V27).
|
|
568
603
|
|
|
569
604
|
Invalid target hard-exits (any loader). Missing under data root warns
|
|
570
|
-
unless --strict. No data root → skip. Match → ok
|
|
605
|
+
unless --strict. No data root → skip. Match → ok + claimed ``erp`` for
|
|
606
|
+
the live ERP probe after REST (T92).
|
|
571
607
|
"""
|
|
572
608
|
try:
|
|
573
609
|
target = load_target(root)
|
|
@@ -576,7 +612,7 @@ def _probe_target(root: Path | None, inst: Instance, *, strict: bool) -> bool:
|
|
|
576
612
|
raise SystemExit(1) from exc
|
|
577
613
|
if root is None:
|
|
578
614
|
output.data("skip target (no data root)")
|
|
579
|
-
return False
|
|
615
|
+
return False, None
|
|
580
616
|
if target is None:
|
|
581
617
|
msg = (
|
|
582
618
|
f"target: no target.yaml under {root} - dataset verified matrix "
|
|
@@ -584,35 +620,27 @@ def _probe_target(root: Path | None, inst: Instance, *, strict: bool) -> bool:
|
|
|
584
620
|
"to require it"
|
|
585
621
|
)
|
|
586
622
|
output.data(f"{'fail' if strict else 'warn'} {msg}")
|
|
587
|
-
return strict
|
|
623
|
+
return strict, None
|
|
588
624
|
if target.default_api != inst.api_version:
|
|
589
625
|
output.data(
|
|
590
626
|
f"fail target: dataset default_api={target.default_api} vs "
|
|
591
627
|
f"configured {inst.api_version}"
|
|
592
628
|
)
|
|
593
|
-
return True
|
|
629
|
+
return True, None
|
|
594
630
|
output.data(
|
|
595
631
|
f"ok target (default_api={target.default_api} matches configured; "
|
|
596
632
|
f"erp={target.erp} claimed)"
|
|
597
633
|
)
|
|
598
|
-
|
|
599
|
-
# skip rather than invent SSH/sqlcmd (control-plane exclusion, V1)
|
|
600
|
-
output.data(f"skip erp (live probe not available; claimed {target.erp})")
|
|
601
|
-
return False
|
|
634
|
+
return False, target.erp
|
|
602
635
|
|
|
603
636
|
|
|
604
|
-
def _probe_endpoints(
|
|
637
|
+
def _probe_endpoints(endpoints: list[tuple[str, str]], inst: Instance) -> bool:
|
|
605
638
|
"""Emit endpoints probe; return True on pass, False on fail (V12/V27).
|
|
606
639
|
|
|
607
640
|
Exact match: a Default entry whose version half equals
|
|
608
|
-
``Instance.api_version``.
|
|
641
|
+
``Instance.api_version``. Caller already fail-closed on unparseable GET.
|
|
609
642
|
"""
|
|
610
643
|
want = f"Default/{inst.api_version}"
|
|
611
|
-
try:
|
|
612
|
-
endpoints = client.list_endpoints()
|
|
613
|
-
except (RuntimeError, httpx.HTTPError) as exc:
|
|
614
|
-
output.data(f"fail endpoints: {exc}")
|
|
615
|
-
return False
|
|
616
644
|
defaults = [v for name, v in endpoints if name == "Default"]
|
|
617
645
|
if inst.api_version in defaults:
|
|
618
646
|
output.data(f"ok endpoints ({want} present)")
|
|
@@ -625,6 +653,34 @@ def _probe_endpoints(client: AcumaticaClient, inst: Instance) -> bool:
|
|
|
625
653
|
return False
|
|
626
654
|
|
|
627
655
|
|
|
656
|
+
def _major_minor(version: str) -> str:
|
|
657
|
+
"""First two dotted segments (T76/T92 major.minor match)."""
|
|
658
|
+
parts = version.split(".")
|
|
659
|
+
if len(parts) >= 2:
|
|
660
|
+
return f"{parts[0]}.{parts[1]}"
|
|
661
|
+
return version
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def _probe_erp(claimed: str, live: str | None) -> bool:
|
|
665
|
+
"""Emit ERP probe; return True on pass/skip, False on fail (T92).
|
|
666
|
+
|
|
667
|
+
Live id comes from 26.x ``GET /entity`` wrapper
|
|
668
|
+
``version.acumaticaBuildVersion``. Bare array → skip (no HTTP surface).
|
|
669
|
+
Compare major.minor only — patch builds may drift within a claimed line.
|
|
670
|
+
"""
|
|
671
|
+
if live is None:
|
|
672
|
+
output.data(f"skip erp (live probe not available; claimed {claimed})")
|
|
673
|
+
return True
|
|
674
|
+
if _major_minor(live) == _major_minor(claimed):
|
|
675
|
+
output.data(f"ok erp ({live} matches claimed {claimed})")
|
|
676
|
+
return True
|
|
677
|
+
output.data(
|
|
678
|
+
f"fail erp: live {live} vs claimed {claimed} "
|
|
679
|
+
f"(major.minor {_major_minor(live)} vs {_major_minor(claimed)})"
|
|
680
|
+
)
|
|
681
|
+
return False
|
|
682
|
+
|
|
683
|
+
|
|
628
684
|
SEED_DIRS = ("bootstrap", "baseline", "setup", "master")
|
|
629
685
|
|
|
630
686
|
|
|
@@ -890,3 +946,79 @@ def _exit_on_drift(inst: Instance, drifts: list[str], files: int) -> None:
|
|
|
890
946
|
output.data(f" {line}")
|
|
891
947
|
raise SystemExit(2)
|
|
892
948
|
output.success(f"no drift on {inst.tenant} ({inst.base_url}, {files} file(s))")
|
|
949
|
+
|
|
950
|
+
|
|
951
|
+
@cli.command("snapshot")
|
|
952
|
+
@click.argument(
|
|
953
|
+
"files", nargs=-1, required=False, type=click.Path(exists=True, path_type=Path)
|
|
954
|
+
)
|
|
955
|
+
@click.option(
|
|
956
|
+
"--out",
|
|
957
|
+
"out_dir",
|
|
958
|
+
type=click.Path(file_okay=False, path_type=Path),
|
|
959
|
+
default=None,
|
|
960
|
+
help="Observation output directory (default: state/)",
|
|
961
|
+
)
|
|
962
|
+
@click.option(
|
|
963
|
+
"--diff",
|
|
964
|
+
"do_diff",
|
|
965
|
+
is_flag=True,
|
|
966
|
+
help="Compare live vs disk; write nothing (exit 0 either way)",
|
|
967
|
+
)
|
|
968
|
+
@click.option(
|
|
969
|
+
"--assert-unchanged",
|
|
970
|
+
is_flag=True,
|
|
971
|
+
help="Like --diff, but exit 2 when state moved (idempotence gate)",
|
|
972
|
+
)
|
|
973
|
+
@click.option(
|
|
974
|
+
"--dry-run",
|
|
975
|
+
is_flag=True,
|
|
976
|
+
help="Resolve views and validate sources without HTTP",
|
|
977
|
+
)
|
|
978
|
+
@pass_instance
|
|
979
|
+
def snapshot_cmd(
|
|
980
|
+
inst: Instance,
|
|
981
|
+
files: tuple[Path, ...],
|
|
982
|
+
out_dir: Path | None,
|
|
983
|
+
do_diff: bool,
|
|
984
|
+
assert_unchanged: bool,
|
|
985
|
+
dry_run: bool,
|
|
986
|
+
) -> None:
|
|
987
|
+
"""Capture live derived state into committed observation files.
|
|
988
|
+
|
|
989
|
+
FILES are snapshot view YAML files or directories. Omitted, they default
|
|
990
|
+
to the data repo's config/snapshot/ directory (hard-cut; no root
|
|
991
|
+
snapshot/ fallback). Default write target is state/ (--out; no
|
|
992
|
+
snapshots/ fallback). Bare capture writes observations (change is
|
|
993
|
+
fine). --diff compares live to disk without writing.
|
|
994
|
+
--assert-unchanged is the warm-run idempotence gate (exit 2 when
|
|
995
|
+
moved). Never writes seed trees or endpoint: symbols (V32). Exit 0
|
|
996
|
+
ok, 1 op fail, 2 only under --assert-unchanged when state moved.
|
|
997
|
+
"""
|
|
998
|
+
assert_target_compatible(inst)
|
|
999
|
+
if not files:
|
|
1000
|
+
default = data_root() / "config" / "snapshot"
|
|
1001
|
+
if not default.is_dir():
|
|
1002
|
+
raise SystemExit(f"{default}: snapshot directory does not exist")
|
|
1003
|
+
files = (Path(os.path.relpath(default)),)
|
|
1004
|
+
paths = snapshot.expand_view_files(files)
|
|
1005
|
+
views = [snapshot.load_view(path) for path in paths]
|
|
1006
|
+
dest = out_dir if out_dir is not None else Path("state")
|
|
1007
|
+
if dry_run:
|
|
1008
|
+
code = snapshot.run_views(None, views, out_dir=dest, mode="dry")
|
|
1009
|
+
else:
|
|
1010
|
+
mode = "assert" if assert_unchanged else "diff" if do_diff else "write"
|
|
1011
|
+
with AcumaticaClient(inst) as client:
|
|
1012
|
+
code = snapshot.run_views(client, views, out_dir=dest, mode=mode)
|
|
1013
|
+
if code:
|
|
1014
|
+
raise SystemExit(code)
|
|
1015
|
+
if dry_run:
|
|
1016
|
+
return
|
|
1017
|
+
if assert_unchanged:
|
|
1018
|
+
output.success(f"{len(views)} snapshot(s) unchanged on {inst.tenant}")
|
|
1019
|
+
elif do_diff:
|
|
1020
|
+
output.success(f"{len(views)} snapshot(s) compared on {inst.tenant}")
|
|
1021
|
+
else:
|
|
1022
|
+
output.success(
|
|
1023
|
+
f"{len(views)} snapshot(s) written under {dest} on {inst.tenant}"
|
|
1024
|
+
)
|
|
@@ -65,10 +65,22 @@ def unwrap(entity: dict[str, Any]) -> dict[str, Any]:
|
|
|
65
65
|
def parse_entity_list(response: httpx.Response) -> list[tuple[str, str]]:
|
|
66
66
|
"""Parse ``GET /entity`` into ``[(name, version), ...]`` (fail-closed).
|
|
67
67
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
shape change is re-verified
|
|
68
|
+
Dual shape (V31 / gh #20): top-level JSON array (legacy) or object with
|
|
69
|
+
an ``endpoints`` array (26.x wrapper). Each row needs string ``name``
|
|
70
|
+
and ``version``. Unparseable body → RuntimeError with status,
|
|
71
|
+
content-type, and a short raw hint so a shape change is re-verified
|
|
72
|
+
(V12) rather than silently skipped.
|
|
73
|
+
"""
|
|
74
|
+
return parse_entity_response(response)[0]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def parse_entity_response(
|
|
78
|
+
response: httpx.Response,
|
|
79
|
+
) -> tuple[list[tuple[str, str]], str | None]:
|
|
80
|
+
"""Parse ``GET /entity`` → ``(endpoints, acumatica_build_version?)``.
|
|
81
|
+
|
|
82
|
+
Endpoints dual-shape per V31. Build id is ``version.acumaticaBuildVersion``
|
|
83
|
+
on the 26.x wrapper only; bare array → ``None`` (T92).
|
|
72
84
|
"""
|
|
73
85
|
try:
|
|
74
86
|
body = response.json()
|
|
@@ -80,15 +92,17 @@ def parse_entity_list(response: httpx.Response) -> list[tuple[str, str]]:
|
|
|
80
92
|
f"content-type {response.headers.get('content-type', '?')}; "
|
|
81
93
|
f"first 200 chars: {hint})"
|
|
82
94
|
) from exc
|
|
83
|
-
|
|
95
|
+
rows = _entity_list_rows(body)
|
|
96
|
+
if rows is None:
|
|
84
97
|
hint = str(body)[:200]
|
|
85
98
|
raise RuntimeError(
|
|
86
99
|
"GET /entity response not parseable as endpoint list "
|
|
87
|
-
f"(status {response.status_code}; expected non-empty JSON array
|
|
100
|
+
f"(status {response.status_code}; expected non-empty JSON array "
|
|
101
|
+
"or object with non-empty 'endpoints' array; "
|
|
88
102
|
f"first 200 chars: {hint})"
|
|
89
103
|
)
|
|
90
104
|
out: list[tuple[str, str]] = []
|
|
91
|
-
for item in
|
|
105
|
+
for item in rows:
|
|
92
106
|
if not isinstance(item, dict):
|
|
93
107
|
raise RuntimeError(
|
|
94
108
|
"GET /entity response not parseable as endpoint list "
|
|
@@ -102,7 +116,31 @@ def parse_entity_list(response: httpx.Response) -> list[tuple[str, str]]:
|
|
|
102
116
|
f"(row missing string name/version: {item!r})"
|
|
103
117
|
)
|
|
104
118
|
out.append((name, version))
|
|
105
|
-
return out
|
|
119
|
+
return out, _entity_build_version(body)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _entity_list_rows(body: Any) -> list[Any] | None:
|
|
123
|
+
"""Return endpoint row list from array or 26.x wrapper; else None."""
|
|
124
|
+
if isinstance(body, list) and body:
|
|
125
|
+
return body
|
|
126
|
+
if isinstance(body, dict):
|
|
127
|
+
endpoints = body.get("endpoints")
|
|
128
|
+
if isinstance(endpoints, list) and endpoints:
|
|
129
|
+
return endpoints
|
|
130
|
+
return None
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _entity_build_version(body: Any) -> str | None:
|
|
134
|
+
"""``version.acumaticaBuildVersion`` from 26.x wrapper; else None."""
|
|
135
|
+
if not isinstance(body, dict):
|
|
136
|
+
return None
|
|
137
|
+
version = body.get("version")
|
|
138
|
+
if not isinstance(version, dict):
|
|
139
|
+
return None
|
|
140
|
+
build = version.get("acumaticaBuildVersion")
|
|
141
|
+
if isinstance(build, str) and build:
|
|
142
|
+
return build
|
|
143
|
+
return None
|
|
106
144
|
|
|
107
145
|
|
|
108
146
|
# The list GET's optimized-export failure (B9): the contract API's list GET
|
|
@@ -245,8 +283,16 @@ class AcumaticaClient:
|
|
|
245
283
|
|
|
246
284
|
Fail-closed on unparseable body — see ``parse_entity_list``.
|
|
247
285
|
"""
|
|
286
|
+
return self.entity_root()[0]
|
|
287
|
+
|
|
288
|
+
def entity_root(self) -> tuple[list[tuple[str, str]], str | None]:
|
|
289
|
+
"""Authenticated ``GET /entity`` → endpoints + optional ERP build id.
|
|
290
|
+
|
|
291
|
+
Build id is present only on the 26.x wrapper
|
|
292
|
+
(``version.acumaticaBuildVersion``). Bare array → ``None`` (T92).
|
|
293
|
+
"""
|
|
248
294
|
r = self._checked(self._http.get("/entity"))
|
|
249
|
-
return
|
|
295
|
+
return parse_entity_response(r)
|
|
250
296
|
|
|
251
297
|
@staticmethod
|
|
252
298
|
def _checked(r: httpx.Response) -> httpx.Response:
|
|
@@ -492,3 +538,27 @@ class AcumaticaClient:
|
|
|
492
538
|
return self._checked(
|
|
493
539
|
self._http.post("/CustomizationApi/publishEnd", json={})
|
|
494
540
|
).json()
|
|
541
|
+
|
|
542
|
+
# -- OData Generic Inquiry (snapshot gi: source; V33) --
|
|
543
|
+
|
|
544
|
+
def _odata_gi_root(self) -> str:
|
|
545
|
+
"""24R2+ OData GI service root ``/t/<tenant>/api/odata/gi`` (V33)."""
|
|
546
|
+
tenant = quote(self.instance.tenant, safe="")
|
|
547
|
+
return f"/t/{tenant}/api/odata/gi"
|
|
548
|
+
|
|
549
|
+
def odata_gi_metadata(self, name: str) -> str:
|
|
550
|
+
"""GET OData GI service ``$metadata`` XML (V33 param validation).
|
|
551
|
+
|
|
552
|
+
``name`` is reserved for future per-GI metadata URLs; the 24R2
|
|
553
|
+
service exposes one EDMX document at the service root.
|
|
554
|
+
"""
|
|
555
|
+
del name # service-level metadata; per-GI filter is validate_gi_params
|
|
556
|
+
r = self._checked(self._http.get(f"{self._odata_gi_root()}/$metadata"))
|
|
557
|
+
return r.text
|
|
558
|
+
|
|
559
|
+
def odata_gi(self, name: str, params: dict[str, str] | None = None) -> Any:
|
|
560
|
+
"""GET OData GI rows as JSON (requires Expose via OData on the GI)."""
|
|
561
|
+
gi = quote(name, safe="")
|
|
562
|
+
return self._checked(
|
|
563
|
+
self._http.get(f"{self._odata_gi_root()}/{gi}", params=params)
|
|
564
|
+
).json()
|
|
@@ -58,6 +58,11 @@ INIT_TEMPLATES = (
|
|
|
58
58
|
("finance/setup/10-financial-year.yaml", "setup/10-financial-year.yaml"),
|
|
59
59
|
("finance/setup/20-master-calendar.yaml", "setup/20-master-calendar.yaml"),
|
|
60
60
|
("finance/setup/30-open-periods.yaml", "setup/30-open-periods.yaml"),
|
|
61
|
+
# V28/V32: observer views under config/snapshot/ (not SEED_DIRS; lone config/ ok)
|
|
62
|
+
(
|
|
63
|
+
"finance/snapshot/10-trial-balance.yaml",
|
|
64
|
+
"config/snapshot/10-trial-balance.yaml",
|
|
65
|
+
),
|
|
61
66
|
)
|
|
62
67
|
|
|
63
68
|
# Opt-in `--flavor distribution` overlays + extras (V28/V29/T87). Resource
|
|
@@ -168,6 +173,10 @@ DISTRIBUTION_TEMPLATES = (
|
|
|
168
173
|
),
|
|
169
174
|
("distribution/scenario/30-build.yaml", "scenario/30-build.yaml"),
|
|
170
175
|
("distribution/scenario/40-sell.yaml", "scenario/40-sell.yaml"),
|
|
176
|
+
(
|
|
177
|
+
"distribution/snapshot/10-trial-balance.yaml",
|
|
178
|
+
"config/snapshot/10-trial-balance.yaml",
|
|
179
|
+
),
|
|
171
180
|
("distribution/README.md", "README.md"),
|
|
172
181
|
)
|
|
173
182
|
|
|
@@ -237,10 +246,13 @@ class Instance(BaseSettings):
|
|
|
237
246
|
def templates_for(flavor: str | None) -> tuple[tuple[str, str], ...]:
|
|
238
247
|
"""Resolve (resource, dest) pairs for ``config init`` (V28/T87).
|
|
239
248
|
|
|
240
|
-
Absent flavor → finance-minimal ``INIT_TEMPLATES`` at root
|
|
241
|
-
keeps root meta
|
|
242
|
-
|
|
243
|
-
|
|
249
|
+
Absent flavor → finance-minimal ``INIT_TEMPLATES`` at root (+ observer
|
|
250
|
+
``config/snapshot/``). ``distribution`` keeps root meta
|
|
251
|
+
(``.env``/``.gitignore``/``target.yaml``), rehomes finance seed under
|
|
252
|
+
``config/``, then overlays ``DISTRIBUTION_TEMPLATES`` (config/ seeds +
|
|
253
|
+
lifecycle ``scenario/`` + ``config/snapshot/`` + README). Never dual
|
|
254
|
+
root+config seed trees. Paths already under ``config/`` (snapshot
|
|
255
|
+
views) pass through unchanged.
|
|
244
256
|
"""
|
|
245
257
|
if flavor is None:
|
|
246
258
|
return INIT_TEMPLATES
|
|
@@ -256,11 +268,14 @@ def templates_for(flavor: str | None) -> tuple[tuple[str, str], ...]:
|
|
|
256
268
|
by_dest[dest] = res
|
|
257
269
|
order.append(dest)
|
|
258
270
|
continue
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
271
|
+
if dest.startswith("config/"):
|
|
272
|
+
target = dest
|
|
273
|
+
else:
|
|
274
|
+
target = (
|
|
275
|
+
f"config/{dest}"
|
|
276
|
+
if any(dest.startswith(p) for p in _SEED_PREFIXES)
|
|
277
|
+
else dest
|
|
278
|
+
)
|
|
264
279
|
if target not in by_dest:
|
|
265
280
|
order.append(target)
|
|
266
281
|
by_dest[target] = res
|