Skip to content

Commit 2e196bf

Browse files
committed
feat(release): add release deploy
1 parent d0d1dd8 commit 2e196bf

10 files changed

Lines changed: 198 additions & 51 deletions

File tree

rootfs/api/models/app.py

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121

2222
from api.utils import get_session
2323
from api.exceptions import AlreadyExists, DryccException, ServiceUnavailable
24-
from api.utils import generate_app_name, apply_tasks
24+
from api.utils import DeployLock, generate_app_name, apply_tasks
2525
from scheduler import KubeHTTPException, KubeException
2626
from .gateway import Gateway, Route
2727
from .limit import LimitPlan
@@ -255,16 +255,17 @@ def pipeline(self, release, procfile_types=None, force_deploy=False):
255255
if not rollback_on_failure:
256256
self.log(f"{prefix} deploy do not rollback on failure")
257257
self.deploy(release, procfile_types, force_deploy, rollback_on_failure)
258-
release.state = "succeed"
258+
if release.state == "created":
259+
release.state = "succeed"
260+
release.add_condition(state="succeed", action="pipeline", ptypes=procfile_types)
259261
except Exception as e:
260-
release.state = "crashed"
261-
release.failed = True
262-
if release.summary:
263-
release.summary += " "
264-
release.summary += "{} pipeline a release that failed".format(self.owner)
265-
release.exception = "error: {}".format(str(e))
262+
release.failed, release.state = True, "crashed"
263+
release.add_condition(
264+
state="crashed", action="pipeline", ptypes=procfile_types, exception=str(e))
266265
self.log(f"{prefix} pipeline runtime error: {release.exception}", logging.ERROR)
267-
release.save()
266+
finally:
267+
DeployLock(self.pk).release(procfile_types)
268+
release.save(update_fields=["state", "failed"]) # avoid overwriting other fields
268269
self.log(f"{prefix} run completed...")
269270

270271
def deploy(self, release, procfile_types=None, force_deploy=False, rollback_on_failure=True):

rootfs/api/models/build.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
from django.db import models
44
from django.contrib.auth import get_user_model
55
from api.exceptions import DryccException, Conflict
6-
from api.tasks import run_pipeline
76
from .base import UuidAuditedModel
87

98
User = get_user_model()
@@ -79,8 +78,8 @@ def create_release(self, user, *args, **kwargs):
7978
build=self,
8079
config=latest_release.config,
8180
)
82-
if self.app.appsettings_set.latest().autorollback:
83-
run_pipeline.delay(new_release, force_deploy=False)
81+
if self.app.appsettings_set.latest().autodeploy:
82+
new_release.deploy(force_deploy=False)
8483
return new_release
8584
except Exception as e:
8685
# check if the exception is during create or publish
@@ -95,7 +94,8 @@ def create_release(self, user, *args, **kwargs):
9594
self.owner, str(self.uuid)[:7])
9695
# Get the exception that has occured
9796
new_release.exception = "error: {}".format(str(e))
98-
new_release.save()
97+
# avoid overwriting other fields
98+
new_release.save(update_fields=["state", "failed", "summary", "exception"])
9999
if 'new_release' not in locals():
100100
self.delete()
101101
raise DryccException(str(e)) from e

rootfs/api/models/release.py

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,19 @@
11
import logging
22

3+
from datetime import datetime
4+
from django.utils import timezone
35
from django.conf import settings
46
from django.db import models
57
from django.db.models import Q
68
from django.contrib.auth import get_user_model
9+
from django.db.models import F, Func, Value, JSONField
710
from api.tasks import run_pipeline
811
from api.exceptions import DryccException, AlreadyExists
912
from scheduler import KubeHTTPException
1013
from scheduler.resources.pod import DEFAULT_CONTAINER_PORT
14+
15+
from ..utils import DeployLock
16+
1117
from .base import UuidAuditedModel
1218
from .appsettings import AppSettings
1319

@@ -34,6 +40,7 @@ class Release(UuidAuditedModel):
3440
summary = models.TextField(blank=True, null=True)
3541
failed = models.BooleanField(default=False)
3642
exception = models.TextField(blank=True, null=True)
43+
conditions = models.JSONField(default=list)
3744

3845
config = models.ForeignKey('Config', on_delete=models.CASCADE)
3946
build = models.ForeignKey('Build', null=True, on_delete=models.CASCADE)
@@ -46,10 +53,6 @@ class Meta:
4653
def __str__(self):
4754
return "{0}-{1}".format(self.app.id, self.version_name)
4855

49-
@property
50-
def deploying(self):
51-
return self.build is not None and self.state == "created"
52-
5356
@property
5457
def procfile_types(self):
5558
if self.build is not None:
@@ -77,6 +80,18 @@ def get_runners(self, procfile_types):
7780
break
7881
return results
7982

83+
def add_condition(self, **kwargs):
84+
if "created" not in kwargs:
85+
kwargs["created"] = datetime.now(timezone.utc).strftime(settings.DRYCC_DATETIME_FORMAT)
86+
type(self).objects.filter(pk=self.pk).update(
87+
conditions=Func(
88+
F("conditions"),
89+
Value(["0"]),
90+
Value(kwargs, JSONField()),
91+
function="jsonb_insert",
92+
)
93+
)
94+
8095
def get_deploy_image(self, container_type):
8196
"""
8297
In the deploy phase of dryccfile
@@ -147,6 +162,12 @@ def get_port(self, procfile_type):
147162
procfile_type, {}).get(
148163
'PORT', self.config.values.get('PORT', DEFAULT_CONTAINER_PORT)))
149164

165+
def deploy(self, procfile_types=None, force_deploy=False):
166+
lock = DeployLock(self.app.pk)
167+
if not lock.acquire(procfile_types, force=force_deploy):
168+
raise DryccException('there is an executing pipeline, please wait or force deploy')
169+
run_pipeline.delay(self, procfile_types, force_deploy)
170+
150171
def previous(self):
151172
"""
152173
Return the previous Release to this one.
@@ -191,7 +212,7 @@ def rollback(self, user, procfile_types=None, version=None):
191212
summary="{} rolled back to v{}".format(user, version),
192213
)
193214
if self.build is not None:
194-
run_pipeline.delay(new_release, procfile_types, force_deploy=True)
215+
new_release.deploy(procfile_types, force_deploy=True)
195216
return new_release
196217
except Exception as e:
197218
# check if the exception is during create or publish
@@ -206,7 +227,8 @@ def rollback(self, user, procfile_types=None, version=None):
206227
self.owner)
207228
# Get the exception that has occured
208229
new_release.exception = "error: {}".format(str(e))
209-
new_release.save()
230+
# avoid overwriting other fields
231+
new_release.save(update_fields=["state", "failed", "summary", "exception"])
210232
raise DryccException(str(e)) from e
211233

212234
def cleanup_old(self, procfile_types=None):
@@ -378,5 +400,4 @@ def save(self, *args, **kwargs): # noqa
378400
else:
379401
# There were no changes to this release
380402
raise AlreadyExists("{} changed nothing - release stopped".format(self.owner))
381-
382403
super(Release, self).save(*args, **kwargs)

rootfs/api/tests/test_config.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -279,7 +279,7 @@ def test_config_deploy_failure(self, mock_requests):
279279
app = App.objects.get(id=app_id)
280280
release = app.release_set.latest()
281281
self.assertEqual(release.failed, True)
282-
self.assertEqual(release.exception, "error: Boom!")
282+
self.assertEqual(release.conditions[0]['exception'], "Boom!")
283283

284284
def test_invalid_config_keys(self, mock_requests):
285285
"""Test that invalid config keys are rejected.
@@ -386,7 +386,7 @@ def test_config_failures(self, mock_requests):
386386
self.assertEqual(response.status_code, 201)
387387
self.assertEqual(app.release_set.latest().version, 4)
388388
self.assertEqual(app.release_set.latest().failed, True)
389-
self.assertEqual(app.release_set.latest().exception, "error: Boom!")
389+
self.assertEqual(app.release_set.latest().conditions[0]['exception'], "Boom!")
390390
self.assertEqual(app.release_set.filter(failed=False).latest().version, 3)
391391

392392
# create a build to see that the new release is created with the last successful config
@@ -464,7 +464,7 @@ def test_unset_limits_error(self, mock_requests):
464464
build=build
465465
)
466466
# deploy
467-
app.pipeline(release)
467+
release.deploy()
468468
# unset error
469469
body = {
470470
'limits': {
@@ -542,7 +542,7 @@ def test_set_config_limits_run(self, *args, **kwargs):
542542
build=build
543543
)
544544
# deploy
545-
app.pipeline(release)
545+
release.deploy()
546546
body = {
547547
'values': json.dumps({'PORT': 5000}),
548548
'limits': {

rootfs/api/tests/test_gateway.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ def create_app_with_domain_and_deploy(self):
5555
build=build
5656
)
5757
# deploy
58-
app.pipeline(release)
58+
release.deploy()
5959
return app_id
6060

6161
def change_certs_auto(self, app_id, enabled):

rootfs/api/tests/test_lock.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import time
2+
from api.tests import DryccTestCase
3+
from api.utils import CacheLock, DeployLock
4+
5+
6+
class TestLock(DryccTestCase):
7+
8+
def test_cache_lock(self):
9+
key = f"test_key_1_{int(time.time())}"
10+
lock1 = CacheLock(key)
11+
lock2 = CacheLock(key)
12+
timeout = 5
13+
self.assertEqual(lock1.acquire(True, timeout), True)
14+
self.assertEqual(lock2.acquire(False, timeout), False)
15+
time.sleep(5)
16+
start_time = time.time()
17+
self.assertEqual(lock2.acquire(False, timeout), True)
18+
self.assertEqual(lock1.acquire(True, timeout + 1), True)
19+
self.assertTrue(time.time()-start_time > 5)
20+
self.assertEqual(lock2.acquire(False, timeout), False)
21+
lock1.release()
22+
self.assertEqual(lock2.acquire(False, timeout), True)
23+
24+
def test_deploy_lock(self):
25+
app_id = f"test_key_1_{int(time.time())}"
26+
lock1 = DeployLock(app_id)
27+
lock2 = DeployLock(app_id)
28+
self.assertEqual(lock1.acquire(None), True)
29+
self.assertEqual(lock2.acquire(["web", "task"]), False)
30+
lock1.release(None)
31+
self.assertEqual(lock2.acquire(["web", "task"]), True)
32+
self.assertEqual(lock1.acquire(None), False)
33+
self.assertEqual(lock1.acquire(["web"]), False)
34+
self.assertEqual(lock1.acquire(["bing"]), True)
35+
lock2.release(["web", "task"])
36+
self.assertEqual(lock1.acquire(["web"]), True)
37+
self.assertEqual(lock1.acquire(["task"]), True)
38+
self.assertEqual(lock2.acquire(["web", "task"]), False)
39+
self.assertEqual(lock2.acquire(None), False)
40+
lock2.release(["web", "task", "bing"])
41+
self.assertEqual(lock2.acquire(None), True)
42+
lock2.release(None)
43+
self.assertEqual(lock1.acquire(["web", "task"]), True)
44+
self.assertEqual(lock2.acquire(["web", "task"]), False)
45+
self.assertEqual(lock2.acquire(["web", "bing"], True), True)
46+
self.assertEqual(lock1.acquire(["web", "bing", "task"]), False)
47+
self.assertEqual(lock1.acquire(["web", "bing", "task"], True), True)
48+
lock2.release(["web", "bing", "task"])

rootfs/api/tests/test_pods.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -278,16 +278,18 @@ def test_container_errors(self, mock_requests):
278278
build=build
279279
)
280280
# deploy
281-
app.pipeline(release)
281+
release.deploy()
282282
url = f"/v2/apps/{app_id}/ptypes/scale"
283283
body = {'web': 'not_an_int'}
284284
response = self.client.post(url, body)
285285
self.assertEqual(response.status_code, 204, response.data)
286+
app = App.objects.get(id=app_id)
286287
self.assertEqual(app.structure, {'web': 1})
287288

288289
body = {'invalid': 1}
289290
response = self.client.post(url, body)
290291
self.assertEqual(response.status_code, 204, response.data)
292+
app = App.objects.get(id=app_id)
291293
self.assertEqual(app.structure, {'web': 1})
292294

293295
def test_container_str(self, mock_requests):
@@ -512,7 +514,7 @@ def test_command_good(self, mock_requests):
512514
build=build
513515
)
514516
# deploy
515-
app.pipeline(release)
517+
release.deploy()
516518

517519
# use `start web` for backwards compatibility with buildpacks
518520
self.assertEqual(release.get_deploy_args('web'), [])
@@ -571,7 +573,7 @@ def test_run_command_good(self, mock_requests):
571573
build=build
572574
)
573575
# deploy
574-
app.pipeline(release)
576+
release.deploy()
575577

576578
# create a run pod
577579
url = f"/v2/apps/{app_id}/run"
@@ -632,7 +634,7 @@ def test_run_not_fail_on_debug(self, mock_requests):
632634
build=build
633635
)
634636
# deploy
635-
app.pipeline(release)
637+
release.deploy()
636638

637639
# create a run pod
638640
url = f"/v2/apps/{app_id}/run"
@@ -669,7 +671,7 @@ def test_scaling_does_not_add_run_proctypes_to_structure(self, mock_requests):
669671
build=build
670672
)
671673
# deploy
672-
app.pipeline(release)
674+
release.deploy()
673675

674676
# create a run pod
675677
url = f"/v2/apps/{app_id}/run"

rootfs/api/tests/test_release.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,33 @@ def test_release(self, mock_requests):
105105
self.assertEqual(response.status_code, 405, response.content)
106106
return release3
107107

108+
def test_conditions(self, mock_requests):
109+
app_id = self.create_app()
110+
url = f"/v2/apps/{app_id}/builds"
111+
body = {
112+
'image': 'autotest/example',
113+
'stack': 'heroku-18',
114+
'sha': 'a'*40,
115+
'procfile': {
116+
'web': 'node server.js',
117+
'worker-test1': 'node worker.js'
118+
}
119+
}
120+
response = self.client.post(url, body)
121+
self.assertEqual(response.status_code, 201, response.data)
122+
url = f'/v2/apps/{app_id}/releases/'
123+
response = self.client.get(url)
124+
self.assertEqual(len(response.data['results'][0]['conditions']), 1)
125+
126+
with mock.patch('api.models.app.App.deploy') as mock_deploy:
127+
mock_deploy.side_effect = Exception('Boom!')
128+
url = f"/v2/apps/{app_id}/builds"
129+
response = self.client.post(url, body)
130+
self.assertEqual(response.status_code, 201, response.data)
131+
url = f'/v2/apps/{app_id}/releases/'
132+
response = self.client.get(url)
133+
self.assertEqual(response.data['results'][0]['conditions'][0]['exception'], 'Boom!')
134+
108135
def test_get_image(self, mock_requests):
109136
app_id = self.create_app()
110137
url = f"/v2/apps/{app_id}/builds"
@@ -192,7 +219,8 @@ def test_response_data(self, mock_requests):
192219
response = self.client.get(url)
193220
for key in response.data.keys():
194221
self.assertIn(key, ['uuid', 'owner', 'created', 'updated', 'app', 'build', 'config',
195-
'summary', 'version', 'state', 'failed', 'exception'])
222+
'summary', 'version', 'state', 'failed', 'conditions',
223+
'exception'])
196224
expected = {
197225
'owner': self.user.username,
198226
'app': app_id,

0 commit comments

Comments
 (0)