mirror of
https://github.com/alvierahman90/mandrake.git
synced 2024-12-15 12:21:59 +00:00
initial commit
This commit is contained in:
commit
0880eee6ee
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
env
|
||||||
|
context_dir
|
2
.mandrakeignore
Normal file
2
.mandrakeignore
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
env
|
||||||
|
context_dir
|
31
Mandrake.toml
Normal file
31
Mandrake.toml
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
[defaults]
|
||||||
|
username = "alvie"
|
||||||
|
password = "passoword"
|
||||||
|
try-multiple = false
|
||||||
|
run-local = true
|
||||||
|
output = "out"
|
||||||
|
|
||||||
|
[[remotes]]
|
||||||
|
name = "a2"
|
||||||
|
host = "alvps2"
|
||||||
|
default = false
|
||||||
|
|
||||||
|
[remotes.ssh]
|
||||||
|
host = "alvps2"
|
||||||
|
|
||||||
|
[[remotes]]
|
||||||
|
name = "gareth"
|
||||||
|
host = "http://gareth:5000"
|
||||||
|
default = false
|
||||||
|
|
||||||
|
[remotes.ssh]
|
||||||
|
host = "gareth"
|
||||||
|
|
||||||
|
[[remotes]]
|
||||||
|
name = "local"
|
||||||
|
host = "http://localhost:5000"
|
||||||
|
default = true
|
||||||
|
job-id = "dc1f1627-7fb9-4bca-989c-535eb79bc899"
|
||||||
|
|
||||||
|
[remotes.ssh]
|
||||||
|
host = "alvie@localhost"
|
2
config-server.toml
Normal file
2
config-server.toml
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
docker-daemon-url = "unix:///var/run/docker.sock"
|
||||||
|
context-dir = "/home/alvie/Documents/projects/mandrake/context_dir"
|
8
requirements.txt
Normal file
8
requirements.txt
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
certifi==2023.11.17
|
||||||
|
charset-normalizer==3.3.2
|
||||||
|
docker==7.0.0
|
||||||
|
idna==3.6
|
||||||
|
packaging==23.2
|
||||||
|
requests==2.31.0
|
||||||
|
tomli==2.0.1
|
||||||
|
urllib3==2.1.0
|
140
src/mandrake-server.py
Executable file
140
src/mandrake-server.py
Executable file
@ -0,0 +1,140 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
|
||||||
|
import concurrent.futures
|
||||||
|
import pathlib as pl
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
import traceback
|
||||||
|
import time
|
||||||
|
|
||||||
|
|
||||||
|
import docker
|
||||||
|
import tomli
|
||||||
|
|
||||||
|
|
||||||
|
from flask import Flask, request
|
||||||
|
|
||||||
|
|
||||||
|
STATE_WAITING = "WAITING"
|
||||||
|
STATE_CONTEXT_DELIVERED = "CONTEXT_DELIVERED"
|
||||||
|
STATE_SUBMITTED_TO_POOL = "SUBMITTED_TO_POOL"
|
||||||
|
STATE_BUILDING_CONTAINER = "BUILDING_CONTAINER"
|
||||||
|
STATE_BUILD_FAILED = "BUILD_FAILED"
|
||||||
|
STATE_RUNNING = "RUNNING"
|
||||||
|
STATE_RUNNING_FAILED = "RUNNING_FAILED"
|
||||||
|
STATE_FINISED = "FINISHED"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def get_args():
|
||||||
|
""" Get command line arguments """
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument('config', type=pl.Path)
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
args = get_args()
|
||||||
|
config = tomli.loads(args.config.read_text())
|
||||||
|
|
||||||
|
def get_docker_daemon(config):
|
||||||
|
base_url = config.get('docker-daemon-url')
|
||||||
|
base_url = base_url or "unix:///var/run/docker.sock"
|
||||||
|
|
||||||
|
return docker.DockerClient(base_url=base_url)
|
||||||
|
|
||||||
|
dock = get_docker_daemon(config)
|
||||||
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
state = {}
|
||||||
|
pool = concurrent.futures.ThreadPoolExecutor(max_workers=4)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/jobs", methods = [ "POST" ])
|
||||||
|
def post_jobs():
|
||||||
|
job_id = str(uuid.uuid4())
|
||||||
|
job = {
|
||||||
|
"id": job_id,
|
||||||
|
"state": STATE_WAITING,
|
||||||
|
"context_dir": f"{config['context-dir']}/{job_id}",
|
||||||
|
}
|
||||||
|
state[job['id']] = job
|
||||||
|
|
||||||
|
return json.dumps(job)
|
||||||
|
|
||||||
|
def submit_job(job_id, params):
|
||||||
|
tag = f"mandrake-job-{job_id}"
|
||||||
|
path = f"{config['context-dir']}/{job_id}"
|
||||||
|
def func():
|
||||||
|
state[job_id]['state'] = STATE_BUILDING_CONTAINER
|
||||||
|
try:
|
||||||
|
(image, _) = dock.images.build(
|
||||||
|
path=path,
|
||||||
|
tag=tag,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
state[job_id]['state'] = STATE_BUILD_FAILED
|
||||||
|
state[job_id]['err'] = '\n'.join(traceback.format_exception(e))
|
||||||
|
return
|
||||||
|
|
||||||
|
state[job_id]['state'] = STATE_RUNNING
|
||||||
|
|
||||||
|
container = None
|
||||||
|
try:
|
||||||
|
container = dock.containers.run(
|
||||||
|
image = image.id,
|
||||||
|
volumes = {
|
||||||
|
path: {
|
||||||
|
'bind': '/context',
|
||||||
|
'mode': 'rw',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
detach=True,
|
||||||
|
**params,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
state[job_id]['state'] = STATE_RUNNING_FAILED
|
||||||
|
state[job_id]['err'] = '\n'.join(traceback.format_exception(e))
|
||||||
|
return
|
||||||
|
|
||||||
|
while True:
|
||||||
|
time.sleep(1)
|
||||||
|
container = dock.containers.get(container.id)
|
||||||
|
print(f"{container.status=}")
|
||||||
|
print( container.status not in [ 'created', 'running' ])
|
||||||
|
if container.status not in [ 'created', 'running' ]:
|
||||||
|
break
|
||||||
|
|
||||||
|
state[job_id]['state'] = STATE_FINISED
|
||||||
|
output = container.attach(stdout=True, stderr=True, logs=True)
|
||||||
|
state[job_id]['output'] = output.decode("utf-8")
|
||||||
|
|
||||||
|
return func
|
||||||
|
|
||||||
|
@app.route("/jobs/<job_id>", methods = [ "PATCH" ])
|
||||||
|
def patch_job(job_id):
|
||||||
|
new_state = request.get_json()['state']
|
||||||
|
params = request.get_json()['params']
|
||||||
|
|
||||||
|
state[job_id]['state'] = new_state
|
||||||
|
|
||||||
|
if new_state == STATE_CONTEXT_DELIVERED:
|
||||||
|
state[job_id]['state'] = STATE_SUBMITTED_TO_POOL
|
||||||
|
pool.submit(submit_job(job_id, params))
|
||||||
|
|
||||||
|
return json.dumps(state[job_id])
|
||||||
|
|
||||||
|
@app.route("/jobs")
|
||||||
|
def get_jobs():
|
||||||
|
return json.dumps(state)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/jobs/<job_id>")
|
||||||
|
def get_job(job_id):
|
||||||
|
return json.dumps(state[job_id])
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app.run(debug=True)
|
157
src/mandrake.py
Executable file
157
src/mandrake.py
Executable file
@ -0,0 +1,157 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import requests
|
||||||
|
import time
|
||||||
|
|
||||||
|
import tomli
|
||||||
|
import tomli_w
|
||||||
|
import sysrsync as rsync
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
STATE_WAITING = "WAITING"
|
||||||
|
STATE_CONTEXT_DELIVERED = "CONTEXT_DELIVERED"
|
||||||
|
STATE_SUBMITTED_TO_POOL = "SUBMITTED_TO_POOL"
|
||||||
|
STATE_BUILDING_CONTAINER = "BUILDING_CONTAINER"
|
||||||
|
STATE_BUILD_FAILED = "BUILD_FAILED"
|
||||||
|
STATE_RUNNING = "RUNNING"
|
||||||
|
STATE_RUNNING_FAILED = "RUNNING_FAILED"
|
||||||
|
STATE_FINISED = "FINISHED"
|
||||||
|
|
||||||
|
|
||||||
|
def get_args():
|
||||||
|
""" Get command line arguments """
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument('-m', '--mandrakefile', type=Path, default=Path('Mandrake.toml'))
|
||||||
|
parser.add_argument('-i', '--ignorefile', type=Path, default=Path('.mandrakeignore'))
|
||||||
|
parser.add_argument('-r', '--remote', type=str, default=None)
|
||||||
|
parser.add_argument('-f', '--force', action='store_true')
|
||||||
|
parser.add_argument('-c', '--create', action='store_true')
|
||||||
|
parser.add_argument('command', nargs="+")
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def get_remote(config, args):
|
||||||
|
if len(config['remotes']) < 1:
|
||||||
|
raise ValueError(f"No remotes defined in {args.mandrakefile}")
|
||||||
|
|
||||||
|
if args.remote is not None:
|
||||||
|
for remote in config['remotes']:
|
||||||
|
if remote['name'] == args.remote:
|
||||||
|
return remote
|
||||||
|
|
||||||
|
raise ValueError(f"Remote '{args.remote}' not found in config")
|
||||||
|
|
||||||
|
for remote in config['remotes']:
|
||||||
|
if remote['default']:
|
||||||
|
return remote
|
||||||
|
|
||||||
|
return config['remotes'][0]
|
||||||
|
|
||||||
|
|
||||||
|
def create_job(remote):
|
||||||
|
print(f"{remote['host']}/jobs")
|
||||||
|
return requests.post(f"{remote['host']}/jobs").json()
|
||||||
|
|
||||||
|
def send_context(remote, job, ignorefile):
|
||||||
|
# TODO ignore files in .dockerignore and .gitignore
|
||||||
|
options = ['--recursive', '--update', '-v']
|
||||||
|
if ignorefile.exists():
|
||||||
|
options.append(f"--exclude-from={ignorefile}")
|
||||||
|
|
||||||
|
rsync.run(
|
||||||
|
source=".",
|
||||||
|
destination=job['context_dir'],
|
||||||
|
destination_ssh=remote['ssh']['host'],
|
||||||
|
options=options,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def sync_output_folder(remote, job, output_folder):
|
||||||
|
options = ['--recursive', '--update', '-v']
|
||||||
|
source_folder = f"{job['context_dir']}/{output_folder}"
|
||||||
|
destination_folder = output_folder
|
||||||
|
|
||||||
|
rsync.run(
|
||||||
|
source = source_folder,
|
||||||
|
source_ssh = remote['ssh']['host'],
|
||||||
|
destination = destination_folder,
|
||||||
|
options = options,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_job_status(remote, job_id):
|
||||||
|
return requests.get(f"{remote['host']}/jobs/{job_id}").json()
|
||||||
|
|
||||||
|
|
||||||
|
def send_command(remote, job, command):
|
||||||
|
requests.patch(f"{remote['host']}/jobs/{job['id']}", json = {
|
||||||
|
'state': STATE_CONTEXT_DELIVERED,
|
||||||
|
'params': {
|
||||||
|
'command': command
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def main(args):
|
||||||
|
""" Entry point for script """
|
||||||
|
print("Reading config...")
|
||||||
|
config = tomli.loads(args.mandrakefile.read_text())
|
||||||
|
print(f"{config =}")
|
||||||
|
remote = get_remote(config, args)
|
||||||
|
print(f"{remote =}")
|
||||||
|
print("Detecting job or creating job...")
|
||||||
|
job_id = remote.get('job-id')
|
||||||
|
if job_id is None or args.create:
|
||||||
|
print("Job not present in config for remote. Creating...")
|
||||||
|
job_id = create_job(remote)['id']
|
||||||
|
print(f"Saving job_id to {args.mandrakefile}")
|
||||||
|
remote['job-id'] = job_id
|
||||||
|
args.mandrakefile.write_text(tomli_w.dumps(config))
|
||||||
|
|
||||||
|
print("Retreiving job status...")
|
||||||
|
job = get_job_status(remote, job_id)
|
||||||
|
oldstate = None
|
||||||
|
print(f"{job =}")
|
||||||
|
while job['state'] not in [ STATE_BUILD_FAILED, STATE_RUNNING_FAILED, STATE_FINISED, STATE_WAITING ]:
|
||||||
|
if args.force:
|
||||||
|
break
|
||||||
|
|
||||||
|
job = get_job_status(remote, job_id)
|
||||||
|
if job != oldstate:
|
||||||
|
print(f"Job already in progress (pass -f to force): {job =}")
|
||||||
|
oldstate = job
|
||||||
|
|
||||||
|
print("Sending context via rsync...")
|
||||||
|
send_context(remote, job, args.ignorefile)
|
||||||
|
print(f"Executing command on remote server: {args.command}")
|
||||||
|
send_command(remote, job, args.command)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
job = get_job_status(remote, job_id)
|
||||||
|
if job != oldstate:
|
||||||
|
print(f"{job=}")
|
||||||
|
oldstate = job
|
||||||
|
|
||||||
|
if job['state'] in [ STATE_BUILD_FAILED, STATE_RUNNING_FAILED, STATE_FINISED ]:
|
||||||
|
if 'err' in job.keys():
|
||||||
|
print(job['err'])
|
||||||
|
return 1
|
||||||
|
|
||||||
|
if job['state'] == STATE_FINISED:
|
||||||
|
sync_output_folder(remote, job, config['defaults']['output'])
|
||||||
|
print(f"{job=}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
try:
|
||||||
|
sys.exit(main(get_args()))
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
sys.exit(0)
|
Loading…
Reference in New Issue
Block a user