acumatica-cli 0.2.2__py3-none-any.whl
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/__init__.py +0 -0
- acumatica_cli/bootstrap.py +105 -0
- acumatica_cli/bootstrap_plugin.cs +62 -0
- acumatica_cli/bootstrap_project.xml +117 -0
- acumatica_cli/cli.py +351 -0
- acumatica_cli/client.py +251 -0
- acumatica_cli/config.py +113 -0
- acumatica_cli/firstlogin.py +171 -0
- acumatica_cli/models.py +21 -0
- acumatica_cli/output.py +70 -0
- acumatica_cli/seed.py +120 -0
- acumatica_cli/tenant.py +149 -0
- acumatica_cli-0.2.2.dist-info/METADATA +187 -0
- acumatica_cli-0.2.2.dist-info/RECORD +16 -0
- acumatica_cli-0.2.2.dist-info/WHEEL +4 -0
- acumatica_cli-0.2.2.dist-info/entry_points.txt +3 -0
|
File without changes
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Bootstrap customization package: build the zip, publish via /CustomizationApi.
|
|
2
|
+
|
|
3
|
+
An unconfigured tenant cannot be configured through the Default endpoint
|
|
4
|
+
(features are off, no company/branch exists, credit terms have no entity —
|
|
5
|
+
docs/rest-api.md). The CustomizationApi is the one door that works on a
|
|
6
|
+
virgin tenant, so bootstrap = publish a package whose CustomizationPlugin
|
|
7
|
+
(`bootstrap_plugin.cs`) enables features on publish — the contract API
|
|
8
|
+
cannot write CS100000 at all (T3 verdict) — and whose Bootstrap contract
|
|
9
|
+
endpoint exposes CS101500 company + CS206500 credit terms for seeding
|
|
10
|
+
(`bootstrap_project.xml`, serialization verified T12).
|
|
11
|
+
|
|
12
|
+
Customization publishes are tenant-scoped, so the package must be published
|
|
13
|
+
per tenant; publish() is idempotent — an already-published package is a
|
|
14
|
+
no-op skip, and the plugin's UpdateDatabase is a keyed update on re-run.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import io
|
|
18
|
+
import time
|
|
19
|
+
import xml.etree.ElementTree as ET
|
|
20
|
+
import zipfile
|
|
21
|
+
from importlib import resources
|
|
22
|
+
|
|
23
|
+
import httpx
|
|
24
|
+
|
|
25
|
+
from .client import AcumaticaClient
|
|
26
|
+
|
|
27
|
+
# Alphanumeric only: CstDbStorage.ValidatePackageName rejects '-' and '_'
|
|
28
|
+
# (verified vs 26.101.0225 — "Invalid project name")
|
|
29
|
+
PACKAGE_NAME = "AcuBootstrap"
|
|
30
|
+
PACKAGE_DESCRIPTION = (
|
|
31
|
+
"acu bootstrap: CustomizationPlugin enables features on publish; "
|
|
32
|
+
"Bootstrap endpoint exposes company + credit terms"
|
|
33
|
+
)
|
|
34
|
+
PLUGIN_CLASS = "AcuBootstrapPlugin"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def package_zip() -> bytes:
|
|
38
|
+
"""Build the customization package: a zip holding project.xml.
|
|
39
|
+
|
|
40
|
+
The C# plugin travels as a <Graph> item whose Source ATTRIBUTE holds the
|
|
41
|
+
file content (Customization.CstCodeFile shape, verified vs 26.101.0225:
|
|
42
|
+
inline CDATA and zip-file variants are silently dropped on import).
|
|
43
|
+
ElementTree escapes the newlines as on serialization.
|
|
44
|
+
"""
|
|
45
|
+
pkg = resources.files("acumatica_cli")
|
|
46
|
+
root = ET.fromstring((pkg / "bootstrap_project.xml").read_bytes())
|
|
47
|
+
graph = ET.SubElement(root, "Graph")
|
|
48
|
+
graph.set("ClassName", PLUGIN_CLASS)
|
|
49
|
+
graph.set("FileType", "NewFile")
|
|
50
|
+
graph.set("Source", (pkg / "bootstrap_plugin.cs").read_text(encoding="utf-8"))
|
|
51
|
+
buf = io.BytesIO()
|
|
52
|
+
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
|
53
|
+
zf.writestr("project.xml", ET.tostring(root, encoding="utf-8"))
|
|
54
|
+
return buf.getvalue()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _log_tail(status: dict[str, object], limit: int = 5) -> str:
|
|
58
|
+
"""Last few publish-log messages, for a one-line error (SPEC V9)."""
|
|
59
|
+
log = status.get("log")
|
|
60
|
+
if not isinstance(log, list):
|
|
61
|
+
return ""
|
|
62
|
+
messages = [
|
|
63
|
+
str(entry.get("message", "")) for entry in log if isinstance(entry, dict)
|
|
64
|
+
]
|
|
65
|
+
return "; ".join(m for m in messages[-limit:] if m)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def publish(client: AcumaticaClient, timeout: float = 600.0, poll: float = 5.0) -> str:
|
|
69
|
+
"""Publish the bootstrap package into the client's session tenant.
|
|
70
|
+
|
|
71
|
+
Idempotent: returns ``"already published"`` when getPublished lists the
|
|
72
|
+
package AND its content still exists (a recreated tenant with the same
|
|
73
|
+
CompanyID keeps the stale publication row while the content and the
|
|
74
|
+
plugin's writes are gone), else import -> publishBegin -> poll publishEnd
|
|
75
|
+
until the server reports completion. Transport errors while polling are
|
|
76
|
+
tolerated — publishing restarts the app domain, so the site may briefly
|
|
77
|
+
drop connections mid-publish. Raises RuntimeError on a failed publish or
|
|
78
|
+
on timeout.
|
|
79
|
+
"""
|
|
80
|
+
if PACKAGE_NAME in client.customization_published() and (
|
|
81
|
+
client.customization_project_exists(PACKAGE_NAME)
|
|
82
|
+
):
|
|
83
|
+
return "already published"
|
|
84
|
+
client.customization_import(
|
|
85
|
+
PACKAGE_NAME, package_zip(), description=PACKAGE_DESCRIPTION
|
|
86
|
+
)
|
|
87
|
+
client.customization_publish_begin([PACKAGE_NAME])
|
|
88
|
+
deadline = time.monotonic() + timeout
|
|
89
|
+
while True:
|
|
90
|
+
try:
|
|
91
|
+
status = client.customization_publish_end()
|
|
92
|
+
except httpx.TransportError:
|
|
93
|
+
status = {} # site restarting mid-publish; keep polling
|
|
94
|
+
if status.get("isFailed"):
|
|
95
|
+
detail = _log_tail(status)
|
|
96
|
+
raise RuntimeError(
|
|
97
|
+
f"publishing {PACKAGE_NAME} failed" + (f": {detail}" if detail else "")
|
|
98
|
+
)
|
|
99
|
+
if status.get("isCompleted"):
|
|
100
|
+
return "published"
|
|
101
|
+
if time.monotonic() >= deadline:
|
|
102
|
+
raise RuntimeError(
|
|
103
|
+
f"publishing {PACKAGE_NAME} did not complete within {timeout:.0f}s"
|
|
104
|
+
)
|
|
105
|
+
time.sleep(poll)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// acu bootstrap CustomizationPlugin (SPEC T11).
|
|
2
|
+
//
|
|
3
|
+
// Runs in-process on customization publish - the one write path to
|
|
4
|
+
// FeaturesSet that works: the contract API cannot persist features no
|
|
5
|
+
// matter what endpoint fronts CS100000 (T3 verdict, docs/rest-api.md).
|
|
6
|
+
//
|
|
7
|
+
// Deliberately writes through PXDatabase, not FeaturesMaint: the graph
|
|
8
|
+
// save collides with the publish pipeline's concurrent plugin invocations
|
|
9
|
+
// ("Another process has added the 'FeaturesSet' record" - observed live,
|
|
10
|
+
// 26.101.0225) and nothing persists. A keyed row write is deterministic
|
|
11
|
+
// and idempotent: update the existing row, insert when absent. All 205
|
|
12
|
+
// NOT NULL bit columns are filled reflectively (only ~136 carry DB
|
|
13
|
+
// defaults); Status = 0 means Validated (PXIntList, verified vs live).
|
|
14
|
+
using System.Collections.Generic;
|
|
15
|
+
using System.Reflection;
|
|
16
|
+
using PX.Data;
|
|
17
|
+
using PX.Objects.CS;
|
|
18
|
+
|
|
19
|
+
namespace AcuBootstrap
|
|
20
|
+
{
|
|
21
|
+
public class AcuBootstrapPlugin : Customization.CustomizationPlugin
|
|
22
|
+
{
|
|
23
|
+
private static readonly HashSet<string> Enabled = new HashSet<string>
|
|
24
|
+
{
|
|
25
|
+
"FinancialModule",
|
|
26
|
+
"FinancialStandard",
|
|
27
|
+
"DistributionModule",
|
|
28
|
+
"Inventory",
|
|
29
|
+
"Branch",
|
|
30
|
+
"MultiCompany",
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
public override void UpdateDatabase()
|
|
34
|
+
{
|
|
35
|
+
var flags = new List<PXDataFieldAssign>();
|
|
36
|
+
foreach (PropertyInfo prop in typeof(FeaturesSet).GetProperties())
|
|
37
|
+
{
|
|
38
|
+
if (prop.PropertyType == typeof(bool?))
|
|
39
|
+
{
|
|
40
|
+
flags.Add(new PXDataFieldAssign(
|
|
41
|
+
prop.Name, PXDbType.Bit, Enabled.Contains(prop.Name)));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
flags.Add(new PXDataFieldAssign("Status", PXDbType.Int, 0)); // Validated
|
|
45
|
+
|
|
46
|
+
using (var tx = new PXTransactionScope())
|
|
47
|
+
{
|
|
48
|
+
bool updated = PXDatabase.Update<FeaturesSet>(flags.ToArray());
|
|
49
|
+
if (!updated)
|
|
50
|
+
{
|
|
51
|
+
PXDatabase.Insert<FeaturesSet>(flags.ToArray());
|
|
52
|
+
WriteLog("AcuBootstrap: FeaturesSet row inserted (features enabled)");
|
|
53
|
+
}
|
|
54
|
+
else
|
|
55
|
+
{
|
|
56
|
+
WriteLog("AcuBootstrap: FeaturesSet row updated (features enabled)");
|
|
57
|
+
}
|
|
58
|
+
tx.Complete();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="utf-8"?>
|
|
2
|
+
<!--
|
|
3
|
+
acu bootstrap customization package template (SPEC T2 + T11 + T12).
|
|
4
|
+
|
|
5
|
+
package_zip() (bootstrap.py) parses this template and injects a
|
|
6
|
+
<Graph ClassName="AcuBootstrapPlugin" FileType="NewFile" Source="..."/>
|
|
7
|
+
item carrying bootstrap_plugin.cs - the CustomizationPlugin that enables
|
|
8
|
+
features on publish (CS100000 is un-writable through the contract API;
|
|
9
|
+
T3 verdict). Item shape verified vs 26.101.0225: Customization.CstCodeFile
|
|
10
|
+
Tag=Graph, source rides in the Source ATTRIBUTE (XML-escaped), not CDATA.
|
|
11
|
+
|
|
12
|
+
Root element shape verified vs the training packages shipped on the live
|
|
13
|
+
box (HelpAndTraining/T*/PhoneRepairShop.zip, same 26.101.0225 build):
|
|
14
|
+
project.xml's root is <Customization level description product-version>;
|
|
15
|
+
the project NAME comes from the import call, not the XML.
|
|
16
|
+
|
|
17
|
+
The <EntityEndpoint> item (T12, verified vs 26.101.0225 by import
|
|
18
|
+
round-trip 2026-07-08): the <Endpoint> child is the XmlSerializer form of
|
|
19
|
+
PX.Api.ContractBased.Common.Model.Endpoint - namespace
|
|
20
|
+
http://www.acumatica.com/entity/maintenance/5.31 (the earlier
|
|
21
|
+
entity/data-model guess is what the importer rejected as "Unknown root
|
|
22
|
+
node"), systemContractVersion 4 (SystemContracts.V4, the build's only
|
|
23
|
+
IsCurrent implementation). No .endpoint file is involved - the
|
|
24
|
+
PX.Api.ContractBased.Common.dll "*.endpoint" globs belong to the
|
|
25
|
+
source-control folder layout, not the package zip. The server's own
|
|
26
|
+
getProject re-serialization carries no attributes on <EntityEndpoint>;
|
|
27
|
+
identity comes from the child's name/version.
|
|
28
|
+
|
|
29
|
+
Entity payload (screen->graph->view->DAC chain read off the live box):
|
|
30
|
+
- Company CS101500 -> OrganizationMaint, PrimaryView BAccount
|
|
31
|
+
(DAC PX.Objects.CS.DAC.OrganizationBAccount); the old
|
|
32
|
+
draft's Organization view / OrganizationCD do not exist on
|
|
33
|
+
this build. Mappings follow the screen's OWN bindings
|
|
34
|
+
(CS101500.aspx read off the live box, T13) - a DAC prop
|
|
35
|
+
existing on the primary view is not enough, writes only
|
|
36
|
+
land through the view the screen edits:
|
|
37
|
+
- AcctCD/AcctName -> BAccount (primary view);
|
|
38
|
+
- OrganizationType/BaseCuryID -> OrganizationView
|
|
39
|
+
(PX.Objects.GL.DAC.Organization; the BAccount projections
|
|
40
|
+
of these echo the PUT but insert an empty GL row - 422
|
|
41
|
+
"BaseCuryID cannot be empty");
|
|
42
|
+
- CountryID -> AddressDummy (PX.Objects.CR.Address, the
|
|
43
|
+
Main Address binding; the graph inserts the Address row
|
|
44
|
+
on first save and its CountryID is required - 422).
|
|
45
|
+
- CreditTerms CS206500 -> TermsMaint, PrimaryView TermsDef
|
|
46
|
+
(DAC PX.Objects.CS.Terms); DayDue00 is Nullable<Int16>
|
|
47
|
+
-> ShortValue, DiscPercent Nullable<Decimal> -> DecimalValue.
|
|
48
|
+
Contract field names = DAC property names verbatim (no rename layer).
|
|
49
|
+
Features (CS100000) stays OUT of the endpoint - writes through any
|
|
50
|
+
contract endpoint do not persist (T3 verdict); the plugin owns features.
|
|
51
|
+
-->
|
|
52
|
+
<Customization level="" description="acu bootstrap: CustomizationPlugin enables features on publish; Bootstrap endpoint exposes company + credit terms" product-version="26.101">
|
|
53
|
+
<EntityEndpoint>
|
|
54
|
+
<Endpoint xmlns="http://www.acumatica.com/entity/maintenance/5.31" name="Bootstrap" version="1.0.0" systemContractVersion="4">
|
|
55
|
+
<TopLevelEntity name="Company" screen="CS101500">
|
|
56
|
+
<Fields>
|
|
57
|
+
<Field name="AcctCD" type="StringValue" />
|
|
58
|
+
<Field name="AcctName" type="StringValue" />
|
|
59
|
+
<Field name="OrganizationType" type="StringValue" />
|
|
60
|
+
<Field name="BaseCuryID" type="StringValue" />
|
|
61
|
+
<Field name="CountryID" type="StringValue" />
|
|
62
|
+
</Fields>
|
|
63
|
+
<Mappings>
|
|
64
|
+
<Mapping field="AcctCD">
|
|
65
|
+
<To object="BAccount" field="AcctCD" />
|
|
66
|
+
</Mapping>
|
|
67
|
+
<Mapping field="AcctName">
|
|
68
|
+
<To object="BAccount" field="AcctName" />
|
|
69
|
+
</Mapping>
|
|
70
|
+
<Mapping field="OrganizationType">
|
|
71
|
+
<To object="OrganizationView" field="OrganizationType" />
|
|
72
|
+
</Mapping>
|
|
73
|
+
<Mapping field="BaseCuryID">
|
|
74
|
+
<To object="OrganizationView" field="BaseCuryID" />
|
|
75
|
+
</Mapping>
|
|
76
|
+
<Mapping field="CountryID">
|
|
77
|
+
<To object="AddressDummy" field="CountryID" />
|
|
78
|
+
</Mapping>
|
|
79
|
+
</Mappings>
|
|
80
|
+
</TopLevelEntity>
|
|
81
|
+
<TopLevelEntity name="CreditTerms" screen="CS206500">
|
|
82
|
+
<Fields>
|
|
83
|
+
<Field name="TermsID" type="StringValue" />
|
|
84
|
+
<Field name="Descr" type="StringValue" />
|
|
85
|
+
<Field name="VisibleTo" type="StringValue" />
|
|
86
|
+
<Field name="DueType" type="StringValue" />
|
|
87
|
+
<Field name="DayDue00" type="ShortValue" />
|
|
88
|
+
<Field name="DiscType" type="StringValue" />
|
|
89
|
+
<Field name="DiscPercent" type="DecimalValue" />
|
|
90
|
+
</Fields>
|
|
91
|
+
<Mappings>
|
|
92
|
+
<Mapping field="TermsID">
|
|
93
|
+
<To object="TermsDef" field="TermsID" />
|
|
94
|
+
</Mapping>
|
|
95
|
+
<Mapping field="Descr">
|
|
96
|
+
<To object="TermsDef" field="Descr" />
|
|
97
|
+
</Mapping>
|
|
98
|
+
<Mapping field="VisibleTo">
|
|
99
|
+
<To object="TermsDef" field="VisibleTo" />
|
|
100
|
+
</Mapping>
|
|
101
|
+
<Mapping field="DueType">
|
|
102
|
+
<To object="TermsDef" field="DueType" />
|
|
103
|
+
</Mapping>
|
|
104
|
+
<Mapping field="DayDue00">
|
|
105
|
+
<To object="TermsDef" field="DayDue00" />
|
|
106
|
+
</Mapping>
|
|
107
|
+
<Mapping field="DiscType">
|
|
108
|
+
<To object="TermsDef" field="DiscType" />
|
|
109
|
+
</Mapping>
|
|
110
|
+
<Mapping field="DiscPercent">
|
|
111
|
+
<To object="TermsDef" field="DiscPercent" />
|
|
112
|
+
</Mapping>
|
|
113
|
+
</Mappings>
|
|
114
|
+
</TopLevelEntity>
|
|
115
|
+
</Endpoint>
|
|
116
|
+
</EntityEndpoint>
|
|
117
|
+
</Customization>
|
acumatica_cli/cli.py
ADDED
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
"""acu - Acumatica configuration as code.
|
|
2
|
+
|
|
3
|
+
acu provision --id N --login T one command: create -> bootstrap -> apply -> diff
|
|
4
|
+
acu tenant list|create|delete tenant CRUD (ac.exe over SSH)
|
|
5
|
+
acu apply [--dry-run] FILES... seed baseline YAML via the REST API
|
|
6
|
+
acu diff FILES... drift check: baseline vs live tenant
|
|
7
|
+
acu schema [--out DIR] dump the endpoint's OpenAPI schema
|
|
8
|
+
acu config show print the resolved target instance
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import click
|
|
15
|
+
import httpx
|
|
16
|
+
import yaml
|
|
17
|
+
|
|
18
|
+
from . import bootstrap, firstlogin, output, seed
|
|
19
|
+
from .client import AcumaticaClient
|
|
20
|
+
from .config import Instance, data_root, load_instance
|
|
21
|
+
from .tenant import TenantManager
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@click.group(help=__doc__)
|
|
25
|
+
@click.version_option(package_name="acumatica-cli")
|
|
26
|
+
@click.option(
|
|
27
|
+
"-t", "--tenant", default=None, help="Override the tenant API sessions sign in to"
|
|
28
|
+
)
|
|
29
|
+
@click.option(
|
|
30
|
+
"--host",
|
|
31
|
+
default=None,
|
|
32
|
+
help="Override the acu.yaml host (base_url/ssh re-derive from it)",
|
|
33
|
+
)
|
|
34
|
+
@click.pass_context
|
|
35
|
+
def cli(ctx: click.Context, tenant: str | None, host: str | None) -> None:
|
|
36
|
+
"""Resolve the target instance and stash it in the Click context."""
|
|
37
|
+
inst = load_instance(host=host)
|
|
38
|
+
if tenant is not None:
|
|
39
|
+
inst = inst.model_copy(update={"tenant": tenant})
|
|
40
|
+
ctx.obj = inst
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def main() -> None:
|
|
44
|
+
"""Entry point: run the CLI, mapping expected failures to one-line errors.
|
|
45
|
+
|
|
46
|
+
RuntimeError (SSH/ac.exe, REST, first-login) and httpx transport errors
|
|
47
|
+
print `x message` and exit 1; ACU_DEBUG=1 re-raises for the traceback.
|
|
48
|
+
"""
|
|
49
|
+
try:
|
|
50
|
+
cli()
|
|
51
|
+
except (RuntimeError, httpx.HTTPError) as exc:
|
|
52
|
+
if os.environ.get("ACU_DEBUG"):
|
|
53
|
+
raise
|
|
54
|
+
output.error(str(exc))
|
|
55
|
+
raise SystemExit(1) from exc
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@cli.group("tenant")
|
|
59
|
+
def tenant_group() -> None:
|
|
60
|
+
"""Tenant CRUD on the instance (ac.exe CompanyConfig over SSH)."""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@tenant_group.command("list")
|
|
64
|
+
@click.pass_obj
|
|
65
|
+
def tenant_list(inst: Instance) -> None:
|
|
66
|
+
"""List tenants: CompanyID, sign-in name, internal CD, type."""
|
|
67
|
+
tenants = TenantManager(inst).list()
|
|
68
|
+
output.table(
|
|
69
|
+
f"Tenants on {inst.host}",
|
|
70
|
+
("ID", "Login", "CD", "Type"),
|
|
71
|
+
(
|
|
72
|
+
(str(t.company_id), t.login_name, t.company_cd, t.company_type)
|
|
73
|
+
for t in tenants
|
|
74
|
+
),
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@tenant_group.command("create")
|
|
79
|
+
@click.option(
|
|
80
|
+
"--id",
|
|
81
|
+
"company_id",
|
|
82
|
+
type=int,
|
|
83
|
+
required=True,
|
|
84
|
+
help="CompanyID (first free is usually 3)",
|
|
85
|
+
)
|
|
86
|
+
@click.option(
|
|
87
|
+
"--login",
|
|
88
|
+
"login_name",
|
|
89
|
+
required=True,
|
|
90
|
+
help="Acumatica tenant name as shown on the sign-in page",
|
|
91
|
+
)
|
|
92
|
+
@click.option(
|
|
93
|
+
"--type",
|
|
94
|
+
"company_type",
|
|
95
|
+
default="",
|
|
96
|
+
help="Inserted data set: '' = clean, SalesDemo = demo",
|
|
97
|
+
)
|
|
98
|
+
@click.option("--parent", "parent_id", type=int, default=1, show_default=True)
|
|
99
|
+
@click.option("--hidden", is_flag=True, help="Do not show on the sign-in page")
|
|
100
|
+
@click.option(
|
|
101
|
+
"--no-init",
|
|
102
|
+
is_flag=True,
|
|
103
|
+
help="Skip app-pool recycle + first-login password change",
|
|
104
|
+
)
|
|
105
|
+
@click.pass_obj
|
|
106
|
+
def tenant_create(
|
|
107
|
+
inst: Instance,
|
|
108
|
+
company_id: int,
|
|
109
|
+
login_name: str,
|
|
110
|
+
company_type: str,
|
|
111
|
+
parent_id: int,
|
|
112
|
+
hidden: bool,
|
|
113
|
+
no_init: bool,
|
|
114
|
+
) -> None:
|
|
115
|
+
"""Create a tenant and make it automation-ready.
|
|
116
|
+
|
|
117
|
+
Chains the verified steps (docs/ac-exe.md, docs/rest-api.md): ac.exe
|
|
118
|
+
CompanyConfig with the admin password preset, an app-pool recycle so the
|
|
119
|
+
running app sees the tenant, then a REST login check (with the sign-in
|
|
120
|
+
screen's first-login password-change flow as fallback).
|
|
121
|
+
"""
|
|
122
|
+
mgr = TenantManager(inst)
|
|
123
|
+
with output.step(f"creating tenant {company_id} ({login_name}) on {inst.host}"):
|
|
124
|
+
raw = mgr.create(company_id, login_name, parent_id, not hidden, company_type)
|
|
125
|
+
output.data(raw.splitlines()[-1] if raw.strip() else "created")
|
|
126
|
+
if no_init:
|
|
127
|
+
output.warn("skipping init: tenant is invisible until an app-pool recycle")
|
|
128
|
+
return
|
|
129
|
+
_init_tenant(inst, mgr, login_name)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _init_tenant(inst: Instance, mgr: TenantManager, login_name: str) -> None:
|
|
133
|
+
"""Make a freshly created tenant automation-ready (V5 recycle + login check)."""
|
|
134
|
+
with output.step("recycling app pool (tenant map loads at app start)"):
|
|
135
|
+
mgr.recycle_app_pool()
|
|
136
|
+
with output.step("verifying REST login (screen-flow password change as fallback)"):
|
|
137
|
+
result = firstlogin.initialize_admin_password(inst, tenant=login_name)
|
|
138
|
+
output.success(f"admin {result}; tenant {login_name} is ready")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@tenant_group.command("delete")
|
|
142
|
+
@click.option("--id", "company_id", type=int, required=True)
|
|
143
|
+
@click.confirmation_option(prompt="Delete this tenant and all its data?")
|
|
144
|
+
@click.pass_obj
|
|
145
|
+
def tenant_delete(inst: Instance, company_id: int) -> None:
|
|
146
|
+
"""Delete the tenant and all its data, then recycle the app pool."""
|
|
147
|
+
mgr = TenantManager(inst)
|
|
148
|
+
raw = mgr.delete(company_id)
|
|
149
|
+
output.data(raw.splitlines()[-1] if raw.strip() else "done")
|
|
150
|
+
with output.step("recycling app pool (drops the tenant from the running app)"):
|
|
151
|
+
mgr.recycle_app_pool()
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@cli.group("config")
|
|
155
|
+
def config_group() -> None:
|
|
156
|
+
"""Local read-only config inspection (never talks to a live instance)."""
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@config_group.command("show")
|
|
160
|
+
@click.pass_obj
|
|
161
|
+
def config_show(inst: Instance) -> None:
|
|
162
|
+
"""Print the fully resolved instance as a complete acu.yaml document.
|
|
163
|
+
|
|
164
|
+
Resolves through the same load_instance path every live command uses,
|
|
165
|
+
so the printed values are exactly what a live command would trust.
|
|
166
|
+
Credentials never appear - redirect to a file and edit: the output
|
|
167
|
+
loads back through load_instance unchanged.
|
|
168
|
+
"""
|
|
169
|
+
doc = inst.model_dump(exclude={"username", "password"})
|
|
170
|
+
output.data("# resolved by `acu config show` - a complete acu.yaml")
|
|
171
|
+
output.data(
|
|
172
|
+
"# credentials come from .env (ACU_USER / ACU_PASSWORD), never from here"
|
|
173
|
+
)
|
|
174
|
+
for line in yaml.safe_dump(doc, sort_keys=False).splitlines():
|
|
175
|
+
output.data(line)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def expand_files(files: tuple[Path, ...]) -> list[Path]:
|
|
179
|
+
"""Expand directory arguments into their *.yaml files, sorted."""
|
|
180
|
+
paths: list[Path] = []
|
|
181
|
+
for path in files:
|
|
182
|
+
if path.is_dir():
|
|
183
|
+
found = sorted(path.glob("*.yaml"))
|
|
184
|
+
if not found:
|
|
185
|
+
raise SystemExit(f"{path}: no *.yaml files in directory")
|
|
186
|
+
paths += found
|
|
187
|
+
else:
|
|
188
|
+
paths.append(path)
|
|
189
|
+
return paths
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@cli.command("provision")
|
|
193
|
+
@click.option(
|
|
194
|
+
"--id",
|
|
195
|
+
"company_id",
|
|
196
|
+
type=int,
|
|
197
|
+
required=True,
|
|
198
|
+
help="CompanyID (first free is usually 3)",
|
|
199
|
+
)
|
|
200
|
+
@click.option(
|
|
201
|
+
"--login",
|
|
202
|
+
"login_name",
|
|
203
|
+
required=True,
|
|
204
|
+
help="Acumatica tenant name as shown on the sign-in page",
|
|
205
|
+
)
|
|
206
|
+
@click.option(
|
|
207
|
+
"--type",
|
|
208
|
+
"company_type",
|
|
209
|
+
default="",
|
|
210
|
+
help="Inserted data set: '' = clean, SalesDemo = demo",
|
|
211
|
+
)
|
|
212
|
+
@click.option("--parent", "parent_id", type=int, default=1, show_default=True)
|
|
213
|
+
@click.pass_obj
|
|
214
|
+
def provision_cmd(
|
|
215
|
+
inst: Instance,
|
|
216
|
+
company_id: int,
|
|
217
|
+
login_name: str,
|
|
218
|
+
company_type: str,
|
|
219
|
+
parent_id: int,
|
|
220
|
+
) -> None:
|
|
221
|
+
"""One command to a configured tenant: create -> bootstrap -> apply -> diff.
|
|
222
|
+
|
|
223
|
+
Chains the verified pipeline (docs/rest-api.md) idempotently: tenant
|
|
224
|
+
create over SSH (skipped when the login name already exists), bootstrap
|
|
225
|
+
package publish (skipped when already published), bootstrap/ YAML through
|
|
226
|
+
the custom endpoint, baseline/ through the Default endpoint, then a drift
|
|
227
|
+
check over everything applied - exit 2 on drift.
|
|
228
|
+
"""
|
|
229
|
+
root = data_root()
|
|
230
|
+
baseline_dir = root / "baseline"
|
|
231
|
+
if not baseline_dir.is_dir():
|
|
232
|
+
raise SystemExit(f"{baseline_dir}: not a directory - nothing to provision")
|
|
233
|
+
seed_dirs = [d for d in (root / "bootstrap", baseline_dir) if d.is_dir()]
|
|
234
|
+
# every session below signs in to the provisioned tenant, never a default
|
|
235
|
+
inst = inst.model_copy(update={"tenant": login_name})
|
|
236
|
+
|
|
237
|
+
mgr = TenantManager(inst)
|
|
238
|
+
if any(t.login_name == login_name for t in mgr.list()):
|
|
239
|
+
output.info(f"tenant {login_name} exists on {inst.host} - skipping create")
|
|
240
|
+
else:
|
|
241
|
+
with output.step(f"creating tenant {company_id} ({login_name}) on {inst.host}"):
|
|
242
|
+
raw = mgr.create(company_id, login_name, parent_id, True, company_type)
|
|
243
|
+
output.data(raw.splitlines()[-1] if raw.strip() else "created")
|
|
244
|
+
_init_tenant(inst, mgr, login_name)
|
|
245
|
+
|
|
246
|
+
with (
|
|
247
|
+
output.step(f"publishing {bootstrap.PACKAGE_NAME} to {inst.tenant}"),
|
|
248
|
+
AcumaticaClient(inst) as client,
|
|
249
|
+
):
|
|
250
|
+
result = bootstrap.publish(client)
|
|
251
|
+
output.success(f"{bootstrap.PACKAGE_NAME} {result}")
|
|
252
|
+
# unconditional: the publish restarts the site BEFORE its DB transaction
|
|
253
|
+
# commits, so the restarted domain caches the feature slot pre-plugin
|
|
254
|
+
# (verified live: gated screens stay 403 until one more recycle); on the
|
|
255
|
+
# skip path a recycle is the cheap way to make a resumed run sound too
|
|
256
|
+
with output.step("recycling app pool (feature set loads at app start)"):
|
|
257
|
+
mgr.recycle_app_pool()
|
|
258
|
+
with output.step("waiting for the site to come back"):
|
|
259
|
+
firstlogin.initialize_admin_password(inst, tenant=login_name)
|
|
260
|
+
|
|
261
|
+
# fresh session: publishing restarts the app domain, so don't trust the
|
|
262
|
+
# cookie that watched it happen
|
|
263
|
+
drifts: list[str] = []
|
|
264
|
+
seed_paths = expand_files(tuple(seed_dirs))
|
|
265
|
+
with AcumaticaClient(inst) as client:
|
|
266
|
+
for path in seed_paths:
|
|
267
|
+
baseline = seed.load_baseline(path)
|
|
268
|
+
output.data(f"{path} -> {inst.host}/{inst.tenant} ({baseline.entity})")
|
|
269
|
+
n = seed.apply(client, baseline)
|
|
270
|
+
output.data(f" {n} record(s)")
|
|
271
|
+
# diff everything applied, bootstrap YAML included - a PUT that
|
|
272
|
+
# answers 200 can persist nothing (T3), the drift check is the proof
|
|
273
|
+
for path in seed_paths:
|
|
274
|
+
drifts += seed.diff(client, seed.load_baseline(path))
|
|
275
|
+
_exit_on_drift(inst, drifts, len(seed_paths))
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
@cli.command("apply")
|
|
279
|
+
@click.argument(
|
|
280
|
+
"files", nargs=-1, required=True, type=click.Path(exists=True, path_type=Path)
|
|
281
|
+
)
|
|
282
|
+
@click.option("--dry-run", is_flag=True, help="Show what would be PUT without writing")
|
|
283
|
+
@click.pass_obj
|
|
284
|
+
def apply_cmd(inst: Instance, files: tuple[Path, ...], dry_run: bool) -> None:
|
|
285
|
+
"""Seed baseline YAML into the tenant (idempotent PUT upserts).
|
|
286
|
+
|
|
287
|
+
FILES are baseline YAML files or directories containing them.
|
|
288
|
+
"""
|
|
289
|
+
with AcumaticaClient(inst) as client:
|
|
290
|
+
for path in expand_files(files):
|
|
291
|
+
baseline = seed.load_baseline(path)
|
|
292
|
+
output.data(f"{path} -> {inst.host}/{inst.tenant} ({baseline.entity})")
|
|
293
|
+
n = seed.apply(client, baseline, dry_run=dry_run)
|
|
294
|
+
output.data(f" {n} record(s){' (dry run)' if dry_run else ''}")
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
@cli.command("schema")
|
|
298
|
+
@click.option(
|
|
299
|
+
"--out",
|
|
300
|
+
"out_dir",
|
|
301
|
+
type=click.Path(file_okay=False, path_type=Path),
|
|
302
|
+
default=None,
|
|
303
|
+
help="Output directory (default: <data repo>/schemas)",
|
|
304
|
+
)
|
|
305
|
+
@click.pass_obj
|
|
306
|
+
def schema_cmd(inst: Instance, out_dir: Path | None) -> None:
|
|
307
|
+
"""Dump the endpoint's OpenAPI schema (swagger.json) into schemas/.
|
|
308
|
+
|
|
309
|
+
The schema is the authoritative field-level reference for the exact
|
|
310
|
+
build - regenerate rather than version (the file is ~3 MB).
|
|
311
|
+
"""
|
|
312
|
+
if out_dir is None:
|
|
313
|
+
out_dir = data_root() / "schemas"
|
|
314
|
+
out_file = out_dir / f"swagger-{inst.endpoint.replace('/', '-')}.json"
|
|
315
|
+
with (
|
|
316
|
+
output.step(f"dumping OpenAPI schema from {inst.host} ({inst.endpoint})"),
|
|
317
|
+
AcumaticaClient(inst) as client,
|
|
318
|
+
):
|
|
319
|
+
raw = client.swagger()
|
|
320
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
321
|
+
out_file.write_bytes(raw)
|
|
322
|
+
output.data(f"{out_file} ({len(raw)} bytes)")
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
@cli.command("diff")
|
|
326
|
+
@click.argument(
|
|
327
|
+
"files", nargs=-1, required=True, type=click.Path(exists=True, path_type=Path)
|
|
328
|
+
)
|
|
329
|
+
@click.pass_obj
|
|
330
|
+
def diff_cmd(inst: Instance, files: tuple[Path, ...]) -> None:
|
|
331
|
+
"""Compare baseline YAML against the live tenant; exit 2 on drift.
|
|
332
|
+
|
|
333
|
+
FILES are baseline YAML files or directories containing them.
|
|
334
|
+
"""
|
|
335
|
+
paths = expand_files(files)
|
|
336
|
+
drifts: list[str] = []
|
|
337
|
+
with AcumaticaClient(inst) as client:
|
|
338
|
+
for path in paths:
|
|
339
|
+
baseline = seed.load_baseline(path)
|
|
340
|
+
drifts += seed.diff(client, baseline)
|
|
341
|
+
_exit_on_drift(inst, drifts, len(paths))
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _exit_on_drift(inst: Instance, drifts: list[str], files: int) -> None:
|
|
345
|
+
"""Report drift lines and exit 2 (the load-bearing diff contract, V9)."""
|
|
346
|
+
if drifts:
|
|
347
|
+
output.error(f"DRIFT on {inst.host}/{inst.tenant}:")
|
|
348
|
+
for line in drifts:
|
|
349
|
+
output.data(f" {line}")
|
|
350
|
+
raise SystemExit(2)
|
|
351
|
+
output.success(f"no drift on {inst.host}/{inst.tenant} ({files} file(s))")
|