From 2448fbf2fb301c6897be07c555f7a28591e617c8 Mon Sep 17 00:00:00 2001 From: Zhongwei Li Date: Sat, 29 Nov 2025 17:58:35 +0800 Subject: [PATCH] Initial commit --- .claude-plugin/plugin.json | 14 + README.md | 3 + commands/add-iot-edge-module.md | 9 + plugin.lock.json | 129 +++ skills/iot-edge-module/SKILL.md | 776 ++++++++++++++++++ .../assets/template-.dockerignore | 3 + .../assets/template-.gitignore | 34 + .../assets/template-Dockerfile.amd64 | 33 + .../assets/template-Dockerfile.amd64.debug | 38 + .../assets/template-GlobalUsings.cs | 10 + .../template-LoggingBuilderExtensions.cs | 22 + .../assets/template-ModuleConstants.cs | 9 + .../assets/template-Program.cs | 35 + .../assets/template-Service.cs | 59 ++ .../assets/template-ServiceLoggerMessages.cs | 45 + .../template-base.deployment.manifest.json | 111 +++ .../assets/template-launchSettings.json | 13 + .../assets/template-module.json | 17 + skills/iot-edge-module/assets/template.csproj | 32 + .../references/deployment-manifests.md | 238 ++++++ .../references/module-structure.md | 292 +++++++ .../scripts/detect_project_structure.py | 291 +++++++ .../scripts/manage_solution.py | 286 +++++++ .../iot-edge-module/scripts/scan_manifests.py | 138 ++++ .../scripts/update_deployment_manifest.py | 303 +++++++ 25 files changed, 2940 insertions(+) create mode 100644 .claude-plugin/plugin.json create mode 100644 README.md create mode 100644 commands/add-iot-edge-module.md create mode 100644 plugin.lock.json create mode 100644 skills/iot-edge-module/SKILL.md create mode 100644 skills/iot-edge-module/assets/template-.dockerignore create mode 100644 skills/iot-edge-module/assets/template-.gitignore create mode 100644 skills/iot-edge-module/assets/template-Dockerfile.amd64 create mode 100644 skills/iot-edge-module/assets/template-Dockerfile.amd64.debug create mode 100644 skills/iot-edge-module/assets/template-GlobalUsings.cs create mode 100644 skills/iot-edge-module/assets/template-LoggingBuilderExtensions.cs create mode 100644 skills/iot-edge-module/assets/template-ModuleConstants.cs create mode 100644 skills/iot-edge-module/assets/template-Program.cs create mode 100644 skills/iot-edge-module/assets/template-Service.cs create mode 100644 skills/iot-edge-module/assets/template-ServiceLoggerMessages.cs create mode 100644 skills/iot-edge-module/assets/template-base.deployment.manifest.json create mode 100644 skills/iot-edge-module/assets/template-launchSettings.json create mode 100644 skills/iot-edge-module/assets/template-module.json create mode 100644 skills/iot-edge-module/assets/template.csproj create mode 100644 skills/iot-edge-module/references/deployment-manifests.md create mode 100644 skills/iot-edge-module/references/module-structure.md create mode 100644 skills/iot-edge-module/scripts/detect_project_structure.py create mode 100644 skills/iot-edge-module/scripts/manage_solution.py create mode 100644 skills/iot-edge-module/scripts/scan_manifests.py create mode 100644 skills/iot-edge-module/scripts/update_deployment_manifest.py diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 0000000..65882a5 --- /dev/null +++ b/.claude-plugin/plugin.json @@ -0,0 +1,14 @@ +{ + "name": "azure-iot", + "description": "Azure IoT services automation and scaffolding for IoT Edge modules, IoT Hub, and related services", + "version": "1.0.0", + "author": { + "name": "atc-net" + }, + "skills": [ + "./skills" + ], + "commands": [ + "./commands" + ] +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..4cb016e --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# azure-iot + +Azure IoT services automation and scaffolding for IoT Edge modules, IoT Hub, and related services diff --git a/commands/add-iot-edge-module.md b/commands/add-iot-edge-module.md new file mode 100644 index 0000000..9fcc913 --- /dev/null +++ b/commands/add-iot-edge-module.md @@ -0,0 +1,9 @@ +# Add IoT Edge Module + +DO NOT output any text. Immediately invoke the Skill tool with skill name "azure-iot:iot-edge-module" without any preamble or explanation. The skill will handle all communication with the user. + +The user has provided: +- Module name: {{ARG1}} +- Module description: {{ARG2}} + +The skill will handle all scaffolding, configuration detection, and user interaction. diff --git a/plugin.lock.json b/plugin.lock.json new file mode 100644 index 0000000..9ab2daa --- /dev/null +++ b/plugin.lock.json @@ -0,0 +1,129 @@ +{ + "$schema": "internal://schemas/plugin.lock.v1.json", + "pluginId": "gh:atc-net/atc-agentic-toolkit:.claude/plugins/azure-iot", + "normalized": { + "repo": null, + "ref": "refs/tags/v20251128.0", + "commit": "39470c01345508768838f4ba78c8b8cf6e9425b5", + "treeHash": "edbe6b86c177fd35d4d0624dc2dae6c65fd9d6fa2bdbc1cd92467947c8317368", + "generatedAt": "2025-11-28T10:13:58.918699Z", + "toolVersion": "publish_plugins.py@0.2.0" + }, + "origin": { + "remote": "git@github.com:zhongweili/42plugin-data.git", + "branch": "master", + "commit": "aa1497ed0949fd50e99e70d6324a29c5b34f9390", + "repoRoot": "/Users/zhongweili/projects/openmind/42plugin-data" + }, + "manifest": { + "name": "azure-iot", + "description": "Azure IoT services automation and scaffolding for IoT Edge modules, IoT Hub, and related services", + "version": "1.0.0" + }, + "content": { + "files": [ + { + "path": "README.md", + "sha256": "def862cd6e8e3bb1e270a31ef8b871ba9ea0e17429fb147e740f6805ca4ead38" + }, + { + "path": ".claude-plugin/plugin.json", + "sha256": "6b78aaabfddf874157210a76bfb862e70ca8b4fcaf87236954889e1031ec3388" + }, + { + "path": "commands/add-iot-edge-module.md", + "sha256": "6991d0c024f8e843e931463e0b34d4b74e2cc28a0af8b654c45eee6949a5ae36" + }, + { + "path": "skills/iot-edge-module/SKILL.md", + "sha256": "a390e6d4a1aab9ee1c700b0803766c7078bd004ba4a03af3a100d17f9920ee71" + }, + { + "path": "skills/iot-edge-module/references/deployment-manifests.md", + "sha256": "7d519cdbbd4664466c88e062bcd70a3c984a15562e2eec4663be1f9ca85788ba" + }, + { + "path": "skills/iot-edge-module/references/module-structure.md", + "sha256": "98c0fe4306c09936cef6752b6c8530311b30f134368bd600f99d32c700da10e8" + }, + { + "path": "skills/iot-edge-module/scripts/manage_solution.py", + "sha256": "6afaffe2c2a2e24f96495f75971d62e46765e2b4962c2d2714a46e5d46533238" + }, + { + "path": "skills/iot-edge-module/scripts/detect_project_structure.py", + "sha256": "239e1bb748342a5a1610e0488e3a158d9a301627a421a574ec5b4d62360802ae" + }, + { + "path": "skills/iot-edge-module/scripts/scan_manifests.py", + "sha256": "bbfe7a9ce7d93d1410e34392ecb3fdeed3d7b1be8ab9c70413abccb54e10d251" + }, + { + "path": "skills/iot-edge-module/scripts/update_deployment_manifest.py", + "sha256": "91b978218ec39bf0c1d4ce2f4d67e8a06249a6f50f751ca9c84b2350f512a12a" + }, + { + "path": "skills/iot-edge-module/assets/template-.gitignore", + "sha256": "1cf9ba714f125eb59c0df3d9e841f6351fe9b4166e8188f1aeca076f2ad68556" + }, + { + "path": "skills/iot-edge-module/assets/template-LoggingBuilderExtensions.cs", + "sha256": "36a0f8700cfc4a101d22157519b4daa9a9598ffa48da425816ece571d693d14e" + }, + { + "path": "skills/iot-edge-module/assets/template-launchSettings.json", + "sha256": "d976ce544625055819a31aad85d4309a295788dfac23127bda94f940af639c16" + }, + { + "path": "skills/iot-edge-module/assets/template-Program.cs", + "sha256": "285e417f80fa5d42718c6aa2cb33c296babdbc636937b081bc2be731525de50c" + }, + { + "path": "skills/iot-edge-module/assets/template-.dockerignore", + "sha256": "5212ec08face4bac0198a06e9c8320d3570e29add2b86b53c31505bd4853252c" + }, + { + "path": "skills/iot-edge-module/assets/template-GlobalUsings.cs", + "sha256": "f27b180d757272907ecbc438d5c68cef705b667c6f155619f55b7ceda00b0232" + }, + { + "path": "skills/iot-edge-module/assets/template.csproj", + "sha256": "21a4239cd41dad5e63f1d7feca49e1e8f96feb75447da3e3a97cffea8b1388db" + }, + { + "path": "skills/iot-edge-module/assets/template-Dockerfile.amd64.debug", + "sha256": "bbe62c83ab54215e82a5680bbd633033098c7e5bf2add99fffeff86bce8488c3" + }, + { + "path": "skills/iot-edge-module/assets/template-Service.cs", + "sha256": "e29924bb4a2ab8222f827928ad769fc250514f13871ae5b70ec268eba102a1de" + }, + { + "path": "skills/iot-edge-module/assets/template-ModuleConstants.cs", + "sha256": "67aae94fed55dcb85abd5710a3bfdc6a4d7f5ee3f71e3eb63732ada4a1c85e67" + }, + { + "path": "skills/iot-edge-module/assets/template-Dockerfile.amd64", + "sha256": "53ffda3250ac1ae2590aecbb2f3240e76e92d619458ebffafc2f6c959a71c127" + }, + { + "path": "skills/iot-edge-module/assets/template-ServiceLoggerMessages.cs", + "sha256": "3c0ad3e0133fd80f4be94603e89c8372497e01f9938afcd7b1f3cd698edbb260" + }, + { + "path": "skills/iot-edge-module/assets/template-base.deployment.manifest.json", + "sha256": "5263ac57350ee0ed4982bebdd5052d0f22345909b758ae3552d47921eda51b24" + }, + { + "path": "skills/iot-edge-module/assets/template-module.json", + "sha256": "6a61be7a3d4de16b2612cc6754648d72bee6e8511ef1d2ba875421fe7f3a1478" + } + ], + "dirSha256": "edbe6b86c177fd35d4d0624dc2dae6c65fd9d6fa2bdbc1cd92467947c8317368" + }, + "security": { + "scannedAt": null, + "scannerVersion": null, + "flags": [] + } +} \ No newline at end of file diff --git a/skills/iot-edge-module/SKILL.md b/skills/iot-edge-module/SKILL.md new file mode 100644 index 0000000..00b4c18 --- /dev/null +++ b/skills/iot-edge-module/SKILL.md @@ -0,0 +1,776 @@ +--- +name: iot-edge-module +description: This skill should be used when creating a new Azure IoT Edge module. Use when the user requests to scaffold, create, or set up a new edge module. The skill automates module scaffolding, manifest configuration, and shared contract generation with intelligent project structure detection. +--- + +# IoT Edge Module Scaffolding Skill + +This skill automates the creation of new Azure IoT Edge modules. Use this skill to scaffold a complete module structure including source code, Docker configuration, deployment manifests, and shared contracts. + +## Execution Environment + +**IMPORTANT: All bash commands MUST use Unix syntax regardless of platform.** + +- Always use Unix bash syntax (e.g., `test -d`, `[ -f ]`, etc.) +- NEVER use Windows CMD syntax (e.g., `if exist`, `dir`, etc.) +- NEVER pipe commands to null (`2>/dev/null`, `2>nul`, etc.) - let all output and errors show naturally +- Bash is available on all platforms: native on Linux/macOS, via WSL/Git Bash on Windows +- Python scripts are cross-platform and handle path normalization internally + +## Communication Guidelines + +**Be concise and avoid stating obvious or expected behavior:** + +- Don't explain why assets are in the plugin directory (this is expected) +- Don't describe where script files "should" be located (this is obvious) +- Focus output on actionable information, progress, and actual errors only +- Avoid verbose explanations of normal/expected conditions + +## When to Use This Skill + +Trigger this skill when the user requests to: + +- Create a new IoT Edge module +- Scaffold an edge module +- Set up a new module for IoT Edge +- Add a new module to the edge deployment + +## Prerequisites + +Before scaffolding a module, gather the following information from the user: + +1. **Module name** (PascalCase, e.g., "DataProcessorModule") +2. **Module description** (brief description of module purpose) + +## Scaffolding Process + +Follow these steps in order to scaffold a new IoT Edge module: + +### Step 1: Detect or Load Project Structure + +Run the project structure detection script to identify existing patterns: + +```bash +python scripts/detect_project_structure.py --root . +``` + +### Step 1.5: Verify IoTEdgeModules Folder Structure + +**Check if the IoTEdgeModules folder exists:** + +If the detection script couldn't find `modules_base_path` or it doesn't exist: + +**Prompt user:** + +``` +IoTEdgeModules folder not found. Where should modules be created? + +1. Create at default location: src/IoTEdgeModules/modules/ +2. Specify custom path +3. Cancel scaffolding + +Choose option (1/2/3): +``` + +**If option 1 selected:** + +- Create directory structure: + ``` + src/IoTEdgeModules/ + ├── modules/ + └── config/ + ``` +- Update detected configuration with this path + +**If option 2 selected:** + +- Prompt: "Enter custom IoTEdgeModules path (e.g., edge/modules/):" +- Validate path is reasonable +- Create directory structure at custom location +- Update detected configuration + +**If option 3 selected:** + +- Exit scaffolding with message: "Scaffolding cancelled by user" + +**What this detects:** + +- Modules base path (e.g., `src/IoTEdgeModules/modules`) +- Contracts project path and name +- Deployment manifests location +- Project namespace +- Container registry URL +- NuGet feed URL (if configured) + +**Processing the output:** + +1. Parse the JSON output +2. If `config_source` is `"saved"`, use the saved configuration silently +3. If `config_source` is `"detected"`, present findings to user for confirmation +4. If detection fails or user rejects, prompt for each value manually + +**Confirmation prompt (if detected, not saved):** + +Display the detected configuration to the user in a clear, readable format: + +``` +Detected project structure: +• Modules location: +• Project namespace: +• Container registry: +• Contracts project: () +• Deployment manifests: found +• NuGet feed: +``` + +Then use AskUserQuestion tool to ask for confirmation: + +- **Question**: "Use this detected configuration?" +- **Header**: "Config" +- **Options**: + 1. "Yes, use it" - "Proceed with the detected configuration" + 2. "Save and use" - "Save this configuration for future modules and use it now" + 3. "No, customize" - "Manually specify configuration values instead" + +**If user selects "Save and use":** + +```bash +python scripts/detect_project_structure.py --root . --save +``` + +**If user selects "No, customize" or if detection fails, prompt for:** + +- Project namespace (e.g., "Company.IoT.EdgeAPI") +- Container registry URL (e.g., "myregistry.azurecr.io") +- Modules base path (default: "src/IoTEdgeModules/modules") +- Contracts project name and path (or "none" if not using shared contracts) + +### Step 2: Gather Module-Specific Information + +Ask the user for: + +**Required:** + +1. **Module name** (PascalCase) +2. **Module description** + +**Optional Features:** + +**A. Private NuGet Feed** + +- Ask: "Does this project require a private NuGet feed? (Yes/No)" +- If Yes and not detected: Prompt for NuGet feed URL +- If Yes and detected: Confirm detected URL or allow override + +**B. Shared Contracts Project** + +- If contracts project was detected: Automatically use it for module constants (no prompt needed) +- If not detected: Ask "Do you have a shared contracts project? (Yes/No/Create standalone)" + +### Step 3: Validate and Normalize Module Names + +Convert the user-provided module name to required formats: + +**ModuleName (PascalCase):** + +- Remove "Module" suffix if present, then add it back +- Example: "DataProcessor" → "DataProcessorModule" +- Example: "DataProcessorModule" → "DataProcessorModule" + +**modulename (lowercase):** + +- Convert ModuleName to lowercase +- Example: "DataProcessorModule" → "dataprocessormodule" + +**Confirm with user using AskUserQuestion tool:** + +Present the module details and ask for confirmation: + +- **Question**: "Proceed with creating this module?" +- **Header**: "Confirm Module" +- **Options**: + - "Yes, create module" → Continue to Step 4 + - "No, use different name" → Go back to Step 2 (gather module name) + +Display in the question description: + +``` +Module will be created as: +• C# class name: +• Module ID: +• Directory: // +``` + +**Do NOT assume "Yes" or proceed without using AskUserQuestion tool and getting explicit user confirmation** + +### Step 4: Create Module Directory Structure + +Create the module directory: + +``` +// +``` + +Check if directory already exists (MUST use this exact bash syntax): + +```bash +test -d "/" && echo "EXISTS" || echo "NOT_EXISTS" +``` + +**Note:** Do NOT use Windows CMD syntax like `if exist`. Always use Unix bash syntax as shown above. + +- If EXISTS: Ask user "Module directory exists. Overwrite? (Yes/Rename/Cancel)" +- If Rename: Prompt for new name and restart from Step 3 + +### Step 5: Generate Module Files from Templates + +Use the template files in `assets/` to generate module files with runtime substitutions. The skill generates 11 files total. + +**Placeholder substitutions:** + +| Placeholder | Value | Example | +|-------------|-------|---------| +| `{{ModuleName}}` | PascalCase module name | DataProcessorModule | +| `{{modulename}}` | Lowercase module name | dataprocessormodule | +| `{{ModuleDescription}}` | User-provided description | Processes sensor data | +| `{{CONTAINER_REGISTRY}}` | Detected or provided registry | myregistry.azurecr.io | +| `{{PROJECT_NAMESPACE}}` | Detected or provided namespace | Company.IoT.EdgeAPI | +| `{{MODULE_CSPROJ_PATH}}` | Calculated module csproj path: `//.csproj` | src/IoTEdgeModules/modules/dataprocessormodule/DataProcessorModule.csproj | +| `{{MODULE_PUBLISH_PATH}}` | Calculated publish path | src/IoTEdgeModules/modules/dataprocessormodule | +| `{{CONTRACTS_PROJECT_REFERENCE}}` | Conditional contracts reference | See below | +| `{{CONTRACTS_CSPROJ_COPY}}` | Conditional Dockerfile COPY | See below | +| `{{NUGET_CONFIG_SECTION}}` | Conditional NuGet configuration | See below | + +**Conditional placeholder handling:** + +**A. Contracts Project Reference (`{{CONTRACTS_PROJECT_REFERENCE}}`)** + +**Calculate relative path from module directory to contracts directory:** + +Example: + +- Module at: `src/IoTEdgeModules/modules/mydemomodule/` +- Contracts at: `src/Company.IoT.Modules.Contracts/` +- Relative path: `../../../Company.IoT.Modules.Contracts` + - Go up 3 levels: `mydemomodule/` → `modules/` → `IoTEdgeModules/` → `src/` + - Then down to: `Company.IoT.Modules.Contracts/` + +If using shared contracts: + +```xml + + + +``` + +If NOT using shared contracts: + +```xml + +``` + +**B. Contracts Dockerfile COPY (`{{CONTRACTS_CSPROJ_COPY}}`)** + +If using shared contracts: + +```dockerfile +COPY /*.csproj ./src/ +``` + +If NOT using shared contracts: +``` +(empty - no COPY line) +``` + +**C. NuGet Configuration (`{{NUGET_CONFIG_SECTION}}`)** + +If using private NuGet feed: + +```dockerfile +ENV VSS_NUGET_EXTERNAL_FEED_ENDPOINTS="{\"endpointCredentials\": [{\"endpoint\":\"\", \"username\":\"docker\", \"password\":\"${FEED_ACCESSTOKEN}\"}]}" + +``` + +If NOT using private NuGet feed: + +``` +(empty - no ENV line) +``` + +**Template file mappings:** + +| Template File | Target File | Notes | +|---------------|-------------|-------| +| `template.csproj` | `.csproj` | Rename to match ModuleName | +| `template-module.json` | `module.json` | - | +| `template-Program.cs` | `Program.cs` | - | +| `template-Service.cs` | `Service.cs` | Rename to match ModuleName | +| `template-GlobalUsings.cs` | `GlobalUsings.cs` | - | +| `template-ServiceLoggerMessages.cs` | `ServiceLoggerMessages.cs` | Rename to match ModuleName | +| `template-Dockerfile.amd64` | `Dockerfile.amd64` | - | +| `template-Dockerfile.amd64.debug` | `Dockerfile.amd64.debug` | - | +| `template-.dockerignore` | `.dockerignore` | - | +| `template-.gitignore` | `.gitignore` | - | +| `template-launchSettings.json` | `Properties/launchSettings.json` | Create Properties/ first | + +**Processing workflow:** + +For each template file listed in the table above, process sequentially: + +1. Read the template file from `assets/` +2. Replace all placeholders with calculated values +3. Write to target location in module directory using the target filename from the table +4. Report progress: "✓ Created " + +Process all 11 files one at a time before proceeding to Step 6. + +### Step 6: Create Shared Contract Constants (Conditional) + +**If using shared contracts project:** + +**Directory:** `//` + +**File:** `Constants.cs` + +**Process:** + +1. Create directory if it doesn't exist +2. Read `template-ModuleConstants.cs` +3. Replace placeholders +4. Write to contracts project location + +**If NOT using shared contracts:** + +**Directory:** `//Contracts/` + +**File:** `Constants.cs` + +**Process:** + +1. Create `Contracts/` folder in module directory +2. Read `template-ModuleConstants.cs` +3. Replace `{{PROJECT_NAMESPACE}}.Modules.Contracts` with just `{{ModuleName}}.Contracts` +4. Write to module's Contracts folder + +### Step 6.5: Create LoggingBuilderExtensions (First Module Only) + +This extension method is required for `AddModuleConsoleLogging()` in Program.cs. + +**If using shared contracts project:** + +Check if `/Extensions/LoggingBuilderExtensions.cs` exists: + +- If file exists: Skip this step (already created by previous module) +- If file does NOT exist: Create it + +**Directory:** `/Extensions/` + +**File:** `LoggingBuilderExtensions.cs` + +**Process:** + +1. Create `Extensions/` directory if it doesn't exist +2. Read `template-LoggingBuilderExtensions.cs` +3. Replace `{{PROJECT_NAMESPACE}}` placeholder +4. Write to contracts project Extensions folder +5. Report to user: "✓ Created LoggingBuilderExtensions.cs in shared contracts project" + +**If NOT using shared contracts:** + +**Directory:** `//Extensions/` + +**File:** `LoggingBuilderExtensions.cs` + +**Process:** + +1. Create `Extensions/` folder in module directory +2. Read `template-LoggingBuilderExtensions.cs` +3. Replace `{{PROJECT_NAMESPACE}}.Modules.Contracts` with `{{ModuleName}}` +4. Write to module's Extensions folder + +### Step 7: Scan and Select Deployment Manifests + +Run the manifest scanning script: + +```bash +python scripts/scan_manifests.py --root . +``` + +**Process the output:** + +1. Parse JSON to get list of manifest files +2. If 0 manifests found: Go to Step 7.5 (create base manifest) +3. If 1 manifest found: Ask "Add module to ? (Yes/No)" +4. If multiple manifests found: Present selection list + +### Step 7.5: Handle "No Manifests Found" Scenario + +**If scan_manifests.py returns 0 manifests:** + +**Prompt user:** + +``` +No deployment manifests found. This appears to be the first module in the project. + +Create a base deployment manifest with this module? (Yes/No) +``` + +**If user selects No:** + +- Skip to Step 9 (README update) +- Inform user: "Module created without deployment manifest. You'll need to create a manifest manually." + +**If user selects Yes:** + +1. **Prompt for manifest name:** + ``` + Manifest name (default: base): _ + ``` + - Accept user input or use "base" as default + - Validate name (alphanumeric, dashes, underscores only) + +2. **Create base deployment manifest:** + - Read template: `assets/template-base.deployment.manifest.json` + - Determine manifest path: `/{name}.deployment.manifest.json` + - If `manifests_base_path` not detected, use `/../{name}.deployment.manifest.json` + - Write base manifest to file + +3. **Add the new module to the base manifest:** + - Run update script: + ```bash + python scripts/update_deployment_manifest.py \ + "" \ + "" \ + --registry "" + ``` + - This adds the newly scaffolded module as the first custom module + +4. **Report to user:** + ``` + ✓ Created base deployment manifest: + ✓ Added to manifest (startup order: 1) + ``` + +**Continue to Step 8 (or Step 9 if no updates needed)** + +**Multi-manifest selection prompt:** + +``` +Found deployment manifests: + +1. ( modules) + Path: + +2. ( modules) + Path: + +Which manifest(s) should include this module? +(Enter numbers separated by commas, or 'all', or 'none') +``` + +### Step 8: Update Deployment Manifests (Automated) + +For each selected manifest, run the update script: + +```bash +python scripts/update_deployment_manifest.py \ + "" \ + "" \ + --registry "" +``` + +**Process the output:** + +1. Check for `"success": true` in JSON output +2. Report to user: "✓ Added to (startup order: )" +3. If error: Report error and provide manual fallback instructions + +**Error handling:** + +If the script fails (e.g., module already exists, invalid JSON): + +1. Show error message from script +2. Provide manual instructions: + +``` +Manual update required for : + +Add to $edgeAgent.properties.desired.modules: +{ + "": { + "version": "1.0", + "type": "docker", + "status": "running", + "restartPolicy": "always", + "startupOrder": , + "settings": { + "image": "${MODULES.}", + "createOptions": { + "HostConfig": { + "LogConfig": { + "Type": "json-file", + "Config": { + "max-size": "10m", + "max-file": "10" + } + }, + "Mounts": [{ + "Type": "volume", + "Target": "/app/data/", + "Source": "" + }] + } + } + } + } +} + +Add to $edgeHub.properties.desired.routes: +{ + "ToIoTHub": { + "route": "FROM /messages/modules//outputs/* INTO $upstream", + "priority": 0, + "timeToLiveSecs": 86400 + } +} +``` + +### Step 9: Update README.md (Optional) + +Search for "Solution project overview" or "IoTEdge modules" section in README.md: + +**If section exists:** + +- Add entry: `- **** (\`\`) - ` +- Insert alphabetically + +**If section doesn't exist:** + +- Ask user: "README.md section not found. Create it? (Yes/No)" + +### Step 9.5: Add Module to Solution File + +**Detect solution file:** + +Run the solution detection script: + +```bash +python scripts/manage_solution.py --root . --detect +``` + +**Process detection results:** + +**If .slnx file found:** + +Automatically add module to solution: + +```bash +python scripts/manage_solution.py \ + --root . \ + --add-module "" \ + --module-name "" +``` + +- Parse JSON output +- If `action: "added"`: Report "✓ Added to solution at position " +- If `action: "already_exists"`: Report "Module already in solution" +- If `action: "error"`: Show error and continue + +**If .sln file found:** + +- Run manual instruction generator: + ```bash + python scripts/manage_solution.py \ + --root . \ + --add-module "" \ + --module-name "" + ``` +- Parse JSON output and display `instructions` field to user +- Recommend using: `dotnet sln add ""` + +**If no solution file found:** + +- Skip this step +- Inform user: "No solution file found. Module created successfully without solution integration." + +### Step 10: Provide Summary and Next Steps + +**Summary of created files:** +``` +✓ Module scaffolding complete! + +Created: +• Module directory: / (11 files) +• Constants file: +• LoggingBuilderExtensions: <"Created" or "Already exists - skipped"> +• Updated manifests: manifest(s) [or "Created base manifest" if first module] +• Solution integration: <"Added to .slnx" or "Manual instructions provided" or "Skipped"> + +Configuration: +• Container registry: +• Project namespace: +• NuGet feed: +• Shared contracts: <"Yes" or "No"> +``` + +**Next steps for the user:** + +1. **Implement module logic:** + - Edit `Service.cs` + - Add business logic in `ExecuteAsync()` + - Register direct method handlers if needed + +2. **Add dependencies (if needed):** + - Update `.csproj` with additional NuGet packages + - Add service registrations in `Program.cs` + +3. **Configure module (if needed):** + - Create options classes in `Options/` folder + - Add environment variables to deployment manifest: + ```json + "env": { + "MyOptions__Setting": { "value": "value" } + } + ``` + +4. **Test locally:** + - Use `Properties/launchSettings.json` for standalone mode + - Run: `dotnet run --project ` + - Module runs with mock IoT Hub client + +5. **Add direct methods (if needed):** + - Add method names to `Constants.cs` + - Register handlers in service: + ```csharp + await moduleClient.RegisterMethodHandlerAsync( + YourModuleConstants.DirectMethodName, + HandleMethodAsync, + stoppingToken); + ``` + +6. **Customize routing (if needed):** + - Default route: Module → IoT Hub (`$upstream`) + - For module-to-module: Update route to use `BrokeredEndpoint` + - Edit in deployment manifest + +7. **Build and deploy:** + - Build module Docker image + - Push to container registry + - Deploy manifest to IoT Hub + +**File paths for quick reference:** + +- Module source: `/` +- Constants: `` +- Deployment manifest(s): `` + +## Reference Documentation + +For detailed information, see reference files: + +- `references/module-structure.md` - Complete module structure reference +- `references/deployment-manifests.md` - Deployment manifest reference + +Load these when: + +- User asks about module structure details +- User needs help with deployment manifest configuration +- Debugging scaffolding issues +- Understanding naming conventions + +## Common Customizations + +After scaffolding, users may want to: + +**1. Add Quartz scheduler support:** + +- Add NuGet package `Quartz` +- Register: `services.AddQuartz()` +- Create `Jobs/` folder with `IJob` implementations + +**2. Add configuration options:** + +- Create `Options/` folder +- Define option classes +- Register: `services.Configure(hostContext.Configuration)` +- Set via env vars in deployment manifest + +**3. Add module-to-module routing:** + +- Update route in deployment manifest: + ```json + "route": "FROM /messages/modules/source/* INTO BrokeredEndpoint(\"/modules/target/inputs/input1\")" + ``` + +**4. Add host binds (replace volume mounts):** + +- Update deployment manifest `createOptions`: + ```json + "Binds": ["/host/path/:/container/path/"] + ``` + +**5. Add privileged access (for device access like TPM):** + +- Update deployment manifest `createOptions.HostConfig`: + ```json + "Privileged": true + ``` + +**6. Remove volume mount (for stateless modules):** + +- Delete `Mounts` section from deployment manifest + +## Error Handling + +**Module directory exists:** + +- Prompt: "Overwrite/Rename/Cancel" +- If Overwrite: Delete existing directory first +- If Rename: Go back to Step 3 with new name + +**Manifest update fails:** + +- Show error from Python script +- Provide manual update instructions +- Continue with other manifests + +**Detection fails:** + +- Fall back to manual prompts for each value +- Offer to save configuration for future runs + +**Missing Python:** + +- If Python not available, provide manual instructions for all steps +- Skip automated manifest updates, provide JSON templates + +## Advanced: Configuration File Format + +Saved configuration (`.claude/.iot-edge-module-config.json`): + +```json +{ + "config_source": "detected", + "modules_base_path": "src/IoTEdgeModules/modules", + "contracts_project_path": "src/Company.Modules.Contracts", + "contracts_project_name": "Company.Modules.Contracts", + "manifests_base_path": "src/IoTEdgeModules", + "project_namespace": "Company.IoT.EdgeAPI", + "container_registry": "myregistry.azurecr.io", + "nuget_feed_url": null, + "has_contracts_project": true, + "has_nuget_feed": false +} +``` + +Users can manually edit this file to override auto-detection. + +## Notes + +- Module directory names: lowercase with "module" suffix (e.g., `dataprocessormodule`) +- C# class names: PascalCase with "Module" suffix (e.g., `DataProcessorModule`) +- Namespaces: PascalCase, no "module" suffix (e.g., `namespace DataProcessorModule;`) +- All modules use non-root user (moduleuser, UID 2000) for security +- Build context is repo root (`contextPath: "../../../"`) +- Log rotation: 10MB max size, 10 files +- Default route: Module outputs → `$upstream` (IoT Hub) diff --git a/skills/iot-edge-module/assets/template-.dockerignore b/skills/iot-edge-module/assets/template-.dockerignore new file mode 100644 index 0000000..2afb4f0 --- /dev/null +++ b/skills/iot-edge-module/assets/template-.dockerignore @@ -0,0 +1,3 @@ +# Ignore .NET build outputs +**/bin +**/obj diff --git a/skills/iot-edge-module/assets/template-.gitignore b/skills/iot-edge-module/assets/template-.gitignore new file mode 100644 index 0000000..cfdd624 --- /dev/null +++ b/skills/iot-edge-module/assets/template-.gitignore @@ -0,0 +1,34 @@ +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ +**/Properties/launchSettings.json + +*_i.c +*_p.c +*_i.h +*.ilk +*.meta +*.obj +*.pch +*.pdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc +.vs + +[Bb]in/ +[Oo]bj/ diff --git a/skills/iot-edge-module/assets/template-Dockerfile.amd64 b/skills/iot-edge-module/assets/template-Dockerfile.amd64 new file mode 100644 index 0000000..ef19f27 --- /dev/null +++ b/skills/iot-edge-module/assets/template-Dockerfile.amd64 @@ -0,0 +1,33 @@ +FROM mcr.microsoft.com/dotnet/sdk:9.0-bookworm-slim AS build-env +ARG FEED_ACCESSTOKEN +WORKDIR /app + +RUN curl -L https://raw.githubusercontent.com/Microsoft/artifacts-credprovider/master/helpers/installcredprovider.sh | sh + +RUN mkdir src +COPY {{MODULE_CSPROJ_PATH}}/*.csproj ./src/ +{{CONTRACTS_CSPROJ_COPY}} +COPY Directory.Build.props . +COPY .editorconfig . +COPY nuget.config . + +{{NUGET_CONFIG_SECTION}} +RUN dotnet restore "./src/{{ModuleName}}.csproj" +COPY src/ ./src/ +RUN dotnet publish "{{MODULE_PUBLISH_PATH}}/{{ModuleName}}.csproj" -c Release -o out + +FROM mcr.microsoft.com/dotnet/runtime:9.0-bookworm-slim +WORKDIR /app +COPY --from=build-env /app/out ./ + +ENV USER=moduleuser +ENV PUID=2000 +ENV TPM_GID=3000 + +RUN useradd --uid $PUID --shell /bin/bash --create-home "$USER" +RUN groupadd -f -g $TPM_GID aziottpm +RUN usermod -a -G aziottpm $USER + +USER $USER + +ENTRYPOINT ["./{{ModuleName}}"] diff --git a/skills/iot-edge-module/assets/template-Dockerfile.amd64.debug b/skills/iot-edge-module/assets/template-Dockerfile.amd64.debug new file mode 100644 index 0000000..f1bea77 --- /dev/null +++ b/skills/iot-edge-module/assets/template-Dockerfile.amd64.debug @@ -0,0 +1,38 @@ +FROM mcr.microsoft.com/dotnet/sdk:9.0-bookworm-slim AS build-env +ARG FEED_ACCESSTOKEN +WORKDIR /app + +RUN curl -L https://raw.githubusercontent.com/Microsoft/artifacts-credprovider/master/helpers/installcredprovider.sh | sh + +RUN mkdir src +COPY {{MODULE_CSPROJ_PATH}}/*.csproj ./src/ +{{CONTRACTS_CSPROJ_COPY}} +COPY Directory.Build.props . +COPY .editorconfig . +COPY nuget.config . + +{{NUGET_CONFIG_SECTION}} +RUN dotnet restore "./src/{{ModuleName}}.csproj" +COPY src/ ./src/ +RUN dotnet publish "{{MODULE_PUBLISH_PATH}}/{{ModuleName}}.csproj" -c Debug -o out + +FROM mcr.microsoft.com/dotnet/runtime:9.0-bookworm-slim +WORKDIR /app +COPY --from=build-env /app/out ./ + +RUN apt-get update && \ + apt-get install -y --no-install-recommends unzip procps && \ + rm -rf /var/lib/apt/lists/* && \ + curl -sSL https://aka.ms/getvsdbgsh | bash /dev/stdin -v latest -l ~/vsdbg + +ENV USER=moduleuser +ENV PUID=2000 +ENV TPM_GID=3000 + +RUN useradd --uid $PUID --shell /bin/bash --create-home "$USER" +RUN groupadd -f -g $TPM_GID aziottpm +RUN usermod -a -G aziottpm $USER + +USER $USER + +ENTRYPOINT ["./{{ModuleName}}"] diff --git a/skills/iot-edge-module/assets/template-GlobalUsings.cs b/skills/iot-edge-module/assets/template-GlobalUsings.cs new file mode 100644 index 0000000..e65e58b --- /dev/null +++ b/skills/iot-edge-module/assets/template-GlobalUsings.cs @@ -0,0 +1,10 @@ +global using Atc.Azure.IoTEdge.Extensions; +global using Atc.Azure.IoTEdge.Factories; +global using Atc.Azure.IoTEdge.TestMocks; +global using Atc.Azure.IoTEdge.Wrappers; +global using {{PROJECT_NAMESPACE}}.Modules.Contracts.Extensions; +global using {{PROJECT_NAMESPACE}}.Modules.Contracts.{{ModuleName}}; +global using Microsoft.Azure.Devices.Client; +global using Microsoft.Extensions.DependencyInjection; +global using Microsoft.Extensions.Hosting; +global using Microsoft.Extensions.Logging; diff --git a/skills/iot-edge-module/assets/template-LoggingBuilderExtensions.cs b/skills/iot-edge-module/assets/template-LoggingBuilderExtensions.cs new file mode 100644 index 0000000..6a806f0 --- /dev/null +++ b/skills/iot-edge-module/assets/template-LoggingBuilderExtensions.cs @@ -0,0 +1,22 @@ +namespace {{PROJECT_NAMESPACE}}.Modules.Contracts.Extensions; + +public static class LoggingBuilderExtensions +{ + /// + /// Adds systemd console logging with standardized timestamp format and log level configuration. + /// + /// The to configure. + /// The for chaining. + public static ILoggingBuilder AddModuleConsoleLogging(this ILoggingBuilder builder) + { + builder.AddSystemdConsole(options => + { + options.UseUtcTimestamp = true; + options.TimestampFormat = " yyyy-MM-dd HH:mm:ss.fff zzz "; + }); + + builder.SetMinimumLevel(LogLevel.Information); + + return builder; + } +} \ No newline at end of file diff --git a/skills/iot-edge-module/assets/template-ModuleConstants.cs b/skills/iot-edge-module/assets/template-ModuleConstants.cs new file mode 100644 index 0000000..9087c28 --- /dev/null +++ b/skills/iot-edge-module/assets/template-ModuleConstants.cs @@ -0,0 +1,9 @@ +namespace {{PROJECT_NAMESPACE}}.Modules.Contracts.{{ModuleName}}; + +public static class {{ModuleName}}Constants +{ + public const string ModuleId = "{{modulename}}"; + + //// TODO: Add direct method name constants here + //// public const string DirectMethodExampleMethod = "ExampleMethod"; +} \ No newline at end of file diff --git a/skills/iot-edge-module/assets/template-Program.cs b/skills/iot-edge-module/assets/template-Program.cs new file mode 100644 index 0000000..f08e3a6 --- /dev/null +++ b/skills/iot-edge-module/assets/template-Program.cs @@ -0,0 +1,35 @@ +namespace {{ModuleName}}; + +public static class Program +{ + public static async Task Main(string[] args) + { + using var host = Host.CreateDefaultBuilder(args) + .ConfigureServices((hostContext, services) => + { + services.AddLogging(builder => + { + builder.AddModuleConsoleLogging(); + }); + + if (hostContext.IsStandaloneMode()) + { + services.AddSingleton(); + } + else + { + services.AddModuleClientWrapper(TransportSettingsFactory.BuildMqttTransportSettings()); + } + + services.AddSingleton(); + + //// TODO: Add your service registrations here + + services.AddHostedService<{{ModuleName}}Service>(); + }) + .UseConsoleLifetime() + .Build(); + + await host.RunAsync(); + } +} diff --git a/skills/iot-edge-module/assets/template-Service.cs b/skills/iot-edge-module/assets/template-Service.cs new file mode 100644 index 0000000..3a1c930 --- /dev/null +++ b/skills/iot-edge-module/assets/template-Service.cs @@ -0,0 +1,59 @@ +namespace {{ModuleName}}; + +/// +/// The main {{ModuleName}}Service. +/// +public sealed partial class {{ModuleName}}Service : IHostedService +{ + private readonly IHostApplicationLifetime hostApplication; + private readonly IModuleClientWrapper moduleClient; + + public {{ModuleName}}Service( + ILogger<{{ModuleName}}Service> logger, + IHostApplicationLifetime hostApplication, + IModuleClientWrapper moduleClient) + { + this.logger = logger; + this.hostApplication = hostApplication; + this.moduleClient = moduleClient; + } + + public async Task StartAsync(CancellationToken cancellationToken) + { + hostApplication.ApplicationStarted.Register(OnStarted); + hostApplication.ApplicationStopping.Register(OnStopping); + hostApplication.ApplicationStopped.Register(OnStopped); + + moduleClient.SetConnectionStatusChangesHandler(LogConnectionStatusChange); + + await moduleClient.OpenAsync(cancellationToken); + + // TODO: Register direct method handlers here + //// await moduleClient.SetMethodHandlerAsync("MethodName", HandleMethodAsync, string.Empty, cancellationToken); + + LogModuleClientStarted({{ModuleName}}Constants.ModuleId); + } + + public async Task StopAsync(CancellationToken cancellationToken) + { + try + { + await moduleClient.CloseAsync(cancellationToken); + } + catch (OperationCanceledException) + { + // Cancellation is expected during shutdown — safe to ignore + } + + LogModuleClientStopped({{ModuleName}}Constants.ModuleId); + } + + private void OnStarted() + => LogModuleStarted({{ModuleName}}Constants.ModuleId); + + private void OnStopping() + => LogModuleStopping({{ModuleName}}Constants.ModuleId); + + private void OnStopped() + => LogModuleStopped({{ModuleName}}Constants.ModuleId); +} diff --git a/skills/iot-edge-module/assets/template-ServiceLoggerMessages.cs b/skills/iot-edge-module/assets/template-ServiceLoggerMessages.cs new file mode 100644 index 0000000..9beef83 --- /dev/null +++ b/skills/iot-edge-module/assets/template-ServiceLoggerMessages.cs @@ -0,0 +1,45 @@ +namespace {{ModuleName}}; + +/// +/// {{ModuleName}}Service LoggerMessages. +/// +public sealed partial class {{ModuleName}}Service +{ + private readonly ILogger<{{ModuleName}}Service> logger; + + [LoggerMessage( + EventId = Atc.Azure.IoTEdge.LoggingEventIdConstants.ModuleStarted, + Level = LogLevel.Trace, + Message = "Successfully started module '{ModuleName}'")] + private partial void LogModuleStarted(string moduleName); + + [LoggerMessage( + EventId = Atc.Azure.IoTEdge.LoggingEventIdConstants.ModuleStopping, + Level = LogLevel.Trace, + Message = "Stopping module '{ModuleName}'")] + private partial void LogModuleStopping(string moduleName); + + [LoggerMessage( + EventId = Atc.Azure.IoTEdge.LoggingEventIdConstants.ModuleStopped, + Level = LogLevel.Trace, + Message = "Successfully stopped module '{moduleName}'")] + private partial void LogModuleStopped(string moduleName); + + [LoggerMessage( + EventId = Atc.Azure.IoTEdge.LoggingEventIdConstants.ModuleClientStarted, + Level = LogLevel.Trace, + Message = "Successfully started moduleClient for module '{ModuleName}'")] + private partial void LogModuleClientStarted(string moduleName); + + [LoggerMessage( + EventId = Atc.Azure.IoTEdge.LoggingEventIdConstants.ModuleClientStopped, + Level = LogLevel.Trace, + Message = "Successfully stopped moduleClient for module '{ModuleName}'")] + private partial void LogModuleClientStopped(string moduleName); + + [LoggerMessage( + EventId = Atc.Azure.IoTEdge.LoggingEventIdConstants.ConnectionStatusChange, + Level = LogLevel.Debug, + Message = "Connection status changed: Status={Status}, Reason={Reason}")] + private partial void LogConnectionStatusChange(ConnectionStatus status, ConnectionStatusChangeReason reason); +} diff --git a/skills/iot-edge-module/assets/template-base.deployment.manifest.json b/skills/iot-edge-module/assets/template-base.deployment.manifest.json new file mode 100644 index 0000000..3a4e9ed --- /dev/null +++ b/skills/iot-edge-module/assets/template-base.deployment.manifest.json @@ -0,0 +1,111 @@ +{ + "modulesContent": { + "$edgeAgent": { + "properties.desired": { + "schemaVersion": "1.1", + "modules": {}, + "runtime": { + "type": "docker", + "settings": { + "minDockerVersion": "v1.25", + "registryCredentials": { + "registryName": { + "username": "${ContainerRegistryUserName}", + "password": "${ContainerRegistryPassword}", + "address": "${ContainerRegistryLoginServer}" + } + } + } + }, + "systemModules": { + "edgeAgent": { + "imagePullPolicy": "on-create", + "type": "docker", + "env": { + "storageFolder": { + "value": "/aziot/storage/" + }, + "UpstreamProtocol": { + "value": "AMQPWS" + } + }, + "settings": { + "image": "mcr.microsoft.com/azureiotedge-agent:1.5", + "createOptions": { + "HostConfig": { + "Binds": [ + "/etc/aziot/storage/:/aziot/storage/" + ], + "LogConfig": { + "Type": "json-file", + "Config": { + "max-size": "10m", + "max-file": "10" + } + } + } + } + } + }, + "edgeHub": { + "imagePullPolicy": "on-create", + "type": "docker", + "env": { + "storageFolder": { + "value": "/aziot/storage/" + }, + "UpstreamProtocol": { + "value": "AMQPWS" + } + }, + "status": "running", + "restartPolicy": "always", + "startupOrder": 0, + "settings": { + "image": "mcr.microsoft.com/azureiotedge-hub:1.5", + "createOptions": { + "HostConfig": { + "Binds": [ + "/etc/aziot/storage/:/aziot/storage/" + ], + "LogConfig": { + "Type": "json-file", + "Config": { + "max-size": "10m", + "max-file": "10" + } + }, + "PortBindings": { + "5671/tcp": [ + { + "HostPort": "5671" + } + ], + "8883/tcp": [ + { + "HostPort": "8883" + } + ], + "443/tcp": [ + { + "HostPort": "443" + } + ] + } + } + } + } + } + } + } + }, + "$edgeHub": { + "properties.desired": { + "schemaVersion": "1.1", + "storeAndForwardConfiguration": { + "timeToLiveSecs": 86400 + } + } + } + } +} diff --git a/skills/iot-edge-module/assets/template-launchSettings.json b/skills/iot-edge-module/assets/template-launchSettings.json new file mode 100644 index 0000000..9834741 --- /dev/null +++ b/skills/iot-edge-module/assets/template-launchSettings.json @@ -0,0 +1,13 @@ +{ + "profiles": { + "{{ModuleName}}": { + "commandName": "Project", + "environmentVariables": { + "DOTNET_ENVIRONMENT": "Standalone" + } + }, + "Docker": { + "commandName": "Docker" + } + } +} diff --git a/skills/iot-edge-module/assets/template-module.json b/skills/iot-edge-module/assets/template-module.json new file mode 100644 index 0000000..f0567d9 --- /dev/null +++ b/skills/iot-edge-module/assets/template-module.json @@ -0,0 +1,17 @@ +{ + "$schema-version": "0.0.1", + "description": "{{ModuleDescription}}", + "image": { + "repository": "{{CONTAINER_REGISTRY}}/{{modulename}}", + "tag": { + "version": "0.0.${BUILD_BUILDID}", + "platforms": { + "amd64": "./Dockerfile.amd64", + "amd64.debug": "./Dockerfile.amd64.debug" + } + }, + "buildOptions": [], + "contextPath": "../../../" + }, + "language": "csharp" +} diff --git a/skills/iot-edge-module/assets/template.csproj b/skills/iot-edge-module/assets/template.csproj new file mode 100644 index 0000000..e6857e0 --- /dev/null +++ b/skills/iot-edge-module/assets/template.csproj @@ -0,0 +1,32 @@ + + + + Exe + net9.0 + Linux + false + + + + + + + + + + + + + + + + + + +{{CONTRACTS_PROJECT_REFERENCE}} + + + + + + diff --git a/skills/iot-edge-module/references/deployment-manifests.md b/skills/iot-edge-module/references/deployment-manifests.md new file mode 100644 index 0000000..47005cf --- /dev/null +++ b/skills/iot-edge-module/references/deployment-manifests.md @@ -0,0 +1,238 @@ +# IoT Edge Deployment Manifests Reference + +This document provides reference information for the deployment manifest structure used in this project. + +## Manifest Types + +Projects typically use one or more deployment manifests to organize modules: + +### Example: Base Deployment Manifest + +**Naming pattern**: `*.deployment.manifest.json` (e.g., `base.deployment.manifest.json`) + +**Purpose**: Contains modules that should be deployed to all or specific edge devices. + +**Example modules**: +- Metric collector modules - Collects operational metrics +- Telemetry transformation modules - Enriches raw telemetry +- Custom business logic modules + +**Routing**: +- Modules typically route to `$upstream` (IoT Hub) or to other modules via BrokeredEndpoint + +## Deployment Manifest Structure + +### Top-Level Structure + +```json +{ + "modulesContent": { + "$edgeAgent": { ... }, + "$edgeHub": { ... } + } +} +``` + +### Module Definition ($edgeAgent) + +Each module is defined under `$edgeAgent` with the following structure: + +```json +"properties.desired.modules.": { + "version": "1.0", + "type": "docker", + "status": "running", + "restartPolicy": "always", + "startupOrder": , + "settings": { + "image": "${MODULES.}", + "createOptions": { + "HostConfig": { + "LogConfig": { + "Type": "json-file", + "Config": { + "max-size": "10m", + "max-file": "10" + } + }, + "Binds": [ + // Optional host volume binds + ], + "Mounts": [ + // Optional volume mounts + ] + } + } + }, + "env": { + // Optional environment variables + } +} +``` + +**Key fields**: +- `version`: Module version (typically "1.0") +- `type`: Always "docker" +- `status`: Desired status ("running") +- `restartPolicy`: Restart behavior ("always") +- `startupOrder`: Module startup sequence (lower starts first, system modules use 0) +- `image`: Container image (use variable substitution: `${MODULES.}`) +- `createOptions`: Docker container creation options + - `LogConfig`: Log rotation settings (10m max size, 10 files) + - `Binds`: Host path bindings for persistent storage + - `Mounts`: Named volume mounts +- `env`: Environment variables (use variable substitution for secrets) + +### Routing Configuration ($edgeHub) + +Each route is defined under `$edgeHub`: + +```json +"properties.desired.routes.": { + "route": "", + "priority": 0, + "timeToLiveSecs": 86400 +} +``` + +**Common route patterns**: + +1. **Module to IoT Hub (upstream)**: + ```json + "route": "FROM /messages/modules//outputs/* INTO $upstream" + ``` + +2. **Module to module (BrokeredEndpoint)**: + ```json + "route": "FROM /messages/modules//* INTO BrokeredEndpoint(\"/modules//inputs/\")" + ``` + +**Key fields**: +- `route`: Route expression using IoT Edge routing syntax +- `priority`: Route priority (0 = normal) +- `timeToLiveSecs`: Message TTL (86400 = 24 hours) + +### Variable Substitution + +Manifests use variable substitution for dynamic values: + +- `${MODULES.}` - Module container image URI +- `${ContainerRegistryUserName}` - Registry username +- `${ContainerRegistryPassword}` - Registry password +- `${ContainerRegistryLoginServer}` - Registry server URL +- `${LogAnalyticsWorkspaceId}` - Log Analytics workspace ID +- `${LogAnalyticsWorkspaceSharedKey}` - Log Analytics shared key +- `${IotHubResourceId}` - IoT Hub resource ID + +## Module-Specific Patterns + +### Modules with Volume Mounts + +Use named volumes for module-specific storage: + +```json +"Mounts": [ + { + "Type": "volume", + "Target": "/app/data/", + "Source": "" + } +] +``` + +### Modules with Host Binds + +Use host binds for shared storage or device access: + +```json +"Binds": [ + "/srv/aziotedge/opc/opcpublisher/:/app/opc/opcpublisher/", + "/dev/tpm0:/dev/tpm0" +] +``` + +### Modules with Privileged Access + +For modules requiring device access (e.g., TPM): + +```json +"Privileged": true +``` + +### Modules with Environment Variables + +Configure modules via environment variables: + +```json +"env": { + "OptionsClass__PropertyName": { + "value": "value or ${VariableSubstitution}" + } +} +``` + +## Adding a New Module to a Manifest + +To add a new module to a deployment manifest: + +1. **Add module definition to `$edgeAgent`**: + - Use `properties.desired.modules.` as the key + - Set appropriate `startupOrder` (consider dependencies) + - Set `image` to `${MODULES.}` + - Configure `createOptions` (log rotation, binds, mounts) + - Add environment variables if needed + +2. **Add routing to `$edgeHub`**: + - Use descriptive route name: `properties.desired.routes.ToIoTHub` + - Set route expression based on message flow + - Use standard priority (0) and TTL (86400) + +3. **Update system properties** (only if needed): + - System modules (`edgeAgent`, `edgeHub`) are defined once + - Runtime registry credentials are shared across manifests + +## Example: Adding a New Module + +```json +{ + "modulesContent": { + "$edgeAgent": { + "properties.desired.modules.mynewmodule": { + "version": "1.0", + "type": "docker", + "status": "running", + "restartPolicy": "always", + "startupOrder": 5, + "settings": { + "image": "${MODULES.mynewmodule}", + "createOptions": { + "HostConfig": { + "LogConfig": { + "Type": "json-file", + "Config": { + "max-size": "10m", + "max-file": "10" + } + }, + "Mounts": [ + { + "Type": "volume", + "Target": "/app/data/", + "Source": "mynewmodule" + } + ] + } + } + } + } + }, + "$edgeHub": { + "properties.desired.routes.mynewmoduleToIoTHub": { + "route": "FROM /messages/modules/mynewmodule/outputs/* INTO $upstream", + "priority": 0, + "timeToLiveSecs": 86400 + } + } + } +} +``` diff --git a/skills/iot-edge-module/references/module-structure.md b/skills/iot-edge-module/references/module-structure.md new file mode 100644 index 0000000..2ae2920 --- /dev/null +++ b/skills/iot-edge-module/references/module-structure.md @@ -0,0 +1,292 @@ +# IoT Edge Module Structure Reference + +This document describes the standard structure and files for IoT Edge modules in this project. + +## Module Directory Structure + +Each module follows this standard structure: + +``` +/ # Lowercase with "module" suffix +├── .dockerignore # Docker build exclusions +├── .gitignore # Git exclusions (bin/, obj/) +├── Dockerfile.amd64 # Production build +├── Dockerfile.amd64.debug # Debug build with vsdbg +├── module.json # IoT Edge module metadata +├── .csproj # .NET 9.0 project file +├── Program.cs # Application entry point +├── GlobalUsings.cs # Global namespace imports +├── LoggingEventIdConstants.cs # Logging event IDs +├── Service.cs # Main hosted service +├── ServiceLoggerMessages.cs # Logging messages +├── Properties/ +│ └── launchSettings.json # Local debugging configuration +├── Services/ # Optional: Business logic services +├── Contracts/ # Optional: Module-specific contracts +├── Options/ # Optional: Configuration option classes +├── Jobs/ # Optional: Quartz scheduler jobs +└── [Other domain-specific folders] +``` + +## Required Files + +### 1. module.json + +**Purpose**: IoT Edge module metadata for build and deployment. + +**Location**: `/module.json` + +**Schema**: +```json +{ + "$schema-version": "0.0.1", + "description": "Module description", + "image": { + "repository": "yourregistry.azurecr.io/", + "tag": { + "version": "0.0.${BUILD_BUILDID}", + "platforms": { + "amd64": "./Dockerfile.amd64", + "amd64.debug": "./Dockerfile.amd64.debug" + } + }, + "buildOptions": [], + "contextPath": "../../../" + }, + "language": "csharp" +} +``` + +**Key fields**: +- `repository`: Azure Container Registry URL + lowercase module name +- `version`: Uses `${BUILD_BUILDID}` for CI/CD builds +- `platforms`: Maps platform to Dockerfile +- `contextPath`: Points to repo root (`../../../`) for multi-project Docker builds + +### 2. .csproj + +**Purpose**: .NET project configuration. + +**Target framework**: `net9.0` +**Output type**: `Exe` (console application) +**Docker target OS**: `Linux` + +**Required dependencies**: +- `Atc` - Common utilities +- `Atc.Azure.IoTEdge` - IoT Edge abstractions +- `Microsoft.Azure.Devices.Client` - IoT Hub SDK +- `Microsoft.Extensions.Hosting` - Generic Host + +**Optional project reference**: +- Shared contracts project (e.g., `Company.ProjectName.Modules.Contracts`) - Shared constants and contracts across modules + +### 3. Program.cs + +**Purpose**: Application entry point using .NET Generic Host. + +**Standard pattern**: +```csharp +using var host = Host.CreateDefaultBuilder(args) + .ConfigureServices((hostContext, services) => + { + services.AddLogging(builder => + { + builder.AddModuleConsoleLogging(); + }); + + if (hostContext.IsStandaloneMode()) + { + services.AddSingleton(); + } + else + { + services.AddModuleClientWrapper(TransportSettingsFactory.BuildMqttTransportSettings()); + } + + services.AddSingleton(); + + // Add your service registrations here + + services.AddHostedService(); + }) + .UseConsoleLifetime() + .Build(); + +await host.RunAsync(); +``` + +**Key components**: +- `AddModuleConsoleLogging()` - Structured console logging +- `IsStandaloneMode()` - Detects local vs. edge runtime +- `AddModuleClientWrapper()` - IoT Hub connectivity with MQTT +- `AddHostedService<>()` - Main service registration + +### 4. Main Service File + +**Purpose**: Main module logic as a `BackgroundService`. + +**Naming**: `Service.cs` + +**Responsibilities**: +- Open IoT Hub connection on startup +- Register direct method handlers +- Implement core module logic +- Handle graceful shutdown + +### 5. GlobalUsings.cs + +**Purpose**: Global namespace imports for cleaner code. + +**Standard imports**: +```csharp +global using Microsoft.Extensions.DependencyInjection; +global using Microsoft.Extensions.Hosting; +global using Microsoft.Extensions.Logging; +``` + +### 6. LoggingEventIdConstants.cs + +**Purpose**: Centralized logging event IDs. + +**Standard event IDs**: +- `1000` - ModuleStarting +- `1001` - ModuleStarted +- `1002` - ModuleStopping +- `1003` - ModuleStopped + +### 7. ServiceLoggerMessages.cs + +**Purpose**: Compile-time logging using source generators. + +**Pattern**: +```csharp +internal static partial class YourModuleServiceLoggerMessages +{ + [LoggerMessage( + EventId = LoggingEventIdConstants.ModuleStarting, + Level = LogLevel.Information, + Message = "Module is starting")] + internal static partial void LogModuleStarting(this ILogger logger); +} +``` + +### 8. Dockerfile.amd64 + +**Purpose**: Production Docker image build. + +**Multi-stage build**: +1. **Build stage**: .NET SDK 9.0, restore dependencies, publish release build +2. **Runtime stage**: .NET Runtime 9.0, non-root user, security hardening + +**Security features**: +- Non-root user (`moduleuser`, UID 2000) +- TPM access group (GID 3000) +- Minimal runtime image + +### 9. Dockerfile.amd64.debug + +**Purpose**: Debug Docker image with remote debugging support. + +**Additional features**: +- vsdbg debugger installation +- Debug build configuration + +### 10. .dockerignore + +**Purpose**: Exclude files from Docker build context. + +**Excludes**: bin/, obj/, .git, .vs, node_modules, etc. + +### 11. .gitignore + +**Purpose**: Exclude build artifacts from Git. + +**Excludes**: bin/, obj/ + +### 12. Properties/launchSettings.json + +**Purpose**: Local debugging configuration. + +**Required environment variables**: +- `IOTEDGE_MODULEID` - Module identifier +- `EdgeHubConnectionString` - Local connection string for standalone mode +- `EdgeModuleCACertificateFile` - Certificate file path (can be empty) + +## Shared Contracts + +### Module Constants + +**Location**: `//Constants.cs` + +**Purpose**: Shared constants for module identification and direct methods. + +**Structure**: +```csharp +namespace Company.ProjectName.Modules.Contracts.; + +public static class Constants +{ + public const string ModuleId = ""; + + // Direct method names + public const string DirectMethodExample = "ExampleMethod"; +} +``` + +## Naming Conventions + +- **Module directory**: Lowercase with "module" suffix (e.g., `mynewmodule`) +- **C# classes**: PascalCase without "module" suffix (e.g., `MyNewModule`) +- **Namespace**: PascalCase matching class name (e.g., `namespace MyNewModule;`) +- **Constants file**: `Constants.cs` in shared contracts +- **Dockerfile**: `Dockerfile.amd64` and `Dockerfile.amd64.debug` + +## Configuration Pattern + +Modules use `IOptions` for configuration: + +1. **Define options class**: + ```csharp + public class MyModuleOptions + { + public string Setting { get; set; } + } + ``` + +2. **Register in DI**: + ```csharp + services.Configure(hostContext.Configuration); + ``` + +3. **Inject options**: + ```csharp + public MyService(IOptions options) + ``` + +4. **Set via environment variables** in deployment manifest: + ```json + "env": { + "MyModuleOptions__Setting": { + "value": "value" + } + } + ``` + +## Optional Folders + +- `Services/` - Business logic and integration services +- `Contracts/` - Module-specific data contracts (not shared) +- `Options/` - Configuration option classes +- `Jobs/` - Quartz scheduler job definitions (requires `AddQuartz()`) +- `Filters/` - Domain-specific filtering logic +- `Providers/` - Factory patterns, client providers +- `Publishers/` - Message publishers +- `Scrapers/` - Data scraping logic + +## README.md Documentation + +When creating a new module, update `README.md` in the repository root: + +**Section**: "Solution project overview for IoTEdge modules" + +Add your module to the list with a brief description of its purpose. diff --git a/skills/iot-edge-module/scripts/detect_project_structure.py b/skills/iot-edge-module/scripts/detect_project_structure.py new file mode 100644 index 0000000..d47807b --- /dev/null +++ b/skills/iot-edge-module/scripts/detect_project_structure.py @@ -0,0 +1,291 @@ +#!/usr/bin/env python3 +""" +Detect IoT Edge project structure by scanning for existing patterns. + +This script auto-detects: +- Modules base path +- Contracts project path and name +- Deployment manifests location +- Project namespace +- Container registry (from existing manifests) +- NuGet feed URL (from existing Dockerfiles) + +Returns JSON with detected configuration or empty fields if not found. +""" + +import json +import os +import re +import sys +from pathlib import Path +from typing import Optional, Dict, Any + + +def find_files_recursive(root_path: Path, pattern: str) -> list[Path]: + """Find files matching glob pattern recursively, excluding test directories.""" + files = list(root_path.rglob(pattern)) + + # Always filter out test directories + files = [f for f in files if not any(part.lower().startswith('test') for part in f.parts)] + + return files + + +def find_modules_base_path(root_path: Path) -> Optional[str]: + """ + Find modules base path by looking for existing modules. + Expected pattern: **/modules/*/Program.cs + """ + module_programs = find_files_recursive(root_path, "modules/*/Program.cs") + + if module_programs: + # Extract the modules directory path + # E.g., /path/to/src/IoTEdgeModules/modules/foo/Program.cs -> src/IoTEdgeModules/modules + first_match = module_programs[0] + modules_dir = first_match.parent.parent # Go up from /foo/Program.cs to /modules + relative_path = modules_dir.relative_to(root_path) + return str(relative_path).replace('\\', '/') + + return None + + +def find_contracts_project(root_path: Path) -> Optional[Dict[str, str]]: + """ + Find contracts project by looking for *Modules.Contracts*.csproj or *Contracts*.csproj + Returns dict with path and name, or None if not found. + """ + # Try specific pattern first + contracts_projects = find_files_recursive(root_path, "*Modules.Contracts*.csproj") + + # Fallback to generic contracts pattern + if not contracts_projects: + contracts_projects = find_files_recursive(root_path, "*Contracts*.csproj") + + if contracts_projects: + first_match = contracts_projects[0] + project_dir = first_match.parent + project_name = first_match.stem # Filename without .csproj + relative_path = project_dir.relative_to(root_path) + + return { + "path": str(relative_path).replace('\\', '/'), + "name": project_name + } + + return None + + +def find_deployment_manifests(root_path: Path) -> list[str]: + """ + Find all deployment manifest files (*.deployment.manifest.json). + Returns list of relative paths. + """ + manifests = find_files_recursive(root_path, "*.deployment.manifest.json") + return [str(m.relative_to(root_path)).replace('\\', '/') for m in manifests] + + +def extract_namespace_from_csharp(root_path: Path, contracts_path: Optional[str] = None) -> Optional[str]: + """ + Extract project namespace by: + 1. Reading a C# file from contracts project (if available) + 2. Finding pattern: namespace .Modules.Contracts. + 3. Extracting base: + """ + cs_files = [] + + # Try contracts project first + if contracts_path: + contracts_full_path = root_path / contracts_path + cs_files = list(contracts_full_path.rglob("*.cs")) + + # Fallback: scan any .cs file in project + if not cs_files: + cs_files = list(root_path.rglob("modules/*/*.cs"))[:10] # Sample first 10 + + # Pattern to match: namespace .Modules.Contracts. or similar + namespace_pattern = re.compile(r'namespace\s+([A-Za-z0-9.]+?)\.Modules(?:\.Contracts)?(?:\.[A-Za-z0-9]+)?;') + + for cs_file in cs_files: + try: + content = cs_file.read_text(encoding='utf-8') + match = namespace_pattern.search(content) + if match: + # Extract the base namespace before .Modules + return match.group(1) + except Exception: + continue + + return None + + +def extract_container_registry(root_path: Path, manifest_paths: list[str]) -> Optional[str]: + """ + Extract container registry URL from existing module.json or deployment manifests. + Looks for "repository": "registry.azurecr.io/modulename" pattern. + """ + registry_pattern = re.compile(r'"repository":\s*"([^/"]+)/[^"]+"') + + # First try module.json files (more reliable) + module_jsons = find_files_recursive(root_path, "modules/*/module.json") + for module_json in module_jsons[:5]: # Check first 5 + try: + content = module_json.read_text(encoding='utf-8') + match = registry_pattern.search(content) + if match: + return match.group(1) + except Exception: + continue + + # Fallback to deployment manifests + for manifest_path in manifest_paths: + try: + full_path = root_path / manifest_path + content = full_path.read_text(encoding='utf-8') + match = registry_pattern.search(content) + if match: + return match.group(1) + except Exception: + continue + + return None + + +def extract_nuget_feed_url(root_path: Path, modules_base_path: Optional[str]) -> Optional[str]: + """ + Extract NuGet feed URL from existing Dockerfiles. + Looks for VSS_NUGET_EXTERNAL_FEED_ENDPOINTS pattern. + """ + dockerfiles = find_files_recursive(root_path, "modules/*/Dockerfile*") + + # Match both regular and escaped quotes: "endpoint":"url" or \"endpoint\":\"url\" + nuget_pattern = re.compile(r'endpoint\\?":\\s*\\?"(https://[^"\\]+/nuget/v3/index\.json)\\?"') + + for dockerfile in dockerfiles[:10]: # Check first 10 + try: + content = dockerfile.read_text(encoding='utf-8') + match = nuget_pattern.search(content) + if match: + return match.group(1) + except Exception: + continue + + return None + + +def load_saved_config(root_path: Path) -> Optional[Dict[str, Any]]: + """Load saved project configuration if it exists.""" + config_path = root_path / ".claude" / ".iot-edge-module-config.json" + + if config_path.exists(): + try: + return json.loads(config_path.read_text(encoding='utf-8')) + except Exception: + return None + + return None + + +def save_project_config(root_path: Path, config: Dict[str, Any]) -> bool: + """Save project configuration for future runs.""" + config_path = root_path / ".claude" / ".iot-edge-module-config.json" + + try: + config_path.parent.mkdir(parents=True, exist_ok=True) + config_path.write_text(json.dumps(config, indent=2), encoding='utf-8') + return True + except Exception as e: + print(f"Warning: Could not save config: {e}", file=sys.stderr) + return False + + +def detect_project_structure(root_path: Path, force_detect: bool = False) -> Dict[str, Any]: + """ + Detect project structure by scanning for patterns. + + Args: + root_path: Root directory of the project + force_detect: If True, ignore saved config and re-detect + + Returns: + Dictionary with detected configuration + """ + # Try to load saved config first + if not force_detect: + saved_config = load_saved_config(root_path) + if saved_config: + saved_config["config_source"] = "saved" + return saved_config + + # Perform detection + modules_base_path = find_modules_base_path(root_path) + contracts_project = find_contracts_project(root_path) + manifest_paths = find_deployment_manifests(root_path) + + contracts_path = contracts_project["path"] if contracts_project else None + project_namespace = extract_namespace_from_csharp(root_path, contracts_path) + container_registry = extract_container_registry(root_path, manifest_paths) + nuget_feed_url = extract_nuget_feed_url(root_path, modules_base_path) + + config = { + "config_source": "detected", + "modules_base_path": modules_base_path, + "contracts_project_path": contracts_path, + "contracts_project_name": contracts_project["name"] if contracts_project else None, + "manifests_found": manifest_paths, + "manifests_base_path": os.path.dirname(manifest_paths[0]) if manifest_paths else None, + "project_namespace": project_namespace, + "container_registry": container_registry, + "nuget_feed_url": nuget_feed_url, + "has_contracts_project": contracts_project is not None, + "has_nuget_feed": nuget_feed_url is not None + } + + return config + + +def main(): + """Main entry point.""" + import argparse + + parser = argparse.ArgumentParser( + description="Detect IoT Edge project structure" + ) + parser.add_argument( + "--root", + type=str, + default=".", + help="Root directory of the project (default: current directory)" + ) + parser.add_argument( + "--force", + action="store_true", + help="Force re-detection, ignore saved config" + ) + parser.add_argument( + "--save", + action="store_true", + help="Save detected configuration for future runs" + ) + + args = parser.parse_args() + + root_path = Path(args.root).resolve() + + if not root_path.exists(): + print(json.dumps({"error": f"Path does not exist: {root_path}"})) + sys.exit(1) + + # Detect structure + config = detect_project_structure(root_path, force_detect=args.force) + + # Save if requested + if args.save and config["config_source"] == "detected": + if save_project_config(root_path, config): + config["config_saved"] = True + + # Output as JSON + print(json.dumps(config, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/skills/iot-edge-module/scripts/manage_solution.py b/skills/iot-edge-module/scripts/manage_solution.py new file mode 100644 index 0000000..df8325b --- /dev/null +++ b/skills/iot-edge-module/scripts/manage_solution.py @@ -0,0 +1,286 @@ +#!/usr/bin/env python3 +""" +Solution File Manager for IoT Edge Module Scaffolding + +Manages adding newly created modules to solution files. +Supports: +- .slnx (XML-based, modern format) - auto-add capability +- .sln (legacy format with GUIDs) - manual instructions only +""" + +import argparse +import json +import sys +import xml.etree.ElementTree as ET +from pathlib import Path + + +def find_solution_file(root_path: Path) -> dict: + """ + Find solution file in project root. + + Returns: + dict with keys: + - type: "slnx", "sln", or "none" + - path: Path to solution file or None + - name: Solution file name or None + """ + # Always exclude test directories + def is_test_dir(path: Path) -> bool: + return any(part.lower().startswith('test') for part in path.parts) + + # Look for .slnx first (modern format) + slnx_files = [f for f in root_path.rglob("*.slnx") if not is_test_dir(f)] + if slnx_files: + # Use the first one found, preferring root directory + slnx_files.sort(key=lambda p: (len(p.parts), p)) + return { + "type": "slnx", + "path": slnx_files[0], + "name": slnx_files[0].name + } + + # Fall back to .sln (legacy format) + sln_files = [f for f in root_path.rglob("*.sln") if not is_test_dir(f)] + if sln_files: + sln_files.sort(key=lambda p: (len(p.parts), p)) + return { + "type": "sln", + "path": sln_files[0], + "name": sln_files[0].name + } + + return { + "type": "none", + "path": None, + "name": None + } + + +def add_module_to_slnx(slnx_path: Path, module_csproj_path: str) -> dict: + """ + Add module to .slnx solution file. + + Args: + slnx_path: Path to .slnx file + module_csproj_path: Relative path to module .csproj (e.g., "src/IoTEdgeModules/modules/mymodule/MyModule.csproj") + + Returns: + dict with keys: + - success: bool + - message: str + - action: "added", "already_exists", or "error" + """ + try: + # Parse XML + tree = ET.parse(slnx_path) + root = tree.getroot() + + # Find or create /modules/ folder + modules_folder = None + for folder in root.findall("Folder"): + if folder.get("Name") == "/modules/": + modules_folder = folder + break + + if modules_folder is None: + # Create /modules/ folder if it doesn't exist + modules_folder = ET.SubElement(root, "Folder") + modules_folder.set("Name", "/modules/") + + # Check if module already exists + for project in modules_folder.findall("Project"): + if project.get("Path") == module_csproj_path: + return { + "success": True, + "message": f"Module already exists in solution: {module_csproj_path}", + "action": "already_exists" + } + + # Get all existing project paths for alphabetical insertion + existing_projects = [(p.get("Path"), p) for p in modules_folder.findall("Project")] + existing_paths = [path for path, _ in existing_projects] + + # Find insertion point (alphabetical order) + insertion_index = 0 + for i, existing_path in enumerate(existing_paths): + if module_csproj_path.lower() < existing_path.lower(): + insertion_index = i + break + insertion_index = i + 1 + + # Create new project element + new_project = ET.Element("Project") + new_project.set("Path", module_csproj_path) + + # Insert at the calculated position + modules_folder.insert(insertion_index, new_project) + + # Write back to file with proper formatting + # Add indentation + indent_xml(root) + tree.write(slnx_path, encoding="utf-8", xml_declaration=False) + + return { + "success": True, + "message": f"Added module to solution at position {insertion_index + 1} of {len(existing_paths) + 1}", + "action": "added", + "insertion_index": insertion_index, + "total_modules": len(existing_paths) + 1 + } + + except ET.ParseError as e: + return { + "success": False, + "message": f"Failed to parse .slnx file: {e}", + "action": "error" + } + except Exception as e: + return { + "success": False, + "message": f"Error adding module to .slnx: {e}", + "action": "error" + } + + +def indent_xml(elem, level=0): + """ + Recursively add indentation to an XML element and its children for pretty printing. + + Args: + elem: xml.etree.ElementTree.Element + The XML element to indent. + level: int, optional + The current indentation level (default is 0). + + Returns: + None. The function modifies the XML element in place. + """ + indent = "\n" + " " * level + if len(elem): + if not elem.text or not elem.text.strip(): + elem.text = indent + " " + if not elem.tail or not elem.tail.strip(): + elem.tail = indent + for child in elem: + indent_xml(child, level + 1) + if not child.tail or not child.tail.strip(): + child.tail = indent + else: + if level and (not elem.tail or not elem.tail.strip()): + elem.tail = indent + + +def get_sln_manual_instructions(module_csproj_path: str, module_name: str) -> str: + """ + Generate manual instructions for adding module to .sln file. + + .sln files require GUIDs which are complex to generate correctly, + so we provide manual instructions instead. + """ + instructions = f""" +Manual instructions for adding module to .sln file: + +.sln files require project GUIDs which must be generated. The easiest way is to use Visual Studio or the dotnet CLI: + +Option 1: Using dotnet CLI (recommended): + dotnet sln add "{module_csproj_path}" + +Option 2: Using Visual Studio: + 1. Open the solution in Visual Studio + 2. Right-click on the solution in Solution Explorer + 3. Select "Add" → "Existing Project" + 4. Navigate to and select: {module_csproj_path} + +Option 3: Manual editing (advanced): + 1. Open the .sln file in a text editor + 2. Generate a new GUID (use online generator or PowerShell: [guid]::NewGuid()) + 3. Add project entry: + + Project("{{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}}") = "{module_name}", "{module_csproj_path}", "{{YOUR-NEW-GUID}}" + EndProject + + 4. Add to solution configuration platforms section (match existing patterns) + +Note: The dotnet CLI approach is recommended as it handles all GUID generation automatically. +""" + return instructions + + +def main(): + parser = argparse.ArgumentParser( + description="Manage solution file updates for IoT Edge modules" + ) + parser.add_argument( + "--root", + type=str, + default=".", + help="Root directory to search for solution file (default: current directory)" + ) + parser.add_argument( + "--detect", + action="store_true", + help="Detect solution file type and location" + ) + parser.add_argument( + "--add-module", + type=str, + help="Relative path to module .csproj to add to solution" + ) + parser.add_argument( + "--module-name", + type=str, + help="Module name (for manual instructions)" + ) + + args = parser.parse_args() + root_path = Path(args.root).resolve() + + # Detect solution file + solution_info = find_solution_file(root_path) + + if args.detect: + # Just output detection results + print(json.dumps(solution_info, indent=2, default=str)) + return 0 + + if args.add_module: + if solution_info["type"] == "none": + result = { + "success": False, + "message": "No solution file found", + "action": "error" + } + print(json.dumps(result, indent=2)) + return 1 + + if solution_info["type"] == "slnx": + # Auto-add to .slnx + result = add_module_to_slnx( + solution_info["path"], + args.add_module + ) + print(json.dumps(result, indent=2)) + return 0 if result["success"] else 1 + + elif solution_info["type"] == "sln": + # Provide manual instructions for .sln + module_name = args.module_name or Path(args.add_module).stem + instructions = get_sln_manual_instructions(args.add_module, module_name) + result = { + "success": True, + "message": "Manual instructions generated for .sln file", + "action": "manual_instructions", + "instructions": instructions, + "solution_path": str(solution_info["path"]) + } + print(json.dumps(result, indent=2)) + return 0 + + # No action specified + parser.print_help() + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/iot-edge-module/scripts/scan_manifests.py b/skills/iot-edge-module/scripts/scan_manifests.py new file mode 100644 index 0000000..280811a --- /dev/null +++ b/skills/iot-edge-module/scripts/scan_manifests.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +""" +Scan for IoT Edge deployment manifest files. + +Finds all *.deployment.manifest.json files in the project and returns +metadata about each manifest. +""" + +import json +import sys +from pathlib import Path +from typing import Dict, Any, List + + +def extract_manifest_metadata(manifest_path: Path) -> Dict[str, Any]: + """ + Extract metadata from a deployment manifest file. + + Args: + manifest_path: Path to the manifest file + + Returns: + Dictionary with manifest metadata + """ + try: + content = manifest_path.read_text(encoding='utf-8') + manifest_data = json.loads(content) + + # Extract module count from $edgeAgent + modules_count = 0 + module_names = [] + edge_agent = manifest_data.get("modulesContent", {}).get("$edgeAgent", {}) + + # Navigate the nested JSON structure correctly + for key in edge_agent.keys(): + if key.startswith("properties.desired.modules."): + modules_count += 1 + # Extract module name from key like "properties.desired.modules.modulename" + module_name = key.replace("properties.desired.modules.", "") + module_names.append(module_name) + + # Extract route count from $edgeHub + routes_count = 0 + edge_hub = manifest_data.get("modulesContent", {}).get("$edgeHub", {}) + for key in edge_hub.keys(): + if key.startswith("properties.desired.routes."): + routes_count += 1 + + return { + "valid": True, + "modules_count": modules_count, + "module_names": module_names, + "routes_count": routes_count + } + except Exception as e: + return { + "valid": False, + "error": str(e) + } + + +def scan_manifests(root_path: Path) -> List[Dict[str, Any]]: + """ + Scan for all deployment manifest files, excluding test directories. + + Args: + root_path: Root directory of the project + + Returns: + List of dictionaries with manifest information + """ + manifests = list(root_path.rglob("*.deployment.manifest.json")) + + # Always exclude test directories + manifests = [m for m in manifests if not any(part.lower().startswith('test') for part in m.parts)] + + results = [] + for manifest_path in manifests: + relative_path = manifest_path.relative_to(root_path) + metadata = extract_manifest_metadata(manifest_path) + + result = { + "path": str(relative_path).replace('\\', '/'), + "name": manifest_path.name, + "basename": manifest_path.stem.replace('.deployment.manifest', ''), + "absolute_path": str(manifest_path), + **metadata + } + + results.append(result) + + # Sort by path for consistent ordering + results.sort(key=lambda x: x["path"]) + + return results + + +def main(): + """Main entry point.""" + import argparse + + parser = argparse.ArgumentParser( + description="Scan for IoT Edge deployment manifest files" + ) + parser.add_argument( + "--root", + type=str, + default=".", + help="Root directory of the project (default: current directory)" + ) + parser.add_argument( + "--verbose", + action="store_true", + help="Include detailed metadata for each manifest" + ) + + args = parser.parse_args() + + root_path = Path(args.root).resolve() + + if not root_path.exists(): + print(json.dumps({"error": f"Path does not exist: {root_path}"})) + sys.exit(1) + + # Scan for manifests + manifests = scan_manifests(root_path) + + # Output as JSON + output = { + "manifests_found": len(manifests), + "manifests": manifests + } + + print(json.dumps(output, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/skills/iot-edge-module/scripts/update_deployment_manifest.py b/skills/iot-edge-module/scripts/update_deployment_manifest.py new file mode 100644 index 0000000..8303b4a --- /dev/null +++ b/skills/iot-edge-module/scripts/update_deployment_manifest.py @@ -0,0 +1,303 @@ +#!/usr/bin/env python3 +""" +Update IoT Edge deployment manifest with a new module. + +This script: +1. Parses the deployment manifest JSON +2. Finds the highest startupOrder in $edgeAgent.modules +3. Inserts a new module definition with calculated startupOrder +4. Adds a default route to $edgeHub +5. Validates and saves the updated JSON +""" + +import json +import sys +from pathlib import Path +from typing import Dict, Any, Optional + + +def find_highest_startup_order(manifest_data: Dict[str, Any]) -> int: + """ + Find the highest startupOrder value in existing modules. + + Args: + manifest_data: Parsed manifest JSON + + Returns: + Highest startupOrder value, or 0 if no modules found + """ + edge_agent = manifest_data.get("modulesContent", {}).get("$edgeAgent", {}) + + # Find all keys that start with "properties.desired.modules." + modules = {} + for key, value in edge_agent.items(): + if key.startswith("properties.desired.modules."): + modules[key] = value + + highest_order = 0 + for module_config in modules.values(): + startup_order = module_config.get("startupOrder", 0) + highest_order = max(highest_order, startup_order) + + return highest_order + + +def create_module_definition( + module_name: str, + container_registry: str, + with_volume: bool = True +) -> Dict[str, Any]: + """ + Create a standard module definition for $edgeAgent. + + Args: + module_name: Lowercase module name + container_registry: Container registry URL + with_volume: Whether to include volume mount + + Returns: + Module definition dictionary + """ + create_options = { + "HostConfig": { + "LogConfig": { + "Type": "json-file", + "Config": { + "max-size": "10m", + "max-file": "10" + } + } + } + } + + # Add volume mount if requested + if with_volume: + create_options["HostConfig"]["Mounts"] = [ + { + "Type": "volume", + "Target": "/app/data/", + "Source": module_name + } + ] + + module_def = { + "version": "1.0", + "type": "docker", + "status": "running", + "restartPolicy": "always", + "startupOrder": 1, + "settings": { + "image": f"${{MODULES.{module_name}}}", + "createOptions": create_options + } + } + + return module_def + + +def create_default_route(module_name: str) -> Dict[str, Any]: + """ + Create a default route for the module to IoT Hub. + + Args: + module_name: Lowercase module name + + Returns: + Route definition dictionary + """ + return { + "route": f"FROM /messages/modules/{module_name}/outputs/* INTO $upstream", + "priority": 0, + "timeToLiveSecs": 86400 + } + + +def module_exists(manifest_data: Dict[str, Any], module_name: str) -> bool: + """ + Check if a module already exists in the manifest. + + Args: + manifest_data: Parsed manifest JSON + module_name: Module name to check + + Returns: + True if module exists, False otherwise + """ + edge_agent = manifest_data.get("modulesContent", {}).get("$edgeAgent", {}) + module_key = f"properties.desired.modules.{module_name}" + return module_key in edge_agent + + +def add_module_to_manifest( + manifest_data: Dict[str, Any], + module_name: str, + container_registry: str, + with_volume: bool = True +) -> Dict[str, Any]: + """ + Add a new module to the deployment manifest. + + Args: + manifest_data: Parsed manifest JSON + module_name: Lowercase module name + container_registry: Container registry URL + with_volume: Whether to include volume mount + + Returns: + Updated manifest data with module added + + Raises: + ValueError: If module already exists + """ + # Check if module already exists + if module_exists(manifest_data, module_name): + raise ValueError(f"Module '{module_name}' already exists in manifest") + + # Create module definition with startupOrder = 1 + module_def = create_module_definition( + module_name, + container_registry, + with_volume + ) + + # Add to $edgeAgent using dotted key (Azure IoT Edge format) + if "modulesContent" not in manifest_data: + manifest_data["modulesContent"] = {} + if "$edgeAgent" not in manifest_data["modulesContent"]: + manifest_data["modulesContent"]["$edgeAgent"] = {} + + module_key = f"properties.desired.modules.{module_name}" + manifest_data["modulesContent"]["$edgeAgent"][module_key] = module_def + + # Create and add route to $edgeHub using dotted key (Azure IoT Edge format) + route_name = f"{module_name}ToIoTHub" + route_def = create_default_route(module_name) + + if "$edgeHub" not in manifest_data["modulesContent"]: + manifest_data["modulesContent"]["$edgeHub"] = {} + + route_key = f"properties.desired.routes.{route_name}" + manifest_data["modulesContent"]["$edgeHub"][route_key] = route_def + + return manifest_data + + +def update_manifest_file( + manifest_path: Path, + module_name: str, + container_registry: str, + with_volume: bool = True +) -> Dict[str, Any]: + """ + Update a deployment manifest file with a new module. + + Args: + manifest_path: Path to manifest file + module_name: Lowercase module name + container_registry: Container registry URL + with_volume: Whether to include volume mount + + Returns: + Dictionary with operation result + + Raises: + FileNotFoundError: If manifest file doesn't exist + json.JSONDecodeError: If manifest is invalid JSON + ValueError: If module already exists + """ + if not manifest_path.exists(): + raise FileNotFoundError(f"Manifest file not found: {manifest_path}") + + # Read and parse manifest + content = manifest_path.read_text(encoding='utf-8') + manifest_data = json.loads(content) + + # Store original for comparison + edge_agent = manifest_data.get("modulesContent", {}).get("$edgeAgent", {}) + original_module_count = len([k for k in edge_agent.keys() if k.startswith("properties.desired.modules.")]) + + # Add module + updated_manifest = add_module_to_manifest( + manifest_data, + module_name, + container_registry, + with_volume + ) + + # Write back to file + updated_content = json.dumps(updated_manifest, indent=2) + manifest_path.write_text(updated_content, encoding='utf-8') + + edge_agent_updated = updated_manifest.get("modulesContent", {}).get("$edgeAgent", {}) + new_module_count = len([k for k in edge_agent_updated.keys() if k.startswith("properties.desired.modules.")]) + + return { + "success": True, + "manifest_path": str(manifest_path), + "module_name": module_name, + "startup_order": 1, + "modules_before": original_module_count, + "modules_after": new_module_count, + "route_added": f"{module_name}ToIoTHub" + } + + +def main(): + """Main entry point.""" + import argparse + + parser = argparse.ArgumentParser( + description="Update IoT Edge deployment manifest with a new module" + ) + parser.add_argument( + "manifest_path", + type=str, + help="Path to deployment manifest file" + ) + parser.add_argument( + "module_name", + type=str, + help="Lowercase module name" + ) + parser.add_argument( + "--registry", + type=str, + required=True, + help="Container registry URL (e.g., myregistry.azurecr.io)" + ) + parser.add_argument( + "--no-volume", + action="store_true", + help="Don't add volume mount to module" + ) + + args = parser.parse_args() + + manifest_path = Path(args.manifest_path) + + try: + result = update_manifest_file( + manifest_path, + args.module_name, + args.registry, + with_volume=not args.no_volume + ) + + print(json.dumps(result, indent=2)) + + except FileNotFoundError as e: + print(json.dumps({"success": False, "error": str(e)}), file=sys.stderr) + sys.exit(1) + except json.JSONDecodeError as e: + print(json.dumps({"success": False, "error": f"Invalid JSON: {e}"}), file=sys.stderr) + sys.exit(1) + except ValueError as e: + print(json.dumps({"success": False, "error": str(e)}), file=sys.stderr) + sys.exit(1) + except Exception as e: + print(json.dumps({"success": False, "error": f"Unexpected error: {e}"}), file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main()