419 lines
15 KiB
Python
419 lines
15 KiB
Python
"""flow bootstrap — environment provisioning with plan-then-execute model."""
|
|
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Any, Dict, List
|
|
|
|
from flow.core.action import Action, ActionExecutor
|
|
from flow.core.config import FlowContext, load_manifest
|
|
from flow.core.paths import DOTFILES_DIR
|
|
from flow.core.process import run_command
|
|
from flow.core.variables import substitute
|
|
|
|
|
|
def register(subparsers):
|
|
p = subparsers.add_parser(
|
|
"bootstrap", aliases=["setup", "provision"],
|
|
help="Environment provisioning",
|
|
)
|
|
sub = p.add_subparsers(dest="bootstrap_command")
|
|
|
|
# run
|
|
run_p = sub.add_parser("run", help="Run bootstrap actions")
|
|
run_p.add_argument("--profile", help="Profile name to use")
|
|
run_p.add_argument("--dry-run", action="store_true", help="Show plan without executing")
|
|
run_p.add_argument("--var", action="append", default=[], help="Set variable KEY=VALUE")
|
|
run_p.set_defaults(handler=run_bootstrap)
|
|
|
|
# list
|
|
ls = sub.add_parser("list", help="List available profiles")
|
|
ls.set_defaults(handler=run_list)
|
|
|
|
# show
|
|
show = sub.add_parser("show", help="Show profile configuration")
|
|
show.add_argument("profile", help="Profile name")
|
|
show.set_defaults(handler=run_show)
|
|
|
|
p.set_defaults(handler=lambda ctx, args: p.print_help())
|
|
|
|
|
|
def _get_profiles(ctx: FlowContext) -> dict:
|
|
profiles = ctx.manifest.get("profiles")
|
|
if profiles is None:
|
|
if "environments" in ctx.manifest:
|
|
raise RuntimeError(
|
|
"Manifest key 'environments' is no longer supported. Rename it to 'profiles'."
|
|
)
|
|
return {}
|
|
|
|
if not isinstance(profiles, dict):
|
|
raise RuntimeError("Manifest key 'profiles' must be a mapping")
|
|
|
|
return profiles
|
|
|
|
|
|
def _parse_variables(var_args: list) -> dict:
|
|
variables = {}
|
|
for v in var_args:
|
|
if "=" not in v:
|
|
raise ValueError(f"Invalid --var value '{v}'. Expected KEY=VALUE")
|
|
key, value = v.split("=", 1)
|
|
if not key:
|
|
raise ValueError(f"Invalid --var value '{v}'. KEY cannot be empty")
|
|
variables[key] = value
|
|
return variables
|
|
|
|
|
|
def _plan_actions(ctx: FlowContext, profile_name: str, env_config: dict, variables: dict) -> List[Action]:
|
|
"""Plan all actions from a profile configuration."""
|
|
actions = []
|
|
|
|
# Variable checks
|
|
for req_var in env_config.get("requires", []):
|
|
actions.append(Action(
|
|
type="check-variable",
|
|
description=f"Check required variable: {req_var}",
|
|
data={"variable": req_var, "variables": variables},
|
|
skip_on_error=False,
|
|
))
|
|
|
|
# Hostname
|
|
if "hostname" in env_config:
|
|
hostname = substitute(env_config["hostname"], variables)
|
|
actions.append(Action(
|
|
type="set-hostname",
|
|
description=f"Set hostname to: {hostname}",
|
|
data={"hostname": hostname},
|
|
skip_on_error=False,
|
|
))
|
|
|
|
# Locale
|
|
if "locale" in env_config:
|
|
actions.append(Action(
|
|
type="set-locale",
|
|
description=f"Set locale to: {env_config['locale']}",
|
|
data={"locale": env_config["locale"]},
|
|
skip_on_error=True,
|
|
os_filter="linux",
|
|
))
|
|
|
|
# Shell
|
|
if "shell" in env_config:
|
|
actions.append(Action(
|
|
type="set-shell",
|
|
description=f"Set shell to: {env_config['shell']}",
|
|
data={"shell": env_config["shell"]},
|
|
skip_on_error=True,
|
|
os_filter="linux",
|
|
))
|
|
|
|
# Packages
|
|
if "packages" in env_config:
|
|
packages_config = env_config["packages"]
|
|
pm = env_config.get("package-manager", "apt-get")
|
|
|
|
# Package manager update
|
|
actions.append(Action(
|
|
type="pm-update",
|
|
description=f"Update {pm} package repositories",
|
|
data={"pm": pm},
|
|
skip_on_error=False,
|
|
))
|
|
|
|
# Standard packages
|
|
standard = []
|
|
for pkg in packages_config.get("standard", []) + packages_config.get("package", []):
|
|
if isinstance(pkg, str):
|
|
standard.append(pkg)
|
|
else:
|
|
standard.append(pkg["name"])
|
|
|
|
if standard:
|
|
actions.append(Action(
|
|
type="install-packages",
|
|
description=f"Install {len(standard)} packages via {pm}",
|
|
data={"pm": pm, "packages": standard, "type": "standard"},
|
|
skip_on_error=False,
|
|
))
|
|
|
|
# Cask packages (macOS)
|
|
cask = []
|
|
for pkg in packages_config.get("cask", []):
|
|
if isinstance(pkg, str):
|
|
cask.append(pkg)
|
|
else:
|
|
cask.append(pkg["name"])
|
|
|
|
if cask:
|
|
actions.append(Action(
|
|
type="install-packages",
|
|
description=f"Install {len(cask)} cask packages via {pm}",
|
|
data={"pm": pm, "packages": cask, "type": "cask"},
|
|
skip_on_error=False,
|
|
os_filter="macos",
|
|
))
|
|
|
|
# Binary packages
|
|
binaries_manifest = ctx.manifest.get("binaries", {})
|
|
for pkg in packages_config.get("binary", []):
|
|
pkg_name = pkg if isinstance(pkg, str) else pkg["name"]
|
|
binary_def = binaries_manifest.get(pkg_name, {})
|
|
actions.append(Action(
|
|
type="install-binary",
|
|
description=f"Install binary: {pkg_name}",
|
|
data={"name": pkg_name, "definition": binary_def, "spec": pkg if isinstance(pkg, dict) else {}},
|
|
skip_on_error=True,
|
|
))
|
|
|
|
# SSH keygen
|
|
for ssh_config in env_config.get("ssh_keygen", []):
|
|
filename = ssh_config.get("filename", f"id_{ssh_config['type']}")
|
|
actions.append(Action(
|
|
type="generate-ssh-key",
|
|
description=f"Generate SSH key: {filename}",
|
|
data=ssh_config,
|
|
skip_on_error=True,
|
|
))
|
|
|
|
# Config linking
|
|
for config in env_config.get("configs", []):
|
|
config_name = config if isinstance(config, str) else config["name"]
|
|
actions.append(Action(
|
|
type="link-config",
|
|
description=f"Link configuration: {config_name}",
|
|
data={"config_name": config_name},
|
|
skip_on_error=True,
|
|
))
|
|
|
|
# Custom commands
|
|
for i, command in enumerate(env_config.get("runcmd", []), 1):
|
|
actions.append(Action(
|
|
type="run-command",
|
|
description=f"Run custom command {i}",
|
|
data={"command": command},
|
|
skip_on_error=True,
|
|
))
|
|
|
|
return actions
|
|
|
|
|
|
def _register_handlers(executor: ActionExecutor, ctx: FlowContext, variables: dict):
|
|
"""Register all action type handlers."""
|
|
|
|
def handle_check_variable(data):
|
|
var = data["variable"]
|
|
if var not in data.get("variables", {}):
|
|
raise RuntimeError(f"Required variable not set: {var}")
|
|
|
|
def handle_set_hostname(data):
|
|
hostname = data["hostname"]
|
|
if ctx.platform.os == "macos":
|
|
run_command(f"sudo scutil --set ComputerName '{hostname}'", ctx.console)
|
|
run_command(f"sudo scutil --set HostName '{hostname}'", ctx.console)
|
|
run_command(f"sudo scutil --set LocalHostName '{hostname}'", ctx.console)
|
|
else:
|
|
run_command(f"sudo hostnamectl set-hostname '{hostname}'", ctx.console)
|
|
|
|
def handle_set_locale(data):
|
|
locale = data["locale"]
|
|
run_command(f"sudo locale-gen {locale}", ctx.console)
|
|
run_command(f"sudo update-locale LANG={locale}", ctx.console)
|
|
|
|
def handle_set_shell(data):
|
|
shell = data["shell"]
|
|
shell_path = shutil.which(shell)
|
|
if not shell_path:
|
|
raise RuntimeError(f"Shell not found: {shell}")
|
|
try:
|
|
with open("/etc/shells") as f:
|
|
if shell_path not in f.read():
|
|
run_command(f"echo '{shell_path}' | sudo tee -a /etc/shells", ctx.console)
|
|
except FileNotFoundError:
|
|
pass
|
|
run_command(f"chsh -s {shell_path}", ctx.console)
|
|
|
|
def handle_pm_update(data):
|
|
pm = data["pm"]
|
|
commands = {
|
|
"apt-get": "sudo apt-get update -qq",
|
|
"apt": "sudo apt update -qq",
|
|
"brew": "brew update",
|
|
}
|
|
cmd = commands.get(pm, f"sudo {pm} update")
|
|
run_command(cmd, ctx.console)
|
|
|
|
def handle_install_packages(data):
|
|
pm = data["pm"]
|
|
packages = data["packages"]
|
|
pkg_type = data.get("type", "standard")
|
|
pkg_str = " ".join(packages)
|
|
|
|
if pm in ("apt-get", "apt"):
|
|
cmd = f"sudo {pm} install -y {pkg_str}"
|
|
elif pm == "brew" and pkg_type == "cask":
|
|
cmd = f"brew install --cask {pkg_str}"
|
|
elif pm == "brew":
|
|
cmd = f"brew install {pkg_str}"
|
|
else:
|
|
cmd = f"sudo {pm} install {pkg_str}"
|
|
|
|
run_command(cmd, ctx.console)
|
|
|
|
def handle_install_binary(data):
|
|
from flow.core.variables import substitute_template
|
|
pkg_name = data["name"]
|
|
pkg_def = data["definition"]
|
|
if not pkg_def:
|
|
raise RuntimeError(f"No binary definition for: {pkg_name}")
|
|
|
|
source = pkg_def.get("source", "")
|
|
if not source.startswith("github:"):
|
|
raise RuntimeError(f"Unsupported source: {source}")
|
|
|
|
owner_repo = source[len("github:"):]
|
|
version = pkg_def.get("version", "")
|
|
asset_pattern = pkg_def.get("asset-pattern", "")
|
|
platform_map = pkg_def.get("platform-map", {})
|
|
mapping = platform_map.get(ctx.platform.platform)
|
|
if not mapping:
|
|
raise RuntimeError(f"No platform mapping for {ctx.platform.platform}")
|
|
|
|
template_ctx = {**mapping, "version": version}
|
|
asset = substitute_template(asset_pattern, template_ctx)
|
|
url = f"https://github.com/{owner_repo}/releases/download/v{version}/{asset}"
|
|
template_ctx["downloadUrl"] = url
|
|
|
|
install_script = pkg_def.get("install-script", "")
|
|
if install_script:
|
|
resolved = substitute_template(install_script, template_ctx)
|
|
run_command(resolved, ctx.console)
|
|
|
|
def handle_generate_ssh_key(data):
|
|
ssh_dir = Path.home() / ".ssh"
|
|
ssh_dir.mkdir(mode=0o700, exist_ok=True)
|
|
key_type = data["type"]
|
|
comment = substitute(data.get("comment", ""), variables)
|
|
filename = data.get("filename", f"id_{key_type}")
|
|
key_path = ssh_dir / filename
|
|
if key_path.exists():
|
|
ctx.console.warn(f"SSH key already exists: {key_path}")
|
|
return
|
|
run_command(f'ssh-keygen -t {key_type} -f "{key_path}" -N "" -C "{comment}"', ctx.console)
|
|
|
|
def handle_link_config(data):
|
|
config_name = data["config_name"]
|
|
ctx.console.info(f"Linking config: {config_name}")
|
|
|
|
def handle_run_command(data):
|
|
command = substitute(data["command"], variables)
|
|
run_command(command, ctx.console)
|
|
|
|
executor.register("check-variable", handle_check_variable)
|
|
executor.register("set-hostname", handle_set_hostname)
|
|
executor.register("set-locale", handle_set_locale)
|
|
executor.register("set-shell", handle_set_shell)
|
|
executor.register("pm-update", handle_pm_update)
|
|
executor.register("install-packages", handle_install_packages)
|
|
executor.register("install-binary", handle_install_binary)
|
|
executor.register("generate-ssh-key", handle_generate_ssh_key)
|
|
executor.register("link-config", handle_link_config)
|
|
executor.register("run-command", handle_run_command)
|
|
|
|
|
|
def run_bootstrap(ctx: FlowContext, args):
|
|
# Check if flow package exists in dotfiles and link it first
|
|
flow_pkg = DOTFILES_DIR / "common" / "flow"
|
|
if flow_pkg.exists() and (flow_pkg / ".config" / "flow").exists():
|
|
ctx.console.info("Found flow config in dotfiles, linking...")
|
|
# Link flow package first
|
|
result = subprocess.run(
|
|
[sys.executable, "-m", "flow", "dotfiles", "link", "flow"],
|
|
capture_output=True, text=True,
|
|
)
|
|
if result.returncode == 0:
|
|
ctx.console.success("Flow config linked from dotfiles")
|
|
# Reload manifest from newly linked location
|
|
ctx.manifest = load_manifest()
|
|
else:
|
|
detail = (result.stderr or "").strip() or (result.stdout or "").strip() or "unknown error"
|
|
ctx.console.warn(f"Failed to link flow config: {detail}")
|
|
|
|
profiles = _get_profiles(ctx)
|
|
if not profiles:
|
|
ctx.console.error("No profiles found in manifest.")
|
|
sys.exit(1)
|
|
|
|
profile_name = args.profile
|
|
if not profile_name:
|
|
if len(profiles) == 1:
|
|
profile_name = next(iter(profiles))
|
|
else:
|
|
ctx.console.error(f"Multiple profiles available. Specify with --profile: {', '.join(profiles.keys())}")
|
|
sys.exit(1)
|
|
|
|
if profile_name not in profiles:
|
|
ctx.console.error(f"Profile not found: {profile_name}. Available: {', '.join(profiles.keys())}")
|
|
sys.exit(1)
|
|
|
|
env_config = profiles[profile_name]
|
|
|
|
profile_os = env_config.get("os")
|
|
if profile_os and profile_os != ctx.platform.os:
|
|
ctx.console.error(
|
|
f"Profile '{profile_name}' targets '{profile_os}', current OS is '{ctx.platform.os}'"
|
|
)
|
|
sys.exit(1)
|
|
|
|
try:
|
|
variables = _parse_variables(args.var)
|
|
except ValueError as e:
|
|
ctx.console.error(str(e))
|
|
sys.exit(1)
|
|
|
|
actions = _plan_actions(ctx, profile_name, env_config, variables)
|
|
executor = ActionExecutor(ctx.console)
|
|
_register_handlers(executor, ctx, variables)
|
|
executor.execute(actions, dry_run=args.dry_run, current_os=ctx.platform.os)
|
|
|
|
|
|
def run_list(ctx: FlowContext, args):
|
|
profiles = _get_profiles(ctx)
|
|
if not profiles:
|
|
ctx.console.info("No profiles defined in manifest.")
|
|
return
|
|
|
|
headers = ["PROFILE", "OS", "PACKAGES", "ACTIONS"]
|
|
rows = []
|
|
for name, config in sorted(profiles.items()):
|
|
os_name = config.get("os", "any")
|
|
pkg_count = 0
|
|
for section in config.get("packages", {}).values():
|
|
if isinstance(section, list):
|
|
pkg_count += len(section)
|
|
action_count = sum(1 for k in ("hostname", "locale", "shell") if k in config)
|
|
action_count += len(config.get("ssh_keygen", []))
|
|
action_count += len(config.get("configs", []))
|
|
action_count += len(config.get("runcmd", []))
|
|
rows.append([name, os_name, str(pkg_count), str(action_count)])
|
|
|
|
ctx.console.table(headers, rows)
|
|
|
|
|
|
def run_show(ctx: FlowContext, args):
|
|
profiles = _get_profiles(ctx)
|
|
profile_name = args.profile
|
|
|
|
if profile_name not in profiles:
|
|
ctx.console.error(f"Profile not found: {profile_name}. Available: {', '.join(profiles.keys())}")
|
|
sys.exit(1)
|
|
|
|
env_config = profiles[profile_name]
|
|
variables = {}
|
|
actions = _plan_actions(ctx, profile_name, env_config, variables)
|
|
|
|
executor = ActionExecutor(ctx.console)
|
|
executor.execute(actions, dry_run=True)
|