Skip to content

Commit 0b9bd3b

Browse files
author
Matthew Fisher
committed
fix(controller): deploy only when Build is present
In terms of 12 factor's best practices, a build and configuration is a release. Currently, we create and deploy a new release with any change to an application published a release. This is because we create an initial build on app creation. With this change, no build is created on app creation, and the first release is published to the scheduler once the release has a build. Other notable changes: - the release's image field has been removed in favour of an app's ID + the release's version - lots of the view logic has been refactored into the models - build and config are mandatory in Release.new Making Build and Config mandatory in Release.new had to happen as Builds can be nil, which caused bugs when trying to roll back to a previous release with a nil Build. Release.rollback would call Release.new with an older build's release info, which sometimes had a nil Build. Because of how Release.new worked, Release.new would use it's own current Build in the new release if the supplied build was nil, causing unintentional behaviour.
1 parent 7f4ff24 commit 0b9bd3b

9 files changed

Lines changed: 340 additions & 111 deletions

File tree

controller/api/models.py

Lines changed: 75 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -134,10 +134,9 @@ def log(self, message):
134134
f.write(msg.encode('utf-8'))
135135

136136
def create(self, *args, **kwargs):
137-
"""Create a new application with an initial release"""
137+
"""Create a new application with an initial config and release"""
138138
config = Config.objects.create(owner=self.owner, app=self)
139-
build = Build.objects.create(owner=self.owner, app=self, image=settings.DEFAULT_BUILD)
140-
Release.objects.create(version=1, owner=self.owner, app=self, config=config, build=build)
139+
Release.objects.create(version=1, owner=self.owner, app=self, config=config, build=None)
141140

142141
def delete(self, *args, **kwargs):
143142
"""Delete this application including all containers"""
@@ -154,6 +153,8 @@ def _clean_app_logs(self):
154153

155154
def scale(self, user, structure): # noqa
156155
"""Scale containers up or down to match requested structure."""
156+
if self.release_set.latest().build is None:
157+
raise EnvironmentError('No build associated with this release')
157158
requested_structure = structure.copy()
158159
release = self.release_set.latest()
159160
# test for available process types
@@ -310,12 +311,12 @@ def logs(self):
310311

311312
def run(self, user, command):
312313
"""Run a one-off command in an ephemeral app container."""
313-
314314
# FIXME: remove the need for SSH private keys by using
315315
# a scheduler that supports one-off admin tasks natively
316316
if not settings.SSH_PRIVATE_KEY:
317317
raise EnvironmentError('Support for admin commands is not configured')
318-
318+
if self.release_set.latest().build is None:
319+
raise EnvironmentError('No build associated with this release to run this command')
319320
# TODO: add support for interactive shell
320321
msg = "{} runs '{}'".format(user.username, command)
321322
log_event(self, msg)
@@ -327,7 +328,7 @@ def run(self, user, command):
327328
release=self.release_set.latest(),
328329
type='run',
329330
num=c_num)
330-
image = c.release.image + ':v' + str(c.release.version)
331+
image = c.release.image
331332

332333
# check for backwards compatibility
333334
def _has_hostname(image):
@@ -374,7 +375,7 @@ class Container(UuidAuditedModel):
374375
protected=True, propagate=False)
375376

376377
def short_name(self):
377-
return "{}.{}.{}".format(self.release.app.id, self.type, self.num)
378+
return "{}.{}.{}".format(self.app.id, self.type, self.num)
378379
short_name.short_description = 'Name'
379380

380381
def __str__(self):
@@ -418,7 +419,7 @@ def clone(self, release):
418419

419420
@transition(field=state, source=INITIALIZED, target=CREATED, on_error=ERROR)
420421
def create(self):
421-
image = self.release.image + ':v' + str(self.release.version)
422+
image = self.release.image
422423
kwargs = {'memory': self.release.config.memory,
423424
'cpu': self.release.config.cpu,
424425
'tags': self.release.config.tags}
@@ -466,7 +467,10 @@ def destroy(self):
466467

467468
def run(self, command):
468469
"""Run a one-off command"""
469-
image = self.release.image + ':v' + str(self.release.version)
470+
if self.release.build is None:
471+
raise EnvironmentError('No build associated with this release '
472+
'to run this command')
473+
image = self.release.image
470474
job_id = self._job_id
471475
entrypoint = '/bin/bash'
472476
if self.release.build.procfile:
@@ -528,6 +532,23 @@ class Meta:
528532
ordering = ['-created']
529533
unique_together = (('app', 'uuid'),)
530534

535+
def create(self, user, *args, **kwargs):
536+
latest_release = self.app.release_set.latest()
537+
source_version = 'latest'
538+
if self.sha:
539+
source_version = 'git-{}'.format(self.sha)
540+
new_release = latest_release.new(user,
541+
build=self,
542+
config=latest_release.config,
543+
source_version=source_version)
544+
initial = True if self.app.structure == {} else False
545+
try:
546+
self.app.deploy(user, new_release, initial=initial)
547+
return new_release
548+
except RuntimeError:
549+
new_release.delete()
550+
raise
551+
531552
def __str__(self):
532553
return "{0}-{1}".format(self.app.id, self.uuid[:7])
533554

@@ -569,9 +590,7 @@ class Release(UuidAuditedModel):
569590
summary = models.TextField(blank=True, null=True)
570591

571592
config = models.ForeignKey('Config')
572-
build = models.ForeignKey('Build')
573-
# NOTE: image contains combined build + config, ready to run
574-
image = models.CharField(max_length=256, default=settings.DEFAULT_BUILD)
593+
build = models.ForeignKey('Build', null=True)
575594

576595
class Meta:
577596
get_latest_by = 'created'
@@ -581,36 +600,43 @@ class Meta:
581600
def __str__(self):
582601
return "{0}-v{1}".format(self.app.id, self.version)
583602

584-
def new(self, user, config=None, build=None, summary=None, source_version='latest'):
603+
@property
604+
def image(self):
605+
return '{}:v{}'.format(self.app.id, str(self.version))
606+
607+
def new(self, user, config, build, summary=None, source_version='latest'):
585608
"""
586609
Create a new application release using the provided Build and Config
587610
on behalf of a user.
588611
589612
Releases start at v1 and auto-increment.
590613
"""
591-
if not config:
592-
config = self.config
593-
if not build:
594-
build = self.build
595-
# always create a release off the latest image
596-
source_tag = 'git-{}'.format(build.sha) if build.sha else source_version
597-
source_image = '{}:{}'.format(build.image, source_tag)
598614
# construct fully-qualified target image
599615
new_version = self.version + 1
600-
tag = 'v{}'.format(new_version)
601-
release_image = '{}:{}'.format(self.app.id, tag)
602-
target_image = '{}'.format(self.app.id)
603616
# create new release and auto-increment version
604617
release = Release.objects.create(
605618
owner=user, app=self.app, config=config,
606-
build=build, version=new_version, image=target_image, summary=summary)
619+
build=build, version=new_version, summary=summary)
620+
try:
621+
release.publish()
622+
except EnvironmentError as e:
623+
# If we cannot publish this app, just log and carry on
624+
logger.info(e)
625+
pass
626+
return release
627+
628+
def publish(self, source_version='latest'):
629+
if self.build is None:
630+
raise EnvironmentError('No build associated with this release to publish')
631+
source_tag = 'git-{}'.format(self.build.sha) if self.build.sha else source_version
632+
source_image = '{}:{}'.format(self.build.image, source_tag)
607633
# IOW, this image did not come from the builder
608634
# FIXME: remove check for mock registry module
609-
if not build.sha and 'mock' not in settings.REGISTRY_MODULE:
635+
if not self.build.sha and 'mock' not in settings.REGISTRY_MODULE:
610636
# we assume that the image is not present on our registry,
611637
# so shell out a task to pull in the repository
612638
data = {
613-
'src': build.image
639+
'src': self.build.image
614640
}
615641
requests.post(
616642
'{}/v1/repositories/{}/tags'.format(settings.REGISTRY_URL,
@@ -620,14 +646,12 @@ def new(self, user, config=None, build=None, summary=None, source_version='lates
620646
# update the source image to the repository we just imported
621647
source_image = self.app.id
622648
# if the image imported had a tag specified, use that tag as the source
623-
if ':' in build.image:
624-
if '/' not in build.image[build.image.rfind(':') + 1:]:
625-
source_image += build.image[build.image.rfind(':'):]
626-
649+
if ':' in self.build.image:
650+
if '/' not in self.build.image[self.build.image.rfind(':') + 1:]:
651+
source_image += self.build.image[self.build.image.rfind(':'):]
627652
publish_release(source_image,
628-
config.values,
629-
release_image,)
630-
return release
653+
self.config.values,
654+
self.image)
631655

632656
def previous(self):
633657
"""
@@ -645,6 +669,24 @@ def previous(self):
645669
prev_release = None
646670
return prev_release
647671

672+
def rollback(self, user, version):
673+
if version < 1:
674+
raise EnvironmentError('version cannot be below 0')
675+
summary = "{} rolled back to v{}".format(user, version)
676+
prev = self.app.release_set.get(version=version)
677+
new_release = self.new(
678+
user,
679+
build=prev.build,
680+
config=prev.config,
681+
summary=summary,
682+
source_version='v{}'.format(version))
683+
try:
684+
self.app.deploy(user, new_release)
685+
return new_release
686+
except RuntimeError:
687+
new_release.delete()
688+
raise
689+
648690
def save(self, *args, **kwargs): # noqa
649691
if not self.summary:
650692
self.summary = ''

0 commit comments

Comments
 (0)