Skip to content

Commit e58abc2

Browse files
authored
live stream image/release build logs in development (#163)
* live stream image/release build logs in development we didn't guard around KUBERNETES_ENABLED here so in dev there's no streaming and jsut a bunch of errors when running. this streams image/release logs to the client using redis while building in development * add stubs for redis
1 parent ccd64dd commit e58abc2

5 files changed

Lines changed: 212 additions & 178 deletions

File tree

cabotage/celery/tasks/build.py

Lines changed: 26 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
generate_kubernetes_imagepullsecrets,
4747
)
4848

49+
from cabotage.utils.build_log_stream import run_and_stream
4950
from cabotage.utils.release_build_context import RELEASE_DOCKERFILE_TEMPLATE
5051
from cabotage.utils.github import post_deployment_status_update
5152
from cabotage.utils import procfile
@@ -340,23 +341,24 @@ def build_release_buildkit(release):
340341
os.makedirs(os.path.join(tempdir, ".docker"), exist_ok=True)
341342
with open(os.path.join(tempdir, ".docker", "config.json"), "w") as f:
342343
f.write(dockerconfigjson)
344+
343345
try:
344-
completed_subprocess = subprocess.run( # nosec
346+
output = run_and_stream(
345347
buildctl_command + buildctl_args,
346348
env={"BUILDKIT_HOST": buildkitd_url, "HOME": tempdir},
347349
cwd=tempdir,
348-
check=True,
349-
stdout=subprocess.PIPE,
350-
stderr=subprocess.STDOUT,
351-
text=True,
350+
broker_url=current_app.config["CELERY_BROKER_URL"],
351+
build_type="release",
352+
build_job_id=release.build_job_id,
352353
)
353-
except subprocess.CalledProcessError as exc:
354-
raise BuildError(f"Build subprocess failed: {exc}")
355-
release.release_build_log = (
356-
" ".join(buildctl_command + buildctl_args)
357-
+ "\n"
358-
+ completed_subprocess.stdout
359-
)
354+
except subprocess.CalledProcessError as proc_exc:
355+
release.release_build_log = proc_exc.output
356+
db.session.commit()
357+
raise BuildError(
358+
f"Build subprocess failed with exit code {proc_exc.returncode}"
359+
)
360+
361+
release.release_build_log = output
360362
db.session.commit()
361363
except Exception as exc:
362364
raise BuildError(f"Build failed: {exc}")
@@ -892,27 +894,24 @@ def file_path(filename):
892894
os.path.join(tempdir, ".secret", "github_access_token"), "w"
893895
) as f:
894896
f.write(access_token)
897+
895898
try:
896-
completed_subprocess = subprocess.run( # nosec
899+
output = run_and_stream(
897900
buildctl_command + buildctl_args,
898901
env={"BUILDKIT_HOST": buildkitd_url, "HOME": tempdir},
899902
cwd=tempdir,
900-
check=True,
901-
stdout=subprocess.PIPE,
902-
stderr=subprocess.STDOUT,
903-
text=True,
904-
)
905-
except subprocess.CalledProcessError as exc:
906-
image.image_build_log = (
907-
" ".join(buildctl_command + buildctl_args) + "\n" + exc.output
903+
broker_url=current_app.config["CELERY_BROKER_URL"],
904+
build_type="image",
905+
build_job_id=image.build_job_id,
908906
)
907+
except subprocess.CalledProcessError as proc_exc:
908+
image.image_build_log = proc_exc.output
909909
db.session.commit()
910-
raise BuildError(f"Build subprocess failed: {exc}")
911-
image.image_build_log = (
912-
" ".join(buildctl_command + buildctl_args)
913-
+ "\n"
914-
+ completed_subprocess.stdout
915-
)
910+
raise BuildError(
911+
f"Build subprocess failed with exit code {proc_exc.returncode}"
912+
)
913+
914+
image.image_build_log = output
916915
db.session.commit()
917916
except Exception as exc:
918917
raise BuildError(f"Build failed: {exc}")

cabotage/server/user/views.py

Lines changed: 91 additions & 151 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,11 @@
9494
)
9595

9696
from cabotage.celery.tasks.deploy import scale_deployment
97+
from cabotage.utils.build_log_stream import (
98+
get_redis_client,
99+
read_log_stream,
100+
stream_key,
101+
)
97102

98103
Activity = activity_plugin.activity_cls
99104
user_blueprint = Blueprint(
@@ -959,30 +964,12 @@ def image_detail(image_id):
959964
)
960965

961966

962-
@sock.route("/image/<image_id>/livelogs", bp=user_blueprint)
963-
@login_required
964-
def image_build_livelogs(ws, image_id):
965-
image = Image.query.filter_by(id=image_id).first_or_404()
966-
if image.error:
967-
abort(404)
968-
if not ViewApplicationPermission(image.application.id).can():
969-
abort(403)
970-
build_job_id = image.build_job_id
971-
image_build_log = image.image_build_log
972-
if image_build_log is not None:
973-
ws.send(f"Job Pod imagebuild-{build_job_id}")
974-
for line in image_build_log.split("\n"):
975-
ws.send(f" {line}")
976-
ws.send("=================END OF LOGS=================")
977-
978-
db.session.remove()
979-
967+
def _stream_k8s_job_logs(ws, job_name, namespace, poll_interval=1):
968+
"""Stream logs from a Kubernetes job pod over a WebSocket."""
980969
api_client = kubernetes_ext.kubernetes_client
981970
core_api_instance = kubernetes.client.CoreV1Api(api_client)
982971
batch_api_instance = kubernetes.client.BatchV1Api(api_client)
983972

984-
job_name, namespace = (f"imagebuild-{build_job_id}", "default")
985-
986973
@backoff.on_exception(
987974
backoff.constant,
988975
kubernetes.client.exceptions.ApiException,
@@ -1008,22 +995,28 @@ def fetch_job_object():
1008995
except kubernetes.client.exceptions.ApiException as exc:
1009996
ws.send("=================END OF LOGS=================")
1010997
print(f"Encountered exception: {exc}")
1011-
return False
998+
return
1012999

10131000
if len(pods.items) != 1:
10141001
ws.send("=================END OF LOGS=================")
10151002
print("Found too many pods!")
1016-
return False
1003+
return
10171004

10181005
pod = pods.items[0]
10191006
while True:
1020-
pod = core_api_instance.read_namespaced_pod(
1021-
pod.metadata.name, pod.metadata.namespace
1022-
)
1023-
if pod.status.phase == "Running":
1024-
break
1025-
time.sleep(1)
1026-
ws.send(f"Job Pod imagebuild-{build_job_id}")
1007+
try:
1008+
pod = core_api_instance.read_namespaced_pod(
1009+
pod.metadata.name, pod.metadata.namespace
1010+
)
1011+
if pod.status.phase == "Running":
1012+
break
1013+
time.sleep(poll_interval)
1014+
except kubernetes.client.exceptions.ApiException as exc:
1015+
ws.send("=================END OF LOGS=================")
1016+
print(f"Encountered exception: {exc}")
1017+
return
1018+
1019+
ws.send(f"Job Pod {job_name}")
10271020
w = kubernetes.watch.Watch()
10281021
for line in w.stream(
10291022
core_api_instance.read_namespaced_pod_log,
@@ -1039,6 +1032,53 @@ def fetch_job_object():
10391032
ws.send("=================END OF LOGS=================")
10401033

10411034

1035+
def _stream_redis_build_logs(ws, build_type, build_job_id, job_label):
1036+
"""Stream build logs from Redis over a WebSocket."""
1037+
redis_client = get_redis_client(current_app.config["CELERY_BROKER_URL"])
1038+
log_key = stream_key(build_type, build_job_id)
1039+
ws.send(f"Job Pod {job_label}")
1040+
for line in read_log_stream(redis_client, log_key):
1041+
if line is None:
1042+
continue
1043+
ws.send(f" {line}")
1044+
ws.send("=================END OF LOGS=================")
1045+
1046+
1047+
@sock.route("/image/<image_id>/livelogs", bp=user_blueprint)
1048+
@login_required
1049+
def image_build_livelogs(ws, image_id):
1050+
image = Image.query.filter_by(id=image_id).first_or_404()
1051+
if image.error:
1052+
abort(404)
1053+
if not ViewApplicationPermission(image.application.id).can():
1054+
abort(403)
1055+
build_job_id = image.build_job_id
1056+
image_build_log = image.image_build_log
1057+
1058+
if image_build_log is not None:
1059+
ws.send(f"Job Pod imagebuild-{build_job_id}")
1060+
for line in image_build_log.split("\n"):
1061+
ws.send(f" {line}")
1062+
ws.send("=================END OF LOGS=================")
1063+
return
1064+
1065+
db.session.remove()
1066+
1067+
if current_app.config["KUBERNETES_ENABLED"]:
1068+
_stream_k8s_job_logs(
1069+
ws,
1070+
f"imagebuild-{build_job_id}",
1071+
"default",
1072+
)
1073+
else:
1074+
_stream_redis_build_logs(
1075+
ws,
1076+
"image",
1077+
build_job_id,
1078+
f"imagebuild-{build_job_id}",
1079+
)
1080+
1081+
10421082
@user_blueprint.route("/applications/<application_id>/releases")
10431083
@login_required
10441084
def application_releases(application_id):
@@ -1090,74 +1130,29 @@ def release_build_livelogs(ws, release_id):
10901130
abort(403)
10911131
build_job_id = release.build_job_id
10921132
release_build_log = release.release_build_log
1133+
10931134
if release_build_log is not None:
10941135
ws.send(f"Job Pod releasebuild-{build_job_id}")
10951136
for line in release_build_log.split("\n"):
10961137
ws.send(f" {line}")
10971138
ws.send("=================END OF LOGS=================")
1139+
return
10981140

10991141
db.session.remove()
11001142

1101-
api_client = kubernetes_ext.kubernetes_client
1102-
core_api_instance = kubernetes.client.CoreV1Api(api_client)
1103-
batch_api_instance = kubernetes.client.BatchV1Api(api_client)
1104-
1105-
job_name, namespace = (f"releasebuild-{build_job_id}", "default")
1106-
1107-
@backoff.on_exception(
1108-
backoff.constant,
1109-
kubernetes.client.exceptions.ApiException,
1110-
max_tries=20,
1111-
interval=0.25,
1112-
)
1113-
def fetch_job_object():
1114-
return batch_api_instance.read_namespaced_job(job_name, namespace)
1115-
1116-
try:
1117-
job_object = fetch_job_object()
1118-
except kubernetes.client.exceptions.ApiException:
1119-
ws.send("=================END OF LOGS=================")
1120-
return
1121-
1122-
label_selector = ",".join(
1123-
[f"{k}={v}" for k, v in job_object.spec.template.metadata.labels.items()]
1124-
)
1125-
try:
1126-
pods = core_api_instance.list_namespaced_pod(
1127-
namespace, label_selector=label_selector
1143+
if current_app.config["KUBERNETES_ENABLED"]:
1144+
_stream_k8s_job_logs(
1145+
ws,
1146+
f"releasebuild-{build_job_id}",
1147+
"default",
11281148
)
1129-
except kubernetes.client.exceptions.ApiException as exc:
1130-
ws.send("=================END OF LOGS=================")
1131-
print(f"Encountered exception: {exc}")
1132-
return False
1133-
1134-
if len(pods.items) != 1:
1135-
ws.send("=================END OF LOGS=================")
1136-
print("Found too many pods!")
1137-
return False
1138-
1139-
pod = pods.items[0]
1140-
while True:
1141-
pod = core_api_instance.read_namespaced_pod(
1142-
pod.metadata.name, pod.metadata.namespace
1149+
else:
1150+
_stream_redis_build_logs(
1151+
ws,
1152+
"release",
1153+
build_job_id,
1154+
f"releasebuild-{build_job_id}",
11431155
)
1144-
if pod.status.phase == "Running":
1145-
break
1146-
time.sleep(1)
1147-
ws.send(f"Job Pod releasebuild-{build_job_id}")
1148-
w = kubernetes.watch.Watch()
1149-
for line in w.stream(
1150-
core_api_instance.read_namespaced_pod_log,
1151-
name=pod.metadata.name,
1152-
namespace=namespace,
1153-
container=job_object.metadata.labels["process"],
1154-
follow=True,
1155-
_preload_content=False,
1156-
pretty="true",
1157-
):
1158-
ws.send(f" {line}")
1159-
1160-
ws.send("=================END OF LOGS=================")
11611156

11621157

11631158
@sock.route("/deployment/<deployment_id>/livelogs", bp=user_blueprint)
@@ -1175,75 +1170,20 @@ def deployment_livelogs(ws, deployment_id):
11751170
for line in deploy_log.split("\n"):
11761171
ws.send(f" {line}")
11771172
ws.send("=================END OF LOGS=================")
1173+
return
11781174

11791175
db.session.remove()
11801176

1181-
api_client = kubernetes_ext.kubernetes_client
1182-
core_api_instance = kubernetes.client.CoreV1Api(api_client)
1183-
batch_api_instance = kubernetes.client.BatchV1Api(api_client)
1184-
1185-
job_name = f"deployment-{job_id}"
1186-
1187-
@backoff.on_exception(
1188-
backoff.constant,
1189-
kubernetes.client.exceptions.ApiException,
1190-
max_tries=20,
1191-
interval=0.25,
1192-
)
1193-
def fetch_job_object():
1194-
return batch_api_instance.read_namespaced_job(job_name, namespace)
1195-
1196-
try:
1197-
job_object = fetch_job_object()
1198-
except kubernetes.client.exceptions.ApiException:
1199-
ws.send("=================END OF LOGS=================")
1200-
return
1201-
1202-
label_selector = ",".join(
1203-
[f"{k}={v}" for k, v in job_object.spec.template.metadata.labels.items()]
1204-
)
1205-
try:
1206-
pods = core_api_instance.list_namespaced_pod(
1207-
namespace, label_selector=label_selector
1177+
if current_app.config["KUBERNETES_ENABLED"]:
1178+
_stream_k8s_job_logs(
1179+
ws,
1180+
f"deployment-{job_id}",
1181+
namespace,
1182+
poll_interval=0.25,
12081183
)
1209-
except kubernetes.client.exceptions.ApiException as exc:
1210-
ws.send("=================END OF LOGS=================")
1211-
print(f"Encountered exception: {exc}")
1212-
return False
1213-
1214-
if len(pods.items) != 1:
1184+
else:
1185+
# deploy runs synchronously, log should already be in DB
12151186
ws.send("=================END OF LOGS=================")
1216-
print("Found too many pods!")
1217-
return False
1218-
1219-
pod = pods.items[0]
1220-
while True:
1221-
try:
1222-
pod = core_api_instance.read_namespaced_pod(
1223-
pod.metadata.name, pod.metadata.namespace
1224-
)
1225-
if pod.status.phase == "Running":
1226-
break
1227-
time.sleep(0.25)
1228-
except kubernetes.client.exceptions.ApiException as exc:
1229-
ws.send("=================END OF LOGS=================")
1230-
print(f"Encountered exception: {exc}")
1231-
return False
1232-
1233-
ws.send(f"Job Pod deployment-{job_id}")
1234-
w = kubernetes.watch.Watch()
1235-
for line in w.stream(
1236-
core_api_instance.read_namespaced_pod_log,
1237-
name=pod.metadata.name,
1238-
namespace=namespace,
1239-
container=job_object.metadata.labels["process"],
1240-
follow=True,
1241-
_preload_content=False,
1242-
pretty="true",
1243-
):
1244-
ws.send(f" {line}")
1245-
1246-
ws.send("=================END OF LOGS=================")
12471187

12481188

12491189
@user_blueprint.route("/deployment/<deployment_id>")

0 commit comments

Comments
 (0)