]> git-server-git.apps.pok.os.sepia.ceph.com Git - ceph-cm-ansible.git/commitdiff
Creating a new role to deploy and configure MAAS
authorFernando <fernando.alcocer.ochoa@ibm.com>
Tue, 1 Apr 2025 19:18:05 +0000 (13:18 -0600)
committerFernando <fernando.alcocer.ochoa@ibm.com>
Wed, 17 Sep 2025 16:33:05 +0000 (10:33 -0600)
19 files changed:
maas.yml [new file with mode: 0644]
roles/maas/README.md [new file with mode: 0644]
roles/maas/defaults/main.yml [new file with mode: 0644]
roles/maas/meta/main.yml [new file with mode: 0644]
roles/maas/tasks/add_machines.yml [new file with mode: 0644]
roles/maas/tasks/add_users.yml [new file with mode: 0644]
roles/maas/tasks/config_dhcpd_subnet.yml [new file with mode: 0644]
roles/maas/tasks/config_dns.yml [new file with mode: 0644]
roles/maas/tasks/config_maas.yml [new file with mode: 0644]
roles/maas/tasks/config_ntp.yml [new file with mode: 0644]
roles/maas/tasks/initialize_region_rack.yml [new file with mode: 0644]
roles/maas/tasks/initialize_secondary_rack.yml [new file with mode: 0644]
roles/maas/tasks/install_maasdb.yml [new file with mode: 0644]
roles/maas/tasks/main.yml [new file with mode: 0644]
roles/maas/templates/arm_uefi.j2 [new file with mode: 0644]
roles/maas/templates/dhcpd.classes.snippet.j2 [new file with mode: 0644]
roles/maas/templates/dhcpd.global.snippet.j2 [new file with mode: 0644]
roles/maas/templates/dhcpd.hosts.snippet.j2 [new file with mode: 0644]
roles/maas/templates/dhcpd.pools.snippet.j2 [new file with mode: 0644]

diff --git a/maas.yml b/maas.yml
new file mode 100644 (file)
index 0000000..f52a741
--- /dev/null
+++ b/maas.yml
@@ -0,0 +1,5 @@
+---
+- hosts: maas
+  roles:
+    - maas
+  become: true
diff --git a/roles/maas/README.md b/roles/maas/README.md
new file mode 100644 (file)
index 0000000..e504a0a
--- /dev/null
@@ -0,0 +1,156 @@
+# Ansible Playbook: MAAS Installation and Configuration
+
+This Ansible playbook automates the installation and initial configuration of [MAAS (Metal as a Service)](https://maas.io/) on Ubuntu-based systems.
+
+## Features
+
+- Installs MAAS packages
+- Initializes MAAS with a default user with High Availability
+- Configures networking (DHCP, DNS, etc.)
+- Adds Machines from inventory into MAAS
+
+## Requirements
+
+- Ansible 2.10+
+- Ubuntu 20.04 or later on the target system(s)
+- Sudo access on target host
+- Internet access (for downloading MAAS packages and images)
+- At least 2 Nodes to deploy MAAS with High Availability
+
+## Inventory
+
+Define your inventory in `hosts.ini` with the following structure:
+
+```ini
+[maas_region_rack_server]
+test1 ip=172.x.x.x ipmi=10.0.8.x mac=08:00:27:ed:43:x
+
+[maas_rack_server]
+test2 ip=172.x.x.x ipmi=10.0.8.x mac=08:00:27:ed:43:x
+
+[maas_db_server]
+test1 ip=172.x.x.x ipmi=10.0.8.x mac=08:00:27:ed:43:x
+
+You can do this installation with 3 or 2 nodes depending on your needs.
+If you want to use a dedicated DB server you can just put it in the maas_db_server group, use a different server in maas_region_rack_server and another in maas_rack_server.
+Or if you want to simplify and you dont mind to use your maas server as DB server too, you can use the same node in maas_db_server and in maas_region_rack_server, as they are different services and use different ports they can be installed on the same node. This way you use only 2 nodes for the installation the db+region+rack server and the secondary rack for high availability.
+
+The systems you want to add into MAAS should be on a group called [testnodes] with the same structure.
+
+## Variables
+
+You can configure the playbook via group_vars/maas.yml in the secret repo or defaults/main.yml. Common variables include:
+maas_admin_username: "admin"
+maas_admin_password: "adminpass"
+maas_admin_email: "admin@example.com"
+maas_db_name: "maasdb"
+maas_db_user: "maas"
+maas_db_password: "maaspassword"
+maas_version: "3.5"
+
+NTP variables include:
+maas_ntp_servers: "ntp.ubuntu.com"  # NTP servers, specified as IP addresses or hostnames delimited by commas and/or spaces, to be used as time references for MAAS itself, the machines MAAS deploys, and devices that make use of MAAS's DHCP services. MAAS uses ntp.ubuntu.com by default. You can put a single server or multiple servers.
+maas_ntp_external_only: "false" # Configure all region controller hosts, rack controller hosts, and subsequently deployed machines to refer directly to the configured external NTP servers. Otherwise only region controller hosts will be configured to use those external NTP servers, rack contoller hosts will in turn refer to the regions' NTP servers, and deployed machines will refer to the racks' NTP servers. The value of this variable can be true or false.
+
+DNS variables include:
+dns_domains: # This is the list of domains you want to create, in this case we have 2 domains, but you can list here all the domains you need.
+  - ceph: Static primary domain (e.g., `front.sepia.ceph.com`).
+  - ipmi: Static IPMI domain (`ipmi.sepia.ceph.com`).
+default_domains: List of domains to preserve/ignore (default: `["maas"]`). The default domain is a DNS domain that is used by maas when you deploy a machine it is used by maas for internal dns records so we choose to exclude it from our ansible role.
+
+DHCP variables include:
+dhcp_maas_global:
+  - ddns-update-style: none
+  - default-lease-time: 43200
+  - max-lease-time: 172800
+  - one-lease-per-client: "true"
+
+This list will be used to populate the global DHCP snippet. You can add additional keys and values. Just make sure they follow the syntax required for dhcpd.conf.
+The global configuration is optional, so you can just remove the elements of the list if you do not need them.
+
+dhcp_maas_subnets: #This is a list of dictionaries, you can list here all the subnets you want to configure and use any name you want in this case we use front and back but you can include here any other or change the names.
+  front:
+    cidr: 10.0.8.0/24
+    ipvar: ip
+    macvar: mac
+    start_ip: 10.0.8.10
+    end_ip: 10.0.8.20
+    ip_range_type: dynamic
+    classes:
+      virtual: "match if substring(hardware, 0, 4) = 01:52:54:00"
+      lxc: "match if substring(hardware, 0, 4) = 01:52:54:ff"
+    pools:
+      virtual:
+        range: 172.21.10.20 172.21.10.250
+      unknown_clients:
+        range:
+          - 172.21.11.0 172.21.11.19
+          - 172.21.13.170 172.21.13.250
+      lxc:
+        range: 172.21.14.1 172.21.14.200
+  back:
+    cidr: 172.21.16.0/20
+    ipvar: back
+    macvar: backmac
+    start_ip: 172.21.16.10
+    end_ip: 172.21.16.20
+    ip_range_type: dynamic
+
+This is large dictionary that gets parsed out into individual snippet files. Each top-level key (front and back in the example) will get its own snippet file created.
+
+Under each subnet, cidr, ipvar, and macvar are required. ipvar and macvar tell the Jinja2 template which IP address and MAC address should be used for each host in each subnet snippet, the value of these variables should be the name of the variable that holds the ip address and mac address, respectively (for hosts that have more than one interface). That is, you might have "ipfront=1.2.3.4 ipback=5.6.7.8", and for the front subnet, 'ipvar' would be set to 'ipfront', and for the back network, 'ipvar' would be set to 'ipback', if those variables are not defined in the inventory then that host will not be included into the subnet configuration.
+
+Here's a line from our Ansible inventory host file
+
+smithi001.front.sepia.ceph.com mac=0C:C4:7A:BD:15:E8 ip=172.21.15.1 ipmi=172.21.47.1 bmc=0C:C4:7A:6E:21:A7
+
+This will result in a static lease for smithi001-front with IP 172.21.15.1 and MAC 0C:C4:7A:BD:15:E8 in front_hosts snippet and a smithi001-ipmi entry with IP 172.21.47.1 with MAC 0C:C4:7A:6E:21:A7 in ipmi_hosts snippet.
+
+start_ip, end_ip and ip_range_type are required too in order to create an IP range. MAAS needs a range in order to enable DHCP on the subnet. In this case the ip_range_type is configured as dynamic, it could be dynamic or static.
+
+The classes are optional, they are groups of DHCP clients defined by specific criteria, allowing the possibility to apply custom DHCP options or behaviors to those groups. This enables more granular control over how DHCP services are delivered to different client types, like assigning specific IP addresses or configuring other network parameters based on device type or other characteristics. In this case we have virtual and lxc but you can include here any group you want with any name. In our specific case we are including into these groups hosts that match with an specific mac address criteria.
+
+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.
+
+## Usage
+
+1. Clone the repository:
+
+git clone https://github.com/ceph/ceph-cm-ansible.git
+cd ceph-cm-ansible
+
+2. Update inventory and variables.
+
+3. Run the playbook:
+
+ansible-playbook maas.yml
+
+## Role Structure
+
+maas
+  ├── defaults
+  │   └── main.yml
+  ├── meta
+  │   └── main.yml
+  ├── README.md
+  ├── tasks
+  │   ├── add_machines.yml
+  │   ├── config_dhcpd_subnet.yml
+  │   ├── config_dns.yml
+  │   ├── config_ntp.yml
+  │   ├── initialize_region_rack.yml
+  │   ├── initialize_secondary_rack.yml
+  │   ├── install_maasdb.yml
+  │   └── main.yml
+  └── templates
+      ├── dhcpd.classes.snippet.j2
+      ├── dhcpd.global.snippet.j2
+      ├── dhcpd.hosts.snippet.j2
+      └── dhcpd.pools.snippet.j2
+
+## Tags
+
+- install_maas #Install MAAS and postgreSQL only and initializes the region+rack server and the secondary rack.
+- add-machines #Add Machines to MAAS only if they are not already present.
+- 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.
diff --git a/roles/maas/defaults/main.yml b/roles/maas/defaults/main.yml
new file mode 100644 (file)
index 0000000..882abf2
--- /dev/null
@@ -0,0 +1,28 @@
+---
+# MAAS user and database variables
+maas_admin_username: "admin"
+maas_db_name: "maasdb"
+maas_db_user: "maas"
+postgres_version: "16"
+
+#General variables
+maas_version: "3.6"
+maas_install_method: "apt"
+maas_home_dir: "/home/ubuntu/maas"
+
+# DNS variables
+default_domains:
+  - "maas"
+
+maas_dns_domains:
+  ceph: "front.sepia.ceph.com"
+  ipmi: "ipmi.sepia.ceph.com"
+
+# NTP variables
+maas_ntp_servers: "ntp.ubuntu.com"
+maas_ntp_external_only: "false"
+
+# Users variables
+keys_repo: "https://github.com/ceph/keys"
+keys_branch: main
+keys_repo_path: "~/.cache/src/keys"
diff --git a/roles/maas/meta/main.yml b/roles/maas/meta/main.yml
new file mode 100644 (file)
index 0000000..313fd69
--- /dev/null
@@ -0,0 +1,3 @@
+---
+dependencies:
+  - role: secrets
diff --git a/roles/maas/tasks/add_machines.yml b/roles/maas/tasks/add_machines.yml
new file mode 100644 (file)
index 0000000..7fc1bfb
--- /dev/null
@@ -0,0 +1,22 @@
+---
+- name: Add all machines from inventory to MAAS
+  when: inventory_hostname in groups['maas_region_rack_server']
+  tags: add_machines
+  block:
+    - name: Get existing machines in MAAS
+      command: "maas {{ maas_admin_username }} machines read"
+      register: existing_machines
+
+    - name: Extract existing hostnames
+      set_fact:
+        existing_hostnames: "{{ existing_machines.stdout | from_json | map(attribute='hostname') | list }}"
+    
+    - name: Add Machines into MAAS
+      vars:
+        hostname: "{{ item.split('.')[0] }}"
+        mac_address: "{{ hostvars[item]['mac'] }}"
+        arch: "{{ hostvars[item]['arch'] }}"
+      when: hostname not in existing_hostnames and mac_address is defined and arch is defined
+      loop: "{{ groups['testnodes'] }}"
+      command: "maas {{ maas_admin_username }} machines create architecture={{ arch }} mac_addresses={{ mac_address }} hostname={{ item }} power_type=manual deployed=true"
+      
diff --git a/roles/maas/tasks/add_users.yml b/roles/maas/tasks/add_users.yml
new file mode 100644 (file)
index 0000000..c0cf8a6
--- /dev/null
@@ -0,0 +1,53 @@
+---
+- name: Add all users from inventory variables to MAAS
+  when: inventory_hostname in groups['maas_region_rack_server']
+  tags: add_users
+  block:
+    - name: Get existing users in MAAS
+      command: "maas {{ maas_admin_username }} users read"
+      register: existing_users
+
+    - name: Extract existing usernames
+      set_fact:
+        existing_usernames: "{{ existing_users.stdout | from_json | map(attribute='username') | list }}"
+    
+    - name: Create all admin users.
+      command: "maas {{ maas_admin_username }} users create username={{ item.name }} email={{ item.email }} password={{ item.name}}temp is_superuser=1"
+      with_items: "{{ admin_users }}"
+      when: item.name not in existing_usernames
+
+    - name: Merge admin_users and lab_users
+      set_fact:
+        pubkey_users: "{{ admin_users|list }}" #+ lab_users|list }}"
+
+    - name: Clone the keys repo
+      local_action:
+        module: git
+        repo: "{{ keys_repo }}"
+        version: "{{ keys_branch }}"
+        force: yes
+        dest: "{{ keys_repo_path }}"
+      become: false
+      when: keys_repo is defined
+      connection: local
+      run_once: true
+      register: clone_keys
+      until: clone_keys is success
+      retries: 5
+      delay: 10
+    
+    - name: Update authorized_keys using the keys repo
+      vars:
+        user: "{{ item.name }}"
+        key: "{{ lookup('file', keys_repo_path + '/ssh/' + item.name + '.pub') }}"
+      command: "maas {{ maas_admin_username }} sshkeys create user={{ user }} key='{{ key }}'"
+      with_items: "{{ pubkey_users }}"
+      when: item.key is undefined and keys_repo is defined
+    
+    - name: Update authorized_keys for each user with literal keys
+      vars:
+        user: "{{ item.name }}"
+        key: "{{ item.key }}"
+      command: "maas {{ maas_admin_username }} sshkeys create user={{ user }} key='{{ key }}'"
+      with_items: "{{ pubkey_users }}"
+      when: item.key is defined
diff --git a/roles/maas/tasks/config_dhcpd_subnet.yml b/roles/maas/tasks/config_dhcpd_subnet.yml
new file mode 100644 (file)
index 0000000..10687f8
--- /dev/null
@@ -0,0 +1,169 @@
+---
+- name: Configure MAAS DHCP
+  when: inventory_hostname in groups['maas_region_rack_server']
+  tags: config_dhcp
+  block:
+  # This section enables DHCP on the subnets included into the secrets repo group_vars and creates an IP range for them
+   - name: Read maas ipranges
+     command: "maas {{ maas_admin_username }} ipranges read"
+     register: ip_ranges_raw
+
+   - name: Parse IP range JSON
+     set_fact:
+       existing_start_ips: "{{ ip_ranges_raw.stdout | from_json | map(attribute='start_ip') | list }}"
+       existing_end_ips: "{{ ip_ranges_raw.stdout | from_json | map(attribute='end_ip') | list }}"
+   
+   - name: Create IP Range for {{ subnet_name }} subnet
+     command: "maas {{ maas_admin_username }} ipranges create type={{ subnet_data.ip_range_type }} start_ip={{ subnet_data.start_ip }} end_ip={{ subnet_data.end_ip }}"
+     when: subnet_data.start_ip not in existing_start_ips and subnet_data.end_ip not in existing_end_ips
+
+   - name: Read maas subnet information
+     command: "maas {{ maas_admin_username }} subnet read {{ subnet_data.cidr }}"
+     register: subnet_info
+
+   - name: Define subnet variables
+     set_fact:
+       fabric_name: "{{ (subnet_info.stdout | from_json).vlan.fabric }}"
+       vlan_vid: "{{ (subnet_info.stdout | from_json).vlan.vid }}"
+       vlan_id: "{{ (subnet_info.stdout | from_json).id }}"
+   
+   - name: Enable DHCP on {{ subnet_name }} subnet
+     command: "maas {{ maas_admin_username }} vlan update {{ fabric_name }} {{ vlan_vid }} dhcp_on=True primary_rack={{ groups['maas_region_rack_server'][0].split('.')[0] }} secondary_rack={{ groups['maas_rack_server'][0].split('.')[0] }}"
+   
+   # This section creates the directory where the snippets are going to be copied
+   
+   - name: Define snippets path
+     set_fact:
+       snippets_path: "{{ '/var/snap/maas/common/maas/dhcp/snippets' if maas_install_method == 'snap' else '/var/lib/maas/dhcp/snippets' }}"
+  
+   - name: Create snippets directory
+     file:
+       path: "{{ snippets_path }}"
+       state: directory
+       mode: '0755'
+     register: snippets_directory
+     failed_when: snippets_directory.failed == true
+   
+   # This section verifies if the snippets already exist and creates the name variables
+   - name: Get current snippet names
+     command: bash -c "maas {{ maas_admin_username }} dhcpsnippets read"
+     register: current_snippets
+   - name: Parse snippet names JSON
+     set_fact:
+       existing_snippets: "{{ current_snippets.stdout | from_json | map(attribute='name') | list }}"
+
+   - name: Define snippet name variables
+     set_fact:
+       global_snippet: "global_dhcp"
+       classes_snippet: "{{ subnet_name }}_classes"
+       pools_snippet: "{{ subnet_name }}_pools"
+       hosts_snippet: "{{ subnet_name }}_hosts"    
+
+   # This section copies the snippets
+   
+   - name: Copy global DHCP snippet
+     template:
+       src: dhcpd.global.snippet.j2
+       dest: "{{ snippets_path }}/global_dhcp_snippet"
+     register: dhcp_global_config
+   
+   - name: Copy {{ subnet_name }} subnet classes snippet
+     template:
+       src: dhcpd.classes.snippet.j2
+       dest: "{{ snippets_path }}/{{ subnet_name }}_classes_snippet"
+     when: subnet_data.classes is defined
+     register: dhcp_classes_config
+   
+   - name: Copy {{ subnet_name }} subnet pools snippet
+     template:
+       src: dhcpd.pools.snippet.j2
+       dest: "{{ snippets_path }}/{{ subnet_name }}_pools_snippet"
+     when: subnet_data.pools is defined
+     register: dhcp_pools_config
+   
+   - name: Copy {{ subnet_name }} subnet hosts snippet
+     template:
+       src: dhcpd.hosts.snippet.j2
+       dest: "{{ snippets_path }}/{{ subnet_name }}_hosts_snippet"
+     register: dhcp_hosts_config
+   
+   # This section decodes the snippet files and creates the variables to add them into MAAS
+   
+   - name: Slurp global DHCP file content
+     slurp:
+       src: "{{ snippets_path }}/global_dhcp_snippet"
+     when: dhcp_global_config.failed == false
+     register: global_file
+   
+   - name: Decode global DHCP file content
+     set_fact:
+       global_content: "{{ global_file.content | b64decode }}"
+     when: dhcp_global_config.failed == false
+   
+   - name: Slurp {{ subnet_name }} classes file content
+     slurp:
+       src: "{{ snippets_path }}/{{ subnet_name }}_classes_snippet"
+     when: subnet_data.classes is defined and dhcp_classes_config.failed == false
+     register: classes_file
+   
+   - name: Decode {{ subnet_name }} classes file content
+     set_fact:
+       classes_content: "{{ classes_file.content | b64decode }}"
+     when: subnet_data.classes is defined and dhcp_classes_config.failed == false
+   
+   - name: Slurp {{ subnet_name }} pools file content
+     slurp:
+       src: "{{ snippets_path }}/{{ subnet_name }}_pools_snippet"
+     when: subnet_data.pools is defined and dhcp_pools_config.failed == false
+     register: pools_file
+   
+   - name: Decode {{ subnet_name }} pools file content
+     set_fact:
+       pools_content: "{{ pools_file.content | b64decode }}"
+     when: subnet_data.pools is defined and dhcp_pools_config.failed == false
+   
+   - name: Slurp {{ subnet_name }} hosts file content
+     slurp:
+       src: "{{ snippets_path }}/{{ subnet_name }}_hosts_snippet"
+     register: hosts_file
+   
+   - name: Decode {{ subnet_name }} hosts file content
+     set_fact:
+       hosts_content: "{{ hosts_file.content | b64decode }}"
+   
+   # This section deletes the snippets if already exist
+   
+   - name: Delete global DHCP snippet if already exists
+     command: "maas {{ maas_admin_username }} dhcpsnippet delete {{ global_snippet }}"
+     when: dhcp_global_config.changed == true and global_snippet in existing_snippets
+   
+   - name: Delete {{ subnet_name }} subnet classes snippet if already exists
+     command: "maas {{ maas_admin_username }} dhcpsnippet delete {{ classes_snippet }}"
+     when: subnet_data.classes is defined and dhcp_classes_config.changed == true and classes_snippet in existing_snippets
+   
+   - name: Delete {{ subnet_name }} subnet pools snippet if already exists
+     command: "maas {{ maas_admin_username }} dhcpsnippet delete {{ pools_snippet }}"
+     when: subnet_data.pools is defined and dhcp_pools_config.changed == true and pools_snippet in existing_snippets
+   
+   - name: Delete {{ subnet_name }} subnet hosts snippet if already exists
+     command: "maas {{ maas_admin_username }} dhcpsnippet delete {{ hosts_snippet }}"
+     when: dhcp_hosts_config.changed == true and hosts_snippet in existing_snippets
+   
+   # This section adds snippets into MAAS
+   
+   - name: Add global DHCP snippet into MAAS
+     command: "maas {{ maas_admin_username }} dhcpsnippets create name='{{ global_snippet }}' value='{{ global_content }}' description='This snippet configures the global DHCP options' global_snippet=true"
+     when: dhcp_global_config.failed == false and dhcp_global_config.changed == true
+   
+   - name: Add {{ subnet_name }} classes snippet into MAAS
+     command: "maas {{ maas_admin_username }} dhcpsnippets create name='{{ classes_snippet }}' value='{{ classes_content }}' description='This snippet configures the classes in {{ subnet_name }} subnet' subnet='{{ vlan_id }}'"
+     when: subnet_data.classes is defined and dhcp_classes_config.failed == false and dhcp_classes_config.changed == true
+   
+   - name: Add {{ subnet_name }} pools snippet into MAAS
+     command: "maas {{ maas_admin_username }} dhcpsnippets create name='{{ pools_snippet }}' value='{{ pools_content }}' description='This snippet configures the pools in {{ subnet_name }} subnet' subnet='{{ vlan_id }}'"
+     when: subnet_data.pools is defined and dhcp_pools_config.failed == false and dhcp_pools_config.changed == true
+   
+   - name: Add {{ subnet_name }} hosts snippet into MAAS
+     command: "maas {{ maas_admin_username }} dhcpsnippets create name='{{ hosts_snippet }}' value='{{ hosts_content }}' description='This snippet configures the hosts in {{ subnet_name }} subnet' subnet='{{ vlan_id }}'"
+     when: dhcp_hosts_config.failed == false and dhcp_hosts_config.changed == true
diff --git a/roles/maas/tasks/config_dns.yml b/roles/maas/tasks/config_dns.yml
new file mode 100644 (file)
index 0000000..3058dea
--- /dev/null
@@ -0,0 +1,85 @@
+---
+- name: Configures MAAS DNS
+  when: inventory_hostname in groups['maas_region_rack_server']
+  tags: config_dns
+  block:
+    - name: Get existing DNS resources
+      ansible.builtin.command: "maas {{ maas_admin_username }} dnsresources read"
+      register: existing_resources
+      changed_when: false
+    
+    - name: Initialize DNS records list
+      ansible.builtin.set_fact:
+        dns_records: []
+    
+    - name: Define target hosts for DNS records
+      ansible.builtin.set_fact:
+        target_hosts: "{{ groups | dict2items | rejectattr('key', 'equalto', 'maas') | map(attribute='value') | flatten | unique | default([]) }}"
+        when: groups.keys() | length > 1
+    
+    - name: Build DNS records for all interfaces
+      ansible.builtin.set_fact:
+        dns_records: "{{ dns_records + [{'name': item[0].split('.')[0], 'ip': interface_ip, 'type': 'A', 'domain': item[1].value}] }}"
+      loop: "{{ (target_hosts | default([])) | product(maas_dns_domains | dict2items) | list }}"
+      vars:
+        interface_ip: "{{ hostvars[item[0]][item[1].key] if item[1].key != 'ceph' else hostvars[item[0]]['ip'] }}"
+      when:
+        - target_hosts is defined and target_hosts | length > 0
+        - "item[1].key in hostvars[item[0]] or (item[1].key == 'ceph' and 'ip' in hostvars[item[0]])"
+    
+    - name: Parse desired FQDNs
+      ansible.builtin.set_fact:
+        desired_fqdns: "{{ dns_records | map(attribute='name') | zip(dns_records | map(attribute='domain')) | map('join', '.') | list }}"
+      when: dns_records | length > 0
+    
+    - name: Remove unwanted DNS records
+      ansible.builtin.command: "maas {{ maas_admin_username }} dnsresource delete {{ item.id }}"
+      loop: "{{ existing_resources.stdout | from_json }}"
+      when: >
+        dns_records | length > 0 and
+        item.fqdn not in desired_fqdns
+      register: dns_deletion
+      failed_when: dns_deletion.rc != 0 and "does not exist" not in dns_deletion.stderr
+    
+    - name: Get updated DNS resources after deletions
+      ansible.builtin.command: "maas {{ maas_admin_username }} dnsresources read"
+      register: updated_resources
+      changed_when: false
+    
+    - name: Get existing DNS domains
+      ansible.builtin.command: "maas {{ maas_admin_username }} domains read"
+      register: existing_domains
+      changed_when: false
+    
+    - name: Parse existing domains
+      ansible.builtin.set_fact:
+        current_domains: "{{ existing_domains.stdout | from_json | map(attribute='name') | list }}"
+    
+    - name: Remove unwanted domains
+      ansible.builtin.command: "maas {{ maas_admin_username }} domain delete {{ item.id }}"
+      loop: "{{ existing_domains.stdout | from_json }}"
+      when: >
+        item.name not in default_domains and
+        item.name not in maas_dns_domains.values()
+      register: domain_deletion
+      failed_when: domain_deletion.rc != 0 and "does not exist" not in domain_deletion.stderr and "protected foreign keys" not in domain_deletion.stderr
+    
+    - name: Ensure new DNS domains exist
+      ansible.builtin.command: "maas {{ maas_admin_username }} domains create name={{ item.value }}"
+      loop: "{{ maas_dns_domains | dict2items }}"
+      when: item.value not in current_domains
+      register: domain_creation
+      failed_when: domain_creation.rc != 0 and "already exists" not in domain_creation.stderr
+    
+    - name: Ensure DNS records exist
+      ansible.builtin.command: >
+        maas {{ maas_admin_username }} dnsresources create
+        fqdn={{ item.name }}.{{ item.domain }}
+        ip_addresses={{ item.ip }}
+      loop: "{{ dns_records }}"
+      when: >
+        dns_records | length > 0 and
+        (item.name + '.' + item.domain) not in
+        (updated_resources.stdout | from_json | map(attribute='fqdn') | list)
+      register: dns_creation
+      failed_when: dns_creation.rc != 0 and "already exists" not in dns_creation.stderr
diff --git a/roles/maas/tasks/config_maas.yml b/roles/maas/tasks/config_maas.yml
new file mode 100644 (file)
index 0000000..515d0a4
--- /dev/null
@@ -0,0 +1,73 @@
+---
+- name: Config MAAS
+  when: inventory_hostname in groups['maas_region_rack_server']
+  tags: config_maas
+  block:
+    - name: Check if MAAS was already unsquashed
+      stat:
+        path: "/var/lib/snapd/snaps/maas_x1.snap"
+      register: maas_x1
+
+    - name: Verify that MAAS directory exist
+      ansible.builtin.file:
+        path: "{{ maas_home_dir }}"
+        state: directory
+        owner: root
+        group: root
+        mode: '0755'
+      when: "maas_install_method == 'snap' and not maas_x1.stat.exists"
+      register: maas_home
+
+    - name: Check installed MAAS snap
+      shell: "sudo ls -t /var/lib/snapd/snaps/maas_*"
+      when: "maas_install_method == 'snap' and not maas_x1.stat.exists"
+      register: maas_snap
+
+    - name: Unsquahs MAAS FS
+      command: "sudo unsquashfs -d {{ maas_home_dir }} {{ maas_snap.stdout }}"
+      when: "maas_install_method == 'snap' and maas_home is defined and not maas_x1.stat.exists"
+      register: maas_fs
+
+    - name: Change MAAS current to home directory
+      command: "sudo snap try {{ maas_home_dir }}"
+      when: "maas_install_method == 'snap' and maas_fs is defined and not maas_x1.stat.exists"
+
+    - name: Check UEFI template directory
+      shell: "ls {{ maas_home_dir }}/lib/python*/site-packages/provisioningserver/templates/uefi/config.local.arm64.template"
+      when: "maas_install_method == 'snap'"
+      register: uefi_template_path
+
+    - name: Copy UEFI template to support ARM OS's
+      ansible.builtin.template:
+        src: arm_uefi.j2
+        dest: "{{ uefi_template_path.stdout if maas_install_method == 'snap' else '/usr/lib/python3/dist-packages/provisioningserver/templates/uefi/config.local.arm64.template' }}"
+        owner: root
+        group: root
+        mode: '0644'
+    
+    - name: Check curtin scripts directory
+      shell: "ls {{ maas_home_dir }}/usr/lib/python3/dist-packages/curtin/commands/install_grub.py"
+      when: "maas_install_method == 'snap'"
+      register: curtin_scripts_path    
+
+    - name: Add force flag into install_grub curtin script to allow ARM deployment
+      ansible.builtin.replace:
+        path: "{{ curtin_scripts_path.stdout if maas_install_method == 'snap' else '/usr/lib/python3/dist-packages/curtin/commands/install_grub.py' }}"
+        regexp: "'--recheck']"
+        replace: "'--recheck', '--force']"
+
+    - name: Check curtin_userdata directory
+      shell: "ls {{ maas_home_dir }}/etc/maas/preseeds/curtin_userdata"
+      when: "maas_install_method == 'snap'"
+      register: curtin_userdata_path
+
+    - name: Copy curtin_userdata template to generate CM user
+      ansible.builtin.blockinfile:
+        path: "{{ curtin_userdata_path.stdout if maas_install_method == 'snap' else '/etc/maas/preseeds/curtin_userdata' }}"
+        insertafter: EOF
+        block: |
+          90_create_cm_user: ["curtin", "in-target", "--", "sh", "-c", "useradd {{ cm_user }} -m -s /bin/bash -g sudo"]
+          92_configure_sudo: ["curtin", "in-target", "--", "sh", "-c", "printf '%%sudo ALL=(ALL) NOPASSWD: ALL\nDefaults !requiretty\nDefaults visiblepw' >> /etc/sudoers.d/cephlab_sudo"]
+          94_create_ssh_directory: ["curtin", "in-target", "--", "sh", "-c", "mkdir -p /home/cm/.ssh"]
+          96_copy_ssh_keys_cm: ["curtin", "in-target", "--", "sh", "-c", "echo '{{ cm_user_ssh_keys|join('\n') }}' >> /home/cm/.ssh/authorized_keys"]
+      when: "cm_user_ssh_keys is defined and cm_user is defined"
diff --git a/roles/maas/tasks/config_ntp.yml b/roles/maas/tasks/config_ntp.yml
new file mode 100644 (file)
index 0000000..eea4180
--- /dev/null
@@ -0,0 +1,10 @@
+---
+- name: Configure NTP service
+  when: inventory_hostname in groups['maas_region_rack_server']
+  tags: config_ntp
+  block:
+    - name: Configure NTP servers to sync MAAS
+      command: "maas {{ maas_admin_username }} maas set-config name=ntp_servers value={{ maas_ntp_servers }}"
+
+    - name: Configure the option to use NTP external only
+      command: "maas {{ maas_admin_username }} maas set-config name=ntp_external_only value={{ maas_ntp_external_only }}"
diff --git a/roles/maas/tasks/initialize_region_rack.yml b/roles/maas/tasks/initialize_region_rack.yml
new file mode 100644 (file)
index 0000000..28f0dd3
--- /dev/null
@@ -0,0 +1,46 @@
+---
+- name: Initialize MAAS Region + Rack Controller
+  when: inventory_hostname in groups['maas_region_rack_server'] and maas_install.failed == false and maas_install.changed == true
+  tags: install_maas
+  block:
+    - name: List all enabled services
+      ansible.builtin.service_facts:
+      when: "maas_install_method == 'snap'"
+
+    - name: Disable timesyncd service
+      systemd_service:
+        name: "{{ item }}"
+        state: stopped
+        enabled: false
+      when: "maas_install_method == 'snap' and '{{ item }}.service' in ansible_facts.services and ansible_facts['services']['{{ item }}.service']['status'] != 'not-found'" 
+      loop:
+        - systemd-timesyncd
+        - chrony
+
+    - name: Initialize MAAS Region Controller Snap
+      expect:  
+        command: "maas init region+rack --database-uri postgres://{{ maas_db_user }}:{{ maas_db_password }}@localhost/{{ maas_db_name }}"
+        responses:
+          "MAAS URL*": ""
+          "Controller has already been initialized*": ""
+        timeout: 300
+      when: "maas_install_method == 'snap'"
+
+    - name: Starting MAAS region service Apt
+      ansible.builtin.systemd:
+        name: maas-regiond.service
+        state: started
+        no_block: false
+      when: "maas_install_method == 'apt'"
+  
+    - name: Perform database migrations
+      command: "{{ 'maas' if maas_install_method == 'snap' else 'maas-region' }} migrate"
+
+    - name: Create MAAS admin user
+      command: "sudo maas createadmin --username={{ maas_admin_username }} --password={{ maas_admin_password }} --email={{ maas_admin_email }}"
+      register: admin_user_created
+      ignore_errors: true
+
+    - name: Restart MAAS services
+      command: "snap restart maas"
+      when: "maas_install_method == 'snap'"
diff --git a/roles/maas/tasks/initialize_secondary_rack.yml b/roles/maas/tasks/initialize_secondary_rack.yml
new file mode 100644 (file)
index 0000000..f1167af
--- /dev/null
@@ -0,0 +1,36 @@
+---
+- name: Get secret for init-rack
+  command: "cat {{ '/var/snap/maas/common/maas/secret' if maas_install_method == 'snap' else '/var/lib/maas/secret' }}"
+  when: inventory_hostname in groups['maas_region_rack_server'] and maas_install.failed == false and maas_install.changed == true
+  tags: install_maas
+  register: secret_var
+
+- name: Initialize MAAS Rack Controller
+  when: inventory_hostname in groups['maas_rack_server'] and maas_install.failed == false and secret_var is defined and maas_install.changed == true
+  tags: install_maas
+  block:
+    - name: List all enabled services
+      ansible.builtin.service_facts:
+      when: "maas_install_method == 'snap'"
+
+    - name: Disable timesyncd service
+      systemd_service:
+        name: "{{ item }}"
+        state: stopped
+        enabled: false
+      when: "maas_install_method == 'snap' and '{{ item }}.service' in ansible_facts.services and ansible_facts['services']['{{ item }}.service']['status'] != 'not-found'"
+      loop:
+        - systemd-timesyncd
+        - chrony
+
+    - name: Register Rack Controller with Region Controller Snap
+      command: "maas init rack --maas-url http://{{ hostvars[groups['maas_region_rack_server'].0]['ip'] }}:5240/MAAS/ --secret {{ hostvars[groups['maas_region_rack_server'].0]['secret_var']['stdout'] }}"
+      when: "maas_install_method == 'snap'"
+
+    - name: Register Rack Controller with Region Controller Apt
+      command: "maas-rack register --url=http://{{ hostvars[groups['maas_region_rack_server'].0]['ip'] }}:5240/MAAS/ --secret={{ hostvars[groups['maas_region_rack_server'].0]['secret_var']['stdout'] }}"
+      when: "maas_install_method == 'apt'"
+
+    - name: Restart MAAS Rack Controller
+      command: "snap restart maas"
+      when: "maas_install_method == 'snap'"
diff --git a/roles/maas/tasks/install_maasdb.yml b/roles/maas/tasks/install_maasdb.yml
new file mode 100644 (file)
index 0000000..2b434cd
--- /dev/null
@@ -0,0 +1,33 @@
+---
+- name: Install PostgreSQL
+  apt:
+    name: postgresql-{{ postgres_version}}
+    state: present
+  when: inventory_hostname in groups['maas_db_server']
+  tags: 
+    - install_maas
+    - install_db
+  register: postgres_install
+
+- name: Configure PostgreSQL for MAAS
+  when: inventory_hostname in groups['maas_db_server'] and postgres_install is changed
+  tags: 
+    - install_maas
+    - install_db  
+  block:
+    - name: Create PostgreSQL user for MAAS
+      command: sudo -i -u postgres psql -c "CREATE USER \"{{ maas_db_user }}\" WITH ENCRYPTED PASSWORD '{{ maas_db_password }}'"
+          
+    - name: Create PostgreSQL database for MAAS
+      command: sudo -i -u postgres createdb -O "{{ maas_db_user }}" "{{ maas_db_name }}"
+          
+    - name: Allow MAAS region controller to connect
+      lineinfile:
+        path: /etc/postgresql/{{ postgres_version }}/main/pg_hba.conf
+        line: "host    {{ maas_db_name }}    {{ maas_db_user }}    0/0    md5"
+        insertafter: EOF
+
+    - name: Restart PostgreSQL
+      systemd:
+        name: postgresql
+        state: restarted
diff --git a/roles/maas/tasks/main.yml b/roles/maas/tasks/main.yml
new file mode 100644 (file)
index 0000000..b5c75eb
--- /dev/null
@@ -0,0 +1,109 @@
+---
+# Playbook to install and configure MAAS
+- name: Fail if not an Ubuntu system
+  fail:
+    msg: "This playbook only supports Ubuntu systems"
+  when: ansible_distribution != "Ubuntu"
+
+- name: Ensure system is up-to-date
+  apt:
+    update_cache: yes
+    upgrade: full
+
+# Install and configure the MAAS DB
+- import_tasks: install_maasdb.yml
+
+# Install MAAS
+- name: Install MAAS with Snap
+  snap:
+    name: maas
+    classic: yes
+    channel: "{{ maas_version }}/stable"
+    state: present
+  tags: install_maas
+  when: "maas_install_method == 'snap'"
+  register: maas_install_snap
+
+- name: Add MAAS apt repository
+  ansible.builtin.apt_repository:
+    repo: "ppa:maas/{{ maas_version }}"
+  tags: install_maas
+  when: "maas_install_method == 'apt'"
+
+- name: Install MAAS with Apt
+  ansible.builtin.apt:
+    name: maas
+    state: present
+  tags: install_maas
+  when: "maas_install_method == 'apt'"
+  register: maas_install_apt
+
+- name: Normalize install result
+  set_fact:
+    maas_install: "{{ maas_install_snap if maas_install_method == 'snap' else maas_install_apt }}"
+  changed_when: "(maas_install_method == 'apt' and maas_install_apt is defined and maas_install_apt.changed) or (maas_install_method == 'snap' and maas_install_snap is defined and maas_install_snap.changed)"
+  tags: install_maas
+
+# Initialize MAAS  
+- import_tasks: initialize_region_rack.yml
+
+- import_tasks: initialize_secondary_rack.yml
+
+# Configure MAAS
+- import_tasks: config_maas.yml
+
+# Logging into the MAAS API to use CLI
+- name: Get API key
+  command: maas apikey --username={{ maas_admin_username }}
+  when: inventory_hostname in groups['maas_region_rack_server']
+  tags:
+  - config_dhcp
+  - add_machines
+  - config_dns
+  - config_ntp
+  - add_users  
+  register: maas_api_key
+
+- name: Log into MAAS API
+  command: "maas login {{ maas_admin_username }} http://{{ hostvars[groups['maas_region_rack_server'].0]['ip'] }}:5240/MAAS/api/2.0/ {{ maas_api_key.stdout }}"
+  when: inventory_hostname in groups['maas_region_rack_server']
+  tags:
+  - config_dhcp
+  - add_machines
+  - config_dns
+  - config_ntp
+  - add_users  
+
+# Configure NTP Service
+- import_tasks: config_ntp.yml
+
+# Configure DNS Service
+- import_tasks: config_dns.yml
+
+# Configure DHCP Service
+- name: dhcp_configuration
+  include_tasks: config_dhcpd_subnet.yml
+  loop: "{{ dhcp_maas_subnets|dict2items }}"
+  loop_control:
+    loop_var: subnet
+  vars:
+    subnet_name: "{{ subnet.key }}"
+    subnet_data: "{{ subnet.value }}"
+  tags: config_dhcp
+
+# Add Machines into MAAS
+- import_tasks: add_machines.yml
+
+# Add Users into MAAS
+- import_tasks: add_users.yml
+
+# Logout from MAAS API  
+- name: Logout from MAAS
+  command: "maas logout {{ maas_admin_username }}"
+  tags:
+  - config_dhcp
+  - add_machines
+  - config_dns
+  - config_ntp
+  - add_users
+  when: inventory_hostname in groups['maas_region_rack_server']
diff --git a/roles/maas/templates/arm_uefi.j2 b/roles/maas/templates/arm_uefi.j2
new file mode 100644 (file)
index 0000000..0b0baa7
--- /dev/null
@@ -0,0 +1,27 @@
+{{ '{{' }}if debug{{ '}}' }}set debug="all"{{ '{{' }}endif{{ '}}' }}
+set default="0"
+set timeout=0
+
+menuentry 'Local' {
+    echo 'Booting local disk...'
+    # This is the default bootloader location according to the UEFI spec.
+    search --set=root --file /efi/boot/bootaa64.efi
+    if [ $? -eq 0 ]; then
+        chainloader /efi/boot/bootaa64.efi
+        boot
+    fi
+
+{% set distros = ["rocky", "centos", "ubuntu"] %}
+
+{% for item in distros %}
+
+    search --set=root --file /efi/{{ item }}/grubaa64.efi
+    if [ $? -eq 0 ]; then
+        chainloader /efi/{{ item }}/grubaa64.efi
+        boot
+    fi
+
+{% endfor %}
+    # If no bootloader is found exit and allow the next device to boot.
+    exit
+}
diff --git a/roles/maas/templates/dhcpd.classes.snippet.j2 b/roles/maas/templates/dhcpd.classes.snippet.j2
new file mode 100644 (file)
index 0000000..b9cbad6
--- /dev/null
@@ -0,0 +1,8 @@
+  {% if subnet_data.classes is defined -%}
+  {% for class_name, class_string in subnet_data.classes.items() -%}
+  class "{{ class_name }}" {
+    {{ class_string }};
+  }
+
+  {% endfor -%}
+  {%- endif -%}
diff --git a/roles/maas/templates/dhcpd.global.snippet.j2 b/roles/maas/templates/dhcpd.global.snippet.j2
new file mode 100644 (file)
index 0000000..027b09b
--- /dev/null
@@ -0,0 +1,5 @@
+{% for item in dhcp_maas_global %}
+{% for key, value in item.items() %}
+{{ key }} {{ value }};
+{% endfor %}
+{% endfor %}
diff --git a/roles/maas/templates/dhcpd.hosts.snippet.j2 b/roles/maas/templates/dhcpd.hosts.snippet.j2
new file mode 100644 (file)
index 0000000..d1d8013
--- /dev/null
@@ -0,0 +1,16 @@
+  {% for host in groups['all'] | sort | unique -%}
+  {% if hostvars[host][subnet_data.macvar] is defined -%}
+  {% if hostvars[host][subnet_data.ipvar] | ansible.utils.ipaddr(subnet_data.cidr) -%}
+  host {{ host.split('.')[0] }}-{{ subnet_name }} {
+   {% if hostvars[host]['domain_name_servers'] is defined -%}
+    option domain-name-servers {{ hostvars[host]['domain_name_servers']|join(', ') }};
+    {% endif -%}
+    hardware ethernet {{ hostvars[host][subnet_data.macvar] }};
+    fixed-address {{ hostvars[host][subnet_data.ipvar] }};
+  {% if hostvars[host]['dhcp_option_hostname'] is defined and hostvars[host]['dhcp_option_hostname'] == true %}
+  option host-name "{{ host.split('.')[0] }}";
+  {% endif -%}
+  }
+  {% endif -%}
+  {% endif -%}
+  {% endfor -%}
diff --git a/roles/maas/templates/dhcpd.pools.snippet.j2 b/roles/maas/templates/dhcpd.pools.snippet.j2
new file mode 100644 (file)
index 0000000..2d7af05
--- /dev/null
@@ -0,0 +1,23 @@
+  {% if subnet_data.pools is defined -%}
+  {% for pool, pool_value in subnet_data.pools.items() -%}
+  pool {
+    {% if pool == "unknown_clients" -%}
+    allow unknown-clients;
+    {% else -%}
+    allow members of "{{ pool }}";
+    {% endif -%}
+    {% if pool_value.range is string -%}
+    range {{ pool_value.range }};
+    {% else -%}
+    range {{ pool_value.range|join(';\n    range ') }};
+    {% endif -%}
+    {% if pool_value.next_server is defined -%}
+    next-server {{ pool_value.next_server }};
+    {% endif -%}
+    {% if pool_value.filename is defined -%}
+    filename "{{ pool_value.filename }}";
+    {% endif -%}
+  }
+
+  {% endfor -%}
+  {%- endif -%}