This commit is contained in:
2026-02-12 09:42:59 +02:00
commit 906adb539d
87 changed files with 5288 additions and 0 deletions

418
commands/bootstrap.py Normal file
View File

@@ -0,0 +1,418 @@
"""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)