diff --git a/.claude/commands/ignition-configure.md b/.claude/commands/ignition-configure.md new file mode 100644 index 0000000..658da3f --- /dev/null +++ b/.claude/commands/ignition-configure.md @@ -0,0 +1,191 @@ +# ignition-configure + +Use this skill whenever the user asks you to configure, inspect, or manage the Ignition gateway +via the HTTP API — adding connections, importing tags, checking status, managing modules, etc. + +## Setup: Resolve token and host + +1. Read `docker/.env` for `GATEWAY_HOSTNAME` (default: `localhost:8088`) +2. Read `~/.config/ignition-dev/secrets.env` for `IGNITION_API_TOKEN` (format: `name:token`) + - Fall back to asking the user if not found +3. Set shell variables for all subsequent calls: + +```bash +TOKEN=$(grep IGNITION_API_TOKEN ~/.config/ignition-dev/secrets.env | cut -d= -f2) +GW="http://$(grep GATEWAY_HOSTNAME docker/.env | cut -d= -f2- || echo 'localhost:8088')" +HEADER="X-Ignition-API-Token: $TOKEN" +``` + +## Step 1: Confirm gateway is reachable + +Always check before making changes: + +```bash +curl -s -H "$HEADER" $GW/data/api/v1/overview | python3 -m json.tool +``` + +If this fails, check `docker compose ps` and gateway logs before proceeding. + +## Step 2: Inspect current state + +Before creating or modifying anything, read what's already there: + +```bash +# Gateway connections overview +curl -s -H "$HEADER" $GW/data/api/v1/overview/connections | python3 -m json.tool + +# Any critical problems +curl -s -H "$HEADER" $GW/data/api/v1/overview/problems | python3 -m json.tool +``` + +--- + +## Common Configuration Tasks + +### Add a database connection + +```bash +# First — describe the schema to see available props +curl -s -H "$HEADER" $GW/data/api/v1/resources/type/ignition/database-connection | python3 -m json.tool + +# Create +curl -s -X POST -H "$HEADER" -H "Content-Type: application/json" \ + $GW/data/api/v1/resources/ignition/database-connection \ + -d '{ + "name": "ignition_db", + "enabled": true, + "props": { + "ConnectURL": "jdbc:postgresql://postgres:5432/ignition", + "Username": "ignition", + "Password": "'"$POSTGRES_PASSWORD"'" + } + }' + +# Verify +curl -s -H "$HEADER" $GW/data/api/v1/resources/find/ignition/database-connection/ignition_db | python3 -m json.tool +``` + +### Add an OPC connection (Modbus or AB PLC) + +```bash +# Describe schema first +curl -s -H "$HEADER" $GW/data/api/v1/resources/type/ignition/opc-connection | python3 -m json.tool + +# Create +curl -s -X POST -H "$HEADER" -H "Content-Type: application/json" \ + $GW/data/api/v1/resources/ignition/opc-connection \ + -d '{ + "name": "modbus-sim-pumps", + "enabled": true, + "props": { + "EndpointUrl": "opc.tcp://modbus-sim-pumps:4840", + "SecurityPolicy": "None", + "MessageSecurity": "None" + } + }' +``` + +### Add an OPC-UA device + +```bash +# List available device types +curl -s -H "$HEADER" $GW/data/api/v1/resources/type/com.inductiveautomation.opcua/device | python3 -m json.tool + +# Create +curl -s -X POST -H "$HEADER" -H "Content-Type: application/json" \ + $GW/data/api/v1/resources/com.inductiveautomation.opcua/device \ + -d '{ "name": "MyDevice", "enabled": true, "props": { ... } }' +``` + +### Modify an existing resource + +Always GET first to obtain the current `signature`: + +```bash +# Step 1: Get current config and signature +RESOURCE=$(curl -s -H "$HEADER" $GW/data/api/v1/resources/find/ignition/database-connection/ignition_db) +SIG=$(echo $RESOURCE | python3 -c "import json,sys; print(json.load(sys.stdin)['signature'])") + +# Step 2: Modify (include signature in body) +curl -s -X PUT -H "$HEADER" -H "Content-Type: application/json" \ + $GW/data/api/v1/resources/ignition/database-connection \ + -d "{\"name\": \"ignition_db\", \"signature\": \"$SIG\", \"props\": { ... }}" +``` + +### Import tags + +```bash +# Export first (to see current state or create a template) +curl -s -H "$HEADER" \ + "$GW/data/api/v1/tags/export?provider=default&path=Devices&recursive=true" \ + -o tags-export.json + +# Import (collisionPolicy: Abort | Overwrite | Ignore | MergeOverwrite | MergeIgnore) +curl -s -X POST -H "$HEADER" -H "Content-Type: application/json" \ + "$GW/data/api/v1/tags/import?provider=default&path=Devices&collisionPolicy=MergeOverwrite" \ + -d @tags-export.json +``` + +### Download a backup + +```bash +curl -s -H "$HEADER" \ + "$GW/data/api/v1/backup" \ + -o "gateway-$(date +%Y%m%d-%H%M%S).gwbk" +``` + +--- + +## After Making Changes — Scan to Sync + +After editing files in `ignition/project/`: +```bash +curl -s -X POST -H "$HEADER" $GW/data/api/v1/scan/projects +``` + +After editing files in `docker/gw-config/`: +```bash +curl -s -X POST -H "$HEADER" $GW/data/api/v1/scan/config +``` + +Check scan status: +```bash +curl -s -H "$HEADER" $GW/data/api/v1/scan/projects | python3 -m json.tool +curl -s -H "$HEADER" $GW/data/api/v1/scan/config | python3 -m json.tool +``` + +--- + +## Gateway Restart ⚠️ + +**Never restart without explicit user confirmation.** Check for pending tasks first: + +```bash +# Check what requires a restart +curl -s -H "$HEADER" $GW/data/api/v1/restart-tasks/pending | python3 -m json.tool + +# Restart (confirm=true required — disruptive to all active sessions) +curl -s -X POST -H "$HEADER" "$GW/data/api/v1/restart-tasks/restart?confirm=true" +``` + +--- + +## Logs and Diagnostics + +```bash +# Recent warnings and errors +curl -s -H "$HEADER" "$GW/data/api/v1/logs?minLevel=WARN&limit=50" | python3 -m json.tool + +# Logs for a specific logger +curl -s -H "$HEADER" "$GW/data/api/v1/logs?logger=IgnitionGateway&limit=25" | python3 -m json.tool + +# Audit trail +curl -s -H "$HEADER" "$GW/data/api/v1/audit/log/Audit?limit=25" | python3 -m json.tool +``` + +--- + +## Reference + +Full API reference: [ignition/ignition-api.md](../../ignition/ignition-api.md) +Live spec: `http://localhost:8088/openapi` diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 4b2ae67..5dc4fa2 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -1,7 +1,20 @@ { "permissions": { "allow": [ - "Bash(find c:/Git/framework-ignition-docker/ignition -type f -name *)" + "Bash(find c:/Git/framework-ignition-docker/ignition -type f -name *)", + "WebFetch(domain:www.docs.inductiveautomation.com)", + "Bash(curl -s -H \"X-Ignition-API-Token: 0Pda7AWJeXENyHlvqi74ORZ20BeWtaDzk2L3WIsaUZs\" http://localhost:8088/openapi.json)", + "Bash(curl -sk -H \"X-Ignition-API-Token: Claude:0Pda7AWJeXENyHlvqi74ORZ20BeWtaDzk2L3WIsaUZs\" https://localhost:8043/openapi.json)", + "Bash(python3 -m json.tool)", + "Bash(curl -s -H \"X-Ignition-API-Token: Claude:0Pda7AWJeXENyHlvqi74ORZ20BeWtaDzk2L3WIsaUZs\" http://localhost:8088/openapi.json)", + "Bash(claude mcp:*)", + "Bash(command -v claude)", + "Read(//c/Users/b.peck/.local/bin/**)", + "Read(//usr/local/bin/**)", + "Bash(/c/Users/b.peck/.local/bin/claude mcp:*)", + "mcp__obsidian__obsidian_update_note", + "mcp__obsidian__obsidian_list_notes", + "mcp__obsidian__obsidian_read_note" ] } } diff --git a/CLAUDE.md b/CLAUDE.md index a770fe4..22165f5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,43 +13,47 @@ The PLC I/O Testing Platform is the first project built on this framework. - Gitea at 192.168.3.59:3000 for source control - Jython 2.7 inside Ignition; Python 3.10+ for external tooling - Claude Code on the Linux host (VS Code Remote to Docker host) +- WikiJS at wikijs.primecontrols-dev.com for team documentation (`.md` files auto-sync on `git push` via `tools/publish_docs.py`) +## Gateway HTTP API + +Ignition 8.3 exposes a full REST API. Use it for infrastructure-as-code configuration — +adding DB connections, OPC connections, importing tags, managing projects, etc. + +- **Browse**: `http://:8088/openapi` +- **Spec**: `http://:8088/openapi.json` +- **Auth header**: `X-Ignition-API-Token: :` +- **Full reference**: `ignition/ignition-api.md` +- **Skill**: `/ignition-configure` — resolves token and walks through config tasks ## API Key / Secrets Policy **Source of truth**: `~/.config/ignition-dev/secrets.env` (chmod 600, never committed) **Template**: `.env.example` in repo root — update this when adding new secrets -### Rules - All secrets via environment variables — no hardcoded values in any file - Docker Compose references host env vars: `VAR=${VAR}` pattern - Python scripts use `python-dotenv` + `os.environ["KEY"]` (loud failure on missing) - `.env` at project root is a symlink to secrets file — listed in .gitignore - CI secrets live in Gitea repository secrets settings -### Required Variables -See `.env.example` for full list. +Key variables: `IGNITION_API_TOKEN`, `POSTGRES_PASSWORD` — see `.env.example` for full list. ## Language Rules ### Jython 2.7 (Inside Ignition) -All scripts in `ignition/project/` and gateway scripting run Jython 2.7. -Hard constraints — violating these causes runtime errors: +All scripts in `ignition/project/` run Jython 2.7. Top violations: -- NO f-strings → use `"text {}".format(val)` or `"text %s" % val` +- NO f-strings → use `"text {}".format(val)` - NO walrus operator `:=` -- NO `pathlib` → use `os.path` - NO type hints → no `def foo(x: int) -> str:` -- NO `dataclasses`, `enum.auto()`, `asyncio` -- NO dictionary unpacking `{**d1, **d2}` → use `d1.update(d2)` -- NO `yield from` → use explicit loops -- Imports: `system.*` for Ignition API, standard Java/Jython libs only + +Full constraint list: [ignition/CLAUDE.md](ignition/CLAUDE.md) ### Python 3.10+ (External Tooling) -All scripts outside Ignition (test runners, utilities, CI/CD) use Python 3.10+. -Comment the target environment at the top of every file: +All scripts outside Ignition use Python 3.10+. Add at top of every file: ```python # env: python3.10+ (external tooling — NOT Ignition) @@ -63,6 +67,7 @@ Comment the target environment at the top of every file: 4. **Never restart Ignition gateway** without explicit user confirmation. 5. **Never `docker compose down`** without confirmation (destroys unnamed volumes). 6. **Every change needs a verification step** — scenario run, curl, tag read. +7. **Scan after file edits** — after editing `ignition/project/` run `POST /data/api/v1/scan/projects`; after editing `docker/gw-config/` run `POST /data/api/v1/scan/config`. Use `/ignition-configure`. ## Canonical Patterns (Do Not Change Without Discussion) @@ -73,24 +78,13 @@ Decouples simulation from PLC I/O structure. All device test tags follow this. ### JSON Test Scenarios -Five required sections: -- `identity` — scenario ID, version, description -- `device` — device type, UDT path, tag references -- `initial_state` — tag values to set before test begins -- `sequence` — ordered steps with `delay_ms` between actions -- `expected_outcomes` — pass/fail criteria with `tolerance` - +Five required sections: `identity`, `device`, `initial_state`, `sequence`, `expected_outcomes`. See `testing/CLAUDE.md` for full schema. ### WebDev API All external I/O goes through WebDev endpoints — never direct tag file edits at runtime: -- `tagWrite` — write values to tags -- `tagRead` — read tag values -- `tagBrowse` — browse tag tree -- `health` — gateway and connection status - -See `webdev/CLAUDE.md` for endpoint contracts. +`tagWrite`, `tagRead`, `tagBrowse`, `health`. See `webdev/CLAUDE.md`. ### Ignition as I/O Orchestrator @@ -102,17 +96,23 @@ Never bypass Ignition to write directly to Modbus or PLC. ``` project-root/ -├── CLAUDE.md ← you are here +├── CLAUDE.md +├── scripts/ ← setup.sh and utility scripts ├── docker/ -│ ├── CLAUDE.md ← Docker Compose patterns +│ ├── CLAUDE.md │ ├── docker-compose.yml │ ├── docker-compose.override.yml +│ ├── gw-build/ ← custom Ignition Dockerfile +│ ├── gw-init/ ← gateway env vars (no secrets) +│ ├── gw-commission/ ← commissioning.json +│ ├── gw-config/ ← gateway runtime config (bind-mounted; runtime dirs gitignored) +│ ├── gw-secret/ ← admin password file (gitignored) │ └── config/ -│ ├── ignition/ -│ ├── postgres/ -│ └── traefik/ +│ ├── postgres/ ← init.sql +│ └── traefik/ ← traefik.yml, dynamic/ ├── ignition/ -│ ├── CLAUDE.md ← Ignition project structure +│ ├── CLAUDE.md +│ ├── ignition-api.md ← Gateway HTTP API reference │ └── project/ │ ├── com.inductiveautomation.perspective/ │ ├── com.inductiveautomation.webdev/ @@ -121,23 +121,14 @@ project-root/ │ ├── script-python/ │ └── global-props/ ├── testing/ -│ ├── CLAUDE.md ← test scenario schema & runner +│ ├── CLAUDE.md │ ├── scenarios/ │ ├── runner/ │ └── results/ ├── webdev/ -│ ├── CLAUDE.md ← WebDev API contract +│ ├── CLAUDE.md │ └── endpoints/ ├── tools/ │ └── (Python 3.10+ external utilities) └── docs/ - └── (project documentation) ``` - -## Module CLAUDE.md Files - -Each subdirectory has its own CLAUDE.md with domain-specific rules: -- `docker/CLAUDE.md` — container orchestration, volumes, networking -- `ignition/CLAUDE.md` — project structure, UDTs, scripting, resources -- `testing/CLAUDE.md` — scenario schema, test runner, results format -- `webdev/CLAUDE.md` — API endpoint contracts, request/response formats \ No newline at end of file diff --git a/README.md b/README.md index 446909e..6b51f3c 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,16 @@ Reusable scaffold for Ignition SCADA + Docker projects, designed to work with Cl - Docker + Docker Compose installed on the host (Ubuntu 24 recommended) - Git repo cloned to the host machine +### Automated (recommended) + +```bash +bash scripts/setup.sh +``` + +The script creates the `proxy` network, sets up `~/.config/ignition-dev/secrets.env`, symlinks `docker/.env` to it, and scaffolds the gateway password file. Edit those two files, then jump to step 4 below. + +### Manual + ### 1. Create the secrets file ```bash @@ -47,7 +57,17 @@ Key variables in `.env`: | `GATEWAY_HOSTNAME` | `ignition.localhost` | Public hostname (Traefik routing) | | `POSTGRES_PASSWORD` | `changeme` | PostgreSQL password | -### 3. Build and start the stack +### 3. Create the external Docker network + +The stack uses an external `proxy` network (shared across projects). Create it once per Docker host: + +```bash +docker network create proxy +``` + +This is a no-op if it already exists. + +### 4. Build and start the stack ```bash cd docker @@ -55,14 +75,14 @@ docker compose build # builds the custom Ignition image docker compose up -d # starts all services in background ``` -### 4. Verify services are healthy +### 5. Verify services are healthy ```bash docker compose ps # all services should show "healthy" or "running" curl -s http://localhost:8088/StatusPing # should return "RUNNING" ``` -### 5. Open the gateway +### 6. Open the gateway - Ignition gateway: http://localhost:8088 - Traefik dashboard: http://localhost:8080 @@ -71,7 +91,7 @@ curl -s http://localhost:8088/StatusPing # should return "RUNNING" > **Note:** On first boot the gateway skips the setup wizard (`commissioning.json` pre-commissions it). > Log in with username `admin` and the password from `gw-secret/GATEWAY_ADMIN_PASSWORD`. -### 6. Connect Ignition Designer +### 7. Connect Ignition Designer Open Ignition Designer Launcher, add gateway at `http://:8088`, and open the **Framework** project. The project files in `ignition/project/` are live-mounted — changes saved in Designer write directly to Git-tracked files. @@ -85,6 +105,7 @@ See [CLAUDE.md](CLAUDE.md) for project directives, canonical patterns, and direc ``` ├── CLAUDE.md ← root directives (Claude Code reads this automatically) +├── scripts/ ← setup and utility scripts ├── docker/ ← Docker Compose stack and configs ├── ignition/ ← Ignition project (mounted into container) ├── testing/ ← test scenarios, runner, results diff --git a/docker/config/traefik/traefik.yml b/docker/config/traefik/traefik.yml index 512daf4..857cb16 100644 --- a/docker/config/traefik/traefik.yml +++ b/docker/config/traefik/traefik.yml @@ -15,5 +15,11 @@ providers: directory: /etc/traefik/dynamic watch: true +# Suppress encoded-character split-view warning (Ignition is RFC 3986 compliant) +core: + defaultRuleSyntax: v3 + log: level: INFO + +ping: {} diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 8c419ef..67b9ed9 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -39,7 +39,8 @@ services: volumes: - ignition-data:/usr/local/bin/ignition/data - ./gw-config:/usr/local/bin/ignition/data/config:rw - - ./gw-commission/commissioning.json:/usr/local/bin/ignition/data/commissioning.json:ro + # commissioning.json disabled — EULA hash is version-specific; let env vars handle it + # - ./gw-commission/commissioning.json:/usr/local/bin/ignition/data/commissioning.json:ro - ../ignition/project:/usr/local/bin/ignition/data/projects/framework:rw healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8088/StatusPing"] @@ -108,6 +109,7 @@ services: networks: proxy: name: proxy + external: true secrets: gateway-admin-password: diff --git a/ignition/CLAUDE.md b/ignition/CLAUDE.md index b05b86e..4942c87 100644 --- a/ignition/CLAUDE.md +++ b/ignition/CLAUDE.md @@ -108,6 +108,19 @@ Changing the PLC program only requires updating `LinkPath` and `LinkData`. ## Scripting +### Jython 2.7 Constraints + +All scripts in `ignition/project/` run Jython 2.7. Violating these causes runtime errors: + +- NO f-strings → use `"text {}".format(val)` or `"text %s" % val` +- NO walrus operator `:=` +- NO `pathlib` → use `os.path` +- NO type hints → no `def foo(x: int) -> str:` +- NO `dataclasses`, `enum.auto()`, `asyncio` +- NO dictionary unpacking `{**d1, **d2}` → use `d1.update(d2)` +- NO `yield from` → use explicit loops +- Imports: `system.*` for Ignition API, standard Java/Jython libs only + ### Project Library Scripts (`script-python/`) Jython 2.7 — see root CLAUDE.md for language constraints. diff --git a/ignition/ignition-api.md b/ignition/ignition-api.md new file mode 100644 index 0000000..2391fdb --- /dev/null +++ b/ignition/ignition-api.md @@ -0,0 +1,434 @@ +# Ignition Gateway HTTP API — Reference + +Parent: [../CLAUDE.md](../CLAUDE.md) + +Live spec: `http://:8088/openapi` (UI) · `http://:8088/openapi.json` (JSON) + +--- + +## Authentication + +All requests require an API token in a custom header: + +``` +X-Ignition-API-Token: : +``` + +The `` is the token's display name in the gateway. Tokens are managed at +**Platform > Security > API Keys** in the gateway web UI, or via the config API. + +- **GET requests** are not recorded in the audit log. +- **POST / PUT / DELETE** are recorded in the audit log — use with intent. +- Store tokens in `~/.config/ignition-dev/secrets.env` as `IGNITION_API_TOKEN=:`. + Never hardcode tokens in scripts or commit them. + +```bash +TOKEN="Claude:0Pda7AWJeXENyHlvqi74ORZ20BeWtaDzk2L3WIsaUZs" +GW="http://localhost:8088" +curl -s -H "X-Ignition-API-Token: $TOKEN" $GW/data/api/v1/overview +``` + +--- + +## Gateway Status + +```bash +# General status — uptime, edition, state +GET /data/api/v1/overview + +# All connections (DB, OPC, device) +GET /data/api/v1/overview/connections + +# Critical problems blocking normal operation +GET /data/api/v1/overview/problems + +# Gateway name only +GET /data/api/v1/overview/name + +# Detailed gateway info +GET /data/api/v1/gateway-info +``` + +--- + +## Scan / Filesystem Sync + +Ignition watches mounted directories for changes, but an explicit scan guarantees +immediate pickup. **Always scan after editing project or config files.** + +```bash +# Trigger project scan — run after editing ignition/project/ files +POST /data/api/v1/scan/projects + +# Check project scan status +GET /data/api/v1/scan/projects + +# Trigger config scan — run after editing gw-config/ files +POST /data/api/v1/scan/config + +# Check config scan status +GET /data/api/v1/scan/config +``` + +### Scan Lock (bulk operations) + +Acquire a scan lock before making multiple changes to prevent partial-state scans. +The lock releases automatically when the POST scan is triggered. + +```bash +# Acquire lock (body: {"timeoutSeconds": 60}) +POST /data/api/v1/scan-lock/projects +POST /data/api/v1/scan-lock/config + +# Check current lock holder +GET /data/api/v1/scan-lock/projects +GET /data/api/v1/scan-lock/config +``` + +--- + +## Resource API Pattern + +Almost all gateway configuration uses a common CRUD pattern: + +``` +GET /data/api/v1/resources/list/{moduleId}/{typeId} list all (verbose) +GET /data/api/v1/resources/names/{moduleId}/{typeId} names + enabled status +GET /data/api/v1/resources/find/{moduleId}/{typeId}/{name} get one + signature +GET /data/api/v1/resources/type/{moduleId}/{typeId} describe schema +POST /data/api/v1/resources/{moduleId}/{typeId} create +PUT /data/api/v1/resources/{moduleId}/{typeId} modify (requires signature) +DELETE /data/api/v1/resources/{moduleId}/{typeId}/{name}/{sig} delete +POST /data/api/v1/resources/rename/{moduleId}/{typeId}/{name} rename +``` + +> **Always GET before PUT/DELETE** — the `signature` field from a GET response is required +> for modify and delete operations. It changes whenever the resource is updated. + +### Key moduleId / typeId pairs + +| Resource | moduleId | typeId | +|---|---|---| +| Database connection | `ignition` | `database-connection` | +| OPC connection | `ignition` | `opc-connection` | +| Tag provider | `ignition` | `tag-provider` | +| OPC-UA device | `com.inductiveautomation.opcua` | `device` | +| OPC-UA server config | `com.inductiveautomation.opcua` | `server-config` | +| OPC-UA access control | `com.inductiveautomation.opcua` | `access-control` | + +### Common query parameters + +| Param | Example | Description | +|---|---|---| +| `limit` | `25` | Max items to return | +| `offset` | `50` | Items to skip | +| `sortBy` | `asc(name)` | Sort field + direction | +| `search` | `postgres` | Free-text filter | +| `filter[field[op]]` | `filter[enabled[eq]]=true` | Field filter | + +Filter operators: `eq` `ne` `cn` `sw` `ew` `gt` `gte` `lt` `lte` `rgx` + +--- + +## Database Connections + +```bash +# List all database connections +GET /data/api/v1/resources/list/ignition/database-connection + +# Get one (returns config + signature) +GET /data/api/v1/resources/find/ignition/database-connection/{name} + +# Create a PostgreSQL connection +POST /data/api/v1/resources/ignition/database-connection +Content-Type: application/json +{ + "name": "ignition_db", + "enabled": true, + "props": { + "ConnectURL": "jdbc:postgresql://postgres:5432/ignition", + "Username": "ignition", + "Password": "", + "ValidateOnCheckout": true + } +} + +# Modify (requires signature from GET) +PUT /data/api/v1/resources/ignition/database-connection +{ "name": "ignition_db", "signature": "", "props": { ... } } + +# Delete +DELETE /data/api/v1/resources/ignition/database-connection/{name}/{signature} + +# Describe schema (shows all available props) +GET /data/api/v1/resources/type/ignition/database-connection +``` + +--- + +## OPC Connections + +Used to connect Ignition to Modbus simulators and AB PLC controllers via OPC-UA. + +```bash +# List all OPC connections +GET /data/api/v1/resources/list/ignition/opc-connection + +# Get one +GET /data/api/v1/resources/find/ignition/opc-connection/{name} + +# Create an OPC-UA connection to a Modbus sim +POST /data/api/v1/resources/ignition/opc-connection +{ + "name": "modbus-sim-pumps", + "enabled": true, + "props": { + "EndpointUrl": "opc.tcp://modbus-sim-pumps:4840", + "SecurityPolicy": "None", + "MessageSecurity": "None" + } +} + +# Describe schema +GET /data/api/v1/resources/type/ignition/opc-connection +``` + +--- + +## OPC-UA Devices + +OPC-UA device driver instances (Allen-Bradley, Modbus TCP, etc.). + +```bash +# List all devices +GET /data/api/v1/resources/list/com.inductiveautomation.opcua/device + +# Get one +GET /data/api/v1/resources/find/com.inductiveautomation.opcua/device/{name} + +# Describe available device types and their properties +GET /data/api/v1/resources/type/com.inductiveautomation.opcua/device + +# Create (body varies by device type — use type endpoint to discover props) +POST /data/api/v1/resources/com.inductiveautomation.opcua/device +``` + +--- + +## Tag Providers + +```bash +# List tag providers +GET /data/api/v1/resources/list/ignition/tag-provider + +# Get one +GET /data/api/v1/resources/find/ignition/tag-provider/{name} + +# Describe schema +GET /data/api/v1/resources/type/ignition/tag-provider + +# Create / modify follow the standard resource pattern +POST /data/api/v1/resources/ignition/tag-provider +PUT /data/api/v1/resources/ignition/tag-provider +``` + +--- + +## Tag Import / Export + +Primary mechanism for bulk tag management outside of Designer. + +```bash +# Export tags as JSON (default), XML, or CSV +GET /data/api/v1/tags/export?provider=default&path=Devices&recursive=true&type=json +# Save to file: +curl -s -H "X-Ignition-API-Token: $TOKEN" \ + "$GW/data/api/v1/tags/export?provider=default&path=Devices&recursive=true" \ + -o tags-export.json + +# Import tags +# collisionPolicy: Abort | Overwrite | Ignore | MergeOverwrite | MergeIgnore +POST /data/api/v1/tags/import?provider=default&path=Devices&collisionPolicy=Overwrite +Content-Type: application/json + +``` + +--- + +## Projects + +```bash +# List all projects +GET /data/api/v1/projects/list + +# Get project details +GET /data/api/v1/projects/find/{name} + +# Create a project +POST /data/api/v1/projects +{ "name": "my-project", "title": "My Project", "enabled": true } + +# Modify a project +PUT /data/api/v1/projects/{name} + +# Export project as zip archive +GET /data/api/v1/projects/export/{name} + +# Import project from zip +POST /data/api/v1/projects/import/{name}?overwrite=true +Content-Type: application/octet-stream + + +# Delete project +DELETE /data/api/v1/projects/{name}?confirm=true +``` + +After creating or modifying projects via file edits, trigger a scan: +```bash +curl -s -X POST -H "X-Ignition-API-Token: $TOKEN" $GW/data/api/v1/scan/projects +``` + +--- + +## Perspective Sessions + +```bash +# List all active Perspective sessions +GET /data/perspective/api/v1/sessions/ + +# Get session details +GET /data/perspective/api/v1/session/{sessionId} + +# List pages in a session +GET /data/perspective/api/v1/session/{sessionId}/pages + +# List views on a page +GET /data/perspective/api/v1/session/{sessionId}/page/{pageId}/views + +# Terminate session(s) +DELETE /data/perspective/api/v1/sessions?sessionId={id}&message=Maintenance +``` + +--- + +## Modules + +```bash +# List all healthy (non-quarantined) modules +GET /data/api/v1/modules/healthy + +# List quarantined modules +GET /data/api/v1/modules/quarantined + +# Enable or disable a module +PUT /data/api/v1/modules/toggle-state +{ "moduleId": "com.inductiveautomation.webdev", "enabled": true } + +# Upload a module file (.modl) +POST /data/api/v1/modules/upload?fileName=MyModule.modl +Content-Type: application/octet-stream + +# Install after upload +POST /data/api/v1/modules/install?moduleId=com.example.mymodule + +# Accept EULA +POST /data/api/v1/modules/eula?moduleId=com.example.mymodule + +# Uninstall (takes effect after restart) +DELETE /data/api/v1/modules/uninstall +{ "moduleIds": ["com.example.mymodule"] } +``` + +--- + +## Logs + +```bash +# Query gateway logs +GET /data/api/v1/logs?minLevel=WARN&limit=100 + +# Filter by logger name +GET /data/api/v1/logs?logger=IgnitionGateway&limit=50 + +# Filter by time range (ISO 8601) +GET /data/api/v1/logs?startTime=2026-03-17T00:00:00Z&endTime=2026-03-17T23:59:59Z + +# List all loggers and their current levels +GET /data/api/v1/logs/loggers + +# Set a logger level temporarily +POST /data/api/v1/logs/loggers/{loggerName}?level=DEBUG + +# Download full log file +GET /data/api/v1/logs/download +``` + +--- + +## Audit Log + +```bash +# Query audit events for a profile (default profile name: "Audit") +GET /data/api/v1/audit/log/Audit?limit=50 + +# Filter by actor +GET /data/api/v1/audit/log/Audit?actorFilter=admin + +# Filter by action +GET /data/api/v1/audit/log/Audit?actionFilter=Created +``` + +--- + +## Gateway Backup + +```bash +# Download a .gwbk backup file +curl -s -H "X-Ignition-API-Token: $TOKEN" \ + "$GW/data/api/v1/backup" \ + -o "gateway-$(date +%Y%m%d-%H%M%S).gwbk" + +# Restore from backup (gateway will restart) +POST /data/api/v1/backup?restoreDisabled=false +Content-Type: multipart/form-data + +``` + +--- + +## Gateway Restart ⚠️ + +**Disruptive — all sessions will be terminated. Get explicit user confirmation first.** + +```bash +# Check if a restart is needed (pending tasks) +GET /data/api/v1/restart-tasks/pending + +# Restart — confirm=true is required +curl -s -X POST -H "X-Ignition-API-Token: $TOKEN" \ + "$GW/data/api/v1/restart-tasks/restart?confirm=true" +``` + +Per `CLAUDE.md` safety rules: never restart the gateway without explicit user confirmation. + +--- + +## API Token Management + +```bash +# Generate a new key/hash pair (store the key — it is not retrievable again) +POST /data/api/v1/api-token/generate + +# List existing API token configs +GET /data/api/v1/resources/list/ignition/api-token + +# Create an API token resource +POST /data/api/v1/resources/ignition/api-token +{ + "name": "MyToken", + "props": { + "Enabled": true, + "RateLimit": -1 + } +} +``` diff --git a/scripts/install-hooks.sh b/scripts/install-hooks.sh new file mode 100644 index 0000000..6021560 --- /dev/null +++ b/scripts/install-hooks.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# install-hooks.sh — install git hooks for this repo. +# Called automatically by setup.sh; safe to re-run. +# Usage: bash scripts/install-hooks.sh + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +HOOKS_DIR="$REPO_ROOT/.git/hooks" + +if [[ ! -d "$HOOKS_DIR" ]]; then + echo "ERROR: .git/hooks directory not found. Are you in a git repo?" + exit 1 +fi + +# ── post-push: sync markdown docs to WikiJS ─────────────────────────────────── +POST_PUSH="$HOOKS_DIR/post-push" + +cat > "$POST_PUSH" << 'EOF' +#!/usr/bin/env bash +# post-push hook — sync markdown files to WikiJS after every push. +cd "$(git rev-parse --show-toplevel)" +if command -v python3 &>/dev/null; then + python3 tools/publish_docs.py || echo "[wikijs] publish failed — run manually: python3 tools/publish_docs.py" +else + echo "[wikijs] python3 not found — skipping doc sync" +fi +EOF + +chmod +x "$POST_PUSH" +echo " Installed post-push hook → $POST_PUSH" diff --git a/scripts/setup.sh b/scripts/setup.sh new file mode 100644 index 0000000..5d58404 --- /dev/null +++ b/scripts/setup.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# setup.sh — first-time environment bootstrap for the Ignition + Docker framework. +# Run once after cloning. Safe to re-run (all steps are idempotent). +# Usage: bash scripts/setup.sh + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +DOCKER_DIR="$REPO_ROOT/docker" +SECRETS_DIR="$HOME/.config/ignition-dev" +SECRETS_FILE="$SECRETS_DIR/secrets.env" + +echo "=== Ignition + Docker Framework — First-Time Setup ===" + +# ── 1. Create the external proxy network ──────────────────────────────────── +echo "" +echo "[1/4] Docker proxy network..." +if docker network inspect proxy &>/dev/null; then + echo " proxy network already exists — skipping." +else + docker network create proxy + echo " Created proxy network." +fi + +# ── 2. Secrets file at ~/.config/ignition-dev/secrets.env ─────────────────── +echo "" +echo "[2/4] Secrets file..." +if [[ -f "$SECRETS_FILE" ]]; then + echo " $SECRETS_FILE already exists — skipping." +else + mkdir -p "$SECRETS_DIR" + cp "$DOCKER_DIR/.env.example" "$SECRETS_FILE" + chmod 600 "$SECRETS_FILE" + echo " Created $SECRETS_FILE (chmod 600)." + echo " ACTION REQUIRED: Edit $SECRETS_FILE and set real passwords." +fi + +# ── 3. Symlink docker/.env → secrets file ─────────────────────────────────── +echo "" +echo "[3/4] docker/.env symlink..." +ENV_LINK="$DOCKER_DIR/.env" +if [[ -L "$ENV_LINK" && "$(readlink "$ENV_LINK")" == "$SECRETS_FILE" ]]; then + echo " Symlink already correct — skipping." +elif [[ -f "$ENV_LINK" && ! -L "$ENV_LINK" ]]; then + echo " WARNING: $ENV_LINK exists as a regular file, not a symlink." + echo " It will not be replaced automatically. Inspect and remove it if safe." +else + ln -sf "$SECRETS_FILE" "$ENV_LINK" + echo " Linked docker/.env → $SECRETS_FILE" +fi + +# ── 4. Gateway admin password file ────────────────────────────────────────── +echo "" +echo "[4/5] Gateway admin password..." +PW_FILE="$DOCKER_DIR/gw-secret/GATEWAY_ADMIN_PASSWORD" +PW_EXAMPLE="$DOCKER_DIR/gw-secret/GATEWAY_ADMIN_PASSWORD.example" +if [[ -f "$PW_FILE" && "$(cat "$PW_FILE")" != "changeme" ]]; then + echo " Password file already set — skipping." +else + if [[ ! -f "$PW_FILE" ]]; then + cp "$PW_EXAMPLE" "$PW_FILE" + echo " Created $PW_FILE from example." + fi + echo " ACTION REQUIRED: Edit docker/gw-secret/GATEWAY_ADMIN_PASSWORD and set a real password." +fi + +# ── 5. Git hooks ───────────────────────────────────────────────────────────── +echo "" +echo "[5/5] Git hooks..." +bash "$REPO_ROOT/scripts/install-hooks.sh" + +# ── Done ───────────────────────────────────────────────────────────────────── +echo "" +echo "=== Setup complete ===" +echo "" +echo "Next steps:" +echo " 1. Edit $SECRETS_FILE (set passwords, WIKIJS_API_KEY)" +echo " 2. Edit docker/gw-secret/GATEWAY_ADMIN_PASSWORD (gateway admin password)" +echo " 3. cd docker && docker compose build && docker compose up -d" +echo " 4. curl -s http://localhost:8088/StatusPing # should return RUNNING" +echo " 5. python3 tools/publish_docs.py --dry-run # verify WikiJS doc sync" diff --git a/tools/publish_docs.py b/tools/publish_docs.py new file mode 100644 index 0000000..1f9f652 --- /dev/null +++ b/tools/publish_docs.py @@ -0,0 +1,200 @@ +# env: python3.10+ (external tooling — NOT Ignition) +""" +publish_docs.py — sync selected markdown files to WikiJS via GraphQL API. + +Reads WIKIJS_API_KEY and WIKIJS_URL from the environment (or docker/.env via python-dotenv). +Run manually or via the git post-push hook installed by scripts/install-hooks.sh. + +Usage: + python3 tools/publish_docs.py # sync all files + python3 tools/publish_docs.py --dry-run # preview only, no API calls +""" + +import argparse +import os +import sys +from pathlib import Path + +import requests +from dotenv import load_dotenv + +# ── Config ──────────────────────────────────────────────────────────────────── + +REPO_ROOT = Path(__file__).parent.parent + +# Load secrets from docker/.env (symlink to ~/.config/ignition-dev/secrets.env) +load_dotenv(REPO_ROOT / "docker" / ".env") + +WIKIJS_URL = os.environ.get("WIKIJS_URL", "https://wikijs.primecontrols-dev.com") +WIKIJS_API_KEY = os.environ["WIKIJS_API_KEY"] # loud failure if missing + +GRAPHQL_ENDPOINT = f"{WIKIJS_URL.rstrip('/')}/graphql" +LOCALE = "en" + +# Maps repo-relative file path → WikiJS page path (no leading slash) +MANIFEST: dict[str, str] = { + "README.md": "en/engineering/AI-Framework/home", + "CLAUDE.md": "en/engineering/AI-Framework/CLAUDE", + "docker/CLAUDE.md": "en/engineering/AI-Framework/docker", + "ignition/CLAUDE.md": "en/engineering/AI-Framework/ignition", + "testing/CLAUDE.md": "en/engineering/AI-Framework/testing", + "webdev/CLAUDE.md": "en/engineering/AI-Framework/webdev", + "ignition/ignition-api.md": "en/engineering/AI-Framework/ignition/ignition-api", + "docker/config/traefik/dynamic/README.md": "en/engineering/AI-Framework/docker/traefik", +} + + +# ── Helpers ─────────────────────────────────────────────────────────────────── + +def gql(query: str, variables: dict | None = None) -> dict: + resp = requests.post( + GRAPHQL_ENDPOINT, + json={"query": query, "variables": variables or {}}, + headers={ + "Authorization": f"Bearer {WIKIJS_API_KEY}", + "Content-Type": "application/json", + }, + timeout=15, + ) + resp.raise_for_status() + data = resp.json() + if "errors" in data: + raise RuntimeError(f"GraphQL errors: {data['errors']}") + return data + + +def page_id_for_path(wiki_path: str) -> int | None: + """Return the page ID for an existing WikiJS page, or None if not found.""" + query = """ + query ($path: String!, $locale: String!) { + pages { + singleByPath(path: $path, locale: $locale) { + id + } + } + } + """ + try: + data = gql(query, {"path": wiki_path, "locale": LOCALE}) + page = data["data"]["pages"]["singleByPath"] + return page["id"] if page else None + except RuntimeError: + return None + + +def derive_title(local_path: str) -> str: + stem = Path(local_path).stem # e.g. "ignition-api" + if stem.upper() == "README": + # Use parent directory name for README files + parent = Path(local_path).parent.name + if parent == ".": + return "Home" + return parent.replace("-", " ").replace("_", " ").title() + return stem.replace("-", " ").replace("_", " ").title() + + +def create_page(wiki_path: str, title: str, content: str) -> None: + mutation = """ + mutation ($path: String!, $title: String!, $content: String!, $locale: String!) { + pages { + create( + path: $path + title: $title + content: $content + locale: $locale + editor: "markdown" + isPublished: true + isPrivate: false + tags: [] + description: "" + ) { + responseResult { + succeeded + errorCode + message + } + } + } + } + """ + data = gql(mutation, {"path": wiki_path, "title": title, "content": content, "locale": LOCALE}) + result = data["data"]["pages"]["create"]["responseResult"] + if not result["succeeded"]: + raise RuntimeError(f"Create failed [{result['errorCode']}]: {result['message']}") + + +def update_page(page_id: int, title: str, content: str) -> None: + mutation = """ + mutation ($id: Int!, $title: String!, $content: String!) { + pages { + update( + id: $id + title: $title + content: $content + editor: "markdown" + isPublished: true + isPrivate: false + tags: [] + description: "" + ) { + responseResult { + succeeded + errorCode + message + } + } + } + } + """ + data = gql(mutation, {"id": page_id, "title": title, "content": content}) + result = data["data"]["pages"]["update"]["responseResult"] + if not result["succeeded"]: + raise RuntimeError(f"Update failed [{result['errorCode']}]: {result['message']}") + + +# ── Main ────────────────────────────────────────────────────────────────────── + +def main() -> int: + parser = argparse.ArgumentParser(description="Sync markdown files to WikiJS") + parser.add_argument("--dry-run", action="store_true", help="Preview only, no API calls") + args = parser.parse_args() + + if args.dry_run: + print("Dry run — no changes will be made.\n") + + errors = 0 + + for local_rel, wiki_path in MANIFEST.items(): + local_file = REPO_ROOT / local_rel + title = derive_title(local_rel) + + if not local_file.exists(): + print(f"[SKIP] {local_rel} (file not found)") + continue + + if args.dry_run: + print(f"[DRY-RUN] {local_rel} → {WIKIJS_URL}/{wiki_path} (title: {title!r})") + continue + + content = local_file.read_text(encoding="utf-8") + + try: + page_id = page_id_for_path(wiki_path) + if page_id is None: + create_page(wiki_path, title, content) + print(f"[CREATED] {local_rel} → /{wiki_path}") + else: + update_page(page_id, title, content) + print(f"[OK] {local_rel} → /{wiki_path}") + except Exception as exc: + print(f"[ERROR] {local_rel}: {exc}", file=sys.stderr) + errors += 1 + + if errors: + print(f"\n{errors} file(s) failed.", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main())