anolis-workbench 0.1.0__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.
- anolis_workbench/__init__.py +1 -0
- anolis_workbench/catalog/providers.json +96 -0
- anolis_workbench/cli/__init__.py +1 -0
- anolis_workbench/cli/main.py +13 -0
- anolis_workbench/cli/package_cli.py +46 -0
- anolis_workbench/cli/validate_cli.py +44 -0
- anolis_workbench/core/__init__.py +1 -0
- anolis_workbench/core/exporter.py +393 -0
- anolis_workbench/core/launcher.py +732 -0
- anolis_workbench/core/package_validator.py +396 -0
- anolis_workbench/core/paths.py +72 -0
- anolis_workbench/core/projects.py +271 -0
- anolis_workbench/core/renderer.py +278 -0
- anolis_workbench/core/validator.py +143 -0
- anolis_workbench/frontend/css/style.css +1167 -0
- anolis_workbench/frontend/index.html +252 -0
- anolis_workbench/schema/system.schema.json +136 -0
- anolis_workbench/schemas/machine-profile.schema.json +182 -0
- anolis_workbench/schemas/runtime-config.schema.json +732 -0
- anolis_workbench/server/__init__.py +1 -0
- anolis_workbench/server/app.py +256 -0
- anolis_workbench/server/routes/__init__.py +1 -0
- anolis_workbench/server/routes/commission.py +136 -0
- anolis_workbench/server/routes/compose.py +177 -0
- anolis_workbench/server/routes/operate.py +110 -0
- anolis_workbench/templates/bioreactor-manual/system.json +101 -0
- anolis_workbench/templates/mixed-bus-mock/system.json +93 -0
- anolis_workbench/templates/sim-quickstart/system.json +58 -0
- anolis_workbench-0.1.0.dist-info/METADATA +85 -0
- anolis_workbench-0.1.0.dist-info/RECORD +33 -0
- anolis_workbench-0.1.0.dist-info/WHEEL +4 -0
- anolis_workbench-0.1.0.dist-info/entry_points.txt +4 -0
- anolis_workbench-0.1.0.dist-info/licenses/LICENSE +661 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Anolis Workbench package."""
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema_version": 1,
|
|
3
|
+
"providers": [
|
|
4
|
+
{
|
|
5
|
+
"kind": "sim",
|
|
6
|
+
"display_name": "Provider-Sim",
|
|
7
|
+
"description": "Simulated devices for development and testing",
|
|
8
|
+
"repo": "anolis-provider-sim",
|
|
9
|
+
"build_docs": "docs/",
|
|
10
|
+
"check_config_flag": "--check-config",
|
|
11
|
+
"topology_fields": [
|
|
12
|
+
{ "key": "provider_name", "type": "string", "required": false, "default": "sim0" },
|
|
13
|
+
{ "key": "startup_policy", "type": "enum", "values": ["strict", "degraded"], "default": "degraded" },
|
|
14
|
+
{ "key": "simulation_mode", "type": "enum", "values": ["inert", "non_interacting"], "default": "non_interacting" },
|
|
15
|
+
{ "key": "tick_rate_hz", "type": "float", "min": 1, "max": 100, "default": 10.0 }
|
|
16
|
+
],
|
|
17
|
+
"device_types": [
|
|
18
|
+
{ "type": "tempctl", "display": "Temperature Controller", "fields": [
|
|
19
|
+
{ "key": "initial_temp", "type": "float", "default": 25.0 }
|
|
20
|
+
]},
|
|
21
|
+
{ "type": "motorctl", "display": "Motor Controller", "fields": [
|
|
22
|
+
{ "key": "max_speed", "type": "float", "default": 3000.0 }
|
|
23
|
+
]},
|
|
24
|
+
{ "type": "relayio", "display": "Relay I/O", "fields": [] },
|
|
25
|
+
{ "type": "analogsensor","display": "Analog Sensor", "fields": [] }
|
|
26
|
+
],
|
|
27
|
+
"readonly_devices": [
|
|
28
|
+
{
|
|
29
|
+
"type": "chaos_control",
|
|
30
|
+
"display": "Fault Injection (chaos_control)",
|
|
31
|
+
"tooltip": "Fault injection device — always included by the provider, not configurable here."
|
|
32
|
+
}
|
|
33
|
+
],
|
|
34
|
+
"path_fields": [
|
|
35
|
+
{ "key": "executable", "type": "path", "required": true }
|
|
36
|
+
],
|
|
37
|
+
"composer_notes": {
|
|
38
|
+
"sim_mode": "not_supported_v1",
|
|
39
|
+
"sim_mode_rationale": "The 'sim' simulation_mode requires an external physics engine (physics_config_path, ambient_temp_c, ambient_signal_path). These are not part of the v1 composer form. Only 'non_interacting' and 'inert' modes are exposed in v1."
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
"kind": "bread",
|
|
44
|
+
"display_name": "Provider-Bread",
|
|
45
|
+
"description": "BREAD-over-CRUMBS hardware devices (RLHT, DCMT)",
|
|
46
|
+
"repo": "anolis-provider-bread",
|
|
47
|
+
"build_docs": "docs/build.md",
|
|
48
|
+
"check_config_flag": "--check-config",
|
|
49
|
+
"topology_fields": [
|
|
50
|
+
{ "key": "provider_name", "type": "string", "required": false, "default": "bread0" },
|
|
51
|
+
{ "key": "require_live_session","type": "bool", "default": false },
|
|
52
|
+
{ "key": "query_delay_us", "type": "int", "default": 10000 },
|
|
53
|
+
{ "key": "timeout_ms", "type": "int", "default": 100 },
|
|
54
|
+
{ "key": "retry_count", "type": "int", "default": 2 }
|
|
55
|
+
],
|
|
56
|
+
"device_types": [
|
|
57
|
+
{ "type": "rlht", "display": "RLHT Heater", "fields": [] },
|
|
58
|
+
{ "type": "dcmt", "display": "DCMT Motor", "fields": [] }
|
|
59
|
+
],
|
|
60
|
+
"path_fields": [
|
|
61
|
+
{ "key": "executable", "type": "path", "required": true },
|
|
62
|
+
{ "key": "bus_path", "type": "string", "required": true, "placeholder": "/dev/i2c-1 or mock://name" }
|
|
63
|
+
],
|
|
64
|
+
"composer_notes": {
|
|
65
|
+
"scan_mode": "not_supported_v1",
|
|
66
|
+
"scan_mode_rationale": "Scan discovery output can vary across runs depending on bus state at startup. The composer requires a stable, committed device list at save-time. Manual address entry is used in v1. A future version may add a scan-then-import flow once scan output is proven stable."
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
"kind": "ezo",
|
|
71
|
+
"display_name": "Provider-EZO",
|
|
72
|
+
"description": "Atlas Scientific EZO I2C sensors (pH, DO, EC, ORP, RTD, HUM)",
|
|
73
|
+
"repo": "anolis-provider-ezo",
|
|
74
|
+
"build_docs": "docs/",
|
|
75
|
+
"check_config_flag": "--check-config",
|
|
76
|
+
"topology_fields": [
|
|
77
|
+
{ "key": "provider_name", "type": "string", "required": false, "default": "ezo0" },
|
|
78
|
+
{ "key": "query_delay_us", "type": "int", "default": 300000 },
|
|
79
|
+
{ "key": "timeout_ms", "type": "int", "default": 300 },
|
|
80
|
+
{ "key": "retry_count", "type": "int", "default": 2 }
|
|
81
|
+
],
|
|
82
|
+
"device_types": [
|
|
83
|
+
{ "type": "ph", "display": "pH Sensor", "fields": [] },
|
|
84
|
+
{ "type": "do", "display": "Dissolved Oxygen", "fields": [] },
|
|
85
|
+
{ "type": "ec", "display": "Conductivity", "fields": [] },
|
|
86
|
+
{ "type": "orp", "display": "ORP", "fields": [] },
|
|
87
|
+
{ "type": "rtd", "display": "Temperature (RTD)", "fields": [] },
|
|
88
|
+
{ "type": "hum", "display": "Humidity", "fields": [] }
|
|
89
|
+
],
|
|
90
|
+
"path_fields": [
|
|
91
|
+
{ "key": "executable", "type": "path", "required": true },
|
|
92
|
+
{ "key": "bus_path", "type": "string", "required": true, "placeholder": "/dev/i2c-1 or mock://name" }
|
|
93
|
+
]
|
|
94
|
+
}
|
|
95
|
+
]
|
|
96
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""CLI entry points for Anolis Workbench."""
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""CLI to build deterministic commissioning handoff packages."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import pathlib
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
from anolis_workbench.core import exporter
|
|
10
|
+
from anolis_workbench.core import paths as paths_module
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _parse_args() -> argparse.Namespace:
|
|
14
|
+
parser = argparse.ArgumentParser(description="Build an Anolis handoff package (.anpkg) from a project.")
|
|
15
|
+
parser.add_argument("project_name", help="Project name under the configured systems root.")
|
|
16
|
+
parser.add_argument(
|
|
17
|
+
"output",
|
|
18
|
+
nargs="?",
|
|
19
|
+
help="Output file path (default: ./<project_name>.anpkg).",
|
|
20
|
+
)
|
|
21
|
+
return parser.parse_args()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def main() -> int:
|
|
25
|
+
args = _parse_args()
|
|
26
|
+
project_name = str(args.project_name).strip()
|
|
27
|
+
if project_name == "":
|
|
28
|
+
print("ERROR: project_name is required", file=sys.stderr)
|
|
29
|
+
return 2
|
|
30
|
+
|
|
31
|
+
project_dir = paths_module.SYSTEMS_ROOT / project_name
|
|
32
|
+
output = pathlib.Path(args.output).expanduser() if args.output else pathlib.Path(f"{project_name}.anpkg")
|
|
33
|
+
out_path = output.resolve()
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
exporter.build_package(project_dir=project_dir, out_path=out_path)
|
|
37
|
+
except exporter.ExportError as exc:
|
|
38
|
+
print(f"ERROR: {exc}", file=sys.stderr)
|
|
39
|
+
return 1
|
|
40
|
+
|
|
41
|
+
print(out_path)
|
|
42
|
+
return 0
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
if __name__ == "__main__":
|
|
46
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""CLI to validate Anolis handoff package archives/directories."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import pathlib
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
from anolis_workbench.core import package_validator
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _parse_args() -> argparse.Namespace:
|
|
13
|
+
parser = argparse.ArgumentParser(
|
|
14
|
+
description="Validate an Anolis handoff package archive or extracted directory.",
|
|
15
|
+
)
|
|
16
|
+
parser.add_argument(
|
|
17
|
+
"package_path",
|
|
18
|
+
help="Path to .anpkg/.zip archive or extracted package directory.",
|
|
19
|
+
)
|
|
20
|
+
parser.add_argument(
|
|
21
|
+
"--runtime-bin",
|
|
22
|
+
default=None,
|
|
23
|
+
help="Optional runtime binary path for --check-config replay validation.",
|
|
24
|
+
)
|
|
25
|
+
return parser.parse_args()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def main() -> int:
|
|
29
|
+
args = _parse_args()
|
|
30
|
+
package_path = pathlib.Path(args.package_path).expanduser().resolve()
|
|
31
|
+
runtime_bin = pathlib.Path(args.runtime_bin).expanduser().resolve() if args.runtime_bin else None
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
package_validator.validate_package(package_path, runtime_bin=runtime_bin)
|
|
35
|
+
except package_validator.PackageValidationError as exc:
|
|
36
|
+
print(f"Validation failed: {exc}", file=sys.stderr)
|
|
37
|
+
return 1
|
|
38
|
+
|
|
39
|
+
print(f"Package validation passed: {package_path}")
|
|
40
|
+
return 0
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
if __name__ == "__main__":
|
|
44
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Core domain library for Anolis Workbench."""
|
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
"""Deterministic commissioning handoff package exporter.
|
|
2
|
+
|
|
3
|
+
Core export logic with no HTTP/UI dependency. Thin wrappers in server.py and
|
|
4
|
+
tools/package.py should call build_package().
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import pathlib
|
|
12
|
+
import re
|
|
13
|
+
import zipfile
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
from importlib import resources
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
import jsonschema
|
|
19
|
+
import yaml
|
|
20
|
+
|
|
21
|
+
from anolis_workbench.core import paths as paths_module
|
|
22
|
+
from anolis_workbench.core import renderer as renderer_module
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ExportError(RuntimeError):
|
|
26
|
+
"""Raised when export cannot produce a valid package."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
_ZIP_EPOCH = (1980, 1, 1, 0, 0, 0)
|
|
30
|
+
_MACHINE_ID_RE = re.compile(r"[^a-z0-9-]+")
|
|
31
|
+
_MACHINE_PROFILE_SCHEMA_CACHE: "dict[str, Any] | None" = None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def build_package(project_dir: pathlib.Path, out_path: pathlib.Path) -> None:
|
|
35
|
+
"""Build a deterministic .anpkg zip archive from a commissioned project."""
|
|
36
|
+
project_root = project_dir.resolve()
|
|
37
|
+
system_path = project_root / "system.json"
|
|
38
|
+
if not system_path.is_file():
|
|
39
|
+
raise ExportError(f"Project file not found: {system_path}")
|
|
40
|
+
|
|
41
|
+
system = _load_system_json(system_path)
|
|
42
|
+
project_name = project_root.name
|
|
43
|
+
rendered = renderer_module.render(system, project_name)
|
|
44
|
+
runtime_yaml = rendered.get("anolis-runtime.yaml")
|
|
45
|
+
if not isinstance(runtime_yaml, str) or runtime_yaml.strip() == "":
|
|
46
|
+
raise ExportError("Renderer did not produce anolis-runtime.yaml")
|
|
47
|
+
|
|
48
|
+
runtime_payload = yaml.safe_load(runtime_yaml) or {}
|
|
49
|
+
if not isinstance(runtime_payload, dict):
|
|
50
|
+
raise ExportError("Rendered runtime YAML root must be a mapping/object")
|
|
51
|
+
|
|
52
|
+
provider_ids = _rewrite_provider_args(runtime_payload)
|
|
53
|
+
provider_files = _collect_provider_files(rendered, project_root, provider_ids)
|
|
54
|
+
behavior_rel_paths = _rewrite_and_collect_behaviors(
|
|
55
|
+
runtime_payload=runtime_payload,
|
|
56
|
+
project_dir=project_root,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
redaction_applied = _redact_runtime_secrets(runtime_payload)
|
|
60
|
+
runtime_text = yaml.safe_dump(runtime_payload, sort_keys=False)
|
|
61
|
+
|
|
62
|
+
machine_profile = _build_machine_profile(
|
|
63
|
+
system=system,
|
|
64
|
+
project_name=project_name,
|
|
65
|
+
provider_ids=provider_ids,
|
|
66
|
+
behavior_rel_paths=behavior_rel_paths,
|
|
67
|
+
)
|
|
68
|
+
_validate_machine_profile(machine_profile)
|
|
69
|
+
machine_profile_text = yaml.safe_dump(machine_profile, sort_keys=False)
|
|
70
|
+
|
|
71
|
+
exported_at = _deterministic_exported_at(system)
|
|
72
|
+
provenance = {
|
|
73
|
+
"exported_at": exported_at,
|
|
74
|
+
"schema_versions": {
|
|
75
|
+
"machine-profile": 1,
|
|
76
|
+
"runtime-config": 1,
|
|
77
|
+
},
|
|
78
|
+
"package_format_version": 1,
|
|
79
|
+
"source_project": project_name,
|
|
80
|
+
"redaction_policy": {
|
|
81
|
+
"telemetry_influxdb_token_removed": redaction_applied,
|
|
82
|
+
"deploy_time_env_var": "INFLUXDB_TOKEN",
|
|
83
|
+
},
|
|
84
|
+
}
|
|
85
|
+
provenance_text = json.dumps(provenance, indent=2, sort_keys=True) + "\n"
|
|
86
|
+
|
|
87
|
+
files: dict[str, bytes] = {
|
|
88
|
+
"machine-profile.yaml": machine_profile_text.encode("utf-8"),
|
|
89
|
+
"runtime/anolis-runtime.yaml": runtime_text.encode("utf-8"),
|
|
90
|
+
"meta/provenance.json": provenance_text.encode("utf-8"),
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
for rel_path, content in provider_files.items():
|
|
94
|
+
files[rel_path] = content
|
|
95
|
+
|
|
96
|
+
for rel_path, source_path in behavior_rel_paths.items():
|
|
97
|
+
files[rel_path] = source_path.read_bytes()
|
|
98
|
+
|
|
99
|
+
_assert_no_secret_leak(files)
|
|
100
|
+
files["meta/checksums.sha256"] = _checksums_file_bytes(files)
|
|
101
|
+
_write_zip_deterministic(files=files, out_path=out_path.resolve())
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _load_system_json(system_path: pathlib.Path) -> dict[str, Any]:
|
|
105
|
+
try:
|
|
106
|
+
payload = json.loads(system_path.read_text(encoding="utf-8"))
|
|
107
|
+
except (json.JSONDecodeError, OSError) as exc:
|
|
108
|
+
raise ExportError(f"Failed reading {system_path}: {exc}") from exc
|
|
109
|
+
if not isinstance(payload, dict):
|
|
110
|
+
raise ExportError("system.json root must be an object")
|
|
111
|
+
return payload
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _rewrite_provider_args(runtime_payload: dict[str, Any]) -> list[str]:
|
|
115
|
+
providers = runtime_payload.get("providers")
|
|
116
|
+
if not isinstance(providers, list) or not providers:
|
|
117
|
+
raise ExportError("runtime.providers must be a non-empty list")
|
|
118
|
+
|
|
119
|
+
provider_ids: list[str] = []
|
|
120
|
+
seen: set[str] = set()
|
|
121
|
+
for entry in providers:
|
|
122
|
+
if not isinstance(entry, dict):
|
|
123
|
+
raise ExportError("runtime.providers entries must be objects")
|
|
124
|
+
provider_id = entry.get("id")
|
|
125
|
+
if not isinstance(provider_id, str) or provider_id.strip() == "":
|
|
126
|
+
raise ExportError("runtime.providers[].id must be a non-empty string")
|
|
127
|
+
provider_id = provider_id.strip()
|
|
128
|
+
if provider_id in seen:
|
|
129
|
+
raise ExportError(f"Duplicate provider id in runtime.providers: {provider_id}")
|
|
130
|
+
seen.add(provider_id)
|
|
131
|
+
provider_ids.append(provider_id)
|
|
132
|
+
|
|
133
|
+
expected_cfg = f"providers/{provider_id}.yaml"
|
|
134
|
+
raw_args = entry.get("args")
|
|
135
|
+
args = [str(item) for item in raw_args] if isinstance(raw_args, list) else []
|
|
136
|
+
replaced = False
|
|
137
|
+
for idx, token in enumerate(args[:-1]):
|
|
138
|
+
if token == "--config":
|
|
139
|
+
args[idx + 1] = expected_cfg
|
|
140
|
+
replaced = True
|
|
141
|
+
break
|
|
142
|
+
if not replaced:
|
|
143
|
+
args.extend(["--config", expected_cfg])
|
|
144
|
+
entry["args"] = args
|
|
145
|
+
|
|
146
|
+
return provider_ids
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _collect_provider_files(
|
|
150
|
+
rendered: dict[str, str],
|
|
151
|
+
project_dir: pathlib.Path,
|
|
152
|
+
provider_ids: list[str],
|
|
153
|
+
) -> dict[str, bytes]:
|
|
154
|
+
files: dict[str, bytes] = {}
|
|
155
|
+
for provider_id in provider_ids:
|
|
156
|
+
rel = f"providers/{provider_id}.yaml"
|
|
157
|
+
rendered_text = rendered.get(rel)
|
|
158
|
+
if isinstance(rendered_text, str) and rendered_text.strip() != "":
|
|
159
|
+
files[rel] = rendered_text.encode("utf-8")
|
|
160
|
+
continue
|
|
161
|
+
|
|
162
|
+
fallback = project_dir / rel
|
|
163
|
+
if fallback.is_file():
|
|
164
|
+
files[rel] = fallback.read_bytes()
|
|
165
|
+
continue
|
|
166
|
+
|
|
167
|
+
raise ExportError(f"Provider config not found for {provider_id}: expected '{rel}'")
|
|
168
|
+
return files
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _rewrite_and_collect_behaviors(
|
|
172
|
+
*,
|
|
173
|
+
runtime_payload: dict[str, Any],
|
|
174
|
+
project_dir: pathlib.Path,
|
|
175
|
+
) -> dict[str, pathlib.Path]:
|
|
176
|
+
behavior_files: dict[str, pathlib.Path] = {}
|
|
177
|
+
automation = runtime_payload.get("automation")
|
|
178
|
+
if not isinstance(automation, dict):
|
|
179
|
+
return behavior_files
|
|
180
|
+
|
|
181
|
+
behavior_ref = automation.get("behavior_tree") or automation.get("behavior_tree_path")
|
|
182
|
+
if not isinstance(behavior_ref, str) or behavior_ref.strip() == "":
|
|
183
|
+
return behavior_files
|
|
184
|
+
|
|
185
|
+
source = _resolve_behavior_path(behavior_ref.strip(), project_dir)
|
|
186
|
+
rel = f"runtime/behaviors/{source.name}"
|
|
187
|
+
automation["behavior_tree"] = rel
|
|
188
|
+
automation.pop("behavior_tree_path", None)
|
|
189
|
+
behavior_files[rel] = source
|
|
190
|
+
return behavior_files
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _resolve_behavior_path(raw: str, project_dir: pathlib.Path) -> pathlib.Path:
|
|
194
|
+
raw_path = pathlib.Path(raw)
|
|
195
|
+
if raw_path.is_absolute():
|
|
196
|
+
raise ExportError("automation.behavior_tree must be a relative path")
|
|
197
|
+
|
|
198
|
+
data_root = paths_module.DATA_ROOT.resolve()
|
|
199
|
+
candidates = [
|
|
200
|
+
(project_dir / raw_path).resolve(),
|
|
201
|
+
(data_root / raw_path).resolve(),
|
|
202
|
+
]
|
|
203
|
+
for candidate in candidates:
|
|
204
|
+
if candidate.is_file() and (_is_within(candidate, project_dir) or _is_within(candidate, data_root)):
|
|
205
|
+
return candidate
|
|
206
|
+
raise ExportError(f"Behavior tree file not found for automation.behavior_tree='{raw}'")
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _is_within(path: pathlib.Path, root: pathlib.Path) -> bool:
|
|
210
|
+
try:
|
|
211
|
+
path.resolve().relative_to(root.resolve())
|
|
212
|
+
except ValueError:
|
|
213
|
+
return False
|
|
214
|
+
return True
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _redact_runtime_secrets(runtime_payload: dict[str, Any]) -> bool:
|
|
218
|
+
redacted = False
|
|
219
|
+
telemetry = runtime_payload.get("telemetry")
|
|
220
|
+
if not isinstance(telemetry, dict):
|
|
221
|
+
return redacted
|
|
222
|
+
|
|
223
|
+
if "influx_token" in telemetry:
|
|
224
|
+
telemetry.pop("influx_token", None)
|
|
225
|
+
redacted = True
|
|
226
|
+
|
|
227
|
+
influxdb = telemetry.get("influxdb")
|
|
228
|
+
if isinstance(influxdb, dict) and "token" in influxdb:
|
|
229
|
+
influxdb.pop("token", None)
|
|
230
|
+
redacted = True
|
|
231
|
+
|
|
232
|
+
return redacted
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _build_machine_profile(
|
|
236
|
+
*,
|
|
237
|
+
system: dict[str, Any],
|
|
238
|
+
project_name: str,
|
|
239
|
+
provider_ids: list[str],
|
|
240
|
+
behavior_rel_paths: dict[str, pathlib.Path],
|
|
241
|
+
) -> dict[str, Any]:
|
|
242
|
+
meta = system.get("meta")
|
|
243
|
+
display_name = project_name
|
|
244
|
+
if isinstance(meta, dict):
|
|
245
|
+
raw = meta.get("name")
|
|
246
|
+
if isinstance(raw, str) and raw.strip() != "":
|
|
247
|
+
display_name = raw.strip()
|
|
248
|
+
|
|
249
|
+
machine_id = _machine_id_from_name(project_name)
|
|
250
|
+
providers = {provider_id: {"config": f"providers/{provider_id}.yaml"} for provider_id in provider_ids}
|
|
251
|
+
compatibility_providers = {
|
|
252
|
+
provider_id: {"strategy": "local-build", "version": "unspecified"} for provider_id in provider_ids
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
payload: dict[str, Any] = {
|
|
256
|
+
"schema_version": 1,
|
|
257
|
+
"machine_id": machine_id,
|
|
258
|
+
"display_name": display_name,
|
|
259
|
+
"runtime_profiles": {
|
|
260
|
+
"manual": "runtime/anolis-runtime.yaml",
|
|
261
|
+
},
|
|
262
|
+
"providers": providers,
|
|
263
|
+
"validation": {
|
|
264
|
+
"expected_providers": provider_ids,
|
|
265
|
+
},
|
|
266
|
+
"contracts": {
|
|
267
|
+
"runtime_config_baseline": "docs/contracts/runtime-config-baseline.md",
|
|
268
|
+
"runtime_http_baseline": "docs/contracts/runtime-http-baseline.md",
|
|
269
|
+
},
|
|
270
|
+
"compatibility": {
|
|
271
|
+
"runtime": {
|
|
272
|
+
"config_contract": "01-runtime-config",
|
|
273
|
+
"http_contract": "02-runtime-http",
|
|
274
|
+
},
|
|
275
|
+
"providers": compatibility_providers,
|
|
276
|
+
},
|
|
277
|
+
}
|
|
278
|
+
if behavior_rel_paths:
|
|
279
|
+
payload["behaviors"] = sorted(behavior_rel_paths.keys())
|
|
280
|
+
return payload
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _validate_machine_profile(profile: dict[str, Any]) -> None:
|
|
284
|
+
global _MACHINE_PROFILE_SCHEMA_CACHE
|
|
285
|
+
if _MACHINE_PROFILE_SCHEMA_CACHE is None:
|
|
286
|
+
schema_file = resources.files("anolis_workbench").joinpath("schemas").joinpath("machine-profile.schema.json")
|
|
287
|
+
try:
|
|
288
|
+
payload = json.loads(schema_file.read_text(encoding="utf-8"))
|
|
289
|
+
except FileNotFoundError as exc:
|
|
290
|
+
raise ExportError("Bundled machine-profile schema not found in package data") from exc
|
|
291
|
+
if not isinstance(payload, dict):
|
|
292
|
+
raise ExportError("Bundled machine-profile schema root must be an object")
|
|
293
|
+
_MACHINE_PROFILE_SCHEMA_CACHE = payload
|
|
294
|
+
validator = jsonschema.Draft7Validator(_MACHINE_PROFILE_SCHEMA_CACHE)
|
|
295
|
+
errors = sorted(validator.iter_errors(profile), key=lambda e: list(e.path))
|
|
296
|
+
if errors:
|
|
297
|
+
msgs = "; ".join(f"{'.' + '.'.join(str(p) for p in e.path) if e.path else '$'}: {e.message}" for e in errors)
|
|
298
|
+
raise ExportError(f"Generated machine-profile failed schema validation: {msgs}")
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _machine_id_from_name(name: str) -> str:
|
|
302
|
+
lowered = name.strip().lower().replace("_", "-")
|
|
303
|
+
cleaned = _MACHINE_ID_RE.sub("-", lowered)
|
|
304
|
+
cleaned = re.sub(r"-{2,}", "-", cleaned).strip("-")
|
|
305
|
+
if cleaned == "":
|
|
306
|
+
cleaned = "machine"
|
|
307
|
+
if not cleaned[0].isalnum():
|
|
308
|
+
cleaned = f"m-{cleaned}"
|
|
309
|
+
return cleaned
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _deterministic_exported_at(system: dict[str, Any]) -> str:
|
|
313
|
+
meta = system.get("meta")
|
|
314
|
+
if isinstance(meta, dict):
|
|
315
|
+
created = meta.get("created")
|
|
316
|
+
if isinstance(created, str):
|
|
317
|
+
parsed = _normalize_iso_timestamp(created)
|
|
318
|
+
if parsed is not None:
|
|
319
|
+
return parsed
|
|
320
|
+
return "1970-01-01T00:00:00Z"
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _normalize_iso_timestamp(raw: str) -> str | None:
|
|
324
|
+
text = raw.strip()
|
|
325
|
+
if text == "":
|
|
326
|
+
return None
|
|
327
|
+
if text.endswith("Z"):
|
|
328
|
+
text = text[:-1] + "+00:00"
|
|
329
|
+
try:
|
|
330
|
+
parsed = datetime.fromisoformat(text)
|
|
331
|
+
except ValueError:
|
|
332
|
+
return None
|
|
333
|
+
if parsed.tzinfo is None:
|
|
334
|
+
parsed = parsed.replace(tzinfo=timezone.utc)
|
|
335
|
+
else:
|
|
336
|
+
parsed = parsed.astimezone(timezone.utc)
|
|
337
|
+
return parsed.replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _assert_no_secret_leak(files: dict[str, bytes]) -> None:
|
|
341
|
+
for rel_path, content in sorted(files.items()):
|
|
342
|
+
if rel_path.endswith(".xml"):
|
|
343
|
+
continue
|
|
344
|
+
|
|
345
|
+
text = content.decode("utf-8")
|
|
346
|
+
if rel_path.endswith(".yaml") or rel_path.endswith(".yml"):
|
|
347
|
+
payload = yaml.safe_load(text)
|
|
348
|
+
elif rel_path.endswith(".json"):
|
|
349
|
+
payload = json.loads(text)
|
|
350
|
+
else:
|
|
351
|
+
continue
|
|
352
|
+
|
|
353
|
+
for key_path, value in _iter_key_values(payload):
|
|
354
|
+
if "token" in key_path.split(".")[-1].lower():
|
|
355
|
+
if isinstance(value, str) and value.strip() != "":
|
|
356
|
+
raise ExportError(f"Secret-like token value leaked in package file '{rel_path}' at {key_path}")
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _iter_key_values(payload: Any, prefix: str = "$"):
|
|
360
|
+
if isinstance(payload, dict):
|
|
361
|
+
for key, value in payload.items():
|
|
362
|
+
child = f"{prefix}.{key}" if prefix else str(key)
|
|
363
|
+
yield from _iter_key_values(value, child)
|
|
364
|
+
return
|
|
365
|
+
if isinstance(payload, list):
|
|
366
|
+
for idx, value in enumerate(payload):
|
|
367
|
+
child = f"{prefix}[{idx}]"
|
|
368
|
+
yield from _iter_key_values(value, child)
|
|
369
|
+
return
|
|
370
|
+
yield prefix, payload
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _checksums_file_bytes(files: dict[str, bytes]) -> bytes:
|
|
374
|
+
lines: list[str] = []
|
|
375
|
+
for rel_path in sorted(path for path in files if path != "meta/checksums.sha256"):
|
|
376
|
+
digest = hashlib.sha256(files[rel_path]).hexdigest()
|
|
377
|
+
lines.append(f"{digest} {rel_path}")
|
|
378
|
+
return ("\n".join(lines) + "\n").encode("utf-8")
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _write_zip_deterministic(*, files: dict[str, bytes], out_path: pathlib.Path) -> None:
|
|
382
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
383
|
+
tmp_path = out_path.with_suffix(out_path.suffix + ".tmp")
|
|
384
|
+
|
|
385
|
+
with zipfile.ZipFile(tmp_path, mode="w") as archive:
|
|
386
|
+
for rel_path in sorted(files):
|
|
387
|
+
info = zipfile.ZipInfo(filename=rel_path, date_time=_ZIP_EPOCH)
|
|
388
|
+
info.compress_type = zipfile.ZIP_STORED
|
|
389
|
+
info.create_system = 3
|
|
390
|
+
info.external_attr = (0o100644 & 0xFFFF) << 16
|
|
391
|
+
archive.writestr(info, files[rel_path])
|
|
392
|
+
|
|
393
|
+
tmp_path.replace(out_path)
|