Add workspace generation playbook

Sem-Ver: feature
Change-Id: Ic08f9b5b208799261aca140f212fb18e7acb5ee0
diff --git a/.gitignore b/.gitignore
index 79f3920..e02caf7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,5 +2,9 @@
 .vscode
 doc/build/*
 doc/source/roles/*/defaults
+molecule/default/group_vars/*
+!molecule/default/group_vars/.gitkeep
+molecule/default/host_vars/*
+!molecule/default/host_vars/.gitkeep
 galaxy.yml
 *.tar.gz
\ No newline at end of file
diff --git a/molecule/default/create.yml b/molecule/default/create.yml
index 1261a25..54c5267 100644
--- a/molecule/default/create.yml
+++ b/molecule/default/create.yml
@@ -12,9 +12,10 @@
 # License for the specific language governing permissions and limitations
 # under the License.
 
-- import_playbook: vexxhost.atmosphere.generate_secrets
+- import_playbook: vexxhost.atmosphere.generate_workspace
   vars:
-    secrets_path: "{{ lookup('env', 'MOLECULE_EPHEMERAL_DIRECTORY') }}/secrets.yml"
+    workspace_path: "{{ lookup('env', 'MOLECULE_SCENARIO_DIRECTORY') }}"
+    domain_name: "{{ '{{' }} hostvars['ctl1']['ansible_host'].replace('.', '-') {{ '}}' }}.nip.io"
 
 - hosts: localhost
   connection: local
diff --git a/molecule/default/destroy.yml b/molecule/default/destroy.yml
index 9b99c6d..599fe49 100644
--- a/molecule/default/destroy.yml
+++ b/molecule/default/destroy.yml
@@ -17,7 +17,7 @@
   gather_facts: false
   no_log: "{{ molecule_no_log }}"
   vars:
-    secrets_path: "{{ lookup('env', 'MOLECULE_EPHEMERAL_DIRECTORY') }}/secrets.yml"
+    workspace_path: "{{ lookup('env', 'MOLECULE_SCENARIO_DIRECTORY') }}"
 
     stack_name: "{{ lookup('env', 'ATMOSPHERE_STACK_NAME') | default('atmosphere', True) }}"
   tasks:
@@ -29,6 +29,7 @@
         path: "{{ molecule_instance_config }}"
         state: absent
 
-    - copy:
-        dest: "{{ secrets_path }}"
-        content: "{}"
+    - shell: rm -rf {{ workspace_path }}/{{ item }}/*
+      loop:
+        - group_vars
+        - host_vars
diff --git a/molecule/default/group_vars/.gitkeep b/molecule/default/group_vars/.gitkeep
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/molecule/default/group_vars/.gitkeep
diff --git a/molecule/default/host_vars/.gitkeep b/molecule/default/host_vars/.gitkeep
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/molecule/default/host_vars/.gitkeep
diff --git a/molecule/default/molecule.yml b/molecule/default/molecule.yml
index ac0b158..9d4dfe7 100644
--- a/molecule/default/molecule.yml
+++ b/molecule/default/molecule.yml
@@ -39,57 +39,13 @@
 provisioner:
   name: ansible
   options:
-    extra-vars: "@${MOLECULE_EPHEMERAL_DIRECTORY}/secrets.yml"
+    inventory: "${MOLECULE_EPHEMERAL_DIRECTORY}/workspace"
   config_options:
     ssh_connection:
       pipelining: true
   inventory:
-    group_vars:
-      all:
-        atmosphere_image_repository: us-docker.pkg.dev/vexxhost-infra/openstack
-        ceph_mon_fsid: 441193d8-fed9-485b-87f4-09245ddc1fe7
-        ceph_mon_public_network: 10.96.240.0/24
-        kubernetes_hostname: 10.96.240.10
-        # OpenStack
-        openstack_helm_endpoints_region_name: RegionOne
-        # Keystone
-        openstack_helm_endpoints_keystone_api_host: "identity.{{ hostvars['ctl1']['ansible_host'].replace('.', '-') }}.nip.io"
-        # Glance
-        openstack_helm_endpoints_glance_api_host: "image.{{ hostvars['ctl1']['ansible_host'].replace('.', '-') }}.nip.io"
-        # Cinder
-        openstack_helm_endpoints_cinder_api_host: "volume.{{ hostvars['ctl1']['ansible_host'].replace('.', '-') }}.nip.io"
-        # Placement
-        openstack_helm_endpoints_placement_api_host: "placement.{{ hostvars['ctl1']['ansible_host'].replace('.', '-') }}.nip.io"
-        # Neutron
-        openstack_helm_endpoints_neutron_api_host: "network.{{ hostvars['ctl1']['ansible_host'].replace('.', '-') }}.nip.io"
-        openstack_helm_neutron_values:
-          conf:
-            auto_bridge_add:
-              br-ex: ens4
-        # Nova
-        openstack_helm_endpoints_nova_api_host: "compute.{{ hostvars['ctl1']['ansible_host'].replace('.', '-') }}.nip.io"
-        openstack_helm_endpoints_nova_novnc_host: "vnc.{{ hostvars['ctl1']['ansible_host'].replace('.', '-') }}.nip.io"
-        # Ironic
-        openstack_helm_endpoints_ironic_api_host: "baremetal.{{ hostvars['ctl1']['ansible_host'].replace('.', '-') }}.nip.io"
-        # Designate
-        openstack_helm_endpoints_designate_api_host: "dns.{{ hostvars['ctl1']['ansible_host'].replace('.', '-') }}.nip.io"
-        # Octavia
-        openstack_helm_endpoints_octavia_api_host: "load-balancer.{{ hostvars['ctl1']['ansible_host'].replace('.', '-') }}.nip.io"
-        # Senlin
-        openstack_helm_endpoints_senlin_api_host: "clustering.{{ hostvars['ctl1']['ansible_host'].replace('.', '-') }}.nip.io"
-        # Heat
-        openstack_helm_endpoints_heat_api_host: "orchestration.{{ hostvars['ctl1']['ansible_host'].replace('.', '-') }}.nip.io"
-        openstack_helm_endpoints_heat_cfn_api_host: "cloudformation.{{ hostvars['ctl1']['ansible_host'].replace('.', '-') }}.nip.io"
-        # Horizon
-        openstack_helm_endpoints_horizon_api_host: "dashboard.{{ hostvars['ctl1']['ansible_host'].replace('.', '-') }}.nip.io"
-      controllers:
-        kubernetes_keepalived_vrid: 42
-        kubernetes_keepalived_interface: ens3
-        kubernetes_keepalived_vip: 10.96.240.10
-      cephs:
-        ceph_osd_devices:
-          - /dev/vdb
-          - /dev/vdc
-          - /dev/vdd
+    links:
+      host_vars: "${MOLECULE_SCENARIO_DIRECTORY}/host_vars"
+      group_vars: "${MOLECULE_SCENARIO_DIRECTORY}/group_vars"
 verifier:
   name: testinfra
diff --git a/playbooks/generate_secrets.yml b/playbooks/generate_secrets.yml
deleted file mode 100644
index 6ed535c..0000000
--- a/playbooks/generate_secrets.yml
+++ /dev/null
@@ -1,38 +0,0 @@
----
-- hosts: localhost
-  gather_facts: false
-  tasks:
-    - name: Ensure the secrets file exists
-      ansible.builtin.file:
-        path: "{{ secrets_path }}"
-        state: touch
-
-    - name: Load the current secrets into a variable
-      ansible.builtin.include_vars:
-        file: "{{ secrets_path }}"
-        name: secrets
-
-    - name: Generate secrets for missing variables
-      ansible.builtin.set_fact:
-        secrets: "{{ secrets| default({}) | combine({item: lookup('password', '/dev/null chars=ascii_lowercase,ascii_uppercase,digits length=32')}) }}"
-      # NOTE(mnaser): We don't want to override existing secrets, so we generate
-      #               a new one if and only if it doesn't exist
-      when: item not in secrets
-      # NOTE(mnaser): This is absolutely hideous but there's no clean way of
-      #               doing this using `with_fileglob` or `with_filetree`
-      with_lines: >
-        ls {{ playbook_dir }}/../roles/*/defaults/main.yml |
-          xargs grep undef |
-            egrep -v '(_host|region_name)' |
-              cut -d':' -f2
-
-    - name: Write new secrets file to disk
-      ansible.builtin.copy:
-        content: "{{ secrets | to_nice_yaml }}"
-        dest: "{{ secrets_path }}"
-
-    - name: Encrypt secrets file with Vault password
-      ansible.builtin.shell:
-        ansible-vault encrypt --vault-password-file {{ secrets_vault_password_file }} {{ secrets_path }}
-      when:
-        - secrets_vault_password_file is defined
\ No newline at end of file
diff --git a/playbooks/generate_workspace.yml b/playbooks/generate_workspace.yml
new file mode 100644
index 0000000..4211fa0
--- /dev/null
+++ b/playbooks/generate_workspace.yml
@@ -0,0 +1,279 @@
+# Copyright (c) 2022 VEXXHOST, Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+- name: Generate workspace for Atmosphere
+  hosts: localhost
+  gather_facts: false
+  tasks:
+    - name: Create folders for workspace
+      ansible.builtin.file:
+        path: "{{ workspace_path }}/{{ item }}"
+        state: directory
+      loop:
+        - group_vars
+        - group_vars/all
+        - group_vars/controllers
+        - group_vars/cephs
+        - group_vars/computes
+        - host_vars
+
+- name: Generate Ceph control plane configuration for workspace
+  hosts: localhost
+  gather_facts: false
+  vars:
+    _ceph_path: "{{ workspace_path }}/group_vars/all/ceph.yml"
+    # Input variables
+    ceph_fsid: "{{ lookup('password', '/dev/null chars=ascii_letters,digits') | to_uuid }}"
+    ceph_public_network: 10.96.240.0/24
+  tasks:
+    - name: Ensure the Ceph control plane configuration file exists
+      ansible.builtin.file:
+        path: "{{ _ceph_path }}"
+        state: touch
+
+    - name: Load the current Ceph control plane configuration into a variable
+      ansible.builtin.include_vars:
+        file: "{{ _ceph_path }}"
+        name: ceph
+
+    - name: Generate Ceph control plane values for missing variables
+      ansible.builtin.set_fact:
+        ceph: "{{ ceph | default({}) | combine({item.key: item.value}) }}"
+      # NOTE(mnaser): We don't want to override existing Ceph configurations,
+      #               so we generate a stub one if and only if it doesn't exist
+      when: item.key not in ceph
+      # NOTE(mnaser): This is absolutely hideous but there's no clean way of
+      #               doing this using `with_fileglob` or `with_filetree`
+      with_dict:
+        ceph_mon_fsid: "{{ ceph_fsid }}"
+        ceph_mon_public_network: "{{ ceph_public_network }}"
+
+    - name: Write new Ceph control plane configuration file to disk
+      ansible.builtin.copy:
+        content: "{{ ceph | to_nice_yaml(indent=2, width=180) }}"
+        dest: "{{ _ceph_path }}"
+
+- name: Generate Ceph OSD configuration for workspace
+  hosts: localhost
+  gather_facts: false
+  vars:
+    _ceph_osd_path: "{{ workspace_path }}/group_vars/cephs/osds.yml"
+  tasks:
+    - name: Ensure the Ceph OSDs configuration file exists
+      ansible.builtin.file:
+        path: "{{ _ceph_osd_path }}"
+        state: touch
+
+    - name: Load the current Ceph OSDs configuration into a variable
+      ansible.builtin.include_vars:
+        file: "{{ _ceph_osd_path }}"
+        name: ceph_osd
+
+    - name: Generate Ceph OSDs values for missing variables
+      ansible.builtin.set_fact:
+        ceph_osd: "{{ ceph_osd | default({}) | combine({item.key: item.value}) }}"
+      # NOTE(mnaser): We don't want to override existing Ceph configurations,
+      #               so we generate a stub one if and only if it doesn't exist
+      when: item.key not in ceph_osd
+      # NOTE(mnaser): This is absolutely hideous but there's no clean way of
+      #               doing this using `with_fileglob` or `with_filetree`
+      with_dict:
+        ceph_osd_devices:
+          - /dev/vdb
+          - /dev/vdc
+          - /dev/vdd
+
+    - name: Write new Ceph OSDs configuration file to disk
+      ansible.builtin.copy:
+        content: "{{ ceph_osd | to_nice_yaml(indent=2, width=180) }}"
+        dest: "{{ _ceph_osd_path }}"
+
+- name: Generate Kubernetes configuration for workspace
+  hosts: localhost
+  gather_facts: false
+  vars:
+    _kubernetes_path: "{{ workspace_path }}/group_vars/all/kubernetes.yml"
+  tasks:
+    - name: Ensure the Kubernetes configuration file exists
+      ansible.builtin.file:
+        path: "{{ _kubernetes_path }}"
+        state: touch
+
+    - name: Load the current Kubernetes configuration into a variable
+      ansible.builtin.include_vars:
+        file: "{{ _kubernetes_path }}"
+        name: kubernetes
+
+    - name: Generate Kubernetes values for missing variables
+      ansible.builtin.set_fact:
+        kubernetes: "{{ kubernetes | default({}) | combine({item.key: item.value}) }}"
+      # NOTE(mnaser): We don't want to override existing Ceph configurations,
+      #               so we generate a stub one if and only if it doesn't exist
+      when: item.key not in kubernetes
+      # NOTE(mnaser): This is absolutely hideous but there's no clean way of
+      #               doing this using `with_fileglob` or `with_filetree`
+      with_dict:
+        kubernetes_hostname: 10.96.240.10
+        kubernetes_keepalived_vrid: 42
+        kubernetes_keepalived_interface: ens3
+        kubernetes_keepalived_vip: 10.96.240.10
+
+    - name: Write new Kubernetes configuration file to disk
+      ansible.builtin.copy:
+        content: "{{ kubernetes | to_nice_yaml(indent=2, width=180) }}"
+        dest: "{{ _kubernetes_path }}"
+
+- name: Generate endpoints for workspace
+  hosts: localhost
+  gather_facts: false
+  vars:
+    _endpoints_path: "{{ workspace_path }}/group_vars/all/endpoints.yml"
+    # Input variables
+    region_name: RegionOne
+    domain_name: vexxhost.cloud
+  tasks:
+    - name: Ensure the endpoints file exists
+      ansible.builtin.file:
+        path: "{{ _endpoints_path }}"
+        state: touch
+    
+    - name: Load the current endpoints into a variable
+      ansible.builtin.include_vars:
+        file: "{{ _endpoints_path }}"
+        name: endpoints
+
+    - name: Generate endpoint skeleton for missing variables
+      ansible.builtin.set_fact:
+        endpoints: |
+          {{
+            endpoints |
+              default({}) |
+                combine({item: default_map[item]})
+          }}
+      # NOTE(mnaser): We don't want to override existing endpoints, so we generate
+      #               a stub one if and only if it doesn't exist
+      when: item not in endpoints
+      # NOTE(mnaser): This is absolutely hideous but there's no clean way of
+      #               doing this using `with_fileglob` or `with_filetree`
+      with_lines: >
+        ls {{ playbook_dir }}/../roles/*/defaults/main.yml |
+          xargs grep undef |
+            egrep '(_host|region_name)' |
+              cut -d':' -f2
+      # NOTE(mnaser): We use these variables to generate map of service name to
+      #               service type in order to generate the URLs
+      vars:
+        default_map:
+          openstack_helm_endpoints_region_name: "{{ region_name }}"
+          openstack_helm_endpoints_cinder_api_host: "volume.{{ domain_name }}"
+          openstack_helm_endpoints_designate_api_host: "dns.{{ domain_name }}"
+          openstack_helm_endpoints_glance_api_host: "image.{{ domain_name }}"
+          openstack_helm_endpoints_heat_api_host: "orchestration.{{ domain_name }}"
+          openstack_helm_endpoints_heat_cfn_api_host: "cloudformation.{{ domain_name }}"
+          openstack_helm_endpoints_horizon_api_host: "dashboard.{{ domain_name }}"
+          openstack_helm_endpoints_ironic_api_host: "baremetal.{{ domain_name }}"
+          openstack_helm_endpoints_keystone_api_host: "identity.{{ domain_name }}"
+          openstack_helm_endpoints_neutron_api_host: "network.{{ domain_name }}"
+          openstack_helm_endpoints_nova_api_host: "compute.{{ domain_name }}"
+          openstack_helm_endpoints_nova_novnc_host: "vnc.{{ domain_name }}"
+          openstack_helm_endpoints_octavia_api_host: "load-balancer.{{ domain_name }}"
+          openstack_helm_endpoints_placement_api_host: "placement.{{ domain_name }}"
+          openstack_helm_endpoints_senlin_api_host: "clustering.{{ domain_name }}"
+
+    - name: Write new endpoints file to disk
+      ansible.builtin.copy:
+        content: "{{ endpoints | to_nice_yaml(indent=2, width=180) }}"
+        dest: "{{ _endpoints_path }}"
+
+    - name: Ensure the endpoints file exists
+      ansible.builtin.file:
+        path: "{{ _endpoints_path }}"
+        state: touch
+
+- name: Generate Neutron configuration for workspace
+  hosts: localhost
+  gather_facts: false
+  vars:
+    _neutron_path: "{{ workspace_path }}/group_vars/all/neutron.yml"
+    # Input variables
+  tasks:
+    - name: Ensure the Neutron configuration file exists
+      ansible.builtin.file:
+        path: "{{ _neutron_path }}"
+        state: touch
+
+    - name: Load the current Neutron configuration into a variable
+      ansible.builtin.include_vars:
+        file: "{{ _neutron_path }}"
+        name: neutron
+
+    - name: Generate Neutron values for missing variables
+      ansible.builtin.set_fact:
+        neutron: "{{ neutron | default({}) | combine({item.key: item.value}) }}"
+      # NOTE(mnaser): We don't want to override existing Ceph configurations,
+      #               so we generate a stub one if and only if it doesn't exist
+      when: item.key not in neutron
+      # NOTE(mnaser): This is absolutely hideous but there's no clean way of
+      #               doing this using `with_fileglob` or `with_filetree`
+      with_dict:
+        openstack_helm_neutron_values:
+          conf:
+            auto_bridge_add:
+              br-ex: ens4
+
+    - name: Write new Neutron configuration file to disk
+      ansible.builtin.copy:
+        content: "{{ neutron | to_nice_yaml(indent=2, width=180) }}"
+        dest: "{{ _neutron_path }}"
+
+- name: Generate secrets for workspace
+  hosts: localhost
+  gather_facts: false
+  vars:
+    secrets_path: "{{ workspace_path }}/group_vars/all/secrets.yml"
+  tasks:
+    - name: Ensure the secrets file exists
+      ansible.builtin.file:
+        path: "{{ secrets_path }}"
+        state: touch
+
+    - name: Load the current secrets into a variable
+      ansible.builtin.include_vars:
+        file: "{{ secrets_path }}"
+        name: secrets
+
+    - name: Generate secrets for missing variables
+      ansible.builtin.set_fact:
+        secrets: "{{ secrets | default({}) | combine({item: lookup('password', '/dev/null chars=ascii_lowercase,ascii_uppercase,digits length=32')}) }}"
+      # NOTE(mnaser): We don't want to override existing secrets, so we generate
+      #               a new one if and only if it doesn't exist
+      when: item not in secrets
+      # NOTE(mnaser): This is absolutely hideous but there's no clean way of
+      #               doing this using `with_fileglob` or `with_filetree`
+      with_lines: >
+        ls {{ playbook_dir }}/../roles/*/defaults/main.yml |
+          xargs grep undef |
+            egrep -v '(_host|region_name)' |
+              cut -d':' -f2
+
+    - name: Write new secrets file to disk
+      ansible.builtin.copy:
+        content: "{{ secrets | to_nice_yaml }}"
+        dest: "{{ secrets_path }}"
+
+    - name: Encrypt secrets file with Vault password
+      ansible.builtin.shell:
+        ansible-vault encrypt --vault-password-file {{ secrets_vault_password_file }} {{ secrets_path }}
+      when:
+        - secrets_vault_password_file is defined
\ No newline at end of file
diff --git a/releasenotes/notes/add-workspace-generation-8ff28781216beccd.yaml b/releasenotes/notes/add-workspace-generation-8ff28781216beccd.yaml
new file mode 100644
index 0000000..a4780e5
--- /dev/null
+++ b/releasenotes/notes/add-workspace-generation-8ff28781216beccd.yaml
@@ -0,0 +1,4 @@
+---
+features:
+  - Added playbook to allow for generating workspace for deployment and
+    integrate it into Molecule in order to make sure we always test it.
\ No newline at end of file