Created
April 28, 2017 14:39
-
-
Save 9seconds/c6ff6127204e17e689756451d21f7715 to your computer and use it in GitHub Desktop.
Script which builds rannts website
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env python3 | |
# -*- coding: utf-8 -*- | |
# vim: set ft=python: | |
import argparse | |
import contextlib | |
import json | |
import logging | |
import os | |
import os.path | |
import random | |
import shutil | |
import string | |
import subprocess | |
import sys | |
import tempfile | |
import time | |
import fasteners | |
import requests | |
WORK_DIR = "/var/lib/ranntsbuilder" | |
RANDOM_NAME_LENGTH = 4 | |
VSCALE_API_RATE_LIMIT = 2 # wait for 2 seconds between each request | |
VM_MEMORY_REQUIRED = 1024 | |
VM_WAIT_TMO = 300 | |
VSCALE_DEFAULT_USER = "root" | |
BUILD_TIMEOUT = 20 * 60 # 20 minutes | |
class VscaleClient: # NOQA | |
BASE_URL = "https://api.vscale.io/v1" | |
TIMEOUT = 5 | |
def __init__(self, token): | |
self.session = requests.Session() | |
self.session.headers.update( | |
{ | |
"Content-Type": "application/json", | |
"X-Token": token | |
} | |
) | |
self.last_request_at = None | |
def get(self, endpoint): | |
return self.send(self.session.get, endpoint) | |
def post(self, endpoint, data): | |
return self.send(self.session.post, endpoint, json=data) | |
def put(self, endpoint, data): | |
return self.send(self.session.put, endpoint, json=data) | |
def delete(self, endpoint): | |
return self.send(self.session.delete, endpoint) | |
def send(self, method, endpoint, *args, **kwargs): | |
endpoint = self.BASE_URL + endpoint | |
kwargs.setdefault("timeout", self.TIMEOUT) | |
if self.last_request_at and self.last_request_at + \ | |
VSCALE_API_RATE_LIMIT > time.monotonic(): | |
time.sleep(VSCALE_API_RATE_LIMIT) | |
self.last_request_at = time.monotonic() | |
response = method(endpoint, *args, **kwargs) | |
response.raise_for_status() | |
return response.json() | |
def __enter__(self): | |
return self | |
def __exit__(self, *exc_info): | |
self.session.close() | |
def main(): | |
logging.basicConfig(level=logging.DEBUG) | |
options = get_options() | |
config = json.load(options.config) | |
options.config.close() | |
lock = fasteners.InterProcessLock(options.config.name) | |
got_lock = lock.acquire(blocking=False, timeout=60) | |
if not got_lock: | |
logging.info("Lock is still acquired, exit") | |
return | |
try: | |
local_commit, remote_commit = get_commits(options, config) | |
if local_commit == remote_commit: | |
logging.info( | |
"Latest deployed commit matches latest remove commit, stop.") | |
return | |
logging.info("Current deployed commit %s, remote is %s", | |
local_commit, remote_commit) | |
with updated_commit(options.latest_commit_path.name, remote_commit): | |
with tempdir() as tmpdir: | |
with deployed_vm(options, config) as vm_ipaddress: | |
run_build(tmpdir, remote_commit, vm_ipaddress) | |
deploy(tmpdir, config) | |
finally: | |
lock.release() | |
def get_commits(options, config): | |
local_commit = options.latest_commit_path.read().strip() | |
if options.commit: | |
remote_commit = options.commit | |
else: | |
remote_commit = get_remote_commit( | |
options.repo or config["repo_url"], | |
options.branch or config["branch"] | |
) | |
return local_commit, remote_commit | |
def get_remote_commit(repo_url, branch): | |
command = [ | |
"git", "ls-remote", | |
"--heads", | |
"--quiet", | |
"--exit-code", | |
repo_url, branch | |
] | |
output = subprocess.check_output(command) | |
output = output.decode("utf-8") | |
first_line = output.split("\n")[0] | |
first_line = first_line.strip() | |
commit_sha = first_line.split()[0] | |
return commit_sha | |
@contextlib.contextmanager | |
def tempdir(): | |
directory = tempfile.mkdtemp(prefix="ranntsbuilder") | |
logging.info("Use %r as temporary directory", directory) | |
try: | |
yield directory | |
finally: | |
shutil.rmtree(directory, ignore_errors=True) | |
@contextlib.contextmanager | |
def updated_commit(filepath, commit_to_update): | |
try: | |
yield | |
except Exception: | |
logging.exception("Exception has happened. Do not update local commit") | |
else: | |
with open(filepath, "wt") as fp: | |
fp.write(commit_to_update) | |
@contextlib.contextmanager | |
def deployed_vm(options, config): | |
with VscaleClient(config["token"]) as vscale: | |
with uploaded_ssh_key(options, vscale) as ssh_keyfile_id: | |
with created_vm(vscale, config, ssh_keyfile_id) as vm_id: | |
vm_ipaddress = wait_until_vm_ready(vscale, vm_id) | |
logging.info("VM ip address is %s", vm_ipaddress) | |
yield vm_ipaddress | |
@contextlib.contextmanager | |
def uploaded_ssh_key(options, vscale): | |
payload = { | |
"key": options.public_key.read().strip(), | |
"name": make_random_name() | |
} | |
response = vscale.post("/sshkeys", payload) | |
ssh_keyfile_id = response["id"] | |
logging.info("Uploaded ssh key %s with id %s", | |
payload["name"], ssh_keyfile_id) | |
try: | |
yield ssh_keyfile_id | |
finally: | |
vscale.delete("/sshkeys/{0}".format(ssh_keyfile_id)) | |
logging.info("Removed ssh key %s with id %s", | |
payload["name"], ssh_keyfile_id) | |
@contextlib.contextmanager | |
def created_vm(vscale, config, ssh_keyfile_id): | |
plans = vscale.get("/rplans") | |
choosen_plan = get_choosen_plan(plans, config["template"]) | |
payload = { | |
"make_from": config["template"], | |
"location": random.choice(choosen_plan["locations"]), | |
"do_start": True, | |
"keys": [ssh_keyfile_id], | |
"name": make_random_name(), | |
"rplan": choosen_plan["id"] | |
} | |
response = vscale.post("/scalets", payload) | |
try: | |
yield response["ctid"] | |
finally: | |
# vscale is not stable for deleting volumes so it is better to | |
# give it a rest before deleting | |
time.sleep(10) | |
vscale.delete("/scalets/{0}".format(response["ctid"])) | |
def get_choosen_plan(plans, template): | |
if not plans: | |
raise ValueError("Plan list is empty") | |
return min( | |
(plan for plan in plans | |
if plan["memory"] >= VM_MEMORY_REQUIRED | |
and template in plan["templates"]), | |
key=lambda plan: plan["memory"] | |
) | |
def wait_until_vm_ready(vscale, vm_id): | |
start_time = time.monotonic() | |
iteration = 0 | |
vm_status = {} | |
while (start_time + VM_WAIT_TMO) >= time.monotonic(): | |
iteration += 1 | |
time.sleep(random.uniform(0, iteration)) | |
vm_status = vscale.get("/scalets/{0}".format(vm_id)) | |
if vm_status["active"] and vm_status["status"] == "started": | |
for addr_class in "private_address", "public_address": | |
if "address" in vm_status[addr_class]: | |
return vm_status[addr_class]["address"] | |
logging.info("Continue to wait for VM address") | |
raise RuntimeError( | |
"Timeout while waiting for vm status. Last status is {0}".format( | |
vm_status)) | |
def run_build(tmpdir, remote_commit, vm_ipaddress): | |
env = os.environ.copy() | |
env["ANSIBLE_HOST_KEY_CHECKING"] = "False" | |
env["ANSIBLE_NOCOLOR"] = "True" | |
extra_vars = { | |
"commit_hash": remote_commit, | |
"copy_back": tmpdir | |
} | |
command = [ | |
"ansible-playbook", | |
"--flush-cache", | |
"--private-key", os.path.join(WORK_DIR, "sshkeyfile"), | |
"-vv", | |
"-c", "ssh", | |
"--user", VSCALE_DEFAULT_USER, | |
"--inventory", "{0},".format(vm_ipaddress), | |
"--extra-vars", json.dumps( | |
extra_vars, separators=(",", ":"), indent=None), | |
os.path.join(WORK_DIR, "playbook.yaml") | |
] | |
logging.info("Execute: %s", command) | |
return subprocess.run( | |
command, | |
timeout=BUILD_TIMEOUT, | |
env=env, | |
check=True, | |
stdin=subprocess.DEVNULL | |
) | |
def deploy(tmpdir, config): | |
command = [ | |
"rsync", "-aq", "--delete-delay", "{0}/".format(tmpdir), | |
"{0}/".format(config["siteroot"]) | |
] | |
return subprocess.run(command, check=True, stdin=subprocess.DEVNULL) | |
def make_random_name(): | |
return "rb-" + "".join( | |
random.choice(string.ascii_letters) for _ in range(RANDOM_NAME_LENGTH)) | |
def get_options(): | |
parser = argparse.ArgumentParser(description="Builder for rannts website") | |
parser.add_argument( | |
"-c", "--config", | |
default=os.path.join(WORK_DIR, "config.json"), | |
type=argparse.FileType("rt"), | |
help="Path to the config file." | |
) | |
parser.add_argument( | |
"-l", "--latest-commit-path", | |
default=os.path.join(WORK_DIR, "latest_commit"), | |
type=argparse.FileType("rt"), | |
help="Path to the latest commit" | |
) | |
parser.add_argument( | |
"-p", "--public-key", | |
default=os.path.join(WORK_DIR, "sshkeyfile.pub"), | |
type=argparse.FileType("rt"), | |
help="Path to ssh public keyfile" | |
) | |
parser.add_argument( | |
"-k", "--private-key", | |
default=os.path.join(WORK_DIR, "sshkeyfile"), | |
type=argparse.FileType("rt"), | |
help="Path to ssh private keyfile" | |
) | |
parser.add_argument( | |
"-r", "--repo", | |
default=None, | |
help="URL of the repository to fetch. Default is in config." | |
) | |
parser.add_argument( | |
"-s", "--commit", | |
default=None, | |
help="Use this commit, not latest from the top" | |
) | |
parser.add_argument( | |
"-b", "--branch", | |
default=None, | |
help="Branch to use. Default is in config." | |
) | |
return parser.parse_args() | |
if __name__ == "__main__": | |
sys.exit(main()) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment