flow
This commit is contained in:
418
commands/bootstrap.py
Normal file
418
commands/bootstrap.py
Normal 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)
|
||||
Reference in New Issue
Block a user