Skip to content

Commit 8a41fa6

Browse files
author
Duncan Blythe
committed
Initial design pattern and features.
1 parent 234ebe6 commit 8a41fa6

File tree

7 files changed

+292
-0
lines changed

7 files changed

+292
-0
lines changed

Diff for: .gitignore

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
.idea
2+
.jd
3+
/checkpoints

Diff for: jobdeploy/__init__.py

Whitespace-only changes.

Diff for: jobdeploy/__main__.py

+85
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import click
2+
import json
3+
import os
4+
5+
from controller import build_meta
6+
from resources import load_all_resources, load_resource
7+
8+
9+
@click.group()
10+
def cli():
11+
...
12+
13+
14+
class KeyValuePairs(click.ParamType):
15+
"""Convert to key value pairs"""
16+
name = "key-value-pairs"
17+
18+
def convert(self, value, param, ctx):
19+
"""
20+
Convert to key value pairs
21+
22+
:param value: value
23+
:param param: parameter
24+
:param ctx: context
25+
"""
26+
if not value.strip():
27+
return {}
28+
try:
29+
my_dict = dict([x.split('=') for x in value.split(',')])
30+
for k, val in my_dict.items():
31+
if val.isnumeric():
32+
my_dict[k] = eval(val)
33+
elif val in {'true', 'True', 'false', 'False'}:
34+
my_dict[k] = val.lower == 'true'
35+
elif '+' in val:
36+
val = val.split('+')
37+
val = [x for x in val if x]
38+
val = [eval(x) if x.isnumeric() else x for x in val]
39+
my_dict[k] = val
40+
return my_dict
41+
except TypeError:
42+
self.fail(
43+
"expected string for key-value-pairs() conversion, got "
44+
f"{value!r} of type {type(value).__name__}",
45+
param,
46+
ctx,
47+
)
48+
except ValueError:
49+
self.fail(f"{value!r} is not a valid key-value-pair", param, ctx)
50+
51+
52+
@cli.command()
53+
def ls():
54+
print(json.dumps(load_all_resources(), indent=2))
55+
56+
57+
@cli.command()
58+
@click.argument('id')
59+
@click.option('--purge/--no-purge', default=False, help='purge resource')
60+
def rm(id, purge):
61+
r = load_resource(id)
62+
if 'stopped' not in r:
63+
build_meta(r['template'], 'down', id=id)
64+
if purge:
65+
build_meta(r['template'], 'purge', id=id)
66+
67+
os.system(f'rm -rf .jd/{r["params"]["subdir"]}')
68+
69+
70+
@cli.command(help='build template')
71+
@click.argument('template')
72+
@click.argument('method')
73+
@click.option('--kwargs', default=None, help='key-value pairs to add to build',
74+
type=KeyValuePairs())
75+
def build(template, method, kwargs):
76+
print(kwargs)
77+
if kwargs is None:
78+
kwargs = {}
79+
if template.endswith('.yaml'):
80+
template = template.split('.yaml')[0]
81+
build_meta(template, method, **kwargs)
82+
83+
84+
if __name__ == '__main__':
85+
cli()

Diff for: jobdeploy/controller.py

+60
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import datetime
2+
import json
3+
import os
4+
import random
5+
6+
from templates import call_template, load_template
7+
8+
9+
def random_id():
10+
""" Random ID identifier."""
11+
letters = list('ABDEFGHIJKLMNOPQRSTUVWXZYZ0123456789')
12+
id_ = [random.choice(letters) for _ in range(8)]
13+
return ''.join(id_)
14+
15+
16+
def build_meta(path, method, **params):
17+
"""
18+
Call template at "path" with parameters.
19+
20+
:param path: Template .yaml path.
21+
:param method: Name of build to run.
22+
"""
23+
if method != 'up':
24+
assert set(params.keys()) == {'id'}
25+
else:
26+
assert 'id' not in params
27+
params['id'] = random_id()
28+
29+
prefix = path.replace('/', '-')
30+
subdir = prefix + '-' + params['id']
31+
32+
if method != 'up':
33+
with open(f'.jd/{subdir}/meta.json') as f:
34+
meta = json.load(f)
35+
params.update(meta['params'])
36+
37+
else:
38+
params['subdir'] = subdir
39+
os.system(f'mkdir -p .jd/{params["subdir"]}/tasks')
40+
try:
41+
with open('.jd/project.json') as f:
42+
params.update(json.load(f))
43+
except FileNotFoundError:
44+
pass
45+
46+
template, binds = load_template(path)
47+
if method == 'up':
48+
params.update(binds)
49+
50+
call_template(template, method, **params)
51+
52+
if method == 'up':
53+
meta = {'params': params, 'created': str(datetime.datetime.now()), 'template': path}
54+
with open(f'.jd/{params["subdir"]}/meta.json', 'w') as f:
55+
json.dump(meta, f)
56+
57+
if method == 'down':
58+
meta['stopped'] = str(datetime.datetime.now())
59+
with open(f'.jd/{params["subdir"]}/meta.json', 'w') as f:
60+
json.dump(meta, f)

Diff for: jobdeploy/resources.py

+27
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import json
2+
import os
3+
4+
5+
def load_all_resources():
6+
"""
7+
Load all of the meta data of all deployments.
8+
"""
9+
all_ = []
10+
subdirs = [x for x in os.listdir('.jd') if x != 'project.json']
11+
for subdir in subdirs:
12+
with open(f'.jd/{subdir}/meta.json') as f:
13+
meta = json.load(f)
14+
all_.append(meta)
15+
all_ = sorted(all_, key=lambda x: x['created'])
16+
return all_
17+
18+
19+
def load_resource(id_):
20+
"""
21+
Load meta data of id.
22+
23+
:param id_: ID identified of deployment.
24+
"""
25+
subdir = [x for x in os.listdir('.jd') if x.endswith(id_)][0]
26+
with open(f'.jd/{subdir}/meta.json') as f:
27+
return json.load(f)

Diff for: jobdeploy/templates.py

+114
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import json
2+
3+
from jinja2 import Template, StrictUndefined
4+
import os
5+
import yaml
6+
7+
8+
def load_template(path):
9+
"""
10+
Load deployment template.
11+
12+
:params path: Load template from path. If template has "parent" field, then combine with parent
13+
template. Parameters named in binds are set in the parent by default.
14+
"""
15+
with open(path + '.yaml') as f:
16+
template = yaml.safe_load(f.read())
17+
if not 'parent' in template:
18+
return template
19+
with open(template['parent']) as f:
20+
parent = yaml.safe_load(f.read())
21+
binds = template.get('binds', {})
22+
parent['params'].extend([x for x in template.get('params', []) if x not in parent['params']])
23+
for k in template.get('builds', {}):
24+
parent['builds'][k] = template['builds'][k]
25+
return parent, binds
26+
27+
28+
def create_value(value, other_values, params):
29+
"""
30+
Format a value using template parameters.
31+
32+
:param value: Value to be formatted using Jinja2.
33+
:param other_values: Other pre-built values to be used in the value with {{ values[...] }}.
34+
:param params: Dictionary of parameters, referred to by {{ params[...] }}.
35+
"""
36+
if isinstance(value, str):
37+
return Template(value, undefined=StrictUndefined).render(params=params,
38+
values=other_values)
39+
elif isinstance(value, list):
40+
return [create_value(x, other_values, params) for x in value]
41+
42+
elif isinstance(value, dict):
43+
return {k: create_value(value[k], other_values, params) for k in value}
44+
45+
else:
46+
raise NotImplementedError('only strings, and recursively lists and dicts supported')
47+
48+
49+
def create_values(values, **params):
50+
"""
51+
Create values. Go through dictionary of values based on parameters dictionary.
52+
53+
:param values: Dictionary of values with Jinja2 variables in strings.
54+
"""
55+
out = {}
56+
for k in values:
57+
out[k] = create_value(values[k], out, params)
58+
return out
59+
60+
61+
def call_template(template, method, **params):
62+
"""
63+
Call template with parameters.
64+
65+
:param template: Loaded template dictionary.
66+
:param method: Name of build to run.
67+
"""
68+
69+
assert set(params.keys()) == set(template['params']), \
70+
(f'missing keys: {set(template["params"]) - set(params.keys())}; '
71+
f'unexpected keys: {set(params.keys()) - set(template["params"])}')
72+
73+
def build_method(method):
74+
cf = template['builds'][method]
75+
if method == 'up' and 'values' in template:
76+
values = create_values(template, **params)
77+
with open(params['subdir'] + '/values.json', 'w') as f:
78+
json.dump(values, f)
79+
else:
80+
try:
81+
with open(params['subdir'] + '/values.json') as f:
82+
values = json.load(f)
83+
except FileNotFoundError:
84+
values = {}
85+
if cf['type'] != 'sequence':
86+
print(f'building "{method}"')
87+
88+
content = Template(cf['content'], undefined=StrictUndefined).render(
89+
params=params, values=values,
90+
)
91+
lines = content.split('\n')
92+
len_ = max([len(x) for x in lines])
93+
print(f'content:\n ' + len_ * '-')
94+
print('\n'.join([' ' + x for x in lines]))
95+
print(f' ' + len_ * '-')
96+
if cf['type'] == 'file':
97+
path = f'.jd/{params["subdir"]}/tasks/{method}'
98+
with open(path, 'w') as f:
99+
f.write(content)
100+
if cf['type'] == 'script':
101+
path = f'.jd/{params["subdir"]}/tasks/{method}'
102+
with open(path, 'w') as f:
103+
f.write(content)
104+
os.system(f'chmod +x {path}')
105+
exit_code = os.system(f'./{path}')
106+
if exit_code and exit_code not in cf.get('whitelist', []):
107+
raise Exception(f'script exited with non-zero exit code: {exit_code}.')
108+
return
109+
110+
for m in cf['content']:
111+
build_method(m)
112+
113+
build_method(method)
114+

Diff for: requirements.txt

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
click
2+
pyyaml
3+
jinja2

0 commit comments

Comments
 (0)