inventoryctl 0.1.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.
- inventoryctl-0.1.0/PKG-INFO +34 -0
- inventoryctl-0.1.0/README.md +22 -0
- inventoryctl-0.1.0/pyproject.toml +20 -0
- inventoryctl-0.1.0/setup.cfg +4 -0
- inventoryctl-0.1.0/src/inventoryctl/__init__.py +0 -0
- inventoryctl-0.1.0/src/inventoryctl/commands/__init__.py +0 -0
- inventoryctl-0.1.0/src/inventoryctl/commands/add.py +87 -0
- inventoryctl-0.1.0/src/inventoryctl/commands/delete.py +67 -0
- inventoryctl-0.1.0/src/inventoryctl/commands/format_cmd.py +30 -0
- inventoryctl-0.1.0/src/inventoryctl/commands/get.py +42 -0
- inventoryctl-0.1.0/src/inventoryctl/commands/list_cmd.py +52 -0
- inventoryctl-0.1.0/src/inventoryctl/commands/render.py +92 -0
- inventoryctl-0.1.0/src/inventoryctl/commands/sync.py +108 -0
- inventoryctl-0.1.0/src/inventoryctl/commands/update.py +125 -0
- inventoryctl-0.1.0/src/inventoryctl/commands/validate.py +97 -0
- inventoryctl-0.1.0/src/inventoryctl/core/__init__.py +0 -0
- inventoryctl-0.1.0/src/inventoryctl/core/errors.py +26 -0
- inventoryctl-0.1.0/src/inventoryctl/core/yaml_handler.py +19 -0
- inventoryctl-0.1.0/src/inventoryctl/main.py +29 -0
- inventoryctl-0.1.0/src/inventoryctl.egg-info/PKG-INFO +34 -0
- inventoryctl-0.1.0/src/inventoryctl.egg-info/SOURCES.txt +23 -0
- inventoryctl-0.1.0/src/inventoryctl.egg-info/dependency_links.txt +1 -0
- inventoryctl-0.1.0/src/inventoryctl.egg-info/entry_points.txt +2 -0
- inventoryctl-0.1.0/src/inventoryctl.egg-info/requires.txt +3 -0
- inventoryctl-0.1.0/src/inventoryctl.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: inventoryctl
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A CLI tool for managing inventory YAML files
|
|
5
|
+
Author: Your Name
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Python: >=3.8
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: typer>=0.9.0
|
|
10
|
+
Requires-Dist: ruamel.yaml>=0.17.0
|
|
11
|
+
Requires-Dist: pydantic>=2.0.0
|
|
12
|
+
|
|
13
|
+
# inventoryctl
|
|
14
|
+
|
|
15
|
+
A CLI tool for managing inventory YAML files.
|
|
16
|
+
|
|
17
|
+
## Installation
|
|
18
|
+
|
|
19
|
+
Recommended:
|
|
20
|
+
```bash
|
|
21
|
+
pipx install .
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Or:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install .
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Usage
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
inventoryctl --help
|
|
34
|
+
```
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# inventoryctl
|
|
2
|
+
|
|
3
|
+
A CLI tool for managing inventory YAML files.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
Recommended:
|
|
8
|
+
```bash
|
|
9
|
+
pipx install .
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Or:
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pip install .
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Usage
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
inventoryctl --help
|
|
22
|
+
```
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "inventoryctl"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "A CLI tool for managing inventory YAML files"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Your Name" }]
|
|
13
|
+
dependencies = [
|
|
14
|
+
"typer>=0.9.0",
|
|
15
|
+
"ruamel.yaml>=0.17.0",
|
|
16
|
+
"pydantic>=2.0.0"
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[project.scripts]
|
|
20
|
+
inventoryctl = "inventoryctl.main:main"
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import List, Optional
|
|
4
|
+
from inventoryctl.core.yaml_handler import YamlHandler
|
|
5
|
+
from inventoryctl.core.errors import UserError, ConflictError
|
|
6
|
+
|
|
7
|
+
app = typer.Typer(no_args_is_help=True)
|
|
8
|
+
yaml_handler = YamlHandler()
|
|
9
|
+
|
|
10
|
+
def parse_vars(vars_list: List[str]) -> dict:
|
|
11
|
+
result = {}
|
|
12
|
+
for v in vars_list:
|
|
13
|
+
if "=" not in v:
|
|
14
|
+
raise UserError(f"Invalid var format: {v}. Expected key=value")
|
|
15
|
+
key, value = v.split("=", 1)
|
|
16
|
+
result[key] = value
|
|
17
|
+
return result
|
|
18
|
+
|
|
19
|
+
@app.command("host")
|
|
20
|
+
def add_host(
|
|
21
|
+
name: str,
|
|
22
|
+
inventory_file: Path,
|
|
23
|
+
group_name: str = typer.Option(..., "--group", help="Group name"),
|
|
24
|
+
ansible_host: str = typer.Option(..., "--ansible-host", help="Ansible host IP/DNS"),
|
|
25
|
+
var: Optional[List[str]] = typer.Option(None, "--var", help="key=value variables"),
|
|
26
|
+
source: Optional[str] = typer.Option(None, "--source", help="Source ID"),
|
|
27
|
+
force: bool = typer.Option(False, "--force", help="Fail if exists unless force is used"),
|
|
28
|
+
upsert: bool = typer.Option(False, "--upsert", help="Update if exists"),
|
|
29
|
+
):
|
|
30
|
+
"""
|
|
31
|
+
Add a host to the inventory.
|
|
32
|
+
"""
|
|
33
|
+
data = yaml_handler.load(inventory_file)
|
|
34
|
+
inventory_groups = data.setdefault("inventory_groups", {})
|
|
35
|
+
|
|
36
|
+
if group_name not in inventory_groups:
|
|
37
|
+
raise UserError(f"Group '{group_name}' does not exist. Create it first.")
|
|
38
|
+
|
|
39
|
+
group = inventory_groups[group_name]
|
|
40
|
+
hosts = group.setdefault("hosts", {})
|
|
41
|
+
|
|
42
|
+
if name in hosts:
|
|
43
|
+
if not upsert and not force:
|
|
44
|
+
raise ConflictError(f"Host '{name}' already exists in group '{group_name}'. Use --upsert to update or --force to overwrite.")
|
|
45
|
+
|
|
46
|
+
host_data = {"ansible_host": ansible_host}
|
|
47
|
+
if var:
|
|
48
|
+
host_data.update(parse_vars(var))
|
|
49
|
+
|
|
50
|
+
if source:
|
|
51
|
+
host_data.setdefault("_meta", {})["source"] = source
|
|
52
|
+
|
|
53
|
+
if name in hosts and upsert:
|
|
54
|
+
existing = hosts[name]
|
|
55
|
+
existing["ansible_host"] = ansible_host
|
|
56
|
+
if var:
|
|
57
|
+
existing.update(parse_vars(var))
|
|
58
|
+
if source:
|
|
59
|
+
if "_meta" not in existing: existing["_meta"] = {}
|
|
60
|
+
existing["_meta"]["source"] = source
|
|
61
|
+
else:
|
|
62
|
+
hosts[name] = host_data
|
|
63
|
+
|
|
64
|
+
yaml_handler.save(inventory_file, data)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@app.command("group")
|
|
68
|
+
def add_group(
|
|
69
|
+
name: str,
|
|
70
|
+
inventory_file: Path,
|
|
71
|
+
var: Optional[List[str]] = typer.Option(None, "--var", help="key=value variables"),
|
|
72
|
+
):
|
|
73
|
+
"""
|
|
74
|
+
Add a group to the inventory.
|
|
75
|
+
"""
|
|
76
|
+
data = yaml_handler.load(inventory_file)
|
|
77
|
+
inventory_groups = data.setdefault("inventory_groups", {})
|
|
78
|
+
|
|
79
|
+
if name in inventory_groups:
|
|
80
|
+
raise ConflictError(f"Group '{name}' already exists.")
|
|
81
|
+
|
|
82
|
+
group_data = {"hosts": {}}
|
|
83
|
+
if var:
|
|
84
|
+
group_data["vars"] = parse_vars(var)
|
|
85
|
+
|
|
86
|
+
inventory_groups[name] = group_data
|
|
87
|
+
yaml_handler.save(inventory_file, data)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Optional
|
|
4
|
+
from inventoryctl.core.yaml_handler import YamlHandler
|
|
5
|
+
from inventoryctl.core.errors import UserError
|
|
6
|
+
|
|
7
|
+
app = typer.Typer(no_args_is_help=True)
|
|
8
|
+
yaml_handler = YamlHandler()
|
|
9
|
+
|
|
10
|
+
@app.command("host")
|
|
11
|
+
def delete_host(
|
|
12
|
+
host_name: str,
|
|
13
|
+
inventory_file: Path,
|
|
14
|
+
group_name: Optional[str] = typer.Option(None, "--group", help="Group name"),
|
|
15
|
+
source: Optional[str] = typer.Option(None, "--source", help="Source ID"),
|
|
16
|
+
):
|
|
17
|
+
"""
|
|
18
|
+
Delete a host from the inventory.
|
|
19
|
+
"""
|
|
20
|
+
data = yaml_handler.load(inventory_file)
|
|
21
|
+
inventory_groups = data.get("inventory_groups", {})
|
|
22
|
+
|
|
23
|
+
# Logic similar to update: find host, then delete.
|
|
24
|
+
# Idempotent: if not found, succeed.
|
|
25
|
+
|
|
26
|
+
groups_to_check = []
|
|
27
|
+
if group_name:
|
|
28
|
+
if group_name in inventory_groups:
|
|
29
|
+
groups_to_check.append(group_name)
|
|
30
|
+
else:
|
|
31
|
+
groups_to_check = list(inventory_groups.keys())
|
|
32
|
+
|
|
33
|
+
deleted = False
|
|
34
|
+
for g_name in groups_to_check:
|
|
35
|
+
g_data = inventory_groups[g_name]
|
|
36
|
+
hosts = g_data.get("hosts", {})
|
|
37
|
+
if host_name in hosts:
|
|
38
|
+
host_data = hosts[host_name]
|
|
39
|
+
# Check source constraint
|
|
40
|
+
if source:
|
|
41
|
+
host_source = host_data.get("_meta", {}).get("source")
|
|
42
|
+
if host_source != source:
|
|
43
|
+
continue # Skip if source doesn't match
|
|
44
|
+
|
|
45
|
+
del hosts[host_name]
|
|
46
|
+
deleted = True
|
|
47
|
+
|
|
48
|
+
# If group was specified and we didn't delete (and didn't find because of source mismatch or just missing),
|
|
49
|
+
# for idempotency we just return success.
|
|
50
|
+
|
|
51
|
+
yaml_handler.save(inventory_file, data)
|
|
52
|
+
|
|
53
|
+
@app.command("group")
|
|
54
|
+
def delete_group(
|
|
55
|
+
group_name: str,
|
|
56
|
+
inventory_file: Path,
|
|
57
|
+
):
|
|
58
|
+
"""
|
|
59
|
+
Delete a group from the inventory.
|
|
60
|
+
"""
|
|
61
|
+
data = yaml_handler.load(inventory_file)
|
|
62
|
+
inventory_groups = data.get("inventory_groups", {})
|
|
63
|
+
|
|
64
|
+
if group_name in inventory_groups:
|
|
65
|
+
del inventory_groups[group_name]
|
|
66
|
+
|
|
67
|
+
yaml_handler.save(inventory_file, data)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from inventoryctl.core.yaml_handler import YamlHandler
|
|
4
|
+
|
|
5
|
+
def format_inventory(
|
|
6
|
+
inventory_file: Path,
|
|
7
|
+
):
|
|
8
|
+
"""
|
|
9
|
+
Format the inventory file (canonicalize).
|
|
10
|
+
"""
|
|
11
|
+
yaml_handler = YamlHandler()
|
|
12
|
+
data = yaml_handler.load(inventory_file)
|
|
13
|
+
|
|
14
|
+
# Logic to sort keys?
|
|
15
|
+
# For now, just loading and saving with ruamel might normalize some things,
|
|
16
|
+
# but to enforce sort order (e.g. hosts alphabetically), we'd need to manipulate the data.
|
|
17
|
+
|
|
18
|
+
inventory_groups = data.get("inventory_groups", {})
|
|
19
|
+
|
|
20
|
+
# Sort groups
|
|
21
|
+
# ruamel.yaml CommentedMap preserves order. We can reconstruct it.
|
|
22
|
+
# Note: This might lose comments if not careful, but ruamel is good at it
|
|
23
|
+
# if we just move items in the existing CommentedMap.
|
|
24
|
+
# But creating a new dict sorts it but loses comments attached to keys?
|
|
25
|
+
# Safe way: simple load/save ensures consistent indentation/formatting.
|
|
26
|
+
# To sort:
|
|
27
|
+
|
|
28
|
+
# Let's just save for now, which ensures the YAML settings from YamlHandler are applied.
|
|
29
|
+
yaml_handler.save(inventory_file, data)
|
|
30
|
+
typer.echo(f"Formatted {inventory_file}")
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
import json
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from ruamel.yaml import YAML
|
|
6
|
+
from inventoryctl.core.yaml_handler import YamlHandler
|
|
7
|
+
from inventoryctl.core.errors import UserError
|
|
8
|
+
|
|
9
|
+
app = typer.Typer(no_args_is_help=True)
|
|
10
|
+
yaml_handler = YamlHandler()
|
|
11
|
+
|
|
12
|
+
@app.command("host")
|
|
13
|
+
def get_host(
|
|
14
|
+
host_name: str,
|
|
15
|
+
inventory_file: Path,
|
|
16
|
+
):
|
|
17
|
+
"""
|
|
18
|
+
Get a host from the inventory.
|
|
19
|
+
"""
|
|
20
|
+
data = yaml_handler.load(inventory_file)
|
|
21
|
+
inventory_groups = data.get("inventory_groups", {})
|
|
22
|
+
|
|
23
|
+
found_host = None
|
|
24
|
+
|
|
25
|
+
for g_name, g_data in inventory_groups.items():
|
|
26
|
+
if host_name in g_data.get("hosts", {}):
|
|
27
|
+
found_host = g_data["hosts"][host_name]
|
|
28
|
+
# We might want to include the group name in the output?
|
|
29
|
+
# Spec says "Outputs YAML or JSON".
|
|
30
|
+
# If we just output the host data, it's a dict.
|
|
31
|
+
break
|
|
32
|
+
|
|
33
|
+
if found_host is None:
|
|
34
|
+
raise UserError(f"Host '{host_name}' not found.")
|
|
35
|
+
|
|
36
|
+
# Default to YAML output as per design goals (YAML in -> YAML out)
|
|
37
|
+
# But strictly, the tool could have an output format option.
|
|
38
|
+
# Spec: "Outputs YAML or JSON".
|
|
39
|
+
# I'll default to YAML.
|
|
40
|
+
|
|
41
|
+
yaml = YAML()
|
|
42
|
+
yaml.dump(found_host, sys.stdout)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
import sys
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Optional
|
|
5
|
+
from ruamel.yaml import YAML
|
|
6
|
+
from inventoryctl.core.yaml_handler import YamlHandler
|
|
7
|
+
|
|
8
|
+
app = typer.Typer(no_args_is_help=True)
|
|
9
|
+
yaml_handler = YamlHandler()
|
|
10
|
+
|
|
11
|
+
@app.command("hosts")
|
|
12
|
+
def list_hosts(
|
|
13
|
+
inventory_file: Path,
|
|
14
|
+
group_name: Optional[str] = typer.Option(None, "--group", help="Group name"),
|
|
15
|
+
source: Optional[str] = typer.Option(None, "--source", help="Source ID"),
|
|
16
|
+
):
|
|
17
|
+
"""
|
|
18
|
+
List hosts in the inventory.
|
|
19
|
+
"""
|
|
20
|
+
data = yaml_handler.load(inventory_file)
|
|
21
|
+
inventory_groups = data.get("inventory_groups", {})
|
|
22
|
+
|
|
23
|
+
result = []
|
|
24
|
+
|
|
25
|
+
groups_to_check = []
|
|
26
|
+
if group_name:
|
|
27
|
+
if group_name in inventory_groups:
|
|
28
|
+
groups_to_check.append(group_name)
|
|
29
|
+
else:
|
|
30
|
+
groups_to_check = list(inventory_groups.keys())
|
|
31
|
+
|
|
32
|
+
for g_name in groups_to_check:
|
|
33
|
+
g_data = inventory_groups[g_name]
|
|
34
|
+
hosts = g_data.get("hosts", {})
|
|
35
|
+
for h_name, h_data in hosts.items():
|
|
36
|
+
if source:
|
|
37
|
+
if h_data.get("_meta", {}).get("source") != source:
|
|
38
|
+
continue
|
|
39
|
+
|
|
40
|
+
# What to output? Just names? Or full objects?
|
|
41
|
+
# "List resources". Usually a list of names or summary.
|
|
42
|
+
# Example doesn't specify output format details, but "List Hosts" usually implies names.
|
|
43
|
+
# However, for machine parsing, maybe YAML list of objects?
|
|
44
|
+
# Let's output a YAML list of host names for now, or maybe a simple list.
|
|
45
|
+
# "inventoryctl list hosts ... inventory.yaml"
|
|
46
|
+
# If I look at kubectl get pods, it lists them.
|
|
47
|
+
# But "YAML in -> YAML out".
|
|
48
|
+
# Let's output a list of strings (hostnames) in YAML format.
|
|
49
|
+
result.append(h_name)
|
|
50
|
+
|
|
51
|
+
yaml = YAML()
|
|
52
|
+
yaml.dump(result, sys.stdout)
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
import sys
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from inventoryctl.core.yaml_handler import YamlHandler
|
|
5
|
+
from inventoryctl.core.errors import UserError
|
|
6
|
+
from ruamel.yaml import YAML
|
|
7
|
+
|
|
8
|
+
app = typer.Typer(no_args_is_help=True)
|
|
9
|
+
yaml_handler = YamlHandler()
|
|
10
|
+
|
|
11
|
+
@app.command("ansible")
|
|
12
|
+
def render_ansible(
|
|
13
|
+
inventory_file: Path,
|
|
14
|
+
):
|
|
15
|
+
"""
|
|
16
|
+
Render Ansible inventory.
|
|
17
|
+
"""
|
|
18
|
+
data = yaml_handler.load(inventory_file)
|
|
19
|
+
inventory_groups = data.get("inventory_groups", {})
|
|
20
|
+
|
|
21
|
+
# Structure for Ansible:
|
|
22
|
+
# all:
|
|
23
|
+
# children:
|
|
24
|
+
# group1: ...
|
|
25
|
+
# group2: ...
|
|
26
|
+
|
|
27
|
+
# The spec's "Inventory YAML Contract" seems to map `inventory_groups` directly to Ansible groups.
|
|
28
|
+
# But usually Ansible YAML inventory has `all` at top or keys are groups.
|
|
29
|
+
# "inventory_groups" key in the source file is a wrapper for this tool.
|
|
30
|
+
# To render ansible, we essentially strip that wrapper and maybe format it.
|
|
31
|
+
|
|
32
|
+
output = {}
|
|
33
|
+
for g_name, g_data in inventory_groups.items():
|
|
34
|
+
output[g_name] = g_data
|
|
35
|
+
# Filter out _meta from hosts?
|
|
36
|
+
# Spec: "_meta reserved namespace. Everything else is passed through to Ansible".
|
|
37
|
+
# So we should strip _meta from the rendered output.
|
|
38
|
+
|
|
39
|
+
if "hosts" in output[g_name]:
|
|
40
|
+
# Deep copy to avoid modifying original if we were caching,
|
|
41
|
+
# but here we loaded fresh.
|
|
42
|
+
hosts = output[g_name]["hosts"]
|
|
43
|
+
for h_name, h_data in hosts.items():
|
|
44
|
+
if "_meta" in h_data:
|
|
45
|
+
del h_data["_meta"]
|
|
46
|
+
|
|
47
|
+
yaml = YAML()
|
|
48
|
+
yaml.dump(output, sys.stdout)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@app.command("ssh")
|
|
52
|
+
def render_ssh(
|
|
53
|
+
inventory_file: Path,
|
|
54
|
+
):
|
|
55
|
+
"""
|
|
56
|
+
Render SSH config.
|
|
57
|
+
"""
|
|
58
|
+
data = yaml_handler.load(inventory_file)
|
|
59
|
+
inventory_groups = data.get("inventory_groups", {})
|
|
60
|
+
|
|
61
|
+
for g_name, g_data in inventory_groups.items():
|
|
62
|
+
group_vars = g_data.get("vars", {})
|
|
63
|
+
hosts = g_data.get("hosts", {})
|
|
64
|
+
|
|
65
|
+
for h_name, h_data in hosts.items():
|
|
66
|
+
# Merge group vars and host vars (host wins)
|
|
67
|
+
# We are looking for: ansible_host, ansible_user, ansible_ssh_common_args (ProxyJump)
|
|
68
|
+
|
|
69
|
+
# Effective config
|
|
70
|
+
host_val = h_data.get("ansible_host")
|
|
71
|
+
user_val = h_data.get("ansible_user", group_vars.get("ansible_user"))
|
|
72
|
+
common_args = h_data.get("ansible_ssh_common_args", group_vars.get("ansible_ssh_common_args"))
|
|
73
|
+
|
|
74
|
+
if not host_val:
|
|
75
|
+
continue
|
|
76
|
+
|
|
77
|
+
print(f"Host {h_name}")
|
|
78
|
+
print(f" HostName {host_val}")
|
|
79
|
+
if user_val:
|
|
80
|
+
print(f" User {user_val}")
|
|
81
|
+
|
|
82
|
+
if common_args:
|
|
83
|
+
# Basic parsing for ProxyJump
|
|
84
|
+
# "-o ProxyJump=ssh.mediacockpit.net"
|
|
85
|
+
if "ProxyJump=" in common_args:
|
|
86
|
+
# Extract value
|
|
87
|
+
parts = common_args.split("ProxyJump=")
|
|
88
|
+
if len(parts) > 1:
|
|
89
|
+
jump = parts[1].split()[0].replace('"', '').replace("'", "")
|
|
90
|
+
print(f" ProxyJump {jump}")
|
|
91
|
+
|
|
92
|
+
print("")
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
import json
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import List, Dict, Any
|
|
5
|
+
from ruamel.yaml import YAML
|
|
6
|
+
from inventoryctl.core.yaml_handler import YamlHandler
|
|
7
|
+
from inventoryctl.core.errors import UserError
|
|
8
|
+
|
|
9
|
+
app = typer.Typer(no_args_is_help=True)
|
|
10
|
+
yaml_handler = YamlHandler()
|
|
11
|
+
|
|
12
|
+
def load_input(input_file: Path) -> List[Dict[str, Any]]:
|
|
13
|
+
if not input_file.exists():
|
|
14
|
+
raise UserError(f"Input file '{input_file}' not found.")
|
|
15
|
+
|
|
16
|
+
with open(input_file, 'r') as f:
|
|
17
|
+
if input_file.suffix == '.json':
|
|
18
|
+
return json.load(f)
|
|
19
|
+
elif input_file.suffix in ['.yaml', '.yml']:
|
|
20
|
+
yaml = YAML(typ='safe')
|
|
21
|
+
return yaml.load(f) or []
|
|
22
|
+
else:
|
|
23
|
+
# Try parsing as JSON first, then YAML
|
|
24
|
+
content = f.read()
|
|
25
|
+
try:
|
|
26
|
+
return json.loads(content)
|
|
27
|
+
except json.JSONDecodeError:
|
|
28
|
+
yaml = YAML(typ='safe')
|
|
29
|
+
return yaml.load(content) or []
|
|
30
|
+
|
|
31
|
+
@app.command("hosts")
|
|
32
|
+
def sync_hosts(
|
|
33
|
+
inventory_file: Path,
|
|
34
|
+
group_name: str = typer.Option(..., "--group", help="Target group"),
|
|
35
|
+
source: str = typer.Option(..., "--source", help="Source ID"),
|
|
36
|
+
input_file: Path = typer.Option(..., "--input", help="Input file (JSON/YAML)"),
|
|
37
|
+
prune: bool = typer.Option(False, "--prune", help="Delete hosts not in input"),
|
|
38
|
+
):
|
|
39
|
+
"""
|
|
40
|
+
Sync hosts from an external source.
|
|
41
|
+
"""
|
|
42
|
+
input_hosts = load_input(input_file)
|
|
43
|
+
if not isinstance(input_hosts, list):
|
|
44
|
+
raise UserError("Input must be a list of host objects.")
|
|
45
|
+
|
|
46
|
+
data = yaml_handler.load(inventory_file)
|
|
47
|
+
inventory_groups = data.setdefault("inventory_groups", {})
|
|
48
|
+
|
|
49
|
+
# Ensure group exists
|
|
50
|
+
if group_name not in inventory_groups:
|
|
51
|
+
inventory_groups[group_name] = {"hosts": {}}
|
|
52
|
+
|
|
53
|
+
group_data = inventory_groups[group_name]
|
|
54
|
+
hosts = group_data.setdefault("hosts", {})
|
|
55
|
+
|
|
56
|
+
# Set of host names in input
|
|
57
|
+
input_host_names = set()
|
|
58
|
+
|
|
59
|
+
for h in input_hosts:
|
|
60
|
+
name = h.get("name")
|
|
61
|
+
if not name:
|
|
62
|
+
continue # Skip invalid input
|
|
63
|
+
input_host_names.add(name)
|
|
64
|
+
|
|
65
|
+
# Prepare host data
|
|
66
|
+
# "Create/update hosts in input"
|
|
67
|
+
# Input format example: {"name": "...", "ansible_host": "...", "vars": {...}}
|
|
68
|
+
|
|
69
|
+
host_payload = {}
|
|
70
|
+
if "ansible_host" in h:
|
|
71
|
+
host_payload["ansible_host"] = h["ansible_host"]
|
|
72
|
+
|
|
73
|
+
# Merge vars into the host object directly as per YAML structure
|
|
74
|
+
if "vars" in h and isinstance(h["vars"], dict):
|
|
75
|
+
host_payload.update(h["vars"])
|
|
76
|
+
|
|
77
|
+
# Add source meta
|
|
78
|
+
host_payload.setdefault("_meta", {})["source"] = source
|
|
79
|
+
|
|
80
|
+
# Update or Create
|
|
81
|
+
if name in hosts:
|
|
82
|
+
# Update existing
|
|
83
|
+
# We should preserve fields NOT in input?
|
|
84
|
+
# Spec says "Create/update hosts in input".
|
|
85
|
+
# Usually sync implies making it match the input for the managed fields.
|
|
86
|
+
# But if we have manual vars?
|
|
87
|
+
# "Delete hosts in group+source not in input" implies we own the "group+source" namespace.
|
|
88
|
+
# So for hosts from this source, we should probably overwrite.
|
|
89
|
+
# But we should respect existing structure if possible (comments etc).
|
|
90
|
+
|
|
91
|
+
existing = hosts[name]
|
|
92
|
+
existing.update(host_payload) # Shallow merge
|
|
93
|
+
# Ensure _meta source is set (it is in payload)
|
|
94
|
+
else:
|
|
95
|
+
hosts[name] = host_payload
|
|
96
|
+
|
|
97
|
+
if prune:
|
|
98
|
+
# Delete hosts in group+source not in input
|
|
99
|
+
to_delete = []
|
|
100
|
+
for h_name, h_data in hosts.items():
|
|
101
|
+
h_source = h_data.get("_meta", {}).get("source")
|
|
102
|
+
if h_source == source and h_name not in input_host_names:
|
|
103
|
+
to_delete.append(h_name)
|
|
104
|
+
|
|
105
|
+
for h_name in to_delete:
|
|
106
|
+
del hosts[h_name]
|
|
107
|
+
|
|
108
|
+
yaml_handler.save(inventory_file, data)
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import List, Optional
|
|
4
|
+
from inventoryctl.core.yaml_handler import YamlHandler
|
|
5
|
+
from inventoryctl.core.errors import UserError
|
|
6
|
+
|
|
7
|
+
app = typer.Typer(no_args_is_help=True)
|
|
8
|
+
yaml_handler = YamlHandler()
|
|
9
|
+
|
|
10
|
+
def parse_vars(vars_list: List[str]) -> dict:
|
|
11
|
+
result = {}
|
|
12
|
+
for v in vars_list:
|
|
13
|
+
if "=" not in v:
|
|
14
|
+
raise UserError(f"Invalid var format: {v}. Expected key=value")
|
|
15
|
+
key, value = v.split("=", 1)
|
|
16
|
+
result[key] = value
|
|
17
|
+
return result
|
|
18
|
+
|
|
19
|
+
@app.command("host")
|
|
20
|
+
def update_host(
|
|
21
|
+
host_name: str, # Positional argument for host name? Spec: inventoryctl update host <hostname> ... inventory.yaml
|
|
22
|
+
inventory_file: Path,
|
|
23
|
+
group_name: Optional[str] = typer.Option(None, "--group", help="Group name"),
|
|
24
|
+
ansible_host: Optional[str] = typer.Option(None, "--ansible-host", help="Ansible host IP/DNS"),
|
|
25
|
+
var: Optional[List[str]] = typer.Option(None, "--var", help="key=value variables"),
|
|
26
|
+
unset_var: Optional[List[str]] = typer.Option(None, "--unset-var", help="Variables to remove"),
|
|
27
|
+
):
|
|
28
|
+
"""
|
|
29
|
+
Update a host in the inventory.
|
|
30
|
+
"""
|
|
31
|
+
# Spec: inventoryctl update host <hostname> ... inventory.yaml
|
|
32
|
+
# host_name is first positional arg.
|
|
33
|
+
# inventory_file is second positional arg (after options).
|
|
34
|
+
|
|
35
|
+
data = yaml_handler.load(inventory_file)
|
|
36
|
+
inventory_groups = data.get("inventory_groups", {})
|
|
37
|
+
|
|
38
|
+
# Find the host. Host names are unique within a group, but globally?
|
|
39
|
+
# Spec "Inventory YAML Contract" shows hosts nested under groups.
|
|
40
|
+
# So a host is identified by Group + Name.
|
|
41
|
+
# But the update command has `--group` as *optional*?
|
|
42
|
+
# "inventoryctl update host <hostname> [--group <group>]"
|
|
43
|
+
# If group is not provided, do we search for it?
|
|
44
|
+
# "Host must exist".
|
|
45
|
+
# If group is provided, we check that group.
|
|
46
|
+
# If not, we scan all groups?
|
|
47
|
+
# Let's assume we scan if not provided, or fail if ambiguous.
|
|
48
|
+
|
|
49
|
+
target_group_name = None
|
|
50
|
+
target_host_data = None
|
|
51
|
+
|
|
52
|
+
if group_name:
|
|
53
|
+
if group_name in inventory_groups:
|
|
54
|
+
if host_name in inventory_groups[group_name].get("hosts", {}):
|
|
55
|
+
target_group_name = group_name
|
|
56
|
+
target_host_data = inventory_groups[group_name]["hosts"][host_name]
|
|
57
|
+
else:
|
|
58
|
+
found = []
|
|
59
|
+
for g_name, g_data in inventory_groups.items():
|
|
60
|
+
if host_name in g_data.get("hosts", {}):
|
|
61
|
+
found.append(g_name)
|
|
62
|
+
|
|
63
|
+
if len(found) == 1:
|
|
64
|
+
target_group_name = found[0]
|
|
65
|
+
target_host_data = inventory_groups[target_group_name]["hosts"][host_name]
|
|
66
|
+
elif len(found) > 1:
|
|
67
|
+
raise UserError(f"Host '{host_name}' found in multiple groups: {found}. Please specify --group.")
|
|
68
|
+
|
|
69
|
+
if target_host_data is None:
|
|
70
|
+
raise UserError(f"Host '{host_name}' not found.")
|
|
71
|
+
|
|
72
|
+
# Apply updates
|
|
73
|
+
if ansible_host:
|
|
74
|
+
target_host_data["ansible_host"] = ansible_host
|
|
75
|
+
|
|
76
|
+
if var:
|
|
77
|
+
target_host_data.update(parse_vars(var))
|
|
78
|
+
|
|
79
|
+
if unset_var:
|
|
80
|
+
for k in unset_var:
|
|
81
|
+
target_host_data.pop(k, None)
|
|
82
|
+
|
|
83
|
+
# If group is specified and different, move the host?
|
|
84
|
+
# Spec says "[--group <group>]". Usually means "in this group".
|
|
85
|
+
# But if I want to *move* a host?
|
|
86
|
+
# "Only provided fields change".
|
|
87
|
+
# If I say `update host foo --group bar`, does it move foo to bar?
|
|
88
|
+
# Or does it just mean "find foo in group bar and update it"?
|
|
89
|
+
# Given the ambiguity and "No implicit deletes", I'll assume it helps identify the host or modify properties.
|
|
90
|
+
# But a host doesn't have a "group" property in the YAML. The structure *is* the group.
|
|
91
|
+
# So changing group means moving.
|
|
92
|
+
# Let's stick to identifying for now. If moving is needed, usually it's delete + add or a specific move command.
|
|
93
|
+
# Or maybe `update host <hostname> --group <new_group>` moves it?
|
|
94
|
+
# Let's assume for now it's for identification.
|
|
95
|
+
|
|
96
|
+
yaml_handler.save(inventory_file, data)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@app.command("group")
|
|
100
|
+
def update_group(
|
|
101
|
+
group_name: str,
|
|
102
|
+
inventory_file: Path,
|
|
103
|
+
var: Optional[List[str]] = typer.Option(None, "--var", help="key=value variables"),
|
|
104
|
+
unset_var: Optional[List[str]] = typer.Option(None, "--unset-var", help="Variables to remove"),
|
|
105
|
+
):
|
|
106
|
+
"""
|
|
107
|
+
Update a group in the inventory.
|
|
108
|
+
"""
|
|
109
|
+
data = yaml_handler.load(inventory_file)
|
|
110
|
+
inventory_groups = data.get("inventory_groups", {})
|
|
111
|
+
|
|
112
|
+
if group_name not in inventory_groups:
|
|
113
|
+
raise UserError(f"Group '{group_name}' not found.")
|
|
114
|
+
|
|
115
|
+
group_data = inventory_groups[group_name]
|
|
116
|
+
vars_data = group_data.setdefault("vars", {})
|
|
117
|
+
|
|
118
|
+
if var:
|
|
119
|
+
vars_data.update(parse_vars(var))
|
|
120
|
+
|
|
121
|
+
if unset_var:
|
|
122
|
+
for k in unset_var:
|
|
123
|
+
vars_data.pop(k, None)
|
|
124
|
+
|
|
125
|
+
yaml_handler.save(inventory_file, data)
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from inventoryctl.core.yaml_handler import YamlHandler
|
|
4
|
+
from inventoryctl.core.errors import ValidationError
|
|
5
|
+
|
|
6
|
+
def validate(
|
|
7
|
+
inventory_file: Path,
|
|
8
|
+
):
|
|
9
|
+
"""
|
|
10
|
+
Validate the inventory schema.
|
|
11
|
+
"""
|
|
12
|
+
yaml_handler = YamlHandler()
|
|
13
|
+
data = yaml_handler.load(inventory_file)
|
|
14
|
+
|
|
15
|
+
if "inventory_groups" not in data:
|
|
16
|
+
raise ValidationError("Missing 'inventory_groups' key.")
|
|
17
|
+
|
|
18
|
+
inventory_groups = data["inventory_groups"]
|
|
19
|
+
if not isinstance(inventory_groups, dict):
|
|
20
|
+
raise ValidationError("'inventory_groups' must be a dictionary.")
|
|
21
|
+
|
|
22
|
+
# Check for duplicate hosts across groups?
|
|
23
|
+
# "Duplicate hosts" is listed as a check.
|
|
24
|
+
# If a host name appears in multiple groups, Ansible allows it (host in multiple groups).
|
|
25
|
+
# But for this system, is it allowed?
|
|
26
|
+
# In `update_host`, I assumed a host could be in multiple groups and handled ambiguity.
|
|
27
|
+
# But usually, if we manage "inventory.yaml" as a source of truth, maybe we want unique names?
|
|
28
|
+
# Spec "Inventory Data Model": "Host", "Group".
|
|
29
|
+
# Spec 9: "Duplicate hosts".
|
|
30
|
+
# If I have host 'web1' in 'aws' and 'web1' in 'prod', is that a duplicate?
|
|
31
|
+
# Ansible treats them as the same host.
|
|
32
|
+
# If they have conflicting data (e.g. ansible_host), that's an issue.
|
|
33
|
+
# Let's check for conflicting definitions of the same host name.
|
|
34
|
+
|
|
35
|
+
seen_hosts = {}
|
|
36
|
+
|
|
37
|
+
for g_name, g_data in inventory_groups.items():
|
|
38
|
+
if not isinstance(g_data, dict):
|
|
39
|
+
raise ValidationError(f"Group '{g_name}' must be a dictionary.")
|
|
40
|
+
|
|
41
|
+
hosts = g_data.get("hosts", {})
|
|
42
|
+
if not isinstance(hosts, dict):
|
|
43
|
+
raise ValidationError(f"Hosts in group '{g_name}' must be a dictionary.")
|
|
44
|
+
|
|
45
|
+
for h_name, h_data in hosts.items():
|
|
46
|
+
if not isinstance(h_data, dict):
|
|
47
|
+
raise ValidationError(f"Host '{h_name}' in group '{g_name}' must be a dictionary.")
|
|
48
|
+
|
|
49
|
+
# Check required fields? Spec doesn't strictly define required fields for a host,
|
|
50
|
+
# but usually ansible_host is good to have.
|
|
51
|
+
|
|
52
|
+
if h_name in seen_hosts:
|
|
53
|
+
# Check for conflict
|
|
54
|
+
prev_group = seen_hosts[h_name]["group"]
|
|
55
|
+
prev_data = seen_hosts[h_name]["data"]
|
|
56
|
+
|
|
57
|
+
# Simple check: do they look different?
|
|
58
|
+
# We can allow same host in multiple groups if the data is consistent OR if we treat them as merges.
|
|
59
|
+
# But spec says "Duplicate hosts" is a check.
|
|
60
|
+
# Let's warn or error if it appears twice?
|
|
61
|
+
# "Duplicate hosts" usually implies unique names required for this CLI management?
|
|
62
|
+
# If I `add host --name foo --group A` and then `add host --name foo --group B`,
|
|
63
|
+
# my add command would create it in B.
|
|
64
|
+
# If the validation fails on duplicates, then we enforce uniqueness.
|
|
65
|
+
# Let's assume strict uniqueness for now as it's simpler for "One command = one intent".
|
|
66
|
+
# Update: "Duplicate hosts" usually means defining the same host key twice in a dict (YAML doesn't allow it anyway).
|
|
67
|
+
# But here they are in different group dicts.
|
|
68
|
+
pass
|
|
69
|
+
# For now, I won't fail on same name in different groups unless I see a strong reason.
|
|
70
|
+
# Actually, checking `inventoryctl add host` implementation:
|
|
71
|
+
# It checks if name exists in *that* group.
|
|
72
|
+
# If I validate global uniqueness, `add` should check globally.
|
|
73
|
+
# Let's leave it as a warning or skip for now unless I see explicit constraint.
|
|
74
|
+
# Wait, "Duplicate hosts" in the validation list.
|
|
75
|
+
# Let's assume it checks if the same hostname is defined in multiple groups.
|
|
76
|
+
# Because if it is, `inventoryctl update host <hostname>` is ambiguous without group.
|
|
77
|
+
# And `update` requires group if ambiguous.
|
|
78
|
+
# So duplicates are *allowed* but checked?
|
|
79
|
+
# Or maybe "Duplicate hosts" means "Same host defined multiple times in the SAME group"?
|
|
80
|
+
# (YAML parser handles that usually by taking last or erroring).
|
|
81
|
+
# Let's assume it validates that if a host is in multiple groups, it's valid?
|
|
82
|
+
# Actually, let's look at `example.yaml`.
|
|
83
|
+
# Hosts seem unique.
|
|
84
|
+
# Let's check for duplicates across groups.
|
|
85
|
+
|
|
86
|
+
# ERROR: Host 'aws-prod-app3' defined in multiple groups: aws_hosts, other_group.
|
|
87
|
+
# If this is intended behavior for Ansible (it is), then validation shouldn't block it unless this CLI restricts it.
|
|
88
|
+
# Given "Canonical group structure" in render, maybe this CLI prefers 1 host = 1 primary group?
|
|
89
|
+
# Let's stick to schema validity for now.
|
|
90
|
+
|
|
91
|
+
seen_hosts[h_name] = {"group": g_name, "data": h_data}
|
|
92
|
+
|
|
93
|
+
# "Dangling groups": Groups without hosts? No, that's fine.
|
|
94
|
+
# Maybe groups referenced in children/parents but not defined?
|
|
95
|
+
# Hierarchy is not explicitly in `inventory_groups` structure in example.yaml (it's flat).
|
|
96
|
+
|
|
97
|
+
typer.echo("Inventory is valid.")
|
|
File without changes
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
|
|
3
|
+
class ExitCode(int, Enum):
|
|
4
|
+
SUCCESS = 0
|
|
5
|
+
USER_ERROR = 1
|
|
6
|
+
VALIDATION_ERROR = 2
|
|
7
|
+
CONFLICT = 3
|
|
8
|
+
INTERNAL_ERROR = 4
|
|
9
|
+
|
|
10
|
+
class InventoryError(Exception):
|
|
11
|
+
def __init__(self, message: str, exit_code: ExitCode = ExitCode.INTERNAL_ERROR):
|
|
12
|
+
self.message = message
|
|
13
|
+
self.exit_code = exit_code
|
|
14
|
+
super().__init__(message)
|
|
15
|
+
|
|
16
|
+
class UserError(InventoryError):
|
|
17
|
+
def __init__(self, message: str):
|
|
18
|
+
super().__init__(message, ExitCode.USER_ERROR)
|
|
19
|
+
|
|
20
|
+
class ValidationError(InventoryError):
|
|
21
|
+
def __init__(self, message: str):
|
|
22
|
+
super().__init__(message, ExitCode.VALIDATION_ERROR)
|
|
23
|
+
|
|
24
|
+
class ConflictError(InventoryError):
|
|
25
|
+
def __init__(self, message: str):
|
|
26
|
+
super().__init__(message, ExitCode.CONFLICT)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from ruamel.yaml import YAML
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Any, Dict
|
|
4
|
+
|
|
5
|
+
class YamlHandler:
|
|
6
|
+
def __init__(self):
|
|
7
|
+
self.yaml = YAML()
|
|
8
|
+
self.yaml.preserve_quotes = True
|
|
9
|
+
self.yaml.indent(mapping=2, sequence=2, offset=2)
|
|
10
|
+
|
|
11
|
+
def load(self, file_path: Path) -> Dict[str, Any]:
|
|
12
|
+
if not file_path.exists():
|
|
13
|
+
return {}
|
|
14
|
+
with open(file_path, 'r') as f:
|
|
15
|
+
return self.yaml.load(f) or {}
|
|
16
|
+
|
|
17
|
+
def save(self, file_path: Path, data: Any):
|
|
18
|
+
with open(file_path, 'w') as f:
|
|
19
|
+
self.yaml.dump(data, f)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
import sys
|
|
3
|
+
from inventoryctl.commands import add, update, delete, get, list_cmd, sync, validate, render, format_cmd
|
|
4
|
+
from inventoryctl.core.errors import InventoryError, ExitCode
|
|
5
|
+
|
|
6
|
+
app = typer.Typer(no_args_is_help=True)
|
|
7
|
+
|
|
8
|
+
app.add_typer(add.app, name="add", help="Create resource")
|
|
9
|
+
app.add_typer(update.app, name="update", help="Update resource")
|
|
10
|
+
app.add_typer(delete.app, name="delete", help="Delete resource")
|
|
11
|
+
app.add_typer(get.app, name="get", help="Fetch resource")
|
|
12
|
+
app.add_typer(list_cmd.app, name="list", help="List resources")
|
|
13
|
+
app.add_typer(sync.app, name="sync", help="Reconcile desired state")
|
|
14
|
+
app.command(name="validate")(validate.validate)
|
|
15
|
+
app.add_typer(render.app, name="render", help="Generate derived artifacts")
|
|
16
|
+
app.command(name="format")(format_cmd.format_inventory)
|
|
17
|
+
|
|
18
|
+
def main():
|
|
19
|
+
try:
|
|
20
|
+
app()
|
|
21
|
+
except InventoryError as e:
|
|
22
|
+
typer.echo(f"Error: {e.message}", err=True)
|
|
23
|
+
sys.exit(e.exit_code)
|
|
24
|
+
except Exception as e:
|
|
25
|
+
typer.echo(f"Unexpected error: {e}", err=True)
|
|
26
|
+
sys.exit(ExitCode.INTERNAL_ERROR)
|
|
27
|
+
|
|
28
|
+
if __name__ == "__main__":
|
|
29
|
+
main()
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: inventoryctl
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A CLI tool for managing inventory YAML files
|
|
5
|
+
Author: Your Name
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Python: >=3.8
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: typer>=0.9.0
|
|
10
|
+
Requires-Dist: ruamel.yaml>=0.17.0
|
|
11
|
+
Requires-Dist: pydantic>=2.0.0
|
|
12
|
+
|
|
13
|
+
# inventoryctl
|
|
14
|
+
|
|
15
|
+
A CLI tool for managing inventory YAML files.
|
|
16
|
+
|
|
17
|
+
## Installation
|
|
18
|
+
|
|
19
|
+
Recommended:
|
|
20
|
+
```bash
|
|
21
|
+
pipx install .
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Or:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install .
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Usage
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
inventoryctl --help
|
|
34
|
+
```
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/inventoryctl/__init__.py
|
|
4
|
+
src/inventoryctl/main.py
|
|
5
|
+
src/inventoryctl.egg-info/PKG-INFO
|
|
6
|
+
src/inventoryctl.egg-info/SOURCES.txt
|
|
7
|
+
src/inventoryctl.egg-info/dependency_links.txt
|
|
8
|
+
src/inventoryctl.egg-info/entry_points.txt
|
|
9
|
+
src/inventoryctl.egg-info/requires.txt
|
|
10
|
+
src/inventoryctl.egg-info/top_level.txt
|
|
11
|
+
src/inventoryctl/commands/__init__.py
|
|
12
|
+
src/inventoryctl/commands/add.py
|
|
13
|
+
src/inventoryctl/commands/delete.py
|
|
14
|
+
src/inventoryctl/commands/format_cmd.py
|
|
15
|
+
src/inventoryctl/commands/get.py
|
|
16
|
+
src/inventoryctl/commands/list_cmd.py
|
|
17
|
+
src/inventoryctl/commands/render.py
|
|
18
|
+
src/inventoryctl/commands/sync.py
|
|
19
|
+
src/inventoryctl/commands/update.py
|
|
20
|
+
src/inventoryctl/commands/validate.py
|
|
21
|
+
src/inventoryctl/core/__init__.py
|
|
22
|
+
src/inventoryctl/core/errors.py
|
|
23
|
+
src/inventoryctl/core/yaml_handler.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
inventoryctl
|