YAML parser
main / Explore-Gitea-Actions (push) Failing after 13s

This commit is contained in:
2026-08-04 11:53:58 +01:00
parent 8d73d5ded7
commit f807eab49c
5 changed files with 231 additions and 165 deletions
+5 -10
View File
@@ -14,22 +14,19 @@ acts:
- name: "DB Dump and Backup" - name: "DB Dump and Backup"
steps: steps:
- name: "find database container" - name: "find database container"
actions: action: "get_service_container_name"
find_container: "get_service_container_name"
context: context:
service_name: "gitea-db" service_name: "gitea-db"
- name: "dump database" - name: "dump database"
actions: action: "dump_container_pg_db"
dump_database: "dump_container_pg_db"
context: context:
dump_path: "/tmp/db_dump.sql" dump_path: "/tmp/db_dump.sql"
db_user: "gitea" db_user: "gitea"
database: "gitea" database: "gitea"
- name: "restic database backup" - name: "restic database backup"
actions: action: "backup_data_to_restic_repo"
dump_database: "backup_data_to_restic_repo"
context: context:
source_path: "/tmp/db_dump.sql" source_path: "/tmp/db_dump.sql"
tags: ["gitea", "db-dump"] tags: ["gitea", "db-dump"]
@@ -37,8 +34,7 @@ acts:
- name: "Backup gitea data" - name: "Backup gitea data"
steps: steps:
- name: "backup gitea mount points data" - name: "backup gitea mount points data"
actions: action: "backup_data_to_restic_repo"
backup_data_to_restic_repo: "backup_data_to_restic_repo"
context: context:
source_path: "/home/esilva/Trash/docker-compose/resources/gitea/gitea_data/" source_path: "/home/esilva/Trash/docker-compose/resources/gitea/gitea_data/"
tags: ["gitea", "data"] tags: ["gitea", "data"]
@@ -46,8 +42,7 @@ acts:
- name: "Backup gitea PG data" - name: "Backup gitea PG data"
steps: steps:
- name: "backup gitea database mount points data" - name: "backup gitea database mount points data"
actions: action: "backup_data_to_restic_repo"
backup_data_to_restic_repo: "backup_data_to_restic_repo"
context: context:
source_path: "/home/esilva/Trash/docker-compose/resources/gitea/postgres_data/" source_path: "/home/esilva/Trash/docker-compose/resources/gitea/postgres_data/"
tags: ["gitea", "db-data"] tags: ["gitea", "db-data"]
+102 -74
View File
@@ -1,13 +1,13 @@
from __future__ import annotations from __future__ import annotations
from re import A
from dataclasses import dataclass import yaml
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from pydantic import BaseModel from pydantic import BaseModel
from playbook.models import ActModel, PlaybookModel, timed_run from playbook.models import ActModel, PlaybookModel, StepModel, timed_run
from playbook.logging_models import Status, StepLogModel from playbook.logging_models import Status, StepLogModel
from playbook.action_registry import ActionFn from playbook.action_registry import ActionFn, ActionRegistry
class StepIF(ABC): class StepIF(ABC):
@@ -21,18 +21,6 @@ class StepIF(ABC):
""" Return a mapping of action roles (pre, play, post) to function names. """ """ Return a mapping of action roles (pre, play, post) to function names. """
pass pass
# class PlaybookError(Exception):
# """ Base exception for Playbook parsing and execution failures. """
# pass
# @dataclass
# class StepEntry(object):
# name: str
# step: StepIF
# class Play(object): # class Play(object):
# def __init__(self, name: str, registries: dict[str, ActionRegistry]): # def __init__(self, name: str, registries: dict[str, ActionRegistry]):
# self.name: str = name # self.name: str = name
@@ -130,76 +118,116 @@ class StepIF(ABC):
# playLog.msg = "success" # playLog.msg = "success"
# #
# return playLog # return playLog
#
# def _play_steps(self, steps: list[CustomStep]) -> StepLogModel:
# playLog: StepLogModel = StepLogModel( class PlaybookYAMLParser():
# step_name=self.name, @staticmethod
# status=Status.GOOD, def _parse_registries(reg_names: list[str]) -> list[ActionRegistry]:
# duration_sec=0, registries: list[ActionRegistry] = []
# msg="", for reg in reg_names:
# error=[], registries.extend(ActionRegistry.load_registries_from_file(reg))
# substeps=[], return registries
# )
# @staticmethod
# prev_ctx: dict[str, object] = {} def _parse_steps(steps: list[dict[str, object]],
# for step in steps: registries: list[ActionRegistry]) -> list[StepModel]:
# log: StepLogModel = timed_run(step.run, self._context | prev_ctx) step_list: list[StepModel] = []
# prev_ctx = log.pipe_ctx for step in steps:
# if not isinstance(step['name'], str):
# playLog.substeps.append(log) raise ValueError(
# f"name must of type `str` not `{type(step["name"])}`")
# if log.failed: if not isinstance(step['action'], str):
# playLog.error.append({ raise ValueError(
# "status": "failed", f"action must of type `str` not `{type(step["action"])}`")
# "output": f"failed in step: {step.name}" if not isinstance(step['context'], dict):
# }) raise ValueError(
# playLog.status = Status.BAD f"context must of type `dict` not `{type(step["context"])}`")
# break
# name: str = step['name']
# if playLog.status == Status.GOOD: action: str = step['action']
# playLog.msg = "success" context: dict[str, object] = step['context']
#
# return playLog found = False
# fn: ActionFn
# def _get_function_from_registry(self, fn_name) -> ActionFn: for reg in registries:
# for reg in self._registries.values(): try:
# try: fn = reg.get(action)
# return reg.get(fn_name, "*") found = True
# except ValueError: except:
# continue continue
#
# raise ValueError(f"no function with name '{fn_name}' was found") if not found:
# raise ValueError(
# def _build_custom_step(self, name, actions, ctx) -> CustomStep: f"Unable to find function `{action}` in any registry")
# functionlist: list[ActionFn] = []
# for f in actions: step_list.append(StepModel(name=name, action=fn, context=context))
# try:
# fn = self._get_function_from_registry(actions[f]) return step_list
# functionlist.append(fn)
# except KeyError as e: @staticmethod
# raise PlaybookError( def _parse_acts(acts_data: list[dict[str, object]],
# f"Step '{name}' is missing required action field: {e}") from e registries: list[ActionRegistry]) -> list[ActModel]:
# acts: list[ActModel] = []
# return CustomStep(name, functionlist, ctx) for act in acts_data:
if not isinstance(act["name"], str):
raise ValueError(
f"name must of type `str` not `{type(act["name"])}`")
if not isinstance(act["steps"], list):
raise ValueError(
f"steps must of type `list` not `{type(act["steps"])}`")
name: str = act["name"]
steps: list[dict[str, object]] = act["steps"]
step_list = PlaybookYAMLParser._parse_steps(steps, registries)
modeled_act: ActModel = ActModel(name=name, steps=step_list)
acts.append(modeled_act)
return acts
@staticmethod
def from_yaml(fp: str) -> Playbook:
with open(fp, "rb") as source_file:
data = yaml.safe_load(source_file)
reg_yaml = data.get("registries", [])
registries = PlaybookYAMLParser._parse_registries(reg_yaml)
acts_yaml = data.get("acts", {})
acts: list[ActModel] = PlaybookYAMLParser._parse_acts(
acts_yaml, registries)
global_context = data.get("global_context", {})
log_dir: str = data.get("log_dir", "./")
playbook_name: str = data.get("playbook_name")
new_playbook: PlaybookModel = PlaybookModel(
playbook_name=playbook_name,
acts=acts,
log_dir=log_dir,
registries=registries,
global_context=global_context
)
return Playbook(new_playbook)
pass
class Playbook(): class Playbook():
model: PlaybookModel model: PlaybookModel
def __init__(self, model: PlaybookModel): def __init__(self, model: PlaybookModel):
self.model = model self.model = model
@property @property
def acts(self) -> list[ActModel]: def acts(self) -> list[ActModel]:
return self.model.acts return self.model.acts
@classmethod @classmethod
def from_yaml(cls, fp: str) -> Playbook: def from_yaml(cls, fp: str) -> Playbook:
try: return PlaybookYAMLParser.from_yaml(fp)
new_playboook = PlaybookModel.from_yaml_file(fp)
return Playbook(new_playboook)
except Exception as e:
raise e
def view_playbook(self) -> str: def view_playbook(self) -> str:
return self.model.model_dump_json() return self.model.model_dump_json()
+5 -4
View File
@@ -4,6 +4,7 @@ import importlib.util
from playbook.logging_models import StepLogModel from playbook.logging_models import StepLogModel
from typing import Callable from typing import Callable
from pydantic import BaseModel, PrivateAttr from pydantic import BaseModel, PrivateAttr
from dataclasses import field
ActionFn = Callable[[dict[str, object], str], StepLogModel] ActionFn = Callable[[dict[str, object], str], StepLogModel]
@@ -17,21 +18,21 @@ class Action(BaseModel):
class ActionRegistry(BaseModel): class ActionRegistry(BaseModel):
name: str name: str
_actions: dict[str, Action] = PrivateAttr(default_factory=dict) actions: dict[str, Action] = field(default_factory=dict)
def register(self, name: str, version: str): def register(self, name: str, version: str):
"""Decorator to register python functions with a name and version.""" """Decorator to register python functions with a name and version."""
def decorator(function: ActionFn): def decorator(function: ActionFn):
self._actions[name] = Action(name=name, fn=function, ver=version) self.actions[name] = Action(name=name, fn=function, ver=version)
return function return function
return decorator return decorator
def get(self, name: str, ver: str = '*') -> ActionFn: def get(self, name: str, ver: str = '*') -> ActionFn:
if name not in self._actions: if name not in self.actions:
raise ValueError( raise ValueError(
f"Action '{name}' is not registered in registry '{self.name}'") f"Action '{name}' is not registered in registry '{self.name}'")
return self._actions[name].fn return self.actions[name].fn
@staticmethod @staticmethod
def load_registries_from_file(file_path: str) -> list[ActionRegistry]: def load_registries_from_file(file_path: str) -> list[ActionRegistry]:
+48 -5
View File
@@ -15,14 +15,17 @@ class LogTiming(BaseModel):
duration_sec: float = 0.0 duration_sec: float = 0.0
class StepLogModel(BaseModel): class BaseLog(BaseModel):
name: str name: str
timing: LogTiming = field(default=LogTiming()) timing: LogTiming = field(default=LogTiming())
status: Status status: Status
log_file_path: str = "" # If set we have logged this particular step to a file
class ActLog(BaseLog):
error: str = "" error: str = ""
msg: str = "" msg: str = ""
pipe_ctx: dict[str, object] = field(default_factory=dict) logs: list[StepLogModel]
log_file_path: str = "" # If set we have logged this particular step to a file
@property @property
def failed(self) -> bool: def failed(self) -> bool:
@@ -30,9 +33,49 @@ class StepLogModel(BaseModel):
return len(self.error) > 0 or self.status == Status.BAD return len(self.error) > 0 or self.status == Status.BAD
@classmethod @classmethod
def ok(cls, name: str, msg: str = "success", pipe_ctx: dict[str, object] = {}) -> StepLogModel: def ok(cls, name: str, logs: list[StepLogModel], msg: str = "success") -> ActLog:
return cls(name=name, status=Status.GOOD, msg=msg, pipe_ctx=pipe_ctx) return cls(name=name, status=Status.GOOD, msg=msg, logs=logs)
@classmethod @classmethod
def fail(cls, name: str, err: str, logs: list[StepLogModel]) -> ActLog:
return cls(name=name, status=Status.BAD, error=err, logs=logs)
class PlaybookLog(BaseLog):
errors: list[str] = []
summary: str = ""
act_logs: list[ActLog]
@property
def failed(self) -> bool:
return len(self.errors) > 0 or self.status == Status.BAD
@classmethod
def ok(cls, name: str, logs: list[ActLog], msg: str = "success") -> PlaybookLog:
return cls(name=name, status=Status.GOOD, summary=msg, act_logs=logs)
@classmethod
def fail(cls, name: str, logs: list[ActLog], err: list[str]) -> PlaybookLog:
return cls(name=name, status=Status.BAD, errors=err, act_logs=logs)
class StepLogModel(BaseLog):
error: str = ""
msg: str = ""
substeps_logs: list[StepLogModel] = []
pipe_ctx: dict[str, object] = field(default_factory=dict)
@property
def failed(self) -> bool:
""" Checks if the step failed """
return len(self.error) > 0 or self.status == Status.BAD
@classmethod
def ok(cls, name: str, msg: str = "success", pipe_ctx: dict[str, object] = {},
substeps: list[StepLogModel] = []) -> StepLogModel:
return cls(name=name, status=Status.GOOD, msg=msg, pipe_ctx=pipe_ctx,
substeps_logs=substeps)
@ classmethod
def fail(cls, name: str, err: str) -> StepLogModel: def fail(cls, name: str, err: str) -> StepLogModel:
return cls(name=name, status=Status.BAD, error=err) return cls(name=name, status=Status.BAD, error=err)
+71 -72
View File
@@ -5,62 +5,46 @@ import time
import functools import functools
from typing import Callable from typing import Callable
from datetime import datetime, timezone from datetime import datetime, timezone
from pydantic import BaseModel, field_validator from pydantic import BaseModel, ValidationInfo, field_serializer, field_validator, model_validator
from playbook.action_registry import ActionRegistry, ActionFn from playbook.action_registry import ActionRegistry, ActionFn
from playbook.logging_models import StepLogModel from playbook.logging_models import PlaybookLog, StepLogModel, ActLog, Status
CtxType = dict[str, object]
class StepModel(BaseModel): class StepModel(BaseModel):
name: str name: str
_actions: list[ActionFn] action: ActionFn
_context: dict[str, object] context: dict[str, object]
def get_action_names(self) -> list[str]: @field_serializer("action")
return [action.__name__ for action in self._actions] def serialize_action_fn(self, action_fn: ActionFn, _info) -> str:
return getattr(action_fn, "__name__", str(action_fn))
# NOTE: refactor this method to be more readable
def run(self, ctx) -> StepLogModel: def run(self, ctx) -> StepLogModel:
""" Run the step. """ """ Run the step. """
return StepLogModel.ok(self.name, msg="success") substeps: list[StepLogModel] = []
# substeps: list[StepLogModel] = [] previous_step_ctx: dict[str, object] = {}
# status: Status = Status.GOOD for action in self.actions:
# try:
# # NOTE: local_ctx: CtxType = ctx | previous_step_ctx | self.context
# # Use this to move context from the previous step to the next step log: StepLogModel = timed_run(
# # This is wrong, we are moving away from having a list of steps here to a single action, local_ctx, self.name
# # step for each operation )
# prev_step_ctx: dict[str, object] = {} except Exception as e:
# log: StepLogModel = StepLogModel.fail(
# for op in self.__actions: self.name, str(e)
# try: )
# log: StepLogModel = timed_run(
# op, ctx | prev_step_ctx | self.__context, self.name) previous_step_ctx = log.pipe_ctx
# except Exception as e:
# log: StepLogModel = StepLogModel.fail( substeps.append(log)
# self.name, [{"status": "failed", "output": str(e)}]) if log.status == Status.BAD:
# return StepLogModel.fail(self.name, err=f"failed on step: {log.name}")
# status = Status.BAD if log.failed else Status.GOOD
# prev_step_ctx = log.pipe_ctx return StepLogModel.ok(self.name, msg="success", substeps=substeps)
#
# substeps.append(log)
#
# if status == Status.BAD:
# break
#
# errors = []
# msg: str = ""
# if status == Status.BAD:
# # Note, use this to set the standar error output/formatting
# # errors = [{"status": "failed", "output": ""}]
# errors = []
# else:
# msg = "success"
#
# return StepLogModel(
# step_name=self.name, status=status,
# msg=msg, error=errors, substeps=substeps, pipe_ctx=prev_step_ctx
# )
class ActModel(BaseModel): class ActModel(BaseModel):
@@ -68,8 +52,15 @@ class ActModel(BaseModel):
steps: list[StepModel] steps: list[StepModel]
# NOTE: this shouldnt return a steplogmodel but an actlogmodel or something like that # NOTE: this shouldnt return a steplogmodel but an actlogmodel or something like that
def run(self, ctx) -> StepLogModel: def run(self, ctx) -> ActLog:
return StepLogModel.ok(self.name, msg="success") step_logs: list[StepLogModel] = []
for step in self.steps:
stepLog: StepLogModel = step.run(ctx)
step_logs.append(stepLog)
if stepLog.failed:
break
return ActLog.ok(self.name, msg="success", logs=step_logs)
class PlaybookModel(BaseModel): class PlaybookModel(BaseModel):
@@ -79,36 +70,43 @@ class PlaybookModel(BaseModel):
global_context: dict[str, str] global_context: dict[str, str]
acts: list[ActModel] acts: list[ActModel]
@field_validator("registries", mode="before") @model_validator(mode="before")
@classmethod @classmethod
def parse_registries(cls, v: object) -> list[ActionRegistry]: # def parse_registries(cls, v: object) -> list[ActionRegistry]:
result = [] def load_registries(cls, data: object, info: ValidationInfo) -> object:
if isinstance(v, list): if not isinstance(data, dict):
for item in v: return data
if isinstance(item, str):
try: raw_registries = data.get("registries", [])
registries: list[ActionRegistry] = \ loaded_registries: list[ActionRegistry] = []
ActionRegistry.load_registries_from_file(item)
result.extend(registries) for item in raw_registries:
except Exception as e: if isinstance(item, str):
raise e loaded_registries.extend(ActionRegistry.load_registries_from_file(item))
else:
raise ValueError(f"Invalid registry type: {type(item)}") data["registries"] = loaded_registries
return result
if info.context is not None:
info.context["registries"] = loaded_registries
return data
@classmethod @classmethod
def from_yaml_file(cls, fp: str): def from_yaml_file(cls, fp: str):
with open(fp, "rb") as f: with open(fp, "rb") as f:
data = yaml.safe_load(f) data = yaml.safe_load(f)
return cls(**data) return cls.model_validate(data, context={})
# return cls(**data)
def _run(self) -> StepLogModel: def _run(self) -> PlaybookLog:
act_logs: list[ActLog] = []
for act in self.acts: for act in self.acts:
act.run(self.global_context) log: ActLog = act.run(self.global_context)
act_logs.append(log)
return StepLogModel.ok(name="somasjd", msg="ajshdsajhd") return PlaybookLog.ok(name="somasjd", msg="ajshdsajhd", logs=act_logs)
def run(self) -> StepLogModel: def run(self) -> PlaybookLog:
return timed_run(self._run) return timed_run(self._run)
@@ -128,7 +126,7 @@ class ContextChecker:
else: else:
missing_keys = [key for key in args if key not in ctx] missing_keys = [key for key in args if key not in ctx]
missing_keys_msg = f"missing context keys: {missing_keys}" missing_keys_msg = f"missing context keys: {missing_keys}"
return StepLog.fail(name, [{"status": "failed", "output": missing_keys_msg}]) return StepLogModel.fail(name, err=missing_keys_msg)
return wrapper return wrapper
return decorator return decorator
@@ -142,17 +140,18 @@ def human_readable_date(time: int) -> str:
return dt.strftime(f"%Y-%m-%dT%H:%M:%S.{nanos:09d}%:z") return dt.strftime(f"%Y-%m-%dT%H:%M:%S.{nanos:09d}%:z")
def timed_run(op: Callable[..., StepLogModel], *args: object, **kwargs: object) -> StepLogModel: # def timed_run(op: Callable[..., StepLogModel], *args: object, **kwargs: object) -> StepLogModel:
def timed_run(op, *args: object, **kwargs: object):
start_date: int = time.time_ns() start_date: int = time.time_ns()
start_time: int = time.perf_counter_ns() start_time: int = time.perf_counter_ns()
try: try:
log: StepLogModel = op(*args, **kwargs) log = op(*args, **kwargs)
finally: finally:
delta = time.perf_counter_ns() - start_time delta = time.perf_counter_ns() - start_time
# NOTE: we could simplyfy this by having a marshalling method # NOTE: we could simplyfy this by having a marshalling method
if isinstance(log, StepLogModel): if isinstance(log, StepLogModel | PlaybookLog | ActLog):
end_date: int = time.time_ns() end_date: int = time.time_ns()
log.timing.duration_sec = delta / 1_000_000_000.0 log.timing.duration_sec = delta / 1_000_000_000.0
log.timing.start_date = human_readable_date(start_date) log.timing.start_date = human_readable_date(start_date)