sync: auto-sync from HOWARD-HOME at 2026-07-05 20:23:39
Author: Howard Enos Machine: HOWARD-HOME Timestamp: 2026-07-05 20:23:39
This commit is contained in:
73
.claude/skills/owncloud/scripts/dry_test.sh
Normal file
73
.claude/skills/owncloud/scripts/dry_test.sh
Normal file
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env bash
|
||||
# Non-destructive verification for the owncloud skill.
|
||||
# - every WRITE command is run WITHOUT --confirm -> must refuse (rc 3), no SSH write
|
||||
# - hard-fail guards are run (validation precedes the gate, so no execution) -> rc 2
|
||||
# - read-only commands must succeed (rc 0) and JSON reads must parse
|
||||
# NOTHING here runs a state change on the server.
|
||||
OC=".claude/skills/owncloud/scripts/owncloud.py"
|
||||
pass=0; fail=0
|
||||
chk() { # chk <expected_rc> <label> -- <cmd...>
|
||||
local exp="$1" label="$2"; shift 3
|
||||
"$@" >/dev/null 2>&1; local rc=$?
|
||||
if [ "$rc" = "$exp" ]; then printf ' [PASS] %-40s (rc %s)\n' "$label" "$rc"; pass=$((pass+1))
|
||||
else printf ' [FAIL] %-40s (got rc %s, want %s)\n' "$label" "$rc" "$exp"; fail=$((fail+1)); fi
|
||||
}
|
||||
jchk() { # jchk <label> -- <cmd...> (stdout must be valid JSON)
|
||||
local label="$1"; shift 2
|
||||
if "$@" 2>/dev/null | py -c "import sys,json; json.load(sys.stdin)" 2>/dev/null; then
|
||||
printf ' [PASS] %-40s (valid JSON)\n' "$label"; pass=$((pass+1))
|
||||
else printf ' [FAIL] %-40s (bad JSON)\n' "$label"; fail=$((fail+1)); fi
|
||||
}
|
||||
|
||||
echo "== WRITE commands without --confirm must REFUSE (rc 3) =="
|
||||
chk 3 "user-add" -- py "$OC" user-add zz.dry --gen-password
|
||||
chk 3 "user-delete" -- py "$OC" user-delete zz.dry
|
||||
chk 3 "user-enable" -- py "$OC" user-enable zz.dry
|
||||
chk 3 "user-disable" -- py "$OC" user-disable zz.dry
|
||||
chk 3 "user-reset-password" -- py "$OC" user-reset-password zz.dry --gen-password
|
||||
chk 3 "user-modify" -- py "$OC" user-modify zz.dry --email a@b.com
|
||||
chk 3 "quota-set" -- py "$OC" quota-set zz.dry "5 GB"
|
||||
chk 3 "quota-reset" -- py "$OC" quota-reset zz.dry
|
||||
chk 3 "group-add" -- py "$OC" group-add zz.dry
|
||||
chk 3 "group-delete" -- py "$OC" group-delete zz.dry
|
||||
chk 3 "group-add-member" -- py "$OC" group-add-member zz.dry -m sysadmin
|
||||
chk 3 "group-remove-member" -- py "$OC" group-remove-member zz.dry -m sysadmin
|
||||
chk 3 "maintenance on" -- py "$OC" maintenance on
|
||||
chk 3 "maintenance off" -- py "$OC" maintenance off
|
||||
chk 3 "app-enable" -- py "$OC" app-enable activity
|
||||
chk 3 "app-disable" -- py "$OC" app-disable activity
|
||||
chk 3 "config-set" -- py "$OC" config-set zz.key --value v
|
||||
chk 3 "files-scan --user" -- py "$OC" files-scan --user zz.dry
|
||||
chk 3 "files-scan --all" -- py "$OC" files-scan --all
|
||||
chk 3 "transfer-ownership" -- py "$OC" transfer-ownership a b
|
||||
chk 3 "trashbin-cleanup" -- py "$OC" trashbin-cleanup --user zz.dry
|
||||
chk 3 "versions-cleanup" -- py "$OC" versions-cleanup --user zz.dry
|
||||
|
||||
echo "== hard-fail guards (validation precedes the gate; no execution) rc 2 =="
|
||||
chk 2 "user-add bad uid" -- py "$OC" user-add "bad uid!" --gen-password --confirm
|
||||
chk 2 "user-add no password" -- py "$OC" user-add validuid --confirm
|
||||
chk 2 "user-modify no fields" -- py "$OC" user-modify zz.dry --confirm
|
||||
chk 2 "files-scan no target" -- py "$OC" files-scan --confirm
|
||||
chk 2 "trashbin-cleanup no target"-- py "$OC" trashbin-cleanup --confirm
|
||||
chk 2 "versions-cleanup no target"-- py "$OC" versions-cleanup --confirm
|
||||
|
||||
echo "== read-only commands must succeed (rc 0) =="
|
||||
chk 0 "status" -- py "$OC" status
|
||||
chk 0 "groups" -- py "$OC" groups
|
||||
chk 0 "group-members" -- py "$OC" group-members admin
|
||||
chk 0 "user-groups" -- py "$OC" user-groups sysadmin
|
||||
chk 0 "shares" -- py "$OC" shares
|
||||
chk 0 "shares --user" -- py "$OC" shares --user sysadmin
|
||||
chk 0 "apps" -- py "$OC" apps
|
||||
chk 0 "maintenance status" -- py "$OC" maintenance status
|
||||
chk 0 "config-get key" -- py "$OC" config-get maintenance
|
||||
chk 0 "quota (read)" -- py "$OC" quota sysadmin
|
||||
|
||||
echo "== JSON read outputs must parse =="
|
||||
jchk "groups --json" -- py "$OC" groups --json
|
||||
jchk "apps --json" -- py "$OC" apps --json
|
||||
jchk "shares --json" -- py "$OC" shares --json
|
||||
|
||||
echo
|
||||
echo "==== $pass passed, $fail failed ===="
|
||||
[ "$fail" = 0 ]
|
||||
@@ -55,7 +55,7 @@ def _gen_password(n: int = 20) -> str:
|
||||
return "".join(secrets.choice(alpha) for _ in range(n))
|
||||
|
||||
|
||||
def _resolve_password(args, *, allow_gen: bool = True):
|
||||
def _resolve_password(args):
|
||||
"""Return (password, was_generated) from --password / --password-env / --gen-password.
|
||||
Prefer --password-env or --gen-password: a literal --password lands in shell
|
||||
history and `ps` on the operator's machine."""
|
||||
@@ -67,15 +67,18 @@ def _resolve_password(args, *, allow_gen: bool = True):
|
||||
if not v:
|
||||
raise SystemExit(f"[ERROR] env var {env_var} is unset/empty")
|
||||
return v, False
|
||||
if allow_gen and getattr(args, "gen_password", False):
|
||||
if getattr(args, "gen_password", False):
|
||||
return _gen_password(), True
|
||||
return None, False
|
||||
|
||||
|
||||
def _vault_reminder(uid: str) -> None:
|
||||
print("[CRITICAL] House rule: store this credential in the SOPS vault now, e.g.:")
|
||||
def _vault_reminder(uid: str, stream=None) -> None:
|
||||
out = stream or sys.stdout
|
||||
print("[CRITICAL] House rule: store this credential in the SOPS vault now, e.g.:",
|
||||
file=out)
|
||||
print(f" bash .claude/scripts/vault.sh set-field "
|
||||
f"infrastructure/owncloud-users/{uid}.sops.yaml credentials.password '<paste-here>'")
|
||||
f"infrastructure/owncloud-users/{uid}.sops.yaml credentials.password '<paste-here>'",
|
||||
file=out)
|
||||
|
||||
|
||||
def cmd_status(c: OwncloudClient, args) -> int:
|
||||
@@ -204,13 +207,20 @@ def cmd_user_add(c: OwncloudClient, args) -> int:
|
||||
if not _gated(desc, args.confirm):
|
||||
return 3
|
||||
res = c.create_user(args.uid, pw, args.display_name, args.email, args.group)
|
||||
if args.json:
|
||||
# keep stdout parseable: password + vault reminder go to stderr
|
||||
if generated:
|
||||
print(f"[CRITICAL] generated password for '{args.uid}' (shown once); "
|
||||
"store in vault", file=sys.stderr)
|
||||
_vault_reminder(args.uid, stream=sys.stderr)
|
||||
print(json.dumps({"uid": args.uid, "created": True,
|
||||
"generated_password": pw if generated else None,
|
||||
"detail": res}, indent=2))
|
||||
return 0
|
||||
print(f"[OK] created user '{args.uid}'")
|
||||
if generated:
|
||||
print(f"[CRITICAL] generated password (shown once): {pw}")
|
||||
_vault_reminder(args.uid)
|
||||
if args.json:
|
||||
print(json.dumps({"uid": args.uid, "created": True,
|
||||
"generated_password": generated, "detail": res}, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
@@ -246,13 +256,19 @@ def cmd_user_reset_password(c: OwncloudClient, args) -> int:
|
||||
if not _gated(f"reset password for user '{args.uid}'", args.confirm):
|
||||
return 3
|
||||
c.reset_password(args.uid, pw)
|
||||
if args.json:
|
||||
if generated:
|
||||
print(f"[CRITICAL] generated password for '{args.uid}' (shown once); "
|
||||
"store in vault", file=sys.stderr)
|
||||
_vault_reminder(args.uid, stream=sys.stderr)
|
||||
print(json.dumps({"uid": args.uid, "password_reset": True,
|
||||
"generated_password": pw if generated else None},
|
||||
indent=2))
|
||||
return 0
|
||||
print(f"[OK] reset password for '{args.uid}'")
|
||||
if generated:
|
||||
print(f"[CRITICAL] generated password (shown once): {pw}")
|
||||
_vault_reminder(args.uid)
|
||||
if args.json:
|
||||
print(json.dumps({"uid": args.uid, "password_reset": True,
|
||||
"generated_password": generated}, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -250,84 +250,115 @@ class OwncloudClient:
|
||||
f"{(err or out).strip()[:300]}")
|
||||
return out
|
||||
|
||||
def _occ_env_ok(self, env: dict, args: str,
|
||||
timeout: int = EXEC_TIMEOUT) -> str:
|
||||
"""occ_env variant of _occ_ok (occ_env is not wrapped by _occ_ok)."""
|
||||
rc, out, err = self.occ_env(env, args, timeout=timeout)
|
||||
if rc != 0:
|
||||
label = args.split()[0] if args else "occ"
|
||||
raise bc.BackupError(f"occ {label} failed (rc={rc}): "
|
||||
f"{(err or out).strip()[:300]}")
|
||||
return out
|
||||
|
||||
def _occ_json(self, args: str, timeout: int = EXEC_TIMEOUT) -> Any:
|
||||
"""occ with --output=json; parse defensively so a non-JSON banner
|
||||
(maintenance notice, PHP deprecation, update line) surfaces as a clean
|
||||
BackupError rather than an uncaught JSONDecodeError traceback."""
|
||||
out = self._occ_ok(args, timeout=timeout).strip()
|
||||
try:
|
||||
return json.loads(out or "null")
|
||||
except json.JSONDecodeError:
|
||||
label = args.split()[0] if args else "occ"
|
||||
raise bc.BackupError(f"occ {label} returned non-JSON: {out[:300]}")
|
||||
|
||||
# -- user lifecycle --------------------------------------------------------
|
||||
def create_user(self, uid: str, password: str,
|
||||
display_name: Optional[str] = None,
|
||||
email: Optional[str] = None,
|
||||
groups: Optional[list[str]] = None) -> dict:
|
||||
parts = [f"user:add {shlex.quote(uid)} --password-from-env -n"]
|
||||
# options first, then `--`, then the positional uid so a uid that
|
||||
# happens to start with '-' is never parsed by occ as an option flag.
|
||||
parts = ["user:add --password-from-env -n"]
|
||||
if display_name:
|
||||
parts.append(f"--display-name={shlex.quote(display_name)}")
|
||||
if email:
|
||||
parts.append(f"--email={shlex.quote(email)}")
|
||||
for g in (groups or []):
|
||||
parts.append(f"-g {shlex.quote(g)}")
|
||||
rc, out, err = self.occ_env({"OC_PASS": password}, " ".join(parts))
|
||||
if rc != 0:
|
||||
raise bc.BackupError(f"user:add {uid} failed (rc={rc}): "
|
||||
f"{(err or out).strip()[:300]}")
|
||||
parts.append(f"--group={shlex.quote(g)}")
|
||||
parts.append(f"-- {shlex.quote(uid)}")
|
||||
out = self._occ_env_ok({"OC_PASS": password}, " ".join(parts))
|
||||
return {"uid": uid, "output": out.strip()}
|
||||
|
||||
def delete_user(self, uid: str, force: bool = False) -> str:
|
||||
return self._occ_ok(f"user:delete {shlex.quote(uid)}"
|
||||
+ (" -f" if force else ""), timeout=DU_TIMEOUT).strip()
|
||||
return self._occ_ok(f"user:delete{' -f' if force else ''} -- "
|
||||
f"{shlex.quote(uid)}", timeout=DU_TIMEOUT).strip()
|
||||
|
||||
def set_user_enabled(self, uid: str, enabled: bool) -> str:
|
||||
verb = "user:enable" if enabled else "user:disable"
|
||||
return self._occ_ok(f"{verb} {shlex.quote(uid)}").strip()
|
||||
return self._occ_ok(f"{verb} -- {shlex.quote(uid)}").strip()
|
||||
|
||||
def reset_password(self, uid: str, password: str) -> str:
|
||||
rc, out, err = self.occ_env(
|
||||
return self._occ_env_ok(
|
||||
{"OC_PASS": password},
|
||||
f"user:resetpassword {shlex.quote(uid)} --password-from-env")
|
||||
if rc != 0:
|
||||
raise bc.BackupError(f"user:resetpassword {uid} failed (rc={rc}): "
|
||||
f"{(err or out).strip()[:300]}")
|
||||
return out.strip()
|
||||
f"user:resetpassword --password-from-env -- {shlex.quote(uid)}").strip()
|
||||
|
||||
def modify_user(self, uid: str, key: str, value: str) -> str:
|
||||
"""key is one of: displayname, email (per occ user:modify)."""
|
||||
return self._occ_ok(
|
||||
f"user:modify {shlex.quote(uid)} {shlex.quote(key)} {shlex.quote(value)}").strip()
|
||||
f"user:modify -- {shlex.quote(uid)} {shlex.quote(key)} "
|
||||
f"{shlex.quote(value)}").strip()
|
||||
|
||||
# -- quota (occ user:setting <uid> files quota) ----------------------------
|
||||
def get_quota(self, uid: str) -> Optional[str]:
|
||||
rc, out, err = self.occ(f"user:setting {shlex.quote(uid)} files quota")
|
||||
return out.strip() or None if rc == 0 else None
|
||||
"""Return the user's quota string, or None when it is unset (= default).
|
||||
A non-existent user or a genuine occ failure raises BackupError - occ
|
||||
returns rc=1 for both 'setting unset' and 'user missing', so we key off
|
||||
the message ('setting does not exist' == unset)."""
|
||||
rc, out, err = self.occ(f"user:setting -- {shlex.quote(uid)} files quota")
|
||||
if rc == 0:
|
||||
return out.strip() or None
|
||||
msg = (out or err).strip()
|
||||
if "setting does not exist" in msg.lower():
|
||||
return None
|
||||
raise bc.BackupError(f"occ user:setting quota failed (rc={rc}): {msg[:300]}")
|
||||
|
||||
def set_quota(self, uid: str, value: str) -> str:
|
||||
return self._occ_ok(
|
||||
f"user:setting {shlex.quote(uid)} files quota --value={shlex.quote(value)}").strip()
|
||||
f"user:setting --value={shlex.quote(value)} "
|
||||
f"-- {shlex.quote(uid)} files quota").strip()
|
||||
|
||||
def reset_quota(self, uid: str) -> str:
|
||||
return self._occ_ok(
|
||||
f"user:setting {shlex.quote(uid)} files quota --delete").strip()
|
||||
f"user:setting --delete -- {shlex.quote(uid)} files quota").strip()
|
||||
|
||||
# -- groups ----------------------------------------------------------------
|
||||
def list_groups(self) -> Any:
|
||||
return json.loads(self._occ_ok("group:list --output=json") or "[]")
|
||||
return self._occ_json("group:list --output=json")
|
||||
|
||||
def group_members(self, group: str) -> Any:
|
||||
return json.loads(self._occ_ok(
|
||||
f"group:list-members {shlex.quote(group)} --output=json") or "[]")
|
||||
return self._occ_json(
|
||||
f"group:list-members --output=json -- {shlex.quote(group)}")
|
||||
|
||||
def user_groups(self, uid: str) -> Any:
|
||||
return json.loads(self._occ_ok(
|
||||
f"user:list-groups {shlex.quote(uid)} --output=json") or "[]")
|
||||
return self._occ_json(
|
||||
f"user:list-groups --output=json -- {shlex.quote(uid)}")
|
||||
|
||||
def create_group(self, name: str) -> str:
|
||||
return self._occ_ok(f"group:add {shlex.quote(name)}").strip()
|
||||
return self._occ_ok(f"group:add -- {shlex.quote(name)}").strip()
|
||||
|
||||
def delete_group(self, name: str) -> str:
|
||||
return self._occ_ok(f"group:delete {shlex.quote(name)}").strip()
|
||||
return self._occ_ok(f"group:delete -- {shlex.quote(name)}").strip()
|
||||
|
||||
def add_group_members(self, group: str, members: list[str]) -> str:
|
||||
ms = " ".join(f"-m {shlex.quote(m)}" for m in members)
|
||||
return self._occ_ok(f"group:add-member {shlex.quote(group)} {ms}").strip()
|
||||
# --member= binds each value even if it starts with '-'.
|
||||
ms = " ".join(f"--member={shlex.quote(m)}" for m in members)
|
||||
return self._occ_ok(
|
||||
f"group:add-member {ms} -- {shlex.quote(group)}").strip()
|
||||
|
||||
def remove_group_members(self, group: str, members: list[str]) -> str:
|
||||
ms = " ".join(f"-m {shlex.quote(m)}" for m in members)
|
||||
return self._occ_ok(f"group:remove-member {shlex.quote(group)} {ms}").strip()
|
||||
ms = " ".join(f"--member={shlex.quote(m)}" for m in members)
|
||||
return self._occ_ok(
|
||||
f"group:remove-member {ms} -- {shlex.quote(group)}").strip()
|
||||
|
||||
# -- shares (read-only, straight from the DB; occ has no share list) --------
|
||||
SHARE_TYPES = {0: "user", 1: "group", 3: "public_link",
|
||||
@@ -369,24 +400,35 @@ class OwncloudClient:
|
||||
return self._occ_ok("maintenance:mode " + ("--on" if on else "--off")).strip()
|
||||
|
||||
def maintenance_status(self) -> bool:
|
||||
rc, out, err = self.occ("config:system:get maintenance")
|
||||
return rc == 0 and out.strip().lower() in ("true", "1", "yes")
|
||||
# reuse config_get so a genuine occ failure raises (not a false 'off');
|
||||
# the maintenance key is always set, so None would itself be anomalous.
|
||||
val = self.config_get("maintenance")
|
||||
return (val or "").strip().lower() in ("true", "1", "yes")
|
||||
|
||||
def list_apps(self) -> Any:
|
||||
return json.loads(self._occ_ok("app:list --output=json") or "{}")
|
||||
return self._occ_json("app:list --output=json")
|
||||
|
||||
def enable_app(self, app: str) -> str:
|
||||
return self._occ_ok(f"app:enable {shlex.quote(app)}").strip()
|
||||
return self._occ_ok(f"app:enable -- {shlex.quote(app)}").strip()
|
||||
|
||||
def disable_app(self, app: str) -> str:
|
||||
return self._occ_ok(f"app:disable {shlex.quote(app)}").strip()
|
||||
return self._occ_ok(f"app:disable -- {shlex.quote(app)}").strip()
|
||||
|
||||
def config_get(self, name: str) -> Optional[str]:
|
||||
rc, out, err = self.occ(f"config:system:get {shlex.quote(name)}")
|
||||
return out.strip() if rc == 0 else None
|
||||
"""Return the system config value, or None when the key is genuinely
|
||||
unset (occ rc=1 with empty output). A non-zero rc that carries an error
|
||||
message is a real failure and raises, so callers never mistake a broken
|
||||
occ read for 'value is unset'."""
|
||||
rc, out, err = self.occ(f"config:system:get -- {shlex.quote(name)}")
|
||||
if rc == 0:
|
||||
return out.strip()
|
||||
msg = (err or out).strip()
|
||||
if not msg:
|
||||
return None
|
||||
raise bc.BackupError(f"occ config:system:get failed (rc={rc}): {msg[:300]}")
|
||||
|
||||
def config_list(self) -> Any:
|
||||
return json.loads(self._occ_ok("config:list system --output=json") or "{}")
|
||||
return self._occ_json("config:list system --output=json")
|
||||
|
||||
def config_set(self, name: str, value: str, vtype: str = "string") -> str:
|
||||
return self._occ_ok(
|
||||
@@ -396,33 +438,32 @@ class OwncloudClient:
|
||||
# -- files admin -----------------------------------------------------------
|
||||
def files_scan(self, uid: Optional[str] = None, all_users: bool = False,
|
||||
path: Optional[str] = None) -> str:
|
||||
opts = "files:scan"
|
||||
if path:
|
||||
opts += f" --path={shlex.quote(path)}"
|
||||
if all_users:
|
||||
args = "files:scan --all"
|
||||
args = f"{opts} --all"
|
||||
elif uid:
|
||||
args = f"files:scan {shlex.quote(uid)}"
|
||||
args = f"{opts} -- {shlex.quote(uid)}"
|
||||
else:
|
||||
raise bc.BackupError("files_scan requires a uid or all_users=True")
|
||||
if path:
|
||||
args += f" --path={shlex.quote(path)}"
|
||||
return self._occ_ok(args, timeout=DU_TIMEOUT).strip()
|
||||
|
||||
def transfer_ownership(self, src: str, dst: str,
|
||||
path: Optional[str] = None) -> str:
|
||||
args = (f"files:transfer-ownership {shlex.quote(src)} "
|
||||
f"{shlex.quote(dst)} -s")
|
||||
args = "files:transfer-ownership -s"
|
||||
if path:
|
||||
args += f" --path={shlex.quote(path)}"
|
||||
args += f" -- {shlex.quote(src)} {shlex.quote(dst)}"
|
||||
return self._occ_ok(args, timeout=DU_TIMEOUT).strip()
|
||||
|
||||
def trashbin_cleanup(self, users: list[str]) -> str:
|
||||
us = " ".join(shlex.quote(u) for u in users)
|
||||
return self._occ_ok(f"trashbin:cleanup {us}".strip(),
|
||||
timeout=DU_TIMEOUT).strip()
|
||||
us = (" -- " + " ".join(shlex.quote(u) for u in users)) if users else ""
|
||||
return self._occ_ok(f"trashbin:cleanup{us}", timeout=DU_TIMEOUT).strip()
|
||||
|
||||
def versions_cleanup(self, users: list[str]) -> str:
|
||||
us = " ".join(shlex.quote(u) for u in users)
|
||||
return self._occ_ok(f"versions:cleanup {us}".strip(),
|
||||
timeout=DU_TIMEOUT).strip()
|
||||
us = (" -- " + " ".join(shlex.quote(u) for u in users)) if users else ""
|
||||
return self._occ_ok(f"versions:cleanup{us}", timeout=DU_TIMEOUT).strip()
|
||||
|
||||
|
||||
def _int(v: Any) -> Optional[int]:
|
||||
|
||||
Reference in New Issue
Block a user