Initial commit
This commit is contained in:
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