- 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
549 lines
18 KiB
Python
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()
|