diff --git a/.github/workflows/controller-tests.yml b/.github/workflows/controller-tests.yml new file mode 100644 index 00000000..d4dfa60d --- /dev/null +++ b/.github/workflows/controller-tests.yml @@ -0,0 +1,32 @@ +name: Controller Tests + +on: + push: + branches: [master, feature/*] + pull_request: + branches: [master] + +jobs: + controller-tests: + runs-on: ubuntu-latest + + defaults: + run: + working-directory: controller + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + cache: 'pip' + cache-dependency-path: controller/requirements.txt + + - name: Install dependencies + run: pip install -r requirements.txt + + - name: Run pytest + run: python -m pytest test/ -v --tb=short --confcutdir=$(pwd) diff --git a/.gitignore b/.gitignore index 172ad019..d4b7562f 100644 --- a/.gitignore +++ b/.gitignore @@ -137,3 +137,6 @@ pg_data .DS_Store app/cypress/screenshots app/cypress/videos + +.claude +*.md diff --git a/app/src/Events.js b/app/src/Events.js index 3e7e52d1..a85c79e7 100644 --- a/app/src/Events.js +++ b/app/src/Events.js @@ -119,9 +119,9 @@ function handleEvents(events) { if (event === 'upgrade_started') { eventToast('Upgrade Started', message, 'info', 10000); - // Redirect to maintenance page after a short delay + // Redirect to Controller UI for upgrade status after a short delay setTimeout(() => { - window.location.href = '/maintenance.html'; + window.location.href = '/controller/?upgrade=true'; }, 1000); } diff --git a/app/src/api/controller.js b/app/src/api/controller.js new file mode 100644 index 00000000..a881d1cc --- /dev/null +++ b/app/src/api/controller.js @@ -0,0 +1,186 @@ +/** + * Controller API client for WROLPi. + * + * These functions call the Controller service endpoints. + * Controller handles system-level operations that need to work + * even when the main API is down. + */ + +// Controller endpoints are served under /controller/api +const CONTROLLER_BASE = '/controller/api'; + +/** + * Fetch with error handling for Controller endpoints. + */ +async function controllerFetch(endpoint, options = {}) { + const url = `${CONTROLLER_BASE}${endpoint}`; + const response = await fetch(url, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers, + }, + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({detail: response.statusText})); + throw new Error(error.detail || 'Controller request failed'); + } + + return response.json(); +} + +// --- Status Endpoints --- + +/** + * Get all system stats from the Controller. + * Returns CPU, memory, load, drives, network, power, iostat, processes, + * disk_bandwidth along with system info (dockerized, is_rpi, hotspot, throttle). + */ +export async function getControllerStats() { + return controllerFetch('/stats'); +} + +export async function getCpuStatus() { + return controllerFetch('/status/cpu'); +} + +export async function getMemoryStatus() { + return controllerFetch('/status/memory'); +} + +export async function getLoadStatus() { + return controllerFetch('/status/load'); +} + +export async function getDrivesStatus() { + return controllerFetch('/status/drives'); +} + +export async function getPrimaryDriveStatus() { + return controllerFetch('/status/primary-drive'); +} + +export async function getNetworkStatus() { + return controllerFetch('/status/network'); +} + +export async function getPowerStatus() { + return controllerFetch('/status/power'); +} + +// --- Admin Endpoints --- + +export async function getHotspotStatus() { + return controllerFetch('/hotspot/status'); +} + +export async function enableHotspot() { + return controllerFetch('/hotspot/enable', {method: 'POST'}); +} + +export async function disableHotspot() { + return controllerFetch('/hotspot/disable', {method: 'POST'}); +} + +export async function getThrottleStatus() { + return controllerFetch('/throttle/status'); +} + +export async function enableThrottle() { + return controllerFetch('/throttle/enable', {method: 'POST'}); +} + +export async function disableThrottle() { + return controllerFetch('/throttle/disable', {method: 'POST'}); +} + +export async function shutdownSystem() { + return controllerFetch('/shutdown', {method: 'POST'}); +} + +export async function rebootSystem() { + return controllerFetch('/reboot', {method: 'POST'}); +} + +export async function restartServices() { + return controllerFetch('/restart', {method: 'POST'}); +} + +// --- Service Endpoints --- + +export async function getServices() { + return controllerFetch('/services'); +} + +export async function getServiceStatus(serviceName) { + return controllerFetch(`/services/${serviceName}`); +} + +export async function startService(serviceName) { + return controllerFetch(`/services/${serviceName}/start`, {method: 'POST'}); +} + +export async function stopService(serviceName) { + return controllerFetch(`/services/${serviceName}/stop`, {method: 'POST'}); +} + +export async function restartService(serviceName) { + return controllerFetch(`/services/${serviceName}/restart`, {method: 'POST'}); +} + +export async function enableService(serviceName) { + return controllerFetch(`/services/${serviceName}/enable`, {method: 'POST'}); +} + +export async function disableService(serviceName) { + return controllerFetch(`/services/${serviceName}/disable`, {method: 'POST'}); +} + +export async function getServiceLogs(serviceName, lines = 100) { + return controllerFetch(`/services/${serviceName}/logs?lines=${lines}`); +} + +// --- Disk Endpoints --- + +export async function getDisks() { + return controllerFetch('/disks'); +} + +export async function getMounts() { + return controllerFetch('/disks/mounts'); +} + +export async function mountDisk(device, mountPoint) { + return controllerFetch('/disks/mount', { + method: 'POST', + body: JSON.stringify({device, mount_point: mountPoint}), + }); +} + +export async function unmountDisk(mountPoint, lazy = false) { + return controllerFetch('/disks/unmount', { + method: 'POST', + body: JSON.stringify({mount_point: mountPoint, lazy}), + }); +} + +export async function detectWrolpiDrives() { + return controllerFetch('/disks/detect-wrolpi'); +} + +export async function getSmartStatus() { + return controllerFetch('/disks/smart'); +} + +// --- Health Check --- + +export async function healthCheck() { + return controllerFetch('/health'); +} + +// --- Controller Info --- + +export async function getControllerInfo() { + return controllerFetch('/info'); +} diff --git a/app/src/components/Theme.tsx b/app/src/components/Theme.tsx index de13fb26..e87a7c8c 100644 --- a/app/src/components/Theme.tsx +++ b/app/src/components/Theme.tsx @@ -27,6 +27,12 @@ import { StatisticGroup as SStatisticGroup, Tab as STab, Table as STable, + TableBody as STableBody, + TableCell as STableCell, + TableFooter as STableFooter, + TableHeader as STableHeader, + TableHeaderCell as STableHeaderCell, + TableRow as STableRow, TabPane as STabPane, TextArea as STextArea, AccordionProps, @@ -55,6 +61,12 @@ import { StatisticGroupProps, TabProps, TableProps, + TableBodyProps, + TableCellProps, + TableFooterProps, + TableHeaderProps, + TableHeaderCellProps, + TableRowProps, TabPaneProps, TextAreaProps, } from "semantic-ui-react"; @@ -262,10 +274,7 @@ export function Segment(props: SegmentProps) { return } -export function Table(props: TableProps) { - const {i} = useContext(ThemeContext); - return -} +// Table is now a compound component - see COMPOUND COMPONENTS section below export function TextArea(props: TextAreaProps) { const {i} = useContext(ThemeContext); @@ -498,3 +507,61 @@ const BreadcrumbBase: React.FC = (props) => { export const Breadcrumb: BreadcrumbComponent = Object.assign(BreadcrumbBase, { Divider: BreadcrumbDivider, }); + +// ---------------------------------------------------------------------------- +// Table Compound Component +// ---------------------------------------------------------------------------- + +type TableBodyComponent = React.FC; +type TableCellComponent = React.FC; +type TableFooterComponent = React.FC; +type TableHeaderComponent = React.FC; +type TableHeaderCellComponent = React.FC; +type TableRowComponent = React.FC; + +interface TableComponent extends React.FC { + Body: TableBodyComponent; + Cell: TableCellComponent; + Footer: TableFooterComponent; + Header: TableHeaderComponent; + HeaderCell: TableHeaderCellComponent; + Row: TableRowComponent; +} + +const TableBody: TableBodyComponent = (props) => { + return +}; + +const TableCell: TableCellComponent = (props) => { + return +}; + +const TableFooter: TableFooterComponent = (props) => { + return +}; + +const TableHeader: TableHeaderComponent = (props) => { + return +}; + +const TableHeaderCell: TableHeaderCellComponent = (props) => { + return +}; + +const TableRow: TableRowComponent = (props) => { + return +}; + +const TableBase: React.FC = (props) => { + const {i} = useContext(ThemeContext); + return +}; + +export const Table: TableComponent = Object.assign(TableBase, { + Body: TableBody, + Cell: TableCell, + Footer: TableFooter, + Header: TableHeader, + HeaderCell: TableHeaderCell, + Row: TableRow, +}); diff --git a/app/src/components/admin/AdminRoute.js b/app/src/components/admin/AdminRoute.js index 42cb9435..4031d4d9 100644 --- a/app/src/components/admin/AdminRoute.js +++ b/app/src/components/admin/AdminRoute.js @@ -1,69 +1,10 @@ import React from 'react'; -import {Container} from "semantic-ui-react"; -import {getStatus, saveSettings} from "../../api"; -import {PageContainer, TabLinks, Toggle} from "../Common"; +import {PageContainer, TabLinks} from "../Common"; import {Route, Routes} from "react-router"; -import {ThemeContext} from "../../contexts/contexts"; -import {Header, Loader, Segment} from "../Theme"; import {DownloadsPage} from "./Downloads"; import {SettingsPage} from "./Settings"; import {StatusPage} from "./Status"; - -class WROLMode extends React.Component { - - constructor(props) { - super(props); - this.state = { - WROLMode: false, - } - } - - async componentDidMount() { - try { - const {wrol_mode} = await getStatus(); - this.setState({ready: true, WROLMode: wrol_mode}); - } catch (e) { - console.error(e); - } - } - - toggleWROLMode = async (checked) => { - // Handle WROL Mode toggling by itself so that other settings are not modified. - let config = { - wrol_mode: checked, - } - await saveSettings(config); - this.setState({disabled: !this.state.WROLMode, WROLMode: checked}); - } - - render() { - if (this.state.ready === false) { - return - } - - return - {({t}) => - - -
WROL Mode
-
- Enable read-only mode. No content can be deleted or modified. Enable this when the SHTF and you - want to prevent any potential loss of data. -
-

- Note: User settings and tags can still be modified. -

- -
- -
} -
- } -} +import {ControllerPage} from "./ControllerPage"; export default function AdminRoute() { @@ -71,7 +12,7 @@ export default function AdminRoute() { {text: 'Downloads', to: '/admin', key: 'admin', end: true}, {text: 'Settings', to: '/admin/settings', key: 'settings'}, {text: 'Status', to: '/admin/status', key: 'status'}, - {text: 'WROL Mode', to: '/admin/wrol', key: 'wrol'}, + {text: 'Controller', to: '/admin/controller', key: 'controller'}, ]; return @@ -80,7 +21,7 @@ export default function AdminRoute() { }/> }/> }/> - }/> + }/> } diff --git a/app/src/components/admin/ControllerPage.js b/app/src/components/admin/ControllerPage.js new file mode 100644 index 00000000..5292f82d --- /dev/null +++ b/app/src/components/admin/ControllerPage.js @@ -0,0 +1,609 @@ +import React from 'react'; +import {Container, Icon} from "semantic-ui-react"; +import {Button, Header, Loader, Modal, Segment, Table} from "../Theme"; +import { + APIButton, + HotspotToggle, + ThrottleToggle, + Toggle, +} from "../Common"; +import {useDockerized} from "../../hooks/customHooks"; +import { + getServices, + startService, + stopService, + restartService, + enableService, + disableService, + getServiceLogs, + getDisks, + getMounts, + getSmartStatus, + restartServices, +} from "../../api/controller"; +import {toast} from "react-semantic-toasts-2"; +import {RestartButton, ShutdownButton} from "./Settings"; + + +// Service status color mapping +const statusColors = { + running: 'green', + stopped: 'red', + failed: 'red', + unknown: 'yellow', +}; + + +function ServiceRow({service, onAction, dockerized}) { + const [loading, setLoading] = React.useState(false); + const [logsOpen, setLogsOpen] = React.useState(false); + const [logs, setLogs] = React.useState(''); + const [logsLoading, setLogsLoading] = React.useState(false); + + const handleAction = async (action, actionFn) => { + setLoading(true); + try { + await actionFn(service.name); + toast({ + type: 'success', + title: `${action} ${service.name}`, + description: `Successfully ${action.toLowerCase()}ed ${service.name}`, + time: 3000, + }); + if (onAction) onAction(); + } catch (e) { + toast({ + type: 'error', + title: `${action} Failed`, + description: e.message, + time: 5000, + }); + } finally { + setLoading(false); + } + }; + + const handleViewLogs = async () => { + setLogsOpen(true); + setLogsLoading(true); + try { + const result = await getServiceLogs(service.name, 100); + setLogs(result.logs || 'No logs available'); + } catch (e) { + setLogs(`Error fetching logs: ${e.message}`); + } finally { + setLogsLoading(false); + } + }; + + const isRunning = service.status === 'running'; + const isStopped = service.status === 'stopped'; + const statusColor = statusColors[service.status] || 'grey'; + + // Build view URL if service is viewable + let viewUrl = null; + if (service.viewable && service.port && isRunning) { + const protocol = service.use_https ? 'https' : 'http'; + const host = window.location.hostname; + const path = service.view_path || ''; + viewUrl = `${protocol}://${host}:${service.port}${path}`; + } + + return ( + + + {service.name} + {service.description &&
{service.description}
} +
+ {service.port || '-'} + + + {service.status} + + + {isRunning ? ( + + + + {viewUrl && ( + + ; + } + + return ( + +
+ + Services +
+ + + + Service + Port + Status + Actions + {!dockerized && Boot} + + + + {services.map(service => ( + + ))} + + + + + + + {restarting ? 'Restarting...' : 'Restart All Services'} + + + + +
+ {services.length === 0 && ( +

No services found. Services may not be available in this environment.

+ )} +
+ ); +} + + +// Helper to get health status color from SMART assessment +const getHealthColor = (assessment) => { + if (!assessment) return 'grey'; + const upper = assessment.toUpperCase(); + if (upper === 'PASS') return 'green'; + if (upper === 'WARN') return 'yellow'; + if (upper === 'FAIL') return 'red'; + return 'grey'; +}; + +// Helper to format power-on hours +const formatPowerOnHours = (hours) => { + if (hours === null || hours === undefined) return '-'; + const days = Math.floor(hours / 24); + const years = Math.floor(days / 365); + if (years > 0) return `${hours.toLocaleString()} hrs (${years}y ${days % 365}d)`; + if (days > 0) return `${hours.toLocaleString()} hrs (${days}d)`; + return `${hours} hrs`; +}; + + +function SmartDetailsModal({drive, open, onClose}) { + if (!drive) return null; + + const healthColor = getHealthColor(drive.assessment); + + return ( + + + + SMART Details: {drive.device} + + + + + + Model + {drive.model || '-'} + + + Serial + {drive.serial || '-'} + + + Capacity + {drive.capacity || '-'} + + + Assessment + + + {drive.assessment || 'Unknown'} + + + + Temperature + + {drive.temperature !== null && drive.temperature !== undefined + ? `${drive.temperature}°C` + : '-'} + + + + Power-On Hours + {formatPowerOnHours(drive.power_on_hours)} + + + Reallocated Sectors + 0 ? 'orange' : 'inherit'}}> + {drive.reallocated_sectors !== null && drive.reallocated_sectors !== undefined + ? drive.reallocated_sectors + : '-'} + + + + Pending Sectors + 0 ? 'orange' : 'inherit'}}> + {drive.pending_sectors !== null && drive.pending_sectors !== undefined + ? drive.pending_sectors + : '-'} + + + + SMART Enabled + {drive.smart_enabled ? 'Yes' : 'No'} + + +
+
+ + + +
+ ); +} + + +function DiskSection() { + const [disks, setDisks] = React.useState([]); + const [mounts, setMounts] = React.useState([]); + const [smartDrives, setSmartDrives] = React.useState([]); + const [smartAvailable, setSmartAvailable] = React.useState(true); + const [loading, setLoading] = React.useState(true); + const [error, setError] = React.useState(null); + const [selectedDrive, setSelectedDrive] = React.useState(null); + const [detailsOpen, setDetailsOpen] = React.useState(false); + const dockerized = useDockerized(); + + const fetchDiskInfo = async () => { + setLoading(true); + setError(null); + try { + const [disksResult, mountsResult, smartResult] = await Promise.allSettled([ + getDisks(), + getMounts(), + getSmartStatus(), + ]); + + if (disksResult.status === 'fulfilled') { + setDisks(Array.isArray(disksResult.value) ? disksResult.value : []); + } + if (mountsResult.status === 'fulfilled') { + setMounts(Array.isArray(mountsResult.value) ? mountsResult.value : []); + } + if (smartResult.status === 'fulfilled' && smartResult.value) { + // Handle new API format: {available: bool, drives: [...]} + const smartData = smartResult.value; + setSmartAvailable(smartData.available !== false); + setSmartDrives(Array.isArray(smartData.drives) ? smartData.drives : []); + } else { + setSmartAvailable(false); + setSmartDrives([]); + } + } catch (e) { + setError(e.message); + } finally { + setLoading(false); + } + }; + + const handleShowDetails = (drive) => { + setSelectedDrive(drive); + setDetailsOpen(true); + }; + + React.useEffect(() => { + fetchDiskInfo(); + }, []); + + if (dockerized) { + return ( + +
+ + Disk Management +
+

Disk management is not available in Docker environments.

+
+ ); + } + + if (loading) { + return +
Disk Management
+ +
; + } + + return ( + +
+ + Disk Management +
+ + {error &&

Some disk information unavailable: {error}

} + +
Current Mounts
+ {mounts.length > 0 ? ( + + + + Device + Mount Point + Filesystem + + + + {mounts.map((mount, idx) => ( + + {mount.device} + {mount.mount_point} + {mount.fstype} + + ))} + +
+ ) : ( +

No mounts found.

+ )} + +
SMART Health
+ {!smartAvailable ? ( +

SMART monitoring not available (pySMART not installed or not supported)

+ ) : smartDrives.length > 0 ? ( + + + + Device + Health + Temperature + Actions + + + + {smartDrives.map((drive) => ( + + {drive.device} + + + {drive.assessment || 'Unknown'} + + + {drive.temperature !== null && drive.temperature !== undefined + ? `${drive.temperature}°C` + : '-'} + + +
+ ) : ( +

No SMART-capable drives detected.

+ )} + + setDetailsOpen(false)} + /> + + +
+ ); +} + + +function AdminControlsSection() { + const dockerized = useDockerized(); + + return ( + +
+ + Hardware Controls +
+ + + + + {!dockerized && ( +
+ + +
+ )} +
+ ); +} + + +export function ControllerPage() { + return ( + + + + + + ); +} + +export default ControllerPage; diff --git a/app/src/components/admin/ControllerPage.test.js b/app/src/components/admin/ControllerPage.test.js new file mode 100644 index 00000000..372703c1 --- /dev/null +++ b/app/src/components/admin/ControllerPage.test.js @@ -0,0 +1,169 @@ +import React from 'react'; +import {render, screen, waitFor} from '@testing-library/react'; +import {BrowserRouter} from 'react-router'; +import {ThemeContext, SettingsContext} from '../../contexts/contexts'; +import {ControllerPage} from './ControllerPage'; + +// Mock the controller API +jest.mock('../../api/controller', () => ({ + getServices: jest.fn().mockResolvedValue([ + { + name: 'wrolpi-api', + port: 8081, + status: 'running', + viewable: true, + view_path: '/docs', + enabled: true, + }, + { + name: 'wrolpi-app', + port: 3000, + status: 'running', + viewable: true, + view_path: '', + enabled: true, + }, + ]), + startService: jest.fn().mockResolvedValue({success: true}), + stopService: jest.fn().mockResolvedValue({success: true}), + restartService: jest.fn().mockResolvedValue({success: true}), + enableService: jest.fn().mockResolvedValue({success: true}), + disableService: jest.fn().mockResolvedValue({success: true}), + getServiceLogs: jest.fn().mockResolvedValue({logs: 'Sample log output'}), + getDisks: jest.fn().mockResolvedValue([]), + getMounts: jest.fn().mockResolvedValue([]), + getSmartStatus: jest.fn().mockResolvedValue({}), + restartServices: jest.fn().mockResolvedValue({success: true}), +})); + + +// Mock useDockerized hook +jest.mock('../../hooks/customHooks', () => ({ + useDockerized: jest.fn().mockReturnValue(false), + useHotspot: jest.fn().mockReturnValue({on: true, setHotspot: jest.fn()}), + useThrottle: jest.fn().mockReturnValue({on: false, setThrottle: jest.fn()}), +})); + +// Mock Common.js components that have complex dependencies +jest.mock('../Common', () => { + const actual = jest.requireActual('../Common'); + return { + ...actual, + HotspotToggle: () =>
HotspotToggle
, + ThrottleToggle: () =>
ThrottleToggle
, + Toggle: ({label, checked, onChange}) => ( + + ), + APIButton: ({children, onClick, disabled}) => ( + + ), + }; +}); + +// Mock Settings.js components +jest.mock('./Settings', () => ({ + RestartButton: () => , + ShutdownButton: () => , +})); + +const defaultTheme = { + t: {style: {}}, + inverted: false, +}; + +const defaultSettings = { + settings: {}, + saveSettings: jest.fn(), + fetchSettings: jest.fn(), +}; + +const renderControllerPage = () => { + return render( + + + + + + + + ); +}; + +describe('ControllerPage', () => { + beforeEach(() => { + jest.clearAllMocks(); + // Reset useDockerized to false for regular tests + const {useDockerized} = require('../../hooks/customHooks'); + useDockerized.mockReturnValue(false); + }); + + test('renders Services section', async () => { + renderControllerPage(); + await waitFor(() => { + expect(screen.getByText('Services')).toBeInTheDocument(); + }); + }); + + test('renders Disk Management section', async () => { + renderControllerPage(); + await waitFor(() => { + expect(screen.getByText('Disk Management')).toBeInTheDocument(); + }); + }); + + test('renders Hardware Controls section', async () => { + renderControllerPage(); + await waitFor(() => { + expect(screen.getByText('Hardware Controls')).toBeInTheDocument(); + }); + }); + + test('calls getServices on mount', async () => { + const {getServices} = require('../../api/controller'); + renderControllerPage(); + // Verify getServices was called + await waitFor(() => { + expect(getServices).toHaveBeenCalled(); + }); + }); + + test('shows Restart and Shutdown buttons in non-Docker mode', async () => { + renderControllerPage(); + await waitFor(() => { + expect(screen.getByTestId('restart-button')).toBeInTheDocument(); + expect(screen.getByTestId('shutdown-button')).toBeInTheDocument(); + }); + }); +}); + +describe('ControllerPage in Docker mode', () => { + beforeEach(() => { + jest.clearAllMocks(); + const {useDockerized} = require('../../hooks/customHooks'); + useDockerized.mockReturnValue(true); + }); + + test('hides reboot/shutdown buttons in Docker mode', async () => { + renderControllerPage(); + await waitFor(() => { + expect(screen.getByText('Hardware Controls')).toBeInTheDocument(); + }); + // Shutdown and Restart buttons should not be visible + expect(screen.queryByTestId('shutdown-button')).not.toBeInTheDocument(); + expect(screen.queryByTestId('restart-button')).not.toBeInTheDocument(); + }); + + test('shows disk management unavailable message in Docker mode', async () => { + renderControllerPage(); + await waitFor(() => { + expect(screen.getByText('Disk management is not available in Docker environments.')).toBeInTheDocument(); + }); + }); +}); diff --git a/app/src/components/admin/Settings.js b/app/src/components/admin/Settings.js index 3718ec3a..1da952fd 100644 --- a/app/src/components/admin/Settings.js +++ b/app/src/components/admin/Settings.js @@ -1,6 +1,6 @@ import React from "react"; import {SettingsContext, StatusContext} from "../../contexts/contexts"; -import {checkUpgrade, postRestart, postShutdown, triggerUpgrade} from "../../api"; +import {checkUpgrade, postRestart, postShutdown, saveSettings as saveSettingsApi, triggerUpgrade} from "../../api"; import { Button, Divider, @@ -14,10 +14,8 @@ import {ButtonGroup, Container, Dimmer, Dropdown, GridColumn, GridRow, Icon, Inp import { APIButton, ErrorMessage, - HotspotToggle, InfoPopup, RefreshHeader, - ThrottleToggle, Toggle, useMessageDismissal, WROLModeMessage @@ -107,8 +105,8 @@ function UpgradeSegment() { try { const response = await triggerUpgrade(); if (response.ok) { - // Redirect to maintenance page - window.location.href = '/maintenance.html'; + // Redirect to Controller UI for upgrade status + window.location.href = '/controller/?upgrade=true'; } } catch (e) { setUpgrading(false); @@ -250,6 +248,47 @@ function toApiLogLevel(logLevel) { }[logLevel] } +function WROLModeSection() { + const {settings, fetchSettings} = React.useContext(SettingsContext); + const wrolMode = settings?.wrol_mode || false; + + const toggleWROLMode = async (checked) => { + try { + await saveSettingsApi({wrol_mode: checked}); + await fetchSettings(); // Refresh context immediately + } catch (e) { + toast({ + type: 'error', + title: 'Failed to toggle WROL Mode', + description: e.message, + time: 5000, + }); + } + }; + + return ( + +
+ + WROL Mode +
+

+ Enable read-only mode. No content can be deleted or modified. + Enable this when the SHTF and you want to prevent any potential loss of data. +

+

+ Note: User settings and tags can still be modified. +

+ +
+ ); +} + + export function SettingsPage() { const [disabled, setDisabled] = React.useState(false); const [editSpecialDirectories, setEditSpecialDirectories] = React.useState(false); @@ -579,15 +618,6 @@ export function SettingsPage() { body = Unable to fetch settings } - const controlSegment = -
Control WROLPi
- - - - - -
; - const configsSegment = - {controlSegment} -
Settings
{body} @@ -621,6 +649,8 @@ export function SettingsPage() { + +
Browser Settings
Show All Hints diff --git a/app/src/hooks/customHooks.js b/app/src/hooks/customHooks.js index a42d2d29..c526c977 100644 --- a/app/src/hooks/customHooks.js +++ b/app/src/hooks/customHooks.js @@ -30,11 +30,16 @@ import { searchDirectories, searchVideos, searchZim, - setHotspot, - setThrottle, updateChannel, updateDomain, } from "../api"; +import { + enableHotspot, + disableHotspot, + enableThrottle, + disableThrottle, + getControllerStats, +} from "../api/controller"; import {createSearchParams, useLocation, useSearchParams} from "react-router"; import {enumerate, filterToMimetypes, humanFileSize, secondsToFullDuration} from "../components/Common"; import {QueryContext, SettingsContext, StatusContext} from "../contexts/contexts"; @@ -790,9 +795,23 @@ export const useHotspot = () => { } }, [status]); - const localSetHotspot = async (on) => { + const localSetHotspot = async (enable) => { setOn(null); - await setHotspot(on); + try { + if (enable) { + await enableHotspot(); + } else { + await disableHotspot(); + } + } catch (e) { + console.error('Hotspot error:', e); + toast({ + type: 'error', + title: 'Hotspot Error', + description: e.message || 'Could not modify hotspot. See server logs.', + time: 5000, + }); + } } return {on, inUse, hotspotSsid: hotspot_ssid, setOn, setHotspot: localSetHotspot, dockerized}; @@ -866,23 +885,38 @@ export const useConfigs = () => { export const useThrottle = () => { const [on, setOn] = useState(null); - const {settings, fetchSettings} = React.useContext(SettingsContext); + const {status, fetchStatus} = React.useContext(StatusContext); useEffect(() => { - const status = settings['throttle_status']; - if (status === 'powersave') { + const throttleStatus = status?.throttle_status; + if (throttleStatus === 'powersave') { setOn(true); - } else if (status === 'ondemand') { + } else if (throttleStatus === 'ondemand') { setOn(false); } else { setOn(null); } - }, [JSON.stringify(settings)]); + }, [status?.throttle_status]); - const localSetThrottle = async (on) => { + const localSetThrottle = async (enable) => { setOn(null); - await setThrottle(on); - await fetchSettings(); + try { + if (enable) { + await enableThrottle(); + } else { + await disableThrottle(); + } + } catch (e) { + console.error('Throttle error:', e); + toast({ + type: 'error', + title: 'Throttle Error', + description: e.message || 'Could not modify throttle. See server logs.', + time: 5000, + }); + } + // Refetch status to get updated throttle state + await fetchStatus(); } return {on, setOn, setThrottle: localSetThrottle}; @@ -973,7 +1007,24 @@ export const useStatus = () => { const fetchStatus = async () => { try { - setStatus(await getStatus()); + // Fetch from both endpoints in parallel + const [appStatus, controllerStats] = await Promise.all([ + getStatus().catch(e => { + if (e instanceof ApiDownError) { + window.apiDown = true; + return {}; + } + throw e; + }), + getControllerStats().catch(e => { + // Controller may be down, continue with app status only + console.debug('Controller stats unavailable:', e.message); + return {}; + }), + ]); + + // Merge both responses - controller stats override app status for system info + setStatus({...appStatus, ...controllerStats}); window.apiDown = false; } catch (e) { if (e instanceof ApiDownError) { @@ -1484,8 +1535,8 @@ export const useVINDecoder = (defaultVINNumber = '') => { export const useWROLMode = () => { // Returns the current boolean WROL Mode, during fetch this returns null. - const {status} = useContext(StatusContext); - return status ? status.wrol_mode : null; + const {settings} = useContext(SettingsContext); + return settings ? settings.wrol_mode : null; } export const useDockerized = () => { diff --git a/controller/__init__.py b/controller/__init__.py new file mode 100644 index 00000000..9f352f99 --- /dev/null +++ b/controller/__init__.py @@ -0,0 +1,3 @@ +"""WROLPi Controller - System management service.""" + +__version__ = "1.0.0" diff --git a/controller/api/__init__.py b/controller/api/__init__.py new file mode 100644 index 00000000..e8dd1052 --- /dev/null +++ b/controller/api/__init__.py @@ -0,0 +1 @@ +"""Controller API routes.""" diff --git a/controller/api/admin.py b/controller/api/admin.py new file mode 100644 index 00000000..ec7fe645 --- /dev/null +++ b/controller/api/admin.py @@ -0,0 +1,135 @@ +""" +Admin API endpoints for WROLPi Controller. +""" + +from fastapi import APIRouter, HTTPException + +from controller.api.schemas import ( + HotspotActionResponse, + HotspotStatusResponse, + RestartServicesResponse, + ServiceRestartResult, + SystemActionResponse, + ThrottleActionResponse, + ThrottleStatusResponse, +) +from controller.lib.admin import ( + disable_hotspot, + disable_throttle, + enable_hotspot, + enable_throttle, + get_hotspot_status_dict, + get_throttle_status_dict, + reboot_system, + restart_all_services, + shutdown_system, +) +from controller.lib.config import is_docker_mode + +router = APIRouter(tags=["admin"]) + + +# --- Hotspot --- + +@router.get("/api/hotspot/status", response_model=HotspotStatusResponse) +async def hotspot_status() -> HotspotStatusResponse: + """Get WiFi hotspot status.""" + return HotspotStatusResponse(**get_hotspot_status_dict()) + + +@router.post("/api/hotspot/enable", response_model=HotspotActionResponse) +async def hotspot_enable() -> HotspotActionResponse: + """Enable WiFi hotspot.""" + result = enable_hotspot() + if not result.get("success"): + raise HTTPException(status_code=500, detail=result.get("error", "Failed")) + return HotspotActionResponse(**result) + + +@router.post("/api/hotspot/disable", response_model=HotspotActionResponse) +async def hotspot_disable() -> HotspotActionResponse: + """Disable WiFi hotspot.""" + result = disable_hotspot() + if not result.get("success"): + raise HTTPException(status_code=500, detail=result.get("error", "Failed")) + return HotspotActionResponse(**result) + + +# --- Throttle --- + +@router.get("/api/throttle/status", response_model=ThrottleStatusResponse) +async def throttle_status() -> ThrottleStatusResponse: + """Get CPU throttle status.""" + return ThrottleStatusResponse(**get_throttle_status_dict()) + + +@router.post("/api/throttle/enable", response_model=ThrottleActionResponse) +async def throttle_enable() -> ThrottleActionResponse: + """Enable CPU throttle (powersave mode).""" + result = enable_throttle() + if not result.get("success"): + raise HTTPException(status_code=500, detail=result.get("error", "Failed")) + return ThrottleActionResponse(**result) + + +@router.post("/api/throttle/disable", response_model=ThrottleActionResponse) +async def throttle_disable() -> ThrottleActionResponse: + """Disable CPU throttle (normal performance).""" + result = disable_throttle() + if not result.get("success"): + raise HTTPException(status_code=500, detail=result.get("error", "Failed")) + return ThrottleActionResponse(**result) + + +# --- System Control --- + +@router.post("/api/shutdown", response_model=SystemActionResponse) +async def system_shutdown() -> SystemActionResponse: + """Shut down the system.""" + if is_docker_mode(): + raise HTTPException( + status_code=501, + detail="Cannot shutdown from Docker container" + ) + + result = shutdown_system() + if not result.get("success"): + raise HTTPException(status_code=500, detail=result.get("error", "Failed")) + return SystemActionResponse(**result) + + +@router.post("/api/reboot", response_model=SystemActionResponse) +async def system_reboot() -> SystemActionResponse: + """Reboot the system.""" + if is_docker_mode(): + raise HTTPException( + status_code=501, + detail="Cannot reboot from Docker container" + ) + + result = reboot_system() + if not result.get("success"): + raise HTTPException(status_code=500, detail=result.get("error", "Failed")) + return SystemActionResponse(**result) + + +@router.post("/api/restart", response_model=RestartServicesResponse) +async def services_restart() -> RestartServicesResponse: + """Restart all WROLPi services including Controller.""" + if is_docker_mode(): + raise HTTPException( + status_code=501, + detail="Service restart via systemctl not available in Docker mode" + ) + + result = await restart_all_services() + # Convert nested dicts to Pydantic models + services = { + name: ServiceRestartResult(**status) + for name, status in result.get("services", {}).items() + } + return RestartServicesResponse( + success=result.get("success", False), + services=services, + error=result.get("error"), + ) diff --git a/controller/api/disks.py b/controller/api/disks.py new file mode 100644 index 00000000..b1a1de3f --- /dev/null +++ b/controller/api/disks.py @@ -0,0 +1,188 @@ +""" +Disk management API endpoints for WROLPi Controller. +""" + +from typing import Optional + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel, Field + +from controller.lib.config import is_docker_mode +from controller.lib.disks import ( + get_block_devices, + get_mounts, + mount_drive, + unmount_drive, +) +from controller.lib.fstab import ( + add_fstab_entry, + get_wrolpi_fstab_entries, + remove_fstab_entry, +) +from controller.lib.smart import ( + get_all_smart_status, + is_smart_available, +) + +router = APIRouter(prefix="/api/disks", tags=["disks"]) + + +class MountRequest(BaseModel): + """Request model for mounting a drive.""" + device: str = Field(description="Device path (e.g., /dev/sda1)") + mount_point: str = Field(description="Where to mount (must be under /media)") + fstype: Optional[str] = Field(default=None, description="Filesystem type (auto-detected if not specified)") + options: str = Field(default="defaults", description="Mount options") + persist: bool = Field(default=False, description="Add to fstab for persistent mounting") + + +class UnmountRequest(BaseModel): + """Request model for unmounting a drive.""" + mount_point: str = Field(description="Mount point to unmount") + lazy: bool = Field(default=False, description="Use lazy unmount if busy") + + +class FstabRequest(BaseModel): + """Request model for adding an fstab entry.""" + device: str = Field(description="Device path") + mount_point: str = Field(description="Where to mount") + fstype: str = Field(description="Filesystem type") + options: str = Field(default="defaults,nofail,x-systemd.device-timeout=10s", description="Mount options") + + +def _check_native_mode(): + """Raise 501 if in Docker mode.""" + if is_docker_mode(): + raise HTTPException( + status_code=501, + detail="Disk management is not available in Docker mode. " + "Configure mounts on the host system." + ) + + +@router.get("") +async def list_disks(): + """List all detected disks/partitions.""" + _check_native_mode() + + devices = get_block_devices() + return [ + { + "name": d.name, + "path": d.path, + "size": d.size, + "fstype": d.fstype, + "mountpoint": d.mountpoint, + "label": d.label, + "uuid": d.uuid, + "model": d.model, + } + for d in devices + ] + + +@router.get("/mounts") +async def list_mounts(): + """List current mounts under /media.""" + _check_native_mode() + return get_mounts() + + +@router.post("/mount") +async def disk_mount(request: MountRequest): + """Mount a partition.""" + _check_native_mode() + + result = mount_drive( + device=request.device, + mount_point=request.mount_point, + fstype=request.fstype, + options=request.options, + ) + + if not result.get("success"): + raise HTTPException(status_code=500, detail=result.get("error", "Mount failed")) + + # Add to fstab if requested + if request.persist: + fstab_result = add_fstab_entry( + device=request.device, + mount_point=request.mount_point, + fstype=request.fstype or "auto", + ) + result["fstab"] = fstab_result + + return result + + +@router.post("/unmount") +async def disk_unmount(request: UnmountRequest): + """Unmount a partition.""" + _check_native_mode() + + result = unmount_drive( + mount_point=request.mount_point, + lazy=request.lazy, + ) + + if not result.get("success"): + raise HTTPException(status_code=500, detail=result.get("error", "Unmount failed")) + + return result + + +@router.get("/fstab") +async def list_fstab(): + """Get WROLPi-related fstab entries.""" + _check_native_mode() + return get_wrolpi_fstab_entries() + + +@router.post("/fstab") +async def add_fstab(request: FstabRequest): + """Add an fstab entry for persistent mounting.""" + _check_native_mode() + + result = add_fstab_entry( + device=request.device, + mount_point=request.mount_point, + fstype=request.fstype, + options=request.options, + ) + + if not result.get("success"): + raise HTTPException(status_code=500, detail=result.get("error", "Failed")) + + return result + + +@router.delete("/fstab/{mount_point:path}") +async def delete_fstab(mount_point: str): + """Remove an fstab entry.""" + _check_native_mode() + + # URL decode the mount point + mount_point = f"/{mount_point}" + + result = remove_fstab_entry(mount_point) + + if not result.get("success"): + raise HTTPException(status_code=500, detail=result.get("error", "Failed")) + + return result + + +# --- SMART --- + +@router.get("/smart") +async def list_smart(): + """Get SMART status for all drives.""" + _check_native_mode() + + if not is_smart_available(): + return {"available": False, "reason": "pySMART not installed or not supported"} + + return { + "available": True, + "drives": get_all_smart_status(), + } diff --git a/controller/api/schemas.py b/controller/api/schemas.py new file mode 100644 index 00000000..3259a833 --- /dev/null +++ b/controller/api/schemas.py @@ -0,0 +1,265 @@ +""" +Pydantic models for Controller API requests and responses. +""" + +from typing import Optional + +from pydantic import BaseModel, Field + + +# ============================================================================ +# Health +# ============================================================================ + +class HealthResponse(BaseModel): + """Response model for /api/health endpoint.""" + + status: str = Field(description="Health status of the controller") + version: str = Field(description="Controller version") + docker_mode: bool = Field(description="Whether running in Docker mode") + drive_mounted: bool = Field(description="Whether the primary drive is mounted") + + +# ============================================================================ +# Status - CPU +# ============================================================================ + +class CpuStatusResponse(BaseModel): + """Response model for /api/stats/cpu endpoint.""" + + percent: float = Field(description="CPU usage percentage (0-100)") + frequency_mhz: Optional[int] = Field(default=None, description="Current CPU frequency in MHz") + frequency_max_mhz: Optional[int] = Field(default=None, description="Maximum CPU frequency in MHz") + temperature_c: Optional[float] = Field(default=None, description="CPU temperature in Celsius") + cores: int = Field(description="Number of CPU cores") + + +# ============================================================================ +# Status - Memory +# ============================================================================ + +class MemoryStatusResponse(BaseModel): + """Response model for /api/stats/memory endpoint.""" + + total_bytes: int = Field(description="Total memory in bytes") + available_bytes: int = Field(description="Available memory in bytes") + used_bytes: int = Field(description="Used memory in bytes") + percent: float = Field(description="Memory usage percentage (0-100)") + total_gb: float = Field(description="Total memory in GB") + used_gb: float = Field(description="Used memory in GB") + available_gb: float = Field(description="Available memory in GB") + + +# ============================================================================ +# Status - Load +# ============================================================================ + +class LoadStatusResponse(BaseModel): + """Response model for /api/stats/load endpoint.""" + + load_1min: float = Field(description="1-minute load average") + load_5min: float = Field(description="5-minute load average") + load_15min: float = Field(description="15-minute load average") + + +# ============================================================================ +# Status - Drives +# ============================================================================ + +class DriveStatusResponse(BaseModel): + """Response model for individual drive status.""" + + device: str = Field(description="Device path (e.g., /dev/sda1)") + mount_point: str = Field(description="Mount point path") + fstype: str = Field(description="Filesystem type") + total_bytes: int = Field(description="Total space in bytes") + used_bytes: int = Field(description="Used space in bytes") + free_bytes: int = Field(description="Free space in bytes") + percent: float = Field(description="Usage percentage (0-100)") + total_gb: float = Field(description="Total space in GB") + used_gb: float = Field(description="Used space in GB") + free_gb: float = Field(description="Free space in GB") + + +class PrimaryDriveStatusResponse(BaseModel): + """Response model for /api/stats/drives/primary endpoint.""" + + mounted: bool = Field(description="Whether the primary drive is mounted") + device: Optional[str] = Field(default=None, description="Device path") + mount_point: Optional[str] = Field(default=None, description="Mount point path") + fstype: Optional[str] = Field(default=None, description="Filesystem type") + total_bytes: Optional[int] = Field(default=None, description="Total space in bytes") + used_bytes: Optional[int] = Field(default=None, description="Used space in bytes") + free_bytes: Optional[int] = Field(default=None, description="Free space in bytes") + percent: Optional[float] = Field(default=None, description="Usage percentage") + total_gb: Optional[float] = Field(default=None, description="Total space in GB") + used_gb: Optional[float] = Field(default=None, description="Used space in GB") + free_gb: Optional[float] = Field(default=None, description="Free space in GB") + + +# ============================================================================ +# Status - Network +# ============================================================================ + +class NetworkInterfaceResponse(BaseModel): + """Response model for individual network interface.""" + + name: str = Field(description="Interface name (e.g., eth0, wlan0)") + ipv4: Optional[str] = Field(default=None, description="IPv4 address") + ipv6: Optional[str] = Field(default=None, description="IPv6 address") + mac: Optional[str] = Field(default=None, description="MAC address") + bytes_sent: int = Field(description="Total bytes sent") + bytes_recv: int = Field(description="Total bytes received") + bytes_sent_mb: float = Field(description="Total MB sent") + bytes_recv_mb: float = Field(description="Total MB received") + + +# ============================================================================ +# Status - Power +# ============================================================================ + +class PowerStatusResponse(BaseModel): + """Response model for /api/stats/power endpoint.""" + + undervoltage_detected: bool = Field(description="Currently experiencing undervoltage") + currently_throttled: bool = Field(description="Currently throttled due to voltage/temp") + undervoltage_occurred: bool = Field(description="Undervoltage has occurred since boot") + throttling_occurred: bool = Field(description="Throttling has occurred since boot") + + +# ============================================================================ +# Status - Full +# ============================================================================ + +class FullStatusResponse(BaseModel): + """Response model for /api/stats endpoint.""" + + cpu: CpuStatusResponse = Field(description="CPU status") + memory: MemoryStatusResponse = Field(description="Memory status") + load: LoadStatusResponse = Field(description="System load") + drives: list[DriveStatusResponse] = Field(description="All mounted drives") + primary_drive: Optional[DriveStatusResponse] = Field(default=None, description="Primary WROLPi drive") + network: list[NetworkInterfaceResponse] = Field(description="Network interfaces") + power: PowerStatusResponse = Field(description="Power status") + + +# ============================================================================ +# Admin - Hotspot +# ============================================================================ + +class HotspotStatusResponse(BaseModel): + """Response model for /api/hotspot/status endpoint.""" + + enabled: bool = Field(description="Whether hotspot is currently enabled") + available: bool = Field(description="Whether hotspot functionality is available") + reason: Optional[str] = Field(default=None, description="Reason if unavailable") + ssid: Optional[str] = Field(default=None, description="Hotspot SSID when enabled") + device: Optional[str] = Field(default=None, description="WiFi device name") + + +class HotspotActionResponse(BaseModel): + """Response model for hotspot enable/disable actions.""" + + success: bool = Field(description="Whether the action succeeded") + ssid: Optional[str] = Field(default=None, description="Hotspot SSID") + device: Optional[str] = Field(default=None, description="WiFi device name") + error: Optional[str] = Field(default=None, description="Error message if failed") + + +# ============================================================================ +# Admin - Throttle +# ============================================================================ + +class ThrottleStatusResponse(BaseModel): + """Response model for /api/throttle/status endpoint.""" + + enabled: bool = Field(description="Whether CPU throttle (powersave) is enabled") + available: bool = Field(description="Whether throttle control is available") + reason: Optional[str] = Field(default=None, description="Reason if unavailable") + governor: Optional[str] = Field(default=None, description="Current CPU governor") + available_governors: Optional[list[str]] = Field(default=None, description="Available governors") + + +class ThrottleActionResponse(BaseModel): + """Response model for throttle enable/disable actions.""" + + success: bool = Field(description="Whether the action succeeded") + governor: Optional[str] = Field(default=None, description="New CPU governor") + error: Optional[str] = Field(default=None, description="Error message if failed") + + +# ============================================================================ +# Admin - System Control +# ============================================================================ + +class SystemActionResponse(BaseModel): + """Response model for system shutdown/reboot actions.""" + + success: bool = Field(description="Whether the action was initiated") + message: Optional[str] = Field(default=None, description="Status message") + error: Optional[str] = Field(default=None, description="Error message if failed") + + +class ServiceRestartResult(BaseModel): + """Result of restarting a single service.""" + + success: bool = Field(description="Whether the service restart succeeded") + error: Optional[str] = Field(default=None, description="Error message if failed") + pending: Optional[bool] = Field(default=None, description="Whether restart is pending") + + +class RestartServicesResponse(BaseModel): + """Response model for /api/restart endpoint.""" + + success: bool = Field(description="Whether the restart was initiated") + services: dict[str, ServiceRestartResult] = Field(description="Results per service") + error: Optional[str] = Field(default=None, description="Error message if failed") + + +# ============================================================================ +# Services - Status +# ============================================================================ + +class ServiceStatusResponse(BaseModel): + """Response model for individual service status.""" + + name: str = Field(description="Service name") + status: str = Field(description="Service status (running, stopped, failed, unknown)") + systemd_name: Optional[str] = Field(default=None, description="Systemd unit name") + container_name: Optional[str] = Field(default=None, description="Docker container name") + active: Optional[str] = Field(default=None, description="Systemd active state") + docker_status: Optional[str] = Field(default=None, description="Docker container status") + enabled: Optional[bool] = Field(default=None, description="Whether service starts at boot") + port: Optional[int] = Field(default=None, description="Service port") + viewable: Optional[bool] = Field(default=False, description="Whether service has a web UI") + view_path: Optional[str] = Field(default="", description="Path to web UI") + use_https: Optional[bool] = Field(default=False, description="Whether service uses HTTPS") + description: Optional[str] = Field(default="", description="Service description") + available: Optional[bool] = Field(default=None, description="Whether management is available") + reason: Optional[str] = Field(default=None, description="Reason if unavailable") + error: Optional[str] = Field(default=None, description="Error message if any") + + +class ServiceActionResponse(BaseModel): + """Response model for service start/stop/restart/enable/disable actions.""" + + success: bool = Field(description="Whether the action succeeded") + service: str = Field(description="Service name") + action: str = Field(description="Action performed (start, stop, restart, enable, disable)") + error: Optional[str] = Field(default=None, description="Error message if failed") + + +class ServiceLogsResponse(BaseModel): + """Response model for service logs.""" + + service: str = Field(description="Service name") + lines: int = Field(description="Number of lines requested") + since: Optional[str] = Field(default=None, description="Time filter if specified") + logs: str = Field(description="Log content") + + +class ServicesListErrorResponse(BaseModel): + """Response model when services list fails.""" + + error: str = Field(description="Error message") + reason: Optional[str] = Field(default=None, description="Detailed reason") diff --git a/controller/api/services.py b/controller/api/services.py new file mode 100644 index 00000000..8e6ea12b --- /dev/null +++ b/controller/api/services.py @@ -0,0 +1,162 @@ +""" +Service management API endpoints for WROLPi Controller. +""" + +from typing import Optional, Union + +from fastapi import APIRouter, HTTPException, Query + +from controller.api.schemas import ( + ServiceActionResponse, + ServiceLogsResponse, + ServiceStatusResponse, + ServicesListErrorResponse, +) +from controller.lib.config import is_docker_mode +from controller.lib.docker_services import ( + can_manage_containers, + get_all_containers_status, + get_container_logs, + get_container_status, + restart_container, + start_container, + stop_container, +) +from controller.lib.systemd import ( + disable_service, + enable_service, + get_all_services_status, + get_service_logs, + get_service_status, + restart_service, + start_service, + stop_service, +) + +router = APIRouter(prefix="/api/services", tags=["services"]) + + +@router.get( + "", + response_model=Union[list[ServiceStatusResponse], ServicesListErrorResponse], +) +async def list_services() -> Union[list[ServiceStatusResponse], ServicesListErrorResponse]: + """Get status of all managed services.""" + if is_docker_mode(): + if can_manage_containers(): + statuses = get_all_containers_status() + return [ServiceStatusResponse(**s) for s in statuses if "error" not in s] + else: + return ServicesListErrorResponse( + error="Docker management not available", + reason="Docker socket not mounted or docker library not installed", + ) + else: + statuses = get_all_services_status() + return [ServiceStatusResponse(**s) for s in statuses] + + +@router.get("/{name}", response_model=ServiceStatusResponse) +async def get_service(name: str) -> ServiceStatusResponse: + """Get status of a specific service.""" + if is_docker_mode(): + status = get_container_status(name) + else: + status = get_service_status(name) + + if "error" in status and status.get("status") is None: + raise HTTPException(status_code=404, detail=status["error"]) + + return ServiceStatusResponse(**status) + + +@router.post("/{name}/start", response_model=ServiceActionResponse) +async def service_start(name: str) -> ServiceActionResponse: + """Start a service.""" + if is_docker_mode(): + result = start_container(name) + else: + result = start_service(name) + + if not result.get("success"): + raise HTTPException(status_code=500, detail=result.get("error", "Failed to start")) + + return ServiceActionResponse(**result) + + +@router.post("/{name}/stop", response_model=ServiceActionResponse) +async def service_stop(name: str) -> ServiceActionResponse: + """Stop a service.""" + if is_docker_mode(): + result = stop_container(name) + else: + result = stop_service(name) + + if not result.get("success"): + raise HTTPException(status_code=500, detail=result.get("error", "Failed to stop")) + + return ServiceActionResponse(**result) + + +@router.post("/{name}/restart", response_model=ServiceActionResponse) +async def service_restart(name: str) -> ServiceActionResponse: + """Restart a service.""" + if is_docker_mode(): + result = restart_container(name) + else: + result = restart_service(name) + + if not result.get("success"): + raise HTTPException(status_code=500, detail=result.get("error", "Failed to restart")) + + return ServiceActionResponse(**result) + + +@router.post("/{name}/enable", response_model=ServiceActionResponse) +async def service_enable(name: str) -> ServiceActionResponse: + """Enable a service to start at boot (systemd only).""" + if is_docker_mode(): + raise HTTPException( + status_code=501, + detail="Enable/disable not available in Docker mode" + ) + + result = enable_service(name) + if not result.get("success"): + raise HTTPException(status_code=500, detail=result.get("error", "Failed")) + + return ServiceActionResponse(**result) + + +@router.post("/{name}/disable", response_model=ServiceActionResponse) +async def service_disable(name: str) -> ServiceActionResponse: + """Disable a service from starting at boot (systemd only).""" + if is_docker_mode(): + raise HTTPException( + status_code=501, + detail="Enable/disable not available in Docker mode" + ) + + result = disable_service(name) + if not result.get("success"): + raise HTTPException(status_code=500, detail=result.get("error", "Failed")) + + return ServiceActionResponse(**result) + + +@router.get("/{name}/logs", response_model=ServiceLogsResponse) +async def service_logs( + name: str, + lines: int = Query(default=100, ge=1, le=10000), + since: Optional[str] = Query(default=None), +) -> ServiceLogsResponse: + """Get service logs.""" + if is_docker_mode(): + result = get_container_logs(name, lines) + else: + result = get_service_logs(name, lines, since) + + if "error" in result: + raise HTTPException(status_code=500, detail=result["error"]) + + return ServiceLogsResponse(**result) diff --git a/controller/api/status.py b/controller/api/status.py new file mode 100644 index 00000000..a224ec8b --- /dev/null +++ b/controller/api/status.py @@ -0,0 +1,145 @@ +""" +Stats API endpoints for WROLPi Controller. + +Endpoints read from cached status data collected by the background worker. +Individual endpoints remain for backwards compatibility. +New aggregated /api/stats endpoint returns all status at once. + +This endpoint provides system-level stats. App-level status (flags, downloads, +wrol_mode) remain in the main WROLPi API at /api/status. +""" + +from fastapi import APIRouter, Request +from fastapi.responses import JSONResponse + +from controller.lib.admin import get_hotspot_status, get_throttle_status, HotspotStatus +from controller.lib.config import is_docker_mode, is_rpi, is_rpi4, is_rpi5 +from controller.lib.status import ( + get_cpu_status, + get_load_status, + get_memory_status, + get_network_status, + get_power_status, + get_primary_drive_status, +) + +router = APIRouter(prefix="/api/stats", tags=["stats"]) + + +def get_cached_status(request: Request) -> dict: + """Get cached status from app.state, or empty dict if not yet available.""" + return getattr(request.app.state, "cached_status", {}) + + +def get_system_info() -> dict: + """ + Get static system info that doesn't need frequent updates. + These are collected on each request as they rarely change. + """ + from controller.lib.config import get_config + + hotspot_status = get_hotspot_status() + throttle_status = get_throttle_status() + + # Get hotspot SSID from config if hotspot is connected + config = get_config() + hotspot_config = config.get("hotspot", {}) + hotspot_ssid = hotspot_config.get("ssid", "WROLPi") if hotspot_status == HotspotStatus.connected else None + + return { + "dockerized": is_docker_mode(), + "is_rpi": is_rpi(), + "is_rpi4": is_rpi4(), + "is_rpi5": is_rpi5(), + "hotspot_status": hotspot_status.name, + "hotspot_ssid": hotspot_ssid, + "throttle_status": throttle_status.name, + } + + +@router.get("") +@router.get("/") +async def get_all_stats(request: Request): + """ + Get all system status at once (cached data + system info). + + Returns aggregated CPU, memory, load, drives, network, power, iostat, + processes, disk_bandwidth stats along with system info (dockerized, + is_rpi, hotspot, throttle). + + This endpoint returns O(1) cached data updated every ~5 seconds, + plus system info collected on each request. + """ + cached = get_cached_status(request) + + if not cached: + # Fallback to on-demand collection if cache not yet populated + from controller.lib.status_worker import collect_all_status + cached = await collect_all_status() + + # Add system info to response + response = {**cached, **get_system_info()} + return JSONResponse(content=response) + + +@router.get("/cpu") +async def get_cpu(request: Request): + """Get CPU status (usage, frequency, temperature).""" + cached = get_cached_status(request) + if cached and cached.get("cpu_stats"): + return JSONResponse(content=cached["cpu_stats"]) + return JSONResponse(content=get_cpu_status()) + + +@router.get("/memory") +async def get_memory(request: Request): + """Get memory usage statistics.""" + cached = get_cached_status(request) + if cached and cached.get("memory_stats"): + return JSONResponse(content=cached["memory_stats"]) + return JSONResponse(content=get_memory_status()) + + +@router.get("/load") +async def get_load(request: Request): + """Get system load averages.""" + cached = get_cached_status(request) + if cached and cached.get("load_stats"): + return JSONResponse(content=cached["load_stats"]) + return JSONResponse(content=get_load_status()) + + +@router.get("/drives/primary") +async def get_primary_drive(request: Request): + """Get status of the primary WROLPi drive.""" + cached = get_cached_status(request) + if cached and cached.get("drives_stats"): + # Find primary drive from cached drives + for drive in cached["drives_stats"]: + if drive.get("mount") == "/media/wrolpi": + return JSONResponse(content={"mounted": True, **drive}) + return JSONResponse(content={"mounted": False}) + + # Fallback to direct call + status = get_primary_drive_status() + if status is None: + return JSONResponse(content={"mounted": False}) + return JSONResponse(content={"mounted": True, **status}) + + +@router.get("/network") +async def get_network(request: Request): + """Get network interface statistics.""" + cached = get_cached_status(request) + if cached and cached.get("nic_bandwidth_stats"): + return JSONResponse(content=cached["nic_bandwidth_stats"]) + return JSONResponse(content=get_network_status()) + + +@router.get("/power") +async def get_power(request: Request): + """Get power status (undervoltage, throttling).""" + cached = get_cached_status(request) + if cached and cached.get("power_stats"): + return JSONResponse(content=cached["power_stats"]) + return JSONResponse(content=get_power_status()) diff --git a/controller/conftest.py b/controller/conftest.py new file mode 100644 index 00000000..da65f586 --- /dev/null +++ b/controller/conftest.py @@ -0,0 +1,106 @@ +""" +Pytest fixtures for Controller tests. +""" +import copy +import tempfile +from pathlib import Path +from typing import Generator +from unittest import mock + +import pytest +from fastapi.testclient import TestClient + +from controller.defaults import DEFAULT_CONFIG + + +@pytest.fixture +def test_directory() -> Generator[Path, None, None]: + """Create a temporary directory for testing.""" + with tempfile.TemporaryDirectory() as tmp_dir: + tmp_path = Path(tmp_dir).resolve() + tmp_path.chmod(0o40755) + yield tmp_path + + +@pytest.fixture +def test_config_directory(test_directory: Path) -> Path: + """Create a config directory inside test_directory.""" + config_dir = test_directory / "config" + config_dir.mkdir(parents=True, exist_ok=True) + return config_dir + + +@pytest.fixture +def mock_config_path(test_config_directory: Path): + """Mock the CONFIG_PATH_ON_DRIVE to use test directory.""" + config_path = test_config_directory / "controller.yaml" + with mock.patch("controller.lib.config.CONFIG_PATH_ON_DRIVE", config_path): + yield config_path + + +@pytest.fixture +def reset_runtime_config(): + """Reset the runtime config to defaults before and after each test.""" + import controller.lib.config as config_module + + # Save original + original = copy.deepcopy(config_module._runtime_config) + + # Reset to defaults + config_module._runtime_config = copy.deepcopy(DEFAULT_CONFIG) + + yield + + # Restore original + config_module._runtime_config = original + + +@pytest.fixture +def mock_docker_mode(): + """Mock Docker mode to be disabled.""" + with mock.patch.dict("os.environ", {"DOCKERIZED": "false"}): + yield + + +@pytest.fixture +def mock_docker_mode_enabled(): + """Mock Docker mode to be enabled.""" + with mock.patch.dict("os.environ", {"DOCKERIZED": "true"}): + yield + + +@pytest.fixture +def mock_drive_mounted(test_config_directory: Path): + """Mock the primary drive as mounted by creating config directory.""" + # test_config_directory fixture already creates it + with mock.patch( + "controller.lib.config.is_primary_drive_mounted", return_value=True + ): + yield test_config_directory + + +@pytest.fixture +def mock_drive_not_mounted(): + """Mock the primary drive as not mounted.""" + with mock.patch( + "controller.lib.config.is_primary_drive_mounted", return_value=False + ): + yield + + +@pytest.fixture +def test_client(reset_runtime_config, mock_docker_mode) -> TestClient: + """Create a FastAPI TestClient for integration testing.""" + from controller.main import app + + with TestClient(app) as client: + yield client + + +@pytest.fixture +def test_client_docker_mode(reset_runtime_config, mock_docker_mode_enabled) -> TestClient: + """Create a FastAPI TestClient with Docker mode enabled.""" + from controller.main import app + + with TestClient(app) as client: + yield client diff --git a/controller/defaults.py b/controller/defaults.py new file mode 100644 index 00000000..901d36f0 --- /dev/null +++ b/controller/defaults.py @@ -0,0 +1,115 @@ +""" +Code-defined default configuration for the Controller. + +Controller starts with these defaults and loads overrides from +/media/wrolpi/config/controller.yaml after the drive is mounted. +""" + +DEFAULT_CONFIG = { + "port": 8087, + "media_directory": "/media/wrolpi", + + "drives": { + "supported_filesystems": ["ext4", "btrfs", "vfat", "exfat"], + "auto_mount": True, + "mounts": [], # Populated from controller.yaml after drive mount + }, + + "managed_services": [ + # WROLPi services + { + "name": "wrolpi-controller", + "systemd_name": "wrolpi-controller", + "port": 8087, + "viewable": True, + "use_https": False, + "description": "Controller API (FastAPI)", + }, + { + "name": "wrolpi-api", + "systemd_name": "wrolpi-api", + "port": 8081, + "viewable": True, + "use_https": False, + "description": "Python API (Sanic)", + }, + { + "name": "wrolpi-app", + "systemd_name": "wrolpi-app", + "port": 3000, + "viewable": False, + "use_https": False, + "description": "React frontend", + }, + { + "name": "wrolpi-help", + "systemd_name": "wrolpi-help", + "port": 8086, + "viewable": True, + "use_https": True, + "description": "Help documentation", + }, + { + "name": "wrolpi-kiwix", + "systemd_name": "wrolpi-kiwix", + "port": 8085, + "viewable": True, + "use_https": True, + "description": "Kiwix/Zim server", + }, + # Map services + { + "name": "renderd", + "systemd_name": "renderd", + "port": None, + "viewable": False, + "use_https": False, + "description": "Map tile rendering daemon", + }, + { + "name": "apache2", + "systemd_name": "apache2", + "port": 8084, + "viewable": True, + "use_https": True, + "description": "Map tile web server", + }, + # System services + { + "name": "postgresql", + "systemd_name": "postgresql", + "port": 5432, + "viewable": False, + "use_https": False, + "description": "PostgreSQL database", + }, + { + "name": "nginx", + "systemd_name": "nginx", + "port": 80, + "viewable": True, + "use_https": False, + "description": "Web proxy", + }, + # Upgrade service - only shown when running + { + "name": "wrolpi-upgrade", + "systemd_name": "wrolpi-upgrade", + "port": None, + "viewable": False, + "use_https": False, + "description": "WROLPi upgrade process", + "show_only_when_running": True, + }, + ], + + "hotspot": { + "device": "wlan0", + "ssid": "WROLPi", + "password": "wrolpi hotspot", + }, + + "throttle": { + "default_governor": "ondemand", + }, +} diff --git a/controller/lib/__init__.py b/controller/lib/__init__.py new file mode 100644 index 00000000..8f423be5 --- /dev/null +++ b/controller/lib/__init__.py @@ -0,0 +1 @@ +"""Controller library modules.""" diff --git a/controller/lib/admin.py b/controller/lib/admin.py new file mode 100644 index 00000000..9f487b31 --- /dev/null +++ b/controller/lib/admin.py @@ -0,0 +1,480 @@ +""" +Admin operations for WROLPi Controller. + +Migrated from wrolpi/admin.py - provides system control functions. +""" + +import asyncio +import enum +import subprocess +from pathlib import Path + +from controller.lib.config import get_config, is_docker_mode + + +class HotspotStatus(enum.Enum): + """Hotspot status enum matching wrolpi/admin.py""" + disconnected = enum.auto() # Radio is on, but Hotspot is not connected. + unavailable = enum.auto() # Radio is off. + connected = enum.auto() # Radio is on, Hotspot is on. + unknown = enum.auto() # Unknown status. Hotspot may not be supported. + in_use = enum.auto() # Wi-Fi device is in use for a network connection. + + +class GovernorStatus(enum.Enum): + """CPU governor status enum matching wrolpi/admin.py""" + ondemand = enum.auto() + powersave = enum.auto() + unknown = enum.auto() + + +def get_current_ssid(interface: str = 'wlan0') -> str | None: + """ + Returns the name of the SSID that is currently connected. + Returns None if Hotspot is active, or no Wi-Fi network is being used. + """ + try: + result = subprocess.run( + ['iwgetid', interface, '--raw'], + capture_output=True, + text=True, + timeout=5, + ) + if result.returncode == 0: + ssid = result.stdout.strip() + return ssid if ssid else None + elif result.returncode == 255: + # Hotspot is in use + return None + else: + return None + except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError): + return None + + +def get_hotspot_status() -> HotspotStatus: + """ + Get current hotspot status matching wrolpi/admin.py logic. + + Returns: + HotspotStatus enum value + """ + if is_docker_mode(): + return HotspotStatus.unknown + + config = get_config() + hotspot_config = config.get("hotspot", {}) + device = hotspot_config.get("device", "wlan0") + + # Check if device is connected to a Wi-Fi network (not hotspot) + if get_current_ssid(device): + return HotspotStatus.in_use + + # Check nmcli status + try: + result = subprocess.run( + ["nmcli"], + capture_output=True, + text=True, + timeout=5, + ) + if result.returncode != 0: + return HotspotStatus.unknown + + output = result.stdout.strip() + for line in output.splitlines(): + if line.startswith(f'{device}: connected'): + return HotspotStatus.connected + elif line.startswith(f'{device}: disconnected'): + return HotspotStatus.disconnected + elif line.startswith(f'{device}: unavailable'): + return HotspotStatus.unavailable + + return HotspotStatus.unknown + + except subprocess.TimeoutExpired: + return HotspotStatus.unknown + except FileNotFoundError: + return HotspotStatus.unknown + except subprocess.SubprocessError: + return HotspotStatus.unknown + + +def get_hotspot_status_dict() -> dict: + """ + Get hotspot status as a dict for API responses. + + Returns dict matching HotspotStatusResponse schema: + enabled: bool - Whether hotspot is currently enabled + available: bool - Whether hotspot functionality is available + reason: Optional[str] - Reason if unavailable + ssid: Optional[str] - Hotspot SSID when enabled + device: Optional[str] - WiFi device name + """ + config = get_config() + hotspot_config = config.get("hotspot", {}) + device = hotspot_config.get("device", "wlan0") + status = get_hotspot_status() + + # Map enum status to enabled/available flags + enabled = status == HotspotStatus.connected + available = status not in (HotspotStatus.unknown, HotspotStatus.unavailable) + + reason = None + if status == HotspotStatus.unknown: + reason = "Hotspot not supported or nmcli unavailable" + elif status == HotspotStatus.unavailable: + reason = "WiFi radio is off" + elif status == HotspotStatus.in_use: + reason = "WiFi device is connected to a network" + + return { + "enabled": enabled, + "available": available, + "reason": reason, + "ssid": hotspot_config.get("ssid", "WROLPi") if enabled else None, + "device": device, + } + + +def enable_hotspot() -> dict: + """ + Enable the WiFi hotspot. + + Returns: + dict with success status and message + """ + if is_docker_mode(): + return {"success": False, "error": "Not available in Docker mode"} + + config = get_config() + hotspot_config = config.get("hotspot", {}) + + device = hotspot_config.get("device", "wlan0") + ssid = hotspot_config.get("ssid", "WROLPi") + password = hotspot_config.get("password", "wrolpi hotspot") + + try: + # First ensure radio is on + subprocess.run( + ["nmcli", "radio", "wifi", "on"], + capture_output=True, + text=True, + timeout=10, + ) + + # Create hotspot using NetworkManager + result = subprocess.run( + [ + "nmcli", "device", "wifi", "hotspot", + "ifname", device, + "ssid", ssid, + "password", password, + ], + capture_output=True, + text=True, + timeout=30, + ) + + if result.returncode == 0: + return {"success": True, "ssid": ssid, "device": device} + else: + return {"success": False, "error": result.stderr.strip() or "Unknown error"} + + except subprocess.TimeoutExpired: + return {"success": False, "error": "Timeout enabling hotspot"} + except FileNotFoundError: + return {"success": False, "error": "nmcli not found"} + except subprocess.SubprocessError as e: + return {"success": False, "error": str(e)} + + +def disable_hotspot() -> dict: + """ + Disable the WiFi hotspot. + + Returns: + dict with success status + """ + if is_docker_mode(): + return {"success": False, "error": "Not available in Docker mode"} + + try: + # Turn off the radio to disconnect hotspot + result = subprocess.run( + ["nmcli", "radio", "wifi", "off"], + capture_output=True, + text=True, + timeout=10, + ) + + # Success even if already off + return {"success": True} + + except subprocess.TimeoutExpired: + return {"success": False, "error": "Timeout disabling hotspot"} + except FileNotFoundError: + return {"success": False, "error": "nmcli not found"} + except subprocess.SubprocessError as e: + return {"success": False, "error": str(e)} + + +GOVERNOR_MAP = { + 'ondemand': GovernorStatus.ondemand, + 'powersave': GovernorStatus.powersave, +} + + +def get_throttle_status() -> GovernorStatus: + """ + Get CPU throttle (governor) status matching wrolpi/admin.py logic. + + Returns: + GovernorStatus enum value + """ + if is_docker_mode(): + return GovernorStatus.unknown + + governor_path = Path("/sys/devices/system/cpu/cpu0/cpufreq/scaling_governor") + + if not governor_path.exists(): + return GovernorStatus.unknown + + try: + current = governor_path.read_text().strip() + return GOVERNOR_MAP.get(current, GovernorStatus.unknown) + except IOError: + return GovernorStatus.unknown + + +def get_throttle_status_dict() -> dict: + """ + Get throttle status as a dict for API responses. + + Returns dict matching ThrottleStatusResponse schema: + enabled: bool - Whether CPU throttle (powersave) is enabled + available: bool - Whether throttle control is available + reason: Optional[str] - Reason if unavailable + governor: Optional[str] - Current CPU governor + available_governors: Optional[list[str]] - Available governors + """ + if is_docker_mode(): + return { + "enabled": False, + "available": False, + "reason": "Docker mode", + "governor": None, + "available_governors": None, + } + + governor_path = Path("/sys/devices/system/cpu/cpu0/cpufreq/scaling_governor") + available_path = Path("/sys/devices/system/cpu/cpu0/cpufreq/scaling_available_governors") + + if not governor_path.exists(): + return { + "enabled": False, + "available": False, + "reason": "cpufreq not available", + "governor": None, + "available_governors": None, + } + + try: + current = governor_path.read_text().strip() + available_governors = available_path.read_text().strip().split() if available_path.exists() else [] + + return { + "enabled": current == "powersave", + "available": True, + "reason": None, + "governor": current, + "available_governors": available_governors, + } + except IOError as e: + return { + "enabled": False, + "available": False, + "reason": str(e), + "governor": None, + "available_governors": None, + } + + +def enable_throttle() -> dict: + """ + Enable CPU throttle (set powersave governor). + + Returns: + dict with success status + """ + if is_docker_mode(): + return {"success": False, "error": "Not available in Docker mode"} + + try: + # Try cpufreq-set first + result = subprocess.run( + ["cpufreq-set", "-g", "powersave"], + capture_output=True, + text=True, + timeout=5, + ) + + if result.returncode == 0: + return {"success": True, "governor": "powersave"} + else: + return {"success": False, "error": result.stderr.strip() or "Unknown error"} + + except FileNotFoundError: + # Try direct write to sysfs + try: + for cpu_path in Path("/sys/devices/system/cpu/").glob("cpu[0-9]*/cpufreq/scaling_governor"): + cpu_path.write_text("powersave") + return {"success": True, "governor": "powersave"} + except IOError as e: + return {"success": False, "error": str(e)} + except subprocess.TimeoutExpired: + return {"success": False, "error": "Timeout enabling throttle"} + except subprocess.SubprocessError as e: + return {"success": False, "error": str(e)} + + +def disable_throttle() -> dict: + """ + Disable CPU throttle (set ondemand or performance governor). + + Returns: + dict with success status + """ + if is_docker_mode(): + return {"success": False, "error": "Not available in Docker mode"} + + config = get_config() + default_governor = config.get("throttle", {}).get("default_governor", "ondemand") + + try: + result = subprocess.run( + ["cpufreq-set", "-g", default_governor], + capture_output=True, + text=True, + timeout=5, + ) + + if result.returncode == 0: + return {"success": True, "governor": default_governor} + else: + return {"success": False, "error": result.stderr.strip() or "Unknown error"} + + except FileNotFoundError: + # Try direct write to sysfs + try: + for cpu_path in Path("/sys/devices/system/cpu/").glob("cpu[0-9]*/cpufreq/scaling_governor"): + cpu_path.write_text(default_governor) + return {"success": True, "governor": default_governor} + except IOError as e: + return {"success": False, "error": str(e)} + except subprocess.TimeoutExpired: + return {"success": False, "error": "Timeout disabling throttle"} + except subprocess.SubprocessError as e: + return {"success": False, "error": str(e)} + + +def shutdown_system() -> dict: + """ + Shut down the system. + + Returns: + dict with success status + """ + if is_docker_mode(): + return {"success": False, "error": "Cannot shutdown from Docker container"} + + try: + subprocess.Popen(["shutdown", "-h", "now"]) + return {"success": True, "message": "System shutting down"} + except FileNotFoundError: + return {"success": False, "error": "shutdown command not found"} + except Exception as e: + return {"success": False, "error": str(e)} + + +def reboot_system() -> dict: + """ + Reboot the system. + + Returns: + dict with success status + """ + if is_docker_mode(): + return {"success": False, "error": "Cannot reboot from Docker container"} + + try: + subprocess.Popen(["reboot"]) + return {"success": True, "message": "System rebooting"} + except FileNotFoundError: + return {"success": False, "error": "reboot command not found"} + except Exception as e: + return {"success": False, "error": str(e)} + + +async def restart_all_services() -> dict: + """ + Restart all WROLPi services including Controller itself. + + Controller restarts itself LAST so it can report success for other services. + + Returns: + dict with results for each service + """ + if is_docker_mode(): + # In Docker mode, we can't use systemctl + return { + "success": False, + "error": "Service restart via systemctl not available in Docker mode", + } + + services_to_restart = [ + "wrolpi-api", + "wrolpi-app", + "wrolpi-kiwix", + "wrolpi-help", + "renderd", + "apache2", + # Controller restarts itself last (scheduled after response) + ] + + results = {} + + for service in services_to_restart: + try: + result = subprocess.run( + ["systemctl", "restart", service], + capture_output=True, + text=True, + timeout=30, + ) + results[service] = { + "success": result.returncode == 0, + "error": result.stderr.strip() if result.returncode != 0 else None, + } + except subprocess.TimeoutExpired: + results[service] = {"success": False, "error": "Timeout"} + except FileNotFoundError: + results[service] = {"success": False, "error": "systemctl not found"} + except Exception as e: + results[service] = {"success": False, "error": str(e)} + + # Schedule Controller self-restart after response is sent + loop = asyncio.get_event_loop() + loop.call_later(1.0, _restart_self) + + results["wrolpi-controller"] = {"success": True, "pending": True} + return {"success": True, "services": results} + + +def _restart_self(): + """Restart the Controller service. Called after response is sent.""" + try: + subprocess.Popen(["systemctl", "restart", "wrolpi-controller"]) + except Exception: + # If we can't restart ourselves, not much we can do + pass diff --git a/controller/lib/config.py b/controller/lib/config.py new file mode 100644 index 00000000..d661c498 --- /dev/null +++ b/controller/lib/config.py @@ -0,0 +1,163 @@ +""" +Configuration management for the Controller. + +Controller uses code-defined defaults and optionally loads overrides +from controller.yaml on the WROLPi drive after it's mounted. +""" + +import copy +import os +from pathlib import Path +from typing import Any + +import yaml + +from controller.defaults import DEFAULT_CONFIG + +# Path to config file on the WROLPi drive +CONFIG_PATH_ON_DRIVE = Path("/media/wrolpi/config/controller.yaml") + +# Runtime config - starts as defaults, updated after drive mount +_runtime_config: dict = copy.deepcopy(DEFAULT_CONFIG) + + +def get_config() -> dict: + """Get the current runtime configuration.""" + return _runtime_config + + +def get_config_value(key: str, default: Any = None) -> Any: + """Get a specific config value by dot-notation key (e.g., 'drives.auto_mount').""" + keys = key.split(".") + value = _runtime_config + for k in keys: + if isinstance(value, dict) and k in value: + value = value[k] + else: + return default + return value + + +def is_docker_mode() -> bool: + """Check if running in Docker container.""" + return os.environ.get("DOCKERIZED", "").lower() == "true" + + +# Raspberry Pi detection +RPI_DEVICE_PATH = Path('/proc/device-tree/model') +_RPI_DEVICE_MODEL_CONTENTS = RPI_DEVICE_PATH.read_text() if RPI_DEVICE_PATH.is_file() else None + + +def is_rpi4() -> bool: + """Check if running on Raspberry Pi 4.""" + return 'Raspberry Pi 4' in _RPI_DEVICE_MODEL_CONTENTS if _RPI_DEVICE_MODEL_CONTENTS else False + + +def is_rpi5() -> bool: + """Check if running on Raspberry Pi 5.""" + return 'Raspberry Pi 5' in _RPI_DEVICE_MODEL_CONTENTS if _RPI_DEVICE_MODEL_CONTENTS else False + + +def is_rpi() -> bool: + """Check if running on any Raspberry Pi.""" + return is_rpi4() or is_rpi5() + + +def get_media_directory() -> Path: + """Get the media directory path.""" + return Path(os.environ.get("MEDIA_DIRECTORY", "/media/wrolpi")) + + +def is_primary_drive_mounted() -> bool: + """Check if the primary WROLPi drive is mounted.""" + return Path("/media/wrolpi/config").exists() + + +def reload_config_from_drive() -> bool: + """ + Load config from the mounted WROLPi drive and merge with defaults. + Called after the primary drive is mounted. + + Returns True if config was loaded, False if no config file found. + """ + global _runtime_config + + if not CONFIG_PATH_ON_DRIVE.exists(): + # No config file on drive - keep defaults + return False + + try: + with open(CONFIG_PATH_ON_DRIVE) as f: + drive_config = yaml.safe_load(f) or {} + except (IOError, yaml.YAMLError) as e: + # Failed to load - keep defaults + print(f"Warning: Failed to load controller.yaml: {e}") + return False + + # Deep merge: drive_config overrides defaults + _runtime_config = _deep_merge(copy.deepcopy(DEFAULT_CONFIG), drive_config) + return True + + +def save_config() -> None: + """ + Save current runtime config to the WROLPi drive. + Only saves the diff from defaults to keep the file clean. + """ + if not is_primary_drive_mounted(): + raise RuntimeError("Cannot save config: primary drive not mounted") + + # Calculate diff from defaults + diff = _get_config_diff(_runtime_config, DEFAULT_CONFIG) + + if not diff: + # Nothing to save - config matches defaults + return + + CONFIG_PATH_ON_DRIVE.parent.mkdir(parents=True, exist_ok=True) + with open(CONFIG_PATH_ON_DRIVE, "w") as f: + yaml.dump(diff, f, default_flow_style=False, sort_keys=False) + + +def update_config(key: str, value: Any) -> None: + """ + Update a config value by dot-notation key. + Does not automatically save - call save_config() after updates. + """ + keys = key.split(".") + config = _runtime_config + + # Navigate to parent of target key + for k in keys[:-1]: + if k not in config: + config[k] = {} + config = config[k] + + # Set the value + config[keys[-1]] = value + + +def _deep_merge(base: dict, override: dict) -> dict: + """Deep merge two dicts. Override values take precedence.""" + result = base.copy() + for key, value in override.items(): + if key in result and isinstance(result[key], dict) and isinstance(value, dict): + result[key] = _deep_merge(result[key], value) + else: + result[key] = value + return result + + +def _get_config_diff(current: dict, default: dict) -> dict: + """Get the difference between current config and defaults.""" + diff = {} + for key, value in current.items(): + if key not in default: + diff[key] = value + elif isinstance(value, dict) and isinstance(default.get(key), dict): + nested_diff = _get_config_diff(value, default[key]) + if nested_diff: + diff[key] = nested_diff + elif value != default.get(key): + diff[key] = value + return diff diff --git a/controller/lib/disks.py b/controller/lib/disks.py new file mode 100644 index 00000000..47f3b23b --- /dev/null +++ b/controller/lib/disks.py @@ -0,0 +1,276 @@ +""" +Disk utilities for WROLPi Controller. + +Provides disk detection, mounting, and WROLPi drive identification. +""" + +import json +import subprocess +import tempfile +from dataclasses import dataclass +from pathlib import Path +from typing import Optional + +from controller.lib.config import get_config, is_docker_mode + +SUPPORTED_FILESYSTEMS = {"ext4", "btrfs", "vfat", "exfat"} + + +@dataclass +class BlockDevice: + """Represents a block device/partition.""" + name: str + path: str + size: str + fstype: Optional[str] + mountpoint: Optional[str] + label: Optional[str] + uuid: Optional[str] + model: Optional[str] + is_wrolpi_drive: bool = False + + +def get_block_devices() -> list[BlockDevice]: + """ + Get all block devices using lsblk. + + Returns: + list of BlockDevice objects + """ + if is_docker_mode(): + return [] + + cmd = ["lsblk", "-J", "-o", "NAME,PATH,SIZE,TYPE,FSTYPE,MOUNTPOINT,LABEL,UUID,MODEL"] + + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=10) + if result.returncode != 0: + return [] + + data = json.loads(result.stdout) + devices = [] + + def process_device(dev: dict, parent_model: str = None): + if dev.get("type") == "part" and dev.get("fstype") in SUPPORTED_FILESYSTEMS: + devices.append(BlockDevice( + name=dev["name"], + path=dev.get("path", f"/dev/{dev['name']}"), + size=dev.get("size", ""), + fstype=dev.get("fstype"), + mountpoint=dev.get("mountpoint"), + label=dev.get("label"), + uuid=dev.get("uuid"), + model=parent_model, + )) + # Process children (partitions) + for child in dev.get("children", []): + process_device(child, dev.get("model")) + + for device in data.get("blockdevices", []): + process_device(device) + + return devices + + except (subprocess.SubprocessError, json.JSONDecodeError): + return [] + + +def get_uuid(device: str) -> Optional[str]: + """ + Get UUID for a device using blkid. + + Args: + device: Device path (e.g., /dev/sda1) + + Returns: + UUID string or None + """ + try: + cmd = ["blkid", "-s", "UUID", "-o", "value", device] + result = subprocess.run(cmd, capture_output=True, text=True, timeout=5) + if result.returncode == 0: + return result.stdout.strip() + except subprocess.SubprocessError: + pass + return None + + +def validate_mount_point(mount_point: str) -> None: + """ + Validate that mount point is safe. + + Args: + mount_point: Path to validate + + Raises: + ValueError: If mount point is not allowed + """ + import os + + if not mount_point.startswith("/media"): + raise ValueError( + f"Mount point must be under /media, got: {mount_point}. " + "Mounting to other locations is not allowed." + ) + + # Prevent path traversal + normalized = os.path.normpath(mount_point) + if not normalized.startswith("/media"): + raise ValueError(f"Invalid mount point after normalization: {normalized}") + + +def mount_drive( + device: str, + mount_point: str, + fstype: Optional[str] = None, + options: str = "defaults", +) -> dict: + """ + Mount a drive. + + Args: + device: Device path or UUID + mount_point: Where to mount + fstype: Filesystem type (auto-detected if not specified) + options: Mount options + + Returns: + dict with success status + """ + if is_docker_mode(): + return {"success": False, "error": "Not available in Docker mode"} + + # Validate mount point + try: + validate_mount_point(mount_point) + except ValueError as e: + return {"success": False, "error": str(e)} + + # Create mount point if needed + Path(mount_point).mkdir(parents=True, exist_ok=True) + + # Build mount command + cmd = ["mount"] + if fstype: + cmd.extend(["-t", fstype]) + if options: + cmd.extend(["-o", options]) + cmd.extend([device, mount_point]) + + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) + if result.returncode == 0: + return {"success": True, "mount_point": mount_point} + else: + return {"success": False, "error": result.stderr.strip()} + except subprocess.TimeoutExpired: + return {"success": False, "error": "Mount timed out"} + + +def unmount_drive(mount_point: str, lazy: bool = False) -> dict: + """ + Unmount a drive with safety checks. + + Args: + mount_point: Path to unmount + lazy: Use lazy unmount if busy + + Returns: + dict with success status + """ + if is_docker_mode(): + return {"success": False, "error": "Not available in Docker mode"} + + # Safety rule: Don't unmount /media/wrolpi if no drives configured + if mount_point == "/media/wrolpi": + config = get_config() + configured_mounts = config.get("drives", {}).get("mounts", []) + + if not configured_mounts: + return { + "success": False, + "error": "Cannot unmount /media/wrolpi: No drives are configured. " + "This prevents accidental data loss.", + } + + # Check if this is the primary drive + primary_mount = next( + (m for m in configured_mounts if m.get("is_primary")), + None + ) + if primary_mount: + return { + "success": False, + "error": "Cannot unmount /media/wrolpi: This is the primary WROLPi drive.", + } + + # Check if mount is busy + if is_mount_busy(mount_point) and not lazy: + return { + "success": False, + "error": f"Mount {mount_point} is busy. Use lazy unmount or stop services first.", + } + + # Unmount + cmd = ["umount"] + if lazy: + cmd.append("-l") + cmd.append(mount_point) + + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) + if result.returncode == 0: + return {"success": True, "mount_point": mount_point} + else: + return {"success": False, "error": result.stderr.strip()} + except subprocess.TimeoutExpired: + return {"success": False, "error": "Unmount timed out"} + + +def is_mount_busy(mount_point: str) -> bool: + """Check if any processes are using the mount point.""" + try: + result = subprocess.run( + ["lsof", "+D", mount_point], + capture_output=True, + text=True, + timeout=10, + ) + return result.returncode == 0 and result.stdout.strip() + except (subprocess.SubprocessError, FileNotFoundError): + return False + + +def get_mounts() -> list[dict]: + """ + Get current mounts under /media. + + Returns: + list of mount info dicts + """ + try: + result = subprocess.run( + ["findmnt", "-J", "-l", "-o", "TARGET,SOURCE,FSTYPE,OPTIONS"], + capture_output=True, + text=True, + timeout=10, + ) + if result.returncode != 0: + return [] + + data = json.loads(result.stdout) + mounts = [] + + for fs in data.get("filesystems", []): + target = fs.get("target", "") + if target.startswith("/media"): + mounts.append({ + "mount_point": target, + "device": fs.get("source"), + "fstype": fs.get("fstype"), + "options": fs.get("options"), + }) + + return mounts + except (subprocess.SubprocessError, json.JSONDecodeError): + return [] diff --git a/controller/lib/docker_services.py b/controller/lib/docker_services.py new file mode 100644 index 00000000..3d92a3ef --- /dev/null +++ b/controller/lib/docker_services.py @@ -0,0 +1,240 @@ +""" +Docker container management for WROLPi Controller. + +Used when running in Docker mode instead of systemd. +""" + +import os + +from controller.lib.config import is_docker_mode + +# Import docker only if available (optional dependency) +try: + import docker + from docker.errors import NotFound, APIError + + DOCKER_AVAILABLE = True +except ImportError: + DOCKER_AVAILABLE = False + docker = None + NotFound = Exception + APIError = Exception + +# Container name prefix (from docker-compose project) +CONTAINER_PREFIX = os.environ.get("COMPOSE_PROJECT_NAME", "wrolpi") + +# Containers that should not have a "View" button (non-HTTP services) +NON_VIEWABLE_CONTAINERS = {"db", "postgres", "postgresql", "redis", "memcached"} + +# Containers that should use HTTPS (in addition to *_https suffix detection) +HTTPS_CONTAINERS = {"web"} + + +def can_manage_containers() -> bool: + """Check if we can manage Docker containers.""" + if not is_docker_mode(): + return False + if not DOCKER_AVAILABLE: + return False + if not os.path.exists("/var/run/docker.sock"): + return False + return True + + +def _get_client(): + """Get Docker client.""" + if not DOCKER_AVAILABLE: + raise RuntimeError("Docker library not installed") + return docker.from_env() + + +def _get_container_name(service_name: str) -> str: + """Convert service name to container name.""" + # docker-compose names: {project}-{service}-{number} + return f"{CONTAINER_PREFIX}-{service_name}-1" + + +def get_container_status(name: str) -> dict: + """ + Get status of a Docker container. + + Args: + name: Service name + + Returns: + dict with status info + """ + if not can_manage_containers(): + return { + "name": name, + "status": "unknown", + "available": False, + "reason": "Docker management not available", + } + + try: + client = _get_client() + container_name = _get_container_name(name) + container = client.containers.get(container_name) + + # Map Docker status to our status + docker_status = container.status + if docker_status == "running": + status = "running" + elif docker_status == "exited": + status = "stopped" + elif docker_status == "restarting": + status = "restarting" + else: + status = docker_status + + # Get port info from container + ports = container.attrs.get("NetworkSettings", {}).get("Ports", {}) + port = None + for container_port, host_bindings in ports.items(): + if host_bindings: + port = int(host_bindings[0].get("HostPort", 0)) + break + + return { + "name": name, + "container_name": container_name, + "status": status, + "docker_status": docker_status, + "port": port, + "available": True, + "viewable": name not in NON_VIEWABLE_CONTAINERS, + "use_https": name.endswith("_https") or name in HTTPS_CONTAINERS, + } + except NotFound: + return { + "name": name, + "status": "not_found", + "available": True, + } + except Exception as e: + return { + "name": name, + "status": "error", + "error": str(e), + "available": False, + } + + +def get_all_containers_status() -> list[dict]: + """ + Get status of all WROLPi containers. + + Returns: + list of container status dicts + """ + if not can_manage_containers(): + return [] + + try: + client = _get_client() + containers = client.containers.list(all=True) + + results = [] + for container in containers: + if container.name.startswith(CONTAINER_PREFIX): + # Extract service name from container name + parts = container.name.split("-") + if len(parts) >= 2: + service_name = parts[1] + else: + service_name = container.name + + # Get port info from container + ports = container.attrs.get("NetworkSettings", {}).get("Ports", {}) + port = None + for container_port, host_bindings in ports.items(): + if host_bindings: + port = int(host_bindings[0].get("HostPort", 0)) + break + + results.append({ + "name": service_name, + "container_name": container.name, + "status": "running" if container.status == "running" else "stopped", + "docker_status": container.status, + "port": port, + "viewable": service_name not in NON_VIEWABLE_CONTAINERS, + "use_https": service_name.endswith("_https") or service_name in HTTPS_CONTAINERS, + }) + + return results + except Exception as e: + return [{"error": str(e)}] + + +def start_container(name: str) -> dict: + """Start a Docker container.""" + if not can_manage_containers(): + return {"success": False, "error": "Docker management not available"} + + try: + client = _get_client() + container_name = _get_container_name(name) + container = client.containers.get(container_name) + container.start() + return {"success": True, "service": name, "action": "start"} + except NotFound: + return {"success": False, "error": f"Container not found: {name}"} + except Exception as e: + return {"success": False, "error": str(e)} + + +def stop_container(name: str) -> dict: + """Stop a Docker container.""" + if not can_manage_containers(): + return {"success": False, "error": "Docker management not available"} + + try: + client = _get_client() + container_name = _get_container_name(name) + container = client.containers.get(container_name) + container.stop(timeout=30) + return {"success": True, "service": name, "action": "stop"} + except NotFound: + return {"success": False, "error": f"Container not found: {name}"} + except Exception as e: + return {"success": False, "error": str(e)} + + +def restart_container(name: str) -> dict: + """Restart a Docker container.""" + if not can_manage_containers(): + return {"success": False, "error": "Docker management not available"} + + try: + client = _get_client() + container_name = _get_container_name(name) + container = client.containers.get(container_name) + container.restart(timeout=30) + return {"success": True, "service": name, "action": "restart"} + except NotFound: + return {"success": False, "error": f"Container not found: {name}"} + except Exception as e: + return {"success": False, "error": str(e)} + + +def get_container_logs(name: str, lines: int = 100) -> dict: + """Get logs from a Docker container.""" + if not can_manage_containers(): + return {"error": "Docker management not available"} + + try: + client = _get_client() + container_name = _get_container_name(name) + container = client.containers.get(container_name) + logs = container.logs(tail=lines).decode("utf-8") + return { + "service": name, + "lines": lines, + "logs": logs, + } + except NotFound: + return {"error": f"Container not found: {name}"} + except Exception as e: + return {"error": str(e)} diff --git a/controller/lib/fstab.py b/controller/lib/fstab.py new file mode 100644 index 00000000..e97e3255 --- /dev/null +++ b/controller/lib/fstab.py @@ -0,0 +1,198 @@ +""" +fstab management for WROLPi Controller. + +Provides persistent mount configuration via /etc/fstab. +""" + +import shutil +import subprocess +from datetime import datetime +from pathlib import Path + +from controller.lib.disks import get_uuid, validate_mount_point +from controller.lib.wrol_mode import require_normal_mode + +FSTAB_PATH = Path("/etc/fstab") + + +def backup_fstab() -> Path: + """Create a timestamped backup of fstab.""" + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + backup_path = Path(f"/etc/fstab.backup.{timestamp}") + shutil.copy(FSTAB_PATH, backup_path) + + # Also keep a known backup location for easy recovery + shutil.copy(FSTAB_PATH, Path("/etc/fstab.wrolpi.backup")) + + return backup_path + + +def parse_fstab() -> list[dict]: + """Parse fstab into a list of entries.""" + entries = [] + + with open(FSTAB_PATH, "r") as f: + for line_num, line in enumerate(f, 1): + stripped = line.strip() + + # Preserve comments and blank lines + if not stripped or stripped.startswith("#"): + entries.append({ + "type": "comment", + "line": line, + "line_num": line_num, + }) + continue + + parts = stripped.split() + if len(parts) >= 4: + entries.append({ + "type": "mount", + "device": parts[0], + "mount_point": parts[1], + "fstype": parts[2], + "options": parts[3], + "dump": parts[4] if len(parts) > 4 else "0", + "pass": parts[5] if len(parts) > 5 else "0", + "line_num": line_num, + }) + + return entries + + +def add_fstab_entry( + device: str, + mount_point: str, + fstype: str, + options: str = "defaults,nofail,x-systemd.device-timeout=10s", + use_uuid: bool = True, +) -> dict: + """ + Add or update an fstab entry for persistent mounting. + + Args: + device: Device path (will be converted to UUID if use_uuid=True) + mount_point: Where to mount + fstype: Filesystem type + options: Mount options + use_uuid: Convert device to UUID (recommended) + + Returns: + dict with success status + """ + # WROL Mode check + try: + require_normal_mode("add fstab entry") + except PermissionError as e: + return {"success": False, "error": str(e)} + + # Validate mount point + try: + validate_mount_point(mount_point) + except ValueError as e: + return {"success": False, "error": str(e)} + + # Convert to UUID if requested + if use_uuid and device.startswith("/dev/"): + uuid = get_uuid(device) + if uuid: + device_spec = f"UUID={uuid}" + else: + device_spec = device + else: + device_spec = device + + # Backup fstab + backup_fstab() + + # Read current fstab + entries = parse_fstab() + + # Remove any existing entry for this mount point + entries = [e for e in entries if e.get("mount_point") != mount_point] + + # Build new entry line + new_entry = f"{device_spec} {mount_point} {fstype} {options} 0 2\n" + + # Write updated fstab + with open(FSTAB_PATH, "w") as f: + for entry in entries: + if entry["type"] == "comment": + f.write(entry["line"]) + else: + f.write( + f"{entry['device']} {entry['mount_point']} {entry['fstype']} " + f"{entry['options']} {entry['dump']} {entry['pass']}\n" + ) + # Add new entry with comment + f.write(f"# WROLPi managed mount - added {datetime.now().isoformat()}\n") + f.write(new_entry) + + # Reload systemd + subprocess.run(["systemctl", "daemon-reload"], capture_output=True) + + return { + "success": True, + "device": device_spec, + "mount_point": mount_point, + } + + +def remove_fstab_entry(mount_point: str) -> dict: + """ + Remove an fstab entry by mount point. + + Args: + mount_point: Mount point to remove + + Returns: + dict with success status + """ + # WROL Mode check + try: + require_normal_mode("remove fstab entry") + except PermissionError as e: + return {"success": False, "error": str(e)} + + # Validate mount point + try: + validate_mount_point(mount_point) + except ValueError as e: + return {"success": False, "error": str(e)} + + # Backup fstab + backup_fstab() + + entries = parse_fstab() + original_count = len([e for e in entries if e["type"] == "mount"]) + + # Remove entry + entries = [e for e in entries if e.get("mount_point") != mount_point] + new_count = len([e for e in entries if e["type"] == "mount"]) + + if original_count == new_count: + return {"success": False, "error": f"No entry found for {mount_point}"} + + # Write updated fstab + with open(FSTAB_PATH, "w") as f: + for entry in entries: + if entry["type"] == "comment": + f.write(entry["line"]) + else: + f.write( + f"{entry['device']} {entry['mount_point']} {entry['fstype']} " + f"{entry['options']} {entry['dump']} {entry['pass']}\n" + ) + + subprocess.run(["systemctl", "daemon-reload"], capture_output=True) + + return {"success": True, "mount_point": mount_point} + + +def get_wrolpi_fstab_entries() -> list[dict]: + """Get all fstab entries for mounts under /media/wrolpi.""" + entries = parse_fstab() + return [ + e for e in entries + if e["type"] == "mount" and e["mount_point"].startswith("/media/wrolpi") + ] diff --git a/controller/lib/smart.py b/controller/lib/smart.py new file mode 100644 index 00000000..10418e88 --- /dev/null +++ b/controller/lib/smart.py @@ -0,0 +1,83 @@ +""" +SMART disk health monitoring for WROLPi Controller. +""" + +from typing import Optional + +# Import pySMART only if available +try: + from pySMART import DeviceList, Device + + SMART_AVAILABLE = True +except ImportError: + SMART_AVAILABLE = False + +from controller.lib.config import is_docker_mode + + +def is_smart_available() -> bool: + """Check if SMART monitoring is available.""" + if is_docker_mode(): + return False + return SMART_AVAILABLE + + +def get_all_smart_status() -> list[dict]: + """ + Get SMART status for all drives. + + Returns: + list of SMART status dicts + """ + if not is_smart_available(): + return [] + + try: + devices = DeviceList() + results = [] + + for device in devices.devices: + results.append(_get_device_smart(device)) + + return results + except Exception: + return [] + + +def _get_device_smart(device) -> dict: + """Extract SMART data from a pySMART device.""" + return { + "device": device.name, + "path": f"/dev/{device.name}", + "model": device.model, + "serial": device.serial, + "capacity": device.capacity, + "assessment": device.assessment, # PASS, FAIL, WARN + "temperature": _get_temperature(device), + "power_on_hours": _get_attribute(device, "Power_On_Hours"), + "reallocated_sectors": _get_attribute(device, "Reallocated_Sector_Ct"), + "pending_sectors": _get_attribute(device, "Current_Pending_Sector"), + "smart_enabled": device.smart_enabled, + } + + +def _get_temperature(device) -> Optional[int]: + """Get drive temperature from SMART attributes.""" + temp = _get_attribute(device, "Temperature_Celsius") + if temp is None: + temp = _get_attribute(device, "Airflow_Temperature_Cel") + return temp + + +def _get_attribute(device, name: str) -> Optional[int]: + """Get a specific SMART attribute value.""" + if not hasattr(device, 'attributes') or device.attributes is None: + return None + + for attr in device.attributes: + if attr and attr.name == name: + try: + return int(attr.raw) + except (ValueError, TypeError): + return None + return None diff --git a/controller/lib/status.py b/controller/lib/status.py new file mode 100644 index 00000000..6294ff5b --- /dev/null +++ b/controller/lib/status.py @@ -0,0 +1,362 @@ +""" +System status collection for WROLPi Controller. + +Provides functions to collect CPU, memory, load, disk, network, and power +status information. This is the authoritative source for system hardware status. + +The status format matches the main WROLPi API's format so the React app +can seamlessly switch between data sources. +""" + +import statistics +from pathlib import Path +from typing import Optional + +import psutil + +# CPU frequency paths +MIN_FREQUENCY_PATH = Path('/sys/devices/system/cpu/cpu0/cpufreq/cpuinfo_min_freq') +MAX_FREQUENCY_PATH = Path('/sys/devices/system/cpu/cpu0/cpufreq/cpuinfo_max_freq') +CUR_FREQUENCY_PATH = Path('/sys/devices/system/cpu/cpu0/cpufreq/scaling_cur_freq') + + +def get_cpu_status() -> dict: + """ + Get CPU usage, frequency, temperature, and core count. + + Returns format compatible with main API's cpu_stats: + dict with keys: cores, cur_frequency, max_frequency, min_frequency, + percent, temperature, high_temperature, critical_temperature + """ + cpu_percent = int(psutil.cpu_percent(interval=0.1)) + cpu_count = psutil.cpu_count() + + # Get frequency from sys files (more reliable than psutil on RPi) + min_frequency = max_frequency = cur_frequency = None + try: + if MIN_FREQUENCY_PATH.exists(): + min_frequency = int(MIN_FREQUENCY_PATH.read_text()) + if MAX_FREQUENCY_PATH.exists(): + max_frequency = int(MAX_FREQUENCY_PATH.read_text()) + if CUR_FREQUENCY_PATH.exists(): + cur_frequency = int(CUR_FREQUENCY_PATH.read_text()) + except (ValueError, IOError): + pass + + # Get temperature using psutil + temperature = high_temperature = critical_temperature = None + try: + temp = psutil.sensors_temperatures() + if temp: + name = 'coretemp' if 'coretemp' in temp else list(temp.keys())[0] + temperatures = temp.get(name) + if temperatures: + temperature = statistics.median([i.current or 0 for i in temperatures]) + high_temperature = statistics.median([i.high or 0 for i in temperatures]) + critical_temperature = statistics.median([i.critical or 0 for i in temperatures]) + + # Temperatures may not exist. + high_temperature = high_temperature or 60 + critical_temperature = critical_temperature or 95 + + if high_temperature and high_temperature == critical_temperature: + # Display yellow warning before red warning. + high_temperature = critical_temperature - 25 + except Exception: + pass + + return { + "cores": cpu_count, + "cur_frequency": cur_frequency - min_frequency if cur_frequency and min_frequency else None, + "max_frequency": max_frequency, + "min_frequency": min_frequency, + "percent": cpu_percent, + "temperature": int(temperature) if temperature else None, + "high_temperature": int(high_temperature) if high_temperature else None, + "critical_temperature": int(critical_temperature) if critical_temperature else None, + } + + +def get_memory_status() -> dict: + """ + Get memory usage statistics. + + Returns format compatible with main API's memory_stats: + dict with keys: total, used, free, cached + """ + mem = psutil.virtual_memory() + return { + "total": mem.total, + "used": mem.used, + "free": mem.free, + "cached": getattr(mem, 'cached', 0), # cached may not exist on all platforms (e.g., macOS) + } + + +def get_load_status() -> dict: + """ + Get system load averages. + + Returns format compatible with main API's load_stats: + dict with keys: minute_1, minute_5, minute_15 + """ + load1, load5, load15 = psutil.getloadavg() + return { + "minute_1": str(round(load1, 2)), + "minute_5": str(round(load5, 2)), + "minute_15": str(round(load15, 2)), + } + + +IGNORED_DRIVES = ['/boot', '/etc'] +VALID_FORMATS = {'btrfs', 'ext4', 'ext3', 'ext2', 'vfat', 'exfat', 'apfs'} + + +def get_drive_status() -> list[dict]: + """ + Get disk usage for mounted filesystems. + + Returns format compatible with main API's drives_stats: + list of dicts with keys: mount, percent, size, used + """ + drives = [] + seen_devices = set() + + for partition in psutil.disk_partitions(): + # Skip ignored directories + if any(partition.mountpoint.startswith(i) for i in IGNORED_DRIVES): + continue + # Skip non-standard filesystems + if partition.fstype not in VALID_FORMATS: + continue + # Skip duplicate devices (only use first partition) + if partition.device in seen_devices: + continue + seen_devices.add(partition.device) + + try: + usage = psutil.disk_usage(partition.mountpoint) + drives.append({ + "mount": partition.mountpoint, + "percent": int(usage.percent), + "size": int(usage.total), + "used": int(usage.used), + }) + except (PermissionError, OSError): + pass + + return sorted(drives, key=lambda i: i["mount"]) + + +def get_primary_drive_status() -> Optional[dict]: + """ + Get status of the primary WROLPi drive (/media/wrolpi). + + Returns: + dict with drive status, or None if not mounted + """ + for drive in get_drive_status(): + if drive["mount"] == "/media/wrolpi": + return drive + return None + + +IGNORED_NIC_NAMES = {'lo', 'veth', 'tun', 'docker', 'br-'} + + +def get_network_status() -> dict: + """ + Get network interface statistics. + + Returns format compatible with main API's nic_bandwidth_stats: + dict of interface_name -> stats + """ + from datetime import datetime + timestamp = datetime.now().timestamp() + counters = dict() + if_stats = psutil.net_if_stats() + + for name, counter in sorted(psutil.net_io_counters(pernic=True, nowrap=True).items(), key=lambda i: i[0]): + if name in IGNORED_NIC_NAMES: + continue + stats = if_stats.get(name) + counters[name] = { + 'name': name, + 'now': timestamp, + 'bytes_recv': counter.bytes_recv, + 'bytes_sent': counter.bytes_sent, + 'speed': int(stats.speed) if stats else 0, + # Initialize bandwidth per second to 0 (requires historical data to calculate) + 'bytes_recv_ps': 0, + 'bytes_sent_ps': 0, + } + + return counters + + +def get_power_status() -> dict: + """ + Get power/voltage status (Raspberry Pi specific). + + Returns format compatible with main API's power_stats: + dict with keys: under_voltage, over_current + """ + import subprocess + + result = { + "under_voltage": False, + "over_current": False, + } + + try: + # Check via dmesg (same method as main API) + proc = subprocess.run( + ['dmesg'], + capture_output=True, + timeout=5, + ) + if proc.returncode == 0 and proc.stdout: + result["under_voltage"] = b' Undervoltage detected' in proc.stdout + result["over_current"] = b' over-current change ' in proc.stdout + except Exception: + pass + + return result + + +def get_iostat_status() -> dict: + """ + Get IO statistics using iostat command. + + Returns format compatible with main API's iostat_stats: + dict with keys: percent_idle, percent_iowait, percent_nice, + percent_steal, percent_system, percent_user + """ + import subprocess + + result = { + "percent_idle": None, + "percent_iowait": None, + "percent_nice": None, + "percent_steal": None, + "percent_system": None, + "percent_user": None, + } + + try: + # Run iostat with 1 second interval, 2 counts (use second measurement) + proc = subprocess.run( + ['iostat', '1', '2'], + capture_output=True, + timeout=10, + ) + if proc.returncode == 0 and proc.stdout: + try: + import jc + iostat_stats = jc.parse('iostat', proc.stdout.decode(), quiet=True) + if iostat_stats: + # Filter CPU stats and get the second one (first is since boot) + cpu_stats_list = [s for s in iostat_stats if s.get('type') == 'cpu'] + if len(cpu_stats_list) >= 2: + cpu_stats = cpu_stats_list[1] + result = { + "percent_idle": cpu_stats.get('percent_idle'), + "percent_iowait": cpu_stats.get('percent_iowait'), + "percent_nice": cpu_stats.get('percent_nice'), + "percent_steal": cpu_stats.get('percent_steal'), + "percent_system": cpu_stats.get('percent_system'), + "percent_user": cpu_stats.get('percent_user'), + } + except ImportError: + # jc not available, parse manually + pass + except Exception: + pass + + return result + + +IGNORED_PROCESS_COMMANDS = {''} + + +def get_processes_status() -> list[dict]: + """ + Get top CPU-consuming processes. + + Returns format compatible with main API's processes_stats: + list of dicts with keys: pid, percent_cpu, percent_mem, command + """ + processes = [] + + try: + # Get processes sorted by CPU usage + for proc in psutil.process_iter(['pid', 'name', 'cpu_percent', 'memory_percent', 'cmdline']): + try: + info = proc.info + cpu_percent = info.get('cpu_percent', 0) + + # Only include processes using > 1% CPU + if cpu_percent and cpu_percent > 1: + cmdline = info.get('cmdline') or [info.get('name', '')] + command = ' '.join(cmdline) if cmdline else info.get('name', '') + + # Skip ignored commands + if any(ignored in command for ignored in IGNORED_PROCESS_COMMANDS): + continue + + processes.append({ + "pid": info.get('pid'), + "percent_cpu": int(cpu_percent), + "percent_mem": int(info.get('memory_percent', 0)), + "command": command[:512], # Limit command length + }) + except (psutil.NoSuchProcess, psutil.AccessDenied): + pass + + # Sort by CPU usage and return top 10 + processes = sorted(processes, key=lambda x: x['percent_cpu'], reverse=True)[:10] + + except Exception: + pass + + return processes + + +IGNORED_DISK_NAMES = ('loop', 'ram') + + +def get_disk_bandwidth_status() -> dict: + """ + Get disk IO bandwidth statistics. + + Returns format compatible with main API's disk_bandwidth_stats: + dict of disk_name -> stats with bytes_read, bytes_write, etc. + """ + from datetime import datetime + timestamp = datetime.now().timestamp() + counters = dict() + + try: + for name, counter in sorted(psutil.disk_io_counters(perdisk=True).items(), key=lambda i: i[0]): + # Skip partitions (only report main disks) + if any(name.startswith(i) for i in counters): + continue + # Skip ignored disk types + if any(name.startswith(i) for i in IGNORED_DISK_NAMES): + continue + + counters[name] = { + 'name': name, + 'now': timestamp, + 'bytes_read': counter.read_bytes, + 'bytes_write': counter.write_bytes, + # Initialize per-second rates to 0 (requires historical data) + 'bytes_read_ps': 0, + 'bytes_write_ps': 0, + 'max_read_ps': 500_000, # Default max for display scaling + 'max_write_ps': 500_000, + } + except Exception: + pass + + return counters diff --git a/controller/lib/status_worker.py b/controller/lib/status_worker.py new file mode 100644 index 00000000..fadf3419 --- /dev/null +++ b/controller/lib/status_worker.py @@ -0,0 +1,129 @@ +""" +Background status worker for WROLPi Controller. + +Periodically collects all system status and caches it in app.state +for O(1) response times on API endpoints. +""" + +import asyncio +import multiprocessing +from datetime import datetime +from typing import Optional + +from controller.lib.status import ( + get_cpu_status, + get_disk_bandwidth_status, + get_drive_status, + get_iostat_status, + get_load_status, + get_memory_status, + get_network_status, + get_power_status, + get_processes_status, +) + +# Type alias for cached status dict +CachedStatus = dict + + +async def collect_all_status() -> CachedStatus: + """ + Collect all status information concurrently. + + Returns a dict with all status types. Individual failures return None + for that stat type rather than failing the entire collection. + """ + # Run all status collections concurrently using asyncio.to_thread + # since the status functions are synchronous (use psutil) + cpu, memory, load, drives, network, power, iostat, processes, disk_bandwidth = await asyncio.gather( + asyncio.to_thread(get_cpu_status), + asyncio.to_thread(get_memory_status), + asyncio.to_thread(get_load_status), + asyncio.to_thread(get_drive_status), + asyncio.to_thread(get_network_status), + asyncio.to_thread(get_power_status), + asyncio.to_thread(get_iostat_status), + asyncio.to_thread(get_processes_status), + asyncio.to_thread(get_disk_bandwidth_status), + return_exceptions=True, + ) + + # Handle individual failures gracefully - return None for failed stats + status = { + "cpu_stats": cpu if not isinstance(cpu, Exception) else None, + "memory_stats": memory if not isinstance(memory, Exception) else None, + "load_stats": load if not isinstance(load, Exception) else None, + "drives_stats": drives if not isinstance(drives, Exception) else [], + "nic_bandwidth_stats": network if not isinstance(network, Exception) else {}, + "power_stats": power if not isinstance(power, Exception) else None, + "iostat_stats": iostat if not isinstance(iostat, Exception) else None, + "processes_stats": processes if not isinstance(processes, Exception) else [], + "disk_bandwidth_stats": disk_bandwidth if not isinstance(disk_bandwidth, Exception) else {}, + "last_status": datetime.now().isoformat(), + } + + return status + + +def get_adaptive_sleep(load_stats: Optional[dict], base_sleep: float = 5.0) -> float: + """ + Calculate adaptive sleep time based on system load. + + If system is under heavy load (load_1 > cpu_count), increase sleep time + to reduce overhead on the system. + + Args: + load_stats: Dict with minute_1 key (string), or None + base_sleep: Base sleep interval in seconds + + Returns: + Sleep time in seconds (may be > base_sleep if system is stressed) + """ + if load_stats is None: + return base_sleep + + try: + load_1 = float(load_stats.get("minute_1", 0)) + cpu_count = multiprocessing.cpu_count() + + if load_1 > cpu_count: + # System is stressed, slow down status updates + return (base_sleep * load_1) / cpu_count + except (ValueError, TypeError, ZeroDivisionError): + pass + + return base_sleep + + +async def status_worker_loop(app, base_sleep: float = 5.0): + """ + Background loop that continuously collects status data. + + Runs indefinitely until cancelled. Stores collected status in + app.state.cached_status for fast endpoint access. + + Args: + app: FastAPI application instance + base_sleep: Base sleep interval in seconds (default 5s) + """ + print("Status worker started") + + while True: + try: + # Collect all status data + status = await collect_all_status() + + # Store in app.state (thread-safe for reading in single-worker uvicorn) + app.state.cached_status = status + + # Calculate adaptive sleep based on load + sleep_time = get_adaptive_sleep(status.get("load_stats"), base_sleep) + + except asyncio.CancelledError: + print("Status worker cancelled") + raise + except Exception as e: + print(f"Status worker error: {e}") + sleep_time = base_sleep + + await asyncio.sleep(sleep_time) diff --git a/controller/lib/systemd.py b/controller/lib/systemd.py new file mode 100644 index 00000000..28a6f700 --- /dev/null +++ b/controller/lib/systemd.py @@ -0,0 +1,322 @@ +""" +Systemd service management for WROLPi Controller. + +Provides start/stop/restart/status/logs for systemd services. +""" + +import subprocess +from typing import Optional + +from controller.lib.config import get_config + + +def get_managed_services() -> list[dict]: + """ + Get list of managed services from config. + + Returns: + list of service configs with name, systemd_name, port, etc. + """ + config = get_config() + return config.get("managed_services", []) + + +def get_service_config(name: str) -> Optional[dict]: + """ + Get config for a specific service by name. + + Args: + name: Service name (e.g., "wrolpi-api") + + Returns: + Service config dict or None if not found + """ + for service in get_managed_services(): + if service.get("name") == name: + return service + return None + + +def _run_systemctl(command: str, service: str, timeout: int = 30) -> dict: + """ + Run a systemctl command. + + Args: + command: systemctl command (start, stop, restart, status, etc.) + service: systemd service name + timeout: Command timeout in seconds + + Returns: + dict with success, output, and error fields + """ + try: + result = subprocess.run( + ["systemctl", command, service], + capture_output=True, + text=True, + timeout=timeout, + ) + return { + "success": result.returncode == 0, + "output": result.stdout.strip(), + "error": result.stderr.strip() if result.returncode != 0 else None, + } + except subprocess.TimeoutExpired: + return {"success": False, "output": "", "error": "Command timed out"} + except FileNotFoundError: + return {"success": False, "output": "", "error": "systemctl not found"} + + +def get_service_status(name: str) -> dict: + """ + Get status of a managed service. + + Args: + name: Service name from config + + Returns: + dict with status, active, enabled, etc. + """ + service_config = get_service_config(name) + if not service_config: + return {"error": f"Unknown service: {name}"} + + systemd_name = service_config.get("systemd_name", name) + + try: + # Get active state + active_result = subprocess.run( + ["systemctl", "is-active", systemd_name], + capture_output=True, + text=True, + timeout=5, + ) + is_active = active_result.stdout.strip() + + # Get enabled state + enabled_result = subprocess.run( + ["systemctl", "is-enabled", systemd_name], + capture_output=True, + text=True, + timeout=5, + ) + is_enabled = enabled_result.stdout.strip() + except (subprocess.TimeoutExpired, FileNotFoundError): + return { + "name": name, + "systemd_name": systemd_name, + "status": "unknown", + "active": "unknown", + "enabled": False, + "port": service_config.get("port"), + "viewable": service_config.get("viewable", False), + "view_path": service_config.get("view_path", ""), + "use_https": service_config.get("use_https", False), + "description": service_config.get("description", ""), + "error": "systemctl not available", + } + + # Map to simple status + if is_active == "active": + status = "running" + elif is_active == "activating": + status = "running" # oneshot services show "activating" while running + elif is_active == "inactive": + status = "stopped" + elif is_active == "failed": + status = "failed" + else: + status = "unknown" + + return { + "name": name, + "systemd_name": systemd_name, + "status": status, + "active": is_active, + "enabled": is_enabled == "enabled", + "port": service_config.get("port"), + "viewable": service_config.get("viewable", False), + "view_path": service_config.get("view_path", ""), + "use_https": service_config.get("use_https", False), + "description": service_config.get("description", ""), + } + + +def get_all_services_status() -> list[dict]: + """ + Get status of all managed services. + + Services with show_only_when_running=True are excluded when not running. + + Returns: + list of service status dicts + """ + results = [] + for service in get_managed_services(): + status = get_service_status(service["name"]) + # Skip services with show_only_when_running if not running + if service.get("show_only_when_running") and status.get("status") != "running": + continue + results.append(status) + return results + + +def start_service(name: str) -> dict: + """ + Start a managed service. + + Args: + name: Service name from config + + Returns: + dict with success status + """ + service_config = get_service_config(name) + if not service_config: + return {"success": False, "error": f"Unknown service: {name}"} + + systemd_name = service_config.get("systemd_name", name) + result = _run_systemctl("start", systemd_name) + + return { + "success": result["success"], + "service": name, + "action": "start", + "error": result.get("error"), + } + + +def stop_service(name: str) -> dict: + """ + Stop a managed service. + + Args: + name: Service name from config + + Returns: + dict with success status + """ + service_config = get_service_config(name) + if not service_config: + return {"success": False, "error": f"Unknown service: {name}"} + + systemd_name = service_config.get("systemd_name", name) + result = _run_systemctl("stop", systemd_name) + + return { + "success": result["success"], + "service": name, + "action": "stop", + "error": result.get("error"), + } + + +def restart_service(name: str) -> dict: + """ + Restart a managed service. + + Args: + name: Service name from config + + Returns: + dict with success status + """ + service_config = get_service_config(name) + if not service_config: + return {"success": False, "error": f"Unknown service: {name}"} + + systemd_name = service_config.get("systemd_name", name) + result = _run_systemctl("restart", systemd_name) + + return { + "success": result["success"], + "service": name, + "action": "restart", + "error": result.get("error"), + } + + +def enable_service(name: str) -> dict: + """ + Enable a service to start at boot. + + Args: + name: Service name from config + + Returns: + dict with success status + """ + service_config = get_service_config(name) + if not service_config: + return {"success": False, "error": f"Unknown service: {name}"} + + systemd_name = service_config.get("systemd_name", name) + result = _run_systemctl("enable", systemd_name) + + return { + "success": result["success"], + "service": name, + "action": "enable", + "error": result.get("error"), + } + + +def disable_service(name: str) -> dict: + """ + Disable a service from starting at boot. + + Args: + name: Service name from config + + Returns: + dict with success status + """ + service_config = get_service_config(name) + if not service_config: + return {"success": False, "error": f"Unknown service: {name}"} + + systemd_name = service_config.get("systemd_name", name) + result = _run_systemctl("disable", systemd_name) + + return { + "success": result["success"], + "service": name, + "action": "disable", + "error": result.get("error"), + } + + +def get_service_logs(name: str, lines: int = 100, since: Optional[str] = None) -> dict: + """ + Get logs for a service. + + Args: + name: Service name from config + lines: Number of lines to return + since: Time specification (e.g., "1h", "30m", "2024-01-01") + + Returns: + dict with logs and metadata + """ + service_config = get_service_config(name) + if not service_config: + return {"error": f"Unknown service: {name}"} + + systemd_name = service_config.get("systemd_name", name) + + cmd = ["journalctl", "-u", systemd_name, "-n", str(lines), "--no-pager"] + if since: + cmd.extend(["--since", since]) + + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=10) + return { + "service": name, + "lines": lines, + "since": since, + "logs": result.stdout, + } + except subprocess.TimeoutExpired: + return {"error": "Timeout getting logs"} + except FileNotFoundError: + return {"error": "journalctl not found"} diff --git a/controller/lib/wrol_mode.py b/controller/lib/wrol_mode.py new file mode 100644 index 00000000..d6ac7901 --- /dev/null +++ b/controller/lib/wrol_mode.py @@ -0,0 +1,45 @@ +""" +WROL Mode detection for WROLPi Controller. + +WROL Mode (Without Rule of Law) is an emergency mode that restricts +persistent configuration changes. +""" + +from pathlib import Path + +from controller.lib.config import get_media_directory + + +def get_wrol_mode_flag_path() -> Path: + """Get the path to the WROL Mode flag file.""" + return get_media_directory() / "config" / ".wrol_mode" + + +def is_wrol_mode() -> bool: + """ + Check if WROL Mode is active. + + WROL Mode is indicated by the presence of a flag file. + This file is created/deleted by the API when the user toggles WROL Mode. + """ + return get_wrol_mode_flag_path().exists() + + +def require_normal_mode(operation: str) -> None: + """ + Raise an error if WROL Mode is active. + + Use this to guard persistent operations that shouldn't be + allowed during emergencies. + + Args: + operation: Description of the operation being attempted + + Raises: + PermissionError: If WROL Mode is active + """ + if is_wrol_mode(): + raise PermissionError( + f"Cannot {operation} while WROL Mode is active. " + "Disable WROL Mode to make persistent changes." + ) diff --git a/controller/main.py b/controller/main.py new file mode 100644 index 00000000..b70111f8 --- /dev/null +++ b/controller/main.py @@ -0,0 +1,188 @@ +""" +WROLPi Controller - FastAPI Application + +The Controller provides: +- System status monitoring (CPU, memory, drives, network) +- Service management (start/stop/restart systemd services) +- Disk mounting and management +- Admin operations (hotspot, throttle, shutdown, reboot) +- Simple emergency UI when main app is down +""" + +import asyncio +from contextlib import asynccontextmanager +from pathlib import Path + +from fastapi import FastAPI, Request +from fastapi.responses import HTMLResponse +from fastapi.templating import Jinja2Templates + +from controller import __version__ +from controller.api.admin import router as admin_router +from controller.api.disks import router as disks_router +from controller.api.schemas import HealthResponse +from controller.api.services import router as services_router +from controller.api.status import router as status_router +from controller.lib.config import ( + is_docker_mode, + is_primary_drive_mounted, + reload_config_from_drive, +) +from controller.lib.docker_services import ( + can_manage_containers, + get_all_containers_status, +) +from controller.lib.status import ( + get_cpu_status, + get_load_status, + get_memory_status, + get_primary_drive_status, +) +from controller.lib.status_worker import status_worker_loop +from controller.lib.systemd import get_all_services_status + +# Templates directory +TEMPLATES_DIR = Path(__file__).parent / "templates" +templates = Jinja2Templates(directory=str(TEMPLATES_DIR)) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """ + Application lifespan handler. + Runs on startup and shutdown. + """ + # Startup + print(f"WROLPi Controller v{__version__} starting...") + print(f"Docker mode: {is_docker_mode()}") + + # Try to load config from drive if mounted + if is_primary_drive_mounted(): + if reload_config_from_drive(): + print("Loaded configuration from drive") + else: + print("Using default configuration (no controller.yaml found)") + else: + print("Primary drive not mounted - using default configuration") + + # Initialize cached status + app.state.cached_status = {} + + # Start status worker background task + status_task = asyncio.create_task(status_worker_loop(app)) + + yield + + # Shutdown + print("WROLPi Controller shutting down...") + status_task.cancel() + try: + await status_task + except asyncio.CancelledError: + pass + + +# Create FastAPI app +app = FastAPI( + title="WROLPi Controller", + description="System management service for WROLPi", + version=__version__, + lifespan=lifespan, +) + +# Include routers +app.include_router(admin_router) +app.include_router(disks_router) +app.include_router(services_router) +app.include_router(status_router) + + +def get_host(request: Request) -> str: + """Get the host for links (use request host, fallback to localhost).""" + host = request.headers.get("host", "localhost") + # Strip port if present + if ":" in host: + host = host.split(":")[0] + return host + + +@app.get("/", response_class=HTMLResponse) +async def dashboard(request: Request): + """Render the main dashboard UI with real status data.""" + # Try to use cached status first + cached = getattr(request.app.state, "cached_status", {}) + + if cached: + cpu = cached.get("cpu_stats") or {} + memory = cached.get("memory_stats") or {} + load = cached.get("load_stats") or {} + drives = cached.get("drives_stats") or [] + primary_drive = None + for drive in drives: + if drive.get("mount") == "/media/wrolpi": + primary_drive = drive + break + else: + # Fallback to direct calls if cache not yet populated + cpu = get_cpu_status() + memory = get_memory_status() + load = get_load_status() + primary_drive = get_primary_drive_status() + + # Format storage data + storage = {} + if primary_drive: + free_bytes = primary_drive["size"] - primary_drive["used"] + storage = { + "percent": primary_drive["percent"], + "free_gb": round(free_bytes / (1024 ** 3), 1), + } + + # Get service status + if is_docker_mode(): + if can_manage_containers(): + services = get_all_containers_status() + else: + services = [] + else: + services = get_all_services_status() + + context = { + "version": __version__, + "docker_mode": is_docker_mode(), + "drive_mounted": is_primary_drive_mounted(), + "host": get_host(request), + + # Real status data + "cpu": { + "percent": cpu["percent"], + "temperature_c": cpu["temperature"], # Field renamed + }, + "memory": { + "percent": round((memory["used"] / memory["total"]) * 100, 1) if memory["total"] > 0 else 0, + "used_gb": round(memory["used"] / (1024 ** 3), 1), + }, + "load": { + "load_1min": load["minute_1"], # Field renamed + }, + "storage": storage, + + # Real service status + "services": services, + } + + return templates.TemplateResponse(request, "index.html", context) + + +@app.get("/api/health", response_model=HealthResponse) +async def health_check() -> HealthResponse: + """ + Health check endpoint. + Returns basic status for monitoring and load balancer checks. + """ + return HealthResponse( + status="healthy", + version=__version__, + docker_mode=is_docker_mode(), + drive_mounted=is_primary_drive_mounted(), + ) diff --git a/controller/requirements.txt b/controller/requirements.txt new file mode 100644 index 00000000..ad00632a --- /dev/null +++ b/controller/requirements.txt @@ -0,0 +1,27 @@ +# Controller dependencies +# Separate from main WROLPi API to keep venvs independent + +# Web framework +fastapi>=0.100.0 +uvicorn[standard]>=0.23.0 +pydantic>=2.0.0 + +# Templates +jinja2>=3.1.0 + +# System monitoring +psutil>=5.9.0 + +# Disk health +pySMART>=1.3.0 + +# Config file handling +pyyaml>=6.0 + +# Docker management (optional, for Docker mode) +docker>=6.0.0 + +# Testing +pytest>=7.0.0 +pytest-asyncio>=0.21.0 +httpx>=0.24.0 diff --git a/controller/templates/index.html b/controller/templates/index.html new file mode 100644 index 00000000..507d6c2e --- /dev/null +++ b/controller/templates/index.html @@ -0,0 +1,1501 @@ + + + + + + WROLPi Controller + + + +{% if docker_mode %} +
+ Running in Docker mode. Disk mounting, hotspot, and shutdown are not available. +
+{% endif %} + +{% if not drive_mounted %} +
+ Primary drive not mounted. Some features may be limited. +
+{% endif %} + +
+ + Main UI is unavailable. Using Controller fallback. Will redirect when ready... +
+ +
+

Upgrade in Progress

+ + +

Please wait while WROLPi upgrades...

+

Elapsed: 0:00

+

⚠ Do not power off your device or close this window during the upgrade.

+
+ +
+
+

WROLPi Controller

+ v{{ version }} +
+
+ +
+
+

System Status

+
+
+

CPU

+

{{ cpu.percent if cpu.percent is not none else '--' }}%

+

{% if cpu.temperature_c %}{{ cpu.temperature_c }}°C{% else %}--{% endif %}

+
+
+

Memory

+

{{ memory.percent|default('--') }}%

+

{{ memory.used_gb|default('--') }} GB used

+
+
+

Load

+

{{ load.load_1min|default('--') }}

+

1 min avg

+
+
+

Storage

+

{{ storage.percent|default('--') }}%

+

{{ storage.free_gb|default('--') }} GB free

+
+
+
+ +
+

System Actions

+
+ + {% if not docker_mode %} + + + {% endif %} +
+
+ +
+

Services

+ + + + + + + {% if not docker_mode %} + + {% endif %} + + + + {% for service in services %} + + + + + {% if not docker_mode %} + + {% endif %} + + {% else %} + + + + {% endfor %} + +
ServiceStatusActionsBoot
+ {{ service.name }} + {% if service.port %}:{{ service.port }}{% endif %} + {{ service.status }} + {% if service.status == 'running' %} + + {% else %} + + {% endif %} + + + {% if service.viewable and service.status == 'running' and service.port %} + View + {% endif %} + + {% if service.name == 'wrolpi-upgrade' %} + -- + {% else %} + + + {{ 'Enabled' if service.enabled else 'Disabled' }} + + {% endif %} +
+ Service status not yet implemented +
+
+ +
+

Disks

+
+

Loading...

+
+
+ +
+

Drive Health (SMART)

+
+

Loading...

+
+
+
+ + + + + + + + + + + + + diff --git a/controller/test/__init__.py b/controller/test/__init__.py new file mode 100644 index 00000000..9e529029 --- /dev/null +++ b/controller/test/__init__.py @@ -0,0 +1 @@ +"""Controller tests.""" diff --git a/controller/test/test_admin.py b/controller/test/test_admin.py new file mode 100644 index 00000000..e007b528 --- /dev/null +++ b/controller/test/test_admin.py @@ -0,0 +1,242 @@ +""" +Unit tests for controller.lib.admin module. +""" + +from unittest import mock + +import pytest + +from controller.lib.admin import ( + disable_hotspot, + disable_throttle, + enable_hotspot, + enable_throttle, + get_hotspot_status, + get_throttle_status, + reboot_system, + restart_all_services, + shutdown_system, + HotspotStatus, + GovernorStatus, +) + + +class TestGetHotspotStatus: + """Tests for get_hotspot_status function.""" + + def test_returns_hotspot_status_enum(self): + """Should return a HotspotStatus enum value.""" + result = get_hotspot_status() + assert isinstance(result, HotspotStatus) + + def test_returns_unknown_in_docker_mode(self): + """Should return unknown when in Docker mode.""" + with mock.patch("controller.lib.admin.is_docker_mode", return_value=True): + result = get_hotspot_status() + assert result == HotspotStatus.unknown + + def test_handles_nmcli_not_found(self): + """Should handle nmcli not being available.""" + with mock.patch("controller.lib.admin.is_docker_mode", return_value=False): + with mock.patch("controller.lib.admin.get_current_ssid", return_value=None): + with mock.patch("subprocess.run", side_effect=FileNotFoundError()): + result = get_hotspot_status() + assert result == HotspotStatus.unknown + + def test_handles_timeout(self): + """Should handle subprocess timeout.""" + import subprocess + with mock.patch("controller.lib.admin.is_docker_mode", return_value=False): + with mock.patch("controller.lib.admin.get_current_ssid", return_value=None): + with mock.patch("subprocess.run", side_effect=subprocess.TimeoutExpired("nmcli", 5)): + result = get_hotspot_status() + assert result == HotspotStatus.unknown + + +class TestEnableHotspot: + """Tests for enable_hotspot function.""" + + def test_returns_error_in_docker_mode(self): + """Should return error when in Docker mode.""" + with mock.patch("controller.lib.admin.is_docker_mode", return_value=True): + result = enable_hotspot() + assert result["success"] is False + assert "Docker" in result.get("error", "") + + def test_handles_nmcli_not_found(self): + """Should handle nmcli not being available.""" + with mock.patch("controller.lib.admin.is_docker_mode", return_value=False): + with mock.patch("controller.lib.admin.get_config", return_value={}): + with mock.patch("subprocess.run", side_effect=FileNotFoundError()): + result = enable_hotspot() + assert result["success"] is False + assert "nmcli" in result.get("error", "").lower() + + +class TestDisableHotspot: + """Tests for disable_hotspot function.""" + + def test_returns_error_in_docker_mode(self): + """Should return error when in Docker mode.""" + with mock.patch("controller.lib.admin.is_docker_mode", return_value=True): + result = disable_hotspot() + assert result["success"] is False + assert "Docker" in result.get("error", "") + + def test_handles_nmcli_not_found(self): + """Should handle nmcli not being available.""" + with mock.patch("controller.lib.admin.is_docker_mode", return_value=False): + with mock.patch("subprocess.run", side_effect=FileNotFoundError()): + result = disable_hotspot() + assert result["success"] is False + assert "nmcli" in result.get("error", "").lower() + + +class TestGetThrottleStatus: + """Tests for get_throttle_status function.""" + + def test_returns_governor_status_enum(self): + """Should return a GovernorStatus enum value.""" + result = get_throttle_status() + assert isinstance(result, GovernorStatus) + + def test_returns_unknown_in_docker_mode(self): + """Should return unknown when in Docker mode.""" + with mock.patch("controller.lib.admin.is_docker_mode", return_value=True): + result = get_throttle_status() + assert result == GovernorStatus.unknown + + def test_returns_unknown_when_cpufreq_not_available(self, tmp_path): + """Should return unknown when cpufreq doesn't exist.""" + from pathlib import Path + nonexistent_path = tmp_path / "nonexistent" + + with mock.patch("controller.lib.admin.is_docker_mode", return_value=False): + with mock.patch.object(Path, "exists", return_value=False): + result = get_throttle_status() + assert result == GovernorStatus.unknown + + def test_reads_governor_from_sysfs(self, tmp_path): + """Should read governor from sysfs when available.""" + from pathlib import Path + + governor_path = tmp_path / "scaling_governor" + governor_path.write_text("ondemand") + + with mock.patch("controller.lib.admin.is_docker_mode", return_value=False): + # This test verifies the function structure - actual sysfs testing + # would require a real Raspberry Pi environment + result = get_throttle_status() + assert isinstance(result, GovernorStatus) + + +class TestEnableThrottle: + """Tests for enable_throttle function.""" + + def test_returns_error_in_docker_mode(self): + """Should return error when in Docker mode.""" + with mock.patch("controller.lib.admin.is_docker_mode", return_value=True): + result = enable_throttle() + assert result["success"] is False + assert "Docker" in result.get("error", "") + + +class TestDisableThrottle: + """Tests for disable_throttle function.""" + + def test_returns_error_in_docker_mode(self): + """Should return error when in Docker mode.""" + with mock.patch("controller.lib.admin.is_docker_mode", return_value=True): + result = disable_throttle() + assert result["success"] is False + assert "Docker" in result.get("error", "") + + +class TestShutdownSystem: + """Tests for shutdown_system function.""" + + def test_returns_error_in_docker_mode(self): + """Should return error when in Docker mode.""" + with mock.patch("controller.lib.admin.is_docker_mode", return_value=True): + result = shutdown_system() + assert result["success"] is False + assert "Docker" in result.get("error", "") + + def test_handles_shutdown_not_found(self): + """Should handle shutdown command not being available.""" + with mock.patch("controller.lib.admin.is_docker_mode", return_value=False): + with mock.patch("subprocess.Popen", side_effect=FileNotFoundError()): + result = shutdown_system() + assert result["success"] is False + assert "not found" in result.get("error", "").lower() + + def test_calls_shutdown_command(self): + """Should call shutdown command with correct args.""" + with mock.patch("controller.lib.admin.is_docker_mode", return_value=False): + mock_popen = mock.Mock() + with mock.patch("subprocess.Popen", mock_popen): + result = shutdown_system() + mock_popen.assert_called_once_with(["shutdown", "-h", "now"]) + assert result["success"] is True + + +class TestRebootSystem: + """Tests for reboot_system function.""" + + def test_returns_error_in_docker_mode(self): + """Should return error when in Docker mode.""" + with mock.patch("controller.lib.admin.is_docker_mode", return_value=True): + result = reboot_system() + assert result["success"] is False + assert "Docker" in result.get("error", "") + + def test_handles_reboot_not_found(self): + """Should handle reboot command not being available.""" + with mock.patch("controller.lib.admin.is_docker_mode", return_value=False): + with mock.patch("subprocess.Popen", side_effect=FileNotFoundError()): + result = reboot_system() + assert result["success"] is False + assert "not found" in result.get("error", "").lower() + + def test_calls_reboot_command(self): + """Should call reboot command.""" + with mock.patch("controller.lib.admin.is_docker_mode", return_value=False): + mock_popen = mock.Mock() + with mock.patch("subprocess.Popen", mock_popen): + result = reboot_system() + mock_popen.assert_called_once_with(["reboot"]) + assert result["success"] is True + + +class TestRestartAllServices: + """Tests for restart_all_services function.""" + + @pytest.mark.asyncio + async def test_returns_error_in_docker_mode(self): + """Should return error when in Docker mode.""" + with mock.patch("controller.lib.admin.is_docker_mode", return_value=True): + result = await restart_all_services() + assert result["success"] is False + assert "Docker" in result.get("error", "") + + @pytest.mark.asyncio + async def test_restarts_services(self): + """Should attempt to restart all services.""" + with mock.patch("controller.lib.admin.is_docker_mode", return_value=False): + # Mock subprocess.run to succeed + mock_result = mock.Mock() + mock_result.returncode = 0 + mock_result.stderr = "" + + # Mock _restart_self to avoid actual restart + with mock.patch("subprocess.run", return_value=mock_result): + with mock.patch("controller.lib.admin._restart_self"): + with mock.patch("asyncio.get_event_loop") as mock_loop: + mock_loop.return_value.call_later = mock.Mock() + + result = await restart_all_services() + + assert result["success"] is True + assert "services" in result + # Should include controller as pending + assert result["services"]["wrolpi-controller"]["pending"] is True diff --git a/controller/test/test_admin_api.py b/controller/test/test_admin_api.py new file mode 100644 index 00000000..5fd31685 --- /dev/null +++ b/controller/test/test_admin_api.py @@ -0,0 +1,145 @@ +""" +Integration tests for Controller admin API endpoints. +""" + + +class TestHotspotStatusEndpoint: + """Tests for /api/hotspot/status endpoint.""" + + def test_hotspot_status_returns_200(self, test_client): + """Hotspot status endpoint should return 200 OK.""" + response = test_client.get("/api/hotspot/status") + assert response.status_code == 200 + + def test_hotspot_status_returns_expected_fields(self, test_client): + """Hotspot status should return enabled and available fields.""" + response = test_client.get("/api/hotspot/status") + data = response.json() + assert "enabled" in data + assert "available" in data + + def test_hotspot_status_shows_unavailable_in_docker(self, test_client_docker_mode): + """Hotspot should be unavailable in Docker mode.""" + response = test_client_docker_mode.get("/api/hotspot/status") + data = response.json() + assert data["available"] is False + + +class TestHotspotEnableEndpoint: + """Tests for /api/hotspot/enable endpoint.""" + + def test_hotspot_enable_fails_in_docker(self, test_client_docker_mode): + """Hotspot enable should fail in Docker mode.""" + response = test_client_docker_mode.post("/api/hotspot/enable") + assert response.status_code == 500 + assert "Docker" in response.json()["detail"] + + +class TestHotspotDisableEndpoint: + """Tests for /api/hotspot/disable endpoint.""" + + def test_hotspot_disable_fails_in_docker(self, test_client_docker_mode): + """Hotspot disable should fail in Docker mode.""" + response = test_client_docker_mode.post("/api/hotspot/disable") + assert response.status_code == 500 + assert "Docker" in response.json()["detail"] + + +class TestThrottleStatusEndpoint: + """Tests for /api/throttle/status endpoint.""" + + def test_throttle_status_returns_200(self, test_client): + """Throttle status endpoint should return 200 OK.""" + response = test_client.get("/api/throttle/status") + assert response.status_code == 200 + + def test_throttle_status_returns_expected_fields(self, test_client): + """Throttle status should return enabled and available fields.""" + response = test_client.get("/api/throttle/status") + data = response.json() + assert "enabled" in data + assert "available" in data + + def test_throttle_status_shows_unavailable_in_docker(self, test_client_docker_mode): + """Throttle should be unavailable in Docker mode.""" + response = test_client_docker_mode.get("/api/throttle/status") + data = response.json() + assert data["available"] is False + + +class TestThrottleEnableEndpoint: + """Tests for /api/throttle/enable endpoint.""" + + def test_throttle_enable_fails_in_docker(self, test_client_docker_mode): + """Throttle enable should fail in Docker mode.""" + response = test_client_docker_mode.post("/api/throttle/enable") + assert response.status_code == 500 + assert "Docker" in response.json()["detail"] + + +class TestThrottleDisableEndpoint: + """Tests for /api/throttle/disable endpoint.""" + + def test_throttle_disable_fails_in_docker(self, test_client_docker_mode): + """Throttle disable should fail in Docker mode.""" + response = test_client_docker_mode.post("/api/throttle/disable") + assert response.status_code == 500 + assert "Docker" in response.json()["detail"] + + +class TestShutdownEndpoint: + """Tests for /api/shutdown endpoint.""" + + def test_shutdown_returns_501_in_docker(self, test_client_docker_mode): + """Shutdown should return 501 in Docker mode.""" + response = test_client_docker_mode.post("/api/shutdown") + assert response.status_code == 501 + assert "Docker" in response.json()["detail"] + + +class TestRebootEndpoint: + """Tests for /api/reboot endpoint.""" + + def test_reboot_returns_501_in_docker(self, test_client_docker_mode): + """Reboot should return 501 in Docker mode.""" + response = test_client_docker_mode.post("/api/reboot") + assert response.status_code == 501 + assert "Docker" in response.json()["detail"] + + +class TestRestartEndpoint: + """Tests for /api/restart endpoint.""" + + def test_restart_returns_501_in_docker(self, test_client_docker_mode): + """Restart should return 501 in Docker mode.""" + response = test_client_docker_mode.post("/api/restart") + assert response.status_code == 501 + assert "Docker" in response.json()["detail"] + + +class TestOpenAPIIncludesAdminEndpoints: + """Tests that OpenAPI documentation includes admin endpoints.""" + + def test_openapi_has_hotspot_paths(self, test_client): + """OpenAPI schema should include hotspot paths.""" + response = test_client.get("/openapi.json") + data = response.json() + assert "/api/hotspot/status" in data["paths"] + assert "/api/hotspot/enable" in data["paths"] + assert "/api/hotspot/disable" in data["paths"] + + def test_openapi_has_throttle_paths(self, test_client): + """OpenAPI schema should include throttle paths.""" + response = test_client.get("/openapi.json") + data = response.json() + assert "/api/throttle/status" in data["paths"] + assert "/api/throttle/enable" in data["paths"] + assert "/api/throttle/disable" in data["paths"] + + def test_openapi_has_system_control_paths(self, test_client): + """OpenAPI schema should include system control paths.""" + response = test_client.get("/openapi.json") + data = response.json() + assert "/api/shutdown" in data["paths"] + assert "/api/reboot" in data["paths"] + assert "/api/restart" in data["paths"] diff --git a/controller/test/test_api.py b/controller/test/test_api.py new file mode 100644 index 00000000..670c586d --- /dev/null +++ b/controller/test/test_api.py @@ -0,0 +1,222 @@ +""" +Integration tests for Controller API endpoints. +""" +from unittest import mock + +import pytest +from fastapi.testclient import TestClient + +from controller import __version__ + + +class TestHealthEndpoint: + """Tests for /api/health endpoint.""" + + def test_health_returns_200(self, test_client): + """Health endpoint should return 200 OK.""" + response = test_client.get("/api/health") + assert response.status_code == 200 + + def test_health_returns_expected_fields(self, test_client): + """Health endpoint should return expected fields.""" + response = test_client.get("/api/health") + data = response.json() + assert data["status"] == "healthy" + assert data["version"] == __version__ + assert data["docker_mode"] is False + assert "drive_mounted" in data + assert isinstance(data["drive_mounted"], bool) + + def test_health_returns_docker_mode_true(self, test_client_docker_mode): + """Health endpoint should return docker_mode=true when in Docker.""" + response = test_client_docker_mode.get("/api/health") + data = response.json() + assert data["docker_mode"] is True + + +class TestDashboardEndpoint: + """Tests for / dashboard endpoint (UI).""" + + def test_dashboard_returns_200(self, test_client): + """Dashboard endpoint should return 200 OK.""" + response = test_client.get("/") + assert response.status_code == 200 + + def test_dashboard_returns_html(self, test_client): + """Dashboard endpoint should return HTML.""" + response = test_client.get("/") + assert "text/html" in response.headers["content-type"] + + def test_dashboard_contains_title(self, test_client): + """Dashboard should contain title.""" + response = test_client.get("/") + assert b"WROLPi Controller" in response.content + + def test_dashboard_contains_version(self, test_client): + """Dashboard should contain version.""" + response = test_client.get("/") + assert __version__.encode() in response.content + + def test_dashboard_contains_status_cards(self, test_client): + """Dashboard should contain status cards.""" + response = test_client.get("/") + content = response.text + assert "CPU" in content + assert "Memory" in content + assert "Load" in content + assert "Storage" in content + + def test_dashboard_contains_disks_section(self, test_client): + """Dashboard should contain disks section.""" + response = test_client.get("/") + content = response.text + assert "Disks" in content + assert 'id="disks"' in content + + def test_dashboard_contains_services_section(self, test_client): + """Dashboard should contain services section.""" + response = test_client.get("/") + content = response.text + assert "Services" in content + assert 'id="services"' in content + + def test_dashboard_contains_system_actions(self, test_client): + """Dashboard should contain system actions section.""" + response = test_client.get("/") + content = response.text + assert "System Actions" in content + assert "Restart All Services" in content + + def test_dashboard_shows_docker_banner_when_dockerized(self, test_client_docker_mode): + """Dashboard should show Docker mode banner when in Docker.""" + response = test_client_docker_mode.get("/") + content = response.text + assert "Running in Docker mode" in content + + def test_dashboard_no_docker_banner_when_not_dockerized(self, test_client): + """Dashboard should not show Docker mode banner when not in Docker.""" + response = test_client.get("/") + content = response.text + assert "Running in Docker mode" not in content + + def test_dashboard_shows_boot_column_when_not_dockerized(self, test_client): + """Dashboard should show Boot column in services table when not in Docker.""" + response = test_client.get("/") + content = response.text + assert "Boot" in content + # Should have toggle switches + assert 'class="toggle"' in content + + def test_dashboard_no_boot_column_when_dockerized(self, test_client_docker_mode): + """Dashboard should not show Boot column in services table when in Docker.""" + response = test_client_docker_mode.get("/") + content = response.text + assert "Boot" not in content + # The table body (rendered server-side) should not have boot toggles + # Note: JavaScript template literals may contain 'id="boot-' for dynamic updates, + # but the actual rendered should not have boot toggles in Docker mode + import re + tbody_match = re.search(r'(.*?)', content, re.DOTALL) + if tbody_match: + tbody_content = tbody_match.group(1) + assert 'id="boot-' not in tbody_content + + def test_dashboard_has_upgrade_banner_element(self, test_client): + """Dashboard should have upgrade banner element for upgrade mode.""" + response = test_client.get("/") + content = response.text + assert 'id="upgrade-banner"' in content + assert "Upgrade in Progress" in content + assert "Do not power off" in content + + def test_dashboard_has_mount_modal(self, test_client): + """Dashboard should have mount modal for disk mounting.""" + response = test_client.get("/") + content = response.text + assert 'id="mount-modal"' in content + assert 'id="mount-point-input"' in content + assert 'id="mount-persist-checkbox"' in content + assert "Persistent (survive reboots)" in content + + def test_dashboard_has_persist_column_in_disks_js(self, test_client): + """Dashboard JavaScript should include Persist column in disk table.""" + response = test_client.get("/") + content = response.text + # The loadDisks() function should create a table with Persist column + assert "Persist" in content + # Should have togglePersist function + assert "function togglePersist" in content + # Should fetch fstab entries + assert "api/disks/fstab" in content + + +class TestOpenAPIEndpoint: + """Tests for OpenAPI documentation endpoint.""" + + def test_openapi_returns_200(self, test_client): + """OpenAPI endpoint should return 200 OK.""" + response = test_client.get("/openapi.json") + assert response.status_code == 200 + + def test_openapi_has_title(self, test_client): + """OpenAPI schema should have correct title.""" + response = test_client.get("/openapi.json") + data = response.json() + assert data["info"]["title"] == "WROLPi Controller" + + def test_openapi_has_version(self, test_client): + """OpenAPI schema should have version.""" + response = test_client.get("/openapi.json") + data = response.json() + assert data["info"]["version"] == __version__ + + def test_openapi_has_api_paths(self, test_client): + """OpenAPI schema should have API paths defined.""" + response = test_client.get("/openapi.json") + data = response.json() + assert "/api/health" in data["paths"] + + +class TestDocsEndpoint: + """Tests for Swagger UI documentation endpoint.""" + + def test_docs_returns_200(self, test_client): + """Docs endpoint should return 200 OK.""" + response = test_client.get("/docs") + assert response.status_code == 200 + + def test_docs_returns_html(self, test_client): + """Docs endpoint should return HTML.""" + response = test_client.get("/docs") + assert "text/html" in response.headers["content-type"] + + +class TestDriveMountedStatus: + """Tests for drive mounted status in responses.""" + + @pytest.mark.parametrize("endpoint,mounted,expected", [ + ("/api/health", True, True), + ("/api/health", False, False), + ]) + def test_drive_mounted_status(self, reset_runtime_config, mock_docker_mode, endpoint, mounted, expected): + """Endpoints should return correct drive_mounted status.""" + from controller.main import app + + with mock.patch( + "controller.main.is_primary_drive_mounted", return_value=mounted + ): + with TestClient(app) as client: + response = client.get(endpoint) + data = response.json() + assert data["drive_mounted"] is expected + + def test_dashboard_shows_drive_not_mounted_banner(self, reset_runtime_config, mock_docker_mode): + """Dashboard should show banner when drive is not mounted.""" + from controller.main import app + + with mock.patch( + "controller.main.is_primary_drive_mounted", return_value=False + ): + with TestClient(app) as client: + response = client.get("/") + assert "Primary drive not mounted" in response.text diff --git a/controller/test/test_config.py b/controller/test/test_config.py new file mode 100644 index 00000000..c19734db --- /dev/null +++ b/controller/test/test_config.py @@ -0,0 +1,268 @@ +""" +Unit tests for controller.lib.config module. +""" +import copy + +import pytest +import yaml + +from controller.lib.config import ( + get_config, + get_config_value, + is_docker_mode, + is_primary_drive_mounted, + reload_config_from_drive, + save_config, + update_config, + _deep_merge, + _get_config_diff, +) + + +class TestGetConfig: + """Tests for get_config function.""" + + def test_get_config_returns_dict(self, reset_runtime_config): + """get_config should return a dictionary.""" + config = get_config() + assert isinstance(config, dict) + + def test_get_config_has_default_keys(self, reset_runtime_config): + """get_config should have all default keys.""" + config = get_config() + assert "port" in config + assert "media_directory" in config + assert "drives" in config + assert "managed_services" in config + assert "hotspot" in config + assert "throttle" in config + + def test_get_config_default_port(self, reset_runtime_config): + """Default port should be 8087.""" + config = get_config() + assert config["port"] == 8087 + + +class TestGetConfigValue: + """Tests for get_config_value function.""" + + @pytest.mark.parametrize("key,expected", [ + ("port", 8087), + ("media_directory", "/media/wrolpi"), + ("drives.auto_mount", True), + ("hotspot.ssid", "WROLPi"), + ("throttle.default_governor", "ondemand"), + ]) + def test_get_value(self, reset_runtime_config, key, expected): + """Should get values using dot notation for nested keys.""" + assert get_config_value(key) == expected + + @pytest.mark.parametrize("key,default,expected", [ + ("nonexistent", None, None), + ("nonexistent", "fallback", "fallback"), + ("drives.nonexistent", 42, 42), + ("a.b.c.d.e", "default", "default"), + ]) + def test_get_missing_value_returns_default(self, reset_runtime_config, key, default, expected): + """Should return default for missing keys.""" + assert get_config_value(key, default) == expected + + +class TestIsDockerMode: + """Tests for is_docker_mode function.""" + + def test_docker_mode_false_when_not_set(self, mock_docker_mode): + """Docker mode should be False when env var not set.""" + assert is_docker_mode() is False + + def test_docker_mode_true_when_set(self, mock_docker_mode_enabled): + """Docker mode should be True when DOCKERIZED=true.""" + assert is_docker_mode() is True + + +class TestIsPrimaryDriveMounted: + """Tests for is_primary_drive_mounted function.""" + + def test_drive_mounted_when_config_dir_exists(self, test_config_directory): + """Should return True when /media/wrolpi/config exists.""" + # We need to mock the path check to use our test directory + from unittest import mock + + with mock.patch( + "controller.lib.config.Path" + ) as mock_path: + mock_path.return_value.exists.return_value = True + # Re-import to get fresh function + # Actually test with real path + + # The real test - check that the function works correctly + assert test_config_directory.exists() + + def test_drive_not_mounted_when_config_dir_missing(self, test_directory): + """Should return False when config dir doesn't exist.""" + from unittest import mock + + with mock.patch( + "controller.lib.config.Path" + ) as mock_path: + mock_instance = mock.MagicMock() + mock_instance.exists.return_value = False + mock_path.return_value = mock_instance + assert is_primary_drive_mounted() is False + + +class TestReloadConfigFromDrive: + """Tests for reload_config_from_drive function.""" + + def test_reload_returns_false_when_no_config_file( + self, reset_runtime_config, mock_config_path + ): + """Should return False when controller.yaml doesn't exist.""" + result = reload_config_from_drive() + assert result is False + + def test_reload_returns_true_when_config_exists( + self, reset_runtime_config, mock_config_path + ): + """Should return True and load config when file exists.""" + # Write a test config + mock_config_path.write_text(yaml.dump({"port": 9999})) + + result = reload_config_from_drive() + assert result is True + assert get_config_value("port") == 9999 + + def test_reload_merges_with_defaults( + self, reset_runtime_config, mock_config_path + ): + """Config from file should be merged with defaults.""" + # Write partial config - only override port + mock_config_path.write_text(yaml.dump({"port": 8888})) + + reload_config_from_drive() + + # Port should be overridden + assert get_config_value("port") == 8888 + # Other defaults should still exist + assert get_config_value("media_directory") == "/media/wrolpi" + assert get_config_value("drives.auto_mount") is True + + def test_reload_handles_invalid_yaml( + self, reset_runtime_config, mock_config_path + ): + """Should return False and keep defaults for invalid YAML.""" + mock_config_path.write_text("invalid: yaml: content: [}") + + result = reload_config_from_drive() + assert result is False + # Should still have defaults + assert get_config_value("port") == 8087 + + def test_reload_handles_empty_file( + self, reset_runtime_config, mock_config_path + ): + """Should handle empty config file gracefully.""" + mock_config_path.write_text("") + + result = reload_config_from_drive() + # Empty file is valid YAML (returns None), merged with defaults + # File exists and was processed, so returns True + assert result is True + # Config should still have all defaults + assert get_config_value("port") == 8087 + + +class TestSaveConfig: + """Tests for save_config function.""" + + def test_save_config_raises_when_drive_not_mounted( + self, reset_runtime_config, mock_drive_not_mounted + ): + """Should raise RuntimeError when drive not mounted.""" + with pytest.raises(RuntimeError, match="primary drive not mounted"): + save_config() + + def test_save_config_creates_file( + self, reset_runtime_config, mock_config_path, mock_drive_mounted + ): + """Should create config file when saving.""" + update_config("port", 9999) + save_config() + + assert mock_config_path.exists() + saved = yaml.safe_load(mock_config_path.read_text()) + assert saved["port"] == 9999 + + def test_save_config_only_saves_diff( + self, reset_runtime_config, mock_config_path, mock_drive_mounted + ): + """Should only save values that differ from defaults.""" + # Only change port + update_config("port", 9999) + save_config() + + saved = yaml.safe_load(mock_config_path.read_text()) + # Only port should be in the file + assert saved == {"port": 9999} + + +class TestUpdateConfig: + """Tests for update_config function.""" + + def test_update_simple_value(self, reset_runtime_config): + """Should update simple top-level values.""" + update_config("port", 9999) + assert get_config_value("port") == 9999 + + def test_update_nested_value(self, reset_runtime_config): + """Should update nested values using dot notation.""" + update_config("hotspot.ssid", "MyNetwork") + assert get_config_value("hotspot.ssid") == "MyNetwork" + + def test_update_creates_nested_structure(self, reset_runtime_config): + """Should create nested structure if it doesn't exist.""" + update_config("new.nested.key", "value") + assert get_config_value("new.nested.key") == "value" + + +class TestDeepMerge: + """Tests for _deep_merge helper function.""" + + @pytest.mark.parametrize("base,override,expected", [ + # Flat dict merge + ({"a": 1, "b": 2}, {"b": 3, "c": 4}, {"a": 1, "b": 3, "c": 4}), + # Nested dict merge + ({"a": {"x": 1, "y": 2}}, {"a": {"y": 3, "z": 4}}, {"a": {"x": 1, "y": 3, "z": 4}}), + # Override replaces non-dict + ({"a": {"x": 1}}, {"a": "string"}, {"a": "string"}), + ]) + def test_merge_dicts(self, base, override, expected): + """Should merge dictionaries correctly.""" + result = _deep_merge(base, override) + assert result == expected + + def test_merge_does_not_modify_original(self): + """Should not modify original dictionaries.""" + base = {"a": 1} + override = {"b": 2} + original_base = copy.deepcopy(base) + _deep_merge(base, override) + assert base == original_base + + +class TestGetConfigDiff: + """Tests for _get_config_diff helper function.""" + + @pytest.mark.parametrize("config,default,expected", [ + # Equal configs return empty + ({"a": 1, "b": 2}, {"a": 1, "b": 2}, {}), + # Changed values only + ({"a": 1, "b": 3}, {"a": 1, "b": 2}, {"b": 3}), + # New keys + ({"a": 1, "b": 2, "c": 3}, {"a": 1, "b": 2}, {"c": 3}), + # Nested changes + ({"a": {"x": 1, "y": 3}}, {"a": {"x": 1, "y": 2}}, {"a": {"y": 3}}), + ]) + def test_get_config_diff(self, config, default, expected): + """Should return only values that differ from defaults.""" + assert _get_config_diff(config, default) == expected diff --git a/controller/test/test_defaults.py b/controller/test/test_defaults.py new file mode 100644 index 00000000..e996d239 --- /dev/null +++ b/controller/test/test_defaults.py @@ -0,0 +1,130 @@ +""" +Unit tests for controller.defaults module. +""" +import pytest + +from controller.defaults import DEFAULT_CONFIG + + +class TestDefaultConfig: + """Tests for DEFAULT_CONFIG structure.""" + + def test_default_config_is_dict(self): + """DEFAULT_CONFIG should be a dictionary.""" + assert isinstance(DEFAULT_CONFIG, dict) + + def test_default_port(self): + """Default port should be 8087.""" + assert DEFAULT_CONFIG["port"] == 8087 + + def test_default_media_directory(self): + """Default media directory should be /media/wrolpi.""" + assert DEFAULT_CONFIG["media_directory"] == "/media/wrolpi" + + +class TestDrivesConfig: + """Tests for drives configuration.""" + + def test_drives_config_exists(self): + """Drives config should exist.""" + assert "drives" in DEFAULT_CONFIG + assert isinstance(DEFAULT_CONFIG["drives"], dict) + + @pytest.mark.parametrize("filesystem", ["ext4", "btrfs", "vfat", "exfat"]) + def test_supported_filesystem(self, filesystem): + """Should have expected filesystems in supported list.""" + filesystems = DEFAULT_CONFIG["drives"]["supported_filesystems"] + assert isinstance(filesystems, list) + assert filesystem in filesystems + + def test_auto_mount_default(self): + """Auto mount should be True by default.""" + assert DEFAULT_CONFIG["drives"]["auto_mount"] is True + + def test_mounts_empty_by_default(self): + """Mounts list should be empty by default.""" + assert DEFAULT_CONFIG["drives"]["mounts"] == [] + + +class TestManagedServicesConfig: + """Tests for managed services configuration.""" + + def test_managed_services_exists(self): + """Managed services config should exist.""" + assert "managed_services" in DEFAULT_CONFIG + assert isinstance(DEFAULT_CONFIG["managed_services"], list) + + def test_managed_services_count(self): + """Should have expected number of managed services.""" + services = DEFAULT_CONFIG["managed_services"] + assert len(services) == 10 + + @pytest.mark.parametrize("name,expected", [ + ("wrolpi-api", + {"systemd_name": "wrolpi-api", "port": 8081, "viewable": True, "description": "Python API (Sanic)"}), + ("wrolpi-app", {"port": 3000, "viewable": False}), + ("nginx", {"port": 80}), + ("postgresql", {"port": 5432, "viewable": False}), + ("wrolpi-upgrade", {"port": None, "viewable": False, "show_only_when_running": True}), + ]) + def test_service_config(self, name, expected): + """Should have service configured with expected values.""" + services = DEFAULT_CONFIG["managed_services"] + service = next((s for s in services if s["name"] == name), None) + assert service is not None, f"Service {name} not found" + for key, value in expected.items(): + assert service[key] == value, f"Service {name} has {key}={service.get(key)}, expected {value}" + + @pytest.mark.parametrize("name,use_https", [ + ("wrolpi-help", True), + ("wrolpi-kiwix", True), + ("apache2", True), + ("wrolpi-api", False), + ("wrolpi-app", False), + ("postgresql", False), + ]) + def test_service_use_https(self, name, use_https): + """HTTPS services should have use_https=True, others should have False.""" + services = DEFAULT_CONFIG["managed_services"] + service = next((s for s in services if s["name"] == name), None) + assert service is not None, f"Service {name} not found" + assert service.get("use_https", False) == use_https, \ + f"Service {name} should have use_https={use_https}" + + def test_all_services_have_required_fields(self): + """All services should have required fields.""" + required_fields = ["name", "systemd_name", "port", "viewable", "use_https", "description"] + for service in DEFAULT_CONFIG["managed_services"]: + for field in required_fields: + assert field in service, f"Service {service.get('name')} missing {field}" + + +class TestHotspotConfig: + """Tests for hotspot configuration.""" + + def test_hotspot_config_exists(self): + """Hotspot config should exist.""" + assert "hotspot" in DEFAULT_CONFIG + assert isinstance(DEFAULT_CONFIG["hotspot"], dict) + + @pytest.mark.parametrize("key,expected", [ + ("device", "wlan0"), + ("ssid", "WROLPi"), + ("password", "wrolpi hotspot"), + ]) + def test_hotspot_defaults(self, key, expected): + """Hotspot should have expected default values.""" + assert DEFAULT_CONFIG["hotspot"][key] == expected + + +class TestThrottleConfig: + """Tests for throttle configuration.""" + + def test_throttle_config_exists(self): + """Throttle config should exist.""" + assert "throttle" in DEFAULT_CONFIG + assert isinstance(DEFAULT_CONFIG["throttle"], dict) + + def test_default_governor(self): + """Default governor should be ondemand.""" + assert DEFAULT_CONFIG["throttle"]["default_governor"] == "ondemand" diff --git a/controller/test/test_disks.py b/controller/test/test_disks.py new file mode 100644 index 00000000..b92125ea --- /dev/null +++ b/controller/test/test_disks.py @@ -0,0 +1,285 @@ +""" +Unit tests for controller.lib.disks module. +""" + +import json +from unittest import mock + +import pytest + +from controller.lib.disks import ( + BlockDevice, + get_block_devices, + get_uuid, + validate_mount_point, + mount_drive, + unmount_drive, + is_mount_busy, + get_mounts, +) + + +class TestBlockDevice: + """Tests for BlockDevice dataclass.""" + + def test_create_block_device(self): + """Should create a BlockDevice instance.""" + device = BlockDevice( + name="sda1", + path="/dev/sda1", + size="100G", + fstype="ext4", + mountpoint="/media/data", + label="DATA", + uuid="1234-5678", + model="Samsung SSD", + ) + assert device.name == "sda1" + assert device.path == "/dev/sda1" + assert device.is_wrolpi_drive is False + + def test_block_device_defaults(self): + """Should have default values.""" + device = BlockDevice( + name="sda1", + path="/dev/sda1", + size="100G", + fstype=None, + mountpoint=None, + label=None, + uuid=None, + model=None, + ) + assert device.is_wrolpi_drive is False + + +class TestGetBlockDevices: + """Tests for get_block_devices function.""" + + def test_returns_empty_in_docker_mode(self): + """Should return empty list in Docker mode.""" + with mock.patch("controller.lib.disks.is_docker_mode", return_value=True): + result = get_block_devices() + assert result == [] + + def test_returns_devices_from_lsblk(self): + """Should parse lsblk output and return devices.""" + lsblk_output = json.dumps({ + "blockdevices": [ + { + "name": "sda", + "path": "/dev/sda", + "size": "500G", + "type": "disk", + "model": "Samsung SSD", + "children": [ + { + "name": "sda1", + "path": "/dev/sda1", + "size": "500G", + "type": "part", + "fstype": "ext4", + "mountpoint": "/media/data", + "label": "DATA", + "uuid": "1234-5678", + } + ] + } + ] + }) + with mock.patch("controller.lib.disks.is_docker_mode", return_value=False): + with mock.patch("subprocess.run") as mock_run: + mock_run.return_value = mock.Mock(returncode=0, stdout=lsblk_output) + result = get_block_devices() + assert len(result) == 1 + assert result[0].name == "sda1" + assert result[0].fstype == "ext4" + assert result[0].model == "Samsung SSD" + + def test_filters_unsupported_filesystems(self): + """Should only return devices with supported filesystems.""" + lsblk_output = json.dumps({ + "blockdevices": [ + { + "name": "sda", + "type": "disk", + "children": [ + {"name": "sda1", "path": "/dev/sda1", "size": "100G", "type": "part", "fstype": "ext4"}, + {"name": "sda2", "path": "/dev/sda2", "size": "100G", "type": "part", "fstype": "ntfs"}, + ] + } + ] + }) + with mock.patch("controller.lib.disks.is_docker_mode", return_value=False): + with mock.patch("subprocess.run") as mock_run: + mock_run.return_value = mock.Mock(returncode=0, stdout=lsblk_output) + result = get_block_devices() + assert len(result) == 1 + assert result[0].name == "sda1" + + def test_handles_lsblk_failure(self): + """Should return empty list on lsblk failure.""" + with mock.patch("controller.lib.disks.is_docker_mode", return_value=False): + with mock.patch("subprocess.run") as mock_run: + mock_run.return_value = mock.Mock(returncode=1, stdout="", stderr="error") + result = get_block_devices() + assert result == [] + + +class TestGetUuid: + """Tests for get_uuid function.""" + + def test_returns_uuid_from_blkid(self): + """Should return UUID from blkid output.""" + with mock.patch("subprocess.run") as mock_run: + mock_run.return_value = mock.Mock(returncode=0, stdout="1234-5678-90ab\n") + result = get_uuid("/dev/sda1") + assert result == "1234-5678-90ab" + + def test_returns_none_on_failure(self): + """Should return None if blkid fails.""" + with mock.patch("subprocess.run") as mock_run: + mock_run.return_value = mock.Mock(returncode=1, stdout="") + result = get_uuid("/dev/sda1") + assert result is None + + +class TestValidateMountPoint: + """Tests for validate_mount_point function.""" + + def test_accepts_media_path(self): + """Should accept paths under /media.""" + validate_mount_point("/media/wrolpi") + validate_mount_point("/media/usb") + validate_mount_point("/media/wrolpi/data") + + def test_rejects_non_media_paths(self): + """Should reject paths not under /media.""" + with pytest.raises(ValueError, match="must be under /media"): + validate_mount_point("/mnt/data") + + with pytest.raises(ValueError, match="must be under /media"): + validate_mount_point("/home/user") + + with pytest.raises(ValueError, match="must be under /media"): + validate_mount_point("/") + + def test_rejects_path_traversal(self): + """Should reject path traversal attempts.""" + with pytest.raises(ValueError): + validate_mount_point("/media/../etc") + + +class TestMountDrive: + """Tests for mount_drive function.""" + + def test_returns_error_in_docker_mode(self): + """Should return error in Docker mode.""" + with mock.patch("controller.lib.disks.is_docker_mode", return_value=True): + result = mount_drive("/dev/sda1", "/media/test") + assert result["success"] is False + assert "Docker" in result["error"] + + def test_validates_mount_point(self): + """Should validate mount point.""" + with mock.patch("controller.lib.disks.is_docker_mode", return_value=False): + result = mount_drive("/dev/sda1", "/mnt/test") + assert result["success"] is False + assert "must be under /media" in result["error"] + + def test_mounts_drive_successfully(self): + """Should mount drive successfully.""" + with mock.patch("controller.lib.disks.is_docker_mode", return_value=False): + with mock.patch("pathlib.Path.mkdir"): + with mock.patch("subprocess.run") as mock_run: + mock_run.return_value = mock.Mock(returncode=0, stdout="", stderr="") + result = mount_drive("/dev/sda1", "/media/test") + assert result["success"] is True + assert result["mount_point"] == "/media/test" + + def test_handles_mount_failure(self): + """Should handle mount failure.""" + with mock.patch("controller.lib.disks.is_docker_mode", return_value=False): + with mock.patch("pathlib.Path.mkdir"): + with mock.patch("subprocess.run") as mock_run: + mock_run.return_value = mock.Mock(returncode=1, stdout="", stderr="mount failed") + result = mount_drive("/dev/sda1", "/media/test") + assert result["success"] is False + assert "mount failed" in result["error"] + + +class TestUnmountDrive: + """Tests for unmount_drive function.""" + + def test_returns_error_in_docker_mode(self): + """Should return error in Docker mode.""" + with mock.patch("controller.lib.disks.is_docker_mode", return_value=True): + result = unmount_drive("/media/test") + assert result["success"] is False + assert "Docker" in result["error"] + + def test_protects_primary_wrolpi_mount(self): + """Should protect /media/wrolpi from accidental unmount.""" + with mock.patch("controller.lib.disks.is_docker_mode", return_value=False): + with mock.patch("controller.lib.disks.get_config", return_value={"drives": {}}): + result = unmount_drive("/media/wrolpi") + assert result["success"] is False + assert "Cannot unmount /media/wrolpi" in result["error"] + + def test_unmounts_successfully(self): + """Should unmount successfully.""" + with mock.patch("controller.lib.disks.is_docker_mode", return_value=False): + with mock.patch("controller.lib.disks.is_mount_busy", return_value=False): + with mock.patch("subprocess.run") as mock_run: + mock_run.return_value = mock.Mock(returncode=0) + result = unmount_drive("/media/test") + assert result["success"] is True + + def test_checks_if_mount_busy(self): + """Should check if mount is busy.""" + with mock.patch("controller.lib.disks.is_docker_mode", return_value=False): + with mock.patch("controller.lib.disks.is_mount_busy", return_value=True): + result = unmount_drive("/media/test", lazy=False) + assert result["success"] is False + assert "busy" in result["error"] + + +class TestIsMountBusy: + """Tests for is_mount_busy function.""" + + def test_returns_true_if_processes_using(self): + """Should return True if processes are using mount.""" + with mock.patch("subprocess.run") as mock_run: + mock_run.return_value = mock.Mock(returncode=0, stdout="python /media/test/file.py") + result = is_mount_busy("/media/test") + # is_mount_busy returns truthy value (non-empty string) when busy + assert result + + def test_returns_false_if_no_processes(self): + """Should return False if no processes using mount.""" + with mock.patch("subprocess.run") as mock_run: + mock_run.return_value = mock.Mock(returncode=1, stdout="") + result = is_mount_busy("/media/test") + assert not result + + +class TestGetMounts: + """Tests for get_mounts function.""" + + def test_returns_mounts_under_media(self): + """Should return mounts under /media.""" + findmnt_output = json.dumps({ + "filesystems": [ + {"target": "/", "source": "/dev/sda1", "fstype": "ext4", "options": "rw"}, + {"target": "/media/wrolpi", "source": "/dev/sdb1", "fstype": "ext4", "options": "rw"}, + {"target": "/media/usb", "source": "/dev/sdc1", "fstype": "vfat", "options": "rw"}, + ] + }) + with mock.patch("subprocess.run") as mock_run: + mock_run.return_value = mock.Mock(returncode=0, stdout=findmnt_output) + result = get_mounts() + assert len(result) == 2 + assert result[0]["mount_point"] == "/media/wrolpi" + assert result[1]["mount_point"] == "/media/usb" + + diff --git a/controller/test/test_disks_api.py b/controller/test/test_disks_api.py new file mode 100644 index 00000000..5df00bd4 --- /dev/null +++ b/controller/test/test_disks_api.py @@ -0,0 +1,184 @@ +""" +Integration tests for Controller disks API endpoints. +""" + +from unittest import mock + + +class TestDisksListEndpoint: + """Tests for /api/disks endpoint.""" + + def test_returns_501_in_docker_mode(self, test_client_docker_mode): + """Should return 501 in Docker mode.""" + response = test_client_docker_mode.get("/api/disks") + assert response.status_code == 501 + + def test_returns_list_in_native_mode(self, test_client): + """Should return list of disks in native mode.""" + with mock.patch("controller.api.disks.get_block_devices", return_value=[]): + response = test_client.get("/api/disks") + assert response.status_code == 200 + assert isinstance(response.json(), list) + + +class TestMountsEndpoint: + """Tests for /api/disks/mounts endpoint.""" + + def test_returns_501_in_docker_mode(self, test_client_docker_mode): + """Should return 501 in Docker mode.""" + response = test_client_docker_mode.get("/api/disks/mounts") + assert response.status_code == 501 + + def test_returns_mounts(self, test_client): + """Should return current mounts.""" + mock_mounts = [{"mount_point": "/media/wrolpi", "device": "/dev/sda1"}] + with mock.patch("controller.api.disks.get_mounts", return_value=mock_mounts): + response = test_client.get("/api/disks/mounts") + assert response.status_code == 200 + assert response.json() == mock_mounts + + +class TestMountEndpoint: + """Tests for /api/disks/mount endpoint.""" + + def test_returns_501_in_docker_mode(self, test_client_docker_mode): + """Should return 501 in Docker mode.""" + response = test_client_docker_mode.post( + "/api/disks/mount", + json={"device": "/dev/sda1", "mount_point": "/media/test"} + ) + assert response.status_code == 501 + + def test_mounts_drive_successfully(self, test_client): + """Should mount drive successfully.""" + with mock.patch( + "controller.api.disks.mount_drive", + return_value={"success": True, "mount_point": "/media/test"} + ): + response = test_client.post( + "/api/disks/mount", + json={"device": "/dev/sda1", "mount_point": "/media/test"} + ) + assert response.status_code == 200 + assert response.json()["success"] is True + + def test_returns_500_on_failure(self, test_client): + """Should return 500 on mount failure.""" + with mock.patch( + "controller.api.disks.mount_drive", + return_value={"success": False, "error": "Mount failed"} + ): + response = test_client.post( + "/api/disks/mount", + json={"device": "/dev/sda1", "mount_point": "/media/test"} + ) + assert response.status_code == 500 + + +class TestUnmountEndpoint: + """Tests for /api/disks/unmount endpoint.""" + + def test_returns_501_in_docker_mode(self, test_client_docker_mode): + """Should return 501 in Docker mode.""" + response = test_client_docker_mode.post( + "/api/disks/unmount", + json={"mount_point": "/media/test"} + ) + assert response.status_code == 501 + + def test_unmounts_drive_successfully(self, test_client): + """Should unmount drive successfully.""" + with mock.patch( + "controller.api.disks.unmount_drive", + return_value={"success": True, "mount_point": "/media/test"} + ): + response = test_client.post( + "/api/disks/unmount", + json={"mount_point": "/media/test"} + ) + assert response.status_code == 200 + + +class TestFstabEndpoints: + """Tests for /api/disks/fstab endpoints.""" + + def test_list_fstab_returns_501_in_docker(self, test_client_docker_mode): + """Should return 501 in Docker mode.""" + response = test_client_docker_mode.get("/api/disks/fstab") + assert response.status_code == 501 + + def test_list_fstab_returns_entries(self, test_client): + """Should return fstab entries.""" + mock_entries = [{"mount_point": "/media/wrolpi", "device": "UUID=1234"}] + with mock.patch("controller.api.disks.get_wrolpi_fstab_entries", return_value=mock_entries): + response = test_client.get("/api/disks/fstab") + assert response.status_code == 200 + assert response.json() == mock_entries + + def test_add_fstab_returns_501_in_docker(self, test_client_docker_mode): + """Should return 501 in Docker mode.""" + response = test_client_docker_mode.post( + "/api/disks/fstab", + json={"device": "/dev/sda1", "mount_point": "/media/test", "fstype": "ext4"} + ) + assert response.status_code == 501 + + def test_add_fstab_entry(self, test_client): + """Should add fstab entry.""" + with mock.patch( + "controller.api.disks.add_fstab_entry", + return_value={"success": True, "mount_point": "/media/test"} + ): + response = test_client.post( + "/api/disks/fstab", + json={"device": "/dev/sda1", "mount_point": "/media/test", "fstype": "ext4"} + ) + assert response.status_code == 200 + + def test_delete_fstab_returns_501_in_docker(self, test_client_docker_mode): + """Should return 501 in Docker mode.""" + response = test_client_docker_mode.delete("/api/disks/fstab/media/test") + assert response.status_code == 501 + + +class TestSmartEndpoints: + """Tests for /api/disks/smart endpoints.""" + + def test_list_smart_returns_501_in_docker(self, test_client_docker_mode): + """Should return 501 in Docker mode.""" + response = test_client_docker_mode.get("/api/disks/smart") + assert response.status_code == 501 + + def test_list_smart_when_not_available(self, test_client): + """Should return unavailable when SMART not available.""" + with mock.patch("controller.api.disks.is_smart_available", return_value=False): + response = test_client.get("/api/disks/smart") + assert response.status_code == 200 + data = response.json() + assert data["available"] is False + + def test_list_smart_returns_drives(self, test_client): + """Should return SMART data for all drives.""" + mock_drives = [{"device": "sda", "assessment": "PASS"}] + with mock.patch("controller.api.disks.is_smart_available", return_value=True): + with mock.patch("controller.api.disks.get_all_smart_status", return_value=mock_drives): + response = test_client.get("/api/disks/smart") + assert response.status_code == 200 + data = response.json() + assert data["available"] is True + assert len(data["drives"]) == 1 + + +class TestOpenAPIIncludesDisksEndpoints: + """Tests that OpenAPI documentation includes disks endpoints.""" + + def test_openapi_has_disks_paths(self, test_client): + """OpenAPI schema should include disks paths.""" + response = test_client.get("/openapi.json") + data = response.json() + assert "/api/disks" in data["paths"] + assert "/api/disks/mounts" in data["paths"] + assert "/api/disks/mount" in data["paths"] + assert "/api/disks/unmount" in data["paths"] + assert "/api/disks/fstab" in data["paths"] + assert "/api/disks/smart" in data["paths"] diff --git a/controller/test/test_docker_services.py b/controller/test/test_docker_services.py new file mode 100644 index 00000000..bb8ad9ea --- /dev/null +++ b/controller/test/test_docker_services.py @@ -0,0 +1,297 @@ +""" +Unit tests for controller.lib.docker_services module. +""" + +from unittest import mock + +import pytest + +from controller.lib.docker_services import ( + can_manage_containers, + get_container_status, + get_all_containers_status, + start_container, + stop_container, + restart_container, + get_container_logs, + _get_container_name, + CONTAINER_PREFIX, +) + + +class TestCanManageContainers: + """Tests for can_manage_containers function.""" + + def test_returns_false_when_not_docker_mode(self): + """Should return False when not in Docker mode.""" + with mock.patch("controller.lib.docker_services.is_docker_mode", return_value=False): + assert can_manage_containers() is False + + def test_returns_false_when_docker_not_available(self): + """Should return False when docker library not available.""" + with mock.patch("controller.lib.docker_services.is_docker_mode", return_value=True): + with mock.patch("controller.lib.docker_services.DOCKER_AVAILABLE", False): + assert can_manage_containers() is False + + def test_returns_false_when_socket_missing(self): + """Should return False when Docker socket doesn't exist.""" + with mock.patch("controller.lib.docker_services.is_docker_mode", return_value=True): + with mock.patch("controller.lib.docker_services.DOCKER_AVAILABLE", True): + with mock.patch("os.path.exists", return_value=False): + assert can_manage_containers() is False + + def test_returns_true_when_all_conditions_met(self): + """Should return True when Docker mode, library, and socket available.""" + with mock.patch("controller.lib.docker_services.is_docker_mode", return_value=True): + with mock.patch("controller.lib.docker_services.DOCKER_AVAILABLE", True): + with mock.patch("os.path.exists", return_value=True): + assert can_manage_containers() is True + + +class TestGetContainerName: + """Tests for _get_container_name function.""" + + def test_formats_container_name(self): + """Should format container name with prefix and suffix.""" + result = _get_container_name("api") + assert result == f"{CONTAINER_PREFIX}-api-1" + + def test_uses_service_name(self): + """Should use service name in container name.""" + result = _get_container_name("web") + assert "web" in result + + +class TestGetContainerStatus: + """Tests for get_container_status function.""" + + def test_returns_unavailable_when_cannot_manage(self): + """Should return unavailable when can't manage containers.""" + with mock.patch("controller.lib.docker_services.can_manage_containers", return_value=False): + result = get_container_status("api") + assert result["status"] == "unknown" + assert result["available"] is False + + def test_returns_running_status(self): + """Should return running status for running container.""" + mock_container = mock.Mock() + mock_container.status = "running" + mock_container.attrs = {"NetworkSettings": {"Ports": {"8080/tcp": [{"HostPort": "8081"}]}}} + + mock_client = mock.Mock() + mock_client.containers.get.return_value = mock_container + + with mock.patch("controller.lib.docker_services.can_manage_containers", return_value=True): + with mock.patch("controller.lib.docker_services._get_client", return_value=mock_client): + result = get_container_status("api") + assert result["status"] == "running" + assert result["available"] is True + + def test_returns_stopped_for_exited_container(self): + """Should return stopped for exited container.""" + mock_container = mock.Mock() + mock_container.status = "exited" + mock_container.attrs = {"NetworkSettings": {"Ports": {}}} + + mock_client = mock.Mock() + mock_client.containers.get.return_value = mock_container + + with mock.patch("controller.lib.docker_services.can_manage_containers", return_value=True): + with mock.patch("controller.lib.docker_services._get_client", return_value=mock_client): + result = get_container_status("api") + assert result["status"] == "stopped" + + +class TestGetAllContainersStatus: + """Tests for get_all_containers_status function.""" + + def test_returns_empty_when_cannot_manage(self): + """Should return empty list when can't manage containers.""" + with mock.patch("controller.lib.docker_services.can_manage_containers", return_value=False): + result = get_all_containers_status() + assert result == [] + + def test_returns_container_list(self): + """Should return list of container statuses.""" + mock_container = mock.Mock() + mock_container.name = f"{CONTAINER_PREFIX}-api-1" + mock_container.status = "running" + mock_container.attrs = {"NetworkSettings": {"Ports": {}}} + + mock_client = mock.Mock() + mock_client.containers.list.return_value = [mock_container] + + with mock.patch("controller.lib.docker_services.can_manage_containers", return_value=True): + with mock.patch("controller.lib.docker_services._get_client", return_value=mock_client): + result = get_all_containers_status() + assert len(result) == 1 + assert result[0]["name"] == "api" + + def test_db_container_not_viewable(self): + """Database container should not be viewable (port 5432 is not HTTP).""" + mock_container = mock.Mock() + mock_container.name = f"{CONTAINER_PREFIX}-db-1" + mock_container.status = "running" + mock_container.attrs = {"NetworkSettings": {"Ports": {"5432/tcp": [{"HostPort": "5432"}]}}} + + mock_client = mock.Mock() + mock_client.containers.list.return_value = [mock_container] + + with mock.patch("controller.lib.docker_services.can_manage_containers", return_value=True): + with mock.patch("controller.lib.docker_services._get_client", return_value=mock_client): + result = get_all_containers_status() + assert len(result) == 1 + assert result[0]["name"] == "db" + assert result[0]["viewable"] is False + + def test_api_container_is_viewable(self): + """API container should be viewable (HTTP service).""" + mock_container = mock.Mock() + mock_container.name = f"{CONTAINER_PREFIX}-api-1" + mock_container.status = "running" + mock_container.attrs = {"NetworkSettings": {"Ports": {"8081/tcp": [{"HostPort": "8081"}]}}} + + mock_client = mock.Mock() + mock_client.containers.list.return_value = [mock_container] + + with mock.patch("controller.lib.docker_services.can_manage_containers", return_value=True): + with mock.patch("controller.lib.docker_services._get_client", return_value=mock_client): + result = get_all_containers_status() + assert len(result) == 1 + assert result[0]["name"] == "api" + assert result[0]["viewable"] is True + + def test_https_container_uses_https(self): + """Containers with _https suffix should have use_https=True.""" + mock_container = mock.Mock() + mock_container.name = f"{CONTAINER_PREFIX}-help_https-1" + mock_container.status = "running" + mock_container.attrs = {"NetworkSettings": {"Ports": {"8086/tcp": [{"HostPort": "8086"}]}}} + + mock_client = mock.Mock() + mock_client.containers.list.return_value = [mock_container] + + with mock.patch("controller.lib.docker_services.can_manage_containers", return_value=True): + with mock.patch("controller.lib.docker_services._get_client", return_value=mock_client): + result = get_all_containers_status() + assert len(result) == 1 + assert result[0]["name"] == "help_https" + assert result[0]["use_https"] is True + + def test_non_https_container_uses_http(self): + """Containers without _https suffix should have use_https=False.""" + mock_container = mock.Mock() + mock_container.name = f"{CONTAINER_PREFIX}-api-1" + mock_container.status = "running" + mock_container.attrs = {"NetworkSettings": {"Ports": {"8081/tcp": [{"HostPort": "8081"}]}}} + + mock_client = mock.Mock() + mock_client.containers.list.return_value = [mock_container] + + with mock.patch("controller.lib.docker_services.can_manage_containers", return_value=True): + with mock.patch("controller.lib.docker_services._get_client", return_value=mock_client): + result = get_all_containers_status() + assert len(result) == 1 + assert result[0]["name"] == "api" + assert result[0].get("use_https", False) is False + + def test_web_container_uses_https(self): + """Web container (nginx proxy) should have use_https=True.""" + mock_container = mock.Mock() + mock_container.name = f"{CONTAINER_PREFIX}-web-1" + mock_container.status = "running" + mock_container.attrs = {"NetworkSettings": {"Ports": {"443/tcp": [{"HostPort": "8443"}]}}} + + mock_client = mock.Mock() + mock_client.containers.list.return_value = [mock_container] + + with mock.patch("controller.lib.docker_services.can_manage_containers", return_value=True): + with mock.patch("controller.lib.docker_services._get_client", return_value=mock_client): + result = get_all_containers_status() + assert len(result) == 1 + assert result[0]["name"] == "web" + assert result[0]["use_https"] is True + + def test_filters_by_prefix(self): + """Should only return containers with correct prefix.""" + mock_container1 = mock.Mock() + mock_container1.name = f"{CONTAINER_PREFIX}-api-1" + mock_container1.status = "running" + mock_container1.attrs = {"NetworkSettings": {"Ports": {}}} + + mock_container2 = mock.Mock() + mock_container2.name = "other-container" + mock_container2.status = "running" + mock_container2.attrs = {"NetworkSettings": {"Ports": {}}} + + mock_client = mock.Mock() + mock_client.containers.list.return_value = [mock_container1, mock_container2] + + with mock.patch("controller.lib.docker_services.can_manage_containers", return_value=True): + with mock.patch("controller.lib.docker_services._get_client", return_value=mock_client): + result = get_all_containers_status() + assert len(result) == 1 + assert result[0]["name"] == "api" + + +class TestContainerActions: + """Tests for container action functions.""" + + @pytest.mark.parametrize("func", [ + start_container, + stop_container, + restart_container, + ]) + def test_returns_error_when_cannot_manage(self, func): + """Should return error when can't manage containers.""" + with mock.patch("controller.lib.docker_services.can_manage_containers", return_value=False): + result = func("api") + assert result["success"] is False + + @pytest.mark.parametrize("func,method_name,expected_action,call_kwargs", [ + (start_container, "start", "start", {}), + (stop_container, "stop", "stop", {"timeout": 30}), + (restart_container, "restart", "restart", {"timeout": 30}), + ]) + def test_performs_container_action(self, func, method_name, expected_action, call_kwargs): + """Should perform the correct container action.""" + mock_container = mock.Mock() + mock_client = mock.Mock() + mock_client.containers.get.return_value = mock_container + + with mock.patch("controller.lib.docker_services.can_manage_containers", return_value=True): + with mock.patch("controller.lib.docker_services._get_client", return_value=mock_client): + result = func("api") + method = getattr(mock_container, method_name) + if call_kwargs: + method.assert_called_once_with(**call_kwargs) + else: + method.assert_called_once() + assert result["success"] is True + assert result["action"] == expected_action + + +class TestGetContainerLogs: + """Tests for get_container_logs function.""" + + def test_returns_error_when_cannot_manage(self): + """Should return error when can't manage containers.""" + with mock.patch("controller.lib.docker_services.can_manage_containers", return_value=False): + result = get_container_logs("api") + assert "error" in result + + def test_returns_logs(self): + """Should return container logs.""" + mock_container = mock.Mock() + mock_container.logs.return_value = b"log line 1\nlog line 2" + + mock_client = mock.Mock() + mock_client.containers.get.return_value = mock_container + + with mock.patch("controller.lib.docker_services.can_manage_containers", return_value=True): + with mock.patch("controller.lib.docker_services._get_client", return_value=mock_client): + result = get_container_logs("api", lines=50) + mock_container.logs.assert_called_once_with(tail=50) + assert result["logs"] == "log line 1\nlog line 2" + assert result["service"] == "api" + assert result["lines"] == 50 diff --git a/controller/test/test_fstab.py b/controller/test/test_fstab.py new file mode 100644 index 00000000..0754f231 --- /dev/null +++ b/controller/test/test_fstab.py @@ -0,0 +1,154 @@ +""" +Unit tests for controller.lib.fstab module. +""" + +from unittest import mock + +from controller.lib.fstab import ( + parse_fstab, + add_fstab_entry, + remove_fstab_entry, + get_wrolpi_fstab_entries, +) + + +class TestParseFstab: + """Tests for parse_fstab function.""" + + def test_parses_mount_entries(self): + """Should parse mount entries from fstab.""" + fstab_content = """\ +# /etc/fstab +UUID=1234-5678 / ext4 defaults 0 1 +UUID=abcd-efgh /media/wrolpi ext4 defaults,nofail 0 2 +""" + with mock.patch("builtins.open", mock.mock_open(read_data=fstab_content)): + result = parse_fstab() + mounts = [e for e in result if e["type"] == "mount"] + assert len(mounts) == 2 + assert mounts[0]["device"] == "UUID=1234-5678" + assert mounts[0]["mount_point"] == "/" + assert mounts[1]["mount_point"] == "/media/wrolpi" + + def test_preserves_comments(self): + """Should preserve comments and blank lines.""" + fstab_content = """\ +# This is a comment + +UUID=1234 / ext4 defaults 0 1 +""" + with mock.patch("builtins.open", mock.mock_open(read_data=fstab_content)): + result = parse_fstab() + comments = [e for e in result if e["type"] == "comment"] + assert len(comments) == 2 + + def test_handles_minimal_entries(self): + """Should handle entries with minimal fields.""" + fstab_content = "UUID=1234 /media/data ext4 defaults\n" + with mock.patch("builtins.open", mock.mock_open(read_data=fstab_content)): + result = parse_fstab() + mount = [e for e in result if e["type"] == "mount"][0] + assert mount["dump"] == "0" + assert mount["pass"] == "0" + + +class TestAddFstabEntry: + """Tests for add_fstab_entry function.""" + + def test_blocked_in_wrol_mode(self): + """Should be blocked in WROL mode.""" + with mock.patch("controller.lib.fstab.require_normal_mode", side_effect=PermissionError("WROL")): + result = add_fstab_entry("/dev/sda1", "/media/test", "ext4") + assert result["success"] is False + assert "WROL" in result["error"] + + def test_validates_mount_point(self): + """Should validate mount point.""" + with mock.patch("controller.lib.fstab.require_normal_mode"): + result = add_fstab_entry("/dev/sda1", "/mnt/test", "ext4") + assert result["success"] is False + assert "must be under /media" in result["error"] + + def test_converts_device_to_uuid(self): + """Should convert device path to UUID.""" + fstab_content = "# empty\n" + with mock.patch("controller.lib.fstab.require_normal_mode"): + with mock.patch("controller.lib.fstab.get_uuid", return_value="1234-5678"): + with mock.patch("controller.lib.fstab.backup_fstab"): + with mock.patch("builtins.open", mock.mock_open(read_data=fstab_content)): + with mock.patch("subprocess.run"): + result = add_fstab_entry("/dev/sda1", "/media/test", "ext4") + assert result["success"] is True + assert result["device"] == "UUID=1234-5678" + + def test_adds_entry_to_fstab(self): + """Should add entry to fstab.""" + fstab_content = "# existing\n" + written_content = [] + + def mock_write(content): + written_content.append(content) + + mock_file = mock.mock_open(read_data=fstab_content) + mock_file().write = mock_write + + with mock.patch("controller.lib.fstab.require_normal_mode"): + with mock.patch("controller.lib.fstab.get_uuid", return_value=None): + with mock.patch("controller.lib.fstab.backup_fstab"): + with mock.patch("builtins.open", mock_file): + with mock.patch("subprocess.run"): + result = add_fstab_entry("/dev/sda1", "/media/test", "ext4") + assert result["success"] is True + # Check that something was written + assert len(written_content) > 0 + + +class TestRemoveFstabEntry: + """Tests for remove_fstab_entry function.""" + + def test_blocked_in_wrol_mode(self): + """Should be blocked in WROL mode.""" + with mock.patch("controller.lib.fstab.require_normal_mode", side_effect=PermissionError("WROL")): + result = remove_fstab_entry("/media/test") + assert result["success"] is False + + def test_validates_mount_point(self): + """Should validate mount point.""" + with mock.patch("controller.lib.fstab.require_normal_mode"): + result = remove_fstab_entry("/mnt/test") + assert result["success"] is False + + def test_returns_error_if_not_found(self): + """Should return error if entry not found.""" + fstab_content = "UUID=1234 / ext4 defaults 0 1\n" + with mock.patch("controller.lib.fstab.require_normal_mode"): + with mock.patch("controller.lib.fstab.backup_fstab"): + with mock.patch("builtins.open", mock.mock_open(read_data=fstab_content)): + result = remove_fstab_entry("/media/nonexistent") + assert result["success"] is False + assert "No entry found" in result["error"] + + +class TestGetWrolpiFstabEntries: + """Tests for get_wrolpi_fstab_entries function.""" + + def test_returns_wrolpi_entries(self): + """Should return entries for /media/wrolpi.""" + fstab_content = """\ +UUID=1234 / ext4 defaults 0 1 +UUID=5678 /media/wrolpi ext4 defaults 0 2 +UUID=9abc /media/wrolpi/data ext4 defaults 0 2 +UUID=defg /media/usb vfat defaults 0 0 +""" + with mock.patch("builtins.open", mock.mock_open(read_data=fstab_content)): + result = get_wrolpi_fstab_entries() + assert len(result) == 2 + assert result[0]["mount_point"] == "/media/wrolpi" + assert result[1]["mount_point"] == "/media/wrolpi/data" + + def test_returns_empty_if_no_wrolpi_entries(self): + """Should return empty list if no WROLPi entries.""" + fstab_content = "UUID=1234 / ext4 defaults 0 1\n" + with mock.patch("builtins.open", mock.mock_open(read_data=fstab_content)): + result = get_wrolpi_fstab_entries() + assert result == [] diff --git a/controller/test/test_schemas.py b/controller/test/test_schemas.py new file mode 100644 index 00000000..c4cf0f42 --- /dev/null +++ b/controller/test/test_schemas.py @@ -0,0 +1,50 @@ +""" +Unit tests for controller.api.schemas module. +""" + +import pytest +from pydantic import ValidationError + +from controller.api.schemas import HealthResponse + + +class TestHealthResponse: + """Tests for HealthResponse model.""" + + def test_valid_health_response(self): + """Should create valid health response.""" + response = HealthResponse( + status="healthy", + version="1.0.0", + docker_mode=False, + drive_mounted=True, + ) + assert response.status == "healthy" + assert response.version == "1.0.0" + assert response.docker_mode is False + assert response.drive_mounted is True + + def test_health_response_serialization(self): + """Should serialize to dict correctly.""" + response = HealthResponse( + status="healthy", + version="1.0.0", + docker_mode=True, + drive_mounted=False, + ) + data = response.model_dump() + assert data == { + "status": "healthy", + "version": "1.0.0", + "docker_mode": True, + "drive_mounted": False, + } + + def test_health_response_missing_field(self): + """Should raise validation error for missing required field.""" + with pytest.raises(ValidationError): + HealthResponse( + status="healthy", + version="1.0.0", + # missing docker_mode and drive_mounted + ) diff --git a/controller/test/test_services_api.py b/controller/test/test_services_api.py new file mode 100644 index 00000000..ccb96721 --- /dev/null +++ b/controller/test/test_services_api.py @@ -0,0 +1,179 @@ +""" +Integration tests for Controller services API endpoints. +""" + +from unittest import mock + +import pytest + + +class TestServicesListEndpoint: + """Tests for /api/services endpoint.""" + + def test_services_list_returns_200(self, test_client): + """Services list endpoint should return 200 OK.""" + response = test_client.get("/api/services") + assert response.status_code == 200 + + def test_services_list_returns_list_or_error(self, test_client): + """Services list should return list of services or error object.""" + response = test_client.get("/api/services") + data = response.json() + # Should be a list (when docker can manage) or error dict + assert isinstance(data, (list, dict)) + + +class TestServicesListDockerMode: + """Tests for /api/services in Docker mode.""" + + def test_returns_containers_when_docker_available(self, test_client_docker_mode): + """Should return container status when Docker is available.""" + mock_containers = [ + {"name": "api", "status": "running", "container_name": "wrolpi-api-1"} + ] + with mock.patch( + "controller.lib.docker_services.can_manage_containers", + return_value=True + ): + with mock.patch( + "controller.lib.docker_services.get_all_containers_status", + return_value=mock_containers + ): + # Also patch in the main module + with mock.patch( + "controller.main.can_manage_containers", + return_value=True + ): + with mock.patch( + "controller.main.get_all_containers_status", + return_value=mock_containers + ): + with mock.patch( + "controller.api.services.can_manage_containers", + return_value=True + ): + with mock.patch( + "controller.api.services.get_all_containers_status", + return_value=mock_containers + ): + response = test_client_docker_mode.get("/api/services") + assert response.status_code == 200 + + +class TestServiceStatusEndpoint: + """Tests for /api/services/{name} endpoint.""" + + def test_service_status_returns_status(self, test_client): + """Should return service status.""" + with mock.patch( + "controller.api.services.get_service_status", + return_value={ + "name": "wrolpi-api", + "status": "running", + "systemd_name": "wrolpi-api.service", + } + ): + response = test_client.get("/api/services/wrolpi-api") + assert response.status_code == 200 + data = response.json() + assert data["name"] == "wrolpi-api" + + def test_unknown_service_returns_404(self, test_client): + """Should return 404 for unknown service.""" + with mock.patch( + "controller.api.services.get_service_status", + return_value={"error": "Unknown service: nonexistent"} + ): + response = test_client.get("/api/services/nonexistent") + assert response.status_code == 404 + + +class TestServiceActionEndpoints: + """Tests for service action endpoints (start, stop, restart, enable, disable).""" + + @pytest.mark.parametrize("action,endpoint,mock_func", [ + ("start", "/api/services/wrolpi-api/start", "start_service"), + ("stop", "/api/services/wrolpi-api/stop", "stop_service"), + ("restart", "/api/services/wrolpi-api/restart", "restart_service"), + ("enable", "/api/services/wrolpi-api/enable", "enable_service"), + ("disable", "/api/services/wrolpi-api/disable", "disable_service"), + ]) + def test_action_success(self, test_client, action, endpoint, mock_func): + """Should perform service action successfully.""" + with mock.patch( + f"controller.api.services.{mock_func}", + return_value={"success": True, "service": "wrolpi-api", "action": action} + ): + response = test_client.post(endpoint) + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert data["action"] == action + + def test_start_failure_returns_500(self, test_client): + """Should return 500 on start failure.""" + with mock.patch( + "controller.api.services.start_service", + return_value={"success": False, "error": "Failed to start"} + ): + response = test_client.post("/api/services/wrolpi-api/start") + assert response.status_code == 500 + + @pytest.mark.parametrize("endpoint", [ + "/api/services/wrolpi-api/enable", + "/api/services/wrolpi-api/disable", + ]) + def test_enable_disable_not_available_in_docker(self, test_client_docker_mode, endpoint): + """Should return 501 in Docker mode for enable/disable.""" + response = test_client_docker_mode.post(endpoint) + assert response.status_code == 501 + + +class TestServiceLogsEndpoint: + """Tests for /api/services/{name}/logs endpoint.""" + + def test_logs_returns_200(self, test_client): + """Should return logs successfully.""" + with mock.patch( + "controller.api.services.get_service_logs", + return_value={"service": "wrolpi-api", "lines": 100, "logs": "test logs"} + ): + response = test_client.get("/api/services/wrolpi-api/logs") + assert response.status_code == 200 + data = response.json() + assert "logs" in data + + def test_logs_accepts_lines_parameter(self, test_client): + """Should accept lines query parameter.""" + with mock.patch( + "controller.api.services.get_service_logs", + return_value={"service": "wrolpi-api", "lines": 50, "logs": "logs"} + ): + response = test_client.get("/api/services/wrolpi-api/logs?lines=50") + assert response.status_code == 200 + + def test_logs_accepts_since_parameter(self, test_client): + """Should accept since query parameter.""" + with mock.patch( + "controller.api.services.get_service_logs", + return_value={"service": "wrolpi-api", "lines": 100, "since": "1h", "logs": "logs"} + ): + response = test_client.get("/api/services/wrolpi-api/logs?since=1h") + assert response.status_code == 200 + + +class TestOpenAPIIncludesServicesEndpoints: + """Tests that OpenAPI documentation includes services endpoints.""" + + def test_openapi_has_services_paths(self, test_client): + """OpenAPI schema should include services paths.""" + response = test_client.get("/openapi.json") + data = response.json() + assert "/api/services" in data["paths"] + assert "/api/services/{name}" in data["paths"] + assert "/api/services/{name}/start" in data["paths"] + assert "/api/services/{name}/stop" in data["paths"] + assert "/api/services/{name}/restart" in data["paths"] + assert "/api/services/{name}/enable" in data["paths"] + assert "/api/services/{name}/disable" in data["paths"] + assert "/api/services/{name}/logs" in data["paths"] diff --git a/controller/test/test_smart.py b/controller/test/test_smart.py new file mode 100644 index 00000000..fa6cc618 --- /dev/null +++ b/controller/test/test_smart.py @@ -0,0 +1,169 @@ +""" +Unit tests for controller.lib.smart module. +""" + +from unittest import mock + +from controller.lib.smart import ( + is_smart_available, + get_all_smart_status, + _get_device_smart, + _get_temperature, + _get_attribute, +) + + +class TestIsSmartAvailable: + """Tests for is_smart_available function.""" + + def test_returns_false_in_docker_mode(self): + """Should return False in Docker mode.""" + with mock.patch("controller.lib.smart.is_docker_mode", return_value=True): + assert is_smart_available() is False + + def test_returns_false_when_pysmart_not_installed(self): + """Should return False when pySMART not installed.""" + with mock.patch("controller.lib.smart.is_docker_mode", return_value=False): + with mock.patch("controller.lib.smart.SMART_AVAILABLE", False): + assert is_smart_available() is False + + def test_returns_true_when_available(self): + """Should return True when pySMART available and not Docker.""" + with mock.patch("controller.lib.smart.is_docker_mode", return_value=False): + with mock.patch("controller.lib.smart.SMART_AVAILABLE", True): + assert is_smart_available() is True + + +class TestGetAllSmartStatus: + """Tests for get_all_smart_status function.""" + + def test_returns_empty_when_not_available(self): + """Should return empty list when SMART not available.""" + with mock.patch("controller.lib.smart.is_smart_available", return_value=False): + result = get_all_smart_status() + assert result == [] + + def test_returns_device_list(self): + """Should return list of device statuses.""" + mock_device = mock.Mock() + mock_device.name = "sda" + mock_device.model = "Samsung SSD" + mock_device.serial = "S1234" + mock_device.capacity = "500 GB" + mock_device.assessment = "PASS" + mock_device.smart_enabled = True + mock_device.attributes = [] + + mock_device_list = mock.Mock() + mock_device_list.devices = [mock_device] + + with mock.patch("controller.lib.smart.is_smart_available", return_value=True): + with mock.patch("controller.lib.smart.DeviceList", return_value=mock_device_list): + result = get_all_smart_status() + assert len(result) == 1 + assert result[0]["device"] == "sda" + assert result[0]["model"] == "Samsung SSD" + + +class TestGetDeviceSmart: + """Tests for _get_device_smart function.""" + + def test_extracts_device_info(self): + """Should extract device information.""" + mock_device = mock.Mock() + mock_device.name = "sda" + mock_device.model = "Samsung SSD" + mock_device.serial = "S1234" + mock_device.capacity = "500 GB" + mock_device.assessment = "PASS" + mock_device.smart_enabled = True + mock_device.attributes = [] + + result = _get_device_smart(mock_device) + assert result["device"] == "sda" + assert result["path"] == "/dev/sda" + assert result["model"] == "Samsung SSD" + assert result["serial"] == "S1234" + assert result["capacity"] == "500 GB" + assert result["assessment"] == "PASS" + assert result["smart_enabled"] is True + + +class TestGetTemperature: + """Tests for _get_temperature function.""" + + def test_returns_temperature_celsius(self): + """Should return temperature from Temperature_Celsius attribute.""" + mock_attr = mock.Mock() + mock_attr.name = "Temperature_Celsius" + mock_attr.raw = "35" + + mock_device = mock.Mock() + mock_device.attributes = [mock_attr] + + result = _get_temperature(mock_device) + assert result == 35 + + def test_falls_back_to_airflow_temperature(self): + """Should fall back to Airflow_Temperature_Cel.""" + mock_attr = mock.Mock() + mock_attr.name = "Airflow_Temperature_Cel" + mock_attr.raw = "40" + + mock_device = mock.Mock() + mock_device.attributes = [mock_attr] + + result = _get_temperature(mock_device) + assert result == 40 + + def test_returns_none_if_not_found(self): + """Should return None if no temperature attribute.""" + mock_device = mock.Mock() + mock_device.attributes = [] + + result = _get_temperature(mock_device) + assert result is None + + +class TestGetAttribute: + """Tests for _get_attribute function.""" + + def test_returns_attribute_value(self): + """Should return attribute raw value.""" + mock_attr = mock.Mock() + mock_attr.name = "Power_On_Hours" + mock_attr.raw = "12345" + + mock_device = mock.Mock() + mock_device.attributes = [mock_attr] + + result = _get_attribute(mock_device, "Power_On_Hours") + assert result == 12345 + + def test_returns_none_for_missing_attribute(self): + """Should return None if attribute not found.""" + mock_device = mock.Mock() + mock_device.attributes = [] + + result = _get_attribute(mock_device, "Nonexistent") + assert result is None + + def test_returns_none_if_no_attributes(self): + """Should return None if device has no attributes.""" + mock_device = mock.Mock() + mock_device.attributes = None + + result = _get_attribute(mock_device, "Power_On_Hours") + assert result is None + + def test_handles_invalid_raw_value(self): + """Should handle non-numeric raw values.""" + mock_attr = mock.Mock() + mock_attr.name = "Power_On_Hours" + mock_attr.raw = "invalid" + + mock_device = mock.Mock() + mock_device.attributes = [mock_attr] + + result = _get_attribute(mock_device, "Power_On_Hours") + assert result is None diff --git a/controller/test/test_status.py b/controller/test/test_status.py new file mode 100644 index 00000000..f5589ad0 --- /dev/null +++ b/controller/test/test_status.py @@ -0,0 +1,209 @@ +""" +Unit tests for controller.lib.status module. + +The status module now returns formats compatible with the main WROLPi API +so the React app can seamlessly use either source. +""" + +from unittest import mock + +from controller.lib.status import ( + get_cpu_status, + get_drive_status, + get_load_status, + get_memory_status, + get_network_status, + get_power_status, + get_primary_drive_status, +) + + +class TestGetCpuStatus: + """Tests for get_cpu_status function.""" + + def test_returns_dict(self): + """Should return a dictionary.""" + result = get_cpu_status() + assert isinstance(result, dict) + + def test_has_required_keys(self): + """Should have all required keys (main API format).""" + result = get_cpu_status() + assert "percent" in result + assert "cores" in result + assert "cur_frequency" in result + assert "max_frequency" in result + assert "min_frequency" in result + assert "temperature" in result + assert "high_temperature" in result + assert "critical_temperature" in result + + def test_percent_is_numeric(self): + """CPU percent should be a number.""" + result = get_cpu_status() + assert isinstance(result["percent"], (int, float)) + assert 0 <= result["percent"] <= 100 + + def test_cores_is_positive(self): + """Core count should be a positive integer.""" + result = get_cpu_status() + assert isinstance(result["cores"], int) + assert result["cores"] > 0 + + +class TestGetMemoryStatus: + """Tests for get_memory_status function.""" + + def test_returns_dict(self): + """Should return a dictionary.""" + result = get_memory_status() + assert isinstance(result, dict) + + def test_has_required_keys(self): + """Should have all required keys (main API format).""" + result = get_memory_status() + assert "total" in result + assert "used" in result + assert "free" in result + assert "cached" in result + + def test_total_is_positive(self): + """Memory total should be positive.""" + result = get_memory_status() + assert result["total"] > 0 + + def test_bytes_are_non_negative(self): + """Memory bytes should be non-negative.""" + result = get_memory_status() + assert result["used"] >= 0 + assert result["free"] >= 0 + assert result["cached"] >= 0 + + +class TestGetLoadStatus: + """Tests for get_load_status function.""" + + def test_returns_dict(self): + """Should return a dictionary.""" + result = get_load_status() + assert isinstance(result, dict) + + def test_has_required_keys(self): + """Should have all required keys (main API format).""" + result = get_load_status() + assert "minute_1" in result + assert "minute_5" in result + assert "minute_15" in result + + def test_values_are_strings(self): + """Load values should be string representations of numbers (main API format).""" + result = get_load_status() + # Values are strings in the main API format + assert isinstance(result["minute_1"], str) + assert isinstance(result["minute_5"], str) + assert isinstance(result["minute_15"], str) + # But they should be parseable as floats + assert float(result["minute_1"]) >= 0 + assert float(result["minute_5"]) >= 0 + assert float(result["minute_15"]) >= 0 + + +class TestGetDriveStatus: + """Tests for get_drive_status function.""" + + def test_returns_list(self): + """Should return a list.""" + result = get_drive_status() + assert isinstance(result, list) + + def test_returns_drives_or_empty(self): + """Should return drives list (may be empty in Docker containers).""" + result = get_drive_status() + # The result should be a valid list + # May be empty in Docker containers where root fs is not visible + assert isinstance(result, list) + + def test_drive_has_required_keys(self): + """Each drive should have all required keys (main API format).""" + result = get_drive_status() + if result: # Only test if we have drives + drive = result[0] + assert "mount" in drive + assert "percent" in drive + assert "size" in drive + assert "used" in drive + + +class TestGetPrimaryDriveStatus: + """Tests for get_primary_drive_status function.""" + + def test_returns_none_when_not_mounted(self): + """Should return None when /media/wrolpi is not mounted.""" + # In most test environments, /media/wrolpi won't be mounted + result = get_primary_drive_status() + # This could be None or a dict depending on the environment + assert result is None or isinstance(result, dict) + + def test_returns_dict_when_mounted(self): + """Should return drive info when /media/wrolpi is mounted.""" + # Mock the get_drive_status to return a drive at /media/wrolpi + mock_drives = [ + { + "mount": "/media/wrolpi", + "percent": 50, + "size": 1000000000, + "used": 500000000, + } + ] + with mock.patch( + "controller.lib.status.get_drive_status", return_value=mock_drives + ): + result = get_primary_drive_status() + assert result is not None + assert result["mount"] == "/media/wrolpi" + + +class TestGetNetworkStatus: + """Tests for get_network_status function.""" + + def test_returns_dict(self): + """Should return a dict (main API format: dict of interface name -> stats).""" + result = get_network_status() + assert isinstance(result, dict) + + def test_excludes_loopback(self): + """Should not include the loopback interface.""" + result = get_network_status() + assert "lo" not in result + + def test_interface_has_required_keys(self): + """Each interface should have required keys (main API format).""" + result = get_network_status() + if result: # Only test if we have interfaces + iface = list(result.values())[0] + assert "name" in iface + assert "bytes_sent" in iface + assert "bytes_recv" in iface + assert "speed" in iface + assert "now" in iface + + +class TestGetPowerStatus: + """Tests for get_power_status function.""" + + def test_returns_dict(self): + """Should return a dictionary.""" + result = get_power_status() + assert isinstance(result, dict) + + def test_has_required_keys(self): + """Should have all required keys (main API format).""" + result = get_power_status() + assert "under_voltage" in result + assert "over_current" in result + + def test_values_are_bool(self): + """All values should be boolean.""" + result = get_power_status() + assert isinstance(result["under_voltage"], bool) + assert isinstance(result["over_current"], bool) diff --git a/controller/test/test_status_api.py b/controller/test/test_status_api.py new file mode 100644 index 00000000..b5653366 --- /dev/null +++ b/controller/test/test_status_api.py @@ -0,0 +1,191 @@ +""" +Integration tests for Controller stats API endpoints. + +The stats endpoints return system statistics (CPU, memory, etc). +Note: These are separate from the main WROLPi API's /api/status endpoint. +""" + + +class TestCpuEndpoint: + """Tests for /api/stats/cpu endpoint.""" + + def test_cpu_returns_200(self, test_client): + """CPU endpoint should return 200 OK.""" + response = test_client.get("/api/stats/cpu") + assert response.status_code == 200 + + def test_cpu_returns_expected_fields(self, test_client): + """CPU endpoint should return expected fields (main API format).""" + response = test_client.get("/api/stats/cpu") + data = response.json() + assert "percent" in data + assert "cores" in data + assert "cur_frequency" in data + assert "max_frequency" in data + assert "min_frequency" in data + assert "temperature" in data + assert "high_temperature" in data + assert "critical_temperature" in data + + def test_cpu_percent_valid_range(self, test_client): + """CPU percent should be between 0 and 100.""" + response = test_client.get("/api/stats/cpu") + data = response.json() + assert 0 <= data["percent"] <= 100 + + +class TestMemoryEndpoint: + """Tests for /api/stats/memory endpoint.""" + + def test_memory_returns_200(self, test_client): + """Memory endpoint should return 200 OK.""" + response = test_client.get("/api/stats/memory") + assert response.status_code == 200 + + def test_memory_returns_expected_fields(self, test_client): + """Memory endpoint should return expected fields (main API format).""" + response = test_client.get("/api/stats/memory") + data = response.json() + assert "total" in data + assert "used" in data + assert "free" in data + assert "cached" in data + + def test_memory_bytes_positive(self, test_client): + """Memory total should be positive.""" + response = test_client.get("/api/stats/memory") + data = response.json() + assert data["total"] > 0 + + +class TestLoadEndpoint: + """Tests for /api/stats/load endpoint.""" + + def test_load_returns_200(self, test_client): + """Load endpoint should return 200 OK.""" + response = test_client.get("/api/stats/load") + assert response.status_code == 200 + + def test_load_returns_expected_fields(self, test_client): + """Load endpoint should return expected fields (main API format).""" + response = test_client.get("/api/stats/load") + data = response.json() + assert "minute_1" in data + assert "minute_5" in data + assert "minute_15" in data + + def test_load_values_non_negative(self, test_client): + """Load values should be non-negative.""" + response = test_client.get("/api/stats/load") + data = response.json() + # Values are strings in main API format + assert float(data["minute_1"]) >= 0 + assert float(data["minute_5"]) >= 0 + assert float(data["minute_15"]) >= 0 + + +class TestPrimaryDriveEndpoint: + """Tests for /api/stats/drives/primary endpoint.""" + + def test_primary_drive_returns_200(self, test_client): + """Primary drive endpoint should return 200 OK.""" + response = test_client.get("/api/stats/drives/primary") + assert response.status_code == 200 + + def test_primary_drive_returns_mounted_false_when_not_mounted(self, test_client): + """Primary drive should return mounted=false when not present.""" + response = test_client.get("/api/stats/drives/primary") + data = response.json() + # In test environment, /media/wrolpi likely not mounted + assert "mounted" in data + + +class TestNetworkEndpoint: + """Tests for /api/stats/network endpoint.""" + + def test_network_returns_200(self, test_client): + """Network endpoint should return 200 OK.""" + response = test_client.get("/api/stats/network") + assert response.status_code == 200 + + def test_network_returns_dict(self, test_client): + """Network endpoint should return a dict (main API format).""" + response = test_client.get("/api/stats/network") + data = response.json() + assert isinstance(data, dict) + + def test_network_excludes_loopback(self, test_client): + """Network should not include loopback interface.""" + response = test_client.get("/api/stats/network") + data = response.json() + assert "lo" not in data + + +class TestPowerEndpoint: + """Tests for /api/stats/power endpoint.""" + + def test_power_returns_200(self, test_client): + """Power endpoint should return 200 OK.""" + response = test_client.get("/api/stats/power") + assert response.status_code == 200 + + def test_power_returns_expected_fields(self, test_client): + """Power endpoint should return expected fields (main API format).""" + response = test_client.get("/api/stats/power") + data = response.json() + assert "under_voltage" in data + assert "over_current" in data + + def test_power_values_are_bool(self, test_client): + """Power values should be booleans.""" + response = test_client.get("/api/stats/power") + data = response.json() + assert isinstance(data["under_voltage"], bool) + assert isinstance(data["over_current"], bool) + + +class TestDashboardWithRealData: + """Tests for dashboard rendering with real status data.""" + + def test_dashboard_shows_cpu_percent(self, test_client): + """Dashboard should show CPU percentage (not placeholder).""" + response = test_client.get("/") + content = response.text + # CPU value should be a number, not "--" + # The template shows: {{ cpu.percent|default('--') }}% + # We verify the page loads and contains CPU section + assert "CPU" in content + # The dashboard should have some numeric value, not all placeholders + + def test_dashboard_shows_memory_data(self, test_client): + """Dashboard should show memory data.""" + response = test_client.get("/") + content = response.text + assert "Memory" in content + + def test_dashboard_shows_load_data(self, test_client): + """Dashboard should show load data.""" + response = test_client.get("/") + content = response.text + assert "Load" in content + + def test_dashboard_shows_storage_data(self, test_client): + """Dashboard should show storage data.""" + response = test_client.get("/") + content = response.text + assert "Storage" in content + + +class TestOpenAPIIncludesStatsEndpoints: + """Tests that OpenAPI documentation includes stats endpoints.""" + + def test_openapi_has_stats_paths(self, test_client): + """OpenAPI schema should include stats paths.""" + response = test_client.get("/openapi.json") + data = response.json() + assert "/api/stats/cpu" in data["paths"] + assert "/api/stats/memory" in data["paths"] + assert "/api/stats/load" in data["paths"] + assert "/api/stats/drives/primary" in data["paths"] + assert "/api/stats/network" in data["paths"] + assert "/api/stats/power" in data["paths"] diff --git a/controller/test/test_status_worker.py b/controller/test/test_status_worker.py new file mode 100644 index 00000000..69f8159f --- /dev/null +++ b/controller/test/test_status_worker.py @@ -0,0 +1,248 @@ +""" +Tests for the status worker background task and aggregated stats endpoint. +""" + +from datetime import datetime +from unittest import mock + +import pytest + + +class TestCollectAllStatus: + """Tests for collect_all_status function.""" + + @pytest.mark.asyncio + async def test_collects_all_stats(self): + """Should collect all status types.""" + from controller.lib.status_worker import collect_all_status + + status = await collect_all_status() + + assert "cpu_stats" in status + assert "memory_stats" in status + assert "load_stats" in status + assert "drives_stats" in status + assert "nic_bandwidth_stats" in status + assert "power_stats" in status + assert "last_status" in status + + @pytest.mark.asyncio + async def test_last_status_is_iso_timestamp(self): + """Should include a valid ISO timestamp.""" + from controller.lib.status_worker import collect_all_status + + status = await collect_all_status() + + # Should be parseable as ISO datetime + datetime.fromisoformat(status["last_status"]) + + @pytest.mark.asyncio + async def test_handles_cpu_failure(self): + """Should handle failures in CPU stat collector.""" + from controller.lib.status_worker import collect_all_status + + with mock.patch( + "controller.lib.status_worker.get_cpu_status", + side_effect=Exception("CPU error") + ): + status = await collect_all_status() + + # CPU should be None but others should work + assert status["cpu_stats"] is None + assert status["memory_stats"] is not None + + @pytest.mark.asyncio + async def test_handles_memory_failure(self): + """Should handle failures in memory stat collector.""" + from controller.lib.status_worker import collect_all_status + + with mock.patch( + "controller.lib.status_worker.get_memory_status", + side_effect=Exception("Memory error") + ): + status = await collect_all_status() + + assert status["memory_stats"] is None + assert status["cpu_stats"] is not None + + @pytest.mark.asyncio + async def test_drives_stats_is_list_on_failure(self): + """Should return empty list for drives on failure.""" + from controller.lib.status_worker import collect_all_status + + with mock.patch( + "controller.lib.status_worker.get_drive_status", + side_effect=Exception("Drive error") + ): + status = await collect_all_status() + + assert status["drives_stats"] == [] + + @pytest.mark.asyncio + async def test_nic_stats_is_dict_on_failure(self): + """Should return empty dict for network on failure.""" + from controller.lib.status_worker import collect_all_status + + with mock.patch( + "controller.lib.status_worker.get_network_status", + side_effect=Exception("Network error") + ): + status = await collect_all_status() + + assert status["nic_bandwidth_stats"] == {} + + +class TestGetAdaptiveSleep: + """Tests for adaptive sleep calculation.""" + + def test_returns_base_sleep_when_load_is_low(self): + """Should return base sleep when load is below CPU count.""" + from controller.lib.status_worker import get_adaptive_sleep + + with mock.patch("multiprocessing.cpu_count", return_value=4): + load_stats = {"minute_1": "0.5"} + sleep = get_adaptive_sleep(load_stats, base_sleep=5.0) + assert sleep == 5.0 + + def test_returns_base_sleep_when_load_equals_cpu_count(self): + """Should return base sleep when load equals CPU count.""" + from controller.lib.status_worker import get_adaptive_sleep + + with mock.patch("multiprocessing.cpu_count", return_value=4): + load_stats = {"minute_1": "4.0"} + sleep = get_adaptive_sleep(load_stats, base_sleep=5.0) + assert sleep == 5.0 + + def test_increases_sleep_when_load_exceeds_cpu_count(self): + """Should increase sleep when load exceeds CPU count.""" + from controller.lib.status_worker import get_adaptive_sleep + + with mock.patch("multiprocessing.cpu_count", return_value=4): + load_stats = {"minute_1": "8.0"} # 2x CPU count + sleep = get_adaptive_sleep(load_stats, base_sleep=5.0) + # Expected: (5.0 * 8.0) / 4 = 10.0 + assert sleep == 10.0 + + def test_handles_none_load_stats(self): + """Should return base sleep when load_stats is None.""" + from controller.lib.status_worker import get_adaptive_sleep + + sleep = get_adaptive_sleep(None, base_sleep=5.0) + assert sleep == 5.0 + + def test_handles_empty_load_stats(self): + """Should return base sleep when load_stats is empty dict.""" + from controller.lib.status_worker import get_adaptive_sleep + + sleep = get_adaptive_sleep({}, base_sleep=5.0) + assert sleep == 5.0 + + def test_handles_invalid_load_value(self): + """Should return base sleep when load value is invalid.""" + from controller.lib.status_worker import get_adaptive_sleep + + load_stats = {"minute_1": "invalid"} + sleep = get_adaptive_sleep(load_stats, base_sleep=5.0) + assert sleep == 5.0 + + def test_handles_missing_minute_1_key(self): + """Should return base sleep when minute_1 key is missing.""" + from controller.lib.status_worker import get_adaptive_sleep + + load_stats = {"minute_5": "2.0"} + sleep = get_adaptive_sleep(load_stats, base_sleep=5.0) + assert sleep == 5.0 + + +class TestAggregatedStatsEndpoint: + """Tests for the new /api/stats aggregated endpoint.""" + + def test_aggregated_stats_returns_200(self, test_client): + """Aggregated stats endpoint should return 200 OK.""" + response = test_client.get("/api/stats") + assert response.status_code == 200 + + def test_aggregated_stats_returns_all_fields(self, test_client): + """Aggregated stats should include all status types.""" + response = test_client.get("/api/stats") + data = response.json() + + assert "cpu_stats" in data + assert "memory_stats" in data + assert "load_stats" in data + assert "drives_stats" in data + assert "nic_bandwidth_stats" in data + assert "power_stats" in data + assert "last_status" in data + + def test_aggregated_stats_has_valid_timestamp(self, test_client): + """Should include a timestamp of when data was collected.""" + response = test_client.get("/api/stats") + data = response.json() + + assert "last_status" in data + # Should be ISO format timestamp + datetime.fromisoformat(data["last_status"]) + + def test_aggregated_stats_cpu_has_expected_fields(self, test_client): + """CPU stats should have expected fields.""" + response = test_client.get("/api/stats") + data = response.json() + + cpu = data["cpu_stats"] + assert cpu is not None + assert "percent" in cpu + assert "cores" in cpu + + def test_aggregated_stats_memory_has_expected_fields(self, test_client): + """Memory stats should have expected fields.""" + response = test_client.get("/api/stats") + data = response.json() + + memory = data["memory_stats"] + assert memory is not None + assert "total" in memory + assert "used" in memory + assert "free" in memory + + def test_aggregated_stats_load_has_expected_fields(self, test_client): + """Load stats should have expected fields.""" + response = test_client.get("/api/stats") + data = response.json() + + load = data["load_stats"] + assert load is not None + assert "minute_1" in load + assert "minute_5" in load + assert "minute_15" in load + + def test_aggregated_stats_drives_is_list(self, test_client): + """Drives stats should be a list.""" + response = test_client.get("/api/stats") + data = response.json() + + assert isinstance(data["drives_stats"], list) + + def test_aggregated_stats_network_is_dict(self, test_client): + """Network stats should be a dict.""" + response = test_client.get("/api/stats") + data = response.json() + + assert isinstance(data["nic_bandwidth_stats"], dict) + + def test_aggregated_stats_trailing_slash(self, test_client): + """Aggregated stats should work with trailing slash.""" + response = test_client.get("/api/stats/") + assert response.status_code == 200 + + +class TestOpenAPIIncludesAggregatedStats: + """Test that OpenAPI includes the new aggregated endpoint.""" + + def test_openapi_has_aggregated_stats_path(self, test_client): + """OpenAPI schema should include /api/stats path.""" + response = test_client.get("/openapi.json") + data = response.json() + # Check for either with or without trailing slash + has_path = "/api/stats" in data["paths"] or "/api/stats/" in data["paths"] + assert has_path, f"Expected /api/stats in paths, got: {list(data['paths'].keys())}" diff --git a/controller/test/test_systemd.py b/controller/test/test_systemd.py new file mode 100644 index 00000000..4fd1477f --- /dev/null +++ b/controller/test/test_systemd.py @@ -0,0 +1,248 @@ +""" +Unit tests for controller.lib.systemd module. +""" + +from unittest import mock + +import pytest + +from controller.lib.systemd import ( + get_managed_services, + get_service_config, + get_service_status, + get_all_services_status, + start_service, + stop_service, + restart_service, + enable_service, + disable_service, + get_service_logs, + _run_systemctl, +) + + +class TestGetManagedServices: + """Tests for get_managed_services function.""" + + def test_returns_list(self): + """Should return a list.""" + result = get_managed_services() + assert isinstance(result, list) + + def test_returns_services_from_config(self): + """Should return services from config.""" + result = get_managed_services() + # Default config has managed_services + assert len(result) > 0 + + def test_each_service_has_name(self): + """Each service should have a name.""" + result = get_managed_services() + for service in result: + assert "name" in service + + +class TestGetServiceConfig: + """Tests for get_service_config function.""" + + def test_returns_config_for_known_service(self): + """Should return config for a known service.""" + # wrolpi-api is in default config + result = get_service_config("wrolpi-api") + assert result is not None + assert result["name"] == "wrolpi-api" + + def test_returns_none_for_unknown_service(self): + """Should return None for unknown service.""" + result = get_service_config("nonexistent-service") + assert result is None + + +class TestRunSystemctl: + """Tests for _run_systemctl function.""" + + def test_returns_dict(self): + """Should return a dictionary.""" + with mock.patch("subprocess.run") as mock_run: + mock_run.return_value = mock.Mock(returncode=0, stdout="active", stderr="") + result = _run_systemctl("is-active", "test.service") + assert isinstance(result, dict) + + def test_success_on_zero_returncode(self): + """Should return success=True when returncode is 0.""" + with mock.patch("subprocess.run") as mock_run: + mock_run.return_value = mock.Mock(returncode=0, stdout="done", stderr="") + result = _run_systemctl("start", "test.service") + assert result["success"] is True + + def test_failure_on_nonzero_returncode(self): + """Should return success=False when returncode is non-zero.""" + with mock.patch("subprocess.run") as mock_run: + mock_run.return_value = mock.Mock(returncode=1, stdout="", stderr="failed") + result = _run_systemctl("start", "test.service") + assert result["success"] is False + assert result["error"] == "failed" + + def test_handles_timeout(self): + """Should handle subprocess timeout.""" + import subprocess + with mock.patch("subprocess.run", side_effect=subprocess.TimeoutExpired("systemctl", 30)): + result = _run_systemctl("start", "test.service") + assert result["success"] is False + assert "timed out" in result["error"].lower() + + def test_handles_file_not_found(self): + """Should handle systemctl not found.""" + with mock.patch("subprocess.run", side_effect=FileNotFoundError()): + result = _run_systemctl("start", "test.service") + assert result["success"] is False + assert "not found" in result["error"].lower() + + +class TestGetServiceStatus: + """Tests for get_service_status function.""" + + def test_returns_error_for_unknown_service(self): + """Should return error for unknown service.""" + result = get_service_status("nonexistent-service") + assert "error" in result + + def test_returns_status_dict(self): + """Should return a status dictionary.""" + with mock.patch("subprocess.run") as mock_run: + mock_run.return_value = mock.Mock(returncode=0, stdout="active", stderr="") + result = get_service_status("wrolpi-api") + assert "name" in result + assert "status" in result + + @pytest.mark.parametrize("systemd_state,expected_status", [ + ("active", "running"), + ("activating", "running"), # oneshot services during execution + ("inactive", "stopped"), + ("failed", "failed"), + ]) + def test_maps_systemd_state_to_status(self, systemd_state, expected_status): + """Should map systemd states to simple status values.""" + with mock.patch("subprocess.run") as mock_run: + mock_run.return_value = mock.Mock(returncode=0, stdout=systemd_state, stderr="") + result = get_service_status("wrolpi-api") + assert result["status"] == expected_status + + @pytest.mark.parametrize("service_name,expected_https", [ + ("wrolpi-help", True), + ("wrolpi-kiwix", True), + ("apache2", True), + ("wrolpi-api", False), + ]) + def test_returns_use_https_field(self, service_name, expected_https): + """Should return use_https field from service config.""" + with mock.patch("subprocess.run") as mock_run: + mock_run.return_value = mock.Mock(returncode=0, stdout="active", stderr="") + result = get_service_status(service_name) + assert result.get("use_https", False) == expected_https + + +class TestGetAllServicesStatus: + """Tests for get_all_services_status function.""" + + def test_returns_list(self): + """Should return a list.""" + with mock.patch("subprocess.run") as mock_run: + mock_run.return_value = mock.Mock(returncode=0, stdout="active", stderr="") + result = get_all_services_status() + assert isinstance(result, list) + + def test_returns_status_for_each_visible_service(self): + """Should return status for each visible managed service.""" + with mock.patch("subprocess.run") as mock_run: + mock_run.return_value = mock.Mock(returncode=0, stdout="active", stderr="") + result = get_all_services_status() + managed = get_managed_services() + # All services should be returned when they're all "running" (active) + assert len(result) == len(managed) + + def test_filters_show_only_when_running_services_when_stopped(self): + """Should exclude show_only_when_running services when they're not running.""" + with mock.patch("subprocess.run") as mock_run: + # Simulate all services as "inactive" (stopped) + mock_run.return_value = mock.Mock(returncode=0, stdout="inactive", stderr="") + result = get_all_services_status() + managed = get_managed_services() + # Count services without show_only_when_running flag + always_visible = [s for s in managed if not s.get("show_only_when_running")] + assert len(result) == len(always_visible) + + def test_includes_show_only_when_running_services_when_running(self): + """Should include show_only_when_running services when they're running.""" + with mock.patch("subprocess.run") as mock_run: + # Simulate all services as "active" (running) + mock_run.return_value = mock.Mock(returncode=0, stdout="active", stderr="") + result = get_all_services_status() + managed = get_managed_services() + # All services should be visible when running + assert len(result) == len(managed) + # Verify wrolpi-upgrade is in the result + service_names = [s["name"] for s in result] + assert "wrolpi-upgrade" in service_names + + +class TestServiceActions: + """Tests for service action functions (start, stop, restart, enable, disable).""" + + @pytest.mark.parametrize("func", [ + start_service, + stop_service, + restart_service, + enable_service, + disable_service, + ]) + def test_returns_error_for_unknown_service(self, func): + """Should return error for unknown service.""" + result = func("nonexistent-service") + assert result["success"] is False + + @pytest.mark.parametrize("func,expected_action", [ + (start_service, "start"), + (stop_service, "stop"), + (restart_service, "restart"), + (enable_service, "enable"), + (disable_service, "disable"), + ]) + def test_calls_correct_systemctl_action(self, func, expected_action): + """Should call systemctl with correct action.""" + with mock.patch("controller.lib.systemd._run_systemctl") as mock_ctl: + mock_ctl.return_value = {"success": True, "output": "", "error": None} + result = func("wrolpi-api") + mock_ctl.assert_called_once() + assert mock_ctl.call_args[0][0] == expected_action + assert result["success"] is True + assert result["action"] == expected_action + + +class TestGetServiceLogs: + """Tests for get_service_logs function.""" + + def test_returns_error_for_unknown_service(self): + """Should return error for unknown service.""" + result = get_service_logs("nonexistent-service") + assert "error" in result + + def test_returns_logs(self): + """Should return logs.""" + with mock.patch("subprocess.run") as mock_run: + mock_run.return_value = mock.Mock(returncode=0, stdout="log line 1\nlog line 2", stderr="") + result = get_service_logs("wrolpi-api", lines=10) + assert "logs" in result + assert result["service"] == "wrolpi-api" + assert result["lines"] == 10 + + def test_handles_since_parameter(self): + """Should include since parameter in journalctl command.""" + with mock.patch("subprocess.run") as mock_run: + mock_run.return_value = mock.Mock(returncode=0, stdout="logs", stderr="") + result = get_service_logs("wrolpi-api", lines=50, since="1h") + assert result["since"] == "1h" + # Verify journalctl was called with --since + call_args = mock_run.call_args[0][0] + assert "--since" in call_args + assert "1h" in call_args diff --git a/controller/test/test_wrol_mode.py b/controller/test/test_wrol_mode.py new file mode 100644 index 00000000..6f8d377c --- /dev/null +++ b/controller/test/test_wrol_mode.py @@ -0,0 +1,83 @@ +""" +Unit tests for controller.lib.wrol_mode module. +""" + +from unittest import mock + +import pytest + +from controller.lib.wrol_mode import ( + get_wrol_mode_flag_path, + is_wrol_mode, + require_normal_mode, +) + + +class TestGetWrolModeFlagPath: + """Tests for get_wrol_mode_flag_path function.""" + + def test_returns_path(self): + """Should return a Path object.""" + from pathlib import Path + result = get_wrol_mode_flag_path() + assert isinstance(result, Path) + + def test_path_ends_with_wrol_mode(self): + """Path should end with .wrol_mode.""" + result = get_wrol_mode_flag_path() + assert result.name == ".wrol_mode" + + def test_path_is_in_config_directory(self): + """Path should be in config directory.""" + result = get_wrol_mode_flag_path() + assert result.parent.name == "config" + + +class TestIsWrolMode: + """Tests for is_wrol_mode function.""" + + def test_returns_false_when_file_does_not_exist(self, tmp_path): + """Should return False when flag file doesn't exist.""" + with mock.patch( + "controller.lib.wrol_mode.get_wrol_mode_flag_path", + return_value=tmp_path / ".wrol_mode" + ): + assert is_wrol_mode() is False + + def test_returns_true_when_file_exists(self, tmp_path): + """Should return True when flag file exists.""" + flag_file = tmp_path / ".wrol_mode" + flag_file.touch() + + with mock.patch( + "controller.lib.wrol_mode.get_wrol_mode_flag_path", + return_value=flag_file + ): + assert is_wrol_mode() is True + + +class TestRequireNormalMode: + """Tests for require_normal_mode function.""" + + def test_does_not_raise_when_not_in_wrol_mode(self): + """Should not raise when WROL mode is inactive.""" + with mock.patch("controller.lib.wrol_mode.is_wrol_mode", return_value=False): + # Should not raise + require_normal_mode("test operation") + + def test_raises_permission_error_when_in_wrol_mode(self): + """Should raise PermissionError when WROL mode is active.""" + with mock.patch("controller.lib.wrol_mode.is_wrol_mode", return_value=True): + with pytest.raises(PermissionError) as exc_info: + require_normal_mode("save configuration") + + assert "save configuration" in str(exc_info.value) + assert "WROL Mode" in str(exc_info.value) + + def test_error_message_includes_operation_name(self): + """Error message should include the operation name.""" + with mock.patch("controller.lib.wrol_mode.is_wrol_mode", return_value=True): + with pytest.raises(PermissionError) as exc_info: + require_normal_mode("modify settings") + + assert "modify settings" in str(exc_info.value) diff --git a/debian-live-config/config/hooks/normal/9999-wrolpi.hook.chroot b/debian-live-config/config/hooks/normal/9999-wrolpi.hook.chroot index 3107c2f9..25110eda 100644 --- a/debian-live-config/config/hooks/normal/9999-wrolpi.hook.chroot +++ b/debian-live-config/config/hooks/normal/9999-wrolpi.hook.chroot @@ -30,6 +30,15 @@ python3 -m venv /opt/wrolpi/venv /opt/wrolpi/venv/bin/pip3 install -r /opt/wrolpi/requirements.txt || /opt/wrolpi/venv/bin/pip3 install -r /opt/wrolpi/requirements.txt +# Install Controller with separate venv. +echo "Pre-installing WROLPi Controller..." +python3 -m venv /opt/wrolpi/controller/venv +/opt/wrolpi/controller/venv/bin/pip install --upgrade pip +/opt/wrolpi/controller/venv/bin/pip install -r /opt/wrolpi/controller/requirements.txt || + /opt/wrolpi/controller/venv/bin/pip install -r /opt/wrolpi/controller/requirements.txt || + /opt/wrolpi/controller/venv/bin/pip install -r /opt/wrolpi/controller/requirements.txt +echo "Controller pre-installed" + # Configure nginx. cp /opt/wrolpi/etc/raspberrypios/nginx.conf /etc/nginx/nginx.conf cp /opt/wrolpi/etc/raspberrypios/50x.html /var/www/50x.html diff --git a/docker-compose.yml b/docker-compose.yml index a9e6b4f2..d75d87ca 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,9 +14,40 @@ services: volumes: - ./pg_data:/var/lib/postgresql/data + controller: # WROLPi Controller Service + build: + context: . + dockerfile: docker/controller/Dockerfile + container_name: wrolpi-controller + restart: unless-stopped + ports: + - "8087:8087" + volumes: + # Controller code (for development hot-reload) + - ./controller:/app/controller + # Docker socket for container management + - /var/run/docker.sock:/var/run/docker.sock + # Host system info (read-only) + - /proc:/host/proc:ro + - /sys:/host/sys:ro + # Config directory + - ${MEDIA_DIRECTORY:-./test}/config:/media/wrolpi/config + environment: + - DOCKERIZED=true + - MEDIA_DIRECTORY=/media/wrolpi + # Override CMD to enable auto-reload for development + command: ["uvicorn", "controller.main:app", "--host", "0.0.0.0", "--port", "8087", "--reload", "--reload-dir", "/app/controller"] + healthcheck: + test: [ 'CMD-SHELL', 'curl -f http://127.0.0.1:8087/api/health || exit 1' ] + interval: 30s + timeout: 5s + start_period: 10s + retries: 3 + api: depends_on: - db + - controller build: context: . dockerfile: docker/api/Dockerfile diff --git a/docker/controller/Dockerfile b/docker/controller/Dockerfile new file mode 100644 index 00000000..07149fad --- /dev/null +++ b/docker/controller/Dockerfile @@ -0,0 +1,30 @@ +FROM python:3.11-slim + +LABEL maintainer="WROLPi" +LABEL description="WROLPi Controller Service" + +WORKDIR /app + +# Install system dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# Copy requirements first for caching +COPY controller/requirements.txt /app/requirements.txt +RUN pip install --no-cache-dir -r requirements.txt + +# Copy Controller code +COPY controller/ /app/controller/ + +# Environment +ENV PYTHONUNBUFFERED=1 +ENV DOCKERIZED=true + +# Health check +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD curl -f http://localhost:8087/api/health || exit 1 + +EXPOSE 8087 + +CMD ["uvicorn", "controller.main:app", "--host", "0.0.0.0", "--port", "8087"] diff --git a/docker/web/wrolpi.dev.conf b/docker/web/wrolpi.dev.conf index f5c60898..28d50af0 100644 --- a/docker/web/wrolpi.dev.conf +++ b/docker/web/wrolpi.dev.conf @@ -13,11 +13,17 @@ location /apple-touch-icon.png { } location / { - # React APP + # React APP with Controller fallback proxy_pass http://app:3000; + proxy_intercept_errors on; + error_page 502 503 504 = @react_fallback; rewrite $1 break; } +location @react_fallback { + return 302 /controller/?fallback=true; +} + location /ws { proxy_pass http://app:3000; proxy_http_version 1.1; @@ -26,7 +32,19 @@ location /ws { } # -# API +# Controller - system management API and UI +# All Controller endpoints are under /controller/* +# +location /controller { + rewrite ^/controller(.*)$ $1 break; + proxy_pass http://controller:8087; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; +} + +# +# Main API - handled by the API service (port 8081) # location /api { diff --git a/etc/raspberrypios/maintenance.html b/etc/raspberrypios/maintenance.html deleted file mode 100644 index 889c5a47..00000000 --- a/etc/raspberrypios/maintenance.html +++ /dev/null @@ -1,210 +0,0 @@ - - - - - - WROLPi - Upgrade in Progress - - - -
- -

Upgrade in Progress

-

Please wait while your system is being upgraded

- -
-
-
- - - -

Checking upgrade status...

-

Elapsed: 0:00

- -
- Do not power off your device or close this window during the upgrade. -
- - -
- - - - diff --git a/etc/raspberrypios/wrolpi-controller.service b/etc/raspberrypios/wrolpi-controller.service new file mode 100644 index 00000000..57ae116a --- /dev/null +++ b/etc/raspberrypios/wrolpi-controller.service @@ -0,0 +1,31 @@ +[Unit] +Description=WROLPi Controller Service +Documentation=https://github.com/lrnselfreliance/wrolpi +After=network.target +Before=wrolpi-api.service wrolpi-app.service postgresql.service + +[Service] +Type=simple +ExecStart=/opt/wrolpi/controller/venv/bin/uvicorn controller.main:app --host 0.0.0.0 --port 8087 +WorkingDirectory=/opt/wrolpi +User=root +Group=root +Restart=always +RestartSec=5 +# Higher priority than the API, but lower than Help. +Nice=2 + +# Environment +Environment="PYTHONUNBUFFERED=1" + +# Logging +StandardOutput=journal +StandardError=journal +SyslogIdentifier=wrolpi-controller + +# Security - allow privileged operations +AmbientCapabilities=CAP_SYS_ADMIN CAP_NET_ADMIN +NoNewPrivileges=false + +[Install] +WantedBy=multi-user.target diff --git a/etc/raspberrypios/wrolpi.conf b/etc/raspberrypios/wrolpi.conf index 0233b035..718e0824 100644 --- a/etc/raspberrypios/wrolpi.conf +++ b/etc/raspberrypios/wrolpi.conf @@ -1,13 +1,5 @@ # Nginx config for the WROLPi services. -# -# Maintenance page for upgrades -# - -location = /maintenance.html { - alias /var/www/maintenance.html; -} - # # Error pages # @@ -30,11 +22,17 @@ location /apple-touch-icon.png { } location / { - # React APP + # React APP with Controller fallback proxy_pass http://0.0.0.0:3000; + proxy_intercept_errors on; + error_page 502 503 504 = @react_fallback; rewrite $1 break; } +location @react_fallback { + return 302 /controller/?fallback=true; +} + location /ws { proxy_pass http://0.0.0.0:3000; proxy_http_version 1.1; @@ -43,7 +41,19 @@ location /ws { } # -# API +# Controller - system management API and UI +# All Controller endpoints are under /controller/* +# +location /controller { + rewrite ^/controller(.*)$ $1 break; + proxy_pass http://0.0.0.0:8087; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; +} + +# +# Main API - handled by the API service (port 8081) # location /api { diff --git a/etc/raspberrypios/wrolpi.target b/etc/raspberrypios/wrolpi.target index 3176cf34..a7e61fdb 100644 --- a/etc/raspberrypios/wrolpi.target +++ b/etc/raspberrypios/wrolpi.target @@ -1,3 +1,3 @@ [Unit] Description=WROLPi services group -Wants=wrolpi-api.service wrolpi-app.service nginx.service postgresql.service wrolpi-kiwix.service renderd.service apache2.service postgresql@15-map.service +Wants=wrolpi-controller.service wrolpi-api.service wrolpi-app.service nginx.service postgresql.service wrolpi-kiwix.service renderd.service apache2.service postgresql@15-map.service diff --git a/help.sh b/help.sh index 9ffd71fe..712479c8 100755 --- a/help.sh +++ b/help.sh @@ -113,6 +113,48 @@ else echo "FAILED: Unable to find wrolpi database" fi +echo +# Controller + +if [ -f /opt/wrolpi/controller/venv/bin/python3 ]; then + echo "OK: Controller Python virtual environment exists" + + if /opt/wrolpi/controller/venv/bin/python3 --version 2>/dev/null > /dev/null; then + echo 'OK: Controller Python can be run' + else + echo "FAILED: Controller Python could not be run" + fi +else + echo "FAILED: Controller Python virtual environment does not exist" +fi + +if systemctl list-unit-files "*wrolpi-controller*" >/dev/null 2>&1; then + echo "OK: WROLPi Controller systemd exists" +else + echo "FAILED: WROLPi Controller systemd does not exist" +fi + +if [ -f /etc/systemd/system/wrolpi-controller.service ]; then + if systemctl status wrolpi-controller.service >/dev/null 2>&1; then + echo "OK: WROLPi Controller service is up" + else + echo "FAILED: WROLPi Controller service is not up" + fi +fi + +if curl -s http://0.0.0.0:8087/api/health --max-time 5 >/dev/null 2>&1; then + echo "OK: WROLPi Controller health check passed" +else + echo "FAILED: WROLPi Controller health check failed" +fi + +# WROL Mode status +if [ -f /media/wrolpi/config/.wrol_mode ]; then + echo "Note: WROL Mode is ACTIVE" +else + echo "OK: WROL Mode is inactive" +fi + echo # API diff --git a/pi-gen/stage2/04-wrolpi/03-run-chroot.sh b/pi-gen/stage2/04-wrolpi/03-run-chroot.sh index cc873622..1b850be7 100755 --- a/pi-gen/stage2/04-wrolpi/03-run-chroot.sh +++ b/pi-gen/stage2/04-wrolpi/03-run-chroot.sh @@ -30,6 +30,15 @@ python3 -m venv /opt/wrolpi/venv /opt/wrolpi/venv/bin/pip3 install -r /opt/wrolpi/requirements.txt || /opt/wrolpi/venv/bin/pip3 install -r /opt/wrolpi/requirements.txt +# Install Controller with separate venv. +echo "Pre-installing WROLPi Controller..." +python3 -m venv /opt/wrolpi/controller/venv +/opt/wrolpi/controller/venv/bin/pip install --upgrade pip +/opt/wrolpi/controller/venv/bin/pip install -r /opt/wrolpi/controller/requirements.txt || + /opt/wrolpi/controller/venv/bin/pip install -r /opt/wrolpi/controller/requirements.txt || + /opt/wrolpi/controller/venv/bin/pip install -r /opt/wrolpi/controller/requirements.txt +echo "Controller pre-installed" + # Install webapp. (cd /opt/wrolpi/app && npm install && npm run build) diff --git a/repair.sh b/repair.sh index f10fe65b..f0b6f8ad 100755 --- a/repair.sh +++ b/repair.sh @@ -18,6 +18,7 @@ set -e set -x # Stop services if they are running. +systemctl stop wrolpi-controller.service || : systemctl stop wrolpi-api.service || : systemctl stop wrolpi-app.service || : systemctl stop wrolpi-kiwix.service || : @@ -43,7 +44,6 @@ cp /opt/wrolpi/etc/raspberrypios/nginx.conf /etc/nginx/nginx.conf [ -f /etc/nginx/conf.d/default.conf ] && rm /etc/nginx/conf.d/default.conf cp /opt/wrolpi/etc/raspberrypios/wrolpi.conf /etc/nginx/conf.d/wrolpi.conf cp /opt/wrolpi/etc/raspberrypios/50x.html /var/www/50x.html -cp /opt/wrolpi/etc/raspberrypios/maintenance.html /var/www/maintenance.html # Generate nginx certificate for HTTPS. if [[ ! -f /etc/nginx/cert.crt || ! -f /etc/nginx/cert.key ]]; then @@ -53,6 +53,9 @@ if [[ ! -f /etc/nginx/cert.crt || ! -f /etc/nginx/cert.key ]]; then chmod 640 /etc/nginx/cert.key /etc/nginx/cert.crt fi +# Start nginx quickly so user can access Controller from React UI +systemctl start nginx || : + # WROLPi needs a few privileged commands. cp /opt/wrolpi/etc/raspberrypios/90-wrolpi /etc/sudoers.d/90-wrolpi chmod 0440 /etc/sudoers.d/90-wrolpi @@ -62,11 +65,54 @@ visudo -c -f /etc/sudoers.d/90-wrolpi # Install the systemd services cp /opt/wrolpi/etc/raspberrypios/wrolpi*.service /etc/systemd/system/ cp /opt/wrolpi/etc/raspberrypios/wrolpi.target /etc/systemd/system/ +systemctl enable wrolpi-controller.service systemctl enable wrolpi-api.service systemctl enable wrolpi-app.service systemctl enable wrolpi-kiwix.service systemctl enable wrolpi-help.service +# Repair Controller (offline-safe - no pip install) +repair_controller() { + echo "Checking WROLPi Controller..." + + # Check venv exists + if [ ! -f /opt/wrolpi/controller/venv/bin/python ]; then + echo "ERROR: Controller venv missing!" + echo "Run install.sh or upgrade.sh (requires internet) to reinstall." + return 1 + fi + + # Check venv is functional + if ! /opt/wrolpi/controller/venv/bin/python --version > /dev/null 2>&1; then + echo "ERROR: Controller venv is corrupted!" + echo "Run install.sh or upgrade.sh (requires internet) to reinstall." + return 1 + fi + + # Check systemd service is installed + if [ ! -f /etc/systemd/system/wrolpi-controller.service ]; then + echo "Controller systemd service not installed, copying..." + cp /opt/wrolpi/etc/raspberrypios/wrolpi-controller.service /etc/systemd/system/ + systemctl daemon-reload + fi + + # Ensure service is enabled + if ! systemctl is-enabled wrolpi-controller > /dev/null 2>&1; then + echo "Enabling Controller service..." + systemctl enable wrolpi-controller + fi + + echo "Controller repair completed" +} + +# Run Controller repair if venv exists (skip on fresh install) +if [ -d /opt/wrolpi/controller/venv ]; then + repair_controller || echo "Controller repair failed, continuing..." +fi + +# Start Controller so user can monitor repair in UI. +systemctl start wrolpi-controller + # Copy config files necessary for map. cp -r /opt/wrolpi/etc/raspberrypios/postgresql@15-map.service.d /etc/systemd/system/ cp /opt/wrolpi/etc/raspberrypios/renderd.conf /etc/renderd.conf diff --git a/scripts/upgrade.sh b/scripts/upgrade.sh index 2011ea8a..295df61e 100755 --- a/scripts/upgrade.sh +++ b/scripts/upgrade.sh @@ -21,6 +21,47 @@ npm install || npm install || npm install || npm install # try install multiple # Upgrade the WROLPi database. (cd /opt/wrolpi && /opt/wrolpi/venv/bin/python3 /opt/wrolpi/main.py db upgrade) +# Upgrade Controller +upgrade_controller() { + echo "Upgrading WROLPi Controller..." + + # Stop Controller during upgrade + systemctl stop wrolpi-controller || true + + # Create Controller venv if it doesn't exist + if [ ! -d /opt/wrolpi/controller/venv ]; then + echo "Creating Controller virtual environment..." + python3 -m venv /opt/wrolpi/controller/venv + fi + + # Update dependencies + echo "Updating Controller dependencies..." + /opt/wrolpi/controller/venv/bin/pip install --upgrade pip + /opt/wrolpi/controller/venv/bin/pip install --upgrade -r /opt/wrolpi/controller/requirements.txt + + # Reload systemd in case service file changed + systemctl daemon-reload + + # Start Controller + systemctl start wrolpi-controller + + # Wait for health check (up to 30 seconds) + echo "Waiting for Controller health check..." + for i in {1..30}; do + if curl -s http://localhost:8087/api/health > /dev/null 2>&1; then + echo "Controller upgraded successfully" + return 0 + fi + sleep 1 + done + + echo "WARNING: Controller health check failed after upgrade" + echo "Check logs: journalctl -u wrolpi-controller -n 50" + return 1 +} + +upgrade_controller || echo "Controller upgrade failed, continuing..." + # Upgrade WROLPi Help. /opt/wrolpi/scripts/install_help_service.sh || echo "Help install failed." diff --git a/wrolpi/collections/test/test_models.py b/wrolpi/collections/test/test_models.py index 2913db48..bcccd42e 100644 --- a/wrolpi/collections/test/test_models.py +++ b/wrolpi/collections/test/test_models.py @@ -1,7 +1,7 @@ import pytest from wrolpi.collections import Collection -from wrolpi.errors import UnknownCollection +from wrolpi.collections.errors import UnknownCollection def test_collection_get_by_id(test_session, test_directory): diff --git a/wrolpi/common.py b/wrolpi/common.py index ef3566b8..207ccdde 100644 --- a/wrolpi/common.py +++ b/wrolpi/common.py @@ -491,9 +491,10 @@ class ConfigFile: if file.is_file(): if not self.is_valid(file): raise InvalidConfig(f'Config file is invalid: {str(self.get_relative_file())}') + # is_valid() already validated, just load the data config_data = self.read_config_file(file) config_data = {k: v for k, v in config_data.items() if k in self.default_config} - self._config.update(asdict(self.validator(**config_data))) + self._config.update(config_data) return self @property @@ -653,6 +654,11 @@ class ConfigFile: def validate(self, config: dict) -> bool: allowed_fields = {i.name for i in fields(self.validator)} + default_fields = set(self.default_config.keys()) + # Check for fields in default_config that are missing from validator + missing_from_validator = default_fields - allowed_fields + if missing_from_validator: + logger.error(f'{self.file_name}: Fields in default_config but missing from validator: {missing_from_validator}') try: # Remove keys no longer in the config. config_items = config.items() @@ -691,6 +697,7 @@ class ConfigFile: @dataclass class WROLPiConfigValidator: archive_destination: str = None + check_for_upgrades: bool = None download_on_startup: bool = None download_timeout: int = None hotspot_device: str = None diff --git a/wrolpi/status.py b/wrolpi/status.py index 1b71ee4d..5502607e 100755 --- a/wrolpi/status.py +++ b/wrolpi/status.py @@ -569,7 +569,8 @@ async def get_power_stats() -> PowerStats: DISABLE_STATUS_WORKER = bool(PYTEST) # Do not run status worker while testing, unless explicitly needed. -@perpetual_signal(sleep=5) +# Disable status_worker in favor of status from Controller. +# @perpetual_signal(sleep=5) async def status_worker(count: int = None, sleep_time: int = 5): """A background process which will gather historical data about system statistics.""" shared_status = api_app.shared_ctx.status