labops 0.3.2__tar.gz → 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. labops-0.4.0/.release-please-manifest.json +3 -0
  2. {labops-0.3.2 → labops-0.4.0}/CHANGELOG.md +20 -0
  3. {labops-0.3.2 → labops-0.4.0}/PKG-INFO +1 -1
  4. {labops-0.3.2 → labops-0.4.0}/justfile +14 -1
  5. labops-0.4.0/models/input_conf/common_validators/web_services.py +34 -0
  6. {labops-0.3.2 → labops-0.4.0}/models/input_conf/creds.py +3 -0
  7. labops-0.4.0/models/input_conf/custom_types.py +5 -0
  8. labops-0.4.0/models/input_conf/docker.py +12 -0
  9. labops-0.4.0/models/input_conf/host.py +69 -0
  10. labops-0.4.0/models/input_conf/lxc.py +25 -0
  11. labops-0.4.0/models/input_conf/vm.py +30 -0
  12. labops-0.4.0/models/input_conf/web_services.py +12 -0
  13. labops-0.4.0/models/input_conf/yaml_root.py +120 -0
  14. {labops-0.3.2 → labops-0.4.0}/pyproject.toml +1 -1
  15. {labops-0.3.2 → labops-0.4.0}/src/cli/host.py +1 -1
  16. {labops-0.3.2 → labops-0.4.0}/src/cli/lxc.py +2 -1
  17. {labops-0.3.2 → labops-0.4.0}/src/cli/validate.py +2 -2
  18. {labops-0.3.2 → labops-0.4.0}/src/cli/vm.py +1 -1
  19. {labops-0.3.2 → labops-0.4.0}/src/host/find.py +1 -1
  20. {labops-0.3.2 → labops-0.4.0}/src/host/setup.py +1 -1
  21. {labops-0.3.2 → labops-0.4.0}/src/host/update.py +2 -2
  22. {labops-0.3.2 → labops-0.4.0}/src/lxc/find.py +2 -1
  23. {labops-0.3.2 → labops-0.4.0}/src/lxc/update.py +2 -1
  24. labops-0.4.0/src/utils/yaml_validator.py +55 -0
  25. {labops-0.3.2 → labops-0.4.0}/src/vm/find.py +6 -6
  26. labops-0.4.0/test-samples/homelab-complete.yml +105 -0
  27. {labops-0.3.2 → labops-0.4.0}/uv.lock +1 -1
  28. labops-0.3.2/.release-please-manifest.json +0 -3
  29. labops-0.3.2/models/input_conf/hosts.py +0 -61
  30. labops-0.3.2/models/input_conf/yaml_root.py +0 -38
  31. labops-0.3.2/src/utils/yaml_validator.py +0 -24
  32. labops-0.3.2/test-samples/homelab-complete.yml +0 -100
  33. {labops-0.3.2 → labops-0.4.0}/.devcontainer/devcontainer.json +0 -0
  34. {labops-0.3.2 → labops-0.4.0}/.github/dependabot.yml +0 -0
  35. {labops-0.3.2 → labops-0.4.0}/.github/workflows/publish.yml +0 -0
  36. {labops-0.3.2 → labops-0.4.0}/.github/workflows/release_please.yml +0 -0
  37. {labops-0.3.2 → labops-0.4.0}/.gitignore +0 -0
  38. {labops-0.3.2 → labops-0.4.0}/.pre-commit-config.yaml +0 -0
  39. {labops-0.3.2 → labops-0.4.0}/.python-version +0 -0
  40. {labops-0.3.2 → labops-0.4.0}/LICENSE +0 -0
  41. {labops-0.3.2 → labops-0.4.0}/README.md +0 -0
  42. {labops-0.3.2 → labops-0.4.0}/ansible/playbooks/host/setup/alpine.yml +0 -0
  43. {labops-0.3.2 → labops-0.4.0}/ansible/playbooks/host/setup/common.yml +0 -0
  44. {labops-0.3.2 → labops-0.4.0}/ansible/playbooks/host/setup/debian.yml +0 -0
  45. {labops-0.3.2 → labops-0.4.0}/ansible/playbooks/host/setup/redhat.yml +0 -0
  46. {labops-0.3.2 → labops-0.4.0}/ansible/playbooks/host/setup.yml +0 -0
  47. {labops-0.3.2 → labops-0.4.0}/ansible/playbooks/host/update/alpine.yml +0 -0
  48. {labops-0.3.2 → labops-0.4.0}/ansible/playbooks/host/update/debian.yml +0 -0
  49. {labops-0.3.2 → labops-0.4.0}/ansible/playbooks/host/update/redhat.yml +0 -0
  50. {labops-0.3.2 → labops-0.4.0}/ansible/playbooks/host/update.yml +0 -0
  51. {labops-0.3.2 → labops-0.4.0}/ansible/playbooks/lxc/update/alpine.yml +0 -0
  52. {labops-0.3.2 → labops-0.4.0}/ansible/playbooks/lxc/update/debian.yml +0 -0
  53. {labops-0.3.2 → labops-0.4.0}/ansible/playbooks/lxc/update/redhat.yml +0 -0
  54. {labops-0.3.2 → labops-0.4.0}/ansible/playbooks/lxc/update.yml +0 -0
  55. {labops-0.3.2 → labops-0.4.0}/ansible/requirements.yml +0 -0
  56. {labops-0.3.2 → labops-0.4.0}/img/Cover.png +0 -0
  57. {labops-0.3.2 → labops-0.4.0}/labops_cli.py +0 -0
  58. {labops-0.3.2 → labops-0.4.0}/models/input_conf/settings.py +0 -0
  59. {labops-0.3.2 → labops-0.4.0}/release-please-config.json +0 -0
  60. {labops-0.3.2 → labops-0.4.0}/src/cli/core.py +0 -0
  61. {labops-0.3.2 → labops-0.4.0}/src/host/__init__.py +0 -0
  62. {labops-0.3.2 → labops-0.4.0}/src/lxc/__init__.py +0 -0
  63. {labops-0.3.2 → labops-0.4.0}/src/utils/__init__.py +0 -0
  64. {labops-0.3.2 → labops-0.4.0}/src/utils/ansible_runner.py +0 -0
  65. {labops-0.3.2 → labops-0.4.0}/src/vm/__init__.py +0 -0
  66. {labops-0.3.2 → labops-0.4.0}/test-samples/docker/homeassistant/docker-compose.yaml +0 -0
  67. {labops-0.3.2 → labops-0.4.0}/test-samples/docker/nebula-sync/docker-compose.yaml +0 -0
  68. {labops-0.3.2 → labops-0.4.0}/test-samples/docker/nginx/docker-compose.yaml +0 -0
  69. {labops-0.3.2 → labops-0.4.0}/test-samples/homelab-test.yml +0 -0
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "0.4.0"
3
+ }
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.4.0](https://github.com/FreezeManny/labops/compare/labops-v0.3.2...labops-v0.4.0) (2026-04-22)
4
+
5
+
6
+ ### Features
7
+
8
+ * add devcontainer commands and initialize docker attribute in Host model ([0838b09](https://github.com/FreezeManny/labops/commit/0838b099ecae1f0c51ac21b469234c561fae02a2))
9
+ * add validation for duplicate web service ports in host, lxc, and vm models ([683dd75](https://github.com/FreezeManny/labops/commit/683dd7537fb2028064316f19137b243bdf4c202a))
10
+ * added duplicate VMID Validation for proxmox host and lxc ([cb6d35a](https://github.com/FreezeManny/labops/commit/cb6d35ad07cafd7843c56c42cde98380600e0e41))
11
+ * added proxy name duplication check ([c68baf6](https://github.com/FreezeManny/labops/commit/c68baf6d4bbc90ab4d217784fb324c16d3acb33e))
12
+ * changed docker and webservice structure ([10a6da9](https://github.com/FreezeManny/labops/commit/10a6da94e1d6ddf0a6ba432243ee17412ed4af76))
13
+ * Changed LXC Structure ([95b9615](https://github.com/FreezeManny/labops/commit/95b9615dc99e9747ccff7b2be2c2b8c0833c74c9))
14
+ * enhance YAML validation by adding unique IP address check ([f9ecfdf](https://github.com/FreezeManny/labops/commit/f9ecfdf390b501832ec73bed8c2e761c2ce21662))
15
+ * Improve error handling for duplicate ports, VM IDs, and IP addresses in validators ([5fb8aa4](https://github.com/FreezeManny/labops/commit/5fb8aa427e57aeb56afc1bc754a5e41fd43679ba))
16
+ * Pretty output for one error ([6a570f0](https://github.com/FreezeManny/labops/commit/6a570f02d58ce9f0d0736968e8b0f1b3ce1e3166))
17
+
18
+
19
+ ### Bug Fixes
20
+
21
+ * fixed validate ([e8d229f](https://github.com/FreezeManny/labops/commit/e8d229fa831bf3611f4680ce8205658b68adbf27))
22
+
3
23
  ## [0.3.2](https://github.com/FreezeManny/labops/compare/labops-v0.3.1...labops-v0.3.2) (2026-04-21)
4
24
 
5
25
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: labops
3
- Version: 0.3.2
3
+ Version: 0.4.0
4
4
  Summary: YAML Based homeLAB
5
5
  License-File: LICENSE
6
6
  Requires-Python: >=3.14
@@ -1,15 +1,28 @@
1
1
  runner := "uv run labops_cli.py"
2
2
  system := "debian"
3
- test_conf := "./test-samples/homelab-test.yml"
3
+ test_conf := "./test-samples/homelab-complete.yml"
4
4
 
5
5
  test-args := ""
6
6
  # In normal use no --file is needed — the CLI walks up from cwd and finds
7
7
  # homelab.yml automatically, just like `docker compose` finds compose.yml.
8
8
  # Test recipes pass --file explicitly to point at the sample config.
9
9
 
10
+ # ----------- DEVCONTAINER ------
11
+ container-start:
12
+ devcontainer up --workspace-folder .
13
+
14
+ container-attach:
15
+ devcontainer exec --workspace-folder . bash
16
+
10
17
  pre-commit:
11
18
  uv run pre-commit run --all-files
12
19
 
20
+ build:
21
+ uv build
22
+
23
+ local-install:
24
+ pipx install --force $(ls -t dist/*.whl | head -n1) --force
25
+
13
26
  # ── Normal usage (auto-discovers homelab.yml from cwd) ────────────────────────
14
27
 
15
28
  validate:
@@ -0,0 +1,34 @@
1
+ from typing import TypeVar, Any
2
+ from ..web_services import WebService
3
+ from ..docker import Docker
4
+
5
+ T = TypeVar("T")
6
+
7
+
8
+ def check_duplicate_ws_ports(obj: T) -> T:
9
+ all_ports: set[int] = set()
10
+ errors: list[str] = []
11
+
12
+ web_services: WebService | None = getattr(obj, "web_services", None)
13
+ if web_services:
14
+ for ws in web_services.root:
15
+ if ws.port in all_ports:
16
+ errors.append(f"Duplicate port found: {ws.port}")
17
+ else:
18
+ all_ports.add(ws.port)
19
+
20
+ docker: Docker | None = getattr(obj, "docker", None)
21
+ if docker:
22
+ for stack in docker.stacks.values():
23
+ stack_ws: WebService | None = getattr(stack, "web_services", None)
24
+ if stack_ws:
25
+ for ws in stack_ws.root:
26
+ if ws.port in all_ports:
27
+ errors.append(f"Duplicate port found: {ws.port}")
28
+ else:
29
+ all_ports.add(ws.port)
30
+
31
+ if errors:
32
+ raise ValueError("\n".join(errors))
33
+
34
+ return obj
@@ -2,6 +2,7 @@
2
2
  from pydantic import BaseModel, model_validator, field_validator, FilePath
3
3
  from typing import Optional, Dict, Any, Literal
4
4
  import os
5
+ import typer
5
6
 
6
7
  class Creds(BaseModel):
7
8
  username: str
@@ -23,4 +24,6 @@ class Creds(BaseModel):
23
24
  raise ValueError('Mutual exclusion error: Cannot set both passwd and ssh_key_path.')
24
25
  if not has_passwd and not has_key:
25
26
  raise ValueError('Missing credentials: Must set either passwd or ssh_key_path.')
27
+ if has_passwd and not has_key:
28
+ typer.secho('WARNING: Using only password authentication. Some features only work with an SSH key.', fg=typer.colors.YELLOW)
26
29
  return self
@@ -0,0 +1,5 @@
1
+ from typing import Literal
2
+
3
+ OSType = Literal["debian", "alpine", "redhat"]
4
+ HostType = Literal["bare-metal", "proxmox"]
5
+
@@ -0,0 +1,12 @@
1
+ from pydantic import BaseModel, model_validator, DirectoryPath
2
+ from typing import Optional, Dict
3
+
4
+ from .web_services import WebServices
5
+
6
+ class Docker(BaseModel):
7
+ root_path: str
8
+ stacks: Dict[str, StackEntry]
9
+
10
+ class StackEntry(BaseModel):
11
+ config_path: DirectoryPath
12
+ web_services: Optional[WebServices] = None
@@ -0,0 +1,69 @@
1
+ from pydantic import BaseModel, model_validator, DirectoryPath
2
+ from typing import Optional, Dict, Any, Literal
3
+ from ipaddress import IPv4Address
4
+
5
+ from .creds import Creds
6
+ from .web_services import WebServices
7
+ from .docker import Docker
8
+ from .lxc import LXCs
9
+ from .vm import VMs
10
+ from .custom_types import HostType, OSType
11
+ from .common_validators.web_services import check_duplicate_ws_ports
12
+
13
+ class Host(BaseModel):
14
+ name: str = ""
15
+ type: HostType = "bare-metal"
16
+ os: OSType
17
+ ip: IPv4Address
18
+ creds: Optional[Creds] = None
19
+ lxc: Optional[LXCs] = None
20
+ vm: Optional[VMs] = None
21
+ docker: Optional[Docker] = None
22
+ web_services: Optional[WebServices] = None
23
+
24
+ @model_validator(mode="after")
25
+ def check_proxmox_support(self) -> "Host":
26
+ if self.type != "proxmox":
27
+ if self.lxc is not None or self.vm is not None:
28
+ raise ValueError(
29
+ "Fields 'lxc' and 'vm' are only allowed when type is 'proxmox'"
30
+ )
31
+ return self
32
+
33
+ @model_validator(mode="after")
34
+ def check_duplicate_vmid(self) -> "Host":
35
+ all_ids: set[int] = set()
36
+ errors: list[str] = []
37
+ if self.lxc:
38
+ for lxc_obj in self.lxc.values():
39
+ if lxc_obj.vmid in all_ids:
40
+ errors.append(f"Duplicate vmid found: {lxc_obj.vmid}")
41
+ else:
42
+ all_ids.add(lxc_obj.vmid)
43
+ if self.vm:
44
+ for vm_obj in self.vm.values():
45
+ if vm_obj.vmid in all_ids:
46
+ errors.append(f"Duplicate vmid found: {vm_obj.vmid}")
47
+ else:
48
+ all_ids.add(vm_obj.vmid)
49
+ if errors:
50
+ raise ValueError("\n".join(errors))
51
+ return self
52
+
53
+ @model_validator(mode="after")
54
+ def check_duplicate_ws_ports(self) -> "Host":
55
+ return check_duplicate_ws_ports(self)
56
+
57
+ @model_validator(mode="after")
58
+ def propagate_lxc_vm_names(self) -> "Host":
59
+ # Inject the dictionary key as the 'name' attribute for child LXCs
60
+ if self.lxc:
61
+ for k, v in self.lxc.items():
62
+ v.name: str = k
63
+
64
+ # Inject the dictionary key as the 'name' attribute for child VMs
65
+ if self.vm:
66
+ for k, v in self.vm.items():
67
+ v.name: str = k
68
+
69
+ return self
@@ -0,0 +1,25 @@
1
+ from pydantic import BaseModel, DirectoryPath, model_validator
2
+ from ipaddress import IPv4Address
3
+ from typing import Optional, Dict
4
+
5
+ from .creds import Creds
6
+ from .web_services import WebServices
7
+ from .docker import Docker
8
+
9
+ from .custom_types import OSType
10
+ from .common_validators.web_services import check_duplicate_ws_ports
11
+
12
+ class LXC(BaseModel):
13
+ name: str = ""
14
+ ip: IPv4Address
15
+ os: OSType
16
+ vmid: int
17
+ creds: Optional[Creds] = None
18
+ web_services: Optional[WebServices] = None
19
+ docker: Optional[Docker] = None
20
+
21
+ @model_validator(mode="after")
22
+ def validate_ws_ports(self) -> "LXC":
23
+ return check_duplicate_ws_ports(self)
24
+
25
+ LXCs = Dict[str, LXC]
@@ -0,0 +1,30 @@
1
+
2
+ from pydantic import BaseModel, model_validator, DirectoryPath
3
+ from typing import Optional, Dict, Any, Literal
4
+ from ipaddress import IPv4Address
5
+
6
+ from .creds import Creds
7
+ from .web_services import WebServices
8
+ from .docker import Docker
9
+ from .lxc import LXC
10
+ from .custom_types import OSType, HostType
11
+ from .common_validators.web_services import check_duplicate_ws_ports
12
+
13
+ class VM(BaseModel):
14
+ name: str = ""
15
+ type: HostType = "bare-metal"
16
+ os: OSType
17
+ ip: IPv4Address
18
+ vmid: int
19
+ creds: Optional[Creds] = None
20
+ lxc: Optional[Dict[str, LXC]] = None
21
+ vm: Optional[Dict[str, "VM"]] = None ## CHECK THIS
22
+ web_services: Optional[WebServices] = None
23
+ docker: Optional[Docker] = None
24
+
25
+ @model_validator(mode="after")
26
+ def validate_ws_ports(self) -> "VM":
27
+ return check_duplicate_ws_ports(self)
28
+
29
+
30
+ VMs = Dict[str, VM]
@@ -0,0 +1,12 @@
1
+ from pydantic import BaseModel, model_validator, DirectoryPath, RootModel
2
+ from typing import Optional, Dict, List, Generator
3
+
4
+ class WebServices(RootModel):
5
+ root: List[WebService]
6
+
7
+ def __getitem__(self, item: int) -> WebService:
8
+ return self.root[item]
9
+
10
+ class WebService(BaseModel):
11
+ port: int
12
+ proxy_name: Optional[str] = None
@@ -0,0 +1,120 @@
1
+ from pydantic import BaseModel, model_validator
2
+ from typing import Optional, Dict, Any
3
+ from ipaddress import IPv4Address
4
+
5
+ from .lxc import LXC
6
+ from .vm import VM
7
+ from .web_services import WebService
8
+ from .docker import Docker, StackEntry
9
+ from models.input_conf.host import Host
10
+ from models.input_conf.settings import Settings
11
+
12
+ class YamlRoot(BaseModel):
13
+ settings: Settings
14
+ hosts: Optional[Dict[str, Host]] = None
15
+
16
+ @model_validator(mode="after")
17
+ def propagate_host_names(self) -> "YamlRoot":
18
+ if self.hosts:
19
+ for k, host in self.hosts.items():
20
+ host.name = k
21
+ return self
22
+
23
+ @model_validator(mode="after")
24
+ def validate_unique_ips(self) -> "YamlRoot":
25
+
26
+ all_ips: set[IPv4Address] = set()
27
+ errors: list[str] = []
28
+
29
+ def check_ips(node: object) -> None:
30
+ # Check for an 'ip' attribute of type IPv4Address
31
+ ip : IPv4Address | None = getattr(node, "ip", None)
32
+ if isinstance(ip, IPv4Address):
33
+ if ip in all_ips:
34
+ errors.append(f"Duplicate IP address found across configuration: '{ip}'")
35
+ else:
36
+ all_ips.add(ip)
37
+ # Check for lxc and vm attributes that are dict-like
38
+ lxc: LXC | None = getattr(node, "lxc", None)
39
+ if isinstance(lxc, dict):
40
+ for lxc_node in lxc.values():
41
+ check_ips(lxc_node)
42
+ vm: VM | None = getattr(node, "vm", None)
43
+ if isinstance(vm, dict):
44
+ for vm_node in vm.values():
45
+ check_ips(vm_node)
46
+
47
+ if self.hosts:
48
+ for host in self.hosts.values():
49
+ check_ips(host)
50
+
51
+ if errors:
52
+ raise ValueError("\n".join(errors))
53
+
54
+ return self
55
+
56
+ @model_validator(mode="after")
57
+ def validate_unique_names_and_proxy_names(self) -> "YamlRoot":
58
+ all_names: set[str] = set()
59
+ all_proxy_names: set[str] = set()
60
+ errors: list[str] = []
61
+
62
+ def check_proxy_names(node: object) -> None:
63
+ web_services: WebService | None = getattr(node, "web_services", None)
64
+ if web_services:
65
+ for ws in getattr(web_services, "root", []):
66
+ proxy_name: str | None = getattr(ws, "proxy_name", None)
67
+ if proxy_name:
68
+ if proxy_name in all_proxy_names:
69
+ errors.append(f"Duplicate proxy_name found across configuration: '{proxy_name}'")
70
+ else:
71
+ all_proxy_names.add(proxy_name)
72
+ docker: Docker | None = getattr(node, "docker", None)
73
+ if docker:
74
+ stacks: dict[str, StackEntry] = getattr(docker, "stacks", {})
75
+ for stack in stacks.values():
76
+ stack_ws: WebService | None = getattr(stack, "web_services", None)
77
+ if stack_ws:
78
+ for ws in getattr(stack_ws, "root", []):
79
+ proxy_name = getattr(ws, "proxy_name", None)
80
+ if proxy_name:
81
+ if proxy_name in all_proxy_names:
82
+ errors.append(f"Duplicate proxy_name found across configuration: '{proxy_name}'")
83
+ else:
84
+ all_proxy_names.add(proxy_name)
85
+ lxc: LXC | None = getattr(node, "lxc", None)
86
+ if isinstance(lxc, dict):
87
+ for lxc_node in lxc.values():
88
+ check_proxy_names(lxc_node)
89
+ vm: VM | None = getattr(node, "vm", None)
90
+ if isinstance(vm, dict):
91
+ for vm_node in vm.values():
92
+ check_proxy_names(vm_node)
93
+
94
+ if self.hosts:
95
+ for k, host in self.hosts.items():
96
+ if k in all_names:
97
+ errors.append(f"Duplicate name found across configuration: '{k}'")
98
+ else:
99
+ all_names.add(k)
100
+
101
+ if host.lxc:
102
+ for lxc_name in host.lxc.keys():
103
+ if lxc_name in all_names:
104
+ errors.append(f"Duplicate name found across configuration: '{lxc_name}'")
105
+ else:
106
+ all_names.add(lxc_name)
107
+
108
+ if host.vm:
109
+ for vm_name in host.vm.keys():
110
+ if vm_name in all_names:
111
+ errors.append(f"Duplicate name found across configuration: '{vm_name}'")
112
+ else:
113
+ all_names.add(vm_name)
114
+
115
+ check_proxy_names(host)
116
+
117
+ if errors:
118
+ raise ValueError("\n".join(errors))
119
+
120
+ return self
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "labops"
3
- version = "0.3.2"
3
+ version = "0.4.0"
4
4
  description = "YAML Based homeLAB"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.14"
@@ -4,7 +4,7 @@ from rich.table import Table
4
4
 
5
5
  from src.cli.core import get_model, resolve_targets, console, state
6
6
  from models.input_conf.yaml_root import YamlRoot
7
- from models.input_conf.hosts import Host
7
+ from models.input_conf.host import Host
8
8
  import src.host as host
9
9
 
10
10
  app = typer.Typer(help="Manage bare-metal hosts.", no_args_is_help=True)
@@ -5,7 +5,8 @@ from rich.table import Table
5
5
 
6
6
  from src.cli.core import get_model, resolve_targets, console, state
7
7
  from models.input_conf.yaml_root import YamlRoot
8
- from models.input_conf.hosts import LXC, Host
8
+ from models.input_conf.host import Host
9
+ from models.input_conf.lxc import LXC
9
10
  import src.lxc as lxc
10
11
 
11
12
  app = typer.Typer(help="Manage Proxmox LXC containers from Config.", no_args_is_help=True)
@@ -5,11 +5,11 @@ from rich.table import Table
5
5
 
6
6
  from src.cli.core import ConfigError, get_model, console
7
7
  from models.input_conf.yaml_root import YamlRoot
8
- from models.input_conf.hosts import Host
8
+ from models.input_conf.host import Host
9
9
 
10
10
  app = typer.Typer(help="Validate configuration.")
11
11
 
12
- @app.callback()
12
+ @app.callback(invoke_without_command=True)
13
13
  def validate(ctx: typer.Context) -> None:
14
14
  if ctx.invoked_subcommand:
15
15
  return
@@ -4,7 +4,7 @@ from rich.table import Table
4
4
 
5
5
  from src.cli.core import get_model, resolve_targets, console
6
6
  from models.input_conf.yaml_root import YamlRoot
7
- from models.input_conf.hosts import Host
7
+ from models.input_conf.host import Host
8
8
  import src.vm as vm
9
9
 
10
10
  app = typer.Typer(help="Manage virtual machines.", no_args_is_help=True)
@@ -1,5 +1,5 @@
1
1
  from models.input_conf.yaml_root import YamlRoot
2
- from models.input_conf.hosts import Host
2
+ from models.input_conf.host import Host
3
3
 
4
4
  def findAll(config: YamlRoot) -> list[Host]:
5
5
  if config.hosts is None:
@@ -2,7 +2,7 @@ from ansible_runner.runner import Runner
2
2
  from pathlib import Path
3
3
  import getpass
4
4
 
5
- from models.input_conf.hosts import Host
5
+ from models.input_conf.host import Host
6
6
  from models.input_conf.creds import Creds
7
7
  from src.utils.ansible_runner import run_playbook
8
8
 
@@ -1,6 +1,6 @@
1
1
  from ansible_runner.runner import Runner
2
- from models.input_conf.hosts import Host
3
- from models.input_conf.hosts import OSType
2
+ from models.input_conf.host import Host
3
+ from models.input_conf.host import OSType
4
4
  from models.input_conf.creds import Creds
5
5
  from src.utils.ansible_runner import run_playbook
6
6
 
@@ -1,6 +1,7 @@
1
1
  from src.utils.ansible_runner import run_playbook
2
2
  from models.input_conf.yaml_root import YamlRoot
3
- from models.input_conf.hosts import Host, LXC
3
+ from models.input_conf.host import Host
4
+ from models.input_conf.lxc import LXC
4
5
 
5
6
  def findAll(config: YamlRoot) -> list[tuple[Host, LXC]]:
6
7
  """Returns a list of all LXCs found inside Proxmox hosts in the Yaml config."""
@@ -1,5 +1,6 @@
1
1
  from src.utils.ansible_runner import run_playbook
2
- from models.input_conf.hosts import Host, LXC
2
+ from models.input_conf.host import Host
3
+ from models.input_conf.lxc import LXC
3
4
  from models.input_conf.creds import Creds
4
5
 
5
6
  def update(proxmox_lxc_pairs: list[tuple[Host, LXC]], default_creds: Creds, dry_run: bool = False, verbose: bool = False) -> None:
@@ -0,0 +1,55 @@
1
+ import os
2
+ from pathlib import Path
3
+ from typing import Dict, Any, Optional
4
+ from pydantic import ValidationError
5
+ from rich.console import Console
6
+ from rich.panel import Panel
7
+
8
+ from models.input_conf.yaml_root import YamlRoot
9
+
10
+ console = Console()
11
+
12
+ def validate_yaml(raw: Dict[str, Any], rootPath: str) -> Optional[YamlRoot]:
13
+ base_dir: Path = Path(rootPath).parent.resolve()
14
+ original_dir: str = os.getcwd()
15
+
16
+ os.chdir(base_dir)
17
+
18
+ model = None
19
+ try:
20
+ # model_validate is the standard Pydantic V2 way to load from dictionaries
21
+ model: YamlRoot = YamlRoot.model_validate(raw)
22
+
23
+ except ValidationError as e:
24
+ error_messages = []
25
+ for error in e.errors():
26
+ # Extract the exact path of the failure (e.g., hosts.cprox)
27
+ location: str = ".".join(str(loc) for loc in error.get('loc', []))
28
+
29
+ # Clean up the redundant Pydantic prefix
30
+ raw_msg: str = error.get('msg', '').replace('Value error, ', '')
31
+
32
+ # A single validator may raise multiple newline-separated errors
33
+ for msg in raw_msg.split("\n"):
34
+ if location:
35
+ error_messages.append(f"[bold yellow]{location}[/bold yellow]: [red]{msg}[/red]")
36
+ else:
37
+ error_messages.append(f"[red]{msg}[/red]")
38
+
39
+ formatted_errors = "\n".join(f"• {msg}" for msg in error_messages)
40
+
41
+ # Print the styled error panel
42
+ console.print(
43
+ Panel(
44
+ formatted_errors,
45
+ title="[bold red]YAML Validation Failed",
46
+ border_style="red",
47
+ expand=False
48
+ )
49
+ )
50
+
51
+ finally:
52
+ # Always ensure we return to the original working directory
53
+ os.chdir(original_dir)
54
+
55
+ return model
@@ -1,27 +1,27 @@
1
1
  from models.input_conf.yaml_root import YamlRoot
2
- from models.input_conf.hosts import Host
2
+ from models.input_conf.vm import VM
3
3
 
4
4
 
5
- def findAll(config: YamlRoot) -> list[Host]:
5
+ def findAll(config: YamlRoot) -> list[VM]:
6
6
  if config.hosts is None:
7
7
  raise KeyError("No hosts are defined in the configuration.")
8
8
 
9
- vms: list[Host] = []
9
+ vms: list[VM] = []
10
10
  for host in config.hosts.values():
11
11
  if host.vm is not None:
12
12
  vms.extend(host.vm.values())
13
13
  return vms
14
14
 
15
- def find(config: YamlRoot, targets: list[str]) -> list[Host]:
15
+ def find(config: YamlRoot, targets: list[str]) -> list[VM]:
16
16
  if config.hosts is None:
17
17
  raise KeyError("No hosts are defined in the configuration.")
18
18
 
19
- all_vms: dict[str, Host] = {}
19
+ all_vms: dict[str, VM] = {}
20
20
  for host in config.hosts.values():
21
21
  if host.vm is not None:
22
22
  all_vms.update(host.vm)
23
23
 
24
- found_vms: list[Host] = []
24
+ found_vms: list[VM] = []
25
25
  for target in targets:
26
26
  found = False
27
27
  if target in all_vms:
@@ -0,0 +1,105 @@
1
+ settings:
2
+ default_creds:
3
+ username: root
4
+ passwd: changeme
5
+ #ssh_key_path: xxxxx
6
+ dns:
7
+ local_dns_suffix: .lab
8
+ pihole_location: 10.0.10.5
9
+ proxy:
10
+ proxy_suffix: .mfritz.top
11
+ proxy_location: 10.0.10.6
12
+
13
+ hosts:
14
+ proxmox_test:
15
+ type: proxmox
16
+ os: debian
17
+ ip: 10.0.10.4
18
+ lxc:
19
+ wireguard:
20
+ ip: 10.0.10.8
21
+ os: alpine
22
+ vmid: 101
23
+
24
+ cprox:
25
+ type: proxmox
26
+ os: debian
27
+ ip: 10.0.10.3
28
+ lxc:
29
+ wireguard:
30
+ ip: 10.0.10.2
31
+ os: alpine
32
+ vmid: 100
33
+ tailscale:
34
+ ip: 10.0.20.3
35
+ os: alpine
36
+ vmid: 101
37
+ docker:
38
+ ip: 10.0.10.6
39
+ os: debian
40
+ vmid: 102
41
+ docker:
42
+ root_path: "/home/manuel"
43
+ stacks:
44
+ nginx-proxy-manager:
45
+ config_path: "./docker/nginx/"
46
+ web_services:
47
+ - proxy_name: home
48
+ port: 81
49
+ nebula-sync:
50
+ config_path: "./docker/nebula-sync/"
51
+ dfs-aip-interface:
52
+ config_path: "./docker/homeassistant/"
53
+ web_services:
54
+ - proxy_name: dfs-aip
55
+ port: 8081
56
+
57
+ home:
58
+ ip: 10.0.10.10
59
+ os: debian
60
+ vmid: 103
61
+ docker:
62
+ root_path: "/home/manuel"
63
+ stacks:
64
+ homeassistant:
65
+ config_path: "./docker/homeassistant/"
66
+ web_services:
67
+ - proxy_name: esphome
68
+ port: 6052
69
+ - proxy_name: home
70
+ port: 8123
71
+ - proxy_name: z2mqtt
72
+ port: 8080
73
+
74
+ pihole:
75
+ ip: 10.0.10.5
76
+ os: debian
77
+ vmid: 105
78
+ web_services:
79
+ - proxy_name: pihole
80
+ port: 8080
81
+
82
+ vm:
83
+ fr24-radar:
84
+ os: debian
85
+ ip: 10.0.50.149
86
+ vmid: 111
87
+
88
+ test-system:
89
+ os: debian
90
+ ip: 10.0.10.42
91
+
92
+ tmp-pi:
93
+ os: debian
94
+ ip: 10.0.10.254
95
+
96
+ lifeboat:
97
+ type: bare-metal
98
+ os: debian
99
+ ip: 10.0.10.9
100
+ web_services:
101
+ - proxy_name: pihole2
102
+ port: 8080
103
+
104
+
105
+
@@ -268,7 +268,7 @@ wheels = [
268
268
 
269
269
  [[package]]
270
270
  name = "labops"
271
- version = "0.3.2"
271
+ version = "0.4.0"
272
272
  source = { editable = "." }
273
273
  dependencies = [
274
274
  { name = "ansible-core" },
@@ -1,3 +0,0 @@
1
- {
2
- ".": "0.3.2"
3
- }
@@ -1,61 +0,0 @@
1
- from pydantic import BaseModel, model_validator, DirectoryPath
2
- from typing import Optional, Dict, Any, Literal
3
- from ipaddress import IPv4Address
4
-
5
- from models.input_conf.creds import Creds
6
-
7
- OSType = Literal["debian", "alpine", "redhat"]
8
- HostType = Literal["bare-metal", "proxmox"]
9
-
10
-
11
- class WebService(BaseModel):
12
- port: int
13
- proxy_name: Optional[str] = None
14
-
15
-
16
- class DockerStack(BaseModel):
17
- config_path: DirectoryPath
18
- web_services: Optional[Dict[str, WebService]] = None
19
-
20
-
21
- class LXC(BaseModel):
22
- name: str = ""
23
- ip: IPv4Address
24
- os: OSType
25
- vmid: int
26
- creds: Optional[Creds] = None
27
- web_services: Optional[Dict[str, WebService]] = None
28
- docker_stack: Optional[Dict[str, DockerStack]] = None
29
-
30
- class Host(BaseModel):
31
- name: str = ""
32
- type: HostType = "bare-metal"
33
- os: OSType
34
- ip: IPv4Address
35
- creds: Optional[Creds] = None
36
- lxc: Optional[Dict[str, LXC]] = None
37
- vm: Optional[Dict[str, "Host"]] = None ## CHECK THIS
38
- web_services: Optional[Dict[str, WebService]] = None
39
-
40
- @model_validator(mode="after")
41
- def check_proxmox_support(self) -> "Host":
42
- if self.type != "proxmox":
43
- if self.lxc is not None or self.vm is not None:
44
- raise ValueError(
45
- "Fields 'lxc' and 'vm' are only allowed when type is 'proxmox'"
46
- )
47
- return self
48
-
49
- @model_validator(mode="after")
50
- def propagate_lxc_vm_names(self) -> "Host":
51
- # Inject the dictionary key as the 'name' attribute for child LXCs
52
- if self.lxc:
53
- for k, v in self.lxc.items():
54
- v.name = k
55
-
56
- # Inject the dictionary key as the 'name' attribute for child VMs
57
- if self.vm:
58
- for k, v in self.vm.items():
59
- v.name = k
60
-
61
- return self
@@ -1,38 +0,0 @@
1
- from pydantic import BaseModel, model_validator
2
- from typing import Optional, Dict
3
-
4
- from models.input_conf.hosts import Host
5
- from models.input_conf.settings import Settings
6
-
7
- class YamlRoot(BaseModel):
8
- settings: Settings
9
- hosts: Optional[Dict[str, Host]] = None
10
-
11
- @model_validator(mode="after")
12
- def propagate_host_names(self) -> "YamlRoot":
13
- if self.hosts:
14
- for k, host in self.hosts.items():
15
- host.name = k
16
- return self
17
-
18
- @model_validator(mode="after")
19
- def validate_unique_names(self) -> "YamlRoot":
20
- all_names = set()
21
- if self.hosts:
22
- for k, host in self.hosts.items():
23
- if k in all_names:
24
- raise ValueError(f"Duplicate name found across configuration: '{k}'")
25
- all_names.add(k)
26
-
27
- if host.lxc:
28
- for lxc_name in host.lxc.keys():
29
- if lxc_name in all_names:
30
- raise ValueError(f"Duplicate name found across configuration: '{lxc_name}'")
31
- all_names.add(lxc_name)
32
-
33
- if host.vm:
34
- for vm_name in host.vm.keys():
35
- if vm_name in all_names:
36
- raise ValueError(f"Duplicate name found across configuration: '{vm_name}'")
37
- all_names.add(vm_name)
38
- return self
@@ -1,24 +0,0 @@
1
- from typing import Dict, Any, Optional
2
- from pydantic import ValidationError
3
- import os
4
- from pathlib import Path
5
-
6
- from models.input_conf.yaml_root import YamlRoot
7
-
8
-
9
- def validate_yaml(raw: Dict[str, Any], rootPath: str) -> Optional[YamlRoot]:
10
- base_dir: Path = Path(rootPath).parent.resolve()
11
- original_dir: str = os.getcwd()
12
- os.chdir(base_dir)
13
-
14
- model = None
15
- try:
16
- try:
17
- model = YamlRoot(**raw)
18
- except ValidationError as e:
19
- print("Validation error in YAML structure:")
20
- print(e)
21
- finally:
22
- os.chdir(original_dir)
23
-
24
- return model
@@ -1,100 +0,0 @@
1
- settings:
2
- default_creds:
3
- username: root
4
- passwd: changeme
5
- #ssh_key_path: xxxxx
6
- dns:
7
- local_dns_suffix: .lab
8
- pihole_location: 10.0.10.5
9
- proxy:
10
- proxy_suffix: .mfritz.top
11
- proxy_location: 10.0.10.6
12
-
13
- hosts:
14
- cprox:
15
- type: proxmox
16
- os: debian
17
- ip: 10.0.10.3
18
- lxc:
19
- wireguard:
20
- ip: 10.0.10.2
21
- os: alpine
22
- vmid: 100
23
- tailscale:
24
- ip: 10.0.20.3
25
- os: alpine
26
- vmid: 101
27
- docker:
28
- ip: 10.0.10.6
29
- os: debian
30
- vmid: 102
31
- docker_stack:
32
- nginx-proxy-manager:
33
- config_path: "./docker/nginx/"
34
- web_services:
35
- app:
36
- port: 81
37
- proxy_name: nginx
38
- nebula-sync:
39
- config_path: "./docker/nebula-sync/"
40
- dfs-aip-interface:
41
- config_path: "./docker/homeassistant/"
42
- web_services:
43
- dfs-aip:
44
- port: 8081
45
- proxy_name: dfs-aip
46
-
47
- home:
48
- ip: 10.0.10.10
49
- os: debian
50
- vmid: 103
51
- docker_stack:
52
- homeassistant:
53
- config_path: "./docker/homeassistant/"
54
- web_services:
55
- esphome:
56
- port: 6052
57
- homeassistant:
58
- port: 8123
59
- proxy_name: home
60
- zigbee2mqtt:
61
- port: 8080
62
- proxy_name: z2mqtt
63
-
64
- pihole:
65
- ip: 10.0.10.5
66
- os: debian
67
- vmid: 105
68
- web_services:
69
- admin-panel:
70
- port: 8080
71
- proxy_name: pihole
72
-
73
- vm:
74
- fr24-radar:
75
- os: debian
76
- ip: 10.0.50.149
77
- # for later -> Add option to add dncp
78
- # dhcp-reserve: # for later
79
- # ip: 10.0.30.5
80
- # mac: FF:FF:FF:FF:FF:FF
81
-
82
- test-system:
83
- os: debian
84
- ip: 10.0.10.42
85
-
86
- tmp-pi:
87
- os: debian
88
- ip: 10.0.10.254
89
- # for later -> Add option to add dncp
90
- # type: dhcp
91
- lifeboat:
92
- type: bare-metal
93
- os: debian
94
- ip: 10.0.10.9
95
- web_services:
96
- pihole:
97
- port: 8080
98
- proxy_name: pihole2
99
-
100
-
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes