Skip to content

Commit 8c62590

Browse files
committed
feat(setup): implement project setup with preinstall support and locking mechanism
Adds a preinstall feature to service setup, allowing for preparatory installations before service startup. This improves initial startup times and dependency handling. Also introduces an apt locking mechanism to prevent conflicts during package management operations, ensuring smoother installations and updates.
1 parent b04b9cf commit 8c62590

8 files changed

Lines changed: 487 additions & 126 deletions

File tree

main.py

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
from utils.auto_update import Update
99
from utils.dependencies import initialize_dependencies
1010
from utils.plex_dbrepair import start_plex_dbrepair_worker
11-
from concurrent.futures import ThreadPoolExecutor, wait, FIRST_COMPLETED
11+
from utils.setup import setup_project
12+
from concurrent.futures import ThreadPoolExecutor, wait, FIRST_COMPLETED, as_completed
1213
import subprocess, threading, time, tomllib, os, socket, errno, psutil, json, urllib.parse
1314

1415

@@ -541,6 +542,58 @@ def _apply_waits_to_service(config_manager, key: str, wait_entries: list[dict])
541542
_set_wait_for_urls(cfg, wait_entries)
542543

543544

545+
def _collect_enabled_process_names(config_manager) -> list[str]:
546+
process_names = []
547+
for key, cfg in config_manager.config.items():
548+
if not isinstance(cfg, dict):
549+
continue
550+
if key == "dumb":
551+
continue
552+
if "instances" in cfg and isinstance(cfg["instances"], dict):
553+
for inst_cfg in cfg["instances"].values():
554+
if isinstance(inst_cfg, dict) and inst_cfg.get("enabled"):
555+
process_name = inst_cfg.get("process_name")
556+
if process_name:
557+
process_names.append(process_name)
558+
continue
559+
if cfg.get("enabled"):
560+
process_name = cfg.get("process_name")
561+
if process_name:
562+
process_names.append(process_name)
563+
return process_names
564+
565+
566+
def _preinstall_enabled_services(process_handler, config_manager) -> None:
567+
process_names = _collect_enabled_process_names(config_manager)
568+
if not process_names:
569+
return
570+
logger.info("Pre-installing enabled services before startup.")
571+
max_workers = min(4, max(1, len(process_names)))
572+
573+
def _run_preinstall(name: str) -> None:
574+
if process_handler.shutting_down:
575+
return
576+
with process_handler.process_context(name):
577+
key, _ = config_manager.find_key_for_process(name)
578+
if key in {"pgadmin", "postgres"}:
579+
logger.info("Preinstall skip for %s; requires running dependency.", name)
580+
return
581+
success, error = setup_project(process_handler, name, preinstall=True)
582+
if not success:
583+
raise RuntimeError(error)
584+
585+
with ThreadPoolExecutor(max_workers=max_workers) as executor:
586+
futures = {executor.submit(_run_preinstall, name): name for name in process_names}
587+
for future in as_completed(futures):
588+
name = futures[future]
589+
try:
590+
future.result()
591+
except Exception as e:
592+
logger.error("Pre-install failed for %s: %s", name, e)
593+
process_handler.shutdown(exit_code=1)
594+
raise
595+
596+
544597
def _build_dependency_map(config_manager) -> dict[str, set[str]]:
545598
deps = {
546599
"riven_backend": {"postgres"},
@@ -676,6 +729,7 @@ def main():
676729
_apply_prowlarr_waits(config, _collect_arr_ping_waits(config))
677730
_apply_waits_to_service(config, "tautulli", _build_plex_wait_entries(config))
678731
_apply_waits_to_service(config, "seerr", _build_media_wait_entries(config))
732+
_preinstall_enabled_services(process_handler, config)
679733

680734
if config.get("dumb", {}).get("api_service", {}).get("enabled"):
681735
start_fastapi_process()

utils/apt_lock.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
from contextlib import contextmanager
2+
import threading
3+
import time
4+
import subprocess
5+
6+
from utils.global_logger import logger
7+
8+
9+
_APT_LOCK = threading.Lock()
10+
11+
12+
def _looks_like_lock_error(stderr: str) -> bool:
13+
if not stderr:
14+
return False
15+
needle = stderr.lower()
16+
return (
17+
"dpkg frontend lock" in needle
18+
or "could not get lock" in needle
19+
or "unable to acquire the dpkg frontend lock" in needle
20+
or "could not acquire dpkg frontend lock" in needle
21+
)
22+
23+
24+
@contextmanager
25+
def apt_lock():
26+
_APT_LOCK.acquire()
27+
try:
28+
yield
29+
finally:
30+
_APT_LOCK.release()
31+
32+
33+
def run_locked(cmd, *, retries: int = 6, delay_s: float = 5.0, **kwargs):
34+
with apt_lock():
35+
last_err = None
36+
for attempt in range(max(1, retries)):
37+
try:
38+
return subprocess.run(cmd, **kwargs)
39+
except subprocess.CalledProcessError as e:
40+
stderr = getattr(e, "stderr", "") or ""
41+
if not _looks_like_lock_error(stderr) or attempt == retries - 1:
42+
raise
43+
wait_s = delay_s * (attempt + 1)
44+
logger.warning(
45+
"dpkg/apt lock busy; retrying in %.1fs (%s)",
46+
wait_s,
47+
cmd,
48+
)
49+
time.sleep(wait_s)
50+
last_err = e
51+
if last_err:
52+
raise last_err

utils/auto_update.py

Lines changed: 27 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -203,8 +203,9 @@ def update_check(self, process_name, config, key, instance_name):
203203
)
204204
if process_name in self.process_handler.process_names:
205205
self.stop_process(process_name)
206-
if process_name in self.process_handler.setup_tracker:
207-
self.process_handler.setup_tracker.remove(process_name)
206+
with self.process_handler.setup_tracker_lock:
207+
if process_name in self.process_handler.setup_tracker:
208+
self.process_handler.setup_tracker.remove(process_name)
208209
release_version = f"{update_info.get('latest_version')}"
209210
if not prerelease and not nightly:
210211
config["release_version"] = release_version
@@ -256,8 +257,9 @@ def update_check_pinned_version(
256257
)
257258
if process_name in self.process_handler.process_names:
258259
self.stop_process(process_name)
259-
if process_name in self.process_handler.setup_tracker:
260-
self.process_handler.setup_tracker.remove(process_name)
260+
with self.process_handler.setup_tracker_lock:
261+
if process_name in self.process_handler.setup_tracker:
262+
self.process_handler.setup_tracker.remove(process_name)
261263

262264
success, error = setup_project(self.process_handler, process_name)
263265
if not success:
@@ -295,8 +297,9 @@ def update_check_jellyfin_latest(self, process_name, config, key, instance_name)
295297
)
296298
if process_name in self.process_handler.process_names:
297299
self.stop_process(process_name)
298-
if process_name in self.process_handler.setup_tracker:
299-
self.process_handler.setup_tracker.remove(process_name)
300+
with self.process_handler.setup_tracker_lock:
301+
if process_name in self.process_handler.setup_tracker:
302+
self.process_handler.setup_tracker.remove(process_name)
300303

301304
installer = JellyfinInstaller()
302305
success, error = installer.install_jellyfin_server()
@@ -335,8 +338,9 @@ def update_check_emby_latest(self, process_name, config, key, instance_name):
335338
)
336339
if process_name in self.process_handler.process_names:
337340
self.stop_process(process_name)
338-
if process_name in self.process_handler.setup_tracker:
339-
self.process_handler.setup_tracker.remove(process_name)
341+
with self.process_handler.setup_tracker_lock:
342+
if process_name in self.process_handler.setup_tracker:
343+
self.process_handler.setup_tracker.remove(process_name)
340344

341345
original_release_enabled = config.get("release_version_enabled")
342346
original_release_version = config.get("release_version")
@@ -376,8 +380,9 @@ def update_check_arr_latest(self, process_name, config, key, instance_name):
376380
)
377381
if process_name in self.process_handler.process_names:
378382
self.stop_process(process_name)
379-
if process_name in self.process_handler.setup_tracker:
380-
self.process_handler.setup_tracker.remove(process_name)
383+
with self.process_handler.setup_tracker_lock:
384+
if process_name in self.process_handler.setup_tracker:
385+
self.process_handler.setup_tracker.remove(process_name)
381386

382387
success, error = installer.install()
383388
if not success:
@@ -479,8 +484,9 @@ def update_check_plex(self, process_name, config, key, instance_name):
479484
)
480485
if process_name in self.process_handler.process_names:
481486
self.stop_process(process_name)
482-
if process_name in self.process_handler.setup_tracker:
483-
self.process_handler.setup_tracker.remove(process_name)
487+
with self.process_handler.setup_tracker_lock:
488+
if process_name in self.process_handler.setup_tracker:
489+
self.process_handler.setup_tracker.remove(process_name)
484490

485491
success, error = installer.install_plex_media_server()
486492
if not success:
@@ -512,6 +518,7 @@ def start_process(self, process_name, config, key, instance_name):
512518
instance_name = refreshed_instance
513519

514520
if config.get("wait_for_dir", False):
521+
sleep_s = 10
515522
while not os.path.exists(wait_dir := config["wait_for_dir"]):
516523
if self.process_handler.shutting_down:
517524
self.logger.info(
@@ -522,10 +529,12 @@ def start_process(self, process_name, config, key, instance_name):
522529
self.logger.info(
523530
f"Waiting for directory {wait_dir} to become available before starting {process_name}"
524531
)
525-
time.sleep(10)
532+
time.sleep(sleep_s)
533+
sleep_s = min(60, int(sleep_s * 1.5))
526534

527535
wait_mounts = config.get("wait_for_mounts") or []
528536
if wait_mounts:
537+
sleep_s = 10
529538
while True:
530539
if self.process_handler.shutting_down:
531540
self.logger.info(
@@ -545,7 +554,8 @@ def start_process(self, process_name, config, key, instance_name):
545554
process_name,
546555
", ".join(missing),
547556
)
548-
time.sleep(10)
557+
time.sleep(sleep_s)
558+
sleep_s = min(60, int(sleep_s * 1.5))
549559

550560
if config.get("wait_for_url", False):
551561
wait_for_urls = config["wait_for_url"]
@@ -560,6 +570,7 @@ def start_process(self, process_name, config, key, instance_name):
560570
f"Waiting to start {process_name} until {wait_url} is accessible."
561571
)
562572

573+
sleep_s = 5
563574
while time.time() - start_time < 600:
564575
if self.process_handler.shutting_down:
565576
self.logger.info(
@@ -589,7 +600,8 @@ def start_process(self, process_name, config, key, instance_name):
589600
)
590601
except requests.RequestException as e:
591602
logger.debug(f"Waiting for {wait_url}: {e}")
592-
time.sleep(5)
603+
time.sleep(sleep_s)
604+
sleep_s = min(60, int(sleep_s * 1.5))
593605
else:
594606
raise RuntimeError(
595607
f"Timeout: {wait_url} is not accessible after 600 seconds."

utils/jellyfin.py

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from utils.global_logger import logger
2+
from utils.apt_lock import run_locked, apt_lock
23
import subprocess, os
34

45

@@ -32,18 +33,19 @@ def install_jellyfin_server(self, version=None):
3233
self.logger.info("Installing Jellyfin media server...")
3334

3435
# Step 1: Ensure required tools
35-
subprocess.run(["apt", "update"], check=True)
36-
subprocess.run(["apt", "install", "-y", "gnupg", "curl"], check=True)
36+
run_locked(["apt", "update"], check=True)
37+
run_locked(["apt", "install", "-y", "gnupg", "curl"], check=True)
3738

3839
# Step 2: Add universe repo
3940
with open("/etc/os-release") as f:
4041
os_release = f.read().lower()
4142
if "ubuntu" in os_release:
42-
subprocess.run(["add-apt-repository", "-y", "universe"], check=True)
43+
run_locked(["add-apt-repository", "-y", "universe"], check=True)
4344

4445
# Step 3: Create keyring and download GPG key
4546
os.makedirs("/etc/apt/keyrings", exist_ok=True)
46-
self.download_and_install_jellyfin_gpg_key()
47+
with apt_lock():
48+
self.download_and_install_jellyfin_gpg_key()
4749

4850
# Step 4: Create sources file
4951
version_os = subprocess.check_output(
@@ -72,15 +74,15 @@ def install_jellyfin_server(self, version=None):
7274
f.write(sources_content)
7375

7476
# Step 5: Update apt
75-
subprocess.run(["apt", "update"], check=True)
77+
run_locked(["apt", "update"], check=True)
7678

7779
# Step 6: Install jellyfin metapackage
7880
if version:
79-
subprocess.run(
81+
run_locked(
8082
["apt", "install", "-y", f"jellyfin={version}"], check=True
8183
)
8284
else:
83-
subprocess.run(["apt", "install", "-y", "jellyfin"], check=True)
85+
run_locked(["apt", "install", "-y", "jellyfin"], check=True)
8486

8587
# Step 7: Ensure web client is available
8688
expected_web_path = "/usr/lib/jellyfin/bin/jellyfin-web"

utils/plex.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from utils.global_logger import logger
2+
from utils.apt_lock import run_locked
23
from utils.config_loader import CONFIG_MANAGER
34
from utils.versions import Versions
45
import os, platform, subprocess, tempfile, requests
@@ -98,7 +99,7 @@ def install_plex_media_server(self, version=None):
9899
for chunk in r.iter_content(chunk_size=8192):
99100
tmp_file.write(chunk)
100101

101-
subprocess.run(
102+
run_locked(
102103
[
103104
"dpkg",
104105
"-i",

utils/postgres.py

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -620,7 +620,8 @@ def pgadmin_setup(process_handler):
620620

621621
pgadmin_db_uri = f"postgresql://{postgres_user}:{postgres_password}@{postgres_host}:{postgres_port}/pgadmin"
622622

623-
process_handler.setup_tracker.add(pgadmin_process_name)
623+
with process_handler.setup_tracker_lock:
624+
process_handler.setup_tracker.add(pgadmin_process_name)
624625
success, error = start_pgadmin(
625626
process_handler,
626627
pgadmin_config_dir,
@@ -633,7 +634,8 @@ def pgadmin_setup(process_handler):
633634
pgadmin_default_server,
634635
)
635636
if not success:
636-
process_handler.setup_tracker.remove(pgadmin_process_name)
637+
with process_handler.setup_tracker_lock:
638+
process_handler.setup_tracker.remove(pgadmin_process_name)
637639
return False, error
638640

639641
server_details = {
@@ -647,7 +649,8 @@ def pgadmin_setup(process_handler):
647649

648650
success, error = add_pgadmin_server_to_db(pgadmin_db_uri, server_details)
649651
if not success:
650-
process_handler.setup_tracker.remove(pgadmin_process_name)
652+
with process_handler.setup_tracker_lock:
653+
process_handler.setup_tracker.remove(pgadmin_process_name)
651654
return False, error
652655

653656
success, error = start_pgagent(
@@ -657,11 +660,13 @@ def pgadmin_setup(process_handler):
657660
return False, error
658661

659662
logger.info("pgAdmin setup completed successfully.")
660-
process_handler.setup_tracker.add(pgadmin_process_name)
663+
with process_handler.setup_tracker_lock:
664+
process_handler.setup_tracker.add(pgadmin_process_name)
661665
return True, None
662666

663667
except Exception as e:
664-
process_handler.setup_tracker.remove(pgadmin_process_name)
668+
with process_handler.setup_tracker_lock:
669+
process_handler.setup_tracker.remove(pgadmin_process_name)
665670
return False, f"Unhandled exception during pgAdmin setup: {e}"
666671

667672

@@ -1002,7 +1007,8 @@ def postgres_setup(process_handler=None):
10021007
postgres_config_dir=postgres_config_dir,
10031008
postgres_config_file=postgres_config_file,
10041009
)
1005-
process_handler.setup_tracker.add(postgres_process_name)
1010+
with process_handler.setup_tracker_lock:
1011+
process_handler.setup_tracker.add(postgres_process_name)
10061012
process = process_handler.start_process(
10071013
postgres_process_name, postgres_config_dir, postgres_command
10081014
)

0 commit comments

Comments
 (0)