]> git-server-git.apps.pok.os.sepia.ceph.com Git - ceph-cm-ansible.git/commitdiff
maas: Tasks to manage networking in MAAS
authorDavid Galloway <david.galloway@ibm.com>
Mon, 4 May 2026 21:45:29 +0000 (17:45 -0400)
committerDavid Galloway <david.galloway@ibm.com>
Fri, 8 May 2026 20:55:04 +0000 (16:55 -0400)
Signed-off-by: David Galloway <david.galloway@ibm.com>
12 files changed:
roles/maas/README.md
roles/maas/tasks/main.yml
roles/maas/tasks/networking.yml [new file with mode: 0644]
roles/maas/tasks/networking/domain_create.yml [new file with mode: 0644]
roles/maas/tasks/networking/fabric_create.yml [new file with mode: 0644]
roles/maas/tasks/networking/fabric_vlans_read_from_maas.yml [new file with mode: 0644]
roles/maas/tasks/networking/space_create.yml [new file with mode: 0644]
roles/maas/tasks/networking/subnet_apply.yml [new file with mode: 0644]
roles/maas/tasks/networking/subnet_range_create.yml [new file with mode: 0644]
roles/maas/tasks/networking/vlan_build_index.yml [new file with mode: 0644]
roles/maas/tasks/networking/vlan_create.yml [new file with mode: 0644]
roles/maas/tasks/networking/vlan_update.yml [new file with mode: 0644]

index 1dde14eb84ffb22691cdf548a38d0d69f8bfaabb..aa1665c4ad96cf9a5db042350dd9bd549f0a4d85 100644 (file)
@@ -6,7 +6,7 @@ This Ansible playbook automates the installation and initial configuration of [M
 
 - Installs MAAS packages
 - Initializes MAAS with a default user with High Availability
-- Configures networking (DHCP, DNS, etc.)
+- Configures networking (Domains, Spaces, Fabrics, VLANs, Subnets, IP ranges, DHCP, DNS, etc.) via the MAAS REST API
 - Adds Machines from inventory into MAAS
 
 ## Requirements
@@ -121,6 +121,55 @@ The classes are optional, they are groups of DHCP clients defined by specific cr
 
 The pools are optional too, they are ranges of IP addresses that a DHCP server uses to automatically assign to DHCP clients on a network. These addresses are dynamically allocated, meaning they are leased to clients for a specific duration and can be reclaimed when no longer in use. DHCP pools allow for efficient IP address management and are essential for networks where devices are frequently added or moved. In the example above we are using pools to assign IPs to the classes we just defined and to the unknown_clients which are servers that are not defined into the DHCP config file.
 
+Networking variables include:
+
+The networking configuration is driven entirely by the `maas_networking` data structure (MUST be defined in `group_vars/all.yml`). It is consumed by `tasks/networking.yml`, which then includes the smaller, single-purpose task files under `tasks/networking/`. The role talks to MAAS over its REST API (using the OAuth header built by `_auth_header.yml`), and is idempotent: it reads what already exists and only creates or updates what is missing or different.
+
+The top-level shape is a list of fabrics, each with its own list of VLANs, and each VLAN with its own list of subnets:
+
+maas_networking:
+  - fabric: pok                      # Fabric name (created if missing)
+    vlans:
+      - vid: 1338                    # 802.1Q VLAN id (required)
+        name: new-front              # Required. Must match ^[a-z0-9-]+$ (lowercase, digits, dashes)
+        description: "..."           # Optional, stored on the VLAN
+        mtu: 1500                    # Optional, applied during VLAN update
+        dhcp_on: false               # Optional. If true, MAAS DHCP is enabled for the VLAN
+        primary_rack_controller: ""  # Optional per-VLAN override of the global primary rack controller
+        subnets:
+          - cidr: 10.20.192.0/20     # Required
+            domain: front.sepia.ceph.com   # Optional. Collected and created as MAAS Domains
+            inventory_prefixes: ["front"]  # Hint to match ansible inventory hosts/IPs during host creation
+            managed: false           # Optional. Should MAAS allocate IPs on this subnet?
+            space: "pok"             # Optional. Created as a MAAS Space and bound to the VLAN
+            gateway: 10.20.192.1     # Optional. Maps to MAAS subnet `gateway_ip`
+            dns_servers: []          # Optional list. Falls back to maas_global_dns_servers when empty
+            ip_ranges:               # Optional list of reserved/dynamic ranges
+              - type: reserved       # 'reserved' or 'dynamic'
+                start_ip: 10.20.192.1
+                end_ip: 10.20.207.253
+              - type: dynamic
+                start_ip: 10.20.207.254
+                end_ip: 10.20.207.254
+
+Supporting variables:
+
+maas_global_dns_servers: Optional list of DNS server IPs. Used as a fallback for any subnet whose `dns_servers` list is empty or unset.
+
+maas_global_primary_rack_controller: Optional. Hostname of the controller to use as primary rack for any VLAN that does not declare its own `primary_rack_controller`. If neither the global value nor a per-VLAN value is set, the role will fail early during validation rather than silently leave VLANs without a rack controller.
+
+maas_overwrite_ipranges: Optional, defaults to `false`. When `true`, overlapping IP ranges discovered in MAAS will be deleted before the desired range is created. When `false` (the default) the play will fail with the conflicting range ids/spans so the user can resolve it manually.
+
+What `tasks/networking.yml` does, in order:
+
+1. Validates the inventory before making any API calls. It fails fast if any DHCP-enabled VLAN is missing a `dynamic` ip_range, if any VLAN name violates the `^[a-z0-9-]+$` rule, or if `maas_global_primary_rack_controller` is unset and any VLAN omits `primary_rack_controller`.
+2. Reads the existing MAAS Domains and creates any new domains found in `maas_networking[*].vlans[*].subnets[*].domain` (`networking/domain_create.yml`).
+3. Reads the existing MAAS Spaces and creates any new spaces found in the subnet definitions (`networking/space_create.yml`).
+4. Reads the existing Fabrics and creates any missing fabrics named in `maas_networking[*].fabric` (`networking/fabric_create.yml`).
+5. Reads each fabric's VLANs (`networking/fabric_vlans_read_from_maas.yml`) and builds a `_vlan_index` keyed by fabric and VID (`networking/vlan_build_index.yml`). Missing VLANs are created with their `vid`, `name`, `description`, `mtu`, and `space`, but not yet with `dhcp_on` (`networking/vlan_create.yml`). The index is then rebuilt to pick up newly-created VLANs.
+6. For every (fabric, vlan, subnet) triple, applies the subnet (`networking/subnet_apply.yml`): creates it if missing or updates it in place, sets `gateway_ip` and `managed`, applies DNS servers (subnet-level first, then `maas_global_dns_servers`), and reconciles its IP ranges (`networking/subnet_range_create.yml`). The range task skips exact matches, refuses to create a `dynamic` range on an unmanaged subnet, and either fails on overlaps or replaces them depending on `maas_overwrite_ipranges`.
+7. Finally, updates each VLAN's mutable properties — `name`, `mtu`, `space`, `primary_rack`, and `dhcp_on` — only after IP ranges exist, since MAAS will reject `dhcp_on=true` on a VLAN with no dynamic range (`networking/vlan_update.yml`).
+
 Users variables include:
 
 keys_repo: "https://github.com/ceph/keys"
@@ -160,7 +209,18 @@ maas
 │   ├── initialize_region_rack.yml
 │   ├── initialize_secondary_rack.yml
 │   ├── install_maasdb.yml
-│   └── main.yml
+│   ├── main.yml
+│   ├── networking.yml
+│   └── networking
+│       ├── domain_create.yml
+│       ├── fabric_create.yml
+│       ├── fabric_vlans_read_from_maas.yml
+│       ├── space_create.yml
+│       ├── subnet_apply.yml
+│       ├── subnet_range_create.yml
+│       ├── vlan_build_index.yml
+│       ├── vlan_create.yml
+│       └── vlan_update.yml
 └── templates
     ├── arm_uefi.j2
     ├── dhcpd.classes.snippet.j2
@@ -176,3 +236,4 @@ maas
 - config_dhcp #Configures DHCP options only if there are any change in the DHCP variables.
 - config_dns #Configure DNS domains and add the DNS Records that are not currently into a domain.
 - config_maas #Configure curtin_scripts files and modify some default files in MAAS to address issues during depoyment, also it configures the global kernel parameters.
+- networking #Run only the MAAS networking reconciliation tasks (Domains, Spaces, Fabrics, VLANs, Subnets, IP ranges) driven by the `maas_networking` variable.
index 23f09f5d8dbcf21f01d490818d04e42838cbf3d9..757f315ed701a32a38f34f801e63c999bcec6d59 100644 (file)
     - always
     - api
 
+# Configure Networks
+- import_tasks: networking.yml
+  tags:
+    - networking
+
+
 # Configure NTP Service
 - import_tasks: config_ntp.yml
 
diff --git a/roles/maas/tasks/networking.yml b/roles/maas/tasks/networking.yml
new file mode 100644 (file)
index 0000000..ca092c0
--- /dev/null
@@ -0,0 +1,361 @@
+---
+# Purpose: Top-level orchestrator that walks the `maas_networking` data
+# structure (defined in group_vars) and reconciles MAAS so it matches: any
+# missing Domains, Spaces, Fabrics, VLANs, Subnets, and IP ranges are
+# created; existing ones are updated where they drift; nothing is deleted.
+#
+# Per-step task files live in tasks/networking/. Each file has its own
+# Purpose/Expects/Returns header explaining its single job. Read this file
+# top-to-bottom to follow the order of operations; read the helpers when
+# you want the details of one step.
+#
+# Inputs (the role expects these to be set by the caller / group_vars):
+#   - maas_api_url                       : e.g. "http://10.64.1.25:5240"
+#                                          (no trailing /MAAS/api/2.0; we
+#                                          add that here)
+#   - maas_api_key                       : "<consumer>:<token>:<secret>"
+#                                          parsed by api_auth_pretasks.yml
+#                                          before this file runs.
+#   - maas_networking                    : the desired fabric/vlan/subnet
+#                                          structure. See README.md for the
+#                                          schema.
+#   - maas_global_dns_servers            : optional list of DNS servers used
+#                                          as the fallback when a subnet has
+#                                          no `dns_servers` of its own.
+#   - maas_global_primary_rack_controller: optional rack-controller
+#                                          hostname. Either set this or set
+#                                          `primary_rack_controller` on
+#                                          every VLAN (validated below).
+#
+# Side effects: many. See each helper task in tasks/networking/ for the
+# exact MAAS API operations it performs.
+
+################################################################################
+# API base
+################################################################################
+- name: Set MAAS API base URL
+  set_fact:
+    _maas_api: "{{ maas_api_url | trim('/') }}/MAAS/api/2.0"
+
+################################################################################
+# Inventory Validation
+################################################################################
+
+# --- Check for DHCP-enabled VLANs that are missing dynamic ip_ranges ----------
+
+# Always init so the assert never sees an undefined var
+- name: Init list of DHCP violations
+  set_fact:
+    _dhcp_missing_dynamic: []
+
+- name: Build list of fabric/vlan pairs
+  set_fact:
+    _fabric_vlans: "{{ maas_networking | subelements('vlans', skip_missing=True) }}"
+
+- name: Find DHCP-enabled VLANs that are missing dynamic ranges
+  vars:
+    _vlan: "{{ item.1 }}"
+    _dyn_count: >-
+      {{
+        (_vlan.subnets | default([]))
+        | selectattr('ip_ranges','defined')
+        | map(attribute='ip_ranges')
+        | flatten
+        | selectattr('type','equalto','dynamic')
+        | list
+        | length
+      }}
+  when:
+    - _vlan.dhcp_on | default(false) | bool
+    - (_dyn_count | int) == 0
+  set_fact:
+    _dhcp_missing_dynamic: >-
+      {{
+        (_dhcp_missing_dynamic | default([]))
+        + [ { 'fabric': item.0.fabric, 'vid': _vlan.vid, 'name': _vlan.name | default('') } ]
+      }}
+  loop: "{{ _fabric_vlans }}"
+  loop_control:
+    label: "{{ item.0.fabric }}:{{ item.1.vid }}"
+
+- name: Fail if any DHCP-enabled VLAN lacks a dynamic range
+  assert:
+    that:
+      - (_dhcp_missing_dynamic | default([])) | length == 0
+    fail_msg: >-
+      DHCP is enabled but no dynamic range is defined on these VLANs:
+      {{ (_dhcp_missing_dynamic | default([])) | to_nice_json }}
+
+# --- Check for undefined primary rack controller per VLAN ---------------------
+
+# 1) Capture global if provided (and non-empty)
+- name: Capture global primary rack controller id (if set)
+  set_fact:
+    _global_primary_rack_controller: "{{ maas_global_primary_rack_controller | string }}"
+  when:
+    - maas_global_primary_rack_controller is defined
+    - (maas_global_primary_rack_controller | string) | length > 0
+
+# 2) If no global, ensure every VLAN declares primary_rack_controller
+- name: Build list of VLANs missing primary_rack_controller (when no global set)
+  set_fact:
+    _vlans_missing_prc: |
+      {% set missing = [] %}
+      {% for pair in (maas_networking | subelements('vlans', skip_missing=True)) %}
+      {%   set fab = pair[0] %}
+      {%   set v   = pair[1] %}
+      {%   if v.primary_rack_controller is not defined or (v.primary_rack_controller | string) | length == 0 %}
+      {%     set _ = missing.append(fab.fabric ~ ":VID " ~ (v.vid | string)) %}
+      {%   endif %}
+      {% endfor %}
+      {{ missing }}
+  when: _global_primary_rack_controller is not defined
+
+- name: Require maas_global_primary_rack_controller or per-VLAN primary_rack_controller
+  assert:
+    that:
+      - (_vlans_missing_prc | length == 0)
+    fail_msg: >-
+      Missing primary rack controller configuration.
+      Either set 'maas_global_primary_rack_controller' or add 'primary_rack_controller'
+      on each VLAN. Missing for:
+      {{ (_vlans_missing_prc | default([])) | join('\n') }}
+  when: _global_primary_rack_controller is not defined
+
+################################################################################
+# Domains
+################################################################################
+- name: Collect unique domains from maas_networking
+  set_fact:
+    _wanted_domains: >-
+      {{
+        maas_networking
+        | map(attribute='vlans') | flatten
+        | map(attribute='subnets') | flatten
+        | selectattr('domain','defined')
+        | map(attribute='domain')
+        | list | unique
+      }}
+
+- include_tasks: _auth_header.yml
+
+- name: Read existing domains
+  uri:
+    url: "{{ _maas_api }}/domains/"
+    method: GET
+    headers: { Authorization: "{{ maas_auth_header }}" }
+    return_content: true
+  register: _domains_resp
+  no_log: true
+
+- name: Index domains by name
+  set_fact:
+    _domains_by_name: "{{ (_domains_resp.json | default([])) | items2dict(key_name='name', value_name='id') }}"
+
+- name: Compute domains to create
+  set_fact:
+    _new_domains: "{{ _wanted_domains | difference((_domains_by_name.keys() | list)) }}"
+
+- name: Ensure desired domains exist
+  include_tasks: networking/domain_create.yml
+  loop: "{{ _new_domains }}"
+  loop_control:
+    loop_var: domain_name
+  no_log: true
+
+################################################################################
+# Spaces
+################################################################################
+- name: Collect unique spaces from maas_networking
+  set_fact:
+    _wanted_spaces: >-
+      {{
+        maas_networking
+        | map(attribute='vlans') | flatten
+        | map(attribute='subnets') | flatten
+        | selectattr('space','defined')
+        | map(attribute='space')
+        | list | unique
+      }}
+
+- include_tasks: _auth_header.yml
+
+- name: Read existing spaces
+  uri:
+    url: "{{ _maas_api }}/spaces/"
+    method: GET
+    headers:
+      Authorization: "{{ maas_auth_header }}"
+      Accept: application/json
+    return_content: true
+    use_netrc: false
+  register: _spaces_resp
+  no_log: true
+
+- name: Index spaces by name
+  set_fact:
+    _spaces_by_name: "{{ (_spaces_resp.json | default([])) | items2dict(key_name='name', value_name='id') }}"
+
+- name: Compute spaces to create
+  set_fact:
+    _new_spaces: "{{ _wanted_spaces | difference((_spaces_by_name.keys() | list)) }}"
+
+- name: Ensure desired spaces exist
+  include_tasks: networking/space_create.yml
+  loop: "{{ _new_spaces }}"
+  loop_control:
+    loop_var: space_name
+  no_log: true
+
+################################################################################
+# Fabrics
+################################################################################
+- include_tasks: _auth_header.yml
+
+- name: Read fabrics
+  uri:
+    url: "{{ _maas_api }}/fabrics/"
+    method: GET
+    headers:
+      Authorization: "{{ maas_auth_header }}"
+      Accept: application/json
+    return_content: true
+    use_netrc: false
+  register: _fabrics_resp
+  no_log: true
+
+- name: Index fabrics by name
+  set_fact:
+    _fabric_by_name: "{{ (_fabrics_resp.json | default([])) | items2dict(key_name='name', value_name='id') }}"
+
+- name: Collect desired fabric names from maas_networking
+  set_fact:
+    _wanted_fabrics: "{{ maas_networking | map(attribute='fabric') | list | unique }}"
+
+- name: Compute fabrics to create
+  set_fact:
+    _new_fabrics: "{{ _wanted_fabrics | difference((_fabric_by_name.keys() | list)) }}"
+
+- name: Ensure fabrics exist
+  include_tasks: networking/fabric_create.yml
+  loop: "{{ _new_fabrics }}"
+  loop_control:
+    loop_var: fabric_name
+  no_log: true
+
+- include_tasks: _auth_header.yml
+
+- name: Refresh fabrics after creates
+  uri:
+    url: "{{ _maas_api }}/fabrics/"
+    method: GET
+    headers:
+      Authorization: "{{ maas_auth_header }}"
+      Accept: application/json
+    return_content: true
+    use_netrc: false
+  register: _fabrics_resp2
+  no_log: true
+
+- name: Re-index fabrics
+  set_fact:
+    _fabric_by_name: "{{ (_fabrics_resp2.json | default([])) | items2dict(key_name='name', value_name='id') }}"
+
+################################################################################
+# VLANs
+################################################################################
+- name: Validate VLAN names
+  loop: "{{ maas_networking | subelements('vlans', skip_missing=True) }}"
+  loop_control:
+    loop_var: item
+  assert:
+    that:
+      - item.1.name is match('^[a-z0-9-]+$')
+    fail_msg: "Invalid VLAN name '{{ item.1.name }}' — only lowercase letters and dashes are allowed."
+
+# Read VLANs per fabric (looped helper so each GET has fresh auth)
+- name: init raw vlans holder
+  set_fact:
+    _vlans_raw_by_fabric: {}
+
+- name: Read VLANs for each fabric
+  include_tasks: networking/fabric_vlans_read_from_maas.yml
+  loop: "{{ maas_networking }}"
+  loop_control:
+    loop_var: fab_obj
+  no_log: true
+
+- name: Build VLAN index (first pass)
+  include_tasks: networking/vlan_build_index.yml
+
+- name: Create VLANs that are missing
+  vars:
+    _fname: "{{ pair.0.fabric }}"
+    vlan:   "{{ pair.1 }}"
+    _vrec:  "{{ _vlan_index.get(_fname, {}) }}"
+    # handle both string and int vid keys so creation works regardless of index build
+    _exists: "{{ (_vrec.get(vlan.vid | string) is not none) or (_vrec.get(vlan.vid) is not none) }}"
+  include_tasks: networking/vlan_create.yml
+  loop: "{{ maas_networking | subelements('vlans', skip_missing=True) }}"
+  loop_control:
+    loop_var: pair
+    label: "{{ pair.0.fabric }}:{{ pair.1.vid }}"
+  when: not _exists
+
+# Refresh VLANs after creates (read again via helper) and rebuild index
+- name: Reset raw vlans holder
+  set_fact:
+    _vlans_raw_by_fabric: {}
+
+- name: Re-read VLANs for each fabric
+  include_tasks: networking/fabric_vlans_read_from_maas.yml
+  loop: "{{ maas_networking }}"
+  loop_control:
+    loop_var: fab_obj
+
+- name: Build VLAN index (second pass)
+  include_tasks: networking/vlan_build_index.yml
+
+################################################################################
+# Subnets (create/update DNS + ranges) BEFORE enabling VLAN DHCP
+################################################################################
+# Refresh (fabric, vlan) pairs after VLAN creation so the subnet pass below
+# sees any newly-added VLANs.
+- name: Refresh fabric/vlan pairs for subnet processing
+  set_fact:
+    _fabric_vlans: "{{ maas_networking | subelements('vlans', skip_missing=True) }}"
+
+- name: Build list of (fabric, vlan, subnet) triples
+  set_fact:
+    _subnet_triples: |
+      {% set out = [] %}
+      {% for pair in _fabric_vlans %}
+      {%   set fab = pair[0] %}
+      {%   set vlan = pair[1] %}
+      {%   for sn in vlan.subnets | default([]) %}
+      {%     set _ = out.append([fab, vlan, sn]) %}
+      {%   endfor %}
+      {% endfor %}
+      {{ out }}
+
+- name: Ensure subnets, DNS servers, and IP ranges
+  include_tasks: networking/subnet_apply.yml
+  vars:
+    trio: "{{ item }}"
+  loop: "{{ _subnet_triples }}"
+  loop_control:
+    label: "{{ item[0].fabric }} : VID {{ item[1].vid }} : {{ item[2].cidr }}"
+
+################################################################################
+# VLAN property updates (name/mtu/dhcp_on/space) AFTER ranges exist
+################################################################################
+
+- name: Call VLAN Update tasks
+  include_tasks: networking/vlan_update.yml
+  loop: "{{ maas_networking | subelements('vlans', skip_missing=True) }}"
+  loop_control:
+    loop_var: pair
+    label: "{{ pair.0.fabric }}:{{ pair.1.vid }}"
+  vars:
+    _fname: "{{ pair.0.fabric }}"
+    vlan:   "{{ pair.1 }}"
diff --git a/roles/maas/tasks/networking/domain_create.yml b/roles/maas/tasks/networking/domain_create.yml
new file mode 100644 (file)
index 0000000..6f7fd1c
--- /dev/null
@@ -0,0 +1,29 @@
+---
+# Purpose: Create one DNS domain in MAAS by name. Idempotent — accepts 409
+# (already exists) as success.
+#
+# Expects:
+#   - _maas_api       : MAAS API base URL (e.g. http://.../MAAS/api/2.0)
+#   - maas_auth_header: OAuth header string (rebuilt fresh below)
+#   - domain_name     : the domain to create (e.g. front.sepia.ceph.com)
+# Returns: nothing.
+# Side effects: POST {{ _maas_api }}/domains/ with body name={{ domain_name }}.
+
+- include_tasks: ../_auth_header.yml
+  no_log: true
+
+- uri:
+    url: "{{ _maas_api }}/domains/"
+    method: POST
+    headers:
+      Authorization: "{{ maas_auth_header }}"
+      Content-Type: application/x-www-form-urlencoded
+      Accept: application/json
+    body_format: form-urlencoded
+    body:
+      name: "{{ domain_name }}"
+    status_code: [200, 201, 409]
+    use_netrc: false
+    return_content: false
+    validate_certs: true
+  no_log: true
diff --git a/roles/maas/tasks/networking/fabric_create.yml b/roles/maas/tasks/networking/fabric_create.yml
new file mode 100644 (file)
index 0000000..0832140
--- /dev/null
@@ -0,0 +1,28 @@
+---
+# Purpose: Create one MAAS fabric by name. Idempotent — accepts 409
+# (already exists) as success.
+#
+# Expects:
+#   - _maas_api       : MAAS API base URL
+#   - maas_auth_header: OAuth header string (rebuilt fresh below)
+#   - fabric_name     : the fabric to create (e.g. "pok")
+# Returns: nothing.
+# Side effects: POST {{ _maas_api }}/fabrics/ with body name={{ fabric_name }}.
+
+- include_tasks: ../_auth_header.yml
+  no_log: true
+
+- name: "Create {{ fabric_name }} fabric"
+  uri:
+    url: "{{ _maas_api }}/fabrics/"
+    method: POST
+    headers:
+      Authorization: "{{ maas_auth_header }}"
+      Content-Type: application/x-www-form-urlencoded
+      Accept: application/json
+    body_format: form-urlencoded
+    body:
+      name: "{{ fabric_name }}"
+    status_code: [200, 201, 409]
+    use_netrc: false
+  no_log: true
diff --git a/roles/maas/tasks/networking/fabric_vlans_read_from_maas.yml b/roles/maas/tasks/networking/fabric_vlans_read_from_maas.yml
new file mode 100644 (file)
index 0000000..43a83d4
--- /dev/null
@@ -0,0 +1,37 @@
+---
+# Purpose: For one fabric (passed in as `fab_obj`), GET its VLAN list from
+# MAAS and merge the result into the running `_vlans_raw_by_fabric` accumulator.
+# Looped over every fabric by `tasks/networking.yml` so we end up with a single
+# dict keyed by fabric name.
+#
+# Expects:
+#   - _maas_api            : MAAS API base URL
+#   - maas_auth_header     : OAuth header string (rebuilt fresh below)
+#   - _fabric_by_name      : { "<fabric_name>": <fabric_id> } produced earlier
+#                            in networking.yml from GET /fabrics/
+#   - fab_obj              : one entry from `maas_networking`; uses .fabric
+#   - _vlans_raw_by_fabric : the accumulator (initialized to {} by the caller)
+# Returns:
+#   - _vlans_raw_by_fabric : same dict, now with an entry
+#                            { fab_obj.fabric: [<vlan_obj>, ...] } merged in.
+# Side effects: GET {{ _maas_api }}/fabrics/<id>/vlans/.
+
+- include_tasks: ../_auth_header.yml
+  no_log: true
+
+- name: Read VLANs for fabric {{ fab_obj.fabric }} from MAAS
+  uri:
+    url: "{{ _maas_api }}/fabrics/{{ _fabric_by_name[fab_obj.fabric] }}/vlans/"
+    method: GET
+    headers:
+      Authorization: "{{ maas_auth_header }}"
+      Accept: application/json
+    return_content: true
+    use_netrc: false
+  register: _vlans_resp
+  no_log: true
+
+- name: Merge VLANs for fabric {{ fab_obj.fabric }} into _vlans_raw_by_fabric
+  set_fact:
+    _vlans_raw_by_fabric: "{{ _vlans_raw_by_fabric | combine({ fab_obj.fabric: (_vlans_resp.json | default([])) }, recursive=True) }}"
+  no_log: true
diff --git a/roles/maas/tasks/networking/space_create.yml b/roles/maas/tasks/networking/space_create.yml
new file mode 100644 (file)
index 0000000..9ffc4a4
--- /dev/null
@@ -0,0 +1,28 @@
+---
+# Purpose: Create one MAAS space by name. Idempotent — accepts 409
+# (already exists) as success.
+#
+# Expects:
+#   - _maas_api       : MAAS API base URL
+#   - maas_auth_header: OAuth header string (rebuilt fresh below)
+#   - space_name      : the space to create (e.g. "pok")
+# Returns: nothing.
+# Side effects: POST {{ _maas_api }}/spaces/ with body name={{ space_name }}.
+
+- include_tasks: ../_auth_header.yml
+  no_log: true
+
+- name: Create space {{ space_name }} in MAAS
+  uri:
+    url: "{{ _maas_api }}/spaces/"
+    method: POST
+    headers:
+      Authorization: "{{ maas_auth_header }}"
+      Content-Type: application/x-www-form-urlencoded
+      Accept: application/json
+    body_format: form-urlencoded
+    body:
+      name: "{{ space_name }}"
+    status_code: [200, 201, 409]
+    use_netrc: false
+  no_log: true
diff --git a/roles/maas/tasks/networking/subnet_apply.yml b/roles/maas/tasks/networking/subnet_apply.yml
new file mode 100644 (file)
index 0000000..8332924
--- /dev/null
@@ -0,0 +1,364 @@
+---
+# Purpose: Reconcile one (fabric, vlan, subnet) triple from `maas_networking`
+# against MAAS — create the subnet if missing, otherwise PUT the desired
+# gateway_ip / managed flag, then push DNS servers and reconcile IP ranges.
+# Called in a loop from networking.yml over every subnet across every VLAN
+# across every fabric.
+#
+# Expects:
+#   - trio                  : [fabric_obj, vlan_obj, subnet_obj]
+#                             (one element of `_subnet_triples`)
+#   - _vlan_index           : { fabric: { vid_str: vlan_obj } } — the parent
+#                             VLAN's MAAS id is read from here
+#   - _maas_api, maas_auth_header
+# Optional:
+#   - maas_global_dns_servers : fallback DNS servers when subnet.dns_servers
+#                               is empty/unset
+#   - maas_overwrite_ipranges : passed through to subnet_range_create
+# Returns: nothing.
+# Side effects:
+#   - GET  /subnets/                          (list)
+#   - POST /subnets/                          (create, if missing)
+#   - PUT  /subnets/<id>/                     (update gateway/managed)
+#   - PUT  /subnets/<id>/                     (set dns_servers, when present)
+#   - GET  /ipranges/                         (used by subnet_range_create)
+#   - delegates IP-range creation to subnet_range_create.yml.
+
+# 0) Validate input triple
+- name: Verify triple input
+  assert:
+    that:
+      - trio is defined
+      - trio | length == 3
+    fail_msg: "subnet_apply.yml expects trio=[fabric, vlan, subnet], got: {{ trio | default('undefined') }}"
+
+# 1) Unpack triple
+- name: Extract fabric, vlan, and subnet
+  set_fact:
+    _fname: "{{ trio[0].fabric }}"
+    vlan:   "{{ trio[1] }}"
+    subnet: "{{ trio[2] }}"
+
+# 2) Ensure VLAN exists in index & resolve its numeric id
+- name: Ensure VLAN is present in index
+  assert:
+    that:
+      - _vlan_index[_fname] is defined
+      - _vlan_index[_fname][vlan.vid | string] is defined
+    fail_msg: >-
+      VLAN {{ vlan.vid }} not found in index for fabric {{ _fname }}.
+      Known vids here: {{ (_vlan_index.get(_fname, {}) | dict2items | map(attribute='key') | list) }}
+
+- name: Resolve VLAN object from index
+  set_fact:
+    _vobj: "{{ _vlan_index[_fname][vlan.vid | string] }}"
+
+- name: Extract VLAN numeric id
+  set_fact:
+    _vid: "{{ _vobj.id }}"
+
+# 3) Read subnets (global) and normalize to a list
+- include_tasks: ../_auth_header.yml
+  no_log: true
+
+- name: Read subnets (global list)
+  uri:
+    url: "{{ _maas_api }}/subnets/"
+    method: GET
+    headers:
+      Authorization: "{{ maas_auth_header }}"
+      Accept: application/json
+    return_content: true
+    use_netrc: false
+  register: _subnets_resp
+  no_log: true
+
+- name: Normalize subnets list
+  set_fact:
+    _subnets_list: >-
+      {{
+        (_subnets_resp.json.subnets
+          if (_subnets_resp.json is mapping and 'subnets' in _subnets_resp.json)
+          else (_subnets_resp.json | default([])))
+      }}
+
+# Find the existing subnet id by CIDR (none if missing)
+- name: Extract existing subnet id by CIDR
+  set_fact:
+    _existing_subnet_id: >-
+      {{
+        (_subnets_list
+          | selectattr('cidr','equalto', subnet.cidr)
+          | map(attribute='id') | list | first)
+        | default(none)
+      }}
+
+- name: Decide if subnet already exists
+  set_fact:
+    _subnet_exists: "{{ _existing_subnet_id is not none and (_existing_subnet_id|string)|length > 0 }}"
+
+# Working subnet id variable (may be set later by create)
+- set_fact:
+    _subnet_id: "{{ _existing_subnet_id }}"
+
+# NOTE: _vid (set above as _vobj.id) is the parent VLAN's numeric id used
+# below in subnet create/update bodies as the `vlan` form field.
+
+# 4) CREATE if missing
+- name: Build subnet create body
+  set_fact:
+    _subnet_create_body: >-
+      {{
+        {'cidr': subnet.cidr, 'vlan': _vid}
+        | combine( (subnet.gateway is defined) | ternary({'gateway_ip': subnet.gateway}, {}), recursive=True )
+        | combine( (subnet.managed is defined) | ternary({'managed': subnet.managed|bool}, {}), recursive=True )
+      }}
+
+- include_tasks: ../_auth_header.yml
+  when: not _subnet_exists
+  no_log: true
+
+- name: Create subnet (if missing)
+  uri:
+    url: "{{ _maas_api }}/subnets/"
+    method: POST
+    headers:
+      Authorization: "{{ maas_auth_header }}"
+      Content-Type: application/x-www-form-urlencoded
+      Accept: application/json
+    body_format: form-urlencoded
+    body: "{{ _subnet_create_body }}"
+    status_code: [200, 201, 409]
+    return_content: true
+    use_netrc: false
+  register: _subnet_create_resp
+  when: not _subnet_exists
+  no_log: true
+
+- name: Set final _subnet_id
+  set_fact:
+    _subnet_id: >-
+      {{
+        (
+          _existing_subnet_id
+          if _subnet_exists
+          else (
+            _subnet_create_resp.json.id
+            if (_subnet_create_resp is defined and _subnet_create_resp.json is defined and _subnet_create_resp.json.id is defined)
+            else none
+          )
+        )
+      }}
+
+- name: Ensure _subnet_id is set (fallback lookup)
+  set_fact:
+    _subnet_id: >-
+      {{
+        _subnet_id
+        if (_subnet_id is not none and (_subnet_id|string)|length > 0)
+        else (
+          (_subnets_list
+            | selectattr('cidr','equalto', subnet.cidr)
+            | map(attribute='id') | list | first) | default(none)
+        )
+      }}
+
+- include_tasks: ../_auth_header.yml
+  when: _subnet_id is none or (_subnet_id|string)|length == 0
+  no_log: true
+
+- name: Re-read subnets (only if _subnet_id still missing)
+  uri:
+    url: "{{ _maas_api }}/subnets/"
+    method: GET
+    headers:
+      Authorization: "{{ maas_auth_header }}"
+      Accept: application/json
+    return_content: true
+    use_netrc: false
+  register: _subnets_resp_refetch
+  when: _subnet_id is none or (_subnet_id|string)|length == 0
+  no_log: true
+
+- name: Normalize subnets list (refetch)
+  set_fact:
+    _subnets_list: >-
+      {{
+        (_subnets_resp_refetch.json.subnets
+         if (_subnets_resp_refetch is defined and _subnets_resp_refetch.json is mapping and 'subnets' in _subnets_resp_refetch.json)
+         else (_subnets_resp_refetch.json | default([])))
+      }}
+  when: _subnets_resp_refetch is defined
+
+- name: Final fallback - derive _subnet_id from refetch
+  set_fact:
+    _subnet_id: >-
+      {{
+        _subnet_id
+        if (_subnet_id is not none and (_subnet_id|string)|length > 0)
+        else (
+          (_subnets_list
+            | selectattr('cidr','equalto', subnet.cidr)
+            | map(attribute='id') | list | first) | default(none)
+        )
+      }}
+
+# 5) UPDATE if present
+- name: Build subnet update body
+  set_fact:
+    _subnet_update_body: >-
+      {{
+        {'cidr': subnet.cidr, 'vlan': _vid}
+        | combine( (subnet.gateway is defined) | ternary({'gateway_ip': subnet.gateway}, {}), recursive=True )
+        | combine( (subnet.managed is defined) | ternary({'managed': subnet.managed|bool}, {}), recursive=True )
+      }}
+
+- include_tasks: ../_auth_header.yml
+  when: _subnet_id is not none
+  no_log: true
+
+- name: Update subnet (if exists)
+  uri:
+    url: "{{ _maas_api }}/subnets/{{ _subnet_id }}/"
+    method: PUT
+    headers:
+      Authorization: "{{ maas_auth_header }}"
+      Content-Type: application/x-www-form-urlencoded
+      Accept: application/json
+    body_format: form-urlencoded
+    body: "{{ _subnet_update_body }}"
+    status_code: [200]
+    return_content: true
+    use_netrc: false
+  when: _subnet_id is not none and (_subnet_id|string)|length > 0
+  no_log: true
+
+# 6) DNS servers
+# DNS servers: prefer subnet.dns_servers[], else maas_global_dns_servers
+- name: Choose DNS servers for this subnet
+  set_fact:
+    _dns_list: "{{ subnet.dns_servers | default(maas_global_dns_servers | default([])) | list }}"
+
+- include_tasks: ../_auth_header.yml
+  when: _dns_list | length > 0 and _subnet_id is not none and (_subnet_id|string)|length > 0
+  no_log: true
+
+- name: Set DNS servers on subnet
+  uri:
+    url: "{{ _maas_api }}/subnets/{{ _subnet_id }}/"
+    method: PUT
+    headers:
+      Authorization: "{{ maas_auth_header }}"
+      Content-Type: application/x-www-form-urlencoded
+      Accept: application/json
+    body_format: form-urlencoded
+    body: "{{ {'dns_servers': _dns_list | join(' ')} }}"
+    status_code: [200]
+    use_netrc: false
+  when: _dns_list | length > 0 and _subnet_id is not none and (_subnet_id|string)|length > 0
+  no_log: true
+
+# 7) IP ranges
+# IP ranges (read from top-level /ipranges/, not /subnets/{id}/ipranges/)
+- include_tasks: ../_auth_header.yml
+  when:
+    - _subnet_id is not none
+    - subnet.ip_ranges is defined
+  no_log: true
+
+- name: Read all ipranges (we'll filter by subnet)
+  uri:
+    url: "{{ _maas_api }}/ipranges/"
+    method: GET
+    headers:
+      Authorization: "{{ maas_auth_header }}"
+      Accept: application/json
+    return_content: true
+    use_netrc: false
+    status_code: [200]
+  register: _all_ranges_resp
+  when:
+    - _subnet_id is not none
+    - subnet.ip_ranges is defined
+  no_log: true
+
+# Normalize payload so later tasks don’t depend on .json vs .content
+- name: Normalize ipranges payload to a list
+  set_fact:
+    _ipranges_list: >-
+      {{
+        _all_ranges_resp.json
+          if (_all_ranges_resp is defined and _all_ranges_resp.json is defined and _all_ranges_resp.json != '')
+          else (_all_ranges_resp.content | from_json)
+      }}
+  when:
+    - _subnet_id is not none
+    - subnet.ip_ranges is defined
+    - _all_ranges_resp is defined
+
+- name: Show _subnet_id and ipranges count
+  debug:
+    msg:
+      - "_subnet_id (int) = {{ _subnet_id | int }}"
+      - "ipranges total = {{ (_ipranges_list | default([])) | length }}"
+    verbosity: 1
+  when:
+    - _subnet_id is not none
+    - subnet.ip_ranges is defined
+    - _ipranges_list is defined
+
+- name: Build normalized ipranges list
+  set_fact:
+    _ipranges_normalized: |
+      {% set out = [] %}
+      {% for r in (_ipranges_list | default([])) %}
+      {%   set sid = ((r.subnet.id if (r.subnet is mapping and 'id' in r.subnet) else r.subnet) | int) %}
+      {%   set _ = out.append({
+            'id': r.id,
+            'type': r.type,
+            'start_ip': r.start_ip,
+            'end_ip': r.end_ip,
+            'computed_subnet_id': sid
+          }) %}
+      {% endfor %}
+      {{ out }}
+  when:
+    - _subnet_id is not none
+    - subnet.ip_ranges is defined
+    - _ipranges_list is defined
+
+- name: Filter normalized ipranges to this subnet (robust int compare)
+  set_fact:
+    _subnet_ranges_existing: |
+      {% set sid = _subnet_id | int %}
+      {% set out = [] %}
+      {% for r in (_ipranges_normalized | default([])) %}
+      {%   if (r.computed_subnet_id | int) == sid %}
+      {%     set _ = out.append(r) %}
+      {%   endif %}
+      {% endfor %}
+      {{ out }}
+  when:
+    - _subnet_id is not none
+    - subnet.ip_ranges is defined
+    - _ipranges_normalized is defined
+
+- name: Create missing ranges
+  vars:
+    _exists: >-
+      {{
+        (_subnet_ranges_existing | default([]))
+        | selectattr('type','equalto', ipr.type | default('reserved'))
+        | selectattr('start_ip','equalto', ipr.start_ip)
+        | selectattr('end_ip','equalto', ipr.end_ip)
+        | list | length > 0
+      }}
+  include_tasks: subnet_range_create.yml
+  loop: "{{ subnet.ip_ranges | default([]) }}"
+  loop_control:
+    loop_var: ipr
+    label: "{{ ipr.type }} {{ ipr.start_ip }}-{{ ipr.end_ip }}"
+  when:
+    - _subnet_id is not none
+    - subnet.ip_ranges is defined
+    - not _exists
diff --git a/roles/maas/tasks/networking/subnet_range_create.yml b/roles/maas/tasks/networking/subnet_range_create.yml
new file mode 100644 (file)
index 0000000..f14a994
--- /dev/null
@@ -0,0 +1,249 @@
+---
+# Purpose: Ensure one IP range exists on a subnet. Called from subnet_apply.yml
+# inside a loop over `subnet.ip_ranges`. Three outcomes:
+#   1) An exact match (same type/start/end) already exists → skip.
+#   2) A range overlapping the requested span exists → fail loudly, unless
+#      maas_overwrite_ipranges=true, in which case the overlapping ranges are
+#      deleted first and the new range is created.
+#   3) Otherwise → create the range.
+# Refuses to create a `dynamic` range on an unmanaged subnet (MAAS would
+# reject it with a confusing error).
+#
+# Expects:
+#   - _subnet_id              : MAAS subnet id (int) from subnet_apply
+#   - ipr                     : { type, start_ip, end_ip } loop item
+#   - _subnet_ranges_existing : pre-filtered list of existing ranges on this
+#                               subnet (refreshed below from /ipranges/)
+#   - _maas_api, maas_auth_header
+# Optional:
+#   - maas_overwrite_ipranges : default false. When true, overlapping ranges
+#                               are deleted before the new range is created.
+# Returns: nothing.
+# Side effects:
+#   - GET    /ipranges/             (server truth, before and after delete)
+#   - GET    /subnets/<id>/         (check `managed` flag)
+#   - DELETE /ipranges/<id>/        (only when maas_overwrite_ipranges=true)
+#   - POST   /ipranges/             (create the requested range)
+
+- name: Default overwrite flag
+  set_fact:
+    maas_overwrite_ipranges: "{{ maas_overwrite_ipranges | default(false) | bool }}"
+
+# Helper facts
+- set_fact:
+    _ipr_type: "{{ ipr.type | default('reserved') }}"
+    _ipr_start: "{{ ipr.start_ip }}"
+    _ipr_end: "{{ ipr.end_ip }}"
+    _overlaps: []
+
+# --- exact match detection (boolean, no None pitfalls) ---
+- name: Compute exact-match flag for this subnet/type/span
+  vars:
+    _want_type:  "{{ _ipr_type  | string }}"
+    _want_start: "{{ _ipr_start | string }}"
+    _want_end:   "{{ _ipr_end   | string }}"
+  set_fact:
+    _exact_exists: >-
+      {{
+        (
+          (_subnet_ranges_existing | default([]))
+          | selectattr('type',     'equalto', _want_type)
+          | selectattr('start_ip', 'equalto', _want_start)
+          | selectattr('end_ip',   'equalto', _want_end)
+          | list | length
+        ) > 0
+      }}
+
+# Verbose-only diagnostic so the run log is quiet by default.
+- name: Debug exact-match decision
+  debug:
+    msg:
+      - "subnet_id: {{ _subnet_id }}"
+      - "existing ranges on this subnet: {{ _subnet_ranges_existing | length }}"
+      - "looking for: {{ _ipr_type }} {{ _ipr_start }}-{{ _ipr_end }}"
+      - "exact_exists={{ _exact_exists }}"
+    verbosity: 1
+
+# --- overlap detection stays as you had it ---
+
+# Skip only when an exact already exists
+- name: Skip create when exact range already exists
+  debug:
+    msg: "IP range already present ({{ _ipr_type }} {{ _ipr_start }}-{{ _ipr_end }}); skipping."
+  when: _exact_exists
+
+# Always define _overlaps, even if earlier overlap-compute tasks were skipped
+- name: Ensure _overlaps is defined
+  set_fact:
+    _overlaps: "{{ _overlaps | default([]) }}"
+
+- include_tasks: ../_auth_header.yml
+  no_log: true
+
+- name: Read all ipranges (server truth)
+  uri:
+    url: "{{ _maas_api }}/ipranges/"
+    method: GET
+    headers:
+      Authorization: "{{ maas_auth_header }}"
+      Accept: application/json
+    return_content: true
+    status_code: [200]
+  register: _ipr_read
+  no_log: true
+
+- name: Filter ipranges down to this subnet id
+  set_fact:
+    _subnet_ranges_existing: >-
+      {{ (_ipr_read.json | default([]))
+         | selectattr('subnet.id','equalto', _subnet_id)
+         | list }}
+
+# Build tuple/list forms of the new range once
+- name: Compute tuple forms of new start/end
+  set_fact:
+    _new_start_t: "{{ _ipr_start | split('.') | map('int') | list }}"
+    _new_end_t:   "{{ _ipr_end   | split('.') | map('int') | list }}"
+
+# existing.start <= new.end  AND  existing.end >= new.start  (inclusive)
+- name: Accumulate overlaps for this subnet/type/span (inclusive, no ipaddr)
+  set_fact:
+    _overlaps: "{{ _overlaps + [r] }}"
+  loop: "{{ _subnet_ranges_existing | default([]) }}"
+  loop_control:
+    loop_var: r
+  when:
+    - (r.start_ip | split('.') | map('int') | list) <= _new_end_t
+    - (r.end_ip   | split('.') | map('int') | list) >= _new_start_t
+
+- name: Debug overlaps (if any)
+  debug:
+    msg:
+      - "Overlaps (ids): {{ _overlaps | map(attribute='id') | list }}"
+      - "Overlaps (types): {{ _overlaps | map(attribute='type') | list }}"
+      - "Overlaps (spans): {{ _overlaps | map(attribute='start_ip') | list }} — {{ _overlaps | map(attribute='end_ip') | list }}"
+    verbosity: 1
+  when: _overlaps | length > 0
+
+# Fail on overlapping ranges (unless overwrite enabled)
+- name: Fail on overlapping ranges (unless overwrite enabled)
+  fail:
+    msg: >-
+      Requested {{ _ipr_type }} range {{ _ipr_start }}-{{ _ipr_end }}
+      overlaps existing ranges:
+      {{ (_overlaps | default([])) | map(attribute='start_ip') | list }} - {{ (_overlaps | default([])) | map(attribute='end_ip') | list }}.
+      Re-run with maas_overwrite_ipranges=true to replace them.
+  when:
+    - not _exact_exists
+    - (_overlaps | default([])) | length > 0
+    - not maas_overwrite_ipranges
+
+- include_tasks: ../_auth_header.yml
+  no_log: true
+
+- name: Read this subnet to check for managed=true and dynamic range mismatch
+  uri:
+    url: "{{ _maas_api }}/subnets/{{ _subnet_id }}/"
+    method: GET
+    headers:
+      Authorization: "{{ maas_auth_header }}"
+      Accept: application/json
+    return_content: true
+    status_code: [200]
+  register: _subnet_read
+  no_log: true
+
+- set_fact:
+    _server_subnet_managed: "{{ (_subnet_read.json.managed | default(false)) | bool }}"
+
+- name: Fail if subnet is unmanaged but a dynamic range is requested
+  fail:
+    msg: >-
+      Refusing to create a dynamic range on unmanaged subnet id={{ _subnet_id }}
+      ({{ _subnet_read.json.cidr }}). Set 'managed: true' on the subnet or use a
+      reserved range instead. Requested: {{ _ipr_type }} {{ _ipr_start }}-{{ _ipr_end }}.
+  when:
+    - _ipr_type == 'dynamic'
+    - not _server_subnet_managed
+
+# Delete overlapping ipranges before create
+- include_tasks: ../_auth_header.yml
+  when:
+    - not _exact_exists
+    - (_overlaps | default([])) | length > 0
+    - maas_overwrite_ipranges
+  no_log: true
+
+# before delete loop
+- set_fact:
+    _overlap_ids: "{{ _overlaps | map(attribute='id') | list | unique | list }}"
+
+- name: Delete overlapping ipranges before create
+  uri:
+    url: "{{ _maas_api }}/ipranges/{{ ov_id }}/"
+    method: DELETE
+    headers:
+      Authorization: "{{ maas_auth_header }}"
+      Accept: application/json
+    status_code: [200, 204, 404]
+    return_content: false
+  loop: "{{ _overlap_ids }}"
+  loop_control:
+    loop_var: ov_id
+    label: "delete id={{ ov_id }}"
+  when:
+    - (_overlaps | length) > 0
+    - maas_overwrite_ipranges
+    - not _exact_exists
+  no_log: true
+
+- include_tasks: ../_auth_header.yml
+  no_log: true
+
+- name: Read all ipranges again (post-delete)
+  uri:
+    url: "{{ _maas_api }}/ipranges/"
+    method: GET
+    headers:
+      Authorization: "{{ maas_auth_header }}"
+      Accept: application/json
+    return_content: true
+    status_code: [200]
+  register: _ipr_read_after
+  no_log: true
+
+- name: Filter ipranges down to this subnet id (post-delete)
+  set_fact:
+    _subnet_ranges_existing: >-
+      {{ (_ipr_read_after.json | default([]))
+         | selectattr('subnet.id','equalto', _subnet_id)
+         | list }}
+
+- include_tasks: ../_auth_header.yml
+  when:
+    - not _exact_exists
+    - ((_overlaps | default([])) | length == 0) or maas_overwrite_ipranges
+  no_log: true
+
+- name: Create iprange
+  uri:
+    url: "{{ _maas_api }}/ipranges/"
+    method: POST
+    headers:
+      Authorization: "{{ maas_auth_header }}"
+      Content-Type: application/x-www-form-urlencoded
+      Accept: application/json
+    body_format: form-urlencoded
+    body:
+      subnet: "{{ _subnet_id | string }}"
+      type: "{{ _ipr_type | default('reserved') }}"
+      start_ip: "{{ _ipr_start }}"
+      end_ip: "{{ _ipr_end }}"
+    status_code: [200, 201, 409]
+    return_content: true
+    use_netrc: false
+  register: _range_create_resp
+  when:
+    - not _exact_exists
+    - ((_overlaps | default([])) | length == 0) or maas_overwrite_ipranges
+  no_log: true
diff --git a/roles/maas/tasks/networking/vlan_build_index.yml b/roles/maas/tasks/networking/vlan_build_index.yml
new file mode 100644 (file)
index 0000000..a02af00
--- /dev/null
@@ -0,0 +1,31 @@
+---
+# Purpose: Reshape `_vlans_raw_by_fabric` (built by fabric_vlans_read_from_maas)
+# into a fast, two-level lookup keyed first by fabric name and then by VID
+# (as a string, so callers can do `_vlan_index[fname][vlan.vid | string]`
+# regardless of whether the VID came in as int or string).
+#
+# Expects:
+#   - _vlans_raw_by_fabric : { "<fabric_name>": [<vlan_obj>, ...], ... }
+#                            (assembled across all fabrics; e.g.
+#                            { "tucson-qe": [ {vid: 1300, id: 5011, ...}, ... ] })
+# Returns:
+#   - _vlan_index          : { "<fabric_name>": { "<vid_str>": <vlan_obj> } }
+# Side effects: none. Pure local transform.
+
+- name: Reset _vlan_index
+  set_fact:
+    _vlan_index: {}
+
+- name: Normalize VLAN index with string vid keys
+  set_fact:
+    _vlan_index: |
+      {% set out = {} %}
+      {% for it in (_vlans_raw_by_fabric | default({}) | dict2items) %}
+      {%   set fname = it.key %}
+      {%   set vlist = it.value | default([]) %}
+      {%   set _ = out.update({ fname: {} }) %}
+      {%   for v in vlist %}
+      {%     set _ = out[fname].update({ (v.vid | string): v }) %}
+      {%   endfor %}
+      {% endfor %}
+      {{ out }}
diff --git a/roles/maas/tasks/networking/vlan_create.yml b/roles/maas/tasks/networking/vlan_create.yml
new file mode 100644 (file)
index 0000000..4d5cbce
--- /dev/null
@@ -0,0 +1,50 @@
+---
+# Purpose: Create one VLAN inside a fabric. Called once per (fabric, vlan)
+# pair from the loop in networking.yml; only invoked when the VID isn't
+# already present in `_vlan_index`. Idempotent — accepts 409 (already exists)
+# as success.
+#
+# NOTE: dhcp_on is NOT set here; vlan_update.yml flips it later, after the
+# subnet's IP ranges exist (MAAS rejects dhcp_on=true on a VLAN with no
+# dynamic range).
+#
+# Expects:
+#   - _maas_api       : MAAS API base URL
+#   - maas_auth_header: OAuth header string (rebuilt fresh below)
+#   - _fabric_by_name : { "<fabric_name>": <fabric_id> }
+#   - pair            : a (fabric_obj, vlan_obj) pair from
+#                       `maas_networking | subelements('vlans', ...)`
+#                       (pair.0 = the fabric entry, pair.1 = the vlan entry)
+# Returns: nothing.
+# Side effects: POST {{ _maas_api }}/fabrics/<fid>/vlans/ with body assembled
+# from pair.1's vid/name/description/mtu/space.
+
+- include_tasks: ../_auth_header.yml
+  no_log: true
+
+- name: Build VLAN create body for {{ pair.0.fabric }}:{{ pair.1.vid }}
+  set_fact:
+    _fid: "{{ _fabric_by_name[pair.0.fabric] }}"
+    _vlan_create_body: >-
+      {{
+        {'vid': pair.1.vid}
+        | combine( (pair.1.name is defined)        | ternary({'name': pair.1.name}, {}), recursive=True )
+        | combine( (pair.1.description is defined) | ternary({'description': pair.1.description}, {}), recursive=True )
+        | combine( (pair.1.mtu is defined)         | ternary({'mtu': pair.1.mtu}, {}), recursive=True )
+        | combine( (pair.1.space is defined)       | ternary({'space': pair.1.space}, {}), recursive=True )
+      }}
+
+- name: Create VLAN {{ pair.0.fabric }}:{{ pair.1.vid }} in MAAS
+  uri:
+    url: "{{ _maas_api }}/fabrics/{{ _fid }}/vlans/"
+    method: POST
+    headers:
+      Authorization: "{{ maas_auth_header }}"
+      Content-Type: application/x-www-form-urlencoded
+      Accept: application/json
+    body_format: form-urlencoded
+    body: "{{ _vlan_create_body }}"
+    status_code: [200, 201, 409]
+    return_content: true
+    use_netrc: false
+  no_log: true
diff --git a/roles/maas/tasks/networking/vlan_update.yml b/roles/maas/tasks/networking/vlan_update.yml
new file mode 100644 (file)
index 0000000..5cb7878
--- /dev/null
@@ -0,0 +1,115 @@
+---
+# Purpose: Update one existing VLAN's mutable properties (name, mtu, space,
+# primary_rack, dhcp_on). Called once per (fabric, vlan) pair from the loop
+# in networking.yml AFTER subnets and IP ranges exist — MAAS rejects
+# dhcp_on=true on a VLAN with no dynamic range, which is why this is split
+# from vlan_create.yml.
+#
+# Expects:
+#   - _fname / pair.0.fabric           : fabric name (the loop variable
+#                                        normalizes whichever is set)
+#   - vlan / pair.1                    : the desired VLAN dict from
+#                                        `maas_networking[*].vlans[*]`
+#   - _fabric_by_name                  : { "<fabric_name>": <fabric_id> }
+#   - _vlan_index                      : { "<fabric_name>": { "<vid_str>":
+#                                        <vlan_obj> } } from vlan_build_index
+#   - _maas_api, maas_auth_header
+#   - _global_primary_rack_controller  : optional; per-VLAN
+#                                        `vlan.primary_rack_controller`
+#                                        wins when set, otherwise we fall
+#                                        back to this global value.
+# Returns: nothing.
+# Side effects: PUT {{ _maas_api }}/fabrics/<fid>/vlans/<vid>/ with the
+# computed update body.
+
+- name: Normalize inputs
+  set_fact:
+    _fname: "{{ _fname | default(pair.0.fabric) }}"
+    _fid: "{{ _fabric_by_name[pair.0.fabric] }}"
+    vlan:  "{{ vlan  | default(pair.1) }}"
+
+- name: Ensure VLAN present in index
+  assert:
+    that:
+      - _vlan_index[_fname] is defined
+      - _vlan_index[_fname][vlan.vid | string] is defined
+    fail_msg: >-
+      VLAN {{ vlan.vid }} not found in index for fabric {{ _fname }}.
+      Known vids: {{ _vlan_index.get(_fname, {}) | dict2items | map(attribute='key') | list }}
+
+- name: Clear any stale per-VLAN variables
+  set_fact:
+    _vlan_id: "{{ none }}"
+    _vobj: "{{ none }}"
+    _prc_candidate: ""
+    _primary_rack_controller: "{{ none }}"
+
+- name: Resolve VLAN object
+  set_fact:
+    _vobj: "{{ _vlan_index[_fname][vlan.vid | string] }}"
+
+- name: And ID
+  set_fact:
+    _vlan_id: "{{ _vobj.id | string }}"
+
+# Set the Primary Rack Controller to the VLAN-level defined one.  Otherwise empty string.
+- name: Compute per-VLAN primary rack controller candidate
+  set_fact:
+    _prc_candidate: "{{ (vlan | default({})).get('primary_rack_controller') | default('', true) | string | trim }}"
+
+# Use the VLAN-level defined PRC discovered above or use the global one.
+- name: Decide primary rack controller for this VLAN
+  set_fact:
+    _primary_rack_controller: "{{ _prc_candidate if (_prc_candidate | length) > 0 else (_global_primary_rack_controller | default(omit)) }}"
+
+- name: Build VLAN update body
+  vars:
+    _spaces_list: >-
+      {{
+        (vlan.subnets | default([]))
+        | selectattr('space','defined')
+        | map(attribute='space') | list | unique
+      }}
+    _desired_space: "{{ _spaces_list[0] if (_spaces_list | length) == 1 else omit }}"
+
+    _has_dynamic_for_vlan: >-
+      {{
+        (vlan.subnets | default([]))
+        | selectattr('ip_ranges','defined')
+        | map(attribute='ip_ranges') | flatten
+        | selectattr('type','equalto','dynamic')
+        | list | length > 0
+      }}
+  set_fact:
+    _vlan_update_body: >-
+      {{
+        {'name': vlan.name}
+        | combine( (vlan.mtu is defined) | ternary({'mtu': vlan.mtu}, {}), recursive=True )
+        | combine( (_desired_space is not none) | ternary({'space': _desired_space}, {}), recursive=True )
+        | combine(
+            (vlan.dhcp_on | default(false) | bool and (_primary_rack_controller is defined))
+            | ternary({'primary_rack': _primary_rack_controller}, {}), recursive=True
+          )
+        | combine(
+            (vlan.dhcp_on | default(false) | bool and _has_dynamic_for_vlan)
+            | ternary({'dhcp_on': true}, {}), recursive=True
+          )
+      }}
+
+- include_tasks: ../_auth_header.yml
+  no_log: true
+
+- name: Update VLAN properties
+  uri:
+    url: "{{ _maas_api }}/fabrics/{{ _fid }}/vlans/{{ vlan.vid }}/"
+    method: PUT
+    headers:
+      Authorization: "{{ maas_auth_header }}"
+      Content-Type: application/x-www-form-urlencoded
+      Accept: application/json
+    body_format: form-urlencoded
+    body: "{{ _vlan_update_body }}"
+    status_code: [200]
+    return_content: true
+    use_netrc: false
+  no_log: true