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
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Health check commands for kctl-opencloud."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from typing import Annotated, Any
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from kctl_opencloud.core.callbacks import AppContext
|
|
12
|
+
|
|
13
|
+
app = typer.Typer(help="Health checks and monitoring.")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _check_endpoint(base_url: str, path: str) -> int:
|
|
17
|
+
"""Check an endpoint, return HTTP status code."""
|
|
18
|
+
try:
|
|
19
|
+
r = httpx.get(f"{base_url}{path}", timeout=5, follow_redirects=True)
|
|
20
|
+
return r.status_code
|
|
21
|
+
except httpx.HTTPError:
|
|
22
|
+
return 0
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _run_health_check(c: AppContext) -> dict[str, Any]:
|
|
26
|
+
"""Run all health checks and return results."""
|
|
27
|
+
base_url = c.client.root_url
|
|
28
|
+
score = 0
|
|
29
|
+
checks: dict[str, str] = {}
|
|
30
|
+
|
|
31
|
+
# OCS capabilities (30 pts)
|
|
32
|
+
status = _check_endpoint(base_url, "/ocs/v1.php/cloud/capabilities")
|
|
33
|
+
if status == 200:
|
|
34
|
+
score += 30
|
|
35
|
+
checks["ocs"] = "ok"
|
|
36
|
+
else:
|
|
37
|
+
checks["ocs"] = "fail"
|
|
38
|
+
|
|
39
|
+
# Web UI (20 pts)
|
|
40
|
+
status = _check_endpoint(base_url, "/")
|
|
41
|
+
if status in (200, 302):
|
|
42
|
+
score += 20
|
|
43
|
+
checks["web"] = "ok"
|
|
44
|
+
else:
|
|
45
|
+
checks["web"] = "fail"
|
|
46
|
+
|
|
47
|
+
# Graph API (20 pts)
|
|
48
|
+
status = _check_endpoint(base_url, "/graph/v1.0/me")
|
|
49
|
+
if status in (200, 401):
|
|
50
|
+
score += 20
|
|
51
|
+
checks["graph"] = "ok"
|
|
52
|
+
else:
|
|
53
|
+
checks["graph"] = "fail"
|
|
54
|
+
|
|
55
|
+
# WebDAV (15 pts)
|
|
56
|
+
status = _check_endpoint(base_url, "/remote.php/dav/")
|
|
57
|
+
if status in (200, 401, 207):
|
|
58
|
+
score += 15
|
|
59
|
+
checks["dav"] = "ok"
|
|
60
|
+
else:
|
|
61
|
+
checks["dav"] = "fail"
|
|
62
|
+
|
|
63
|
+
# OIDC discovery (15 pts)
|
|
64
|
+
status = _check_endpoint(base_url, "/.well-known/openid-configuration")
|
|
65
|
+
if status in (200, 301, 302):
|
|
66
|
+
score += 15
|
|
67
|
+
checks["oidc"] = "ok"
|
|
68
|
+
else:
|
|
69
|
+
checks["oidc"] = "fail"
|
|
70
|
+
|
|
71
|
+
overall = "healthy" if score >= 80 else "degraded" if score >= 50 else "unhealthy"
|
|
72
|
+
|
|
73
|
+
return {"score": score, "status": overall, "checks": checks, "url": base_url}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _display_health(c: AppContext, result: dict[str, Any]) -> None:
|
|
77
|
+
"""Display health check results."""
|
|
78
|
+
out = c.output
|
|
79
|
+
|
|
80
|
+
if c.json_mode:
|
|
81
|
+
out.raw_json(result)
|
|
82
|
+
return
|
|
83
|
+
|
|
84
|
+
out.header("OpenCloud Health Check")
|
|
85
|
+
out.kv("URL", result["url"])
|
|
86
|
+
|
|
87
|
+
check_labels = {
|
|
88
|
+
"ocs": ("OCS capabilities", 30),
|
|
89
|
+
"web": ("Web UI reachable", 20),
|
|
90
|
+
"graph": ("Graph API", 20),
|
|
91
|
+
"dav": ("WebDAV", 15),
|
|
92
|
+
"oidc": ("OIDC discovery", 15),
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
for key, (label, pts) in check_labels.items():
|
|
96
|
+
status = result["checks"][key]
|
|
97
|
+
if status == "ok":
|
|
98
|
+
out.success(f"{label} ({pts} pts)")
|
|
99
|
+
else:
|
|
100
|
+
out.error(f"{label} (0/{pts} pts)")
|
|
101
|
+
|
|
102
|
+
score = result["score"]
|
|
103
|
+
overall = result["status"]
|
|
104
|
+
if overall == "healthy":
|
|
105
|
+
out.success(f"Score: {score}/100 — Healthy")
|
|
106
|
+
elif overall == "degraded":
|
|
107
|
+
out.warn(f"Score: {score}/100 — Degraded")
|
|
108
|
+
else:
|
|
109
|
+
out.error(f"Score: {score}/100 — Unhealthy")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@app.callback(invoke_without_command=True)
|
|
113
|
+
def check(
|
|
114
|
+
ctx: typer.Context,
|
|
115
|
+
watch: Annotated[bool, typer.Option("--watch", "-w", help="Continuous monitoring")] = False,
|
|
116
|
+
interval: Annotated[int, typer.Option("--interval", "-i", help="Watch interval in seconds")] = 10,
|
|
117
|
+
) -> None:
|
|
118
|
+
"""Run health checks."""
|
|
119
|
+
c: AppContext = ctx.obj
|
|
120
|
+
|
|
121
|
+
if watch:
|
|
122
|
+
try:
|
|
123
|
+
while True:
|
|
124
|
+
result = _run_health_check(c)
|
|
125
|
+
_display_health(c, result)
|
|
126
|
+
time.sleep(interval)
|
|
127
|
+
except KeyboardInterrupt:
|
|
128
|
+
pass
|
|
129
|
+
else:
|
|
130
|
+
result = _run_health_check(c)
|
|
131
|
+
_display_health(c, result)
|
|
132
|
+
if result["score"] < 50:
|
|
133
|
+
raise typer.Exit(code=2)
|
|
134
|
+
elif result["score"] < 80:
|
|
135
|
+
raise typer.Exit(code=1)
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Sharing and permission commands for kctl-opencloud."""
|
|
2
|
+
|
|
3
|
+
# NOTE: Shares/permissions API uses the beta endpoint /graph/v1beta1.
|
|
4
|
+
# Since API_PREFIX="/graph/v1.0", we use "../v1beta1/" to traverse one
|
|
5
|
+
# segment up. This is verified to work with httpx URL resolution.
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Annotated, Any
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
|
|
13
|
+
from kctl_opencloud.core.callbacks import AppContext
|
|
14
|
+
|
|
15
|
+
app = typer.Typer(help="Sharing and permission management.")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@app.command("list")
|
|
19
|
+
def list_(
|
|
20
|
+
ctx: typer.Context,
|
|
21
|
+
space_id: Annotated[str, typer.Argument(help="Space/drive ID")],
|
|
22
|
+
item_id: Annotated[str, typer.Argument(help="Item (file/folder) ID")],
|
|
23
|
+
) -> None:
|
|
24
|
+
"""List permissions on a file or folder."""
|
|
25
|
+
c: AppContext = ctx.obj
|
|
26
|
+
# Use beta API for permissions
|
|
27
|
+
client = c.client
|
|
28
|
+
data = client.get(f"../v1beta1/drives/{space_id}/items/{item_id}/permissions")
|
|
29
|
+
permissions = data.get("value", []) if isinstance(data, dict) else []
|
|
30
|
+
|
|
31
|
+
rows = []
|
|
32
|
+
for p in permissions:
|
|
33
|
+
link = p.get("link", {})
|
|
34
|
+
granted = p.get("grantedToV2", {})
|
|
35
|
+
user = granted.get("user", {})
|
|
36
|
+
rows.append(
|
|
37
|
+
[
|
|
38
|
+
p.get("id", ""),
|
|
39
|
+
user.get("displayName", link.get("type", "-")),
|
|
40
|
+
", ".join(p.get("roles", [])),
|
|
41
|
+
link.get("webUrl", "-"),
|
|
42
|
+
]
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
c.output.table(
|
|
46
|
+
"Permissions",
|
|
47
|
+
[("ID", "cyan"), ("Granted To", "green"), ("Roles", "yellow"), ("Link", "white")],
|
|
48
|
+
rows,
|
|
49
|
+
data_for_json=permissions,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@app.command("create-link")
|
|
54
|
+
def create_link(
|
|
55
|
+
ctx: typer.Context,
|
|
56
|
+
space_id: Annotated[str, typer.Argument(help="Space/drive ID")],
|
|
57
|
+
item_id: Annotated[str, typer.Argument(help="Item ID")],
|
|
58
|
+
type_: Annotated[str, typer.Option("--type", "-t", help="Link type: view, edit")] = "view",
|
|
59
|
+
password: Annotated[str | None, typer.Option("--password", help="Link password")] = None,
|
|
60
|
+
) -> None:
|
|
61
|
+
"""Create a sharing link."""
|
|
62
|
+
c: AppContext = ctx.obj
|
|
63
|
+
link_data: dict[str, Any] = {
|
|
64
|
+
"type": type_,
|
|
65
|
+
}
|
|
66
|
+
if password:
|
|
67
|
+
link_data["password"] = password
|
|
68
|
+
|
|
69
|
+
result = c.client.post(
|
|
70
|
+
f"../v1beta1/drives/{space_id}/items/{item_id}/createLink",
|
|
71
|
+
data=link_data,
|
|
72
|
+
)
|
|
73
|
+
link_url = result.get("link", {}).get("webUrl", "")
|
|
74
|
+
c.output.success(f"Link created: {link_url}")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@app.command()
|
|
78
|
+
def invite(
|
|
79
|
+
ctx: typer.Context,
|
|
80
|
+
space_id: Annotated[str, typer.Argument(help="Space/drive ID")],
|
|
81
|
+
item_id: Annotated[str, typer.Argument(help="Item ID")],
|
|
82
|
+
user_id: Annotated[str, typer.Option("--user", "-u", help="User ID to invite")],
|
|
83
|
+
role: Annotated[str, typer.Option("--role", "-r", help="Role: viewer, editor")] = "viewer",
|
|
84
|
+
) -> None:
|
|
85
|
+
"""Invite a user to access a file or folder."""
|
|
86
|
+
c: AppContext = ctx.obj
|
|
87
|
+
invite_data: dict[str, Any] = {
|
|
88
|
+
"recipients": [{"objectId": user_id}],
|
|
89
|
+
"roles": [role],
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
c.client.post(
|
|
93
|
+
f"../v1beta1/drives/{space_id}/items/{item_id}/invite",
|
|
94
|
+
data=invite_data,
|
|
95
|
+
)
|
|
96
|
+
c.output.success(f"User {user_id} invited with role '{role}'")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@app.command("delete")
|
|
100
|
+
def delete_permission(
|
|
101
|
+
ctx: typer.Context,
|
|
102
|
+
space_id: Annotated[str, typer.Argument(help="Space/drive ID")],
|
|
103
|
+
item_id: Annotated[str, typer.Argument(help="Item ID")],
|
|
104
|
+
permission_id: Annotated[str, typer.Argument(help="Permission ID")],
|
|
105
|
+
) -> None:
|
|
106
|
+
"""Remove a permission from a file or folder."""
|
|
107
|
+
c: AppContext = ctx.obj
|
|
108
|
+
c.client.delete(f"../v1beta1/drives/{space_id}/items/{item_id}/permissions/{permission_id}")
|
|
109
|
+
c.output.success(f"Permission {permission_id} removed")
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Skill generation command for kctl-opencloud."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
app = typer.Typer(help="Skill management.", hidden=True)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@app.command()
|
|
14
|
+
def generate(
|
|
15
|
+
ctx: typer.Context,
|
|
16
|
+
output: Annotated[str | None, typer.Option("--output", "-o", help="Output directory")] = None,
|
|
17
|
+
install: Annotated[bool, typer.Option("--install", help="Install to user skills dir")] = False,
|
|
18
|
+
check: Annotated[bool, typer.Option("--check", help="Check if skill is stale")] = False,
|
|
19
|
+
) -> None:
|
|
20
|
+
"""Generate Claude Code skill file."""
|
|
21
|
+
from kctl_lib.skill_generator import check_stale, generate_skill
|
|
22
|
+
|
|
23
|
+
from kctl_opencloud.cli import app as cli_app
|
|
24
|
+
|
|
25
|
+
skill_name = "opencloud-admin"
|
|
26
|
+
|
|
27
|
+
extra_path = Path(__file__).parent.parent.parent.parent / "skills" / skill_name / "SKILL.extra.md"
|
|
28
|
+
extra_content = extra_path.read_text() if extra_path.exists() else None
|
|
29
|
+
|
|
30
|
+
if check:
|
|
31
|
+
if check_stale(skill_name, cli_app, extra_content=extra_content):
|
|
32
|
+
typer.echo("Skill is stale — regenerate with: kctl-opencloud skill generate --install")
|
|
33
|
+
raise typer.Exit(code=1)
|
|
34
|
+
typer.echo("Skill is up to date")
|
|
35
|
+
return
|
|
36
|
+
|
|
37
|
+
if output:
|
|
38
|
+
out_dir = Path(output)
|
|
39
|
+
elif install:
|
|
40
|
+
out_dir = Path.home() / ".claude" / "skills" / skill_name
|
|
41
|
+
else:
|
|
42
|
+
out_dir = Path(__file__).parent.parent.parent.parent / "skills" / skill_name
|
|
43
|
+
|
|
44
|
+
generate_skill(
|
|
45
|
+
skill_name=skill_name,
|
|
46
|
+
cli_app=cli_app,
|
|
47
|
+
output_dir=out_dir,
|
|
48
|
+
extra_content=extra_content,
|
|
49
|
+
)
|
|
50
|
+
typer.echo(f"Skill generated at {out_dir}/SKILL.md")
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Space/drive 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="Drive and space management.")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _format_bytes(size: float) -> str:
|
|
15
|
+
"""Format bytes to human readable."""
|
|
16
|
+
for unit in ("B", "KB", "MB", "GB", "TB"):
|
|
17
|
+
if size < 1024:
|
|
18
|
+
return f"{size:.1f} {unit}"
|
|
19
|
+
size /= 1024
|
|
20
|
+
return f"{size:.1f} PB"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@app.command("list")
|
|
24
|
+
def list_(
|
|
25
|
+
ctx: typer.Context,
|
|
26
|
+
type_filter: Annotated[str | None, typer.Option("--type", "-t", help="Filter by type (personal, project)")] = None,
|
|
27
|
+
) -> None:
|
|
28
|
+
"""List all spaces/drives."""
|
|
29
|
+
c: AppContext = ctx.obj
|
|
30
|
+
spaces = c.client.get_all("drives")
|
|
31
|
+
|
|
32
|
+
if type_filter:
|
|
33
|
+
spaces = [s for s in spaces if s.get("driveType") == type_filter]
|
|
34
|
+
|
|
35
|
+
rows = []
|
|
36
|
+
for s in spaces:
|
|
37
|
+
quota = s.get("quota", {})
|
|
38
|
+
used = quota.get("used", 0)
|
|
39
|
+
total = quota.get("total", 0)
|
|
40
|
+
rows.append(
|
|
41
|
+
[
|
|
42
|
+
s.get("id", ""),
|
|
43
|
+
s.get("name", "-"),
|
|
44
|
+
s.get("driveType", "-"),
|
|
45
|
+
_format_bytes(used),
|
|
46
|
+
_format_bytes(total) if total > 0 else "unlimited",
|
|
47
|
+
]
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
c.output.table(
|
|
51
|
+
"Spaces",
|
|
52
|
+
[("ID", "cyan"), ("Name", "green"), ("Type", "yellow"), ("Used", "white"), ("Quota", "white")],
|
|
53
|
+
rows,
|
|
54
|
+
data_for_json=spaces,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@app.command()
|
|
59
|
+
def get(
|
|
60
|
+
ctx: typer.Context,
|
|
61
|
+
space_id: Annotated[str, typer.Argument(help="Space/drive ID")],
|
|
62
|
+
) -> None:
|
|
63
|
+
"""Get space details."""
|
|
64
|
+
c: AppContext = ctx.obj
|
|
65
|
+
space = c.client.get(f"drives/{space_id}")
|
|
66
|
+
|
|
67
|
+
quota = space.get("quota", {})
|
|
68
|
+
sections = [
|
|
69
|
+
(
|
|
70
|
+
"Space",
|
|
71
|
+
[
|
|
72
|
+
("ID", space.get("id", "")),
|
|
73
|
+
("Name", space.get("name", "-")),
|
|
74
|
+
("Type", space.get("driveType", "-")),
|
|
75
|
+
("Description", space.get("description", "-")),
|
|
76
|
+
("Used", _format_bytes(quota.get("used", 0))),
|
|
77
|
+
("Total", _format_bytes(quota.get("total", 0)) if quota.get("total", 0) > 0 else "unlimited"),
|
|
78
|
+
("State", space.get("root", {}).get("deleted", {}).get("state", "active")),
|
|
79
|
+
],
|
|
80
|
+
),
|
|
81
|
+
]
|
|
82
|
+
c.output.detail("Space Details", sections, data_for_json=space)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@app.command()
|
|
86
|
+
def create(
|
|
87
|
+
ctx: typer.Context,
|
|
88
|
+
name: Annotated[str, typer.Argument(help="Space name")],
|
|
89
|
+
description: Annotated[str, typer.Option("--description", "-d", help="Space description")] = "",
|
|
90
|
+
quota: Annotated[int | None, typer.Option("--quota", "-q", help="Quota in bytes")] = None,
|
|
91
|
+
) -> None:
|
|
92
|
+
"""Create a project space."""
|
|
93
|
+
c: AppContext = ctx.obj
|
|
94
|
+
space_data: dict[str, Any] = {
|
|
95
|
+
"name": name,
|
|
96
|
+
"driveType": "project",
|
|
97
|
+
}
|
|
98
|
+
if description:
|
|
99
|
+
space_data["description"] = description
|
|
100
|
+
if quota is not None:
|
|
101
|
+
space_data["quota"] = {"total": quota}
|
|
102
|
+
|
|
103
|
+
space = c.client.post("drives", data=space_data)
|
|
104
|
+
c.output.success(f"Space created: {space.get('name', name)}")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@app.command()
|
|
108
|
+
def update(
|
|
109
|
+
ctx: typer.Context,
|
|
110
|
+
space_id: Annotated[str, typer.Argument(help="Space ID")],
|
|
111
|
+
name: Annotated[str | None, typer.Option("--name", "-n", help="New name")] = None,
|
|
112
|
+
description: Annotated[str | None, typer.Option("--description", "-d", help="New description")] = None,
|
|
113
|
+
quota_val: Annotated[int | None, typer.Option("--quota", "-q", help="New quota in bytes")] = None,
|
|
114
|
+
) -> None:
|
|
115
|
+
"""Update a space."""
|
|
116
|
+
c: AppContext = ctx.obj
|
|
117
|
+
patch_data: dict[str, Any] = {}
|
|
118
|
+
if name is not None:
|
|
119
|
+
patch_data["name"] = name
|
|
120
|
+
if description is not None:
|
|
121
|
+
patch_data["description"] = description
|
|
122
|
+
if quota_val is not None:
|
|
123
|
+
patch_data["quota"] = {"total": quota_val}
|
|
124
|
+
|
|
125
|
+
if not patch_data:
|
|
126
|
+
c.output.warn("No changes specified")
|
|
127
|
+
return
|
|
128
|
+
|
|
129
|
+
c.client.patch(f"drives/{space_id}", data=patch_data)
|
|
130
|
+
c.output.success(f"Space {space_id} updated")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@app.command()
|
|
134
|
+
def delete(
|
|
135
|
+
ctx: typer.Context,
|
|
136
|
+
space_id: Annotated[str, typer.Argument(help="Space ID")],
|
|
137
|
+
force: Annotated[bool, typer.Option("--force", "-f", help="Skip confirmation")] = False,
|
|
138
|
+
) -> None:
|
|
139
|
+
"""Delete a space."""
|
|
140
|
+
c: AppContext = ctx.obj
|
|
141
|
+
if not force:
|
|
142
|
+
typer.confirm(f"Delete space {space_id}?", abort=True)
|
|
143
|
+
c.client.delete(f"drives/{space_id}")
|
|
144
|
+
c.output.success(f"Space {space_id} deleted")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@app.command("quota")
|
|
148
|
+
def quota_cmd(
|
|
149
|
+
ctx: typer.Context,
|
|
150
|
+
space_id: Annotated[str, typer.Argument(help="Space ID")],
|
|
151
|
+
) -> None:
|
|
152
|
+
"""Show space quota usage."""
|
|
153
|
+
c: AppContext = ctx.obj
|
|
154
|
+
space = c.client.get(f"drives/{space_id}")
|
|
155
|
+
q = space.get("quota", {})
|
|
156
|
+
|
|
157
|
+
used = q.get("used", 0)
|
|
158
|
+
total = q.get("total", 0)
|
|
159
|
+
remaining = q.get("remaining", total - used if total > 0 else 0)
|
|
160
|
+
|
|
161
|
+
sections = [
|
|
162
|
+
(
|
|
163
|
+
"Quota",
|
|
164
|
+
[
|
|
165
|
+
("Space", space.get("name", space_id)),
|
|
166
|
+
("Used", _format_bytes(used)),
|
|
167
|
+
("Total", _format_bytes(total) if total > 0 else "unlimited"),
|
|
168
|
+
("Remaining", _format_bytes(remaining) if total > 0 else "unlimited"),
|
|
169
|
+
("Usage", f"{(used / total * 100):.1f}%" if total > 0 else "N/A"),
|
|
170
|
+
],
|
|
171
|
+
),
|
|
172
|
+
]
|
|
173
|
+
c.output.detail("Space Quota", sections, data_for_json=q)
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""User 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="User 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 or email")] = None,
|
|
18
|
+
) -> None:
|
|
19
|
+
"""List all users."""
|
|
20
|
+
c: AppContext = ctx.obj
|
|
21
|
+
params: dict[str, Any] = {}
|
|
22
|
+
if search:
|
|
23
|
+
params["$search"] = search
|
|
24
|
+
|
|
25
|
+
users = c.client.get_all("users", params=params)
|
|
26
|
+
|
|
27
|
+
rows = []
|
|
28
|
+
for u in users:
|
|
29
|
+
rows.append(
|
|
30
|
+
[
|
|
31
|
+
u.get("id", ""),
|
|
32
|
+
u.get("displayName", "-"),
|
|
33
|
+
u.get("mail", "-"),
|
|
34
|
+
"active" if u.get("accountEnabled", True) else "disabled",
|
|
35
|
+
]
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
c.output.table(
|
|
39
|
+
"Users",
|
|
40
|
+
[("ID", "cyan"), ("Name", "green"), ("Email", "white"), ("Status", "yellow")],
|
|
41
|
+
rows,
|
|
42
|
+
data_for_json=users,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@app.command()
|
|
47
|
+
def get(
|
|
48
|
+
ctx: typer.Context,
|
|
49
|
+
user_id: Annotated[str, typer.Argument(help="User ID")],
|
|
50
|
+
) -> None:
|
|
51
|
+
"""Get user details."""
|
|
52
|
+
c: AppContext = ctx.obj
|
|
53
|
+
user = c.client.get(f"users/{user_id}")
|
|
54
|
+
|
|
55
|
+
sections = [
|
|
56
|
+
(
|
|
57
|
+
"User",
|
|
58
|
+
[
|
|
59
|
+
("ID", user.get("id", "")),
|
|
60
|
+
("Display Name", user.get("displayName", "-")),
|
|
61
|
+
("Email", user.get("mail", "-")),
|
|
62
|
+
("Username", user.get("onPremisesSamAccountName", "-")),
|
|
63
|
+
("Enabled", str(user.get("accountEnabled", True))),
|
|
64
|
+
],
|
|
65
|
+
),
|
|
66
|
+
]
|
|
67
|
+
c.output.detail("User Details", sections, data_for_json=user)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@app.command()
|
|
71
|
+
def create(
|
|
72
|
+
ctx: typer.Context,
|
|
73
|
+
email: Annotated[str, typer.Argument(help="User email address")],
|
|
74
|
+
name: Annotated[str | None, typer.Option("--name", "-n", help="Display name")] = None,
|
|
75
|
+
password: Annotated[str | None, typer.Option("--password", help="Initial password")] = None,
|
|
76
|
+
) -> None:
|
|
77
|
+
"""Create a new user."""
|
|
78
|
+
c: AppContext = ctx.obj
|
|
79
|
+
display_name = name or email.split("@")[0]
|
|
80
|
+
username = email.split("@")[0]
|
|
81
|
+
|
|
82
|
+
user_data: dict[str, Any] = {
|
|
83
|
+
"displayName": display_name,
|
|
84
|
+
"mail": email,
|
|
85
|
+
"onPremisesSamAccountName": username,
|
|
86
|
+
"accountEnabled": True,
|
|
87
|
+
}
|
|
88
|
+
if password:
|
|
89
|
+
user_data["passwordProfile"] = {"password": password}
|
|
90
|
+
|
|
91
|
+
user = c.client.post("users", data=user_data)
|
|
92
|
+
c.output.success(f"User created: {user.get('displayName', email)}")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@app.command()
|
|
96
|
+
def update(
|
|
97
|
+
ctx: typer.Context,
|
|
98
|
+
user_id: Annotated[str, typer.Argument(help="User ID")],
|
|
99
|
+
name: Annotated[str | None, typer.Option("--name", "-n", help="Display name")] = None,
|
|
100
|
+
email: Annotated[str | None, typer.Option("--email", help="Email address")] = None,
|
|
101
|
+
enabled: Annotated[bool | None, typer.Option("--enabled/--disabled", help="Account status")] = None,
|
|
102
|
+
) -> None:
|
|
103
|
+
"""Update a user."""
|
|
104
|
+
c: AppContext = ctx.obj
|
|
105
|
+
patch_data: dict[str, Any] = {}
|
|
106
|
+
if name is not None:
|
|
107
|
+
patch_data["displayName"] = name
|
|
108
|
+
if email is not None:
|
|
109
|
+
patch_data["mail"] = email
|
|
110
|
+
if enabled is not None:
|
|
111
|
+
patch_data["accountEnabled"] = enabled
|
|
112
|
+
|
|
113
|
+
if not patch_data:
|
|
114
|
+
c.output.warn("No changes specified")
|
|
115
|
+
return
|
|
116
|
+
|
|
117
|
+
c.client.patch(f"users/{user_id}", data=patch_data)
|
|
118
|
+
c.output.success(f"User {user_id} updated")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@app.command()
|
|
122
|
+
def delete(
|
|
123
|
+
ctx: typer.Context,
|
|
124
|
+
user_id: Annotated[str, typer.Argument(help="User ID")],
|
|
125
|
+
force: Annotated[bool, typer.Option("--force", "-f", help="Skip confirmation")] = False,
|
|
126
|
+
) -> None:
|
|
127
|
+
"""Delete a user."""
|
|
128
|
+
c: AppContext = ctx.obj
|
|
129
|
+
if not force:
|
|
130
|
+
typer.confirm(f"Delete user {user_id}?", abort=True)
|
|
131
|
+
|
|
132
|
+
c.client.delete(f"users/{user_id}")
|
|
133
|
+
c.output.success(f"User {user_id} deleted")
|
|
File without changes
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Typer global callback and shared context for kctl-opencloud.
|
|
2
|
+
|
|
3
|
+
Subclasses AppContextBase from kctl-lib, adding OpenCloud-specific
|
|
4
|
+
client resolution.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
|
|
11
|
+
from kctl_lib.callbacks import AppContextBase
|
|
12
|
+
|
|
13
|
+
from kctl_opencloud.core.client import OpenCloudClient
|
|
14
|
+
from kctl_opencloud.core.config import resolve_connection
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class AppContext(AppContextBase):
|
|
19
|
+
"""OpenCloud-specific application context."""
|
|
20
|
+
|
|
21
|
+
url_override: str | None = None
|
|
22
|
+
token_override: str | None = None
|
|
23
|
+
_client: OpenCloudClient | None = field(default=None, repr=False)
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def client(self) -> OpenCloudClient:
|
|
27
|
+
if self._client is None:
|
|
28
|
+
url, token = resolve_connection(
|
|
29
|
+
profile_name=self.profile,
|
|
30
|
+
url_override=self.url_override,
|
|
31
|
+
token_override=self.token_override,
|
|
32
|
+
)
|
|
33
|
+
self._client = OpenCloudClient(base_url=url, credential=token)
|
|
34
|
+
return self._client
|