Skip to content

Commit b80432e

Browse files
committed
ref(models): split up model classes into their own files
Has the __init__ file still source in all the classes as needed to minimise changes in tests and migrations
1 parent 04e39b5 commit b80432e

16 files changed

Lines changed: 1109 additions & 1042 deletions

rootfs/api/models/__init__.py

Lines changed: 13 additions & 1036 deletions
Large diffs are not rendered by default.

rootfs/api/models/app.py

Lines changed: 420 additions & 0 deletions
Large diffs are not rendered by default.

rootfs/api/models/build.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
from django.conf import settings
2+
from django.db import models
3+
from jsonfield import JSONField
4+
5+
from api.models import UuidAuditedModel
6+
7+
8+
class Build(UuidAuditedModel):
9+
"""
10+
Instance of a software build used by runtime nodes
11+
"""
12+
13+
owner = models.ForeignKey(settings.AUTH_USER_MODEL)
14+
app = models.ForeignKey('App')
15+
image = models.CharField(max_length=256)
16+
17+
# optional fields populated by builder
18+
sha = models.CharField(max_length=40, blank=True)
19+
procfile = JSONField(default={}, blank=True)
20+
dockerfile = models.TextField(blank=True)
21+
22+
class Meta:
23+
get_latest_by = 'created'
24+
ordering = ['-created']
25+
unique_together = (('app', 'uuid'),)
26+
27+
def create(self, user, *args, **kwargs):
28+
latest_release = self.app.release_set.latest()
29+
source_version = 'latest'
30+
if self.sha:
31+
source_version = 'git-{}'.format(self.sha)
32+
new_release = latest_release.new(user,
33+
build=self,
34+
config=latest_release.config,
35+
source_version=source_version)
36+
try:
37+
self.app.deploy(user, new_release)
38+
return new_release
39+
except RuntimeError:
40+
if 'new_release' in locals():
41+
new_release.delete()
42+
raise
43+
44+
def save(self, **kwargs):
45+
try:
46+
previous_build = self.app.build_set.latest()
47+
to_destroy = []
48+
for proctype in previous_build.procfile:
49+
if proctype not in self.procfile:
50+
for c in self.app.container_set.filter(type=proctype):
51+
to_destroy.append(c)
52+
self.app._destroy_containers(to_destroy)
53+
except Build.DoesNotExist:
54+
pass
55+
return super(Build, self).save(**kwargs)
56+
57+
def __str__(self):
58+
return "{0}-{1}".format(self.app.id, str(self.uuid)[:7])

rootfs/api/models/certificate.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
from datetime import datetime
2+
3+
from django.db import models
4+
from django.conf import settings
5+
from django.core.exceptions import ValidationError, SuspiciousOperation
6+
7+
from OpenSSL import crypto
8+
from api.models import AuditedModel
9+
10+
11+
def validate_certificate(value):
12+
try:
13+
crypto.load_certificate(crypto.FILETYPE_PEM, value)
14+
except crypto.Error as e:
15+
raise ValidationError('Could not load certificate: {}'.format(e))
16+
17+
18+
class Certificate(AuditedModel):
19+
"""
20+
Public and private key pair used to secure application traffic at the router.
21+
"""
22+
owner = models.ForeignKey(settings.AUTH_USER_MODEL)
23+
# there is no upper limit on the size of an x.509 certificate
24+
certificate = models.TextField(validators=[validate_certificate])
25+
key = models.TextField()
26+
# X.509 certificates allow any string of information as the common name.
27+
common_name = models.TextField(unique=True)
28+
expires = models.DateTimeField()
29+
30+
def __str__(self):
31+
return self.common_name
32+
33+
def _get_certificate(self):
34+
try:
35+
return crypto.load_certificate(crypto.FILETYPE_PEM, self.certificate)
36+
except crypto.Error as e:
37+
raise SuspiciousOperation(e)
38+
39+
def save(self, *args, **kwargs):
40+
certificate = self._get_certificate()
41+
if not self.common_name:
42+
self.common_name = certificate.get_subject().CN
43+
44+
if not self.expires:
45+
# https://pyopenssl.readthedocs.org/en/latest/api/crypto.html#OpenSSL.crypto.X509.get_notAfter
46+
# Convert bytes to string
47+
timestamp = certificate.get_notAfter().decode(encoding='UTF-8')
48+
# convert openssl's expiry date format to Django's DateTimeField format
49+
self.expires = datetime.strptime(timestamp, '%Y%m%d%H%M%SZ')
50+
51+
return super(Certificate, self).save(*args, **kwargs)

rootfs/api/models/config.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
from django.conf import settings
2+
from django.db import models
3+
from jsonfield import JSONField
4+
5+
from api.models import UuidAuditedModel
6+
7+
8+
class Config(UuidAuditedModel):
9+
"""
10+
Set of configuration values applied as environment variables
11+
during runtime execution of the Application.
12+
"""
13+
14+
owner = models.ForeignKey(settings.AUTH_USER_MODEL)
15+
app = models.ForeignKey('App')
16+
values = JSONField(default={}, blank=True)
17+
memory = JSONField(default={}, blank=True)
18+
cpu = JSONField(default={}, blank=True)
19+
tags = JSONField(default={}, blank=True)
20+
21+
class Meta:
22+
get_latest_by = 'created'
23+
ordering = ['-created']
24+
unique_together = (('app', 'uuid'),)
25+
26+
def __str__(self):
27+
return "{}-{}".format(self.app.id, str(self.uuid)[:7])
28+
29+
def healthcheck(self):
30+
# Update healthcheck - Scheduler determines the app type
31+
path = self.values.get('HEALTHCHECK_URL', '/')
32+
timeout = int(self.values.get('HEALTHCHECK_TIMEOUT', 1))
33+
delay = int(self.values.get('HEALTHCHECK_INITIAL_DELAY', 10))
34+
port = int(self.values.get('HEALTHCHECK_PORT', 8080))
35+
36+
return {'path': path, 'timeout': timeout, 'delay': delay, 'port': port}
37+
38+
def save(self, **kwargs):
39+
"""merge the old config with the new"""
40+
try:
41+
previous_config = self.app.config_set.latest()
42+
for attr in ['cpu', 'memory', 'tags', 'values']:
43+
# Guard against migrations from older apps without fixes to
44+
# JSONField encoding.
45+
try:
46+
data = getattr(previous_config, attr).copy()
47+
except AttributeError:
48+
data = {}
49+
50+
try:
51+
new_data = getattr(self, attr).copy()
52+
except AttributeError:
53+
new_data = {}
54+
55+
data.update(new_data)
56+
# remove config keys if we provided a null value
57+
[data.pop(k) for k, v in new_data.items() if v is None]
58+
setattr(self, attr, data)
59+
except Config.DoesNotExist:
60+
pass
61+
62+
# verify the tags exist on any nodes as labels
63+
if self.tags:
64+
# Get all nodes with label selectors
65+
nodes = self._scheduler._get_nodes(labels=self.tags).json()
66+
if not nodes['items']:
67+
labels = ['{}={}'.format(key, value) for key, value in self.tags.items()]
68+
raise EnvironmentError(
69+
'These tags do not match labels on kubernetes nodes: {}'.format(
70+
', '.join(labels)
71+
)
72+
)
73+
74+
return super(Config, self).save(**kwargs)

rootfs/api/models/container.py

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import logging
2+
3+
from django.conf import settings
4+
from django.db import models
5+
6+
from api.models import UuidAuditedModel, log_event
7+
8+
logger = logging.getLogger(__name__)
9+
10+
11+
def close_db_connections(func, *args, **kwargs):
12+
"""
13+
Decorator to explicitly close db connections during threaded execution
14+
15+
Note this is necessary to work around:
16+
https://code.djangoproject.com/ticket/22420
17+
"""
18+
def _close_db_connections(*args, **kwargs):
19+
ret = None
20+
try:
21+
ret = func(*args, **kwargs)
22+
finally:
23+
from django.db import connections
24+
for conn in connections.all():
25+
conn.close()
26+
return ret
27+
return _close_db_connections
28+
29+
30+
class Container(UuidAuditedModel):
31+
"""
32+
Docker container used to securely host an application process.
33+
"""
34+
35+
owner = models.ForeignKey(settings.AUTH_USER_MODEL)
36+
app = models.ForeignKey('App')
37+
release = models.ForeignKey('Release')
38+
type = models.CharField(max_length=128, blank=False)
39+
num = models.PositiveIntegerField()
40+
41+
@property
42+
def state(self):
43+
return self._scheduler.state(self.job_id).name
44+
45+
def short_name(self):
46+
return "{}.{}.{}".format(self.app.id, self.type, self.num)
47+
short_name.short_description = 'Name'
48+
49+
def __str__(self):
50+
return self.short_name()
51+
52+
class Meta:
53+
get_latest_by = '-created'
54+
ordering = ['created']
55+
56+
@property
57+
def job_id(self):
58+
version = "v{}".format(self.release.version)
59+
return "{self.app.id}_{version}.{self.type}.{self.num}".format(**locals())
60+
61+
def _get_command(self):
62+
try:
63+
# if this is not procfile-based app, ensure they cannot break out
64+
# and run arbitrary commands on the host
65+
# FIXME: remove slugrunner's hardcoded entrypoint
66+
if self.release.build.dockerfile or not self.release.build.sha:
67+
return "bash -c '{}'".format(self.release.build.procfile[self.type])
68+
else:
69+
return 'start {}'.format(self.type)
70+
# if the key is not present or if a parent attribute is None
71+
except (KeyError, TypeError, AttributeError):
72+
# handle special case for Dockerfile deployments
73+
return '' if self.type == 'cmd' else 'start {}'.format(self.type)
74+
75+
_command = property(_get_command)
76+
77+
def clone(self, release):
78+
c = Container.objects.create(owner=self.owner,
79+
app=self.app,
80+
release=release,
81+
type=self.type,
82+
num=self.num)
83+
return c
84+
85+
@close_db_connections
86+
def create(self):
87+
image = self.release.image
88+
kwargs = {'memory': self.release.config.memory,
89+
'cpu': self.release.config.cpu,
90+
'tags': self.release.config.tags,
91+
'envs': self.release.config.values}
92+
try:
93+
self._scheduler.create(
94+
name=self.job_id,
95+
image=image,
96+
command=self._command,
97+
**kwargs
98+
)
99+
except Exception as e:
100+
err = '{} (create): {}'.format(self.job_id, e)
101+
log_event(self.app, err, logging.ERROR)
102+
raise
103+
104+
@close_db_connections
105+
def start(self):
106+
try:
107+
self._scheduler.start(self.job_id)
108+
except Exception as e:
109+
err = '{} (start): {}'.format(self.job_id, e)
110+
log_event(self.app, err, logging.WARNING)
111+
raise
112+
113+
@close_db_connections
114+
def stop(self):
115+
try:
116+
self._scheduler.stop(self.job_id)
117+
except Exception as e:
118+
err = '{} (stop): {}'.format(self.job_id, e)
119+
log_event(self.app, err, logging.ERROR)
120+
raise
121+
122+
@close_db_connections
123+
def destroy(self):
124+
try:
125+
self._scheduler.destroy(self.job_id)
126+
except Exception as e:
127+
err = '{} (destroy): {}'.format(self.job_id, e)
128+
log_event(self.app, err, logging.ERROR)
129+
raise
130+
131+
def run(self, command):
132+
"""Run a one-off command"""
133+
if self.release.build is None:
134+
raise EnvironmentError('No build associated with this release '
135+
'to run this command')
136+
image = self.release.image
137+
entrypoint = '/bin/bash'
138+
# if this is a procfile-based app, switch the entrypoint to slugrunner's default
139+
# FIXME: remove slugrunner's hardcoded entrypoint
140+
if self.release.build.procfile and \
141+
self.release.build.sha and not \
142+
self.release.build.dockerfile:
143+
entrypoint = '/runner/init'
144+
command = "'{}'".format(command)
145+
else:
146+
command = "-c '{}'".format(command)
147+
try:
148+
rc, output = self._scheduler.run(self.job_id, image, entrypoint, command)
149+
return rc, output
150+
except Exception as e:
151+
err = '{} (run): {}'.format(self.job_id, e)
152+
log_event(self.app, err, logging.ERROR)
153+
raise

0 commit comments

Comments
 (0)