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.
@@ -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