sylo-ignition 0.1.0
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.
- package/LICENSE +21 -0
- package/README.md +25 -0
- package/assets/write-allowlist.json +19 -0
- package/extensions/index.ts +349 -0
- package/package.json +36 -0
- package/references/README.md +45 -0
- package/references/gateway-rest-api-8.3.md +119 -0
- package/references/quickref/README.md +12 -0
- package/references/quickref/formats-quickref.md +94 -0
- package/references/quickref/gateway-rest-api-8.3.md +119 -0
- package/scripts/_allowlist.py +119 -0
- package/scripts/_ignition.py +181 -0
- package/scripts/_json_out.py +19 -0
- package/scripts/api_get.py +71 -0
- package/scripts/backup.py +49 -0
- package/scripts/fetch_docs.py +278 -0
- package/scripts/gateway_logs.py +57 -0
- package/scripts/project_create.py +64 -0
- package/scripts/project_resources.py +127 -0
- package/scripts/requirements.txt +3 -0
- package/scripts/resource_read.py +102 -0
- package/scripts/resource_write.py +171 -0
- package/scripts/scan.py +85 -0
- package/scripts/screenshot.py +96 -0
- package/scripts/status.py +91 -0
- package/scripts/validate.py +252 -0
- package/skills/ignition/SKILL.md +131 -0
- package/skills/ignition-reference/SKILL.md +157 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# Ignition 8.3 formats quickref
|
|
2
|
+
|
|
3
|
+
Verified against a live 8.3.9 install (2026-08-30).
|
|
4
|
+
|
|
5
|
+
## On-disk project layout
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
data/projects/<Name>/
|
|
9
|
+
├── project.json # {"title","description","enabled","inheritable","parent"}
|
|
10
|
+
└── com.inductiveautomation.perspective/
|
|
11
|
+
├── views/<viewPath>/ # resource.json + view.json + thumbnail.png
|
|
12
|
+
├── page-config/config.json/ # page config resource
|
|
13
|
+
├── page-startup/onPageStartup.py/
|
|
14
|
+
└── session-props/props.json/
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## resource.json (per resource folder — gateway-owned, never hand-edit existing)
|
|
18
|
+
|
|
19
|
+
```json
|
|
20
|
+
{
|
|
21
|
+
"scope": "G",
|
|
22
|
+
"version": 1,
|
|
23
|
+
"restricted": false,
|
|
24
|
+
"overridable": true,
|
|
25
|
+
"files": ["view.json", "thumbnail.png"],
|
|
26
|
+
"attributes": {
|
|
27
|
+
"lastModificationSignature": "<sha256-hex>",
|
|
28
|
+
"lastModification": { "actor": "operator", "timestamp": "2026-08-30T20:54:34Z" }
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Perspective view.json skeleton (VERIFIED live on 8.3.9)
|
|
34
|
+
|
|
35
|
+
```json
|
|
36
|
+
{
|
|
37
|
+
"custom": {},
|
|
38
|
+
"params": {},
|
|
39
|
+
"props": {},
|
|
40
|
+
"root": {
|
|
41
|
+
"type": "ia.container.coord",
|
|
42
|
+
"meta": { "name": "root" },
|
|
43
|
+
"children": [
|
|
44
|
+
{
|
|
45
|
+
"type": "ia.display.label",
|
|
46
|
+
"meta": { "name": "titleLabel" },
|
|
47
|
+
"position": { "x": 24, "y": 24, "width": 400, "height": 40 },
|
|
48
|
+
"props": { "text": "Overview", "style": { "fontSize": "24px" } }
|
|
49
|
+
}
|
|
50
|
+
]
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
- `root` is a **top-level key holding the root component** — NOT `props.root.children`.
|
|
56
|
+
Malformed views are silently dropped by the scan.
|
|
57
|
+
- `meta.name` unique per view. Position: absolute px inside coord (verified);
|
|
58
|
+
`mode: "flow"` inside flex containers (verified); `mode: "percent"` unverified —
|
|
59
|
+
labels stacked at 0,0 in live testing.
|
|
60
|
+
- Bindings wrap values: `"text": {"binding": {"type": "property", "config": {"path": "view.params.title"}}}`
|
|
61
|
+
— types: property, tag (`[provider]path/to/tag`), expr (Perspective expression
|
|
62
|
+
language), query; `config.transforms` chain output transforms.
|
|
63
|
+
|
|
64
|
+
## Page-config (required for a view to be reachable)
|
|
65
|
+
|
|
66
|
+
`com.inductiveautomation.perspective/page-config/config.json`:
|
|
67
|
+
|
|
68
|
+
```json
|
|
69
|
+
{
|
|
70
|
+
"pages": { "/": { "viewPath": "Home" } },
|
|
71
|
+
"sharedDocks": { "cornerPriority": "top-bottom" }
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
The URL after the project name is the PAGE path; pages map to view paths here.
|
|
76
|
+
|
|
77
|
+
## project.json
|
|
78
|
+
|
|
79
|
+
```json
|
|
80
|
+
{ "title": "Example Project", "description": "", "enabled": true, "inheritable": false, "parent": "" }
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Gateway scripts = Jython 2.7
|
|
84
|
+
|
|
85
|
+
No f-strings / async / pathlib / match. Use `%`/`.format()`. `system.*` functions
|
|
86
|
+
(e.g. `system.tag.readBlocking(["[default]Line1/Speed"])`).
|
|
87
|
+
|
|
88
|
+
## Auth (API key)
|
|
89
|
+
|
|
90
|
+
Header `X-Ignition-API-Token`. Working recipe (8.3.9): API key (Basic Token,
|
|
91
|
+
secure-connections unchecked for plain HTTP) holding a custom security level
|
|
92
|
+
(e.g. `SyloAPI` under `Authenticated`), granted Gateway Read + Write in
|
|
93
|
+
Security > General Settings > Roles and Permissions. 401 = bad token;
|
|
94
|
+
403 = insufficient level; 404 = route doesn't exist.
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# Ignition 8.3 Gateway REST API — verified route map
|
|
2
|
+
|
|
3
|
+
Verified live 2026-08-30 against a **native Windows Ignition 8.3.9** install
|
|
4
|
+
(Perspective module 3.3.9), gateway at `http://localhost:8088`. Full OpenAPI
|
|
5
|
+
spec: `GET /openapi.json` with the `X-Ignition-API-Token` header (12 MB on a
|
|
6
|
+
full-module install, 588 routes). This file is the curated subset the
|
|
7
|
+
sylo-ignition tools actually use.
|
|
8
|
+
|
|
9
|
+
## Auth (verified recipe)
|
|
10
|
+
|
|
11
|
+
- Create API key: **Platform → Security → API Keys → Create API Key +**
|
|
12
|
+
(Basic Token). Token shown once.
|
|
13
|
+
- Key must have **"Require secure connections" unchecked** for plain-HTTP
|
|
14
|
+
local gateways (checked → 403 over HTTP).
|
|
15
|
+
- Role-derived levels (Roles→Administrator) are **greyed out** for API keys.
|
|
16
|
+
Custom levels are assignable. Working setup (verified):
|
|
17
|
+
1. **Platform → Security → Levels**: add custom level `SyloAPI` under
|
|
18
|
+
`Authenticated`
|
|
19
|
+
2. Assign `SyloAPI` to the API key
|
|
20
|
+
3. **Platform → Security → General Settings → Roles and Permissions**:
|
|
21
|
+
check `SyloAPI` for Gateway Read AND Gateway Write permissions
|
|
22
|
+
- Every request: header `X-Ignition-API-Token: <token>`.
|
|
23
|
+
- Status codes: bad/dead token → 401; insufficient security level → 403
|
|
24
|
+
(route exists); no such route → 404 (`No route match for path: /v1/...`).
|
|
25
|
+
|
|
26
|
+
## Scan / safe-edit protocol
|
|
27
|
+
|
|
28
|
+
| Method | Route | Purpose |
|
|
29
|
+
|--------|-------|---------|
|
|
30
|
+
| GET | `/data/api/v1/scan/projects` | Project scan status (`scanActive`, `lastScanTimestamp`) |
|
|
31
|
+
| POST | `/data/api/v1/scan/projects` | **Trigger project scan** — hot-loads disk edits into gateway + Designer |
|
|
32
|
+
| GET/POST | `/data/api/v1/scan/config` | Same for gateway config (`data/config/**`) |
|
|
33
|
+
| GET | `/data/api/v1/scan-lock/projects` | Scan-lock info |
|
|
34
|
+
| POST | `/data/api/v1/scan-lock/projects` | Acquire project scan lock (mutual exclusion while writing on disk) |
|
|
35
|
+
| GET | `/data/api/v1/sync/items` | Config sync status |
|
|
36
|
+
| POST | `/data/api/v1/sync/reset` | Reset config sync |
|
|
37
|
+
|
|
38
|
+
## Projects API
|
|
39
|
+
|
|
40
|
+
| Method | Route | Purpose |
|
|
41
|
+
|--------|-------|---------|
|
|
42
|
+
| GET | `/data/api/v1/projects/list` | List all projects (with resource counts) |
|
|
43
|
+
| GET | `/data/api/v1/projects/names` | Project names |
|
|
44
|
+
| POST | `/data/api/v1/projects` | Create project |
|
|
45
|
+
| GET | `/data/api/v1/projects/find/{name}` | Project details |
|
|
46
|
+
| PUT | `/data/api/v1/projects/{name}` | Modify project (title/description/enabled/parent) |
|
|
47
|
+
| DELETE | `/data/api/v1/projects/{name}` | Delete project |
|
|
48
|
+
| POST | `/data/api/v1/projects/copy` | Copy project |
|
|
49
|
+
| POST | `/data/api/v1/projects/rename/{name}` | Rename project |
|
|
50
|
+
| GET | `/data/api/v1/projects/export/{name}` | **Export project zip** |
|
|
51
|
+
| POST | `/data/api/v1/projects/import/{name}` | **Import project zip** |
|
|
52
|
+
|
|
53
|
+
## Resources API (gateway config, pattern per type)
|
|
54
|
+
|
|
55
|
+
Pattern per resource type (393 routes total):
|
|
56
|
+
`GET /data/api/v1/resources/list|names|type|find|rename|delete/{moduleId}/{typeId}`,
|
|
57
|
+
`PUT|POST /data/api/v1/resources/{moduleId}/{typeId}` (create/update),
|
|
58
|
+
`GET|PUT|DELETE .../datafile/{moduleId}/{typeId}/{name}/{filename}` (attached files),
|
|
59
|
+
`GET .../singleton/{moduleId}/{typeId}` (singletons).
|
|
60
|
+
|
|
61
|
+
Key types for sylo-ignition:
|
|
62
|
+
|
|
63
|
+
| Module | Type | Use |
|
|
64
|
+
|--------|------|-----|
|
|
65
|
+
| `ignition` | `tag-provider` | Tag provider CRUD |
|
|
66
|
+
| `com.inductiveautomation.opcua` | `device` | OPC UA device connections |
|
|
67
|
+
| `ignition` | `opc-connection` | Legacy OPC connections |
|
|
68
|
+
| `ignition` | `database-connection` | DB connections |
|
|
69
|
+
| `com.inductiveautomation.perspective` | `themes` | **Theme resources** (theme.json + css files via datafile routes) |
|
|
70
|
+
| `com.inductiveautomation.perspective` | `fonts`, `icons` | Fonts / icon sets |
|
|
71
|
+
| `ignition` | `security-levels`, `security-properties`, `security-zone` | Security config |
|
|
72
|
+
| `ignition` | `identity-provider`, `user-source` | Authn config |
|
|
73
|
+
|
|
74
|
+
## Tags
|
|
75
|
+
|
|
76
|
+
| Method | Route | Purpose |
|
|
77
|
+
|--------|-------|---------|
|
|
78
|
+
| GET | `/data/api/v1/tags/export` | **Download tag export (JSON)** — provider/tag paths, recursive |
|
|
79
|
+
| POST | `/data/api/v1/tags/import` | **Import tags (JSON)** — create/override, collision handling |
|
|
80
|
+
| PUT | `/data/api/v1/managed-tag-provider` | Write managed tag provider definitions |
|
|
81
|
+
|
|
82
|
+
No REST route for tag **values** (definitions only). Value read/write goes
|
|
83
|
+
through the gateway's OPC-UA server or scripting, not this API.
|
|
84
|
+
|
|
85
|
+
## Perspective module API
|
|
86
|
+
|
|
87
|
+
| Method | Route | Purpose |
|
|
88
|
+
|--------|-------|---------|
|
|
89
|
+
| GET | `/data/perspective/api/v1/sessions/` | Live sessions |
|
|
90
|
+
| GET | `/data/perspective/api/v1/session/{sessionId}` | Session detail |
|
|
91
|
+
| GET | `/data/perspective/api/v1/session/{sessionId}/pages` | Pages in session |
|
|
92
|
+
| GET | `/data/perspective/api/v1/session/{sessionId}/page/{pageId}/views` | Mounted views |
|
|
93
|
+
| DELETE | `/data/perspective/api/v1/sessions` | Terminate session(s) |
|
|
94
|
+
| POST | `/data/perspective/api/v1/themes/copy-base-themes` | **Copy base themes into project** (start of theme customization) |
|
|
95
|
+
|
|
96
|
+
## Gateway status / diagnostics
|
|
97
|
+
|
|
98
|
+
| Method | Route | Purpose |
|
|
99
|
+
|--------|-------|---------|
|
|
100
|
+
| GET | `/data/api/v1/gateway-info` | Version/edition/platform info |
|
|
101
|
+
| GET | `/data/api/v1/overview` | Gateway overview (modules, system) |
|
|
102
|
+
| GET | `/data/api/v1/overview/problems` | Health problems list |
|
|
103
|
+
| GET | `/data/api/v1/overview/connections` | Connection statuses |
|
|
104
|
+
| GET | `/data/api/v1/logs` | Gateway logs (debug scan failures etc.) |
|
|
105
|
+
| GET | `/data/api/v1/logs/download` | Download logs |
|
|
106
|
+
| GET | `/data/api/v1/backup` | **Gateway backup download** (pre-change safety) |
|
|
107
|
+
| POST | `/data/api/v1/backup` | Restore backup |
|
|
108
|
+
| GET | `/data/api/v1/designers` | Open Designer sessions (conflict detection!) |
|
|
109
|
+
| GET | `/data/api/v1/entity/browse` | Browse config entities |
|
|
110
|
+
| GET/POST/PUT/DELETE | `/data/api/v1/mode*` | Deployment modes |
|
|
111
|
+
| GET | `/data/api/v1/licenses` | License info |
|
|
112
|
+
| GET | `/data/api/v1/trial` | Trial info |
|
|
113
|
+
|
|
114
|
+
## OPC-UA module API (client/server PKI)
|
|
115
|
+
|
|
116
|
+
`/data/opc-ua/api/v1/...` — trust/reject/download/upload certificates for
|
|
117
|
+
client & server PKI, regenerate server certificate. Needed for connecting to
|
|
118
|
+
real PLCs (Logix/Modbus/Siemens etc. are driver configs via resources API:
|
|
119
|
+
`com.inductiveautomation.opcua/device`).
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Shared write-allowlist loader / gates for sylo-ignition.
|
|
3
|
+
|
|
4
|
+
The allowlist lives at packages/sylo-ignition/assets/write-allowlist.json and is
|
|
5
|
+
OPERATOR-MANAGED — the agent must never edit it. Every mutating tool
|
|
6
|
+
(resource_write, scan, project_create, REST writes) enforces it in Python:
|
|
7
|
+
refuse anything not explicitly allowed here.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from _json_out import emit_error
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def package_root() -> Path:
|
|
22
|
+
return Path(__file__).resolve().parent.parent
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def allowlist_path() -> Path:
|
|
26
|
+
env = os.environ.get("IGNITION_WRITE_ALLOWLIST", "").strip()
|
|
27
|
+
if env:
|
|
28
|
+
return Path(env).expanduser().resolve()
|
|
29
|
+
return package_root() / "assets" / "write-allowlist.json"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def default_allowlist() -> dict[str, Any]:
|
|
33
|
+
return {
|
|
34
|
+
"allow_writes": False,
|
|
35
|
+
"allow_scan": False,
|
|
36
|
+
"allow_project_create": False,
|
|
37
|
+
"projects": [],
|
|
38
|
+
"updated_at": None,
|
|
39
|
+
"notes": "Operator-managed. The agent cannot edit this file; mutating tools enforce it.",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def load_allowlist() -> dict[str, Any]:
|
|
44
|
+
path = allowlist_path()
|
|
45
|
+
if not path.is_file():
|
|
46
|
+
return default_allowlist()
|
|
47
|
+
try:
|
|
48
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
49
|
+
except (json.JSONDecodeError, OSError):
|
|
50
|
+
return default_allowlist()
|
|
51
|
+
if not isinstance(data, dict):
|
|
52
|
+
return default_allowlist()
|
|
53
|
+
base = default_allowlist()
|
|
54
|
+
base.update(data)
|
|
55
|
+
if not isinstance(base.get("projects"), list):
|
|
56
|
+
base["projects"] = []
|
|
57
|
+
return base
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _project_entry(allow: dict[str, Any], project: str) -> dict[str, Any] | None:
|
|
61
|
+
for entry in allow.get("projects", []):
|
|
62
|
+
if isinstance(entry, dict) and entry.get("name") == project:
|
|
63
|
+
return entry
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def gate_writes(allow: dict[str, Any], project: str) -> None:
|
|
68
|
+
"""Exit with a clear error unless project file-writes are allowed."""
|
|
69
|
+
if not allow.get("allow_writes"):
|
|
70
|
+
emit_error(
|
|
71
|
+
"File writes are disabled in the Ignition write-allowlist "
|
|
72
|
+
f"({allowlist_path()}). The operator must set allow_writes=true."
|
|
73
|
+
)
|
|
74
|
+
entry = _project_entry(allow, project)
|
|
75
|
+
if entry is None:
|
|
76
|
+
emit_error(
|
|
77
|
+
f"Project '{project}' is not in the Ignition write-allowlist. "
|
|
78
|
+
"The operator must add it there (assets/write-allowlist.json) before any write. "
|
|
79
|
+
"The agent must not edit that file."
|
|
80
|
+
)
|
|
81
|
+
if not entry.get("enabled", False):
|
|
82
|
+
emit_error(
|
|
83
|
+
f"Project '{project}' is present but DISABLED in the Ignition write-allowlist. "
|
|
84
|
+
"Ask the operator to enable it (or use a different allowed project)."
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def gate_scan(allow: dict[str, Any]) -> None:
|
|
89
|
+
if not allow.get("allow_scan"):
|
|
90
|
+
emit_error(
|
|
91
|
+
"Gateway scan is disabled in the Ignition write-allowlist "
|
|
92
|
+
f"({allowlist_path()}). The operator must set allow_scan=true to hot-apply edits."
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def gate_project_create(allow: dict[str, Any], name: str) -> None:
|
|
97
|
+
if not allow.get("allow_project_create"):
|
|
98
|
+
emit_error(
|
|
99
|
+
"Project creation is disabled in the Ignition write-allowlist. "
|
|
100
|
+
"The operator must set allow_project_create=true."
|
|
101
|
+
)
|
|
102
|
+
if not name or any(c in name for c in '/\\:*?"<>|') or name.strip() != name:
|
|
103
|
+
emit_error(f"Invalid project name: {name!r}")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def summary(allow: dict[str, Any]) -> dict[str, Any]:
|
|
107
|
+
return {
|
|
108
|
+
"allow_writes": bool(allow.get("allow_writes")),
|
|
109
|
+
"allow_scan": bool(allow.get("allow_scan")),
|
|
110
|
+
"allow_project_create": bool(allow.get("allow_project_create")),
|
|
111
|
+
"writable_projects": [
|
|
112
|
+
e.get("name") for e in allow.get("projects", []) if isinstance(e, dict) and e.get("enabled")
|
|
113
|
+
],
|
|
114
|
+
"path": str(allowlist_path()),
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def note_update() -> dict[str, Any]:
|
|
119
|
+
return {"updated_at": datetime.now(timezone.utc).isoformat()}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Shared Ignition gateway plumbing: config file, REST client, data-dir paths.
|
|
3
|
+
|
|
4
|
+
Connection config lives OUTSIDE any repo at ~/.ignition-sylo/config.json so the
|
|
5
|
+
API token never lands in git:
|
|
6
|
+
|
|
7
|
+
{
|
|
8
|
+
"gateway_url": "http://localhost:8088",
|
|
9
|
+
"api_token": "Sylo:...",
|
|
10
|
+
"data_dir": "C:\\Program Files\\Inductive Automation\\Ignition\\data",
|
|
11
|
+
"default_project": "SyloSandbox"
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
Env overrides (testing): IGNITION_SYLO_CONFIG (path), IGNITION_SYLO_URL,
|
|
15
|
+
IGNITION_SYLO_TOKEN, IGNITION_SYLO_DATA_DIR, IGNITION_SYLO_PROJECT.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import ssl
|
|
23
|
+
import urllib.error
|
|
24
|
+
import urllib.parse
|
|
25
|
+
import urllib.request
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
from _json_out import emit_error
|
|
30
|
+
|
|
31
|
+
CONFIG_FILENAME = "config.json"
|
|
32
|
+
COMMON_DATA_DIRS = (
|
|
33
|
+
r"C:\Program Files\Inductive Automation\Ignition\data",
|
|
34
|
+
r"C:\Program Files (x86)\Inductive Automation\Ignition\data",
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def config_path() -> Path:
|
|
39
|
+
env = os.environ.get("IGNITION_SYLO_CONFIG", "").strip()
|
|
40
|
+
if env:
|
|
41
|
+
return Path(env).expanduser().resolve()
|
|
42
|
+
return Path.home() / ".ignition-sylo" / CONFIG_FILENAME
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _first_existing_data_dir() -> str | None:
|
|
46
|
+
for candidate in COMMON_DATA_DIRS:
|
|
47
|
+
if Path(candidate).is_dir():
|
|
48
|
+
return candidate
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def load_config(require_token: bool = True) -> dict[str, Any]:
|
|
53
|
+
"""Load connection config; emit_error exits on missing pieces."""
|
|
54
|
+
path = config_path()
|
|
55
|
+
cfg: dict[str, Any] = {}
|
|
56
|
+
if path.is_file():
|
|
57
|
+
try:
|
|
58
|
+
cfg = json.loads(path.read_text(encoding="utf-8"))
|
|
59
|
+
except (json.JSONDecodeError, OSError) as e:
|
|
60
|
+
emit_error(f"Could not parse {path}: {e}")
|
|
61
|
+
else:
|
|
62
|
+
emit_error(
|
|
63
|
+
f"No Ignition connection config at {path}. "
|
|
64
|
+
"Create it (see skill: setup section) with gateway_url + api_token "
|
|
65
|
+
"(Platform > Security > API Keys in the gateway web UI)."
|
|
66
|
+
)
|
|
67
|
+
# Env overrides
|
|
68
|
+
cfg["gateway_url"] = os.environ.get("IGNITION_SYLO_URL", "").strip() or cfg.get("gateway_url", "")
|
|
69
|
+
token = os.environ.get("IGNITION_SYLO_TOKEN", "").strip() or cfg.get("api_token", "").strip()
|
|
70
|
+
cfg["api_token"] = token
|
|
71
|
+
cfg["data_dir"] = (
|
|
72
|
+
os.environ.get("IGNITION_SYLO_DATA_DIR", "").strip() or cfg.get("data_dir") or _first_existing_data_dir()
|
|
73
|
+
)
|
|
74
|
+
cfg["default_project"] = (
|
|
75
|
+
os.environ.get("IGNITION_SYLO_PROJECT", "").strip() or cfg.get("default_project") or ""
|
|
76
|
+
)
|
|
77
|
+
cfg["_config_path"] = str(path)
|
|
78
|
+
|
|
79
|
+
if not cfg.get("gateway_url"):
|
|
80
|
+
emit_error(f"gateway_url missing in {path}")
|
|
81
|
+
if require_token and not cfg.get("api_token"):
|
|
82
|
+
emit_error(
|
|
83
|
+
f"api_token missing in {path}. Create an API key (Platform > Security > API Keys), "
|
|
84
|
+
"give it a custom security level granted Gateway Read+Write (see skill setup), "
|
|
85
|
+
f"then put the token in {path}."
|
|
86
|
+
)
|
|
87
|
+
cfg["gateway_url"] = str(cfg["gateway_url"]).rstrip("/")
|
|
88
|
+
return cfg
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def projects_dir(cfg: dict[str, Any]) -> Path:
|
|
92
|
+
return Path(cfg["data_dir"]) / "projects"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def project_dir(cfg: dict[str, Any], project: str) -> Path:
|
|
96
|
+
return projects_dir(cfg) / project
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def api(
|
|
100
|
+
cfg: dict[str, Any],
|
|
101
|
+
route: str,
|
|
102
|
+
method: str = "GET",
|
|
103
|
+
body: Any = None,
|
|
104
|
+
timeout: float = 30.0,
|
|
105
|
+
raw: bool = False,
|
|
106
|
+
) -> tuple[int, Any, dict[str, str]]:
|
|
107
|
+
"""Call the gateway REST API with the X-Ignition-API-Token header.
|
|
108
|
+
|
|
109
|
+
Returns (status, parsed_json_or_text_or_bytes, headers). Auth/permission
|
|
110
|
+
failures are returned as status codes — callers decide how to surface.
|
|
111
|
+
"""
|
|
112
|
+
url = route if route.startswith("http") else cfg["gateway_url"] + route
|
|
113
|
+
data: bytes | None = None
|
|
114
|
+
headers = {"Accept": "application/json"}
|
|
115
|
+
if cfg.get("api_token"):
|
|
116
|
+
headers["X-Ignition-API-Token"] = cfg["api_token"]
|
|
117
|
+
if body is not None:
|
|
118
|
+
if isinstance(body, (bytes, bytearray)):
|
|
119
|
+
data = bytes(body)
|
|
120
|
+
headers["Content-Type"] = "application/octet-stream"
|
|
121
|
+
else:
|
|
122
|
+
data = json.dumps(body).encode("utf-8")
|
|
123
|
+
headers["Content-Type"] = "application/json"
|
|
124
|
+
req = urllib.request.Request(url, data=data, headers=headers, method=method.upper())
|
|
125
|
+
ctx = ssl.create_default_context()
|
|
126
|
+
# Local gateways commonly run plain HTTP or self-signed HTTPS.
|
|
127
|
+
ctx.check_hostname = False
|
|
128
|
+
ctx.verify_mode = ssl.CERT_NONE
|
|
129
|
+
try:
|
|
130
|
+
with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp:
|
|
131
|
+
payload = resp.read()
|
|
132
|
+
status = resp.status
|
|
133
|
+
resp_headers = {k.lower(): v for k, v in resp.headers.items()}
|
|
134
|
+
except urllib.error.HTTPError as e:
|
|
135
|
+
payload = e.read()
|
|
136
|
+
status = e.code
|
|
137
|
+
resp_headers = {k.lower(): v for k, v in e.headers.items()}
|
|
138
|
+
except (urllib.error.URLError, TimeoutError, OSError) as e:
|
|
139
|
+
emit_error(f"Gateway unreachable at {url}: {e}")
|
|
140
|
+
if raw:
|
|
141
|
+
return status, payload, resp_headers
|
|
142
|
+
text = payload.decode("utf-8", errors="replace")
|
|
143
|
+
try:
|
|
144
|
+
return status, json.loads(text), resp_headers
|
|
145
|
+
except (json.JSONDecodeError, ValueError):
|
|
146
|
+
return status, text, resp_headers
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def api_expect(
|
|
150
|
+
cfg: dict[str, Any],
|
|
151
|
+
route: str,
|
|
152
|
+
method: str = "GET",
|
|
153
|
+
body: Any = None,
|
|
154
|
+
timeout: float = 30.0,
|
|
155
|
+
what: str = "",
|
|
156
|
+
) -> Any:
|
|
157
|
+
"""api() that emit_errors on any non-2xx status."""
|
|
158
|
+
status, parsed, _ = api(cfg, route, method=method, body=body, timeout=timeout)
|
|
159
|
+
if status >= 400:
|
|
160
|
+
if isinstance(parsed, dict):
|
|
161
|
+
hint = parsed.get("message") or parsed.get("error") or json.dumps(parsed)[:200]
|
|
162
|
+
else:
|
|
163
|
+
hint = str(parsed)[:200]
|
|
164
|
+
emit_error(f"{method} {route} -> HTTP {status}: {hint}" + (f" ({what})" if what else ""))
|
|
165
|
+
return parsed
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def mask_token(token: str) -> str:
|
|
169
|
+
if not token:
|
|
170
|
+
return ""
|
|
171
|
+
return token[:6] + "..." if len(token) > 9 else "***"
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def resolve_project(cfg: dict[str, Any], project: str | None) -> str:
|
|
175
|
+
name = (project or "").strip() or cfg.get("default_project", "").strip()
|
|
176
|
+
if not name:
|
|
177
|
+
emit_error(
|
|
178
|
+
"No project specified and no default_project in config. "
|
|
179
|
+
"Pass --project or set default_project in ~/.ignition-sylo/config.json."
|
|
180
|
+
)
|
|
181
|
+
return name
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Shared JSON stdout helpers for sylo-ignition scripts."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def emit(payload: dict[str, Any]) -> None:
|
|
12
|
+
"""Print JSON to stdout and exit with code 0 or 1."""
|
|
13
|
+
print(json.dumps(payload, indent=2))
|
|
14
|
+
if payload.get("ok") is False:
|
|
15
|
+
sys.exit(1)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def emit_error(message: str, **extra: Any) -> None:
|
|
19
|
+
emit({"ok": False, "error": message, **extra})
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Read-only GET passthrough for gateway REST routes — ignition_api_get.
|
|
3
|
+
|
|
4
|
+
Lets the agent explore the 588-route API (resource lists, session detail,
|
|
5
|
+
overviews, tag exports) without a dedicated tool per route. GET ONLY — every
|
|
6
|
+
mutating verb is refused; the mutating tools have their own gates.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import json
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from _ignition import api, load_config
|
|
17
|
+
from _json_out import emit, emit_error
|
|
18
|
+
|
|
19
|
+
ALLOWED_PREFIXES = ("/data/", "/openapi.json", "/system/")
|
|
20
|
+
MAX_INLINE_CHARS = 24_000
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def main() -> None:
|
|
24
|
+
parser = argparse.ArgumentParser()
|
|
25
|
+
parser.add_argument("--route", required=True, help="GET route, e.g. /data/api/v1/projects/list (query string allowed)")
|
|
26
|
+
parser.add_argument("--save", default="", help="save the full JSON payload to this file (recommended for big routes)")
|
|
27
|
+
parser.add_argument("--timeout", type=float, default=30.0)
|
|
28
|
+
args = parser.parse_args()
|
|
29
|
+
|
|
30
|
+
route = args.route.strip()
|
|
31
|
+
if not route.startswith(ALLOWED_PREFIXES) or route.startswith("//"):
|
|
32
|
+
emit_error(
|
|
33
|
+
f"Route must start with one of {ALLOWED_PREFIXES}. Got: {route!r}. "
|
|
34
|
+
"Mutating methods are not available through this tool."
|
|
35
|
+
)
|
|
36
|
+
if "?" in route and any(
|
|
37
|
+
kw in route.lower() for kw in ("method=", "delete", "put", "post")
|
|
38
|
+
):
|
|
39
|
+
# route query strings don't carry verbs, but guard obviously wrong usage
|
|
40
|
+
pass # no-op: query params like ?scope=projects are legitimate
|
|
41
|
+
|
|
42
|
+
cfg = load_config()
|
|
43
|
+
status, data, _ = api(cfg, route, method="GET", timeout=args.timeout)
|
|
44
|
+
|
|
45
|
+
out: dict[str, Any] = {"ok": status < 400, "route": route, "http_status": status}
|
|
46
|
+
if status >= 400:
|
|
47
|
+
out["error"] = str(data)[:300]
|
|
48
|
+
emit(out)
|
|
49
|
+
|
|
50
|
+
if args.save:
|
|
51
|
+
save_path = Path(args.save).expanduser()
|
|
52
|
+
save_path.parent.mkdir(parents=True, exist_ok=True)
|
|
53
|
+
if isinstance(data, (dict, list)):
|
|
54
|
+
save_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
55
|
+
else:
|
|
56
|
+
save_path.write_text(str(data), encoding="utf-8")
|
|
57
|
+
out["saved_to"] = str(save_path)
|
|
58
|
+
out["hint"] = "Full payload saved to disk — read it with the read tool."
|
|
59
|
+
else:
|
|
60
|
+
text = json.dumps(data, indent=2) if not isinstance(data, str) else data
|
|
61
|
+
if len(text) > MAX_INLINE_CHARS:
|
|
62
|
+
out["payload"] = text[:MAX_INLINE_CHARS]
|
|
63
|
+
out["truncated"] = True
|
|
64
|
+
out["hint"] = "Truncated — re-run with --save <path> to capture the full payload."
|
|
65
|
+
else:
|
|
66
|
+
out["payload"] = data
|
|
67
|
+
emit(out)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
if __name__ == "__main__":
|
|
71
|
+
main()
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Download a gateway backup (.gwbk) — the pre-change safety net.
|
|
3
|
+
|
|
4
|
+
For ignition_backup tool. Read-only (GET /data/api/v1/backup). Backups land in
|
|
5
|
+
~/.ignition-sylo/backups/ by default (outside any repo).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from _ignition import api, load_config
|
|
16
|
+
from _json_out import emit, emit_error
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def main() -> None:
|
|
20
|
+
parser = argparse.ArgumentParser()
|
|
21
|
+
parser.add_argument("--out", default="", help="output .gwbk file (default ~/.ignition-sylo/backups/<ts>.gwbk)")
|
|
22
|
+
args = parser.parse_args()
|
|
23
|
+
|
|
24
|
+
cfg = load_config()
|
|
25
|
+
out_path = Path(args.out).expanduser() if args.out.strip() else (
|
|
26
|
+
Path.home() / ".ignition-sylo" / "backups" / f"gateway-{datetime.now().strftime('%Y%m%d-%H%M%S')}.gwbk"
|
|
27
|
+
)
|
|
28
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
29
|
+
|
|
30
|
+
status, payload, headers = api(cfg, "/data/api/v1/backup", method="GET", timeout=300, raw=True)
|
|
31
|
+
if status >= 400:
|
|
32
|
+
emit_error(f"Backup -> HTTP {status} ({payload[:200]!r})")
|
|
33
|
+
if not payload or len(payload) < 10_000:
|
|
34
|
+
emit_error(f"Backup response suspiciously small ({len(payload)} bytes) — not saved.")
|
|
35
|
+
|
|
36
|
+
out_path.write_bytes(payload)
|
|
37
|
+
emit(
|
|
38
|
+
{
|
|
39
|
+
"ok": True,
|
|
40
|
+
"backup_path": str(out_path),
|
|
41
|
+
"bytes": len(payload),
|
|
42
|
+
"content_type": headers.get("content-type", ""),
|
|
43
|
+
"note": "Restore via gateway web UI (Gateway > Restore) or POST /data/api/v1/backup. Keep this file until changes are verified.",
|
|
44
|
+
}
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
if __name__ == "__main__":
|
|
49
|
+
main()
|