Automatically upload composed images to the cloud
Currently, Azure, vSphere, and OpenStack are supported. See https://github.com/weldr/lorax/pull/826 for more details about this new feature. I've called the upload library "lifted" as a reference to Seuss' The Lorax -- in the book, the Lorax lifts himself up by the seat of his pants through a hole in the smog clouds, and they start calling him the "Lifted Lorax." This adds new features to the /compose route under API v1
This commit is contained in:
parent
584a2d2076
commit
2692e8138c
2
setup.py
2
setup.py
@ -47,7 +47,7 @@ setup(name="lorax",
|
|||||||
url="http://www.github.com/weldr/lorax/",
|
url="http://www.github.com/weldr/lorax/",
|
||||||
download_url="http://www.github.com/weldr/lorax/releases/",
|
download_url="http://www.github.com/weldr/lorax/releases/",
|
||||||
license="GPLv2+",
|
license="GPLv2+",
|
||||||
packages=["pylorax", "pylorax.api", "composer", "composer.cli"],
|
packages=["pylorax", "pylorax.api", "composer", "composer.cli", "lifted"],
|
||||||
package_dir={"" : "src"},
|
package_dir={"" : "src"},
|
||||||
data_files=data_files
|
data_files=data_files
|
||||||
)
|
)
|
||||||
|
47
share/lifted/providers/azure/playbook.yaml
Normal file
47
share/lifted/providers/azure/playbook.yaml
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
- hosts: localhost
|
||||||
|
connection: local
|
||||||
|
tasks:
|
||||||
|
- name: Make sure provided credentials work and the storage account exists
|
||||||
|
azure_rm_storageaccount_facts:
|
||||||
|
subscription_id: "{{ subscription_id }}"
|
||||||
|
client_id: "{{ client_id }}"
|
||||||
|
secret: "{{ secret }}"
|
||||||
|
tenant: "{{ tenant }}"
|
||||||
|
resource_group: "{{ resource_group }}"
|
||||||
|
name: "{{ storage_account_name }}"
|
||||||
|
register: storageaccount_facts
|
||||||
|
- name: Fail if we couldn't log in or the storage account was not found
|
||||||
|
fail:
|
||||||
|
msg: "Invalid credentials or storage account not found!"
|
||||||
|
when: storageaccount_facts.ansible_facts.azure_storageaccounts | length < 1
|
||||||
|
- stat:
|
||||||
|
path: "{{ image_path }}"
|
||||||
|
register: image_stat
|
||||||
|
- set_fact:
|
||||||
|
image_id: "{{ image_name }}-{{ image_stat['stat']['checksum'] }}.vhd"
|
||||||
|
- name: Upload image to Azure
|
||||||
|
azure_rm_storageblob:
|
||||||
|
subscription_id: "{{ subscription_id }}"
|
||||||
|
client_id: "{{ client_id }}"
|
||||||
|
secret: "{{ secret }}"
|
||||||
|
tenant: "{{ tenant }}"
|
||||||
|
resource_group: "{{ resource_group }}"
|
||||||
|
storage_account_name: "{{ storage_account_name }}"
|
||||||
|
container: "{{ storage_container }}"
|
||||||
|
src: "{{ image_path }}"
|
||||||
|
blob: "{{ image_id }}"
|
||||||
|
blob_type: page
|
||||||
|
force: no
|
||||||
|
- set_fact:
|
||||||
|
host: "{{ storage_account_name }}.blob.core.windows.net"
|
||||||
|
- name: Import image
|
||||||
|
azure_rm_image:
|
||||||
|
subscription_id: "{{ subscription_id }}"
|
||||||
|
client_id: "{{ client_id }}"
|
||||||
|
secret: "{{ secret }}"
|
||||||
|
tenant: "{{ tenant }}"
|
||||||
|
resource_group: "{{ resource_group }}"
|
||||||
|
name: "{{ image_name }}"
|
||||||
|
os_type: Linux
|
||||||
|
location: "{{ location }}"
|
||||||
|
source: "https://{{ host }}/{{ storage_container }}/{{ image_id }}"
|
53
share/lifted/providers/azure/provider.toml
Normal file
53
share/lifted/providers/azure/provider.toml
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
display = "Azure"
|
||||||
|
|
||||||
|
supported_types = [
|
||||||
|
"vhd",
|
||||||
|
]
|
||||||
|
|
||||||
|
[settings-info.resource_group]
|
||||||
|
display = "Resource group"
|
||||||
|
type = "string"
|
||||||
|
placeholder = ""
|
||||||
|
regex = ''
|
||||||
|
|
||||||
|
[settings-info.storage_account_name]
|
||||||
|
display = "Storage account name"
|
||||||
|
type = "string"
|
||||||
|
placeholder = ""
|
||||||
|
regex = ''
|
||||||
|
|
||||||
|
[settings-info.storage_container]
|
||||||
|
display = "Storage container"
|
||||||
|
type = "string"
|
||||||
|
placeholder = ""
|
||||||
|
regex = ''
|
||||||
|
|
||||||
|
[settings-info.subscription_id]
|
||||||
|
display = "Subscription ID"
|
||||||
|
type = "string"
|
||||||
|
placeholder = ""
|
||||||
|
regex = ''
|
||||||
|
|
||||||
|
[settings-info.client_id]
|
||||||
|
display = "Client ID"
|
||||||
|
type = "string"
|
||||||
|
placeholder = ""
|
||||||
|
regex = ''
|
||||||
|
|
||||||
|
[settings-info.secret]
|
||||||
|
display = "Secret"
|
||||||
|
type = "string"
|
||||||
|
placeholder = ""
|
||||||
|
regex = ''
|
||||||
|
|
||||||
|
[settings-info.tenant]
|
||||||
|
display = "Tenant"
|
||||||
|
type = "string"
|
||||||
|
placeholder = ""
|
||||||
|
regex = ''
|
||||||
|
|
||||||
|
[settings-info.location]
|
||||||
|
display = "Location"
|
||||||
|
type = "string"
|
||||||
|
placeholder = ""
|
||||||
|
regex = ''
|
4
share/lifted/providers/dummy/playbook.yaml
Normal file
4
share/lifted/providers/dummy/playbook.yaml
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
- hosts: localhost
|
||||||
|
connection: local
|
||||||
|
tasks:
|
||||||
|
- pause: seconds=30
|
4
share/lifted/providers/dummy/provider.toml
Normal file
4
share/lifted/providers/dummy/provider.toml
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
display = "Dummy"
|
||||||
|
|
||||||
|
[settings-info]
|
||||||
|
# This provider has no settings.
|
20
share/lifted/providers/openstack/playbook.yaml
Normal file
20
share/lifted/providers/openstack/playbook.yaml
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
- hosts: localhost
|
||||||
|
connection: local
|
||||||
|
tasks:
|
||||||
|
- stat:
|
||||||
|
path: "{{ image_path }}"
|
||||||
|
register: image_stat
|
||||||
|
- set_fact:
|
||||||
|
image_id: "{{ image_name }}-{{ image_stat['stat']['checksum'] }}.qcow2"
|
||||||
|
- name: Upload image to OpenStack
|
||||||
|
os_image:
|
||||||
|
auth:
|
||||||
|
auth_url: "{{ auth_url }}"
|
||||||
|
username: "{{ username }}"
|
||||||
|
password: "{{ password }}"
|
||||||
|
project_name: "{{ project_name }}"
|
||||||
|
os_user_domain_name: "{{ user_domain_name }}"
|
||||||
|
os_project_domain_name: "{{ project_domain_name }}"
|
||||||
|
name: "{{ image_id }}"
|
||||||
|
filename: "{{ image_path }}"
|
||||||
|
is_public: "{{ is_public }}"
|
45
share/lifted/providers/openstack/provider.toml
Normal file
45
share/lifted/providers/openstack/provider.toml
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
display = "OpenStack"
|
||||||
|
|
||||||
|
supported_types = [
|
||||||
|
"qcow2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[settings-info.auth_url]
|
||||||
|
display = "Authentication URL"
|
||||||
|
type = "string"
|
||||||
|
placeholder = ""
|
||||||
|
regex = ''
|
||||||
|
|
||||||
|
[settings-info.username]
|
||||||
|
display = "Username"
|
||||||
|
type = "string"
|
||||||
|
placeholder = ""
|
||||||
|
regex = ''
|
||||||
|
|
||||||
|
[settings-info.password]
|
||||||
|
display = "Password"
|
||||||
|
type = "string"
|
||||||
|
placeholder = ""
|
||||||
|
regex = ''
|
||||||
|
|
||||||
|
[settings-info.project_name]
|
||||||
|
display = "Project name"
|
||||||
|
type = "string"
|
||||||
|
placeholder = ""
|
||||||
|
regex = ''
|
||||||
|
|
||||||
|
[settings-info.user_domain_name]
|
||||||
|
display = "User domain name"
|
||||||
|
type = "string"
|
||||||
|
placeholder = ""
|
||||||
|
regex = ''
|
||||||
|
|
||||||
|
[settings-info.project_domain_name]
|
||||||
|
display = "Project domain name"
|
||||||
|
type = "string"
|
||||||
|
placeholder = ""
|
||||||
|
regex = ''
|
||||||
|
|
||||||
|
[settings-info.is_public]
|
||||||
|
display = "Allow public access"
|
||||||
|
type = "boolean"
|
17
share/lifted/providers/vsphere/playbook.yaml
Normal file
17
share/lifted/providers/vsphere/playbook.yaml
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
- hosts: localhost
|
||||||
|
connection: local
|
||||||
|
tasks:
|
||||||
|
- stat:
|
||||||
|
path: "{{ image_path }}"
|
||||||
|
register: image_stat
|
||||||
|
- set_fact:
|
||||||
|
image_id: "{{ image_name }}-{{ image_stat['stat']['checksum'] }}.vmdk"
|
||||||
|
- name: Upload image to vSphere
|
||||||
|
vsphere_copy:
|
||||||
|
login: "{{ username }}"
|
||||||
|
password: "{{ password }}"
|
||||||
|
host: "{{ host }}"
|
||||||
|
datacenter: "{{ datacenter }}"
|
||||||
|
datastore: "{{ datastore }}"
|
||||||
|
src: "{{ image_path }}"
|
||||||
|
path: "{{ folder }}/{{ image_id }}"
|
42
share/lifted/providers/vsphere/provider.toml
Normal file
42
share/lifted/providers/vsphere/provider.toml
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
display = "vSphere"
|
||||||
|
|
||||||
|
supported_types = [
|
||||||
|
"vmdk",
|
||||||
|
]
|
||||||
|
|
||||||
|
[settings-info.datacenter]
|
||||||
|
display = "Datacenter"
|
||||||
|
type = "string"
|
||||||
|
placeholder = ""
|
||||||
|
regex = ''
|
||||||
|
|
||||||
|
[settings-info.datastore]
|
||||||
|
display = "Datastore"
|
||||||
|
type = "string"
|
||||||
|
placeholder = ""
|
||||||
|
regex = ''
|
||||||
|
|
||||||
|
[settings-info.host]
|
||||||
|
display = "Host"
|
||||||
|
type = "string"
|
||||||
|
placeholder = ""
|
||||||
|
regex = ''
|
||||||
|
|
||||||
|
[settings-info.folder]
|
||||||
|
display = "Folder"
|
||||||
|
type = "string"
|
||||||
|
placeholder = ""
|
||||||
|
regex = ''
|
||||||
|
|
||||||
|
[settings-info.username]
|
||||||
|
display = "Username"
|
||||||
|
type = "string"
|
||||||
|
placeholder = ""
|
||||||
|
regex = ''
|
||||||
|
|
||||||
|
[settings-info.password]
|
||||||
|
display = "Password"
|
||||||
|
type = "string"
|
||||||
|
placeholder = ""
|
||||||
|
regex = ''
|
||||||
|
|
16
src/lifted/__init__.py
Normal file
16
src/lifted/__init__.py
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
#
|
||||||
|
# Copyright (C) 2019 Red Hat, Inc.
|
||||||
|
#
|
||||||
|
# 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 2 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 <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
172
src/lifted/providers.py
Normal file
172
src/lifted/providers.py
Normal file
@ -0,0 +1,172 @@
|
|||||||
|
#
|
||||||
|
# Copyright (C) 2019 Red Hat, Inc.
|
||||||
|
#
|
||||||
|
# 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 2 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 <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
|
||||||
|
from glob import glob
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import stat
|
||||||
|
|
||||||
|
import pylorax.api.toml as toml
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_provider(ucfg, provider_name):
|
||||||
|
"""Get information about the specified provider as defined in that
|
||||||
|
provider's `provider.toml`, including the provider's display name and expected
|
||||||
|
settings.
|
||||||
|
|
||||||
|
At a minimum, each setting has a display name (that likely differs from its
|
||||||
|
snake_case name) and a type. Currently, there are two types of settings:
|
||||||
|
string and boolean. String settings can optionally have a "placeholder"
|
||||||
|
value for use on the front end and a "regex" for making sure that a value
|
||||||
|
follows an expected pattern.
|
||||||
|
|
||||||
|
:param ucfg: upload config
|
||||||
|
:type ucfg: object
|
||||||
|
:param provider_name: the name of the provider to look for
|
||||||
|
:type provider_name: str
|
||||||
|
:raises: RuntimeError when the provider couldn't be found
|
||||||
|
:returns: the provider
|
||||||
|
:rtype: dict
|
||||||
|
"""
|
||||||
|
path = os.path.join(ucfg["providers_dir"], provider_name, "provider.toml")
|
||||||
|
try:
|
||||||
|
with open(path) as provider_file:
|
||||||
|
provider = toml.load(provider_file)
|
||||||
|
except OSError as error:
|
||||||
|
raise RuntimeError(f'Couldn\'t find provider "{provider_name}"!') from error
|
||||||
|
|
||||||
|
return provider
|
||||||
|
|
||||||
|
|
||||||
|
def load_profiles(ucfg, provider_name):
|
||||||
|
"""Return all settings profiles associated with a provider
|
||||||
|
|
||||||
|
:param ucfg: upload config
|
||||||
|
:type ucfg: object
|
||||||
|
:param provider_name: name a provider to find profiles for
|
||||||
|
:type provider_name: str
|
||||||
|
:returns: a dict of settings dicts, keyed by profile name
|
||||||
|
:rtype: dict
|
||||||
|
"""
|
||||||
|
|
||||||
|
def load_path(path):
|
||||||
|
with open(path) as file:
|
||||||
|
return toml.load(file)
|
||||||
|
|
||||||
|
def get_name(path):
|
||||||
|
return os.path.splitext(os.path.basename(path))[0]
|
||||||
|
|
||||||
|
paths = glob(os.path.join(ucfg["settings_dir"], provider_name, "*"))
|
||||||
|
return {get_name(path): load_path(path) for path in paths}
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_playbook_path(ucfg, provider_name):
|
||||||
|
"""Given a provider's name, return the path to its playbook
|
||||||
|
|
||||||
|
:param ucfg: upload config
|
||||||
|
:type ucfg: object
|
||||||
|
:param provider_name: the name of the provider to find the playbook for
|
||||||
|
:type provider_name: str
|
||||||
|
:raises: RuntimeError when the provider couldn't be found
|
||||||
|
:returns: the path to the playbook
|
||||||
|
:rtype: str
|
||||||
|
"""
|
||||||
|
path = os.path.join(ucfg["providers_dir"], provider_name, "playbook.yaml")
|
||||||
|
if not os.path.isfile(path):
|
||||||
|
raise RuntimeError(f'Couldn\'t find playbook for "{provider_name}"!')
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def list_providers(ucfg):
|
||||||
|
"""List the names of the available upload providers
|
||||||
|
|
||||||
|
:param ucfg: upload config
|
||||||
|
:type ucfg: object
|
||||||
|
:returns: a list of all available provider_names
|
||||||
|
:rtype: list of str
|
||||||
|
"""
|
||||||
|
paths = glob(os.path.join(ucfg["providers_dir"], "*"))
|
||||||
|
return [os.path.basename(path) for path in paths]
|
||||||
|
|
||||||
|
|
||||||
|
def validate_settings(ucfg, provider_name, settings, image_name=None):
|
||||||
|
"""Raise a ValueError if any settings are invalid
|
||||||
|
|
||||||
|
:param ucfg: upload config
|
||||||
|
:type ucfg: object
|
||||||
|
:param provider_name: the name of the provider to validate the settings
|
||||||
|
against
|
||||||
|
:type provider_name: str
|
||||||
|
:param settings: the settings to validate
|
||||||
|
:type settings: dict
|
||||||
|
:param image_name: optionally check whether an image_name is valid
|
||||||
|
:type image_name: str
|
||||||
|
:raises: ValueError when the passed settings are invalid
|
||||||
|
:raises: RuntimeError when provider_name can't be found
|
||||||
|
"""
|
||||||
|
if image_name == "":
|
||||||
|
raise ValueError("Image name cannot be empty!")
|
||||||
|
type_map = {"string": str, "boolean": bool}
|
||||||
|
settings_info = resolve_provider(ucfg, provider_name)["settings-info"]
|
||||||
|
for key, value in settings.items():
|
||||||
|
if key not in settings_info:
|
||||||
|
raise ValueError(f'Received unexpected setting: "{key}"!')
|
||||||
|
setting_type = settings_info[key]["type"]
|
||||||
|
correct_type = type_map[setting_type]
|
||||||
|
if not isinstance(value, correct_type):
|
||||||
|
raise ValueError(
|
||||||
|
f'Expected a {correct_type} for "{key}", received a {type(value)}!'
|
||||||
|
)
|
||||||
|
if setting_type == "string" and "regex" in settings_info[key]:
|
||||||
|
if not re.match(settings_info[key]["regex"], value):
|
||||||
|
raise ValueError(f'Value "{value}" is invalid for setting "{key}"!')
|
||||||
|
|
||||||
|
|
||||||
|
def save_settings(ucfg, provider_name, profile, settings):
|
||||||
|
"""Save (and overwrite) settings for a given provider
|
||||||
|
|
||||||
|
:param ucfg: upload config
|
||||||
|
:type ucfg: object
|
||||||
|
:param provider_name: the name of the cloud provider, e.g. "azure"
|
||||||
|
:type provider_name: str
|
||||||
|
:param profile: the name of the profile to save
|
||||||
|
:type profile: str != ""
|
||||||
|
:param settings: settings to save for that provider
|
||||||
|
:type settings: dict
|
||||||
|
:raises: ValueError when passed invalid settings or an invalid profile name
|
||||||
|
"""
|
||||||
|
if not profile:
|
||||||
|
raise ValueError("Profile name cannot be empty!")
|
||||||
|
validate_settings(ucfg, provider_name, settings, image_name=None)
|
||||||
|
|
||||||
|
directory = os.path.join(ucfg["settings_dir"], provider_name)
|
||||||
|
|
||||||
|
# create the settings directory if it doesn't exist
|
||||||
|
os.makedirs(directory, exist_ok=True)
|
||||||
|
|
||||||
|
path = os.path.join(directory, f"{profile}.toml")
|
||||||
|
# touch the TOML file if it doesn't exist
|
||||||
|
if not os.path.isfile(path):
|
||||||
|
open(path, "a").close()
|
||||||
|
|
||||||
|
# make sure settings files aren't readable by others, as they will contain
|
||||||
|
# sensitive credentials
|
||||||
|
current = stat.S_IMODE(os.lstat(path).st_mode)
|
||||||
|
os.chmod(path, current & ~stat.S_IROTH)
|
||||||
|
|
||||||
|
with open(path, "w") as settings_file:
|
||||||
|
toml.dump(settings, settings_file)
|
270
src/lifted/queue.py
Normal file
270
src/lifted/queue.py
Normal file
@ -0,0 +1,270 @@
|
|||||||
|
#
|
||||||
|
# Copyright (C) 2019 Red Hat, Inc.
|
||||||
|
#
|
||||||
|
# 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 2 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 <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
|
||||||
|
from functools import partial
|
||||||
|
from glob import glob
|
||||||
|
import logging
|
||||||
|
import multiprocessing
|
||||||
|
|
||||||
|
# We use a multiprocessing Pool for uploads so that we can cancel them with a
|
||||||
|
# simple SIGINT, which should bubble down to subprocesses.
|
||||||
|
from multiprocessing import Pool
|
||||||
|
|
||||||
|
# multiprocessing.dummy is to threads as multiprocessing is to processes.
|
||||||
|
# Since daemonic processes can't have children, we use a thread to monitor the
|
||||||
|
# upload pool.
|
||||||
|
from multiprocessing.dummy import Process
|
||||||
|
|
||||||
|
from operator import attrgetter
|
||||||
|
import os
|
||||||
|
import stat
|
||||||
|
import time
|
||||||
|
|
||||||
|
import pylorax.api.toml as toml
|
||||||
|
|
||||||
|
from lifted.upload import Upload
|
||||||
|
from lifted.providers import resolve_playbook_path, validate_settings
|
||||||
|
|
||||||
|
# the maximum number of simultaneous uploads
|
||||||
|
SIMULTANEOUS_UPLOADS = 1
|
||||||
|
|
||||||
|
log = logging.getLogger("lifted")
|
||||||
|
multiprocessing.log_to_stderr().setLevel(logging.INFO)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_queue_path(ucfg):
|
||||||
|
path = ucfg["queue_dir"]
|
||||||
|
|
||||||
|
# create the upload_queue directory if it doesn't exist
|
||||||
|
os.makedirs(path, exist_ok=True)
|
||||||
|
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def _get_upload_path(ucfg, uuid, write=False):
|
||||||
|
path = os.path.join(_get_queue_path(ucfg), f"{uuid}.toml")
|
||||||
|
if write and not os.path.exists(path):
|
||||||
|
open(path, "a").close()
|
||||||
|
if os.path.exists(path):
|
||||||
|
# make sure uploads aren't readable by others, as they will contain
|
||||||
|
# sensitive credentials
|
||||||
|
current = stat.S_IMODE(os.lstat(path).st_mode)
|
||||||
|
os.chmod(path, current & ~stat.S_IROTH)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def _list_upload_uuids(ucfg):
|
||||||
|
paths = glob(os.path.join(_get_queue_path(ucfg), "*"))
|
||||||
|
return [os.path.splitext(os.path.basename(path))[0] for path in paths]
|
||||||
|
|
||||||
|
|
||||||
|
def _write_upload(ucfg, upload):
|
||||||
|
with open(_get_upload_path(ucfg, upload.uuid, write=True), "w") as upload_file:
|
||||||
|
toml.dump(upload.serializable(), upload_file)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_callback(ucfg):
|
||||||
|
return partial(_write_upload, ucfg)
|
||||||
|
|
||||||
|
|
||||||
|
def get_upload(ucfg, uuid, ignore_missing=False, ignore_corrupt=False):
|
||||||
|
"""Get an Upload object by UUID
|
||||||
|
|
||||||
|
:param ucfg: upload config
|
||||||
|
:type ucfg: object
|
||||||
|
:param uuid: UUID of the upload to get
|
||||||
|
:type uuid: str
|
||||||
|
:param ignore_missing: if True, don't raise a RuntimeError when the
|
||||||
|
specified upload is missing, instead just return None
|
||||||
|
:type ignore_missing: bool
|
||||||
|
:param ignore_corrupt: if True, don't raise a RuntimeError when the
|
||||||
|
specified upload could not be deserialized, instead just return None
|
||||||
|
:type ignore_corrupt: bool
|
||||||
|
:returns: the upload object or None
|
||||||
|
:rtype: Upload or None
|
||||||
|
:raises: RuntimeError
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with open(_get_upload_path(ucfg, uuid), "r") as upload_file:
|
||||||
|
return Upload(**toml.load(upload_file))
|
||||||
|
except FileNotFoundError as error:
|
||||||
|
if not ignore_missing:
|
||||||
|
raise RuntimeError(f"Could not find upload {uuid}!") from error
|
||||||
|
except toml.TomlError as error:
|
||||||
|
if not ignore_corrupt:
|
||||||
|
raise RuntimeError(f"Could not parse upload {uuid}!") from error
|
||||||
|
|
||||||
|
|
||||||
|
def get_uploads(ucfg, uuids):
|
||||||
|
"""Gets a list of Upload objects from a list of upload UUIDs, ignoring
|
||||||
|
missing or corrupt uploads
|
||||||
|
|
||||||
|
:param ucfg: upload config
|
||||||
|
:type ucfg: object
|
||||||
|
:param uuids: list of upload UUIDs to get
|
||||||
|
:type uuids: list of str
|
||||||
|
:returns: a list of the uploads that were successfully deserialized
|
||||||
|
:rtype: list of Upload
|
||||||
|
"""
|
||||||
|
uploads = (
|
||||||
|
get_upload(ucfg, uuid, ignore_missing=True, ignore_corrupt=True)
|
||||||
|
for uuid in uuids
|
||||||
|
)
|
||||||
|
return list(filter(None, uploads))
|
||||||
|
|
||||||
|
|
||||||
|
def get_all_uploads(ucfg):
|
||||||
|
"""Get a list of all stored Upload objects
|
||||||
|
|
||||||
|
:param ucfg: upload config
|
||||||
|
:type ucfg: object
|
||||||
|
:returns: a list of all stored upload objects
|
||||||
|
:rtype: list of Upload
|
||||||
|
"""
|
||||||
|
return get_uploads(ucfg, _list_upload_uuids(ucfg))
|
||||||
|
|
||||||
|
|
||||||
|
def create_upload(ucfg, provider_name, image_name, settings):
|
||||||
|
"""Creates a new upload
|
||||||
|
|
||||||
|
:param ucfg: upload config
|
||||||
|
:type ucfg: object
|
||||||
|
:param provider_name: the name of the cloud provider to upload to, e.g.
|
||||||
|
"azure"
|
||||||
|
:type provider_name: str
|
||||||
|
:param image_name: what to name the image in the cloud
|
||||||
|
:type image_name: str
|
||||||
|
:param settings: settings to pass to the upload, specific to the cloud
|
||||||
|
provider
|
||||||
|
:type settings: dict
|
||||||
|
:returns: the created upload object
|
||||||
|
:rtype: Upload
|
||||||
|
"""
|
||||||
|
validate_settings(ucfg, provider_name, settings, image_name)
|
||||||
|
return Upload(
|
||||||
|
provider_name=provider_name,
|
||||||
|
playbook_path=resolve_playbook_path(ucfg, provider_name),
|
||||||
|
image_name=image_name,
|
||||||
|
settings=settings,
|
||||||
|
status_callback=_write_callback(ucfg),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def ready_upload(ucfg, uuid, image_path):
|
||||||
|
"""Pass an image_path to an upload and mark it ready to execute
|
||||||
|
|
||||||
|
:param ucfg: upload config
|
||||||
|
:type ucfg: object
|
||||||
|
:param uuid: the UUID of the upload to mark ready
|
||||||
|
:type uuid: str
|
||||||
|
:param image_path: the path of the image to pass to the upload
|
||||||
|
:type image_path: str
|
||||||
|
"""
|
||||||
|
get_upload(ucfg, uuid).ready(image_path, _write_callback(ucfg))
|
||||||
|
|
||||||
|
|
||||||
|
def reset_upload(ucfg, uuid, new_image_name=None, new_settings=None):
|
||||||
|
"""Reset an upload so it can be attempted again
|
||||||
|
|
||||||
|
:param ucfg: upload config
|
||||||
|
:type ucfg: object
|
||||||
|
:param uuid: the UUID of the upload to reset
|
||||||
|
:type uuid: str
|
||||||
|
:param new_image_name: optionally update the upload's image_name
|
||||||
|
:type new_image_name: str
|
||||||
|
:param new_settings: optionally update the upload's settings
|
||||||
|
:type new_settings: dict
|
||||||
|
"""
|
||||||
|
upload = get_upload(ucfg, uuid)
|
||||||
|
validate_settings(
|
||||||
|
ucfg,
|
||||||
|
upload.provider_name,
|
||||||
|
new_settings or upload.settings,
|
||||||
|
new_image_name or upload.image_name,
|
||||||
|
)
|
||||||
|
if new_image_name:
|
||||||
|
upload.image_name = new_image_name
|
||||||
|
if new_settings:
|
||||||
|
upload.settings = new_settings
|
||||||
|
upload.reset(_write_callback(ucfg))
|
||||||
|
|
||||||
|
|
||||||
|
def cancel_upload(ucfg, uuid):
|
||||||
|
"""Cancel an upload
|
||||||
|
|
||||||
|
:param ucfg: the compose config
|
||||||
|
:type ucfg: ComposerConfig
|
||||||
|
:param uuid: the UUID of the upload to cancel
|
||||||
|
:type uuid: str
|
||||||
|
"""
|
||||||
|
get_upload(ucfg, uuid).cancel(_write_callback(ucfg))
|
||||||
|
|
||||||
|
|
||||||
|
def delete_upload(ucfg, uuid):
|
||||||
|
"""Delete an upload
|
||||||
|
|
||||||
|
:param ucfg: the compose config
|
||||||
|
:type ucfg: ComposerConfig
|
||||||
|
:param uuid: the UUID of the upload to delete
|
||||||
|
:type uuid: str
|
||||||
|
"""
|
||||||
|
upload = get_upload(ucfg, uuid)
|
||||||
|
if upload and upload.is_cancellable():
|
||||||
|
upload.cancel()
|
||||||
|
os.remove(_get_upload_path(ucfg, uuid))
|
||||||
|
|
||||||
|
|
||||||
|
def start_upload_monitor(ucfg):
|
||||||
|
"""Start a thread that manages the upload queue
|
||||||
|
|
||||||
|
:param ucfg: the compose config
|
||||||
|
:type ucfg: ComposerConfig
|
||||||
|
"""
|
||||||
|
process = Process(target=_monitor, args=(ucfg,))
|
||||||
|
process.daemon = True
|
||||||
|
process.start()
|
||||||
|
|
||||||
|
|
||||||
|
def _monitor(ucfg):
|
||||||
|
log.info("Started upload monitor.")
|
||||||
|
for upload in get_all_uploads(ucfg):
|
||||||
|
# Set abandoned uploads to FAILED
|
||||||
|
if upload.status == "RUNNING":
|
||||||
|
upload.set_status("FAILED", _write_callback(ucfg))
|
||||||
|
pool = Pool(processes=SIMULTANEOUS_UPLOADS)
|
||||||
|
pool_uuids = set()
|
||||||
|
|
||||||
|
def remover(uuid):
|
||||||
|
return lambda _: pool_uuids.remove(uuid)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
# Every second, scoop up READY uploads from the filesystem and throw
|
||||||
|
# them in the pool
|
||||||
|
all_uploads = get_all_uploads(ucfg)
|
||||||
|
for upload in sorted(all_uploads, key=attrgetter("creation_time")):
|
||||||
|
ready = upload.status == "READY"
|
||||||
|
if ready and upload.uuid not in pool_uuids:
|
||||||
|
log.info("Starting upload %s...", upload.uuid)
|
||||||
|
pool_uuids.add(upload.uuid)
|
||||||
|
callback = remover(upload.uuid)
|
||||||
|
pool.apply_async(
|
||||||
|
upload.execute,
|
||||||
|
(_write_callback(ucfg),),
|
||||||
|
callback=callback,
|
||||||
|
error_callback=callback,
|
||||||
|
)
|
||||||
|
time.sleep(1)
|
188
src/lifted/upload.py
Normal file
188
src/lifted/upload.py
Normal file
@ -0,0 +1,188 @@
|
|||||||
|
#
|
||||||
|
# Copyright (C) 2018-2019 Red Hat, Inc.
|
||||||
|
#
|
||||||
|
# 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 2 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 <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from enum import Enum
|
||||||
|
from functools import partial
|
||||||
|
import logging
|
||||||
|
from multiprocessing import current_process
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from ansible_runner.interface import run as ansible_run
|
||||||
|
|
||||||
|
log = logging.getLogger("lifted")
|
||||||
|
|
||||||
|
|
||||||
|
class Upload:
|
||||||
|
"""Represents an upload of an image to a cloud provider. Instances of this
|
||||||
|
class are serialized as TOML and stored in the upload queue directory,
|
||||||
|
which is /var/lib/lorax/upload/queue/ by default"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
uuid=None,
|
||||||
|
provider_name=None,
|
||||||
|
playbook_path=None,
|
||||||
|
image_name=None,
|
||||||
|
settings=None,
|
||||||
|
creation_time=None,
|
||||||
|
upload_log=None,
|
||||||
|
upload_pid=None,
|
||||||
|
image_path=None,
|
||||||
|
status_callback=None,
|
||||||
|
status=None,
|
||||||
|
):
|
||||||
|
self.uuid = uuid or str(uuid4())
|
||||||
|
self.provider_name = provider_name
|
||||||
|
self.playbook_path = playbook_path
|
||||||
|
self.image_name = image_name
|
||||||
|
self.settings = settings
|
||||||
|
self.creation_time = creation_time or datetime.now().timestamp()
|
||||||
|
self.upload_log = upload_log or ""
|
||||||
|
self.upload_pid = upload_pid
|
||||||
|
self.image_path = image_path
|
||||||
|
if status:
|
||||||
|
self.status = status
|
||||||
|
else:
|
||||||
|
self.set_status("WAITING", status_callback)
|
||||||
|
|
||||||
|
def _log(self, message, callback=None):
|
||||||
|
"""Logs something to the upload log with an optional callback
|
||||||
|
|
||||||
|
:param message: the object to log
|
||||||
|
:type message: object
|
||||||
|
:param callback: a function of the form callback(self)
|
||||||
|
:type callback: function
|
||||||
|
"""
|
||||||
|
log.info(str(message))
|
||||||
|
self.upload_log += f"{message}\n"
|
||||||
|
if callback:
|
||||||
|
callback(self)
|
||||||
|
|
||||||
|
def serializable(self):
|
||||||
|
"""Returns a representation of the object as a dict for serialization
|
||||||
|
|
||||||
|
:returns: the object's __dict__
|
||||||
|
:rtype: dict
|
||||||
|
"""
|
||||||
|
return self.__dict__
|
||||||
|
|
||||||
|
def summary(self):
|
||||||
|
"""Return a dict with useful information about the upload
|
||||||
|
|
||||||
|
:returns: upload information
|
||||||
|
:rtype: dict
|
||||||
|
"""
|
||||||
|
|
||||||
|
return {
|
||||||
|
"uuid": self.uuid,
|
||||||
|
"status": self.status,
|
||||||
|
"provider_name": self.provider_name,
|
||||||
|
"image_name": self.image_name,
|
||||||
|
"image_path": self.image_path,
|
||||||
|
"creation_time": self.creation_time,
|
||||||
|
"settings": self.settings,
|
||||||
|
}
|
||||||
|
|
||||||
|
def set_status(self, status, status_callback=None):
|
||||||
|
"""Sets the status of the upload with an optional callback
|
||||||
|
|
||||||
|
:param status: the new status
|
||||||
|
:type status: str
|
||||||
|
:param status_callback: a function of the form callback(self)
|
||||||
|
:type status_callback: function
|
||||||
|
"""
|
||||||
|
self.status = status
|
||||||
|
if status_callback:
|
||||||
|
status_callback(self)
|
||||||
|
|
||||||
|
def ready(self, image_path, status_callback):
|
||||||
|
"""Provide an image_path and mark the upload as ready to execute
|
||||||
|
|
||||||
|
:param image_path: path of the image to upload
|
||||||
|
:type image_path: str
|
||||||
|
:param status_callback: a function of the form callback(self)
|
||||||
|
:type status_callback: function
|
||||||
|
"""
|
||||||
|
self.image_path = image_path
|
||||||
|
if self.status == "WAITING":
|
||||||
|
self.set_status("READY", status_callback)
|
||||||
|
|
||||||
|
def reset(self, status_callback):
|
||||||
|
"""Reset the upload so it can be attempted again
|
||||||
|
|
||||||
|
:param status_callback: a function of the form callback(self)
|
||||||
|
:type status_callback: function
|
||||||
|
"""
|
||||||
|
if self.is_cancellable():
|
||||||
|
raise RuntimeError(f"Can't reset, status is {self.status}!")
|
||||||
|
if not self.image_path:
|
||||||
|
raise RuntimeError(f"Can't reset, no image supplied yet!")
|
||||||
|
# self.error = None
|
||||||
|
self._log("Resetting...")
|
||||||
|
self.set_status("READY", status_callback)
|
||||||
|
|
||||||
|
def is_cancellable(self):
|
||||||
|
"""Is the upload in a cancellable state?
|
||||||
|
|
||||||
|
:returns: whether the upload is cancellable
|
||||||
|
:rtype: bool
|
||||||
|
"""
|
||||||
|
return self.status in ("WAITING", "READY", "RUNNING")
|
||||||
|
|
||||||
|
def cancel(self, status_callback=None):
|
||||||
|
"""Cancel the upload. Sends a SIGINT to self.upload_pid.
|
||||||
|
|
||||||
|
:param status_callback: a function of the form callback(self)
|
||||||
|
:type status_callback: function
|
||||||
|
"""
|
||||||
|
if not self.is_cancellable():
|
||||||
|
raise RuntimeError(f"Can't cancel, status is already {self.status}!")
|
||||||
|
if self.upload_pid:
|
||||||
|
os.kill(self.upload_pid, signal.SIGINT)
|
||||||
|
self.set_status("CANCELLED", status_callback)
|
||||||
|
|
||||||
|
def execute(self, status_callback=None):
|
||||||
|
"""Execute the upload. Meant to be called from a dedicated process so
|
||||||
|
that the upload can be cancelled by sending a SIGINT to
|
||||||
|
self.upload_pid.
|
||||||
|
|
||||||
|
:param status_callback: a function of the form callback(self)
|
||||||
|
:type status_callback: function
|
||||||
|
"""
|
||||||
|
if self.status != "READY":
|
||||||
|
raise RuntimeError("This upload is not ready!")
|
||||||
|
self.upload_pid = current_process().pid
|
||||||
|
self.set_status("RUNNING", status_callback)
|
||||||
|
|
||||||
|
logger = lambda e: self._log(e["stdout"], status_callback)
|
||||||
|
|
||||||
|
runner = ansible_run(
|
||||||
|
playbook=self.playbook_path,
|
||||||
|
extravars={
|
||||||
|
**self.settings,
|
||||||
|
"image_name": self.image_name,
|
||||||
|
"image_path": self.image_path,
|
||||||
|
},
|
||||||
|
event_handler=logger,
|
||||||
|
)
|
||||||
|
if runner.status == "successful":
|
||||||
|
self.set_status("FINISHED", status_callback)
|
||||||
|
else:
|
||||||
|
self.set_status("FAILED", status_callback)
|
@ -54,6 +54,11 @@ def configure(conf_file="/etc/lorax/composer.conf", root_dir="/", test_config=Fa
|
|||||||
conf.add_section("users")
|
conf.add_section("users")
|
||||||
conf.set("users", "root", "1")
|
conf.set("users", "root", "1")
|
||||||
|
|
||||||
|
conf.add_section("upload")
|
||||||
|
conf.set("upload", "providers_dir", os.path.realpath(joinpaths(root_dir, "/usr/share/lorax/lifted/providers/")))
|
||||||
|
conf.set("upload", "queue_dir", os.path.realpath(joinpaths(root_dir, "/var/lib/lorax/upload/queue")))
|
||||||
|
conf.set("upload", "settings_dir", os.path.realpath(joinpaths(root_dir, "/var/lib/lorax/upload/settings")))
|
||||||
|
|
||||||
# Enable all available repo files by default
|
# Enable all available repo files by default
|
||||||
conf.add_section("repos")
|
conf.add_section("repos")
|
||||||
conf.set("repos", "use_system_repos", "1")
|
conf.set("repos", "use_system_repos", "1")
|
||||||
|
@ -45,6 +45,9 @@ BUILD_MISSING_FILE = "BuildMissingFile"
|
|||||||
# Returned from the API for all other errors from a /compose/* route.
|
# Returned from the API for all other errors from a /compose/* route.
|
||||||
COMPOSE_ERROR = "ComposeError"
|
COMPOSE_ERROR = "ComposeError"
|
||||||
|
|
||||||
|
# Returned from the API for all errors from a /upload/* route.
|
||||||
|
UPLOAD_ERROR = "UploadError" # TODO these errors should be more specific
|
||||||
|
|
||||||
# Returned from the API when invalid characters are used in a route path or in
|
# Returned from the API when invalid characters are used in a route path or in
|
||||||
# some identifier.
|
# some identifier.
|
||||||
INVALID_CHARS = "InvalidChars"
|
INVALID_CHARS = "InvalidChars"
|
||||||
|
@ -38,6 +38,8 @@ from pylorax.base import DataHolder
|
|||||||
from pylorax.creator import run_creator
|
from pylorax.creator import run_creator
|
||||||
from pylorax.sysutils import joinpaths, read_tail
|
from pylorax.sysutils import joinpaths, read_tail
|
||||||
|
|
||||||
|
from lifted.queue import create_upload, get_uploads, ready_upload, delete_upload
|
||||||
|
|
||||||
def check_queues(cfg):
|
def check_queues(cfg):
|
||||||
"""Check to make sure the new and run queue symlinks are correct
|
"""Check to make sure the new and run queue symlinks are correct
|
||||||
|
|
||||||
@ -93,7 +95,7 @@ def start_queue_monitor(cfg, uid, gid):
|
|||||||
lib_dir = cfg.get("composer", "lib_dir")
|
lib_dir = cfg.get("composer", "lib_dir")
|
||||||
share_dir = cfg.get("composer", "share_dir")
|
share_dir = cfg.get("composer", "share_dir")
|
||||||
tmp = cfg.get("composer", "tmp")
|
tmp = cfg.get("composer", "tmp")
|
||||||
monitor_cfg = DataHolder(composer_dir=lib_dir, share_dir=share_dir, uid=uid, gid=gid, tmp=tmp)
|
monitor_cfg = DataHolder(cfg=cfg, composer_dir=lib_dir, share_dir=share_dir, uid=uid, gid=gid, tmp=tmp)
|
||||||
p = mp.Process(target=monitor, args=(monitor_cfg,))
|
p = mp.Process(target=monitor, args=(monitor_cfg,))
|
||||||
p.daemon = True
|
p.daemon = True
|
||||||
p.start()
|
p.start()
|
||||||
@ -163,6 +165,11 @@ def monitor(cfg):
|
|||||||
log.info("Finished building %s, results are in %s", dst, os.path.realpath(dst))
|
log.info("Finished building %s, results are in %s", dst, os.path.realpath(dst))
|
||||||
open(joinpaths(dst, "STATUS"), "w").write("FINISHED\n")
|
open(joinpaths(dst, "STATUS"), "w").write("FINISHED\n")
|
||||||
write_timestamp(dst, TS_FINISHED)
|
write_timestamp(dst, TS_FINISHED)
|
||||||
|
|
||||||
|
upload_cfg = cfg.cfg["upload"]
|
||||||
|
for upload in get_uploads(upload_cfg, uuid_get_uploads(cfg.cfg, uuids[0])):
|
||||||
|
log.info("Readying upload %s", upload.uuid)
|
||||||
|
uuid_ready_upload(cfg.cfg, uuids[0], upload.uuid)
|
||||||
except Exception:
|
except Exception:
|
||||||
import traceback
|
import traceback
|
||||||
log.error("traceback: %s", traceback.format_exc())
|
log.error("traceback: %s", traceback.format_exc())
|
||||||
@ -298,7 +305,7 @@ def get_compose_type(results_dir):
|
|||||||
raise RuntimeError("Cannot find ks template for build %s" % os.path.basename(results_dir))
|
raise RuntimeError("Cannot find ks template for build %s" % os.path.basename(results_dir))
|
||||||
return t[0]
|
return t[0]
|
||||||
|
|
||||||
def compose_detail(results_dir):
|
def compose_detail(cfg, results_dir):
|
||||||
"""Return details about the build.
|
"""Return details about the build.
|
||||||
|
|
||||||
:param results_dir: The directory containing the metadata and results for the build
|
:param results_dir: The directory containing the metadata and results for the build
|
||||||
@ -338,6 +345,9 @@ def compose_detail(results_dir):
|
|||||||
|
|
||||||
times = timestamp_dict(results_dir)
|
times = timestamp_dict(results_dir)
|
||||||
|
|
||||||
|
upload_uuids = uuid_get_uploads(cfg, build_id)
|
||||||
|
summaries = [upload.summary() for upload in get_uploads(cfg["upload"], upload_uuids)]
|
||||||
|
|
||||||
return {"id": build_id,
|
return {"id": build_id,
|
||||||
"queue_status": status,
|
"queue_status": status,
|
||||||
"job_created": times.get(TS_CREATED),
|
"job_created": times.get(TS_CREATED),
|
||||||
@ -346,7 +356,8 @@ def compose_detail(results_dir):
|
|||||||
"compose_type": compose_type,
|
"compose_type": compose_type,
|
||||||
"blueprint": blueprint["name"],
|
"blueprint": blueprint["name"],
|
||||||
"version": blueprint["version"],
|
"version": blueprint["version"],
|
||||||
"image_size": image_size
|
"image_size": image_size,
|
||||||
|
"uploads": summaries,
|
||||||
}
|
}
|
||||||
|
|
||||||
def queue_status(cfg):
|
def queue_status(cfg):
|
||||||
@ -367,7 +378,7 @@ def queue_status(cfg):
|
|||||||
new_details = []
|
new_details = []
|
||||||
for n in new_queue:
|
for n in new_queue:
|
||||||
try:
|
try:
|
||||||
d = compose_detail(n)
|
d = compose_detail(cfg, n)
|
||||||
except IOError:
|
except IOError:
|
||||||
continue
|
continue
|
||||||
new_details.append(d)
|
new_details.append(d)
|
||||||
@ -375,7 +386,7 @@ def queue_status(cfg):
|
|||||||
run_details = []
|
run_details = []
|
||||||
for r in run_queue:
|
for r in run_queue:
|
||||||
try:
|
try:
|
||||||
d = compose_detail(r)
|
d = compose_detail(cfg, r)
|
||||||
except IOError:
|
except IOError:
|
||||||
continue
|
continue
|
||||||
run_details.append(d)
|
run_details.append(d)
|
||||||
@ -399,7 +410,7 @@ def uuid_status(cfg, uuid):
|
|||||||
"""
|
"""
|
||||||
uuid_dir = joinpaths(cfg.get("composer", "lib_dir"), "results", uuid)
|
uuid_dir = joinpaths(cfg.get("composer", "lib_dir"), "results", uuid)
|
||||||
try:
|
try:
|
||||||
return compose_detail(uuid_dir)
|
return compose_detail(cfg, uuid_dir)
|
||||||
except IOError:
|
except IOError:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@ -430,11 +441,56 @@ def build_status(cfg, status_filter=None):
|
|||||||
try:
|
try:
|
||||||
status = open(joinpaths(build, "STATUS"), "r").read().strip()
|
status = open(joinpaths(build, "STATUS"), "r").read().strip()
|
||||||
if status in status_filter:
|
if status in status_filter:
|
||||||
results.append(compose_detail(build))
|
results.append(compose_detail(cfg, build))
|
||||||
except IOError:
|
except IOError:
|
||||||
pass
|
pass
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
def _upload_list_path(cfg, uuid):
|
||||||
|
results_dir = joinpaths(cfg.get("composer", "lib_dir"), "results", uuid)
|
||||||
|
if not os.path.isdir(results_dir):
|
||||||
|
raise RuntimeError(f'"{uuid}" is not a valid build uuid!')
|
||||||
|
return joinpaths(results_dir, "UPLOADS")
|
||||||
|
|
||||||
|
def uuid_schedule_upload(cfg, uuid, provider_name, image_name, settings):
|
||||||
|
status = uuid_status(cfg, uuid)
|
||||||
|
if status is None:
|
||||||
|
raise RuntimeError(f'"{uuid}" is not a valid build uuid!')
|
||||||
|
|
||||||
|
upload = create_upload(cfg["upload"], provider_name, image_name, settings)
|
||||||
|
uuid_add_upload(cfg, uuid, upload.uuid)
|
||||||
|
return upload.uuid
|
||||||
|
|
||||||
|
def uuid_get_uploads(cfg, uuid):
|
||||||
|
try:
|
||||||
|
with open(_upload_list_path(cfg, uuid)) as uploads_file:
|
||||||
|
return frozenset(uploads_file.read().split())
|
||||||
|
except FileNotFoundError:
|
||||||
|
return frozenset()
|
||||||
|
|
||||||
|
def uuid_add_upload(cfg, uuid, upload_uuid):
|
||||||
|
if upload_uuid not in uuid_get_uploads(cfg, uuid):
|
||||||
|
with open(_upload_list_path(cfg, uuid), "a") as uploads_file:
|
||||||
|
print(upload_uuid, file=uploads_file)
|
||||||
|
status = uuid_status(cfg, uuid)
|
||||||
|
if status and status["queue_status"] == "FINISHED":
|
||||||
|
uuid_ready_upload(cfg, uuid, upload_uuid)
|
||||||
|
|
||||||
|
def uuid_remove_upload(cfg, uuid, upload_uuid):
|
||||||
|
uploads = uuid_get_uploads(cfg, uuid) - frozenset((upload_uuid,))
|
||||||
|
with open(_upload_list_path(cfg, uuid), "w") as uploads_file:
|
||||||
|
for upload in uploads:
|
||||||
|
print(upload, file=uploads_file)
|
||||||
|
|
||||||
|
def uuid_ready_upload(cfg, uuid, upload_uuid):
|
||||||
|
status = uuid_status(cfg, uuid)
|
||||||
|
if not status:
|
||||||
|
raise RuntimeError(f"{uuid} is not a valid build id!")
|
||||||
|
if status["queue_status"] != "FINISHED":
|
||||||
|
raise RuntimeError(f"Build {uuid} is not finished!")
|
||||||
|
_, image_path = uuid_image(cfg, uuid)
|
||||||
|
ready_upload(cfg["upload"], upload_uuid, image_path)
|
||||||
|
|
||||||
def uuid_cancel(cfg, uuid):
|
def uuid_cancel(cfg, uuid):
|
||||||
"""Cancel a build and delete its results
|
"""Cancel a build and delete its results
|
||||||
|
|
||||||
@ -510,6 +566,10 @@ def uuid_delete(cfg, uuid):
|
|||||||
uuid_dir = joinpaths(cfg.get("composer", "lib_dir"), "results", uuid)
|
uuid_dir = joinpaths(cfg.get("composer", "lib_dir"), "results", uuid)
|
||||||
if not uuid_dir or len(uuid_dir) < 10:
|
if not uuid_dir or len(uuid_dir) < 10:
|
||||||
raise RuntimeError("Directory length is too short: %s" % uuid_dir)
|
raise RuntimeError("Directory length is too short: %s" % uuid_dir)
|
||||||
|
|
||||||
|
for upload in get_uploads(cfg["upload"], uuid_get_uploads(cfg, uuid)):
|
||||||
|
delete_upload(cfg["upload"], upload.uuid)
|
||||||
|
|
||||||
shutil.rmtree(uuid_dir)
|
shutil.rmtree(uuid_dir)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@ -554,13 +614,16 @@ def uuid_info(cfg, uuid):
|
|||||||
raise RuntimeError("Missing deps.toml for %s" % uuid)
|
raise RuntimeError("Missing deps.toml for %s" % uuid)
|
||||||
deps_dict = toml.loads(open(deps_path, "r").read())
|
deps_dict = toml.loads(open(deps_path, "r").read())
|
||||||
|
|
||||||
details = compose_detail(uuid_dir)
|
details = compose_detail(cfg, uuid_dir)
|
||||||
|
|
||||||
commit_path = joinpaths(uuid_dir, "COMMIT")
|
commit_path = joinpaths(uuid_dir, "COMMIT")
|
||||||
if not os.path.exists(commit_path):
|
if not os.path.exists(commit_path):
|
||||||
raise RuntimeError("Missing commit hash for %s" % uuid)
|
raise RuntimeError("Missing commit hash for %s" % uuid)
|
||||||
commit_id = open(commit_path, "r").read().strip()
|
commit_id = open(commit_path, "r").read().strip()
|
||||||
|
|
||||||
|
upload_uuids = uuid_get_uploads(cfg, uuid)
|
||||||
|
summaries = [upload.summary() for upload in get_uploads(cfg["upload"], upload_uuids)]
|
||||||
|
|
||||||
return {"id": uuid,
|
return {"id": uuid,
|
||||||
"config": cfg_dict,
|
"config": cfg_dict,
|
||||||
"blueprint": frozen_dict,
|
"blueprint": frozen_dict,
|
||||||
@ -568,7 +631,8 @@ def uuid_info(cfg, uuid):
|
|||||||
"deps": deps_dict,
|
"deps": deps_dict,
|
||||||
"compose_type": details["compose_type"],
|
"compose_type": details["compose_type"],
|
||||||
"queue_status": details["queue_status"],
|
"queue_status": details["queue_status"],
|
||||||
"image_size": details["image_size"]
|
"image_size": details["image_size"],
|
||||||
|
"uploads": summaries,
|
||||||
}
|
}
|
||||||
|
|
||||||
def uuid_tar(cfg, uuid, metadata=False, image=False, logs=False):
|
def uuid_tar(cfg, uuid, metadata=False, image=False, logs=False):
|
||||||
|
@ -89,6 +89,10 @@ server.register_blueprint(v0_api, url_prefix="/api/v0/")
|
|||||||
|
|
||||||
# Register the v1 API on /api/v1/
|
# Register the v1 API on /api/v1/
|
||||||
# Use v0 routes by default
|
# Use v0 routes by default
|
||||||
server.register_blueprint(v0_api, url_prefix="/api/v1/",
|
skip_rules = [
|
||||||
skip_rules=["/projects/source/info/<source_names>", "/projects/source/new"])
|
"/compose",
|
||||||
|
"/projects/source/info/<source_names>",
|
||||||
|
"/projects/source/new",
|
||||||
|
]
|
||||||
|
server.register_blueprint(v0_api, url_prefix="/api/v1/", skip_rules=skip_rules)
|
||||||
server.register_blueprint(v1_api, url_prefix="/api/v1/")
|
server.register_blueprint(v1_api, url_prefix="/api/v1/")
|
||||||
|
@ -58,7 +58,7 @@ from flask import current_app as api
|
|||||||
from pylorax.sysutils import joinpaths
|
from pylorax.sysutils import joinpaths
|
||||||
from pylorax.api.checkparams import checkparams
|
from pylorax.api.checkparams import checkparams
|
||||||
from pylorax.api.compose import start_build, compose_types
|
from pylorax.api.compose import start_build, compose_types
|
||||||
from pylorax.api.errors import * # pylint: disable=wildcard-import
|
from pylorax.api.errors import * # pylint: disable=wildcard-import,unused-wildcard-import
|
||||||
from pylorax.api.flask_blueprint import BlueprintSkip
|
from pylorax.api.flask_blueprint import BlueprintSkip
|
||||||
from pylorax.api.projects import projects_list, projects_info, projects_depsolve
|
from pylorax.api.projects import projects_list, projects_info, projects_depsolve
|
||||||
from pylorax.api.projects import modules_list, modules_info, ProjectsError, repo_to_source
|
from pylorax.api.projects import modules_list, modules_info, ProjectsError, repo_to_source
|
||||||
|
@ -23,12 +23,20 @@ log = logging.getLogger("lorax-composer")
|
|||||||
from flask import jsonify, request
|
from flask import jsonify, request
|
||||||
from flask import current_app as api
|
from flask import current_app as api
|
||||||
|
|
||||||
|
from lifted.queue import get_upload, reset_upload, cancel_upload, delete_upload
|
||||||
|
from lifted.providers import list_providers, resolve_provider, load_profiles, validate_settings, save_settings
|
||||||
from pylorax.api.checkparams import checkparams
|
from pylorax.api.checkparams import checkparams
|
||||||
from pylorax.api.errors import INVALID_CHARS, PROJECTS_ERROR, SYSTEM_SOURCE, UNKNOWN_SOURCE
|
from pylorax.api.compose import start_build
|
||||||
|
from pylorax.api.errors import BAD_COMPOSE_TYPE, BUILD_FAILED, INVALID_CHARS, MISSING_POST, PROJECTS_ERROR
|
||||||
|
from pylorax.api.errors import SYSTEM_SOURCE, UNKNOWN_BLUEPRINT, UNKNOWN_SOURCE, UNKNOWN_UUID, UPLOAD_ERROR
|
||||||
from pylorax.api.flask_blueprint import BlueprintSkip
|
from pylorax.api.flask_blueprint import BlueprintSkip
|
||||||
from pylorax.api.projects import get_repo_sources, new_repo_source, repo_to_source
|
from pylorax.api.queue import uuid_status, uuid_schedule_upload, uuid_remove_upload
|
||||||
from pylorax.api.regexes import VALID_API_STRING
|
from pylorax.api.projects import get_repo_sources, repo_to_source
|
||||||
|
from pylorax.api.projects import new_repo_source
|
||||||
|
from pylorax.api.regexes import VALID_API_STRING, VALID_BLUEPRINT_NAME
|
||||||
import pylorax.api.toml as toml
|
import pylorax.api.toml as toml
|
||||||
|
from pylorax.api.utils import blueprint_exists
|
||||||
|
|
||||||
|
|
||||||
# Create the v1 routes Blueprint with skip_routes support
|
# Create the v1 routes Blueprint with skip_routes support
|
||||||
v1_api = BlueprintSkip("v1_routes", __name__)
|
v1_api = BlueprintSkip("v1_routes", __name__)
|
||||||
@ -166,3 +174,460 @@ def v1_projects_source_new():
|
|||||||
return jsonify(status=False, errors=[{"id": PROJECTS_ERROR, "msg": str(e)}]), 400
|
return jsonify(status=False, errors=[{"id": PROJECTS_ERROR, "msg": str(e)}]), 400
|
||||||
|
|
||||||
return jsonify(status=True)
|
return jsonify(status=True)
|
||||||
|
|
||||||
|
@v1_api.route("/compose", methods=["POST"])
|
||||||
|
def v1_compose_start():
|
||||||
|
"""Start a compose
|
||||||
|
|
||||||
|
The body of the post should have these fields:
|
||||||
|
blueprint_name - The blueprint name from /blueprints/list/
|
||||||
|
compose_type - The type of output to create, from /compose/types
|
||||||
|
branch - Optional, defaults to master, selects the git branch to use for the blueprint.
|
||||||
|
|
||||||
|
**POST /api/v0/compose**
|
||||||
|
|
||||||
|
Start a compose. The content type should be 'application/json' and the body of the POST
|
||||||
|
should look like this. The "upload" object is optional.
|
||||||
|
|
||||||
|
Example::
|
||||||
|
|
||||||
|
{
|
||||||
|
"blueprint_name": "http-server",
|
||||||
|
"compose_type": "tar",
|
||||||
|
"branch": "master",
|
||||||
|
"upload": {
|
||||||
|
"image_name": "My Image",
|
||||||
|
"provider": "azure",
|
||||||
|
"settings": {
|
||||||
|
"resource_group": "SOMEBODY",
|
||||||
|
"storage_account_name": "ONCE",
|
||||||
|
"storage_container": "TOLD",
|
||||||
|
"location": "ME",
|
||||||
|
"subscription_id": "THE",
|
||||||
|
"client_id": "WORLD",
|
||||||
|
"secret": "IS",
|
||||||
|
"tenant": "GONNA"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Pass it the name of the blueprint, the type of output (from
|
||||||
|
'/api/v0/compose/types'), and the blueprint branch to use. 'branch' is
|
||||||
|
optional and will default to master. It will create a new build and add
|
||||||
|
it to the queue. It returns the build uuid and a status if it succeeds.
|
||||||
|
If an "upload" is given, it will schedule an upload to run when the build
|
||||||
|
finishes.
|
||||||
|
|
||||||
|
Example::
|
||||||
|
|
||||||
|
{
|
||||||
|
"build_id": "e6fa6db4-9c81-4b70-870f-a697ca405cdf",
|
||||||
|
"status": true
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
# Passing ?test=1 will generate a fake FAILED compose.
|
||||||
|
# Passing ?test=2 will generate a fake FINISHED compose.
|
||||||
|
try:
|
||||||
|
test_mode = int(request.args.get("test", "0"))
|
||||||
|
except ValueError:
|
||||||
|
test_mode = 0
|
||||||
|
|
||||||
|
compose = request.get_json(cache=False)
|
||||||
|
|
||||||
|
errors = []
|
||||||
|
if not compose:
|
||||||
|
return jsonify(status=False, errors=[{"id": MISSING_POST, "msg": "Missing POST body"}]), 400
|
||||||
|
|
||||||
|
if "blueprint_name" not in compose:
|
||||||
|
errors.append({"id": UNKNOWN_BLUEPRINT, "msg": "No 'blueprint_name' in the JSON request"})
|
||||||
|
else:
|
||||||
|
blueprint_name = compose["blueprint_name"]
|
||||||
|
|
||||||
|
if "branch" not in compose or not compose["branch"]:
|
||||||
|
branch = "master"
|
||||||
|
else:
|
||||||
|
branch = compose["branch"]
|
||||||
|
|
||||||
|
if "compose_type" not in compose:
|
||||||
|
errors.append({"id": BAD_COMPOSE_TYPE, "msg": "No 'compose_type' in the JSON request"})
|
||||||
|
else:
|
||||||
|
compose_type = compose["compose_type"]
|
||||||
|
|
||||||
|
if VALID_BLUEPRINT_NAME.match(blueprint_name) is None:
|
||||||
|
errors.append({"id": INVALID_CHARS, "msg": "Invalid characters in API path"})
|
||||||
|
|
||||||
|
if not blueprint_exists(api, branch, blueprint_name):
|
||||||
|
errors.append({"id": UNKNOWN_BLUEPRINT, "msg": "Unknown blueprint name: %s" % blueprint_name})
|
||||||
|
|
||||||
|
if "upload" in compose:
|
||||||
|
try:
|
||||||
|
image_name = compose["upload"]["image_name"]
|
||||||
|
provider_name = compose["upload"]["provider"]
|
||||||
|
settings = compose["upload"]["settings"]
|
||||||
|
except KeyError as e:
|
||||||
|
errors.append({"id": UPLOAD_ERROR, "msg": f'Missing parameter {str(e)}!'})
|
||||||
|
try:
|
||||||
|
provider = resolve_provider(api.config["COMPOSER_CFG"]["upload"], provider_name)
|
||||||
|
if "supported_types" in provider and compose_type not in provider["supported_types"]:
|
||||||
|
raise RuntimeError(f'Type "{compose_type}" is not supported by provider "{provider_name}"!')
|
||||||
|
validate_settings(api.config["COMPOSER_CFG"]["upload"], provider_name, settings, image_name)
|
||||||
|
except Exception as e:
|
||||||
|
errors.append({"id": UPLOAD_ERROR, "msg": str(e)})
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
return jsonify(status=False, errors=errors), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
build_id = start_build(api.config["COMPOSER_CFG"], api.config["DNFLOCK"], api.config["GITLOCK"],
|
||||||
|
branch, blueprint_name, compose_type, test_mode)
|
||||||
|
except Exception as e:
|
||||||
|
if "Invalid compose type" in str(e):
|
||||||
|
return jsonify(status=False, errors=[{"id": BAD_COMPOSE_TYPE, "msg": str(e)}]), 400
|
||||||
|
else:
|
||||||
|
return jsonify(status=False, errors=[{"id": BUILD_FAILED, "msg": str(e)}]), 400
|
||||||
|
|
||||||
|
if "upload" in compose:
|
||||||
|
upload_uuid = uuid_schedule_upload(
|
||||||
|
api.config["COMPOSER_CFG"],
|
||||||
|
build_id,
|
||||||
|
provider_name,
|
||||||
|
image_name,
|
||||||
|
settings
|
||||||
|
)
|
||||||
|
|
||||||
|
return jsonify(status=True, build_id=build_id)
|
||||||
|
|
||||||
|
@v1_api.route("/compose/uploads/schedule", defaults={'compose_uuid': ""}, methods=["POST"])
|
||||||
|
@v1_api.route("/compose/uploads/schedule/<compose_uuid>", methods=["POST"])
|
||||||
|
@checkparams([("compose_uuid", "", "no compose UUID given")])
|
||||||
|
def v1_compose_uploads_schedule(compose_uuid):
|
||||||
|
"""Schedule an upload of a compose to a given cloud provider
|
||||||
|
|
||||||
|
**POST /api/v1/uploads/schedule/<compose_uuid>**
|
||||||
|
|
||||||
|
Example request::
|
||||||
|
|
||||||
|
{
|
||||||
|
"image_name": "My Image",
|
||||||
|
"provider": "azure",
|
||||||
|
"settings": {
|
||||||
|
"resource_group": "SOMEBODY",
|
||||||
|
"storage_account_name": "ONCE",
|
||||||
|
"storage_container": "TOLD",
|
||||||
|
"location": "ME",
|
||||||
|
"subscription_id": "THE",
|
||||||
|
"client_id": "WORLD",
|
||||||
|
"secret": "IS",
|
||||||
|
"tenant": "GONNA"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Example response::
|
||||||
|
|
||||||
|
{
|
||||||
|
"status": true,
|
||||||
|
"upload_uuid": "572eb0d0-5348-4600-9666-14526ba628bb"
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
if VALID_API_STRING.match(compose_uuid) is None:
|
||||||
|
error = {"id": INVALID_CHARS, "msg": "Invalid characters in API path"}
|
||||||
|
return jsonify(status=False, errors=[error]), 400
|
||||||
|
|
||||||
|
parsed = request.get_json(cache=False)
|
||||||
|
if not parsed:
|
||||||
|
return jsonify(status=False, errors=[{"id": MISSING_POST, "msg": "Missing POST body"}]), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
image_name = parsed["image_name"]
|
||||||
|
provider_name = parsed["provider"]
|
||||||
|
settings = parsed["settings"]
|
||||||
|
except KeyError as e:
|
||||||
|
error = {"id": UPLOAD_ERROR, "msg": f'Missing parameter {str(e)}!'}
|
||||||
|
return jsonify(status=False, errors=[error]), 400
|
||||||
|
try:
|
||||||
|
compose_type = uuid_status(api.config["COMPOSER_CFG"], compose_uuid)["compose_type"]
|
||||||
|
provider = resolve_provider(api.config["COMPOSER_CFG"]["upload"], provider_name)
|
||||||
|
if "supported_types" in provider and compose_type not in provider["supported_types"]:
|
||||||
|
raise RuntimeError(
|
||||||
|
f'Type "{compose_type}" is not supported by provider "{provider_name}"!'
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify(status=False, errors=[{"id": UPLOAD_ERROR, "msg": str(e)}]), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
upload_uuid = uuid_schedule_upload(
|
||||||
|
api.config["COMPOSER_CFG"],
|
||||||
|
compose_uuid,
|
||||||
|
provider_name,
|
||||||
|
image_name,
|
||||||
|
settings
|
||||||
|
)
|
||||||
|
except RuntimeError as e:
|
||||||
|
return jsonify(status=False, errors=[{"id": UPLOAD_ERROR, "msg": str(e)}]), 400
|
||||||
|
return jsonify(status=True, upload_uuid=upload_uuid)
|
||||||
|
|
||||||
|
@v1_api.route("/compose/uploads/delete", defaults={"compose_uuid": "", "upload_uuid": ""}, methods=["DELETE"])
|
||||||
|
@v1_api.route("/compose/uploads/delete/<compose_uuid>/<upload_uuid>", methods=["DELETE"])
|
||||||
|
@checkparams([("compose_uuid", "", "no compose UUID given"), ("upload_uuid", "", "no upload UUID given")])
|
||||||
|
def v1_compose_uploads_delete(compose_uuid, upload_uuid):
|
||||||
|
"""Delete an upload and disassociate it from its compose
|
||||||
|
|
||||||
|
**DELETE /api/v1/uploads/delete/<compose_uuid>/<upload_uuid>**
|
||||||
|
|
||||||
|
Example response::
|
||||||
|
|
||||||
|
{
|
||||||
|
"status": true,
|
||||||
|
"upload_uuid": "572eb0d0-5348-4600-9666-14526ba628bb"
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
if None in (VALID_API_STRING.match(compose_uuid), VALID_API_STRING.match(upload_uuid)):
|
||||||
|
error = {"id": INVALID_CHARS, "msg": "Invalid characters in API path"}
|
||||||
|
return jsonify(status=False, errors=[error]), 400
|
||||||
|
|
||||||
|
if not uuid_status(api.config["COMPOSER_CFG"], compose_uuid):
|
||||||
|
error = {"id": UNKNOWN_UUID, "msg": "%s is not a valid build uuid" % compose_uuid}
|
||||||
|
return jsonify(status=False, errors=[error]), 400
|
||||||
|
uuid_remove_upload(api.config["COMPOSER_CFG"], compose_uuid, upload_uuid)
|
||||||
|
try:
|
||||||
|
delete_upload(api.config["COMPOSER_CFG"]["upload"], upload_uuid)
|
||||||
|
except RuntimeError as error:
|
||||||
|
return jsonify(status=False, errors=[{"id": UPLOAD_ERROR, "msg": str(error)}])
|
||||||
|
return jsonify(status=True, upload_uuid=upload_uuid)
|
||||||
|
|
||||||
|
@v1_api.route("/upload/info", defaults={"uuid": ""})
|
||||||
|
@v1_api.route("/upload/info/<uuid>")
|
||||||
|
@checkparams([("uuid", "", "no UUID given")])
|
||||||
|
def v1_upload_info(uuid):
|
||||||
|
"""Returns information about a given upload
|
||||||
|
|
||||||
|
**GET /api/v1/upload/info/<uuid>**
|
||||||
|
|
||||||
|
Example response::
|
||||||
|
|
||||||
|
{
|
||||||
|
"status": true,
|
||||||
|
"upload": {
|
||||||
|
"creation_time": 1565620940.069004,
|
||||||
|
"image_name": "My Image",
|
||||||
|
"image_path": "/var/lib/lorax/composer/results/b6218e8f-0fa2-48ec-9394-f5c2918544c4/disk.vhd",
|
||||||
|
"provider_name": "azure",
|
||||||
|
"settings": {
|
||||||
|
"resource_group": "SOMEBODY",
|
||||||
|
"storage_account_name": "ONCE",
|
||||||
|
"storage_container": "TOLD",
|
||||||
|
"location": "ME",
|
||||||
|
"subscription_id": "THE",
|
||||||
|
"client_id": "WORLD",
|
||||||
|
"secret": "IS",
|
||||||
|
"tenant": "GONNA"
|
||||||
|
},
|
||||||
|
"status": "FAILED",
|
||||||
|
"uuid": "b637c411-9d9d-4279-b067-6c8d38e3b211"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
if VALID_API_STRING.match(uuid) is None:
|
||||||
|
return jsonify(status=False, errors=[{"id": INVALID_CHARS, "msg": "Invalid characters in API path"}]), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
upload = get_upload(api.config["COMPOSER_CFG"]["upload"], uuid).summary()
|
||||||
|
except RuntimeError as error:
|
||||||
|
return jsonify(status=False, errors=[{"id": UPLOAD_ERROR, "msg": str(error)}])
|
||||||
|
return jsonify(status=True, upload=upload)
|
||||||
|
|
||||||
|
@v1_api.route("/upload/log", defaults={"uuid": ""})
|
||||||
|
@v1_api.route("/upload/log/<uuid>")
|
||||||
|
@checkparams([("uuid", "", "no UUID given")])
|
||||||
|
def v1_upload_log(uuid):
|
||||||
|
"""Returns an upload's log
|
||||||
|
|
||||||
|
**GET /api/v1/upload/log/<uuid>**
|
||||||
|
|
||||||
|
Example response::
|
||||||
|
|
||||||
|
{
|
||||||
|
"status": true,
|
||||||
|
"log": "\n __________________\r\n< PLAY [localhost] >..."
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
if VALID_API_STRING.match(uuid) is None:
|
||||||
|
error = {"id": INVALID_CHARS, "msg": "Invalid characters in API path"}
|
||||||
|
return jsonify(status=False, errors=[error]), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
upload = get_upload(api.config["COMPOSER_CFG"]["upload"], uuid)
|
||||||
|
except RuntimeError as error:
|
||||||
|
return jsonify(status=False, errors=[{"id": UPLOAD_ERROR, "msg": str(error)}])
|
||||||
|
return jsonify(status=True, log=upload.upload_log)
|
||||||
|
|
||||||
|
@v1_api.route("/upload/reset", defaults={"uuid": ""}, methods=["POST"])
|
||||||
|
@v1_api.route("/upload/reset/<uuid>", methods=["POST"])
|
||||||
|
@checkparams([("uuid", "", "no UUID given")])
|
||||||
|
def v1_upload_reset(uuid):
|
||||||
|
"""Reset an upload so it can be attempted again
|
||||||
|
|
||||||
|
**POST /api/v1/upload/reset/<uuid>**
|
||||||
|
|
||||||
|
Optionally pass in a new image name and/or new settings.
|
||||||
|
|
||||||
|
Example request::
|
||||||
|
|
||||||
|
{
|
||||||
|
"image_name": "My renamed image",
|
||||||
|
"settings": {
|
||||||
|
"resource_group": "ROLL",
|
||||||
|
"storage_account_name": "ME",
|
||||||
|
"storage_container": "I",
|
||||||
|
"location": "AIN'T",
|
||||||
|
"subscription_id": "THE",
|
||||||
|
"client_id": "SHARPEST",
|
||||||
|
"secret": "TOOL",
|
||||||
|
"tenant": "IN"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Example response::
|
||||||
|
|
||||||
|
{
|
||||||
|
"status": true,
|
||||||
|
"uuid": "c75d5d62-9d26-42fc-a8ef-18bb14679fc7"
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
if VALID_API_STRING.match(uuid) is None:
|
||||||
|
error = {"id": INVALID_CHARS, "msg": "Invalid characters in API path"}
|
||||||
|
return jsonify(status=False, errors=[error]), 400
|
||||||
|
|
||||||
|
parsed = request.get_json(cache=False)
|
||||||
|
image_name = parsed.get("image_name") if parsed else None
|
||||||
|
settings = parsed.get("settings") if parsed else None
|
||||||
|
|
||||||
|
try:
|
||||||
|
reset_upload(api.config["COMPOSER_CFG"]["upload"], uuid, image_name, settings)
|
||||||
|
except RuntimeError as error:
|
||||||
|
return jsonify(status=False, errors=[{"id": UPLOAD_ERROR, "msg": str(error)}])
|
||||||
|
return jsonify(status=True, uuid=uuid)
|
||||||
|
|
||||||
|
@v1_api.route("/upload/cancel", defaults={"uuid": ""}, methods=["DELETE"])
|
||||||
|
@v1_api.route("/upload/cancel/<uuid>", methods=["DELETE"])
|
||||||
|
@checkparams([("uuid", "", "no UUID given")])
|
||||||
|
def v1_upload_cancel(uuid):
|
||||||
|
"""Cancel an upload that is either queued or in progress
|
||||||
|
|
||||||
|
**DELETE /api/v1/uploads/delete/<compose_uuid>/<upload_uuid>**
|
||||||
|
|
||||||
|
Example response::
|
||||||
|
|
||||||
|
{
|
||||||
|
"status": true,
|
||||||
|
"uuid": "037a3d56-b421-43e9-9935-c98350c89996"
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
if VALID_API_STRING.match(uuid) is None:
|
||||||
|
error = {"id": INVALID_CHARS, "msg": "Invalid characters in API path"}
|
||||||
|
return jsonify(status=False, errors=[error]), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
cancel_upload(api.config["COMPOSER_CFG"]["upload"], uuid)
|
||||||
|
except RuntimeError as error:
|
||||||
|
return jsonify(status=False, errors=[{"id": UPLOAD_ERROR, "msg": str(error)}])
|
||||||
|
return jsonify(status=True, uuid=uuid)
|
||||||
|
|
||||||
|
@v1_api.route("/upload/providers")
|
||||||
|
def v1_upload_providers():
|
||||||
|
"""Return the information about all upload providers, including their
|
||||||
|
display names, expected settings, and saved profiles. Refer to the
|
||||||
|
`resolve_provider` function.
|
||||||
|
|
||||||
|
**GET /api/v1/upload/providers**
|
||||||
|
|
||||||
|
Example response::
|
||||||
|
|
||||||
|
{
|
||||||
|
"providers": {
|
||||||
|
"azure": {
|
||||||
|
"display": "Azure",
|
||||||
|
"profiles": {
|
||||||
|
"default": {
|
||||||
|
"client_id": "example",
|
||||||
|
...
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings-info": {
|
||||||
|
"client_id": {
|
||||||
|
"display": "Client ID",
|
||||||
|
"placeholder": "",
|
||||||
|
"regex": "",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
...
|
||||||
|
},
|
||||||
|
"supported_types": ["vhd"]
|
||||||
|
},
|
||||||
|
...
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
ucfg = api.config["COMPOSER_CFG"]["upload"]
|
||||||
|
|
||||||
|
provider_names = list_providers(ucfg)
|
||||||
|
|
||||||
|
def get_provider_info(provider_name):
|
||||||
|
provider = resolve_provider(ucfg, provider_name)
|
||||||
|
provider["profiles"] = load_profiles(ucfg, provider_name)
|
||||||
|
return provider
|
||||||
|
|
||||||
|
providers = {provider_name: get_provider_info(provider_name)
|
||||||
|
for provider_name in provider_names}
|
||||||
|
return jsonify(status=True, providers=providers)
|
||||||
|
|
||||||
|
@v1_api.route("/upload/providers/save", methods=["POST"])
|
||||||
|
def v1_providers_save():
|
||||||
|
"""Save provider settings as a profile for later use
|
||||||
|
|
||||||
|
**POST /api/v1/upload/providers/save**
|
||||||
|
|
||||||
|
Example request::
|
||||||
|
|
||||||
|
{
|
||||||
|
"provider": "azure",
|
||||||
|
"profile": "my-profile",
|
||||||
|
"settings": {
|
||||||
|
"resource_group": "SOMEBODY",
|
||||||
|
"storage_account_name": "ONCE",
|
||||||
|
"storage_container": "TOLD",
|
||||||
|
"location": "ME",
|
||||||
|
"subscription_id": "THE",
|
||||||
|
"client_id": "WORLD",
|
||||||
|
"secret": "IS",
|
||||||
|
"tenant": "GONNA"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Saving to an existing profile will overwrite it.
|
||||||
|
|
||||||
|
Example response::
|
||||||
|
|
||||||
|
{
|
||||||
|
"status": true
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
parsed = request.get_json(cache=False)
|
||||||
|
|
||||||
|
if parsed is None:
|
||||||
|
return jsonify(status=False, errors=[{"id": MISSING_POST, "msg": "Missing POST body"}]), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
provider_name = parsed["provider"]
|
||||||
|
profile = parsed["profile"]
|
||||||
|
settings = parsed["settings"]
|
||||||
|
except KeyError as e:
|
||||||
|
error = {"id": UPLOAD_ERROR, "msg": f'Missing parameter {str(e)}!'}
|
||||||
|
return jsonify(status=False, errors=[error]), 400
|
||||||
|
try:
|
||||||
|
save_settings(api.config["COMPOSER_CFG"]["upload"], provider_name, profile, settings)
|
||||||
|
except Exception as e:
|
||||||
|
error = {"id": UPLOAD_ERROR, "msg": str(e)}
|
||||||
|
return jsonify(status=False, errors=[error])
|
||||||
|
return jsonify(status=True)
|
||||||
|
@ -23,6 +23,7 @@ program_log = logging.getLogger("program")
|
|||||||
pylorax_log = logging.getLogger("pylorax")
|
pylorax_log = logging.getLogger("pylorax")
|
||||||
server_log = logging.getLogger("server")
|
server_log = logging.getLogger("server")
|
||||||
dnf_log = logging.getLogger("dnf")
|
dnf_log = logging.getLogger("dnf")
|
||||||
|
lifted_log = logging.getLogger("lifted")
|
||||||
|
|
||||||
import grp
|
import grp
|
||||||
import os
|
import os
|
||||||
@ -43,12 +44,15 @@ from pylorax.api.queue import start_queue_monitor
|
|||||||
from pylorax.api.recipes import open_or_create_repo, commit_recipe_directory
|
from pylorax.api.recipes import open_or_create_repo, commit_recipe_directory
|
||||||
from pylorax.api.server import server, GitLock
|
from pylorax.api.server import server, GitLock
|
||||||
|
|
||||||
|
from lifted.queue import start_upload_monitor
|
||||||
|
|
||||||
VERSION = "{0}-{1}".format(os.path.basename(sys.argv[0]), vernum)
|
VERSION = "{0}-{1}".format(os.path.basename(sys.argv[0]), vernum)
|
||||||
|
|
||||||
def setup_logging(logfile):
|
def setup_logging(logfile):
|
||||||
# Setup logging to console and to logfile
|
# Setup logging to console and to logfile
|
||||||
log.setLevel(logging.DEBUG)
|
log.setLevel(logging.DEBUG)
|
||||||
pylorax_log.setLevel(logging.DEBUG)
|
pylorax_log.setLevel(logging.DEBUG)
|
||||||
|
lifted_log.setLevel(logging.DEBUG)
|
||||||
|
|
||||||
sh = logging.StreamHandler()
|
sh = logging.StreamHandler()
|
||||||
sh.setLevel(logging.INFO)
|
sh.setLevel(logging.INFO)
|
||||||
@ -56,6 +60,7 @@ def setup_logging(logfile):
|
|||||||
sh.setFormatter(fmt)
|
sh.setFormatter(fmt)
|
||||||
log.addHandler(sh)
|
log.addHandler(sh)
|
||||||
pylorax_log.addHandler(sh)
|
pylorax_log.addHandler(sh)
|
||||||
|
lifted_log.addHandler(sh)
|
||||||
|
|
||||||
fh = logging.FileHandler(filename=logfile)
|
fh = logging.FileHandler(filename=logfile)
|
||||||
fh.setLevel(logging.DEBUG)
|
fh.setLevel(logging.DEBUG)
|
||||||
@ -63,6 +68,7 @@ def setup_logging(logfile):
|
|||||||
fh.setFormatter(fmt)
|
fh.setFormatter(fmt)
|
||||||
log.addHandler(fh)
|
log.addHandler(fh)
|
||||||
pylorax_log.addHandler(fh)
|
pylorax_log.addHandler(fh)
|
||||||
|
lifted_log.addHandler(fh)
|
||||||
|
|
||||||
# External program output log
|
# External program output log
|
||||||
program_log.setLevel(logging.DEBUG)
|
program_log.setLevel(logging.DEBUG)
|
||||||
@ -244,6 +250,8 @@ if __name__ == '__main__':
|
|||||||
|
|
||||||
start_queue_monitor(server.config["COMPOSER_CFG"], uid, gid)
|
start_queue_monitor(server.config["COMPOSER_CFG"], uid, gid)
|
||||||
|
|
||||||
|
start_upload_monitor(server.config["COMPOSER_CFG"]["upload"])
|
||||||
|
|
||||||
# Change user and group on the main process. Note that this still happens even if
|
# Change user and group on the main process. Note that this still happens even if
|
||||||
# --user and --group were passed in, but changing to the same user should be fine.
|
# --user and --group were passed in, but changing to the same user should be fine.
|
||||||
os.setgid(gid)
|
os.setgid(gid)
|
||||||
|
Loading…
Reference in New Issue
Block a user