Repository navigation
Expand file tree
/
Copy pathhandler.star
More file actions
5571 lines (4919 loc) · 222 KB
/
Copy pathhandler.star
File metadata and controls
5571 lines (4919 loc) · 222 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# Copyright (c) ClaceIO, LLC
# SPDX-License-Identifier: Apache-2.0
load("openrun.in", "openrun")
load("openrun_admin.in", "openrun_admin")
load("build.in", "build")
load("utils.star", "utils")
# Route handlers for the console. Each screen has a *_data function which
# builds the full page context; action handlers run the mutation and re-render
# the same context with a Flash/FlashError message. Mutation results must read
# ret.error BEFORE the *_data call: an unread plugin error fails the next
# plugin call. The error_handler in app.star is the fallback when that is
# missed.
# ---------- Overview ----------
def ov_can(perms, perm):
# True when the caller holds perm (or the admin super-user permission)
return bool(perms.get(perm) or perms.get("admin"))
def ov_running(entries):
# Count of container entries in the running state
running = 0
for entry in entries:
if entry["state"] == "running":
running += 1
return running
def ov_uptime(secs):
# Compact uptime for the stat tile: "42m", "3h 12m", "12d 4h"
secs = int(secs)
if secs < 3600:
return "%dm" % (secs // 60)
if secs < 86400:
return "%dh %dm" % (secs // 3600, (secs % 3600) // 60)
return "%dd %dh" % (secs // 86400, (secs % 86400) // 3600)
def ov_trim_time(t):
# Timestamps are time.time values, passed through to the templates; the
# go zero time (unset) maps to ""
return utils.nonzero_time(t)
def ov_apps_tile(perms):
# Big apps tile: totals, prod/dev/declarative split, pending promotion
# count and a by-spec mini bar chart. Uses plain list_apps (the
# check_approval audit sweep is too slow for a landing page; the
# needs-approval chip lazy-loads via overview_approvals_handler).
# Tiles the user cannot read return None and are OMITTED from the
# page (overview shows only accessible info, unlike the
# disabled-with-tooltip convention for action controls)
if not ov_can(perms, "app:read"):
return None
ret = openrun.list_apps(include_internal=True)
if ret.error:
return {"Error": ret.error}
rows = build_app_rows(ret.value)
dev = 0
promote = 0
spec_counts = {}
for row in rows:
if row["is_dev"]:
dev += 1
if row["staging"] and row["staging"]["ahead"]:
promote += 1
spec = row["spec"] or "custom"
spec_counts[spec] = spec_counts.get(spec, 0) + 1
specs = sorted(spec_counts.items(), key=lambda kv: (-kv[1], kv[0]))
top = specs[:4]
other = 0
for _, count in specs[4:]:
other += count
max_count = other
for _, count in top:
max_count = max(max_count, count)
by_spec = []
for name, count in top:
by_spec.append({"label": name, "count": count,
"pct": count * 100 // max_count, "other": False})
if other:
by_spec.append({"label": "other", "count": other,
"pct": other * 100 // max_count, "other": True})
return {
"Total": len(rows),
"Prod": len(rows) - dev,
"Dev": dev,
"Promote": promote,
"BySpec": by_spec,
}
def ov_syncs_tile(perms):
# Sync health: one status dot per sync entry, failing entries first
if not ov_can(perms, "sync:read"):
return None
ret = openrun.list_sync()
if ret.error:
return {"Error": ret.error}
entries = []
failing = 0
disabled = 0
for entry in ret.value:
state = entry["status"]["state"] # Enabled / Disabled / Failing
if state == "Failing":
failing += 1
elif state == "Disabled":
disabled += 1
entries.append({
"id": entry["id"],
"repo": entry["path"],
"state": state,
"last_exec": ov_trim_time(entry["status"]["last_execution_time"]),
"error": entry["status"].get("error") or "",
})
order = {"Failing": 0, "Enabled": 1, "Disabled": 2}
entries = sorted(entries, key=lambda e: (order.get(e["state"], 3), e["repo"]))
return {
"Total": len(entries),
"Failing": failing,
"Disabled": disabled,
"Ok": len(entries) - failing - disabled,
"Syncs": entries[:12],
"More": max(0, len(entries) - 12),
}
def ov_services_tile(perms):
# Services and bindings counts (tiny table scans)
tile = {}
if ov_can(perms, "service:read"):
ret = openrun.list_services()
if not ret.error:
tile["Services"] = len(ret.value)
if ov_can(perms, "binding:read"):
ret = openrun.list_bindings()
if not ret.error:
bindings = ret.value
auto = 0
for b in bindings:
if b["path"].startswith("/auto/"):
auto += 1
tile["Bindings"] = len(bindings)
tile["Auto"] = auto
if "Services" not in tile and "Bindings" not in tile:
return None
return tile
def ov_server_tile(perms):
# Server identity chips + metadata replication state, all in-memory
# server-side (server_info never touches the DB or external services).
# config:basic_read is implied by config:read, but get_permissions
# reports held permissions literally, so both are checked
if not (ov_can(perms, "config:basic_read") or perms.get("config:read")):
return None
ret = openrun.server_info()
if ret.error:
return {"Error": ret.error}
info = ret.value
repl = []
for entry in info["metadata_replication"]:
repl.append({
"target": entry["target"],
"state": entry["state"],
"last_sync": ov_trim_time(entry.get("last_sync") or ""),
"error": entry.get("error") or "",
})
commit = info["commit"]
return {
"Version": info["version"],
# short_sha would mangle the "dev_build" placeholder of unreleased
# binaries into "dev_bui"
"Commit": commit if commit == "dev_build" else utils.short_sha(commit),
"Uptime": ov_uptime(info["uptime_secs"]),
"MetadataDB": info["metadata_db_type"],
"AuditDB": info["audit_db_type"],
"Runtime": info.get("container_runtime") or "",
"IsLeader": info["is_leader"],
"MetadataRepl": repl,
# The MCP mini tile: the management endpoint and the app actions
# endpoint state (api.app_mcp.served is enabled AND RBAC enforced)
"Api": info.get("api"),
}
def ov_activity_tile(perms, scope):
# Recent activity ticker: the last few audit events. The default
# "system" scope lists management operations only; "all" includes
# http/action/custom events (the scope chips re-render via the
# /overview/activity fragment)
if not ov_can(perms, "audit:read"):
return None
event_type = "system" if scope != "all" else ""
# start_date is a required arg in the plugin signature (empty = no
# filter). 100 events, ~20 visible - the ticker box scrolls the rest
ret = openrun.list_audit_events(event_type=event_type, start_date="", limit=100)
if ret.error:
return {"Error": ret.error}
events = []
for entry in ret.value:
status = entry.get("status") or ""
is_error = bool(status) and status != "Success" and \
not status.startswith("2") and not status.startswith("3")
events.append({
"user": entry.get("user_id") or "",
"operation": entry.get("operation") or entry.get("event_type") or "",
"target": entry.get("target") or "",
"time": ov_trim_time(entry.get("create_time") or ""),
"is_error": is_error,
})
return {"Events": events, "Scope": "all" if scope == "all" else "system"}
def ov_repl_tile(perms, server):
# Replication tile: the metadata state (from server_info, so visible
# with config:basic_read) plus the lazy-loaded app binding rows
# (app:read - the server filters rows to the caller's readable apps).
# None - and no tile - when the user can see neither. The
# "not configured" metadata note renders only when the caller could
# actually read the metadata state (ShowMetadata), never as a guess
show_bindings = ov_can(perms, "app:read")
show_metadata = bool(server) and not server.get("Error")
metadata = server["MetadataRepl"] if show_metadata else []
if not show_metadata and not show_bindings:
return None
return {"Metadata": metadata, "ShowMetadata": show_metadata,
"ShowBindings": show_bindings}
def overview_data(req):
# Overview home page: fleet counts and health at a glance. Tiles the
# user cannot read - or whose feature is disabled for this install -
# are omitted entirely (each ov_*_tile returns None). Every plugin
# call here must be cheap (in-memory or one small table read):
# container state and binding replication cost external calls, so
# those tiles lazy-load via the /overview/containers and
# /overview/replication fragments
perms = utils.get_perms()
server = ov_server_tile(perms)
data = {
"Title": "Overview",
"Nav": "overview",
"Perms": perms,
"Apps": ov_apps_tile(perms),
"Syncs": ov_syncs_tile(perms),
"ServicesTile": ov_services_tile(perms),
"Server": server,
"Repl": ov_repl_tile(perms, server),
"Activity": ov_activity_tile(perms, "system"),
"ShowContainers": bool(perms.get("feature:container") and
ov_can(perms, "container:read")),
}
data["Empty"] = not (data["Apps"] or data["Syncs"] or data["ServicesTile"] or
data["Server"] or data["Repl"] or data["Activity"] or
data["ShowContainers"])
return data
def ov_container_kind_row(label, ctype):
# One "X of Y running" stat row for a special container listing
ret = openrun.list_containers(type=ctype)
if ret.error:
return {"label": label, "error": ret.error}
return {"label": label, "running": ov_running(ret.value),
"total": len(ret.value)}
def overview_containers_handler(req):
# Lazy containers tile: list_containers shells out to the container
# daemon, so it renders as a skeleton first and loads here
perms = utils.get_perms()
data = {"Perms": perms, "Loaded": True}
if not perms.get("feature:container"):
# Only reachable by a direct fragment request: the page does not
# render the lazy loader when the container feature is disabled
data["Error"] = "containers are disabled for this install"
return data
if not ov_can(perms, "container:read"):
data["Error"] = "requires the container:read or admin permission"
return data
ret = openrun.list_containers()
if ret.error:
data["Error"] = ret.error
return data
# The managed list includes the litestream replication sidecars (they
# carry the app.id label); split them out by their -ls name suffix -
# the same heuristic the replication status API uses (ContainerInfo
# does not expose labels)
apps_total = 0
apps_running = 0
ls_total = 0
ls_running = 0
sc_total = 0
sc_running = 0
job_total = 0
job_running = 0
runtime = ""
for entry in ret.value:
runtime = entry["runtime"]
is_running = entry["state"] == "running"
if entry["name"].endswith("-ls"):
ls_total += 1
ls_running += 1 if is_running else 0
elif entry.get("job"):
# Job run containers (both runtimes): finished ones stay until
# their run record is retired, so most are stopped
job_total += 1
job_running += 1 if is_running else 0
elif entry.get("role") == "sidecar":
# App sidecars are separate labeled containers on docker/podman
# (in-pod on kubernetes, counted with their pod)
sc_total += 1
sc_running += 1 if is_running else 0
else:
apps_total += 1
apps_running += 1 if is_running else 0
data["Total"] = apps_total
data["Running"] = apps_running
rows = []
if perms.get("feature:builder"):
# Builder agent sandboxes and (on kubernetes) kaniko build pods:
# one more daemon call each, fine in this lazy fragment
rows.append(ov_container_kind_row("builder agents", "agent"))
if runtime == "kubernetes":
rows.append(ov_container_kind_row("kaniko builds", "kaniko"))
if runtime != "kubernetes":
rows.append({"label": "app sidecars", "running": sc_running,
"total": sc_total})
rows.append({"label": "litestream sidecars", "running": ls_running,
"total": ls_total})
rows.append({"label": "job runs", "running": job_running,
"total": job_total})
data["Rows"] = rows
return data
def overview_activity_handler(req):
# Re-renders the activity tile when the System/All scope chips change
perms = utils.get_perms()
scope = utils.query_param(req, "scope") or "system"
return {"Perms": perms, "Activity": ov_activity_tile(perms, scope)}
def overview_approvals_handler(req):
# Lazy needs-approval chip on the apps tile: check_approval audits
# staging per app (cached server-side by app version + binding
# generation), too slow for the overview first paint
perms = utils.get_perms()
data = {"Perms": perms, "Loaded": True}
if not ov_can(perms, "app:read"):
return data
ret = openrun.list_apps(include_internal=True, check_approval=True)
if ret.error:
return data
approval = 0
for row in build_app_rows(ret.value):
if row["needs_approval"]:
approval += 1
data["Approval"] = approval
return data
# Replication states that mean attention is needed; everything else
# (healthy, idle, syncing, pending) is a working or quiet-but-fine state
REPL_UNHEALTHY_STATES = ("sidecar_down", "misconfigured", "error")
def overview_replication_handler(req):
# Lazy binding replication summary: replication_status sweeps the
# replica store (S3) and the container daemon on a cache miss (30s
# server-side cache), so the tile shows the free metadata state first
# and this summary loads here. Only PROD app entries are counted
# (staged replication state is on the app detail page); the server
# filters entries per caller (app rows to readable apps via app:read,
# metadata rows to config:basic_read) - metadata rows are dropped here
# regardless, they already rendered with the server tile
perms = utils.get_perms()
data = {"Perms": perms, "Loaded": True}
if not ov_can(perms, "app:read"):
data["Error"] = "requires the app:read or admin permission"
return data
ret = openrun.replication_status()
if ret.error:
data["Error"] = ret.error
return data
# An app is unhealthy when ANY of its prod replication targets is in a
# bad state (an app has at most one sqlite binding today, but keep the
# fold safe if that changes)
app_healthy = {}
for entry in ret.value:
if entry["kind"] != "app" or entry.get("env") != "prod":
continue
bad = entry["state"] in REPL_UNHEALTHY_STATES
for app_path in entry.get("app_paths") or []:
app_healthy[app_path] = app_healthy.get(app_path, True) and not bad
healthy = len([1 for ok in app_healthy.values() if ok])
unhealthy = len(app_healthy) - healthy
data["Total"] = len(app_healthy)
data["HealthyText"] = "%d app%s healthy" % (healthy, "" if healthy == 1 else "s")
data["UnhealthyText"] = "%d app%s unhealthy" % (unhealthy, "" if unhealthy == 1 else "s")
data["HasUnhealthy"] = unhealthy > 0
return data
def replication_data(req):
# Replication detail page (/replication, linked from the overview
# replication tile): one row per PROD app replication target with the
# full entry detail (state, sidecar, last sync, replica size, target
# and the per-database file breakdown), searchable by app path. The
# server filters entries to the caller's readable apps (app:read),
# same as the overview tile; staged state stays on the app detail page
query = utils.query_param(req, "query").lower()
perms = utils.get_perms()
data = {
"Title": "Replication",
"Nav": "overview",
"Query": query,
"Perms": perms,
"Total": 0,
"Unhealthy": 0,
"Rows": [],
}
if not ov_can(perms, "app:read"):
data["FlashError"] = "requires the app:read or admin permission"
return data
ret = openrun.replication_status()
if ret.error:
data["FlashError"] = ret.error
return data
# Resolve each prod app's litestream sidecar container for the row
# drill-down (docker/podman: separate "-ls" containers, prod told apart
# by app_prd_ in the name; none on kubernetes where the sidecar runs
# inside the app pod). list_containers is only granted on container
# feature installs - errors are ignored and the rows render without
# the drill-down, matching the missing /containers routes
sidecars = {}
cont_ret = openrun.list_containers()
cont_error = cont_ret.error
if not cont_error:
for c in cont_ret.value:
if c["app_path"] and c["name"].endswith("-ls") and "app_prd_" in c["name"]:
sidecars[c["app_path"]] = c["id"]
rows = []
for entry in ret.value:
if entry["kind"] != "app" or entry.get("env") != "prod":
continue
# A target replicates one binding; apps sharing the binding share
# the entry - render one row per app so the search works by app
for app_path in entry.get("app_paths") or []:
data["Total"] += 1
bad = entry["state"] in REPL_UNHEALTHY_STATES
if bad:
data["Unhealthy"] += 1
if query and query not in app_path.lower():
continue
files = []
for f in entry.get("files") or []:
files.append({
"path": f["path"],
"size": utils.human_size(int(f.get("size") or 0)),
"last_sync": ov_trim_time(f.get("last_sync") or ""),
})
sidecar = entry.get("sidecar_running")
size = int(entry.get("replica_size") or 0)
rows.append({
"container_id": sidecars.get(app_path) or "",
"app_path": app_path,
"state": entry["state"],
"unhealthy": bad,
"target": entry["target"],
"sidecar": "" if sidecar == None else ("running" if sidecar else "down"),
"last_sync": ov_trim_time(entry.get("last_sync") or ""),
"size": utils.human_size(size) if size else "",
"files": files,
"error": entry.get("error") or "",
})
# Failing targets first, then by app path
data["Rows"] = sorted(rows, key=lambda r: (not r["unhealthy"], r["app_path"]))
return data
# ---------- Apps ----------
def build_app_rows(all_apps):
# Folds staging entries into their main app's row and returns the row
# dicts rendered by the shared app_table template. all_apps must come
# from list_apps with include_internal=True
staging_by_main = {}
for entry in all_apps:
if entry["is_stage"]:
staging_by_main[entry["main_app"]] = entry
rows = []
for entry in all_apps:
if entry["main_app"]:
continue
stage = staging_by_main.get(entry["id"])
# The staging app carries the most recent sync state (prod picks it
# up on promote); fall back to the prod app's value. get() keeps this
# working against servers older than the applied_sync_id field
sync_id = (stage.get("applied_sync_id", "") if stage else "") or entry.get("applied_sync_id", "")
staging = None
if stage:
staging = {
"version": stage["version"],
"git_sha": utils.short_sha(stage["git_sha"]),
"git_message": stage["git_message"],
# staging has a version prod does not have yet
"ahead": stage["version_mismatch"],
}
rows.append({
"name": entry["name"],
"path": entry["path"],
"url": entry["url"],
"auth": entry["auth"],
"is_dev": entry.get("is_dev") or False,
# Only set when list_apps ran with check_approval=True (apps page);
# the backend mirrors the staging app's audit onto the main app
"needs_approval": entry.get("needs_approval") or False,
"is_git": bool(entry["git_branch"]),
# Declarative means a sync source last applied the app. Git
# presence is not the signal: image/proxy spec apps have no git
"sync_id": sync_id,
"is_declarative": bool(sync_id),
# "-" is the placeholder for apps with no source (image/proxy specs)
"source": entry["source"] if entry["source"] != "-" else "",
"source_url": entry["source_url"],
"git_branch": entry["git_branch"],
"spec": entry.get("spec") or "",
"version": entry["version"],
"git_sha": utils.short_sha(entry["git_sha"]),
"git_message": entry["git_message"],
"staging": staging,
"created_by": entry.get("created_by") or "",
"update_time": entry.get("update_time") or "",
"update_user": entry.get("update_user") or "",
})
return rows
def apps_data(req):
# Apps list page: apps grouped by their managing sync, plus unmanaged.
# The promote/approval tabs show the apps waiting on that action as a
# flat list, with the row action switched to Promote/Approve
query = utils.query_param(req, "query")
filter = utils.query_param(req, "filter") # "", "declarative" or "imperative"
tab = utils.query_param(req, "tab") # "", "promote" or "approval"
# include_internal picks up staging/preview apps; staging entries are
# folded into their main app's row instead of being listed separately.
# check_approval adds the needs_approval flag (cached server-side).
# A search containing ":", starting with "/", or equal to "all"
# switches to app path GLOB matching (domain:path form, e.g.
# "example.com:/**", "/utils/**", "all" - the same globs the CLI
# takes); anything else is a substring search over
# name/path/source/user
stripped = query.strip()
glob = ""
if ":" in stripped or stripped.startswith("/") or stripped.lower() == "all":
glob = stripped
list_ret = openrun.list_apps(query="" if glob else query, path=glob,
include_internal=True, check_approval=True)
list_error = list_ret.error
all_apps = list_ret.value if not list_error else []
# Sync entries the user can read. Apps whose sync entry is not visible
# (no sync:read) are shown in the unmanaged section instead
syncs = {}
sync_ret = openrun.list_sync()
for entry in (sync_ret.value if not sync_ret.error else []):
syncs[entry["id"]] = {
"id": entry["id"],
"repo": entry["path"],
"branch": entry["metadata"]["git_branch"],
"state": entry["status"]["state"], # Enabled / Disabled / Failing
"last_exec": utils.nonzero_time(entry["status"]["last_execution_time"]),
}
grouped = {} # sync id -> app rows, for apps last applied by a live sync
unmanaged = [] # created/last updated imperatively
tab_apps = [] # rows for the active promote/approval tab
total = 0
declarative_count = 0
promote_count = 0
approval_count = 0
for app in build_app_rows(all_apps):
total += 1
if app["is_declarative"]:
declarative_count += 1
if (filter == "declarative" and not app["is_declarative"]) or \
(filter == "imperative" and app["is_declarative"]):
continue
# The tab badge counts follow the active declarative/imperative
# filter, matching what the tab tables list
if app["staging"] and app["staging"]["ahead"]:
promote_count += 1
if app["needs_approval"]:
approval_count += 1
if tab == "promote" and app["staging"] and app["staging"]["ahead"]:
tab_apps.append(app)
elif tab == "approval" and app["needs_approval"]:
tab_apps.append(app)
if app["sync_id"] and app["sync_id"] in syncs:
grouped.setdefault(app["sync_id"], []).append(app)
else:
unmanaged.append(app)
# Most recently updated apps first, groups ordered by their most
# recently updated app
groups = []
for sync_id in grouped:
apps = utils.sort_recent(grouped[sync_id], "update_time", "path")
groups.append({
"sync": syncs[sync_id],
"apps": apps,
"newest": apps[0]["update_time"] if apps else "",
"repo": syncs[sync_id]["repo"],
})
data = {
"Title": "Apps",
"Nav": "apps",
"Query": query,
"Filter": filter,
"Tab": tab,
"TabApps": utils.sort_recent(tab_apps, "update_time", "path"),
"Groups": utils.sort_recent(groups, "newest", "repo"),
"Unmanaged": utils.sort_recent(unmanaged, "update_time", "path"),
"Total": total,
"DeclarativeCount": declarative_count,
"ImperativeCount": total - declarative_count,
"PromoteCount": promote_count,
"ApprovalCount": approval_count,
"Perms": utils.get_perms(),
}
if list_error:
# A malformed glob search (":" queries) renders the empty list with
# the parse error instead of crashing to the error page
data["FlashError"] = "Invalid app path glob: %s" % list_error
return data
def load_versions(path):
# Returns the version list for the app at path, newest first
ret = openrun.list_versions(path)
if ret.error:
return [], ret.error
versions = []
for entry in ret.value["versions"] or []:
vm = (entry.get("Metadata") or {}).get("version_metadata") or {}
versions.append({
"version": entry["Version"],
"previous": entry.get("PreviousVersion") or 0,
"active": entry.get("Active") or False,
"user": entry.get("UserId") or "",
"create_time": utils.nonzero_time(entry.get("CreateTime")),
"git_sha": utils.short_sha(vm.get("git_commit") or ""),
"git_message": vm.get("git_message") or "",
"git_branch": vm.get("git_branch") or "",
})
return sorted(versions, key=lambda v: v["version"], reverse=True), ""
def resolve_env_path(path, env):
# The prod app path is the external identifier; staging actions resolve
# the linked staging app's path through get_app
if env == "stage":
app_ret = openrun.get_app(path)
if not app_ret.error:
return app_ret.value["stage_path"]
return path
def version_options(path, app):
# The env-qualified version choices for the Compare/Files selects:
# staging versions first, newest first. Values are "env:version" specs;
# also returns the active version per env
options = []
stage_versions, stage_err = load_versions(app["stage_path"])
prod_versions, prod_err = load_versions(path)
active = {"stage": 0, "prod": 0}
for v in stage_versions:
version = int(v["version"])
if v["active"]:
active["stage"] = version
options.append({
"value": "stage:%d" % version,
"label": "v%d · staging%s" % (version, " (active)" if v["active"] else ""),
})
for v in prod_versions:
version = int(v["version"])
if v["active"]:
active["prod"] = version
options.append({
"value": "prod:%d" % version,
"label": "v%d · prod%s" % (version, " (active)" if v["active"] else ""),
})
return options, active, (stage_err or prod_err)
def version_spec_label(spec):
# Display label for an "env:version" spec
env, _, version = spec.partition(":")
env_label = "staging" if env == "stage" else "prod"
return ("v%s · %s" % (version, env_label)) if version else env_label
def diff_rows(diff):
# Normalize export_app_diff rows for the template: line number 0 means
# no line on that side (spacer row)
rows = []
for row in diff["rows"] or []:
left_line = int(row["left_line"])
right_line = int(row["right_line"])
rows.append({
"kind": row["kind"],
"left_line": ("%d" % left_line) if left_line else "",
"left_text": row["left_text"],
"right_line": ("%d" % right_line) if right_line else "",
"right_text": row["right_text"],
})
return rows
def export_lines(text):
# Split an export output into display lines for the numbered pane
return text.rstrip("\n").split("\n")
def detail_config_data(data, req):
# Config tab: the prod export output. When staging runs a different
# version, the view automatically becomes the prod <-> staging diff -
# the one-look answer to what is pending promotion
app = data["App"]
path = data["Path"]
cfg = {"Error": "", "Single": True, "Label": "", "Badge": "", "Lines": [],
"Rows": [], "Changed": 0, "LeftLabel": "", "RightLabel": ""}
data["Config"] = cfg
if app["is_dev"]:
ret = openrun.export_app(path)
error = ret.error
if error:
cfg["Error"] = error
return
cfg["Label"] = "Dev app declaration"
cfg["Lines"] = export_lines(ret.value)
return
prod_version = int(app["version"])
if not app["staged_changes"]:
ret = openrun.export_app(path)
error = ret.error
if error:
cfg["Error"] = error
return
cfg["Label"] = "Prod · v%d · staging in sync" % prod_version
cfg["Lines"] = export_lines(ret.value)
return
_, active, _ = version_options(path, app)
ret = openrun.export_app_diff(path, "prod:", "stage:")
error = ret.error
if error:
cfg["Error"] = error
return
stage_label = ("v%d" % active["stage"]) if active["stage"] else "version"
if not int(ret.value["changed"]):
# Staging is ahead but the declarative config is identical: the
# pending change is source-only. Two identical panes would read as
# a bug, so show the single config with an explaining badge
prod_ret = openrun.export_app(path)
prod_error = prod_ret.error
if prod_error:
cfg["Error"] = prod_error
return
cfg["Label"] = "Prod · v%d" % prod_version
cfg["Badge"] = "staging %s is ahead - source changed, config identical" % stage_label
cfg["Lines"] = export_lines(prod_ret.value)
return
cfg["Single"] = False
cfg["Label"] = "Prod · v%d" % prod_version
cfg["Badge"] = "staging %s differs - showing changes" % stage_label
cfg["LeftLabel"] = "prod · v%d" % prod_version
cfg["RightLabel"] = ("staging · v%d" % active["stage"]) if active["stage"] else "staging"
cfg["Rows"] = diff_rows(ret.value)
cfg["Changed"] = int(ret.value["changed"])
def detail_compare_data(data, req):
# Compare tab: any two versions side by side as export outputs. Defaults
# to prod active vs staging active when they differ, else the previous
# prod version vs the active one
app = data["App"]
path = data["Path"]
cmp = {"Error": "", "Options": [], "From": "", "To": "", "Rows": [],
"Changed": 0, "Same": False, "LeftLabel": "", "RightLabel": ""}
data["Compare"] = cmp
options, active, err = version_options(path, app)
if err and not options:
cmp["Error"] = err
return
cmp["Options"] = options
from_spec = utils.query_param(req, "from")
to_spec = utils.query_param(req, "to")
if not from_spec or not to_spec:
if app["staged_changes"] and active["stage"]:
from_spec = "prod:%d" % active["prod"]
to_spec = "stage:%d" % active["stage"]
else:
prod_opts = [o["value"] for o in options if o["value"].startswith("prod:")]
to_spec = prod_opts[0] if prod_opts else ""
from_spec = prod_opts[1] if len(prod_opts) > 1 else to_spec
cmp["From"] = from_spec
cmp["To"] = to_spec
if not from_spec or not to_spec:
cmp["Error"] = "No versions to compare yet"
return
ret = openrun.export_app_diff(path, from_spec, to_spec)
error = ret.error
if error:
cmp["Error"] = error
return
cmp["Rows"] = diff_rows(ret.value)
cmp["Changed"] = int(ret.value["changed"])
cmp["Same"] = cmp["Changed"] == 0
cmp["LeftLabel"] = version_spec_label(from_spec)
cmp["RightLabel"] = version_spec_label(to_spec)
def detail_files_data(data, req):
# Files tab: one version's file tree with the builder-files explorer.
# Defaults to the staging active version when it differs from prod
app = data["App"]
path = data["Path"]
ft = {"Error": "", "Options": [], "Selected": "", "Label": "", "Tree": [],
"Count": 0, "TotalSize": "", "DownloadUrl": "", "Endpoint": ""}
data["FilesTab"] = ft
options, active, err = version_options(path, app)
if err and not options:
ft["Error"] = err
return
ft["Options"] = options
sel = utils.query_param(req, "v")
if not sel:
if app["staged_changes"] and active["stage"]:
sel = "stage:%d" % active["stage"]
elif active["prod"]:
sel = "prod:%d" % active["prod"]
ft["Selected"] = sel
env, _, version = sel.partition(":")
if env not in ("prod", "stage") or not version:
ft["Error"] = "No version selected"
return
ft["Label"] = version_spec_label(sel)
resolved = resolve_env_path(path, env)
ret = openrun.list_version_files(resolved, version=version)
error = ret.error
if error:
ft["Error"] = error
return
names = []
total = 0
for entry in ret.value["files"] or []:
names.append(entry["Name"])
total += int(entry["Size"])
ft["Tree"] = build_file_tree(sorted(names))
ft["Count"] = len(names)
ft["TotalSize"] = utils.human_size(total)
ft["DownloadUrl"] = "%s/apps/files/download?path=%s&version=%s&env=%s" % (
req.AppPath, path, version, env)
ft["Endpoint"] = "%s/apps/version_file?app=%s&env=%s&version=%s" % (
req.AppPath, path, env, version)
def apps_detail_config_download_handler(req):
# GET: the prod export output as an apply-ready .star download
path = utils.query_param(req, "path")
ret = openrun.export_app(path)
if ret.error:
data = apps_detail_data(req)
data["FlashError"] = "Export failed: %s" % ret.error
return data
header = ("# Declarative config for %s, generated by the OpenRun console\n" +
"# Apply on an openrun instance with: openrun apply --approve <file>\n\n") % path
name = path.strip("/").replace("/", "_").replace(":", "_") or "app"
return ace.response(header + ret.value, download=name + ".star",
content_type="text/plain")
def apps_version_file_handler(req):
# TEXT route: raw content of one version file, rendered by the Files
# tab's <builder-files> viewer (client side syntax highlighting). The
# component sets the path query parameter to the selected file
app_path = utils.query_param(req, "app").strip()
env = utils.query_param(req, "env").strip() or "prod"
version = utils.query_param(req, "version").strip()
name = utils.query_param(req, "path").strip()
ret = openrun.get_version_file(resolve_env_path(app_path, env),
version=version, name=name)
if ret.error:
return "error: %s" % ret.error
return ret.value
ENV_ORDER = {"prod": "0", "stage": "1", "preview": "2", "dev": "3"}
def container_time_tip(c):
# Tooltip for a container row: when it started (running) or when it
# started and stopped (exited). Times come from list_containers
# times=True; empty when the runtime does not report them
started = short_ts(c.get("started_at"))
tip = c["name"]
if started:
tip += " · started " + started
if c["state"] != "running":
stopped = short_ts(c.get("finished_at"))
if stopped:
tip += " · stopped " + stopped
return tip
def short_ts(t):
# RFC3339 runtime timestamps (UTC, nanosecond precision) trimmed to
# seconds for tooltips: "2026-08-23 09:12:33 UTC"
t = t or ""
if len(t) < 19 or t.startswith("0001-"):
return ""
return t[:10] + " " + t[11:19] + " UTC"
def env_container_rows(app_conts, env):
# Rows of one environment's containers table on the app detail page:
# per type (the app container, each sidecar by name) the most recently
# started running container and the most recently stopped one, if any.
# On docker/podman sidecars are separate containers (role label); on
# kubernetes they run inside the app pods - one pod status call per
# running pod resolves their states, linking to the pod detail
groups = {}
order = []
def add(key, c):
if key not in groups:
groups[key] = []
order.append(key)
groups[key].append(c)
for c in app_conts:
if c["env"] != env or c["name"].endswith("-ls"):
continue
if c.get("job"):
# Job run containers are listed on the Jobs tab, per run
continue
if c.get("role") == "sidecar":
add("sidecar:" + c["sidecar"], c)
continue
add("app", c)
if c.get("sidecars") and c["state"] == "running":
status = openrun.container_kubernetes_status(c["id"])
error = status.error
states = {}
if not error:
for pc in status.value.get("containers") or []:
if pc.get("sidecar"):
states[pc["name"]] = "running" if pc["state"] == "running" and pc.get("ready", True) else pc["state"]
for name in c["sidecars"]:
e = dict(c.items())
e["role"] = "sidecar"
e["sidecar"] = name
e["state"] = states.get(name) or "unknown"
add("sidecar:" + name, e)