Initial commit with basic Molecule

Change-Id: If55c8b62cb219e575857ba5395a48ddac5973e01
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..172bf57
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+.tox
diff --git a/molecule/default/converge.yml b/molecule/default/converge.yml
new file mode 100644
index 0000000..24b07ff
--- /dev/null
+++ b/molecule/default/converge.yml
@@ -0,0 +1,15 @@
+# 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.
+
+- import_playbook: vexxhost.atmosphere.site
diff --git a/molecule/default/create.yml b/molecule/default/create.yml
new file mode 100644
index 0000000..bc41643
--- /dev/null
+++ b/molecule/default/create.yml
@@ -0,0 +1,92 @@
+# 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.
+
+- hosts: localhost
+  connection: local
+  gather_facts: false
+  no_log: "{{ molecule_no_log }}"
+  vars:
+    ssh_port: 22
+    stack_name: "{{ lookup('env', 'STACK_NAME') | default('atmosphere', True) }}"
+    identity_file: "{{ lookup('env', 'MOLECULE_EPHEMERAL_DIRECTORY') }}/id_rsa"
+  tasks:
+    - name: create stack
+      openstack.cloud.stack:
+        name: "{{ stack_name }}"
+        template: heat/stack.yaml
+      register: _os_stack
+
+    - debug:
+        msg: "{{ _os_stack.stack }}"
+
+    - name: grab list of all ip addresses
+      ansible.builtin.set_fact:
+        key_pair: "{{ _os_stack.stack.outputs | json_query(key_query) | first }}"
+        controller_ips: "{{ _os_stack.stack.outputs | community.general.json_query(controller_query) | first }}"
+        storage_ips: "{{ _os_stack.stack.outputs | community.general.json_query(storage_query) | first }}"
+        compute_ips: "{{ _os_stack.stack.outputs | community.general.json_query(compute_query) | first }}"
+      vars:
+        key_query: "[?output_key=='key_pair'].output_value"
+        controller_query: "[?output_key=='controller_floating_ip_addresses'].output_value"
+        storage_query: "[?output_key=='storage_floating_ip_addresses'].output_value"
+        compute_query: "[?output_key=='compute_floating_ip_addresses'].output_value"
+
+    - name: wait for systems to go up
+      ansible.builtin.wait_for:
+        port: "22"
+        host: "{{ item }}"
+        search_regex: SSH
+        timeout: 60
+      loop: "{{ controller_ips + storage_ips + compute_ips }}"
+
+    - name: generate private key file
+      ansible.builtin.copy:
+        dest: "{{ identity_file }}"
+        content: "{{ key_pair }}"
+        mode: 0600
+
+    - name: generate instance config file
+      copy:
+        content: "{{ instance_config | to_yaml }}"
+        dest: "{{ molecule_instance_config }}"
+      vars:
+        base_instance_config: &instance_config
+          user: "ubuntu"
+          port: "{{ ssh_port }}"
+          identity_file: "{{ identity_file }}"
+        instance_config:
+          - <<: *instance_config
+            instance: "ctl1"
+            address: "{{ controller_ips[0] }}"
+          - <<: *instance_config
+            instance: "ctl2"
+            address: "{{ controller_ips[1] }}"
+          - <<: *instance_config
+            instance: "ctl3"
+            address: "{{ controller_ips[2] }}"
+          - <<: *instance_config
+            instance: "nvme1"
+            address: "{{ storage_ips[0] }}"
+          - <<: *instance_config
+            instance: "nvme2"
+            address: "{{ storage_ips[1] }}"
+          - <<: *instance_config
+            instance: "nvme3"
+            address: "{{ storage_ips[2] }}"
+          - <<: *instance_config
+            instance: "kvm1"
+            address: "{{ compute_ips[0] }}"
+          - <<: *instance_config
+            instance: "kvm2"
+            address: "{{ compute_ips[1] }}"
diff --git a/molecule/default/destroy.yml b/molecule/default/destroy.yml
new file mode 100644
index 0000000..fdcd8ad
--- /dev/null
+++ b/molecule/default/destroy.yml
@@ -0,0 +1,28 @@
+# 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.
+
+- hosts: localhost
+  connection: local
+  gather_facts: false
+  no_log: "{{ molecule_no_log }}"
+  vars:
+    stack_name: "{{ lookup('env', 'STACK_NAME') | default('atmosphere', True) }}"
+  tasks:
+    - os_stack:
+        name: "{{ stack_name }}"
+        state: absent
+
+    - file:
+        path: "{{ molecule_instance_config }}"
+        state: absent
diff --git a/molecule/default/heat/server.yaml b/molecule/default/heat/server.yaml
new file mode 100644
index 0000000..7463123
--- /dev/null
+++ b/molecule/default/heat/server.yaml
@@ -0,0 +1,126 @@
+# 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.
+
+heat_template_version: 2016-10-14
+
+parameters:
+  name:
+    type: string
+
+  index:
+    type: number
+
+  image:
+    type: string
+    default: Ubuntu 20.04.3 LTS (x86_64) [2021-10-04]
+    constraints:
+      - custom_constraint: glance.image
+
+  instance_type:
+    type: string
+    default: v3-standard-4
+    constraints:
+      - custom_constraint: nova.flavor
+
+  internal_network:
+    type: string
+    constraints:
+      - custom_constraint: neutron.network
+
+  key_name:
+    type: string
+    constraints:
+      - custom_constraint: nova.keypair
+
+  security_group:
+    type: string
+    constraints:
+      - custom_constraint: neutron.security_group
+
+  public_network:
+    type: string
+    default: public
+    constraints:
+      - custom_constraint: neutron.network
+
+  external_network:
+    type: string
+    constraints:
+      - custom_constraint: neutron.network
+
+  extra_volumes_count:
+    type: number
+    default: 0
+
+  extra_volumes_size:
+    type: number
+    default: 0
+
+conditions:
+  has_extra_volumes:
+    not:
+      equals:
+        - get_param: extra_volumes_count
+        - 0
+
+resources:
+  internal_port:
+    type: OS::Neutron::Port
+    properties:
+      network: { get_param: internal_network }
+      port_security_enabled: false
+
+  floating_ip:
+    type: OS::Neutron::FloatingIP
+    properties:
+      floating_network: { get_param: public_network }
+      port_id: { get_resource: internal_port }
+
+  external_port:
+    type: OS::Neutron::Port
+    properties:
+      network: { get_param: external_network }
+      security_groups:
+        - { get_param: security_group }
+
+  server:
+    type: OS::Nova::Server
+    properties:
+      name:
+        yaql:
+          expression: concat($.data.name, str($.data.index + 1))
+          data:
+            name: { get_param: name }
+            index: { get_param: index }
+      image: { get_param: image }
+      flavor: { get_param: instance_type }
+      key_name: { get_param: key_name }
+      networks:
+        - port: { get_resource: internal_port }
+        - port: { get_resource: external_port }
+
+  volumes:
+    type: OS::Heat::ResourceGroup
+    condition: has_extra_volumes
+    properties:
+      count: { get_param: extra_volumes_count }
+      resource_def:
+        type: volume.yaml
+        properties:
+          instance_uuid: { get_resource: server }
+          volume_size: { get_param: extra_volumes_size }
+
+outputs:
+  floating_ip_address:
+    value: { get_attr: [floating_ip, floating_ip_address] }
diff --git a/molecule/default/heat/stack.yaml b/molecule/default/heat/stack.yaml
new file mode 100644
index 0000000..376380c
--- /dev/null
+++ b/molecule/default/heat/stack.yaml
@@ -0,0 +1,184 @@
+# 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.
+
+heat_template_version: 2016-10-14
+
+parameters:
+  internal_cidr:
+    type: string
+    default: 10.96.240.0/24
+    constraints:
+      - custom_constraint: net_cidr
+
+  external_cidr:
+    type: string
+    default: 10.96.250.0/24
+    constraints:
+      - custom_constraint: net_cidr
+
+  public_network:
+    type: string
+    default: public
+    constraints:
+      - custom_constraint: neutron.network
+
+  image:
+    type: string
+    default: Ubuntu 20.04.3 LTS (x86_64) [2021-10-04]
+    constraints:
+      - custom_constraint: glance.image
+
+  instance_type:
+    type: string
+    default: v3-standard-4
+    constraints:
+      - custom_constraint: nova.flavor
+
+resources:
+  security_group:
+    type: OS::Neutron::SecurityGroup
+    properties:
+      rules:
+        - protocol: tcp
+          remote_ip_prefix: 0.0.0.0/0
+        - protocol: udp
+          remote_ip_prefix: 0.0.0.0/0
+        - protocol: icmp
+          remote_ip_prefix: 0.0.0.0/0
+
+  router:
+    type: OS::Neutron::Router
+    properties:
+      external_gateway_info:
+        network: { get_param: public_network }
+
+  internal_network:
+    type: OS::Neutron::Net
+
+  internal_subnet:
+    type: OS::Neutron::Subnet
+    properties:
+      network: { get_resource: internal_network }
+      cidr: { get_param: internal_cidr }
+      dns_nameservers: ["1.1.1.1"]
+
+  internal_network_router_interface:
+    type: OS::Neutron::RouterInterface
+    properties:
+      router: { get_resource: router }
+      subnet: { get_resource: internal_subnet }
+
+  internal_network_vip:
+    type: OS::Neutron::Port
+    properties:
+      network: { get_resource: internal_network }
+
+  internal_network_vip_floating_ip:
+    type: OS::Neutron::FloatingIP
+    depends_on:
+      - internal_network_router_interface
+    properties:
+      floating_network: { get_param: public_network }
+      port_id: { get_resource: internal_network_vip }
+
+  external_network:
+    type: OS::Neutron::Net
+
+  external_subnet:
+    type: OS::Neutron::Subnet
+    properties:
+      network: { get_resource: external_network }
+      cidr: { get_param: external_cidr }
+      dns_nameservers: ["1.1.1.1"]
+      gateway_ip: null
+
+  external_network_vip:
+    type: OS::Neutron::Port
+    properties:
+      network: { get_resource: external_network }
+
+  key_pair:
+    type: OS::Nova::KeyPair
+    properties:
+      name: { get_param: OS::stack_id }
+      save_private_key: true
+
+  controller:
+    type: OS::Heat::ResourceGroup
+    depends_on:
+      - internal_network_router_interface
+    properties:
+      count: 3
+      resource_def:
+        type: server.yaml
+        properties:
+          name: ctl
+          index: "%index%"
+          image: { get_param: image }
+          instance_type: { get_param: instance_type }
+          key_name: { get_resource: key_pair }
+          security_group: { get_resource: security_group }
+          internal_network: { get_resource: internal_network }
+          public_network: { get_param: public_network }
+          external_network: { get_resource: external_network }
+
+  storage:
+    type: OS::Heat::ResourceGroup
+    depends_on:
+      - internal_network_router_interface
+    properties:
+      count: 3
+      resource_def:
+        type: server.yaml
+        properties:
+          name: nvme
+          index: "%index%"
+          image: { get_param: image }
+          instance_type: { get_param: instance_type }
+          key_name: { get_resource: key_pair }
+          security_group: { get_resource: security_group }
+          internal_network: { get_resource: internal_network }
+          public_network: { get_param: public_network }
+          external_network: { get_resource: external_network }
+          extra_volumes_count: 3
+          extra_volumes_size: 40
+
+  compute:
+    type: OS::Heat::ResourceGroup
+    depends_on:
+      - internal_network_router_interface
+    properties:
+      count: 2
+      resource_def:
+        type: server.yaml
+        properties:
+          name: kvm
+          index: "%index%"
+          image: { get_param: image }
+          instance_type: { get_param: instance_type }
+          key_name: { get_resource: key_pair }
+          security_group: { get_resource: security_group }
+          internal_network: { get_resource: internal_network }
+          public_network: { get_param: public_network }
+          external_network: { get_resource: external_network }
+
+outputs:
+  controller_floating_ip_addresses:
+    value: { get_attr: [controller, floating_ip_address] }
+  storage_floating_ip_addresses:
+    value: { get_attr: [storage, floating_ip_address] }
+  compute_floating_ip_addresses:
+    value: { get_attr: [compute, floating_ip_address] }
+  key_pair:
+    value: { get_attr: [key_pair, private_key] }
diff --git a/molecule/default/heat/volume.yaml b/molecule/default/heat/volume.yaml
new file mode 100644
index 0000000..75158f9
--- /dev/null
+++ b/molecule/default/heat/volume.yaml
@@ -0,0 +1,34 @@
+# 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.
+
+heat_template_version: 2016-10-14
+
+parameters:
+  instance_uuid:
+    type: string
+
+  volume_size:
+    type: number
+
+resources:
+  volume:
+    type: OS::Cinder::Volume
+    properties:
+      size: { get_param: volume_size }
+
+  volume_attachment:
+    type: OS::Cinder::VolumeAttachment
+    properties:
+      instance_uuid: { get_param: instance_uuid }
+      volume_id: { get_resource: volume }
diff --git a/molecule/default/molecule.yml b/molecule/default/molecule.yml
new file mode 100644
index 0000000..2be9955
--- /dev/null
+++ b/molecule/default/molecule.yml
@@ -0,0 +1,133 @@
+# 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.
+
+dependency:
+  name: galaxy
+driver:
+  name: delegated
+platforms:
+  - name: ctl1
+    groups: &controller_groups
+      - controllers
+      - cephs
+      - ceph_mons
+      - ceph_mgrs
+  - name: ctl2
+    groups: *controller_groups
+  - name: ctl3
+    groups: *controller_groups
+  - name: nvme1
+    groups: &nvme_groups
+      - cephs
+      - ceph_osds
+  - name: nvme2
+    groups: *nvme_groups
+  - name: nvme3
+    groups: *nvme_groups
+  - name: kvm1
+    groups: &kvm_groups
+      - computes
+  - name: kvm2
+    groups: *kvm_groups
+provisioner:
+  name: ansible
+  env:
+    ANSIBLE_PIPELINING: "True"
+  inventory:
+    group_vars:
+      all:
+        atmosphere_image_repository: us-docker.pkg.dev/vexxhost-infra/openstack
+        kubernetes_hostname: 10.96.240.10
+      controllers:
+        kubernetes_keepalived_vrid: 42
+        kubernetes_keepalived_interface: ens3
+        kubernetes_keepalived_vip: 10.96.240.10
+        openstack_helm_endpoints_region_name: RegionOne
+        # Memcached
+        openstack_helm_endpoints_memcached_secret_key: 7XsFMtMoUT9c8eCqbBNJycqwIepvLYzfOI2kpYhBUJSl2X5RDHiyolaQqQp36nVR
+        # RabbitMQ
+        openstack_helm_endpoints_rabbitmq_erlang_cookie: openstack-cookie
+        openstack_helm_endpoints_rabbitmq_admin_password: password123
+        # Keystone
+        openstack_helm_endpoints_keystone_api_host: "identity.{{ hostvars['ctl1']['ansible_host'].replace('.', '-') }}.nip.io"
+        openstack_helm_endpoints_keystone_mariadb_password: keystone-mariadb-password
+        openstack_helm_endpoints_keystone_rabbitmq_password: keystone-rabbitmq-password
+        openstack_helm_endpoints_keystone_admin_password: keystone-admin-password
+        # Glance
+        openstack_helm_endpoints_glance_api_host: "image.{{ hostvars['ctl1']['ansible_host'].replace('.', '-') }}.nip.io"
+        openstack_helm_endpoints_glance_mariadb_password: glance-mariadb-password
+        openstack_helm_endpoints_glance_rabbitmq_password: glance-rabbitmq-password
+        openstack_helm_endpoints_glance_keystone_password: glance-keystone-password
+        # Cinder
+        openstack_helm_endpoints_cinder_api_host: "volume.{{ hostvars['ctl1']['ansible_host'].replace('.', '-') }}.nip.io"
+        openstack_helm_endpoints_cinder_mariadb_password: cinder-mariadb-password
+        openstack_helm_endpoints_cinder_rabbitmq_password: cinder-rabbitmq-password
+        openstack_helm_endpoints_cinder_keystone_password: cinder-keystone-password
+        # Placement
+        openstack_helm_endpoints_placement_api_host: "placement.{{ hostvars['ctl1']['ansible_host'].replace('.', '-') }}.nip.io"
+        openstack_helm_endpoints_placement_keystone_password: placement-keystone-password
+        openstack_helm_endpoints_placement_mariadb_password: placement-mariadb-password
+        # Neutron
+        openstack_helm_endpoints_neutron_api_host: "network.{{ hostvars['ctl1']['ansible_host'].replace('.', '-') }}.nip.io"
+        openstack_helm_endpoints_neutron_mariadb_password: neutron-mariadb-password
+        openstack_helm_endpoints_neutron_rabbitmq_password: neutron-rabbitmq-password
+        openstack_helm_endpoints_neutron_keystone_password: neutron-keystone-password
+        openstack_helm_endpoints_neutron_metadata_secret: neutron-metadata-secret
+        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"
+        openstack_helm_endpoints_nova_mariadb_password: nova-mariadb-password
+        openstack_helm_endpoints_nova_rabbitmq_password: nova-rabbitmq-password
+        openstack_helm_endpoints_nova_keystone_password: nova-keystone-password
+        # Ironic
+        openstack_helm_endpoints_ironic_api_host: "baremetal.{{ hostvars['ctl1']['ansible_host'].replace('.', '-') }}.nip.io"
+        openstack_helm_endpoints_ironic_mariadb_password: ironic-mariadb-password
+        openstack_helm_endpoints_ironic_rabbitmq_password: ironic-rabbitmq-password
+        openstack_helm_endpoints_ironic_keystone_password: ironic-keystone-password
+        # Designate
+        openstack_helm_endpoints_designate_api_host: "dns.{{ hostvars['ctl1']['ansible_host'].replace('.', '-') }}.nip.io"
+        openstack_helm_endpoints_designate_mariadb_password: designate-mariadb-password
+        openstack_helm_endpoints_designate_rabbitmq_password: designate-rabbitmq-password
+        openstack_helm_endpoints_designate_keystone_password: designate-keystone-password
+        # Octavia
+        openstack_helm_endpoints_octavia_api_host: "load-balancer.{{ hostvars['ctl1']['ansible_host'].replace('.', '-') }}.nip.io"
+        openstack_helm_endpoints_octavia_mariadb_password: octavia-mariadb-password
+        openstack_helm_endpoints_octavia_rabbitmq_password: octavia-rabbitmq-password
+        openstack_helm_endpoints_octavia_keystone_password: octavia-keystone-password
+        # Heat
+        openstack_helm_endpoints_heat_api_host: "orchestration.{{ hostvars['ctl1']['ansible_host'].replace('.', '-') }}.nip.io"
+        openstack_helm_endpoints_heat_keystone_password: heat-keystone-password
+        openstack_helm_endpoints_heat_trustee_keystone_password: heat-trustee-keystone-password
+        openstack_helm_endpoints_heat_stack_user_keystone_password: heat-stack-user-keystone-password
+        openstack_helm_endpoints_heat_mariadb_password: heat-mariadb-password
+        openstack_helm_endpoints_heat_rabbitmq_password: heat-rabbitmq-password
+        openstack_helm_endpoints_heat_cfn_api_host: "cloudformation.{{ hostvars['ctl1']['ansible_host'].replace('.', '-') }}.nip.io"
+        openstack_helm_heat_auth_encryption_key: heat-auth-encryption-key
+        # Horizon
+        openstack_helm_endpoints_horizon_api_host: "dashboard.{{ hostvars['ctl1']['ansible_host'].replace('.', '-') }}.nip.io"
+        openstack_helm_endpoints_horizon_mariadb_password: horizon-mariadb-password
+      cephs:
+        ceph_mon_fsid: 441193d8-fed9-485b-87f4-09245ddc1fe7
+        ceph_mon_public_network: 10.96.240.0/24
+      ceph_osds:
+        ceph_osd_devices:
+          - /dev/vdb
+          - /dev/vdc
+          - /dev/vdd
+verifier:
+  name: testinfra
diff --git a/molecule/default/prepare.yml b/molecule/default/prepare.yml
new file mode 100644
index 0000000..57f3b85
--- /dev/null
+++ b/molecule/default/prepare.yml
@@ -0,0 +1,21 @@
+# 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.
+
+- hosts: all
+  tasks:
+    # The apt module can not be used for this since it installs python-apt
+    # which can not work until this command fixes the cache.
+    - name: Update apt cache
+      become: yes
+      command: apt-get update
diff --git a/molecule/default/requirements.txt b/molecule/default/requirements.txt
new file mode 100644
index 0000000..4f5af9e
--- /dev/null
+++ b/molecule/default/requirements.txt
@@ -0,0 +1 @@
+openstacksdk
\ No newline at end of file
diff --git a/playbooks/kubernetes.yml b/playbooks/kubernetes.yml
new file mode 100644
index 0000000..dbad10f
--- /dev/null
+++ b/playbooks/kubernetes.yml
@@ -0,0 +1,24 @@
+# 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.
+
+- hosts: all
+  become: true
+  roles:
+    - role: containerd
+    - role: kubernetes
+
+- hosts: controllers
+  become: true
+  roles:
+    - helm
diff --git a/playbooks/site.yml b/playbooks/site.yml
new file mode 100644
index 0000000..c6e311a
--- /dev/null
+++ b/playbooks/site.yml
@@ -0,0 +1,15 @@
+# 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.
+
+- import_playbook: vexxhost.atmosphere.kubernetes
diff --git a/roles/containerd/defaults/main.yml b/roles/containerd/defaults/main.yml
new file mode 100644
index 0000000..10c3662
--- /dev/null
+++ b/roles/containerd/defaults/main.yml
@@ -0,0 +1,15 @@
+# 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.
+
+containerd_pause_image: k8s.gcr.io/pause:3.5
diff --git a/roles/containerd/handlers/main.yml b/roles/containerd/handlers/main.yml
new file mode 100644
index 0000000..4c5f7e3
--- /dev/null
+++ b/roles/containerd/handlers/main.yml
@@ -0,0 +1,18 @@
+# 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: Restart containerd
+  ansible.builtin.service:
+    name: containerd
+    state: restarted
diff --git a/roles/containerd/tasks/main.yml b/roles/containerd/tasks/main.yml
new file mode 100644
index 0000000..77374ce
--- /dev/null
+++ b/roles/containerd/tasks/main.yml
@@ -0,0 +1,48 @@
+# 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: Add repository
+  ansible.builtin.apt_repository:
+    repo: "deb {{ containerd_repository_url }} {{ ansible_distribution_release }} main"
+    state: present
+  when:
+    - containerd_repository_url is defined
+
+- name: Install packages
+  ansible.builtin.apt:
+    name: containerd
+    state: present
+
+- name: Create folder for configuration
+  ansible.builtin.file:
+    path: /etc/containerd
+    state: directory
+  notify:
+    - Restart containerd
+
+- name: Update pause image in configuration
+  ansible.builtin.template:
+    src: config.toml.j2
+    dest: /etc/containerd/config.toml
+  notify:
+    - Restart containerd
+
+- name: Force any restarts if necessary
+  meta: flush_handlers
+
+- name: Enable and start service
+  ansible.builtin.service:
+    name: containerd
+    enabled: true
+    state: started
diff --git a/roles/containerd/templates/config.toml.j2 b/roles/containerd/templates/config.toml.j2
new file mode 100644
index 0000000..4ce5d20
--- /dev/null
+++ b/roles/containerd/templates/config.toml.j2
@@ -0,0 +1,5 @@
+version = 2
+
+[plugins]
+  [plugins."io.containerd.grpc.v1.cri"]
+    sandbox_image = "{{ containerd_pause_image }}"
\ No newline at end of file
diff --git a/roles/helm/defaults/main.yml b/roles/helm/defaults/main.yml
new file mode 100644
index 0000000..61eac60
--- /dev/null
+++ b/roles/helm/defaults/main.yml
@@ -0,0 +1,17 @@
+# 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.
+
+helm_repository_apt_key: https://baltocdn.com/helm/signing.asc
+helm_repository_url: https://baltocdn.com/helm/stable/debian/
+helm_version: 3.8.0
diff --git a/roles/helm/tasks/debian.yml b/roles/helm/tasks/debian.yml
new file mode 100644
index 0000000..f6004b0
--- /dev/null
+++ b/roles/helm/tasks/debian.yml
@@ -0,0 +1,34 @@
+# 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: add apt key
+  ansible.builtin.apt_key:
+    url: "{{ helm_repository_apt_key }}"
+    state: present
+
+- name: configure version pinning
+  ansible.builtin.template:
+    src: apt-preferences.j2
+    dest: /etc/apt/preferences.d/helm
+    mode: 0644
+
+- name: Add package repository
+  ansible.builtin.apt_repository:
+    repo: "deb {{ helm_repository_url }} all main"
+    state: present
+
+- name: Install packages
+  ansible.builtin.apt:
+    name: ["helm"]
+    install_recommends: false
diff --git a/roles/helm/tasks/main.yml b/roles/helm/tasks/main.yml
new file mode 100644
index 0000000..d8cadc2
--- /dev/null
+++ b/roles/helm/tasks/main.yml
@@ -0,0 +1,26 @@
+# 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: Gather operating system facts
+  ansible.builtin.setup:
+    gather_subset:
+      - min
+
+- name: Install packages using operating system specific process
+  ansible.builtin.include_tasks: "{{ ansible_os_family | lower }}.yml"
+
+- name: install helm diff
+  kubernetes.core.helm_plugin:
+    plugin_path: https://github.com/databus23/helm-diff
+    state: present
diff --git a/roles/helm/tasks/redhat.yml b/roles/helm/tasks/redhat.yml
new file mode 100644
index 0000000..260a645
--- /dev/null
+++ b/roles/helm/tasks/redhat.yml
@@ -0,0 +1,24 @@
+# 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: Add package repository
+  ansible.builtin.yum:
+    name: "https://harbottle.gitlab.io/harbottle-main/{{ ansible_distribution_major_version }}/x86_64/harbottle-main-release.rpm"
+    disable_gpg_check: true
+    state: present
+
+- name: Install packages
+  ansible.builtin.yum:
+    name: helm
+    state: present
diff --git a/roles/helm/templates/apt-preferences.j2 b/roles/helm/templates/apt-preferences.j2
new file mode 100644
index 0000000..f3b959a
--- /dev/null
+++ b/roles/helm/templates/apt-preferences.j2
@@ -0,0 +1,3 @@
+Package: helm
+Pin: version {{ helm_version }}-*
+Pin-Priority: 1000
diff --git a/roles/kubernetes/defaults/main.yml b/roles/kubernetes/defaults/main.yml
new file mode 100644
index 0000000..74f6c2c
--- /dev/null
+++ b/roles/kubernetes/defaults/main.yml
@@ -0,0 +1,35 @@
+# 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.
+
+kubernetes_repo_url: "{{ _kubernetes_upstream_apt_repository }}"
+kubernetes_version: 1.22.7
+
+kubernetes_kernel_modules:
+  - br_netfilter
+
+kubernetes_sysctls:
+  - name: net.ipv4.ip_forward
+    value: 1
+  - name: net.ipv4.tcp_l3mdev_accept
+    value: 1
+  - name: net.ipv4.udp_l3mdev_accept
+    value: 1
+  - name: net.bridge.bridge-nf-call-iptables
+    value: 1
+  - name: net.bridge.bridge-nf-call-ip6tables
+    value: 1
+  - name: net.ipv4.conf.all.rp_filter
+    value: 0
+
+kubernetes_control_plane_group: controllers
diff --git a/roles/kubernetes/files/apt-key.gpg b/roles/kubernetes/files/apt-key.gpg
new file mode 100644
index 0000000..3f0b5a8
--- /dev/null
+++ b/roles/kubernetes/files/apt-key.gpg
Binary files differ
diff --git a/roles/kubernetes/files/haproxy.yaml b/roles/kubernetes/files/haproxy.yaml
new file mode 100644
index 0000000..0c8f04f
--- /dev/null
+++ b/roles/kubernetes/files/haproxy.yaml
@@ -0,0 +1,27 @@
+apiVersion: v1
+kind: Pod
+metadata:
+  name: haproxy
+  namespace: kube-system
+spec:
+  containers:
+  - image: haproxy:2.5
+    name: haproxy
+    livenessProbe:
+      failureThreshold: 8
+      httpGet:
+        host: localhost
+        path: /healthz
+        port: 6443
+        scheme: HTTPS
+    volumeMounts:
+    - mountPath: /usr/local/etc/haproxy/haproxy.cfg
+      name: haproxyconf
+      readOnly: true
+  hostNetwork: true
+  volumes:
+  - hostPath:
+      path: /etc/haproxy/haproxy.cfg
+      type: FileOrCreate
+    name: haproxyconf
+status: {}
diff --git a/roles/kubernetes/files/keepalived.yaml b/roles/kubernetes/files/keepalived.yaml
new file mode 100644
index 0000000..643ebbe
--- /dev/null
+++ b/roles/kubernetes/files/keepalived.yaml
@@ -0,0 +1,32 @@
+apiVersion: v1
+kind: Pod
+metadata:
+  creationTimestamp: null
+  name: keepalived
+  namespace: kube-system
+spec:
+  containers:
+  - name: keepalived
+    image: us-docker.pkg.dev/vexxhost-infra/openstack/keepalived:2.0.19
+    command: ["keepalived", "-f", "/etc/keepalived/keepalived.conf", "--dont-fork", "--log-console", "--log-detail", "--dump-conf"]
+    resources: {}
+    securityContext:
+      capabilities:
+        add:
+        - NET_ADMIN
+        - NET_BROADCAST
+        - NET_RAW
+    volumeMounts:
+    - mountPath: /etc/keepalived/keepalived.conf
+      name: config
+    - mountPath: /etc/keepalived/check_apiserver.sh
+      name: check
+  hostNetwork: true
+  volumes:
+  - hostPath:
+      path: /etc/keepalived/keepalived.conf
+    name: config
+  - hostPath:
+      path: /etc/keepalived/check_apiserver.sh
+    name: check
+status: {}
diff --git a/roles/kubernetes/meta/main.yml b/roles/kubernetes/meta/main.yml
new file mode 100644
index 0000000..ca80af7
--- /dev/null
+++ b/roles/kubernetes/meta/main.yml
@@ -0,0 +1,16 @@
+# 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.
+
+dependencies:
+  - role: containerd
diff --git a/roles/kubernetes/tasks/bootstrap-cluster.yml b/roles/kubernetes/tasks/bootstrap-cluster.yml
new file mode 100644
index 0000000..8231267
--- /dev/null
+++ b/roles/kubernetes/tasks/bootstrap-cluster.yml
@@ -0,0 +1,57 @@
+# 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: Determine node to use for bootstrapping cluster
+  block:
+    - name: Check if any control plane is bootstrapped
+      ansible.builtin.stat:
+        path: /etc/kubernetes/admin.conf
+      register: _kubernetes_stat
+      loop: "{{ groups[kubernetes_control_plane_group] }}"
+      delegate_to: "{{ item }}"
+      delegate_facts: True
+
+- name: Pick node from pre-existing cluster
+  ansible.builtin.set_fact:
+    _kubernetes_bootstrap_node: "{{ _kubernetes_stat.results | selectattr('stat.exists', 'equalto', true) | map(attribute='item') | first }}"
+  when: _kubernetes_stat.results | selectattr('stat.exists', 'equalto', true) | length > 0
+
+- name: Select first node to initialize cluster
+  ansible.builtin.set_fact:
+    _kubernetes_bootstrap_node: "{{ groups[kubernetes_control_plane_group] | first }}"
+  when: _kubernetes_stat.results | selectattr('stat.exists', 'equalto', true) | length == 0
+
+- name: Print selected bootstrap node
+  ansible.builtin.debug:
+    msg: "{{ _kubernetes_bootstrap_node }}"
+
+- name: Upload cluster configuration for bootstrap node
+  ansible.builtin.template:
+    src: kubeadm.yaml.j2
+    dest: /etc/kubernetes/kubeadm.yaml
+  when: inventory_hostname == _kubernetes_bootstrap_node
+
+- name: Initialize cluster
+  throttle: 1
+  ansible.builtin.shell: |
+    kubeadm init --config /etc/kubernetes/kubeadm.yaml --upload-certs
+  args:
+    creates: /etc/kubernetes/admin.conf
+  environment:
+    PATH: /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
+  when: inventory_hostname == _kubernetes_bootstrap_node
+
+- name: Join cluster
+  ansible.builtin.include_tasks: join-cluster.yml
+  when: inventory_hostname != _kubernetes_bootstrap_node
diff --git a/roles/kubernetes/tasks/control-plane.yml b/roles/kubernetes/tasks/control-plane.yml
new file mode 100644
index 0000000..cbb8752
--- /dev/null
+++ b/roles/kubernetes/tasks/control-plane.yml
@@ -0,0 +1,89 @@
+# 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: Upload configuration for Keepalived
+  block:
+    - name: Create folder
+      ansible.builtin.file:
+        dest: /etc/keepalived
+        state: directory
+    - name: Upload configuration
+      ansible.builtin.template:
+        src: keepalived.conf.j2
+        dest: /etc/keepalived/keepalived.conf
+    - name: Upload health check
+      ansible.builtin.template:
+        src: check_apiserver.sh.j2
+        dest: /etc/keepalived/check_apiserver.sh
+        mode: 0755
+    - name: Upload Kubernetes manifest
+      ansible.builtin.copy:
+        src: keepalived.yaml
+        dest: /etc/kubernetes/manifests/keepalived.yaml
+
+- name: Upload configuration for HAproxy
+  block:
+    - name: Create folder
+      ansible.builtin.file:
+        dest: /etc/haproxy
+        state: directory
+    - name: Upload configuration
+      ansible.builtin.template:
+        src: haproxy.cfg.j2
+        dest: /etc/haproxy/haproxy.cfg
+    - name: Upload Kubernetes manifest
+      ansible.builtin.copy:
+        src: haproxy.yaml
+        dest: /etc/kubernetes/manifests/haproxy.yaml
+
+- name: Bootstrap cluster
+  include_tasks: bootstrap-cluster.yml
+
+- name: create folder for admin configuration
+  ansible.builtin.file:
+    path: /root/.kube
+    state: directory
+
+- name: copy admin configuration file
+  ansible.builtin.copy:
+    src: /etc/kubernetes/admin.conf
+    dest: /root/.kube/config
+    mode: 0600
+    remote_src: true
+
+- name: install pip
+  ansible.builtin.apt:
+    name: python3-pip
+    install_recommends: false
+
+- name: install kubernetes python package
+  ansible.builtin.pip:
+    name: kubernetes
+
+- name: Allow workloads on control plane nodes
+  run_once: true
+  ansible.builtin.shell: |
+    kubectl taint nodes --all node-role.kubernetes.io/master-
+  ignore_errors: true
+  changed_when: false
+
+- name: Add labels to control plane nodes
+  kubernetes.core.k8s:
+    state: patched
+    kind: Node
+    name: "{{ inventory_hostname_short }}"
+    definition:
+      metadata:
+        labels:
+          openstack-control-plane: enabled
diff --git a/roles/kubernetes/tasks/join-cluster.yml b/roles/kubernetes/tasks/join-cluster.yml
new file mode 100644
index 0000000..b65e347
--- /dev/null
+++ b/roles/kubernetes/tasks/join-cluster.yml
@@ -0,0 +1,63 @@
+# 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: Check if the node is already part of the cluster
+  ansible.builtin.stat:
+    path: /etc/kubernetes/kubelet.conf
+  register: _stat_etc_kubernetes_kubelet_conf
+
+- name: Generate control-plane certificates for joining cluster
+  run_once: true
+  delegate_to: "{{ _kubernetes_bootstrap_node | default(groups[kubernetes_control_plane_group][0]) }}"
+  ansible.builtin.shell: |
+    kubeadm init phase upload-certs --upload-certs 2>/dev/null | grep -v upload-certs
+  changed_when: false
+  register: _kubeadm_init_upload_certs
+  when:
+    - not _stat_etc_kubernetes_kubelet_conf.stat.exists
+    - inventory_hostname in groups[kubernetes_control_plane_group]
+
+- name: Retrieve SHA256 certificate hash
+  run_once: true
+  delegate_to: "{{ _kubernetes_bootstrap_node | default(groups[kubernetes_control_plane_group][0]) }}"
+  community.crypto.x509_certificate_info:
+    path: /etc/kubernetes/pki/ca.crt
+  register: _kubeadm_certificate_info
+  when:
+    - not _stat_etc_kubernetes_kubelet_conf.stat.exists
+
+- name: Generate token for joining cluster
+  run_once: true
+  delegate_to: "{{ _kubernetes_bootstrap_node | default(groups[kubernetes_control_plane_group][0]) }}"
+  ansible.builtin.shell: |
+    kubeadm token create
+  register: _kubeadm_token_create
+  when:
+    - not _stat_etc_kubernetes_kubelet_conf.stat.exists
+
+- name: Upload kubeadm configuration
+  ansible.builtin.template:
+    src: kubeadm.yaml.j2
+    dest: /etc/kubernetes/kubeadm.yaml
+  when:
+    - not _stat_etc_kubernetes_kubelet_conf.stat.exists
+
+- name: Join cluster
+  ansible.builtin.shell: |
+    kubeadm join --config /etc/kubernetes/kubeadm.yaml \
+                 --ignore-preflight-errors=DirAvailable--etc-kubernetes-manifests
+  environment:
+    PATH: /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
+  args:
+    creates: /etc/kubernetes/kubelet.conf
diff --git a/roles/kubernetes/tasks/main.yml b/roles/kubernetes/tasks/main.yml
new file mode 100644
index 0000000..383ce1f
--- /dev/null
+++ b/roles/kubernetes/tasks/main.yml
@@ -0,0 +1,124 @@
+# 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: Add repository keys
+  ansible.builtin.copy:
+    src: apt-key.gpg
+    dest: /usr/share/keyrings/kubernetes-archive-keyring.gpg
+  when:
+    - kubernetes_repo_url == _kubernetes_upstream_apt_repository
+
+- name: Add repository
+  ansible.builtin.apt_repository:
+    repo: "deb {% if kubernetes_repo_url == _kubernetes_upstream_apt_repository %}[signed-by=/usr/share/keyrings/kubernetes-archive-keyring.gpg]{% endif %} {{ kubernetes_repo_url }} kubernetes-xenial main"
+    state: present
+
+- name: Setup version pins
+  ansible.builtin.template:
+    src: apt-preferences.j2
+    dest: /etc/apt/preferences.d/kubernetes
+    mode: 0644
+
+- name: Install packages
+  ansible.builtin.apt:
+    name:
+      - "containerd"
+      - "kubeadm={{ kubernetes_version }}-00"
+      - "kubectl={{ kubernetes_version }}-00"
+      - "kubelet={{ kubernetes_version }}-00"
+    state: present
+
+- name: Enable kernel modules on-boot
+  ansible.builtin.template:
+    src: modules-load.conf.j2
+    dest: /etc/modules-load.d/k8s.conf
+
+- name: Enable kernel modules in runtime
+  community.general.modprobe:
+    name: "{{ item }}"
+    state: present
+  loop: "{{ kubernetes_kernel_modules }}"
+
+- name: Configure sysctl values
+  ansible.posix.sysctl:
+    name: "{{ item.name }}"
+    value: "{{ item.value }}"
+    state: present
+  loop: "{{ kubernetes_sysctls }}"
+
+- name: Check swap status
+  ansible.builtin.command: /sbin/swapon -s
+  changed_when: false
+  register: _swapon
+
+- name: Disable swap
+  ansible.builtin.command: /sbin/swapoff -a
+  ignore_errors: "{{ ansible_check_mode }}"
+  when:
+    - _swapon.stdout
+
+- name: Remove swapfile from /etc/fstab
+  ansible.posix.mount:
+    name: "{{ item }}"
+    fstype: swap
+    state: absent
+  with_items:
+    - swap
+    - none
+
+- name: Configure short hostname
+  ansible.builtin.hostname:
+    name: "{{ inventory_hostname_short }}"
+
+- name: Ensure hostname inside hosts file
+  ansible.builtin.lineinfile:
+    path: /etc/hosts
+    regexp: '^127\.0\.1\.1'
+    line: 127.0.1.1 {{ inventory_hostname }} {{ inventory_hostname_short }}
+
+- name: Setup control plane
+  when: inventory_hostname in groups[kubernetes_control_plane_group]
+  ansible.builtin.include_tasks: control-plane.yml
+
+- name: Setup nodes
+  when: inventory_hostname not in groups[kubernetes_control_plane_group]
+  ansible.builtin.include_tasks: nodes.yml
+
+- name: Add labels to control plane nodes
+  delegate_to: "{{ groups[kubernetes_control_plane_group][0] }}"
+  kubernetes.core.k8s:
+    state: patched
+    kind: Node
+    name: "{{ inventory_hostname_short }}"
+    definition:
+      metadata:
+        labels:
+          openstack-control-plane: enabled
+          openvswitch: enabled
+  when:
+    - inventory_hostname in groups['controllers']
+
+- name: Add labels to compute nodes
+  delegate_to: "{{ groups[kubernetes_control_plane_group][0] }}"
+  kubernetes.core.k8s:
+    state: patched
+    kind: Node
+    name: "{{ inventory_hostname_short }}"
+    definition:
+      metadata:
+        labels:
+          openstack-compute-node: enabled
+          openvswitch: enabled
+  when:
+    - inventory_hostname in groups['computes']
diff --git a/roles/kubernetes/tasks/nodes.yml b/roles/kubernetes/tasks/nodes.yml
new file mode 100644
index 0000000..bc11ac5
--- /dev/null
+++ b/roles/kubernetes/tasks/nodes.yml
@@ -0,0 +1,22 @@
+# 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: Check if Kubernetes is already deployed
+  stat:
+    path: /etc/kubernetes/kubelet.conf
+  register: _kubernetes_kubelet
+
+- name: Join cluster
+  when: not _kubernetes_kubelet.stat.exists
+  ansible.builtin.include_tasks: join-cluster.yml
diff --git a/roles/kubernetes/templates/apt-preferences.j2 b/roles/kubernetes/templates/apt-preferences.j2
new file mode 100644
index 0000000..494a3a8
--- /dev/null
+++ b/roles/kubernetes/templates/apt-preferences.j2
@@ -0,0 +1,11 @@
+Package: kubectl
+Pin: version {{ kubernetes_version }}-00
+Pin-Priority: 1000
+
+Package: kubeadm
+Pin: version {{ kubernetes_version }}-00
+Pin-Priority: 1000
+
+Package: kubelet
+Pin: version {{ kubernetes_version }}-00
+Pin-Priority: 1000
\ No newline at end of file
diff --git a/roles/kubernetes/templates/check_apiserver.sh.j2 b/roles/kubernetes/templates/check_apiserver.sh.j2
new file mode 100644
index 0000000..bc9aa95
--- /dev/null
+++ b/roles/kubernetes/templates/check_apiserver.sh.j2
@@ -0,0 +1,11 @@
+#!/bin/sh
+
+errorExit() {
+    echo "*** $*" 1>&2
+    exit 1
+}
+
+curl --silent --max-time 2 --insecure https://localhost:16443/ -o /dev/null || errorExit "Error GET https://localhost:16443/"
+if ip addr | grep -q {{ kubernetes_keepalived_vip }}; then
+    curl --silent --max-time 2 --insecure https://{{ kubernetes_keepalived_vip }}:6443/ -o /dev/null || errorExit "Error GET https://{{ kubernetes_keepalived_vip }}:6443/"
+fi
\ No newline at end of file
diff --git a/roles/kubernetes/templates/haproxy.cfg.j2 b/roles/kubernetes/templates/haproxy.cfg.j2
new file mode 100644
index 0000000..b4cb838
--- /dev/null
+++ b/roles/kubernetes/templates/haproxy.cfg.j2
@@ -0,0 +1,51 @@
+# /etc/haproxy/haproxy.cfg
+#---------------------------------------------------------------------
+# Global settings
+#---------------------------------------------------------------------
+global
+    log /dev/log local0
+    log /dev/log local1 notice
+    daemon
+
+#---------------------------------------------------------------------
+# common defaults that all the 'listen' and 'backend' sections will
+# use if not designated in their block
+#---------------------------------------------------------------------
+defaults
+    mode                    http
+    log                     global
+    option                  httplog
+    option                  dontlognull
+    option http-server-close
+    option forwardfor       except 127.0.0.0/8
+    option                  redispatch
+    retries                 1
+    timeout http-request    10s
+    timeout queue           20s
+    timeout connect         5s
+    timeout client          20s
+    timeout server          20s
+    timeout http-keep-alive 10s
+    timeout check           10s
+
+#---------------------------------------------------------------------
+# apiserver frontend which proxys to the masters
+#---------------------------------------------------------------------
+frontend apiserver
+    bind *:6443
+    mode tcp
+    option tcplog
+    default_backend apiserver
+
+#---------------------------------------------------------------------
+# round robin balancing for apiserver
+#---------------------------------------------------------------------
+backend apiserver
+    option httpchk GET /healthz
+    http-check expect status 200
+    mode tcp
+    option ssl-hello-chk
+    balance     roundrobin
+{% for host in groups[kubernetes_control_plane_group] %}
+        server {{ host }} {{ hostvars[host]['ansible_host'] }}:16443 check
+{% endfor %}
diff --git a/roles/kubernetes/templates/keepalived.conf.j2 b/roles/kubernetes/templates/keepalived.conf.j2
new file mode 100644
index 0000000..58fde31
--- /dev/null
+++ b/roles/kubernetes/templates/keepalived.conf.j2
@@ -0,0 +1,25 @@
+global_defs {
+    router_id LVS_DEVEL
+}
+
+vrrp_script check_apiserver {
+    script "/etc/keepalived/check_apiserver.sh"
+    interval 3
+    weight -2
+    fall 10
+    rise 2
+}
+
+vrrp_instance kubernetes {
+    state MASTER
+    interface {{ kubernetes_keepalived_interface }}
+    virtual_router_id {{ kubernetes_keepalived_vrid }}
+
+    virtual_ipaddress {
+        {{ kubernetes_keepalived_vip }}
+    }
+
+    track_script {
+        check_apiserver
+    }
+}
\ No newline at end of file
diff --git a/roles/kubernetes/templates/kubeadm.yaml.j2 b/roles/kubernetes/templates/kubeadm.yaml.j2
new file mode 100644
index 0000000..eba1d7c
--- /dev/null
+++ b/roles/kubernetes/templates/kubeadm.yaml.j2
@@ -0,0 +1,55 @@
+---
+apiVersion: kubeadm.k8s.io/v1beta3
+kind: InitConfiguration
+localAPIEndpoint:
+  bindPort: 16443
+nodeRegistration:
+  kubeletExtraArgs:
+    cgroups-per-qos: "false"
+    enforce-node-allocatable: ""
+    node-ip: "{{ ansible_host }}"
+---
+apiVersion: kubeadm.k8s.io/v1beta3
+kind: JoinConfiguration
+nodeRegistration:
+  kubeletExtraArgs:
+    cgroups-per-qos: "false"
+    enforce-node-allocatable: ""
+    node-ip: "{{ ansible_host }}"
+{% if (_kubernetes_bootstrap_node is not defined) or (_kubernetes_bootstrap_node is defined and inventory_hostname != _kubernetes_bootstrap_node) %}
+discovery:
+  bootstrapToken:
+    token: "{{ _kubeadm_token_create.stdout | trim }}"
+    apiServerEndpoint: "{{ kubernetes_hostname }}:6443"
+    caCertHashes: ["sha256:{{ _kubeadm_certificate_info.public_key_fingerprints.sha256 | replace(':', '') }}"]
+{% if inventory_hostname in groups[kubernetes_control_plane_group] %}
+controlPlane:
+  localAPIEndpoint:
+    bindPort: 16443
+  certificateKey: {{ _kubeadm_init_upload_certs.stdout | trim }}
+{% endif %}
+{% endif %}
+---
+apiVersion: kubeadm.k8s.io/v1beta3
+kind: ClusterConfiguration
+controlPlaneEndpoint: "{{ kubernetes_hostname }}:6443"
+apiServer:
+  extraArgs:
+    oidc-username-claim: email
+{% if kubernetes_oidc_issuer_url is defined %}
+    oidc-issuer-url: {{ kubernetes_oidc_issuer_url }}
+    oidc-client-id: {{ kubernetes_oidc_client_id }}
+{% endif %}
+controllerManager:
+  extraArgs:
+    bind-address: "0.0.0.0"
+scheduler:
+  extraArgs:
+    bind-address: "0.0.0.0"
+---
+apiVersion: kubelet.config.k8s.io/v1beta1
+kind: KubeletConfiguration
+---
+apiVersion: kubeproxy.config.k8s.io/v1alpha1
+kind: KubeProxyConfiguration
+metricsBindAddress: 0.0.0.0
\ No newline at end of file
diff --git a/roles/kubernetes/templates/modules-load.conf.j2 b/roles/kubernetes/templates/modules-load.conf.j2
new file mode 100644
index 0000000..2c4d984
--- /dev/null
+++ b/roles/kubernetes/templates/modules-load.conf.j2
@@ -0,0 +1,3 @@
+{% for kubernetes_kernel_module in kubernetes_kernel_modules %}
+{{ kubernetes_kernel_module }}
+{% endfor %}
\ No newline at end of file
diff --git a/roles/kubernetes/vars/main.yml b/roles/kubernetes/vars/main.yml
new file mode 100644
index 0000000..cf153c5
--- /dev/null
+++ b/roles/kubernetes/vars/main.yml
@@ -0,0 +1,15 @@
+# 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.
+
+_kubernetes_upstream_apt_repository: https://apt.kubernetes.io/
diff --git a/setup.cfg b/setup.cfg
new file mode 100644
index 0000000..19a7912
--- /dev/null
+++ b/setup.cfg
@@ -0,0 +1,16 @@
+# 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.
+
+[metadata]
+name = ansible-collection-atmosphere
diff --git a/setup.py b/setup.py
new file mode 100644
index 0000000..dd38643
--- /dev/null
+++ b/setup.py
@@ -0,0 +1,17 @@
+# 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.
+
+import setuptools
+
+setuptools.setup(pbr=True)
diff --git a/tox.ini b/tox.ini
new file mode 100644
index 0000000..feeae86
--- /dev/null
+++ b/tox.ini
@@ -0,0 +1,24 @@
+# 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.
+
+[tox]
+requires = tox-ansible
+skipsdist = True
+
+[testenv]
+usedevelop = True
+passenv =
+    OS_*
+    TERM
+    STACK_NAME
\ No newline at end of file
diff --git a/zuul.d/jobs.yaml b/zuul.d/jobs.yaml
new file mode 100644
index 0000000..c089928
--- /dev/null
+++ b/zuul.d/jobs.yaml
@@ -0,0 +1,28 @@
+# 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.
+
+- job:
+    name: ansible-collection-atmosphere-tox-molecule
+    parent: vexxhost-tox-molecule
+    pre-run:
+      - zuul.d/playbooks/ansible-collection-atmosphere-tox-molecule/pre-run.yml
+    vars:
+      tox_environment:
+        STACK_NAME: "atmosphere-{{ zuul.build }}"
+
+- job:
+    name: ansible-collection-atmosphere-tox-molecule-default
+    parent: ansible-collection-atmosphere-tox-molecule
+    vars:
+      tox_envlist: default
diff --git a/zuul.d/playbooks/ansible-collection-atmosphere-tox-molecule/pre-run.yml b/zuul.d/playbooks/ansible-collection-atmosphere-tox-molecule/pre-run.yml
new file mode 100644
index 0000000..837ba50
--- /dev/null
+++ b/zuul.d/playbooks/ansible-collection-atmosphere-tox-molecule/pre-run.yml
@@ -0,0 +1,26 @@
+# 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.
+
+- hosts: all
+  tasks:
+    - name: Create folder for Ansible collections
+      file:
+        path: "{{ ansible_user_dir }}/.ansible/collections/ansible_collections/vexxhost"
+        state: directory
+
+    - name: Create symbloic link to Ansible collection
+      file:
+        src: "{{ ansible_user_dir }}/{{ zuul.project.src_dir }}"
+        dest: "{{ ansible_user_dir }}/.ansible/collections/ansible_collections/vexxhost/atmosphere"
+        state: link
diff --git a/zuul.d/project.yaml b/zuul.d/project.yaml
new file mode 100644
index 0000000..c154370
--- /dev/null
+++ b/zuul.d/project.yaml
@@ -0,0 +1,21 @@
+# 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.
+
+- project:
+    check:
+      jobs:
+        - ansible-collection-atmosphere-tox-molecule-default
+    gate:
+      jobs:
+        - ansible-collection-atmosphere-tox-molecule-default