lazyops-cli 1.0.0__py3-none-any.whl
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.
- commands/__init__.py +0 -0
- commands/list.py +22 -0
- commands/pack.py +75 -0
- commands/run.py +185 -0
- commands/search.py +57 -0
- commands/source.py +52 -0
- lazyops_cli/__init__.py +0 -0
- lazyops_cli/cli.py +31 -0
- lazyops_cli-1.0.0.dist-info/METADATA +247 -0
- lazyops_cli-1.0.0.dist-info/RECORD +22 -0
- lazyops_cli-1.0.0.dist-info/WHEEL +5 -0
- lazyops_cli-1.0.0.dist-info/entry_points.txt +2 -0
- lazyops_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
- lazyops_cli-1.0.0.dist-info/top_level.txt +3 -0
- registry/__init__.py +0 -0
- registry/catalog.py +50 -0
- registry/config.py +54 -0
- registry/discover.py +28 -0
- registry/fetch.py +175 -0
- registry/packs.py +34 -0
- registry/paths.py +11 -0
- registry/source.py +42 -0
commands/__init__.py
ADDED
|
File without changes
|
commands/list.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
|
|
3
|
+
from registry.catalog import CatalogError, iter_installed_workflows
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def register(app:typer.Typer):
|
|
7
|
+
|
|
8
|
+
@app.command("list",help="List all commands")
|
|
9
|
+
def list_workflows():
|
|
10
|
+
try:
|
|
11
|
+
items = iter_installed_workflows()
|
|
12
|
+
except CatalogError as exc:
|
|
13
|
+
typer.secho(str(exc), fg=typer.colors.RED, err=True)
|
|
14
|
+
raise typer.Exit(1)
|
|
15
|
+
if not items:
|
|
16
|
+
typer.echo("No workflows found.")
|
|
17
|
+
typer.echo("Install packs: lazyops pack add aws")
|
|
18
|
+
return
|
|
19
|
+
for i, item in enumerate(items, start=1):
|
|
20
|
+
wf = item["workflow"]
|
|
21
|
+
name = wf.get("name", item["plugin"])
|
|
22
|
+
typer.echo(f"{i}. {item['pack']}/{item['plugin']} — {name}")
|
commands/pack.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
|
|
3
|
+
from registry import packs as pack_registry
|
|
4
|
+
from registry.fetch import FetchError, list_pack_plugins
|
|
5
|
+
from registry.source import SourceError, get_source
|
|
6
|
+
|
|
7
|
+
pack_app = typer.Typer(help="Manage installed workflow packs (aws,kubernetes,...)")
|
|
8
|
+
|
|
9
|
+
def register(app: typer.Typer):
|
|
10
|
+
app.add_typer(pack_app,name="pack")
|
|
11
|
+
|
|
12
|
+
@pack_app.command("add")
|
|
13
|
+
def pack_add(name:str=typer.Argument(...,help="Pack id eg. aws")):
|
|
14
|
+
try:
|
|
15
|
+
pack_registry.add_pack(name)
|
|
16
|
+
except pack_registry.PackError as exc:
|
|
17
|
+
typer.secho(str(exc),fg=typer.colors.RED, err=True)
|
|
18
|
+
raise typer.Exit(1)
|
|
19
|
+
|
|
20
|
+
typer.secho(f"Installed pack: {name}", fg=typer.colors.GREEN)
|
|
21
|
+
typer.secho("Run workflow with: lazyops run <pack>/<plugin>")
|
|
22
|
+
|
|
23
|
+
@pack_app.command("list")
|
|
24
|
+
def pack_list(pack: str | None = typer.Argument(None, help="Optional pack id to list plugins")):
|
|
25
|
+
if pack is None:
|
|
26
|
+
items = pack_registry.list_packs()
|
|
27
|
+
if not items:
|
|
28
|
+
typer.echo("No packs installed.")
|
|
29
|
+
typer.echo("Install one: lazyops pack add aws")
|
|
30
|
+
return
|
|
31
|
+
try:
|
|
32
|
+
source = get_source()
|
|
33
|
+
except SourceError as exc:
|
|
34
|
+
typer.secho(str(exc), fg=typer.colors.RED, err=True)
|
|
35
|
+
raise typer.Exit(1)
|
|
36
|
+
for name in items:
|
|
37
|
+
try:
|
|
38
|
+
plugins = list_pack_plugins(
|
|
39
|
+
source["url"],
|
|
40
|
+
source["ref"],
|
|
41
|
+
source.get("path_prefix", "plugins"),
|
|
42
|
+
name,
|
|
43
|
+
)
|
|
44
|
+
typer.echo(f"{name} ({len(plugins)} plugins)")
|
|
45
|
+
except FetchError as exc:
|
|
46
|
+
typer.echo(f"{name} (failed to list: {exc})")
|
|
47
|
+
else:
|
|
48
|
+
try:
|
|
49
|
+
source = get_source()
|
|
50
|
+
plugins = list_pack_plugins(
|
|
51
|
+
source["url"],
|
|
52
|
+
source["ref"],
|
|
53
|
+
source.get("path_prefix", "plugins"),
|
|
54
|
+
pack,
|
|
55
|
+
)
|
|
56
|
+
except SourceError as exc:
|
|
57
|
+
typer.secho(str(exc), fg=typer.colors.RED, err=True)
|
|
58
|
+
raise typer.Exit(1)
|
|
59
|
+
except FetchError as exc:
|
|
60
|
+
typer.secho(str(exc), fg=typer.colors.RED, err=True)
|
|
61
|
+
raise typer.Exit(1)
|
|
62
|
+
if not plugins:
|
|
63
|
+
typer.echo(f"No plugins found in pack: {pack}")
|
|
64
|
+
return
|
|
65
|
+
for plugin in plugins:
|
|
66
|
+
typer.echo(f"{pack}/{plugin}")
|
|
67
|
+
|
|
68
|
+
@pack_app.command("remove")
|
|
69
|
+
def pack_remove(name: str = typer.Argument(..., help="Pack id to remove")):
|
|
70
|
+
try:
|
|
71
|
+
pack_registry.remove_pack(name)
|
|
72
|
+
except pack_registry.PackError as exc:
|
|
73
|
+
typer.secho(str(exc), fg=typer.colors.RED, err=True)
|
|
74
|
+
raise typer.Exit(1)
|
|
75
|
+
typer.secho(f"Removed pack: {name}", fg=typer.colors.GREEN)
|
commands/run.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import shutil
|
|
3
|
+
import subprocess
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
import yaml
|
|
7
|
+
|
|
8
|
+
from registry.discover import read_workflow
|
|
9
|
+
from registry.fetch import FetchError, fetch_pack_dir, fetch_plugin_dir, list_pack_plugins
|
|
10
|
+
from registry.packs import list_packs, pack_installed
|
|
11
|
+
from registry.paths import PROJECT_ROOT, venv_python
|
|
12
|
+
from registry.source import SourceError, get_source
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _parse_target(target:str)-> tuple[str,str]:
|
|
16
|
+
if "/" not in target:
|
|
17
|
+
raise typer.BadParameter("Use pack/plugin format, eg. aws/addpatchclasstag")
|
|
18
|
+
|
|
19
|
+
pack,plugin = target.split("/",1)
|
|
20
|
+
if not pack or not plugin:
|
|
21
|
+
raise typer.BadParameter("Use pack/plugin format, e.g. aws/addpatchclasstag")
|
|
22
|
+
return pack,plugin
|
|
23
|
+
|
|
24
|
+
def _run_target(pack: str, plugin: str, extra_args: list[str]) -> None:
|
|
25
|
+
if not pack_installed(pack):
|
|
26
|
+
typer.secho(
|
|
27
|
+
f"Pack not installed: {pack}. Run: lazyops pack add {pack}",
|
|
28
|
+
fg=typer.colors.RED,
|
|
29
|
+
err=True,
|
|
30
|
+
)
|
|
31
|
+
raise typer.Exit(1)
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
source = get_source()
|
|
35
|
+
except SourceError as exc:
|
|
36
|
+
typer.secho(str(exc), fg=typer.colors.RED, err=True)
|
|
37
|
+
raise typer.Exit(1)
|
|
38
|
+
|
|
39
|
+
tmp_root = None
|
|
40
|
+
workflow_dir = None
|
|
41
|
+
try:
|
|
42
|
+
workflow_dir = fetch_plugin_dir(
|
|
43
|
+
url=source["url"],
|
|
44
|
+
ref=source["ref"],
|
|
45
|
+
path_prefix=source.get("path_prefix", "plugins"),
|
|
46
|
+
pack=pack,
|
|
47
|
+
plugin=plugin,
|
|
48
|
+
)
|
|
49
|
+
tmp_root = workflow_dir.parent.parent
|
|
50
|
+
|
|
51
|
+
workflow_path = workflow_dir / "workflow.yaml"
|
|
52
|
+
workflow = read_workflow(workflow_path)
|
|
53
|
+
entrypoint = workflow_dir / workflow["entrypoint"]
|
|
54
|
+
inputs = list(extra_args or [])
|
|
55
|
+
|
|
56
|
+
env = os.environ.copy()
|
|
57
|
+
env.setdefault("LAZYOPS_ROOT", str(PROJECT_ROOT))
|
|
58
|
+
env.setdefault("WORKFLOW_ID", workflow.get("id", plugin))
|
|
59
|
+
env.setdefault("WORKFLOW_ROOT", str(workflow_dir))
|
|
60
|
+
env.setdefault("WORKFLOW_RUNTIME", workflow["runtime"])
|
|
61
|
+
env.setdefault("WORKFLOW_VERSION", workflow.get("version", "1.0.0"))
|
|
62
|
+
env.setdefault("WORKFLOW_PACK", pack)
|
|
63
|
+
env.setdefault("WORKFLOW_PLUGIN", plugin)
|
|
64
|
+
env.setdefault("WORKFLOW_SOURCE_REF", source["ref"])
|
|
65
|
+
|
|
66
|
+
if workflow["runtime"] == "bash":
|
|
67
|
+
cmd = ["bash", str(entrypoint)] + inputs
|
|
68
|
+
elif workflow["runtime"] == "python":
|
|
69
|
+
cmd = [venv_python(), str(entrypoint)] + inputs
|
|
70
|
+
elif workflow["runtime"] == "node":
|
|
71
|
+
cmd = ["node", str(entrypoint)] + inputs
|
|
72
|
+
else:
|
|
73
|
+
typer.secho(f"Unsupported runtime: {workflow['runtime']}", fg=typer.colors.RED, err=True)
|
|
74
|
+
raise typer.Exit(1)
|
|
75
|
+
|
|
76
|
+
subprocess.run(cmd, check=True, env=env, cwd=workflow_dir)
|
|
77
|
+
|
|
78
|
+
except FetchError as exc:
|
|
79
|
+
typer.secho(f"Failed to fetch workflow: {exc}", fg=typer.colors.RED, err=True)
|
|
80
|
+
raise typer.Exit(1)
|
|
81
|
+
finally:
|
|
82
|
+
if tmp_root is not None:
|
|
83
|
+
shutil.rmtree(tmp_root, ignore_errors=True)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _pick_index(prompt: str, max_index: int) -> int:
|
|
87
|
+
while True:
|
|
88
|
+
raw = typer.prompt(prompt)
|
|
89
|
+
try:
|
|
90
|
+
choice = int(raw)
|
|
91
|
+
except ValueError:
|
|
92
|
+
typer.secho(f"Enter a number between 1 and {max_index}", fg=typer.colors.RED)
|
|
93
|
+
continue
|
|
94
|
+
if 1 <= choice <= max_index:
|
|
95
|
+
return choice - 1
|
|
96
|
+
typer.secho(f"Enter a number between 1 and {max_index}", fg=typer.colors.RED)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _interactive_run() -> tuple[str, str]:
|
|
100
|
+
packs = list_packs()
|
|
101
|
+
if not packs:
|
|
102
|
+
typer.secho("No packs installed. Run: lazyops pack add aws", fg=typer.colors.RED, err=True)
|
|
103
|
+
raise typer.Exit(1)
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
source = get_source()
|
|
107
|
+
except SourceError as exc:
|
|
108
|
+
typer.secho(str(exc), fg=typer.colors.RED, err=True)
|
|
109
|
+
raise typer.Exit(1)
|
|
110
|
+
|
|
111
|
+
typer.echo("\nInstalled packs:")
|
|
112
|
+
pack_rows: list[tuple[str, list[str]]] = []
|
|
113
|
+
for i, pack in enumerate(packs, start=1):
|
|
114
|
+
try:
|
|
115
|
+
plugins = list_pack_plugins(
|
|
116
|
+
source["url"],
|
|
117
|
+
source["ref"],
|
|
118
|
+
source.get("path_prefix", "plugins"),
|
|
119
|
+
pack,
|
|
120
|
+
)
|
|
121
|
+
except FetchError as exc:
|
|
122
|
+
typer.secho(f" {i}. {pack} (failed: {exc})", fg=typer.colors.YELLOW)
|
|
123
|
+
plugins = []
|
|
124
|
+
pack_rows.append((pack, plugins))
|
|
125
|
+
typer.echo(f" {i}. {pack} ({len(plugins)} plugins)")
|
|
126
|
+
|
|
127
|
+
pack_idx = _pick_index(f"Select pack [1-{len(packs)}]: ", len(packs))
|
|
128
|
+
pack, plugins = pack_rows[pack_idx]
|
|
129
|
+
|
|
130
|
+
if not plugins:
|
|
131
|
+
typer.secho(f"No plugins in pack {pack}", fg=typer.colors.RED, err=True)
|
|
132
|
+
raise typer.Exit(1)
|
|
133
|
+
|
|
134
|
+
typer.echo(f"\nPlugins in {pack}:")
|
|
135
|
+
tmp_root = None
|
|
136
|
+
try:
|
|
137
|
+
tmp_root, pack_dir = fetch_pack_dir(
|
|
138
|
+
source["url"],
|
|
139
|
+
source["ref"],
|
|
140
|
+
source.get("path_prefix", "plugins"),
|
|
141
|
+
pack,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
# plugins list order from git; enrich with local workflow.yaml names
|
|
145
|
+
plugin_labels: dict[str, str] = {}
|
|
146
|
+
for child in pack_dir.iterdir():
|
|
147
|
+
if not child.is_dir() or child.name == "pack.yaml":
|
|
148
|
+
continue
|
|
149
|
+
yaml_path = child / "workflow.yaml"
|
|
150
|
+
if yaml_path.is_file():
|
|
151
|
+
wf = yaml.safe_load(yaml_path.read_text()) or {}
|
|
152
|
+
plugin_labels[child.name] = wf.get("name", child.name)
|
|
153
|
+
else:
|
|
154
|
+
plugin_labels[child.name] = child.name
|
|
155
|
+
|
|
156
|
+
for i, plugin in enumerate(plugins, start=1):
|
|
157
|
+
label = plugin_labels.get(plugin, plugin)
|
|
158
|
+
typer.echo(f" {i}. {plugin} — {label}")
|
|
159
|
+
|
|
160
|
+
finally:
|
|
161
|
+
if tmp_root is not None:
|
|
162
|
+
shutil.rmtree(tmp_root, ignore_errors=True)
|
|
163
|
+
|
|
164
|
+
plugin_idx = _pick_index(f"Select plugin [1-{len(plugins)}]: ", len(plugins))
|
|
165
|
+
plugin = plugins[plugin_idx]
|
|
166
|
+
|
|
167
|
+
confirm = typer.confirm(f"Run {pack}/{plugin}?")
|
|
168
|
+
if not confirm:
|
|
169
|
+
typer.echo("Cancelled.")
|
|
170
|
+
raise typer.Exit(0)
|
|
171
|
+
|
|
172
|
+
return pack, plugin
|
|
173
|
+
|
|
174
|
+
def register(app: typer.Typer):
|
|
175
|
+
|
|
176
|
+
@app.command("run", help="Run a workflow: lazyops run <pack>/<plugin> [args...]")
|
|
177
|
+
def run_workflow(
|
|
178
|
+
target: str | None = typer.Argument(default=None, help="pack/plugin, e.g. aws/addpatchclasstag"),
|
|
179
|
+
extra_args: list[str] | None = typer.Argument(None),
|
|
180
|
+
):
|
|
181
|
+
if target is None:
|
|
182
|
+
pack, plugin = _interactive_run()
|
|
183
|
+
else:
|
|
184
|
+
pack, plugin = _parse_target(target)
|
|
185
|
+
_run_target(pack, plugin, list(extra_args or []))
|
commands/search.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
|
|
3
|
+
from registry.catalog import CatalogError, iter_installed_workflows
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _matches(query: str, pack: str, plugin: str, workflow_data: dict) -> bool:
|
|
7
|
+
q = query.lower()
|
|
8
|
+
haystack = " ".join(
|
|
9
|
+
[
|
|
10
|
+
pack,
|
|
11
|
+
plugin,
|
|
12
|
+
f"{pack}/{plugin}",
|
|
13
|
+
str(workflow_data.get("id", "")),
|
|
14
|
+
str(workflow_data.get("name", "")),
|
|
15
|
+
str(workflow_data.get("description", "")),
|
|
16
|
+
]
|
|
17
|
+
).lower()
|
|
18
|
+
return q in haystack
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def register(app: typer.Typer):
|
|
23
|
+
|
|
24
|
+
@app.command("search", help="Search details about any command")
|
|
25
|
+
def search_workflows(query: str):
|
|
26
|
+
try:
|
|
27
|
+
items = iter_installed_workflows()
|
|
28
|
+
except CatalogError as exc:
|
|
29
|
+
typer.secho(str(exc), fg=typer.colors.RED, err=True)
|
|
30
|
+
raise typer.Exit(1)
|
|
31
|
+
matches = 0
|
|
32
|
+
for item in items:
|
|
33
|
+
pack = item["pack"]
|
|
34
|
+
plugin = item["plugin"]
|
|
35
|
+
workflow_data = item["workflow"]
|
|
36
|
+
if not _matches(query, pack, plugin, workflow_data):
|
|
37
|
+
continue
|
|
38
|
+
matches += 1
|
|
39
|
+
wf_id = workflow_data.get("id", plugin)
|
|
40
|
+
inputs = workflow_data.get("inputs") or []
|
|
41
|
+
print(f"\033[1mId\033[0m: {pack}/{wf_id}")
|
|
42
|
+
print(f"\033[1mName\033[0m: {workflow_data.get('name', wf_id)}")
|
|
43
|
+
print(f"\033[1mWhat i do?\033[0m: {workflow_data.get('description', '')}")
|
|
44
|
+
if inputs:
|
|
45
|
+
print("\033[1mInputs\033[0m:")
|
|
46
|
+
for inp in inputs:
|
|
47
|
+
label = "required" if inp.get("required") else "optional"
|
|
48
|
+
print(f" - {inp['name']} ({label})")
|
|
49
|
+
else:
|
|
50
|
+
print("\033[1mInputs\033[0m: none")
|
|
51
|
+
arg_names = [inp["name"] for inp in inputs if inp.get("required")]
|
|
52
|
+
args = " ".join(f"<{name}>" for name in arg_names)
|
|
53
|
+
target = f"{pack}/{plugin}"
|
|
54
|
+
print(f"\n\033[1msyntax\033[0m: lazyops run {target}{(' ' + args) if args else ''}")
|
|
55
|
+
print()
|
|
56
|
+
if matches == 0:
|
|
57
|
+
typer.echo(f"No workflows matched '{query}'.")
|
commands/source.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
|
|
3
|
+
from registry import source as source_registry
|
|
4
|
+
from registry.config import CONFIG_PATH
|
|
5
|
+
|
|
6
|
+
source_app = typer.Typer(help="Manage workflow sources (git branches, tags, local paths)")
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def register(app: typer.Typer):
|
|
10
|
+
app.add_typer(source_app, name="source")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@source_app.command("show")
|
|
14
|
+
def source_show():
|
|
15
|
+
try:
|
|
16
|
+
src = source_registry.get_source()
|
|
17
|
+
except source_registry.SourceError as exc:
|
|
18
|
+
typer.secho(str(exc), fg=typer.colors.RED, err=True)
|
|
19
|
+
raise typer.Exit(1)
|
|
20
|
+
typer.echo(f"url: {src['url']}")
|
|
21
|
+
typer.echo(f"ref: {src['ref']}")
|
|
22
|
+
typer.echo(f"path_prefix: {src.get('path_prefix', 'plugins')}")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@source_app.command("update")
|
|
26
|
+
def source_update(
|
|
27
|
+
ref: str = typer.Option(..., "--ref", "-r", help="New branch or tag"),
|
|
28
|
+
):
|
|
29
|
+
try:
|
|
30
|
+
entry = source_registry.update_source_ref(ref)
|
|
31
|
+
except source_registry.SourceError as exc:
|
|
32
|
+
typer.secho(str(exc), fg=typer.colors.RED, err=True)
|
|
33
|
+
raise typer.Exit(1)
|
|
34
|
+
typer.secho(f"Source updated to ref: {entry['ref']}", fg=typer.colors.GREEN)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@source_app.command("init")
|
|
38
|
+
def source_init(
|
|
39
|
+
url: str = typer.Argument(..., help="Git URL of lazyops-plugins repo"),
|
|
40
|
+
ref: str = typer.Option("v1.0.0", "--ref", "-r", help="Branch or tag (version)"),
|
|
41
|
+
path_prefix: str = typer.Option("plugins", "--path-prefix", help="Root dir in repo"),
|
|
42
|
+
):
|
|
43
|
+
try:
|
|
44
|
+
entry = source_registry.init_source(url, ref, path_prefix)
|
|
45
|
+
except source_registry.SourceError as exc:
|
|
46
|
+
typer.secho(str(exc), fg=typer.colors.RED, err=True)
|
|
47
|
+
raise typer.Exit(1)
|
|
48
|
+
typer.secho("Source configured", fg=typer.colors.GREEN)
|
|
49
|
+
typer.echo(f" url: {entry['url']}")
|
|
50
|
+
typer.echo(f" ref: {entry['ref']}")
|
|
51
|
+
typer.echo(f" path_prefix: {entry['path_prefix']}")
|
|
52
|
+
typer.echo(f" config: {CONFIG_PATH}")
|
lazyops_cli/__init__.py
ADDED
|
File without changes
|
lazyops_cli/cli.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
import importlib
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
app = typer.Typer()
|
|
7
|
+
|
|
8
|
+
COMMANDS_DIR = Path(__file__).parent.parent / "commands"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def discover_commands():
|
|
12
|
+
commands = []
|
|
13
|
+
for path in COMMANDS_DIR.iterdir():
|
|
14
|
+
if path.is_file() and path.suffix == ".py" and path.name != "__init__.py":
|
|
15
|
+
module=importlib.import_module(f"commands.{path.stem}")
|
|
16
|
+
if hasattr(module,"register"):
|
|
17
|
+
module.register(app)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@app.command()
|
|
21
|
+
def intro(name: str = "World"):
|
|
22
|
+
print(f"Hello {name}")
|
|
23
|
+
|
|
24
|
+
# This function will be targeted by your custom CLI name
|
|
25
|
+
discover_commands()
|
|
26
|
+
|
|
27
|
+
def run():
|
|
28
|
+
app()
|
|
29
|
+
|
|
30
|
+
if __name__ == "__main__":
|
|
31
|
+
run()
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: lazyops-cli
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: CLI to discover, fetch, and run reusable automation workflows from remote plugin packs.
|
|
5
|
+
Author: Mridul Tiwari
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/MridulTi/LazyOps
|
|
8
|
+
Project-URL: Repository, https://github.com/MridulTi/LazyOps
|
|
9
|
+
Project-URL: Issues, https://github.com/MridulTi/LazyOps/issues
|
|
10
|
+
Project-URL: Documentation, https://github.com/MridulTi/LazyOps#readme
|
|
11
|
+
Keywords: cli,automation,workflows,devops,ops
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Intended Audience :: System Administrators
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
|
|
22
|
+
Classifier: Topic :: System :: Systems Administration
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
License-File: LICENSE
|
|
26
|
+
Requires-Dist: typer>=0.12.0
|
|
27
|
+
Requires-Dist: PyYAML>=6.0
|
|
28
|
+
Dynamic: license-file
|
|
29
|
+
|
|
30
|
+
# LazyOps
|
|
31
|
+
|
|
32
|
+
> Turn your scattered scripts into reusable, searchable, and shareable workflows.
|
|
33
|
+
|
|
34
|
+
## Why LazyOps?
|
|
35
|
+
|
|
36
|
+
Every engineer has a folder full of scripts:
|
|
37
|
+
|
|
38
|
+
```text
|
|
39
|
+
cleanup.sh
|
|
40
|
+
restart-pods.py
|
|
41
|
+
rotate_logs.sh
|
|
42
|
+
debug-nginx.sh
|
|
43
|
+
fix-permissions.sh
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
After a few months:
|
|
47
|
+
|
|
48
|
+
* You forget they exist.
|
|
49
|
+
* You can't remember their arguments.
|
|
50
|
+
* They're undocumented.
|
|
51
|
+
* They only work on your machine.
|
|
52
|
+
* Your teammates rewrite the same automation.
|
|
53
|
+
|
|
54
|
+
LazyOps solves this by adding a lightweight workflow layer on top of existing scripts.
|
|
55
|
+
|
|
56
|
+
No rewrites. No proprietary language. Just structure.
|
|
57
|
+
|
|
58
|
+
## Features
|
|
59
|
+
|
|
60
|
+
* 🚀 Run scripts as named workflows (`lazyops run pack/plugin`)
|
|
61
|
+
* 📦 Install workflow **packs** (aws, kubernetes, security, …) from a remote catalog
|
|
62
|
+
* 🔍 Search and list workflows across installed packs
|
|
63
|
+
* 🐍 Support multiple runtimes (Bash, Python, Node.js, executables)
|
|
64
|
+
* 📝 Interactive picker when you run `lazyops run` with no arguments
|
|
65
|
+
* ✅ Manifest validation before execution
|
|
66
|
+
* 🔖 Pin catalog version by git branch or tag (`source.ref`)
|
|
67
|
+
* 🤝 Workflows live in a separate repo — share and version them independently
|
|
68
|
+
|
|
69
|
+
## Architecture
|
|
70
|
+
|
|
71
|
+
LazyOps is a **CLI-only installer**. Workflows are fetched at runtime from a separate plugins repo:
|
|
72
|
+
|
|
73
|
+
| Repo | Role |
|
|
74
|
+
|------|------|
|
|
75
|
+
| [LazyOps](https://github.com/MridulTi/LazyOps) | CLI, config, fetch & run |
|
|
76
|
+
| [lazyops-plugins](https://github.com/MridulTi/lazyops-plugins) | Workflow catalog (packs + plugins) |
|
|
77
|
+
|
|
78
|
+
Each **pack** is a bundle (e.g. `aws`, `kubernetes`). Each **plugin** is one workflow folder under that pack. The git **branch or tag** on the plugins repo is the catalog **version**.
|
|
79
|
+
|
|
80
|
+
```text
|
|
81
|
+
lazyops-plugins/
|
|
82
|
+
└── plugins/
|
|
83
|
+
└── aws/
|
|
84
|
+
├── pack.yaml
|
|
85
|
+
└── addpatchclasstag/
|
|
86
|
+
├── workflow.yaml
|
|
87
|
+
├── script.sh
|
|
88
|
+
└── README.md
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Run a workflow:
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
lazyops run aws/addpatchclasstag
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
LazyOps fetches the plugin at your configured `source.ref`, validates the manifest, and executes the script.
|
|
98
|
+
|
|
99
|
+
## Installation
|
|
100
|
+
|
|
101
|
+
### From PyPI (recommended)
|
|
102
|
+
|
|
103
|
+
The PyPI package is **`lazyops-cli`** (the name `lazyops` is taken by another project). The command is still `lazyops`:
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
pipx install lazyops-cli
|
|
107
|
+
lazyops --help
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Requires Python 3.10+ and `git` (used to fetch workflows at runtime).
|
|
111
|
+
|
|
112
|
+
### From source (development)
|
|
113
|
+
|
|
114
|
+
LazyOps runs from a project-local virtual environment (`.venv`). Do not install with system `pip`.
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
git clone https://github.com/MridulTi/LazyOps.git
|
|
118
|
+
cd LazyOps
|
|
119
|
+
bash setup.sh
|
|
120
|
+
source ~/.zshrc # or ~/.bashrc — loads the lazyops alias
|
|
121
|
+
lazyops --help # works from any directory
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
`setup.sh` adds a global shell alias pointing at the repo wrapper (which always uses `.venv`):
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
alias lazyops='/path/to/LazyOps/lazyops'
|
|
128
|
+
export LAZYOPS_ROOT='/path/to/LazyOps'
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
You can also run from the repo without the alias:
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
./lazyops --help
|
|
135
|
+
source .venv/bin/activate
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## Quick start
|
|
139
|
+
|
|
140
|
+
Point LazyOps at the plugins catalog, enable packs, and run workflows:
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
# 1. Configure the plugins source (branch = version)
|
|
144
|
+
lazyops source init https://github.com/MridulTi/lazyops-plugins.git --ref v1.0.0
|
|
145
|
+
|
|
146
|
+
# 2. Enable packs you need
|
|
147
|
+
lazyops pack add aws
|
|
148
|
+
lazyops pack add kubernetes
|
|
149
|
+
|
|
150
|
+
# 3. Discover and run
|
|
151
|
+
lazyops list
|
|
152
|
+
lazyops search patch
|
|
153
|
+
lazyops run aws/addpatchclasstag
|
|
154
|
+
lazyops run # interactive: pick pack → pick plugin → confirm
|
|
155
|
+
|
|
156
|
+
# 4. Upgrade catalog version
|
|
157
|
+
lazyops source update --ref v1.2.0
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
For local development against a checkout of lazyops-plugins:
|
|
161
|
+
|
|
162
|
+
```bash
|
|
163
|
+
lazyops source init file:///path/to/lazyops-plugins --ref main
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
Config is stored at `~/.lazyops/config.yaml`:
|
|
167
|
+
|
|
168
|
+
```yaml
|
|
169
|
+
source:
|
|
170
|
+
url: https://github.com/MridulTi/lazyops-plugins.git
|
|
171
|
+
ref: v1.0.0
|
|
172
|
+
path_prefix: plugins
|
|
173
|
+
packs:
|
|
174
|
+
- aws
|
|
175
|
+
- kubernetes
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
## Commands
|
|
179
|
+
|
|
180
|
+
| Command | Purpose |
|
|
181
|
+
|---------|---------|
|
|
182
|
+
| `lazyops source init <url> [--ref]` | Set plugins repo URL and version |
|
|
183
|
+
| `lazyops source show` | Show current source config |
|
|
184
|
+
| `lazyops source update --ref <ref>` | Bump catalog version |
|
|
185
|
+
| `lazyops pack add <pack>` | Enable a pack |
|
|
186
|
+
| `lazyops pack list` | List installed packs |
|
|
187
|
+
| `lazyops pack list <pack>` | List plugins in a pack |
|
|
188
|
+
| `lazyops pack remove <pack>` | Disable a pack |
|
|
189
|
+
| `lazyops run <pack>/<plugin> [args...]` | Fetch and run a workflow |
|
|
190
|
+
| `lazyops run` | Interactive workflow picker |
|
|
191
|
+
| `lazyops list` | List workflows in installed packs |
|
|
192
|
+
| `lazyops search <query>` | Search by name, id, or description |
|
|
193
|
+
|
|
194
|
+
## Example manifest
|
|
195
|
+
|
|
196
|
+
```yaml
|
|
197
|
+
id: addpatchclasstag
|
|
198
|
+
|
|
199
|
+
name: "Add Patch Class Tag"
|
|
200
|
+
|
|
201
|
+
description: "Add patch class tags to instances. Required env: AWS_REGION or REGION."
|
|
202
|
+
|
|
203
|
+
runtime: bash
|
|
204
|
+
|
|
205
|
+
entrypoint: script.sh
|
|
206
|
+
|
|
207
|
+
pack: aws
|
|
208
|
+
|
|
209
|
+
version: 1.0.0
|
|
210
|
+
|
|
211
|
+
inputs:
|
|
212
|
+
- name: instance_id
|
|
213
|
+
required: true
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
## Philosophy
|
|
217
|
+
|
|
218
|
+
Scripts should remain scripts.
|
|
219
|
+
|
|
220
|
+
LazyOps does not replace your automation. It makes it easier to organize, discover, document, and reuse — with a shared catalog your whole team can install and pin to a version.
|
|
221
|
+
|
|
222
|
+
## Roadmap
|
|
223
|
+
|
|
224
|
+
* [x] Workflow specification
|
|
225
|
+
* [x] Remote plugin catalog (source + packs)
|
|
226
|
+
* [x] Fetch and run workflows from git
|
|
227
|
+
* [x] Interactive CLI
|
|
228
|
+
* [x] Search and list
|
|
229
|
+
* [ ] Input validation
|
|
230
|
+
* [ ] Plugin runtimes
|
|
231
|
+
* [ ] Optional web dashboard
|
|
232
|
+
|
|
233
|
+
## Contributing
|
|
234
|
+
|
|
235
|
+
Contributions are welcome.
|
|
236
|
+
|
|
237
|
+
You can help by:
|
|
238
|
+
|
|
239
|
+
* Adding workflows to [lazyops-plugins](https://github.com/MridulTi/lazyops-plugins)
|
|
240
|
+
* Improving CLI documentation
|
|
241
|
+
* Supporting additional runtimes
|
|
242
|
+
* Reporting bugs
|
|
243
|
+
* Suggesting new features
|
|
244
|
+
|
|
245
|
+
## License
|
|
246
|
+
|
|
247
|
+
MIT License
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
commands/list.py,sha256=FntpRYPa5lOfRN1S1hY3uhSWjuxTLS-qEpRjzxy76yw,746
|
|
3
|
+
commands/pack.py,sha256=YmkS5kjsCw6Mhyk88fcSTNBLni5S1sPc3duc0UO7jSo,2705
|
|
4
|
+
commands/run.py,sha256=_Yjn5WWDDPyYLB7HgYLFU8Mp6sbZdE8ViI9P6eq4a68,6448
|
|
5
|
+
commands/search.py,sha256=gjuxp1eTxQRImO6OK2yI1zCho7kCaICxYZk_XokT4iM,2117
|
|
6
|
+
commands/source.py,sha256=fU_19SR0j8XB8Ot9WKKPs9IDturRS2whaPOGExsPMz0,1861
|
|
7
|
+
lazyops_cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
lazyops_cli/cli.py,sha256=D83WJsI9ypGTxa3-DUC3O_l21Uq0qp-NNsuTN708sOM,664
|
|
9
|
+
lazyops_cli-1.0.0.dist-info/licenses/LICENSE,sha256=Qt8MKT0gY0r-pDAO1zEWfB7YHPycn9Bsy7YUDwF8daQ,1070
|
|
10
|
+
registry/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
11
|
+
registry/catalog.py,sha256=R09XEhval3a2CakDbFJrAPN7cw3HuR5b-ex1xOtzsSE,1567
|
|
12
|
+
registry/config.py,sha256=fjSycupyO-GrrgggjBsjHit2jkdUCq_tjwCAzHcWK-Q,1848
|
|
13
|
+
registry/discover.py,sha256=an7R9XmOGHVcTfB97FvUGaxHYtu5F_pImatoy0bu7z0,858
|
|
14
|
+
registry/fetch.py,sha256=ak9u-lephFVtlgxzcb69SWhdIbA5dxvoUoWve-V1O4A,5180
|
|
15
|
+
registry/packs.py,sha256=1UyOe_K2rT7q-EkG4IhLefVJ8_ds4ytp315yAclz9CU,831
|
|
16
|
+
registry/paths.py,sha256=6vxsHPTAgdAUH4cbFHz9Mda0DBfP3SH2ti0MHBU5XIw,261
|
|
17
|
+
registry/source.py,sha256=L71aPgTyHjzzEjawKdEAZu1gNrnGAJstNe8YQLEu33o,1158
|
|
18
|
+
lazyops_cli-1.0.0.dist-info/METADATA,sha256=g-vAFZMTDVjgiS9NkPR1UhPILeqgzKmzcnTcqB3JJPE,6797
|
|
19
|
+
lazyops_cli-1.0.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
20
|
+
lazyops_cli-1.0.0.dist-info/entry_points.txt,sha256=qlVXtgEEZ2FAyGIkzwsb0AigUm5To2vah8yX6C0K238,48
|
|
21
|
+
lazyops_cli-1.0.0.dist-info/top_level.txt,sha256=pTKCX7t0APvoQBsimdqrLxWeI-eX3X1FlWD1NRZNpRY,30
|
|
22
|
+
lazyops_cli-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mridul Tiwari
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
registry/__init__.py
ADDED
|
File without changes
|
registry/catalog.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
|
|
5
|
+
import yaml
|
|
6
|
+
|
|
7
|
+
from registry.fetch import FetchError, fetch_pack_dir
|
|
8
|
+
from registry.packs import list_packs
|
|
9
|
+
from registry.source import SourceError, get_source
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class CatalogError(Exception):
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def iter_installed_workflows() -> list[dict]:
|
|
17
|
+
"""
|
|
18
|
+
Return metadata for every plugin under installed packs.
|
|
19
|
+
Each item: {pack, plugin, workflow}
|
|
20
|
+
"""
|
|
21
|
+
try:
|
|
22
|
+
source = get_source()
|
|
23
|
+
except SourceError as exc:
|
|
24
|
+
raise CatalogError(str(exc)) from exc
|
|
25
|
+
url = source["url"]
|
|
26
|
+
ref = source["ref"]
|
|
27
|
+
path_prefix = source.get("path_prefix", "plugins")
|
|
28
|
+
results: list[dict] = []
|
|
29
|
+
for pack in list_packs():
|
|
30
|
+
tmp_root = None
|
|
31
|
+
try:
|
|
32
|
+
tmp_root, pack_dir = fetch_pack_dir(url, ref, path_prefix, pack)
|
|
33
|
+
for child in sorted(pack_dir.iterdir()):
|
|
34
|
+
if not child.is_dir() or child.name == "pack.yaml":
|
|
35
|
+
continue
|
|
36
|
+
yaml_path = child / "workflow.yaml"
|
|
37
|
+
if not yaml_path.is_file():
|
|
38
|
+
continue
|
|
39
|
+
workflow = yaml.safe_load(yaml_path.read_text()) or {}
|
|
40
|
+
results.append({
|
|
41
|
+
"pack": pack,
|
|
42
|
+
"plugin": child.name,
|
|
43
|
+
"workflow": workflow,
|
|
44
|
+
})
|
|
45
|
+
except FetchError as exc:
|
|
46
|
+
raise CatalogError(f"Failed to load pack {pack}: {exc}") from exc
|
|
47
|
+
finally:
|
|
48
|
+
if tmp_root is not None:
|
|
49
|
+
shutil.rmtree(tmp_root, ignore_errors=True)
|
|
50
|
+
return results
|
registry/config.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import yaml,os
|
|
5
|
+
|
|
6
|
+
DEFAULT_CONFIG_DIR = Path(os.environ.get("LAZYOPS_CONFIG_DIR", str(Path.home() / ".lazyops")))
|
|
7
|
+
CONFIG_PATH = DEFAULT_CONFIG_DIR / "config.yaml"
|
|
8
|
+
|
|
9
|
+
DEFAULT_CONFIG = {"source": None, "packs": []}
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def ensure_config_dir() -> None:
|
|
13
|
+
DEFAULT_CONFIG_DIR.mkdir(parents=True,exist_ok=True)
|
|
14
|
+
|
|
15
|
+
def _migrate_config(data: dict) -> dict:
|
|
16
|
+
"""Normalize config to source + packs; migrate legacy sources[]."""
|
|
17
|
+
if not isinstance(data, dict):
|
|
18
|
+
data = {}
|
|
19
|
+
# Legacy: sources[] → single source (first entry only)
|
|
20
|
+
if "source" not in data and isinstance(data.get("sources"), list):
|
|
21
|
+
legacy = data["sources"]
|
|
22
|
+
if legacy:
|
|
23
|
+
first = legacy[0]
|
|
24
|
+
data["source"] = {
|
|
25
|
+
"url": first.get("url", ""),
|
|
26
|
+
"ref": first.get("ref", "v1.0.0"),
|
|
27
|
+
"path_prefix": first.get("path_prefix", "plugins"),
|
|
28
|
+
}
|
|
29
|
+
del data["sources"]
|
|
30
|
+
if data.get("source") is None:
|
|
31
|
+
data["source"] = None
|
|
32
|
+
elif isinstance(data["source"], dict):
|
|
33
|
+
src = data["source"]
|
|
34
|
+
data["source"] = {
|
|
35
|
+
"url": src.get("url", ""),
|
|
36
|
+
"ref": src.get("ref", "v1.0.0"),
|
|
37
|
+
"path_prefix": src.get("path_prefix", "plugins"),
|
|
38
|
+
}
|
|
39
|
+
if "packs" not in data or not isinstance(data["packs"], list):
|
|
40
|
+
data["packs"] = []
|
|
41
|
+
return data
|
|
42
|
+
|
|
43
|
+
def load_config() -> dict:
|
|
44
|
+
ensure_config_dir()
|
|
45
|
+
if not CONFIG_PATH.is_file():
|
|
46
|
+
return dict(DEFAULT_CONFIG)
|
|
47
|
+
with CONFIG_PATH.open("r",encoding="utf-8") as f:
|
|
48
|
+
data = yaml.safe_load(f) or {}
|
|
49
|
+
return _migrate_config(data)
|
|
50
|
+
|
|
51
|
+
def save_config(data: dict) -> None:
|
|
52
|
+
ensure_config_dir()
|
|
53
|
+
with CONFIG_PATH.open("w", encoding="utf-8") as f:
|
|
54
|
+
yaml.safe_dump(data, f, sort_keys=False, default_flow_style=False)
|
registry/discover.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
import yaml
|
|
3
|
+
|
|
4
|
+
def discover_workflows():
|
|
5
|
+
workflow_root= Path(__file__).parent.parent / "workflows"
|
|
6
|
+
workflows=[]
|
|
7
|
+
for workflows_dir in workflow_root.iterdir():
|
|
8
|
+
if workflows_dir.is_dir():
|
|
9
|
+
yaml_file=workflows_dir/ "workflow.yaml"
|
|
10
|
+
if yaml_file.is_file():
|
|
11
|
+
workflows.append(workflows_dir.name)
|
|
12
|
+
return workflows
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def discover_workflow_path():
|
|
16
|
+
workflow_root= Path(__file__).parent.parent / "workflows"
|
|
17
|
+
workflows=[]
|
|
18
|
+
for workflows_dir in workflow_root.iterdir():
|
|
19
|
+
if workflows_dir.is_dir():
|
|
20
|
+
yaml_file=workflows_dir/ "workflow.yaml"
|
|
21
|
+
if yaml_file.is_file():
|
|
22
|
+
workflows.append(yaml_file)
|
|
23
|
+
return workflows
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def read_workflow(workflow_path: Path):
|
|
27
|
+
with open(workflow_path,"r") as f:
|
|
28
|
+
return yaml.safe_load(f)
|
registry/fetch.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import shutil,yaml
|
|
4
|
+
import subprocess
|
|
5
|
+
import tempfile
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class FetchError(Exception):
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _run_git(args: list[str], cwd: Path | None = None) -> None:
|
|
14
|
+
result = subprocess.run(
|
|
15
|
+
["git",*args],
|
|
16
|
+
cwd=cwd,
|
|
17
|
+
capture_output=True,
|
|
18
|
+
text=True,
|
|
19
|
+
)
|
|
20
|
+
if result.returncode !=0:
|
|
21
|
+
msg=(result.stderr or result.stdout or "").strip()
|
|
22
|
+
raise FetchError(msg or f"git {''.join(args)} failed")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def plugin_relpath(path_prefix: str, pack: str, plugin: str) -> str:
|
|
26
|
+
prefix = path_prefix.strip('/')
|
|
27
|
+
return f"{prefix}/{pack}/{plugin}"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def fetch_plugin_dir(
|
|
31
|
+
url: str,
|
|
32
|
+
ref: str,
|
|
33
|
+
path_prefix: str,
|
|
34
|
+
pack: str,
|
|
35
|
+
plugin: str
|
|
36
|
+
) -> Path:
|
|
37
|
+
"""
|
|
38
|
+
Sparse-clone one workflow dir at ref.
|
|
39
|
+
Returns path to the plugin dir (contains workflow.yaml).
|
|
40
|
+
Caller deletes parent temp dir when done.
|
|
41
|
+
"""
|
|
42
|
+
rel = plugin_relpath(path_prefix, pack, plugin)
|
|
43
|
+
tmp_root = Path(tempfile.mkdtemp(prefix="lazyops-"))
|
|
44
|
+
repo_dir = tmp_root / "repo"
|
|
45
|
+
try:
|
|
46
|
+
_run_git(
|
|
47
|
+
[
|
|
48
|
+
"clone",
|
|
49
|
+
"--depth", "1",
|
|
50
|
+
"--filter=blob:none",
|
|
51
|
+
"--sparse",
|
|
52
|
+
"--branch", ref,
|
|
53
|
+
url,
|
|
54
|
+
str(repo_dir),
|
|
55
|
+
]
|
|
56
|
+
)
|
|
57
|
+
_run_git(["sparse-checkout", "set", rel], cwd=repo_dir)
|
|
58
|
+
workflow_dir = repo_dir / rel
|
|
59
|
+
if not (workflow_dir / "workflow.yaml").is_file():
|
|
60
|
+
raise FetchError(
|
|
61
|
+
f"Workflow not found: {pack}/{plugin} at ref={ref} ({rel})"
|
|
62
|
+
)
|
|
63
|
+
dest = tmp_root / pack / plugin
|
|
64
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
65
|
+
shutil.move(str(workflow_dir), str(dest))
|
|
66
|
+
shutil.rmtree(repo_dir, ignore_errors=True)
|
|
67
|
+
return dest
|
|
68
|
+
except Exception:
|
|
69
|
+
shutil.rmtree(tmp_root, ignore_errors=True)
|
|
70
|
+
raise
|
|
71
|
+
|
|
72
|
+
def fetch_pack_dir(
|
|
73
|
+
url: str,
|
|
74
|
+
ref: str,
|
|
75
|
+
path_prefix: str,
|
|
76
|
+
pack: str,
|
|
77
|
+
) -> tuple[Path, Path]:
|
|
78
|
+
"""
|
|
79
|
+
Sparse-clone plugins/<pack>/ once.
|
|
80
|
+
Returns (tmp_root, pack_dir). Caller must rm tmp_root when done.
|
|
81
|
+
"""
|
|
82
|
+
rel = f"{path_prefix.strip('/')}/{pack}"
|
|
83
|
+
tmp_root = Path(tempfile.mkdtemp(prefix="lazyops-pack-"))
|
|
84
|
+
repo_dir = tmp_root / "repo"
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
_run_git(
|
|
88
|
+
[
|
|
89
|
+
"clone",
|
|
90
|
+
"--depth", "1",
|
|
91
|
+
"--filter=blob:none",
|
|
92
|
+
"--sparse",
|
|
93
|
+
"--branch", ref,
|
|
94
|
+
url,
|
|
95
|
+
str(repo_dir),
|
|
96
|
+
]
|
|
97
|
+
)
|
|
98
|
+
_run_git(["sparse-checkout", "set", rel], cwd=repo_dir)
|
|
99
|
+
|
|
100
|
+
pack_dir = repo_dir / rel
|
|
101
|
+
if not pack_dir.is_dir():
|
|
102
|
+
raise FetchError(f"Pack not found: {pack}")
|
|
103
|
+
|
|
104
|
+
dest = tmp_root / pack
|
|
105
|
+
shutil.move(str(pack_dir), str(dest))
|
|
106
|
+
shutil.rmtree(repo_dir, ignore_errors=True)
|
|
107
|
+
return tmp_root, dest
|
|
108
|
+
|
|
109
|
+
except Exception:
|
|
110
|
+
shutil.rmtree(tmp_root, ignore_errors=True)
|
|
111
|
+
raise
|
|
112
|
+
|
|
113
|
+
def list_pack_plugins(
|
|
114
|
+
url: str,
|
|
115
|
+
ref: str,
|
|
116
|
+
path_prefix: str,
|
|
117
|
+
pack: str,
|
|
118
|
+
) -> list[str]:
|
|
119
|
+
"""List plugin folder names under a pack (for pack list / interactive run)."""
|
|
120
|
+
prefix = path_prefix.strip("/")
|
|
121
|
+
pack_path = f"{prefix}/{pack}"
|
|
122
|
+
tmp_root = Path(tempfile.mkdtemp(prefix="lazyops-ls-"))
|
|
123
|
+
repo_dir = tmp_root / "repo"
|
|
124
|
+
repo_dir.mkdir(parents=True, exist_ok=True)
|
|
125
|
+
try:
|
|
126
|
+
_run_git(["init"], cwd=repo_dir)
|
|
127
|
+
_run_git(["remote", "add", "origin", url], cwd=repo_dir)
|
|
128
|
+
_run_git(["fetch", "--depth", "1", "origin", ref], cwd=repo_dir)
|
|
129
|
+
result = subprocess.run(
|
|
130
|
+
["git", "ls-tree", "-d", "--name-only", f"FETCH_HEAD:{pack_path}"],
|
|
131
|
+
cwd=repo_dir,
|
|
132
|
+
capture_output=True,
|
|
133
|
+
text=True,
|
|
134
|
+
)
|
|
135
|
+
if result.returncode != 0:
|
|
136
|
+
raise FetchError(result.stderr.strip() or f"Pack not found: {pack}")
|
|
137
|
+
names = []
|
|
138
|
+
for line in result.stdout.splitlines():
|
|
139
|
+
name = line.strip()
|
|
140
|
+
if name and name != "pack.yaml":
|
|
141
|
+
names.append(name)
|
|
142
|
+
return sorted(names)
|
|
143
|
+
finally:
|
|
144
|
+
shutil.rmtree(tmp_root, ignore_errors=True)
|
|
145
|
+
|
|
146
|
+
def read_plugin_workflow(
|
|
147
|
+
url: str,
|
|
148
|
+
ref: str,
|
|
149
|
+
path_prefix: str,
|
|
150
|
+
pack: str,
|
|
151
|
+
plugin: str
|
|
152
|
+
) -> dict:
|
|
153
|
+
"""Read workflow.yaml for one plugin at ref (no full clone)"""
|
|
154
|
+
rel = plugin_relpath(path_prefix, pack , plugin) + "/workflow.yaml"
|
|
155
|
+
tmp_root = Path(tempfile.mkdtemp(prefix="lazyops-yaml-"))
|
|
156
|
+
repo_dir = tmp_root / "repo"
|
|
157
|
+
repo_dir.mkdir(parents=True, exist_ok=True)
|
|
158
|
+
|
|
159
|
+
try:
|
|
160
|
+
_run_git(["init"], cwd=repo_dir)
|
|
161
|
+
_run_git(["remote", "add", "origin", url], cwd=repo_dir)
|
|
162
|
+
_run_git(["fetch", "--depth", "1", "origin", ref], cwd=repo_dir)
|
|
163
|
+
|
|
164
|
+
result = subprocess.run(
|
|
165
|
+
["git","show",f"FETCH_HEAD:{rel}"],
|
|
166
|
+
cwd=repo_dir,
|
|
167
|
+
capture_output=True,
|
|
168
|
+
text=True
|
|
169
|
+
)
|
|
170
|
+
if result.returncode != 0:
|
|
171
|
+
raise FetchError(f"Missing workflow.yaml for {pack}/{plugin}")
|
|
172
|
+
|
|
173
|
+
return yaml.safe_load(result.stdout) or {}
|
|
174
|
+
finally:
|
|
175
|
+
shutil.rmtree(tmp_root, ignore_errors=True)
|
registry/packs.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from registry.config import load_config,save_config
|
|
3
|
+
|
|
4
|
+
class PackError(Exception):
|
|
5
|
+
pass
|
|
6
|
+
|
|
7
|
+
def list_packs() -> list[str]:
|
|
8
|
+
return load_config().get("packs") or []
|
|
9
|
+
|
|
10
|
+
def pack_installed(name:str) -> bool:
|
|
11
|
+
return name in list_packs()
|
|
12
|
+
|
|
13
|
+
def add_pack(name:str) -> None:
|
|
14
|
+
if not name:
|
|
15
|
+
raise PackError("pack name is required")
|
|
16
|
+
|
|
17
|
+
config = load_config()
|
|
18
|
+
packs = config.setdefault("packs",[])
|
|
19
|
+
|
|
20
|
+
if name in packs:
|
|
21
|
+
raise PackError(f"Pack already installed: {name}")
|
|
22
|
+
|
|
23
|
+
packs.append(name)
|
|
24
|
+
save_config(config)
|
|
25
|
+
|
|
26
|
+
def remove_pack(name:str) -> None:
|
|
27
|
+
config = load_config()
|
|
28
|
+
packs = config.get("packs") or []
|
|
29
|
+
|
|
30
|
+
if name not in packs:
|
|
31
|
+
raise PackError(f"Pack not installed: {name}")
|
|
32
|
+
|
|
33
|
+
config["packs"] = [p for p in packs if p !=name]
|
|
34
|
+
save_config(config)
|
registry/paths.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def venv_python() -> str:
|
|
8
|
+
candidate = PROJECT_ROOT / ".venv" / "bin" / "python"
|
|
9
|
+
if candidate.is_file():
|
|
10
|
+
return str(candidate)
|
|
11
|
+
return sys.executable
|
registry/source.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from registry.config import load_config, save_config
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class SourceError(Exception):
|
|
7
|
+
pass
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def init_source(url: str, ref: str = "v1.0.0", path_prefix: str = "plugins") -> dict:
|
|
11
|
+
if not url:
|
|
12
|
+
raise SourceError("url is required")
|
|
13
|
+
config = load_config()
|
|
14
|
+
config["source"] = {
|
|
15
|
+
"url": url,
|
|
16
|
+
"ref": ref,
|
|
17
|
+
"path_prefix": path_prefix,
|
|
18
|
+
}
|
|
19
|
+
save_config(config)
|
|
20
|
+
return config["source"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def get_source() -> dict:
|
|
24
|
+
config = load_config()
|
|
25
|
+
source = config.get("source")
|
|
26
|
+
if not source or not isinstance(source, dict) or not source.get("url"):
|
|
27
|
+
raise SourceError(
|
|
28
|
+
"No source configured. Run: lazyops source init <git-url> --ref v1.0.0"
|
|
29
|
+
)
|
|
30
|
+
return source
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def update_source_ref(ref: str) -> dict:
|
|
34
|
+
if not ref:
|
|
35
|
+
raise SourceError("ref is required")
|
|
36
|
+
config = load_config()
|
|
37
|
+
source = config.get("source")
|
|
38
|
+
if not source or not isinstance(source, dict) or not source.get("url"):
|
|
39
|
+
raise SourceError("No source configured. Run: lazyops source init first")
|
|
40
|
+
source["ref"] = ref
|
|
41
|
+
save_config(config)
|
|
42
|
+
return source
|