359 lines
12 KiB
Python
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
|
|
)
|