Skip to content

Commit 8b51669

Browse files
committed
clone backing services when branching
1 parent ed268a9 commit 8b51669

7 files changed

Lines changed: 782 additions & 42 deletions

File tree

cabotage/celery/tasks/branch_deploy.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import datetime
22
import logging
33
import re
4+
from copy import deepcopy
45

56
from flask import current_app
67
from kubernetes.client.rest import ApiException
@@ -23,6 +24,7 @@
2324
IngressHost,
2425
IngressPath,
2526
)
27+
from cabotage.server.models.resources import PostgresResource, RedisResource
2628
from cabotage.server.models.utils import readable_k8s_hostname, safe_k8s_name
2729
from cabotage.utils.github import (
2830
find_or_create_pr_comment,
@@ -35,6 +37,45 @@
3537
Activity = activity_plugin.activity_cls
3638

3739

40+
def _clone_backing_services_for_branch_deploy(base_environment, environment):
41+
"""Create fresh backing-service rows in a PR environment.
42+
43+
The cloned resources inherit sizing/topology/version settings from the
44+
base environment, but they are provisioned independently in the branch
45+
deploy namespace with a clean lifecycle state.
46+
"""
47+
for resource in base_environment.active_resources:
48+
if isinstance(resource, PostgresResource):
49+
cloned = PostgresResource(
50+
environment_id=environment.id,
51+
name=resource.name,
52+
slug=resource.slug,
53+
service_version=resource.service_version,
54+
size_class=resource.size_class,
55+
storage_size=resource.storage_size,
56+
ha_enabled=resource.ha_enabled,
57+
backup_strategy=resource.backup_strategy,
58+
postgres_parameters=deepcopy(resource.postgres_parameters or {}),
59+
)
60+
elif isinstance(resource, RedisResource):
61+
cloned = RedisResource(
62+
environment_id=environment.id,
63+
name=resource.name,
64+
slug=resource.slug,
65+
service_version=resource.service_version,
66+
size_class=resource.size_class,
67+
storage_size=resource.storage_size,
68+
ha_enabled=resource.ha_enabled,
69+
leader_replicas=resource.leader_replicas,
70+
follower_replicas=resource.follower_replicas,
71+
)
72+
else:
73+
continue
74+
db.session.add(cloned)
75+
76+
db.session.flush()
77+
78+
3879
def _create_app_env_for_branch_deploy(
3980
application,
4081
environment,
@@ -651,6 +692,8 @@ def maybe_update_pr_comment_for_app_env(app_env):
651692

652693
def create_branch_deploy(project, pr_number, head_sha, installation_id, head_ref=None):
653694
"""Create an ephemeral environment for a PR and build images for all enrolled apps."""
695+
from cabotage.celery.tasks.resources import reconcile_backing_services
696+
654697
base_env = project.branch_deploy_base_environment
655698
env_slug = f"pr-{pr_number}"
656699

@@ -695,6 +738,8 @@ def create_branch_deploy(project, pr_number, head_sha, installation_id, head_ref
695738
env_config_map[env_config.id] = new_env_config.id
696739
db.session.flush()
697740

741+
_clone_backing_services_for_branch_deploy(base_env, environment)
742+
698743
activity = Activity(
699744
verb="create",
700745
object=environment,
@@ -721,6 +766,9 @@ def create_branch_deploy(project, pr_number, head_sha, installation_id, head_ref
721766

722767
db.session.commit()
723768

769+
if environment.active_resources:
770+
reconcile_backing_services.delay()
771+
724772
# Create K8s namespace + ingresses early so cert-manager can start
725773
# issuing TLS certificates while image builds run in parallel.
726774
try:

cabotage/celery/tasks/build.py

Lines changed: 163 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747

4848
from cabotage.server.models.projects import (
4949
activity_plugin,
50+
Environment,
5051
Image,
5152
Release,
5253
Deployment,
@@ -75,6 +76,136 @@
7576
log = logging.getLogger(__name__)
7677

7778

79+
def _branch_deploy_environment_backing_services_ready(environment):
80+
resources = environment.active_resources
81+
if not resources:
82+
return True
83+
return all(resource.provisioning_status == "ready" for resource in resources)
84+
85+
86+
def _branch_deploy_backing_services_ready(app_env):
87+
environment = app_env.environment if app_env else None
88+
if not environment or environment.forked_from_environment_id is None:
89+
return True
90+
return _branch_deploy_environment_backing_services_ready(environment)
91+
92+
93+
def _mark_image_waiting_for_backing_services(image):
94+
image.image_metadata = {
95+
**(image.image_metadata or {}),
96+
"waiting_for_backing_services": True,
97+
}
98+
db.session.add(image)
99+
db.session.commit()
100+
101+
102+
def _clear_image_waiting_for_backing_services(image):
103+
metadata = dict(image.image_metadata or {})
104+
if "waiting_for_backing_services" in metadata:
105+
metadata.pop("waiting_for_backing_services", None)
106+
image.image_metadata = metadata
107+
db.session.add(image)
108+
db.session.commit()
109+
110+
111+
def _latest_release_for_image(image):
112+
app_env = image.application_environment
113+
if not app_env:
114+
return None
115+
release = app_env.latest_release
116+
if not release or not release.release_metadata:
117+
return None
118+
if release.release_metadata.get("source_image_id") == str(image.id):
119+
return release
120+
return None
121+
122+
123+
def _queue_autodeploy_release_for_image(image):
124+
app_env = image.application_environment
125+
if not app_env or not image.built:
126+
return None
127+
128+
existing_release = _latest_release_for_image(image)
129+
if existing_release is not None:
130+
return existing_release
131+
132+
_clear_image_waiting_for_backing_services(image)
133+
134+
application = image.application
135+
release = image.application.create_release(app_env=app_env)
136+
release.release_metadata = {
137+
**(image.image_metadata or {}),
138+
"source_image_id": str(image.id),
139+
}
140+
db.session.add(release)
141+
db.session.flush()
142+
activity = Activity(
143+
verb="create",
144+
object=release,
145+
data={
146+
"user_id": "automation",
147+
"deployment_id": image.image_metadata.get("id", None),
148+
"description": image.image_metadata.get("description", None),
149+
"timestamp": datetime.datetime.now(datetime.timezone.utc).isoformat(),
150+
},
151+
)
152+
db.session.add(activity)
153+
db.session.commit()
154+
155+
CheckRun.from_metadata(image.image_metadata, app_env).progress(
156+
"Building release...",
157+
detail="Image built, release build starting.",
158+
details_url=cabotage_url(application, f"releases/{release.id}"),
159+
Image=f"images/{image.id}",
160+
Release=f"releases/{release.id}",
161+
)
162+
run_release_build.delay(release_id=release.id)
163+
try:
164+
dispatch_autodeploy_notification(
165+
"release_building",
166+
image.id,
167+
application,
168+
app_env,
169+
image_url=cabotage_url(application, f"images/{image.id}"),
170+
release_url=cabotage_url(application, f"releases/{release.id}"),
171+
image_metadata=image.image_metadata,
172+
)
173+
except Exception:
174+
log.warning(
175+
"Failed to dispatch autodeploy release_building notification",
176+
exc_info=True,
177+
)
178+
return release
179+
180+
181+
def resume_branch_deploy_releases_for_environment(environment_id):
182+
environment = Environment.query.filter_by(id=environment_id).first()
183+
if (
184+
environment is None
185+
or environment.forked_from_environment_id is None
186+
or not _branch_deploy_environment_backing_services_ready(environment)
187+
):
188+
return
189+
190+
for app_env in environment.application_environments:
191+
image = app_env.latest_image
192+
if not image or not image.image_metadata:
193+
continue
194+
if not image.image_metadata.get("auto_deploy"):
195+
continue
196+
if not image.image_metadata.get("branch_deploy"):
197+
continue
198+
199+
if current_app.config.get("CABOTAGE_OMNIBUS_BUILDS"):
200+
if not image.built and image.image_metadata.get(
201+
"waiting_for_backing_services"
202+
):
203+
_clear_image_waiting_for_backing_services(image)
204+
run_omnibus_build.delay(image_id=image.id)
205+
elif image.built:
206+
_queue_autodeploy_release_for_image(image)
207+
208+
78209
def _dispatch_image_failure(image, error_detail):
79210
try:
80211
app = image.application
@@ -1873,48 +2004,21 @@ def run_image_build(image_id: str, buildkit: bool = False):
18732004
and image.image_metadata.get("auto_deploy", False)
18742005
):
18752006
app_env = image.application_environment
1876-
release = image.application.create_release(app_env=app_env)
1877-
release.release_metadata = {
1878-
**(image.image_metadata or {}),
1879-
"source_image_id": str(image.id),
1880-
}
1881-
db.session.add(release)
1882-
db.session.flush()
1883-
activity = Activity(
1884-
verb="create",
1885-
object=release,
1886-
data={
1887-
"user_id": "automation",
1888-
"deployment_id": image.image_metadata.get("id", None),
1889-
"description": image.image_metadata.get("description", None),
1890-
"timestamp": datetime.datetime.now(datetime.timezone.utc).isoformat(),
1891-
},
1892-
)
1893-
db.session.add(activity)
1894-
db.session.commit()
1895-
check.progress(
1896-
"Building release...",
1897-
detail="Image built, release build starting.",
1898-
details_url=cabotage_url(application, f"releases/{release.id}"),
1899-
Image=f"images/{image.id}",
1900-
Release=f"releases/{release.id}",
1901-
)
1902-
run_release_build.delay(release_id=release.id)
1903-
try:
1904-
dispatch_autodeploy_notification(
1905-
"release_building",
1906-
image.id,
1907-
application,
1908-
app_env,
1909-
image_url=cabotage_url(application, f"images/{image.id}"),
1910-
release_url=cabotage_url(application, f"releases/{release.id}"),
1911-
image_metadata=image.image_metadata,
1912-
)
1913-
except Exception:
1914-
log.warning(
1915-
"Failed to dispatch autodeploy release_building notification",
1916-
exc_info=True,
2007+
if image.image_metadata.get(
2008+
"branch_deploy"
2009+
) and not _branch_deploy_backing_services_ready(app_env):
2010+
_mark_image_waiting_for_backing_services(image)
2011+
check.progress(
2012+
"Waiting for backing services...",
2013+
detail=(
2014+
"Image built successfully. Waiting for backing services "
2015+
"to become ready before release build."
2016+
),
2017+
details_url=cabotage_url(application, f"images/{image.id}"),
2018+
Image=f"images/{image.id}",
19172019
)
2020+
else:
2021+
_queue_autodeploy_release_for_image(image)
19182022

19192023

19202024
@shared_task()
@@ -2245,6 +2349,24 @@ def run_omnibus_build(image_id: str):
22452349

22462350
release = None
22472351
app_env = image.application_environment
2352+
if (
2353+
image.image_metadata
2354+
and image.image_metadata.get("auto_deploy", False)
2355+
and image.image_metadata.get("branch_deploy")
2356+
and not _branch_deploy_backing_services_ready(app_env)
2357+
):
2358+
_mark_image_waiting_for_backing_services(image)
2359+
check.progress(
2360+
"Waiting for backing services...",
2361+
detail=(
2362+
"Waiting for backing services to become ready before starting "
2363+
"the branch deploy build."
2364+
),
2365+
details_url=cabotage_url(application, f"images/{image.id}"),
2366+
Image=f"images/{image.id}",
2367+
)
2368+
return
2369+
22482370
try:
22492371
try:
22502372
# Create the release record upfront so build_omnibus_buildkit

cabotage/celery/tasks/resources.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1495,6 +1495,9 @@ def _delete_redis(resource, core_api, custom_api, apps_api=None, rbac_api=None):
14951495
def reconcile_backing_services():
14961496
"""Periodic task: converge all backing service resources to desired state."""
14971497
from cabotage.server.models.resources import Resource
1498+
from cabotage.celery.tasks.build import (
1499+
resume_branch_deploy_releases_for_environment,
1500+
)
14981501

14991502
resources = Resource.query.filter(
15001503
Resource.provisioning_status != "deleting",
@@ -1535,6 +1538,10 @@ def reconcile_backing_services():
15351538
ensure_network_policies(networking_api, namespace)
15361539
reconcile_fn(resource, core_api, custom_api, apps_api, rbac_api)
15371540
db.session.commit()
1541+
if resource.environment.forked_from_environment_id is not None:
1542+
resume_branch_deploy_releases_for_environment(
1543+
resource.environment_id
1544+
)
15381545
except Exception:
15391546
log.exception(
15401547
"Failed to reconcile %s resource %s (%s)",

cabotage/server/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,7 @@ def __call__(self, *args: object, **kwargs: object) -> object:
165165
},
166166
"backing-service-reconciler": {
167167
"task": "cabotage.celery.tasks.resources.reconcile_backing_services",
168-
"schedule": 30.0,
168+
"schedule": 10.0,
169169
"args": None,
170170
},
171171
}

0 commit comments

Comments
 (0)