Skip to content

Instantly share code, notes, and snippets.

@9seconds
Created April 28, 2017 14:39
Show Gist options
  • Save 9seconds/c6ff6127204e17e689756451d21f7715 to your computer and use it in GitHub Desktop.
Save 9seconds/c6ff6127204e17e689756451d21f7715 to your computer and use it in GitHub Desktop.
Script which builds rannts website
#!/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