Initial commit

This commit is contained in:
Zhongwei Li
2025-11-29 17:58:35 +08:00
commit 2448fbf2fb
25 changed files with 2940 additions and 0 deletions

View 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
View File

@@ -0,0 +1,3 @@
# azure-iot
Azure IoT services automation and scaffolding for IoT Edge modules, IoT Hub, and related services

View 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
View 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": []
}
}

View 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)

View File

@@ -0,0 +1,3 @@
# Ignore .NET build outputs
**/bin
**/obj

View 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/

View 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}}"]

View File

@@ -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}}"]

View 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;

View File

@@ -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;
}
}

View File

@@ -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";
}

View 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();
}
}

View 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);
}

View File

@@ -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);
}

View File

@@ -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
}
}
}
}
}

View File

@@ -0,0 +1,13 @@
{
"profiles": {
"{{ModuleName}}": {
"commandName": "Project",
"environmentVariables": {
"DOTNET_ENVIRONMENT": "Standalone"
}
},
"Docker": {
"commandName": "Docker"
}
}
}

View 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"
}

View 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>

View 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
}
}
}
}
```

View 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.

View 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()

View 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())

View 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()

View 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()