Rename application to Samsara and add assessment summary, detailed tabs and local Linux inventory
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -20,8 +20,8 @@ reports/
|
|||||||
*.p12
|
*.p12
|
||||||
|
|
||||||
# Local assessment artifacts (may contain infrastructure details)
|
# Local assessment artifacts (may contain infrastructure details)
|
||||||
server-buddy-assessment.*
|
*-assessment.*
|
||||||
server-buddy-local-*.json
|
*-local-*.json
|
||||||
core
|
core
|
||||||
core.*
|
core.*
|
||||||
*.bundle
|
*.bundle
|
||||||
|
|||||||
BIN
App Logo/Samsara Logo.jpg
Normal file
BIN
App Logo/Samsara Logo.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
70
README.md
70
README.md
@@ -1,68 +1,60 @@
|
|||||||
# Server Buddy
|
# Samsara
|
||||||
|
|
||||||
Desktop retirement assessment tool for Windows Server and Linux administrators. Python 3.11+, PySide6 and versioned JSON. **Local read-only Linux Mint/RHEL-based collection, Windows/Linux synthetic demos, offline JSON import and HTML/JSON reporting are available.** Remote collection and live Windows collection remain incomplete. No real enterprise infrastructure is contacted automatically.
|
Source repository: [oceans2alaska/Samsara](https://git.doorstodurin.com/oceans2alaska/Samsara).
|
||||||
|
|
||||||
## Linux Mint setup
|
Windows Server and Red Hat Enterprise Linux retirement assessment. Collect inventory, review dependencies and risks, and save evidence-based reports. Python 3.11+ and PySide6.
|
||||||
|
|
||||||
|
## Get started
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo apt update
|
|
||||||
sudo apt install python3 python3-venv libegl1 libopengl0 libxcb-cursor0 libxkbcommon-x11-0
|
|
||||||
python3 -m venv .venv
|
python3 -m venv .venv
|
||||||
source .venv/bin/activate
|
source .venv/bin/activate
|
||||||
python -m pip install -r requirements.txt
|
python -m pip install -r requirements.txt
|
||||||
python -m server_buddy.main
|
python -m samsara.main
|
||||||
```
|
```
|
||||||
|
|
||||||
Run from the project root in a graphical desktop session. If Qt mentions the `xcb` cursor library, install `libxcb-cursor0`; reinstalling Python packages does not supply this system library. The launcher diagnoses this common issue before starting Qt.
|
On Windows, create the environment with `py -3 -m venv .venv` and activate `.venv\Scripts\activate`. Run from the project root in a desktop session.
|
||||||
|
|
||||||
On Windows 10/11, create a virtual environment with `py -3 -m venv .venv`, activate `.venv\Scripts\activate`, install requirements and run the same module command. Windows desktop execution still needs validation. Local Linux collection is disabled on Windows; demo and inventory import remain available.
|
Linux desktop compatibility includes Mint. Debian-based desktops may need `python3-venv libegl1 libopengl0 libxcb-cursor0 libxkbcommon-x11-0` installed through their package manager. If Qt reports a missing xcb cursor library, install the package providing `libxcb-cursor.so.0`.
|
||||||
|
|
||||||
## Test a real assessment on your Mint PC
|
## Analyze
|
||||||
|
|
||||||
1. Close any already-open Server Buddy window and restart with `python -m server_buddy.main` to load the updated code.
|
1. Open **Analyze**. Leave **Assessment source → This computer** and **Operating system → Auto-detect** selected.
|
||||||
2. Open **Server Analyzer** and click **Analyze This Linux PC — read-only**. This button assesses the computer running the application, irrespective of the demo target and hostname fields. It never connects to a remote hostname.
|
2. Click **Analyze**. The app identifies the local OS, runs its available read-only collector and prepares the assessment report.
|
||||||
3. Watch the collection stages. Use **Cancel collection** if needed; cancellation retains the previous assessment. Background commands have time/output limits.
|
3. The **Summary** page opens automatically with a retirement risk estimate, OS type/version, important software and enabled scheduled jobs/scripts. Higher scores mean more retirement concerns; the score is not retirement approval.
|
||||||
4. Confirm the banner says **LOCAL LINUX INVENTORY**. Overview shows the actual local OS, kernel, boot time and per-check statuses. Review Services, Network Connections, Scheduled Tasks / Timers, Software, Storage, Dependencies and Risk Assessment. Tables are searchable and sortable.
|
4. Open detailed tabs for **Networking**, **Software**, **Tasks**, **Dependencies**, **Services**, **Storage**, **Roles**, **Certificates**, **Risk findings**, **Collection checks** and **System**. Tables are searchable and sortable.
|
||||||
5. Open **Reports**, save an HTML report and open the saved file in a browser. JSON export is also available. Local reports are labeled as local snapshots rather than imported or synthetic data.
|
5. Click **Save HTML report**, or use **Reports** to export HTML/JSON. **History** reopens assessments from this session.
|
||||||
|
|
||||||
Alternatively select **This Linux PC — read-only** under Settings, then use **Analyze Server**. The local mode ignores the hostname field. Local collection requires no sudo, passwords or remote setup. It does not install packages, restart services, change configuration, resolve remote names or read private keys, service command lines or environment variables.
|
Explicit profiles include Windows Server 2016, 2019, 2022 and 2025; RHEL 7 (legacy), 8, 9 and 10; and RHEL-compatible Linux. A local profile must match the detected OS. Auto-detect also allows local testing on other Linux distributions. Profile choices do not imply that every live collector is implemented or every release is validated.
|
||||||
|
|
||||||
The collector uses systemd, `ss`, `ip`, RPM where available and dpkg on Mint/Debian-derived hosts. Commands unavailable in containers or restricted environments are recorded as failures, with no synthetic replacement. Results describe the process's local namespace and permissions, which may differ from the whole host.
|
Choose **Saved inventory file** to analyze existing JSON; the recorded OS is displayed without probing another computer. **Windows Server sample** and **RHEL sample** provide clearly labeled synthetic examples. No synthetic results are loaded into history automatically.
|
||||||
|
|
||||||
### Current local coverage and limits
|
## Current availability
|
||||||
|
|
||||||
- OS distribution, kernel, boot time, interfaces and addresses.
|
Local Linux collection and offline reporting are implemented. Live Windows and remote collection are not yet available; the UI reports that limitation explicitly. Windows desktops can use saved inventories and samples, but Windows desktop execution still requires platform validation. No server is contacted automatically.
|
||||||
- Loaded system service state/startup; accounts, user services and executable details remain incomplete.
|
|
||||||
- TCP snapshot and process attribution where visible. Direction and historical/UDP activity are unverified.
|
|
||||||
- Loaded system timers; cron/anacron, user timers, unloaded timers and trigger configuration require review.
|
|
||||||
- RPM/dpkg software; containers and manually installed applications are outside coverage.
|
|
||||||
- Mount table, including remote mount references. Capacity/free space and NFS/Samba exports are not yet collected. Mounted remote filesystems are not accessed.
|
|
||||||
- Application certificates, directory integration and workload role inference are explicitly not collected.
|
|
||||||
|
|
||||||
The heuristic score is preliminary and requires administrator review. Routine Linux services are not automatically classified as active application workloads; unclassified services contribute uncertainty. Neither missing connections nor a low score proves retirement safety.
|
The Linux collector observes OS/kernel/boot information, addresses, loaded systemd services and timers (including available calendar/next-run metadata), readable system cron entries, TCP snapshots, RPM or dpkg software and mounted filesystems. Missing commands or restricted permissions produce visible failed checks. Service accounts, user crontabs/timers, anacron/periodic-script configuration, certificate locations, share exports, disk capacity/free space and directory integration remain incomplete or uncollected. Results reflect the execution process's namespace and permissions. Remote mounts are not accessed.
|
||||||
|
|
||||||
## Headless local Linux inventory
|
Collection is read-only, requires no sudo and does not restart services, configure remoting, install software or read private keys, secret-bearing command lines or environment variables. Cancel stops the active collector and retains the previous assessment. Commands have time and output limits.
|
||||||
|
|
||||||
The local collector can also produce JSON without PySide6 or jsonschema (Python standard library only):
|
The summary highlights name-matched server software and running application services; this is not a complete application classifier. Scheduled entries show configured work, not confirmed execution, and cron timezones are unverified. Full inventory and collection limitations remain available in the tabs and exported report.
|
||||||
|
|
||||||
|
Risk is a preliminary heuristic requiring administrator review. Missing connections do not prove non-use, and the tool never declares a server safe for retirement. Review exported hostnames, paths and addresses before sharing.
|
||||||
|
|
||||||
|
## Headless Linux inventory
|
||||||
|
|
||||||
|
The standard-library-only collector can export local inventory without Qt:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 -m server_buddy.collectors.linux --output /tmp/my-local-inventory.json
|
python3 -m samsara.collectors.linux --output /tmp/server-inventory.json
|
||||||
```
|
```
|
||||||
|
|
||||||
Use a new output filename; the CLI will not overwrite an existing file. It reads only the local execution host and writes only the requested report file. Import the JSON in either desktop using **Import Windows / Linux inventory JSON**. This provides a manual offline workflow for future RHEL validation without GUI packages on the assessed host. Do not treat Mint testing as validation of every RHEL release.
|
Use a new filename; existing files are not overwritten. Open the result using **Saved inventory file** on either desktop. Imported check assertions are not independently verified. Import validates the v1 schema, limits input to 10 MiB, removes external diagnostic details and service/task command fields, and recalculates risk.
|
||||||
|
|
||||||
## Synthetic and offline testing
|
## Development
|
||||||
|
|
||||||
The target selector chooses **Windows Server 2016 onward** (`demo-web-01.example.test`) or **RHEL-based Linux** (`demo-rhel-01.example.test`) synthetic fixtures. Click Analyze Server in demo mode to load the selected fixture. All fixture data and timestamps remain clearly labeled. Demo mode rejects other hostnames.
|
|
||||||
|
|
||||||
JSON import validates the v1 collection schema (or a report wrapper), enforces a 10 MiB input limit, recalculates risk and strips task/service command lines and external diagnostic details. Imported timestamps/check statuses are not independently verified. Review all report content before sharing; inventory names, paths and addresses may be sensitive. History is session-only and labels each record's source.
|
|
||||||
|
|
||||||
## Tests
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python -m unittest discover -s tests -v
|
python -m unittest discover -s tests -v
|
||||||
```
|
```
|
||||||
|
|
||||||
Qt tests run offscreen automatically. Tests cover schema validation, source labeling, Linux service/network/package parsing, missing commands, cancellation/timeouts, local GUI workflow and report escaping. The current development environment passed all 21 tests; a real local smoke collection passed schema validation and generated HTML/JSON. In its restricted environment, systemd and interface commands failed visibly, while package, TCP and mount collection ran. GUI desktop appearance and Windows/RHEL execution still need platform validation.
|
Qt tests run offscreen. See [platform support](docs/PLATFORMS.md), [architecture and plan](docs/IMPLEMENTATION_PLAN.md) and [developer guide](docs/DEVELOPMENT.md). Run from source; Windows packaging and production collector validation remain pending.
|
||||||
|
|
||||||
See [implementation plan](docs/IMPLEMENTATION_PLAN.md), [platform support](docs/PLATFORMS.md) and [developer guide](docs/DEVELOPMENT.md). Future Windows collection will use existing authorized PowerShell remoting from a Windows execution host, never Windows modules on Linux. Packaging, Windows inventory and remote SSH collection remain later work.
|
|
||||||
|
|||||||
@@ -1,29 +1,37 @@
|
|||||||
# Development guide
|
# Developer guide
|
||||||
|
|
||||||
Run commands from the repository root. Use a Python 3.11+ virtual environment and requirements.txt. Start with `python -m server_buddy.main`. Assets are resolved relative to the module, independent of the user's working directory; tests use repository-relative schema/fixture paths.
|
Use Python 3.11+ and requirements.txt. Run `python -m samsara.main` from the repository root. The desktop requires a Qt-capable graphical session; tests run offscreen.
|
||||||
|
|
||||||
## Phase 1 behavior
|
## UI and orchestration
|
||||||
|
|
||||||
Qt widgets stay on the GUI thread. CollectionJob loads the bundled JSON in a QThread and delivers stage/result/error signals. Analyze and import buttons are disabled until the worker finishes; closing an active job is deferred to avoid destroying a running thread. Demo loading is finite local I/O; production timeout/cancellation and process lifecycle management are Phase 3 requirements. No subprocesses are launched in Phase 1.
|
Analyze is the default page. Source and OS selection are visible together; Auto-detect is the default. The OS selector is disabled for saved inventories and fixed synthetic samples because their OS is recorded in the input. Windows and RHEL version profiles are explicit user intent, not promises of live collector availability.
|
||||||
|
|
||||||
Tables use Qt proxy filtering across all columns with native numeric sorting. Session history records repeated fixture loads without changing fixture timestamps. Dashboard counts unique hostnames from fixture/import history, while history counts runs. High-risk findings refer to the current assessment. Two OS-specific fixtures supply the demo; user input cannot relabel synthetic evidence as a live server assessment.
|
`CollectionJob` runs detection and collection outside the GUI thread, then delivers stage, result or failure signals. OS mismatches and unsupported collectors preserve the previous result. Cancellation uses QThread interruption and the Linux runner's polling callback. All source/profile controls are locked while collecting. Closing an active worker is deferred to avoid destroying a running thread.
|
||||||
|
|
||||||
Risk rules live in analyzers/assessment.py. Each finding contains classification, evidence, severity and points. Score is the sum capped at 100: 0 informational, 1–29 low, 30–59 medium, 60–89 high, 90–100 critical. These preliminary rules are demonstration logic, not a validated production risk model. Severity is an individual finding's severity, while overall risk is the aggregate. Observation limits always contribute uncertainty.
|
Results are initially empty. Successful assessments create session history records; choosing one restores its inventory and derived assessment. Reports always use the displayed assessment. Successful analysis opens the dedicated Summary page. Its first tab shows retirement risk, OS/version, a capped list of workload software hints and enabled scheduled work. Comprehensive tabs lazily load searchable inventory and evidence tables. The same compact summary is included in HTML reports; all inventory remains exported. Scrollable pages and fixed-size form cards preserve legibility at smaller window sizes.
|
||||||
|
|
||||||
Collection JSON uses schemas/inventory-v1.schema.json. Export JSON wraps the collection as inventory_result and includes the derived assessment. Do not treat the report wrapper as collection JSON. Live collection must enforce the schema at the boundary and tighten field schemas as PowerShell contracts are implemented. Never replace failed checks with synthetic values.
|
## OS detection
|
||||||
|
|
||||||
## Verification in this development environment
|
`core/detection.py` reads local platform metadata and `/etc/os-release`, recognizes RHEL-related families and validates explicit major-version profiles. It does not resolve names or contact remote systems. Other Linux distributions may use Auto-detect. Windows desktop build numbers are not treated as Windows Server edition evidence. Saved inventories use their metadata and skip local OS detection.
|
||||||
|
|
||||||
Python 3.12.3 is available. `python3 -m unittest discover -s tests -v`: four tests passed; schema and Qt smoke tests skipped because jsonschema and PySide6 are absent. An isolated installation under /tmp failed due to package server DNS/network resolution. GUI launch and visual appearance have not been verified here. Run the documented tests after installing dependencies on Linux Mint; also test a Windows desktop before distribution.
|
## Linux backend
|
||||||
|
|
||||||
No PowerShell collection scripts or production collector exist yet, so there is no real PowerShell output to validate. Phase 1 requires no remote accounts, remoting configuration, or administrative privilege.
|
`collectors/linux.py` executes fixed arguments from system binary directories with a restricted environment, no shell or user hostname, suppressed command diagnostics and no sudo. Commands are polled for cancellation, an eight-second limit and 10 MiB output bound, then killed/reaped on failure. Overall command collection is limited to approximately 60 seconds. Reads are limited to local OS/proc metadata; remote mounts are not traversed. The headless CLI uses only the standard library and creates new output files exclusively.
|
||||||
|
|
||||||
## Follow-up verification
|
Report source is included in locally collected server metadata. Import removes that field and treats source assertions as unverified. Inventory import validates schemas/inventory-v1.schema.json, caps size, strips diagnostic strings and command fields, and recalculates assessment results. Tighten field definitions alongside future production collector contracts.
|
||||||
|
|
||||||
After dependencies were installed in the project virtual environment: `.venv/bin/python -m unittest discover -s tests -v` passed all 12 tests, including two offscreen Qt tests, schema validation, Linux inventory import/NFS evidence, sensitive-field exclusion and startup diagnostics. Source compilation passed. Normal X11 startup is blocked by the confirmed missing `libxcb-cursor.so.0`; the launcher prints Mint installation instructions. Windows execution and real RHEL collection remain untested/unimplemented as described in PLATFORMS.md.
|
## Analysis
|
||||||
|
|
||||||
## Local Linux collector
|
Each finding has classification, supporting evidence, severity and points. Score is capped at 100: 0 informational, 1–29 low, 30–59 medium, 60–89 high, 90–100 critical. This is demonstration heuristic logic, not a validated production risk model. Known Linux application service names receive potential-dependency findings; other running services remain unknown rather than all being classified as application workloads. Incomplete checks and observation limits always require review.
|
||||||
|
|
||||||
`collectors/linux.py` executes fixed argument lists from system binary directories with a restricted environment, no shell or user hostname, DEVNULL stderr and no sudo. Each process is polled for cancellation, an eight-second time limit and a 10 MiB output limit; the process is killed and reaped on interruption/failure. Overall collection is bounded to approximately 60 seconds for command execution. Local /proc and /etc/os-release reads do not access mounted remote filesystems. There is no arbitrary command UI. Collection source is included in server metadata; JSON import strips that source field and treats imported evidence as unverified. GUI workers retain the previous assessment on cancellation. New local checks intentionally return partial/missing statuses rather than asserting full coverage.
|
## Verification
|
||||||
|
|
||||||
Verification: 21 unit/UI tests passed, plus a real local read-only smoke collection validated against the JSON schema with HTML/JSON generation. Tests use both RPM and dpkg fixtures, check service/IPv6 parsing and unknown-service classification, command timeout/reaping, cancellation, and source labels. Real RHEL and Windows execution is still pending.
|
Run `python -m unittest discover -s tests -v`. Coverage includes profiles/detection, synthetic isolation, real-source labels, RPM/dpkg parsing, IPv4/IPv6, missing commands, cancellation/timeouts, no automatic assessments, history restoration, imports, UI selection and HTML escaping/export. Offscreen snapshots can verify geometry at standard and smaller resolutions; they do not replace native desktop testing.
|
||||||
|
|
||||||
|
A local Linux smoke collection has passed schema validation and HTML/JSON generation in the restricted development environment. Systemd/interface restrictions were recorded as failed checks. Real Windows and RHEL release validation, remote transports and packaging are still pending. Never replace missing checks with samples.
|
||||||
|
|
||||||
|
Summary presentation is derived by analyzers/summary.py without altering assessment scores. Higher numbers mean more retirement concerns. OS version uses explicit os_version or an OS-name version hint, never the kernel/build as a fallback. Software name rules omit obvious library/development/documentation packages; a running service without a matching package can still be highlighted. Unknown schedules and missing checks remain explicit. Linux scheduler metadata includes system timer calendars/next-run data and bounded readable /etc/crontab and /etc/cron.d entries. Cron command arguments, inline code and environment assignments never enter the collected data; safe script/executable names identify work. User crontabs, anacron, periodic-script contents and execution history remain outside coverage.
|
||||||
|
|
||||||
|
## Application branding
|
||||||
|
|
||||||
|
The supplied Samsara JPEG is bundled unchanged as `samsara/assets/samsara-logo.jpg`. `ui/branding.py` provides the shared Qt application/window icon and Windows taskbar application identity. The sidebar uses the same asset. Future packaged builds must include this asset; the original `App Logo` folder is not required at runtime. Icon loading was checked at 16–256 pixel sizes and the sidebar preview was reviewed offscreen. Native desktop/taskbar appearance still needs platform verification.
|
||||||
|
|||||||
@@ -1,24 +1,32 @@
|
|||||||
# Implementation plan
|
# Architecture and implementation plan
|
||||||
|
|
||||||
Repository inspection: empty project directory with reserved environment directories; no existing application or repository instructions.
|
Samsara is a Windows Server and enterprise Linux retirement assessment tool. Desktop OS and assessed OS are independent.
|
||||||
|
|
||||||
1. **Phase 1 (implemented):** offline PySide6 dashboard, six navigation pages, ten inventory tabs, hostname validation, background demo loading, searchable/sortable tables, session history, explicit execution selection, synthetic fixture, local demo exports and preliminary evidence/risk presentation.
|
## Project structure
|
||||||
2. **Phase 2:** independent read-only PowerShell modules for system/roles, services, TCP/processes, scheduled tasks, registry software, volumes/shares and public certificate metadata. Versioned JSON envelope with per-check statuses. Optional AD enrichment interface; no AD requirement.
|
|
||||||
3. **Phase 3:** Windows execution-host collector using existing authorized Invoke-Command sessions. Resolve trusted script paths; pass validated hostname as an argument to a fixed script via subprocess argument lists (never shell=True). Enforce timeouts, cancellation, process cleanup, bounded output and schema validation. Linux remains offline. No remoting setup.
|
|
||||||
4. **Phase 4:** expand transparent weighted rules, role/process mappings, service account and inbound connection correlation; distinguish observed activity from potential dependencies and unknown evidence. Review critical infrastructure role rules before live use.
|
|
||||||
5. **Phase 5:** production reports, redaction of sensitive service/task arguments, failed-check visibility, export validation and report metadata. Existing Phase 1 exports contain synthetic records only.
|
|
||||||
6. **Phase 6:** collector contract tests, PowerShell validation on Windows, failure/timeout tests, Windows packaging and signed release guidance.
|
|
||||||
|
|
||||||
## Structure and boundaries
|
- `samsara/ui`: Qt interface and background collection worker.
|
||||||
|
- `samsara/core`: hostname validation, OS detection and profile matching.
|
||||||
|
- `samsara/collectors`: offline fixtures, schema-validated JSON intake and local Linux collector.
|
||||||
|
- `samsara/analyzers`: evidence classification and transparent heuristic scoring.
|
||||||
|
- `samsara/reporting`: local HTML/JSON export.
|
||||||
|
- `samsara/models`: reserved for typed collection models.
|
||||||
|
- `samsara/assets`: labeled synthetic fixtures.
|
||||||
|
- `schemas`: versioned collection JSON contract.
|
||||||
|
- `powershell`: Windows collection requirements; scripts pending.
|
||||||
|
- `tests`: core, parsing, detection, worker, UI and export tests.
|
||||||
|
|
||||||
`server_buddy/ui`: Qt views and background worker. `core`: input validation and future orchestration. `collectors`: platform-specific collection backends. `models`: future typed schema adapters. `analyzers`: evidence and heuristic rules. `reporting`: local exports. `assets`: bundled offline fixture. `powershell`: future Windows-only scripts. `schemas`: collection contract. `tests`: fixture, core and optional Qt tests.
|
## Implemented
|
||||||
|
|
||||||
History is session-only; timestamps are original fixture timestamps. No SQLite or remote collection in Phase 1. A missing check never means a negative result. Synthetic demo is bound to a reserved example.test hostname and documentation-only IP addresses. Export pipeline must gain production redaction before any live collection is enabled.
|
The simplified UI starts on Analyze with This computer and Auto-detect selected. History, Reports and Settings are secondary pages. No fabricated assessment is inserted at startup. Local collection detects the OS and uses the Linux backend where available. Version mismatches and unsupported Windows collection fail explicitly. Saved inventories and synthetic samples have separate source choices. A dedicated Summary destination opens after collection, with retirement risk, OS/version, important software and scheduled work; comprehensive Networking, Software, Tasks, Dependencies and other tabs expose evidence. Reports can be saved immediately after an assessment; session history reopens previous results.
|
||||||
|
|
||||||
## Scope expansion: Linux targets and desktop startup
|
Linux collection provides read-only OS, service, timer calendar, system cron, TCP, package and mount observations. Missing evidence is represented by check status, never by synthetic substitution. Commands have cancellation, time/output limits and cleanup. See README for exact omissions.
|
||||||
|
|
||||||
The target scope now includes Windows Server 2016 onward and RHEL-based Linux, with either Windows or Mint as the reporting desktop. Added a RHEL synthetic dataset, target selector, background schema-validated JSON intake, OS-neutral report labels, NFS/Samba dependency rules, and a pre-Qt diagnostic for the missing Mint xcb cursor library. Live collection remains outside the current Phase 1 implementation. See PLATFORMS.md for transport boundaries, Linux inventory mappings and the required test matrix.
|
## Remaining phases
|
||||||
|
|
||||||
## Local Linux testing implementation
|
1. Independent Windows inventory scripts for system/roles, services, TCP/processes, tasks, registry software, storage/shares and public certificate metadata. Do not use Win32_Product. Optional AD enrichment must not become a prerequisite.
|
||||||
|
2. Authorized Windows execution-host remoting and Linux SSH transport, explicit targets/authentication, host verification, bounded output, timeouts and cancellation. No infrastructure configuration.
|
||||||
|
3. Expand Linux inventory for user schedulers and remaining cron/anacron coverage, identities, share exports, disk usage and application-specific public certificates with least-privilege access.
|
||||||
|
4. Validate OS-specific application/critical infrastructure rules, observed versus inferred relationships and historical/manual verification workflows. No automatic retirement approval.
|
||||||
|
5. Production schemas, redaction, Windows/RHEL platform testing, packaging and signed releases.
|
||||||
|
|
||||||
Added real read-only local collection for Mint and RPM-based Linux, a direct Analyze This Linux PC action, Settings mode, cancellation, command time/output bounds and headless JSON export. Real local results have distinct banners/history/report labels. Missing service identities, user/cron tasks, share exports, disk capacity, role correlation and certificate checks remain explicit. Both package formats are covered by parser tests; full RHEL and Windows validation remains pending. No remote transport is enabled.
|
The original empty repository had no existing application or project instructions. All current collection is local or offline; no enterprise targets are contacted in development.
|
||||||
|
|||||||
@@ -1,34 +1,20 @@
|
|||||||
# Platform support and collection plan
|
# Platform support
|
||||||
|
|
||||||
## Current functionality
|
Samsara targets retirement assessment of Windows Server 2016, 2019, 2022 and 2025, RHEL 7 (legacy)/8/9/10, and compatible enterprise Linux distributions. OS profiles express the intended assessment target, not a tested support guarantee for live collection.
|
||||||
|
|
||||||
| Desktop | Windows inventory reporting | RHEL-based inventory reporting | Live collection |
|
| Capability | Current availability |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- |
|
||||||
| Linux Mint, Python 3.11+ | Synthetic demo and schema-validated local JSON import | Synthetic demo and schema-validated local JSON import | Local read-only Linux collector on Linux; no remote collection |
|
| Local Linux collection | Implemented; RPM and dpkg inventory paths |
|
||||||
| Windows 10/11, Python 3.11+ | Same application code; Windows desktop testing pending | Same application code; Windows desktop testing pending | Not implemented |
|
| OS auto-detection | Local OS identification; Linux distribution family and major version checks |
|
||||||
|
| Saved inventory analysis | Windows/Linux v1 JSON, with OS supplied by inventory metadata |
|
||||||
|
| Windows live collection | Not implemented |
|
||||||
|
| Remote SSH/PowerShell collection | Not implemented |
|
||||||
|
| Desktop | Python 3.11+ with Qt on Linux and Windows; Windows execution validation pending |
|
||||||
|
|
||||||
**Update:** Local Linux Mint/RHEL-based collection is now implemented via `collectors/linux.py`, with direct GUI and headless CLI entry points. It uses RPM on hosts where RPM is available and dpkg on Mint. Mint-like package parsing and mocked RPM output are tested; real RHEL release validation remains pending. See README for exact coverage and missing checks. Windows live collection remains unimplemented.
|
Auto-detection reads local OS metadata without remote name resolution or probing. Explicit RHEL selections check family and major version. Other Linux distributions can use Auto-detect for local testing. Windows identification does not infer a Server edition from a desktop kernel number. Unsupported live collectors return an explicit error while keeping prior results.
|
||||||
|
|
||||||
Qt is a desktop dependency, not a dependency to install on assessed servers. Headless RHEL servers will produce JSON through a separate collector and do not need PySide6. Reporting does not require Windows administrative modules on Linux.
|
The local collector uses fixed read-only system commands and records partial or failed checks for unsupported commands, permissions and missing evidence. Test each RHEL/distribution release before relying on production output. Containers may restrict systemd, interfaces, processes and mount namespaces.
|
||||||
|
|
||||||
## Target collection support
|
Future Windows collection must use existing authorized remoting from a Windows execution host, compatible with Windows PowerShell 5.1. Windows administration modules must not be run natively on Linux. Future Linux remote collection must use existing authorized SSH with host-key verification. Neither transport should enable services, install an agent or escalate privileges automatically.
|
||||||
|
|
||||||
Windows targets: Windows Server 2016 onward. Use read-only PowerShell compatible with Windows PowerShell 5.1 from an existing authorized Windows execution host. Linux Mint can import that execution host's JSON. A future transport to a Windows execution host must be explicit and authenticated; never run Windows administration modules natively on Linux. Feature availability and permissions vary, so every module reports complete/partial/failed/not_collected separately. Test each supported Windows Server release before claiming collection compatibility.
|
Required production validation includes Windows desktop/server release testing, RHEL/distribution testing, incomplete permissions, process lifecycle behavior, schema contracts, report redaction, packaging and signing. Linux inventory coverage and current omissions are documented in README.md.
|
||||||
|
|
||||||
RHEL-based targets: RHEL and compatible distributions, initially validate supported enterprise 8/9/10 families individually; older versions require explicit compatibility testing. The offline local collector can run on the Linux execution host and export JSON for manual transfer. Future remote collection will use existing authorized SSH. No agent installation, automatic SSH enablement, privilege escalation or remote configuration. Windows desktop SSH support must be checked at runtime. Offline JSON remains the common fallback.
|
|
||||||
|
|
||||||
Linux inventory requirements:
|
|
||||||
|
|
||||||
- `/etc/os-release`, kernel, boot time, addresses, interfaces and directory integration evidence.
|
|
||||||
- systemd services with state, unit path and execution identity; no secret-bearing environment or command arguments.
|
|
||||||
- `ss` TCP observations, with unprivileged process attribution explicitly marked incomplete where necessary.
|
|
||||||
- system and accessible user timers, cron/anacron schedules; inaccessible accounts and application schedulers remain missing evidence.
|
|
||||||
- RPM package inventory; never install or alter packages.
|
|
||||||
- mounts, local/remote filesystems, free space, NFS exports and Samba shares. Distinguish remote mounts from exported storage and avoid reading protected configuration secrets.
|
|
||||||
- Configured public certificate metadata from explicit locations. Linux has no single Windows-style machine certificate store; do not claim full certificate coverage or inspect private keys.
|
|
||||||
|
|
||||||
Common analysis must distinguish observations, inferred workloads and unknowns. Windows roles and Linux package/service workload hints are not equivalent evidence. NFS, Samba and SMB exports all require dependency review; listing a share does not prove active consumers. Account and critical infrastructure rule mappings need OS-specific coverage before production deployment.
|
|
||||||
|
|
||||||
## Validation remaining
|
|
||||||
|
|
||||||
Windows GUI and collection tests; Windows Server release matrix; RHEL/compatible distribution matrix; SSH authentication and host-key checks; PowerShell transport; timeout/cancellation and output bounds; incomplete permissions; production field redaction; signed packaging. No real enterprise server is contacted during development without an explicit target and authorization.
|
|
||||||
|
|||||||
34
samsara/analyzers/summary.py
Normal file
34
samsara/analyzers/summary.py
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
"""Compact, evidence-based presentation of the full assessment."""
|
||||||
|
import re
|
||||||
|
|
||||||
|
# Name hints for server workloads, not a comprehensive application classifier.
|
||||||
|
WORKLOAD = re.compile(r'(?:nginx|apache|httpd|iis|w3svc|sql|postgres|mariadb|mysql|redis|mongo|oracle|tomcat|haproxy|docker|containerd|podman|samba|smbd|nfs|bind|named|rabbitmq|kafka|jenkins|backup|reporting)', re.I)
|
||||||
|
|
||||||
|
|
||||||
|
def summarize(data, assessment):
|
||||||
|
server, inventory = data['server'], data['inventory']
|
||||||
|
os_name = str(server.get('operating_system') or 'Not collected')
|
||||||
|
version = server.get('os_version')
|
||||||
|
if not version:
|
||||||
|
match = re.search(r'\b(?:20\d{2}|\d+(?:\.\d+)+)\b', os_name)
|
||||||
|
version = match[0] if match else 'Not collected'
|
||||||
|
version = str(version)
|
||||||
|
important = []
|
||||||
|
for row in inventory.get('software', []):
|
||||||
|
if WORKLOAD.search(row['name']) and not row['name'].lower().startswith('lib') and not row['name'].lower().endswith(('-dev', '-devel', '-doc')):
|
||||||
|
important.append({'name': row['name'], 'version': row.get('version', 'Not collected'),
|
||||||
|
'evidence': 'Installed software name matches a server-workload hint; use unverified'})
|
||||||
|
names = {row['name'].lower().split(':')[0] for row in important}
|
||||||
|
for row in inventory.get('services', []):
|
||||||
|
if row.get('state') == 'Running' and WORKLOAD.search(row['name']) and row['name'].lower().removesuffix('.service') not in names:
|
||||||
|
important.append({'name': row['name'], 'version': 'Not collected', 'evidence': 'Running service; application ownership requires review'})
|
||||||
|
jobs = []
|
||||||
|
for row in inventory.get('tasks', []):
|
||||||
|
if row.get('enabled') or row.get('state') == 'Running':
|
||||||
|
jobs.append({'name': row['name'], 'schedule': row.get('triggers', 'Schedule not collected'),
|
||||||
|
'status': row.get('state', 'Enabled schedule; execution unverified'),
|
||||||
|
'account': row.get('account', 'Not collected')})
|
||||||
|
return {'os_type': os_name, 'version': version,
|
||||||
|
'risk_score': assessment['score'], 'risk_level': assessment['level'],
|
||||||
|
'software': important, 'jobs': jobs,
|
||||||
|
'incomplete': sum(check['status'] != 'complete' for check in data['checks'].values())}
|
||||||
BIN
samsara/assets/samsara-logo.jpg
Normal file
BIN
samsara/assets/samsara-logo.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
@@ -1,6 +1,6 @@
|
|||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from server_buddy.core.validation import validate_hostname
|
from samsara.core.validation import validate_hostname
|
||||||
|
|
||||||
DEMO_HOST = "demo-web-01.example.test"
|
DEMO_HOST = "demo-web-01.example.test"
|
||||||
LINUX_DEMO_HOST = "demo-rhel-01.example.test"
|
LINUX_DEMO_HOST = "demo-rhel-01.example.test"
|
||||||
@@ -12,12 +12,12 @@ ALLOWED = {
|
|||||||
'roles': {'name', 'state'},
|
'roles': {'name', 'state'},
|
||||||
'services': {'name', 'display_name', 'state', 'startup', 'account'},
|
'services': {'name', 'display_name', 'state', 'startup', 'account'},
|
||||||
'network': {'state', 'local_address', 'local_port', 'remote_address', 'remote_port', 'pid', 'process', 'direction'},
|
'network': {'state', 'local_address', 'local_port', 'remote_address', 'remote_port', 'pid', 'process', 'direction'},
|
||||||
'tasks': {'name', 'path', 'enabled', 'account', 'triggers'},
|
'tasks': {'name', 'path', 'enabled', 'account', 'triggers', 'state', 'associated_unit'},
|
||||||
'software': {'name', 'publisher', 'version', 'installation_date'},
|
'software': {'name', 'publisher', 'version', 'installation_date'},
|
||||||
'storage': {'type', 'name', 'path', 'capacity_gib', 'free_gib'},
|
'storage': {'type', 'name', 'path', 'capacity_gib', 'free_gib'},
|
||||||
'certificates': {'subject', 'issuer', 'expires', 'thumbprint'},
|
'certificates': {'subject', 'issuer', 'expires', 'thumbprint'},
|
||||||
}
|
}
|
||||||
SERVER_FIELDS = {'hostname', 'platform', 'operating_system', 'os_build', 'last_boot', 'ip_addresses', 'network_adapters', 'domain'}
|
SERVER_FIELDS = {'hostname', 'platform', 'operating_system', 'os_version', 'os_build', 'last_boot', 'ip_addresses', 'network_adapters', 'domain'}
|
||||||
|
|
||||||
|
|
||||||
def load_inventory(path):
|
def load_inventory(path):
|
||||||
@@ -10,6 +10,7 @@ import subprocess
|
|||||||
import tempfile
|
import tempfile
|
||||||
import time
|
import time
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
from samsara.collectors.linux_schedules import read_system_cron, enrich_timers
|
||||||
|
|
||||||
|
|
||||||
class CollectionCancelled(Exception):
|
class CollectionCancelled(Exception):
|
||||||
@@ -98,7 +99,7 @@ def parse_connections(text):
|
|||||||
|
|
||||||
def collect_local_linux(stage=lambda message: None, cancelled=lambda: False, runner=run_command):
|
def collect_local_linux(stage=lambda message: None, cancelled=lambda: False, runner=run_command):
|
||||||
if platform.system() != 'Linux':
|
if platform.system() != 'Linux':
|
||||||
raise ValueError('Local Linux collection requires Linux Mint or a RHEL-based Linux execution host.')
|
raise ValueError('Local Linux collection requires a Linux execution host.')
|
||||||
started = time.monotonic()
|
started = time.monotonic()
|
||||||
data = {'schema_version': '1.0', 'synthetic': False,
|
data = {'schema_version': '1.0', 'synthetic': False,
|
||||||
'collected_at': datetime.now(timezone.utc).isoformat(),
|
'collected_at': datetime.now(timezone.utc).isoformat(),
|
||||||
@@ -134,6 +135,7 @@ def collect_local_linux(stage=lambda message: None, cancelled=lambda: False, run
|
|||||||
key, value = line.split('=', 1)
|
key, value = line.split('=', 1)
|
||||||
release[key] = ' '.join(shlex.split(value))
|
release[key] = ' '.join(shlex.split(value))
|
||||||
data['server']['operating_system'] = release.get('PRETTY_NAME', 'Linux (distribution unknown)')
|
data['server']['operating_system'] = release.get('PRETTY_NAME', 'Linux (distribution unknown)')
|
||||||
|
data['server']['os_version'] = release.get('VERSION_ID', 'Not collected')
|
||||||
boot = next(line.split()[1] for line in Path('/proc/stat').read_text().splitlines() if line.startswith('btime '))
|
boot = next(line.split()[1] for line in Path('/proc/stat').read_text().splitlines() if line.startswith('btime '))
|
||||||
data['server']['last_boot'] = datetime.fromtimestamp(int(boot), timezone.utc).isoformat()
|
data['server']['last_boot'] = datetime.fromtimestamp(int(boot), timezone.utc).isoformat()
|
||||||
check('system', system, 'Local OS, kernel and boot time observed; domain membership not inspected.')
|
check('system', system, 'Local OS, kernel and boot time observed; domain membership not inspected.')
|
||||||
@@ -147,7 +149,16 @@ def collect_local_linux(stage=lambda message: None, cancelled=lambda: False, run
|
|||||||
enabled = command(['systemctl', 'list-unit-files', '--type=' + kind, '--no-legend', '--no-pager'])
|
enabled = command(['systemctl', 'list-unit-files', '--type=' + kind, '--no-legend', '--no-pager'])
|
||||||
data['inventory']['services' if kind == 'service' else 'tasks'] = parse_units(listing, enabled, kind)
|
data['inventory']['services' if kind == 'service' else 'tasks'] = parse_units(listing, enabled, kind)
|
||||||
check('services', lambda: units('service'), 'System systemd units observed; per-service accounts, user units and executables not collected.')
|
check('services', lambda: units('service'), 'System systemd units observed; per-service accounts, user units and executables not collected.')
|
||||||
check('tasks', lambda: units('timer'), 'Loaded system timers observed; unloaded timers, schedules, cron/anacron, user timers and application schedulers require manual review.')
|
def timers():
|
||||||
|
units('timer')
|
||||||
|
enrich_timers(data['inventory']['tasks'], command)
|
||||||
|
check('tasks', timers, 'Loaded system timers and available calendar/next-run metadata observed; unloaded/user timers and application schedulers require review.')
|
||||||
|
def cron():
|
||||||
|
rows, incomplete = read_system_cron(cancelled)
|
||||||
|
data['inventory']['tasks'].extend(rows)
|
||||||
|
if incomplete:
|
||||||
|
raise RuntimeError('Some system cron files inaccessible')
|
||||||
|
check('cron', cron, 'Readable /etc/crontab and /etc/cron.d entries observed. Arguments omitted. User crontabs, anacron rules and periodic-script contents not inspected; timezone and actual execution unverified.')
|
||||||
def network():
|
def network():
|
||||||
data['inventory']['network'] = parse_connections(command(['ss', '-H', '-n', '-t', '-a', '-p']))
|
data['inventory']['network'] = parse_connections(command(['ss', '-H', '-n', '-t', '-a', '-p']))
|
||||||
check('network', network, 'TCP snapshot only. Process visibility is limited by permissions; direction, historical activity, UDP and other namespaces not verified.')
|
check('network', network, 'TCP snapshot only. Process visibility is limited by permissions; direction, historical activity, UDP and other namespaces not verified.')
|
||||||
95
samsara/collectors/linux_schedules.py
Normal file
95
samsara/collectors/linux_schedules.py
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
"""Read scheduler metadata only; never run jobs or report their arguments."""
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import shlex
|
||||||
|
|
||||||
|
SAFE_PATH = re.compile(r'^[A-Za-z0-9_./+-]+$')
|
||||||
|
|
||||||
|
|
||||||
|
def cron_rows(text, source, system_file=False):
|
||||||
|
rows = []
|
||||||
|
for number, raw in enumerate(text.splitlines(), 1):
|
||||||
|
line = raw.strip()
|
||||||
|
if not line or line.startswith('#') or re.match(r'^[A-Za-z_][A-Za-z0-9_]*\s*=', line):
|
||||||
|
continue
|
||||||
|
parts = line.split(None, 1 if line.startswith('@') else 5)
|
||||||
|
count = 1 if line.startswith('@') else 5
|
||||||
|
if count == 1 and parts[0] not in {'@reboot', '@yearly', '@annually', '@monthly', '@weekly', '@daily', '@midnight', '@hourly'}:
|
||||||
|
continue
|
||||||
|
if len(parts) <= count:
|
||||||
|
continue
|
||||||
|
schedule, command = ' '.join(parts[:count]), parts[count]
|
||||||
|
account = 'Current user'
|
||||||
|
if system_file:
|
||||||
|
user_command = command.split(None, 1)
|
||||||
|
if len(user_command) != 2:
|
||||||
|
continue
|
||||||
|
account, command = user_command
|
||||||
|
# A safe executable/script filename is enough to identify the workload.
|
||||||
|
# Arguments, embedded scripts and environment assignments never leave here.
|
||||||
|
try:
|
||||||
|
tokens = shlex.split(command)
|
||||||
|
except ValueError:
|
||||||
|
tokens = []
|
||||||
|
executable = tokens[0] if tokens and SAFE_PATH.fullmatch(tokens[0]) else '[command omitted]'
|
||||||
|
script = executable
|
||||||
|
if Path(executable).name.startswith(('python', 'perl', 'ruby', 'bash', 'sh')) and len(tokens) > 1:
|
||||||
|
if SAFE_PATH.fullmatch(tokens[1]) and not tokens[1].startswith('-'):
|
||||||
|
script = tokens[1]
|
||||||
|
rows.append({'name': f'{Path(script).name} ({source}:{number})', 'path': source,
|
||||||
|
'enabled': True, 'account': account, 'executable': executable,
|
||||||
|
'triggers': schedule + ' (cron; timezone not resolved)',
|
||||||
|
'state': 'Configured schedule; execution unverified'})
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def read_system_cron(cancelled=lambda: False):
|
||||||
|
paths = [Path('/etc/crontab')]
|
||||||
|
directory = Path('/etc/cron.d')
|
||||||
|
if directory.exists():
|
||||||
|
paths.extend(sorted(directory.iterdir()))
|
||||||
|
rows, incomplete = [], False
|
||||||
|
incomplete = len(paths) > 256
|
||||||
|
for path in paths[:256]:
|
||||||
|
if cancelled():
|
||||||
|
from samsara.collectors.linux import CollectionCancelled
|
||||||
|
raise CollectionCancelled()
|
||||||
|
if not path.exists():
|
||||||
|
continue
|
||||||
|
# Avoid following symlinks or special files. Bound every file read.
|
||||||
|
if path.is_symlink() or not path.is_file():
|
||||||
|
incomplete = True
|
||||||
|
continue
|
||||||
|
if path.name.startswith('.') or path.name.endswith(('~', '.bak', '.dpkg-old', '.rpmnew', '.rpmsave')):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
with path.open('r', encoding='utf-8', errors='replace') as source:
|
||||||
|
text = source.read(65537)
|
||||||
|
if len(text) > 65536:
|
||||||
|
incomplete = True
|
||||||
|
continue
|
||||||
|
rows.extend(cron_rows(text, str(path), system_file=True))
|
||||||
|
except OSError:
|
||||||
|
incomplete = True
|
||||||
|
return rows, incomplete
|
||||||
|
|
||||||
|
|
||||||
|
def enrich_timers(rows, command):
|
||||||
|
names = [row['name'] for row in rows if re.fullmatch(r'[A-Za-z0-9_.@:-]+\.timer', row['name'])]
|
||||||
|
if not names:
|
||||||
|
return
|
||||||
|
output = command(['systemctl', 'show', '--property=Id', '--property=TimersCalendar',
|
||||||
|
'--property=NextElapseUSecRealtime', '--property=Triggers', '--no-pager', '--', *names[:200]])
|
||||||
|
details = {}
|
||||||
|
for block in output.strip().split('\n\n'):
|
||||||
|
properties = dict(line.split('=', 1) for line in block.splitlines() if '=' in line)
|
||||||
|
if 'Id' in properties:
|
||||||
|
details[properties['Id']] = properties
|
||||||
|
for row in rows:
|
||||||
|
properties = details.get(row['name'], {})
|
||||||
|
schedule = properties.get('TimersCalendar', '')
|
||||||
|
next_run = properties.get('NextElapseUSecRealtime', '')
|
||||||
|
if schedule or next_run:
|
||||||
|
row['triggers'] = '; '.join(filter(None, [schedule, 'Next: ' + next_run if next_run else '']))
|
||||||
|
if properties.get('Triggers'):
|
||||||
|
row['associated_unit'] = properties['Triggers']
|
||||||
24
samsara/main.py
Normal file
24
samsara/main.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import sys
|
||||||
|
from samsara.core.startup import desktop_error
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
error = desktop_error()
|
||||||
|
if error:
|
||||||
|
print(error, file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
from samsara.ui.branding import configure_taskbar_identity, application_icon
|
||||||
|
configure_taskbar_identity()
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
from samsara.ui.window import MainWindow
|
||||||
|
app = QApplication(sys.argv)
|
||||||
|
app.setApplicationName('Samsara')
|
||||||
|
app.setDesktopFileName('samsara')
|
||||||
|
app.setWindowIcon(application_icon())
|
||||||
|
window = MainWindow()
|
||||||
|
window.show()
|
||||||
|
return app.exec()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
sys.exit(main())
|
||||||
31
samsara/reporting/export.py
Normal file
31
samsara/reporting/export.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import html
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from samsara.analyzers.summary import summarize
|
||||||
|
|
||||||
|
MANUAL_STEPS = ["Confirm application and business owners and obtain retirement approval.",
|
||||||
|
"Review historical monitoring, DNS, load balancers, backups and firewall logs.",
|
||||||
|
"Validate service accounts, scheduled workloads and SMB/NFS/Samba consumers with owners.",
|
||||||
|
"Resolve failed or missing checks and verify rollback and retention requirements."]
|
||||||
|
|
||||||
|
def export_json(path, data, assessment):
|
||||||
|
Path(path).write_text(json.dumps({"inventory_result": data, "assessment": assessment}, indent=2), encoding="utf-8")
|
||||||
|
|
||||||
|
def export_html(path, data, assessment):
|
||||||
|
escape = lambda value: html.escape(str(value))
|
||||||
|
content = "<h1>Samsara assessment</h1><p>SYNTHETIC DEMO — no real collection performed.</p>" if data["synthetic"] else "<h1>Samsara assessment</h1><p>IMPORTED INVENTORY — source assertions not independently verified.</p>"
|
||||||
|
if not data['synthetic'] and data['server'].get('collection_source') == 'Local Linux read-only collection':
|
||||||
|
content = '<h1>Samsara assessment</h1><p>LOCAL LINUX INVENTORY — read-only snapshot of the execution host.</p>'
|
||||||
|
content += "<p>Heuristic requiring administrator review. This report does not authorize retirement.</p>"
|
||||||
|
summary = summarize(data, assessment)
|
||||||
|
content += f"<h2>Summary</h2><p>Retirement risk estimate: {escape(summary['risk_level'])} ({summary['risk_score']}/100). Higher scores mean more retirement concerns.</p>"
|
||||||
|
content += f"<p>OS type: {escape(summary['os_type'])}<br>Version: {escape(summary['version'])}<br>Incomplete or missing checks: {summary['incomplete']}</p>"
|
||||||
|
content += '<h3>Important software</h3><p>Workload name hints and application services; use unverified.</p><ul>'
|
||||||
|
content += ''.join(f"<li>{escape(row['name'])} — {escape(row['version'])}</li>" for row in summary['software']) or '<li>No workload name matches observed; review full inventory and missing checks.</li>'
|
||||||
|
content += '</ul><h3>Scheduled jobs and scripts</h3><p>Configured schedules do not confirm execution.</p><ul>'
|
||||||
|
content += ''.join(f"<li>{escape(row['name'])} — {escape(row['schedule'])}</li>" for row in summary['jobs']) or '<li>No enabled schedules observed; scheduler coverage may be incomplete.</li>'
|
||||||
|
content += '</ul>'
|
||||||
|
for title, value in [("Server overview", data["server"]), ("Collection timestamp", data["collected_at"]), ("Inventory", data["inventory"]), ("Dependencies and risk findings", assessment["findings"]), ("Collection checks / missing evidence", data["checks"])]:
|
||||||
|
content += f"<h2>{title}</h2><pre>{escape(json.dumps(value, indent=2))}</pre>"
|
||||||
|
content += "<h2>Recommended manual verification</h2><ul>" + "".join(f"<li>{escape(s)}</li>" for s in MANUAL_STEPS) + "</ul>"
|
||||||
|
Path(path).write_text('<!doctype html><html lang="en"><meta charset="utf-8"><title>Samsara</title><style>body{font:16px system-ui;max-width:1100px;margin:40px auto;padding:20px}pre{white-space:pre-wrap;background:#eee;padding:16px}</style>' + content + '</html>', encoding="utf-8")
|
||||||
22
samsara/ui/branding.py
Normal file
22
samsara/ui/branding.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
"""Application identity and bundled logo shared by the desktop entry points."""
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
|
||||||
|
APP_ID = 'com.doorstodurin.Samsara'
|
||||||
|
LOGO_PATH = Path(__file__).resolve().parents[1] / 'assets' / 'samsara-logo.jpg'
|
||||||
|
|
||||||
|
|
||||||
|
def configure_taskbar_identity():
|
||||||
|
"""Set the Windows process identity before creating any Qt windows."""
|
||||||
|
if sys.platform == 'win32':
|
||||||
|
import ctypes
|
||||||
|
try:
|
||||||
|
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(APP_ID)
|
||||||
|
except (AttributeError, OSError):
|
||||||
|
# A shell integration failure must not prevent assessments.
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def application_icon():
|
||||||
|
from PySide6.QtGui import QIcon
|
||||||
|
return QIcon(str(LOGO_PATH))
|
||||||
465
samsara/ui/window.py
Normal file
465
samsara/ui/window.py
Normal file
@@ -0,0 +1,465 @@
|
|||||||
|
from PySide6.QtCore import Qt, QThread, Signal, QSortFilterProxyModel
|
||||||
|
from PySide6.QtGui import QStandardItem, QStandardItemModel
|
||||||
|
from PySide6.QtWidgets import (QMainWindow, QWidget, QHBoxLayout, QVBoxLayout, QLabel,
|
||||||
|
QPushButton, QListWidget, QStackedWidget, QLineEdit, QTableView,
|
||||||
|
QFileDialog, QMessageBox, QComboBox, QHeaderView, QFrame, QProgressBar, QScrollArea, QSizePolicy, QLayout, QTabWidget)
|
||||||
|
|
||||||
|
from samsara.ui.branding import application_icon
|
||||||
|
from samsara.collectors.demo import collect_demo, DEMO_HOST, LINUX_DEMO_HOST
|
||||||
|
from samsara.collectors.importer import load_inventory
|
||||||
|
from samsara.collectors.linux import collect_local_linux, CollectionCancelled
|
||||||
|
from samsara.core.detection import detect_local_os, check_profile, PROFILES
|
||||||
|
from samsara.analyzers.assessment import assess
|
||||||
|
from samsara.analyzers.summary import summarize
|
||||||
|
from samsara.reporting.export import export_html, export_json
|
||||||
|
|
||||||
|
|
||||||
|
class CollectionJob(QThread):
|
||||||
|
stage = Signal(str)
|
||||||
|
result = Signal(dict)
|
||||||
|
failed = Signal(str)
|
||||||
|
|
||||||
|
def __init__(self, source, profile='auto', inventory_path='', parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.source, self.profile, self.inventory_path = source, profile, inventory_path
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
try:
|
||||||
|
if self.source == 'local':
|
||||||
|
self.stage.emit('Detecting operating system…')
|
||||||
|
detected = detect_local_os()
|
||||||
|
if detected['platform'] != 'linux':
|
||||||
|
raise ValueError('Windows live collection is not available yet. Open a saved inventory or a sample assessment.')
|
||||||
|
check_profile(detected, self.profile)
|
||||||
|
self.stage.emit('Detected ' + detected['name'] + '. Collecting inventory…')
|
||||||
|
data = collect_local_linux(stage=self.stage.emit, cancelled=self.isInterruptionRequested)
|
||||||
|
elif self.source == 'file':
|
||||||
|
self.stage.emit('Reading saved inventory…')
|
||||||
|
data = load_inventory(self.inventory_path)
|
||||||
|
else:
|
||||||
|
self.stage.emit('Loading sample assessment…')
|
||||||
|
data = collect_demo(DEMO_HOST if self.source == 'windows_demo' else LINUX_DEMO_HOST)
|
||||||
|
if self.isInterruptionRequested():
|
||||||
|
raise CollectionCancelled()
|
||||||
|
self.stage.emit('Preparing assessment report…')
|
||||||
|
self.result.emit(data)
|
||||||
|
except CollectionCancelled:
|
||||||
|
self.failed.emit('Cancelled. Your previous assessment has been kept.')
|
||||||
|
except ValueError as exc:
|
||||||
|
self.failed.emit(str(exc))
|
||||||
|
except Exception:
|
||||||
|
self.failed.emit('Unable to complete assessment. Check file access and collection availability; previous results have been kept.')
|
||||||
|
|
||||||
|
|
||||||
|
class SearchTable(QWidget):
|
||||||
|
def __init__(self, rows):
|
||||||
|
super().__init__()
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
self.setMinimumHeight(230)
|
||||||
|
search = QLineEdit(placeholderText='Search all columns…')
|
||||||
|
layout.addWidget(search)
|
||||||
|
view = QTableView()
|
||||||
|
model = QStandardItemModel(self)
|
||||||
|
columns = list(dict.fromkeys(key for row in rows for key in row))
|
||||||
|
model.setHorizontalHeaderLabels([key.replace('_', ' ').title() for key in columns])
|
||||||
|
for row in rows:
|
||||||
|
items = []
|
||||||
|
for key in columns:
|
||||||
|
value = row.get(key)
|
||||||
|
item = QStandardItem()
|
||||||
|
item.setData('' if value is None else value if isinstance(value, (str, int, float)) else str(value), Qt.ItemDataRole.DisplayRole)
|
||||||
|
item.setEditable(False)
|
||||||
|
items.append(item)
|
||||||
|
model.appendRow(items)
|
||||||
|
proxy = QSortFilterProxyModel(self)
|
||||||
|
proxy.setSourceModel(model)
|
||||||
|
proxy.setFilterKeyColumn(-1)
|
||||||
|
proxy.setFilterCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive)
|
||||||
|
search.textChanged.connect(proxy.setFilterFixedString)
|
||||||
|
view.setModel(proxy)
|
||||||
|
view.setSortingEnabled(True)
|
||||||
|
view.setAlternatingRowColors(True)
|
||||||
|
view.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Interactive)
|
||||||
|
view.horizontalHeader().setStretchLastSection(True)
|
||||||
|
view.resizeColumnsToContents()
|
||||||
|
layout.addWidget(view)
|
||||||
|
if not rows:
|
||||||
|
layout.addWidget(QLabel('No records in this inventory. This is not proof of absence.'))
|
||||||
|
|
||||||
|
|
||||||
|
class MainWindow(QMainWindow):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.setWindowTitle('Samsara')
|
||||||
|
self.setWindowIcon(application_icon())
|
||||||
|
self.resize(1180, 820)
|
||||||
|
self.setMinimumSize(800, 620)
|
||||||
|
self.job = None
|
||||||
|
self.data = None
|
||||||
|
self.assessment = None
|
||||||
|
self.history = []
|
||||||
|
self.records = []
|
||||||
|
self.setStyleSheet('''
|
||||||
|
QWidget { background:#131b27; color:#e5ecf5; font-size:14px; }
|
||||||
|
QLabel#title { font-size:27px; font-weight:600; }
|
||||||
|
QLabel#muted { color:#a4b3c7; }
|
||||||
|
QLabel#summary { font-size:17px; font-weight:600; }
|
||||||
|
QFrame#card { background:#1b2839; border:1px solid #304158; border-radius:8px; }
|
||||||
|
QFrame#card QLabel { background:transparent; }
|
||||||
|
QPushButton { background:#283b52; border:1px solid #40546c; border-radius:5px; padding:9px 16px; }
|
||||||
|
QPushButton#primary { background:#287ac0; border:0; font-weight:600; }
|
||||||
|
QPushButton:hover { background:#346087; } QPushButton:disabled { color:#7e8c9e; background:#253143; }
|
||||||
|
QLineEdit,QComboBox { background:#1e2c3e; border:1px solid #465b75; border-radius:5px; padding:9px; min-height:20px; }
|
||||||
|
QListWidget { background:#172231; border:0; outline:0; }
|
||||||
|
QListWidget::item { padding:14px 12px; margin:3px; border-radius:5px; }
|
||||||
|
QListWidget::item:selected { background:#254b70; }
|
||||||
|
QTabBar::tab { background:#1b2839; padding:10px 12px; border-bottom:2px solid transparent; }
|
||||||
|
QTabBar::tab:selected { border-bottom:2px solid #469fe4; background:#24384f; }
|
||||||
|
QHeaderView::section { background:#273a51; padding:9px; border:0; }
|
||||||
|
QTableView { alternate-background-color:#1b293c; gridline-color:#304057; border:1px solid #304057; }
|
||||||
|
QProgressBar { max-height:4px; border:0; } QProgressBar::chunk { background:#398ed1; }
|
||||||
|
''')
|
||||||
|
central = QWidget()
|
||||||
|
self.setCentralWidget(central)
|
||||||
|
outer = QHBoxLayout(central)
|
||||||
|
outer.setContentsMargins(0, 0, 0, 0)
|
||||||
|
sidebar = QVBoxLayout()
|
||||||
|
sidebar.setContentsMargins(12, 22, 12, 16)
|
||||||
|
brand_row = QHBoxLayout()
|
||||||
|
brand_row.setSpacing(8)
|
||||||
|
self.logo = QLabel()
|
||||||
|
self.logo.setFixedSize(40, 40)
|
||||||
|
self.logo.setPixmap(application_icon().pixmap(40, 40))
|
||||||
|
self.logo.setToolTip('Samsara')
|
||||||
|
self.logo.setAccessibleName('Samsara logo')
|
||||||
|
brand_row.addWidget(self.logo)
|
||||||
|
brand = QLabel('Samsara')
|
||||||
|
brand.setObjectName('summary')
|
||||||
|
brand_row.addWidget(brand)
|
||||||
|
sidebar.addLayout(brand_row)
|
||||||
|
sidebar.addWidget(self.label('Retirement assessments', muted=True))
|
||||||
|
self.nav = QListWidget()
|
||||||
|
self.nav.addItems(['Summary', 'Analyze', 'History', 'Reports', 'Settings'])
|
||||||
|
self.nav.setFixedWidth(170)
|
||||||
|
sidebar.addWidget(self.nav)
|
||||||
|
outer.addLayout(sidebar)
|
||||||
|
self.pages = QStackedWidget()
|
||||||
|
outer.addWidget(self.pages, 1)
|
||||||
|
self.build_pages()
|
||||||
|
self.nav.currentRowChanged.connect(lambda index: self.pages.setCurrentIndex({0: 1, 1: 0, 2: 2, 3: 3, 4: 4}[index]))
|
||||||
|
self.nav.setCurrentRow(1)
|
||||||
|
self.source_changed()
|
||||||
|
|
||||||
|
def label(self, text, muted=False):
|
||||||
|
label = QLabel(text)
|
||||||
|
label.setWordWrap(True)
|
||||||
|
label.setTextFormat(Qt.TextFormat.PlainText)
|
||||||
|
if muted:
|
||||||
|
label.setObjectName('muted')
|
||||||
|
return label
|
||||||
|
|
||||||
|
def page(self, title, subtitle):
|
||||||
|
widget = QWidget()
|
||||||
|
layout = QVBoxLayout(widget)
|
||||||
|
layout.setContentsMargins(24, 22, 24, 20)
|
||||||
|
layout.setSpacing(14)
|
||||||
|
heading = self.label(title)
|
||||||
|
heading.setObjectName('title')
|
||||||
|
layout.addWidget(heading)
|
||||||
|
layout.addWidget(self.label(subtitle, muted=True))
|
||||||
|
layout.setSizeConstraint(QLayout.SizeConstraint.SetMinimumSize)
|
||||||
|
scroll = QScrollArea()
|
||||||
|
scroll.setWidgetResizable(True)
|
||||||
|
scroll.setFrameShape(QFrame.Shape.NoFrame)
|
||||||
|
scroll.setWidget(widget)
|
||||||
|
self.pages.addWidget(scroll)
|
||||||
|
return layout
|
||||||
|
|
||||||
|
def build_pages(self):
|
||||||
|
analyze = self.page('Analyze', 'Choose a source, then analyze its retirement dependencies and risks.')
|
||||||
|
card = QFrame()
|
||||||
|
card.setObjectName('card')
|
||||||
|
card.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
|
||||||
|
form = QVBoxLayout(card)
|
||||||
|
form.setContentsMargins(18, 16, 18, 16)
|
||||||
|
form.setSpacing(10)
|
||||||
|
row = QHBoxLayout()
|
||||||
|
source_col = QVBoxLayout()
|
||||||
|
source_col.addWidget(self.label('Assessment source'))
|
||||||
|
self.source = QComboBox()
|
||||||
|
for label, value in [('This computer', 'local'), ('Saved inventory file', 'file'),
|
||||||
|
('Windows Server sample', 'windows_demo'), ('RHEL sample', 'linux_demo')]:
|
||||||
|
self.source.addItem(label, value)
|
||||||
|
self.source.currentIndexChanged.connect(self.source_changed)
|
||||||
|
source_col.addWidget(self.source)
|
||||||
|
row.addLayout(source_col, 1)
|
||||||
|
os_col = QVBoxLayout()
|
||||||
|
os_col.addWidget(self.label('Operating system'))
|
||||||
|
self.target = QComboBox()
|
||||||
|
for label, value in PROFILES:
|
||||||
|
self.target.addItem(label, value)
|
||||||
|
os_col.addWidget(self.target)
|
||||||
|
row.addLayout(os_col, 1)
|
||||||
|
form.addLayout(row)
|
||||||
|
self.file_row = QWidget()
|
||||||
|
file_layout = QHBoxLayout(self.file_row)
|
||||||
|
file_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
self.inventory_path = QLineEdit(placeholderText='Choose an inventory JSON file…')
|
||||||
|
file_layout.addWidget(self.inventory_path, 1)
|
||||||
|
self.browse = QPushButton('Browse…')
|
||||||
|
self.browse.clicked.connect(self.browse_inventory)
|
||||||
|
file_layout.addWidget(self.browse)
|
||||||
|
form.addWidget(self.file_row)
|
||||||
|
self.source_hint = self.label('', muted=True)
|
||||||
|
form.addWidget(self.source_hint)
|
||||||
|
actions = QHBoxLayout()
|
||||||
|
self.analyze_button = QPushButton('Analyze')
|
||||||
|
self.analyze_button.setObjectName('primary')
|
||||||
|
self.analyze_button.clicked.connect(self.start_collection)
|
||||||
|
actions.addWidget(self.analyze_button)
|
||||||
|
self.cancel_button = QPushButton('Cancel')
|
||||||
|
self.cancel_button.clicked.connect(self.cancel_collection)
|
||||||
|
self.cancel_button.setVisible(False)
|
||||||
|
actions.addWidget(self.cancel_button)
|
||||||
|
actions.addStretch()
|
||||||
|
form.addLayout(actions)
|
||||||
|
analyze.addWidget(card)
|
||||||
|
self.status = self.label('Ready to analyze.', muted=True)
|
||||||
|
analyze.addWidget(self.status)
|
||||||
|
self.progress = QProgressBar()
|
||||||
|
self.progress.setRange(0, 0)
|
||||||
|
self.progress.hide()
|
||||||
|
analyze.addWidget(self.progress)
|
||||||
|
analyze.addStretch()
|
||||||
|
summary_page = self.page('Assessment', 'Start with the summary, then explore the evidence in each tab.')
|
||||||
|
self.result_title = self.label('No assessment yet')
|
||||||
|
self.result_title.setObjectName('summary')
|
||||||
|
self.banner = self.label('Choose Analyze to collect or open inventory.', muted=True)
|
||||||
|
summary_page.addWidget(self.result_title)
|
||||||
|
summary_page.addWidget(self.banner)
|
||||||
|
self.result_tabs = QTabWidget()
|
||||||
|
self.result_tabs.setUsesScrollButtons(True)
|
||||||
|
self.result_tabs.setDocumentMode(True)
|
||||||
|
summary_page.addWidget(self.result_tabs, 1)
|
||||||
|
self.summary_widget = QWidget()
|
||||||
|
summary_layout = QVBoxLayout(self.summary_widget)
|
||||||
|
summary_layout.setContentsMargins(12, 16, 12, 16)
|
||||||
|
summary_layout.setSpacing(16)
|
||||||
|
score_card = QFrame()
|
||||||
|
score_card.setObjectName('card')
|
||||||
|
score_card.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
|
||||||
|
score_layout = QVBoxLayout(score_card)
|
||||||
|
self.retirement_score = self.label('Retirement risk estimate: —')
|
||||||
|
self.retirement_score.setObjectName('summary')
|
||||||
|
score_layout.addWidget(self.retirement_score)
|
||||||
|
score_layout.addWidget(self.label('Higher scores mean more retirement concerns. Heuristic only; administrator review is required.', muted=True))
|
||||||
|
self.coverage_note = self.label('', muted=True)
|
||||||
|
score_layout.addWidget(self.coverage_note)
|
||||||
|
summary_layout.addWidget(score_card)
|
||||||
|
self.os_type = self.label('OS type: —')
|
||||||
|
self.os_version = self.label('Version: —')
|
||||||
|
summary_layout.addWidget(self.os_type)
|
||||||
|
summary_layout.addWidget(self.os_version)
|
||||||
|
software_heading = self.label('Important software')
|
||||||
|
software_heading.setObjectName('summary')
|
||||||
|
summary_layout.addWidget(software_heading)
|
||||||
|
self.important_software = self.label('Run an assessment to see detected server software.')
|
||||||
|
summary_layout.addWidget(self.important_software)
|
||||||
|
summary_layout.addWidget(self.label('Workload name matches and application services; see Software for the full inventory.', muted=True))
|
||||||
|
jobs_heading = self.label('Scheduled jobs & scripts')
|
||||||
|
jobs_heading.setObjectName('summary')
|
||||||
|
summary_layout.addWidget(jobs_heading)
|
||||||
|
self.scheduled_jobs = self.label('Run an assessment to see enabled schedules.')
|
||||||
|
summary_layout.addWidget(self.scheduled_jobs)
|
||||||
|
summary_layout.addStretch()
|
||||||
|
self.quick_save = QPushButton('Save HTML report')
|
||||||
|
self.quick_save.setEnabled(False)
|
||||||
|
self.quick_save.clicked.connect(lambda: self.save_report('html'))
|
||||||
|
summary_layout.addWidget(self.quick_save)
|
||||||
|
self.result_tabs.addTab(self.summary_widget, 'Summary')
|
||||||
|
self.detail_areas = {}
|
||||||
|
for title, key in [('Networking', 'network'), ('Software', 'software'), ('Tasks', 'tasks'),
|
||||||
|
('Dependencies', 'dependencies'), ('Services', 'services'), ('Storage & shares', 'storage'),
|
||||||
|
('Roles', 'roles'), ('Certificates', 'certificates'), ('Risk findings', 'risk'),
|
||||||
|
('Collection checks', 'checks'), ('System', 'overview')]:
|
||||||
|
tab = QWidget()
|
||||||
|
area = QVBoxLayout(tab)
|
||||||
|
area.addWidget(self.label('No assessment loaded.', muted=True))
|
||||||
|
self.detail_areas[key] = area
|
||||||
|
self.result_tabs.addTab(tab, title)
|
||||||
|
self.result_tabs.currentChanged.connect(self.show_details)
|
||||||
|
history = self.page('History', 'Assessments from this session. Select a record to open it.')
|
||||||
|
self.history_list = QListWidget()
|
||||||
|
self.history_list.itemActivated.connect(self.open_history)
|
||||||
|
self.history_list.itemClicked.connect(self.open_history)
|
||||||
|
history.addWidget(self.history_list)
|
||||||
|
self.history_empty = self.label('No assessments yet.', muted=True)
|
||||||
|
history.addWidget(self.history_empty)
|
||||||
|
reports = self.page('Reports', 'Save the current assessment, including evidence and missing checks.')
|
||||||
|
self.report_label = self.label('Analyze a computer or open an inventory first.', muted=True)
|
||||||
|
reports.addWidget(self.report_label)
|
||||||
|
self.report_buttons = []
|
||||||
|
for label, kind in [('Save HTML report', 'html'), ('Export JSON', 'json')]:
|
||||||
|
button = QPushButton(label)
|
||||||
|
button.clicked.connect(lambda checked=False, fmt=kind: self.save_report(fmt))
|
||||||
|
button.setEnabled(False)
|
||||||
|
self.report_buttons.append(button)
|
||||||
|
reports.addWidget(button)
|
||||||
|
reports.addStretch()
|
||||||
|
settings = self.page('Settings', 'Collection availability and assessment behavior.')
|
||||||
|
settings.addWidget(self.label('Windows Server 2016 / 2019 / 2022 / 2025\nRed Hat Enterprise Linux 7 (legacy) / 8 / 9 / 10 and compatible distributions'))
|
||||||
|
settings.addWidget(self.label('Local Linux collection is available. Live Windows and remote collection are not available yet. Saved inventories and samples work on either desktop.', muted=True))
|
||||||
|
settings.addWidget(self.label('Read-only collection • No credentials stored • Session history only', muted=True))
|
||||||
|
settings.addStretch()
|
||||||
|
|
||||||
|
def source_changed(self):
|
||||||
|
source = self.source.currentData()
|
||||||
|
self.file_row.setVisible(source == 'file')
|
||||||
|
self.target.setEnabled(source == 'local' and self.job is None)
|
||||||
|
if source != 'local':
|
||||||
|
self.target.setCurrentIndex(0)
|
||||||
|
hints = {'local': 'Auto-detect identifies this computer’s OS and runs its available collector. No remote connection is made.',
|
||||||
|
'file': 'The operating system is read from the saved inventory. No connection is made.',
|
||||||
|
'windows_demo': 'Synthetic Windows Server 2022 example. No server is contacted.',
|
||||||
|
'linux_demo': 'Synthetic RHEL 9 example. No server is contacted.'}
|
||||||
|
self.source_hint.setText(hints[source])
|
||||||
|
|
||||||
|
def browse_inventory(self):
|
||||||
|
path, _ = QFileDialog.getOpenFileName(self, 'Open inventory', '', 'Inventory JSON (*.json)')
|
||||||
|
if path:
|
||||||
|
self.inventory_path.setText(path)
|
||||||
|
|
||||||
|
def start_collection(self):
|
||||||
|
if self.job is not None:
|
||||||
|
return
|
||||||
|
source = self.source.currentData()
|
||||||
|
if source == 'file' and not self.inventory_path.text().strip():
|
||||||
|
self.status.setText('Choose an inventory file before analyzing.')
|
||||||
|
return
|
||||||
|
self.set_busy(True)
|
||||||
|
self.status.setText('Starting assessment…')
|
||||||
|
self.job = CollectionJob(source, self.target.currentData(), self.inventory_path.text().strip(), self)
|
||||||
|
self.job.stage.connect(self.status.setText)
|
||||||
|
self.job.result.connect(self.collection_done)
|
||||||
|
self.job.failed.connect(self.status.setText)
|
||||||
|
self.job.finished.connect(self.job_finished)
|
||||||
|
self.job.start()
|
||||||
|
|
||||||
|
def set_busy(self, busy):
|
||||||
|
self.analyze_button.setEnabled(not busy)
|
||||||
|
self.source.setEnabled(not busy)
|
||||||
|
self.target.setEnabled(not busy and self.source.currentData() == 'local')
|
||||||
|
self.browse.setEnabled(not busy)
|
||||||
|
self.inventory_path.setEnabled(not busy)
|
||||||
|
self.cancel_button.setVisible(busy)
|
||||||
|
self.cancel_button.setEnabled(busy)
|
||||||
|
self.progress.setVisible(busy)
|
||||||
|
|
||||||
|
def cancel_collection(self):
|
||||||
|
if self.job is not None:
|
||||||
|
self.job.requestInterruption()
|
||||||
|
self.cancel_button.setEnabled(False)
|
||||||
|
self.status.setText('Cancelling…')
|
||||||
|
|
||||||
|
def source_label(self, data):
|
||||||
|
if data['synthetic']:
|
||||||
|
return 'Synthetic sample'
|
||||||
|
return data['server'].get('collection_source', 'Imported inventory — source not independently verified')
|
||||||
|
|
||||||
|
def collection_done(self, data):
|
||||||
|
assessment = assess(data)
|
||||||
|
self.history.append({'hostname': data['server']['hostname'], 'risk': assessment['level'],
|
||||||
|
'source': self.source_label(data), 'collected_at': data['collected_at']})
|
||||||
|
self.records.append((data, assessment))
|
||||||
|
self.history_list.addItem(f"{data['server']['hostname']} • {assessment['level'].title()} risk • {self.source_label(data)}")
|
||||||
|
self.history_empty.hide()
|
||||||
|
self.display_assessment(data, assessment)
|
||||||
|
self.nav.setCurrentRow(0)
|
||||||
|
incomplete = sum(check['status'] != 'complete' for check in data['checks'].values())
|
||||||
|
self.status.setText(f'Assessment report ready. {incomplete} checks need review. Open the Summary and detail tabs to review the report.')
|
||||||
|
|
||||||
|
def display_assessment(self, data, assessment):
|
||||||
|
self.data, self.assessment = data, assessment
|
||||||
|
summary = summarize(data, assessment)
|
||||||
|
self.result_title.setText(data['server']['hostname'])
|
||||||
|
self.banner.setText(self.source_label(data) + ' • ' + data['collected_at'])
|
||||||
|
self.retirement_score.setText(f"Retirement risk estimate: {summary['risk_score']}/100 · {summary['risk_level'].title()}")
|
||||||
|
self.coverage_note.setText(f"{summary['incomplete']} checks incomplete or missing. This estimate does not establish retirement readiness.")
|
||||||
|
self.os_type.setText('OS type: ' + summary['os_type'])
|
||||||
|
self.os_version.setText('Version: ' + summary['version'])
|
||||||
|
software = summary['software']
|
||||||
|
self.important_software.setText('\n'.join(f"• {row['name']} — {row['version']}" for row in software[:6]) +
|
||||||
|
(f"\n+ {len(software) - 6} more matches; see Software." if len(software) > 6 else '') if software else
|
||||||
|
'No server-software name hints identified in collected evidence. Review the full Software and Services tabs.')
|
||||||
|
self.important_software.setToolTip('Name-based workload hints or running application services; not a complete application inventory and not proof of use.')
|
||||||
|
jobs = summary['jobs']
|
||||||
|
self.scheduled_jobs.setText('\n'.join(f"• {row['name']} — {row['schedule']}" for row in jobs[:5]) +
|
||||||
|
(f"\n+ {len(jobs) - 5} more; see Tasks." if len(jobs) > 5 else '') +
|
||||||
|
'\nSchedules indicate configured work; execution is not confirmed.' if jobs else
|
||||||
|
'No enabled schedules observed in collected evidence. Check Tasks and Collection checks for missing scheduler coverage.')
|
||||||
|
self.quick_save.setEnabled(True)
|
||||||
|
self.result_tabs.setCurrentIndex(0)
|
||||||
|
# Discard tables from the previous assessment. Populate detailed tabs on demand.
|
||||||
|
for area in self.detail_areas.values():
|
||||||
|
while area.count():
|
||||||
|
widget = area.takeAt(0).widget()
|
||||||
|
if widget:
|
||||||
|
widget.hide()
|
||||||
|
widget.deleteLater()
|
||||||
|
self.show_details()
|
||||||
|
self.report_label.setText('Current assessment: ' + data['server']['hostname'] + '\n' + self.source_label(data))
|
||||||
|
for button in self.report_buttons:
|
||||||
|
button.setEnabled(True)
|
||||||
|
|
||||||
|
def open_history(self, item):
|
||||||
|
index = self.history_list.row(item)
|
||||||
|
if 0 <= index < len(self.records):
|
||||||
|
self.display_assessment(*self.records[index])
|
||||||
|
self.status.setText('Opened assessment from session history.')
|
||||||
|
self.nav.setCurrentRow(0)
|
||||||
|
|
||||||
|
def show_details(self):
|
||||||
|
if self.data is None or self.result_tabs.currentIndex() == 0:
|
||||||
|
return
|
||||||
|
key = list(self.detail_areas)[self.result_tabs.currentIndex() - 1]
|
||||||
|
area = self.detail_areas[key]
|
||||||
|
if area.count():
|
||||||
|
return
|
||||||
|
if key == 'overview':
|
||||||
|
rows = [{'property': name.replace('_', ' ').title(), 'value': str(value)} for name, value in self.data['server'].items()]
|
||||||
|
elif key == 'checks':
|
||||||
|
rows = [{'check': name, **value} for name, value in self.data['checks'].items()]
|
||||||
|
elif key in ('risk', 'dependencies'):
|
||||||
|
rows = self.assessment['findings']
|
||||||
|
else:
|
||||||
|
rows = self.data['inventory'][key]
|
||||||
|
if key in self.data['checks']:
|
||||||
|
check = self.data['checks'][key]
|
||||||
|
area.addWidget(self.label(f"{check['status'].replace('_', ' ').title()}: {check['message']}", muted=True))
|
||||||
|
area.addWidget(SearchTable(rows))
|
||||||
|
|
||||||
|
def job_finished(self):
|
||||||
|
self.job.deleteLater()
|
||||||
|
self.job = None
|
||||||
|
self.set_busy(False)
|
||||||
|
|
||||||
|
def save_report(self, kind):
|
||||||
|
if self.data is None:
|
||||||
|
return
|
||||||
|
path, _ = QFileDialog.getSaveFileName(self, 'Save assessment', f'samsara-assessment.{kind}', f'{kind.upper()} (*.{kind})')
|
||||||
|
if not path:
|
||||||
|
return
|
||||||
|
if not path.lower().endswith('.' + kind):
|
||||||
|
path += '.' + kind
|
||||||
|
try:
|
||||||
|
(export_html if kind == 'html' else export_json)(path, self.data, self.assessment)
|
||||||
|
QMessageBox.information(self, 'Report saved', 'Assessment saved locally.')
|
||||||
|
except OSError:
|
||||||
|
QMessageBox.warning(self, 'Save failed', 'Unable to write the report. Check the destination and file permissions.')
|
||||||
|
|
||||||
|
def closeEvent(self, event):
|
||||||
|
if self.job is not None and self.job.isRunning():
|
||||||
|
self.status.setText('Cancel the running assessment before closing.')
|
||||||
|
event.ignore()
|
||||||
|
else:
|
||||||
|
event.accept()
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"title": "Server Buddy inventory v1",
|
"title": "Samsara inventory v1",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
"schema_version",
|
"schema_version",
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
import sys
|
|
||||||
from server_buddy.core.startup import desktop_error
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
error = desktop_error()
|
|
||||||
if error:
|
|
||||||
print(error, file=sys.stderr)
|
|
||||||
return 1
|
|
||||||
from PySide6.QtWidgets import QApplication
|
|
||||||
from server_buddy.ui.window import MainWindow
|
|
||||||
app = QApplication(sys.argv)
|
|
||||||
app.setApplicationName('Server Buddy')
|
|
||||||
window = MainWindow()
|
|
||||||
window.show()
|
|
||||||
return app.exec()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
sys.exit(main())
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import html
|
|
||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
MANUAL_STEPS = ["Confirm application and business owners and obtain retirement approval.",
|
|
||||||
"Review historical monitoring, DNS, load balancers, backups and firewall logs.",
|
|
||||||
"Validate service accounts, scheduled workloads and SMB/NFS/Samba consumers with owners.",
|
|
||||||
"Resolve failed or missing checks and verify rollback and retention requirements."]
|
|
||||||
|
|
||||||
def export_json(path, data, assessment):
|
|
||||||
Path(path).write_text(json.dumps({"inventory_result": data, "assessment": assessment}, indent=2), encoding="utf-8")
|
|
||||||
|
|
||||||
def export_html(path, data, assessment):
|
|
||||||
escape = lambda value: html.escape(str(value))
|
|
||||||
content = "<h1>Server Buddy assessment</h1><p>SYNTHETIC DEMO — no real collection performed.</p>" if data["synthetic"] else "<h1>Server Buddy assessment</h1><p>IMPORTED INVENTORY — source assertions not independently verified.</p>"
|
|
||||||
if not data['synthetic'] and data['server'].get('collection_source') == 'Local Linux read-only collection':
|
|
||||||
content = '<h1>Server Buddy assessment</h1><p>LOCAL LINUX INVENTORY — read-only snapshot of the execution host.</p>'
|
|
||||||
content += "<p>Heuristic requiring administrator review. This report does not authorize retirement.</p>"
|
|
||||||
content += f"<p>Risk: {escape(assessment['level'])} ({assessment['score']}/100)</p>"
|
|
||||||
for title, value in [("Server overview", data["server"]), ("Collection timestamp", data["collected_at"]), ("Inventory", data["inventory"]), ("Dependencies and risk findings", assessment["findings"]), ("Collection checks / missing evidence", data["checks"])]:
|
|
||||||
content += f"<h2>{title}</h2><pre>{escape(json.dumps(value, indent=2))}</pre>"
|
|
||||||
content += "<h2>Recommended manual verification</h2><ul>" + "".join(f"<li>{escape(s)}</li>" for s in MANUAL_STEPS) + "</ul>"
|
|
||||||
Path(path).write_text('<!doctype html><html lang="en"><meta charset="utf-8"><title>Server Buddy</title><style>body{font:16px system-ui;max-width:1100px;margin:40px auto;padding:20px}pre{white-space:pre-wrap;background:#eee;padding:16px}</style>' + content + '</html>', encoding="utf-8")
|
|
||||||
@@ -1,318 +0,0 @@
|
|||||||
import platform
|
|
||||||
|
|
||||||
from PySide6.QtCore import Qt, QThread, Signal, QSortFilterProxyModel
|
|
||||||
from PySide6.QtGui import QStandardItem, QStandardItemModel
|
|
||||||
from PySide6.QtWidgets import (QMainWindow, QWidget, QHBoxLayout, QVBoxLayout, QLabel,
|
|
||||||
QPushButton, QListWidget, QStackedWidget, QLineEdit, QTabWidget, QTableView,
|
|
||||||
QFileDialog, QMessageBox, QComboBox, QHeaderView)
|
|
||||||
|
|
||||||
from server_buddy.collectors.demo import collect_demo, DEMO_HOST, LINUX_DEMO_HOST
|
|
||||||
from server_buddy.collectors.importer import load_inventory
|
|
||||||
from server_buddy.collectors.linux import collect_local_linux, CollectionCancelled
|
|
||||||
from server_buddy.core.validation import validate_hostname
|
|
||||||
from server_buddy.analyzers.assessment import assess
|
|
||||||
from server_buddy.reporting.export import export_html, export_json
|
|
||||||
|
|
||||||
|
|
||||||
class CollectionJob(QThread):
|
|
||||||
stage = Signal(str)
|
|
||||||
result = Signal(dict)
|
|
||||||
failed = Signal(str)
|
|
||||||
|
|
||||||
def __init__(self, hostname, parent=None, inventory_path=None, local_linux=False):
|
|
||||||
super().__init__(parent)
|
|
||||||
self.hostname = hostname
|
|
||||||
self.inventory_path = inventory_path
|
|
||||||
self.local_linux = local_linux
|
|
||||||
|
|
||||||
def run(self):
|
|
||||||
try:
|
|
||||||
self.stage.emit('Loading inventory JSON…' if self.inventory_path else 'Loading synthetic inventory…')
|
|
||||||
if self.local_linux:
|
|
||||||
data = collect_local_linux(stage=self.stage.emit, cancelled=self.isInterruptionRequested)
|
|
||||||
else:
|
|
||||||
data = load_inventory(self.inventory_path) if self.inventory_path else collect_demo(self.hostname)
|
|
||||||
if self.isInterruptionRequested():
|
|
||||||
raise CollectionCancelled('Collection cancelled; previous assessment retained.')
|
|
||||||
self.stage.emit('Evaluating inventory evidence…')
|
|
||||||
self.result.emit(data)
|
|
||||||
except CollectionCancelled:
|
|
||||||
self.failed.emit('Collection cancelled; previous assessment retained.')
|
|
||||||
except ValueError as exc:
|
|
||||||
self.failed.emit(str(exc))
|
|
||||||
except Exception:
|
|
||||||
self.failed.emit('Collection could not complete. Verify the selected environment, inventory format and file access. No synthetic replacement was used.')
|
|
||||||
|
|
||||||
|
|
||||||
class SearchTable(QWidget):
|
|
||||||
def __init__(self, rows):
|
|
||||||
super().__init__()
|
|
||||||
layout = QVBoxLayout(self)
|
|
||||||
search = QLineEdit(placeholderText='Search all columns…')
|
|
||||||
layout.addWidget(search)
|
|
||||||
view = QTableView()
|
|
||||||
model = QStandardItemModel(self)
|
|
||||||
columns = list(dict.fromkeys(key for row in rows for key in row))
|
|
||||||
model.setHorizontalHeaderLabels([key.replace('_', ' ').title() for key in columns])
|
|
||||||
for row in rows:
|
|
||||||
items = []
|
|
||||||
for key in columns:
|
|
||||||
value = row.get(key)
|
|
||||||
item = QStandardItem()
|
|
||||||
item.setData('' if value is None else value if isinstance(value, (str, int, float)) else str(value), Qt.ItemDataRole.DisplayRole)
|
|
||||||
item.setEditable(False)
|
|
||||||
items.append(item)
|
|
||||||
model.appendRow(items)
|
|
||||||
proxy = QSortFilterProxyModel(self)
|
|
||||||
proxy.setSourceModel(model)
|
|
||||||
proxy.setFilterKeyColumn(-1)
|
|
||||||
proxy.setFilterCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive)
|
|
||||||
search.textChanged.connect(proxy.setFilterFixedString)
|
|
||||||
view.setModel(proxy)
|
|
||||||
view.setSortingEnabled(True)
|
|
||||||
view.setAlternatingRowColors(True)
|
|
||||||
view.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Interactive)
|
|
||||||
view.horizontalHeader().setStretchLastSection(True)
|
|
||||||
view.resizeColumnsToContents()
|
|
||||||
layout.addWidget(view)
|
|
||||||
if not rows:
|
|
||||||
layout.addWidget(QLabel('No records in this inventory. This is not proof of absence.'))
|
|
||||||
|
|
||||||
|
|
||||||
class MainWindow(QMainWindow):
|
|
||||||
def __init__(self):
|
|
||||||
super().__init__()
|
|
||||||
self.setWindowTitle('Server Buddy — Retirement Assessment')
|
|
||||||
self.resize(1280, 820)
|
|
||||||
self.setMinimumSize(760, 540)
|
|
||||||
self.job = None
|
|
||||||
self.data = collect_demo(DEMO_HOST)
|
|
||||||
self.assessment = assess(self.data)
|
|
||||||
self.history = [self.history_row()]
|
|
||||||
self.setStyleSheet('''
|
|
||||||
QWidget { background:#121b29; color:#e4eaf2; font-size:13px; }
|
|
||||||
QLabel#title { font-size:24px; font-weight:600; }
|
|
||||||
QLabel#banner { background:#46391b; color:#ffde8a; padding:12px; }
|
|
||||||
QPushButton { background:#236a9b; border:0; border-radius:5px; padding:10px 16px; }
|
|
||||||
QPushButton:hover { background:#3284ba; } QPushButton:disabled { background:#394355; }
|
|
||||||
QLineEdit,QComboBox { background:#202e40; border:1px solid #40516a; padding:9px; }
|
|
||||||
QListWidget { background:#182334; border:0; min-width:170px; }
|
|
||||||
QListWidget::item { padding:16px 10px; } QListWidget::item:selected { background:#235477; }
|
|
||||||
QTabBar::tab { padding:9px; background:#243348; } QTabBar::tab:selected { background:#236a9b; }
|
|
||||||
QHeaderView::section { background:#293c53; padding:8px; border:0; }
|
|
||||||
QTableView { alternate-background-color:#1b293c; gridline-color:#304057; }
|
|
||||||
''')
|
|
||||||
central = QWidget()
|
|
||||||
self.setCentralWidget(central)
|
|
||||||
layout = QHBoxLayout(central)
|
|
||||||
self.nav = QListWidget()
|
|
||||||
self.nav.addItems(['Dashboard', 'Server Analyzer', 'Dependency Explorer', 'Assessment History', 'Reports', 'Settings'])
|
|
||||||
self.nav.setMaximumWidth(220)
|
|
||||||
layout.addWidget(self.nav)
|
|
||||||
right = QVBoxLayout()
|
|
||||||
layout.addLayout(right, 1)
|
|
||||||
banner = self.banner = QLabel('SYNTHETIC DEMO • Offline fixtures only • No infrastructure is contacted')
|
|
||||||
banner.setObjectName('banner')
|
|
||||||
banner.setWordWrap(True)
|
|
||||||
right.addWidget(banner)
|
|
||||||
self.pages = QStackedWidget()
|
|
||||||
right.addWidget(self.pages)
|
|
||||||
self.build_pages()
|
|
||||||
self.nav.currentRowChanged.connect(self.pages.setCurrentIndex)
|
|
||||||
self.nav.setCurrentRow(0)
|
|
||||||
|
|
||||||
def page(self, title):
|
|
||||||
widget = QWidget()
|
|
||||||
layout = QVBoxLayout(widget)
|
|
||||||
label = QLabel(title)
|
|
||||||
label.setObjectName('title')
|
|
||||||
layout.addWidget(label)
|
|
||||||
self.pages.addWidget(widget)
|
|
||||||
return layout
|
|
||||||
|
|
||||||
def history_row(self):
|
|
||||||
return {'hostname': self.data['server']['hostname'], 'collected_at': self.data['collected_at'],
|
|
||||||
'risk': self.assessment['level'], 'source': 'Synthetic fixture' if self.data['synthetic'] else self.data['server'].get('collection_source', 'Imported inventory')}
|
|
||||||
|
|
||||||
def build_pages(self):
|
|
||||||
dash = self.page('Retirement assessment dashboard')
|
|
||||||
self.metrics = QLabel()
|
|
||||||
self.metrics.setWordWrap(True)
|
|
||||||
dash.addWidget(self.metrics)
|
|
||||||
dash.addWidget(QLabel('Recently assessed servers — see Source column'))
|
|
||||||
self.recent_area = QVBoxLayout()
|
|
||||||
dash.addLayout(self.recent_area)
|
|
||||||
analyzer = self.page('Server Analyzer')
|
|
||||||
analyzer.addWidget(QLabel('Target platform (demo inventory)'))
|
|
||||||
self.target = QComboBox()
|
|
||||||
self.target.addItems(['Windows Server 2016 onward', 'RHEL-based Linux'])
|
|
||||||
self.target.currentIndexChanged.connect(self.target_changed)
|
|
||||||
analyzer.addWidget(self.target)
|
|
||||||
analyzer.addWidget(QLabel('Server Hostname or FQDN'))
|
|
||||||
row = QHBoxLayout()
|
|
||||||
self.hostname = QLineEdit(DEMO_HOST)
|
|
||||||
row.addWidget(self.hostname)
|
|
||||||
self.analyze_button = QPushButton('Analyze Server')
|
|
||||||
self.analyze_button.clicked.connect(self.start_collection)
|
|
||||||
self.hostname.returnPressed.connect(self.start_collection)
|
|
||||||
row.addWidget(self.analyze_button)
|
|
||||||
analyzer.addLayout(row)
|
|
||||||
self.local_button = QPushButton('Analyze This Linux PC — read-only')
|
|
||||||
self.local_button.setEnabled(platform.system() == 'Linux')
|
|
||||||
self.local_button.clicked.connect(self.start_local_collection)
|
|
||||||
analyzer.addWidget(self.local_button)
|
|
||||||
note = QLabel('Local collection assesses the computer running Server Buddy. No remote hostname is used. Mint testing uses dpkg; RHEL-based Linux uses RPM. No sudo required.')
|
|
||||||
note.setWordWrap(True)
|
|
||||||
analyzer.addWidget(note)
|
|
||||||
self.cancel_button = QPushButton('Cancel collection')
|
|
||||||
self.cancel_button.setEnabled(False)
|
|
||||||
self.cancel_button.clicked.connect(self.cancel_collection)
|
|
||||||
analyzer.addWidget(self.cancel_button)
|
|
||||||
self.import_button = QPushButton('Import Windows / Linux inventory JSON')
|
|
||||||
self.import_button.clicked.connect(self.import_inventory)
|
|
||||||
analyzer.addWidget(self.import_button)
|
|
||||||
self.status = QLabel('Ready. Demo supports only ' + DEMO_HOST)
|
|
||||||
self.status.setWordWrap(True)
|
|
||||||
analyzer.addWidget(self.status)
|
|
||||||
self.tabs = QTabWidget()
|
|
||||||
self.tabs.setUsesScrollButtons(True)
|
|
||||||
analyzer.addWidget(self.tabs)
|
|
||||||
deps = self.page('Dependency Explorer')
|
|
||||||
deps.addWidget(QLabel('Evidence indicates possible consumers; confirm ownership and activity over time.'))
|
|
||||||
self.deps_area = QVBoxLayout()
|
|
||||||
deps.addLayout(self.deps_area)
|
|
||||||
history = self.page('Assessment History')
|
|
||||||
history.addWidget(QLabel('Session history only. Source column distinguishes synthetic and imported records.'))
|
|
||||||
self.history_area = QVBoxLayout()
|
|
||||||
history.addLayout(self.history_area)
|
|
||||||
reports = self.page('Reports')
|
|
||||||
reports.addWidget(QLabel('Export the current assessment with source labels, missing evidence and manual checks. Review inventory for sensitive content before sharing.'))
|
|
||||||
for label, fmt in [('Save HTML report', 'html'), ('Export JSON', 'json')]:
|
|
||||||
button = QPushButton(label)
|
|
||||||
button.clicked.connect(lambda checked=False, kind=fmt: self.save_report(kind))
|
|
||||||
reports.addWidget(button)
|
|
||||||
reports.addStretch()
|
|
||||||
settings = self.page('Settings')
|
|
||||||
settings.addWidget(QLabel('Execution environment for Analyze Server (JSON import is always offline)'))
|
|
||||||
self.environment = QComboBox()
|
|
||||||
self.environment.addItems(['Offline synthetic demo', 'Windows execution host (future phase)', 'This Linux PC — read-only'])
|
|
||||||
settings.addWidget(self.environment)
|
|
||||||
info = QLabel(f'Local platform: {platform.system()}\nWindows remoting is not implemented in Phase 1.\nPlanned targets: Windows Server 2016 onward and RHEL-based Linux.\nWindows collection: authorized Windows execution host. Linux collection: local Mint / RHEL-based inventory now available; remote SSH is planned.\nWindows and Linux desktops can import either inventory format.\nNo credentials are requested or stored. No remote configuration is performed.')
|
|
||||||
info.setWordWrap(True)
|
|
||||||
settings.addWidget(info)
|
|
||||||
settings.addStretch()
|
|
||||||
self.refresh()
|
|
||||||
|
|
||||||
def replace_table(self, layout, rows):
|
|
||||||
while layout.count():
|
|
||||||
item = layout.takeAt(0)
|
|
||||||
if item.widget():
|
|
||||||
item.widget().deleteLater()
|
|
||||||
layout.addWidget(SearchTable(rows))
|
|
||||||
|
|
||||||
def refresh(self):
|
|
||||||
high = sum(f['severity'] in ('high', 'critical') for f in self.assessment['findings'])
|
|
||||||
self.metrics.setText(f'Total servers assessed: {len({row["hostname"] for row in self.history})} • Assessments requiring review: {len({row["hostname"] for row in self.history})} • High-risk findings (current assessment): {high}\nHeuristic risk: {self.assessment["level"].upper()} ({self.assessment["score"]}/100). Administrator review required; never an automatic retirement decision.')
|
|
||||||
self.replace_table(self.recent_area, [self.history[-1]])
|
|
||||||
self.replace_table(self.history_area, self.history)
|
|
||||||
self.replace_table(self.deps_area, self.assessment['findings'])
|
|
||||||
while self.tabs.count():
|
|
||||||
widget = self.tabs.widget(0)
|
|
||||||
self.tabs.removeTab(0)
|
|
||||||
widget.deleteLater()
|
|
||||||
overview = [{'property': k.replace('_', ' ').title(), 'value': str(v)} for k, v in self.data['server'].items()]
|
|
||||||
overview += [{'property': 'Collection timestamp (reported by source)', 'value': self.data['collected_at']}]
|
|
||||||
overview += [{'property': f'Check: {k}', 'value': f'{v["status"]}: {v["message"]}'} for k, v in self.data['checks'].items()]
|
|
||||||
self.tabs.addTab(SearchTable(overview), 'Overview')
|
|
||||||
for key, label in [('roles', 'Roles & Workloads'), ('services', 'Services'), ('network', 'Network Connections'), ('tasks', 'Scheduled Tasks / Timers'), ('software', 'Software'), ('storage', 'Storage & Shares'), ('certificates', 'Certificates')]:
|
|
||||||
self.tabs.addTab(SearchTable(self.data['inventory'][key]), label)
|
|
||||||
self.tabs.addTab(SearchTable(self.assessment['findings']), 'Dependencies')
|
|
||||||
self.tabs.addTab(SearchTable(self.assessment['findings']), 'Risk Assessment')
|
|
||||||
|
|
||||||
def target_changed(self, index):
|
|
||||||
self.hostname.setText(LINUX_DEMO_HOST if index else DEMO_HOST)
|
|
||||||
|
|
||||||
def import_inventory(self):
|
|
||||||
if self.job is not None:
|
|
||||||
return
|
|
||||||
path, _ = QFileDialog.getOpenFileName(self, 'Import inventory JSON', '', 'JSON (*.json)')
|
|
||||||
if path:
|
|
||||||
self.start_job('', path)
|
|
||||||
|
|
||||||
def cancel_collection(self):
|
|
||||||
if self.job is not None:
|
|
||||||
self.job.requestInterruption()
|
|
||||||
self.cancel_button.setEnabled(False)
|
|
||||||
self.status.setText('Cancelling collection…')
|
|
||||||
|
|
||||||
def start_local_collection(self):
|
|
||||||
if self.job is not None:
|
|
||||||
return
|
|
||||||
if platform.system() != 'Linux':
|
|
||||||
self.status.setText('Local Linux collection requires Linux. Windows collection is not implemented yet; use demo or JSON import.')
|
|
||||||
return
|
|
||||||
self.start_job('', local_linux=True)
|
|
||||||
|
|
||||||
def start_job(self, hostname, inventory_path=None, local_linux=False):
|
|
||||||
self.analyze_button.setEnabled(False)
|
|
||||||
self.import_button.setEnabled(False)
|
|
||||||
self.local_button.setEnabled(False)
|
|
||||||
self.cancel_button.setEnabled(True)
|
|
||||||
self.status.setText('Starting local read-only Linux inventory…' if local_linux else 'Loading offline inventory…')
|
|
||||||
self.job = CollectionJob(hostname, self, inventory_path=inventory_path, local_linux=local_linux)
|
|
||||||
self.job.stage.connect(self.status.setText)
|
|
||||||
self.job.result.connect(self.collection_done)
|
|
||||||
self.job.failed.connect(lambda message: self.status.setText('Collection failed: ' + message))
|
|
||||||
self.job.finished.connect(self.job_finished)
|
|
||||||
self.job.start()
|
|
||||||
|
|
||||||
def start_collection(self):
|
|
||||||
if self.job is not None:
|
|
||||||
return
|
|
||||||
if self.environment.currentIndex() == 2:
|
|
||||||
self.start_local_collection()
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
hostname = validate_hostname(self.hostname.text())
|
|
||||||
if self.environment.currentIndex() != 0:
|
|
||||||
raise ValueError('Windows execution host collection is not implemented in Phase 1. Select Offline synthetic demo in Settings.')
|
|
||||||
except ValueError as exc:
|
|
||||||
self.status.setText(str(exc))
|
|
||||||
return
|
|
||||||
self.start_job(hostname)
|
|
||||||
|
|
||||||
def collection_done(self, data):
|
|
||||||
self.data = data
|
|
||||||
self.assessment = assess(data)
|
|
||||||
self.history.append(self.history_row())
|
|
||||||
self.refresh()
|
|
||||||
local = data['server'].get('collection_source') == 'Local Linux read-only collection'
|
|
||||||
self.banner.setText('SYNTHETIC DEMO • Offline fixtures only • No infrastructure is contacted' if data['synthetic'] else 'LOCAL LINUX INVENTORY • Read-only snapshot • Review partial and missing checks' if local else 'IMPORTED INVENTORY • Source assertions not independently verified • No remote connection made')
|
|
||||||
self.status.setText('Complete — synthetic fixture loaded; no real server was assessed.' if data['synthetic'] else 'Complete — local Linux inventory collected. Review partial/failed checks in Overview, then save a report from Reports.' if local else 'Complete — imported inventory analyzed. Collection timestamp and check statuses come from the file.')
|
|
||||||
|
|
||||||
def job_finished(self):
|
|
||||||
self.job.deleteLater()
|
|
||||||
self.job = None
|
|
||||||
self.analyze_button.setEnabled(True)
|
|
||||||
self.import_button.setEnabled(True)
|
|
||||||
self.local_button.setEnabled(platform.system() == 'Linux')
|
|
||||||
self.cancel_button.setEnabled(False)
|
|
||||||
|
|
||||||
def save_report(self, kind):
|
|
||||||
path, _ = QFileDialog.getSaveFileName(self, 'Save assessment', f'server-buddy-assessment.{kind}', f'{kind.upper()} (*.{kind})')
|
|
||||||
if not path:
|
|
||||||
return
|
|
||||||
if not path.lower().endswith('.' + kind):
|
|
||||||
path += '.' + kind
|
|
||||||
try:
|
|
||||||
(export_html if kind == 'html' else export_json)(path, self.data, self.assessment)
|
|
||||||
QMessageBox.information(self, 'Report saved', 'Assessment saved locally.')
|
|
||||||
except OSError:
|
|
||||||
QMessageBox.warning(self, 'Save failed', 'Unable to write the report. Check the destination and file permissions.')
|
|
||||||
|
|
||||||
def closeEvent(self, event):
|
|
||||||
if self.job is not None and self.job.isRunning():
|
|
||||||
self.status.setText('Collection is running. Use Cancel collection, then close when the worker finishes.')
|
|
||||||
event.ignore()
|
|
||||||
else:
|
|
||||||
event.accept()
|
|
||||||
@@ -2,10 +2,10 @@ import json
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
from server_buddy.core.validation import validate_hostname
|
from samsara.core.validation import validate_hostname
|
||||||
from server_buddy.collectors.demo import collect_demo, DEMO_HOST
|
from samsara.collectors.demo import collect_demo, DEMO_HOST
|
||||||
from server_buddy.analyzers.assessment import assess
|
from samsara.analyzers.assessment import assess
|
||||||
from server_buddy.reporting.export import export_html, export_json
|
from samsara.reporting.export import export_html, export_json
|
||||||
|
|
||||||
class CoreTests(unittest.TestCase):
|
class CoreTests(unittest.TestCase):
|
||||||
def test_hostname(self):
|
def test_hostname(self):
|
||||||
@@ -39,6 +39,8 @@ class CoreTests(unittest.TestCase):
|
|||||||
text = report.read_text()
|
text = report.read_text()
|
||||||
self.assertNotIn('<script>', text)
|
self.assertNotIn('<script>', text)
|
||||||
self.assertIn('SYNTHETIC', text)
|
self.assertIn('SYNTHETIC', text)
|
||||||
|
self.assertIn('<title>Samsara</title>', text)
|
||||||
|
self.assertIn('Samsara assessment', text)
|
||||||
self.assertIn('missing evidence', text)
|
self.assertIn('missing evidence', text)
|
||||||
self.assertIn('manual verification', text)
|
self.assertIn('manual verification', text)
|
||||||
output = Path(directory) / 'report.json'
|
output = Path(directory) / 'report.json'
|
||||||
|
|||||||
34
tests/test_detection.py
Normal file
34
tests/test_detection.py
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
from samsara.core.detection import detect_local_os, check_profile, PROFILES
|
||||||
|
|
||||||
|
class DetectionTests(unittest.TestCase):
|
||||||
|
@patch('samsara.core.detection.platform.system', return_value='Linux')
|
||||||
|
def test_rhel_and_compatible_release(self, system):
|
||||||
|
for release in ['ID=rhel\nVERSION_ID="9.4"\nPRETTY_NAME="Red Hat Enterprise Linux 9.4"',
|
||||||
|
'ID=rocky\nID_LIKE="rhel centos fedora"\nVERSION_ID=9.4']:
|
||||||
|
with patch('samsara.core.detection.Path.read_text', return_value=release):
|
||||||
|
info = detect_local_os()
|
||||||
|
self.assertEqual(info['family'], 'rhel')
|
||||||
|
self.assertEqual(info['version'], '9')
|
||||||
|
check_profile(info, 'rhel:9')
|
||||||
|
check_profile(info, 'rhel:any')
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
check_profile(info, 'rhel:8')
|
||||||
|
|
||||||
|
@patch('samsara.core.detection.platform.system', return_value='Linux')
|
||||||
|
@patch('samsara.core.detection.Path.read_text', return_value='ID=linuxmint\nID_LIKE="ubuntu debian"\nVERSION_ID=22\nPRETTY_NAME="Linux Mint"')
|
||||||
|
def test_generic_linux_not_misidentified_as_rhel(self, release, system):
|
||||||
|
info = detect_local_os()
|
||||||
|
self.assertEqual(info['family'], 'linux')
|
||||||
|
check_profile(info, 'auto')
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
check_profile(info, 'rhel:any')
|
||||||
|
|
||||||
|
@patch('samsara.core.detection.platform.system', return_value='Windows')
|
||||||
|
@patch('samsara.core.detection.platform.win32_ver', return_value=('11', '', '', ''))
|
||||||
|
def test_windows_desktop_not_invented_as_server(self, version, system):
|
||||||
|
info = detect_local_os()
|
||||||
|
self.assertEqual(info['platform'], 'windows')
|
||||||
|
self.assertEqual(info['version'], '')
|
||||||
|
self.assertIn(('Windows Server 2025', 'windows:2025'), PROFILES)
|
||||||
@@ -3,10 +3,10 @@ from pathlib import Path
|
|||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
from server_buddy.collectors.demo import collect_demo, LINUX_DEMO_HOST
|
from samsara.collectors.demo import collect_demo, LINUX_DEMO_HOST
|
||||||
from server_buddy.analyzers.assessment import assess
|
from samsara.analyzers.assessment import assess
|
||||||
try:
|
try:
|
||||||
from server_buddy.collectors.importer import load_inventory
|
from samsara.collectors.importer import load_inventory
|
||||||
except ImportError:
|
except ImportError:
|
||||||
load_inventory = None
|
load_inventory = None
|
||||||
|
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ import tempfile
|
|||||||
import unittest
|
import unittest
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from server_buddy.collectors.linux import (collect_local_linux, parse_connections,
|
from samsara.collectors.linux import (collect_local_linux, parse_connections,
|
||||||
parse_units, run_command, CollectionCancelled)
|
parse_units, run_command, CollectionCancelled)
|
||||||
from server_buddy.collectors.demo import collect_demo, LINUX_DEMO_HOST
|
from samsara.collectors.demo import collect_demo, LINUX_DEMO_HOST
|
||||||
from server_buddy.analyzers.assessment import assess
|
from samsara.analyzers.assessment import assess
|
||||||
from server_buddy.reporting.export import export_html
|
from samsara.reporting.export import export_html
|
||||||
|
|
||||||
|
|
||||||
class LinuxTests(unittest.TestCase):
|
class LinuxTests(unittest.TestCase):
|
||||||
@@ -28,12 +28,12 @@ class LinuxTests(unittest.TestCase):
|
|||||||
self.assertEqual(rows[1]['process'], 'nginx')
|
self.assertEqual(rows[1]['process'], 'nginx')
|
||||||
self.assertEqual(rows[1]['direction'], 'Unknown')
|
self.assertEqual(rows[1]['direction'], 'Unknown')
|
||||||
|
|
||||||
@patch('server_buddy.collectors.linux.platform.system', return_value='Windows')
|
@patch('samsara.collectors.linux.platform.system', return_value='Windows')
|
||||||
def test_wrong_execution_platform(self, system):
|
def test_wrong_execution_platform(self, system):
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValueError):
|
||||||
collect_local_linux()
|
collect_local_linux()
|
||||||
|
|
||||||
@patch('server_buddy.collectors.linux.platform.system', return_value='Linux')
|
@patch('samsara.collectors.linux.platform.system', return_value='Linux')
|
||||||
def test_partial_failures_never_use_demo(self, system):
|
def test_partial_failures_never_use_demo(self, system):
|
||||||
def unavailable(args, **kwargs):
|
def unavailable(args, **kwargs):
|
||||||
raise RuntimeError('SECRET diagnostics must not be reported')
|
raise RuntimeError('SECRET diagnostics must not be reported')
|
||||||
@@ -46,7 +46,7 @@ class LinuxTests(unittest.TestCase):
|
|||||||
self.assertNotIn('SECRET', json.dumps(data))
|
self.assertNotIn('SECRET', json.dumps(data))
|
||||||
self.assertEqual(data['checks']['certificates']['status'], 'not_collected')
|
self.assertEqual(data['checks']['certificates']['status'], 'not_collected')
|
||||||
|
|
||||||
@patch('server_buddy.collectors.linux.platform.system', return_value='Linux')
|
@patch('samsara.collectors.linux.platform.system', return_value='Linux')
|
||||||
def test_cancel(self, system):
|
def test_cancel(self, system):
|
||||||
with self.assertRaises(CollectionCancelled):
|
with self.assertRaises(CollectionCancelled):
|
||||||
collect_local_linux(cancelled=lambda: True)
|
collect_local_linux(cancelled=lambda: True)
|
||||||
@@ -61,7 +61,7 @@ class LinuxTests(unittest.TestCase):
|
|||||||
if args[0] == 'dpkg-query':
|
if args[0] == 'dpkg-query':
|
||||||
return 'installed\tnginx:amd64\t1.2\nconfig-files\tremoved\t1.0\n'
|
return 'installed\tnginx:amd64\t1.2\nconfig-files\tremoved\t1.0\n'
|
||||||
return ''
|
return ''
|
||||||
with self.subTest(rpm=rpm), patch('server_buddy.collectors.linux.platform.system', return_value='Linux'), patch('server_buddy.collectors.linux.Path.is_file', return_value=rpm):
|
with self.subTest(rpm=rpm), patch('samsara.collectors.linux.platform.system', return_value='Linux'), patch('samsara.collectors.linux.Path.is_file', return_value=rpm):
|
||||||
data = collect_local_linux(runner=command)
|
data = collect_local_linux(runner=command)
|
||||||
self.assertEqual(len(data['inventory']['software']), 1)
|
self.assertEqual(len(data['inventory']['software']), 1)
|
||||||
self.assertIn('nginx', data['inventory']['software'][0]['name'])
|
self.assertIn('nginx', data['inventory']['software'][0]['name'])
|
||||||
|
|||||||
29
tests/test_schedules.py
Normal file
29
tests/test_schedules.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import unittest
|
||||||
|
from samsara.collectors.linux_schedules import cron_rows, enrich_timers
|
||||||
|
|
||||||
|
class ScheduleTests(unittest.TestCase):
|
||||||
|
def test_cron_schedules_and_scripts_without_sensitive_arguments(self):
|
||||||
|
text = '# ignored\nMAILTO=secret@example.test\n0 2 * * * app /usr/bin/python3 /opt/jobs/report.py --password=SECRET\n@daily root /opt/jobs/backup.sh --token=SECRET\n'
|
||||||
|
rows = cron_rows(text, '/etc/cron.d/example', system_file=True)
|
||||||
|
self.assertEqual(len(rows), 2)
|
||||||
|
self.assertIn('report.py', rows[0]['name'])
|
||||||
|
self.assertIn('0 2 * * *', rows[0]['triggers'])
|
||||||
|
self.assertEqual(rows[0]['account'], 'app')
|
||||||
|
self.assertIn('@daily', rows[1]['triggers'])
|
||||||
|
self.assertNotIn('SECRET', str(rows))
|
||||||
|
self.assertNotIn('secret@example.test', str(rows))
|
||||||
|
self.assertNotIn('arguments', rows[0])
|
||||||
|
|
||||||
|
def test_timer_metadata_and_missing_next_run(self):
|
||||||
|
rows = [{'name': 'report.timer'}, {'name': 'other.timer'}]
|
||||||
|
def command(args):
|
||||||
|
self.assertIn('--', args)
|
||||||
|
return 'Id=report.timer\nTimersCalendar={ OnCalendar=*-*-* 02:00:00 ; next_elapse=... }\nTriggers=report.service\nNextElapseUSecRealtime=Mon 2026-11-02 02:00:00 UTC\n\nId=other.timer\nTimersCalendar=\n'
|
||||||
|
enrich_timers(rows, command)
|
||||||
|
self.assertIn('02:00:00', rows[0]['triggers'])
|
||||||
|
self.assertEqual(rows[0]['associated_unit'], 'report.service')
|
||||||
|
self.assertNotIn('triggers', rows[1])
|
||||||
|
|
||||||
|
def test_inline_shell_not_exposed(self):
|
||||||
|
rows = cron_rows("@hourly bash -c 'echo SECRET'", 'current-user')
|
||||||
|
self.assertNotIn('SECRET', str(rows))
|
||||||
@@ -1,17 +1,17 @@
|
|||||||
import unittest
|
import unittest
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
from server_buddy.core.startup import desktop_error
|
from samsara.core.startup import desktop_error
|
||||||
|
|
||||||
class StartupTests(unittest.TestCase):
|
class StartupTests(unittest.TestCase):
|
||||||
@patch('server_buddy.core.startup.sys.platform', 'linux')
|
@patch('samsara.core.startup.sys.platform', 'linux')
|
||||||
@patch.dict('os.environ', {'DISPLAY': ':0', 'QT_QPA_PLATFORM': 'xcb'}, clear=True)
|
@patch.dict('os.environ', {'DISPLAY': ':0', 'QT_QPA_PLATFORM': 'xcb'}, clear=True)
|
||||||
@patch('server_buddy.core.startup.ctypes.CDLL', side_effect=OSError)
|
@patch('samsara.core.startup.ctypes.CDLL', side_effect=OSError)
|
||||||
def test_missing_xcb_returns_install_instruction(self, loader):
|
def test_missing_xcb_returns_install_instruction(self, loader):
|
||||||
self.assertIn('sudo apt install libxcb-cursor0', desktop_error())
|
self.assertIn('sudo apt install libxcb-cursor0', desktop_error())
|
||||||
|
|
||||||
@patch('server_buddy.core.startup.sys.platform', 'linux')
|
@patch('samsara.core.startup.sys.platform', 'linux')
|
||||||
@patch.dict('os.environ', {'QT_QPA_PLATFORM': 'offscreen'}, clear=True)
|
@patch.dict('os.environ', {'QT_QPA_PLATFORM': 'offscreen'}, clear=True)
|
||||||
@patch('server_buddy.core.startup.ctypes.CDLL')
|
@patch('samsara.core.startup.ctypes.CDLL')
|
||||||
def test_offscreen_does_not_require_xcb(self, loader):
|
def test_offscreen_does_not_require_xcb(self, loader):
|
||||||
self.assertIsNone(desktop_error())
|
self.assertIsNone(desktop_error())
|
||||||
loader.assert_not_called()
|
loader.assert_not_called()
|
||||||
|
|||||||
35
tests/test_summary.py
Normal file
35
tests/test_summary.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import unittest
|
||||||
|
from samsara.analyzers.summary import summarize
|
||||||
|
from samsara.analyzers.assessment import assess
|
||||||
|
from samsara.collectors.demo import collect_demo, LINUX_DEMO_HOST, DEMO_HOST
|
||||||
|
|
||||||
|
class SummaryTests(unittest.TestCase):
|
||||||
|
def test_os_version_workload_and_schedule_evidence(self):
|
||||||
|
for host, version in [(LINUX_DEMO_HOST, '9.4'), (DEMO_HOST, '2022')]:
|
||||||
|
data = collect_demo(host)
|
||||||
|
summary = summarize(data, assess(data))
|
||||||
|
self.assertEqual(summary['version'], version)
|
||||||
|
self.assertTrue(summary['software'])
|
||||||
|
self.assertTrue(summary['jobs'])
|
||||||
|
self.assertEqual(summary['risk_score'], assess(data)['score'])
|
||||||
|
self.assertTrue(all('evidence' in row for row in summary['software']))
|
||||||
|
|
||||||
|
def test_unknown_version_not_kernel_and_disabled_jobs_not_active(self):
|
||||||
|
data = collect_demo(LINUX_DEMO_HOST)
|
||||||
|
data['server']['operating_system'] = 'Enterprise Linux'
|
||||||
|
data['server']['os_build'] = '5.14.0'
|
||||||
|
data['inventory']['tasks'] = [{'name': 'disabled', 'enabled': False}]
|
||||||
|
data['inventory']['software'] = [{'name': 'libsqlite3'}, {'name': 'nginx-doc'}]
|
||||||
|
data['inventory']['services'] = []
|
||||||
|
summary = summarize(data, assess(data))
|
||||||
|
self.assertEqual(summary['version'], 'Not collected')
|
||||||
|
self.assertEqual(summary['jobs'], [])
|
||||||
|
self.assertEqual(summary['software'], [])
|
||||||
|
|
||||||
|
def test_explicit_version_and_missing_schedule(self):
|
||||||
|
data = collect_demo(LINUX_DEMO_HOST)
|
||||||
|
data['server']['os_version'] = '9.5'
|
||||||
|
data['inventory']['tasks'] = [{'name': 'backup', 'enabled': True}]
|
||||||
|
summary = summarize(data, assess(data))
|
||||||
|
self.assertEqual(summary['version'], '9.5')
|
||||||
|
self.assertEqual(summary['jobs'][0]['schedule'], 'Schedule not collected')
|
||||||
139
tests/test_ui.py
139
tests/test_ui.py
@@ -1,12 +1,17 @@
|
|||||||
import os
|
import os
|
||||||
os.environ.setdefault('QT_QPA_PLATFORM', 'offscreen')
|
os.environ.setdefault('QT_QPA_PLATFORM', 'offscreen')
|
||||||
import time
|
import time
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
import unittest
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
try:
|
try:
|
||||||
from PySide6.QtWidgets import QApplication
|
from PySide6.QtWidgets import QApplication
|
||||||
from server_buddy.ui.window import MainWindow
|
from samsara.ui.window import MainWindow
|
||||||
except ImportError:
|
except ImportError:
|
||||||
QApplication = None
|
QApplication = None
|
||||||
|
from samsara.collectors.demo import collect_demo, LINUX_DEMO_HOST
|
||||||
|
|
||||||
|
|
||||||
@unittest.skipIf(QApplication is None, 'PySide6 not installed')
|
@unittest.skipIf(QApplication is None, 'PySide6 not installed')
|
||||||
class UITests(unittest.TestCase):
|
class UITests(unittest.TestCase):
|
||||||
@@ -14,74 +19,98 @@ class UITests(unittest.TestCase):
|
|||||||
def setUpClass(cls):
|
def setUpClass(cls):
|
||||||
cls.app = QApplication.instance() or QApplication([])
|
cls.app = QApplication.instance() or QApplication([])
|
||||||
|
|
||||||
def test_navigation_collection_and_environment_errors(self):
|
def finish(self, window):
|
||||||
|
deadline = time.monotonic() + 5
|
||||||
|
while window.job is not None and time.monotonic() < deadline:
|
||||||
|
self.app.processEvents()
|
||||||
|
time.sleep(.01)
|
||||||
|
self.assertIsNone(window.job)
|
||||||
|
|
||||||
|
def test_default_workflow_navigation_and_no_fabricated_history(self):
|
||||||
window = MainWindow()
|
window = MainWindow()
|
||||||
window.show()
|
self.assertEqual(window.windowTitle(), 'Samsara')
|
||||||
self.assertEqual(window.tabs.count(), 10)
|
self.assertEqual(window.source.currentData(), 'local')
|
||||||
for index in range(6):
|
self.assertEqual(window.target.currentData(), 'auto')
|
||||||
|
self.assertIsNone(window.data)
|
||||||
|
self.assertEqual(window.history, [])
|
||||||
|
self.assertFalse(window.report_buttons[0].isEnabled())
|
||||||
|
self.assertEqual(window.nav.item(0).text(), 'Summary')
|
||||||
|
for index in range(5):
|
||||||
window.nav.setCurrentRow(index)
|
window.nav.setCurrentRow(index)
|
||||||
self.assertEqual(window.pages.currentIndex(), index)
|
self.assertEqual(window.pages.currentIndex(), {0: 1, 1: 0, 2: 2, 3: 3, 4: 4}[index])
|
||||||
window.hostname.setText('host;bad')
|
|
||||||
window.start_collection()
|
|
||||||
self.assertIn('Enter a hostname', window.status.text())
|
|
||||||
window.hostname.setText('demo-web-01.example.test')
|
|
||||||
window.environment.setCurrentIndex(1)
|
|
||||||
window.start_collection()
|
|
||||||
self.assertIn('not implemented', window.status.text())
|
|
||||||
window.environment.setCurrentIndex(0)
|
|
||||||
window.start_collection()
|
|
||||||
deadline = time.monotonic() + 5
|
|
||||||
while window.job is not None and time.monotonic() < deadline:
|
|
||||||
self.app.processEvents()
|
|
||||||
time.sleep(.01)
|
|
||||||
self.assertIsNone(window.job)
|
|
||||||
self.assertEqual(len(window.history), 2)
|
|
||||||
self.assertTrue(window.analyze_button.isEnabled())
|
|
||||||
self.assertIn('Complete', window.status.text())
|
|
||||||
window.close()
|
window.close()
|
||||||
|
|
||||||
def test_linux_demo_collection(self):
|
def test_samples_details_and_history(self):
|
||||||
window = MainWindow()
|
window = MainWindow()
|
||||||
window.target.setCurrentIndex(1)
|
for source, expected in [('linux_demo', 'linux'), ('windows_demo', 'windows')]:
|
||||||
self.assertEqual(window.hostname.text(), 'demo-rhel-01.example.test')
|
window.source.setCurrentIndex(window.source.findData(source))
|
||||||
window.start_collection()
|
self.assertFalse(window.target.isEnabled())
|
||||||
deadline = time.monotonic() + 5
|
window.start_collection()
|
||||||
while window.job is not None and time.monotonic() < deadline:
|
self.finish(window)
|
||||||
self.app.processEvents()
|
self.assertEqual(window.data['server']['platform'], expected)
|
||||||
time.sleep(.01)
|
self.assertIn('Synthetic', window.banner.text())
|
||||||
self.assertIsNone(window.job)
|
self.assertEqual(window.nav.currentRow(), 0)
|
||||||
|
self.assertEqual(window.result_tabs.tabText(0), 'Summary')
|
||||||
|
self.assertIn('/100', window.retirement_score.text())
|
||||||
|
self.assertIn('Version:', window.os_version.text())
|
||||||
|
for index in range(1, window.result_tabs.count()):
|
||||||
|
window.result_tabs.setCurrentIndex(index)
|
||||||
|
self.assertGreaterEqual(list(window.detail_areas.values())[index - 1].count(), 1)
|
||||||
|
window.open_history(window.history_list.item(0))
|
||||||
self.assertEqual(window.data['server']['platform'], 'linux')
|
self.assertEqual(window.data['server']['platform'], 'linux')
|
||||||
self.assertIn('SYNTHETIC', window.banner.text())
|
self.assertEqual(len(window.records), 2)
|
||||||
self.assertEqual(window.tabs.count(), 10)
|
|
||||||
window.close()
|
window.close()
|
||||||
|
|
||||||
def test_local_collection_source_and_report(self):
|
def test_auto_detect_runs_local_collector_and_saves_report(self):
|
||||||
from unittest.mock import patch
|
|
||||||
from server_buddy.collectors.demo import collect_demo, LINUX_DEMO_HOST
|
|
||||||
data = collect_demo(LINUX_DEMO_HOST)
|
data = collect_demo(LINUX_DEMO_HOST)
|
||||||
data['synthetic'] = False
|
data['synthetic'] = False
|
||||||
data['server']['collection_source'] = 'Local Linux read-only collection'
|
data['server']['collection_source'] = 'Local Linux read-only collection'
|
||||||
with patch('server_buddy.ui.window.platform.system', return_value='Linux'), patch('server_buddy.ui.window.collect_local_linux', return_value=data):
|
detected = {'platform': 'linux', 'family': 'linux', 'version': '22', 'name': 'Local Linux'}
|
||||||
|
with patch('samsara.ui.window.detect_local_os', return_value=detected) as detection, patch('samsara.ui.window.collect_local_linux', return_value=data) as collector:
|
||||||
window = MainWindow()
|
window = MainWindow()
|
||||||
# Local collection does not validate or contact the hostname field.
|
window.analyze_button.click()
|
||||||
window.hostname.setText('not-a-remote-target;')
|
|
||||||
window.local_button.click()
|
|
||||||
self.assertFalse(window.analyze_button.isEnabled())
|
self.assertFalse(window.analyze_button.isEnabled())
|
||||||
self.assertTrue(window.cancel_button.isEnabled())
|
self.finish(window)
|
||||||
deadline = time.monotonic() + 5
|
detection.assert_called_once()
|
||||||
while window.job is not None and time.monotonic() < deadline:
|
collector.assert_called_once()
|
||||||
self.app.processEvents()
|
self.assertIn('Local Linux', window.banner.text())
|
||||||
time.sleep(.01)
|
self.assertTrue(window.analyze_button.isEnabled())
|
||||||
self.assertIsNone(window.job)
|
self.assertTrue(window.report_buttons[0].isEnabled())
|
||||||
self.assertIn('LOCAL LINUX', window.banner.text())
|
|
||||||
self.assertEqual(window.history[-1]['source'], 'Local Linux read-only collection')
|
|
||||||
self.assertTrue(window.local_button.isEnabled())
|
|
||||||
self.assertFalse(window.cancel_button.isEnabled())
|
|
||||||
import tempfile
|
|
||||||
from pathlib import Path
|
|
||||||
with tempfile.TemporaryDirectory() as folder:
|
with tempfile.TemporaryDirectory() as folder:
|
||||||
report = Path(folder) / 'local.html'
|
report = Path(folder) / 'local.html'
|
||||||
with patch('server_buddy.ui.window.QFileDialog.getSaveFileName', return_value=(str(report), '')), patch('server_buddy.ui.window.QMessageBox.information'):
|
with patch('samsara.ui.window.QFileDialog.getSaveFileName', return_value=(str(report), '')), patch('samsara.ui.window.QMessageBox.information'):
|
||||||
window.save_report('html')
|
window.quick_save.click()
|
||||||
self.assertIn('LOCAL LINUX INVENTORY', report.read_text())
|
self.assertIn('LOCAL LINUX INVENTORY', report.read_text())
|
||||||
window.close()
|
window.close()
|
||||||
|
|
||||||
|
def test_mismatched_profile_and_windows_unavailable_keep_prior_results(self):
|
||||||
|
window = MainWindow()
|
||||||
|
window.collection_done(collect_demo(LINUX_DEMO_HOST))
|
||||||
|
previous = window.data
|
||||||
|
detected = {'platform': 'linux', 'family': 'linux', 'version': '22', 'name': 'Local Linux'}
|
||||||
|
with patch('samsara.ui.window.detect_local_os', return_value=detected), patch('samsara.ui.window.collect_local_linux') as collector:
|
||||||
|
window.target.setCurrentIndex(window.target.findData('rhel:9'))
|
||||||
|
window.start_collection()
|
||||||
|
self.finish(window)
|
||||||
|
collector.assert_not_called()
|
||||||
|
self.assertIn('does not match', window.status.text())
|
||||||
|
self.assertIs(window.data, previous)
|
||||||
|
with patch('samsara.ui.window.detect_local_os', return_value={'platform': 'windows', 'family': 'windows', 'version': '', 'name': 'Windows'}):
|
||||||
|
window.start_collection()
|
||||||
|
self.finish(window)
|
||||||
|
self.assertIn('not available yet', window.status.text())
|
||||||
|
window.close()
|
||||||
|
|
||||||
|
def test_import_requires_file_and_reads_recorded_os(self):
|
||||||
|
window = MainWindow()
|
||||||
|
window.source.setCurrentIndex(window.source.findData('file'))
|
||||||
|
self.assertFalse(window.target.isEnabled())
|
||||||
|
window.start_collection()
|
||||||
|
self.assertIn('Choose an inventory', window.status.text())
|
||||||
|
with patch('samsara.ui.window.load_inventory', return_value=collect_demo(LINUX_DEMO_HOST)) as loader, patch('samsara.ui.window.detect_local_os') as detect:
|
||||||
|
window.inventory_path.setText('/tmp/saved-inventory.json')
|
||||||
|
window.start_collection()
|
||||||
|
self.finish(window)
|
||||||
|
loader.assert_called_once_with('/tmp/saved-inventory.json')
|
||||||
|
detect.assert_not_called()
|
||||||
|
window.close()
|
||||||
|
|||||||
Reference in New Issue
Block a user