prole/installer/ui/screens/environment.py
chrisfu ec50f82cc8 refactor: deduplicate installer business logic & split screens.py into package
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.
2026-02-20 14:43:20 -08:00

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