letsbe-sysadmin/app/executors/nextcloud_executor.py

359 lines
12 KiB
Python

"""Nextcloud domain configuration executor."""
import asyncio
import subprocess
import time
from pathlib import Path
from typing import Any
from urllib.parse import urlparse
from app.executors.base import BaseExecutor, ExecutionResult
class NextcloudSetDomainExecutor(BaseExecutor):
"""Execute Nextcloud domain configuration via occ commands.
This executor configures Nextcloud's external domain settings by running
occ config:system:set commands via docker compose exec. It keeps the
Orchestrator unaware of container names, occ paths, and docker-compose syntax.
Security measures:
- URL parsing with validation
- No shell=True, command list only
- Timeout enforcement on each subprocess
Payload:
{
"public_url": "https://cloud.example.com"
}
Result (success):
{
"public_url": "https://cloud.example.com",
"host": "cloud.example.com",
"scheme": "https",
"commands_executed": 3,
"logs": {
"overwritehost": "<stdout+stderr>",
"overwriteprotocol": "<stdout+stderr>",
"overwrite.cli.url": "<stdout+stderr>"
}
}
Result (failure):
{
"public_url": "https://cloud.example.com",
"host": "cloud.example.com",
"scheme": "https",
"commands_executed": 2,
"failed_command": "overwriteprotocol",
"failed_args": ["config:system:set", "overwriteprotocol", "--value=https"],
"logs": {...}
}
"""
# TODO: These constants may need adjustment based on actual Nextcloud stack setup
NEXTCLOUD_STACK_DIR = "/opt/letsbe/stacks/nextcloud"
NEXTCLOUD_SERVICE_NAME = "app"
NEXTCLOUD_OCC_PATH = "/var/www/html/occ"
NEXTCLOUD_USER = "www-data"
# Compose file search order (matches DockerExecutor)
COMPOSE_FILE_NAMES = ["docker-compose.yml", "compose.yml"]
# Default timeout for each occ command (seconds)
DEFAULT_COMMAND_TIMEOUT = 60
@property
def task_type(self) -> str:
return "NEXTCLOUD_SET_DOMAIN"
async def execute(self, payload: dict[str, Any]) -> ExecutionResult:
"""Execute Nextcloud domain configuration commands.
Runs three occ config:system:set commands to configure:
- overwritehost: The domain/host portion of the URL
- overwriteprotocol: The scheme (http/https)
- overwrite.cli.url: The full public URL
Args:
payload: Must contain "public_url", optionally "timeout"
Returns:
ExecutionResult with configuration confirmation and logs
"""
self.validate_payload(payload, ["public_url"])
public_url = payload["public_url"]
timeout = payload.get("timeout", self.DEFAULT_COMMAND_TIMEOUT)
# Parse URL into components
try:
scheme, host, normalized_url = self._parse_public_url(public_url)
except ValueError as e:
return ExecutionResult(
success=False,
data={"public_url": public_url},
error=str(e),
)
# Find compose file in the Nextcloud stack directory
stack_dir = Path(self.NEXTCLOUD_STACK_DIR)
compose_file = self._find_compose_file(stack_dir)
if compose_file is None:
self.logger.warning("nextcloud_compose_not_found", dir=self.NEXTCLOUD_STACK_DIR)
return ExecutionResult(
success=False,
data={"public_url": public_url, "host": host, "scheme": scheme},
error=f"Nextcloud compose file not found in {self.NEXTCLOUD_STACK_DIR}. "
f"Looked for: {', '.join(self.COMPOSE_FILE_NAMES)}",
)
self.logger.info(
"nextcloud_setting_domain",
public_url=normalized_url,
host=host,
scheme=scheme,
compose_file=str(compose_file),
)
start_time = time.time()
logs: dict[str, str] = {}
commands_executed = 0
# Define the three occ commands to run
occ_commands = [
("overwritehost", ["config:system:set", "overwritehost", f"--value={host}"]),
("overwriteprotocol", ["config:system:set", "overwriteprotocol", f"--value={scheme}"]),
("overwrite.cli.url", ["config:system:set", "overwrite.cli.url", f"--value={normalized_url}"]),
]
try:
for cmd_name, occ_args in occ_commands:
exit_code, stdout, stderr = await self._run_occ_command(
compose_file,
occ_args,
timeout,
)
logs[cmd_name] = self._combine_output(stdout, stderr)
commands_executed += 1
if exit_code != 0:
duration_ms = (time.time() - start_time) * 1000
self.logger.warning(
"nextcloud_occ_command_failed",
command=cmd_name,
occ_args=occ_args,
exit_code=exit_code,
stderr=stderr[:500] if stderr else None,
)
return ExecutionResult(
success=False,
data={
"public_url": normalized_url,
"host": host,
"scheme": scheme,
"commands_executed": commands_executed,
"failed_command": cmd_name,
"failed_args": occ_args,
"logs": logs,
},
error=f"occ {cmd_name} failed with exit code {exit_code}",
duration_ms=duration_ms,
)
duration_ms = (time.time() - start_time) * 1000
self.logger.info(
"nextcloud_domain_set",
public_url=normalized_url,
host=host,
scheme=scheme,
commands_executed=commands_executed,
duration_ms=duration_ms,
)
return ExecutionResult(
success=True,
data={
"public_url": normalized_url,
"host": host,
"scheme": scheme,
"commands_executed": commands_executed,
"logs": logs,
},
duration_ms=duration_ms,
)
except asyncio.TimeoutError:
duration_ms = (time.time() - start_time) * 1000
self.logger.error(
"nextcloud_timeout",
public_url=normalized_url,
timeout=timeout,
commands_executed=commands_executed,
)
return ExecutionResult(
success=False,
data={
"public_url": normalized_url,
"host": host,
"scheme": scheme,
"commands_executed": commands_executed,
"logs": logs,
},
error=f"Nextcloud occ operation timed out after {timeout} seconds",
duration_ms=duration_ms,
)
except Exception as e:
duration_ms = (time.time() - start_time) * 1000
self.logger.error(
"nextcloud_error",
public_url=normalized_url,
error=str(e),
commands_executed=commands_executed,
)
return ExecutionResult(
success=False,
data={
"public_url": normalized_url,
"host": host,
"scheme": scheme,
"commands_executed": commands_executed,
"logs": logs,
},
error=str(e),
duration_ms=duration_ms,
)
def _parse_public_url(self, public_url: str) -> tuple[str, str, str]:
"""Parse public URL into scheme, host, and normalized URL.
Args:
public_url: Full URL like "https://cloud.example.com" or just "cloud.example.com"
Returns:
Tuple of (scheme, host, normalized_url)
- scheme: "http" or "https" (defaults to "https" if not provided)
- host: Domain with optional port (e.g., "cloud.example.com:8443")
- normalized_url: Full URL with trailing slash stripped
Raises:
ValueError: If URL is invalid or missing host
"""
if not public_url or not public_url.strip():
raise ValueError("public_url cannot be empty")
url = public_url.strip()
# Parse the URL
parsed = urlparse(url)
# Extract scheme, default to "https" if not provided
scheme = parsed.scheme if parsed.scheme else "https"
# Extract host (netloc includes port if present)
host = parsed.netloc
# Handle URLs without scheme (e.g., "cloud.example.com" or "cloud.example.com/path")
# urlparse treats "cloud.example.com" as a path, not netloc
if not host and not parsed.scheme:
# The URL was provided without a scheme, so we need to re-parse with scheme
url_with_scheme = f"https://{url}"
parsed = urlparse(url_with_scheme)
host = parsed.netloc
scheme = "https"
if not host:
raise ValueError(f"Invalid URL - no host found: {public_url}")
# Reconstruct normalized URL (with trailing slash stripped)
normalized_url = f"{scheme}://{host}"
if parsed.path and parsed.path != "/":
normalized_url += parsed.path.rstrip("/")
return scheme, host, normalized_url
def _find_compose_file(self, compose_dir: Path) -> Path | None:
"""Find compose file in the directory.
Searches in order: docker-compose.yml, compose.yml
Args:
compose_dir: Directory to search in
Returns:
Path to compose file, or None if not found
"""
for filename in self.COMPOSE_FILE_NAMES:
compose_file = compose_dir / filename
if compose_file.exists():
return compose_file
return None
def _combine_output(self, stdout: str, stderr: str) -> str:
"""Combine stdout and stderr into a single string.
Args:
stdout: Standard output
stderr: Standard error
Returns:
Combined output string
"""
parts = []
if stdout:
parts.append(stdout)
if stderr:
parts.append(stderr)
return "\n".join(parts)
async def _run_occ_command(
self,
compose_file: Path,
occ_args: list[str],
timeout: int,
) -> tuple[int, str, str]:
"""Run a Nextcloud occ command via docker compose exec.
Args:
compose_file: Path to the docker-compose file
occ_args: Arguments to pass to occ (e.g., ["config:system:set", "overwritehost", "--value=..."])
timeout: Operation timeout in seconds
Returns:
Tuple of (exit_code, stdout, stderr)
"""
def _run() -> tuple[int, str, str]:
# Build command: docker compose -f <file> exec -T --user <user> <service> php <occ_path> <args>
cmd = [
"docker",
"compose",
"-f",
str(compose_file),
"exec",
"-T", # Disable pseudo-TTY allocation
"--user",
self.NEXTCLOUD_USER,
self.NEXTCLOUD_SERVICE_NAME,
"php",
self.NEXTCLOUD_OCC_PATH,
] + occ_args
# Run command from stack directory, no shell=True
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=timeout,
cwd=self.NEXTCLOUD_STACK_DIR,
)
return result.returncode, result.stdout, result.stderr
return await asyncio.wait_for(
asyncio.to_thread(_run),
timeout=timeout + 30, # Watchdog with buffer
)