mirror of
https://github.com/dredx/prole.git
synced 2026-09-24 16:04:31 +00:00
Separation of concerns: merge silent/UI actions & modularize screens. ProleInstallerBase (actions.py): Created shared base class with 37 deduplicated methods previously duplicated between ProleSilentInstaller and ProleInstaller. Namespace, environment, secret, deployment, port-forward, authority/repair, image, and logging helpers now defined once. Subclasses override _get_input() to bridge their data-access layers. screens.py -> screens/ package (18 mixin modules): Split 10,234-line monolithic screens.py into focused mixin modules: base, navigation, welcome, dependencies, network, environment, database, cluster, services, security, ollama, supabase, docker, build, packaging, deploy, validate, cfg. __init__.py composes ProleInstaller from all mixins and re-exports has_display(), main() for full backward compatibility. All 37 tests pass with no regressions.
433 lines
18 KiB
Python
433 lines
18 KiB
Python
"""System environment setup screen and env.sh management."""
|
|
|
|
import os
|
|
import shlex
|
|
import shutil
|
|
import subprocess
|
|
from pathlib import Path
|
|
import tkinter as tk
|
|
from tkinter import ttk, messagebox, filedialog
|
|
from installer import screen as ui
|
|
from installer.core.env import PROJECT_ROOT
|
|
|
|
|
|
class EnvironmentScreenMixin:
|
|
"""System environment setup screen and env.sh management."""
|
|
|
|
def _render_env_setup_page(self):
|
|
# Letterhead at top right
|
|
content_width = self.bg_canvas.winfo_width() or 975
|
|
right_margin = content_width - 48
|
|
|
|
ui.canvas_text(self, right_margin, 40, 'Prole', fill='#6e6e73', font=('SF Pro Text', 32, 'bold'), anchor='ne')
|
|
ui.canvas_text(self, right_margin, 85, "Personal Infrastructure.", fill='#6e6e73',
|
|
font=('SF Pro Text', 18), anchor='ne')
|
|
|
|
# Task heading for clarity
|
|
self._render_title('Environment', y=150)
|
|
self._render_paragraph('Configure your Prole environment. You can accept defaults or choose custom locations. Changing PROLE_HOME will update the defaults for other paths.', y=200)
|
|
|
|
defaults = self._env_defaults()
|
|
existing = self._read_existing_env()
|
|
values = {**defaults, **existing}
|
|
|
|
# Keep entry widgets to read values on Next
|
|
self._env_entries = {}
|
|
|
|
labels = [
|
|
('PROLE_HOME', 'Base directory for data, configs, logs, and scripts'),
|
|
('PROLE_CONF', 'Configuration directory'),
|
|
('PROLE_DATA', 'Data directory'),
|
|
('PROLE_LOGS', 'Logs directory'),
|
|
('PROLE_SERVICE', 'System scripts directory'),
|
|
]
|
|
|
|
x_label = 48
|
|
x_field = 300
|
|
x_btn = 850
|
|
y = 280
|
|
row_h = 75
|
|
|
|
def browse(var_name: str):
|
|
from tkinter import filedialog
|
|
initial = self._env_entries[var_name].get() or Path.home()
|
|
path = filedialog.askdirectory(title=f"Select {var_name}", initialdir=str(Path(initial).expanduser()))
|
|
if path:
|
|
self._env_entries[var_name].delete(0, 'end')
|
|
self._env_entries[var_name].insert(0, path)
|
|
if var_name == 'PROLE_HOME':
|
|
# Update dependent defaults if they still match old pattern
|
|
self._update_dependent_env_paths()
|
|
|
|
# Draw rows
|
|
for key, help_text in labels:
|
|
self._canvas_items.append(ui.canvas_text(self, x_label, y, key, fill='black', font=('SF Pro Text', 12, 'bold'), anchor='w'))
|
|
# Use tk.Entry and place on canvas via create_window
|
|
entry = tk.Entry(self.bg_canvas, bg='white', fg='black', insertbackground='black',
|
|
highlightbackground='#CCCCCC', highlightthickness=1, relief='flat', font=('SF Pro Text', 11))
|
|
entry.insert(0, values[key])
|
|
entry_window = self.bg_canvas.create_window(x_field, y, window=entry, anchor='w', width=500, height=32)
|
|
self._canvas_items.append(entry_window)
|
|
self._overlay_widgets.append(entry)
|
|
self._env_entries[key] = entry
|
|
|
|
# Use tk.Button and place on canvas via create_window
|
|
btn = tk.Button(self.bg_canvas, text='Browse…', command=lambda k=key: browse(k),
|
|
bg='#F5F5DC', fg='black', activebackground='#E5E5D5',
|
|
highlightbackground='#F5F5DC', highlightthickness=0,
|
|
relief='flat', font=('SF Pro Text', 11), padx=10, pady=4)
|
|
btn_window = self.bg_canvas.create_window(x_btn, y, window=btn, anchor='w', width=120)
|
|
self._canvas_items.append(btn_window)
|
|
self._overlay_widgets.append(btn)
|
|
|
|
# hint text
|
|
self._canvas_items.append(ui.canvas_text(self, x_field, y+24, help_text, fill='#6e6e73', font=('SF Pro Text', 10), anchor='nw'))
|
|
y += row_h
|
|
|
|
# When PROLE_HOME changes, update dependent paths that still follow default pattern
|
|
def on_home_change(_evt=None):
|
|
self._update_dependent_env_paths()
|
|
try:
|
|
self._env_entries['PROLE_HOME'].bind('<FocusOut>', on_home_change)
|
|
except Exception:
|
|
pass
|
|
|
|
def _after_env_saved(self):
|
|
"""Reload the installer process environment after saving env.sh."""
|
|
try:
|
|
self.reload_env_from_shell()
|
|
# Ensure we capture PROLE_HOME in Global section
|
|
self.prole_cfg_data['Global']['PROLE_HOME'] = os.environ.get('PROLE_HOME', '')
|
|
self._save_prole_cfg()
|
|
except Exception as e:
|
|
try:
|
|
messagebox.showwarning('Environment', f'Environment saved, but reload failed: {e}')
|
|
except Exception:
|
|
pass
|
|
|
|
def resolve_prole_home(self) -> Path:
|
|
"""Return PROLE_HOME or default to $HOME/.prole as a Path (expanded)."""
|
|
val = os.environ.get('PROLE_HOME')
|
|
if val:
|
|
try:
|
|
return Path(val).expanduser()
|
|
except Exception:
|
|
pass
|
|
return Path.home() / '.prole'
|
|
|
|
def ensure_prole_env(self) -> Path:
|
|
"""Ensure $PROLE_HOME and env.sh exist and are readable. Create minimal env if missing.
|
|
|
|
Returns the resolved PROLE_HOME Path. Raises Exception on failure.
|
|
"""
|
|
home = self.resolve_prole_home()
|
|
try:
|
|
home.mkdir(parents=True, exist_ok=True)
|
|
except Exception as e:
|
|
raise Exception(f"Failed to create PROLE_HOME at {home}: {e}")
|
|
env_file = home / 'env.sh'
|
|
if not env_file.exists():
|
|
# Construct minimal values based on defaults with this home
|
|
vals = {
|
|
'PROLE_HOME': str(home),
|
|
'PROLE_CONF': self._resolve_env_value('PROLE_CONF', str(home / 'conf')) or str(home / 'conf'),
|
|
'PROLE_DATA': self._resolve_env_value('PROLE_DATA', str(home / 'data')) or str(home / 'data'),
|
|
'PROLE_LOGS': self._resolve_env_value('PROLE_LOGS', str(home / 'logs')) or str(home / 'logs'),
|
|
'PROLE_SERVICE': self._resolve_env_value('PROLE_SERVICE', str(home / 'etc')) or str(home / 'etc'),
|
|
}
|
|
self._save_env_to_file(vals)
|
|
# Validate readability
|
|
try:
|
|
data = env_file.read_text()
|
|
if not isinstance(data, str) or len(data) == 0:
|
|
raise Exception("env.sh is empty")
|
|
except Exception as e:
|
|
raise Exception(f"env.sh not readable at {env_file}: {e}")
|
|
return home
|
|
|
|
def reload_env_from_shell(self) -> None:
|
|
"""Reload environment by sourcing $PROLE_HOME/env.sh in a login shell and merging into os.environ."""
|
|
home = self.ensure_prole_env()
|
|
env_file = home / 'env.sh'
|
|
# Use bash to source and print env in null-delimited form
|
|
cmd = (
|
|
f'export PROLE_HOME={shlex.quote(str(home))}; '
|
|
f'source {shlex.quote(str(env_file))}; '
|
|
'env -0'
|
|
)
|
|
try:
|
|
out = subprocess.check_output(['bash', '-lc', cmd])
|
|
except subprocess.CalledProcessError as e:
|
|
raise Exception(f"Failed to reload environment: {e}")
|
|
except Exception as e:
|
|
raise Exception(f"Failed to run bash to reload environment: {e}")
|
|
# Merge variables
|
|
try:
|
|
items = out.split(b'\x00')
|
|
for raw in items:
|
|
if not raw:
|
|
continue
|
|
try:
|
|
kv = raw.decode('utf-8', errors='ignore')
|
|
except Exception:
|
|
continue
|
|
if '=' not in kv:
|
|
continue
|
|
k, v = kv.split('=', 1)
|
|
# Avoid clobbering Python internals we rely on; safe list only
|
|
if k in ('PYTHONPATH', 'PYTHONHOME'):
|
|
continue
|
|
os.environ[k] = v
|
|
except Exception:
|
|
# Best-effort; ignore merge errors
|
|
pass
|
|
def _resolve_prole_conf_dir(self) -> Path:
|
|
return self._resolve_env_dir('PROLE_CONF', 'conf')
|
|
|
|
def _resolve_prole_logs_dir(self) -> Path:
|
|
return self._resolve_env_dir('PROLE_LOGS', 'logs')
|
|
|
|
def _registry_log_path(self) -> Path:
|
|
logs_dir = self._resolve_prole_logs_dir()
|
|
try:
|
|
logs_dir.mkdir(parents=True, exist_ok=True)
|
|
except Exception:
|
|
pass
|
|
path = logs_dir / "local_registry.log"
|
|
self._last_registry_log_path = path
|
|
return path
|
|
|
|
def _common_services_log_path(self) -> Path:
|
|
logs_dir = self._resolve_prole_logs_dir()
|
|
try:
|
|
logs_dir.mkdir(parents=True, exist_ok=True)
|
|
except Exception:
|
|
pass
|
|
path = logs_dir / "init_common_services.log"
|
|
self._last_common_services_log_path = path
|
|
return path
|
|
|
|
def _read_log_tail(self, path: Path, max_lines: int = 400) -> str:
|
|
try:
|
|
from collections import deque
|
|
with path.open('r', encoding='utf-8', errors='ignore') as handle:
|
|
tail = deque(handle, maxlen=max_lines)
|
|
return ''.join(tail)
|
|
except Exception:
|
|
return ""
|
|
|
|
def _restore_common_services_log(self, console) -> bool:
|
|
path = getattr(self, '_last_common_services_log_path', None)
|
|
if not path:
|
|
candidate = self._common_services_log_path()
|
|
if candidate.exists():
|
|
path = candidate
|
|
if not path or not path.exists():
|
|
return False
|
|
content = self._read_log_tail(path)
|
|
if not content:
|
|
return False
|
|
try:
|
|
console.clear()
|
|
console.write(f"Log file: {path}\n\n")
|
|
console.write(content)
|
|
except Exception:
|
|
return False
|
|
return True
|
|
|
|
def _ensure_prole_directories(self) -> None:
|
|
paths = {
|
|
'PROLE_HOME': self._resolve_env_value('PROLE_HOME', str(Path.home() / '.prole')),
|
|
'PROLE_CONF': str(self._resolve_prole_conf_dir()),
|
|
'PROLE_DATA': str(self._resolve_env_dir('PROLE_DATA', 'data')),
|
|
'PROLE_LOGS': str(self._resolve_prole_logs_dir()),
|
|
'PROLE_SERVICE': str(self._resolve_env_dir('PROLE_SERVICE', 'etc')),
|
|
}
|
|
for key, raw in paths.items():
|
|
try:
|
|
Path(raw).expanduser().mkdir(parents=True, exist_ok=True)
|
|
except Exception:
|
|
pass
|
|
if key not in os.environ and raw:
|
|
os.environ[key] = raw
|
|
|
|
def _record_install_log(self, path) -> None:
|
|
if not path:
|
|
return
|
|
try:
|
|
p = Path(path)
|
|
except Exception:
|
|
return
|
|
if not hasattr(self, '_install_run_log_paths'):
|
|
self._install_run_log_paths = []
|
|
sp = str(p)
|
|
if sp not in self._install_run_log_paths:
|
|
self._install_run_log_paths.append(sp)
|
|
|
|
def _update_env_namespace(self, namespace: str):
|
|
try:
|
|
existing = self._read_existing_env()
|
|
defaults = self._env_defaults()
|
|
values = {**defaults, **existing}
|
|
values['NAMESPACE'] = namespace
|
|
self._save_env_to_file(values)
|
|
os.environ['NAMESPACE'] = namespace
|
|
except Exception as e:
|
|
print(f"[WARN] Failed to update env.sh namespace: {e}")
|
|
|
|
def _save_env_to_file(self, values: dict):
|
|
home = Path(values['PROLE_HOME']).expanduser()
|
|
# ensure base dir exists
|
|
home.mkdir(parents=True, exist_ok=True)
|
|
# ensure subdirs exist
|
|
for key in ('PROLE_CONF', 'PROLE_DATA', 'PROLE_LOGS', 'PROLE_SERVICE'):
|
|
try:
|
|
Path(values[key]).expanduser().mkdir(parents=True, exist_ok=True)
|
|
except Exception:
|
|
pass
|
|
# write env.sh atomically
|
|
content = []
|
|
# Executable wrapper + sourceable config
|
|
content.append('#!/usr/bin/env bash')
|
|
content.append('# Prole environment configuration')
|
|
content.append('# This file is generated by the installer. Source it in new shells, or execute as a wrapper:')
|
|
content.append('# "$PROLE_HOME/env.sh" <command> [args…]')
|
|
content.append('# shellcheck shell=bash')
|
|
# Core PROLE_* directories
|
|
for k in ('PROLE_HOME','PROLE_CONF','PROLE_DATA','PROLE_LOGS','PROLE_SERVICE'):
|
|
content.append(f'export {k}="{values[k]}"')
|
|
if values.get('NAMESPACE'):
|
|
content.append(f'export NAMESPACE="{values["NAMESPACE"]}"')
|
|
content.append('')
|
|
# Ensure PATH contains common locations and $PROLE_HOME/bin (POSIX sh compatible)
|
|
content.append('# Ensure PATH works for GUI-launched shells (Docker, etc.)')
|
|
content.append('_prole_add_path() { case ":${PATH}:" in *":$1:"*) ;; *) PATH="$1:${PATH:-}" ;; esac; }')
|
|
content.append('_prole_add_path "$PROLE_HOME/bin"')
|
|
content.append('_prole_add_path "/opt/homebrew/bin"')
|
|
content.append('_prole_add_path "/usr/local/bin"')
|
|
content.append('_prole_add_path "/usr/bin"')
|
|
content.append('_prole_add_path "/bin"')
|
|
content.append('_prole_add_path "/usr/sbin"')
|
|
content.append('_prole_add_path "/sbin"')
|
|
content.append('export PATH')
|
|
content.append('')
|
|
content.append('# Add custom paths below if needed (examples):')
|
|
content.append('')
|
|
content.append('# If executed with arguments (and not sourced), run them under this environment')
|
|
content.append('if [[ "${BASH_SOURCE[0]}" == "${0}" ]] && [ "$#" -gt 0 ]; then')
|
|
content.append(' exec "$@"')
|
|
content.append('fi')
|
|
|
|
new_content = '\n'.join(content) + '\n'
|
|
out = home / 'env.sh'
|
|
|
|
should_write = True
|
|
if out.exists():
|
|
if out.read_text() == new_content:
|
|
should_write = False
|
|
# print(f"[OK] {out} is up to date.")
|
|
|
|
if should_write:
|
|
tmp = home / 'env.sh.tmp'
|
|
tmp.write_text(new_content)
|
|
tmp.replace(out)
|
|
try:
|
|
os.chmod(out, 0o755)
|
|
except Exception:
|
|
pass
|
|
# print(f"[OK] Wrote {out}")
|
|
|
|
# After creating env.sh, deploy additional resources as requested:
|
|
# 1) Deploy init-port-forward.sh into $PROLE_HOME (and ensure it's executable)
|
|
# 2) Copy contents of src/prole/etc (or fallback to top-level etc) into $PROLE_SERVICE
|
|
try:
|
|
prole_home = Path(values['PROLE_HOME']).expanduser()
|
|
prole_service = Path(values['PROLE_SERVICE']).expanduser()
|
|
|
|
# Determine source for init-port-forward.sh
|
|
# Preferred location under repo: PROJECT_ROOT/src/prole/etc/init-port-forward.sh
|
|
init_pf_src_candidates = [
|
|
PROJECT_ROOT / 'src' / 'prole' / 'etc' / 'init-port-forward.sh',
|
|
PROJECT_ROOT / 'etc' / 'init-port-forward.sh',
|
|
]
|
|
init_pf_src = next((p for p in init_pf_src_candidates if p.exists()), None)
|
|
if init_pf_src is not None:
|
|
init_pf_dst = prole_home / 'init-port-forward.sh'
|
|
try:
|
|
data = init_pf_src.read_bytes()
|
|
init_pf_dst.write_bytes(data)
|
|
os.chmod(init_pf_dst, 0o755)
|
|
except Exception:
|
|
# best effort, ignore errors
|
|
pass
|
|
|
|
# Determine etc directory source
|
|
etc_src_candidates = [
|
|
PROJECT_ROOT / 'src' / 'prole' / 'etc',
|
|
PROJECT_ROOT / 'etc',
|
|
]
|
|
etc_src = next((p for p in etc_src_candidates if p.exists() and p.is_dir()), None)
|
|
if etc_src is not None:
|
|
try:
|
|
prole_service.mkdir(parents=True, exist_ok=True)
|
|
except Exception:
|
|
pass
|
|
|
|
# Copy files (non-recursive: contents of etc root). If subdirectories exist, copy them recursively.
|
|
import shutil
|
|
|
|
def _copy_item(src_path: Path, dst_path: Path):
|
|
try:
|
|
if src_path.is_dir():
|
|
# copy directory tree
|
|
if dst_path.exists():
|
|
# remove then copy to keep in sync
|
|
shutil.rmtree(dst_path, ignore_errors=True)
|
|
shutil.copytree(src_path, dst_path)
|
|
else:
|
|
shutil.copy2(src_path, dst_path)
|
|
except Exception:
|
|
pass
|
|
|
|
try:
|
|
for item in etc_src.iterdir():
|
|
_copy_item(item, prole_service / item.name)
|
|
except Exception:
|
|
pass
|
|
except Exception:
|
|
# overall best-effort; do not fail env creation if copies fail
|
|
pass
|
|
|
|
def _update_dependent_env_paths(self):
|
|
try:
|
|
new_home = Path(self._env_entries['PROLE_HOME'].get()).expanduser()
|
|
except Exception:
|
|
return
|
|
mapping = {
|
|
'PROLE_CONF': new_home / 'conf',
|
|
'PROLE_DATA': new_home / 'data',
|
|
'PROLE_LOGS': new_home / 'logs',
|
|
'PROLE_SERVICE': new_home / 'etc',
|
|
}
|
|
# Update entries if they are empty or previously matched the old base
|
|
for key, new_path in mapping.items():
|
|
ent = self._env_entries.get(key)
|
|
if not ent:
|
|
continue
|
|
cur = ent.get().strip()
|
|
if not cur:
|
|
ent.delete(0, 'end')
|
|
ent.insert(0, str(new_path))
|
|
continue
|
|
# If cur looked like old_home/<suffix>, update it
|
|
try:
|
|
# detect suffix
|
|
suffix = new_path.name
|
|
if cur.endswith('/' + suffix):
|
|
# replace base path
|
|
ent.delete(0, 'end')
|
|
ent.insert(0, str(new_path))
|
|
except Exception:
|
|
pass
|
|
# done
|