Initial commit
This commit is contained in:
14
.claude-plugin/plugin.json
Normal file
14
.claude-plugin/plugin.json
Normal file
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
3
README.md
Normal file
3
README.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# azure-iot
|
||||||
|
|
||||||
|
Azure IoT services automation and scaffolding for IoT Edge modules, IoT Hub, and related services
|
||||||
9
commands/add-iot-edge-module.md
Normal file
9
commands/add-iot-edge-module.md
Normal file
@@ -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.
|
||||||
129
plugin.lock.json
Normal file
129
plugin.lock.json
Normal file
@@ -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": []
|
||||||
|
}
|
||||||
|
}
|
||||||
776
skills/iot-edge-module/SKILL.md
Normal file
776
skills/iot-edge-module/SKILL.md
Normal file
@@ -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: <modules_base_path>
|
||||||
|
• Project namespace: <project_namespace>
|
||||||
|
• Container registry: <container_registry>
|
||||||
|
• Contracts project: <contracts_project_name> (<contracts_project_path>)
|
||||||
|
• Deployment manifests: <manifests_found count> found
|
||||||
|
• NuGet feed: <nuget_feed_url or "Not configured">
|
||||||
|
```
|
||||||
|
|
||||||
|
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: <ModuleName>
|
||||||
|
• Module ID: <modulename>
|
||||||
|
• Directory: <modules_base_path>/<modulename>/
|
||||||
|
```
|
||||||
|
|
||||||
|
**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:
|
||||||
|
|
||||||
|
```
|
||||||
|
<modules_base_path>/<modulename>/
|
||||||
|
```
|
||||||
|
|
||||||
|
Check if directory already exists (MUST use this exact bash syntax):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
test -d "<modules_base_path>/<modulename>" && 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: `<modules_base_path>/<modulename>/<ModuleName>.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
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="<relative-path-to-contracts>/<contracts_project_name>.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
```
|
||||||
|
|
||||||
|
If NOT using shared contracts:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<!-- No shared contracts project -->
|
||||||
|
```
|
||||||
|
|
||||||
|
**B. Contracts Dockerfile COPY (`{{CONTRACTS_CSPROJ_COPY}}`)**
|
||||||
|
|
||||||
|
If using shared contracts:
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
COPY <contracts_project_path>/*.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\":\"<nuget_feed_url>\", \"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` | `<ModuleName>.csproj` | Rename to match ModuleName |
|
||||||
|
| `template-module.json` | `module.json` | - |
|
||||||
|
| `template-Program.cs` | `Program.cs` | - |
|
||||||
|
| `template-Service.cs` | `<ModuleName>Service.cs` | Rename to match ModuleName |
|
||||||
|
| `template-GlobalUsings.cs` | `GlobalUsings.cs` | - |
|
||||||
|
| `template-ServiceLoggerMessages.cs` | `<ModuleName>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 <filename>"
|
||||||
|
|
||||||
|
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:** `<contracts_project_path>/<ModuleName>/`
|
||||||
|
|
||||||
|
**File:** `<ModuleName>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:** `<modules_base_path>/<modulename>/Contracts/`
|
||||||
|
|
||||||
|
**File:** `<ModuleName>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 `<contracts_project_path>/Extensions/LoggingBuilderExtensions.cs` exists:
|
||||||
|
|
||||||
|
- If file exists: Skip this step (already created by previous module)
|
||||||
|
- If file does NOT exist: Create it
|
||||||
|
|
||||||
|
**Directory:** `<contracts_project_path>/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:** `<modules_base_path>/<modulename>/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 <manifest_name>? (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: `<manifests_base_path>/{name}.deployment.manifest.json`
|
||||||
|
- If `manifests_base_path` not detected, use `<modules_base_path>/../{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 \
|
||||||
|
"<manifest_path>" \
|
||||||
|
"<modulename>" \
|
||||||
|
--registry "<container_registry>"
|
||||||
|
```
|
||||||
|
- This adds the newly scaffolded module as the first custom module
|
||||||
|
|
||||||
|
4. **Report to user:**
|
||||||
|
```
|
||||||
|
✓ Created base deployment manifest: <manifest_path>
|
||||||
|
✓ Added <modulename> to manifest (startup order: 1)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Continue to Step 8 (or Step 9 if no updates needed)**
|
||||||
|
|
||||||
|
**Multi-manifest selection prompt:**
|
||||||
|
|
||||||
|
```
|
||||||
|
Found <count> deployment manifests:
|
||||||
|
|
||||||
|
1. <manifest_basename> (<modules_count> modules)
|
||||||
|
Path: <manifest_path>
|
||||||
|
|
||||||
|
2. <manifest_basename> (<modules_count> modules)
|
||||||
|
Path: <manifest_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 \
|
||||||
|
"<manifest_path>" \
|
||||||
|
"<modulename>" \
|
||||||
|
--registry "<container_registry>"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Process the output:**
|
||||||
|
|
||||||
|
1. Check for `"success": true` in JSON output
|
||||||
|
2. Report to user: "✓ Added <modulename> to <manifest_name> (startup order: <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 <manifest_path>:
|
||||||
|
|
||||||
|
Add to $edgeAgent.properties.desired.modules:
|
||||||
|
{
|
||||||
|
"<modulename>": {
|
||||||
|
"version": "1.0",
|
||||||
|
"type": "docker",
|
||||||
|
"status": "running",
|
||||||
|
"restartPolicy": "always",
|
||||||
|
"startupOrder": <next-order>,
|
||||||
|
"settings": {
|
||||||
|
"image": "${MODULES.<modulename>}",
|
||||||
|
"createOptions": {
|
||||||
|
"HostConfig": {
|
||||||
|
"LogConfig": {
|
||||||
|
"Type": "json-file",
|
||||||
|
"Config": {
|
||||||
|
"max-size": "10m",
|
||||||
|
"max-file": "10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Mounts": [{
|
||||||
|
"Type": "volume",
|
||||||
|
"Target": "/app/data/",
|
||||||
|
"Source": "<modulename>"
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Add to $edgeHub.properties.desired.routes:
|
||||||
|
{
|
||||||
|
"<modulename>ToIoTHub": {
|
||||||
|
"route": "FROM /messages/modules/<modulename>/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: `- **<modulename>** (\`<module_path>\`) - <ModuleDescription>`
|
||||||
|
- 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_csproj_path>" \
|
||||||
|
--module-name "<ModuleName>"
|
||||||
|
```
|
||||||
|
|
||||||
|
- Parse JSON output
|
||||||
|
- If `action: "added"`: Report "✓ Added to solution at position <insertion_index>"
|
||||||
|
- 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_csproj_path>" \
|
||||||
|
--module-name "<ModuleName>"
|
||||||
|
```
|
||||||
|
- Parse JSON output and display `instructions` field to user
|
||||||
|
- Recommend using: `dotnet sln add "<module_csproj_path>"`
|
||||||
|
|
||||||
|
**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: <module_full_path>/ (11 files)
|
||||||
|
• Constants file: <constants_full_path>
|
||||||
|
• LoggingBuilderExtensions: <"Created" or "Already exists - skipped">
|
||||||
|
• Updated manifests: <manifest_count> manifest(s) [or "Created base manifest" if first module]
|
||||||
|
• Solution integration: <"Added to .slnx" or "Manual instructions provided" or "Skipped">
|
||||||
|
|
||||||
|
Configuration:
|
||||||
|
• Container registry: <container_registry>
|
||||||
|
• Project namespace: <project_namespace>
|
||||||
|
• NuGet feed: <nuget_feed_url or "None">
|
||||||
|
• Shared contracts: <"Yes" or "No">
|
||||||
|
```
|
||||||
|
|
||||||
|
**Next steps for the user:**
|
||||||
|
|
||||||
|
1. **Implement module logic:**
|
||||||
|
- Edit `<ModuleName>Service.cs`
|
||||||
|
- Add business logic in `ExecuteAsync()`
|
||||||
|
- Register direct method handlers if needed
|
||||||
|
|
||||||
|
2. **Add dependencies (if needed):**
|
||||||
|
- Update `<ModuleName>.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_path>`
|
||||||
|
- Module runs with mock IoT Hub client
|
||||||
|
|
||||||
|
5. **Add direct methods (if needed):**
|
||||||
|
- Add method names to `<ModuleName>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: `<module_full_path>/`
|
||||||
|
- Constants: `<constants_full_path>`
|
||||||
|
- Deployment manifest(s): `<manifest_paths>`
|
||||||
|
|
||||||
|
## 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<MyOptions>(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)
|
||||||
3
skills/iot-edge-module/assets/template-.dockerignore
Normal file
3
skills/iot-edge-module/assets/template-.dockerignore
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Ignore .NET build outputs
|
||||||
|
**/bin
|
||||||
|
**/obj
|
||||||
34
skills/iot-edge-module/assets/template-.gitignore
Normal file
34
skills/iot-edge-module/assets/template-.gitignore
Normal file
@@ -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/
|
||||||
33
skills/iot-edge-module/assets/template-Dockerfile.amd64
Normal file
33
skills/iot-edge-module/assets/template-Dockerfile.amd64
Normal file
@@ -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}}"]
|
||||||
@@ -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}}"]
|
||||||
10
skills/iot-edge-module/assets/template-GlobalUsings.cs
Normal file
10
skills/iot-edge-module/assets/template-GlobalUsings.cs
Normal file
@@ -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;
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
namespace {{PROJECT_NAMESPACE}}.Modules.Contracts.Extensions;
|
||||||
|
|
||||||
|
public static class LoggingBuilderExtensions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Adds systemd console logging with standardized timestamp format and log level configuration.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="builder">The <see cref="ILoggingBuilder"/> to configure.</param>
|
||||||
|
/// <returns>The <see cref="ILoggingBuilder"/> for chaining.</returns>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
35
skills/iot-edge-module/assets/template-Program.cs
Normal file
35
skills/iot-edge-module/assets/template-Program.cs
Normal file
@@ -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<IModuleClientWrapper, MockModuleClientWrapper>();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
services.AddModuleClientWrapper(TransportSettingsFactory.BuildMqttTransportSettings());
|
||||||
|
}
|
||||||
|
|
||||||
|
services.AddSingleton<IMethodResponseFactory, MethodResponseFactory>();
|
||||||
|
|
||||||
|
//// TODO: Add your service registrations here
|
||||||
|
|
||||||
|
services.AddHostedService<{{ModuleName}}Service>();
|
||||||
|
})
|
||||||
|
.UseConsoleLifetime()
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
await host.RunAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
59
skills/iot-edge-module/assets/template-Service.cs
Normal file
59
skills/iot-edge-module/assets/template-Service.cs
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
namespace {{ModuleName}};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The main {{ModuleName}}Service.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
namespace {{ModuleName}};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// {{ModuleName}}Service LoggerMessages.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
13
skills/iot-edge-module/assets/template-launchSettings.json
Normal file
13
skills/iot-edge-module/assets/template-launchSettings.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"profiles": {
|
||||||
|
"{{ModuleName}}": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"environmentVariables": {
|
||||||
|
"DOTNET_ENVIRONMENT": "Standalone"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Docker": {
|
||||||
|
"commandName": "Docker"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
17
skills/iot-edge-module/assets/template-module.json
Normal file
17
skills/iot-edge-module/assets/template-module.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
32
skills/iot-edge-module/assets/template.csproj
Normal file
32
skills/iot-edge-module/assets/template.csproj
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
|
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Atc" Version="2.0.562" />
|
||||||
|
<PackageReference Include="Atc.Azure.IoTEdge" Version="1.0.177" />
|
||||||
|
<PackageReference Include="Microsoft.Azure.Devices.Client" Version="1.42.3" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Configuration" Version="9.0.11" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.11" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="9.0.11" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.11" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Configuration.FileExtensions" Version="9.0.11" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="9.0.11" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.11" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="9.0.11" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="9.0.11" />
|
||||||
|
<PackageReference Include="System.Runtime.Loader" Version="4.3.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
{{CONTRACTS_PROJECT_REFERENCE}}
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<InternalsVisibleTo Include="$(AssemblyName).Tests" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
238
skills/iot-edge-module/references/deployment-manifests.md
Normal file
238
skills/iot-edge-module/references/deployment-manifests.md
Normal file
@@ -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.<modulename>": {
|
||||||
|
"version": "1.0",
|
||||||
|
"type": "docker",
|
||||||
|
"status": "running",
|
||||||
|
"restartPolicy": "always",
|
||||||
|
"startupOrder": <number>,
|
||||||
|
"settings": {
|
||||||
|
"image": "${MODULES.<modulename>}",
|
||||||
|
"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.<modulename>}`)
|
||||||
|
- `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.<routename>": {
|
||||||
|
"route": "<route expression>",
|
||||||
|
"priority": 0,
|
||||||
|
"timeToLiveSecs": 86400
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Common route patterns**:
|
||||||
|
|
||||||
|
1. **Module to IoT Hub (upstream)**:
|
||||||
|
```json
|
||||||
|
"route": "FROM /messages/modules/<modulename>/outputs/* INTO $upstream"
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Module to module (BrokeredEndpoint)**:
|
||||||
|
```json
|
||||||
|
"route": "FROM /messages/modules/<sourcemodule>/* INTO BrokeredEndpoint(\"/modules/<targetmodule>/inputs/<inputname>\")"
|
||||||
|
```
|
||||||
|
|
||||||
|
**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.<modulename>}` - 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": "<modulename>"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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.<modulename>` as the key
|
||||||
|
- Set appropriate `startupOrder` (consider dependencies)
|
||||||
|
- Set `image` to `${MODULES.<modulename>}`
|
||||||
|
- Configure `createOptions` (log rotation, binds, mounts)
|
||||||
|
- Add environment variables if needed
|
||||||
|
|
||||||
|
2. **Add routing to `$edgeHub`**:
|
||||||
|
- Use descriptive route name: `properties.desired.routes.<modulename>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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
292
skills/iot-edge-module/references/module-structure.md
Normal file
292
skills/iot-edge-module/references/module-structure.md
Normal file
@@ -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:
|
||||||
|
|
||||||
|
```
|
||||||
|
<modulename>/ # 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
|
||||||
|
├── <ModuleName>.csproj # .NET 9.0 project file
|
||||||
|
├── Program.cs # Application entry point
|
||||||
|
├── GlobalUsings.cs # Global namespace imports
|
||||||
|
├── LoggingEventIdConstants.cs # Logging event IDs
|
||||||
|
├── <ModuleName>Service.cs # Main hosted service
|
||||||
|
├── <ModuleName>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**: `<modulename>/module.json`
|
||||||
|
|
||||||
|
**Schema**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"$schema-version": "0.0.1",
|
||||||
|
"description": "Module description",
|
||||||
|
"image": {
|
||||||
|
"repository": "yourregistry.azurecr.io/<modulename>",
|
||||||
|
"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<IModuleClientWrapper, MockModuleClientWrapper>();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
services.AddModuleClientWrapper(TransportSettingsFactory.BuildMqttTransportSettings());
|
||||||
|
}
|
||||||
|
|
||||||
|
services.AddSingleton<IMethodResponseFactory, MethodResponseFactory>();
|
||||||
|
|
||||||
|
// Add your service registrations here
|
||||||
|
|
||||||
|
services.AddHostedService<YourModuleService>();
|
||||||
|
})
|
||||||
|
.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**: `<ModuleName>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**: `<contracts-project-path>/<ModuleName>/<ModuleName>Constants.cs`
|
||||||
|
|
||||||
|
**Purpose**: Shared constants for module identification and direct methods.
|
||||||
|
|
||||||
|
**Structure**:
|
||||||
|
```csharp
|
||||||
|
namespace Company.ProjectName.Modules.Contracts.<ModuleName>;
|
||||||
|
|
||||||
|
public static class <ModuleName>Constants
|
||||||
|
{
|
||||||
|
public const string ModuleId = "<modulename>";
|
||||||
|
|
||||||
|
// 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**: `<ModuleName>Constants.cs` in shared contracts
|
||||||
|
- **Dockerfile**: `Dockerfile.amd64` and `Dockerfile.amd64.debug`
|
||||||
|
|
||||||
|
## Configuration Pattern
|
||||||
|
|
||||||
|
Modules use `IOptions<T>` for configuration:
|
||||||
|
|
||||||
|
1. **Define options class**:
|
||||||
|
```csharp
|
||||||
|
public class MyModuleOptions
|
||||||
|
{
|
||||||
|
public string Setting { get; set; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Register in DI**:
|
||||||
|
```csharp
|
||||||
|
services.Configure<MyModuleOptions>(hostContext.Configuration);
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Inject options**:
|
||||||
|
```csharp
|
||||||
|
public MyService(IOptions<MyModuleOptions> 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.
|
||||||
291
skills/iot-edge-module/scripts/detect_project_structure.py
Normal file
291
skills/iot-edge-module/scripts/detect_project_structure.py
Normal file
@@ -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 <Something>.Modules.Contracts.<ModuleName>
|
||||||
|
3. Extracting base: <Something>
|
||||||
|
"""
|
||||||
|
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 <Something>.Modules.Contracts.<ModuleName> 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()
|
||||||
286
skills/iot-edge-module/scripts/manage_solution.py
Normal file
286
skills/iot-edge-module/scripts/manage_solution.py
Normal file
@@ -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())
|
||||||
138
skills/iot-edge-module/scripts/scan_manifests.py
Normal file
138
skills/iot-edge-module/scripts/scan_manifests.py
Normal file
@@ -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()
|
||||||
303
skills/iot-edge-module/scripts/update_deployment_manifest.py
Normal file
303
skills/iot-edge-module/scripts/update_deployment_manifest.py
Normal file
@@ -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()
|
||||||
Reference in New Issue
Block a user