diff --git a/requirements.txt b/requirements.txt index 4a4153c..a1b50dc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,6 +6,10 @@ rich # MIT # Set upper bound to match Juju 3.1.x series target juju<3.2 # Apache 2 +# Used in the launch command to launch an instance +openstacksdk==0.61.* +petname + # Used for communication with snapd socket requests # Apache 2 requests-unixsocket # Apache 2 diff --git a/sunbeam/commands/configure.py b/sunbeam/commands/configure.py index f88d7c3..ccae02a 100644 --- a/sunbeam/commands/configure.py +++ b/sunbeam/commands/configure.py @@ -222,11 +222,12 @@ def _retrieve_admin_credentials(jhelper: JujuHelper, model: str) -> dict: class UserOpenRCStep(BaseStep): """Generate openrc for created cloud user.""" - def __init__(self, auth_url: str, auth_version: str, openrc: str): + def __init__(self, auth_url: str, auth_version: str, openrc: str, clouds: str): super().__init__("Generate user openrc", "Generating openrc for cloud usage") self.auth_url = auth_url self.auth_version = auth_version self.openrc = openrc + self.clouds = clouds def is_skip(self, status: Optional["Status"] = None): """Determines if the step should be skipped or not. @@ -253,6 +254,7 @@ def run(self, status: Optional[Status]) -> Result: ) tf_output = json.loads(process.stdout) self._print_openrc(tf_output) + self._print_clouds_yaml(tf_output) return Result(ResultType.COMPLETED) except subprocess.CalledProcessError as e: LOG.exception("Error initializing Terraform") @@ -279,6 +281,28 @@ def _print_openrc(self, tf_output: dict) -> None: else: console.print(_openrc) + def _print_clouds_yaml(self, tf_output: dict) -> None: + """Print a clouds.yaml file and save to disk using provided information""" + _cloudsyaml = f""" +clouds: + sunbeam: + auth: + auth_url: {self.auth_url} + project_name: {tf_output["OS_PROJECT_NAME"]["value"]} + username: {tf_output["OS_USERNAME"]["value"]} + password: {tf_output["OS_PASSWORD"]["value"]} + user_domain_name: {tf_output["OS_USER_DOMAIN_NAME"]["value"]} + project_domain_name: {tf_output["OS_PROJECT_DOMAIN_NAME"]["value"]} + identity_api_version: {self.auth_version} + """ + if self.clouds: + message = f"Writing clouds.yaml to {self.clouds} ... " + console.status(message) + with open(self.clouds, "w") as f_clouds: + os.fchmod(f_clouds.fileno(), mode=0o640) + f_clouds.write(_cloudsyaml) + else: + console.print(_cloudsyaml) class ConfigureCloudStep(BaseStep): """Default cloud configuration for all-in-one install.""" @@ -415,8 +439,9 @@ def run(self, status: Optional[Status]) -> Result: @click.option("-a", "--accept-defaults", help="Accept all defaults.", is_flag=True) @click.option("-p", "--preseed", help="Preseed file.") @click.option("-o", "--openrc", help="Output file for cloud access details.") +@click.option("-c", "--clouds", help="Output file for clouds.yaml") def configure( - openrc: str = None, preseed: str = None, accept_defaults: bool = False + openrc: str = None, preseed: str = None, accept_defaults: bool = False, clouds: str = None ) -> None: """Configure cloud with some sane defaults.""" snap = utils.get_snap() @@ -452,6 +477,7 @@ def configure( auth_url=admin_credentials["OS_AUTH_URL"], auth_version=admin_credentials["OS_AUTH_VERSION"], openrc=openrc, + clouds=clouds ), UpdateExternalNetworkConfigStep(ext_network=ext_network_file), ] diff --git a/sunbeam/commands/launch.py b/sunbeam/commands/launch.py new file mode 100644 index 0000000..6e614b4 --- /dev/null +++ b/sunbeam/commands/launch.py @@ -0,0 +1,132 @@ +# Copyright (c) 2023 Canonical Ltd. +# +# 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 logging +import os +import subprocess + +from typing import List + +import click +import openstack +import petname + + +from rich.console import Console +from snaphelpers import Snap + +LOG = logging.getLogger(__name__) +console = Console() +snap = Snap() + + +def check_output(*args: List[str]) -> str: + """Execute a shell command, returning the output of the command. + + :param args: strings to be composed into the bash call. + + Include our env; pass in any extra keyword args. + """ + return subprocess.check_output( + args, universal_newlines=True, env=os.environ + ).strip() + + +def check(*args: List[str]) -> int: + """Execute a shell command, raising an error on failed excution. + + :param args: strings to be composed into the bash call. + + """ + return subprocess.check_call(args, env=os.environ) + + +def check_keypair(openstack_conn: openstack.connection.Connection): + """ + Check for the sunbeam keypair's existence, creating it if it doesn't. + + """ + console.print("Checking for sunbeam key in OpenStack ... ") + home = os.environ.get("SNAP_REAL_HOME") + key_path = f"{home}/sunbeam" + try: + openstack_conn.compute.get_keypair("sunbeam") + console.print("Found sunbeam key!") + except openstack.exceptions.ResourceNotFound: + console.print(f"No sunbeam key found. Creating SSH key at {key_path}/sunbeam") + id_ = openstack_conn.compute.create_keypair(name="sunbeam") + with open(key_path, "w", encoding="utf-8") as file_: + file_.write(id_.private_key) + check("chmod", "600", key_path) + return key_path + + +@click.command() +@click.option( + "-k", "--key", default="sunbeam", help="The SSH key to use for the instance" +) +def launch(key: str = "sunbeam") -> None: + """ + Launch an OpenStack instance + """ + console.print("Launching an OpenStack instance ... ") + try: + conn = openstack.connect(cloud="sunbeam") + except openstack.exceptions.SDKException: + console.print( + "Unable to connect to OpenStack.", + " Is OpenStack running?", + " Have you run the configure command?", + " Do you have a clouds.yaml file?", + ) + return + + with console.status("Checking for SSH key pair ... "): + if key == "sunbeam": + # Make sure that we have a default ssh key to hand off to the + # instance. + key_path = check_keypair(conn) + else: + # We've been passed an ssh key with an unknown path. Drop in + # some placeholder text for the message at the end of this + # routine, but don't worry about verifying it. We trust the + # caller to have created it! + key_path = "/path/to/your/key" + + with console.status("Creating the OpenStack instance ... "): + instance_name = petname.Generate() + image = conn.compute.find_image("ubuntu-jammy") + flavor = conn.compute.find_flavor("m1.tiny") + network = conn.network.find_network("demo-network") + keypair = conn.compute.find_keypair(key) + server = conn.compute.create_server( + name=instance_name, + image_id=image.id, + flavor_id=flavor.id, + networks=[{"uuid": network.id}], + key_name=keypair.name, + ) + + server = conn.compute.wait_for_server(server) + server_id = server.id + + with console.status("Allocating IP address to instance ... "): + external_network = conn.network.find_network("external-network") + ip_ = conn.network.create_ip(floating_network_id=external_network.id) + conn.compute.add_floating_ip_to_server(server_id, ip_.floating_ip_address) + + console.print( + "Access instance with", f"`ssh -i {key_path} ubuntu@{ip_.floating_ip_address}" + ) diff --git a/sunbeam/main.py b/sunbeam/main.py index 933299e..d61e200 100644 --- a/sunbeam/main.py +++ b/sunbeam/main.py @@ -22,6 +22,7 @@ from sunbeam.commands import configure as configure_cmds from sunbeam.commands import inspect as inspect_cmds from sunbeam.commands import install_script as install_script_cmds +from sunbeam.commands import launch as launch_cmds from sunbeam.commands import openrc as openrc_cmds from sunbeam.commands import reset as reset_cmds from sunbeam.commands import status as status_cmds @@ -55,6 +56,7 @@ def main(): cli.add_command(configure_cmds.configure) cli.add_command(inspect_cmds.inspect) cli.add_command(install_script_cmds.install_script) + cli.add_command(launch_cmds.launch) cli()