Browse Source

Merge pull request #278 from Timmoth/feature/276

Feature/276
Tim Jones 11 hours ago
parent
commit
c4a1bb85ef

+ 9 - 0
RackPeek.Domain/UseCases/Ansible/AnsibleInventoryGenerator.cs

@@ -192,6 +192,15 @@ public static class AnsibleInventoryGenerator {
             if (string.IsNullOrWhiteSpace(k) || string.IsNullOrWhiteSpace(v))
                 continue;
 
+            // Custom host variables via ansible_var_*
+            if (k.StartsWith("ansible_var_", StringComparison.OrdinalIgnoreCase)) {
+                var varName = k.Substring("ansible_var_".Length);
+                if (!string.IsNullOrWhiteSpace(varName))
+                    vars[varName] = v;
+                continue;
+            }
+
+            // Standard ansible_* variables
             if (k.StartsWith("ansible_", StringComparison.OrdinalIgnoreCase))
                 vars[k] = v;
         }

+ 110 - 28
Shared.Rcl/wwwroot/raw_docs/ansible-generator-guide.md

@@ -2,6 +2,8 @@
 
 RackPeek can generate production-ready Ansible inventory directly from your modeled infrastructure.
 
+---
+
 # 1. Making a Resource Ansible-Ready
 
 A resource becomes an Ansible host when it has an address label.
@@ -17,9 +19,27 @@ labels:
 
 Without this, the resource will not appear in inventory.
 
+RackPeek will also accept these alternatives if `ansible_host` is not provided:
+
+| Label      | Used As      |
+| ---------- | ------------ |
+| `ip`       | ansible_host |
+| `hostname` | ansible_host |
+
+Example:
+
+```yaml
+labels:
+  ip: 192.168.1.10
+```
+
 ---
 
-## Recommended Labels
+# 2. Standard Ansible Labels
+
+RackPeek automatically exports any label beginning with **`ansible_`** as an Ansible host variable.
+
+Example:
 
 ```yaml
 labels:
@@ -27,24 +47,67 @@ labels:
   ansible_user: ubuntu
   ansible_port: 22
   ansible_ssh_private_key_file: ~/.ssh/id_rsa
-  env: prod
-  role: web
 ```
 
 ### What these do
 
-| Label                        | Purpose           |
-|------------------------------|-------------------|
-| ansible_host                 | IP or DNS target  |
-| ansible_user                 | SSH user          |
-| ansible_port                 | SSH port          |
-| ansible_ssh_private_key_file | SSH key           |
-| env                          | Used for grouping |
-| role                         | Used for grouping |
+| Label                        | Purpose          |
+| ---------------------------- | ---------------- |
+| ansible_host                 | IP or DNS target |
+| ansible_user                 | SSH user         |
+| ansible_port                 | SSH port         |
+| ansible_ssh_private_key_file | SSH key          |
+
+These variables appear directly in the generated inventory.
+
+---
+
+# 3. Custom Host Variables (`ansible_var_*`)
+
+RackPeek supports exposing **custom variables** to Ansible playbooks using the label prefix:
+
+```
+ansible_var_
+```
+
+The prefix is removed when generating inventory.
+
+### Example
+
+```yaml
+labels:
+  ansible_host: 10.0.0.10
+  ansible_var_mac: 52:54:00:11:22:33
+  ansible_var_rack: rack01
+```
+
+Generated inventory:
+
+```yaml
+cerberus-0:
+  ansible_host: 10.0.0.10
+  mac: 52:54:00:11:22:33
+  rack: rack01
+```
+
+This allows RackPeek to remain the **source of truth for infrastructure metadata** while making the data available to playbooks.
+
+### Example Playbook Usage
+
+```yaml
+- hosts: all
+  gather_facts: false
+
+  tasks:
+    - name: Copy ignition file
+      ansible.builtin.copy:
+        src: "output/{{ inventory_hostname }}.ign"
+        dest: "/srv/ignition/{{ mac }}.ign"
+```
 
 ---
 
-# 2. Using Tags for Grouping
+# 4. Using Tags for Grouping
 
 Tags are simple grouping mechanisms.
 
@@ -75,7 +138,7 @@ vm-web01 ...
 
 ---
 
-# 3. Using Labels for Structured Groups
+# 5. Using Labels for Structured Groups
 
 Labels allow structured grouping.
 
@@ -107,7 +170,7 @@ This is cleaner and more scalable than raw tags.
 
 ---
 
-# 4. Example Resource
+# 6. Example Resource
 
 ```yaml
 - kind: System
@@ -116,19 +179,22 @@ This is cleaner and more scalable than raw tags.
   cores: 4
   ram: 8
   name: vm-web01
+
   tags:
   - prod
   - web
+
   labels:
     ansible_host: 192.168.1.10
     ansible_user: ubuntu
+    ansible_var_mac: 52:54:00:11:22:33
     env: prod
     role: web
 ```
 
 ---
 
-# 5. Generating Inventory
+# 7. Generating Inventory
 
 ## CLI
 
@@ -158,7 +224,7 @@ Click **Generate**.
 
 ---
 
-# 6. Example Generated Inventory
+# 8. Example Generated Inventory
 
 ```ini
 [all:vars]
@@ -166,18 +232,18 @@ ansible_python_interpreter=/usr/bin/python3
 ansible_user=ansible
 
 [env_prod]
-vm-web01 ansible_host=192.168.1.10 ansible_user=ubuntu
+vm-web01 ansible_host=192.168.1.10 ansible_user=ubuntu mac=52:54:00:11:22:33
 
 [role_web]
-vm-web01 ansible_host=192.168.1.10 ansible_user=ubuntu
+vm-web01 ansible_host=192.168.1.10 ansible_user=ubuntu mac=52:54:00:11:22:33
 
 [prod]
-vm-web01 ansible_host=192.168.1.10 ansible_user=ubuntu
+vm-web01 ansible_host=192.168.1.10 ansible_user=ubuntu mac=52:54:00:11:22:33
 ```
 
 ---
 
-# 7. Writing Playbooks Against RackPeek Inventory
+# 9. Writing Playbooks Against RackPeek Inventory
 
 ## Example 1 – Ping Production
 
@@ -239,7 +305,7 @@ ansible-playbook -i inventory.ini ping.yml
 
 ---
 
-# 8. Best Practices
+# 10. Best Practices
 
 ### 1. Use Labels for Structure
 
@@ -254,7 +320,22 @@ Over raw tags when designing larger infrastructure.
 
 ---
 
-### 2. Keep Global Vars Minimal
+### 2. Use `ansible_var_*` for Infrastructure Metadata
+
+Examples:
+
+```
+ansible_var_mac
+ansible_var_rack
+ansible_var_datacenter
+ansible_var_vlan
+```
+
+This allows playbooks to reference infrastructure information without duplicating configuration.
+
+---
+
+### 3. Keep Global Vars Minimal
 
 Use:
 
@@ -268,7 +349,7 @@ Override per host only when needed.
 
 ---
 
-### 3. Separate Infrastructure and Services
+### 4. Separate Infrastructure and Services
 
 Model:
 
@@ -279,7 +360,7 @@ Deploy against systems, not services.
 
 ---
 
-### 4. Keep Inventory Deterministic
+### 5. Keep Inventory Deterministic
 
 Avoid:
 
@@ -289,7 +370,7 @@ Avoid:
 
 ---
 
-# 9. Advanced Pattern (Recommended)
+# 11. Advanced Pattern (Recommended)
 
 Use both:
 
@@ -315,12 +396,13 @@ ansible-playbook site.yml -l env_prod:&role_web
 
 ---
 
-# 10. Summary
+# 12. Summary
 
 To use RackPeek effectively with Ansible:
 
 1. Add `ansible_host` label
 2. Add `env` and `role` labels
 3. Optionally add tags
-4. Generate inventory
-5. Write playbooks targeting groups
+4. Use `ansible_var_*` for custom host variables
+5. Generate inventory
+6. Write playbooks targeting groups

+ 410 - 0
Tests/EndToEnd/GenerateAnsibleInventoryTests.cs

@@ -0,0 +1,410 @@
+using Tests.EndToEnd.Infra;
+using Xunit.Abstractions;
+
+namespace Tests.EndToEnd;
+
+[Collection("Yaml CLI tests")]
+public class GenerateAnsibleInventoryTests(
+    TempYamlCliFixture fs,
+    ITestOutputHelper outputHelper)
+    : IClassFixture<TempYamlCliFixture> {
+    private async Task<(string output, string yaml)> ExecuteAsync(params string[] args) {
+        outputHelper.WriteLine($"rpk {string.Join(" ", args)}");
+
+        var output = await YamlCliTestHost.RunAsync(
+            args,
+            fs.Root,
+            outputHelper,
+            "config.yaml");
+
+        outputHelper.WriteLine(output);
+
+        var yaml = await File.ReadAllTextAsync(Path.Combine(fs.Root, "config.yaml"));
+        return (output, yaml);
+    }
+
+    [Fact]
+    public async Task generate_ansible_inventory_empty_config() {
+        await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), """
+version: 1
+resources: []
+""");
+
+        (var output, _) = await ExecuteAsync("ansible", "inventory");
+
+        Assert.Contains("Generated Inventory", output);
+    }
+
+    [Fact]
+    public async Task generate_ansible_inventory_single_system_ini_format() {
+        await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), """
+version: 1
+resources:
+  - kind: System
+    type: vm
+    name: web-server-01
+    os: ubuntu-22.04
+    cores: 2
+    ram: 4
+    labels:
+      ansible_host: 192.168.1.100
+      ansible_user: admin
+      env: production
+""");
+
+        (var output, _) = await ExecuteAsync("ansible", "inventory", "--group-labels", "env");
+
+        Assert.Contains("Generated Inventory", output);
+        Assert.Contains("web-server-01", output);
+        Assert.Contains("ansible_host=192.168.1.100", output);
+        Assert.Contains("ansible_user=admin", output);
+        Assert.Contains("[env_production]", output);
+    }
+
+    [Fact]
+    public async Task generate_ansible_inventory_with_tag_grouping() {
+        await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), """
+version: 1
+resources:
+  - kind: System
+    type: vm
+    name: web-prod-01
+    os: ubuntu-22.04
+    cores: 2
+    ram: 4
+    tags: [production, web]
+    labels:
+      ansible_host: 10.0.1.10
+      ansible_user: ubuntu
+
+  - kind: System
+    type: vm
+    name: web-prod-02
+    os: ubuntu-22.04
+    cores: 2
+    ram: 4
+    tags: [production, web]
+    labels:
+      ansible_host: 10.0.1.11
+      ansible_user: ubuntu
+
+  - kind: System
+    type: vm
+    name: db-staging-01
+    os: postgres-15
+    cores: 4
+    ram: 8
+    tags: [staging, database]
+    labels:
+      ansible_host: 10.0.2.20
+      ansible_user: postgres
+""");
+
+        (var output, _) = await ExecuteAsync(
+            "ansible", "inventory",
+            "--group-tags", "production,staging,web,database");
+
+        Assert.Contains("[production]", output);
+        Assert.Contains("[staging]", output);
+        Assert.Contains("[web]", output);
+        Assert.Contains("[database]", output);
+        Assert.Contains("web-prod-01", output);
+        Assert.Contains("web-prod-02", output);
+        Assert.Contains("db-staging-01", output);
+    }
+
+    [Fact]
+    public async Task generate_ansible_inventory_with_label_grouping() {
+        await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), """
+version: 1
+resources:
+  - kind: System
+    type: vm
+    name: server-east-01
+    os: debian-12
+    cores: 2
+    ram: 4
+    labels:
+      ansible_host: 10.0.1.10
+      region: us-east
+
+  - kind: System
+    type: vm
+    name: server-west-01
+    os: debian-12
+    cores: 2
+    ram: 4
+    labels:
+      ansible_host: 10.0.2.10
+      region: us-west
+
+  - kind: System
+    type: vm
+    name: server-eu-01
+    os: debian-12
+    cores: 2
+    ram: 4
+    labels:
+      ansible_host: 10.0.3.10
+      region: eu-central
+""");
+
+        (var output, _) = await ExecuteAsync(
+            "ansible", "inventory",
+            "--group-labels", "region");
+
+        Assert.Contains("[region_us_east]", output);
+        Assert.Contains("[region_us_west]", output);
+        Assert.Contains("[region_eu_central]", output);
+        Assert.Contains("server-east-01", output);
+        Assert.Contains("server-west-01", output);
+        Assert.Contains("server-eu-01", output);
+    }
+
+    [Fact]
+    public async Task generate_ansible_inventory_with_global_vars() {
+        await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), """
+version: 1
+resources:
+  - kind: System
+    type: vm
+    name: app-server-01
+    os: ubuntu-22.04
+    cores: 2
+    ram: 4
+    labels:
+      ansible_host: 192.168.1.50
+      ansible_user: deploy
+      env: prod
+""");
+
+        (var output, _) = await ExecuteAsync(
+            "ansible", "inventory",
+            "--group-labels", "env",
+            "--global-var", "ansible_ssh_common_args='-o StrictHostKeyChecking=no'",
+            "--global-var", "python_version=3.10",
+            "--global-var", "app_name=myapp");
+
+        Assert.Contains("Generated Inventory", output);
+        Assert.Contains("ansible_ssh_common_args", output);
+        Assert.Contains("StrictHostKeyChecking=no", output);
+        Assert.Contains("python_version=3.10", output);
+        Assert.Contains("app_name=myapp", output);
+        Assert.Contains("app-server-01", output);
+    }
+
+    [Fact]
+    public async Task generate_ansible_inventory_yaml_format() {
+        await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), """
+version: 1
+resources:
+  - kind: System
+    type: vm
+    name: ansible-test-host
+    os: ubuntu-22.04
+    cores: 2
+    ram: 4
+    labels:
+      ansible_host: 172.16.0.100
+      ansible_user: root
+      env: test
+""");
+
+        (var output, _) = await ExecuteAsync("ansible", "inventory", "--format", "yaml", "--group-labels", "env");
+
+        Assert.Contains("Generated Inventory", output);
+        Assert.Contains("all:", output);
+        Assert.Contains("children:", output);
+        Assert.Contains("ansible-test-host:", output);
+        Assert.Contains("ansible_host: 172.16.0.100", output);
+        Assert.DoesNotContain("[all:vars]", output);
+    }
+
+    [Fact]
+    public async Task generate_ansible_inventory_combined_grouping() {
+        await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), """
+version: 1
+resources:
+  - kind: System
+    type: vm
+    name: prod-web-01
+    os: ubuntu-22.04
+    cores: 2
+    ram: 4
+    tags: [production]
+    labels:
+      ansible_host: 10.0.1.10
+      env: production
+      tier: web
+
+  - kind: System
+    type: vm
+    name: prod-db-01
+    os: postgres-15
+    cores: 4
+    ram: 8
+    tags: [production]
+    labels:
+      ansible_host: 10.0.1.20
+      env: production
+      tier: database
+""");
+
+        (var output, _) = await ExecuteAsync(
+            "ansible", "inventory",
+            "--group-tags", "production",
+            "--group-labels", "env,tier");
+
+        Assert.Contains("[production]", output);
+        Assert.Contains("[env_production]", output);
+        Assert.Contains("[tier_web]", output);
+        Assert.Contains("[tier_database]", output);
+        Assert.Contains("prod-web-01", output);
+        Assert.Contains("prod-db-01", output);
+    }
+
+    [Fact]
+    public async Task generate_ansible_inventory_mixed_resources() {
+        await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), """
+version: 1
+resources:
+  - kind: Server
+    name: srv-prod-01
+    labels:
+      ansible_host: 192.168.1.10
+      ansible_user: root
+      env: production
+
+  - kind: System
+    type: vm
+    name: vm-dev-01
+    os: ubuntu-22.04
+    cores: 2
+    ram: 4
+    labels:
+      ansible_host: 192.168.1.20
+      ansible_user: developer
+      env: development
+
+  - kind: Desktop
+    name: dtp-admin-01
+    labels:
+      ansible_host: 192.168.1.30
+      ansible_user: admin
+      env: production
+
+  - kind: Laptop
+    name: ltp-remote-01
+    labels:
+      ansible_host: 192.168.1.40
+      ansible_user: remote
+      env: remote
+
+  - kind: Switch
+    name: sw-access-01
+    labels:
+      ansible_host: 192.168.1.50
+      ansible_user: network
+      env: network
+""");
+
+        (var output, _) = await ExecuteAsync("ansible", "inventory", "--group-labels", "env");
+
+        Assert.Contains("Generated Inventory", output);
+        Assert.Contains("srv-prod-01", output);
+        Assert.Contains("vm-dev-01", output);
+        Assert.Contains("dtp-admin-01", output);
+        Assert.Contains("ltp-remote-01", output);
+        Assert.Contains("sw-access-01", output);
+    }
+
+    [Fact]
+    public async Task generate_ansible_inventory_with_multiple_labels() {
+        await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), """
+version: 1
+resources:
+  - kind: System
+    type: vm
+    name: multi-label-host
+    os: ubuntu-22.04
+    cores: 2
+    ram: 4
+    labels:
+      ansible_host: 10.0.0.1
+      ansible_user: sysadmin
+      environment: prod
+      team: backend
+      os: ubuntu
+""");
+
+        (var output, _) = await ExecuteAsync(
+            "ansible", "inventory",
+            "--group-labels", "environment,team,os");
+
+        Assert.Contains("[environment_prod]", output);
+        Assert.Contains("[team_backend]", output);
+        Assert.Contains("[os_ubuntu]", output);
+        Assert.Contains("multi-label-host", output);
+    }
+
+    [Fact]
+    public async Task generate_ansible_inventory_ansible_var_labels() {
+        await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), """
+version: 1
+resources:
+  - kind: System
+    type: vm
+    name: web-server-01
+    os: ubuntu-22.04
+    cores: 2
+    ram: 4
+    labels:
+      ansible_host: 192.168.1.100
+      ansible_user: deploy
+      ansible_become: "yes"
+      ansible_var_python_path: /usr/bin/python3
+      ansible_var_site: production
+      ansible_var_app_env: prod
+      env: prod
+""");
+
+        (var output, _) = await ExecuteAsync("ansible", "inventory", "--group-labels", "env");
+
+        Assert.Contains("web-server-01", output);
+        Assert.Contains("ansible_host=192.168.1.100", output);
+        Assert.Contains("ansible_user=deploy", output);
+        Assert.Contains("ansible_become=yes", output);
+        Assert.Contains("python_path=/usr/bin/python3", output);
+        Assert.Contains("site=production", output);
+        Assert.Contains("app_env=prod", output);
+    }
+
+    [Fact]
+    public async Task generate_ansible_inventory_yaml_format_with_ansible_vars() {
+        await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), """
+version: 1
+resources:
+  - kind: System
+    type: vm
+    name: ansible-var-test
+    os: ubuntu-22.04
+    cores: 2
+    ram: 4
+    labels:
+      ansible_host: 10.0.0.50
+      ansible_var_custom_var: custom_value
+      ansible_var_number: "42"
+      env: test
+""");
+
+        (var output, _) = await ExecuteAsync(
+            "ansible", "inventory",
+            "--format", "yaml",
+            "--group-labels", "env");
+
+        Assert.Contains("ansible-var-test:", output);
+        Assert.Contains("ansible_host: 10.0.0.50", output);
+        Assert.Contains("custom_var: custom_value", output);
+        Assert.Contains("number: 42", output);
+    }
+}