"""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": "", "overwriteprotocol": "", "overwrite.cli.url": "" } } 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 exec -T --user php 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 )