Lots of additions from now on I will create smaller commits

This commit is contained in:
2026-07-27 18:17:41 +01:00
parent f3d430d772
commit d130722390
6 changed files with 290 additions and 116 deletions
+10 -14
View File
@@ -2,21 +2,17 @@ playbook_name: "test_playbook"
registries: registries:
- "./registry/directory_actions.py" - "./registry/directory_actions.py"
- "./registry/restic_actions.py" - "./registry/restic_actions.py"
log_dir: "./"
steps: steps:
- name: "backup dir1" - name: "backup rusty-packages"
actions: actions:
pre: "directory_exists" create_repo_dir_if_not_exists: "create_dir_if_not_exists"
play: "copy_directory" create_repo_if_not_exists: "create_repo_if_not_exists"
post: "verify_copy" check_restic_environment: "check_restic_environment"
backup_data_to_restic_repo: "backup_data_to_restic_repo"
context: context:
source_dir: "./dir1" passwordFile: "/home/esilva/Trash/test_borgmatic/pass_file"
destiny_dir: "./dir2" repoPath: "/home/esilva/Trash/test_borgmatic/destiny2"
- name: "backup dir2" sourcePath: "/home/esilva/Trash/rusty_packages"
actions: serviceTag: "rusty-packages"
pre: "directory_exists"
play: "copy_directory"
post: "verify_copy"
context:
source_dir: "./dir1"
destiny_dir: "./dir2"
+40 -25
View File
@@ -4,13 +4,13 @@ import os
import sys import sys
import yaml import yaml
import json import json
import time
import importlib.util import importlib.util
from datetime import datetime, timezone
from typing import Dict, List, Any, Optional from typing import Dict, List, Any, Optional
from dataclasses import dataclass from dataclasses import dataclass
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from playbook.models import Status, StepLog from playbook.models import Status, StepLog, timed_run
from playbook.action_registry import ActionFn, ActionRegistry from playbook.action_registry import ActionFn, ActionRegistry
from playbook.premade_steps_registry import registry from playbook.premade_steps_registry import registry
@@ -38,22 +38,13 @@ class CustomStep(StepIF):
def get_action_names(self) -> List[str]: def get_action_names(self) -> List[str]:
return [action.__name__ for action in self.__actions] return [action.__name__ for action in self.__actions]
def __timed_run(self, op: ActionFn) -> StepLog: # NOTE: refactor this method to be more readable
"""Function that sets the duration_sec field on the log."""
start_time: int = time.perf_counter_ns()
log: StepLog = op(self.__context, self.name)
log.duration_sec = time.perf_counter_ns() - start_time
return log
def run(self) -> StepLog: def run(self) -> StepLog:
""" Run the step. """ """ Run the step. """
start_time: int = time.perf_counter_ns()
substeps: List[StepLog] = [] substeps: List[StepLog] = []
status: Status = Status.GOOD status: Status = Status.GOOD
for op in self.__actions: for op in self.__actions:
log: StepLog = self.__timed_run(op) log: StepLog = timed_run(op, self.__context, op.__name__)
status = Status.BAD if log.failed else Status.GOOD status = Status.BAD if log.failed else Status.GOOD
substeps.append(log) substeps.append(log)
@@ -61,8 +52,6 @@ class CustomStep(StepIF):
if status == Status.BAD: if status == Status.BAD:
break break
delta: int = time.perf_counter_ns() - start_time
errors = [] errors = []
msg: str = "" msg: str = ""
if status == Status.BAD: if status == Status.BAD:
@@ -71,7 +60,7 @@ class CustomStep(StepIF):
msg = "success" msg = "success"
return StepLog( return StepLog(
step_name=self.name, status=status, duration_sec=delta, step_name=self.name, status=status,
msg=msg, error=errors, substeps=substeps, msg=msg, error=errors, substeps=substeps,
) )
@@ -90,6 +79,7 @@ class StepEntry(object):
class Play(object): class Play(object):
def __init__(self, name : str, registries: Optional[List[ActionRegistry]] = None): def __init__(self, name : str, registries: Optional[List[ActionRegistry]] = None):
self.name: str = name self.name: str = name
self.log_dir: str = ""
self.__steps: List[StepEntry] = [] self.__steps: List[StepEntry] = []
self.__registries: List[ActionRegistry] = [registry] + (registries or []) self.__registries: List[ActionRegistry] = [registry] + (registries or [])
@@ -141,7 +131,34 @@ class Play(object):
return json.dumps(data) return json.dumps(data)
def play(self) -> StepLog: def play(self) -> StepLog:
start_time: int = time.perf_counter_ns() log: StepLog = timed_run(self.__play)
if self.log_dir != "":
log.log_file_path = os.path.join(
self.log_dir, self.log_file_name(log.start_date_timestamp)
)
self.__write_log(log)
return log
def log_file_name(self, start_date_ns: int) -> str:
""" Format to ISO 8601. """
seconds = start_date_ns // 1_000_000_000
ms = (start_date_ns % 1_000_000_000) // 1_000_000
dt = datetime.fromtimestamp(seconds, tz=timezone.utc).astimezone()
return dt.strftime(f"{self.name}_%Y-%m-%d_%H-%M-%S.{ms:03d}.log")
def __write_log(self, log: StepLog):
try:
with open(log.log_file_path, "w") as file:
json.dump(log, file)
except Exception as e:
return f"failed to create log file {e}"
return ""
def __play(self) -> StepLog:
playLog: StepLog = StepLog( playLog: StepLog = StepLog(
step_name=self.name, step_name=self.name,
status=Status.GOOD, status=Status.GOOD,
@@ -152,18 +169,15 @@ class Play(object):
) )
for s in self.__steps: for s in self.__steps:
log: StepLog = s.step.run() log: StepLog = timed_run(s.step.run)
playLog.substeps.append(log) playLog.substeps.append(log)
if log.failed: if log.failed:
playLog.error.append(f"failed in step: {s.name}") playLog.error.append({"status": "failed", "output": f"failed in step: {s.name}"})
playLog.status = Status.BAD playLog.status = Status.BAD
break break
delta: int = time.perf_counter_ns() - start_time
playLog.duration_sec = delta
if playLog.status == Status.GOOD: if playLog.status == Status.GOOD:
playLog.msg = "success" playLog.msg = "success"
@@ -227,16 +241,17 @@ class Play(object):
raise PlaybookError(f"Error loading registry '{reg_path}': {e}") from e raise PlaybookError(f"Error loading registry '{reg_path}': {e}") from e
playbook = cls(data["playbook_name"], registries=custom_registries) playbook = cls(data["playbook_name"], registries=custom_registries)
if data["log_dir"]:
playbook.log_dir = data["log_dir"]
for idx, step_cfg in enumerate(data.get("steps", [])): for idx, step_cfg in enumerate(data.get("steps", [])):
step_name = step_cfg.get("name", f"step_{idx}") step_name = step_cfg.get("name", f"step_{idx}")
actions = step_cfg.get("actions", {}) actions = step_cfg.get("actions", {})
context = step_cfg.get("context", {}) context = step_cfg.get("context", {})
if not isinstance(actions, dict) or len(actions) != 3: if not isinstance(actions, dict) or len(actions) <= 0:
raise PlaybookError( raise PlaybookError(
f"Step '{step_name}' (index {idx}) must define an 'actions' map " f"Step '{step_name}' (index {idx}) must define at least one action "
"containing 'pre', 'play' and 'post'"
) )
try: try:
+47 -4
View File
@@ -1,6 +1,8 @@
import time
from enum import StrEnum
from typing import List, Dict, Any, Callable
from datetime import datetime, timezone
from dataclasses import dataclass, field from dataclasses import dataclass, field
from enum import Enum, StrEnum
from typing import Callable, Dict, List, Any
class Status(StrEnum): class Status(StrEnum):
@@ -13,14 +15,55 @@ class StepLog(object):
step_name: str step_name: str
status: Status = Status.BAD # By default set it to bad so the it fails by default status: Status = Status.BAD # By default set it to bad so the it fails by default
# The user needs to be explicit # The user needs to be explicit
duration_sec: int = 0 cmd: str = ""
start_date_timestamp: int = 0
start_date: str = ""
end_date: str = ""
duration_sec: float = 0.0
human_readable_duration: str = ""
msg: str = "" msg: str = ""
error: List[str] = field(default_factory=list) error: List[Dict[Any, Any]] = field(default_factory=list)
substeps: List[StepLog] = field(default_factory=list) substeps: List[StepLog] = field(default_factory=list)
log_file_path: str = ""
@property @property
def failed(self) -> bool: def failed(self) -> bool:
""" Checks if the step failed """ """ Checks if the step failed """
return len(self.error) > 0 or self.status == Status.BAD return len(self.error) > 0 or self.status == Status.BAD
@classmethod
def ok(cls, name: str, msg: str = "success") -> StepLog:
return cls(step_name=name, status=Status.GOOD, msg=msg)
@classmethod
def fail(cls, name: str, errors: List[Any]) -> StepLog:
return cls(step_name=name, status=Status.BAD, error=errors)
def human_readable_date(time: int) -> str:
seconds = time // 1_000_000_000
nanos = time % 1_000_000_000
dt = datetime.fromtimestamp(seconds, tz=timezone.utc).astimezone()
return dt.strftime(f"%Y-%m-%dT%H:%M:%S.{nanos:09d}%:z")
def timed_run(op: Callable[..., StepLog], *args: Any, **kwargs: Any) -> StepLog:
start_date: int = time.time_ns()
start_time: int = time.perf_counter_ns()
try:
log: StepLog = op(*args, **kwargs)
finally:
delta = time.perf_counter_ns() - start_time
# NOTE: we could simplyfy this by having a marshalling method
if isinstance(log, StepLog):
end_date: int = time.time_ns()
log.duration_sec = delta / 1_000_000_000.0
log.start_date = human_readable_date(start_date)
log.start_date_timestamp = start_date
log.end_date = human_readable_date(end_date)
return log
+31 -23
View File
@@ -1,34 +1,42 @@
# directory actions registry # directory actions registry
from playbook.action_registry import ActionRegistry from playbook.action_registry import ActionRegistry
from playbook.models import StepLog, Status from playbook.models import StepLog
import os
diract = ActionRegistry("directory_actions_reg") diract = ActionRegistry("directory_actions_reg")
@diract.register(name="directory_exists", version="stat1.0") @diract.register(name="directory_exists", version="stat1.0")
def directory_exists(ctx, name) -> StepLog: def directory_exists(ctx, name) -> StepLog:
log: StepLog = StepLog( if not os.path.exists(ctx["repoPath"]):
step_name=name, return StepLog.fail(name, [
status=Status.GOOD, {"status": "failed", "output": f"dir '{ctx["repoPath"]}' doesn't exist"}]
msg="source directory exists", )
) return StepLog.ok(name, "directory exists")
return log
@diract.register(name="copy_directory", version="cp1.0") @diract.register(name="create_dir", version="stat1.0")
def copy_directory(ctx, name) -> StepLog: def create_dir(ctx, name) -> StepLog:
log: StepLog = StepLog( try:
step_name=name, os.makedirs(ctx["repoPath"])
status=Status.GOOD, except Exception as e:
msg="directory was copied/movied/borged/rsynced", return StepLog.fail(name, [{"status": "failed", "output": str(e)}])
)
return log return StepLog.ok(name, "dir create successfully")
@diract.register(name="verify_copy", version="verify1.0") @diract.register(name="create_dir_if_not_exists", version="stat1.0")
def verify_copy(ctx, name) -> StepLog: def create_dir_if_not_exists(ctx, name) -> StepLog:
log: StepLog = StepLog( log: StepLog = directory_exists(ctx, name + f".{directory_exists.__name__}")
step_name=name, msg: str = ""
status=Status.GOOD, if log.failed:
msg="copied/movied/borged/rsynced exists and is valid", dir_creation_log: StepLog = create_dir(ctx, name + f".{create_dir.__name__}")
) if dir_creation_log.failed:
return log return StepLog.fail(name, [{"status": "failed", "output": dir_creation_log}])
msg = "dir created succesfully"
else:
msg = "dir already exists"
return StepLog.ok(name, msg)
+161 -40
View File
@@ -1,54 +1,175 @@
# borg actions registry # restic actions registry
from playbook.action_registry import ActionRegistry from playbook.action_registry import ActionRegistry
from playbook.models import StepLog, Status from playbook.models import StepLog
import sys
import json
import subprocess
from typing import List, Dict, Any, Tuple
# NOTE:
# most restic run commands seem to be the same pattern of:
# restic command -> parse output -> grab errors
# we can create a class for this I reckon
def parse_output(line: str) -> List[Dict[str, Any]]:
errors = []
for line in line.split('\n'):
if line.startswith("{"): # } treesitter is borked lmao
data = json.loads(line)
errors.append(data)
return errors
def find_restic_errors(data: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
errors = []
for s in data:
if s["message_type"] == "exit_error":
errors.append(s)
return errors
def run_restic_command(cmd: List[str]) -> Tuple[int, List[Dict[str, Any]]]:
"""
Executes a restic command, streams output live to terminal,
and returns (returncode, parsed_json_objects).
"""
parsed_json = []
# Start process with combined stdout/stderr pipe
process = subprocess.Popen(
cmd,
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1
)
# NOTE: is this what we want?
assert process.stdout is not None
# Read line-by-line in real-time
for line in iter(process.stdout.readline, ''):
# Write live output directly to terminal screen
sys.stdout.write(line)
sys.stdout.flush()
# Parse and capture valid JSON objects on the fly
clean_line = line.strip()
if clean_line.startswith('{'): # }
try:
data = json.loads(clean_line)
parsed_json.append(data)
except json.JSONDecodeError:
pass
process.stdout.close()
returncode = process.wait()
return returncode, parsed_json
restic = ActionRegistry("restic_actions_reg") restic = ActionRegistry("restic_actions_reg")
@restic.register(name="check_repo_exists", version="preborg1.0") @restic.register(name="check_restic_environment", version="")
def check_repo_exists(ctx, name) -> StepLog: def check_restic_environment(ctx, name: str = "") -> StepLog:
passwordFile: str = ctx["passwordFile"] for v in ["passwordFile", "repoPath", "sourcePath", "serviceTag"]:
repoPath: str = ctx["repoPath"] if not ctx.get(v):
return StepLog.fail(name, [f"{v} variable is empty"])
if passwordFile == "": return StepLog.ok(name, "environment configuration seems to be valid")
return StepLog(
step_name=name,
status=Status.BAD,
error=["password file variable is empty"],
)
if repoPath == "":
return StepLog(
step_name=name,
status=Status.BAD,
error=["password file variable is empty"]
)
# If the path doesn't exist restic will make sure of warning us about it @restic.register(name="create_restic_repo", version="")
def create_restic_repo(ctx, name: str = "") -> StepLog:
return StepLog( cmd = ["restic", "init", "--json", "-r", ctx["repoPath"],
step_name=name, "--password-file", ctx["passwordFile"]]
status=Status.GOOD,
msg="password file variable is empty", returncode, json_output = run_restic_command(cmd)
errors: List[Dict[str, Any]] = find_restic_errors(json_output)
# Handle error messages in JSON output
if len(errors) > 0:
return StepLog.fail(name, errors)
# Make sure the return code is good too
if returncode != 0:
return StepLog.fail(name, [{"status": "failed", "output": json_output}])
return StepLog.ok(name, "repo create succesfully")
@restic.register(name="create_repo_if_not_exists", version="")
def create_repo_if_not_exists(ctx, name: str = "") -> StepLog:
log: StepLog = check_if_repo_exists(ctx, "")
msg: str = ""
if log.failed:
# Repo doesn't exist is error code 10
if log.error[0]["code"] != 10:
return StepLog.fail(name, [{"status": "failed", "output": log.error}])
# Create repo
repoCreateLog: StepLog = create_restic_repo(ctx, name + f".{create_restic_repo}")
if repoCreateLog.failed:
return StepLog.fail(name, [{"status": "failed", "output": repoCreateLog.error}])
msg = "repo created"
else:
msg = "repo already exists"
return StepLog.ok(name, msg)
@restic.register(name="check_if_repo_exists", version="")
def check_if_repo_exists(ctx, name: str = "") -> StepLog:
cmd = ["restic", "-r", ctx["repoPath"], "cat", "config", "--json",
"--password-file", ctx["passwordFile"]]
returncode, json_output = run_restic_command(cmd)
errors: List[Dict[str, Any]] = find_restic_errors(json_output)
# Handle error messages in JSON output
if len(errors) > 0:
return StepLog.fail(name, errors)
# Make sure the return code is good too
if returncode != 0:
return StepLog.fail(name, [{"status": "failed", "output": json_output}])
return StepLog.ok(name, "repo seems to exist")
@restic.register(name="backup_data_to_restic_repo", version="")
def backup_data_to_restic_repo(ctx, name) -> StepLog:
out = subprocess.run(
["restic", "backup", ctx["sourcePath"], "--json", "--quiet",
"-r", ctx["repoPath"], "--password-file", ctx["passwordFile"]],
text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
) )
json_output: List[Dict[str, Any]] = parse_output(out.stdout)
errors: List[Dict[str, Any]] = find_restic_errors(json_output)
@restic.register(name="backup_data_to_repo", version="playborg1.0") # Handle error messages in JSON output
def backup_data_to_repo(ctx, name) -> StepLog: if len(errors) > 0:
log: StepLog = StepLog( return StepLog.fail(name, errors)
step_name=name,
status=Status.GOOD,
msg="directory was copied/movied/borged/rsynced",
)
return log
# Make sure the return code is good too
if out.returncode != 0:
return StepLog.fail(name, [{"status": "failed", "output": out.stdout}])
@restic.register(name="post", version="postborg1.0") # Return code is 0, so it was successful, output the message
def validate_data_backup(ctx, name) -> StepLog: try:
log: StepLog = StepLog( data = json.loads(out.stdout)
step_name=name, msg = data
status=Status.GOOD, except:
msg="copied/movied/borged/rsynced exists and is valid", msg = {
) "status": "ran succesfully, but an error occurred parsing the output",
return log "output": out.stdout,
}
return StepLog.ok(name, str(msg))
+1 -10
View File
@@ -19,15 +19,6 @@ def parse_args() -> argparse.Namespace:
help="Path to the playbook yaml file" help="Path to the playbook yaml file"
) )
# NOTE: For now I will only take the registries from the yaml playbook
# # Flag for the custom registry files
# parser.add_argument(
# "-r", "--registry",
# nargs="+",
# default=[],
# help="One or more python files with actions"
# )
return parser.parse_args() return parser.parse_args()
@@ -42,7 +33,7 @@ def main():
traceback.print_exc() traceback.print_exc()
exit(1) exit(1)
print(newPlay.view_playbook()) # print(newPlay.view_playbook())
print(json.dumps(asdict(newPlay.play()), indent=2)) print(json.dumps(asdict(newPlay.play()), indent=2))