diff --git a/client-provisioner/examples/debootstrap_script.py b/client-provisioner/examples/debootstrap_script.py index 0e0d2b9a..da5bc666 100644 --- a/client-provisioner/examples/debootstrap_script.py +++ b/client-provisioner/examples/debootstrap_script.py @@ -113,31 +113,64 @@ def install(**kwargs): # Append the fstab line with open(fstab_file, 'a') as fh: - fh.write("/dev/{disk} {mountpoint} {filesystem} {options} {dump} {cpass}\n".format( + data = "/dev/{disk} {mountpoint} {filesystem} {options} {dump} {cpass}\n".format( disk=disk['disk_id'], mountpoint=disk['mountpoint'], filesystem=disk['filesystem'], options=options, dump=dump, cpass=cpass - )) + ) + fh.write(data) # Write the hostname hostname_file = "{}/etc/hostname".format(temporary_directory) with open(hostname_file, 'w') as fh: fh.write("{}".format(vm_name)) - # Write a DHCP stanza for ens2 + # Fix the cloud-init.target since it's broken + cloudinit_target_file = "{}/etc/systemd/system/cloud-init.target".format(temporary_directory) + with open(cloudinit_target_file, 'w') as fh: + data = """[Install] +WantedBy=multi-user.target +[Unit] +Description=Cloud-init target +After=multi-user.target +""" + fh.write(data) + # NOTE: Due to device ordering within the Libvirt XML configuration, the first Ethernet interface # will always be on PCI bus ID 2, hence the name "ens2". + # Write a DHCP stanza for ens2 ens2_network_file = "{}/etc/network/interfaces.d/ens2".format(temporary_directory) with open(ens2_network_file, 'w') as fh: - fh.write("auto ens2\niface ens2 inet dhcp\n") + data = """auto ens2 +iface ens2 inet dhcp +""" + fh.write(data) + + # Write the DHCP config for ens2 + dhclient_file = "{}/etc/dhcp/dhclient.conf".format(temporary_directory) + with open(dhclient_file, 'w') as fh: + data = """# DHCP client configuration +# Created by vminstall for host web1.i.bonilan.net +option rfc3442-classless-static-routes code 121 = array of unsigned integer 8; +interface "ens2" { + send host-name = "web1"; + send fqdn.fqdn = "web1"; + request subnet-mask, broadcast-address, time-offset, routers, + domain-name, domain-name-servers, domain-search, host-name, + dhcp6.name-servers, dhcp6.domain-search, dhcp6.fqdn, dhcp6.sntp-servers, + netbios-name-servers, netbios-scope, interface-mtu, + rfc3442-classless-static-routes, ntp-servers; +} +""" + fh.write(data) # Write the GRUB configuration grubcfg_file = "{}/etc/default/grub".format(temporary_directory) with open(grubcfg_file, 'w') as fh: - fh.write("""# Written by the PVC provisioner + data = """# Written by the PVC provisioner GRUB_DEFAULT=0 GRUB_TIMEOUT=1 GRUB_DISTRIBUTOR="PVC Virtual Machine" @@ -146,25 +179,39 @@ GRUB_CMDLINE_LINUX="" GRUB_TERMINAL=console GRUB_SERIAL_COMMAND="serial --speed=115200 --unit=0 --word=8 --parity=no --stop=1" GRUB_DISABLE_LINUX_UUID=false -""".format(root_disk=root_disk['disk_id'])) +""".format(root_disk=root_disk['disk_id']) + fh.write(data) - # Chroot and install GRUB so we can boot, then exit the chroot + # Chroot, do some in-root tasks, then exit the chroot # EXITING THE CHROOT IS VERY IMPORTANT OR THE FOLLOWING STAGES OF THE PROVISIONER # WILL FAIL IN UNEXPECTED WAYS! Keep this in mind when using chroot in your scripts. real_root = os.open("/", os.O_RDONLY) os.chroot(temporary_directory) fake_root = os.open("/", os.O_RDONLY) os.fchdir(fake_root) + + # Install and update GRUB os.system( "grub-install --force /dev/rbd/{}/{}_{}".format(root_disk['pool'], vm_name, root_disk['disk_id']) ) os.system( "update-grub" ) + # Set a really dumb root password [TEMPORARY] os.system( "echo root:test123 | chpasswd" ) - # Restore our original root + # Enable cloud-init target on (first) boot + # NOTE: Your user-data should handle this and disable it once done, or things get messy. + # That cloud-init won't run without this hack seems like a bug... but even the official + # Debian cloud images are affected, so who knows. + os.system( + "systemctl enable cloud-init.target" + ) + + # Restore our original root/exit the chroot + # EXITING THE CHROOT IS VERY IMPORTANT OR THE FOLLOWING STAGES OF THE PROVISIONER + # WILL FAIL IN UNEXPECTED WAYS! Keep this in mind when using chroot in your scripts. os.fchdir(real_root) os.chroot(".") os.fchdir(real_root) @@ -182,4 +229,4 @@ GRUB_DISABLE_LINUX_UUID=false del fake_root del real_root - # Everything else is done via cloud-init + # Everything else is done via cloud-init user-data diff --git a/client-provisioner/examples/userdata.yaml b/client-provisioner/examples/userdata.yaml new file mode 100644 index 00000000..9215ff8f --- /dev/null +++ b/client-provisioner/examples/userdata.yaml @@ -0,0 +1,23 @@ +#cloud-config +# Example user-data file to set up an alternate /var/home, a first user and some SSH keys, and some packages +bootcmd: + - "mv /home /var/" + - "locale-gen" +package_update: true +packages: + - openssh-server + - sudo +users: + - name: deploy + gecos: Deploy User + homedir: /var/home/deploy + sudo: "ALL=(ALL) NOPASSWD: ALL" + groups: adm, sudo + lock_passwd: true + ssh_authorized_keys: + - ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBRBGPzlbh5xYD6k8DMZdPNEwemZzKSSpWGOuU72ehfN joshua@bonifacelabs.net 2017-04 +usercmd: + - "groupmod -g 200 deploy" + - "usermod -u 200 deploy" + - "userdel debian" + - "systemctl disable cloud-init.target" diff --git a/client-provisioner/provisioner_lib/provisioner.py b/client-provisioner/provisioner_lib/provisioner.py index 7cb40541..1cbd4d69 100755 --- a/client-provisioner/provisioner_lib/provisioner.py +++ b/client-provisioner/provisioner_lib/provisioner.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 -# pvcapi.py - PVC HTTP API functions +# provisioner.py - PVC Provisioner functions # Part of the Parallel Virtual Cluster (PVC) system # # Copyright (C) 2018-2019 Joshua M. Boniface @@ -165,12 +165,20 @@ def list_template_storage_disks(name): disks = data['disks'] return disks +def list_template_userdata(limit, is_fuzzy=True): + """ + Obtain a list of userdata templates. + """ + data = list_template(limit, 'userdata_template', is_fuzzy) + return data + def template_list(limit): system_templates = list_template_system(limit) network_templates = list_template_network(limit) storage_templates = list_template_storage(limit) + userdata_templates = list_template_userdata(limit) - return { "system_templates": system_templates, "network_templates": network_templates, "storage_templates": storage_templates } + return { "system_templates": system_templates, "network_templates": network_templates, "storage_templates": storage_templates, "userdata_templates": userdata_templates } # # Template Create functions @@ -304,6 +312,49 @@ def create_template_storage_element(name, pool, disk_id, disk_size_gb, filesyste close_database(conn, cur) return flask.jsonify(retmsg), retcode +def create_template_userdata(name, userdata): + if list_template_userdata(name, is_fuzzy=False): + retmsg = { "message": "The userdata template {} already exists".format(name) } + retcode = 400 + return flask.jsonify(retmsg), retcode + + conn, cur = open_database(config) + try: + query = "INSERT INTO userdata_template (name, userdata) VALUES (%s, %s);" + args = (name, userdata) + cur.execute(query, args) + retmsg = { "name": name } + retcode = 200 + except psycopg2.IntegrityError as e: + retmsg = { "message": "Failed to create entry {}".format(name), "error": e } + retcode = 400 + close_database(conn, cur) + return flask.jsonify(retmsg), retcode + +# +# Template update functions +# +def update_template_userdata(name, userdata): + if not list_template_userdata(name, is_fuzzy=False): + retmsg = { "message": "The userdata template {} does not exist".format(name) } + retcode = 400 + return flask.jsonify(retmsg), retcode + + tid = list_template_userdata(name, is_fuzzy=False)[0]['id'] + + conn, cur = open_database(config) + try: + query = "UPDATE userdata_template SET userdata = %s WHERE id = %s;" + args = (userdata, tid) + cur.execute(query, args) + retmsg = { "name": name } + retcode = 200 + except psycopg2.IntegrityError as e: + retmsg = { "message": "Failed to update entry {}".format(name), "error": e } + retcode = 400 + close_database(conn, cur) + return flask.jsonify(retmsg), retcode + # # Template Delete functions # @@ -444,6 +495,25 @@ def delete_template_storage_element(name, disk_id): close_database(conn, cur) return flask.jsonify(retmsg), retcode +def delete_template_userdata(name): + if not list_template_userdata(name, is_fuzzy=False): + retmsg = { "message": "The userdata template {} does not exist".format(name) } + retcode = 400 + return flask.jsonify(retmsg), retcode + + conn, cur = open_database(config) + try: + query = "DELETE FROM userdata_template WHERE name = %s;" + args = (name,) + cur.execute(query, args) + retmsg = { "name": name } + retcode = 200 + except psycopg2.IntegrityError as e: + retmsg = { "message": "Failed to delete entry {}".format(name), "error": e } + retcode = 400 + close_database(conn, cur) + return flask.jsonify(retmsg), retcode + # # Script functions # @@ -491,6 +561,27 @@ def create_script(name, script): close_database(conn, cur) return flask.jsonify(retmsg), retcode +def update_script(name, script): + if not list_script(name, is_fuzzy=False): + retmsg = { "message": "The script {} does not exist".format(name) } + retcode = 400 + return flask.jsonify(retmsg), retcode + + tid = list_script(name, is_fuzzy=False)[0]['id'] + + conn, cur = open_database(config) + try: + query = "UPDATE script SET script = %s WHERE id = %s;" + args = (script, tid) + cur.execute(query, args) + retmsg = { "name": name } + retcode = 200 + except psycopg2.IntegrityError as e: + retmsg = { "message": "Failed to update entry {}".format(name), "error": e } + retcode = 400 + close_database(conn, cur) + return flask.jsonify(retmsg), retcode + def delete_script(name): if not list_script(name, is_fuzzy=False): retmsg = { "message": "The script {} does not exist".format(name) } @@ -540,7 +631,7 @@ def list_profile(limit, is_fuzzy=True): profile_data = dict() profile_data['name'] = profile['name'] # Parse the name of each subelement - for etype in 'system_template', 'network_template', 'storage_template', 'script': + for etype in 'system_template', 'network_template', 'storage_template', 'userdata_template', 'script': query = 'SELECT name from {} WHERE id = %s'.format(etype) args = (profile[etype],) cur.execute(query, args) @@ -553,7 +644,7 @@ def list_profile(limit, is_fuzzy=True): close_database(conn, cur) return data -def create_profile(name, system_template, network_template, storage_template, script, arguments=[]): +def create_profile(name, system_template, network_template, storage_template, userdata_template, script, arguments=[]): if list_profile(name, is_fuzzy=False): retmsg = { "message": "The profile {} already exists".format(name) } retcode = 400 @@ -589,6 +680,16 @@ def create_profile(name, system_template, network_template, storage_template, sc retcode = 400 return flask.jsonify(retmsg), retcode + userdata_templates = list_template_userdata(None) + userdata_template_id = None + for template in userdata_templates: + if template['name'] == userdata_template: + userdata_template_id = template['id'] + if not userdata_template_id: + retmsg = { "message": "The userdata template {} for profile {} does not exist".format(userdata_template, name) } + retcode = 400 + return flask.jsonify(retmsg), retcode + scripts = list_script(None) script_id = None for scr in scripts: @@ -603,8 +704,8 @@ def create_profile(name, system_template, network_template, storage_template, sc conn, cur = open_database(config) try: - query = "INSERT INTO profile (name, system_template, network_template, storage_template, script, arguments) VALUES (%s, %s, %s, %s, %s, %s);" - args = (name, system_template_id, network_template_id, storage_template_id, script_id, arguments_formatted) + query = "INSERT INTO profile (name, system_template, network_template, storage_template, userdata_template, script, arguments) VALUES (%s, %s, %s, %s, %s, %s, %s);" + args = (name, system_template_id, network_template_id, storage_template_id, userdata_template_id, script_id, arguments_formatted) cur.execute(query, args) retmsg = { "name": name } retcode = 200 @@ -663,7 +764,7 @@ def run_os_command(command_string, background=False, environment=None, timeout=N # # Cloned VM provisioning function - executed by the Celery worker # -def clone_vm(self, vm_name, vm_profile): +def clone_vm(self, vm_name, vm_profile, source_volumes): pass # diff --git a/client-provisioner/pvc-metadata.py b/client-provisioner/pvc-metadata.py new file mode 100755 index 00000000..8da74aac --- /dev/null +++ b/client-provisioner/pvc-metadata.py @@ -0,0 +1,202 @@ +#!/usr/bin/env python3 + +# pvc-provisioner.py - PVC Provisioner API interface +# Part of the Parallel Virtual Cluster (PVC) system +# +# Copyright (C) 2018-2019 Joshua M. Boniface +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +############################################################################### + +import flask +import json +import yaml +import os +import sys +import uu +import distutils.util + +import gevent.pywsgi + +import provisioner_lib.provisioner as pvc_provisioner + +import client_lib.common as pvc_common +import client_lib.vm as pvc_vm +import client_lib.network as pvc_network + +# Parse the configuration file +try: + pvc_config_file = os.environ['PVC_CONFIG_FILE'] +except: + print('Error: The "PVC_CONFIG_FILE" environment variable must be set before starting pvc-provisioner.') + exit(1) + +print('Starting PVC Provisioner Metadata API daemon') + +# Read in the config +try: + with open(pvc_config_file, 'r') as cfgfile: + o_config = yaml.load(cfgfile) +except Exception as e: + print('Failed to parse configuration file: {}'.format(e)) + exit(1) + +try: + # Create the config object + config = { + 'debug': o_config['pvc']['debug'], + 'coordinators': o_config['pvc']['coordinators'], + 'listen_address': o_config['pvc']['provisioner']['listen_address'], + 'listen_port': int(o_config['pvc']['provisioner']['listen_port']), + 'auth_enabled': o_config['pvc']['provisioner']['authentication']['enabled'], + 'auth_secret_key': o_config['pvc']['provisioner']['authentication']['secret_key'], + 'auth_tokens': o_config['pvc']['provisioner']['authentication']['tokens'], + 'ssl_enabled': o_config['pvc']['provisioner']['ssl']['enabled'], + 'ssl_key_file': o_config['pvc']['provisioner']['ssl']['key_file'], + 'ssl_cert_file': o_config['pvc']['provisioner']['ssl']['cert_file'], + 'database_host': o_config['pvc']['provisioner']['database']['host'], + 'database_port': int(o_config['pvc']['provisioner']['database']['port']), + 'database_name': o_config['pvc']['provisioner']['database']['name'], + 'database_user': o_config['pvc']['provisioner']['database']['user'], + 'database_password': o_config['pvc']['provisioner']['database']['pass'], + 'queue_host': o_config['pvc']['provisioner']['queue']['host'], + 'queue_port': o_config['pvc']['provisioner']['queue']['port'], + 'queue_path': o_config['pvc']['provisioner']['queue']['path'], + 'storage_hosts': o_config['pvc']['cluster']['storage_hosts'], + 'storage_domain': o_config['pvc']['cluster']['storage_domain'], + 'ceph_monitor_port': o_config['pvc']['cluster']['ceph_monitor_port'], + 'ceph_storage_secret_uuid': o_config['pvc']['cluster']['ceph_storage_secret_uuid'] + } + + if not config['storage_hosts']: + config['storage_hosts'] = config['coordinators'] + + # Set the config object in the pvcapi namespace + pvc_provisioner.config = config +except Exception as e: + print('{}'.format(e)) + exit(1) + +# Get our listening address from the CLI +router_address = sys.argv[1] + +# Try to connect to the database or fail +try: + print('Verifying connectivity to database') + conn, cur = pvc_provisioner.open_database(config) + pvc_provisioner.close_database(conn, cur) +except Exception as e: + print('{}'.format(e)) + exit(1) + +api = flask.Flask(__name__) + +if config['debug']: + api.config['DEBUG'] = True + +if config['auth_enabled']: + api.config["SECRET_KEY"] = config['auth_secret_key'] + +print(api.name) + +def get_vm_details(source_address): + # Start connection to Zookeeper + zk_conn = pvc_common.startZKConnection(config['coordinators']) + _discard, networks = pvc_network.get_list(zk_conn, None) + + # Figure out which server this is via the DHCP address + host_information = dict() + networks_managed = (x for x in networks if x['type'] == 'managed') + for network in networks_managed: + network_leases = pvc_network.getNetworkDHCPLeases(zk_conn, network['vni']) + for network_lease in network_leases: + information = pvc_network.getDHCPLeaseInformation(zk_conn, network['vni'], network_lease) + try: + if information['ip4_address'] == source_address: + host_information = information + except: + pass + + # Get our real information on the host; now we can start querying about it + client_hostname = host_information['hostname'] + client_macaddr = host_information['mac_address'] + client_ipaddr = host_information['ip4_address'] + + # Find the VM with that MAC address - we can't assume that the hostname is actually right + _discard, vm_list = pvc_vm.get_list(zk_conn, None, None, None) + vm_name = None + vm_details = dict() + for vm in vm_list: + try: + for network in vm['networks']: + if network['mac'] == client_macaddr: + vm_name = vm['name'] + vm_details = vm + except: + pass + + # Stop connection to Zookeeper + pvc_common.stopZKConnection(zk_conn) + + return vm_details + +@api.route('/', methods=['GET']) +def api_root(): + return flask.jsonify({"message": "PVC Provisioner Metadata API version 1"}), 209 + +@api.route('//meta-data/', methods=['GET']) +def api_metadata_root(version): + metadata = """instance-id""" + return metadata, 200 + +@api.route('//meta-data/instance-id', methods=['GET']) +def api_metadata_instanceid(version): +# router_address = flask.request.__dict__['environ']['SERVER_NAME'] + source_address = flask.request.__dict__['environ']['REMOTE_ADDR'] + vm_details = get_vm_details(source_address) + instance_id = vm_details['uuid'] + return instance_id, 200 + +@api.route('//user-data', methods=['GET']) +def api_userdata(version): + source_address = flask.request.__dict__['environ']['REMOTE_ADDR'] + vm_details = get_vm_details(source_address) + vm_profile = vm_details['profile'] + print("Profile: {}".format(vm_profile)) + # Get profile details + profile_details = pvc_provisioner.list_profile(vm_profile, is_fuzzy=False)[0] + # Get the userdata + userdata = pvc_provisioner.list_template_userdata(profile_details['userdata_template'])[0]['userdata'] + print(userdata) + return flask.Response(userdata, mimetype='text/cloud-config') + +# +# Entrypoint +# +if __name__ == '__main__': + # Start main API + if config['debug']: + # Run in Flask standard mode + api.run('169.254.169.254', 80) + else: + # Run the PYWSGI serve + http_server = gevent.pywsgi.WSGIServer( + ('10.200.0.1', 80), + api + ) + + print('Starting PyWSGI server at {}:{}'.format('169.254.169.254', 80)) + http_server.serve_forever() + diff --git a/client-provisioner/pvc-provisioner.py b/client-provisioner/pvc-provisioner.py index 32ded21c..858fe876 100755 --- a/client-provisioner/pvc-provisioner.py +++ b/client-provisioner/pvc-provisioner.py @@ -573,7 +573,6 @@ def api_template_network_net_element(template, vni): if flask.request.method == 'DELETE': return pvcprovisioner.delete_template_network_element(template, vni) - @api.route('/api/v1/template/storage', methods=['GET', 'POST']) @authenticator def api_template_storage_root(): @@ -793,10 +792,127 @@ def api_template_storage_disk_element(template, disk_id): if flask.request.method == 'DELETE': return pvcprovisioner.delete_template_storage_element(template, disk_id) +@api.route('/api/v1/template/userdata', methods=['GET', 'POST', 'PUT']) +@authenticator +def api_template_userdata_root(): + """ + /template/userdata - Manage userdata provisioning templates for VM creation. + + GET: List all userdata templates in the provisioning system. + ?limit: Specify a limit to queries. Fuzzy by default; use ^ and $ to force exact matches. + * type: text + * optional: true + * requires: N/A + + POST: Add new userdata template. + ?name: The name of the template. + * type: text + * optional: false + * requires: N/A + ?data: The raw text of the cloud-init user-data. + * type: text (freeform) + * optional: false + * requires: N/A + + PUT: Update existing userdata template. + ?name: The name of the template. + * type: text + * optional: false + * requires: N/A + ?data: The raw text of the cloud-init user-data. + * type: text (freeform) + * optional: false + * requires: N/A + """ + if flask.request.method == 'GET': + # Get name limit + if 'limit' in flask.request.values: + limit = flask.request.values['limit'] + else: + limit = None + + return flask.jsonify(pvcprovisioner.list_template_userdata(limit)), 200 + + if flask.request.method == 'POST': + # Get name data + if 'name' in flask.request.values: + name = flask.request.values['name'] + else: + return flask.jsonify({"message": "A name must be specified."}), 400 + + # Get userdata data + if 'data' in flask.request.values: + data = flask.request.values['data'] + else: + return flask.jsonify({"message": "A userdata object must be specified."}), 400 + + return pvcprovisioner.create_template_userdata(name, data) + + if flask.request.method == 'PUT': + # Get name data + if 'name' in flask.request.values: + name = flask.request.values['name'] + else: + return flask.jsonify({"message": "A name must be specified."}), 400 + + # Get userdata data + if 'data' in flask.request.values: + data = flask.request.values['data'] + else: + return flask.jsonify({"message": "A userdata object must be specified."}), 400 + + return pvcprovisioner.update_template_userdata(name, data) + +@api.route('/api/v1/template/userdata/