kctl-opencloud 0.5.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.
- kctl_opencloud/__init__.py +3 -0
- kctl_opencloud/__main__.py +5 -0
- kctl_opencloud/cli.py +124 -0
- kctl_opencloud/commands/__init__.py +0 -0
- kctl_opencloud/commands/config_cmd.py +191 -0
- kctl_opencloud/commands/dashboard.py +80 -0
- kctl_opencloud/commands/doctor_cmd.py +110 -0
- kctl_opencloud/commands/groups.py +130 -0
- kctl_opencloud/commands/health.py +135 -0
- kctl_opencloud/commands/shares.py +109 -0
- kctl_opencloud/commands/skill_cmd.py +50 -0
- kctl_opencloud/commands/spaces.py +173 -0
- kctl_opencloud/commands/users.py +133 -0
- kctl_opencloud/core/__init__.py +0 -0
- kctl_opencloud/core/callbacks.py +34 -0
- kctl_opencloud/core/client.py +96 -0
- kctl_opencloud/core/config.py +131 -0
- kctl_opencloud/core/exceptions.py +23 -0
- kctl_opencloud/core/output.py +7 -0
- kctl_opencloud-0.5.0.dist-info/METADATA +15 -0
- kctl_opencloud-0.5.0.dist-info/RECORD +23 -0
- kctl_opencloud-0.5.0.dist-info/WHEEL +4 -0
- kctl_opencloud-0.5.0.dist-info/entry_points.txt +2 -0
kctl_opencloud/cli.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Main CLI entry point for kctl-opencloud."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from kctl_lib import KctlError, handle_cli_error
|
|
9
|
+
|
|
10
|
+
from kctl_opencloud import __version__
|
|
11
|
+
from kctl_opencloud.commands.config_cmd import app as config_app
|
|
12
|
+
from kctl_opencloud.commands.dashboard import app as dashboard_app
|
|
13
|
+
from kctl_opencloud.commands.doctor_cmd import app as doctor_app
|
|
14
|
+
from kctl_opencloud.commands.groups import app as groups_app
|
|
15
|
+
from kctl_opencloud.commands.health import app as health_app
|
|
16
|
+
from kctl_opencloud.commands.shares import app as shares_app
|
|
17
|
+
from kctl_opencloud.commands.skill_cmd import app as skill_app
|
|
18
|
+
from kctl_opencloud.commands.spaces import app as spaces_app
|
|
19
|
+
from kctl_opencloud.commands.users import app as users_app
|
|
20
|
+
from kctl_opencloud.core.callbacks import AppContext
|
|
21
|
+
from kctl_lib.self_update import notify_if_outdated
|
|
22
|
+
from kctl_lib.tui import add_tui_command
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def version_callback(value: bool) -> None:
|
|
26
|
+
if value:
|
|
27
|
+
typer.echo(f"kctl-opencloud {__version__}")
|
|
28
|
+
raise typer.Exit()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
app = typer.Typer(
|
|
32
|
+
name="kctl-opencloud",
|
|
33
|
+
help="Kodemeio OpenCloud CLI - manage your OpenCloud file platform.",
|
|
34
|
+
no_args_is_help=True,
|
|
35
|
+
rich_markup_mode="rich",
|
|
36
|
+
pretty_exceptions_enable=False,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@app.callback()
|
|
41
|
+
def main(
|
|
42
|
+
ctx: typer.Context,
|
|
43
|
+
json_output: Annotated[bool, typer.Option("--json", help="Output as JSON")] = False,
|
|
44
|
+
quiet: Annotated[bool, typer.Option("--quiet", "-q", help="Suppress info messages")] = False,
|
|
45
|
+
profile: Annotated[str | None, typer.Option("--profile", "-p", help="Config profile name")] = None,
|
|
46
|
+
url: Annotated[str | None, typer.Option("--url", help="API URL override")] = None,
|
|
47
|
+
token: Annotated[str | None, typer.Option("--token", help="API token override")] = None,
|
|
48
|
+
version: Annotated[
|
|
49
|
+
bool, typer.Option("--version", "-V", callback=version_callback, is_eager=True, help="Show version")
|
|
50
|
+
] = False,
|
|
51
|
+
) -> None:
|
|
52
|
+
"""Kodemeio OpenCloud CLI."""
|
|
53
|
+
ctx.ensure_object(dict)
|
|
54
|
+
ctx.obj = AppContext(
|
|
55
|
+
json_mode=json_output,
|
|
56
|
+
quiet=quiet,
|
|
57
|
+
profile=profile,
|
|
58
|
+
url_override=url,
|
|
59
|
+
token_override=token,
|
|
60
|
+
)
|
|
61
|
+
notify_if_outdated(ctx.obj.output, "kctl-opencloud", __version__)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# Register command groups
|
|
65
|
+
app.add_typer(health_app, name="health")
|
|
66
|
+
app.add_typer(dashboard_app, name="dashboard")
|
|
67
|
+
app.add_typer(config_app, name="config")
|
|
68
|
+
app.add_typer(users_app, name="users")
|
|
69
|
+
app.add_typer(groups_app, name="groups")
|
|
70
|
+
app.add_typer(spaces_app, name="spaces")
|
|
71
|
+
app.add_typer(shares_app, name="shares")
|
|
72
|
+
app.add_typer(doctor_app, name="doctor")
|
|
73
|
+
app.add_typer(skill_app, name="skill", hidden=True)
|
|
74
|
+
add_tui_command(app, service_key="opencloud", version=__version__)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@app.command("self-update")
|
|
78
|
+
def self_update_cmd(ctx: typer.Context) -> None:
|
|
79
|
+
"""Check for updates and upgrade kctl-opencloud."""
|
|
80
|
+
actx: AppContext = ctx.obj
|
|
81
|
+
out = actx.output
|
|
82
|
+
|
|
83
|
+
from kctl_lib.self_update import check_update
|
|
84
|
+
from kctl_lib.self_update import update as do_update
|
|
85
|
+
|
|
86
|
+
latest = check_update("kctl-opencloud", __version__)
|
|
87
|
+
if latest:
|
|
88
|
+
out.info(f"Updating to {latest}...")
|
|
89
|
+
do_update("kctl-opencloud")
|
|
90
|
+
out.success(f"Updated to {latest}")
|
|
91
|
+
else:
|
|
92
|
+
out.success("Already up to date")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@app.command()
|
|
96
|
+
def completions(
|
|
97
|
+
shell: Annotated[str, typer.Argument(help="Shell type: zsh, bash, fish")] = "zsh",
|
|
98
|
+
install: Annotated[bool, typer.Option("--install", help="Install completions")] = False,
|
|
99
|
+
) -> None:
|
|
100
|
+
"""Generate or install shell completions."""
|
|
101
|
+
from kctl_lib.completions import get_completion_script, install_completions
|
|
102
|
+
|
|
103
|
+
if install:
|
|
104
|
+
path = install_completions("kctl-opencloud", shell)
|
|
105
|
+
if path:
|
|
106
|
+
typer.echo(f"Completions installed to {path}")
|
|
107
|
+
else:
|
|
108
|
+
typer.echo(f"Could not install completions for {shell}", err=True)
|
|
109
|
+
raise typer.Exit(code=1)
|
|
110
|
+
else:
|
|
111
|
+
script = get_completion_script("kctl-opencloud", shell)
|
|
112
|
+
typer.echo(script)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _run() -> None:
|
|
116
|
+
"""Entry point with error handling."""
|
|
117
|
+
try:
|
|
118
|
+
app()
|
|
119
|
+
except KctlError as e:
|
|
120
|
+
handle_cli_error(e)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
if __name__ == "__main__":
|
|
124
|
+
_run()
|
|
File without changes
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Configuration management commands for kctl-opencloud."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from kctl_opencloud.core.callbacks import AppContext
|
|
10
|
+
from kctl_opencloud.core.config import (
|
|
11
|
+
SERVICE_KEY,
|
|
12
|
+
ServiceConfig,
|
|
13
|
+
get_default_profile,
|
|
14
|
+
get_profile_names,
|
|
15
|
+
get_service_config,
|
|
16
|
+
remove_profile,
|
|
17
|
+
resolve_active_profile_name,
|
|
18
|
+
set_default_profile,
|
|
19
|
+
set_service_config,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
app = typer.Typer(help="Configuration and profile management.")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _mask_token(token: str) -> str:
|
|
26
|
+
"""Mask a token for display."""
|
|
27
|
+
if not token or len(token) < 8:
|
|
28
|
+
return "****" if token else ""
|
|
29
|
+
return token[:4] + "****" + token[-4:]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@app.command()
|
|
33
|
+
def init(
|
|
34
|
+
ctx: typer.Context,
|
|
35
|
+
url: Annotated[str, typer.Option("--url", help="OpenCloud API URL")] = "",
|
|
36
|
+
token: Annotated[str, typer.Option("--token", help="Machine auth API key")] = "",
|
|
37
|
+
profile_name: Annotated[str, typer.Option("--profile", "-p", help="Profile name")] = "default",
|
|
38
|
+
) -> None:
|
|
39
|
+
"""Initialize configuration with API credentials."""
|
|
40
|
+
c: AppContext = ctx.obj
|
|
41
|
+
|
|
42
|
+
if not url:
|
|
43
|
+
url = typer.prompt("OpenCloud URL", default="https://cloud.kodeme.io")
|
|
44
|
+
if not token:
|
|
45
|
+
token = typer.prompt("Machine Auth API Key", hide_input=True)
|
|
46
|
+
|
|
47
|
+
svc = ServiceConfig(url=url, token=token)
|
|
48
|
+
set_service_config(profile_name, svc)
|
|
49
|
+
set_default_profile(profile_name)
|
|
50
|
+
|
|
51
|
+
# Test connection
|
|
52
|
+
try:
|
|
53
|
+
from kctl_opencloud.core.client import OpenCloudClient
|
|
54
|
+
|
|
55
|
+
client = OpenCloudClient(base_url=url, credential=token)
|
|
56
|
+
status = client.check_health()
|
|
57
|
+
if status == 200:
|
|
58
|
+
c.output.success(f"Connected to {url}")
|
|
59
|
+
elif status == 0:
|
|
60
|
+
c.output.warn(f"Could not reach {url} (connection error)")
|
|
61
|
+
else:
|
|
62
|
+
c.output.warn(f"Connection returned HTTP {status}")
|
|
63
|
+
except Exception as e:
|
|
64
|
+
c.output.warn(f"Could not verify connection: {e}")
|
|
65
|
+
|
|
66
|
+
c.output.success(f"Configuration saved to profile '{profile_name}'")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@app.command()
|
|
70
|
+
def show(ctx: typer.Context) -> None:
|
|
71
|
+
"""Show current configuration."""
|
|
72
|
+
c: AppContext = ctx.obj
|
|
73
|
+
pname = resolve_active_profile_name(c.profile)
|
|
74
|
+
svc = get_service_config(pname)
|
|
75
|
+
|
|
76
|
+
data = {
|
|
77
|
+
"profile": pname,
|
|
78
|
+
"service_key": SERVICE_KEY,
|
|
79
|
+
"url": svc.url or "(not set)",
|
|
80
|
+
"token": _mask_token(svc.token),
|
|
81
|
+
"container_name": svc.container_name or "(default)",
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
sections = [
|
|
85
|
+
(
|
|
86
|
+
"Configuration",
|
|
87
|
+
[
|
|
88
|
+
("Profile", data["profile"]),
|
|
89
|
+
("Service Key", data["service_key"]),
|
|
90
|
+
("URL", data["url"]),
|
|
91
|
+
("Token", data["token"]),
|
|
92
|
+
("Container", data["container_name"]),
|
|
93
|
+
],
|
|
94
|
+
),
|
|
95
|
+
]
|
|
96
|
+
c.output.detail("OpenCloud Config", sections, data_for_json=data)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@app.command()
|
|
100
|
+
def current(ctx: typer.Context) -> None:
|
|
101
|
+
"""Show the active profile name."""
|
|
102
|
+
c: AppContext = ctx.obj
|
|
103
|
+
pname = resolve_active_profile_name(c.profile)
|
|
104
|
+
c.output.text(pname)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@app.command("set")
|
|
108
|
+
def set_(
|
|
109
|
+
ctx: typer.Context,
|
|
110
|
+
key: Annotated[str, typer.Argument(help="Config key (url, token, container_name)")],
|
|
111
|
+
value: Annotated[str, typer.Argument(help="Config value")],
|
|
112
|
+
) -> None:
|
|
113
|
+
"""Set a configuration value."""
|
|
114
|
+
c: AppContext = ctx.obj
|
|
115
|
+
valid_fields = set(ServiceConfig.model_fields.keys())
|
|
116
|
+
if key not in valid_fields:
|
|
117
|
+
c.output.error(f"Invalid key '{key}'. Valid keys: {', '.join(sorted(valid_fields))}")
|
|
118
|
+
raise typer.Exit(code=1)
|
|
119
|
+
|
|
120
|
+
pname = resolve_active_profile_name(c.profile)
|
|
121
|
+
svc = get_service_config(pname)
|
|
122
|
+
setattr(svc, key, value)
|
|
123
|
+
set_service_config(pname, svc)
|
|
124
|
+
display_value = _mask_token(value) if "token" in key else value
|
|
125
|
+
c.output.success(f"Set {key} = {display_value}")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@app.command()
|
|
129
|
+
def profiles(ctx: typer.Context) -> None:
|
|
130
|
+
"""List all configuration profiles."""
|
|
131
|
+
c: AppContext = ctx.obj
|
|
132
|
+
names = get_profile_names()
|
|
133
|
+
default = get_default_profile()
|
|
134
|
+
|
|
135
|
+
rows = []
|
|
136
|
+
for name in names:
|
|
137
|
+
svc = get_service_config(name)
|
|
138
|
+
marker = "*" if name == default else ""
|
|
139
|
+
rows.append([marker, name, svc.url or "(not set)"])
|
|
140
|
+
|
|
141
|
+
c.output.table(
|
|
142
|
+
"Profiles",
|
|
143
|
+
[("", "cyan"), ("Name", "green"), ("URL", "white")],
|
|
144
|
+
rows,
|
|
145
|
+
data_for_json=[{"name": n, "default": n == default} for n in names],
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@app.command("use")
|
|
150
|
+
def use_(
|
|
151
|
+
ctx: typer.Context,
|
|
152
|
+
name: Annotated[str, typer.Argument(help="Profile name to activate")],
|
|
153
|
+
) -> None:
|
|
154
|
+
"""Switch to a different profile."""
|
|
155
|
+
c: AppContext = ctx.obj
|
|
156
|
+
names = get_profile_names()
|
|
157
|
+
if name not in names:
|
|
158
|
+
c.output.error(f"Profile '{name}' not found. Available: {', '.join(names)}")
|
|
159
|
+
raise typer.Exit(code=1)
|
|
160
|
+
set_default_profile(name)
|
|
161
|
+
c.output.success(f"Switched to profile '{name}'")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@app.command()
|
|
165
|
+
def remove(
|
|
166
|
+
ctx: typer.Context,
|
|
167
|
+
name: Annotated[str, typer.Argument(help="Profile name to remove")],
|
|
168
|
+
) -> None:
|
|
169
|
+
"""Remove a configuration profile."""
|
|
170
|
+
c: AppContext = ctx.obj
|
|
171
|
+
remove_profile(name)
|
|
172
|
+
c.output.success(f"Removed profile '{name}'")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@app.command()
|
|
176
|
+
def test(ctx: typer.Context) -> None:
|
|
177
|
+
"""Test the current connection."""
|
|
178
|
+
c: AppContext = ctx.obj
|
|
179
|
+
try:
|
|
180
|
+
status = c.client.check_health()
|
|
181
|
+
if status == 200:
|
|
182
|
+
c.output.success("Connection OK")
|
|
183
|
+
version = c.client.get_version()
|
|
184
|
+
if version:
|
|
185
|
+
c.output.info(f"OpenCloud {version}")
|
|
186
|
+
else:
|
|
187
|
+
c.output.error(f"Connection failed (HTTP {status})")
|
|
188
|
+
raise typer.Exit(code=1)
|
|
189
|
+
except Exception as e:
|
|
190
|
+
c.output.error(f"Connection failed: {e}")
|
|
191
|
+
raise typer.Exit(code=1)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Dashboard commands for kctl-opencloud."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from typing import Annotated, Any
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from kctl_lib.exceptions import AuthenticationError, ConfigError
|
|
10
|
+
|
|
11
|
+
from kctl_opencloud.core.callbacks import AppContext
|
|
12
|
+
|
|
13
|
+
app = typer.Typer(help="System overview and statistics.")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _fetch_dashboard(c: AppContext) -> dict[str, Any]:
|
|
17
|
+
"""Fetch dashboard data."""
|
|
18
|
+
client = c.client
|
|
19
|
+
data: dict[str, Any] = {}
|
|
20
|
+
|
|
21
|
+
# Version
|
|
22
|
+
data["version"] = client.get_version() or "unknown"
|
|
23
|
+
|
|
24
|
+
# Resource counts
|
|
25
|
+
for resource, endpoint in [("users", "users"), ("groups", "groups"), ("spaces", "drives")]:
|
|
26
|
+
try:
|
|
27
|
+
result = client.get(endpoint)
|
|
28
|
+
data[resource] = len(result.get("value", []))
|
|
29
|
+
except (AuthenticationError, ConfigError):
|
|
30
|
+
raise
|
|
31
|
+
except Exception:
|
|
32
|
+
data[resource] = 0
|
|
33
|
+
|
|
34
|
+
return data
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _display_dashboard(c: AppContext, data: dict[str, Any]) -> None:
|
|
38
|
+
"""Display dashboard."""
|
|
39
|
+
out = c.output
|
|
40
|
+
|
|
41
|
+
if c.json_mode:
|
|
42
|
+
out.raw_json(data)
|
|
43
|
+
return
|
|
44
|
+
|
|
45
|
+
out.header("OpenCloud Dashboard")
|
|
46
|
+
out.kv("URL", c.client.root_url)
|
|
47
|
+
out.kv("Version", data["version"])
|
|
48
|
+
out.text("")
|
|
49
|
+
out.kv("Users", str(data.get("users", 0)))
|
|
50
|
+
out.kv("Groups", str(data.get("groups", 0)))
|
|
51
|
+
out.kv("Spaces", str(data.get("spaces", 0)))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@app.callback(invoke_without_command=True)
|
|
55
|
+
def show(
|
|
56
|
+
ctx: typer.Context,
|
|
57
|
+
watch: Annotated[bool, typer.Option("--watch", "-w", help="Continuous monitoring")] = False,
|
|
58
|
+
interval: Annotated[int, typer.Option("--interval", "-i", help="Watch interval")] = 10,
|
|
59
|
+
compact: Annotated[bool, typer.Option("--compact", "-c", help="Compact output")] = False,
|
|
60
|
+
) -> None:
|
|
61
|
+
"""Show system overview."""
|
|
62
|
+
c: AppContext = ctx.obj
|
|
63
|
+
|
|
64
|
+
if watch:
|
|
65
|
+
try:
|
|
66
|
+
while True:
|
|
67
|
+
data = _fetch_dashboard(c)
|
|
68
|
+
if compact:
|
|
69
|
+
c.output.text(f"Users: {data['users']} | Groups: {data['groups']} | Spaces: {data['spaces']}")
|
|
70
|
+
else:
|
|
71
|
+
_display_dashboard(c, data)
|
|
72
|
+
time.sleep(interval)
|
|
73
|
+
except KeyboardInterrupt:
|
|
74
|
+
pass
|
|
75
|
+
else:
|
|
76
|
+
data = _fetch_dashboard(c)
|
|
77
|
+
if compact:
|
|
78
|
+
c.output.text(f"Users: {data['users']} | Groups: {data['groups']} | Spaces: {data['spaces']}")
|
|
79
|
+
else:
|
|
80
|
+
_display_dashboard(c, data)
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Diagnostic checks for kctl-opencloud."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from kctl_opencloud.core.callbacks import AppContext
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class CheckResult:
|
|
14
|
+
name: str
|
|
15
|
+
status: str # ok, fail, warn
|
|
16
|
+
message: str
|
|
17
|
+
fix_command: str = ""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _check_connectivity(ctx: AppContext) -> CheckResult:
|
|
21
|
+
"""Check API connectivity."""
|
|
22
|
+
from kctl_opencloud.core.config import get_service_config, resolve_active_profile_name
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
pname = resolve_active_profile_name(ctx.profile)
|
|
26
|
+
cfg = get_service_config(pname)
|
|
27
|
+
if not cfg.url:
|
|
28
|
+
return CheckResult(
|
|
29
|
+
"API Connectivity",
|
|
30
|
+
"fail",
|
|
31
|
+
"No URL configured",
|
|
32
|
+
fix_command="kctl-opencloud config init",
|
|
33
|
+
)
|
|
34
|
+
# Now try connecting
|
|
35
|
+
from kctl_opencloud.core.client import OpenCloudClient
|
|
36
|
+
|
|
37
|
+
client = OpenCloudClient(base_url=cfg.url, credential=cfg.token)
|
|
38
|
+
status = client.check_health()
|
|
39
|
+
if status == 200:
|
|
40
|
+
return CheckResult("API Connectivity", "ok", f"Connected to {cfg.url}")
|
|
41
|
+
return CheckResult(
|
|
42
|
+
"API Connectivity",
|
|
43
|
+
"warn",
|
|
44
|
+
f"HTTP {status} from {cfg.url}",
|
|
45
|
+
)
|
|
46
|
+
except Exception as e:
|
|
47
|
+
return CheckResult(
|
|
48
|
+
"API Connectivity",
|
|
49
|
+
"fail",
|
|
50
|
+
str(e),
|
|
51
|
+
fix_command="kctl-opencloud config init",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _check_auth(ctx: AppContext) -> CheckResult:
|
|
56
|
+
"""Check authentication."""
|
|
57
|
+
from kctl_opencloud.core.config import get_service_config, resolve_active_profile_name
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
pname = resolve_active_profile_name(ctx.profile)
|
|
61
|
+
cfg = get_service_config(pname)
|
|
62
|
+
if not cfg.url or not cfg.token:
|
|
63
|
+
return CheckResult(
|
|
64
|
+
"Authentication",
|
|
65
|
+
"fail",
|
|
66
|
+
"No URL or token configured",
|
|
67
|
+
fix_command="kctl-opencloud config init",
|
|
68
|
+
)
|
|
69
|
+
from kctl_opencloud.core.client import OpenCloudClient
|
|
70
|
+
|
|
71
|
+
client = OpenCloudClient(base_url=cfg.url, credential=cfg.token)
|
|
72
|
+
client.get("me")
|
|
73
|
+
return CheckResult("Authentication", "ok", "Authenticated")
|
|
74
|
+
except Exception:
|
|
75
|
+
return CheckResult(
|
|
76
|
+
"Authentication",
|
|
77
|
+
"fail",
|
|
78
|
+
"Authentication failed",
|
|
79
|
+
fix_command="kctl-opencloud config set token <your-token>",
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
app = typer.Typer(help="Diagnostic checks.")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@app.callback(invoke_without_command=True)
|
|
87
|
+
def doctor(ctx: typer.Context) -> None:
|
|
88
|
+
"""Run all diagnostic checks."""
|
|
89
|
+
c: AppContext = ctx.obj
|
|
90
|
+
out = c.output
|
|
91
|
+
|
|
92
|
+
checks = [
|
|
93
|
+
_check_connectivity(c),
|
|
94
|
+
_check_auth(c),
|
|
95
|
+
]
|
|
96
|
+
|
|
97
|
+
all_ok = True
|
|
98
|
+
for check in checks:
|
|
99
|
+
if check.status == "ok":
|
|
100
|
+
out.success(f"{check.name}: {check.message}")
|
|
101
|
+
elif check.status == "warn":
|
|
102
|
+
out.warn(f"{check.name}: {check.message}")
|
|
103
|
+
else:
|
|
104
|
+
out.error(f"{check.name}: {check.message}")
|
|
105
|
+
if check.fix_command:
|
|
106
|
+
out.info(f" Fix: {check.fix_command}")
|
|
107
|
+
all_ok = False
|
|
108
|
+
|
|
109
|
+
if not all_ok:
|
|
110
|
+
raise typer.Exit(code=1)
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Group management commands for kctl-opencloud."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated, Any
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from kctl_opencloud.core.callbacks import AppContext
|
|
10
|
+
|
|
11
|
+
app = typer.Typer(help="Group management.")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@app.command("list")
|
|
15
|
+
def list_(
|
|
16
|
+
ctx: typer.Context,
|
|
17
|
+
search: Annotated[str | None, typer.Option("--search", "-s", help="Search by name")] = None,
|
|
18
|
+
) -> None:
|
|
19
|
+
"""List all groups."""
|
|
20
|
+
c: AppContext = ctx.obj
|
|
21
|
+
params: dict[str, Any] = {}
|
|
22
|
+
if search:
|
|
23
|
+
params["$search"] = search
|
|
24
|
+
|
|
25
|
+
groups = c.client.get_all("groups", params=params)
|
|
26
|
+
|
|
27
|
+
rows = []
|
|
28
|
+
for g in groups:
|
|
29
|
+
members = g.get("members", [])
|
|
30
|
+
rows.append(
|
|
31
|
+
[
|
|
32
|
+
g.get("id", ""),
|
|
33
|
+
g.get("displayName", "-"),
|
|
34
|
+
str(len(members)) if members else "0",
|
|
35
|
+
]
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
c.output.table(
|
|
39
|
+
"Groups",
|
|
40
|
+
[("ID", "cyan"), ("Name", "green"), ("Members", "yellow")],
|
|
41
|
+
rows,
|
|
42
|
+
data_for_json=groups,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@app.command()
|
|
47
|
+
def get(
|
|
48
|
+
ctx: typer.Context,
|
|
49
|
+
group_id: Annotated[str, typer.Argument(help="Group ID")],
|
|
50
|
+
) -> None:
|
|
51
|
+
"""Get group details."""
|
|
52
|
+
c: AppContext = ctx.obj
|
|
53
|
+
group = c.client.get(f"groups/{group_id}")
|
|
54
|
+
|
|
55
|
+
members = group.get("members", [])
|
|
56
|
+
sections = [
|
|
57
|
+
(
|
|
58
|
+
"Group",
|
|
59
|
+
[
|
|
60
|
+
("ID", group.get("id", "")),
|
|
61
|
+
("Name", group.get("displayName", "-")),
|
|
62
|
+
("Members", str(len(members))),
|
|
63
|
+
],
|
|
64
|
+
),
|
|
65
|
+
]
|
|
66
|
+
c.output.detail("Group Details", sections, data_for_json=group)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@app.command()
|
|
70
|
+
def create(
|
|
71
|
+
ctx: typer.Context,
|
|
72
|
+
name: Annotated[str, typer.Argument(help="Group display name")],
|
|
73
|
+
) -> None:
|
|
74
|
+
"""Create a new group."""
|
|
75
|
+
c: AppContext = ctx.obj
|
|
76
|
+
group = c.client.post("groups", data={"displayName": name})
|
|
77
|
+
c.output.success(f"Group created: {group.get('displayName', name)}")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@app.command()
|
|
81
|
+
def update(
|
|
82
|
+
ctx: typer.Context,
|
|
83
|
+
group_id: Annotated[str, typer.Argument(help="Group ID")],
|
|
84
|
+
name: Annotated[str, typer.Option("--name", "-n", help="New display name")],
|
|
85
|
+
) -> None:
|
|
86
|
+
"""Update a group."""
|
|
87
|
+
c: AppContext = ctx.obj
|
|
88
|
+
c.client.patch(f"groups/{group_id}", data={"displayName": name})
|
|
89
|
+
c.output.success(f"Group {group_id} updated")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@app.command()
|
|
93
|
+
def delete(
|
|
94
|
+
ctx: typer.Context,
|
|
95
|
+
group_id: Annotated[str, typer.Argument(help="Group ID")],
|
|
96
|
+
force: Annotated[bool, typer.Option("--force", "-f", help="Skip confirmation")] = False,
|
|
97
|
+
) -> None:
|
|
98
|
+
"""Delete a group."""
|
|
99
|
+
c: AppContext = ctx.obj
|
|
100
|
+
if not force:
|
|
101
|
+
typer.confirm(f"Delete group {group_id}?", abort=True)
|
|
102
|
+
c.client.delete(f"groups/{group_id}")
|
|
103
|
+
c.output.success(f"Group {group_id} deleted")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@app.command("add-member")
|
|
107
|
+
def add_member(
|
|
108
|
+
ctx: typer.Context,
|
|
109
|
+
group_id: Annotated[str, typer.Argument(help="Group ID")],
|
|
110
|
+
user_id: Annotated[str, typer.Argument(help="User ID to add")],
|
|
111
|
+
) -> None:
|
|
112
|
+
"""Add a member to a group."""
|
|
113
|
+
c: AppContext = ctx.obj
|
|
114
|
+
c.client.post(
|
|
115
|
+
f"groups/{group_id}/members/$ref",
|
|
116
|
+
data={"@odata.id": f"{c.client.api_base_url}/users/{user_id}"},
|
|
117
|
+
)
|
|
118
|
+
c.output.success(f"User {user_id} added to group {group_id}")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@app.command("remove-member")
|
|
122
|
+
def remove_member(
|
|
123
|
+
ctx: typer.Context,
|
|
124
|
+
group_id: Annotated[str, typer.Argument(help="Group ID")],
|
|
125
|
+
user_id: Annotated[str, typer.Argument(help="User ID to remove")],
|
|
126
|
+
) -> None:
|
|
127
|
+
"""Remove a member from a group."""
|
|
128
|
+
c: AppContext = ctx.obj
|
|
129
|
+
c.client.delete(f"groups/{group_id}/members/{user_id}/$ref")
|
|
130
|
+
c.output.success(f"User {user_id} removed from group {group_id}")
|