diff --git a/etcd/defaults/main.yml b/etcd/defaults/main.yml new file mode 100644 index 0000000000000000000000000000000000000000..4d2f6a4b710000e6865de9335b1e5a21c378fe7b --- /dev/null +++ b/etcd/defaults/main.yml @@ -0,0 +1,41 @@ +--- + +etcd_manage_certs: false +etcd_ssl: "{{ etcd_manage_certs }}" + +# etcd_ca_private_key_file: {{ inventory_dir }}/etcd_ca_key.pem +# etcd_ca_private_key_passphrase: hunter2 +# etcd_ca_certificate_file: {{ inventory_dir }}/etcd_ca_cert.pem + +etcd_listen_peer_url: >- + http{{ 's' if etcd_ssl }}://{{ + lookup('community.general.dig', ansible_fqdn) }}:2380 +etcd_listen_peer_urls: + - "{{ etcd_listen_peer_url }}" +etcd_listen_client_url: >- + http{{ 's' if etcd_ssl }}://{{ + lookup('community.general.dig', ansible_fqdn) }}:2379 +etcd_listen_client_urls: + - "{{ etcd_listen_client_url }}" +etcd_initial_advertise_peer_urls: "{{ etcd_listen_peer_urls }}" +etcd_advertise_client_urls: "{{ etcd_listen_client_urls }}" + +etcd_member_url_format: "http{{ 's' if etcd_ssl }}://%s:2380" + +# Set etcd_members to override automatic construction based on +# `etcd_member_url_format` and `ansible_play_hosts` + +# etcd_members: +# a: https://10.0.0.1:2380 +# b: https://10.0.0.2:2380 +# c: https://10.0.0.3:2380 + +# Set `etcd_initial_cluster_token` to override automatic generation based on +# hashing `etcd_members`. + +# etcd_initial_cluster_token: etcd-cluster + +# Arbitrary extra config variables + +# etcd_extra: +# wal_dir: /srv/ssd/etcd-wal diff --git a/etcd/handlers/main.yml b/etcd/handlers/main.yml new file mode 100644 index 0000000000000000000000000000000000000000..02955f81ea0f9935b31d94d3024dc6284f3328b1 --- /dev/null +++ b/etcd/handlers/main.yml @@ -0,0 +1,6 @@ +--- + +- name: Restart etcd + ansible.builtin.systemd: + name: etcd.service + state: restarted diff --git a/etcd/tasks/certs.yml b/etcd/tasks/certs.yml new file mode 100644 index 0000000000000000000000000000000000000000..4fc3d5f9fa8ec3a5a46320fc8e3ce91c6af7ef32 --- /dev/null +++ b/etcd/tasks/certs.yml @@ -0,0 +1,159 @@ +--- + +- name: Check and configure CA + run_once: true + delegate_to: localhost + block: + - name: Check that CA private key exists + ansible.builtin.assert: + that: + - etcd_ca_private_key_file is defined + - etcd_ca_private_key_passphrase + fail_msg: >- + Please ensure `etcd_ca_private_key_file` points to a suitable private + key for the CA and `etcd_ca_private_key_passphrase` contains its + passphrase. + quiet: true + + - name: Check that CA private key is usable + community.crypto.openssl_privatekey_info: + path: "{{ etcd_ca_private_key_file }}" + passphrase: "{{ etcd_ca_private_key_passphrase }}" + register: ca_privkey_check + failed_when: ca_privkey_check.failed or not ca_privkey_check.can_parse_key + + - name: Check if CA certificate exists + community.crypto.x509_certificate_info: + path: "{{ etcd_ca_certificate_file }}" + register: ca_cert_exists + ignore_errors: true + + - name: Create CA + when: ca_cert_exists.failed or ca_cert_exists.expired + block: + - name: Create CA CSR temporary file + ansible.builtin.tempfile: {} + register: tempfile + + - name: Create CA CSR + community.crypto.openssl_csr: + common_name: etcd CA + basic_constraints_critical: true + basic_constraints: + - "CA:TRUE" + create_subject_key_identifier: true + key_usage_critical: true + key_usage: + - Certificate Sign + - CRL Sign + name_constraints_critical: true + name_constraints_excluded: >- + {{ etcd_ca_name_constraints_excluded | default(omit) }} + name_constraints_permitted: >- + {{ etcd_ca_name_constraints_permitted | default(omit) }} + path: "{{ tempfile.path }}" + privatekey_path: "{{ etcd_ca_private_key_file }}" + privatekey_passphrase: "{{ etcd_ca_private_key_passphrase }}" + use_common_name_for_san: false + + - name: Create CA certificate + community.crypto.x509_certificate: + csr_path: "{{ tempfile.path }}" + path: "{{ etcd_ca_certificate_file }}" + privatekey_path: "{{ etcd_ca_private_key_file }}" + privatekey_passphrase: "{{ etcd_ca_private_key_passphrase }}" + provider: selfsigned + + always: + - name: Delete CSR + ansible.builtin.file: + path: "{{ tempfile.path }}" + state: absent + +- name: Install CA certificate + ansible.builtin.copy: + src: "{{ etcd_ca_certificate_file }}" + dest: /var/lib/etcd/ca.pem + owner: root + group: root + mode: "0644" + +- name: Create node certificate private key + community.crypto.openssl_privatekey: + path: /var/lib/etcd/node_priv.pem + owner: root + group: etcd + mode: "0640" + type: Ed25519 + +- name: Check if node certificate exists + community.crypto.x509_certificate_info: + path: /var/lib/etcd/node_cert.pem + register: node_cert_exists + ignore_errors: true + +- name: Create node certificate + when: node_cert_exists.failed or node_cert_exists.expired + block: + - name: Create node CSR temporary file + ansible.builtin.tempfile: {} + register: tempfile + + - name: Create node CSR + community.crypto.openssl_csr: + common_name: "{{ ansible_fqdn }}" + basic_constraints_critical: true + basic_constraints: + - "CA:FALSE" + create_subject_key_identifier: true + extended_key_usage: + - TLS Web Server Authentication + - TLS Web Client Authentication + key_usage_critical: true + key_usage: + - Digital Signature + - Key Encipherment + path: "{{ tempfile.path }}" + privatekey_path: /var/lib/etcd/node_priv.pem + subject_alt_name: >- + {{ (ansible_all_ipv4_addresses|map("regex_replace", "^", "IP:")|list) + + (ansible_all_ipv6_addresses|map("regex_replace", "^", "IP:")|list) + + ["DNS:%s" % ansible_fqdn, "DNS:%s" % ansible_hostname] + }} + return_content: true + register: node_csr + + - name: Create temporary file for node certificate + ansible.builtin.tempfile: + suffix: "{{ inventory_hostname }}" + register: cert_tempfile + + - name: Create node certificate + delegate_to: localhost + community.crypto.x509_certificate: + csr_content: "{{ node_csr.csr }}" + path: "{{ cert_tempfile.path }}" + provider: ownca + ownca_path: "{{ etcd_ca_certificate_file }}" + ownca_privatekey_path: "{{ etcd_ca_private_key_file }}" + ownca_privatekey_passphrase: "{{ etcd_ca_private_key_passphrase }}" + + - name: Install certificate + ansible.builtin.copy: + src: "{{ cert_tempfile.path }}" + dest: /var/lib/etcd/node_cert.pem + owner: root + group: etcd + mode: "0640" + + always: + - name: Delete temporary certificate file + ansible.builtin.file: + path: "{{ cert_tempfile.path }}" + state: absent + delegate_to: localhost + + - name: Delete CSR + ansible.builtin.file: + path: "{{ tempfile.path }}" + state: absent diff --git a/etcd/tasks/main.yml b/etcd/tasks/main.yml new file mode 100644 index 0000000000000000000000000000000000000000..cd89125f4fed29e69fc279db6b44bcae896dd2c9 --- /dev/null +++ b/etcd/tasks/main.yml @@ -0,0 +1,50 @@ +--- + +- name: Install etcd + ansible.builtin.apt: + name: + - etcd-client + - etcd-server + policy_rc_d: 101 # do not start service + +- name: Setup certificates + ansible.builtin.include_tasks: certs.yml + when: etcd_manage_certs + +- name: Set etcd cluster members and peer URLs fact + ansible.builtin.set_fact: + etcd_members: >- + {{ dict(ansible_play_hosts | zip(values_list)) }} + vars: + # Following ansible-lint would worsen readability, because it does not read + # the `>-` multiline string as such and complains about a space that is + # actually a newline. + # noqa: jinja[spacing] + values_list: >- + [{% for h in ansible_play_hosts -%} + "{{ etcd_member_url_format | format(h) }}" + {%- if not loop.last %},{% endif -%} + {%- endfor %}] + when: etcd_members is not defined + +- name: Set etcd initial cluster token + ansible.builtin.set_fact: + etcd_initial_cluster_token: >- + {{ etcd_members | dictsort | to_json | hash("md5") }} + when: etcd_initial_cluster_token is not defined + +- name: Configure etcd + ansible.builtin.template: + src: environment.j2 + dest: /etc/default/etcd + owner: root + group: root + mode: "0640" + notify: + - Restart etcd + +- name: Enable and start etcd + ansible.builtin.systemd: + name: etcd.service + enabled: true + state: started diff --git a/etcd/templates/environment.j2 b/etcd/templates/environment.j2 new file mode 100644 index 0000000000000000000000000000000000000000..21f6dd96e8bba1cb86e957a2ebf308fb5fa748ce --- /dev/null +++ b/etcd/templates/environment.j2 @@ -0,0 +1,23 @@ +ETCD_NAME="{{ inventory_hostname }}" +ETCD_LISTEN_PEER_URLS="{{ etcd_listen_peer_urls | join(',') }}" +ETCD_LISTEN_CLIENT_URLS="{{ etcd_listen_client_urls | join(',') }}" +ETCD_INITIAL_ADVERTISE_PEER_URLS="{{ etcd_initial_advertise_peer_urls|join(',') }}" +ETCD_INITIAL_CLUSTER="{% for k, v in etcd_members.items() -%} +{{ k }}={{ v }} +{%- if not loop.last %},{% endif -%} +{%- endfor %}" +{# Scaling not yet supported. #} +ETCD_INITIAL_CLUSTER_STATE="new" +ETCD_INITIAL_CLUSTER_TOKEN="{{ etcd_initial_cluster_token }}" +ETCD_ADVERTISE_CLIENT_URLS="{{ etcd_advertise_client_urls | join(',') }}" +{% if etcd_ssl %} +ETCD_KEY_FILE="/var/lib/etcd/node_priv.pem" +ETCD_CERT_FILE="/var/lib/etcd/node_cert.pem" +ETCD_PEER_TRUSTED_CA_FILE="/var/lib/etcd/ca.pem" +ETCD_PEER_KEY_FILE="/var/lib/etcd/node_priv.pem" +ETCD_PEER_CERT_FILE="/var/lib/etcd/node_cert.pem" +ETCD_PEER_CLIENT_CERT_AUTH="true" +{% endif %} +{% for k, v in etcd_extra | default({}) | items %} +ETCD_{{ k | upper }}="{{ v }}" +{% endfor %}