Files
unitforge/backend/cli/__init__.py
William Valentin 860f60591c Fix contrast issues with text-muted and bg-dark classes
- Fixed Bootstrap bg-dark class to use better contrasting color
- Added comprehensive text-muted contrast fixes for various contexts
- Improved dark theme colors for better accessibility
- Fixed CSS inheritance issues for code elements in dark contexts
- All color choices meet WCAG AA contrast requirements
2025-09-14 14:58:35 -07:00

549 lines
18 KiB
Python

"""
UnitForge CLI - Command line interface for systemd unit file management.
This module provides a comprehensive command-line interface for creating,
validating, and managing systemd unit files.
"""
import sys
from pathlib import Path
from typing import Any, Dict
import click
# Add the backend directory to the path so we can import our modules
# This needs to be done before importing our app modules
_backend_path = str(Path(__file__).parent.parent)
if _backend_path not in sys.path:
sys.path.insert(0, _backend_path)
# Import our modules after path setup
from app.core.templates import template_registry # noqa: E402
from app.core.unit_file import SystemdUnitFile, UnitType, create_unit_file # noqa: E402
@click.group()
@click.version_option(version="1.0.0")
def cli():
"""
UnitForge - Create, validate, and manage systemd unit files.
A comprehensive tool for working with systemd unit files from the command line.
"""
pass
@cli.command()
@click.argument("file_path", type=click.Path(exists=True))
@click.option("--verbose", "-v", is_flag=True, help="Show detailed validation output")
@click.option(
"--warnings", "-w", is_flag=True, help="Show warnings in addition to errors"
)
def validate(file_path: str, verbose: bool, warnings: bool):
"""Validate a systemd unit file."""
try:
unit_file = SystemdUnitFile(file_path=file_path)
validation_errors = unit_file.validate()
errors = [e for e in validation_errors if e.severity == "error"]
warning_list = [e for e in validation_errors if e.severity != "error"]
if not errors and not warning_list:
click.echo(click.style("✓ Unit file is valid", fg="green"))
return
if errors:
click.echo(click.style(f"✗ Found {len(errors)} error(s):", fg="red"))
for error in errors:
location = f"[{error.section}"
if error.key:
location += f".{error.key}"
location += "]"
click.echo(f" {location} {error.message}")
if warnings and warning_list:
click.echo(
click.style(f"⚠ Found {len(warning_list)} warning(s):", fg="yellow")
)
for warning in warning_list:
location = f"[{warning.section}"
if warning.key:
location += f".{warning.key}"
location += "]"
click.echo(f" {location} {warning.message}")
if verbose:
click.echo("\nUnit file info:")
info = unit_file.get_info()
if info.description:
click.echo(f" Description: {info.description}")
if info.unit_type:
click.echo(f" Type: {info.unit_type.value}")
if info.requires:
click.echo(f" Requires: {', '.join(info.requires)}")
if info.wants:
click.echo(f" Wants: {', '.join(info.wants)}")
sys.exit(1 if errors else 0)
except Exception as e:
click.echo(click.style(f"Error: {str(e)}", fg="red"))
sys.exit(1)
@cli.command()
@click.option(
"--type",
"unit_type",
type=click.Choice(["service", "timer", "socket", "mount", "target"]),
required=True,
help="Type of unit file to create",
)
@click.option("--name", required=True, help="Name of the unit")
@click.option("--description", help="Description of the unit")
@click.option("--exec-start", help="Command to execute (for services)")
@click.option("--user", help="User to run the service as")
@click.option("--group", help="Group to run the service as")
@click.option("--working-directory", help="Working directory for the service")
@click.option(
"--restart",
type=click.Choice(["no", "always", "on-failure", "on-success"]),
default="on-failure",
help="Restart policy",
)
@click.option(
"--wanted-by",
multiple=True,
help="Target to be wanted by (can be used multiple times)",
)
@click.option("--output", "-o", help="Output file path")
@click.option(
"--validate-output", is_flag=True, help="Validate the generated unit file"
)
def create(
unit_type: str,
name: str,
description: str,
exec_start: str,
user: str,
group: str,
working_directory: str,
restart: str,
wanted_by: tuple,
output: str,
validate_output: bool,
):
"""Create a new systemd unit file."""
try:
# Convert string to enum
unit_type_enum = UnitType(unit_type)
# Build parameters
kwargs: Dict[str, Any] = {
"description": description or f"{name} {unit_type}",
}
if exec_start:
kwargs["exec_start"] = exec_start
if user:
kwargs["user"] = user
if group:
kwargs["group"] = group
if working_directory:
kwargs["working_directory"] = working_directory
if restart:
kwargs["restart"] = restart
if wanted_by:
kwargs["wanted_by"] = [str(item) for item in wanted_by]
# Create the unit file
unit_file = create_unit_file(unit_type_enum, **kwargs)
content = unit_file.to_string()
# Determine output path
if output:
output_path = output
else:
output_path = f"{name}.{unit_type}"
# Write to file
with open(output_path, "w") as f:
f.write(content)
click.echo(click.style(f"✓ Created unit file: {output_path}", fg="green"))
# Validate if requested
if validate_output:
validation_errors = unit_file.validate()
errors = [e for e in validation_errors if e.severity == "error"]
if errors:
click.echo(
click.style(
f"⚠ Generated unit file has {len(errors)} validation error(s)",
fg="yellow",
)
)
for error in errors:
click.echo(f" [{error.section}.{error.key}] {error.message}")
else:
click.echo(click.style("✓ Generated unit file is valid", fg="green"))
except Exception as e:
click.echo(click.style(f"Error: {str(e)}", fg="red"))
sys.exit(1)
@cli.group()
def template():
"""Work with unit file templates."""
pass
@template.command("list")
@click.option("--category", help="Filter by category")
@click.option("--type", "unit_type", help="Filter by unit type")
@click.option("--search", help="Search templates by name, description, or tags")
def list_templates(category: str, unit_type: str, search: str):
"""List available templates."""
templates = template_registry.list_templates()
if category:
templates = [t for t in templates if t.category == category]
if unit_type:
try:
unit_type_enum = UnitType(unit_type.lower())
templates = [t for t in templates if t.unit_type == unit_type_enum]
except ValueError:
click.echo(click.style(f"Invalid unit type: {unit_type}", fg="red"))
return
if search:
templates = template_registry.search(search)
if not templates:
click.echo("No templates found matching the criteria.")
return
# Group by category
categories = {}
for template in templates:
if template.category not in categories:
categories[template.category] = []
categories[template.category].append(template)
for cat, cat_templates in categories.items():
click.echo(click.style(f"\n{cat}:", fg="blue", bold=True))
for template in cat_templates:
click.echo(f" {template.name} ({template.unit_type.value})")
click.echo(f" {template.description}")
if template.tags:
click.echo(f" Tags: {', '.join(template.tags)}")
@template.command("show")
@click.argument("template_name")
def show_template(template_name: str):
"""Show details of a specific template."""
template = template_registry.get_template(template_name)
if not template:
click.echo(click.style(f"Template '{template_name}' not found", fg="red"))
return
click.echo(click.style(f"Template: {template.name}", fg="blue", bold=True))
click.echo(f"Description: {template.description}")
click.echo(f"Type: {template.unit_type.value}")
click.echo(f"Category: {template.category}")
if template.tags:
click.echo(f"Tags: {', '.join(template.tags)}")
click.echo("\nParameters:")
for param in template.parameters:
required_text = "required" if param.required else "optional"
click.echo(f" {param.name} ({param.parameter_type}, {required_text})")
click.echo(f" {param.description}")
if param.default is not None:
click.echo(f" Default: {param.default}")
if param.choices:
click.echo(f" Choices: {', '.join(param.choices)}")
if param.example:
click.echo(f" Example: {param.example}")
@template.command("generate")
@click.argument("template_name")
@click.option(
"--param", "-p", multiple=True, help="Template parameter in key=value format"
)
@click.option("--output", "-o", help="Output file path")
@click.option(
"--validate-output", is_flag=True, help="Validate the generated unit file"
)
@click.option("--interactive", "-i", is_flag=True, help="Interactive parameter input")
def generate_template(
template_name: str,
param: tuple,
output: str,
validate_output: bool,
interactive: bool,
):
"""Generate a unit file from a template."""
template = template_registry.get_template(template_name)
if not template:
click.echo(click.style(f"Template '{template_name}' not found", fg="red"))
return
# Parse parameters
parameters = {}
for p in param:
if "=" not in p:
click.echo(
click.style(f"Invalid parameter format: {p}. Use key=value", fg="red")
)
return
key, value = p.split("=", 1)
parameters[key] = value
# Interactive mode
if interactive:
click.echo(f"Generating unit file from template: {template.name}")
click.echo(f"Description: {template.description}\n")
for template_param in template.parameters:
if template_param.name in parameters:
continue # Skip if already provided via --param
prompt_text = f"{template_param.name}"
if template_param.description:
prompt_text += f" ({template_param.description})"
if template_param.example:
prompt_text += f" [example: {template_param.example}]"
if template_param.required:
value = click.prompt(prompt_text)
else:
default = template_param.default or ""
value = click.prompt(prompt_text, default=default, show_default=True)
if value: # Only add non-empty values
# Type conversion
if template_param.parameter_type == "boolean":
value = value.lower() in ("true", "yes", "1", "on")
elif template_param.parameter_type == "integer":
try:
value = int(value)
except ValueError:
click.echo(
click.style(f"Invalid integer value: {value}", fg="red")
)
return
parameters[template_param.name] = value
# Check required parameters
missing_params = []
for template_param in template.parameters:
if template_param.required and template_param.name not in parameters:
missing_params.append(template_param.name)
if missing_params:
click.echo(
click.style(
f"Missing required parameters: {', '.join(missing_params)}", fg="red"
)
)
click.echo("Use --interactive mode or provide them with --param key=value")
return
try:
# Generate the unit file
unit_file = template.generate(parameters)
content = unit_file.to_string()
# Determine output path
if output:
output_path = output
else:
name = parameters.get("name", "generated")
output_path = f"{name}.{template.unit_type.value}"
# Write to file
with open(output_path, "w") as f:
f.write(content)
click.echo(click.style(f"✓ Generated unit file: {output_path}", fg="green"))
# Validate if requested
if validate_output:
validation_errors = unit_file.validate()
errors = [e for e in validation_errors if e.severity == "error"]
if errors:
click.echo(
click.style(
f"⚠ Generated unit file has {len(errors)} validation error(s)",
fg="yellow",
)
)
for error in errors:
location = f"[{error.section}"
if error.key:
location += f".{error.key}"
location += "]"
click.echo(f" {location} {error.message}")
else:
click.echo(click.style("✓ Generated unit file is valid", fg="green"))
except Exception as e:
click.echo(click.style(f"Error generating unit file: {str(e)}", fg="red"))
sys.exit(1)
@cli.command()
@click.argument("file_path", type=click.Path(exists=True))
def info(file_path: str):
"""Show information about a unit file."""
try:
unit_file = SystemdUnitFile(file_path=file_path)
info = unit_file.get_info()
click.echo(click.style(f"Unit File: {file_path}", fg="blue", bold=True))
if info.name:
click.echo(f"Name: {info.name}")
if info.unit_type:
click.echo(f"Type: {info.unit_type.value}")
if info.description:
click.echo(f"Description: {info.description}")
if info.documentation:
click.echo(f"Documentation: {', '.join(info.documentation)}")
if info.requires:
click.echo(f"Requires: {', '.join(info.requires)}")
if info.wants:
click.echo(f"Wants: {', '.join(info.wants)}")
if info.conflicts:
click.echo(f"Conflicts: {', '.join(info.conflicts)}")
# Show sections and key counts
sections = unit_file.get_sections()
if sections:
click.echo("\nSections:")
for section in sections:
keys = unit_file.get_keys(section)
click.echo(f" {section}: {len(keys)} keys")
except Exception as e:
click.echo(click.style(f"Error: {str(e)}", fg="red"))
sys.exit(1)
@cli.command()
@click.argument("source_file", type=click.Path(exists=True))
@click.argument("dest_file", type=click.Path())
@click.option(
"--set", "set_values", multiple=True, help="Set values in format section.key=value"
)
@click.option(
"--remove", "remove_keys", multiple=True, help="Remove keys in format section.key"
)
@click.option("--validate-output", is_flag=True, help="Validate the modified unit file")
def edit(
source_file: str,
dest_file: str,
set_values: tuple,
remove_keys: tuple,
validate_output: bool,
):
"""Edit a unit file by setting or removing values."""
try:
unit_file = SystemdUnitFile(file_path=source_file)
# Apply modifications
for set_value in set_values:
if "=" not in set_value:
click.echo(
click.style(
f"Invalid set format: {set_value}. Use section.key=value",
fg="red",
)
)
return
key_part, value = set_value.split("=", 1)
if "." not in key_part:
click.echo(
click.style(
f"Invalid key format: {key_part}. Use section.key", fg="red"
)
)
return
section, key = key_part.split(".", 1)
unit_file.set_value(section, key, value)
click.echo(f"Set {section}.{key} = {value}")
for remove_key in remove_keys:
if "." not in remove_key:
click.echo(
click.style(
f"Invalid key format: {remove_key}. Use section.key", fg="red"
)
)
return
section, key = remove_key.split(".", 1)
if unit_file.remove_key(section, key):
click.echo(f"Removed {section}.{key}")
else:
click.echo(click.style(f"Key {section}.{key} not found", fg="yellow"))
# Write modified file
content = unit_file.to_string()
with open(dest_file, "w") as f:
f.write(content)
click.echo(click.style(f"✓ Modified unit file saved: {dest_file}", fg="green"))
# Validate if requested
if validate_output:
validation_errors = unit_file.validate()
errors = [e for e in validation_errors if e.severity == "error"]
if errors:
click.echo(
click.style(
f"⚠ Modified unit file has {len(errors)} validation error(s)",
fg="yellow",
)
)
for error in errors:
location = f"[{error.section}"
if error.key:
location += f".{error.key}"
location += "]"
click.echo(f" {location} {error.message}")
else:
click.echo(click.style("✓ Modified unit file is valid", fg="green"))
except Exception as e:
click.echo(click.style(f"Error: {str(e)}", fg="red"))
sys.exit(1)
if __name__ == "__main__":
cli()