diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 1a6af79..6011038 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -17,7 +17,7 @@ jobs: fetch-depth: 0 - name: Run TruffleHog scan - uses: trufflesecurity/trufflehog@fb74f38f7d00949e1ddd4e49e59ba5dd17f2bb46 # v3.88.1 + uses: trufflesecurity/trufflehog@d411fff7b8879a62509f3fa98c07f247ac089a51 # v3.95.5 with: extra_args: --only-verified @@ -44,7 +44,7 @@ jobs: --exclude-dir=tests \ --exclude-dir=.githooks \ --exclude="*.md" \ - --exclude="pr-checks.yml"; then + --exclude="*.yml"; then echo "[-] Found potential secret: $pattern" FOUND=1 fi diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index 4bd863e..8fb6f64 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -29,7 +29,7 @@ jobs: continue-on-error: true - name: Run TruffleHog - uses: trufflesecurity/trufflehog@fb74f38f7d00949e1ddd4e49e59ba5dd17f2bb46 # v3.88.1 + uses: trufflesecurity/trufflehog@d411fff7b8879a62509f3fa98c07f247ac089a51 # v3.95.5 continue-on-error: true with: path: ./ diff --git a/BACKLOG.md b/BACKLOG.md index 8c5e7a1..908a783 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -1,155 +1,84 @@ # BACKLOG -Tactical work queue for the toolkit, organized into discrete sprints so the road ahead is visible. -Strategic direction lives in [docs/ROADMAP.md](docs/ROADMAP.md); this file is the "what's next" punch list. +Tactical work queue for the toolkit. Strategic direction lives in [docs/ROADMAP.md](docs/ROADMAP.md). Sizing: **S** under an hour, **M** 1-3 hours, **L** half-day or more. --- -## Current state (2026-06-11) +## Current state (2026-06-14, post-cull) -- **Windows behavioral coverage**: 33.54% overall (was 6.68% at session start, +26.86 pp cumulative). -- **Pester tests**: 1214 passing, 0 failing. -- **Production bugs found and fixed via testing**: 7 (5 from Sprint 1, 1 from Sprint 3.3, 1 from Sprint 4.2). -- **Sprints 1, 2, and 3 complete. Sprint 4 in progress (4.1 + 4.2 done).** +The 2026-06-14 ghost-code audit (driven by web research + git archaeology) cut ~13 KLOC of production scripts and ~8 KLOC of tests defending behavior with no operational consumer. -Next: Sprint 4.3 (`Backup-BrowserProfiles.ps1`, 1078 lines, L). -After Sprint 4, two scripts (Get-SystemPerformance, Test-DevEnvironment) need substantial refactor before they can be tested cleanly. +- **What survived**: 4 first-time-setup scripts, `system-updates.ps1` + `Install-SystemUpdatesTask.ps1`, `Backup-DeveloperEnvironment.ps1`, `Manage-Docker.ps1`, `remote-development-setup.ps1`, `Set-StaticIP.ps1`, `Repair-CommonIssues.ps1`. Linux: `nvidia-gpu-exporter.sh`, `disk-cleanup.sh`, `headless-server-setup.sh`. +- **What was killed**: all 5 `Windows/monitoring/*`, `Windows/reporting/Get-SystemReport.ps1`, `Windows/security/Get-UserAccountAudit.ps1`, `Windows/network/Manage-VPN.ps1`, `Windows/development/Test-DevEnvironment.ps1`, `Windows/development/Manage-WSL.ps1`, 5 of 6 `Windows/backup/*` (only Backup-DeveloperEnvironment survives), `Linux/docker/docker-cleanup.sh`, `Linux/kubernetes/pod-health-monitor.sh`, `Linux/monitoring/service-health-monitor.sh`, `Linux/security/security-hardening.sh`, `Linux/maintenance/{log-cleanup,system-update,restore-previous-state}.sh`. +- **Why**: web research showed these duplicated native tools (Task Manager, Event Viewer, `wsl.exe`, `netsh`, OneDrive, Settings app) or the lab-server stack on q-lab (Prometheus/Grafana/Velero/k9s per `~/.claude/docs/INFRASTRUCTURE.md`). Git history showed 174 commits over 14 months, only ~3 looked like "ran it, broke, fixed". The rest was test backfill and refactor churn — the classic over-engineered-personal-toolkit shape. ---- - -## Sprint plan - -| Sprint | Theme | Scripts | Effort | Coverage gain | -|--------|-------|---------|--------|---------------| -| 1 | High-ROI monitoring + dev-tooling | 5 scripts | ~16 hrs | +14.26 pp (DONE) | -| 2 | Read-only reporting & audit | 4 scripts | ~12 hrs | +7.15 pp (DONE) | -| 3 | Mutating system & network | 4 scripts | ~10 hrs | +3.15 pp (DONE) | -| 4 | Backup & state | 5 scripts | ~24 hrs | est. +8-10 pp (NEXT) | -| 5 | Excluded hard scripts | 2 scripts | ~24 hrs | est. +5-7 pp | -| 6 | Test runner + repo hygiene | 3 small items | ~3 hrs | -- | -| 7 | Linux coverage gaps | 4 sh scripts | ~10 hrs | -- | - -Cumulative target after Sprint 5: **~55-60% overall coverage**, all Windows scripts behaviorally tested. - ---- - -## Sprint 4 — Backup & state (in progress) - -Highest mutation risk in the entire backlog (file copies, registry exports, restore paths). -Every test must be sandboxed in `$TestDrive`; nothing touches the real user profile. - -| # | Script | Lines | Size | Status | Notes | -|---|--------|-------|------|--------|-------| -| 4.1 | `Windows/backup/Backup-DeveloperEnvironment.ps1` | 252 | S | DONE | 16 tests, +0.47 pp. | -| 4.2 | `Windows/backup/Backup-UserData.ps1` | ~1050 | L | DONE | 37 tests, +2.91 pp, 1 bug. | -| 4.3 | `Windows/backup/Backup-BrowserProfiles.ps1` | 1078 | L | NEXT | Browser profile backup + restore round-trip. File-mutation hazards. | -| 4.4 | `Windows/backup/Export-SystemState.ps1` | 895 | L | -- | Registry/service export. | -| 4.5 | `Windows/backup/Test-BackupIntegrity.ps1` | 869 | L | -- | Backup verifier — read-only but checksums real archives. | +**Policy going forward**: any script that goes 6 months without a `fix:` commit triggered by real failure is a candidate for archival. No more "behavioral coverage" sprints — favor smoke tests for the surviving setup scripts; do not mock-pad scripts you do not invoke. --- -## Sprint 5 — Excluded hard scripts +## Active work -Both were explicitly excluded from the original survey ranking because they need substantial restructuring before behavioral tests can land. Treat the refactor as the deliverable; tests come after. - -| # | Script | Lines | Size | Notes | -|---|--------|-------|------|-------| -| 5.1 | `Windows/monitoring/Get-SystemPerformance.ps1` | 1457 | L+ | No Main function, 70-line straight-line script body, destructive AutoCleanup option. Refactor first: extract `Invoke-SystemPerformance`, factor out the script body, add testability guard. | -| 5.2 | `Windows/development/Test-DevEnvironment.ps1` | 1213 | L+ | 17 external tools to stub (git, node, npm, python, pip, docker, kubectl, az, gh, code, etc.). Most expensive mocking surface in the repo. | +| Item | Size | Notes | +|------|------|-------| +| Shrink `system-updates.ps1` (873 -> ~150 LOC) | M | The audit flagged this as kept-but-over-engineered. The 3 real `fix:` commits from June 2026 prove it's used; the surrounding ceremony can go. | +| Shrink `Manage-Docker.ps1` (1198 -> ~100 LOC) | M | Keep the cleanup/start/stop helpers; drop everything else. Docker's own CLI covers most of it. | +| Shrink `Repair-CommonIssues.ps1` (677 -> ~200 LOC) | M | Keep DNS/network/Windows-Update fixers; drop the catch-all. | +| Shrink `Set-StaticIP.ps1` (298 -> ~30 LOC) | S | It's wrapping `netsh`/`Set-NetIPAddress`. 30 lines suffices. | +| Replace `fresh-windows-setup.ps1` with a [`winget configure`](https://learn.microsoft.com/en-us/windows/package-manager/configuration/) YAML | M | YAML is now the standard. Keep export/install as thin wrappers around it. | +| `.github/PULL_REQUEST_TEMPLATE.md` | S | Single-author repo, mostly direct commits — decide whether one would actually help before adding. Park unless useful. | --- -## Sprint 6 — Test runner + repo hygiene - -Small items carried over from the prior backlog. +## Cancelled -| # | Item | Size | Notes | -|---|------|------|-------| -| 6.1 | Unify `tests/run-tests.ps1` to invoke BATS when available | S | Currently runs Pester (Windows) only. Linux BATS tests are invoked by CI separately. Single-command local test runner would be a quality-of-life win. | -| 6.2 | `.github/PULL_REQUEST_TEMPLATE.md` (optional) | S | Toolkit has no PR template. Decide whether one would actually help (single-author repo, mostly direct commits) before adding. | -| 6.3 | Add `Restore-VsCodeExtension` retry/backoff | M | When a vscode-marketplace install times out, retry once with backoff. Low priority -- current behavior just logs and continues. | +| Item | Reason | +|------|--------| +| Sprint 7 — Linux coverage gaps (`system-report.sh`, `repair-common-issues.sh`, `test-network-health.sh`, additional service-health monitor) | All would have been more ghost code. q-lab's existing Prometheus/k9s/journalctl/apt stack covers each gap. | +| Tier 4 (Azure/AWS/OneDrive/change-log/drift-detection/compliance) | Carried from old ROADMAP; nothing in this list is load-bearing today and most fall into the same "duplicates a SaaS" pattern that drove the cull. | --- -## Sprint 7 — Linux coverage gaps (deferred) +## Project history -The "parity achieved" claim was dropped in `docs/ROADMAP.md` on 2026-05-27. -Linux scope is headless server (q-lab) so several Windows categories are intentionally out of scope. -Not committed to yet; kept here for visibility. +This section preserves the closeouts from the pre-cull "behavioral coverage" sprints. Most of the code described below was deleted in the 2026-06-14 cull — entries are kept as a record of work done, not as a description of the current codebase. -| Gap | Linux script needed | Size | -|-----|---------------------|------| -| Reporting | `system-report.sh` - hardware/network/services summary mirroring `Get-SystemReport.ps1` | M | -| Troubleshooting | `repair-common-issues.sh` - DNS/network/apt-broken-state recovery | M | -| Network | `test-network-health.sh` - connectivity/DNS/port testing for q-lab | S | -| Monitoring | Expand `service-health-monitor.sh` coverage or add equivalent of `Watch-ServiceHealth.ps1` | M | +## Sprint 6 closeouts (test runner + repo hygiene) ---- +- 2026-06-14: `feat(backup): retry once with 5 s backoff on vscode-extension install` (Sprint 6.3) — added 2-attempt retry loop to `Restore-VsCodeExtension`. **This file was subsequently deleted in the 2026-06-14 cull** (the entire Restore-DeveloperEnvironment.ps1 was removed). Entry retained for historical context only. +- 2026-06-14: `chore(tests): unify run-tests.ps1 to also invoke BATS (Sprint 6.1)` (commit 2b1c504) — Added `-Linux` switch to `tests/run-tests.ps1` plus auto-detect when no flags are given. BATS files invoked with `bats --tap`. Survives the cull (general test infrastructure). -## Deferred (Tier 4 - explicitly low priority) +## Sprint 5 closeouts (excluded hard scripts, DONE) -Carried from ROADMAP.md. Not in the sprint plan above because nothing in this list is actually load-bearing today. +- 2026-06-13: `refactor(development): wrap Test-DevEnvironment main in Invoke-DevEnvironmentTest` (Sprint 5.2, refactor-only) — **Test-DevEnvironment.ps1 deleted in cull**. Entry retained for historical context. +- 2026-06-11: `test(monitoring): behavioral coverage for Get-SystemPerformance` (Sprint 5.1) — **Get-SystemPerformance.ps1 deleted in cull**. Found and fixed one production bug at the time (disconnected `-IncludeDiskAnalysis` wire-up). Bug fix lost with the file. -| Item | Effort | Notes | -|------|--------|-------| -| Azure resource management | 4-5 hr | From ROADMAP.md | -| AWS resource management | 4-5 hr | From ROADMAP.md | -| OneDrive sync automation | 2-3 hr | From ROADMAP.md | -| System change log tracker | 4-5 hr | From ROADMAP.md | -| Configuration drift detection | 3-4 hr | From ROADMAP.md | -| Compliance reporting | 3-4 hr | From ROADMAP.md | - ---- +## Sprint 4 closeouts (backup & state, DONE) -## Sprint 4 closeouts (backup & state, in progress) - -- 2026-06-11: `test(backup): behavioral coverage for Backup-UserData` (Sprint 4.2) - 37 tests across 11 helper functions + Invoke-UserDataBackup top-level. Wrapped 160-line main try/catch in Invoke-UserDataBackup function; replaced inner `exit 1` with `return 1` so the main flow returns an exit code cleanly. **Pester scope discovery**: helpers in the original script read `$VerifyBackup`, `$CompressionLevel`, `$RetentionCount`, `$RetentionDays`, `$DryRun`, etc. via dynamic scope from the script's param block. Pester 5 isolates the dot-sourced script's variables in a scope NOT reachable by `$script:` or `$global:` from the It block, so the test cannot override them after the fact. Solution was to refactor the helpers to take explicit parameters (`Copy-BackupFiles -ComputeHash`, `Compress-BackupFolder -Level`, `Remove-OldBackups -KeepCount/-KeepDays`) and add a full mirrored `param()` block to Invoke-UserDataBackup with the testability guard forwarding all script params. The refactor improves the production code too — explicit beats implicit dynamic scope reads. **Fixed one production bug**: the script accepts `-CompressionLevel SmallestSize` (mapped to `[System.IO.Compression.CompressionLevel]::SmallestSize` enum) but `Compress-Archive`'s `-CompressionLevel` parameter is `[string]` with ValidateSet limited to `Optimal`/`Fastest`/`NoCompression` — so any user picking SmallestSize hit the silent catch path and got "Compression failed". Removed SmallestSize from the script's and helper's ValidateSets and switched the Compress-Archive call to pass the string name. Coverage 30.63% -> 33.54% (+2.91 pp). -- 2026-06-10: `test(backup): behavioral coverage for Backup-DeveloperEnvironment` (Sprint 4.1) - 16 tests. Wrapped straight-line body in `Invoke-DeveloperEnvironmentBackup` with `[CmdletBinding(SupportsShouldProcess=$true)]`; replaced inner `exit 1` with `return $null`; added testability guard. **Pester quirk discovered**: in PS7, `Out-File`'s `-Encoding` parameter has an `ArgumentTransformationAttribute` that converts strings like `"UTF8"` to encoding objects; Pester's `Mock Out-File` replicates the parameter type but NOT the transformer, so mocking `Out-File` breaks the script's `-Encoding UTF8` call with a binding error. Workaround: tests that need the manifest/extensions code paths leave `Out-File` and `New-Item` unmocked so real cmdlets write into `$TestDrive`. Documented inline at the top of the test file. Coverage 30.16% -> 30.63% (+0.47 pp). +132 tests added at the time, +13.96 pp coverage gain. **All 5 of these scripts (Backup-UserData, Backup-BrowserProfiles, Export-SystemState, Test-BackupIntegrity, plus the pair partner Restore-DeveloperEnvironment) were deleted in the cull.** Backup-DeveloperEnvironment (Sprint 4.1) survives. The Sprint 4.2 production bug (`-CompressionLevel SmallestSize` enum/string mismatch) was a real find at the time; the fix lives in the dropped code. ## Sprint 3 closeouts (mutating system & network) -Coverage 28.09% -> 31.24% across 57 new tests. **One real production bug fixed.** - -- 2026-06-10: `test(troubleshooting): behavioral coverage for Repair-CommonIssues` (commit 8af368b, Sprint 3.4) - 11 tests. Removed `#Requires -RunAsAdministrator` for dot-source testability; added testability guard. Test strategy: mock Invoke-CommandWithLogging (the orchestrator) and assert on `$Description` so each Repair-X function is a dispatch check rather than a coupling-to-cmdlet check. -- 2026-06-10: `test(network): behavioral coverage for Manage-VPN` (commit 48d04e8, Sprint 3.3) - 19 tests. **Fixed one production bug**: `Remove-VpnProfile` did not discard `Disconnect-VpnProfile`'s return value, so when called on a Connected profile it emitted `@($true, $true)` to the pipeline instead of `$true`. Any caller using `if ($success = Remove-VpnProfile ...)` saw an array-truthy result in either success or partial-failure cases. Wrapped the call in `$null = Disconnect-VpnProfile`. -- 2026-06-10: `test(network): behavioral coverage for Set-StaticIP` (commit ff4c6a5, Sprint 3.2) - 8 tests. Wrapped the straight-line main flow in Invoke-SetStaticIP with a testability guard; replaced inner `exit` with `return`. -- 2026-06-10: `test(maintenance): behavioral coverage for system-updates` (commit a9275ec, Sprint 3.1) - 19 tests. Wrapped top-level try/catch/finally in Invoke-SystemUpdates; removed `#Requires -RunAsAdministrator`. Added `[CmdletBinding(SupportsShouldProcess=$true)]` to Disable-FastStartup, New-SystemRestorePoint, Update-Winget, Update-Chocolatey, Update-Windows so `$PSCmdlet.ShouldProcess` resolves correctly when the helpers are tested in isolation (was null-ref otherwise). +Coverage 28.09% -> 31.24% at the time. **One real production bug fixed.** `Repair-CommonIssues` and `Set-StaticIP` survive; `Manage-VPN`, `system-updates.ps1` survive (system-updates was Sprint 3.1). +- 2026-06-10: `test(network): behavioral coverage for Manage-VPN` (commit 48d04e8, Sprint 3.3) — **Manage-VPN.ps1 deleted in cull**. The pipeline-emission bug fix is lost with the file. ## Sprint 2 closeouts (read-only reporting & audit) -Coverage 20.94% -> 28.09% across 75 new tests; no production bugs found (all four scripts were already well-shaped read-only data collectors). - -- 2026-06-10: `test(security): behavioral coverage for Get-UserAccountAudit` (commit 15d0eca, Sprint 2.4) - 17 tests. All seven branches of Get-UserSecurityIssues exercised (PasswordNeverExpires, PasswordNotRequired, IsInactive, old password, CRITICAL admin escalation, built-in Administrator, Guest); Get-UserAccountDetails IsAdmin tagging and disabled-account skipping; Get-AuditSummary aggregation including CRITICAL substring detection. Coverage 23.86% -> 28.09% (+4.23 pp). -- 2026-06-10: `test(setup): behavioral coverage for Compare-SoftwareInventory` (commit 2cf55da, Sprint 2.3) - 18 tests. Wrapped top-level try/catch in Invoke-SoftwareInventoryComparison with a testability guard. Tests cover Winget JSON / Chocolatey XML parsing, all four Compare-PackageLists buckets, Import-Inventory file vs directory dispatch, and Export-MissingPackagesScript output. Coverage delta included in Sprint 2.4 closeout. -- 2026-06-10: `test(monitoring): behavioral coverage for Get-ApplicationHealth` (commit cc40e2e, Sprint 2.2) - 18 tests. Registry app enumeration with x86/x64 tagging from WOW6432Node path, dedup, Windows Store apps, winget upgrade parser, choco outdated parser, Application Error / Hang / WER event mapping, process top-10 filtering, Update-Application package-manager routing. -- 2026-06-10: `test(reporting): behavioral coverage for Get-SystemReport` (commit ef928e1, Sprint 2.1) - 13 tests. Hardware/Software/Network/Security/Performance helpers exercised by mocking each CIM/registry call independently; CPU Architecture switch (9 -> x64), SMBIOSMemoryType switch (26 -> DDR4), UAC ConsentPromptBehaviorAdmin switch, fDenyTSConnections inversion, Get-Counter + Win32_OperatingSystem memory branch. Coverage 20.94% -> 23.86% (+2.92 pp). +Coverage 20.94% -> 28.09% at the time across 75 new tests. **All 4 scripts (Get-SystemReport, Compare-SoftwareInventory, Get-ApplicationHealth, Get-UserAccountAudit) had their tests deleted in the cull; Compare-SoftwareInventory script survives, the other 3 scripts were deleted.** ## Sprint 1 closeouts (high-ROI monitoring + dev-tooling) -The current behavioral-testing push started 2026-06-05. Coverage went from 6.68% to 20.94% across 138 new tests; 5 real production bugs were fixed along the way. - -- 2026-06-09: `test(development): behavioral coverage for Manage-WSL` (commit 88bb415) - 32 new tests, +2.86 pp (18.08% -> 20.94%). Refactor: `Main` -> `Invoke-WslManager`, testability guard, `-ConfigPath` param on `Set-WslConfiguration` so `~/.wslconfig` is not clobbered during tests. Highest-risk script (risk=5: distro export/import, `--unregister` can permanently delete user data) now covered. No bugs found. -- 2026-06-09: `test(development): behavioral coverage for Manage-Docker` (commit 9205a31) - 29 new tests, +2.17 pp (15.91% -> 18.08%). Refactor: `Main` -> `Invoke-DockerManager`, testability guard, `-Path` param on Docker Desktop helpers, `Pull-DockerImage` -> `Invoke-DockerImagePull` (approved verb), `$args` -> `$dockerArgs` to stop shadowing the automatic variable. No bugs found. -- 2026-06-07: `test(monitoring): behavioral coverage for Get-EventLogAnalysis` (commit 53bb062) - 27 new tests, +3.96 pp (11.95% -> 15.91%). **Fixed two production bugs**: Summary counters returned the matched hashtable's key count instead of 1 (single-match unwrap trap); `Get-SystemIssues` / `Get-ApplicationIssues` / `Get-SecurityAnalysis` / `Get-FailedLogonDetails` `[Mandatory][array]$Events` rejected empty collections, crashing the script for non-admins who only specified the Security log. -- 2026-06-07: `test(monitoring): behavioral coverage for Test-NetworkHealth` (commit e080cac) - 27 new tests, +3.37 pp (8.58% -> 11.95%). **Fixed three production bugs**: `$host` (automatic variable for the host object) used in place of `$targetHost` in four alert messages; empty-string `$DNSServer` not defaulting to 'System Default' because `??` only triggers on null; AAAA records lost because `IPAddresses` was a single string not an array (`+=` did string concat). -- 2026-06-06: `test(monitoring): behavioral coverage for Watch-ServiceHealth` (commit 9f0e941) - 23 new tests; +1.9 pp (6.68% -> 8.58%). Established the "wrap main in `Invoke-X` + testability guard + explicit params" pattern that the rest of the cycle reuses. +138 tests at the time; 5 real production bugs fixed. **Manage-Docker survives; Manage-WSL, Get-EventLogAnalysis, Test-NetworkHealth, Watch-ServiceHealth were deleted in the cull.** The 5 production bugs (`$host` shadowing, AAAA-record array bug, summary unwrap, Mandatory empty-collection rejection) lived in scripts that are now gone. ## Earlier closeouts (pre-2026-06-05) -- 2026-06-05: `test(backup): behavioral coverage for Restore-DeveloperEnvironment` (commit 7d9b2aa) - 20 new tests after factoring straight-line script into four functions. -- 2026-06-05: `test(setup): behavioral coverage for remote-development-setup` (commit b7cd047) - 17 tests; final setup script covered. -- 2026-06-05: `test(setup): behavioral coverage for fresh-windows-setup` (commit 616643a) - 23 tests; Work/Home profile branching covered. -- 2026-06-05: `test(setup): behavioral coverage for install-from-exported-packages` (commit 4948960) - 26 tests; iex-free Chocolatey bootstrap covered. -- 2026-06-05: `test(setup): behavioral coverage for export-current-packages + empty-Message fix` (commit 08b9022) - 19 tests; uncovered a real production bug (CommonFunctions Mandatory rejecting empty strings). -- 2026-06-05: `test(setup): make setup scripts testable via dot-source` (commit 0612b7e) - removed `#Requires -RunAsAdministrator` from 3 scripts (replaced by runtime `Assert-Administrator`). -- 2026-05-27: `refactor(setup): approved verbs + lock down iex regression` (commit 20affe7) -- 2026-05-27: `refactor(setup): use CommonFunctions for logging instead of local wrappers` (commit 42a1dbd) -- 2026-05-27: `refactor(setup): singular-noun renames + drop Invoke-Expression` (commit 28607cd) -- 2026-05-27: `docs: refresh ROADMAP, drop Linux parity claim` (commit 6a963db) -- 2026-05-25: `feat: add Install-SystemUpdatesTask.ps1` (commit a42ed8e) -- 2026-05-25: `chore: remove dotfiles/claude-config + Windows/ssh, add CmdletBinding to 4 setup scripts` (commit 02a7709) +- 2026-06-05: `test(backup): behavioral coverage for Restore-DeveloperEnvironment` (commit 7d9b2aa) — file deleted in cull. +- 2026-06-05: setup script test coverage (3 commits) — all setup scripts and their tests survive. +- 2026-05-27: `refactor(setup): approved verbs + lock down iex regression` (commit 20affe7) — survives. +- 2026-05-27: `refactor(setup): use CommonFunctions for logging instead of local wrappers` (commit 42a1dbd) — survives. +- 2026-05-27: `docs: refresh ROADMAP, drop Linux parity claim` (commit 6a963db) — ROADMAP rewritten again in the 2026-06-14 cull. +- 2026-05-25: `feat: add Install-SystemUpdatesTask.ps1` (commit a42ed8e) — survives. --- -**Last Updated**: 2026-06-10 +**Last Updated**: 2026-06-14 diff --git a/CHANGELOG.md b/CHANGELOG.md index da9437c..c8adb63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,36 @@ not strictly adhere to semantic versioning (it is a personal toolkit, not a published library) but minor bumps signal new public surface and patch bumps signal bug fixes only. +## [3.0.0] - 2026-06-14 + +### Removed (ghost-code cull) + +Driven by a 2026-06-14 audit (web research + git archaeology) that found ~13 KLOC of production scripts and ~8 KLOC of tests defending behavior with no operational consumer on a single-user laptop. Most categories duplicated either native Windows tools (Task Manager, Event Viewer, `wsl.exe`, Settings) or the lab-server stack on q-lab (Prometheus/Grafana for monitoring, Velero for backup, k9s for Kubernetes). See [BACKLOG.md](BACKLOG.md) for the full rationale. + +- **Windows monitoring (entire category):** `Get-ApplicationHealth.ps1`, `Get-EventLogAnalysis.ps1`, `Get-SystemPerformance.ps1`, `Test-NetworkHealth.ps1`, `Watch-ServiceHealth.ps1` + corresponding tests + dir README. +- **Windows backup (5 of 6):** `Backup-BrowserProfiles.ps1`, `Backup-UserData.ps1`, `Export-SystemState.ps1`, `Restore-DeveloperEnvironment.ps1`, `Test-BackupIntegrity.ps1` + corresponding tests. `Backup-DeveloperEnvironment.ps1` survives (snapshot before rebuild). +- **Windows development (2 of 4):** `Test-DevEnvironment.ps1`, `Manage-WSL.ps1` + corresponding tests. `Manage-Docker.ps1` and `remote-development-setup.ps1` survive. +- **Windows reporting:** `Get-SystemReport.ps1` + test + dir README. +- **Windows security:** `Get-UserAccountAudit.ps1` + test + dir README. +- **Windows network (1 of 2):** `Manage-VPN.ps1` + test. `Set-StaticIP.ps1` survives. +- **Linux maintenance (3 of 4):** `log-cleanup.sh`, `restore-previous-state.sh`, `system-update.sh` + tests. `disk-cleanup.sh` survives. +- **Linux monitoring:** `service-health-monitor.sh` + test + dir README (Grafana dashboards in this folder kept as reference). +- **Linux docker:** `docker-cleanup.sh` + tests + dir README. +- **Linux kubernetes:** `pod-health-monitor.sh` + tests + dir README. +- **Linux security:** `security-hardening.sh` + test + dir README (security work lives in `defensive-toolkit`). +- **Umbrella Pester tests:** `Backup.Tests.ps1`, `Monitoring.Tests.ps1`, `Tier2Scripts.Tests.ps1`, `Tier3Scripts.Tests.ps1`, `DeveloperEnvironment.Tests.ps1`. These pre-dated the per-script `*.Behavioral.Tests.ps1` files and were now redundant or referenced deleted scripts. + +### Changed + +- `README.md`, `QUICKSTART.md`, `BACKLOG.md`, `docs/ROADMAP.md` rewritten to reflect the post-cull scope. +- Subdirectory READMEs (`Windows/backup`, `Windows/network`, `Windows/development`, `Linux/maintenance`, `Linux/monitoring`) updated with scope notes and surviving-script tables. +- `tests/Linux/maintenance.bats` trimmed to cover only `disk-cleanup.sh` (was covering 4 scripts, 3 now deleted). + +### Policy + +- Cancelled Sprint 7 (Linux coverage gaps) — would have produced more ghost code. +- New rule: any script that goes 6 months without a `fix:` commit triggered by real failure is a candidate for archival, not for additional test scaffolding. + ## [2.3.3] - 2026-06-11 ### Fixed diff --git a/Linux/docker/README.md b/Linux/docker/README.md deleted file mode 100644 index bad7567..0000000 --- a/Linux/docker/README.md +++ /dev/null @@ -1,83 +0,0 @@ -# Docker Management Scripts - -Automated Docker maintenance and cleanup for Linux servers. - -## Scripts - -| Script | Purpose | -|--------|---------| -| [docker-cleanup.sh](docker-cleanup.sh) | Image pruning, container cleanup, volume management | - -## Quick Start - -```bash -# Preview cleanup (dry-run) -./docker-cleanup.sh --whatif - -# Run with defaults -./docker-cleanup.sh - -# Keep 2 versions, prune volumes -./docker-cleanup.sh --keep-versions 2 --prune-volumes - -# Remove old containers -./docker-cleanup.sh --container-age-days 30 -``` - -## Parameters - -| Option | Description | Default | -|--------|-------------|---------| -| `--keep-versions N` | Keep N latest versions per image | 3 | -| `--container-age-days N` | Remove containers stopped > N days | 7 | -| `--prune-volumes` | Also prune unused volumes | false | -| `--whatif` | Dry-run mode | false | -| `--config FILE` | Configuration file path | config.json | - -## Configuration - -Create `config.json`: -```json -{ - "keep_versions": 3, - "container_age_days": 7, - "prune_volumes": false, - "protected_images": ["postgres:15", "redis:7-alpine"], - "metrics_enabled": true -} -``` - -## What Gets Cleaned - -| Category | Cleaned | Preserved | -|----------|---------|-----------| -| Dangling images | All `:` | - | -| Tagged images | Beyond keep-versions | N latest per repo | -| Containers | Stopped > age-days | Running, recent | -| Volumes | Unused (if enabled) | In-use | - -## Prometheus Metrics - -Exported to `/var/lib/prometheus/node-exporter/docker_cleanup.prom`: - -| Metric | Description | -|--------|-------------| -| docker_cleanup_disk_reclaimed_bytes | Space freed | -| docker_cleanup_images_removed | Images removed | -| docker_cleanup_containers_removed | Containers removed | - -## Cron Setup - -```bash -# Weekly Sunday 3 AM -0 3 * * 0 /path/to/docker-cleanup.sh --prune-volumes >> /var/log/docker-cleanup/cron.log 2>&1 -``` - -## Prerequisites - -- Docker installed and running -- jq (`apt install jq`) -- User in docker group or root - ---- -**Last Updated**: 2025-12-26 diff --git a/Linux/docker/docker-cleanup.sh b/Linux/docker/docker-cleanup.sh deleted file mode 100644 index c9580b8..0000000 --- a/Linux/docker/docker-cleanup.sh +++ /dev/null @@ -1,505 +0,0 @@ -#!/usr/bin/env bash -# ============================================================================ -# Docker Image and Container Cleanup Script -# ============================================================================ -# Description: Automated Docker cleanup to reclaim disk space -# Author: David Dashti -# Version: 2.0.0 -# Last Updated: 2025-10-18 -# -# Usage: -# ./docker-cleanup.sh [OPTIONS] -# -# Options: -# --keep-versions N Keep N most recent versions per image (default: 3) -# --container-age-days N Remove containers stopped > N days ago (default: 7) -# --prune-volumes Also prune unused volumes (default: false) -# --whatif Dry-run mode (show what would be removed) -# --config FILE Use configuration file (default: config.json) -# --debug Enable debug logging -# --help Show this help message -# -# Examples: -# ./docker-cleanup.sh --whatif -# ./docker-cleanup.sh --keep-versions 2 -# ./docker-cleanup.sh --container-age-days 30 --prune-volumes -# ./docker-cleanup.sh --config /etc/docker-cleanup/config.json -# -# Features: -# - Remove dangling images (:) -# - Keep only N latest versions per image repository -# - Prune stopped containers older than X days -# - Remove unused volumes -# - Prometheus metrics export (disk space reclaimed) -# - Dry-run mode for safe testing -# - Configuration file support -# ============================================================================ - -set -euo pipefail - -# Script configuration -SCRIPT_NAME=$(basename "$0") -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -SCRIPT_VERSION="2.0.0" - -# Source common functions library -if [[ -f "$SCRIPT_DIR/../lib/bash/common-functions.sh" ]]; then - source "$SCRIPT_DIR/../lib/bash/common-functions.sh" -else - echo "[-] ERROR: Cannot find common-functions.sh library" >&2 - exit 1 -fi - -# Configuration defaults -CONFIG_FILE="${SCRIPT_DIR}/config.json" -KEEP_VERSIONS="${KEEP_VERSIONS:-3}" -CONTAINER_AGE_DAYS="${CONTAINER_AGE_DAYS:-7}" -PRUNE_VOLUMES=false -WHATIF_MODE=false -OUTPUT_DIR="${OUTPUT_DIR:-/var/log/docker-cleanup}" -METRICS_DIR="${METRICS_DIR:-/var/lib/prometheus/node-exporter}" -CLUSTER_NAME="${CLUSTER_NAME:-homelab}" - -# Runtime variables -START_TIME=$(get_timestamp) -REMOVED_IMAGES_COUNT=0 -REMOVED_CONTAINERS_COUNT=0 -REMOVED_VOLUMES_COUNT=0 -SPACE_RECLAIMED_BYTES=0 - -# ============================================================================ -# HELPER FUNCTIONS -# ============================================================================ - -check_dependencies() { - check_command docker - - # Test docker daemon access with retry - log_info "Verifying Docker daemon access..." - if ! retry_command 3 2 docker info >/dev/null 2>&1; then - die "Cannot access Docker daemon. Check Docker service status or user permissions." 1 - fi - - log_debug "Docker daemon accessible" -} - -init_directories() { - ensure_dir "$OUTPUT_DIR" - ensure_dir "$METRICS_DIR" -} - -bytes_to_human() { - local bytes=$1 - if [[ $bytes -lt 1024 ]]; then - echo "${bytes}B" - elif [[ $bytes -lt 1048576 ]]; then - echo "$(awk "BEGIN {printf \"%.2f\", $bytes/1024}")KB" - elif [[ $bytes -lt 1073741824 ]]; then - echo "$(awk "BEGIN {printf \"%.2f\", $bytes/1048576}")MB" - else - echo "$(awk "BEGIN {printf \"%.2f\", $bytes/1073741824}")GB" - fi -} - -# ============================================================================== -# CLEANUP FUNCTIONS -# ============================================================================== - -get_disk_usage_before() { - log_info "Calculating current Docker disk usage..." - docker system df --format "{{.Type}}\t{{.TotalCount}}\t{{.Size}}" || true -} - -remove_dangling_images() { - log_info "Checking for dangling images (:)..." - - local dangling_images - dangling_images=$(docker images --filter "dangling=true" -q) - - if [[ -z "$dangling_images" ]]; then - log_success "No dangling images found" - return - fi - - local count - count=$(echo "$dangling_images" | wc -l) - log_info "Found $count dangling images" - - if [[ "$WHATIF_MODE" == true ]]; then - log_info "[WHATIF] Would remove $count dangling images" - return - fi - - # Calculate space before removal - local space_before - space_before=$(docker images --filter "dangling=true" --format "{{.Size}}" | \ - sed 's/MB/*1000000/;s/GB/*1000000000/' | \ - awk '{sum += $1} END {print sum}' | bc 2>/dev/null || echo "0") - - log_info "Removing dangling images..." - docker rmi $dangling_images 2>&1 | grep -v "No such image" || true - - REMOVED_IMAGES_COUNT=$((REMOVED_IMAGES_COUNT + count)) - SPACE_RECLAIMED_BYTES=$((SPACE_RECLAIMED_BYTES + space_before)) - - log_success "Removed $count dangling images" -} - -remove_old_image_versions() { - log_info "Checking for old image versions (keeping $KEEP_VERSIONS latest per repository)..." - - # Get all unique repositories - local repositories - repositories=$(docker images --format "{{.Repository}}" | grep -v "" | sort -u) - - if [[ -z "$repositories" ]]; then - log_info "No repositories with multiple versions found" - return - fi - - local total_removed=0 - - while IFS= read -r repo; do - # Skip if repo is empty - [[ -z "$repo" ]] && continue - - # Get all image IDs for this repository, sorted by creation date (newest first) - local all_images - all_images=$(docker images "$repo" --format "{{.ID}}|{{.CreatedAt}}" | sort -t'|' -k2 -r) - - local image_count - image_count=$(echo "$all_images" | wc -l) - - # Skip if we have fewer images than the keep threshold - if [[ $image_count -le $KEEP_VERSIONS ]]; then - continue - fi - - # Get images to remove (skip the first N) - local images_to_remove - images_to_remove=$(echo "$all_images" | tail -n +$((KEEP_VERSIONS + 1)) | cut -d'|' -f1) - - if [[ -z "$images_to_remove" ]]; then - continue - fi - - local remove_count - remove_count=$(echo "$images_to_remove" | wc -l) - - log_info "Repository: $repo - keeping $KEEP_VERSIONS, removing $remove_count old versions" - - if [[ "$WHATIF_MODE" == true ]]; then - log_info "[WHATIF] Would remove $remove_count old versions of $repo" - continue - fi - - # Remove old images with error logging - while IFS= read -r image_id; do - if docker rmi "$image_id" 2>&1 | grep -q "Deleted"; then - ((total_removed++)) - ((REMOVED_IMAGES_COUNT++)) - log_debug "Removed image: $image_id" - else - log_warning "Failed to remove image: $image_id (may be in use)" - fi - done <<< "$images_to_remove" - - done <<< "$repositories" - - if [[ $total_removed -gt 0 ]]; then - log_success "Removed $total_removed old image versions" - else - log_success "No old image versions to remove" - fi -} - -prune_old_containers() { - log_info "Checking for stopped containers older than $CONTAINER_AGE_DAYS days..." - - # Get stopped containers - local stopped_containers - stopped_containers=$(docker ps -a --filter "status=exited" --format "{{.ID}}|{{.CreatedAt}}") - - if [[ -z "$stopped_containers" ]]; then - log_success "No stopped containers found" - return - fi - - local cutoff_date - cutoff_date=$(date -d "$CONTAINER_AGE_DAYS days ago" +%s) - - local old_containers=() - while IFS='|' read -r container_id created_at; do - local container_date - container_date=$(date -d "$created_at" +%s 2>/dev/null || echo "0") - - if [[ $container_date -lt $cutoff_date ]]; then - old_containers+=("$container_id") - fi - done <<< "$stopped_containers" - - if [[ ${#old_containers[@]} -eq 0 ]]; then - log_success "No old containers to remove" - return - fi - - log_info "Found ${#old_containers[@]} old containers to remove" - - if [[ "$WHATIF_MODE" == true ]]; then - log_info "[WHATIF] Would remove ${#old_containers[@]} old containers" - return - fi - - log_info "Removing old containers..." - for container_id in "${old_containers[@]}"; do - if docker rm "$container_id" &>/dev/null; then - ((REMOVED_CONTAINERS_COUNT++)) - fi - done - - log_success "Removed ${#old_containers[@]} old containers" -} - -prune_unused_volumes() { - if [[ "$PRUNE_VOLUMES" == false ]]; then - log_info "Skipping volume pruning (use --prune-volumes to enable)" - return - fi - - log_info "Checking for unused volumes..." - - if [[ "$WHATIF_MODE" == true ]]; then - local unused_volumes - unused_volumes=$(docker volume ls -q --filter "dangling=true") - if [[ -n "$unused_volumes" ]]; then - local count - count=$(echo "$unused_volumes" | wc -l) - log_info "[WHATIF] Would remove $count unused volumes" - else - log_success "No unused volumes found" - fi - return - fi - - log_info "Pruning unused volumes..." - local prune_output - prune_output=$(docker volume prune -f 2>&1 || echo "") - - # Parse removed volume count - if echo "$prune_output" | grep -q "Deleted Volumes:"; then - REMOVED_VOLUMES_COUNT=$(echo "$prune_output" | grep -c "local" || echo "0") - log_success "Removed $REMOVED_VOLUMES_COUNT unused volumes" - else - log_success "No unused volumes found" - fi -} - -run_docker_system_prune() { - log_info "Running docker system prune (dangling build cache)..." - - if [[ "$WHATIF_MODE" == true ]]; then - log_info "[WHATIF] Would run docker system prune" - return - fi - - local prune_output - prune_output=$(docker system prune -f 2>&1 || echo "") - - # Parse space reclaimed - if echo "$prune_output" | grep -q "Total reclaimed space:"; then - local reclaimed - reclaimed=$(echo "$prune_output" | grep "Total reclaimed space:" | awk '{print $4}') - log_success "System prune reclaimed: $reclaimed" - fi -} - -# ============================================================================== -# PROMETHEUS METRICS -# ============================================================================== - -export_prometheus_metrics() { - local metrics_file="${METRICS_DIR}/docker_cleanup.prom" - local end_time=$(date +%s) - local duration=$((end_time - START_TIME)) - - if [[ "$WHATIF_MODE" == true ]]; then - log_info "[WHATIF] Would export Prometheus metrics to: $metrics_file" - return - fi - - log_info "Exporting Prometheus metrics..." - - local hostname_var - hostname_var=$(hostname) - - cat > "$metrics_file" < N days ago (default: 7) - --prune-volumes Also prune unused volumes (default: false) - --whatif Dry-run mode (show what would be removed) - --help Show this help message - -EXAMPLES: - $SCRIPT_NAME --whatif - $SCRIPT_NAME --keep-versions 2 - $SCRIPT_NAME --container-age-days 30 --prune-volumes - -WHAT GETS REMOVED: - - Dangling images (:) - - Image versions older than the N most recent per repository - - Stopped containers older than N days - - Unused volumes (if --prune-volumes specified) - - Dangling build cache - -PROMETHEUS METRICS: - Metrics are exported to: $METRICS_DIR/docker_cleanup.prom - Configure node_exporter textfile collector to scrape this directory. - -VERSION: - $SCRIPT_VERSION - -EOF -} - -# ============================================================================== -# ARGUMENT PARSING -# ============================================================================== - -parse_arguments() { - while [[ $# -gt 0 ]]; do - case $1 in - --keep-versions) - KEEP_VERSIONS="$2" - shift 2 - ;; - --container-age-days) - CONTAINER_AGE_DAYS="$2" - shift 2 - ;; - --prune-volumes) - PRUNE_VOLUMES=true - shift - ;; - --whatif) - WHATIF_MODE=true - shift - ;; - --help) - show_help - exit 0 - ;; - *) - log_error "Unknown option: $1" - show_help - exit 1 - ;; - esac - done -} - -# ============================================================================== -# MAIN EXECUTION -# ============================================================================== - -main() { - parse_arguments "$@" - - log_info "=== Docker Cleanup Script Started ===" - log_info "Version: $SCRIPT_VERSION" - log_info "Date: $(date)" - log_info "Configuration:" - log_info " - Keep versions per image: $KEEP_VERSIONS" - log_info " - Container age threshold: $CONTAINER_AGE_DAYS days" - log_info " - Prune volumes: $PRUNE_VOLUMES" - - if [[ "$WHATIF_MODE" == true ]]; then - log_warning "Running in WHATIF mode - no changes will be made" - fi - - check_dependencies - init_directories - - # Show disk usage before cleanup - get_disk_usage_before - echo "" - - # Run cleanup operations - remove_dangling_images - remove_old_image_versions - prune_old_containers - prune_unused_volumes - run_docker_system_prune - - # Export metrics and show summary - export_prometheus_metrics - show_summary - - log_success "=== Docker Cleanup Script Completed ===" -} - -# Run main function -main "$@" diff --git a/Linux/kubernetes/README.md b/Linux/kubernetes/README.md deleted file mode 100644 index 1bc4068..0000000 --- a/Linux/kubernetes/README.md +++ /dev/null @@ -1,69 +0,0 @@ -# Kubernetes Monitoring Scripts - -Health monitoring and metrics collection for Kubernetes clusters. - -## Scripts - -| Script | Purpose | -|--------|---------| -| [pod-health-monitor.sh](pod-health-monitor.sh) | Pod health and restart detection | - -## Quick Start - -```bash -# Monitor all pods -./pod-health-monitor.sh - -# Monitor specific namespace -./pod-health-monitor.sh --namespace docker-services - -# Dry-run mode -./pod-health-monitor.sh --whatif -``` - -## Usage - -### pod-health-monitor.sh - -| Option | Description | Default | -|--------|-------------|---------| -| `--namespace NS` | Monitor specific namespace | all | -| `--kubeconfig PATH` | Path to kubeconfig | default | -| `--restart-threshold N` | Alert if restarts > N | 5 | -| `--output-dir DIR` | Log directory | /var/log/k8s-monitor | -| `--whatif` | Dry-run mode | false | - -**Detected Issues**: CrashLoopBackOff, OOMKilled, ImagePullBackOff, Pending, High Restarts - -## Prometheus Metrics - -Exported to `/var/lib/prometheus/node-exporter/`: - -| Metric | Description | -|--------|-------------| -| k8s_pod_unhealthy | Unhealthy pods by reason | -| k8s_pod_restarts_total | Total pod restarts | - -## Cron Setup - -```bash -# Every 5 minutes -*/5 * * * * /path/to/pod-health-monitor.sh >> /var/log/k8s-monitor/cron.log 2>&1 -``` - -## Troubleshooting - -| Issue | Solution | -|-------|----------| -| No pods found | Check `kubectl config current-context` | -| Permission denied | `kubectl auth can-i get pods --all-namespaces` | -| Metrics missing | Verify scripts running via cron | - -## Prerequisites - -- kubectl configured -- jq for JSON parsing -- Read access to Kubernetes cluster - ---- -**Last Updated**: 2025-12-26 diff --git a/Linux/kubernetes/pod-health-monitor.sh b/Linux/kubernetes/pod-health-monitor.sh deleted file mode 100644 index 48496b8..0000000 --- a/Linux/kubernetes/pod-health-monitor.sh +++ /dev/null @@ -1,512 +0,0 @@ -#!/usr/bin/env bash - -# ============================================================================== -# Kubernetes Pod Health Monitor with Prometheus Integration -# ============================================================================== -# -# DESCRIPTION: -# Monitors Kubernetes pod health and exports metrics to Prometheus. -# Detects common pod issues: CrashLoopBackOff, OOMKilled, ImagePullBackOff. -# -# FEATURES: -# - Detects unhealthy pods across all namespaces -# - Identifies pod restart loops and memory issues -# - Exports Prometheus metrics for alerting -# - Logs pod events and container logs for troubleshooting -# - Supports custom KUBECONFIG path -# - Optional namespace filtering -# -# USAGE: -# ./pod-health-monitor.sh [OPTIONS] -# -# OPTIONS: -# --namespace NS Monitor specific namespace (default: all) -# --kubeconfig PATH Path to kubeconfig file -# --output-dir DIR Directory for logs and metrics (default: /var/log/k8s-monitor) -# --restart-threshold N Alert if pod restarts > N times (default: 5) -# --whatif Dry-run mode -# --help Show this help message -# -# EXAMPLES: -# ./pod-health-monitor.sh -# ./pod-health-monitor.sh --namespace docker-services -# ./pod-health-monitor.sh --kubeconfig ~/.kube/config-lab -# ./pod-health-monitor.sh --restart-threshold 10 -# -# REQUIREMENTS: -# - kubectl configured and accessible -# - Read access to Kubernetes cluster -# - jq for JSON parsing -# -# AUTHOR: -# Windows & Linux Sysadmin Toolkit -# -# VERSION: -# 1.0.0 -# -# CHANGELOG: -# 1.0.0 - 2025-10-15 -# - Initial release -# - Pod health detection (CrashLoopBackOff, OOMKilled, etc.) -# - Prometheus metrics export -# - Event and log collection -# -# ============================================================================== - -set -euo pipefail - -# ============================================================================== -# GLOBAL VARIABLES -# ============================================================================== - -SCRIPT_VERSION="1.0.0" -SCRIPT_NAME="$(basename "$0")" - -# Configuration -NAMESPACE="${NAMESPACE:-}" -KUBECONFIG_PATH="${KUBECONFIG:-}" -OUTPUT_DIR="${OUTPUT_DIR:-/var/log/k8s-monitor}" -METRICS_DIR="${METRICS_DIR:-/var/lib/prometheus/node-exporter}" -LOGS_DIR="${OUTPUT_DIR}/pod-logs" -RESTART_THRESHOLD="${RESTART_THRESHOLD:-5}" -WHATIF_MODE=false - -# Runtime variables -START_TIME=$(date +%s) -UNHEALTHY_POD_COUNT=0 -CRASHLOOP_POD_COUNT=0 -OOM_KILLED_POD_COUNT=0 -PENDING_POD_COUNT=0 -IMAGE_PULL_ERROR_COUNT=0 - -# ============================================================================== -# HELPER FUNCTIONS -# ============================================================================== - -log_info() { - echo "[i] $1" -} - -log_success() { - echo "[+] $1" -} - -log_warning() { - echo "[!] $1" >&2 -} - -log_error() { - echo "[-] $1" >&2 -} - -check_dependencies() { - local missing_deps=() - - if ! command -v kubectl &>/dev/null; then - missing_deps+=("kubectl") - fi - - if ! command -v jq &>/dev/null; then - missing_deps+=("jq") - fi - - if [[ ${#missing_deps[@]} -gt 0 ]]; then - log_error "Missing required dependencies: ${missing_deps[*]}" - log_info "Install with: sudo apt install kubectl jq" - exit 1 - fi -} - -init_directories() { - mkdir -p "$OUTPUT_DIR" "$METRICS_DIR" "$LOGS_DIR" - log_info "Initialized directories: $OUTPUT_DIR, $METRICS_DIR, $LOGS_DIR" -} - -setup_kubeconfig() { - if [[ -n "$KUBECONFIG_PATH" ]]; then - export KUBECONFIG="$KUBECONFIG_PATH" - log_info "Using KUBECONFIG: $KUBECONFIG_PATH" - fi - - # Test kubectl access - if ! kubectl cluster-info &>/dev/null; then - log_error "Cannot connect to Kubernetes cluster" - log_info "Check your kubeconfig or use --kubeconfig option" - exit 1 - fi - - log_success "Connected to Kubernetes cluster: $(kubectl cluster-info | head -n 1 | awk '{print $NF}')" -} - -# ============================================================================== -# POD HEALTH CHECK FUNCTIONS -# ============================================================================== - -get_all_pods() { - local namespace_arg="" - if [[ -n "$NAMESPACE" ]]; then - namespace_arg="--namespace=$NAMESPACE" - else - namespace_arg="--all-namespaces" - fi - - kubectl get pods $namespace_arg -o json -} - -check_crashloop_backoff() { - log_info "Checking for CrashLoopBackOff pods..." - - local pods_json="$1" - local crashloop_pods - crashloop_pods=$(echo "$pods_json" | jq -r ' - .items[] | - select(.status.containerStatuses != null) | - select(.status.containerStatuses[].state.waiting.reason == "CrashLoopBackOff") | - "\(.metadata.namespace)|\(.metadata.name)|\(.status.containerStatuses[].restartCount)" - ') - - if [[ -z "$crashloop_pods" ]]; then - log_success "No CrashLoopBackOff pods found" - return - fi - - while IFS='|' read -r ns pod_name restart_count; do - ((CRASHLOOP_POD_COUNT++)) - ((UNHEALTHY_POD_COUNT++)) - - log_warning "CrashLoopBackOff detected: $ns/$pod_name (Restarts: $restart_count)" - - # Collect logs - local log_file="${LOGS_DIR}/${ns}_${pod_name}_$(date +%Y%m%d_%H%M%S).log" - if [[ "$WHATIF_MODE" == false ]]; then - kubectl logs -n "$ns" "$pod_name" --tail=100 > "$log_file" 2>&1 || true - kubectl describe pod -n "$ns" "$pod_name" >> "$log_file" 2>&1 || true - log_info "Logs saved to: $log_file" - else - log_info "[WHATIF] Would save logs to: $log_file" - fi - done <<< "$crashloop_pods" -} - -check_oom_killed() { - log_info "Checking for OOMKilled pods..." - - local pods_json="$1" - local oom_pods - oom_pods=$(echo "$pods_json" | jq -r ' - .items[] | - select(.status.containerStatuses != null) | - select(.status.containerStatuses[].lastState.terminated.reason == "OOMKilled") | - "\(.metadata.namespace)|\(.metadata.name)|\(.status.containerStatuses[].restartCount)" - ') - - if [[ -z "$oom_pods" ]]; then - log_success "No OOMKilled pods found" - return - fi - - while IFS='|' read -r ns pod_name restart_count; do - ((OOM_KILLED_POD_COUNT++)) - ((UNHEALTHY_POD_COUNT++)) - - log_warning "OOMKilled detected: $ns/$pod_name (Restarts: $restart_count)" - - # Get memory limits - local memory_limit - memory_limit=$(kubectl get pod -n "$ns" "$pod_name" -o json | jq -r '.spec.containers[0].resources.limits.memory // "not set"') - log_info "Memory limit: $memory_limit" - - # Collect events - local event_file="${LOGS_DIR}/${ns}_${pod_name}_events_$(date +%Y%m%d_%H%M%S).log" - if [[ "$WHATIF_MODE" == false ]]; then - kubectl get events -n "$ns" --field-selector involvedObject.name="$pod_name" > "$event_file" 2>&1 || true - log_info "Events saved to: $event_file" - fi - done <<< "$oom_pods" -} - -check_pending_pods() { - log_info "Checking for Pending pods..." - - local pods_json="$1" - local pending_pods - pending_pods=$(echo "$pods_json" | jq -r ' - .items[] | - select(.status.phase == "Pending") | - "\(.metadata.namespace)|\(.metadata.name)|\(.status.conditions[].reason // "Unknown")" - ') - - if [[ -z "$pending_pods" ]]; then - log_success "No Pending pods found" - return - fi - - while IFS='|' read -r ns pod_name reason; do - ((PENDING_POD_COUNT++)) - ((UNHEALTHY_POD_COUNT++)) - - log_warning "Pending pod detected: $ns/$pod_name (Reason: $reason)" - done <<< "$pending_pods" -} - -check_image_pull_errors() { - log_info "Checking for ImagePullBackOff/ErrImagePull..." - - local pods_json="$1" - local image_error_pods - image_error_pods=$(echo "$pods_json" | jq -r ' - .items[] | - select(.status.containerStatuses != null) | - select( - .status.containerStatuses[].state.waiting.reason == "ImagePullBackOff" or - .status.containerStatuses[].state.waiting.reason == "ErrImagePull" - ) | - "\(.metadata.namespace)|\(.metadata.name)|\(.status.containerStatuses[].state.waiting.reason)" - ') - - if [[ -z "$image_error_pods" ]]; then - log_success "No image pull errors found" - return - fi - - while IFS='|' read -r ns pod_name reason; do - ((IMAGE_PULL_ERROR_COUNT++)) - ((UNHEALTHY_POD_COUNT++)) - - log_warning "Image pull error: $ns/$pod_name ($reason)" - - # Get image name - local image - image=$(kubectl get pod -n "$ns" "$pod_name" -o json | jq -r '.spec.containers[0].image') - log_info "Image: $image" - done <<< "$image_error_pods" -} - -check_restart_loops() { - log_info "Checking for pods with high restart counts (threshold: $RESTART_THRESHOLD)..." - - local pods_json="$1" - local high_restart_pods - high_restart_pods=$(echo "$pods_json" | jq -r --argjson threshold "$RESTART_THRESHOLD" ' - .items[] | - select(.status.containerStatuses != null) | - select(.status.containerStatuses[].restartCount > $threshold) | - "\(.metadata.namespace)|\(.metadata.name)|\(.status.containerStatuses[].restartCount)" - ') - - if [[ -z "$high_restart_pods" ]]; then - log_success "No pods with high restart counts" - return - fi - - while IFS='|' read -r ns pod_name restart_count; do - log_warning "High restart count: $ns/$pod_name (Restarts: $restart_count)" - done <<< "$high_restart_pods" -} - -# ============================================================================== -# PROMETHEUS METRICS -# ============================================================================== - -export_prometheus_metrics() { - local metrics_file="${METRICS_DIR}/k8s_pod_health.prom" - local end_time=$(date +%s) - local duration=$((end_time - START_TIME)) - - if [[ "$WHATIF_MODE" == true ]]; then - log_info "[WHATIF] Would export Prometheus metrics to: $metrics_file" - return - fi - - log_info "Exporting Prometheus metrics..." - - local cluster_name - cluster_name=$(kubectl config current-context) - - cat > "$metrics_file" < N times (default: 5) - --whatif Dry-run mode - --help Show this help message - -EXAMPLES: - $SCRIPT_NAME - $SCRIPT_NAME --namespace docker-services - $SCRIPT_NAME --kubeconfig ~/.kube/config-lab - $SCRIPT_NAME --restart-threshold 10 - -PROMETHEUS METRICS: - Metrics are exported to: $METRICS_DIR/k8s_pod_health.prom - Configure node_exporter textfile collector to scrape this directory. - -VERSION: - $SCRIPT_VERSION - -EOF -} - -# ============================================================================== -# ARGUMENT PARSING -# ============================================================================== - -parse_arguments() { - while [[ $# -gt 0 ]]; do - case $1 in - --namespace) - NAMESPACE="$2" - shift 2 - ;; - --kubeconfig) - KUBECONFIG_PATH="$2" - shift 2 - ;; - --output-dir) - OUTPUT_DIR="$2" - LOGS_DIR="${OUTPUT_DIR}/pod-logs" - shift 2 - ;; - --restart-threshold) - RESTART_THRESHOLD="$2" - shift 2 - ;; - --whatif) - WHATIF_MODE=true - shift - ;; - --help) - show_help - exit 0 - ;; - *) - log_error "Unknown option: $1" - show_help - exit 1 - ;; - esac - done -} - -# ============================================================================== -# MAIN EXECUTION -# ============================================================================== - -main() { - parse_arguments "$@" - - log_info "=== Kubernetes Pod Health Monitor Started ===" - log_info "Version: $SCRIPT_VERSION" - log_info "Date: $(date)" - - if [[ "$WHATIF_MODE" == true ]]; then - log_warning "Running in WHATIF mode - no logs will be saved" - fi - - check_dependencies - init_directories - setup_kubeconfig - - # Get all pods - log_info "Fetching pod information..." - local pods_json - pods_json=$(get_all_pods) - - # Run health checks - check_crashloop_backoff "$pods_json" - check_oom_killed "$pods_json" - check_pending_pods "$pods_json" - check_image_pull_errors "$pods_json" - check_restart_loops "$pods_json" - - # Export metrics - export_prometheus_metrics - - # Show summary - show_summary - - log_success "=== Kubernetes Pod Health Monitor Completed ===" - - # Exit with error code if unhealthy pods found - if [[ $UNHEALTHY_POD_COUNT -gt 0 ]]; then - exit 1 - fi -} - -# Run main function -main "$@" diff --git a/Linux/maintenance/README.md b/Linux/maintenance/README.md index 4b0b200..2c5521e 100644 --- a/Linux/maintenance/README.md +++ b/Linux/maintenance/README.md @@ -1,117 +1,27 @@ # Linux Maintenance Scripts -Automated update and maintenance scripts for Debian/Ubuntu systems with APT/Snap support. +> **Scope note (2026-06-14):** `system-update.sh`, `log-cleanup.sh`, and `restore-previous-state.sh` were removed in the ghost-code cull. `apt`/`apt-get` and `journalctl --vacuum-time` cover those needs natively, and the rollback script was speculative (never used in a real restore). ## Scripts | Script | Purpose | |--------|---------| -| [system-updates.sh](system-updates.sh) | APT/Snap updates with state export and Prometheus metrics | -| [restore-previous-state.sh](restore-previous-state.sh) | Rollback to pre-update package state | +| [disk-cleanup.sh](disk-cleanup.sh) | APT cache + journal + Docker leftover cleanup with `--whatif` and confirmation guards | -## Quick Start +## Quick Example ```bash -# Make executable -chmod +x system-updates.sh restore-previous-state.sh - -# Install dependencies -sudo apt install -y jq - # Dry-run first -sudo ./system-updates.sh --whatif - -# Run updates -sudo ./system-updates.sh -``` - -## Usage - -### system-updates.sh - -```bash -sudo ./system-updates.sh # Update everything -sudo ./system-updates.sh --skip-apt # Skip APT packages -sudo ./system-updates.sh --skip-snap # Skip Snap packages -sudo ./system-updates.sh --whatif # Dry-run mode -sudo ./system-updates.sh --auto-reboot # Reboot after updates -sudo ./system-updates.sh --config /path/to/config.json -``` - -### restore-previous-state.sh - -```bash -sudo ./restore-previous-state.sh --list # List backups -sudo ./restore-previous-state.sh --latest --show-diff # Preview changes -sudo ./restore-previous-state.sh --latest # Restore latest -sudo ./restore-previous-state.sh --latest --whatif # Dry-run restore -``` - -## Configuration - -Create `config.json` from template: -```bash -cp config.example.json config.json -``` - -| Option | Default | Description | -|--------|---------|-------------| -| AutoReboot | false | Reboot after updates | -| LogRetentionDays | 30 | Days to keep logs | -| SkipAPT | false | Skip APT updates | -| SkipSnap | false | Skip Snap updates | -| ExportMetrics | true | Export Prometheus metrics | - -## Scheduled Updates - -### Cron (Simple) -```bash -sudo crontab -e -# Weekly Sunday 3 AM: -0 3 * * 0 /path/to/system-updates.sh --auto-reboot -``` - -### Systemd Timer (Robust) -```bash -# See /etc/systemd/system/system-updates.{service,timer} -sudo systemctl enable --now system-updates.timer -``` - -## Log Files +sudo ./disk-cleanup.sh --whatif +# Run for real +sudo ./disk-cleanup.sh ``` -/var/log/system-updates/ -├── system-updates_YYYY-MM-DD.log # Main log -├── states/pre-update-state_*.json # Package snapshots -└── metrics/system_updates.prom # Prometheus metrics -``` - -## Prometheus Metrics - -Exported to `/var/log/system-updates/metrics/system_updates.prom`: - -| Metric | Description | -|--------|-------------| -| system_updates_apt_packages_updated | APT packages updated | -| system_updates_snap_packages_updated | Snap packages updated | -| system_updates_reboot_required | Reboot needed (0/1) | -| system_updates_duration_seconds | Update duration | -| system_updates_last_run_timestamp | Last run epoch | - -## Troubleshooting - -| Issue | Solution | -|-------|----------| -| jq not found | `sudo apt install -y jq` | -| Permission denied | `chmod +x *.sh` and use `sudo` | -| APT downgrade fails | Check `apt-cache policy ` for versions | -| Snap not updating | `sudo systemctl status snapd` | ## Prerequisites - Bash 4.0+ - Root/sudo privileges -- jq (for JSON parsing) --- -**Last Updated**: 2025-12-26 +**Last Updated**: 2026-06-14 diff --git a/Linux/maintenance/log-cleanup.sh b/Linux/maintenance/log-cleanup.sh deleted file mode 100755 index adb6b57..0000000 --- a/Linux/maintenance/log-cleanup.sh +++ /dev/null @@ -1,524 +0,0 @@ -#!/usr/bin/env bash - -# ============================================================================== -# Linux Log Cleanup and Rotation Script -# ============================================================================== -# -# DESCRIPTION: -# Automated log file cleanup to reclaim disk space. -# Compresses old logs, removes aged rotated logs, and vacuums journald. -# -# FEATURES: -# - Compress logs older than N days -# - Delete rotated logs older than N days -# - Vacuum journald logs to specified size/time -# - Target specific log directories or all common locations -# - Prometheus metrics export (space reclaimed) -# - Dry-run mode for safe testing -# -# USAGE: -# sudo ./log-cleanup.sh [OPTIONS] -# -# OPTIONS: -# --compress-age-days N Compress logs older than N days (default: 7) -# --delete-age-days N Delete rotated logs older than N days (default: 30) -# --journal-max-size SIZE Max journald size (e.g., 500M, 2G) (default: 500M) -# --log-dir DIR Additional log directory to clean (repeatable) -# --whatif Dry-run mode -# --help Show this help message -# -# EXAMPLES: -# sudo ./log-cleanup.sh --whatif -# sudo ./log-cleanup.sh --compress-age-days 3 --delete-age-days 14 -# sudo ./log-cleanup.sh --journal-max-size 1G -# sudo ./log-cleanup.sh --log-dir /var/log/myapp -# -# REQUIREMENTS: -# - Root/sudo privileges -# - gzip for compression -# - journalctl for systemd journal management -# -# AUTHOR: -# Windows & Linux Sysadmin Toolkit -# -# VERSION: -# 1.0.0 -# -# CHANGELOG: -# 1.0.0 - 2025-10-15 -# - Initial release -# - Log compression and deletion -# - Journald vacuum support -# - Prometheus metrics export -# -# ============================================================================== - -set -euo pipefail - -# ============================================================================== -# GLOBAL VARIABLES -# ============================================================================== - -SCRIPT_VERSION="1.0.0" -SCRIPT_NAME="$(basename "$0")" - -# Configuration -COMPRESS_AGE_DAYS="${COMPRESS_AGE_DAYS:-7}" -DELETE_AGE_DAYS="${DELETE_AGE_DAYS:-30}" -JOURNAL_MAX_SIZE="${JOURNAL_MAX_SIZE:-500M}" -WHATIF_MODE=false -OUTPUT_DIR="${OUTPUT_DIR:-/var/log/log-cleanup}" -METRICS_DIR="${METRICS_DIR:-/var/lib/prometheus/node-exporter}" - -# Additional log directories to clean -CUSTOM_LOG_DIRS=() - -# Common log directories to clean -DEFAULT_LOG_DIRS=( - "/var/log" - "/var/log/sysstat" -) - -# Runtime variables -START_TIME=$(date +%s) -COMPRESSED_FILES_COUNT=0 -DELETED_FILES_COUNT=0 -SPACE_RECLAIMED_BYTES=0 - -# ============================================================================== -# HELPER FUNCTIONS -# ============================================================================== - -log_info() { - echo "[i] $1" -} - -log_success() { - echo "[+] $1" -} - -log_warning() { - echo "[!] $1" >&2 -} - -log_error() { - echo "[-] $1" >&2 -} - -check_root() { - if [[ $EUID -ne 0 ]]; then - log_error "This script must be run as root or with sudo" - exit 1 - fi -} - -check_dependencies() { - local missing_deps=() - - if ! command -v gzip &>/dev/null; then - missing_deps+=("gzip") - fi - - if ! command -v journalctl &>/dev/null; then - log_warning "journalctl not found - journald cleanup will be skipped" - fi - - if [[ ${#missing_deps[@]} -gt 0 ]]; then - log_error "Missing required dependencies: ${missing_deps[*]}" - exit 1 - fi -} - -init_directories() { - mkdir -p "$OUTPUT_DIR" "$METRICS_DIR" -} - -bytes_to_human() { - local bytes=$1 - if [[ $bytes -lt 1024 ]]; then - echo "${bytes}B" - elif [[ $bytes -lt 1048576 ]]; then - echo "$(awk "BEGIN {printf \"%.2f\", $bytes/1024}")KB" - elif [[ $bytes -lt 1073741824 ]]; then - echo "$(awk "BEGIN {printf \"%.2f\", $bytes/1048576}")MB" - else - echo "$(awk "BEGIN {printf \"%.2f\", $bytes/1073741824}")GB" - fi -} - -get_file_size() { - local file="$1" - if [[ -f "$file" ]]; then - stat -f "%z" "$file" 2>/dev/null || stat -c "%s" "$file" 2>/dev/null || echo "0" - else - echo "0" - fi -} - -# ============================================================================== -# CLEANUP FUNCTIONS -# ============================================================================== - -compress_old_logs() { - local log_dir="$1" - log_info "Compressing logs in $log_dir older than $COMPRESS_AGE_DAYS days..." - - if [[ ! -d "$log_dir" ]]; then - log_warning "Directory not found: $log_dir" - return - fi - - # Find uncompressed log files older than threshold - # Exclude already compressed files (.gz, .bz2, .xz) - local old_logs - old_logs=$(find "$log_dir" -type f \ - -name "*.log" -o -name "*.log.[0-9]*" \ - ! -name "*.gz" ! -name "*.bz2" ! -name "*.xz" \ - -mtime +$COMPRESS_AGE_DAYS 2>/dev/null || true) - - if [[ -z "$old_logs" ]]; then - log_success "No old logs to compress in $log_dir" - return - fi - - local count=0 - local space_saved=0 - - while IFS= read -r log_file; do - [[ -z "$log_file" ]] && continue - - # Skip empty files - local file_size - file_size=$(get_file_size "$log_file") - [[ $file_size -eq 0 ]] && continue - - if [[ "$WHATIF_MODE" == true ]]; then - log_info "[WHATIF] Would compress: $log_file" - ((count++)) - continue - fi - - # Compress the file - if gzip "$log_file" 2>/dev/null; then - local compressed_size - compressed_size=$(get_file_size "${log_file}.gz") - space_saved=$((space_saved + file_size - compressed_size)) - ((count++)) - fi - done <<< "$old_logs" - - if [[ $count -gt 0 ]]; then - COMPRESSED_FILES_COUNT=$((COMPRESSED_FILES_COUNT + count)) - SPACE_RECLAIMED_BYTES=$((SPACE_RECLAIMED_BYTES + space_saved)) - log_success "Compressed $count log files in $log_dir ($(bytes_to_human $space_saved) saved)" - fi -} - -delete_old_rotated_logs() { - local log_dir="$1" - log_info "Deleting rotated logs in $log_dir older than $DELETE_AGE_DAYS days..." - - if [[ ! -d "$log_dir" ]]; then - log_warning "Directory not found: $log_dir" - return - fi - - # Find rotated log files (with numbers or .gz extension) - local old_rotated_logs - old_rotated_logs=$(find "$log_dir" -type f \ - \( -name "*.log.[0-9]*.gz" -o -name "*.log.old" -o -name "*.log-[0-9]*" \) \ - -mtime +$DELETE_AGE_DAYS 2>/dev/null || true) - - if [[ -z "$old_rotated_logs" ]]; then - log_success "No old rotated logs to delete in $log_dir" - return - fi - - local count=0 - local space_freed=0 - - while IFS= read -r log_file; do - [[ -z "$log_file" ]] && continue - - local file_size - file_size=$(get_file_size "$log_file") - - if [[ "$WHATIF_MODE" == true ]]; then - log_info "[WHATIF] Would delete: $log_file ($(bytes_to_human $file_size))" - ((count++)) - space_freed=$((space_freed + file_size)) - continue - fi - - if rm -f "$log_file" 2>/dev/null; then - space_freed=$((space_freed + file_size)) - ((count++)) - fi - done <<< "$old_rotated_logs" - - if [[ $count -gt 0 ]]; then - DELETED_FILES_COUNT=$((DELETED_FILES_COUNT + count)) - SPACE_RECLAIMED_BYTES=$((SPACE_RECLAIMED_BYTES + space_freed)) - log_success "Deleted $count old rotated logs in $log_dir ($(bytes_to_human $space_freed) freed)" - fi -} - -vacuum_journald() { - if ! command -v journalctl &>/dev/null; then - log_warning "journalctl not available, skipping journald vacuum" - return - fi - - log_info "Vacuuming journald logs (max size: $JOURNAL_MAX_SIZE)..." - - if [[ "$WHATIF_MODE" == true ]]; then - log_info "[WHATIF] Would vacuum journald to max size: $JOURNAL_MAX_SIZE" - local journal_size - journal_size=$(journalctl --disk-usage 2>/dev/null | awk '{print $NF}' || echo "unknown") - log_info "[WHATIF] Current journal size: $journal_size" - return - fi - - # Get journal size before vacuum - local size_before - size_before=$(journalctl --disk-usage 2>/dev/null | grep -oP '\d+(\.\d+)?[KMG]' | head -n 1 || echo "0") - - # Vacuum by size - journalctl --vacuum-size="$JOURNAL_MAX_SIZE" &>/dev/null || log_warning "Failed to vacuum journald" - - # Get journal size after vacuum - local size_after - size_after=$(journalctl --disk-usage 2>/dev/null | grep -oP '\d+(\.\d+)?[KMG]' | head -n 1 || echo "0") - - log_success "Journald vacuum completed (was: $size_before, now: $size_after)" -} - -clean_specific_large_logs() { - log_info "Checking for specific large log files..." - - # NVIDIA logs - if [[ -f "/var/log/nv-hostengine.log" ]]; then - local nvidia_size - nvidia_size=$(get_file_size "/var/log/nv-hostengine.log") - if [[ $nvidia_size -gt 10485760 ]]; then # > 10MB - log_info "Large NVIDIA log detected: $(bytes_to_human $nvidia_size)" - if [[ "$WHATIF_MODE" == false ]]; then - # Truncate to last 1000 lines - tail -n 1000 /var/log/nv-hostengine.log > /tmp/nv-hostengine.log.tmp - mv /tmp/nv-hostengine.log.tmp /var/log/nv-hostengine.log - log_success "Truncated NVIDIA log to last 1000 lines" - else - log_info "[WHATIF] Would truncate NVIDIA log" - fi - fi - fi - - # UFW logs - if [[ -f "/var/log/ufw.log" ]]; then - local ufw_size - ufw_size=$(get_file_size "/var/log/ufw.log") - if [[ $ufw_size -gt 5242880 ]]; then # > 5MB - log_info "Large UFW log detected: $(bytes_to_human $ufw_size)" - # Let logrotate handle this - fi - fi -} - -# ============================================================================== -# PROMETHEUS METRICS -# ============================================================================== - -export_prometheus_metrics() { - local metrics_file="${METRICS_DIR}/log_cleanup.prom" - local end_time=$(date +%s) - local duration=$((end_time - START_TIME)) - - if [[ "$WHATIF_MODE" == true ]]; then - log_info "[WHATIF] Would export Prometheus metrics to: $metrics_file" - return - fi - - log_info "Exporting Prometheus metrics..." - - local hostname_var - hostname_var=$(hostname) - - cat > "$metrics_file" </dev/null || true - echo "" - log_info "Largest log files:" - du -sh /var/log/* 2>/dev/null | sort -hr | head -n 10 || true -} - -# ============================================================================== -# HELP -# ============================================================================== - -show_help() { - cat < compressed with gzip - - Rotated logs older than N days -> deleted - - Journald logs -> vacuumed to specified size - - Large specific logs (NVIDIA, etc.) -> truncated if > threshold - -PROMETHEUS METRICS: - Metrics are exported to: $METRICS_DIR/log_cleanup.prom - Configure node_exporter textfile collector to scrape this directory. - -VERSION: - $SCRIPT_VERSION - -EOF -} - -# ============================================================================== -# ARGUMENT PARSING -# ============================================================================== - -parse_arguments() { - while [[ $# -gt 0 ]]; do - case $1 in - --compress-age-days) - COMPRESS_AGE_DAYS="$2" - shift 2 - ;; - --delete-age-days) - DELETE_AGE_DAYS="$2" - shift 2 - ;; - --journal-max-size) - JOURNAL_MAX_SIZE="$2" - shift 2 - ;; - --log-dir) - CUSTOM_LOG_DIRS+=("$2") - shift 2 - ;; - --whatif) - WHATIF_MODE=true - shift - ;; - --help) - show_help - exit 0 - ;; - *) - log_error "Unknown option: $1" - show_help - exit 1 - ;; - esac - done -} - -# ============================================================================== -# MAIN EXECUTION -# ============================================================================== - -main() { - parse_arguments "$@" - - log_info "=== Log Cleanup Script Started ===" - log_info "Version: $SCRIPT_VERSION" - log_info "Date: $(date)" - log_info "Configuration:" - log_info " - Compress age: $COMPRESS_AGE_DAYS days" - log_info " - Delete age: $DELETE_AGE_DAYS days" - log_info " - Journal max size: $JOURNAL_MAX_SIZE" - - if [[ "$WHATIF_MODE" == true ]]; then - log_warning "Running in WHATIF mode - no changes will be made" - fi - - check_root - check_dependencies - init_directories - - # Combine default and custom log directories - local all_log_dirs=("${DEFAULT_LOG_DIRS[@]}" "${CUSTOM_LOG_DIRS[@]}") - - # Clean each log directory - for log_dir in "${all_log_dirs[@]}"; do - compress_old_logs "$log_dir" - delete_old_rotated_logs "$log_dir" - done - - # Clean specific large logs - clean_specific_large_logs - - # Vacuum journald - vacuum_journald - - # Export metrics and show summary - export_prometheus_metrics - show_summary - - log_success "=== Log Cleanup Script Completed ===" -} - -# Run main function -main "$@" diff --git a/Linux/maintenance/restore-previous-state.sh b/Linux/maintenance/restore-previous-state.sh deleted file mode 100755 index 12d2698..0000000 --- a/Linux/maintenance/restore-previous-state.sh +++ /dev/null @@ -1,608 +0,0 @@ -#!/usr/bin/env bash - -# ============================================================================== -# Linux Package State Restore Script -# ============================================================================== -# -# DESCRIPTION: -# Restores system to a previous package state after failed updates. -# Analyzes pre-update state JSON files created by system-updates.sh. -# -# FEATURES: -# - List available backup states -# - Show differences between current and backup state -# - Downgrade packages to previous versions (APT and Snap) -# - WhatIf mode for safe preview -# -# USAGE: -# sudo ./restore-previous-state.sh [OPTIONS] -# -# OPTIONS: -# --list List all available pre-update state backups -# --latest Use the most recent backup file -# --backup-file FILE Path to specific pre-update state JSON file -# --show-diff Only show differences (no changes made) -# --whatif Dry-run mode (show what would be done) -# --help Show this help message -# -# EXAMPLES: -# sudo ./restore-previous-state.sh --list -# sudo ./restore-previous-state.sh --latest --show-diff -# sudo ./restore-previous-state.sh --latest -# sudo ./restore-previous-state.sh --backup-file /var/log/system-updates/states/pre-update-state_2025-10-15_10-30-00.json -# -# REQUIREMENTS: -# - Bash 4.0+ -# - Root/sudo privileges -# - jq (for JSON parsing) -# -# AUTHOR: -# Windows & Linux Sysadmin Toolkit -# -# VERSION: -# 1.0.0 -# -# CHANGELOG: -# 1.0.0 - 2025-10-15 -# - Initial release -# - APT and Snap package restore -# - State comparison and diff display -# -# NOTES: -# - APT downgrades use apt install = -# - Snap downgrades use snap revert or snap install --channel -# - Some packages may not be easily reversible -# - Kernel updates should not be downgraded -# -# ============================================================================== - -set -euo pipefail - -# ============================================================================== -# GLOBAL VARIABLES -# ============================================================================== - -SCRIPT_VERSION="1.0.0" -SCRIPT_NAME="$(basename "$0")" -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - -# Default paths -LOG_DIR="${LOG_DIR:-/var/log/system-updates}" -STATE_DIR="${LOG_DIR}/states" - -# Operation mode -LIST_MODE=false -LATEST_MODE=false -SHOW_DIFF_MODE=false -WHATIF_MODE=false -BACKUP_FILE="" - -# ============================================================================== -# HELPER FUNCTIONS -# ============================================================================== - -# Print messages with ASCII markers -log_info() { - echo "[i] $1" -} - -log_success() { - echo "[+] $1" -} - -log_warning() { - echo "[!] $1" >&2 -} - -log_error() { - echo "[-] $1" >&2 -} - -# Check if running as root -check_root() { - if [[ $EUID -ne 0 ]]; then - log_error "This script must be run as root or with sudo" - exit 1 - fi -} - -# Check for jq dependency -check_dependencies() { - if ! command -v jq &>/dev/null; then - log_error "jq is required but not installed" - log_info "Install with: sudo apt install jq" - exit 1 - fi -} - -# ============================================================================== -# BACKUP LISTING FUNCTIONS -# ============================================================================== - -# List all available backup files -list_backup_files() { - if [[ ! -d "$STATE_DIR" ]]; then - log_warning "State directory not found: $STATE_DIR" - return - fi - - local backup_files - backup_files=$(find "$STATE_DIR" -name "pre-update-state_*.json" -type f 2>/dev/null | sort -r) - - if [[ -z "$backup_files" ]]; then - log_warning "No backup state files found in: $STATE_DIR" - log_info "Backup files are created when running system-updates.sh" - return - fi - - log_info "Available backup states:" - echo "" - - local index=1 - while IFS= read -r backup_file; do - local filename - filename=$(basename "$backup_file") - - local timestamp - timestamp=$(jq -r '.timestamp' "$backup_file" 2>/dev/null || echo "Unknown") - - local hostname_backup - hostname_backup=$(jq -r '.hostname' "$backup_file" 2>/dev/null || echo "Unknown") - - local apt_count - apt_count=$(jq -r '.apt_packages | length' "$backup_file" 2>/dev/null || echo "0") - - echo "[$index] $filename" - echo " Created: $timestamp" - echo " Hostname: $hostname_backup" - echo " APT packages: $apt_count" - echo " Path: $backup_file" - echo "" - - ((index++)) - done <<< "$backup_files" -} - -# Get the latest backup file -get_latest_backup() { - if [[ ! -d "$STATE_DIR" ]]; then - log_error "State directory not found: $STATE_DIR" - return 1 - fi - - local latest - latest=$(find "$STATE_DIR" -name "pre-update-state_*.json" -type f 2>/dev/null | sort -r | head -n 1) - - if [[ -z "$latest" ]]; then - log_error "No backup files found in: $STATE_DIR" - return 1 - fi - - echo "$latest" -} - -# ============================================================================== -# STATE COMPARISON FUNCTIONS -# ============================================================================== - -# Get current package state -get_current_state() { - local temp_file - temp_file=$(mktemp) - - log_info "Gathering current package state..." - - # Get APT packages - local apt_packages - apt_packages=$(dpkg-query -W -f='${Package}\t${Version}\n' 2>/dev/null || echo "") - - # Get Snap packages (if available) - local snap_packages="" - if command -v snap &>/dev/null; then - snap_packages=$(snap list 2>/dev/null || echo "") - fi - - # Create JSON state file - cat > "$temp_file" < 0))'), - "snap_packages": $(echo "$snap_packages" | jq -R -s -c 'split("\n") | map(select(length > 0))') -} -EOF - - echo "$temp_file" -} - -# Compare backup and current states -compare_states() { - local backup_file="$1" - local current_file="$2" - - log_info "Comparing package states..." - - # Parse backup APT packages - local backup_apt - backup_apt=$(jq -r '.apt_packages[]' "$backup_file" 2>/dev/null || echo "") - - # Parse current APT packages - local current_apt - current_apt=$(jq -r '.apt_packages[]' "$current_file" 2>/dev/null || echo "") - - # Create associative arrays for comparison - declare -A backup_packages - declare -A current_packages - - # Populate backup packages - while IFS=$'\t' read -r pkg_name pkg_version; do - if [[ -n "$pkg_name" ]]; then - backup_packages["$pkg_name"]="$pkg_version" - fi - done <<< "$backup_apt" - - # Populate current packages - while IFS=$'\t' read -r pkg_name pkg_version; do - if [[ -n "$pkg_name" ]]; then - current_packages["$pkg_name"]="$pkg_version" - fi - done <<< "$current_apt" - - # Find differences - local upgraded_packages=() - local downgraded_packages=() - local added_packages=() - local removed_packages=() - - # Check for upgraded/downgraded packages - for pkg_name in "${!backup_packages[@]}"; do - if [[ -n "${current_packages[$pkg_name]:-}" ]]; then - local backup_ver="${backup_packages[$pkg_name]}" - local current_ver="${current_packages[$pkg_name]}" - - if [[ "$backup_ver" != "$current_ver" ]]; then - # Use dpkg --compare-versions for proper version comparison - if dpkg --compare-versions "$current_ver" gt "$backup_ver" 2>/dev/null; then - upgraded_packages+=("$pkg_name|$backup_ver|$current_ver") - else - downgraded_packages+=("$pkg_name|$backup_ver|$current_ver") - fi - fi - else - # Package in backup but not in current - removed_packages+=("$pkg_name|${backup_packages[$pkg_name]}") - fi - done - - # Check for added packages - for pkg_name in "${!current_packages[@]}"; do - if [[ -z "${backup_packages[$pkg_name]:-}" ]]; then - added_packages+=("$pkg_name|${current_packages[$pkg_name]}") - fi - done - - # Return differences as JSON - local diff_file - diff_file=$(mktemp) - - cat > "$diff_file" < 0))'), - "downgraded": $(printf '%s\n' "${downgraded_packages[@]}" | jq -R -s -c 'split("\n") | map(select(length > 0))'), - "added": $(printf '%s\n' "${added_packages[@]}" | jq -R -s -c 'split("\n") | map(select(length > 0))'), - "removed": $(printf '%s\n' "${removed_packages[@]}" | jq -R -s -c 'split("\n") | map(select(length > 0))') -} -EOF - - echo "$diff_file" -} - -# Display package differences -show_differences() { - local diff_file="$1" - - echo "" - echo "=== Package State Differences ===" - echo "" - - # Upgraded packages - local upgraded_count - upgraded_count=$(jq -r '.upgraded | length' "$diff_file") - if [[ $upgraded_count -gt 0 ]]; then - echo "[i] Upgraded Packages ($upgraded_count):" - jq -r '.upgraded[]' "$diff_file" | while IFS='|' read -r pkg_name old_ver new_ver; do - echo " $pkg_name: $old_ver -> $new_ver" - done - echo "" - fi - - # Downgraded packages - local downgraded_count - downgraded_count=$(jq -r '.downgraded | length' "$diff_file") - if [[ $downgraded_count -gt 0 ]]; then - echo "[!] Downgraded Packages ($downgraded_count):" - jq -r '.downgraded[]' "$diff_file" | while IFS='|' read -r pkg_name old_ver new_ver; do - echo " $pkg_name: $old_ver -> $new_ver" - done - echo "" - fi - - # Added packages - local added_count - added_count=$(jq -r '.added | length' "$diff_file") - if [[ $added_count -gt 0 ]]; then - echo "[+] Added Packages ($added_count):" - jq -r '.added[]' "$diff_file" | while IFS='|' read -r pkg_name pkg_version; do - echo " $pkg_name v$pkg_version" - done - echo "" - fi - - # Removed packages - local removed_count - removed_count=$(jq -r '.removed | length' "$diff_file") - if [[ $removed_count -gt 0 ]]; then - echo "[-] Removed Packages ($removed_count):" - jq -r '.removed[]' "$diff_file" | while IFS='|' read -r pkg_name pkg_version; do - echo " $pkg_name v$pkg_version" - done - echo "" - fi - - # Summary - local total_changes=$((upgraded_count + downgraded_count + added_count + removed_count)) - if [[ $total_changes -eq 0 ]]; then - log_success "No package changes detected" - else - log_info "Total changes: $total_changes packages" - fi - - echo "================================" - echo "" -} - -# ============================================================================== -# RESTORE FUNCTIONS -# ============================================================================== - -# Restore packages to backup state -restore_packages() { - local diff_file="$1" - - log_info "Starting package restore process..." - - local restored_count=0 - local failed_count=0 - - # Downgrade upgraded packages - local upgraded_count - upgraded_count=$(jq -r '.upgraded | length' "$diff_file") - if [[ $upgraded_count -gt 0 ]]; then - log_info "Downgrading $upgraded_count upgraded packages..." - - jq -r '.upgraded[]' "$diff_file" | while IFS='|' read -r pkg_name old_ver new_ver; do - if [[ "$WHATIF_MODE" == true ]]; then - log_info "[WHATIF] Would downgrade $pkg_name from $new_ver to $old_ver" - continue - fi - - log_info "Downgrading $pkg_name to version $old_ver..." - if DEBIAN_FRONTEND=noninteractive apt install -y --allow-downgrades "${pkg_name}=${old_ver}" &>/dev/null; then - log_success "Successfully downgraded $pkg_name" - ((restored_count++)) - else - log_error "Failed to downgrade $pkg_name" - ((failed_count++)) - fi - done - fi - - # Reinstall removed packages - local removed_count - removed_count=$(jq -r '.removed | length' "$diff_file") - if [[ $removed_count -gt 0 ]]; then - log_info "Reinstalling $removed_count removed packages..." - - jq -r '.removed[]' "$diff_file" | while IFS='|' read -r pkg_name pkg_version; do - if [[ "$WHATIF_MODE" == true ]]; then - log_info "[WHATIF] Would install $pkg_name version $pkg_version" - continue - fi - - log_info "Installing $pkg_name version $pkg_version..." - if DEBIAN_FRONTEND=noninteractive apt install -y "${pkg_name}=${pkg_version}" &>/dev/null; then - log_success "Successfully installed $pkg_name" - ((restored_count++)) - else - log_error "Failed to install $pkg_name" - ((failed_count++)) - fi - done - fi - - # Report summary - echo "" - echo "=== Restore Summary ===" - log_success "Successfully restored: $restored_count" - if [[ $failed_count -gt 0 ]]; then - log_error "Failed to restore: $failed_count" - fi - echo "=======================" - echo "" -} - -# ============================================================================== -# HELP FUNCTION -# ============================================================================== - -show_help() { - cat <= - - Snap downgrades use: snap revert or snap install --channel - - Some packages may not be easily reversible - - Kernel packages should not be downgraded - -VERSION: - $SCRIPT_VERSION - -EOF -} - -# ============================================================================== -# ARGUMENT PARSING -# ============================================================================== - -parse_arguments() { - while [[ $# -gt 0 ]]; do - case $1 in - --list) - LIST_MODE=true - shift - ;; - --latest) - LATEST_MODE=true - shift - ;; - --backup-file) - BACKUP_FILE="$2" - shift 2 - ;; - --show-diff) - SHOW_DIFF_MODE=true - shift - ;; - --whatif) - WHATIF_MODE=true - shift - ;; - --help) - show_help - exit 0 - ;; - *) - log_error "Unknown option: $1" - show_help - exit 1 - ;; - esac - done -} - -# ============================================================================== -# MAIN EXECUTION -# ============================================================================== - -main() { - parse_arguments "$@" - - log_info "=== Package State Restore Tool ===" - - # Handle list mode - if [[ "$LIST_MODE" == true ]]; then - list_backup_files - exit 0 - fi - - # Require root for restore operations - check_root - check_dependencies - - # Determine which backup file to use - local backup_path="" - - if [[ "$LATEST_MODE" == true ]]; then - backup_path=$(get_latest_backup) - if [[ -z "$backup_path" ]]; then - exit 1 - fi - log_info "Using latest backup: $(basename "$backup_path")" - elif [[ -n "$BACKUP_FILE" ]]; then - backup_path="$BACKUP_FILE" - if [[ ! -f "$backup_path" ]]; then - log_error "Backup file not found: $backup_path" - exit 1 - fi - else - log_error "Please specify --list, --latest, or --backup-file" - log_info "Usage: $SCRIPT_NAME --latest --show-diff" - exit 1 - fi - - # Load backup state - log_info "Loading backup state from: $backup_path" - - # Get current state - local current_state_file - current_state_file=$(get_current_state) - - # Compare states - local diff_file - diff_file=$(compare_states "$backup_path" "$current_state_file") - - # Show differences - show_differences "$diff_file" - - # Cleanup temp file - rm -f "$current_state_file" - - # If show-diff only, exit here - if [[ "$SHOW_DIFF_MODE" == true ]]; then - log_info "Showing differences only (no changes made)" - rm -f "$diff_file" - exit 0 - fi - - # Check if restore is needed - local total_changes - total_changes=$(jq -r '[.upgraded, .removed] | map(length) | add' "$diff_file") - - if [[ $total_changes -eq 0 ]]; then - log_success "No restore needed - system is already in backup state" - rm -f "$diff_file" - exit 0 - fi - - # Confirm restore - if [[ "$WHATIF_MODE" == false ]]; then - log_warning "This will attempt to restore $total_changes package(s) to their previous state" - log_warning "Some operations may require internet connectivity and can take time" - read -rp "Do you want to proceed? (yes/no): " confirm - - if [[ "$confirm" != "yes" ]]; then - log_info "Restore cancelled by user" - rm -f "$diff_file" - exit 0 - fi - fi - - # Perform restore - restore_packages "$diff_file" - - # Cleanup - rm -f "$diff_file" - - log_success "Restore process completed" -} - -# Run main function -main "$@" diff --git a/Linux/maintenance/system-update.sh b/Linux/maintenance/system-update.sh deleted file mode 100755 index c5ee3e0..0000000 --- a/Linux/maintenance/system-update.sh +++ /dev/null @@ -1,628 +0,0 @@ -#!/usr/bin/env bash - -# ============================================================================== -# Linux System Update Script with Rollback Support -# ============================================================================== -# -# DESCRIPTION: -# Automated system update script for Debian/Ubuntu with APT and Snap support. -# Provides rollback capability via filesystem snapshots. -# -# FEATURES: -# - APT package updates with security focus -# - Snap package updates -# - Pre-update state backup for rollback -# - Prometheus metrics export -# - Configurable via JSON config file -# - WhatIf mode (dry-run) -# - Log retention management -# - Update summary with duration tracking -# -# USAGE: -# sudo ./system-updates.sh [OPTIONS] -# -# OPTIONS: -# --skip-apt Skip APT package updates -# --skip-snap Skip Snap package updates -# --auto-reboot Automatically reboot if required -# --config FILE Path to JSON configuration file -# --whatif Dry-run mode (show what would be done) -# --help Show this help message -# -# EXAMPLES: -# sudo ./system-updates.sh -# sudo ./system-updates.sh --skip-snap --auto-reboot -# sudo ./system-updates.sh --whatif -# sudo ./system-updates.sh --config /etc/system-updates.json -# -# REQUIREMENTS: -# - Bash 4.0+ -# - Root/sudo privileges -# - apt, snap (optional), jq (for JSON parsing) -# -# AUTHOR: -# Windows & Linux Sysadmin Toolkit -# -# VERSION: -# 1.0.0 -# -# CHANGELOG: -# 1.0.0 - 2025-10-15 -# - Initial release -# - APT and Snap update automation -# - Pre-update state export -# - Prometheus metrics support -# - Configuration file support -# - WhatIf mode -# -# ============================================================================== - -set -euo pipefail - -# ============================================================================== -# GLOBAL VARIABLES -# ============================================================================== - -SCRIPT_VERSION="1.0.0" -SCRIPT_NAME="$(basename "$0")" -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - -# Default configuration -LOG_DIR="${LOG_DIR:-/var/log/system-updates}" -LOG_FILE="${LOG_DIR}/system-updates_$(date +%Y-%m-%d).log" -STATE_DIR="${LOG_DIR}/states" -METRICS_DIR="${LOG_DIR}/metrics" -CONFIG_FILE="${SCRIPT_DIR}/config.json" - -# Configuration variables (can be overridden by config file) -SKIP_APT=false -SKIP_SNAP=false -AUTO_REBOOT=false -LOG_RETENTION_DAYS=30 -WHATIF_MODE=false -EXPORT_METRICS=true - -# Runtime variables -START_TIME=$(date +%s) -UPDATE_SUMMARY_APT_UPDATED=0 -UPDATE_SUMMARY_APT_FAILED=0 -UPDATE_SUMMARY_SNAP_UPDATED=0 -UPDATE_SUMMARY_SNAP_FAILED=0 -REBOOT_REQUIRED=false -STATE_FILE="" - -# ============================================================================== -# HELPER FUNCTIONS -# ============================================================================== - -# Print messages with ASCII markers -log_info() { - local msg="$1" - echo "[i] $msg" | tee -a "$LOG_FILE" -} - -log_success() { - local msg="$1" - echo "[+] $msg" | tee -a "$LOG_FILE" -} - -log_warning() { - local msg="$1" - echo "[!] $msg" | tee -a "$LOG_FILE" >&2 -} - -log_error() { - local msg="$1" - echo "[-] $msg" | tee -a "$LOG_FILE" >&2 -} - -# Check if running as root -check_root() { - if [[ $EUID -ne 0 ]]; then - log_error "This script must be run as root or with sudo" - exit 1 - fi - log_success "Running with root privileges" -} - -# Check required commands -check_dependencies() { - local missing_deps=() - - if ! command -v apt &>/dev/null; then - missing_deps+=("apt") - fi - - if ! command -v jq &>/dev/null; then - log_warning "jq not found - JSON config parsing will be limited" - fi - - if [[ ${#missing_deps[@]} -gt 0 ]]; then - log_error "Missing required dependencies: ${missing_deps[*]}" - exit 1 - fi -} - -# Initialize directories -init_directories() { - mkdir -p "$LOG_DIR" "$STATE_DIR" "$METRICS_DIR" - log_info "Initialized directories: $LOG_DIR, $STATE_DIR, $METRICS_DIR" -} - -# Load configuration from JSON file -load_config() { - if [[ ! -f "$CONFIG_FILE" ]]; then - log_info "No configuration file found at $CONFIG_FILE, using defaults" - return - fi - - if ! command -v jq &>/dev/null; then - log_warning "jq not available, cannot parse JSON config file" - return - fi - - log_info "Loading configuration from: $CONFIG_FILE" - - # Parse JSON config (only if parameters not explicitly set) - if [[ "${SKIP_APT_SET:-false}" == "false" ]]; then - SKIP_APT=$(jq -r '.SkipAPT // false' "$CONFIG_FILE") - fi - if [[ "${SKIP_SNAP_SET:-false}" == "false" ]]; then - SKIP_SNAP=$(jq -r '.SkipSnap // false' "$CONFIG_FILE") - fi - if [[ "${AUTO_REBOOT_SET:-false}" == "false" ]]; then - AUTO_REBOOT=$(jq -r '.AutoReboot // false' "$CONFIG_FILE") - fi - - LOG_RETENTION_DAYS=$(jq -r '.LogRetentionDays // 30' "$CONFIG_FILE") - EXPORT_METRICS=$(jq -r '.ExportMetrics // true' "$CONFIG_FILE") - - log_success "Configuration loaded successfully" -} - -# Export pre-update state for rollback -export_preupdate_state() { - STATE_FILE="${STATE_DIR}/pre-update-state_$(date +%Y-%m-%d_%H-%M-%S).json" - - if [[ "$WHATIF_MODE" == true ]]; then - log_info "[WHATIF] Would export pre-update state to: $STATE_FILE" - return - fi - - log_info "Exporting pre-update state..." - - local apt_packages="" - local snap_packages="" - - # Export APT packages - if command -v apt &>/dev/null; then - apt_packages=$(dpkg-query -W -f='${Package}\t${Version}\n' 2>/dev/null || echo "") - fi - - # Export Snap packages - if command -v snap &>/dev/null; then - snap_packages=$(snap list 2>/dev/null || echo "") - fi - - # Create JSON state file - cat > "$STATE_FILE" < 0))'), - "snap_packages": $(echo "$snap_packages" | jq -R -s -c 'split("\n") | map(select(length > 0))') -} -EOF - - log_success "Pre-update state exported to: $STATE_FILE" -} - -# Check if reboot is required -check_reboot_required() { - if [[ -f /var/run/reboot-required ]]; then - log_warning "System reboot is required" - REBOOT_REQUIRED=true - - if [[ -f /var/run/reboot-required.pkgs ]]; then - log_info "Packages requiring reboot:" - cat /var/run/reboot-required.pkgs | tee -a "$LOG_FILE" - fi - - return 0 - fi - return 1 -} - -# Handle reboot -handle_reboot() { - if [[ "$REBOOT_REQUIRED" == false ]]; then - return - fi - - if [[ "$AUTO_REBOOT" == true ]]; then - if [[ "$WHATIF_MODE" == true ]]; then - log_warning "[WHATIF] Would reboot system in 60 seconds" - return - fi - - log_warning "System will reboot in 60 seconds. Press Ctrl+C to cancel." - sleep 60 - reboot - else - log_warning "A system reboot is recommended to complete updates" - log_info "Run 'sudo reboot' when ready" - fi -} - -# ============================================================================== -# UPDATE FUNCTIONS -# ============================================================================== - -# Update APT packages -update_apt() { - if [[ "$SKIP_APT" == true ]]; then - log_info "Skipping APT updates (disabled in configuration)" - return - fi - - log_info "=== Starting APT Updates ===" - - if [[ "$WHATIF_MODE" == true ]]; then - log_info "[WHATIF] Would update APT package lists" - apt list --upgradable 2>/dev/null | tee -a "$LOG_FILE" || true - return - fi - - # Update package lists - log_info "Updating APT package lists..." - if apt update 2>&1 | tee -a "$LOG_FILE"; then - log_success "APT package lists updated" - else - log_error "Failed to update APT package lists" - UPDATE_SUMMARY_APT_FAILED=1 - return - fi - - # Check for upgradable packages - local upgradable_count - upgradable_count=$(apt list --upgradable 2>/dev/null | grep -c upgradable || echo "0") - - if [[ "$upgradable_count" -eq 0 ]]; then - log_success "No APT updates available" - return - fi - - log_info "Found $upgradable_count upgradable APT packages" - UPDATE_SUMMARY_APT_UPDATED=$upgradable_count - - # Perform upgrade - log_info "Upgrading APT packages..." - if DEBIAN_FRONTEND=noninteractive apt upgrade -y 2>&1 | tee -a "$LOG_FILE"; then - log_success "APT packages upgraded successfully" - else - log_error "Failed to upgrade APT packages" - UPDATE_SUMMARY_APT_FAILED=1 - return - fi - - # Autoremove unused packages - log_info "Removing unused packages..." - if DEBIAN_FRONTEND=noninteractive apt autoremove -y 2>&1 | tee -a "$LOG_FILE"; then - log_success "Unused packages removed" - else - log_warning "Failed to remove some unused packages" - fi - - # Clean package cache - log_info "Cleaning package cache..." - if apt clean 2>&1 | tee -a "$LOG_FILE"; then - log_success "Package cache cleaned" - else - log_warning "Failed to clean package cache" - fi -} - -# Update Snap packages -update_snap() { - if [[ "$SKIP_SNAP" == true ]]; then - log_info "Skipping Snap updates (disabled in configuration)" - return - fi - - if ! command -v snap &>/dev/null; then - log_info "Snap is not installed, skipping" - return - fi - - log_info "=== Starting Snap Updates ===" - - if [[ "$WHATIF_MODE" == true ]]; then - log_info "[WHATIF] Would refresh all Snap packages" - snap refresh --list 2>&1 | tee -a "$LOG_FILE" || true - return - fi - - # List pending updates - local snap_pending - snap_pending=$(snap refresh --list 2>&1 || echo "") - - if echo "$snap_pending" | grep -q "All snaps up to date"; then - log_success "No Snap updates available" - return - fi - - # Count pending updates - UPDATE_SUMMARY_SNAP_UPDATED=$(echo "$snap_pending" | grep -c '^[^ ]' || echo "0") - log_info "Found $UPDATE_SUMMARY_SNAP_UPDATED Snap packages to update" - - # Perform refresh - log_info "Refreshing Snap packages..." - if snap refresh 2>&1 | tee -a "$LOG_FILE"; then - log_success "Snap packages refreshed successfully" - else - log_error "Failed to refresh Snap packages" - UPDATE_SUMMARY_SNAP_FAILED=1 - fi -} - -# ============================================================================== -# MAINTENANCE FUNCTIONS -# ============================================================================== - -# Remove old logs -cleanup_old_logs() { - log_info "Cleaning up old log files (older than $LOG_RETENTION_DAYS days)..." - - if [[ "$WHATIF_MODE" == true ]]; then - local old_logs_count - old_logs_count=$(find "$LOG_DIR" -name "*.log" -type f -mtime +"$LOG_RETENTION_DAYS" | wc -l) - log_info "[WHATIF] Would remove $old_logs_count old log files" - return - fi - - local removed_count=0 - while IFS= read -r -d '' log_file; do - rm -f "$log_file" - ((removed_count++)) - done < <(find "$LOG_DIR" -name "*.log" -type f -mtime +"$LOG_RETENTION_DAYS" -print0) - - if [[ $removed_count -gt 0 ]]; then - log_success "Removed $removed_count old log files" - else - log_info "No old log files to remove" - fi -} - -# Export Prometheus metrics -export_prometheus_metrics() { - if [[ "$EXPORT_METRICS" == false ]]; then - return - fi - - local metrics_file="${METRICS_DIR}/system_updates.prom" - local end_time=$(date +%s) - local duration=$((end_time - START_TIME)) - - if [[ "$WHATIF_MODE" == true ]]; then - log_info "[WHATIF] Would export Prometheus metrics to: $metrics_file" - return - fi - - log_info "Exporting Prometheus metrics..." - - cat > "$metrics_file" < **Scope note (2026-06-14):** the `service-health-monitor.sh` script was removed in the ghost-code cull — Prometheus + node-exporter + Grafana already covers service health on q-lab. The dashboards below are kept because they remain useful for the GPU exporter (and as references for Kubernetes/Docker views populated by other tooling on q-lab). ## Dashboards -| Dashboard | Purpose | Source Script | -|-----------|---------|---------------| -| [grafana-dashboard-gpu.json](grafana-dashboard-gpu.json) | NVIDIA GPU metrics | nvidia-gpu-exporter.sh | -| [grafana-dashboard-kubernetes.json](grafana-dashboard-kubernetes.json) | K8s pod health | pod-health-monitor.sh | -| [grafana-dashboard-maintenance.json](grafana-dashboard-maintenance.json) | Docker/log cleanup | docker-cleanup.sh | +| Dashboard | Purpose | Data source | +|-----------|---------|-------------| +| [grafana-dashboard-gpu.json](grafana-dashboard-gpu.json) | NVIDIA GPU metrics | `Linux/gpu/nvidia-gpu-exporter.sh` (scraped via node-exporter textfile collector) | +| [grafana-dashboard-kubernetes.json](grafana-dashboard-kubernetes.json) | K8s pod health (reference) | kube-state-metrics on q-lab | +| [grafana-dashboard-maintenance.json](grafana-dashboard-maintenance.json) | Docker/log cleanup (reference) | Existing q-lab exporters | -## Import Dashboards +## Import a Dashboard ### Grafana UI -1. Open Grafana → **Dashboards** → **Import** -2. Upload JSON file -3. Select Prometheus data source +1. Open Grafana -> **Dashboards** -> **Import** +2. Upload the JSON file +3. Select your Prometheus data source 4. Click **Import** -### API Import +### API + ```bash GRAFANA_URL="http://your-grafana:3000" API_KEY="your-api-key" @@ -29,9 +32,8 @@ curl -X POST "$GRAFANA_URL/api/dashboards/db" \ -d @grafana-dashboard-gpu.json ``` -## Key Metrics +## Key GPU Metrics -### GPU Metrics | Metric | Description | |--------|-------------| | nvidia_gpu_utilization_percent | GPU usage % | @@ -39,52 +41,11 @@ curl -X POST "$GRAFANA_URL/api/dashboards/db" \ | nvidia_gpu_memory_used_bytes | Memory used | | nvidia_gpu_power_watts | Power draw | -### Kubernetes Metrics -| Metric | Description | -|--------|-------------| -| k8s_unhealthy_pods_total | Unhealthy pods | -| k8s_crashloop_pods_total | CrashLoopBackOff pods | -| k8s_oomkilled_pods_total | OOM killed pods | -| k8s_pending_pods_total | Pending pods | - -### Maintenance Metrics -| Metric | Description | -|--------|-------------| -| docker_cleanup_images_removed_total | Images removed | -| docker_cleanup_space_reclaimed_bytes | Space freed | -| log_cleanup_logs_deleted_total | Logs deleted | - -## Prometheus Setup +## Verify -```yaml -scrape_configs: - - job_name: 'node-exporter' - static_configs: - - targets: ['10.143.31.18:9100'] -``` - -## Troubleshooting - -| Issue | Solution | -|-------|----------| -| No data | Verify Prometheus data source connection | -| Metrics missing | Check scripts are running: `crontab -l` | -| Stale data | Check script logs in /var/log/ | - -### Verify Metrics ```bash -# Check node-exporter curl http://10.143.31.18:9100/metrics | grep nvidia_gpu - -# Check Prometheus targets -curl http://prometheus:9090/api/v1/targets ``` -## Prerequisites - -- Grafana with Prometheus data source -- Prometheus scraping node-exporter -- Monitoring scripts running via cron - --- -**Last Updated**: 2025-12-26 +**Last Updated**: 2026-06-14 diff --git a/Linux/monitoring/service-health-monitor.sh b/Linux/monitoring/service-health-monitor.sh deleted file mode 100644 index 2f00f12..0000000 --- a/Linux/monitoring/service-health-monitor.sh +++ /dev/null @@ -1,490 +0,0 @@ -#!/usr/bin/env bash -# ============================================================================ -# Service Health Monitor for Linux -# Monitors critical services, auto-restarts failed services, sends alerts -# ============================================================================ -# -# Usage: -# ./service-health-monitor.sh [OPTIONS] -# -# Options: -# --services Comma-separated list of services to monitor -# --config Load services from JSON config file -# --auto-restart Automatically restart failed services -# --max-restarts Maximum restart attempts per service [default: 3] -# --interval Check interval for daemon mode [default: 60] -# --daemon Run in continuous monitoring mode -# --alert Alert method: log, email, slack, prometheus -# --prometheus Export metrics to Prometheus file -# --verbose Enable verbose output -# --help Show this help message -# -# Examples: -# # Check specific services -# ./service-health-monitor.sh --services docker,nginx,sshd -# -# # Run as daemon with auto-restart -# ./service-health-monitor.sh --daemon --auto-restart --services docker,k3s -# -# # Use config file with Prometheus export -# ./service-health-monitor.sh --config services.json --prometheus /var/lib/node_exporter/services.prom -# -# ============================================================================ - -set -euo pipefail - -# Script metadata -SCRIPT_NAME="service-health-monitor" -SCRIPT_VERSION="1.0.0" -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - -# Source common functions -COMMON_FUNCTIONS="${SCRIPT_DIR}/../lib/bash/common-functions.sh" -if [[ -f "$COMMON_FUNCTIONS" ]]; then - # shellcheck source=../lib/bash/common-functions.sh - source "$COMMON_FUNCTIONS" -else - echo "[-] Common functions library not found: $COMMON_FUNCTIONS" - exit 1 -fi - -# ============================================================================ -# CONFIGURATION -# ============================================================================ - -# Default services to monitor (can be overridden) -DEFAULT_SERVICES=("sshd" "docker" "cron") - -# User-provided services -declare -a SERVICES=() - -# Settings -AUTO_RESTART=false -MAX_RESTARTS=3 -CHECK_INTERVAL=60 -DAEMON_MODE=false -ALERT_METHOD="log" -PROMETHEUS_FILE="" -CONFIG_FILE="" -VERBOSE=false - -# Tracking restart attempts -declare -A RESTART_COUNTS - -# ============================================================================ -# HELPER FUNCTIONS -# ============================================================================ - -show_help() { - head -35 "$0" | tail -30 | sed 's/^# //' | sed 's/^#//' - exit 0 -} - -parse_args() { - while [[ $# -gt 0 ]]; do - case $1 in - --services) - IFS=',' read -ra SERVICES <<< "$2" - shift 2 - ;; - --config) - CONFIG_FILE="$2" - shift 2 - ;; - --auto-restart) - AUTO_RESTART=true - shift - ;; - --max-restarts) - MAX_RESTARTS="$2" - shift 2 - ;; - --interval) - CHECK_INTERVAL="$2" - shift 2 - ;; - --daemon) - DAEMON_MODE=true - shift - ;; - --alert) - ALERT_METHOD="$2" - shift 2 - ;; - --prometheus) - PROMETHEUS_FILE="$2" - shift 2 - ;; - --verbose|-v) - VERBOSE=true - DEBUG=1 - shift - ;; - --help|-h) - show_help - ;; - *) - log_error "Unknown option: $1" - exit 1 - ;; - esac - done -} - -load_config() { - local config_file="$1" - - if [[ ! -f "$config_file" ]]; then - log_error "Config file not found: $config_file" - exit 1 - fi - - check_command jq - - # Load services from JSON array - if jq -e '.services' "$config_file" &>/dev/null; then - while IFS= read -r service; do - SERVICES+=("$service") - done < <(jq -r '.services[]' "$config_file") - log_info "Loaded ${#SERVICES[@]} services from config" - fi - - # Load other settings if present - if jq -e '.auto_restart' "$config_file" &>/dev/null; then - AUTO_RESTART=$(jq -r '.auto_restart' "$config_file") - fi - if jq -e '.max_restarts' "$config_file" &>/dev/null; then - MAX_RESTARTS=$(jq -r '.max_restarts' "$config_file") - fi - if jq -e '.interval' "$config_file" &>/dev/null; then - CHECK_INTERVAL=$(jq -r '.interval' "$config_file") - fi -} - -# ============================================================================ -# SERVICE MONITORING FUNCTIONS -# ============================================================================ - -check_service_status() { - local service="$1" - local status="" - local active=false - local enabled=false - local memory_mb=0 - local uptime_seconds=0 - - # Check if service exists - if ! systemctl list-unit-files "${service}.service" &>/dev/null && \ - ! systemctl list-unit-files "${service}" &>/dev/null; then - # Try common service name variations - if systemctl list-unit-files "${service}d.service" &>/dev/null; then - service="${service}d" - fi - fi - - # Get service status - if systemctl is-active --quiet "$service" 2>/dev/null; then - active=true - status="running" - elif systemctl is-failed --quiet "$service" 2>/dev/null; then - status="failed" - else - status="stopped" - fi - - # Check if enabled - if systemctl is-enabled --quiet "$service" 2>/dev/null; then - enabled=true - fi - - # Get memory usage if active - if [[ "$active" == true ]]; then - memory_mb=$(systemctl show "$service" --property=MemoryCurrent 2>/dev/null | \ - cut -d= -f2 | awk '{print int($1/1024/1024)}') || memory_mb=0 - - # Get uptime (time since last start) - local active_since - active_since=$(systemctl show "$service" --property=ActiveEnterTimestamp 2>/dev/null | \ - cut -d= -f2) - if [[ -n "$active_since" && "$active_since" != "" ]]; then - local start_epoch - start_epoch=$(date -d "$active_since" +%s 2>/dev/null || echo 0) - local now_epoch - now_epoch=$(date +%s) - uptime_seconds=$((now_epoch - start_epoch)) - fi - fi - - # Output as JSON-like format for parsing - echo "{\"service\":\"$service\",\"active\":$active,\"status\":\"$status\",\"enabled\":$enabled,\"memory_mb\":$memory_mb,\"uptime_seconds\":$uptime_seconds}" -} - -format_uptime() { - local seconds="$1" - local days=$((seconds / 86400)) - local hours=$(((seconds % 86400) / 3600)) - local minutes=$(((seconds % 3600) / 60)) - - if [[ $days -gt 0 ]]; then - echo "${days}d ${hours}h ${minutes}m" - elif [[ $hours -gt 0 ]]; then - echo "${hours}h ${minutes}m" - else - echo "${minutes}m" - fi -} - -restart_service() { - local service="$1" - local count="${RESTART_COUNTS[$service]:-0}" - - if [[ $count -ge $MAX_RESTARTS ]]; then - log_error "Service $service has exceeded max restart attempts ($MAX_RESTARTS)" - send_alert "CRITICAL" "$service has failed $count times and will not be restarted automatically" - return 1 - fi - - log_warning "Attempting to restart $service (attempt $((count + 1))/$MAX_RESTARTS)" - - if systemctl restart "$service" 2>/dev/null; then - RESTART_COUNTS[$service]=$((count + 1)) - sleep 2 # Wait for service to stabilize - - if systemctl is-active --quiet "$service" 2>/dev/null; then - log_success "Service $service restarted successfully" - send_alert "INFO" "$service was restarted successfully" - return 0 - else - log_error "Service $service failed to start after restart" - return 1 - fi - else - log_error "Failed to restart $service" - RESTART_COUNTS[$service]=$((count + 1)) - return 1 - fi -} - -# ============================================================================ -# ALERTING FUNCTIONS -# ============================================================================ - -send_alert() { - local severity="$1" - local message="$2" - local timestamp - timestamp=$(date '+%Y-%m-%d %H:%M:%S') - - case "$ALERT_METHOD" in - log) - log_warning "[$severity] $message" - ;; - email) - # Requires mail command configured - if command -v mail &>/dev/null; then - echo "[$timestamp] [$severity] $message" | mail -s "Service Monitor Alert: $severity" root - fi - ;; - slack) - # Requires SLACK_WEBHOOK_URL environment variable - if [[ -n "${SLACK_WEBHOOK_URL:-}" ]]; then - curl -s -X POST -H 'Content-type: application/json' \ - --data "{\"text\":\"[$severity] Service Monitor: $message\"}" \ - "$SLACK_WEBHOOK_URL" &>/dev/null || true - fi - ;; - prometheus) - # Alerts handled via Prometheus metrics - ;; - esac -} - -# ============================================================================ -# PROMETHEUS EXPORT -# ============================================================================ - -export_prometheus_metrics() { - local services_data=("$@") - - if [[ -z "$PROMETHEUS_FILE" ]]; then - return - fi - - local temp_file="${PROMETHEUS_FILE}.tmp" - local metrics_dir - metrics_dir=$(dirname "$PROMETHEUS_FILE") - mkdir -p "$metrics_dir" - - { - echo "# HELP service_up Service status (1=running, 0=not running)" - echo "# TYPE service_up gauge" - echo "# HELP service_enabled Service enabled status (1=enabled, 0=disabled)" - echo "# TYPE service_enabled gauge" - echo "# HELP service_memory_bytes Service memory usage in bytes" - echo "# TYPE service_memory_bytes gauge" - echo "# HELP service_uptime_seconds Service uptime in seconds" - echo "# TYPE service_uptime_seconds gauge" - echo "# HELP service_restart_count Number of automatic restarts" - echo "# TYPE service_restart_count counter" - - for data in "${services_data[@]}"; do - local service active enabled memory_mb uptime_seconds - service=$(echo "$data" | jq -r '.service') - active=$(echo "$data" | jq -r '.active') - enabled=$(echo "$data" | jq -r '.enabled') - memory_mb=$(echo "$data" | jq -r '.memory_mb') - uptime_seconds=$(echo "$data" | jq -r '.uptime_seconds') - - local up_value=0 - [[ "$active" == "true" ]] && up_value=1 - - local enabled_value=0 - [[ "$enabled" == "true" ]] && enabled_value=1 - - echo "service_up{service=\"$service\"} $up_value" - echo "service_enabled{service=\"$service\"} $enabled_value" - echo "service_memory_bytes{service=\"$service\"} $((memory_mb * 1024 * 1024))" - echo "service_uptime_seconds{service=\"$service\"} $uptime_seconds" - echo "service_restart_count{service=\"$service\"} ${RESTART_COUNTS[$service]:-0}" - done - } > "$temp_file" - - mv "$temp_file" "$PROMETHEUS_FILE" - log_debug "Prometheus metrics exported to $PROMETHEUS_FILE" -} - -# ============================================================================ -# DISPLAY FUNCTIONS -# ============================================================================ - -print_header() { - echo "" - echo "==============================================" - echo " Service Health Monitor" - echo "==============================================" - echo "" - log_info "Timestamp: $(date '+%Y-%m-%d %H:%M:%S')" - log_info "Hostname: $(hostname)" - log_info "Services: ${#SERVICES[@]}" - if [[ "$AUTO_RESTART" == true ]]; then - log_info "Auto-restart: enabled (max $MAX_RESTARTS attempts)" - fi - echo "" -} - -print_service_table() { - local services_data=("$@") - - printf "%-20s %-10s %-10s %-12s %-15s\n" "SERVICE" "STATUS" "ENABLED" "MEMORY" "UPTIME" - printf "%-20s %-10s %-10s %-12s %-15s\n" "-------" "------" "-------" "------" "------" - - local failed_count=0 - local running_count=0 - - for data in "${services_data[@]}"; do - local service status enabled memory_mb uptime_seconds active - service=$(echo "$data" | jq -r '.service') - status=$(echo "$data" | jq -r '.status') - enabled=$(echo "$data" | jq -r '.enabled') - memory_mb=$(echo "$data" | jq -r '.memory_mb') - uptime_seconds=$(echo "$data" | jq -r '.uptime_seconds') - active=$(echo "$data" | jq -r '.active') - - local enabled_str="no" - [[ "$enabled" == "true" ]] && enabled_str="yes" - - local memory_str="-" - [[ "$memory_mb" -gt 0 ]] && memory_str="${memory_mb} MB" - - local uptime_str="-" - [[ "$uptime_seconds" -gt 0 ]] && uptime_str=$(format_uptime "$uptime_seconds") - - local status_color="" - local status_reset="" - if [[ "$status" == "running" ]]; then - status_color="${COLOR_GREEN}" - status_reset="${COLOR_RESET}" - ((running_count++)) - elif [[ "$status" == "failed" ]]; then - status_color="${COLOR_RED}" - status_reset="${COLOR_RESET}" - ((failed_count++)) - else - status_color="${COLOR_YELLOW}" - status_reset="${COLOR_RESET}" - ((failed_count++)) - fi - - printf "%-20s ${status_color}%-10s${status_reset} %-10s %-12s %-15s\n" \ - "$service" "$status" "$enabled_str" "$memory_str" "$uptime_str" - done - - echo "" - log_info "Summary: $running_count running, $failed_count not running" -} - -# ============================================================================ -# MAIN MONITORING LOOP -# ============================================================================ - -check_all_services() { - local services_data=() - - for service in "${SERVICES[@]}"; do - local data - data=$(check_service_status "$service") - services_data+=("$data") - - # Check if failed and handle auto-restart - local status - status=$(echo "$data" | jq -r '.status') - if [[ "$status" != "running" && "$AUTO_RESTART" == true ]]; then - restart_service "$service" - elif [[ "$status" != "running" ]]; then - send_alert "WARNING" "Service $service is $status" - fi - done - - # Display results - print_header - print_service_table "${services_data[@]}" - - # Export metrics if configured - if [[ -n "$PROMETHEUS_FILE" ]]; then - export_prometheus_metrics "${services_data[@]}" - fi -} - -main() { - parse_args "$@" - - # Load services from config if specified - if [[ -n "$CONFIG_FILE" ]]; then - load_config "$CONFIG_FILE" - fi - - # Use default services if none specified - if [[ ${#SERVICES[@]} -eq 0 ]]; then - SERVICES=("${DEFAULT_SERVICES[@]}") - log_info "Using default services: ${SERVICES[*]}" - fi - - # Verify jq is available for JSON parsing - check_command jq - - if [[ "$DAEMON_MODE" == true ]]; then - log_info "Starting daemon mode (interval: ${CHECK_INTERVAL}s)" - log_info "Press Ctrl+C to stop" - - trap 'log_info "Stopping service monitor..."; exit 0' SIGINT SIGTERM - - while true; do - check_all_services - sleep "$CHECK_INTERVAL" - done - else - check_all_services - fi -} - -main "$@" diff --git a/Linux/security/README.md b/Linux/security/README.md deleted file mode 100644 index 33cdabf..0000000 --- a/Linux/security/README.md +++ /dev/null @@ -1,35 +0,0 @@ -# Security Hardening Scripts - -Linux security hardening and auditing. - -## Scripts - -| Script | Purpose | -|--------|---------| -| [security-hardening.sh](security-hardening.sh) | SSH, firewall, kernel, and service hardening | - -## Quick Examples - -```bash -# Audit mode (no changes) -./security-hardening.sh --audit - -# Apply hardening -sudo ./security-hardening.sh --apply - -# Specific category -./security-hardening.sh --audit --category ssh -./security-hardening.sh --apply --category firewall -``` - -## Categories - -- **ssh**: Key-only auth, disable root, secure ciphers -- **firewall**: UFW with sensible defaults -- **kernel**: sysctl security parameters -- **permissions**: Sensitive file permissions, SUID/SGID audit -- **users**: Password policies, inactive accounts -- **services**: Disable risky services - ---- -**Last Updated**: 2025-12-26 diff --git a/Linux/security/security-hardening.sh b/Linux/security/security-hardening.sh deleted file mode 100644 index 47a26c7..0000000 --- a/Linux/security/security-hardening.sh +++ /dev/null @@ -1,820 +0,0 @@ -#!/usr/bin/env bash -# ============================================================================ -# Linux Security Hardening Script -# Implements security best practices based on CIS Benchmarks and DISA STIG -# Supports Ubuntu 22.04+ and Debian 12+ -# ============================================================================ -# -# Usage: -# sudo ./security-hardening.sh [OPTIONS] -# -# Options: -# --audit Audit mode - report issues without making changes -# --apply Apply recommended hardening (requires confirmation) -# --auto Apply hardening without prompts (use with caution) -# --level <1|2> Hardening level (1=basic, 2=strict) [default: 1] -# --skip-ssh Skip SSH hardening -# --skip-firewall Skip firewall configuration -# --skip-kernel Skip kernel hardening -# --report Save audit report to file -# --verbose Enable verbose output -# --help Show this help message -# -# Hardening Categories: -# - SSH Configuration (key-only auth, disable root login) -# - Firewall Setup (UFW with sensible defaults) -# - Kernel Hardening (sysctl security parameters) -# - File Permissions (sensitive files, SUID/SGID audit) -# - User Security (password policies, inactive accounts) -# - Service Hardening (disable unnecessary services) -# - Audit Logging (auditd configuration) -# - Automatic Security Updates -# -# ============================================================================ - -set -euo pipefail - -# Script metadata -SCRIPT_NAME="security-hardening" -SCRIPT_VERSION="1.0.0" -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - -# Source common functions -COMMON_FUNCTIONS="${SCRIPT_DIR}/../lib/bash/common-functions.sh" -if [[ -f "$COMMON_FUNCTIONS" ]]; then - # shellcheck source=../lib/bash/common-functions.sh - source "$COMMON_FUNCTIONS" -else - echo "[-] Common functions library not found: $COMMON_FUNCTIONS" - exit 1 -fi - -# ============================================================================ -# CONFIGURATION -# ============================================================================ - -# Default settings -MODE="audit" # audit, apply, or auto -HARDENING_LEVEL=1 # 1=basic, 2=strict -SKIP_SSH=false -SKIP_FIREWALL=false -SKIP_KERNEL=false -REPORT_FILE="" -VERBOSE=false - -# Counters -ISSUES_FOUND=0 -ISSUES_FIXED=0 -WARNINGS=0 - -# Backup directory -BACKUP_DIR="/var/backup/security-hardening/$(date +%Y%m%d-%H%M%S)" - -# ============================================================================ -# HELPER FUNCTIONS -# ============================================================================ - -show_help() { - head -45 "$0" | tail -40 | sed 's/^# //' | sed 's/^#//' - exit 0 -} - -backup_file() { - local file="$1" - if [[ -f "$file" ]]; then - mkdir -p "$BACKUP_DIR" - cp -p "$file" "$BACKUP_DIR/$(basename "$file").bak" - log_debug "Backed up: $file" - fi -} - -report_issue() { - local category="$1" - local description="$2" - local severity="${3:-MEDIUM}" - - ((ISSUES_FOUND++)) - log_warning "[$severity] $category: $description" - - if [[ -n "$REPORT_FILE" ]]; then - echo "[$severity] $category: $description" >> "$REPORT_FILE" - fi -} - -report_pass() { - local category="$1" - local description="$2" - - log_success "$category: $description" - - if [[ -n "$REPORT_FILE" ]]; then - echo "[PASS] $category: $description" >> "$REPORT_FILE" - fi -} - -confirm_action() { - local prompt="$1" - if [[ "$MODE" == "auto" ]]; then - return 0 - fi - - read -r -p "$prompt [y/N] " response - case "$response" in - [yY][eE][sS]|[yY]) - return 0 - ;; - *) - return 1 - ;; - esac -} - -# ============================================================================ -# SSH HARDENING -# ============================================================================ - -audit_ssh() { - log_info "Auditing SSH configuration..." - local sshd_config="/etc/ssh/sshd_config" - - if [[ ! -f "$sshd_config" ]]; then - log_warning "SSH server not installed - skipping SSH audit" - return 0 - fi - - # Check PermitRootLogin - if grep -qE "^\s*PermitRootLogin\s+(yes|without-password)" "$sshd_config" 2>/dev/null; then - report_issue "SSH" "Root login is permitted" "HIGH" - elif grep -qE "^\s*PermitRootLogin\s+no" "$sshd_config" 2>/dev/null; then - report_pass "SSH" "Root login disabled" - else - report_issue "SSH" "PermitRootLogin not explicitly set (defaults may allow)" "MEDIUM" - fi - - # Check PasswordAuthentication - if grep -qE "^\s*PasswordAuthentication\s+yes" "$sshd_config" 2>/dev/null; then - report_issue "SSH" "Password authentication enabled (key-only recommended)" "MEDIUM" - elif grep -qE "^\s*PasswordAuthentication\s+no" "$sshd_config" 2>/dev/null; then - report_pass "SSH" "Password authentication disabled" - fi - - # Check Protocol (for older systems) - if grep -qE "^\s*Protocol\s+1" "$sshd_config" 2>/dev/null; then - report_issue "SSH" "SSHv1 protocol enabled (insecure)" "CRITICAL" - fi - - # Check for weak ciphers - if grep -qE "^\s*Ciphers.*3des|arcfour|blowfish" "$sshd_config" 2>/dev/null; then - report_issue "SSH" "Weak ciphers configured" "HIGH" - fi - - # Check MaxAuthTries - local max_auth - max_auth=$(grep -E "^\s*MaxAuthTries" "$sshd_config" 2>/dev/null | awk '{print $2}') - if [[ -n "$max_auth" && "$max_auth" -gt 4 ]]; then - report_issue "SSH" "MaxAuthTries is too high ($max_auth, recommend 4)" "LOW" - elif [[ -n "$max_auth" ]]; then - report_pass "SSH" "MaxAuthTries set to $max_auth" - fi - - # Check X11Forwarding - if grep -qE "^\s*X11Forwarding\s+yes" "$sshd_config" 2>/dev/null; then - if [[ $HARDENING_LEVEL -ge 2 ]]; then - report_issue "SSH" "X11 forwarding enabled (disable for servers)" "LOW" - fi - fi - - # Check for empty passwords - if grep -qE "^\s*PermitEmptyPasswords\s+yes" "$sshd_config" 2>/dev/null; then - report_issue "SSH" "Empty passwords permitted" "CRITICAL" - else - report_pass "SSH" "Empty passwords not permitted" - fi -} - -harden_ssh() { - log_info "Applying SSH hardening..." - local sshd_config="/etc/ssh/sshd_config" - - if [[ ! -f "$sshd_config" ]]; then - log_warning "SSH server not installed - skipping" - return 0 - fi - - backup_file "$sshd_config" - - # Create hardened config drop-in - local hardened_conf="/etc/ssh/sshd_config.d/99-hardening.conf" - mkdir -p /etc/ssh/sshd_config.d - - cat > "$hardened_conf" << 'EOF' -# Security hardening configuration -# Generated by security-hardening.sh - -# Disable root login -PermitRootLogin no - -# Disable password authentication (use keys only) -PasswordAuthentication no - -# Disable empty passwords -PermitEmptyPasswords no - -# Limit authentication attempts -MaxAuthTries 4 - -# Set login grace time -LoginGraceTime 60 - -# Disable X11 forwarding (unless needed) -X11Forwarding no - -# Disable TCP forwarding (uncomment if not needed) -# AllowTcpForwarding no - -# Use only secure ciphers and MACs -Ciphers aes256-gcm@openssh.com,aes128-gcm@openssh.com,aes256-ctr,aes192-ctr,aes128-ctr -MACs hmac-sha2-512-etm@openssh.com,hmac-sha2-256-etm@openssh.com,hmac-sha2-512,hmac-sha2-256 -KexAlgorithms curve25519-sha256,curve25519-sha256@libssh.org,diffie-hellman-group16-sha512,diffie-hellman-group18-sha512 - -# Logging -LogLevel VERBOSE - -# Client alive settings (disconnect idle sessions) -ClientAliveInterval 300 -ClientAliveCountMax 2 -EOF - - log_success "SSH hardening configuration written to $hardened_conf" - ((ISSUES_FIXED++)) - - # Test and reload SSH - if sshd -t 2>/dev/null; then - systemctl reload sshd 2>/dev/null || systemctl reload ssh 2>/dev/null || true - log_success "SSH configuration reloaded" - else - log_error "SSH configuration test failed - reverting" - rm -f "$hardened_conf" - return 1 - fi -} - -# ============================================================================ -# FIREWALL CONFIGURATION -# ============================================================================ - -audit_firewall() { - log_info "Auditing firewall configuration..." - - # Check if UFW is installed and active - if command -v ufw &>/dev/null; then - local ufw_status - ufw_status=$(ufw status 2>/dev/null | head -1) - - if [[ "$ufw_status" == *"inactive"* ]]; then - report_issue "Firewall" "UFW is installed but inactive" "HIGH" - elif [[ "$ufw_status" == *"active"* ]]; then - report_pass "Firewall" "UFW is active" - - # Check default policies - if ufw status verbose 2>/dev/null | grep -q "Default: deny (incoming)"; then - report_pass "Firewall" "Default incoming policy is deny" - else - report_issue "Firewall" "Default incoming policy should be deny" "MEDIUM" - fi - fi - elif command -v iptables &>/dev/null; then - # Check iptables rules - local rule_count - rule_count=$(iptables -L INPUT -n 2>/dev/null | wc -l) - - if [[ $rule_count -le 2 ]]; then - report_issue "Firewall" "No iptables rules configured (open firewall)" "HIGH" - else - report_pass "Firewall" "iptables rules present ($((rule_count - 2)) rules)" - fi - else - report_issue "Firewall" "No firewall software detected" "HIGH" - fi - - # Check for open ports - if command -v ss &>/dev/null; then - local open_ports - open_ports=$(ss -tulpn 2>/dev/null | grep LISTEN | wc -l) - log_info "Found $open_ports listening services" - - # Check for potentially dangerous open ports - if ss -tulpn 2>/dev/null | grep -qE ":23\s"; then - report_issue "Firewall" "Telnet port (23) is open - use SSH instead" "HIGH" - fi - if ss -tulpn 2>/dev/null | grep -qE ":21\s"; then - report_issue "Firewall" "FTP port (21) is open - use SFTP instead" "MEDIUM" - fi - fi -} - -harden_firewall() { - log_info "Configuring UFW firewall..." - - if ! command -v ufw &>/dev/null; then - log_info "Installing UFW..." - apt-get update -qq - apt-get install -y -qq ufw - fi - - # Set default policies - ufw default deny incoming - ufw default allow outgoing - - # Allow SSH (critical - don't lock yourself out!) - ufw allow ssh - - # Common services (uncomment as needed) - # ufw allow http - # ufw allow https - - # Enable firewall - if confirm_action "Enable UFW firewall? (make sure SSH is allowed)"; then - ufw --force enable - log_success "UFW firewall enabled" - ((ISSUES_FIXED++)) - else - log_warning "UFW not enabled - manual configuration required" - fi -} - -# ============================================================================ -# KERNEL HARDENING -# ============================================================================ - -audit_kernel() { - log_info "Auditing kernel security parameters..." - - # Check key sysctl parameters - declare -A kernel_params=( - ["net.ipv4.ip_forward"]="0:IP forwarding should be disabled (unless router)" - ["net.ipv4.conf.all.accept_redirects"]="0:ICMP redirects should be disabled" - ["net.ipv4.conf.all.send_redirects"]="0:ICMP redirect sending should be disabled" - ["net.ipv4.conf.all.accept_source_route"]="0:Source routing should be disabled" - ["net.ipv4.conf.all.log_martians"]="1:Martian packet logging should be enabled" - ["net.ipv4.tcp_syncookies"]="1:SYN cookies should be enabled" - ["kernel.randomize_va_space"]="2:ASLR should be fully enabled" - ["fs.protected_hardlinks"]="1:Hardlink protection should be enabled" - ["fs.protected_symlinks"]="1:Symlink protection should be enabled" - ) - - for param in "${!kernel_params[@]}"; do - IFS=':' read -r expected_value description <<< "${kernel_params[$param]}" - local current_value - current_value=$(sysctl -n "$param" 2>/dev/null || echo "N/A") - - if [[ "$current_value" == "$expected_value" ]]; then - report_pass "Kernel" "$param = $current_value" - elif [[ "$current_value" == "N/A" ]]; then - log_debug "Kernel parameter not available: $param" - else - report_issue "Kernel" "$description (current: $current_value)" "MEDIUM" - fi - done - - # Check core dumps - if [[ -f /proc/sys/kernel/core_pattern ]]; then - local core_pattern - core_pattern=$(cat /proc/sys/kernel/core_pattern) - if [[ "$core_pattern" != "|/bin/false" && "$core_pattern" != "" ]]; then - if [[ $HARDENING_LEVEL -ge 2 ]]; then - report_issue "Kernel" "Core dumps enabled (security risk for sensitive data)" "LOW" - fi - fi - fi -} - -harden_kernel() { - log_info "Applying kernel hardening..." - - local sysctl_conf="/etc/sysctl.d/99-security-hardening.conf" - backup_file "$sysctl_conf" - - cat > "$sysctl_conf" << 'EOF' -# Security hardening sysctl configuration -# Generated by security-hardening.sh - -# Network security -net.ipv4.conf.all.accept_redirects = 0 -net.ipv4.conf.default.accept_redirects = 0 -net.ipv4.conf.all.send_redirects = 0 -net.ipv4.conf.default.send_redirects = 0 -net.ipv4.conf.all.accept_source_route = 0 -net.ipv4.conf.default.accept_source_route = 0 -net.ipv4.conf.all.log_martians = 1 -net.ipv4.conf.default.log_martians = 1 -net.ipv4.icmp_echo_ignore_broadcasts = 1 -net.ipv4.icmp_ignore_bogus_error_responses = 1 -net.ipv4.tcp_syncookies = 1 -net.ipv4.conf.all.rp_filter = 1 -net.ipv4.conf.default.rp_filter = 1 - -# IPv6 security (if not using IPv6, consider disabling) -net.ipv6.conf.all.accept_redirects = 0 -net.ipv6.conf.default.accept_redirects = 0 -net.ipv6.conf.all.accept_source_route = 0 -net.ipv6.conf.default.accept_source_route = 0 - -# Kernel hardening -kernel.randomize_va_space = 2 -kernel.kptr_restrict = 2 -kernel.dmesg_restrict = 1 -kernel.yama.ptrace_scope = 1 - -# Filesystem hardening -fs.protected_hardlinks = 1 -fs.protected_symlinks = 1 -fs.suid_dumpable = 0 -EOF - - # Apply immediately - sysctl -p "$sysctl_conf" 2>/dev/null || true - log_success "Kernel hardening parameters applied" - ((ISSUES_FIXED++)) -} - -# ============================================================================ -# FILE PERMISSION AUDIT -# ============================================================================ - -audit_file_permissions() { - log_info "Auditing file permissions..." - - # Check sensitive file permissions - declare -A sensitive_files=( - ["/etc/passwd"]="644" - ["/etc/shadow"]="640" - ["/etc/group"]="644" - ["/etc/gshadow"]="640" - ["/etc/ssh/sshd_config"]="600" - ["/etc/crontab"]="600" - ) - - for file in "${!sensitive_files[@]}"; do - if [[ -f "$file" ]]; then - local expected="${sensitive_files[$file]}" - local actual - actual=$(stat -c "%a" "$file" 2>/dev/null) - - if [[ "$actual" == "$expected" ]] || [[ "$actual" -le "$expected" ]]; then - report_pass "Permissions" "$file ($actual)" - else - report_issue "Permissions" "$file has permissions $actual (should be $expected or less)" "MEDIUM" - fi - fi - done - - # Find world-writable files (excluding /tmp, /var/tmp, /dev) - if [[ $HARDENING_LEVEL -ge 2 ]]; then - log_info "Scanning for world-writable files..." - local ww_count - ww_count=$(find / -xdev -type f -perm -0002 \ - -not -path "/proc/*" \ - -not -path "/sys/*" \ - -not -path "/tmp/*" \ - -not -path "/var/tmp/*" \ - 2>/dev/null | wc -l) - - if [[ $ww_count -gt 0 ]]; then - report_issue "Permissions" "Found $ww_count world-writable files" "MEDIUM" - else - report_pass "Permissions" "No world-writable files found (outside temp dirs)" - fi - fi - - # Find SUID/SGID binaries - log_info "Auditing SUID/SGID binaries..." - local suid_count - suid_count=$(find /usr -xdev \( -perm -4000 -o -perm -2000 \) -type f 2>/dev/null | wc -l) - log_info "Found $suid_count SUID/SGID binaries in /usr" - - # Check for unusual SUID binaries - while IFS= read -r suid_file; do - case "$suid_file" in - /usr/bin/sudo|/usr/bin/su|/usr/bin/passwd|/usr/bin/mount|/usr/bin/umount|/usr/bin/ping) - # Expected SUID binaries - ;; - *) - if [[ $HARDENING_LEVEL -ge 2 ]]; then - log_debug "SUID binary: $suid_file" - fi - ;; - esac - done < <(find /usr -xdev -perm -4000 -type f 2>/dev/null) -} - -# ============================================================================ -# USER SECURITY -# ============================================================================ - -audit_user_security() { - log_info "Auditing user security..." - - # Check for users with UID 0 (should only be root) - local uid0_users - uid0_users=$(awk -F: '$3 == 0 { print $1 }' /etc/passwd) - local uid0_count - uid0_count=$(echo "$uid0_users" | wc -w) - - if [[ $uid0_count -eq 1 && "$uid0_users" == "root" ]]; then - report_pass "Users" "Only root has UID 0" - else - report_issue "Users" "Multiple users with UID 0: $uid0_users" "CRITICAL" - fi - - # Check for users without passwords - if [[ -r /etc/shadow ]]; then - local no_pass - no_pass=$(awk -F: '($2 == "" || $2 == "!") && $1 != "root" { print $1 }' /etc/shadow | head -5) - if [[ -n "$no_pass" ]]; then - report_issue "Users" "Accounts without passwords: $no_pass" "HIGH" - else - report_pass "Users" "All accounts have passwords set" - fi - fi - - # Check root account status - if passwd -S root 2>/dev/null | grep -q "L"; then - report_pass "Users" "Root account is locked (use sudo)" - else - if [[ $HARDENING_LEVEL -ge 2 ]]; then - report_issue "Users" "Root account is not locked (consider locking)" "LOW" - fi - fi - - # Check for inactive accounts (no login in 90 days) - if command -v lastlog &>/dev/null; then - local inactive_count - inactive_count=$(lastlog -b 90 2>/dev/null | tail -n +2 | grep -v "Never logged in" | wc -l) - log_info "Found $inactive_count accounts with recent activity (last 90 days)" - fi - - # Check password aging - if [[ -f /etc/login.defs ]]; then - local pass_max_days - pass_max_days=$(grep "^PASS_MAX_DAYS" /etc/login.defs 2>/dev/null | awk '{print $2}') - if [[ -n "$pass_max_days" && "$pass_max_days" -gt 90 ]]; then - report_issue "Users" "Password max age is $pass_max_days days (recommend 90)" "LOW" - elif [[ -n "$pass_max_days" ]]; then - report_pass "Users" "Password max age: $pass_max_days days" - fi - fi -} - -# ============================================================================ -# SERVICE HARDENING -# ============================================================================ - -audit_services() { - log_info "Auditing running services..." - - # Services that should typically be disabled on servers - local risky_services=("telnet" "rsh" "rlogin" "rexec" "tftp" "talk" "ntalk" "xinetd") - - for service in "${risky_services[@]}"; do - if systemctl is-active --quiet "$service" 2>/dev/null; then - report_issue "Services" "$service is running (consider disabling)" "HIGH" - elif systemctl is-enabled --quiet "$service" 2>/dev/null; then - report_issue "Services" "$service is enabled at boot" "MEDIUM" - fi - done - - # Check for unnecessary network services - if systemctl is-active --quiet avahi-daemon 2>/dev/null; then - if [[ $HARDENING_LEVEL -ge 2 ]]; then - report_issue "Services" "avahi-daemon running (mDNS, often not needed on servers)" "LOW" - fi - fi - - # Check automatic updates - if systemctl is-enabled --quiet unattended-upgrades 2>/dev/null; then - report_pass "Services" "Automatic security updates enabled" - else - report_issue "Services" "Automatic security updates not configured" "MEDIUM" - fi -} - -harden_services() { - log_info "Configuring automatic security updates..." - - if ! dpkg -l unattended-upgrades &>/dev/null; then - apt-get update -qq - apt-get install -y -qq unattended-upgrades - fi - - # Enable automatic security updates - dpkg-reconfigure -plow unattended-upgrades 2>/dev/null || true - - # Configure unattended-upgrades - local uu_conf="/etc/apt/apt.conf.d/50unattended-upgrades" - if [[ -f "$uu_conf" ]]; then - # Ensure security updates are enabled (usually already is) - log_success "Unattended upgrades configured" - ((ISSUES_FIXED++)) - fi -} - -# ============================================================================ -# AUDIT LOGGING -# ============================================================================ - -audit_logging() { - log_info "Auditing system logging..." - - # Check if auditd is installed and running - if command -v auditd &>/dev/null; then - if systemctl is-active --quiet auditd 2>/dev/null; then - report_pass "Logging" "auditd is running" - else - report_issue "Logging" "auditd is installed but not running" "MEDIUM" - fi - else - if [[ $HARDENING_LEVEL -ge 2 ]]; then - report_issue "Logging" "auditd not installed (recommended for compliance)" "MEDIUM" - fi - fi - - # Check rsyslog - if systemctl is-active --quiet rsyslog 2>/dev/null; then - report_pass "Logging" "rsyslog is running" - else - report_issue "Logging" "rsyslog is not running" "MEDIUM" - fi - - # Check log rotation - if [[ -f /etc/logrotate.conf ]]; then - report_pass "Logging" "Log rotation configured" - else - report_issue "Logging" "Log rotation not configured" "LOW" - fi - - # Check for auth.log - if [[ -f /var/log/auth.log ]] || [[ -f /var/log/secure ]]; then - report_pass "Logging" "Authentication logging enabled" - else - report_issue "Logging" "Authentication log not found" "MEDIUM" - fi -} - -# ============================================================================ -# SUMMARY REPORT -# ============================================================================ - -print_summary() { - echo "" - echo "==============================================" - echo " Security Audit Summary" - echo "==============================================" - echo "" - - if [[ $ISSUES_FOUND -eq 0 ]]; then - log_success "No security issues found!" - else - log_warning "Issues found: $ISSUES_FOUND" - fi - - if [[ $MODE != "audit" ]]; then - log_info "Issues fixed: $ISSUES_FIXED" - fi - - echo "" - echo "Hardening level: $HARDENING_LEVEL" - echo "Mode: $MODE" - - if [[ -n "$REPORT_FILE" ]]; then - echo "" - log_info "Full report saved to: $REPORT_FILE" - fi - - if [[ -d "$BACKUP_DIR" ]]; then - echo "" - log_info "Configuration backups saved to: $BACKUP_DIR" - fi - - echo "" - if [[ $ISSUES_FOUND -gt 0 && $MODE == "audit" ]]; then - log_info "Run with --apply to fix identified issues" - fi -} - -# ============================================================================ -# MAIN -# ============================================================================ - -parse_args() { - while [[ $# -gt 0 ]]; do - case $1 in - --audit) - MODE="audit" - shift - ;; - --apply) - MODE="apply" - shift - ;; - --auto) - MODE="auto" - shift - ;; - --level) - HARDENING_LEVEL="$2" - shift 2 - ;; - --skip-ssh) - SKIP_SSH=true - shift - ;; - --skip-firewall) - SKIP_FIREWALL=true - shift - ;; - --skip-kernel) - SKIP_KERNEL=true - shift - ;; - --report) - REPORT_FILE="$2" - shift 2 - ;; - --verbose|-v) - VERBOSE=true - DEBUG=1 - shift - ;; - --help|-h) - show_help - ;; - *) - log_error "Unknown option: $1" - exit 1 - ;; - esac - done -} - -main() { - parse_args "$@" - - # Verify running as root for apply modes - if [[ $MODE != "audit" ]]; then - check_root - fi - - echo "" - echo "==============================================" - echo " Linux Security Hardening Script" - echo " Version: $SCRIPT_VERSION" - echo "==============================================" - echo "" - log_info "Mode: $MODE" - log_info "Hardening Level: $HARDENING_LEVEL" - log_info "Date: $(date '+%Y-%m-%d %H:%M:%S')" - log_info "Hostname: $(hostname)" - echo "" - - # Initialize report file - if [[ -n "$REPORT_FILE" ]]; then - echo "Security Hardening Report - $(date)" > "$REPORT_FILE" - echo "Hostname: $(hostname)" >> "$REPORT_FILE" - echo "Mode: $MODE, Level: $HARDENING_LEVEL" >> "$REPORT_FILE" - echo "========================================" >> "$REPORT_FILE" - fi - - # Run audits - if [[ $SKIP_SSH != true ]]; then - audit_ssh - [[ $MODE != "audit" ]] && harden_ssh - fi - - if [[ $SKIP_FIREWALL != true ]]; then - audit_firewall - [[ $MODE != "audit" ]] && harden_firewall - fi - - if [[ $SKIP_KERNEL != true ]]; then - audit_kernel - [[ $MODE != "audit" ]] && harden_kernel - fi - - audit_file_permissions - audit_user_security - audit_services - [[ $MODE != "audit" ]] && harden_services - audit_logging - - # Print summary - print_summary - - # Exit with appropriate code - if [[ $ISSUES_FOUND -gt 0 && $MODE == "audit" ]]; then - exit 1 - fi - exit 0 -} - -main "$@" diff --git a/QUICKSTART.md b/QUICKSTART.md index a1fcf42..8396393 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -19,7 +19,7 @@ SSH_KEY_PATH=~/.ssh/id_ed25519 ## 2. Run Your First Script -### Windows: Schedule Weekly Updates +### Windows: Schedule weekly updates ```powershell # From an elevated pwsh @@ -28,34 +28,43 @@ SSH_KEY_PATH=~/.ssh/id_ed25519 Start-ScheduledTask -TaskName SystemUpdates ``` -### Windows: System Monitoring +### Windows: Snapshot the dev environment before a rebuild ```powershell -.\Windows\monitoring\Get-SystemPerformance.ps1 -OutputFormat HTML -.\Windows\monitoring\Test-NetworkHealth.ps1 +.\Windows\backup\Backup-DeveloperEnvironment.ps1 -BackupPath "D:\DevBackups" ``` -### Linux: Maintenance +### Windows: Provision a fresh machine + +```powershell +.\Windows\first-time-setup\fresh-windows-setup.ps1 +# Then restore your previously-exported package list: +.\Windows\first-time-setup\install-from-exported-packages.ps1 -Manifest .\Windows\package-lists\my-packages.json +``` + +### Linux: Maintenance on q-lab ```bash -./Linux/maintenance/system-updates.sh --whatif -./Linux/docker/docker-cleanup.sh --keep-versions 2 +./Linux/maintenance/disk-cleanup.sh --whatif +./Linux/server/headless-server-setup.sh ``` ## 3. Common Commands | Task | Command | |------|---------| -| Backup user data | `.\Windows\backup\Backup-UserData.ps1 -Destination "D:\Backups"` | -| Check dev environment | `.\Windows\development\Test-DevEnvironment.ps1` | -| Fix common issues | `.\Windows\troubleshooting\Repair-CommonIssues.ps1 -Diagnose` | -| Run tests | `.\tests\run-tests.ps1` | +| Run all tests | `.\tests\run-tests.ps1` | +| Snapshot dev env | `.\Windows\backup\Backup-DeveloperEnvironment.ps1` | +| Schedule weekly updates | `.\Windows\maintenance\Install-SystemUpdatesTask.ps1` | +| Repair DNS/network | `.\Windows\troubleshooting\Repair-CommonIssues.ps1` | +| Clean Docker images | `.\Windows\development\Manage-Docker.ps1 -Cleanup` | ## 4. Documentation | Document | Purpose | |----------|---------| | [README.md](README.md) | Full script listing | +| [BACKLOG.md](BACKLOG.md) | What's planned, what was killed | | [SECURITY.md](SECURITY.md) | Security best practices | | [CONTRIBUTING.md](CONTRIBUTING.md) | Coding standards | | [docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md) | Common issues | diff --git a/README.md b/README.md index 19a9c49..99e533c 100644 --- a/README.md +++ b/README.md @@ -3,60 +3,38 @@ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![PowerShell](https://img.shields.io/badge/PowerShell-7.0+-blue.svg)](https://github.com/PowerShell/PowerShell) [![CI Tests](https://github.com/Dashtid/sysadmin-toolkit/workflows/CI%20-%20Automated%20Testing/badge.svg)](https://github.com/Dashtid/sysadmin-toolkit/actions/workflows/ci.yml) -[![Security Scan](https://github.com/Dashtid/sysadmin-toolkit/workflows/Security%20Scanning/badge.svg)](https://github.com/Dashtid/sysadmin-toolkit/actions/workflows/security-scan.yml) -Personal system administration scripts for Windows and Linux. SSH configuration, monitoring, backup, and maintenance automation. +Personal system administration scripts for Windows and Linux. Narrow scope: fresh-machine setup, weekly update automation, a Docker convenience wrapper, and a GPU exporter for the lab server. -> **Note**: Security hardening scripts are in [defensive-toolkit](https://github.com/Dashtid/defensive-toolkit). +> **Note:** Security hardening lives in [defensive-toolkit](https://github.com/Dashtid/defensive-toolkit). Monitoring and backup happen on the lab server via Prometheus/Grafana and Velero, not here. -## Quick Start - -```bash -git clone https://github.com/Dashtid/sysadmin-toolkit.git -cd sysadmin-toolkit -cp .env.example .env.local # Configure your values -``` +> **2026-06-14:** the toolkit was deliberately culled. Monitoring/reporting/security/most-backup/VPN/WSL/Test-DevEnvironment scripts were removed because they duplicated native tools (Task Manager, Event Viewer, `wsl.exe`, Settings) or the lab-server stack. See [BACKLOG.md](BACKLOG.md) for the new scope and rationale. ## Windows Scripts | Category | Script | Purpose | |----------|--------|---------| -| **Monitoring** | [Get-SystemPerformance.ps1](Windows/monitoring/) | CPU, RAM, disk, network metrics with Prometheus export | -| | [Watch-ServiceHealth.ps1](Windows/monitoring/) | Service monitoring with auto-restart | -| | [Test-NetworkHealth.ps1](Windows/monitoring/) | Connectivity, DNS, port testing | -| | [Get-EventLogAnalysis.ps1](Windows/monitoring/) | Security and error log analysis | -| | [Get-ApplicationHealth.ps1](Windows/monitoring/) | Application crash and version monitoring | -| **Backup** | [Backup-UserData.ps1](Windows/backup/) | User documents with compression | -| | [Backup-BrowserProfiles.ps1](Windows/backup/) | Browser bookmarks and settings | -| | [Backup-DeveloperEnvironment.ps1](Windows/backup/) | VSCode, Terminal, Git, SSH configs | -| | [Export-SystemState.ps1](Windows/backup/) | Drivers, registry, network, services | -| | [Test-BackupIntegrity.ps1](Windows/backup/) | Backup validation and restore testing | -| **Setup** | [fresh-windows-setup.ps1](Windows/first-time-setup/) | Automated Windows 11 setup | -| | [export-current-packages.ps1](Windows/first-time-setup/) | Export Winget/Chocolatey packages | -| **Development** | [Test-DevEnvironment.ps1](Windows/development/) | Validate dev tool installation | -| | [Manage-Docker.ps1](Windows/development/) | Docker Desktop management | -| | [Manage-WSL.ps1](Windows/development/) | WSL2 backup and configuration | -| **Maintenance** | [system-updates.ps1](Windows/maintenance/) | Windows Update automation | -| | [Install-SystemUpdatesTask.ps1](Windows/maintenance/) | Register system-updates.ps1 as a scheduled task | -| **Troubleshooting** | [Repair-CommonIssues.ps1](Windows/troubleshooting/) | Fix DNS, network, update issues | -| **Security** | [Get-UserAccountAudit.ps1](Windows/security/) | User and admin account audit | -| **Network** | [Manage-VPN.ps1](Windows/network/) | VPN connection management | -| **Reporting** | [Get-SystemReport.ps1](Windows/reporting/) | Comprehensive system report | +| **Setup** | [fresh-windows-setup.ps1](Windows/first-time-setup/) | Automated Windows 11 setup (Winget + Chocolatey) | +| | [export-current-packages.ps1](Windows/first-time-setup/) | Export installed Winget/Choco packages to a list | +| | [install-from-exported-packages.ps1](Windows/first-time-setup/) | Restore an exported package list on a fresh box | +| | [Compare-SoftwareInventory.ps1](Windows/first-time-setup/) | Diff two package inventories | +| **Maintenance** | [system-updates.ps1](Windows/maintenance/) | Weekly Winget/Choco/Windows Update automation | +| | [Install-SystemUpdatesTask.ps1](Windows/maintenance/) | Register `system-updates.ps1` as a scheduled task | +| **Backup** | [Backup-DeveloperEnvironment.ps1](Windows/backup/) | Snapshot VSCode, Terminal, Git, SSH configs before a rebuild | +| **Development** | [Manage-Docker.ps1](Windows/development/) | Docker Desktop start/stop/cleanup helper | +| | [remote-development-setup.ps1](Windows/development/) | Configure SSH client for remote development | +| **Network** | [Set-StaticIP.ps1](Windows/network/) | One-shot static IP/DNS/gateway helper | +| **Troubleshooting** | [Repair-CommonIssues.ps1](Windows/troubleshooting/) | DNS, network, and Windows Update fix-it routines | ## Linux Scripts +Scope is narrow on purpose: the lab server (q-lab) covers most operational needs via Prometheus/Grafana/Velero/k9s; the survivors here are the bits those tools don't cover. + | Category | Script | Purpose | |----------|--------|---------| -| **Monitoring** | [pod-health-monitor.sh](Linux/kubernetes/) | Kubernetes pod health and restart detection | -| | [pvc-monitor.sh](Linux/kubernetes/) | PVC usage monitoring | -| | [service-health-monitor.sh](Linux/monitoring/) | Service monitoring with alerts | -| **Maintenance** | [system-updates.sh](Linux/maintenance/) | APT/Snap updates with rollback | -| | [log-cleanup.sh](Linux/maintenance/) | Log rotation and cleanup | -| | [restore-previous-state.sh](Linux/maintenance/) | System state restoration | -| **Docker** | [docker-cleanup.sh](Linux/docker/) | Image cleanup with retention policy | -| **GPU** | [nvidia-gpu-exporter.sh](Linux/gpu/) | NVIDIA GPU metrics for Prometheus | -| **Security** | [security-hardening.sh](Linux/security/) | SSH, firewall, kernel hardening | -| **Server** | [headless-server-setup.sh](Linux/server/) | Ubuntu server provisioning | +| **GPU** | [nvidia-gpu-exporter.sh](Linux/gpu/) | NVIDIA GPU metrics for Prometheus (scraped by Grafana) | +| **Maintenance** | [disk-cleanup.sh](Linux/maintenance/) | APT cache + journal + Docker leftover cleanup | +| **Server** | [headless-server-setup.sh](Linux/server/) | Ubuntu server provisioning for a fresh q-lab-style box | ## Shared Modules @@ -71,9 +49,10 @@ cp .env.example .env.local # Configure your values | Document | Purpose | |----------|---------| | [QUICKSTART.md](QUICKSTART.md) | 5-minute setup guide | +| [BACKLOG.md](BACKLOG.md) | Tactical work queue and post-cull scope | | [SECURITY.md](SECURITY.md) | Security policy and best practices | | [CONTRIBUTING.md](CONTRIBUTING.md) | Coding standards and PR process | -| [docs/ROADMAP.md](docs/ROADMAP.md) | Feature roadmap and progress | +| [docs/ROADMAP.md](docs/ROADMAP.md) | Strategic direction (post-cull) | | [docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md) | Common issues and solutions | ## Prerequisites @@ -88,4 +67,4 @@ cp .env.example .env.local # Configure your values MIT License - See [LICENSE](LICENSE) --- -**Author**: David Dashti | **Version**: 2.3.0 | **Updated**: 2026-06-05 +**Author**: David Dashti | **Version**: 3.0.0 | **Updated**: 2026-06-14 diff --git a/Windows/backup/Backup-BrowserProfiles.ps1 b/Windows/backup/Backup-BrowserProfiles.ps1 deleted file mode 100644 index 153ab86..0000000 --- a/Windows/backup/Backup-BrowserProfiles.ps1 +++ /dev/null @@ -1,1078 +0,0 @@ -#!/usr/bin/env pwsh - -<# -.SYNOPSIS - Backs up browser profiles including bookmarks, settings, and extension data. - -.DESCRIPTION - This script provides comprehensive browser profile backup with: - - Support for Chrome, Edge, Firefox, and Brave browsers - - Backup of bookmarks, preferences, extensions list, and cookies (optional) - - Scheduled backup capability with retention policy - - Restore functionality to recover profiles - - Cross-browser bookmark export (HTML format) - - Compression and encryption options - - Data backed up per browser: - - Bookmarks/favorites - - Preferences/settings - - Extension list (not extension data for security) - - Session data (optional) - - Local storage (optional) - -.PARAMETER Browser - Which browser to backup. Valid values: Chrome, Edge, Firefox, Brave, All. - Default: All - -.PARAMETER OutputPath - Directory path for backup files. Default: toolkit logs/browser-backups directory. - -.PARAMETER IncludeCookies - Include cookies in the backup (may contain sensitive data). - Default: $false - -.PARAMETER IncludeHistory - Include browsing history in the backup. - Default: $false - -.PARAMETER IncludePasswords - Export password manager note (NOT actual passwords, just a reminder file). - Default: $false - -.PARAMETER Compress - Compress the backup to a ZIP file. - Default: $true - -.PARAMETER RetentionDays - Number of days to keep old backups. 0 = keep all. - Default: 30 - -.PARAMETER Restore - Path to a backup ZIP file to restore from. - -.PARAMETER RestoreTarget - Which browser to restore to. Required when using -Restore. - -.PARAMETER ListBackups - List all available backups and their details. - -.PARAMETER OutputFormat - Output format for reports. Valid values: Console, HTML, JSON. - Default: Console - -.PARAMETER WhatIf - Shows what would happen without making changes. - -.EXAMPLE - .\Backup-BrowserProfiles.ps1 - Backs up all browser profiles with default settings. - -.EXAMPLE - .\Backup-BrowserProfiles.ps1 -Browser Chrome -IncludeHistory - Backs up Chrome profile including browsing history. - -.EXAMPLE - .\Backup-BrowserProfiles.ps1 -Browser Firefox -OutputPath "D:\Backups\Browsers" -Compress - Backs up Firefox to a custom location with compression. - -.EXAMPLE - .\Backup-BrowserProfiles.ps1 -ListBackups - Shows all available backups with dates and sizes. - -.EXAMPLE - .\Backup-BrowserProfiles.ps1 -Restore "C:\Backups\Chrome_2025-01-15.zip" -RestoreTarget Chrome -WhatIf - Shows what would be restored without making changes. - -.EXAMPLE - .\Backup-BrowserProfiles.ps1 -Browser All -RetentionDays 7 -OutputFormat HTML - Backs up all browsers, keeps 7 days of backups, and generates HTML report. - -.NOTES - File Name : Backup-BrowserProfiles.ps1 - Author : Windows & Linux Sysadmin Toolkit - Prerequisite : PowerShell 5.1+ (PowerShell 7+ recommended) - Version : 1.0.0 - Creation Date : 2025-11-30 - - Browser Profile Locations: - - Chrome: %LOCALAPPDATA%\Google\Chrome\User Data\Default - - Edge: %LOCALAPPDATA%\Microsoft\Edge\User Data\Default - - Firefox: %APPDATA%\Mozilla\Firefox\Profiles\* - - Brave: %LOCALAPPDATA%\BraveSoftware\Brave-Browser\User Data\Default - - Security Notes: - - Passwords are NOT backed up (security risk) - - Cookies contain session data (optional backup) - - Extensions are listed but not fully backed up (reinstall recommended) - - Change Log: - - 1.0.0 (2025-11-30): Initial release - -.LINK - https://github.com/Dashtid/sysadmin-toolkit -#> - -#Requires -Version 5.1 - -[CmdletBinding(SupportsShouldProcess = $true, DefaultParameterSetName = 'Backup')] -param( - [Parameter(ParameterSetName = 'Backup')] - [ValidateSet('Chrome', 'Edge', 'Firefox', 'Brave', 'All')] - [string]$Browser = 'All', - - [Parameter(ParameterSetName = 'Backup')] - [Parameter(ParameterSetName = 'Restore')] - [string]$OutputPath, - - [Parameter(ParameterSetName = 'Backup')] - [switch]$IncludeCookies, - - [Parameter(ParameterSetName = 'Backup')] - [switch]$IncludeHistory, - - [Parameter(ParameterSetName = 'Backup')] - [switch]$IncludePasswords, - - [Parameter(ParameterSetName = 'Backup')] - [switch]$Compress = $true, - - [Parameter(ParameterSetName = 'Backup')] - [ValidateRange(0, 365)] - [int]$RetentionDays = 30, - - [Parameter(Mandatory = $true, ParameterSetName = 'Restore')] - [string]$Restore, - - [Parameter(ParameterSetName = 'Restore')] - [ValidateSet('Chrome', 'Edge', 'Firefox', 'Brave')] - [string]$RestoreTarget, - - [Parameter(ParameterSetName = 'List')] - [switch]$ListBackups, - - [Parameter()] - [ValidateSet('Console', 'HTML', 'JSON')] - [string]$OutputFormat = 'Console' -) - -#region Module Imports -$modulePath = Join-Path -Path $PSScriptRoot -ChildPath "..\lib\CommonFunctions.psm1" -if (Test-Path $modulePath) { - Import-Module $modulePath -Force -} -else { - # Fallback logging functions if module not found - function Write-Success { param([string]$Message) Write-Host "[+] $Message" -ForegroundColor Green } - function Write-InfoMessage { param([string]$Message) Write-Host "[i] $Message" -ForegroundColor Blue } - function Write-WarningMessage { param([string]$Message) Write-Host "[!] $Message" -ForegroundColor Yellow } - function Write-ErrorMessage { param([string]$Message) Write-Host "[-] $Message" -ForegroundColor Red } - function Get-LogDirectory { return Join-Path $PSScriptRoot "..\..\logs" } -} -#endregion - -#region Configuration -$script:StartTime = Get-Date -$script:ScriptVersion = "1.0.0" - -# Browser profile paths -$script:BrowserPaths = @{ - Chrome = @{ - Name = "Google Chrome" - ProfilePath = Join-Path $env:LOCALAPPDATA "Google\Chrome\User Data" - DefaultProfile = "Default" - BookmarksFile = "Bookmarks" - PreferencesFile = "Preferences" - ExtensionsDir = "Extensions" - HistoryFile = "History" - CookiesFile = "Cookies" - LocalStateFile = "Local State" - } - Edge = @{ - Name = "Microsoft Edge" - ProfilePath = Join-Path $env:LOCALAPPDATA "Microsoft\Edge\User Data" - DefaultProfile = "Default" - BookmarksFile = "Bookmarks" - PreferencesFile = "Preferences" - ExtensionsDir = "Extensions" - HistoryFile = "History" - CookiesFile = "Cookies" - LocalStateFile = "Local State" - } - Firefox = @{ - Name = "Mozilla Firefox" - ProfilePath = Join-Path $env:APPDATA "Mozilla\Firefox\Profiles" - ProfilesIni = Join-Path $env:APPDATA "Mozilla\Firefox\profiles.ini" - BookmarksFile = "places.sqlite" - PreferencesFile = "prefs.js" - ExtensionsDir = "extensions" - CookiesFile = "cookies.sqlite" - } - Brave = @{ - Name = "Brave Browser" - ProfilePath = Join-Path $env:LOCALAPPDATA "BraveSoftware\Brave-Browser\User Data" - DefaultProfile = "Default" - BookmarksFile = "Bookmarks" - PreferencesFile = "Preferences" - ExtensionsDir = "Extensions" - HistoryFile = "History" - CookiesFile = "Cookies" - LocalStateFile = "Local State" - } -} - -# Files/folders to always exclude (security sensitive) -$script:ExcludePatterns = @( - "Login Data*", - "Web Data", - "*.ldb", - "*.log", - "Cache", - "Code Cache", - "GPUCache", - "Service Worker", - "blob_storage", - "IndexedDB", - "File System" -) -#endregion - -#region Helper Functions -function Get-BackupDirectory { - if ($OutputPath) { - $backupDir = $OutputPath - } - else { - $logDir = Get-LogDirectory - $backupDir = Join-Path $logDir "browser-backups" - } - - if (-not (Test-Path $backupDir)) { - New-Item -ItemType Directory -Path $backupDir -Force | Out-Null - } - - return $backupDir -} - -function Test-BrowserInstalled { - param([string]$BrowserKey) - - $config = $script:BrowserPaths[$BrowserKey] - $profilePath = $config.ProfilePath - - if ($BrowserKey -eq 'Firefox') { - return (Test-Path $config.ProfilesIni) - } - else { - $defaultProfile = Join-Path $profilePath $config.DefaultProfile - return (Test-Path $defaultProfile) - } -} - -function Get-FirefoxProfiles { - $profilesIni = $script:BrowserPaths.Firefox.ProfilesIni - $profiles = @() - - if (Test-Path $profilesIni) { - $content = Get-Content $profilesIni -Raw - - # Parse INI file for profile paths - $sections = $content -split '\[Profile\d+\]' | Where-Object { $_ -match 'Path=' } - foreach ($section in $sections) { - if ($section -match 'Path=(.+)') { - $profilePath = $Matches[1].Trim() - if ($section -match 'IsRelative=1') { - $fullPath = Join-Path $env:APPDATA "Mozilla\Firefox\$profilePath" - } - else { - $fullPath = $profilePath - } - if (Test-Path $fullPath) { - $profiles += $fullPath - } - } - } - } - - return $profiles -} - -function Get-BrowserExtensions { - param( - [string]$BrowserKey, - [string]$ProfilePath - ) - - $extensions = @() - - switch ($BrowserKey) { - { $_ -in 'Chrome', 'Edge', 'Brave' } { - $extDir = Join-Path $ProfilePath "Extensions" - if (Test-Path $extDir) { - $extFolders = Get-ChildItem -Path $extDir -Directory -ErrorAction SilentlyContinue - foreach ($ext in $extFolders) { - # Try to get extension name from manifest - $manifestPath = Get-ChildItem -Path $ext.FullName -Filter "manifest.json" -Recurse -ErrorAction SilentlyContinue | Select-Object -First 1 - if ($manifestPath) { - try { - $manifest = Get-Content $manifestPath.FullName -Raw | ConvertFrom-Json - $extensions += [PSCustomObject]@{ - ID = $ext.Name - Name = if ($manifest.name -and $manifest.name -notmatch '^__MSG_') { $manifest.name } else { $ext.Name } - Version = $manifest.version - } - } - catch { - $extensions += [PSCustomObject]@{ - ID = $ext.Name - Name = $ext.Name - Version = "Unknown" - } - } - } - } - } - } - 'Firefox' { - $extDir = Join-Path $ProfilePath "extensions" - if (Test-Path $extDir) { - $extFiles = Get-ChildItem -Path $extDir -ErrorAction SilentlyContinue - foreach ($ext in $extFiles) { - $extensions += [PSCustomObject]@{ - ID = $ext.BaseName - Name = $ext.BaseName - Version = "Unknown" - } - } - } - } - } - - return $extensions -} - -function Export-BookmarksToHtml { - param( - [string]$BrowserKey, - [string]$ProfilePath, - [string]$OutputFile - ) - - $htmlContent = @" - - -Bookmarks Export - $($script:BrowserPaths[$BrowserKey].Name) -

Bookmarks

-

-"@ - - switch ($BrowserKey) { - { $_ -in 'Chrome', 'Edge', 'Brave' } { - $bookmarksFile = Join-Path $ProfilePath $script:BrowserPaths[$BrowserKey].BookmarksFile - if (Test-Path $bookmarksFile) { - try { - $bookmarks = Get-Content $bookmarksFile -Raw | ConvertFrom-Json - - function ConvertBookmarkNode { - param($node, $indent = 1) - $spaces = " " * $indent - $output = "" - - if ($node.type -eq "folder") { - $output += "$spaces

$($node.name)

`n" - $output += "$spaces

`n" - foreach ($child in $node.children) { - $output += ConvertBookmarkNode -node $child -indent ($indent + 1) - } - $output += "$spaces

`n" - } - elseif ($node.type -eq "url") { - $output += "$spaces

$($node.name)`n" - } - - return $output - } - - if ($bookmarks.roots.bookmark_bar) { - $htmlContent += "

Bookmarks Bar

`n

`n" - foreach ($child in $bookmarks.roots.bookmark_bar.children) { - $htmlContent += ConvertBookmarkNode -node $child -indent 2 - } - $htmlContent += "

`n" - } - - if ($bookmarks.roots.other) { - $htmlContent += "

Other Bookmarks

`n

`n" - foreach ($child in $bookmarks.roots.other.children) { - $htmlContent += ConvertBookmarkNode -node $child -indent 2 - } - $htmlContent += "

`n" - } - } - catch { - Write-WarningMessage "Failed to parse bookmarks: $($_.Exception.Message)" - } - } - } - 'Firefox' { - Write-InfoMessage "Firefox bookmarks are stored in SQLite format. Raw database backed up." - } - } - - $htmlContent += "

" - $htmlContent | Out-File -FilePath $OutputFile -Encoding UTF8 -} - -function Backup-BrowserProfile { - param( - [string]$BrowserKey, - [string]$BackupDir, - [switch]$IncludeCookies, - [switch]$IncludeHistory - ) - - $config = $script:BrowserPaths[$BrowserKey] - $timestamp = Get-Date -Format "yyyy-MM-dd_HHmmss" - $backupName = "${BrowserKey}_${timestamp}" - $backupPath = Join-Path $BackupDir $backupName - - Write-InfoMessage "Backing up $($config.Name)..." - - # Create backup directory - New-Item -ItemType Directory -Path $backupPath -Force | Out-Null - - $result = [PSCustomObject]@{ - Browser = $config.Name - BrowserKey = $BrowserKey - BackupPath = $backupPath - Timestamp = $timestamp - FilesBackedUp = @() - Extensions = @() - Success = $false - Error = $null - } - - try { - if ($BrowserKey -eq 'Firefox') { - # Handle Firefox's multiple profile structure - $profiles = Get-FirefoxProfiles - foreach ($profilePath in $profiles) { - $profileName = Split-Path $profilePath -Leaf - $profileBackup = Join-Path $backupPath $profileName - New-Item -ItemType Directory -Path $profileBackup -Force | Out-Null - - # Backup key files - $filesToBackup = @( - $config.BookmarksFile, # places.sqlite - $config.PreferencesFile, # prefs.js - "search.json.mozlz4", - "handlers.json", - "permissions.sqlite", - "content-prefs.sqlite", - "formhistory.sqlite", - "favicons.sqlite" - ) - - if ($IncludeCookies) { - $filesToBackup += $config.CookiesFile - } - - foreach ($file in $filesToBackup) { - $sourcePath = Join-Path $profilePath $file - if (Test-Path $sourcePath) { - Copy-Item -Path $sourcePath -Destination $profileBackup -Force - $result.FilesBackedUp += $file - } - } - - # Get extensions list - $result.Extensions += Get-BrowserExtensions -BrowserKey $BrowserKey -ProfilePath $profilePath - } - } - else { - # Chrome/Edge/Brave structure - $profilePath = Join-Path $config.ProfilePath $config.DefaultProfile - - # Backup bookmarks - $bookmarksSource = Join-Path $profilePath $config.BookmarksFile - if (Test-Path $bookmarksSource) { - Copy-Item -Path $bookmarksSource -Destination $backupPath -Force - $result.FilesBackedUp += $config.BookmarksFile - - # Also export to HTML format - $htmlExport = Join-Path $backupPath "bookmarks_export.html" - Export-BookmarksToHtml -BrowserKey $BrowserKey -ProfilePath $profilePath -OutputFile $htmlExport - } - - # Backup preferences - $prefsSource = Join-Path $profilePath $config.PreferencesFile - if (Test-Path $prefsSource) { - Copy-Item -Path $prefsSource -Destination $backupPath -Force - $result.FilesBackedUp += $config.PreferencesFile - } - - # Backup Local State (contains extension settings, etc.) - $localStateSource = Join-Path $config.ProfilePath $config.LocalStateFile - if (Test-Path $localStateSource) { - Copy-Item -Path $localStateSource -Destination $backupPath -Force - $result.FilesBackedUp += $config.LocalStateFile - } - - # Backup history if requested - if ($IncludeHistory) { - $historySource = Join-Path $profilePath $config.HistoryFile - if (Test-Path $historySource) { - Copy-Item -Path $historySource -Destination $backupPath -Force - $result.FilesBackedUp += $config.HistoryFile - } - } - - # Backup cookies if requested - if ($IncludeCookies) { - $cookiesSource = Join-Path $profilePath $config.CookiesFile - if (Test-Path $cookiesSource) { - Copy-Item -Path $cookiesSource -Destination $backupPath -Force - $result.FilesBackedUp += $config.CookiesFile - } - } - - # Get extensions list - $result.Extensions = Get-BrowserExtensions -BrowserKey $BrowserKey -ProfilePath $profilePath - } - - # Save extensions list to JSON - if ($result.Extensions.Count -gt 0) { - $extListFile = Join-Path $backupPath "extensions_list.json" - $result.Extensions | ConvertTo-Json -Depth 5 | Out-File -FilePath $extListFile -Encoding UTF8 - } - - # Create backup metadata - $metadata = @{ - Browser = $config.Name - BrowserKey = $BrowserKey - BackupDate = Get-Date -Format "yyyy-MM-dd HH:mm:ss" - ComputerName = $env:COMPUTERNAME - UserName = $env:USERNAME - FilesBackedUp = $result.FilesBackedUp - ExtensionCount = $result.Extensions.Count - IncludedCookies = $IncludeCookies.IsPresent - IncludedHistory = $IncludeHistory.IsPresent - } - $metadataFile = Join-Path $backupPath "backup_metadata.json" - $metadata | ConvertTo-Json -Depth 3 | Out-File -FilePath $metadataFile -Encoding UTF8 - - $result.Success = $true - Write-Success "Backed up $($result.FilesBackedUp.Count) files, $($result.Extensions.Count) extensions listed" - } - catch { - $result.Error = $_.Exception.Message - Write-ErrorMessage "Failed to backup $($config.Name): $($_.Exception.Message)" - } - - return $result -} - -function Compress-BackupFolder { - param( - [string]$FolderPath, - [switch]$RemoveOriginal = $true - ) - - $zipPath = "$FolderPath.zip" - - try { - if (Test-Path $zipPath) { - Remove-Item $zipPath -Force - } - - Compress-Archive -Path "$FolderPath\*" -DestinationPath $zipPath -Force - - if ($RemoveOriginal -and (Test-Path $zipPath)) { - Remove-Item $FolderPath -Recurse -Force - } - - Write-Success "Compressed backup to: $(Split-Path $zipPath -Leaf)" - return $zipPath - } - catch { - Write-WarningMessage "Failed to compress backup: $($_.Exception.Message)" - return $FolderPath - } -} - -function Remove-OldBackups { - param( - [string]$BackupDir, - [int]$RetentionDays - ) - - if ($RetentionDays -le 0) { - return - } - - $cutoffDate = (Get-Date).AddDays(-$RetentionDays) - $oldBackups = Get-ChildItem -Path $BackupDir -Filter "*.zip" -ErrorAction SilentlyContinue | - Where-Object { $_.LastWriteTime -lt $cutoffDate } - - foreach ($backup in $oldBackups) { - try { - Remove-Item $backup.FullName -Force - Write-InfoMessage "Removed old backup: $($backup.Name)" - } - catch { - Write-WarningMessage "Failed to remove old backup: $($backup.Name)" - } - } -} - -function Get-BackupList { - $backupDir = Get-BackupDirectory - $backups = @() - - $zipFiles = Get-ChildItem -Path $backupDir -Filter "*.zip" -ErrorAction SilentlyContinue - foreach ($zip in $zipFiles) { - # Parse browser and date from filename - if ($zip.Name -match '^(.+)_(\d{4}-\d{2}-\d{2}_\d{6})\.zip$') { - $backups += [PSCustomObject]@{ - FileName = $zip.Name - Browser = $Matches[1] - BackupDate = [DateTime]::ParseExact($Matches[2], "yyyy-MM-dd_HHmmss", $null) - SizeMB = [math]::Round($zip.Length / 1MB, 2) - FullPath = $zip.FullName - } - } - } - - return $backups | Sort-Object BackupDate -Descending -} - -function Restore-BrowserProfile { - param( - [string]$BackupPath, - [string]$TargetBrowser - ) - - if (-not (Test-Path $BackupPath)) { - Write-ErrorMessage "Backup file not found: $BackupPath" - return $false - } - - $config = $script:BrowserPaths[$TargetBrowser] - $tempDir = Join-Path $env:TEMP "browser_restore_$(Get-Date -Format 'yyyyMMddHHmmss')" - - try { - Write-InfoMessage "Extracting backup..." - Expand-Archive -Path $BackupPath -DestinationPath $tempDir -Force - - # Determine target profile path - if ($TargetBrowser -eq 'Firefox') { - $profiles = Get-FirefoxProfiles - if ($profiles.Count -eq 0) { - Write-ErrorMessage "No Firefox profiles found to restore to" - return $false - } - $targetPath = $profiles[0] - } - else { - $targetPath = Join-Path $config.ProfilePath $config.DefaultProfile - } - - if (-not (Test-Path $targetPath)) { - Write-ErrorMessage "Target profile path not found: $targetPath" - return $false - } - - # Restore files (except metadata) - $filesToRestore = Get-ChildItem -Path $tempDir -File -Recurse | - Where-Object { $_.Name -notin @("backup_metadata.json", "extensions_list.json", "bookmarks_export.html") } - - foreach ($file in $filesToRestore) { - $relativePath = $file.FullName.Substring($tempDir.Length + 1) - $destPath = Join-Path $targetPath (Split-Path $relativePath -Leaf) - - if ($PSCmdlet.ShouldProcess($destPath, "Restore file")) { - # Backup existing file first - if (Test-Path $destPath) { - $backupExisting = "$destPath.backup_$(Get-Date -Format 'yyyyMMddHHmmss')" - Copy-Item -Path $destPath -Destination $backupExisting -Force - } - Copy-Item -Path $file.FullName -Destination $destPath -Force - Write-InfoMessage "Restored: $($file.Name)" - } - } - - Write-Success "Browser profile restored successfully" - Write-WarningMessage "Please restart $($config.Name) for changes to take effect" - - return $true - } - catch { - Write-ErrorMessage "Restore failed: $($_.Exception.Message)" - return $false - } - finally { - if (Test-Path $tempDir) { - Remove-Item $tempDir -Recurse -Force -ErrorAction SilentlyContinue - } - } -} - -function Export-HtmlReport { - param( - [array]$Results, - [string]$OutputPath - ) - - $htmlContent = @" - - - - - - Browser Profile Backup Report - - - -

-

Browser Profile Backup Report

- -
-
-

$($Results.Count)

-

Browsers Processed

-
-
-

$(($Results | Where-Object { $_.Success }).Count)

-

Successful Backups

-
-
-

$(($Results | ForEach-Object { $_.FilesBackedUp.Count } | Measure-Object -Sum).Sum)

-

Files Backed Up

-
-
-

$(($Results | ForEach-Object { $_.Extensions.Count } | Measure-Object -Sum).Sum)

-

Extensions Listed

-
-
-"@ - - foreach ($result in $Results) { - $statusClass = if ($result.Success) { "status-success" } else { "status-failed" } - $statusText = if ($result.Success) { "SUCCESS" } else { "FAILED" } - - $htmlContent += @" -
-
-

$($result.Browser)

- $statusText -
-
-
-
- - $($result.Timestamp) -
-
- - $($result.FilesBackedUp.Count) files -
-
- - $($result.Extensions.Count) extensions -
-
- - $($result.BackupPath) -
-
-"@ - - if ($result.Extensions.Count -gt 0) { - $htmlContent += @" -
-

Extensions:

-"@ - foreach ($ext in ($result.Extensions | Select-Object -First 10)) { - $htmlContent += "
$($ext.Name) (v$($ext.Version))
`n" - } - if ($result.Extensions.Count -gt 10) { - $htmlContent += "
... and $($result.Extensions.Count - 10) more
`n" - } - $htmlContent += "
`n" - } - - $htmlContent += @" -
-
-"@ - } - - $htmlContent += @" - -
- - -"@ - - $htmlContent | Out-File -FilePath $OutputPath -Encoding UTF8 -} -#endregion - -#region Main Execution -function Main { - Write-InfoMessage "Browser Profile Backup v$($script:ScriptVersion)" - Write-InfoMessage "Started at: $($script:StartTime)" - - # Handle List mode - if ($ListBackups) { - $backups = Get-BackupList - if ($backups.Count -eq 0) { - Write-WarningMessage "No backups found" - return - } - - Write-Host "" - Write-Host "Available Backups:" -ForegroundColor Cyan - Write-Host "==================" -ForegroundColor Cyan - foreach ($backup in $backups) { - Write-Host "$($backup.Browser.PadRight(12)) | $($backup.BackupDate.ToString('yyyy-MM-dd HH:mm')) | $($backup.SizeMB) MB | $($backup.FileName)" -ForegroundColor White - } - return - } - - # Handle Restore mode - if ($Restore) { - if (-not $RestoreTarget) { - Write-ErrorMessage "Please specify -RestoreTarget (Chrome, Edge, Firefox, or Brave)" - exit 1 - } - - if ($PSCmdlet.ShouldProcess($RestoreTarget, "Restore browser profile from $Restore")) { - $success = Restore-BrowserProfile -BackupPath $Restore -TargetBrowser $RestoreTarget - exit $(if ($success) { 0 } else { 1 }) - } - return - } - - # Backup mode - $backupDir = Get-BackupDirectory - Write-InfoMessage "Backup directory: $backupDir" - - # Determine which browsers to backup - $browsersToBackup = if ($Browser -eq 'All') { - @('Chrome', 'Edge', 'Firefox', 'Brave') - } - else { - @($Browser) - } - - $results = @() - - foreach ($browserKey in $browsersToBackup) { - if (Test-BrowserInstalled -BrowserKey $browserKey) { - $result = Backup-BrowserProfile -BrowserKey $browserKey -BackupDir $backupDir -IncludeCookies:$IncludeCookies -IncludeHistory:$IncludeHistory - - # Compress if requested - if ($Compress -and $result.Success) { - $compressedPath = Compress-BackupFolder -FolderPath $result.BackupPath - $result.BackupPath = $compressedPath - } - - $results += $result - } - else { - Write-WarningMessage "$($script:BrowserPaths[$browserKey].Name) is not installed or has no profile" - } - } - - # Apply retention policy - if ($RetentionDays -gt 0) { - Remove-OldBackups -BackupDir $backupDir -RetentionDays $RetentionDays - } - - # Password reminder file - if ($IncludePasswords) { - $reminderFile = Join-Path $backupDir "PASSWORD_REMINDER.txt" - $reminderContent = @" -IMPORTANT: Browser passwords are NOT backed up for security reasons. - -To backup your passwords: -1. Chrome/Edge/Brave: Use Settings > Passwords > Export passwords -2. Firefox: Use a dedicated password manager extension - -For best security, use a dedicated password manager like: -- Bitwarden (free, open-source) -- 1Password -- LastPass -- KeePassXC (local) - -Generated: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') -"@ - $reminderContent | Out-File -FilePath $reminderFile -Encoding UTF8 - Write-InfoMessage "Created password reminder file" - } - - # Output results - Write-Host "" - Write-Host "======================================" -ForegroundColor Cyan - Write-Host " BACKUP SUMMARY" -ForegroundColor Cyan - Write-Host "======================================" -ForegroundColor Cyan - - $successCount = ($results | Where-Object { $_.Success }).Count - $totalFiles = ($results | ForEach-Object { $_.FilesBackedUp.Count } | Measure-Object -Sum).Sum - $totalExtensions = ($results | ForEach-Object { $_.Extensions.Count } | Measure-Object -Sum).Sum - - Write-Host "Browsers processed: $($results.Count)" -ForegroundColor White - Write-Host "Successful backups: $successCount" -ForegroundColor $(if ($successCount -eq $results.Count) { "Green" } else { "Yellow" }) - Write-Host "Total files backed up: $totalFiles" -ForegroundColor White - Write-Host "Total extensions listed: $totalExtensions" -ForegroundColor White - - # Generate output based on format - switch ($OutputFormat) { - 'HTML' { - $reportPath = Join-Path $backupDir "backup_report_$(Get-Date -Format 'yyyy-MM-dd_HHmmss').html" - Export-HtmlReport -Results $results -OutputPath $reportPath - Write-Success "HTML report saved to: $reportPath" - } - 'JSON' { - $reportPath = Join-Path $backupDir "backup_report_$(Get-Date -Format 'yyyy-MM-dd_HHmmss').json" - $results | ConvertTo-Json -Depth 10 | Out-File -FilePath $reportPath -Encoding UTF8 - Write-Success "JSON report saved to: $reportPath" - } - } - - $endTime = Get-Date - $duration = $endTime - $script:StartTime - Write-InfoMessage "Completed in $($duration.TotalSeconds.ToString('F1')) seconds" - - # Exit code based on success - $exitCode = if ($successCount -eq $results.Count -and $results.Count -gt 0) { 0 } - elseif ($successCount -gt 0) { 1 } - else { 2 } - - exit $exitCode -} - -# Run main function -Main -#endregion diff --git a/Windows/backup/Backup-UserData.ps1 b/Windows/backup/Backup-UserData.ps1 deleted file mode 100644 index f56e503..0000000 --- a/Windows/backup/Backup-UserData.ps1 +++ /dev/null @@ -1,1060 +0,0 @@ -#!/usr/bin/env pwsh - -<# -.SYNOPSIS - Automated backup script for user data with incremental support and integrity verification. - -.DESCRIPTION - This script provides comprehensive user data backup capabilities including: - - Backup of user documents, desktop, downloads, and custom folders - - Support for scheduled and on-demand backups - - Incremental backup support using file timestamps - - Compression with optional encryption - - Backup verification and integrity checks - - Rotation policy (keep last N backups) - - Multiple destination support (local, network, OneDrive) - - Detailed logging and progress reporting - - Restore capability - - Key features: - - Smart file selection (skip temp files, caches) - - Progress tracking with ETA - - Hash verification for integrity - - Email notification support - - Dry-run mode for preview - -.PARAMETER BackupType - Type of backup: Full, Incremental, Differential. Default: Incremental - -.PARAMETER Destination - Backup destination path (local or network share). - -.PARAMETER SourceFolders - Array of folder paths to backup. Default: Documents, Desktop, Downloads, Pictures - -.PARAMETER ExcludeFolders - Folder names to exclude from backup. - -.PARAMETER ExcludeExtensions - File extensions to exclude (e.g., .tmp, .log). - -.PARAMETER CompressionLevel - Compression level: None, Fastest, Optimal, SmallestSize. Default: Optimal - -.PARAMETER EnableEncryption - Enable AES encryption for backup archive. - -.PARAMETER EncryptionKey - Encryption key for backup (required if EnableEncryption is set). - -.PARAMETER RetentionCount - Number of backup sets to retain. Default: 5 - -.PARAMETER RetentionDays - Days to keep backups (alternative to RetentionCount). Default: 30 - -.PARAMETER VerifyBackup - Verify backup integrity after completion. - -.PARAMETER DryRun - Preview what would be backed up without actually copying files. - -.PARAMETER OutputFormat - Output format for reports. Valid values: Console, HTML, JSON. - Default: Console - -.PARAMETER LogPath - Path for backup log files. - -.PARAMETER IncrementalSince - DateTime for incremental backup reference. Default: Last backup timestamp. - -.EXAMPLE - .\Backup-UserData.ps1 -Destination "D:\Backups" - Creates incremental backup to D:\Backups. - -.EXAMPLE - .\Backup-UserData.ps1 -BackupType Full -Destination "\\server\backups" -VerifyBackup - Full backup to network share with verification. - -.EXAMPLE - .\Backup-UserData.ps1 -SourceFolders "C:\Projects", "C:\Documents" -CompressionLevel SmallestSize - Backs up specific folders with maximum compression. - -.EXAMPLE - .\Backup-UserData.ps1 -Destination "D:\Backups" -RetentionCount 10 -DryRun - Preview backup with 10 backup retention. - -.EXAMPLE - .\Backup-UserData.ps1 -Destination "D:\Backups" -EnableEncryption -EncryptionKey "MySecretKey123!" - Creates encrypted backup. - -.NOTES - File Name : Backup-UserData.ps1 - Author : Windows & Linux Sysadmin Toolkit - Prerequisite : PowerShell 5.1+ - Version : 1.0.0 - Creation Date : 2025-11-30 - - Change Log: - - 1.0.0 (2025-11-30): Initial release - -.LINK - https://github.com/Dashtid/sysadmin-toolkit -#> - -#Requires -Version 5.1 - -[CmdletBinding(SupportsShouldProcess)] -param( - [Parameter()] - [ValidateSet('Full', 'Incremental', 'Differential')] - [string]$BackupType = 'Incremental', - - [Parameter(Mandatory)] - [string]$Destination, - - [Parameter()] - [string[]]$SourceFolders, - - [Parameter()] - [string[]]$ExcludeFolders = @( - 'AppData', - 'node_modules', - '.git', - '.venv', - 'venv', - '__pycache__', - 'bin', - 'obj', - '.vs', - '.idea', - 'Temp', - 'Cache', - 'Caches' - ), - - [Parameter()] - [string[]]$ExcludeExtensions = @( - '.tmp', - '.temp', - '.log', - '.bak', - '.cache', - '.dmp', - '.thumbs.db' - ), - - [Parameter()] - [ValidateSet('None', 'Fastest', 'Optimal')] - [string]$CompressionLevel = 'Optimal', - - [Parameter()] - [switch]$EnableEncryption, - - [Parameter()] - [string]$EncryptionKey, - - [Parameter()] - [ValidateRange(1, 100)] - [int]$RetentionCount = 5, - - [Parameter()] - [ValidateRange(1, 365)] - [int]$RetentionDays = 30, - - [Parameter()] - [switch]$VerifyBackup, - - [Parameter()] - [switch]$DryRun, - - [Parameter()] - [ValidateSet('Console', 'HTML', 'JSON')] - [string]$OutputFormat = 'Console', - - [Parameter()] - [string]$LogPath, - - [Parameter()] - [datetime]$IncrementalSince -) - -#region Module Imports -$modulePath = Join-Path -Path $PSScriptRoot -ChildPath "..\lib\CommonFunctions.psm1" -if (Test-Path $modulePath) { - Import-Module $modulePath -Force -} -else { - function Write-Success { param([string]$Message) Write-Host "[+] $Message" -ForegroundColor Green } - function Write-InfoMessage { param([string]$Message) Write-Host "[i] $Message" -ForegroundColor Blue } - function Write-WarningMessage { param([string]$Message) Write-Host "[!] $Message" -ForegroundColor Yellow } - function Write-ErrorMessage { param([string]$Message) Write-Host "[-] $Message" -ForegroundColor Red } - function Get-LogDirectory { return Join-Path $PSScriptRoot "..\..\logs" } -} -#endregion - -#region Configuration -$script:StartTime = Get-Date -$script:ScriptVersion = "1.0.0" - -# Set default source folders if not specified -if (-not $SourceFolders -or $SourceFolders.Count -eq 0) { - $userProfile = $env:USERPROFILE - $SourceFolders = @( - (Join-Path $userProfile "Documents"), - (Join-Path $userProfile "Desktop"), - (Join-Path $userProfile "Downloads"), - (Join-Path $userProfile "Pictures") - ) -} - -# Set log path -if (-not $LogPath) { - $LogPath = Get-LogDirectory -} - -if (-not (Test-Path $LogPath)) { - New-Item -ItemType Directory -Path $LogPath -Force | Out-Null -} - -# Backup metadata file -$script:MetadataFile = Join-Path $Destination "backup_metadata.json" - -# Statistics -$script:Stats = @{ - TotalFiles = 0 - TotalSize = 0 - BackedUpFiles = 0 - BackedUpSize = 0 - SkippedFiles = 0 - FailedFiles = 0 - Errors = @() -} -#endregion - -#region Helper Functions -function Get-BackupMetadata { - <# - .SYNOPSIS - Retrieves backup metadata from destination. - #> - [CmdletBinding()] - param() - - if (Test-Path $script:MetadataFile) { - try { - return Get-Content $script:MetadataFile -Raw | ConvertFrom-Json - } - catch { - Write-WarningMessage "Could not read backup metadata: $($_.Exception.Message)" - } - } - - return @{ - LastFullBackup = $null - LastIncrementalBackup = $null - BackupHistory = @() - } -} - -function Save-BackupMetadata { - <# - .SYNOPSIS - Saves backup metadata to destination. - #> - [CmdletBinding()] - param( - [Parameter(Mandatory)] - [hashtable]$Metadata - ) - - try { - $Metadata | ConvertTo-Json -Depth 10 | Set-Content $script:MetadataFile -Encoding UTF8 - } - catch { - Write-WarningMessage "Could not save backup metadata: $($_.Exception.Message)" - } -} - -function Get-FilesToBackup { - <# - .SYNOPSIS - Gets list of files to backup based on criteria. - #> - [CmdletBinding()] - param( - [Parameter(Mandatory)] - [string]$SourcePath, - - [datetime]$ModifiedSince - ) - - $files = @() - - if (-not (Test-Path $SourcePath)) { - Write-WarningMessage "Source path not found: $SourcePath" - return $files - } - - try { - $allFiles = Get-ChildItem -Path $SourcePath -Recurse -File -ErrorAction SilentlyContinue - - foreach ($file in $allFiles) { - # Check excluded folders - $skip = $false - foreach ($excludeFolder in $ExcludeFolders) { - if ($file.FullName -like "*\$excludeFolder\*") { - $skip = $true - break - } - } - if ($skip) { - $script:Stats.SkippedFiles++ - continue - } - - # Check excluded extensions - if ($file.Extension.ToLower() -in $ExcludeExtensions) { - $script:Stats.SkippedFiles++ - continue - } - - # Check modification date for incremental backup - if ($ModifiedSince -and $file.LastWriteTime -lt $ModifiedSince) { - $script:Stats.SkippedFiles++ - continue - } - - $script:Stats.TotalFiles++ - $script:Stats.TotalSize += $file.Length - - $files += @{ - FullName = $file.FullName - RelativePath = $file.FullName.Substring($SourcePath.Length).TrimStart('\', '/') - Size = $file.Length - LastWriteTime = $file.LastWriteTime - Hash = $null - } - } - } - catch { - Write-WarningMessage "Error scanning $SourcePath`: $($_.Exception.Message)" - $script:Stats.Errors += "Scan error: $SourcePath - $($_.Exception.Message)" - } - - return $files -} - -function Get-FileHash256 { - <# - .SYNOPSIS - Calculates SHA256 hash for a file. - #> - [CmdletBinding()] - param( - [Parameter(Mandatory)] - [string]$FilePath - ) - - try { - $hash = Get-FileHash -Path $FilePath -Algorithm SHA256 -ErrorAction Stop - return $hash.Hash - } - catch { - return $null - } -} - -function Copy-BackupFiles { - <# - .SYNOPSIS - Copies files to backup destination. - #> - [CmdletBinding(SupportsShouldProcess)] - param( - [Parameter(Mandatory)] - [array]$Files, - - [Parameter(Mandatory)] - [string]$SourceRoot, - - [Parameter(Mandatory)] - [string]$DestinationRoot, - - [switch]$ComputeHash - ) - - $totalFiles = $Files.Count - $currentFile = 0 - $startTime = Get-Date - - foreach ($file in $Files) { - $currentFile++ - $percentComplete = [math]::Round(($currentFile / $totalFiles) * 100, 0) - - # Calculate ETA - $elapsed = (Get-Date) - $startTime - if ($currentFile -gt 1) { - $avgTimePerFile = $elapsed.TotalSeconds / $currentFile - $remainingFiles = $totalFiles - $currentFile - $etaSeconds = $avgTimePerFile * $remainingFiles - $eta = [TimeSpan]::FromSeconds($etaSeconds) - $etaString = "ETA: {0:hh\:mm\:ss}" -f $eta - } - else { - $etaString = "Calculating..." - } - - Write-Progress -Activity "Backing up files" -Status "$currentFile of $totalFiles - $etaString" -PercentComplete $percentComplete -CurrentOperation $file.RelativePath - - $destPath = Join-Path $DestinationRoot $file.RelativePath - $destDir = Split-Path $destPath -Parent - - if ($PSCmdlet.ShouldProcess($file.FullName, "Copy to $destPath")) { - try { - # Create destination directory if needed - if (-not (Test-Path $destDir)) { - New-Item -ItemType Directory -Path $destDir -Force | Out-Null - } - - # Copy file - Copy-Item -Path $file.FullName -Destination $destPath -Force -ErrorAction Stop - - $script:Stats.BackedUpFiles++ - $script:Stats.BackedUpSize += $file.Size - - # Calculate hash if verification is enabled - if ($ComputeHash) { - $file.Hash = Get-FileHash256 -FilePath $file.FullName - } - } - catch { - $script:Stats.FailedFiles++ - $script:Stats.Errors += "Copy failed: $($file.FullName) - $($_.Exception.Message)" - Write-WarningMessage "Failed to copy: $($file.RelativePath)" - } - } - } - - Write-Progress -Activity "Backing up files" -Completed -} - -function Compress-BackupFolder { - <# - .SYNOPSIS - Compresses backup folder to archive. - #> - [CmdletBinding(SupportsShouldProcess)] - param( - [Parameter(Mandatory)] - [string]$SourcePath, - - [Parameter(Mandatory)] - [string]$ArchivePath, - - [ValidateSet('None', 'Fastest', 'Optimal')] - [string]$Level = 'Optimal' - ) - - if ($PSCmdlet.ShouldProcess($SourcePath, "Compress to $ArchivePath")) { - try { - # Compress-Archive's -CompressionLevel is a string ValidateSet limited to - # Optimal / Fastest / NoCompression -- the System.IO.Compression.CompressionLevel - # enum's SmallestSize value is NOT accepted. Map 'None' through to the - # NoCompression string and pass the rest as-is. - $compressArchiveLevel = if ($Level -eq 'None') { 'NoCompression' } else { $Level } - - Write-InfoMessage "Compressing backup (Level: $Level)..." - Compress-Archive -Path "$SourcePath\*" -DestinationPath $ArchivePath -CompressionLevel $compressArchiveLevel -Force - - return $true - } - catch { - Write-ErrorMessage "Compression failed: $($_.Exception.Message)" - $script:Stats.Errors += "Compression failed: $($_.Exception.Message)" - return $false - } - } - - return $false -} - -function Test-BackupIntegrity { - <# - .SYNOPSIS - Verifies backup integrity by comparing file hashes. - #> - [CmdletBinding()] - param( - [Parameter(Mandatory)] - [array]$Files, - - [Parameter(Mandatory)] - [string]$BackupPath - ) - - Write-InfoMessage "Verifying backup integrity..." - - $verified = 0 - $failed = 0 - $totalFiles = ($Files | Where-Object { $_.Hash }).Count - - foreach ($file in ($Files | Where-Object { $_.Hash })) { - $backupFilePath = Join-Path $BackupPath $file.RelativePath - - if (Test-Path $backupFilePath) { - $backupHash = Get-FileHash256 -FilePath $backupFilePath - - if ($backupHash -eq $file.Hash) { - $verified++ - } - else { - $failed++ - $script:Stats.Errors += "Hash mismatch: $($file.RelativePath)" - } - } - else { - $failed++ - $script:Stats.Errors += "File missing from backup: $($file.RelativePath)" - } - } - - Write-InfoMessage "Verification complete: $verified verified, $failed failed" - - return @{ - TotalVerified = $verified - TotalFailed = $failed - Success = $failed -eq 0 - } -} - -function Remove-OldBackups { - <# - .SYNOPSIS - Removes old backups based on retention policy. - #> - [CmdletBinding(SupportsShouldProcess)] - param( - [Parameter(Mandatory)] - [string]$BackupRoot, - - [ValidateRange(1, 100)] - [int]$KeepCount = 5, - - [ValidateRange(1, 365)] - [int]$KeepDays = 30 - ) - - Write-InfoMessage "Applying retention policy..." - - # Get all backup folders/archives - $backups = Get-ChildItem -Path $BackupRoot -Directory -Filter "backup_*" | - Sort-Object -Property CreationTime -Descending - - $archives = Get-ChildItem -Path $BackupRoot -File -Filter "backup_*.zip" | - Sort-Object -Property CreationTime -Descending - - $allBackups = @() - $allBackups += $backups | ForEach-Object { @{ Path = $_.FullName; Date = $_.CreationTime; Type = 'Folder' } } - $allBackups += $archives | ForEach-Object { @{ Path = $_.FullName; Date = $_.CreationTime; Type = 'Archive' } } - $allBackups = $allBackups | Sort-Object -Property Date -Descending - - $removedCount = 0 - - # Remove by count - if ($allBackups.Count -gt $KeepCount) { - $toRemove = $allBackups | Select-Object -Skip $KeepCount - - foreach ($backup in $toRemove) { - if ($PSCmdlet.ShouldProcess($backup.Path, "Remove old backup")) { - try { - if ($backup.Type -eq 'Folder') { - Remove-Item -Path $backup.Path -Recurse -Force - } - else { - Remove-Item -Path $backup.Path -Force - } - $removedCount++ - Write-Verbose "Removed old backup: $($backup.Path)" - } - catch { - Write-WarningMessage "Failed to remove old backup: $($backup.Path)" - } - } - } - } - - # Remove by age - $cutoffDate = (Get-Date).AddDays(-$KeepDays) - $oldBackups = $allBackups | Where-Object { $_.Date -lt $cutoffDate } - - foreach ($backup in $oldBackups) { - # Skip if already in retention count - if ($backup -in ($allBackups | Select-Object -First $KeepCount)) { - continue - } - - if ($PSCmdlet.ShouldProcess($backup.Path, "Remove expired backup")) { - try { - if ($backup.Type -eq 'Folder') { - Remove-Item -Path $backup.Path -Recurse -Force - } - else { - Remove-Item -Path $backup.Path -Force - } - $removedCount++ - } - catch { - Write-WarningMessage "Failed to remove expired backup: $($backup.Path)" - } - } - } - - if ($removedCount -gt 0) { - Write-InfoMessage "Removed $removedCount old backup(s)" - } -} - -function Format-FileSize { - <# - .SYNOPSIS - Formats file size in human-readable format. - #> - [CmdletBinding()] - param( - [Parameter(Mandatory)] - [long]$Bytes - ) - - if ($Bytes -ge 1GB) { - return "{0:N2} GB" -f ($Bytes / 1GB) - } - elseif ($Bytes -ge 1MB) { - return "{0:N2} MB" -f ($Bytes / 1MB) - } - elseif ($Bytes -ge 1KB) { - return "{0:N2} KB" -f ($Bytes / 1KB) - } - else { - return "$Bytes bytes" - } -} -#endregion - -#region Report Functions -function Write-ConsoleReport { - <# - .SYNOPSIS - Outputs backup report to console. - #> - [CmdletBinding()] - param( - [Parameter(Mandatory)] - [hashtable]$Report - ) - - $separator = "=" * 60 - - Write-Host "`n$separator" -ForegroundColor Cyan - Write-Host " BACKUP REPORT" -ForegroundColor Cyan - Write-Host " Generated: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')" -ForegroundColor Cyan - Write-Host "$separator`n" -ForegroundColor Cyan - - # Summary - Write-Host "BACKUP SUMMARY" -ForegroundColor White - Write-Host "-" * 40 - Write-Host " Type: $($Report.BackupType)" - Write-Host " Status: " -NoNewline - if ($Report.Success) { - Write-Host "SUCCESS" -ForegroundColor Green - } - else { - Write-Host "FAILED" -ForegroundColor Red - } - Write-Host " Destination: $($Report.Destination)" - Write-Host " Duration: $($Report.Duration)`n" - - # Statistics - Write-Host "FILE STATISTICS" -ForegroundColor White - Write-Host "-" * 40 - Write-Host " Total Files: $($Report.Stats.TotalFiles)" - Write-Host " Backed Up: $($Report.Stats.BackedUpFiles)" - Write-Host " Skipped: $($Report.Stats.SkippedFiles)" - Write-Host " Failed: " -NoNewline - if ($Report.Stats.FailedFiles -gt 0) { - Write-Host "$($Report.Stats.FailedFiles)" -ForegroundColor Red - } - else { - Write-Host "$($Report.Stats.FailedFiles)" -ForegroundColor Green - } - Write-Host " Total Size: $(Format-FileSize $Report.Stats.TotalSize)" - Write-Host " Backed Up Size: $(Format-FileSize $Report.Stats.BackedUpSize)`n" - - # Verification - if ($Report.Verification) { - Write-Host "VERIFICATION" -ForegroundColor White - Write-Host "-" * 40 - Write-Host " Verified: $($Report.Verification.TotalVerified)" - Write-Host " Failed: " -NoNewline - if ($Report.Verification.TotalFailed -gt 0) { - Write-Host "$($Report.Verification.TotalFailed)" -ForegroundColor Red - } - else { - Write-Host "$($Report.Verification.TotalFailed)" -ForegroundColor Green - } - Write-Host "" - } - - # Errors - if ($Report.Stats.Errors.Count -gt 0) { - Write-Host "ERRORS" -ForegroundColor White - Write-Host "-" * 40 - foreach ($err in ($Report.Stats.Errors | Select-Object -First 10)) { - Write-Host " [-] $err" -ForegroundColor Red - } - if ($Report.Stats.Errors.Count -gt 10) { - Write-Host " ... and $($Report.Stats.Errors.Count - 10) more errors" - } - Write-Host "" - } - - Write-Host $separator -ForegroundColor Cyan -} - -function Export-HTMLReport { - <# - .SYNOPSIS - Generates an HTML backup report. - #> - [CmdletBinding()] - param( - [Parameter(Mandatory)] - [hashtable]$Report, - - [string]$Path - ) - - $timestamp = Get-Date -Format 'yyyy-MM-dd_HH-mm-ss' - $htmlPath = Join-Path $Path "backup-report_$timestamp.html" - - $statusClass = if ($Report.Success) { 'success' } else { 'error' } - $statusText = if ($Report.Success) { 'SUCCESS' } else { 'FAILED' } - - $errorsHtml = "" - if ($Report.Stats.Errors.Count -gt 0) { - $errorsHtml = "

Errors

    " - foreach ($err in $Report.Stats.Errors) { - $errorsHtml += "
  • $([System.Web.HttpUtility]::HtmlEncode($err))
  • " - } - $errorsHtml += "
" - } - - $html = @" - - - - - Backup Report - $($Report.ComputerName) - - - -
-

Backup Report

-

Computer: $($Report.ComputerName) | Date: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')

- -
$statusText
- -

Backup Details

-

Type: $($Report.BackupType) | Destination: $($Report.Destination) | Duration: $($Report.Duration)

- -
-
-
$($Report.Stats.BackedUpFiles)
-
Files Backed Up
-
-
-
$(Format-FileSize $Report.Stats.BackedUpSize)
-
Data Size
-
-
-
$($Report.Stats.SkippedFiles)
-
Files Skipped
-
-
-
$($Report.Stats.FailedFiles)
-
Files Failed
-
-
- - $errorsHtml - - -
- - -"@ - - $html | Set-Content -Path $htmlPath -Encoding UTF8 - Write-Success "HTML report saved: $htmlPath" - return $htmlPath -} - -function Export-JSONReport { - <# - .SYNOPSIS - Exports backup report to JSON format. - #> - [CmdletBinding()] - param( - [Parameter(Mandatory)] - [hashtable]$Report, - - [string]$Path - ) - - $timestamp = Get-Date -Format 'yyyy-MM-dd_HH-mm-ss' - $jsonPath = Join-Path $Path "backup-report_$timestamp.json" - - $Report | ConvertTo-Json -Depth 10 | Set-Content -Path $jsonPath -Encoding UTF8 - Write-Success "JSON report saved: $jsonPath" - return $jsonPath -} -#endregion - -#region Main Execution -function Invoke-UserDataBackup { - [CmdletBinding(SupportsShouldProcess = $true)] - [OutputType([int])] - param( - [Parameter(Mandatory)] - [string]$Destination, - - [ValidateSet('Full', 'Incremental', 'Differential')] - [string]$BackupType = 'Incremental', - - [string[]]$SourceFolders = @(), - - [switch]$DryRun, - - [switch]$VerifyBackup, - - [ValidateSet('None', 'Fastest', 'Optimal')] - [string]$CompressionLevel = 'Optimal', - - [int]$RetentionCount = 5, - - [int]$RetentionDays = 30, - - [ValidateSet('Console', 'HTML', 'JSON')] - [string]$OutputFormat = 'Console', - - [string]$LogPath, - - [Nullable[datetime]]$IncrementalSince - ) - - try { - Write-InfoMessage "=== User Data Backup v$script:ScriptVersion ===" - Write-InfoMessage "Started: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')" - Write-InfoMessage "Backup Type: $BackupType" - - if ($DryRun) { - Write-WarningMessage "DRY RUN MODE - No files will be copied" - } - - # Validate destination - if (-not (Test-Path $Destination)) { - Write-InfoMessage "Creating backup destination: $Destination" - New-Item -ItemType Directory -Path $Destination -Force | Out-Null - } - - # Get backup metadata - $metadata = Get-BackupMetadata - - # Determine incremental reference time - $modifiedSince = $null - if ($BackupType -eq 'Incremental') { - if ($IncrementalSince) { - $modifiedSince = $IncrementalSince - } - elseif ($metadata.LastIncrementalBackup) { - $modifiedSince = [datetime]$metadata.LastIncrementalBackup - } - elseif ($metadata.LastFullBackup) { - $modifiedSince = [datetime]$metadata.LastFullBackup - } - - if ($modifiedSince) { - Write-InfoMessage "Incremental backup since: $($modifiedSince.ToString('yyyy-MM-dd HH:mm:ss'))" - } - else { - Write-InfoMessage "No previous backup found - performing full backup" - $BackupType = 'Full' - } - } - - # Create backup folder name - $backupTimestamp = Get-Date -Format 'yyyy-MM-dd_HH-mm-ss' - $backupFolderName = "backup_${BackupType}_$backupTimestamp" - $backupPath = Join-Path $Destination $backupFolderName - - # Collect files to backup - Write-InfoMessage "Scanning source folders..." - $allFiles = @() - - foreach ($sourceFolder in $SourceFolders) { - if (Test-Path $sourceFolder) { - Write-InfoMessage " Scanning: $sourceFolder" - $files = Get-FilesToBackup -SourcePath $sourceFolder -ModifiedSince $modifiedSince - - foreach ($file in $files) { - $file.SourceRoot = $sourceFolder - $allFiles += $file - } - } - else { - Write-WarningMessage "Source folder not found: $sourceFolder" - } - } - - Write-InfoMessage "Found $($script:Stats.TotalFiles) files to backup ($(Format-FileSize $script:Stats.TotalSize))" - - if ($allFiles.Count -eq 0) { - Write-InfoMessage "No files to backup" - } - else { - # Create backup - if (-not $DryRun) { - New-Item -ItemType Directory -Path $backupPath -Force | Out-Null - - # Group files by source root and copy - $sourceGroups = $allFiles | Group-Object -Property SourceRoot - - foreach ($group in $sourceGroups) { - $sourceRoot = $group.Name - $sourceName = Split-Path $sourceRoot -Leaf - $destRoot = Join-Path $backupPath $sourceName - - Write-InfoMessage "Backing up: $sourceName" - Copy-BackupFiles -Files $group.Group -SourceRoot $sourceRoot -DestinationRoot $destRoot -ComputeHash:$VerifyBackup - } - - # Verify backup if requested - $verification = $null - if ($VerifyBackup -and $allFiles.Count -gt 0) { - $verification = Test-BackupIntegrity -Files $allFiles -BackupPath $backupPath - } - - # Compress if requested - if ($CompressionLevel -ne 'None') { - $archivePath = "$backupPath.zip" - $compressed = Compress-BackupFolder -SourcePath $backupPath -ArchivePath $archivePath -Level $CompressionLevel - - if ($compressed) { - # Remove uncompressed folder - Remove-Item -Path $backupPath -Recurse -Force - Write-Success "Backup compressed to: $archivePath" - } - } - - # Update metadata - $backupEntry = @{ - Timestamp = Get-Date -Format 'o' - Type = $BackupType - Files = $script:Stats.BackedUpFiles - Size = $script:Stats.BackedUpSize - Path = if ($CompressionLevel -ne 'None') { "$backupPath.zip" } else { $backupPath } - } - - if ($BackupType -eq 'Full') { - $metadata.LastFullBackup = Get-Date -Format 'o' - } - else { - $metadata.LastIncrementalBackup = Get-Date -Format 'o' - } - - if (-not $metadata.BackupHistory) { - $metadata.BackupHistory = @() - } - $metadata.BackupHistory = @($backupEntry) + $metadata.BackupHistory | Select-Object -First 50 - - Save-BackupMetadata -Metadata $metadata - - # Apply retention policy - Remove-OldBackups -BackupRoot $Destination -KeepCount $RetentionCount -KeepDays $RetentionDays - } - } - - # Generate report - $duration = (Get-Date) - $script:StartTime - $report = @{ - ComputerName = $env:COMPUTERNAME - BackupType = $BackupType - Destination = $Destination - Success = $script:Stats.FailedFiles -eq 0 -and $script:Stats.Errors.Count -eq 0 - Duration = "{0:hh\:mm\:ss}" -f $duration - Stats = $script:Stats - Verification = $verification - DryRun = $DryRun - } - - # Output report - switch ($OutputFormat) { - 'Console' { Write-ConsoleReport -Report $report } - 'HTML' { Export-HTMLReport -Report $report -Path $LogPath } - 'JSON' { Export-JSONReport -Report $report -Path $LogPath } - } - - Write-Success "=== Backup completed in $($duration.TotalSeconds.ToString('0.00'))s ===" - - # Exit with error code if backup failed - if (-not $report.Success) { - return 1 - } - - return 0 - } - catch { - Write-ErrorMessage "Fatal error: $($_.Exception.Message)" - return 1 - } -} - -if ($MyInvocation.InvocationName -ne '.') { - $invokeArgs = @{ - Destination = $Destination - BackupType = $BackupType - SourceFolders = $SourceFolders - DryRun = $DryRun - VerifyBackup = $VerifyBackup - CompressionLevel = $CompressionLevel - RetentionCount = $RetentionCount - RetentionDays = $RetentionDays - OutputFormat = $OutputFormat - LogPath = $LogPath - } - if ($PSBoundParameters.ContainsKey('IncrementalSince')) { - $invokeArgs.IncrementalSince = $IncrementalSince - } - $exitCode = Invoke-UserDataBackup @invokeArgs - if ($exitCode -ne 0) { exit $exitCode } -} -#endregion diff --git a/Windows/backup/Export-SystemState.ps1 b/Windows/backup/Export-SystemState.ps1 deleted file mode 100644 index cdbc38d..0000000 --- a/Windows/backup/Export-SystemState.ps1 +++ /dev/null @@ -1,895 +0,0 @@ -#!/usr/bin/env pwsh - -<# -.SYNOPSIS - Exports complete system configuration for disaster recovery. - -.DESCRIPTION - This script exports critical system state information including: - - Installed drivers with version information - - Registry keys for startup programs and services - - Network configuration (adapters, IP, DNS, routes, firewall) - - Scheduled tasks with full XML definitions - - Windows optional features state - - Service configurations - - Installed packages (Winget and Chocolatey) - - Event logs (optional) - - The export creates a structured folder with all components, - optionally compressed into a single archive. - -.PARAMETER Destination - Export destination folder path. - -.PARAMETER Include - Components to export. Valid values: All, Drivers, Registry, Network, Tasks, Features, Services, Packages. - Default: All - -.PARAMETER Compress - Create a ZIP archive of the export folder. - -.PARAMETER OutputFormat - Output format for the summary report. Valid values: Console, HTML, JSON, All. - Default: Console - -.PARAMETER IncludeEventLogs - Include recent event logs in export (can be large). - -.PARAMETER EventLogDays - Number of days of event logs to export. Default: 7 - -.PARAMETER DryRun - Preview what would be exported without actually exporting. - -.EXAMPLE - .\Export-SystemState.ps1 -Destination "D:\Backups\SystemState" - Exports all system state components to the specified folder. - -.EXAMPLE - .\Export-SystemState.ps1 -Destination "D:\Backups" -Include Drivers,Network -Compress - Exports only drivers and network config, compressed into a ZIP. - -.EXAMPLE - .\Export-SystemState.ps1 -Destination "D:\Backups" -IncludeEventLogs -EventLogDays 30 - Exports all components including 30 days of event logs. - -.NOTES - File Name : Export-SystemState.ps1 - Author : Windows & Linux Sysadmin Toolkit - Prerequisite : PowerShell 5.1+, some components require Administrator - Version : 1.0.0 - -.LINK - https://github.com/Dashtid/sysadmin-toolkit -#> - -#Requires -Version 5.1 - -[CmdletBinding(SupportsShouldProcess = $true)] -param( - [Parameter(Mandatory = $true)] - [string]$Destination, - - [ValidateSet('All', 'Drivers', 'Registry', 'Network', 'Tasks', 'Features', 'Services', 'Packages')] - [string[]]$Include = @('All'), - - [switch]$Compress, - - [ValidateSet('Console', 'HTML', 'JSON', 'All')] - [string]$OutputFormat = 'Console', - - [switch]$IncludeEventLogs, - - [ValidateRange(1, 365)] - [int]$EventLogDays = 7, - - [switch]$DryRun -) - -#region Module Imports -$modulePath = Join-Path -Path $PSScriptRoot -ChildPath "..\lib\CommonFunctions.psm1" -if (Test-Path $modulePath) { - Import-Module $modulePath -Force -} -else { - # Fallback inline definitions - function Write-Success { param([string]$Message) Write-Host "[+] $Message" -ForegroundColor Green } - function Write-InfoMessage { param([string]$Message) Write-Host "[i] $Message" -ForegroundColor Blue } - function Write-WarningMessage { param([string]$Message) Write-Host "[!] $Message" -ForegroundColor Yellow } - function Write-ErrorMessage { param([string]$Message) Write-Host "[-] $Message" -ForegroundColor Red } - function Test-IsAdministrator { - $identity = [Security.Principal.WindowsIdentity]::GetCurrent() - $principal = New-Object Security.Principal.WindowsPrincipal($identity) - return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) - } -} -#endregion - -#region Configuration -$script:StartTime = Get-Date -$script:ScriptVersion = "1.0.0" -$script:ExportFolder = $null -$script:Stats = @{ - ComponentsExported = 0 - FilesCreated = 0 - TotalSize = 0 - Errors = @() - Warnings = @() -} -#endregion - -#region Helper Functions - -function Get-ExportComponents { - <# - .SYNOPSIS - Determines which components to export based on Include parameter. - #> - param([string[]]$Include) - - $allComponents = @('Drivers', 'Registry', 'Network', 'Tasks', 'Features', 'Services', 'Packages') - - if ($Include -contains 'All') { - return $allComponents - } - return $Include -} - -function New-ExportFolder { - <# - .SYNOPSIS - Creates the export folder structure. - #> - param([string]$BasePath) - - $timestamp = Get-Date -Format "yyyy-MM-dd_HHmmss" - $folderName = "SystemState_$timestamp" - $exportPath = Join-Path $BasePath $folderName - - if (-not $DryRun) { - New-Item -ItemType Directory -Path $exportPath -Force | Out-Null - - # Create subdirectories - @('drivers', 'registry', 'network', 'tasks', 'tasks\xml', 'features', 'services', 'packages', 'eventlogs') | ForEach-Object { - New-Item -ItemType Directory -Path (Join-Path $exportPath $_) -Force | Out-Null - } - } - - return $exportPath -} - -function Export-Drivers { - <# - .SYNOPSIS - Exports installed driver information. - #> - param([string]$ExportPath) - - Write-InfoMessage "Exporting drivers..." - - if ($DryRun) { - Write-InfoMessage " [DryRun] Would export driver information" - return @{ Success = $true; Files = 0 } - } - - $driversPath = Join-Path $ExportPath "drivers" - $filesCreated = 0 - - try { - # Get PnP devices with driver info - $drivers = Get-PnpDevice -ErrorAction SilentlyContinue | Where-Object { $_.Class } | ForEach-Object { - $driverInfo = Get-PnpDeviceProperty -InstanceId $_.InstanceId -KeyName 'DEVPKEY_Device_DriverVersion' -ErrorAction SilentlyContinue - [PSCustomObject]@{ - Name = $_.FriendlyName - Class = $_.Class - Status = $_.Status - InstanceId = $_.InstanceId - Manufacturer = $_.Manufacturer - DriverVersion = $driverInfo.Data - Present = $_.Present - } - } - - # Export as JSON - $jsonPath = Join-Path $driversPath "drivers.json" - $drivers | ConvertTo-Json -Depth 5 | Out-File -FilePath $jsonPath -Encoding UTF8 - $filesCreated++ - - # Export as CSV for spreadsheet viewing - $csvPath = Join-Path $driversPath "drivers.csv" - $drivers | Export-Csv -Path $csvPath -NoTypeInformation -Encoding UTF8 - $filesCreated++ - - # Also run driverquery for raw output - $driverQueryPath = Join-Path $driversPath "driverquery.txt" - driverquery /v /fo list | Out-File -FilePath $driverQueryPath -Encoding UTF8 - $filesCreated++ - - $script:Stats.FilesCreated += $filesCreated - Write-Success " Exported $($drivers.Count) drivers ($filesCreated files)" - return @{ Success = $true; Files = $filesCreated; Count = $drivers.Count } - } - catch { - $script:Stats.Errors += "Drivers: $($_.Exception.Message)" - Write-ErrorMessage " Failed to export drivers: $($_.Exception.Message)" - return @{ Success = $false; Files = 0 } - } -} - -function Export-RegistryKeys { - <# - .SYNOPSIS - Exports important registry keys. - #> - param([string]$ExportPath) - - Write-InfoMessage "Exporting registry keys..." - - if ($DryRun) { - Write-InfoMessage " [DryRun] Would export registry keys" - return @{ Success = $true; Files = 0 } - } - - $registryPath = Join-Path $ExportPath "registry" - $filesCreated = 0 - - $keysToExport = @( - @{ Name = "run-keys-hklm"; Path = "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Run" }, - @{ Name = "run-keys-hkcu"; Path = "HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Run" }, - @{ Name = "runonce-hklm"; Path = "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce" }, - @{ Name = "shell-folders"; Path = "HKCU\Software\Microsoft\Windows\CurrentVersion\Explorer\User Shell Folders" }, - @{ Name = "environment-system"; Path = "HKLM\SYSTEM\CurrentControlSet\Control\Session Manager\Environment" }, - @{ Name = "environment-user"; Path = "HKCU\Environment" } - ) - - foreach ($key in $keysToExport) { - try { - $regFile = Join-Path $registryPath "$($key.Name).reg" - $result = reg export $key.Path $regFile /y 2>&1 - - if (Test-Path $regFile) { - $filesCreated++ - Write-Success " Exported: $($key.Name)" - } - } - catch { - $script:Stats.Warnings += "Registry key $($key.Name): $($_.Exception.Message)" - Write-WarningMessage " Could not export $($key.Name)" - } - } - - $script:Stats.FilesCreated += $filesCreated - return @{ Success = $true; Files = $filesCreated } -} - -function Export-NetworkConfig { - <# - .SYNOPSIS - Exports network configuration. - #> - param([string]$ExportPath) - - Write-InfoMessage "Exporting network configuration..." - - if ($DryRun) { - Write-InfoMessage " [DryRun] Would export network configuration" - return @{ Success = $true; Files = 0 } - } - - $networkPath = Join-Path $ExportPath "network" - $filesCreated = 0 - - try { - # Network adapters - $adapters = Get-NetAdapter | Select-Object Name, InterfaceDescription, Status, MacAddress, LinkSpeed, MediaType - $adapters | ConvertTo-Json -Depth 3 | Out-File -FilePath (Join-Path $networkPath "adapters.json") -Encoding UTF8 - $filesCreated++ - - # IP configuration - $ipConfig = Get-NetIPConfiguration | ForEach-Object { - [PSCustomObject]@{ - InterfaceAlias = $_.InterfaceAlias - InterfaceIndex = $_.InterfaceIndex - IPv4Address = $_.IPv4Address.IPAddress - IPv4Gateway = $_.IPv4DefaultGateway.NextHop - DNSServer = $_.DNSServer.ServerAddresses -join ', ' - NetProfile = $_.NetProfile.Name - } - } - $ipConfig | ConvertTo-Json -Depth 3 | Out-File -FilePath (Join-Path $networkPath "ip-config.json") -Encoding UTF8 - $filesCreated++ - - # Routes - $routes = Get-NetRoute | Where-Object { $_.DestinationPrefix -ne '::' -and $_.DestinationPrefix -ne '::/0' } | - Select-Object DestinationPrefix, NextHop, RouteMetric, InterfaceAlias, AddressFamily - $routes | ConvertTo-Json -Depth 3 | Out-File -FilePath (Join-Path $networkPath "routes.json") -Encoding UTF8 - $filesCreated++ - - # DNS settings - $dns = Get-DnsClientServerAddress | Where-Object { $_.ServerAddresses } | - Select-Object InterfaceAlias, AddressFamily, ServerAddresses - $dns | ConvertTo-Json -Depth 3 | Out-File -FilePath (Join-Path $networkPath "dns.json") -Encoding UTF8 - $filesCreated++ - - # Firewall profiles - $firewall = Get-NetFirewallProfile | Select-Object Name, Enabled, DefaultInboundAction, DefaultOutboundAction, LogFileName - $firewall | ConvertTo-Json -Depth 3 | Out-File -FilePath (Join-Path $networkPath "firewall-profiles.json") -Encoding UTF8 - $filesCreated++ - - # Firewall rules (enabled only to reduce size) - $firewallRules = Get-NetFirewallRule | Where-Object { $_.Enabled -eq 'True' } | - Select-Object Name, DisplayName, Direction, Action, Profile, Enabled | Sort-Object DisplayName - $firewallRules | ConvertTo-Json -Depth 3 | Out-File -FilePath (Join-Path $networkPath "firewall-rules.json") -Encoding UTF8 - $filesCreated++ - - $script:Stats.FilesCreated += $filesCreated - Write-Success " Exported network configuration ($filesCreated files)" - return @{ Success = $true; Files = $filesCreated } - } - catch { - $script:Stats.Errors += "Network: $($_.Exception.Message)" - Write-ErrorMessage " Failed to export network config: $($_.Exception.Message)" - return @{ Success = $false; Files = $filesCreated } - } -} - -function Export-ScheduledTasks { - <# - .SYNOPSIS - Exports scheduled tasks. - #> - param([string]$ExportPath) - - Write-InfoMessage "Exporting scheduled tasks..." - - if ($DryRun) { - Write-InfoMessage " [DryRun] Would export scheduled tasks" - return @{ Success = $true; Files = 0 } - } - - $tasksPath = Join-Path $ExportPath "tasks" - $xmlPath = Join-Path $tasksPath "xml" - $filesCreated = 0 - - try { - # Get all tasks (excluding Microsoft system tasks to reduce noise) - $tasks = Get-ScheduledTask | Where-Object { $_.TaskPath -notlike '\Microsoft\*' } | - Select-Object TaskName, TaskPath, State, Description, Author, @{N='Triggers';E={$_.Triggers.Count}}, @{N='Actions';E={$_.Actions.Count}} - - # Export summary - $tasks | ConvertTo-Json -Depth 5 | Out-File -FilePath (Join-Path $tasksPath "tasks-summary.json") -Encoding UTF8 - $filesCreated++ - - # Export individual task XMLs - $exportedTasks = 0 - foreach ($task in (Get-ScheduledTask | Where-Object { $_.TaskPath -notlike '\Microsoft\*' })) { - try { - $safeName = $task.TaskName -replace '[\\/:*?"<>|]', '_' - $xmlFile = Join-Path $xmlPath "$safeName.xml" - Export-ScheduledTask -TaskName $task.TaskName -TaskPath $task.TaskPath | Out-File -FilePath $xmlFile -Encoding UTF8 - $filesCreated++ - $exportedTasks++ - } - catch { - $script:Stats.Warnings += "Task $($task.TaskName): $($_.Exception.Message)" - } - } - - $script:Stats.FilesCreated += $filesCreated - Write-Success " Exported $exportedTasks scheduled tasks" - return @{ Success = $true; Files = $filesCreated; Count = $exportedTasks } - } - catch { - $script:Stats.Errors += "Tasks: $($_.Exception.Message)" - Write-ErrorMessage " Failed to export tasks: $($_.Exception.Message)" - return @{ Success = $false; Files = $filesCreated } - } -} - -function Export-WindowsFeatures { - <# - .SYNOPSIS - Exports Windows optional features state. - #> - param([string]$ExportPath) - - Write-InfoMessage "Exporting Windows features..." - - if ($DryRun) { - Write-InfoMessage " [DryRun] Would export Windows features" - return @{ Success = $true; Files = 0 } - } - - $featuresPath = Join-Path $ExportPath "features" - - try { - $features = Get-WindowsOptionalFeature -Online -ErrorAction SilentlyContinue | - Select-Object FeatureName, State, Description | Sort-Object FeatureName - - $jsonPath = Join-Path $featuresPath "windows-features.json" - $features | ConvertTo-Json -Depth 3 | Out-File -FilePath $jsonPath -Encoding UTF8 - - $script:Stats.FilesCreated++ - Write-Success " Exported $($features.Count) Windows features" - return @{ Success = $true; Files = 1; Count = $features.Count } - } - catch { - $script:Stats.Errors += "Features: $($_.Exception.Message)" - Write-ErrorMessage " Failed to export features: $($_.Exception.Message)" - return @{ Success = $false; Files = 0 } - } -} - -function Export-Services { - <# - .SYNOPSIS - Exports Windows services configuration. - #> - param([string]$ExportPath) - - Write-InfoMessage "Exporting services..." - - if ($DryRun) { - Write-InfoMessage " [DryRun] Would export services" - return @{ Success = $true; Files = 0 } - } - - $servicesPath = Join-Path $ExportPath "services" - - try { - $services = Get-Service | ForEach-Object { - $wmiService = Get-CimInstance -ClassName Win32_Service -Filter "Name='$($_.Name)'" -ErrorAction SilentlyContinue - [PSCustomObject]@{ - Name = $_.Name - DisplayName = $_.DisplayName - Status = $_.Status - StartType = $_.StartType - Description = $wmiService.Description - PathName = $wmiService.PathName - Account = $wmiService.StartName - } - } | Sort-Object Name - - $jsonPath = Join-Path $servicesPath "services.json" - $services | ConvertTo-Json -Depth 3 | Out-File -FilePath $jsonPath -Encoding UTF8 - - # Also export CSV for easy viewing - $csvPath = Join-Path $servicesPath "services.csv" - $services | Export-Csv -Path $csvPath -NoTypeInformation -Encoding UTF8 - - $script:Stats.FilesCreated += 2 - Write-Success " Exported $($services.Count) services" - return @{ Success = $true; Files = 2; Count = $services.Count } - } - catch { - $script:Stats.Errors += "Services: $($_.Exception.Message)" - Write-ErrorMessage " Failed to export services: $($_.Exception.Message)" - return @{ Success = $false; Files = 0 } - } -} - -function Export-InstalledPackages { - <# - .SYNOPSIS - Exports installed packages from Winget and Chocolatey. - #> - param([string]$ExportPath) - - Write-InfoMessage "Exporting installed packages..." - - if ($DryRun) { - Write-InfoMessage " [DryRun] Would export installed packages" - return @{ Success = $true; Files = 0 } - } - - $packagesPath = Join-Path $ExportPath "packages" - $filesCreated = 0 - - # Winget export - if (Get-Command winget -ErrorAction SilentlyContinue) { - try { - $wingetFile = Join-Path $packagesPath "winget-packages.json" - winget export -o $wingetFile --accept-source-agreements 2>&1 | Out-Null - - if (Test-Path $wingetFile) { - $wingetCount = (Get-Content $wingetFile | ConvertFrom-Json).Sources.Packages.Count - Write-Success " Exported $wingetCount Winget packages" - $filesCreated++ - } - } - catch { - $script:Stats.Warnings += "Winget export: $($_.Exception.Message)" - Write-WarningMessage " Winget export failed" - } - } - else { - Write-WarningMessage " Winget not found, skipping" - } - - # Chocolatey export - if (Get-Command choco -ErrorAction SilentlyContinue) { - try { - $chocoFile = Join-Path $packagesPath "chocolatey-packages.config" - choco export $chocoFile 2>&1 | Out-Null - - if (Test-Path $chocoFile) { - $chocoCount = ([xml](Get-Content $chocoFile)).packages.package.Count - Write-Success " Exported $chocoCount Chocolatey packages" - $filesCreated++ - } - } - catch { - $script:Stats.Warnings += "Chocolatey export: $($_.Exception.Message)" - Write-WarningMessage " Chocolatey export failed" - } - } - else { - Write-WarningMessage " Chocolatey not found, skipping" - } - - $script:Stats.FilesCreated += $filesCreated - return @{ Success = $true; Files = $filesCreated } -} - -function Export-EventLogs { - <# - .SYNOPSIS - Exports recent event logs. - #> - param( - [string]$ExportPath, - [int]$Days - ) - - Write-InfoMessage "Exporting event logs (last $Days days)..." - - if ($DryRun) { - Write-InfoMessage " [DryRun] Would export event logs" - return @{ Success = $true; Files = 0 } - } - - $logsPath = Join-Path $ExportPath "eventlogs" - $filesCreated = 0 - $startDate = (Get-Date).AddDays(-$Days) - - $logsToExport = @('System', 'Application', 'Security') - - foreach ($logName in $logsToExport) { - try { - $events = Get-WinEvent -FilterHashtable @{ - LogName = $logName - StartTime = $startDate - } -MaxEvents 5000 -ErrorAction SilentlyContinue | Select-Object TimeCreated, LevelDisplayName, Id, ProviderName, Message - - if ($events) { - $jsonFile = Join-Path $logsPath "$logName-${Days}days.json" - $events | ConvertTo-Json -Depth 3 | Out-File -FilePath $jsonFile -Encoding UTF8 - $filesCreated++ - Write-Success " Exported $($events.Count) events from $logName" - } - } - catch { - $script:Stats.Warnings += "EventLog $logName`: $($_.Exception.Message)" - Write-WarningMessage " Could not export $logName log" - } - } - - $script:Stats.FilesCreated += $filesCreated - return @{ Success = $true; Files = $filesCreated } -} - -function New-ExportManifest { - <# - .SYNOPSIS - Creates a manifest file documenting the export. - #> - param( - [string]$ExportPath, - [string[]]$Components, - [hashtable]$Results - ) - - $manifest = [PSCustomObject]@{ - ExportDate = Get-Date -Format "yyyy-MM-dd HH:mm:ss" - ComputerName = $env:COMPUTERNAME - UserName = $env:USERNAME - OSVersion = (Get-CimInstance Win32_OperatingSystem).Caption - ScriptVersion = $script:ScriptVersion - Components = $Components - IncludedEventLogs = $IncludeEventLogs.IsPresent - EventLogDays = if ($IncludeEventLogs) { $EventLogDays } else { $null } - Statistics = @{ - FilesCreated = $script:Stats.FilesCreated - Errors = $script:Stats.Errors.Count - Warnings = $script:Stats.Warnings.Count - } - Results = $Results - } - - $manifestPath = Join-Path $ExportPath "manifest.json" - $manifest | ConvertTo-Json -Depth 5 | Out-File -FilePath $manifestPath -Encoding UTF8 - - return $manifestPath -} - -function Compress-ExportFolder { - <# - .SYNOPSIS - Compresses the export folder into a ZIP archive. - #> - param([string]$FolderPath) - - $archivePath = "$FolderPath.zip" - - try { - Compress-Archive -Path "$FolderPath\*" -DestinationPath $archivePath -Force - Remove-Item -Path $FolderPath -Recurse -Force - Write-Success "Created archive: $archivePath" - return $archivePath - } - catch { - $script:Stats.Errors += "Compression: $($_.Exception.Message)" - Write-ErrorMessage "Failed to compress: $($_.Exception.Message)" - return $FolderPath - } -} - -function Write-ConsoleReport { - <# - .SYNOPSIS - Displays the export summary to console. - #> - param([hashtable]$Results) - - $separator = "=" * 60 - Write-Host "`n$separator" -ForegroundColor Cyan - Write-Host " SYSTEM STATE EXPORT REPORT" -ForegroundColor Cyan - Write-Host "$separator" -ForegroundColor Cyan - - Write-Host "`nExport Location: " -NoNewline - Write-Host $script:ExportFolder -ForegroundColor White - - Write-Host "Duration: " -NoNewline - $duration = (Get-Date) - $script:StartTime - Write-Host "$($duration.ToString('hh\:mm\:ss'))" -ForegroundColor White - - Write-Host "`nCOMPONENTS:" -ForegroundColor Cyan - foreach ($component in $Results.Keys) { - $result = $Results[$component] - $status = if ($result.Success) { "[+]" } else { "[-]" } - $color = if ($result.Success) { "Green" } else { "Red" } - Write-Host " $status $component" -ForegroundColor $color -NoNewline - if ($result.Count) { - Write-Host " ($($result.Count) items)" -ForegroundColor Gray - } - else { - Write-Host "" - } - } - - Write-Host "`nSTATISTICS:" -ForegroundColor Cyan - Write-Host " Files Created: $($script:Stats.FilesCreated)" - - if ($script:Stats.Warnings.Count -gt 0) { - Write-Host "`nWARNINGS:" -ForegroundColor Yellow - $script:Stats.Warnings | ForEach-Object { Write-Host " [!] $_" -ForegroundColor Yellow } - } - - if ($script:Stats.Errors.Count -gt 0) { - Write-Host "`nERRORS:" -ForegroundColor Red - $script:Stats.Errors | ForEach-Object { Write-Host " [-] $_" -ForegroundColor Red } - } - - Write-Host "`n$separator`n" -ForegroundColor Cyan -} - -function Export-HTMLReport { - <# - .SYNOPSIS - Generates an HTML report of the export. - #> - param( - [string]$OutputPath, - [hashtable]$Results - ) - - $htmlPath = Join-Path $OutputPath "export-report.html" - $duration = (Get-Date) - $script:StartTime - - $html = @" - - - - System State Export Report - - - -
-

System State Export Report

-

Computer: $env:COMPUTERNAME | Date: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') | Duration: $($duration.ToString('hh\:mm\:ss'))

- -
-
-
$($script:Stats.FilesCreated)
-
Files Created
-
-
-
$($Results.Keys.Count)
-
Components
-
-
-
$($script:Stats.Errors.Count)
-
Errors
-
-
- -

Export Components

- - - $(foreach ($component in $Results.Keys) { - $r = $Results[$component] - $statusClass = if ($r.Success) { 'success' } else { 'error' } - $statusText = if ($r.Success) { 'Success' } else { 'Failed' } - $details = if ($r.Count) { "$($r.Count) items" } else { "$($r.Files) files" } - "" - }) -
ComponentStatusDetails
$component$statusText$details
- -

Export Location: $script:ExportFolder

-
- - -"@ - - $html | Out-File -FilePath $htmlPath -Encoding UTF8 - Write-Success "HTML report saved: $htmlPath" -} - -function Export-JSONReport { - <# - .SYNOPSIS - Generates a JSON report of the export. - #> - param( - [string]$OutputPath, - [hashtable]$Results - ) - - $jsonPath = Join-Path $OutputPath "export-report.json" - - $report = @{ - ComputerName = $env:COMPUTERNAME - ExportDate = Get-Date -Format "o" - Duration = ((Get-Date) - $script:StartTime).ToString() - ExportPath = $script:ExportFolder - Statistics = $script:Stats - Results = $Results - } - - $report | ConvertTo-Json -Depth 10 | Out-File -FilePath $jsonPath -Encoding UTF8 - Write-Success "JSON report saved: $jsonPath" -} -#endregion - -#region Main Execution -try { - Write-Host "" - Write-InfoMessage "========================================" - Write-InfoMessage " System State Export v$script:ScriptVersion" - Write-InfoMessage "========================================" - - if ($DryRun) { - Write-WarningMessage "DRY RUN MODE - No files will be created" - } - - # Check admin for full functionality - if (-not (Test-IsAdministrator)) { - Write-WarningMessage "Running without admin privileges. Some exports may be limited." - } - - # Create export folder - if (-not (Test-Path $Destination)) { - New-Item -ItemType Directory -Path $Destination -Force | Out-Null - } - - $script:ExportFolder = New-ExportFolder -BasePath $Destination - Write-InfoMessage "Export folder: $script:ExportFolder" - - # Determine components to export - $components = Get-ExportComponents -Include $Include - Write-InfoMessage "Components to export: $($components -join ', ')" - - # Export each component - $results = @{} - - if ($components -contains 'Drivers') { - $results['Drivers'] = Export-Drivers -ExportPath $script:ExportFolder - $script:Stats.ComponentsExported++ - } - - if ($components -contains 'Registry') { - $results['Registry'] = Export-RegistryKeys -ExportPath $script:ExportFolder - $script:Stats.ComponentsExported++ - } - - if ($components -contains 'Network') { - $results['Network'] = Export-NetworkConfig -ExportPath $script:ExportFolder - $script:Stats.ComponentsExported++ - } - - if ($components -contains 'Tasks') { - $results['Tasks'] = Export-ScheduledTasks -ExportPath $script:ExportFolder - $script:Stats.ComponentsExported++ - } - - if ($components -contains 'Features') { - $results['Features'] = Export-WindowsFeatures -ExportPath $script:ExportFolder - $script:Stats.ComponentsExported++ - } - - if ($components -contains 'Services') { - $results['Services'] = Export-Services -ExportPath $script:ExportFolder - $script:Stats.ComponentsExported++ - } - - if ($components -contains 'Packages') { - $results['Packages'] = Export-InstalledPackages -ExportPath $script:ExportFolder - $script:Stats.ComponentsExported++ - } - - # Event logs (optional) - if ($IncludeEventLogs) { - $results['EventLogs'] = Export-EventLogs -ExportPath $script:ExportFolder -Days $EventLogDays - $script:Stats.ComponentsExported++ - } - - # Create manifest - if (-not $DryRun) { - New-ExportManifest -ExportPath $script:ExportFolder -Components $components -Results $results | Out-Null - $script:Stats.FilesCreated++ - } - - # Compress if requested - if ($Compress -and -not $DryRun) { - Write-InfoMessage "Compressing export..." - $script:ExportFolder = Compress-ExportFolder -FolderPath $script:ExportFolder - } - - # Generate reports - switch ($OutputFormat) { - 'Console' { Write-ConsoleReport -Results $results } - 'HTML' { Write-ConsoleReport -Results $results; Export-HTMLReport -OutputPath $Destination -Results $results } - 'JSON' { Write-ConsoleReport -Results $results; Export-JSONReport -OutputPath $Destination -Results $results } - 'All' { - Write-ConsoleReport -Results $results - Export-HTMLReport -OutputPath $Destination -Results $results - Export-JSONReport -OutputPath $Destination -Results $results - } - } - - Write-Success "Export complete: $script:ExportFolder" - - if ($script:Stats.Errors.Count -gt 0) { - exit 1 - } - exit 0 -} -catch { - Write-ErrorMessage "Fatal error: $($_.Exception.Message)" - Write-ErrorMessage "Stack trace: $($_.ScriptStackTrace)" - exit 1 -} -#endregion diff --git a/Windows/backup/README.md b/Windows/backup/README.md index 5ece074..769878b 100644 --- a/Windows/backup/README.md +++ b/Windows/backup/README.md @@ -1,36 +1,24 @@ # Windows Backup Scripts -Backup, export, and validation utilities for Windows systems. +Snapshot the developer environment (VSCode, Terminal, Git, SSH) before a machine rebuild. + +> **Scope note (2026-06-14):** the broader backup tier (`Backup-UserData`, `Backup-BrowserProfiles`, `Export-SystemState`, `Test-BackupIntegrity`, `Restore-DeveloperEnvironment`) was removed in the ghost-code cull. User data is covered by OneDrive sync; browser bookmarks/extensions sync natively; lab-server backups live on q-backup via Velero. See [BACKLOG.md](../../BACKLOG.md) for rationale. ## Scripts | Script | Purpose | |--------|---------| -| [Backup-UserData.ps1](Backup-UserData.ps1) | Backup user documents, desktop, downloads with compression | -| [Backup-BrowserProfiles.ps1](Backup-BrowserProfiles.ps1) | Backup browser bookmarks, extensions, settings | -| [Export-SystemState.ps1](Export-SystemState.ps1) | Export drivers, registry, network, tasks, services | -| [Test-BackupIntegrity.ps1](Test-BackupIntegrity.ps1) | Validate backup archives and test restores | +| [Backup-DeveloperEnvironment.ps1](Backup-DeveloperEnvironment.ps1) | Snapshot VSCode settings, Windows Terminal config, Git config, SSH keys | -## Quick Examples +## Quick Example ```powershell -# Backup user data -.\Backup-UserData.ps1 -Destination "D:\Backups" -Compress - -# Export system configuration -.\Export-SystemState.ps1 -Destination "D:\SystemState" -Include All -Compress - -# Validate a backup -.\Test-BackupIntegrity.ps1 -BackupPath "D:\Backups\backup.zip" -TestType Full - -# Test restore to temp location -.\Test-BackupIntegrity.ps1 -BackupPath "D:\Backups" -TestType Restore -RestoreTarget "C:\Temp\TestRestore" -CleanupAfterTest +# Snapshot before a rebuild +.\Backup-DeveloperEnvironment.ps1 -BackupPath "D:\DevBackups" ``` -## Output Formats - -All scripts support `-OutputFormat Console|HTML|JSON|All`. +The script writes a manifest alongside the archive so the contents are self-describing. --- -**Last Updated**: 2025-12-25 +**Last Updated**: 2026-06-14 diff --git a/Windows/backup/Restore-DeveloperEnvironment.ps1 b/Windows/backup/Restore-DeveloperEnvironment.ps1 deleted file mode 100644 index efb1ff6..0000000 --- a/Windows/backup/Restore-DeveloperEnvironment.ps1 +++ /dev/null @@ -1,315 +0,0 @@ -#!/usr/bin/env pwsh - -<# -.SYNOPSIS - Restores developer environment from backup. - -.DESCRIPTION - Restores developer environment configurations from a backup created by - Backup-DeveloperEnvironment.ps1. Supports: - - Selective restoration (choose which items to restore) - - Automatic backup of current files before overwriting - - VSCode extensions reinstallation - - WhatIf support for preview - - Restores: - - VSCode settings and keybindings - - VSCode extensions (reinstalls from list) - - Windows Terminal settings - - PowerShell profile - - Git configuration - - SSH configuration - -.PARAMETER BackupPath - Path to backup folder containing manifest.json. - -.PARAMETER RestoreExtensions - Reinstall VSCode extensions from backup list. Default: $true - -.PARAMETER CreateBackupFirst - Backup current files before restoring. Default: $true - -.PARAMETER Force - Overwrite existing files without prompting. - -.PARAMETER WhatIf - Shows what would be restored without making changes. - -.EXAMPLE - .\Restore-DeveloperEnvironment.ps1 -BackupPath "C:\Users\User\Backups\DevEnv\20251226-120000" - Restores from specified backup. - -.EXAMPLE - .\Restore-DeveloperEnvironment.ps1 -BackupPath $backupDir -WhatIf - Preview what would be restored. - -.EXAMPLE - .\Restore-DeveloperEnvironment.ps1 -BackupPath $backupDir -RestoreExtensions:$false - Restore without reinstalling VSCode extensions. - -.NOTES - Author: Windows & Linux Sysadmin Toolkit - Version: 1.1.0 - Requires: PowerShell 5.1+ - -.LINK - Backup-DeveloperEnvironment.ps1 -#> - -[CmdletBinding(SupportsShouldProcess)] -param( - [Parameter(Mandatory)] - [ValidateScript({ Test-Path $_ })] - [string]$BackupPath, - - [Parameter()] - [switch]$RestoreExtensions = $true, - - [Parameter()] - [switch]$CreateBackupFirst = $true, - - [Parameter()] - [switch]$Force -) - -#Requires -Version 5.1 - -# Import CommonFunctions -$modulePath = Join-Path $PSScriptRoot "..\lib\CommonFunctions.psm1" -if (Test-Path $modulePath) { - Import-Module $modulePath -Force -} -else { - # Fallback logging functions if module not available - function Write-Success { param($Message) Write-Host "[+] $Message" -ForegroundColor Green } - function Write-InfoMessage { param($Message) Write-Host "[i] $Message" -ForegroundColor Blue } - function Write-WarningMessage { param($Message) Write-Host "[!] $Message" -ForegroundColor Yellow } - function Write-ErrorMessage { param($Message) Write-Host "[-] $Message" -ForegroundColor Red } -} - -function Read-RestoreManifest { - [CmdletBinding()] - param( - [Parameter(Mandatory = $true)] - [string]$Source - ) - - $manifestPath = Join-Path $Source "manifest.json" - if (-not (Test-Path $manifestPath)) { - Write-ErrorMessage "Manifest not found: $manifestPath" - Write-ErrorMessage "This does not appear to be a valid developer environment backup." - throw "Manifest not found at $manifestPath" - } - - try { - return Get-Content $manifestPath -Raw | ConvertFrom-Json - } - catch { - Write-ErrorMessage "Failed to parse manifest: $($_.Exception.Message)" - throw "Failed to parse manifest: $($_.Exception.Message)" - } -} - -function Restore-ManifestItem { - [CmdletBinding(SupportsShouldProcess)] - [OutputType([hashtable])] - param( - [Parameter(Mandatory = $true)] - $Item, - - [bool]$BackupCurrentFirst = $true - ) - - # Returns @{ Outcome = 'Restored'|'Skipped'|'Error'; Reason = '...' } - - Write-InfoMessage "Processing: $($Item.Name)" - - if (-not (Test-Path $Item.BackupFile)) { - Write-WarningMessage "Backup file not found: $($Item.BackupFile)" - return @{ Outcome = 'Skipped'; Reason = 'BackupFileMissing' } - } - - if (-not $Item.OriginalPath) { - Write-WarningMessage "No original path specified for $($Item.Name)" - return @{ Outcome = 'Skipped'; Reason = 'NoOriginalPath' } - } - - # Create parent directory if needed - $parentDir = Split-Path $Item.OriginalPath -Parent - if (-not (Test-Path $parentDir)) { - if ($PSCmdlet.ShouldProcess($parentDir, "Create directory")) { - try { - New-Item -ItemType Directory -Path $parentDir -Force | Out-Null - Write-InfoMessage "Created directory: $parentDir" - } - catch { - Write-ErrorMessage "Failed to create directory: $($_.Exception.Message)" - return @{ Outcome = 'Error'; Reason = 'CreateParentFailed' } - } - } - } - - # Backup current file before overwriting - if ($BackupCurrentFirst -and (Test-Path $Item.OriginalPath)) { - $backupFile = "$($Item.OriginalPath).bak" - if ($PSCmdlet.ShouldProcess($Item.OriginalPath, "Create backup at $backupFile")) { - try { - Copy-Item -Path $Item.OriginalPath -Destination $backupFile -Force - Write-InfoMessage "Created backup: $backupFile" - } - catch { - Write-WarningMessage "Failed to create backup of current file: $($_.Exception.Message)" - } - } - } - - # Restore file - if ($PSCmdlet.ShouldProcess($Item.OriginalPath, "Restore from $($Item.BackupFile)")) { - try { - Copy-Item -Path $Item.BackupFile -Destination $Item.OriginalPath -Force - Write-Success "Restored: $($Item.Name)" - return @{ Outcome = 'Restored'; Reason = $null } - } - catch { - Write-ErrorMessage "Failed to restore $($Item.Name): $($_.Exception.Message)" - return @{ Outcome = 'Error'; Reason = 'CopyFailed' } - } - } - - return @{ Outcome = 'Skipped'; Reason = 'WhatIf' } -} - -function Restore-VsCodeExtension { - [CmdletBinding(SupportsShouldProcess)] - [OutputType([hashtable])] - param( - [Parameter(Mandatory = $true)] - $ExtensionsItem - ) - - if (-not (Test-Path $ExtensionsItem.BackupFile)) { - Write-InfoMessage "No VSCode extensions backup file" - return @{ Installed = 0; Total = 0; Skipped = $true } - } - - Write-Host "" - Write-InfoMessage "Restoring VSCode extensions..." - - $codeCmd = Get-Command code -ErrorAction SilentlyContinue - if (-not $codeCmd) { - Write-WarningMessage "VSCode CLI (code) not found in PATH - skipping extension restore" - return @{ Installed = 0; Total = 0; Skipped = $true } - } - - $extensions = Get-Content $ExtensionsItem.BackupFile - if (-not $extensions) { - return @{ Installed = 0; Total = 0; Skipped = $false } - } - - $totalExtensions = ($extensions | Measure-Object).Count - $installedCount = 0 - - foreach ($extension in $extensions) { - $extension = $extension.Trim() - if ([string]::IsNullOrWhiteSpace($extension)) { - continue - } - - if ($PSCmdlet.ShouldProcess($extension, "Install VSCode extension")) { - try { - Write-InfoMessage "Installing: $extension" - $null = & code --install-extension $extension --force 2>&1 - if ($LASTEXITCODE -eq 0) { - $installedCount++ - } - else { - Write-WarningMessage "Failed to install: $extension" - } - } - catch { - Write-WarningMessage "Error installing $extension : $($_.Exception.Message)" - } - } - } - - Write-Success "Installed $installedCount of $totalExtensions VSCode extensions" - return @{ Installed = $installedCount; Total = $totalExtensions; Skipped = $false } -} - -function Invoke-Restore { - [CmdletBinding(SupportsShouldProcess)] - param( - [Parameter(Mandatory = $true)] - [string]$Source, - - [bool]$RestoreVsCodeExtensions = $true, - - [bool]$BackupCurrentFirst = $true - ) - - $manifest = Read-RestoreManifest -Source $Source - - Write-InfoMessage "Developer Environment Restore" - Write-InfoMessage "Backup from: $($manifest.BackupDate)" - Write-InfoMessage "Computer: $($manifest.ComputerName)" - Write-InfoMessage "User: $($manifest.UserName)" - Write-Host "" - - $successCount = 0 - $skipCount = 0 - $errorCount = 0 - - foreach ($item in $manifest.Items) { - # Skip VSCode extensions (handled separately) - if ($item.Name -eq "VSCode-Extensions") { - continue - } - - $result = Restore-ManifestItem -Item $item -BackupCurrentFirst $BackupCurrentFirst - switch ($result.Outcome) { - 'Restored' { $successCount++ } - 'Skipped' { $skipCount++ } - 'Error' { $errorCount++ } - } - } - - if ($RestoreVsCodeExtensions) { - $extensionsItem = $manifest.Items | Where-Object { $_.Name -eq "VSCode-Extensions" } - if ($extensionsItem) { - $extResult = Restore-VsCodeExtension -ExtensionsItem $extensionsItem - if ($extResult.Skipped) { $skipCount++ } - } - else { - Write-InfoMessage "No VSCode extensions backup found" - } - } - - Write-Host "" - Write-InfoMessage "Restore Summary" - Write-Host " Restored: $successCount items" - Write-Host " Skipped: $skipCount items" - Write-Host " Errors: $errorCount items" - Write-Host "" - - if ($successCount -gt 0 -and $errorCount -eq 0) { - Write-Success "Restore complete" - } - elseif ($errorCount -gt 0) { - Write-WarningMessage "Restore completed with errors" - } - else { - Write-WarningMessage "No items were restored" - } - - return @{ Restored = $successCount; Skipped = $skipCount; Errors = $errorCount } -} - -# Run Invoke-Restore when invoked as a script. When dot-sourced for testing, skip -# auto-run so test files can load function definitions into scope and exercise -# them with mocks. -if ($MyInvocation.InvocationName -ne '.') { - $null = Invoke-Restore ` - -Source $BackupPath ` - -RestoreVsCodeExtensions:$RestoreExtensions ` - -BackupCurrentFirst:$CreateBackupFirst -} diff --git a/Windows/backup/Test-BackupIntegrity.ps1 b/Windows/backup/Test-BackupIntegrity.ps1 deleted file mode 100644 index ab66cdc..0000000 --- a/Windows/backup/Test-BackupIntegrity.ps1 +++ /dev/null @@ -1,869 +0,0 @@ -#!/usr/bin/env pwsh - -<# -.SYNOPSIS - Validates backup archives are restorable and uncorrupted. - -.DESCRIPTION - This script performs integrity testing on backup archives created by - Backup-UserData.ps1 or similar backup tools. It supports: - - Quick mode: Validate archive structure and sample file hashes - - Full mode: Extract entire archive to temp location, verify all files - - Restore mode: Actually restore to a target location for testing - - Key features: - - SHA256 hash verification against backup_metadata.json - - ZIP archive integrity testing - - Configurable sample percentage for quick tests - - Detailed integrity reports (Console, HTML, JSON) - - Automatic cleanup of test restore folders - -.PARAMETER BackupPath - Path to the backup archive (.zip) or backup folder to test. - -.PARAMETER TestType - Type of integrity test to perform. - - Quick: Validate structure and sample hashes (fastest) - - Full: Extract and verify all files (thorough) - - Restore: Actually restore to target location (most thorough) - Default: Quick - -.PARAMETER RestoreTarget - Target directory for test restore (required if TestType is Restore). - -.PARAMETER SamplePercent - Percentage of files to verify in Quick mode. Default: 10 - -.PARAMETER OutputFormat - Output format for reports. Valid values: Console, HTML, JSON, All. - Default: Console - -.PARAMETER OutputPath - Directory for report output files. - -.PARAMETER IncludeFileList - Include list of all verified files in the report. - -.PARAMETER CleanupAfterTest - Remove test restore folder after validation (applies to Full and Restore modes). - -.EXAMPLE - .\Test-BackupIntegrity.ps1 -BackupPath "D:\Backups\backup_2025-12-25.zip" - Quick integrity check on the specified backup archive. - -.EXAMPLE - .\Test-BackupIntegrity.ps1 -BackupPath "D:\Backups\backup_2025-12-25.zip" -TestType Full -CleanupAfterTest - Full extraction test with automatic cleanup. - -.EXAMPLE - .\Test-BackupIntegrity.ps1 -BackupPath "D:\Backups\backup_2025-12-25.zip" -TestType Restore -RestoreTarget "D:\TestRestore" - Restore backup to test location for manual verification. - -.NOTES - File Name : Test-BackupIntegrity.ps1 - Author : Windows & Linux Sysadmin Toolkit - Prerequisite : PowerShell 5.1+ - Version : 1.0.0 - -.LINK - https://github.com/Dashtid/sysadmin-toolkit -#> - -#Requires -Version 5.1 - -[CmdletBinding()] -param( - [Parameter(Mandatory = $true)] - [ValidateScript({ Test-Path $_ })] - [string]$BackupPath, - - [ValidateSet('Quick', 'Full', 'Restore')] - [string]$TestType = 'Quick', - - [string]$RestoreTarget, - - [ValidateRange(1, 100)] - [int]$SamplePercent = 10, - - [ValidateSet('Console', 'HTML', 'JSON', 'All')] - [string]$OutputFormat = 'Console', - - [string]$OutputPath, - - [switch]$IncludeFileList, - - [switch]$CleanupAfterTest -) - -#region Module Imports -$modulePath = Join-Path -Path $PSScriptRoot -ChildPath "..\lib\CommonFunctions.psm1" -if (Test-Path $modulePath) { - Import-Module $modulePath -Force -} -else { - function Write-Success { param([string]$Message) Write-Host "[+] $Message" -ForegroundColor Green } - function Write-InfoMessage { param([string]$Message) Write-Host "[i] $Message" -ForegroundColor Blue } - function Write-WarningMessage { param([string]$Message) Write-Host "[!] $Message" -ForegroundColor Yellow } - function Write-ErrorMessage { param([string]$Message) Write-Host "[-] $Message" -ForegroundColor Red } -} -#endregion - -#region Configuration -$script:StartTime = Get-Date -$script:ScriptVersion = "1.0.0" -$script:TempFolder = $null -$script:Stats = @{ - TotalFiles = 0 - FilesVerified = 0 - FilesFailed = 0 - HashesMatched = 0 - HashesFailed = 0 - TotalSize = 0 - VerifiedFiles = @() - FailedFiles = @() - Errors = @() - Warnings = @() -} -#endregion - -#region Helper Functions - -function Get-BackupInfo { - <# - .SYNOPSIS - Retrieves basic information about the backup. - #> - param([string]$Path) - - $info = @{ - Path = $Path - IsArchive = $Path -match '\.zip$' - Exists = Test-Path $Path - Size = $null - FileCount = $null - BackupDate = $null - HasMetadata = $false - } - - if ($info.IsArchive) { - $item = Get-Item $Path - $info.Size = $item.Length - $info.BackupDate = $item.LastWriteTime - - # Check archive contents - try { - Add-Type -AssemblyName System.IO.Compression.FileSystem - $archive = [System.IO.Compression.ZipFile]::OpenRead($Path) - $info.FileCount = $archive.Entries.Count - $info.HasMetadata = ($archive.Entries | Where-Object { $_.Name -eq 'backup_metadata.json' }).Count -gt 0 - $archive.Dispose() - } - catch { - $script:Stats.Errors += "Could not read archive: $($_.Exception.Message)" - } - } - else { - $info.Size = (Get-ChildItem $Path -Recurse -File | Measure-Object -Property Length -Sum).Sum - $info.FileCount = (Get-ChildItem $Path -Recurse -File).Count - $info.BackupDate = (Get-Item $Path).LastWriteTime - $info.HasMetadata = Test-Path (Join-Path $Path 'backup_metadata.json') - } - - return $info -} - -function Test-ArchiveStructure { - <# - .SYNOPSIS - Validates the ZIP archive can be opened and read. - #> - param([string]$ArchivePath) - - Write-InfoMessage "Testing archive structure..." - - try { - Add-Type -AssemblyName System.IO.Compression.FileSystem - $archive = [System.IO.Compression.ZipFile]::OpenRead($ArchivePath) - - $entryCount = $archive.Entries.Count - $totalSize = ($archive.Entries | Measure-Object -Property Length -Sum).Sum - - $archive.Dispose() - - Write-Success " Archive is valid: $entryCount entries, $(Format-FileSize $totalSize)" - return @{ - Valid = $true - EntryCount = $entryCount - TotalSize = $totalSize - } - } - catch { - $script:Stats.Errors += "Archive structure: $($_.Exception.Message)" - Write-ErrorMessage " Archive is corrupted or invalid: $($_.Exception.Message)" - return @{ - Valid = $false - EntryCount = 0 - TotalSize = 0 - Error = $_.Exception.Message - } - } -} - -function Get-BackupMetadata { - <# - .SYNOPSIS - Reads backup metadata from archive or folder. - #> - param( - [string]$BackupPath, - [bool]$IsArchive - ) - - Write-InfoMessage "Reading backup metadata..." - - try { - if ($IsArchive) { - Add-Type -AssemblyName System.IO.Compression.FileSystem - $archive = [System.IO.Compression.ZipFile]::OpenRead($BackupPath) - - $metadataEntry = $archive.Entries | Where-Object { $_.Name -eq 'backup_metadata.json' } | Select-Object -First 1 - - if ($metadataEntry) { - $stream = $metadataEntry.Open() - $reader = New-Object System.IO.StreamReader($stream) - $content = $reader.ReadToEnd() - $reader.Close() - $stream.Close() - $archive.Dispose() - - $metadata = $content | ConvertFrom-Json - Write-Success " Metadata loaded successfully" - return $metadata - } - else { - $archive.Dispose() - Write-WarningMessage " No metadata file found in archive" - return $null - } - } - else { - $metadataPath = Join-Path $BackupPath 'backup_metadata.json' - if (Test-Path $metadataPath) { - $metadata = Get-Content $metadataPath -Raw | ConvertFrom-Json - Write-Success " Metadata loaded successfully" - return $metadata - } - else { - Write-WarningMessage " No metadata file found" - return $null - } - } - } - catch { - $script:Stats.Warnings += "Metadata: $($_.Exception.Message)" - Write-WarningMessage " Could not read metadata: $($_.Exception.Message)" - return $null - } -} - -function Expand-BackupToTemp { - <# - .SYNOPSIS - Extracts archive to a temporary folder for testing. - #> - param([string]$ArchivePath) - - Write-InfoMessage "Extracting archive to temporary folder..." - - $tempPath = Join-Path $env:TEMP "BackupIntegrityTest_$(Get-Date -Format 'yyyyMMdd_HHmmss')" - - try { - New-Item -ItemType Directory -Path $tempPath -Force | Out-Null - Expand-Archive -Path $ArchivePath -DestinationPath $tempPath -Force - - $fileCount = (Get-ChildItem $tempPath -Recurse -File).Count - Write-Success " Extracted $fileCount files to: $tempPath" - - $script:TempFolder = $tempPath - return $tempPath - } - catch { - $script:Stats.Errors += "Extraction: $($_.Exception.Message)" - Write-ErrorMessage " Extraction failed: $($_.Exception.Message)" - return $null - } -} - -function Test-FileHashes { - <# - .SYNOPSIS - Verifies file hashes against metadata. - #> - param( - [string]$FolderPath, - [object]$Metadata, - [int]$SamplePercent - ) - - Write-InfoMessage "Verifying file hashes..." - - if (-not $Metadata -or -not $Metadata.FileHashes) { - Write-WarningMessage " No hash data in metadata, skipping hash verification" - return @{ - Verified = 0 - Failed = 0 - Skipped = $true - } - } - - $hashData = @{} - if ($Metadata.FileHashes -is [System.Collections.IDictionary]) { - $hashData = $Metadata.FileHashes - } - else { - # Convert PSObject to hashtable - $Metadata.FileHashes.PSObject.Properties | ForEach-Object { - $hashData[$_.Name] = $_.Value - } - } - - $allFiles = Get-ChildItem $FolderPath -Recurse -File - $script:Stats.TotalFiles = $allFiles.Count - - # Sample files if not 100% - if ($SamplePercent -lt 100) { - $sampleCount = [Math]::Max(1, [Math]::Ceiling($allFiles.Count * $SamplePercent / 100)) - $filesToCheck = $allFiles | Get-Random -Count $sampleCount - Write-InfoMessage " Sampling $sampleCount of $($allFiles.Count) files ($SamplePercent%)" - } - else { - $filesToCheck = $allFiles - } - - $verified = 0 - $failed = 0 - - foreach ($file in $filesToCheck) { - $relativePath = $file.FullName.Substring($FolderPath.Length + 1) - - if ($hashData.ContainsKey($relativePath)) { - try { - $actualHash = (Get-FileHash -Path $file.FullName -Algorithm SHA256).Hash - $expectedHash = $hashData[$relativePath] - - if ($actualHash -eq $expectedHash) { - $verified++ - $script:Stats.HashesMatched++ - if ($IncludeFileList) { - $script:Stats.VerifiedFiles += $relativePath - } - } - else { - $failed++ - $script:Stats.HashesFailed++ - $script:Stats.FailedFiles += @{ - Path = $relativePath - Expected = $expectedHash - Actual = $actualHash - } - Write-WarningMessage " Hash mismatch: $relativePath" - } - } - catch { - $failed++ - $script:Stats.Warnings += "Hash check $relativePath`: $($_.Exception.Message)" - } - } - else { - # File not in metadata (new file or metadata incomplete) - $script:Stats.FilesVerified++ - } - } - - $script:Stats.FilesVerified = $verified - $script:Stats.FilesFailed = $failed - - if ($failed -eq 0) { - Write-Success " Verified $verified files, 0 failures" - } - else { - Write-WarningMessage " Verified $verified files, $failed failures" - } - - return @{ - Verified = $verified - Failed = $failed - Skipped = $false - } -} - -function Test-FileExtraction { - <# - .SYNOPSIS - Tests that all files can be extracted from archive. - #> - param([string]$ArchivePath) - - Write-InfoMessage "Testing file extraction..." - - try { - Add-Type -AssemblyName System.IO.Compression.FileSystem - $archive = [System.IO.Compression.ZipFile]::OpenRead($ArchivePath) - - $totalEntries = $archive.Entries.Count - $readable = 0 - $failed = 0 - - foreach ($entry in $archive.Entries) { - if ($entry.Length -gt 0) { - try { - $stream = $entry.Open() - $buffer = New-Object byte[] 1024 - $bytesRead = $stream.Read($buffer, 0, $buffer.Length) - $stream.Close() - $readable++ - } - catch { - $failed++ - $script:Stats.FailedFiles += $entry.FullName - } - } - else { - $readable++ # Empty file or directory - } - } - - $archive.Dispose() - - if ($failed -eq 0) { - Write-Success " All $readable entries are readable" - } - else { - Write-WarningMessage " $readable readable, $failed failed" - } - - return @{ - Readable = $readable - Failed = $failed - Total = $totalEntries - } - } - catch { - $script:Stats.Errors += "Extraction test: $($_.Exception.Message)" - Write-ErrorMessage " Extraction test failed: $($_.Exception.Message)" - return @{ - Readable = 0 - Failed = 0 - Total = 0 - Error = $_.Exception.Message - } - } -} - -function Restore-ToTarget { - <# - .SYNOPSIS - Restores backup to target location for testing. - #> - param( - [string]$BackupPath, - [string]$TargetPath, - [bool]$IsArchive - ) - - Write-InfoMessage "Restoring to target: $TargetPath" - - try { - if (-not (Test-Path $TargetPath)) { - New-Item -ItemType Directory -Path $TargetPath -Force | Out-Null - } - - if ($IsArchive) { - Expand-Archive -Path $BackupPath -DestinationPath $TargetPath -Force - } - else { - Copy-Item -Path "$BackupPath\*" -Destination $TargetPath -Recurse -Force - } - - $fileCount = (Get-ChildItem $TargetPath -Recurse -File).Count - Write-Success " Restored $fileCount files" - - return @{ - Success = $true - FileCount = $fileCount - Path = $TargetPath - } - } - catch { - $script:Stats.Errors += "Restore: $($_.Exception.Message)" - Write-ErrorMessage " Restore failed: $($_.Exception.Message)" - return @{ - Success = $false - FileCount = 0 - Error = $_.Exception.Message - } - } -} - -function Format-FileSize { - <# - .SYNOPSIS - Formats bytes to human-readable size. - #> - param([long]$Bytes) - - if ($Bytes -ge 1GB) { return "{0:N2} GB" -f ($Bytes / 1GB) } - elseif ($Bytes -ge 1MB) { return "{0:N2} MB" -f ($Bytes / 1MB) } - elseif ($Bytes -ge 1KB) { return "{0:N2} KB" -f ($Bytes / 1KB) } - else { return "$Bytes bytes" } -} - -function Remove-TempFolder { - <# - .SYNOPSIS - Removes temporary test folder. - #> - param([string]$Path) - - if ($Path -and (Test-Path $Path)) { - try { - Remove-Item -Path $Path -Recurse -Force - Write-Success "Cleaned up temporary folder" - } - catch { - Write-WarningMessage "Could not remove temp folder: $Path" - } - } -} - -function Write-ConsoleReport { - <# - .SYNOPSIS - Displays integrity test results to console. - #> - param([hashtable]$Results) - - $separator = "=" * 60 - Write-Host "`n$separator" -ForegroundColor Cyan - Write-Host " BACKUP INTEGRITY REPORT" -ForegroundColor Cyan - Write-Host "$separator" -ForegroundColor Cyan - - Write-Host "`nBackup: " -NoNewline - Write-Host $BackupPath -ForegroundColor White - - Write-Host "Test Type: " -NoNewline - Write-Host $TestType -ForegroundColor White - - Write-Host "Duration: " -NoNewline - $duration = (Get-Date) - $script:StartTime - Write-Host "$($duration.ToString('hh\:mm\:ss'))" -ForegroundColor White - - # Overall status - $overallSuccess = ($script:Stats.Errors.Count -eq 0) -and ($script:Stats.FilesFailed -eq 0) - Write-Host "`nOVERALL STATUS: " -NoNewline - if ($overallSuccess) { - Write-Host "PASSED" -ForegroundColor Green - } - else { - Write-Host "FAILED" -ForegroundColor Red - } - - # Results - Write-Host "`nRESULTS:" -ForegroundColor Cyan - if ($Results.ArchiveValid -ne $null) { - $status = if ($Results.ArchiveValid) { "[+]" } else { "[-]" } - $color = if ($Results.ArchiveValid) { "Green" } else { "Red" } - Write-Host " $status Archive Structure" -ForegroundColor $color - } - - if ($Results.HashVerification) { - $hv = $Results.HashVerification - if ($hv.Skipped) { - Write-Host " [!] Hash Verification (skipped - no metadata)" -ForegroundColor Yellow - } - else { - $status = if ($hv.Failed -eq 0) { "[+]" } else { "[-]" } - $color = if ($hv.Failed -eq 0) { "Green" } else { "Red" } - Write-Host " $status Hash Verification: $($hv.Verified) verified, $($hv.Failed) failed" -ForegroundColor $color - } - } - - if ($Results.RestoreResult) { - $rr = $Results.RestoreResult - $status = if ($rr.Success) { "[+]" } else { "[-]" } - $color = if ($rr.Success) { "Green" } else { "Red" } - Write-Host " $status Restore Test: $($rr.FileCount) files" -ForegroundColor $color - } - - # Errors and warnings - if ($script:Stats.Warnings.Count -gt 0) { - Write-Host "`nWARNINGS:" -ForegroundColor Yellow - $script:Stats.Warnings | ForEach-Object { Write-Host " [!] $_" -ForegroundColor Yellow } - } - - if ($script:Stats.Errors.Count -gt 0) { - Write-Host "`nERRORS:" -ForegroundColor Red - $script:Stats.Errors | ForEach-Object { Write-Host " [-] $_" -ForegroundColor Red } - } - - if ($IncludeFileList -and $script:Stats.FailedFiles.Count -gt 0) { - Write-Host "`nFAILED FILES:" -ForegroundColor Red - $script:Stats.FailedFiles | ForEach-Object { - if ($_ -is [string]) { - Write-Host " [-] $_" -ForegroundColor Red - } - else { - Write-Host " [-] $($_.Path)" -ForegroundColor Red - } - } - } - - Write-Host "`n$separator`n" -ForegroundColor Cyan -} - -function Export-HTMLReport { - <# - .SYNOPSIS - Generates an HTML integrity report. - #> - param( - [string]$OutputPath, - [hashtable]$Results - ) - - if (-not $OutputPath) { $OutputPath = Split-Path $BackupPath -Parent } - - $htmlPath = Join-Path $OutputPath "integrity-report_$(Get-Date -Format 'yyyyMMdd_HHmmss').html" - $duration = (Get-Date) - $script:StartTime - $overallSuccess = ($script:Stats.Errors.Count -eq 0) -and ($script:Stats.FilesFailed -eq 0) - $statusClass = if ($overallSuccess) { 'success' } else { 'error' } - $statusText = if ($overallSuccess) { 'PASSED' } else { 'FAILED' } - - $html = @" - - - - Backup Integrity Report - - - -
-

Backup Integrity Report

-

Backup: $BackupPath

-

Test Type: $TestType | Duration: $($duration.ToString('hh\:mm\:ss')) | Date: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')

- -

Overall Status: $statusText

- -

Test Results

- - - $(if ($Results.ArchiveValid -ne $null) { - $class = if ($Results.ArchiveValid) { 'success' } else { 'error' } - $text = if ($Results.ArchiveValid) { 'Passed' } else { 'Failed' } - "" - }) - $(if ($Results.HashVerification) { - $hv = $Results.HashVerification - if ($hv.Skipped) { - "" - } else { - $class = if ($hv.Failed -eq 0) { 'success' } else { 'error' } - $text = if ($hv.Failed -eq 0) { 'Passed' } else { 'Failed' } - "" - } - }) - $(if ($Results.RestoreResult) { - $rr = $Results.RestoreResult - $class = if ($rr.Success) { 'success' } else { 'error' } - $text = if ($rr.Success) { 'Passed' } else { 'Failed' } - "" - }) -
TestResultDetails
Archive Structure$text$($Results.FileCount) files
Hash VerificationSkippedNo metadata available
Hash Verification$text$($hv.Verified) verified, $($hv.Failed) failed
Restore Test$text$($rr.FileCount) files restored
- - $(if ($script:Stats.Errors.Count -gt 0) { - "

Errors

    " + - ($script:Stats.Errors | ForEach-Object { "
  • $_
  • " }) + - "
" - }) -
- - -"@ - - $html | Out-File -FilePath $htmlPath -Encoding UTF8 - Write-Success "HTML report saved: $htmlPath" -} - -function Export-JSONReport { - <# - .SYNOPSIS - Generates a JSON integrity report. - #> - param( - [string]$OutputPath, - [hashtable]$Results - ) - - if (-not $OutputPath) { $OutputPath = Split-Path $BackupPath -Parent } - - $jsonPath = Join-Path $OutputPath "integrity-report_$(Get-Date -Format 'yyyyMMdd_HHmmss').json" - - $report = @{ - BackupPath = $BackupPath - TestType = $TestType - TestDate = Get-Date -Format "o" - Duration = ((Get-Date) - $script:StartTime).ToString() - OverallSuccess = ($script:Stats.Errors.Count -eq 0) -and ($script:Stats.FilesFailed -eq 0) - Results = $Results - Statistics = $script:Stats - } - - $report | ConvertTo-Json -Depth 10 | Out-File -FilePath $jsonPath -Encoding UTF8 - Write-Success "JSON report saved: $jsonPath" -} -#endregion - -#region Main Execution -try { - Write-Host "" - Write-InfoMessage "========================================" - Write-InfoMessage " Backup Integrity Test v$script:ScriptVersion" - Write-InfoMessage "========================================" - - # Validate parameters - if ($TestType -eq 'Restore' -and -not $RestoreTarget) { - Write-ErrorMessage "RestoreTarget is required when TestType is 'Restore'" - exit 1 - } - - # Get backup info - $backupInfo = Get-BackupInfo -Path $BackupPath - Write-InfoMessage "Backup: $(Format-FileSize $backupInfo.Size), $($backupInfo.FileCount) files" - - $results = @{ - BackupInfo = $backupInfo - } - - # Test archive structure (if ZIP) - if ($backupInfo.IsArchive) { - $archiveTest = Test-ArchiveStructure -ArchivePath $BackupPath - $results.ArchiveValid = $archiveTest.Valid - $results.FileCount = $archiveTest.EntryCount - - if (-not $archiveTest.Valid) { - Write-ErrorMessage "Archive is corrupted, cannot continue" - Write-ConsoleReport -Results $results - exit 1 - } - } - - # Get metadata - $metadata = Get-BackupMetadata -BackupPath $BackupPath -IsArchive $backupInfo.IsArchive - $results.HasMetadata = ($null -ne $metadata) - - # Perform tests based on TestType - switch ($TestType) { - 'Quick' { - # Quick: Test archive readability and sample hashes - if ($backupInfo.IsArchive) { - $extractTest = Test-FileExtraction -ArchivePath $BackupPath - $results.ExtractionTest = $extractTest - } - - # Sample hash verification (need to extract for this) - if ($metadata -and $metadata.FileHashes) { - $tempPath = Expand-BackupToTemp -ArchivePath $BackupPath - if ($tempPath) { - $hashResult = Test-FileHashes -FolderPath $tempPath -Metadata $metadata -SamplePercent $SamplePercent - $results.HashVerification = $hashResult - Remove-TempFolder -Path $tempPath - } - } - else { - $results.HashVerification = @{ Skipped = $true } - } - } - - 'Full' { - # Full: Extract everything and verify all hashes - if ($backupInfo.IsArchive) { - $tempPath = Expand-BackupToTemp -ArchivePath $BackupPath - } - else { - $tempPath = $BackupPath - } - - if ($tempPath) { - $hashResult = Test-FileHashes -FolderPath $tempPath -Metadata $metadata -SamplePercent 100 - $results.HashVerification = $hashResult - - if ($CleanupAfterTest -and $backupInfo.IsArchive) { - Remove-TempFolder -Path $tempPath - } - } - } - - 'Restore' { - # Restore: Actually restore and verify - $restoreResult = Restore-ToTarget -BackupPath $BackupPath -TargetPath $RestoreTarget -IsArchive $backupInfo.IsArchive - $results.RestoreResult = $restoreResult - - if ($restoreResult.Success -and $metadata) { - $hashResult = Test-FileHashes -FolderPath $RestoreTarget -Metadata $metadata -SamplePercent 100 - $results.HashVerification = $hashResult - } - - if ($CleanupAfterTest -and $restoreResult.Success) { - Remove-TempFolder -Path $RestoreTarget - } - } - } - - # Generate reports - switch ($OutputFormat) { - 'Console' { Write-ConsoleReport -Results $results } - 'HTML' { Write-ConsoleReport -Results $results; Export-HTMLReport -OutputPath $OutputPath -Results $results } - 'JSON' { Write-ConsoleReport -Results $results; Export-JSONReport -OutputPath $OutputPath -Results $results } - 'All' { - Write-ConsoleReport -Results $results - Export-HTMLReport -OutputPath $OutputPath -Results $results - Export-JSONReport -OutputPath $OutputPath -Results $results - } - } - - # Exit code based on results - $success = ($script:Stats.Errors.Count -eq 0) -and ($script:Stats.FilesFailed -eq 0) - if ($success) { - Write-Success "Backup integrity verified successfully" - exit 0 - } - else { - Write-ErrorMessage "Backup integrity check failed" - exit 1 - } -} -catch { - Write-ErrorMessage "Fatal error: $($_.Exception.Message)" - Write-ErrorMessage "Stack trace: $($_.ScriptStackTrace)" - exit 1 -} -finally { - # Cleanup temp folder if it exists - if ($script:TempFolder -and (Test-Path $script:TempFolder)) { - Remove-TempFolder -Path $script:TempFolder - } -} -#endregion diff --git a/Windows/development/Manage-WSL.ps1 b/Windows/development/Manage-WSL.ps1 deleted file mode 100644 index b0221f4..0000000 --- a/Windows/development/Manage-WSL.ps1 +++ /dev/null @@ -1,1035 +0,0 @@ -#!/usr/bin/env pwsh - -<# -.SYNOPSIS - Manages WSL2 (Windows Subsystem for Linux) installation, configuration, and maintenance. - -.DESCRIPTION - This script provides comprehensive WSL2 management capabilities: - - Install and configure WSL2 - - Manage WSL distributions (install, remove, list) - - Export and import distributions for backup/migration - - Configure WSL resource limits (.wslconfig) - - Manage WSL network settings - - Troubleshoot common WSL issues - - Start/stop/restart WSL - - Features: - - Distribution installation from Microsoft Store or custom images - - Backup and restore distributions - - Memory and CPU limit configuration - - Network troubleshooting - - Integration with Windows Terminal - -.PARAMETER Action - The action to perform. Valid values: - - Status: Show WSL status and installed distributions - - Install: Install WSL2 and optionally a distribution - - List: List available distributions - - Export: Export a distribution to a file - - Import: Import a distribution from a file - - Remove: Remove a distribution - - Configure: Configure WSL settings (.wslconfig) - - Start: Start WSL - - Stop: Stop all WSL instances - - Restart: Restart WSL - - Troubleshoot: Diagnose WSL issues - - Update: Update WSL kernel - - SetDefault: Set default distribution - Default: Status - -.PARAMETER Distribution - Name of the WSL distribution to manage. - -.PARAMETER ExportPath - Path for export/import operations. - -.PARAMETER MemoryLimit - Memory limit for WSL (e.g., "4GB", "8GB"). Used with Configure action. - -.PARAMETER ProcessorCount - Number of logical processors for WSL. Used with Configure action. - -.PARAMETER SwapSize - Swap file size (e.g., "2GB", "4GB"). Used with Configure action. - -.PARAMETER InstallLocation - Custom installation location for importing distributions. - -.PARAMETER Version - WSL version (1 or 2). Default: 2 - -.PARAMETER OutputFormat - Output format. Valid values: Console, JSON, HTML. - Default: Console - -.EXAMPLE - .\Manage-WSL.ps1 -Action Status - Shows WSL status and installed distributions. - -.EXAMPLE - .\Manage-WSL.ps1 -Action Install -Distribution Ubuntu - Installs WSL2 and Ubuntu distribution. - -.EXAMPLE - .\Manage-WSL.ps1 -Action Export -Distribution Ubuntu -ExportPath "D:\Backups\ubuntu.tar" - Exports Ubuntu distribution to a backup file. - -.EXAMPLE - .\Manage-WSL.ps1 -Action Import -Distribution MyUbuntu -ExportPath "D:\Backups\ubuntu.tar" -InstallLocation "D:\WSL\Ubuntu" - Imports a distribution from backup. - -.EXAMPLE - .\Manage-WSL.ps1 -Action Configure -MemoryLimit "8GB" -ProcessorCount 4 -SwapSize "4GB" - Configures WSL resource limits. - -.EXAMPLE - .\Manage-WSL.ps1 -Action Troubleshoot - Runs WSL diagnostics and suggests fixes. - -.EXAMPLE - .\Manage-WSL.ps1 -Action List - Lists all available distributions from Microsoft Store. - -.NOTES - File Name : Manage-WSL.ps1 - Author : Windows & Linux Sysadmin Toolkit - Prerequisite : PowerShell 5.1+ (PowerShell 7+ recommended) - Windows 10 version 1903+ or Windows 11 - Version : 1.0.0 - Creation Date : 2025-11-30 - - Administrator privileges required for: - - Installing/removing WSL - - Installing distributions - - Modifying Windows features - - WSL Config File Location: %USERPROFILE%\.wslconfig - - Change Log: - - 1.0.0 (2025-11-30): Initial release - -.LINK - https://github.com/Dashtid/sysadmin-toolkit -#> - -#Requires -Version 5.1 - -[CmdletBinding(SupportsShouldProcess = $true)] -param( - [Parameter(Position = 0)] - [ValidateSet('Status', 'Install', 'List', 'Export', 'Import', 'Remove', 'Configure', 'Start', 'Stop', 'Restart', 'Troubleshoot', 'Update', 'SetDefault')] - [string]$Action = 'Status', - - [Parameter()] - [string]$Distribution, - - [Parameter()] - [string]$ExportPath, - - [Parameter()] - [string]$MemoryLimit, - - [Parameter()] - [ValidateRange(1, 64)] - [int]$ProcessorCount, - - [Parameter()] - [string]$SwapSize, - - [Parameter()] - [string]$InstallLocation, - - [Parameter()] - [ValidateSet(1, 2)] - [int]$Version = 2, - - [Parameter()] - [ValidateSet('Console', 'JSON', 'HTML')] - [string]$OutputFormat = 'Console' -) - -#region Module Imports -$modulePath = Join-Path -Path $PSScriptRoot -ChildPath "..\lib\CommonFunctions.psm1" -if (Test-Path $modulePath) { - Import-Module $modulePath -Force -} -else { - # Fallback logging functions if module not found - function Write-Success { param([string]$Message) Write-Host "[+] $Message" -ForegroundColor Green } - function Write-InfoMessage { param([string]$Message) Write-Host "[i] $Message" -ForegroundColor Blue } - function Write-WarningMessage { param([string]$Message) Write-Host "[!] $Message" -ForegroundColor Yellow } - function Write-ErrorMessage { param([string]$Message) Write-Host "[-] $Message" -ForegroundColor Red } - function Get-LogDirectory { return Join-Path $PSScriptRoot "..\..\logs" } - function Test-IsAdministrator { - $currentIdentity = [Security.Principal.WindowsIdentity]::GetCurrent() - $principal = New-Object Security.Principal.WindowsPrincipal($currentIdentity) - return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) - } -} -#endregion - -#region Configuration -$script:StartTime = Get-Date -$script:ScriptVersion = "1.0.0" -$script:WslConfigPath = Join-Path $env:USERPROFILE ".wslconfig" - -# Available distributions -$script:AvailableDistros = @{ - 'Ubuntu' = 'Ubuntu' - 'Ubuntu-20.04' = 'Ubuntu 20.04 LTS' - 'Ubuntu-22.04' = 'Ubuntu 22.04 LTS' - 'Ubuntu-24.04' = 'Ubuntu 24.04 LTS' - 'Debian' = 'Debian GNU/Linux' - 'kali-linux' = 'Kali Linux' - 'openSUSE-Leap-15.5' = 'openSUSE Leap 15.5' - 'SLES-15' = 'SUSE Linux Enterprise Server 15' - 'OracleLinux_9_1' = 'Oracle Linux 9.1' - 'AlmaLinux-9' = 'AlmaLinux 9' -} -#endregion - -#region Helper Functions -function Test-WslInstalled { - try { - $wslPath = Get-Command wsl.exe -ErrorAction SilentlyContinue - return ($null -ne $wslPath) - } - catch { - return $false - } -} - -function Test-WslEnabled { - try { - $wslFeature = Get-WindowsOptionalFeature -Online -FeatureName Microsoft-Windows-Subsystem-Linux -ErrorAction SilentlyContinue - $vmFeature = Get-WindowsOptionalFeature -Online -FeatureName VirtualMachinePlatform -ErrorAction SilentlyContinue - return ($wslFeature.State -eq 'Enabled' -and $vmFeature.State -eq 'Enabled') - } - catch { - return $false - } -} - -function Get-WslVersion { - try { - $version = wsl --version 2>&1 - if ($LASTEXITCODE -eq 0) { - $versionLine = $version | Select-String "WSL version:" | ForEach-Object { $_.Line } - if ($versionLine -match 'WSL version:\s*(.+)') { - return $Matches[1].Trim() - } - } - return "Unknown" - } - catch { - return "Unknown" - } -} - -function Get-WslDistributions { - $distributions = @() - - try { - # Get verbose list - $wslList = wsl --list --verbose 2>&1 - - if ($LASTEXITCODE -eq 0 -and $wslList) { - # Parse the output (skip header line) - $lines = $wslList -split "`n" | Where-Object { $_ -match '\S' } | Select-Object -Skip 1 - - foreach ($line in $lines) { - # Handle default marker (*) and parse columns - $isDefault = $line.StartsWith('*') - $cleanLine = $line.TrimStart('*', ' ') - - # Parse: NAME STATE VERSION - if ($cleanLine -match '^(\S+)\s+(\S+)\s+(\d+)') { - $distributions += [PSCustomObject]@{ - Name = $Matches[1] - State = $Matches[2] - Version = [int]$Matches[3] - IsDefault = $isDefault - } - } - } - } - } - catch { - Write-WarningMessage "Failed to get distribution list: $($_.Exception.Message)" - } - - return $distributions -} - -function Get-WslStatus { - $status = [PSCustomObject]@{ - WslInstalled = Test-WslInstalled - WslEnabled = $false - WslVersion = "Unknown" - DefaultVersion = 2 - Distributions = @() - RunningCount = 0 - ConfigFile = $null - } - - if ($status.WslInstalled) { - $status.WslEnabled = Test-WslEnabled - $status.WslVersion = Get-WslVersion - $status.Distributions = Get-WslDistributions - - # Count running distributions - $status.RunningCount = ($status.Distributions | Where-Object { $_.State -eq "Running" }).Count - - # Check for config file - if (Test-Path $script:WslConfigPath) { - $status.ConfigFile = Get-Content $script:WslConfigPath -Raw - } - - # Get default version - try { - $defaultVersion = wsl --status 2>&1 | Select-String "Default Version:" | ForEach-Object { $_.Line } - if ($defaultVersion -match 'Default Version:\s*(\d+)') { - $status.DefaultVersion = [int]$Matches[1] - } - } - catch { } - } - - return $status -} - -function Show-WslStatus { - $status = Get-WslStatus - - Write-Host "" - Write-Host "======================================" -ForegroundColor Cyan - Write-Host " WSL STATUS" -ForegroundColor Cyan - Write-Host "======================================" -ForegroundColor Cyan - Write-Host "" - - # WSL Installation Status - Write-Host "WSL Installation:" -ForegroundColor White - if ($status.WslInstalled) { - Write-Host " [+] WSL is installed" -ForegroundColor Green - Write-Host " [i] WSL Version: $($status.WslVersion)" -ForegroundColor Blue - Write-Host " [i] Default WSL Version: $($status.DefaultVersion)" -ForegroundColor Blue - } - else { - Write-Host " [-] WSL is not installed" -ForegroundColor Red - Write-Host " [i] Run: wsl --install" -ForegroundColor Yellow - return - } - - # Windows Features - Write-Host "" - Write-Host "Windows Features:" -ForegroundColor White - if ($status.WslEnabled) { - Write-Host " [+] WSL feature enabled" -ForegroundColor Green - Write-Host " [+] Virtual Machine Platform enabled" -ForegroundColor Green - } - else { - Write-Host " [!] Some required features may not be enabled" -ForegroundColor Yellow - } - - # Distributions - Write-Host "" - Write-Host "Installed Distributions:" -ForegroundColor White - if ($status.Distributions.Count -eq 0) { - Write-Host " [!] No distributions installed" -ForegroundColor Yellow - Write-Host " [i] Run: wsl --install -d Ubuntu" -ForegroundColor Blue - } - else { - foreach ($distro in $status.Distributions) { - $stateColor = switch ($distro.State) { - "Running" { "Green" } - "Stopped" { "Gray" } - default { "White" } - } - $defaultMarker = if ($distro.IsDefault) { " (default)" } else { "" } - Write-Host " - $($distro.Name)$defaultMarker" -ForegroundColor White - Write-Host " State: " -NoNewline -ForegroundColor Gray - Write-Host "$($distro.State)" -ForegroundColor $stateColor - Write-Host " Version: WSL$($distro.Version)" -ForegroundColor Gray - } - } - - # Running instances - Write-Host "" - Write-Host "Running Instances: $($status.RunningCount)" -ForegroundColor $(if ($status.RunningCount -gt 0) { "Green" } else { "Gray" }) - - # Config file - Write-Host "" - Write-Host "Configuration:" -ForegroundColor White - if ($status.ConfigFile) { - Write-Host " [+] .wslconfig exists" -ForegroundColor Green - Write-Host " [i] Path: $($script:WslConfigPath)" -ForegroundColor Blue - } - else { - Write-Host " [i] No .wslconfig file (using defaults)" -ForegroundColor Gray - } -} - -function Install-Wsl { - param( - [string]$Distro, - [int]$WslVersion = 2 - ) - - if (-not (Test-IsAdministrator)) { - Write-ErrorMessage "Administrator privileges required to install WSL" - return $false - } - - Write-InfoMessage "Installing WSL..." - - try { - if ($Distro) { - # Install WSL with specific distribution - Write-InfoMessage "Installing WSL with $Distro..." - $result = wsl --install -d $Distro 2>&1 - } - else { - # Install WSL with default Ubuntu - Write-InfoMessage "Installing WSL with default distribution..." - $result = wsl --install 2>&1 - } - - if ($LASTEXITCODE -eq 0) { - Write-Success "WSL installation initiated" - Write-WarningMessage "A system restart may be required to complete installation" - return $true - } - else { - # Check if already installed - if ($result -match "already installed") { - Write-InfoMessage "WSL is already installed" - return $true - } - Write-ErrorMessage "Installation failed: $result" - return $false - } - } - catch { - Write-ErrorMessage "Installation error: $($_.Exception.Message)" - return $false - } -} - -function Export-WslDistribution { - param( - [string]$Name, - [string]$Path - ) - - $distros = Get-WslDistributions - $distro = $distros | Where-Object { $_.Name -eq $Name } - - if (-not $distro) { - Write-ErrorMessage "Distribution '$Name' not found" - return $false - } - - # Ensure directory exists - $exportDir = Split-Path $Path -Parent - if ($exportDir -and -not (Test-Path $exportDir)) { - New-Item -ItemType Directory -Path $exportDir -Force | Out-Null - } - - Write-InfoMessage "Exporting '$Name' to '$Path'..." - Write-InfoMessage "This may take several minutes depending on distribution size..." - - try { - $startTime = Get-Date - $result = wsl --export $Name $Path 2>&1 - - if ($LASTEXITCODE -eq 0) { - $duration = (Get-Date) - $startTime - $fileSize = (Get-Item $Path).Length / 1GB - - Write-Success "Export completed successfully" - Write-InfoMessage "Duration: $($duration.TotalMinutes.ToString('F1')) minutes" - Write-InfoMessage "File size: $($fileSize.ToString('F2')) GB" - return $true - } - else { - Write-ErrorMessage "Export failed: $result" - return $false - } - } - catch { - Write-ErrorMessage "Export error: $($_.Exception.Message)" - return $false - } -} - -function Import-WslDistribution { - param( - [string]$Name, - [string]$Path, - [string]$Location, - [int]$WslVersion = 2 - ) - - if (-not (Test-Path $Path)) { - Write-ErrorMessage "Import file not found: $Path" - return $false - } - - # Create install location if it doesn't exist - if (-not (Test-Path $Location)) { - New-Item -ItemType Directory -Path $Location -Force | Out-Null - } - - Write-InfoMessage "Importing '$Name' from '$Path'..." - Write-InfoMessage "Install location: $Location" - Write-InfoMessage "This may take several minutes..." - - try { - $startTime = Get-Date - $result = wsl --import $Name $Location $Path --version $WslVersion 2>&1 - - if ($LASTEXITCODE -eq 0) { - $duration = (Get-Date) - $startTime - - Write-Success "Import completed successfully" - Write-InfoMessage "Duration: $($duration.TotalMinutes.ToString('F1')) minutes" - return $true - } - else { - Write-ErrorMessage "Import failed: $result" - return $false - } - } - catch { - Write-ErrorMessage "Import error: $($_.Exception.Message)" - return $false - } -} - -function Remove-WslDistribution { - param([string]$Name) - - $distros = Get-WslDistributions - $distro = $distros | Where-Object { $_.Name -eq $Name } - - if (-not $distro) { - Write-ErrorMessage "Distribution '$Name' not found" - return $false - } - - Write-WarningMessage "This will permanently delete '$Name' and all its data!" - - try { - $result = wsl --unregister $Name 2>&1 - - if ($LASTEXITCODE -eq 0) { - Write-Success "Distribution '$Name' removed successfully" - return $true - } - else { - Write-ErrorMessage "Removal failed: $result" - return $false - } - } - catch { - Write-ErrorMessage "Removal error: $($_.Exception.Message)" - return $false - } -} - -function Set-WslConfiguration { - param( - [string]$Memory, - [int]$Processors, - [string]$Swap, - [string]$ConfigPath = $script:WslConfigPath - ) - - # Read existing config if present - if (Test-Path $ConfigPath) { - Write-InfoMessage "Existing .wslconfig found, merging settings..." - } - - # Build config content - $configContent = "[wsl2]`n" - - if ($Memory) { - $configContent += "memory=$Memory`n" - Write-InfoMessage "Setting memory limit: $Memory" - } - - if ($Processors -gt 0) { - $configContent += "processors=$Processors`n" - Write-InfoMessage "Setting processor count: $Processors" - } - - if ($Swap) { - $configContent += "swap=$Swap`n" - Write-InfoMessage "Setting swap size: $Swap" - } - - # Add some recommended settings - $configContent += @" -localhostForwarding=true -nestedVirtualization=true -"@ - - try { - $configContent | Out-File -FilePath $ConfigPath -Encoding UTF8 -Force - Write-Success "WSL configuration saved to: $ConfigPath" - Write-InfoMessage "Restart WSL for changes to take effect: wsl --shutdown" - return $true - } - catch { - Write-ErrorMessage "Failed to save configuration: $($_.Exception.Message)" - return $false - } -} - -function Invoke-WslCommand { - param( - [string]$Command, - [string]$Distro - ) - - if ($Distro) { - $result = wsl -d $Distro -- $Command 2>&1 - } - else { - $result = wsl -- $Command 2>&1 - } - - return $result -} - -function Start-WslDistribution { - param([string]$Name) - - if ($Name) { - Write-InfoMessage "Starting $Name..." - $null = wsl -d $Name -- echo "Started" 2>&1 - } - else { - Write-InfoMessage "Starting default distribution..." - $null = wsl -- echo "Started" 2>&1 - } - - if ($LASTEXITCODE -eq 0) { - Write-Success "WSL started" - return $true - } - else { - Write-ErrorMessage "Failed to start WSL" - return $false - } -} - -function Stop-WslInstances { - Write-InfoMessage "Stopping all WSL instances..." - - try { - $result = wsl --shutdown 2>&1 - - if ($LASTEXITCODE -eq 0) { - Write-Success "All WSL instances stopped" - return $true - } - else { - Write-ErrorMessage "Failed to stop WSL: $result" - return $false - } - } - catch { - Write-ErrorMessage "Error stopping WSL: $($_.Exception.Message)" - return $false - } -} - -function Update-WslKernel { - Write-InfoMessage "Updating WSL kernel..." - - try { - $result = wsl --update 2>&1 - - if ($LASTEXITCODE -eq 0) { - Write-Success "WSL kernel updated" - return $true - } - else { - Write-InfoMessage "Update result: $result" - return $true - } - } - catch { - Write-ErrorMessage "Update error: $($_.Exception.Message)" - return $false - } -} - -function Set-WslDefault { - param([string]$Name) - - $distros = Get-WslDistributions - $distro = $distros | Where-Object { $_.Name -eq $Name } - - if (-not $distro) { - Write-ErrorMessage "Distribution '$Name' not found" - return $false - } - - try { - $result = wsl --set-default $Name 2>&1 - - if ($LASTEXITCODE -eq 0) { - Write-Success "'$Name' set as default distribution" - return $true - } - else { - Write-ErrorMessage "Failed to set default: $result" - return $false - } - } - catch { - Write-ErrorMessage "Error: $($_.Exception.Message)" - return $false - } -} - -function Invoke-WslTroubleshoot { - Write-InfoMessage "Running WSL diagnostics..." - Write-Host "" - - $results = @() - - # 1. Check WSL installation - Write-Host "1. Checking WSL installation..." -ForegroundColor Cyan - if (Test-WslInstalled) { - $results += [PSCustomObject]@{ Check = "WSL Installed"; Status = "PASS"; Details = "wsl.exe found" } - Write-Host " [+] WSL is installed" -ForegroundColor Green - } - else { - $results += [PSCustomObject]@{ Check = "WSL Installed"; Status = "FAIL"; Details = "wsl.exe not found" } - Write-Host " [-] WSL is not installed" -ForegroundColor Red - Write-Host " [i] Run: wsl --install" -ForegroundColor Yellow - } - - # 2. Check Windows features - Write-Host "2. Checking Windows features..." -ForegroundColor Cyan - try { - $wslFeature = Get-WindowsOptionalFeature -Online -FeatureName Microsoft-Windows-Subsystem-Linux -ErrorAction Stop - if ($wslFeature.State -eq 'Enabled') { - $results += [PSCustomObject]@{ Check = "WSL Feature"; Status = "PASS"; Details = "Enabled" } - Write-Host " [+] Windows Subsystem for Linux: Enabled" -ForegroundColor Green - } - else { - $results += [PSCustomObject]@{ Check = "WSL Feature"; Status = "FAIL"; Details = "Disabled" } - Write-Host " [-] Windows Subsystem for Linux: Disabled" -ForegroundColor Red - } - - $vmFeature = Get-WindowsOptionalFeature -Online -FeatureName VirtualMachinePlatform -ErrorAction Stop - if ($vmFeature.State -eq 'Enabled') { - $results += [PSCustomObject]@{ Check = "VM Platform"; Status = "PASS"; Details = "Enabled" } - Write-Host " [+] Virtual Machine Platform: Enabled" -ForegroundColor Green - } - else { - $results += [PSCustomObject]@{ Check = "VM Platform"; Status = "FAIL"; Details = "Disabled" } - Write-Host " [-] Virtual Machine Platform: Disabled" -ForegroundColor Red - } - } - catch { - $results += [PSCustomObject]@{ Check = "Windows Features"; Status = "ERROR"; Details = $_.Exception.Message } - Write-Host " [!] Cannot check Windows features (run as admin)" -ForegroundColor Yellow - } - - # 3. Check virtualization - Write-Host "3. Checking virtualization..." -ForegroundColor Cyan - try { - $cpu = Get-CimInstance -ClassName Win32_Processor - if ($cpu.VirtualizationFirmwareEnabled) { - $results += [PSCustomObject]@{ Check = "Virtualization"; Status = "PASS"; Details = "Enabled in BIOS" } - Write-Host " [+] Virtualization enabled in BIOS" -ForegroundColor Green - } - else { - $results += [PSCustomObject]@{ Check = "Virtualization"; Status = "WARN"; Details = "May need BIOS enable" } - Write-Host " [!] Virtualization may need to be enabled in BIOS" -ForegroundColor Yellow - } - } - catch { - $results += [PSCustomObject]@{ Check = "Virtualization"; Status = "UNKNOWN"; Details = "Cannot determine" } - Write-Host " [?] Cannot determine virtualization status" -ForegroundColor Gray - } - - # 4. Check Hyper-V - Write-Host "4. Checking Hyper-V compatibility..." -ForegroundColor Cyan - try { - $hyperv = Get-WindowsOptionalFeature -Online -FeatureName Microsoft-Hyper-V-All -ErrorAction SilentlyContinue - if ($hyperv) { - $results += [PSCustomObject]@{ Check = "Hyper-V"; Status = "INFO"; Details = $hyperv.State } - Write-Host " [i] Hyper-V: $($hyperv.State)" -ForegroundColor Blue - } - else { - $results += [PSCustomObject]@{ Check = "Hyper-V"; Status = "INFO"; Details = "Not available" } - Write-Host " [i] Hyper-V not available (may not be required)" -ForegroundColor Gray - } - } - catch { } - - # 5. Check WSL version - Write-Host "5. Checking WSL version..." -ForegroundColor Cyan - $wslVersion = Get-WslVersion - $results += [PSCustomObject]@{ Check = "WSL Version"; Status = "INFO"; Details = $wslVersion } - Write-Host " [i] WSL Version: $wslVersion" -ForegroundColor Blue - - # 6. Check distributions - Write-Host "6. Checking distributions..." -ForegroundColor Cyan - $distros = Get-WslDistributions - if ($distros.Count -gt 0) { - $results += [PSCustomObject]@{ Check = "Distributions"; Status = "PASS"; Details = "$($distros.Count) installed" } - Write-Host " [+] $($distros.Count) distribution(s) installed" -ForegroundColor Green - foreach ($d in $distros) { - $stateIcon = if ($d.State -eq "Running") { "[+]" } else { "[ ]" } - Write-Host " $stateIcon $($d.Name) (WSL$($d.Version))" -ForegroundColor $(if ($d.State -eq "Running") { "Green" } else { "Gray" }) - } - } - else { - $results += [PSCustomObject]@{ Check = "Distributions"; Status = "WARN"; Details = "None installed" } - Write-Host " [!] No distributions installed" -ForegroundColor Yellow - } - - # 7. Check networking - Write-Host "7. Checking WSL networking..." -ForegroundColor Cyan - try { - $wslAdapter = Get-NetAdapter | Where-Object { $_.Name -match "WSL|vEthernet" } - if ($wslAdapter) { - $results += [PSCustomObject]@{ Check = "WSL Network"; Status = "PASS"; Details = "Adapter found" } - Write-Host " [+] WSL network adapter found" -ForegroundColor Green - } - else { - $results += [PSCustomObject]@{ Check = "WSL Network"; Status = "INFO"; Details = "No adapter (may be normal)" } - Write-Host " [i] No WSL network adapter (normal if WSL not running)" -ForegroundColor Gray - } - } - catch { - Write-Host " [?] Cannot check network adapters" -ForegroundColor Gray - } - - # 8. Check .wslconfig - Write-Host "8. Checking .wslconfig..." -ForegroundColor Cyan - if (Test-Path $script:WslConfigPath) { - $results += [PSCustomObject]@{ Check = ".wslconfig"; Status = "PASS"; Details = "File exists" } - Write-Host " [+] .wslconfig exists" -ForegroundColor Green - } - else { - $results += [PSCustomObject]@{ Check = ".wslconfig"; Status = "INFO"; Details = "Using defaults" } - Write-Host " [i] No .wslconfig (using defaults)" -ForegroundColor Gray - } - - # Summary - Write-Host "" - Write-Host "======================================" -ForegroundColor Cyan - Write-Host " DIAGNOSTIC SUMMARY" -ForegroundColor Cyan - Write-Host "======================================" -ForegroundColor Cyan - - $passCount = ($results | Where-Object { $_.Status -eq "PASS" }).Count - $failCount = ($results | Where-Object { $_.Status -eq "FAIL" }).Count - $warnCount = ($results | Where-Object { $_.Status -eq "WARN" }).Count - - Write-Host "Passed: $passCount" -ForegroundColor Green - Write-Host "Failed: $failCount" -ForegroundColor Red - Write-Host "Warnings: $warnCount" -ForegroundColor Yellow - - # Recommendations - if ($failCount -gt 0) { - Write-Host "" - Write-Host "Recommendations:" -ForegroundColor Cyan - - if (($results | Where-Object { $_.Check -eq "WSL Installed" -and $_.Status -eq "FAIL" })) { - Write-Host " - Install WSL: wsl --install" -ForegroundColor White - } - if (($results | Where-Object { $_.Check -eq "WSL Feature" -and $_.Status -eq "FAIL" })) { - Write-Host " - Enable WSL feature (run as admin):" -ForegroundColor White - Write-Host " dism.exe /online /enable-feature /featurename:Microsoft-Windows-Subsystem-Linux /all /norestart" -ForegroundColor Gray - } - if (($results | Where-Object { $_.Check -eq "VM Platform" -and $_.Status -eq "FAIL" })) { - Write-Host " - Enable Virtual Machine Platform (run as admin):" -ForegroundColor White - Write-Host " dism.exe /online /enable-feature /featurename:VirtualMachinePlatform /all /norestart" -ForegroundColor Gray - } - if (($results | Where-Object { $_.Check -eq "Virtualization" -and $_.Status -eq "WARN" })) { - Write-Host " - Enable virtualization in BIOS/UEFI settings" -ForegroundColor White - } - } - - return $results -} - -function Show-AvailableDistributions { - Write-Host "" - Write-Host "Available Distributions:" -ForegroundColor Cyan - Write-Host "========================" -ForegroundColor Cyan - Write-Host "" - - # Get online list - try { - $onlineList = wsl --list --online 2>&1 - if ($LASTEXITCODE -eq 0) { - Write-Host $onlineList - } - else { - # Fallback to our known list - Write-Host "Common distributions:" -ForegroundColor White - foreach ($distro in $script:AvailableDistros.GetEnumerator()) { - Write-Host " - $($distro.Key): $($distro.Value)" -ForegroundColor Gray - } - } - } - catch { - Write-Host "Common distributions:" -ForegroundColor White - foreach ($distro in $script:AvailableDistros.GetEnumerator()) { - Write-Host " - $($distro.Key): $($distro.Value)" -ForegroundColor Gray - } - } - - Write-Host "" - Write-Host "Install with: wsl --install -d " -ForegroundColor Blue -} -#endregion - -#region Main Execution -function Invoke-WslManager { - <# - .SYNOPSIS - Dispatches the requested Action to the appropriate WSL helper. - #> - [CmdletBinding(SupportsShouldProcess = $true)] - param() - - Write-InfoMessage "WSL Manager v$($script:ScriptVersion)" - - switch ($Action) { - 'Status' { - Show-WslStatus - } - - 'Install' { - if ($PSCmdlet.ShouldProcess("WSL", "Install")) { - $success = Install-Wsl -Distro $Distribution -WslVersion $Version - exit $(if ($success) { 0 } else { 1 }) - } - } - - 'List' { - Show-AvailableDistributions - } - - 'Export' { - if (-not $Distribution -or -not $ExportPath) { - Write-ErrorMessage "Please specify -Distribution and -ExportPath" - exit 1 - } - - if ($PSCmdlet.ShouldProcess($Distribution, "Export to $ExportPath")) { - $success = Export-WslDistribution -Name $Distribution -Path $ExportPath - exit $(if ($success) { 0 } else { 1 }) - } - } - - 'Import' { - if (-not $Distribution -or -not $ExportPath -or -not $InstallLocation) { - Write-ErrorMessage "Please specify -Distribution, -ExportPath, and -InstallLocation" - exit 1 - } - - if ($PSCmdlet.ShouldProcess($Distribution, "Import from $ExportPath")) { - $success = Import-WslDistribution -Name $Distribution -Path $ExportPath -Location $InstallLocation -WslVersion $Version - exit $(if ($success) { 0 } else { 1 }) - } - } - - 'Remove' { - if (-not $Distribution) { - Write-ErrorMessage "Please specify -Distribution" - exit 1 - } - - if ($PSCmdlet.ShouldProcess($Distribution, "Remove distribution")) { - $success = Remove-WslDistribution -Name $Distribution - exit $(if ($success) { 0 } else { 1 }) - } - } - - 'Configure' { - if (-not $MemoryLimit -and $ProcessorCount -eq 0 -and -not $SwapSize) { - Write-ErrorMessage "Please specify at least one of: -MemoryLimit, -ProcessorCount, -SwapSize" - exit 1 - } - - if ($PSCmdlet.ShouldProcess(".wslconfig", "Update configuration")) { - $success = Set-WslConfiguration -Memory $MemoryLimit -Processors $ProcessorCount -Swap $SwapSize - exit $(if ($success) { 0 } else { 1 }) - } - } - - 'Start' { - $success = Start-WslDistribution -Name $Distribution - exit $(if ($success) { 0 } else { 1 }) - } - - 'Stop' { - if ($PSCmdlet.ShouldProcess("All WSL instances", "Stop")) { - $success = Stop-WslInstances - exit $(if ($success) { 0 } else { 1 }) - } - } - - 'Restart' { - if ($PSCmdlet.ShouldProcess("WSL", "Restart")) { - Write-InfoMessage "Stopping WSL..." - Stop-WslInstances - Start-Sleep -Seconds 2 - Write-InfoMessage "Starting WSL..." - Start-WslDistribution -Name $Distribution - } - } - - 'Troubleshoot' { - $results = Invoke-WslTroubleshoot - - if ($OutputFormat -eq 'JSON') { - $outputPath = Join-Path (Get-LogDirectory) "wsl_diagnostic_$(Get-Date -Format 'yyyyMMdd_HHmmss').json" - $results | ConvertTo-Json -Depth 5 | Out-File $outputPath -Encoding UTF8 - Write-Success "JSON report saved to: $outputPath" - } - } - - 'Update' { - if ($PSCmdlet.ShouldProcess("WSL Kernel", "Update")) { - $success = Update-WslKernel - exit $(if ($success) { 0 } else { 1 }) - } - } - - 'SetDefault' { - if (-not $Distribution) { - Write-ErrorMessage "Please specify -Distribution" - exit 1 - } - - if ($PSCmdlet.ShouldProcess($Distribution, "Set as default")) { - $success = Set-WslDefault -Name $Distribution - exit $(if ($success) { 0 } else { 1 }) - } - } - } - - $endTime = Get-Date - $duration = $endTime - $script:StartTime - Write-InfoMessage "Completed in $($duration.TotalSeconds.ToString('F1')) seconds" -} - -# Run Invoke-WslManager when invoked as a script. When dot-sourced for -# testing, skip auto-run so test files can load function definitions into scope. -if ($MyInvocation.InvocationName -ne '.') { - Invoke-WslManager -} -#endregion diff --git a/Windows/development/README.md b/Windows/development/README.md index b2eb015..848b926 100644 --- a/Windows/development/README.md +++ b/Windows/development/README.md @@ -1,28 +1,20 @@ # Development Environment Scripts -Setup and management for development tools on Windows. +> **Scope note (2026-06-14):** `Test-DevEnvironment.ps1` and `Manage-WSL.ps1` were removed in the ghost-code cull — `wsl.exe` covers WSL operations natively and the dev-env checker had no clear trigger. ## Scripts | Script | Purpose | |--------|---------| -| [Test-DevEnvironment.ps1](Test-DevEnvironment.ps1) | Validate installed dev tools (Git, Node, Python, VSCode) | -| [Manage-Docker.ps1](Manage-Docker.ps1) | Docker Desktop management and cleanup | -| [Manage-WSL.ps1](Manage-WSL.ps1) | WSL2 distribution backup, restore, and configuration | -| [remote-development-setup.ps1](remote-development-setup.ps1) | Configure SSH for remote development | +| [Manage-Docker.ps1](Manage-Docker.ps1) | Docker Desktop start/stop/cleanup helper | +| [remote-development-setup.ps1](remote-development-setup.ps1) | Configure SSH client for remote development | -## Quick Examples +## Quick Example ```powershell -# Check dev environment -.\Test-DevEnvironment.ps1 - -# Clean Docker images +# Cleanup Docker images .\Manage-Docker.ps1 -Cleanup -KeepVersions 2 - -# Backup WSL distribution -.\Manage-WSL.ps1 -Export -Distribution Ubuntu -Path "D:\Backups" ``` --- -**Last Updated**: 2025-12-26 +**Last Updated**: 2026-06-14 diff --git a/Windows/development/Test-DevEnvironment.ps1 b/Windows/development/Test-DevEnvironment.ps1 deleted file mode 100644 index 8c86b28..0000000 --- a/Windows/development/Test-DevEnvironment.ps1 +++ /dev/null @@ -1,1213 +0,0 @@ -#!/usr/bin/env pwsh - -<# -.SYNOPSIS - Validates development environment setup and identifies missing or misconfigured tools. - -.DESCRIPTION - This script provides comprehensive development environment validation: - - Verify installed development tools (Git, Node.js, Python, etc.) - - Check version requirements against minimum/recommended versions - - Validate PATH configuration - - Test SSH key setup for Git operations - - Verify IDE installations (VSCode, Visual Studio) - - Check package manager configurations - - Install missing tools automatically (optional) - - Generate detailed environment reports - - Features: - - Configurable tool requirements via JSON - - Automatic tool installation via package managers - - SSH key validation for GitHub/GitLab/Bitbucket - - Environment variable checks - - IDE extension verification - - Development-ready status assessment - -.PARAMETER Profile - Predefined development profile to validate against. Valid values: - - WebDev: Web development (Node.js, npm, Git, VSCode) - - Python: Python development (Python, pip, venv, Git) - - DevOps: DevOps tools (Docker, kubectl, terraform, Git) - - FullStack: Full stack (all common tools) - - Custom: Use custom requirements file - Default: FullStack - -.PARAMETER RequirementsFile - Path to custom JSON requirements file. Used with -Profile Custom. - -.PARAMETER AutoInstall - Automatically install missing tools using available package managers. - -.PARAMETER CheckSSH - Validate SSH key configuration for Git hosting services. - -.PARAMETER CheckExtensions - Check for recommended VS Code extensions. - -.PARAMETER OutputFormat - Output format. Valid values: Console, HTML, JSON. - Default: Console - -.PARAMETER OutputPath - Path for output report files. - -.PARAMETER Verbose - Show detailed information about each check. - -.EXAMPLE - .\Test-DevEnvironment.ps1 - Validates full stack development environment with console output. - -.EXAMPLE - .\Test-DevEnvironment.ps1 -Profile WebDev -AutoInstall - Validates web development environment and installs missing tools. - -.EXAMPLE - .\Test-DevEnvironment.ps1 -Profile Python -CheckSSH -OutputFormat HTML - Validates Python environment, checks SSH, and generates HTML report. - -.EXAMPLE - .\Test-DevEnvironment.ps1 -Profile Custom -RequirementsFile ".\my-requirements.json" - Validates against custom requirements file. - -.EXAMPLE - .\Test-DevEnvironment.ps1 -CheckExtensions -OutputFormat JSON -OutputPath "C:\Reports" - Validates environment with VS Code extensions and saves JSON report. - -.NOTES - File Name : Test-DevEnvironment.ps1 - Author : Windows & Linux Sysadmin Toolkit - Prerequisite : PowerShell 5.1+ (PowerShell 7+ recommended) - Version : 1.0.0 - Creation Date : 2025-11-30 - - Supported Package Managers: - - Winget (Windows Package Manager) - - Chocolatey - - Scoop - - SSH Key Locations: - - Windows: %USERPROFILE%\.ssh\ - - Git Bash: ~/.ssh/ - - Change Log: - - 1.0.0 (2025-11-30): Initial release - -.LINK - https://github.com/Dashtid/sysadmin-toolkit -#> - -#Requires -Version 5.1 - -[CmdletBinding()] -param( - [Parameter()] - [ValidateSet('WebDev', 'Python', 'DevOps', 'FullStack', 'Custom')] - [string]$Profile = 'FullStack', - - [Parameter()] - [string]$RequirementsFile, - - [Parameter()] - [switch]$AutoInstall, - - [Parameter()] - [switch]$CheckSSH, - - [Parameter()] - [switch]$CheckExtensions, - - [Parameter()] - [ValidateSet('Console', 'HTML', 'JSON')] - [string]$OutputFormat = 'Console', - - [Parameter()] - [string]$OutputPath -) - -#region Module Imports -$modulePath = Join-Path -Path $PSScriptRoot -ChildPath "..\lib\CommonFunctions.psm1" -if (Test-Path $modulePath) { - Import-Module $modulePath -Force -} -else { - # Fallback logging functions if module not found - function Write-Success { param([string]$Message) Write-Host "[+] $Message" -ForegroundColor Green } - function Write-InfoMessage { param([string]$Message) Write-Host "[i] $Message" -ForegroundColor Blue } - function Write-WarningMessage { param([string]$Message) Write-Host "[!] $Message" -ForegroundColor Yellow } - function Write-ErrorMessage { param([string]$Message) Write-Host "[-] $Message" -ForegroundColor Red } - function Get-LogDirectory { return Join-Path $PSScriptRoot "..\..\logs" } -} -#endregion - -#region Configuration -$script:StartTime = Get-Date -$script:ScriptVersion = "1.0.0" - -# Tool definitions with version parsing patterns -$script:ToolDefinitions = @{ - # Version Control - git = @{ - Name = "Git" - Command = "git" - VersionArgs = "--version" - VersionPattern = 'git version (\d+\.\d+\.\d+)' - MinVersion = "2.30.0" - Category = "Version Control" - WingetId = "Git.Git" - ChocoId = "git" - Required = $true - } - - # JavaScript/Node.js - node = @{ - Name = "Node.js" - Command = "node" - VersionArgs = "--version" - VersionPattern = 'v(\d+\.\d+\.\d+)' - MinVersion = "18.0.0" - Category = "JavaScript Runtime" - WingetId = "OpenJS.NodeJS.LTS" - ChocoId = "nodejs-lts" - Required = $false - } - npm = @{ - Name = "npm" - Command = "npm" - VersionArgs = "--version" - VersionPattern = '(\d+\.\d+\.\d+)' - MinVersion = "9.0.0" - Category = "Package Manager" - Required = $false - } - yarn = @{ - Name = "Yarn" - Command = "yarn" - VersionArgs = "--version" - VersionPattern = '(\d+\.\d+\.\d+)' - MinVersion = "1.22.0" - Category = "Package Manager" - WingetId = "Yarn.Yarn" - ChocoId = "yarn" - Required = $false - } - pnpm = @{ - Name = "pnpm" - Command = "pnpm" - VersionArgs = "--version" - VersionPattern = '(\d+\.\d+\.\d+)' - MinVersion = "8.0.0" - Category = "Package Manager" - Required = $false - } - - # Python - python = @{ - Name = "Python" - Command = "python" - VersionArgs = "--version" - VersionPattern = 'Python (\d+\.\d+\.\d+)' - MinVersion = "3.9.0" - Category = "Language Runtime" - WingetId = "Python.Python.3.12" - ChocoId = "python" - Required = $false - } - pip = @{ - Name = "pip" - Command = "pip" - VersionArgs = "--version" - VersionPattern = 'pip (\d+\.\d+)' - MinVersion = "23.0" - Category = "Package Manager" - Required = $false - } - - # DevOps Tools - docker = @{ - Name = "Docker" - Command = "docker" - VersionArgs = "--version" - VersionPattern = 'Docker version (\d+\.\d+\.\d+)' - MinVersion = "24.0.0" - Category = "Containers" - WingetId = "Docker.DockerDesktop" - ChocoId = "docker-desktop" - Required = $false - } - kubectl = @{ - Name = "kubectl" - Command = "kubectl" - VersionArgs = "version --client --short" - VersionPattern = 'v(\d+\.\d+\.\d+)' - MinVersion = "1.28.0" - Category = "Kubernetes" - WingetId = "Kubernetes.kubectl" - ChocoId = "kubernetes-cli" - Required = $false - } - helm = @{ - Name = "Helm" - Command = "helm" - VersionArgs = "version --short" - VersionPattern = 'v(\d+\.\d+\.\d+)' - MinVersion = "3.12.0" - Category = "Kubernetes" - WingetId = "Helm.Helm" - ChocoId = "kubernetes-helm" - Required = $false - } - terraform = @{ - Name = "Terraform" - Command = "terraform" - VersionArgs = "version" - VersionPattern = 'Terraform v(\d+\.\d+\.\d+)' - MinVersion = "1.5.0" - Category = "Infrastructure" - WingetId = "Hashicorp.Terraform" - ChocoId = "terraform" - Required = $false - } - - # IDEs and Editors - code = @{ - Name = "VS Code" - Command = "code" - VersionArgs = "--version" - VersionPattern = '(\d+\.\d+\.\d+)' - MinVersion = "1.85.0" - Category = "IDE" - WingetId = "Microsoft.VisualStudioCode" - ChocoId = "vscode" - Required = $false - } - - # Other Tools - pwsh = @{ - Name = "PowerShell 7" - Command = "pwsh" - VersionArgs = "--version" - VersionPattern = 'PowerShell (\d+\.\d+\.\d+)' - MinVersion = "7.4.0" - Category = "Shell" - WingetId = "Microsoft.PowerShell" - ChocoId = "powershell-core" - Required = $false - } - wsl = @{ - Name = "WSL" - Command = "wsl" - VersionArgs = "--version" - VersionPattern = 'WSL version:\s*(\d+\.\d+\.\d+)' - MinVersion = "2.0.0" - Category = "Virtualization" - Required = $false - } - gh = @{ - Name = "GitHub CLI" - Command = "gh" - VersionArgs = "--version" - VersionPattern = 'gh version (\d+\.\d+\.\d+)' - MinVersion = "2.40.0" - Category = "Version Control" - WingetId = "GitHub.cli" - ChocoId = "gh" - Required = $false - } -} - -# Profile definitions -$script:Profiles = @{ - WebDev = @('git', 'node', 'npm', 'code') - Python = @('git', 'python', 'pip', 'code') - DevOps = @('git', 'docker', 'kubectl', 'helm', 'terraform', 'code') - FullStack = @('git', 'node', 'npm', 'python', 'pip', 'docker', 'code', 'pwsh', 'gh') -} - -# Recommended VS Code extensions by profile -$script:RecommendedExtensions = @{ - WebDev = @( - 'dbaeumer.vscode-eslint', - 'esbenp.prettier-vscode', - 'ritwickdey.liveserver', - 'bradlc.vscode-tailwindcss' - ) - Python = @( - 'ms-python.python', - 'ms-python.vscode-pylance', - 'ms-python.debugpy', - 'charliermarsh.ruff' - ) - DevOps = @( - 'ms-azuretools.vscode-docker', - 'ms-kubernetes-tools.vscode-kubernetes-tools', - 'hashicorp.terraform', - 'redhat.vscode-yaml' - ) - FullStack = @( - 'dbaeumer.vscode-eslint', - 'ms-python.python', - 'ms-azuretools.vscode-docker', - 'eamodio.gitlens', - 'github.copilot' - ) -} -#endregion - -#region Helper Functions -function Compare-SemVer { - param( - [string]$Version1, - [string]$Version2 - ) - - # Parse version strings - $v1Parts = $Version1 -split '\.' | ForEach-Object { [int]$_ } - $v2Parts = $Version2 -split '\.' | ForEach-Object { [int]$_ } - - # Pad to 3 parts - while ($v1Parts.Count -lt 3) { $v1Parts += 0 } - while ($v2Parts.Count -lt 3) { $v2Parts += 0 } - - for ($i = 0; $i -lt 3; $i++) { - if ($v1Parts[$i] -gt $v2Parts[$i]) { return 1 } - if ($v1Parts[$i] -lt $v2Parts[$i]) { return -1 } - } - - return 0 -} - -function Test-ToolInstalled { - param([string]$ToolKey) - - $tool = $script:ToolDefinitions[$ToolKey] - $result = [PSCustomObject]@{ - Name = $tool.Name - Command = $tool.Command - Installed = $false - Version = $null - MinVersion = $tool.MinVersion - VersionOk = $false - Path = $null - Category = $tool.Category - Error = $null - } - - try { - $cmd = Get-Command $tool.Command -ErrorAction SilentlyContinue - if ($cmd) { - $result.Installed = $true - $result.Path = $cmd.Source - - # Get version - $versionOutput = & $tool.Command $tool.VersionArgs.Split(' ') 2>&1 - - if ($versionOutput -match $tool.VersionPattern) { - $result.Version = $Matches[1] - - # Compare versions - $comparison = Compare-SemVer -Version1 $result.Version -Version2 $tool.MinVersion - $result.VersionOk = ($comparison -ge 0) - } - } - } - catch { - $result.Error = $_.Exception.Message - } - - return $result -} - -function Get-SSHKeyStatus { - $sshResults = @() - - $sshDir = Join-Path $env:USERPROFILE ".ssh" - - if (-not (Test-Path $sshDir)) { - return @([PSCustomObject]@{ - Check = "SSH Directory" - Status = "FAIL" - Details = "No .ssh directory found" - Path = $sshDir - }) - } - - $sshResults += [PSCustomObject]@{ - Check = "SSH Directory" - Status = "PASS" - Details = "Directory exists" - Path = $sshDir - } - - # Check for common key types - $keyTypes = @( - @{ Name = "id_ed25519"; Preferred = $true }, - @{ Name = "id_rsa"; Preferred = $false }, - @{ Name = "id_ecdsa"; Preferred = $false } - ) - - $foundKeys = @() - foreach ($key in $keyTypes) { - $keyPath = Join-Path $sshDir $key.Name - $pubKeyPath = "$keyPath.pub" - - if (Test-Path $keyPath) { - $foundKeys += $key.Name - $status = if ($key.Preferred) { "PASS" } else { "WARN" } - $details = if ($key.Preferred) { "Recommended key type" } else { "Consider upgrading to ED25519" } - - $sshResults += [PSCustomObject]@{ - Check = "SSH Key: $($key.Name)" - Status = $status - Details = $details - Path = $keyPath - } - - # Check public key - if (Test-Path $pubKeyPath) { - $sshResults += [PSCustomObject]@{ - Check = "Public Key: $($key.Name).pub" - Status = "PASS" - Details = "Public key exists" - Path = $pubKeyPath - } - } - else { - $sshResults += [PSCustomObject]@{ - Check = "Public Key: $($key.Name).pub" - Status = "WARN" - Details = "Public key missing" - Path = $pubKeyPath - } - } - } - } - - if ($foundKeys.Count -eq 0) { - $sshResults += [PSCustomObject]@{ - Check = "SSH Keys" - Status = "FAIL" - Details = "No SSH keys found" - Path = $sshDir - } - } - - # Check SSH config - $configPath = Join-Path $sshDir "config" - if (Test-Path $configPath) { - $sshResults += [PSCustomObject]@{ - Check = "SSH Config" - Status = "PASS" - Details = "Config file exists" - Path = $configPath - } - } - else { - $sshResults += [PSCustomObject]@{ - Check = "SSH Config" - Status = "INFO" - Details = "No config file (optional)" - Path = $configPath - } - } - - # Check known_hosts - $knownHosts = Join-Path $sshDir "known_hosts" - if (Test-Path $knownHosts) { - $hosts = (Get-Content $knownHosts | Measure-Object).Count - $sshResults += [PSCustomObject]@{ - Check = "Known Hosts" - Status = "PASS" - Details = "$hosts host(s) saved" - Path = $knownHosts - } - } - - # Test GitHub SSH connection - try { - $githubTest = ssh -T git@github.com 2>&1 - if ($githubTest -match "successfully authenticated") { - $sshResults += [PSCustomObject]@{ - Check = "GitHub SSH" - Status = "PASS" - Details = "SSH authentication working" - Path = "github.com" - } - } - else { - $sshResults += [PSCustomObject]@{ - Check = "GitHub SSH" - Status = "WARN" - Details = "May need to add key to GitHub" - Path = "github.com" - } - } - } - catch { - $sshResults += [PSCustomObject]@{ - Check = "GitHub SSH" - Status = "INFO" - Details = "Could not test connection" - Path = "github.com" - } - } - - return $sshResults -} - -function Get-VSCodeExtensions { - $extensions = @() - - try { - $output = code --list-extensions 2>&1 - if ($LASTEXITCODE -eq 0) { - $extensions = $output | Where-Object { $_ -and $_.Trim() } - } - } - catch { } - - return $extensions -} - -function Test-VSCodeExtensions { - param([string]$ProfileName) - - $results = @() - $installedExtensions = Get-VSCodeExtensions - $recommended = $script:RecommendedExtensions[$ProfileName] - - if (-not $recommended) { - return @([PSCustomObject]@{ - Extension = "N/A" - Status = "INFO" - Details = "No recommended extensions for this profile" - }) - } - - foreach ($ext in $recommended) { - $isInstalled = $installedExtensions -contains $ext - $results += [PSCustomObject]@{ - Extension = $ext - Status = if ($isInstalled) { "PASS" } else { "WARN" } - Details = if ($isInstalled) { "Installed" } else { "Not installed (recommended)" } - } - } - - return $results -} - -function Test-PackageManagers { - $results = @() - - # Winget - $winget = Get-Command winget -ErrorAction SilentlyContinue - if ($winget) { - $results += [PSCustomObject]@{ - Manager = "Winget" - Status = "PASS" - Available = $true - Path = $winget.Source - } - } - else { - $results += [PSCustomObject]@{ - Manager = "Winget" - Status = "WARN" - Available = $false - Path = $null - } - } - - # Chocolatey - $choco = Get-Command choco -ErrorAction SilentlyContinue - if ($choco) { - $results += [PSCustomObject]@{ - Manager = "Chocolatey" - Status = "PASS" - Available = $true - Path = $choco.Source - } - } - else { - $results += [PSCustomObject]@{ - Manager = "Chocolatey" - Status = "INFO" - Available = $false - Path = $null - } - } - - # Scoop - $scoop = Get-Command scoop -ErrorAction SilentlyContinue - if ($scoop) { - $results += [PSCustomObject]@{ - Manager = "Scoop" - Status = "PASS" - Available = $true - Path = $scoop.Source - } - } - else { - $results += [PSCustomObject]@{ - Manager = "Scoop" - Status = "INFO" - Available = $false - Path = $null - } - } - - return $results -} - -function Install-MissingTool { - param( - [string]$ToolKey, - [PSCustomObject]$PackageManagers - ) - - $tool = $script:ToolDefinitions[$ToolKey] - - # Try Winget first - if ($PackageManagers | Where-Object { $_.Manager -eq "Winget" -and $_.Available } ) { - if ($tool.WingetId) { - Write-InfoMessage "Installing $($tool.Name) via Winget..." - try { - winget install --id $tool.WingetId --accept-source-agreements --accept-package-agreements - if ($LASTEXITCODE -eq 0) { - Write-Success "$($tool.Name) installed via Winget" - return $true - } - } - catch { } - } - } - - # Try Chocolatey - if ($PackageManagers | Where-Object { $_.Manager -eq "Chocolatey" -and $_.Available }) { - if ($tool.ChocoId) { - Write-InfoMessage "Installing $($tool.Name) via Chocolatey..." - try { - choco install $tool.ChocoId -y - if ($LASTEXITCODE -eq 0) { - Write-Success "$($tool.Name) installed via Chocolatey" - return $true - } - } - catch { } - } - } - - Write-WarningMessage "Could not automatically install $($tool.Name)" - return $false -} - -function Get-EnvironmentVariables { - $results = @() - - # PATH check - $pathDirs = $env:PATH -split ';' | Where-Object { $_ } - $results += [PSCustomObject]@{ - Variable = "PATH" - Status = "INFO" - Value = "$($pathDirs.Count) directories" - Details = $pathDirs.Count - } - - # Common dev environment variables - $devVars = @( - @{ Name = "JAVA_HOME"; Required = $false }, - @{ Name = "GOPATH"; Required = $false }, - @{ Name = "PYTHONPATH"; Required = $false }, - @{ Name = "NODE_PATH"; Required = $false }, - @{ Name = "DOCKER_HOST"; Required = $false } - ) - - foreach ($var in $devVars) { - $value = [Environment]::GetEnvironmentVariable($var.Name) - if ($value) { - $results += [PSCustomObject]@{ - Variable = $var.Name - Status = "PASS" - Value = $value - Details = "Set" - } - } - else { - $results += [PSCustomObject]@{ - Variable = $var.Name - Status = "INFO" - Value = "(not set)" - Details = "Not set (may be optional)" - } - } - } - - return $results -} - -function Export-HtmlReport { - param( - [array]$ToolResults, - [array]$SSHResults, - [array]$ExtensionResults, - [array]$PackageManagers, - [array]$EnvVars, - [string]$ProfileName, - [string]$OutputPath - ) - - $passCount = ($ToolResults | Where-Object { $_.VersionOk }).Count - $failCount = ($ToolResults | Where-Object { $_.Installed -and -not $_.VersionOk }).Count - $missingCount = ($ToolResults | Where-Object { -not $_.Installed }).Count - - $htmlContent = @" - - - - - - Development Environment Report - - - -
-

Development Environment Report

-

Profile: $ProfileName | Generated: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')

- -
-
-

$passCount

-

Passed

-
-
-

$failCount

-

Outdated

-
-
-

$missingCount

-

Missing

-
-
-

$($ToolResults.Count)

-

Total Checked

-
-
- -

Development Tools

- - - - - - - - -"@ - - foreach ($tool in $ToolResults) { - $statusClass = if (-not $tool.Installed) { "status-warn" } - elseif ($tool.VersionOk) { "status-pass" } - else { "status-fail" } - $statusText = if (-not $tool.Installed) { "Missing" } - elseif ($tool.VersionOk) { "OK" } - else { "Outdated" } - - $htmlContent += @" - - - - - - - -"@ - } - - $htmlContent += @" -
ToolCategoryStatusVersionRequired
$($tool.Name)$($tool.Category)$statusText - $(if ($tool.Version) { "$($tool.Version) (min: $($tool.MinVersion))" } else { "-" }) - $(if ($tool.Required) { "Yes" } else { "No" })
-"@ - - if ($SSHResults) { - $htmlContent += @" -

SSH Configuration

- - - - - - -"@ - foreach ($ssh in $SSHResults) { - $statusClass = switch ($ssh.Status) { - "PASS" { "status-pass" } - "FAIL" { "status-fail" } - "WARN" { "status-warn" } - default { "status-info" } - } - $htmlContent += @" - - - - - -"@ - } - $htmlContent += "
CheckStatusDetails
$($ssh.Check)$($ssh.Status)$($ssh.Details)
" - } - - if ($ExtensionResults) { - $htmlContent += @" -

VS Code Extensions

- - - - - - -"@ - foreach ($ext in $ExtensionResults) { - $statusClass = switch ($ext.Status) { - "PASS" { "status-pass" } - "WARN" { "status-warn" } - default { "status-info" } - } - $htmlContent += @" - - - - - -"@ - } - $htmlContent += "
ExtensionStatusDetails
$($ext.Extension)$($ext.Status)$($ext.Details)
" - } - - $htmlContent += @" - -
- - -"@ - - $htmlContent | Out-File -FilePath $OutputPath -Encoding UTF8 -} -#endregion - -#region Main Execution -function Main { - Write-InfoMessage "Development Environment Validator v$($script:ScriptVersion)" - Write-InfoMessage "Profile: $Profile" - Write-Host "" - - # Determine tools to check - $toolsToCheck = if ($Profile -eq 'Custom' -and $RequirementsFile) { - if (Test-Path $RequirementsFile) { - (Get-Content $RequirementsFile | ConvertFrom-Json).Tools - } - else { - Write-ErrorMessage "Requirements file not found: $RequirementsFile" - exit 1 - } - } - else { - $script:Profiles[$Profile] - } - - # Check tools - Write-Host "======================================" -ForegroundColor Cyan - Write-Host " DEVELOPMENT TOOLS" -ForegroundColor Cyan - Write-Host "======================================" -ForegroundColor Cyan - Write-Host "" - - $toolResults = @() - foreach ($toolKey in $toolsToCheck) { - if ($script:ToolDefinitions.ContainsKey($toolKey)) { - $result = Test-ToolInstalled -ToolKey $toolKey - $toolResults += $result - - $statusIcon = if (-not $result.Installed) { "[-]" } - elseif ($result.VersionOk) { "[+]" } - else { "[!]" } - $statusColor = if (-not $result.Installed) { "Red" } - elseif ($result.VersionOk) { "Green" } - else { "Yellow" } - - Write-Host "$statusIcon $($result.Name): " -NoNewline -ForegroundColor $statusColor - if ($result.Installed) { - Write-Host "$($result.Version) " -NoNewline -ForegroundColor White - if ($result.VersionOk) { - Write-Host "(OK)" -ForegroundColor Green - } - else { - Write-Host "(needs $($result.MinVersion)+)" -ForegroundColor Yellow - } - } - else { - Write-Host "Not installed" -ForegroundColor Red - } - } - } - - # Auto-install missing tools if requested - if ($AutoInstall) { - $packageManagers = Test-PackageManagers - $missingTools = $toolResults | Where-Object { -not $_.Installed } - - if ($missingTools.Count -gt 0) { - Write-Host "" - Write-Host "======================================" -ForegroundColor Cyan - Write-Host " AUTO-INSTALLING TOOLS" -ForegroundColor Cyan - Write-Host "======================================" -ForegroundColor Cyan - - foreach ($tool in $missingTools) { - $toolKey = $toolsToCheck | Where-Object { $script:ToolDefinitions[$_].Name -eq $tool.Name } - if ($toolKey) { - Install-MissingTool -ToolKey $toolKey -PackageManagers $packageManagers - } - } - } - } - - # Check SSH - $sshResults = @() - if ($CheckSSH) { - Write-Host "" - Write-Host "======================================" -ForegroundColor Cyan - Write-Host " SSH CONFIGURATION" -ForegroundColor Cyan - Write-Host "======================================" -ForegroundColor Cyan - Write-Host "" - - $sshResults = Get-SSHKeyStatus - foreach ($ssh in $sshResults) { - $icon = switch ($ssh.Status) { - "PASS" { "[+]" } - "FAIL" { "[-]" } - "WARN" { "[!]" } - default { "[i]" } - } - $color = switch ($ssh.Status) { - "PASS" { "Green" } - "FAIL" { "Red" } - "WARN" { "Yellow" } - default { "Blue" } - } - Write-Host "$icon $($ssh.Check): $($ssh.Details)" -ForegroundColor $color - } - } - - # Check VS Code extensions - $extensionResults = @() - if ($CheckExtensions) { - Write-Host "" - Write-Host "======================================" -ForegroundColor Cyan - Write-Host " VS CODE EXTENSIONS" -ForegroundColor Cyan - Write-Host "======================================" -ForegroundColor Cyan - Write-Host "" - - $extensionResults = Test-VSCodeExtensions -ProfileName $Profile - foreach ($ext in $extensionResults) { - $icon = if ($ext.Status -eq "PASS") { "[+]" } else { "[!]" } - $color = if ($ext.Status -eq "PASS") { "Green" } else { "Yellow" } - Write-Host "$icon $($ext.Extension): $($ext.Details)" -ForegroundColor $color - } - } - - # Check package managers - $packageManagers = Test-PackageManagers - Write-Host "" - Write-Host "======================================" -ForegroundColor Cyan - Write-Host " PACKAGE MANAGERS" -ForegroundColor Cyan - Write-Host "======================================" -ForegroundColor Cyan - Write-Host "" - - foreach ($pm in $packageManagers) { - $icon = if ($pm.Available) { "[+]" } else { "[i]" } - $color = if ($pm.Available) { "Green" } else { "Gray" } - Write-Host "$icon $($pm.Manager): $(if ($pm.Available) { 'Available' } else { 'Not installed' })" -ForegroundColor $color - } - - # Environment variables - $envVars = Get-EnvironmentVariables - - # Summary - Write-Host "" - Write-Host "======================================" -ForegroundColor Cyan - Write-Host " SUMMARY" -ForegroundColor Cyan - Write-Host "======================================" -ForegroundColor Cyan - Write-Host "" - - $passCount = ($toolResults | Where-Object { $_.Installed -and $_.VersionOk }).Count - $outdatedCount = ($toolResults | Where-Object { $_.Installed -and -not $_.VersionOk }).Count - $missingCount = ($toolResults | Where-Object { -not $_.Installed }).Count - - Write-Host "Tools OK: $passCount" -ForegroundColor Green - Write-Host "Tools Outdated: $outdatedCount" -ForegroundColor Yellow - Write-Host "Tools Missing: $missingCount" -ForegroundColor Red - - # Overall status - $overallStatus = if ($missingCount -gt 0 -or $outdatedCount -gt 0) { - "NEEDS ATTENTION" - } - else { - "READY FOR DEVELOPMENT" - } - - Write-Host "" - Write-Host "Overall: " -NoNewline - if ($overallStatus -eq "READY FOR DEVELOPMENT") { - Write-Host $overallStatus -ForegroundColor Green - } - else { - Write-Host $overallStatus -ForegroundColor Yellow - } - - # Output report - if ($OutputFormat -ne 'Console') { - $outputDir = if ($OutputPath) { $OutputPath } else { Get-LogDirectory } - if (-not (Test-Path $outputDir)) { - New-Item -ItemType Directory -Path $outputDir -Force | Out-Null - } - - $timestamp = Get-Date -Format 'yyyyMMdd_HHmmss' - - switch ($OutputFormat) { - 'HTML' { - $reportPath = Join-Path $outputDir "devenv_report_$timestamp.html" - Export-HtmlReport -ToolResults $toolResults -SSHResults $sshResults -ExtensionResults $extensionResults -PackageManagers $packageManagers -EnvVars $envVars -ProfileName $Profile -OutputPath $reportPath - Write-Success "HTML report saved to: $reportPath" - } - 'JSON' { - $reportPath = Join-Path $outputDir "devenv_report_$timestamp.json" - $report = @{ - Timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss' - Profile = $Profile - Tools = $toolResults - SSH = $sshResults - Extensions = $extensionResults - PackageManagers = $packageManagers - EnvVars = $envVars - Summary = @{ - PassCount = $passCount - OutdatedCount = $outdatedCount - MissingCount = $missingCount - OverallStatus = $overallStatus - } - } - $report | ConvertTo-Json -Depth 10 | Out-File -FilePath $reportPath -Encoding UTF8 - Write-Success "JSON report saved to: $reportPath" - } - } - } - - $endTime = Get-Date - $duration = $endTime - $script:StartTime - Write-InfoMessage "Completed in $($duration.TotalSeconds.ToString('F1')) seconds" - - # Exit code - $exitCode = if ($missingCount -gt 0) { 2 } - elseif ($outdatedCount -gt 0) { 1 } - else { 0 } - - exit $exitCode -} - -# Run main function -Main -#endregion diff --git a/Windows/monitoring/Get-ApplicationHealth.ps1 b/Windows/monitoring/Get-ApplicationHealth.ps1 deleted file mode 100644 index 9c66d59..0000000 --- a/Windows/monitoring/Get-ApplicationHealth.ps1 +++ /dev/null @@ -1,799 +0,0 @@ -#Requires -Version 5.1 -<# -.SYNOPSIS - Monitors application health, installed software versions, and detects outdated applications. - -.DESCRIPTION - This script provides comprehensive application health monitoring including: - - Check if critical applications are installed - - Verify application versions (detect outdated) - - Detect application crashes (from Event Log) - - Monitor application resource usage - - Auto-update apps via Winget/Chocolatey (optional) - - Generate application status reports (Console, HTML, JSON, CSV) - -.PARAMETER RequiredApps - Array of application names that must be installed. - -.PARAMETER CheckUpdates - Check for available updates via Winget and Chocolatey. - -.PARAMETER AutoUpdate - Automatically update outdated applications (requires admin). - -.PARAMETER CheckCrashes - Check Event Log for application crashes. Default: $true. - -.PARAMETER CrashDays - Number of days to look back for crashes. Default: 7. - -.PARAMETER OutputFormat - Output format: Console, HTML, JSON, CSV, or All. Default: Console. - -.PARAMETER OutputPath - Directory for output files. Default: toolkit logs directory. - -.EXAMPLE - .\Get-ApplicationHealth.ps1 - Runs application health check with default settings. - -.EXAMPLE - .\Get-ApplicationHealth.ps1 -RequiredApps "Google Chrome", "Visual Studio Code", "Git" - Checks that specified applications are installed. - -.EXAMPLE - .\Get-ApplicationHealth.ps1 -CheckUpdates -OutputFormat HTML - Checks for updates and generates HTML report. - -.EXAMPLE - .\Get-ApplicationHealth.ps1 -AutoUpdate -RequiredApps "7-Zip" - Automatically updates specified applications if outdated. - -.NOTES - Author: Windows & Linux Sysadmin Toolkit - Version: 1.0.0 - Requires: PowerShell 5.1+ - Recommendation: Run with administrator privileges for complete access. - -.OUTPUTS - PSCustomObject containing application health data with properties: - - InstalledApps, MissingApps, OutdatedApps, Crashes, ResourceUsage - -.LINK - https://learn.microsoft.com/en-us/powershell/module/packagemanagement/ -#> - -[CmdletBinding()] -param( - [Parameter()] - [string[]]$RequiredApps, - - [Parameter()] - [switch]$CheckUpdates, - - [Parameter()] - [switch]$AutoUpdate, - - [Parameter()] - [switch]$CheckCrashes = $true, - - [Parameter()] - [ValidateRange(1, 90)] - [int]$CrashDays = 7, - - [Parameter()] - [ValidateSet('Console', 'HTML', 'JSON', 'CSV', 'All')] - [string]$OutputFormat = 'Console', - - [Parameter()] - [string]$OutputPath -) - -#region Module Import -$scriptRoot = Split-Path -Parent $MyInvocation.MyCommand.Path -$modulePath = Join-Path (Split-Path -Parent $scriptRoot) "lib\CommonFunctions.psm1" - -if (Test-Path $modulePath) { - Import-Module $modulePath -Force -} else { - function Write-Success { param([string]$Message) Write-Host "[+] $Message" -ForegroundColor Green } - function Write-InfoMessage { param([string]$Message) Write-Host "[i] $Message" -ForegroundColor Blue } - function Write-WarningMessage { param([string]$Message) Write-Host "[!] $Message" -ForegroundColor Yellow } - function Write-ErrorMessage { param([string]$Message) Write-Host "[-] $Message" -ForegroundColor Red } - function Test-IsAdministrator { - $currentIdentity = [Security.Principal.WindowsIdentity]::GetCurrent() - $principal = New-Object Security.Principal.WindowsPrincipal($currentIdentity) - return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) - } - function Get-LogDirectory { - $logPath = Join-Path $scriptRoot "..\..\..\logs" - if (-not (Test-Path $logPath)) { New-Item -ItemType Directory -Path $logPath -Force | Out-Null } - return (Resolve-Path $logPath).Path - } -} -#endregion - -#region Helper Functions -function Get-InstalledApplications { - <# - .SYNOPSIS - Gets all installed applications from registry. - #> - [CmdletBinding()] - param() - - $apps = @() - - # Registry paths for installed applications - $registryPaths = @( - "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*", - "HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*", - "HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*" - ) - - foreach ($path in $registryPaths) { - try { - $items = Get-ItemProperty -Path $path -ErrorAction SilentlyContinue | - Where-Object { $_.DisplayName -and $_.DisplayName -ne "" } - - foreach ($item in $items) { - # Check if already added (avoid duplicates) - if ($apps | Where-Object { $_.Name -eq $item.DisplayName -and $_.Version -eq $item.DisplayVersion }) { - continue - } - - $apps += [PSCustomObject]@{ - Name = $item.DisplayName - Version = $item.DisplayVersion - Publisher = $item.Publisher - InstallDate = if ($item.InstallDate) { - try { [datetime]::ParseExact($item.InstallDate, "yyyyMMdd", $null) } catch { $null } - } else { $null } - InstallLocation = $item.InstallLocation - UninstallString = $item.UninstallString - Architecture = if ($path -match "WOW6432Node") { "x86" } else { "x64" } - Source = "Registry" - } - } - } catch { - Write-Verbose "Error reading registry path ${path}: $($_.Exception.Message)" - } - } - - # Also get Windows Store apps - try { - $storeApps = Get-AppxPackage -ErrorAction SilentlyContinue | Where-Object { $_.IsFramework -eq $false } - foreach ($app in $storeApps) { - $apps += [PSCustomObject]@{ - Name = $app.Name - Version = $app.Version - Publisher = $app.Publisher - InstallDate = $null - InstallLocation = $app.InstallLocation - UninstallString = $null - Architecture = $app.Architecture - Source = "WindowsStore" - } - } - } catch { - Write-Verbose "Error getting Windows Store apps: $($_.Exception.Message)" - } - - return $apps | Sort-Object Name -} - -function Test-ApplicationInstalled { - <# - .SYNOPSIS - Checks if an application is installed. - #> - [CmdletBinding()] - param( - [Parameter(Mandatory)] - [string]$AppName, - - [Parameter(Mandatory)] - [PSCustomObject[]]$InstalledApps - ) - - $found = $InstalledApps | Where-Object { - $_.Name -like "*$AppName*" -or - $_.Name -eq $AppName - } - - return $found -} - -function Get-WingetUpdates { - <# - .SYNOPSIS - Gets available updates from Winget. - #> - [CmdletBinding()] - param() - - $updates = @() - - # Check if winget is available - $winget = Get-Command winget -ErrorAction SilentlyContinue - if (-not $winget) { - Write-WarningMessage "Winget is not installed or not in PATH" - return $updates - } - - try { - Write-InfoMessage "Checking for updates via Winget..." - - # Run winget upgrade and parse output - $output = winget upgrade --accept-source-agreements 2>$null - - # Parse the output (skip header lines) - $lines = $output -split "`n" | Where-Object { $_ -match '\S' } - $headerFound = $false - $dataStarted = $false - - foreach ($line in $lines) { - # Skip until we find the header separator - if ($line -match '^-+') { - $headerFound = $true - $dataStarted = $true - continue - } - - if (-not $dataStarted) { continue } - - # Skip footer lines - if ($line -match 'upgrades available' -or $line -match '^$') { continue } - - # Parse the line (format: Name Id Version Available Source) - if ($line -match '^\s*(.+?)\s{2,}(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s*$') { - $updates += [PSCustomObject]@{ - Name = $Matches[1].Trim() - Id = $Matches[2] - CurrentVersion = $Matches[3] - AvailableVersion = $Matches[4] - Source = $Matches[5] - PackageManager = "Winget" - } - } - } - } catch { - Write-WarningMessage "Error checking Winget updates: $($_.Exception.Message)" - } - - return $updates -} - -function Get-ChocolateyUpdates { - <# - .SYNOPSIS - Gets available updates from Chocolatey. - #> - [CmdletBinding()] - param() - - $updates = @() - - # Check if choco is available - $choco = Get-Command choco -ErrorAction SilentlyContinue - if (-not $choco) { - Write-Verbose "Chocolatey is not installed" - return $updates - } - - try { - Write-InfoMessage "Checking for updates via Chocolatey..." - - $output = choco outdated --limit-output 2>$null - - foreach ($line in $output) { - if ($line -match '^([^|]+)\|([^|]+)\|([^|]+)\|') { - $updates += [PSCustomObject]@{ - Name = $Matches[1] - Id = $Matches[1] - CurrentVersion = $Matches[2] - AvailableVersion = $Matches[3] - Source = "Chocolatey" - PackageManager = "Chocolatey" - } - } - } - } catch { - Write-WarningMessage "Error checking Chocolatey updates: $($_.Exception.Message)" - } - - return $updates -} - -function Get-ApplicationCrashes { - <# - .SYNOPSIS - Gets application crash events from Event Log. - #> - [CmdletBinding()] - param( - [Parameter()] - [int]$Days = 7 - ) - - $crashes = @() - $startTime = (Get-Date).AddDays(-$Days) - - try { - Write-InfoMessage "Checking Event Log for application crashes (last $Days days)..." - - # Application Error events (Event ID 1000) - $appErrors = Get-WinEvent -FilterHashtable @{ - LogName = 'Application' - Id = 1000 - StartTime = $startTime - } -ErrorAction SilentlyContinue - - foreach ($event in $appErrors) { - $crashes += [PSCustomObject]@{ - TimeCreated = $event.TimeCreated - EventId = $event.Id - Application = if ($event.Properties[0]) { $event.Properties[0].Value } else { "Unknown" } - Version = if ($event.Properties[1]) { $event.Properties[1].Value } else { "Unknown" } - FaultingModule = if ($event.Properties[3]) { $event.Properties[3].Value } else { "Unknown" } - ExceptionCode = if ($event.Properties[6]) { $event.Properties[6].Value } else { "Unknown" } - EventType = "Application Error" - } - } - - # Application Hang events (Event ID 1002) - $appHangs = Get-WinEvent -FilterHashtable @{ - LogName = 'Application' - Id = 1002 - StartTime = $startTime - } -ErrorAction SilentlyContinue - - foreach ($event in $appHangs) { - $crashes += [PSCustomObject]@{ - TimeCreated = $event.TimeCreated - EventId = $event.Id - Application = if ($event.Properties[0]) { $event.Properties[0].Value } else { "Unknown" } - Version = if ($event.Properties[1]) { $event.Properties[1].Value } else { "Unknown" } - FaultingModule = "N/A" - ExceptionCode = "Hang" - EventType = "Application Hang" - } - } - - # Windows Error Reporting events - $werEvents = Get-WinEvent -FilterHashtable @{ - LogName = 'Application' - Id = 1001 - StartTime = $startTime - } -ErrorAction SilentlyContinue - - foreach ($event in $werEvents) { - $crashes += [PSCustomObject]@{ - TimeCreated = $event.TimeCreated - EventId = $event.Id - Application = if ($event.Properties[5]) { $event.Properties[5].Value } else { "Unknown" } - Version = if ($event.Properties[6]) { $event.Properties[6].Value } else { "Unknown" } - FaultingModule = if ($event.Properties[9]) { $event.Properties[9].Value } else { "Unknown" } - ExceptionCode = if ($event.Properties[7]) { $event.Properties[7].Value } else { "Unknown" } - EventType = "Windows Error Report" - } - } - - } catch { - Write-WarningMessage "Error reading Event Log: $($_.Exception.Message)" - } - - return $crashes | Sort-Object TimeCreated -Descending -} - -function Get-ApplicationResourceUsage { - <# - .SYNOPSIS - Gets current resource usage by applications. - #> - [CmdletBinding()] - param( - [Parameter()] - [int]$TopCount = 10 - ) - - $usage = @() - - try { - # Get processes sorted by memory usage - $processes = Get-Process | Where-Object { $_.WorkingSet64 -gt 50MB } | - Sort-Object WorkingSet64 -Descending | - Select-Object -First $TopCount - - foreach ($proc in $processes) { - $cpuPercent = try { - $proc.CPU / (Get-Date - $proc.StartTime).TotalSeconds * 100 - } catch { 0 } - - $usage += [PSCustomObject]@{ - ProcessName = $proc.ProcessName - ProcessId = $proc.Id - MemoryMB = [math]::Round($proc.WorkingSet64 / 1MB, 2) - CPUSeconds = [math]::Round($proc.CPU, 2) - HandleCount = $proc.HandleCount - ThreadCount = $proc.Threads.Count - StartTime = $proc.StartTime - Responding = $proc.Responding - } - } - } catch { - Write-WarningMessage "Error getting process information: $($_.Exception.Message)" - } - - return $usage -} - -function Update-Application { - <# - .SYNOPSIS - Updates an application using Winget or Chocolatey. - #> - [CmdletBinding()] - param( - [Parameter(Mandatory)] - [PSCustomObject]$UpdateInfo - ) - - try { - if ($UpdateInfo.PackageManager -eq "Winget") { - Write-InfoMessage "Updating $($UpdateInfo.Name) via Winget..." - $result = winget upgrade --id $UpdateInfo.Id --accept-package-agreements --accept-source-agreements 2>&1 - return $? - } elseif ($UpdateInfo.PackageManager -eq "Chocolatey") { - Write-InfoMessage "Updating $($UpdateInfo.Name) via Chocolatey..." - $result = choco upgrade $UpdateInfo.Id -y 2>&1 - return $LASTEXITCODE -eq 0 - } - } catch { - Write-ErrorMessage "Failed to update $($UpdateInfo.Name): $($_.Exception.Message)" - return $false - } - - return $false -} - -function Export-HtmlReport { - <# - .SYNOPSIS - Exports application health report to HTML format. - #> - [CmdletBinding()] - param( - [Parameter(Mandatory)] - [PSCustomObject]$HealthData, - - [Parameter(Mandatory)] - [string]$OutputFile - ) - - $html = @" - - - - Application Health Report - - - -
-

Application Health Report

-

Generated: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')

- -

Summary

-
-
-

Installed Applications

-
$($HealthData.InstalledApps.Count)
-
-
-

Missing Required

-
$($HealthData.MissingApps.Count)
-
-
-

Updates Available

-
$($HealthData.AvailableUpdates.Count)
-
-
-

Recent Crashes

-
$($HealthData.Crashes.Count)
-
-
-"@ - - # Missing applications section - if ($HealthData.MissingApps.Count -gt 0) { - $html += @" -

Missing Required Applications

- - -"@ - foreach ($app in $HealthData.MissingApps) { - $html += "" - } - $html += "
Application NameStatus
$appNot Installed
" - } - - # Available updates section - if ($HealthData.AvailableUpdates.Count -gt 0) { - $html += @" -

Available Updates

- - -"@ - foreach ($update in $HealthData.AvailableUpdates) { - $html += "" - } - $html += "
ApplicationCurrent VersionAvailable VersionSource
$($update.Name)$($update.CurrentVersion)$($update.AvailableVersion)$($update.Source)
" - } - - # Recent crashes section - if ($HealthData.Crashes.Count -gt 0) { - $html += @" -

Recent Application Crashes

- - -"@ - foreach ($crash in $HealthData.Crashes | Select-Object -First 20) { - $html += "" - } - $html += "
TimeApplicationVersionTypeFaulting Module
$($crash.TimeCreated.ToString('yyyy-MM-dd HH:mm'))$($crash.Application)$($crash.Version)$($crash.EventType)$($crash.FaultingModule)
" - } - - # Resource usage section - if ($HealthData.ResourceUsage.Count -gt 0) { - $html += @" -

Top Resource Consumers

- - -"@ - foreach ($proc in $HealthData.ResourceUsage) { - $respondingClass = if ($proc.Responding) { "status-ok" } else { "status-missing" } - $html += "" - } - $html += "
ProcessMemory (MB)CPU (sec)HandlesThreadsResponding
$($proc.ProcessName)$($proc.MemoryMB)$($proc.CPUSeconds)$($proc.HandleCount)$($proc.ThreadCount)$(if($proc.Responding){'Yes'}else{'No'})
" - } - - $html += @" -
- - -"@ - - $html | Out-File -FilePath $OutputFile -Encoding UTF8 -} -#endregion - -#region Main Execution -function Invoke-ApplicationHealthCheck { - [CmdletBinding()] - param() - - Write-InfoMessage "Starting Application Health Check" - - # Check for admin privileges - if (-not (Test-IsAdministrator)) { - Write-WarningMessage "Running without administrator privileges. Some features may be limited." - } - - # Set output path - if (-not $OutputPath) { - $OutputPath = Get-LogDirectory - } - if (-not (Test-Path $OutputPath)) { - New-Item -ItemType Directory -Path $OutputPath -Force | Out-Null - } - - $timestamp = Get-Date -Format "yyyyMMdd_HHmmss" - - # Get installed applications - Write-InfoMessage "Scanning installed applications..." - $installedApps = Get-InstalledApplications - Write-Success "Found $($installedApps.Count) installed applications" - - # Check required applications - $missingApps = @() - $foundRequiredApps = @() - if ($RequiredApps) { - Write-InfoMessage "Checking required applications..." - foreach ($appName in $RequiredApps) { - $found = Test-ApplicationInstalled -AppName $appName -InstalledApps $installedApps - if ($found) { - $foundRequiredApps += $found | Select-Object -First 1 - Write-Success "Found: $appName (v$($found[0].Version))" - } else { - $missingApps += $appName - Write-ErrorMessage "Missing: $appName" - } - } - } - - # Check for updates - $availableUpdates = @() - if ($CheckUpdates -or $AutoUpdate) { - $wingetUpdates = Get-WingetUpdates - $chocoUpdates = Get-ChocolateyUpdates - $availableUpdates = $wingetUpdates + $chocoUpdates - - if ($availableUpdates.Count -gt 0) { - Write-WarningMessage "Found $($availableUpdates.Count) applications with updates available" - } else { - Write-Success "All applications are up to date" - } - } - - # Auto-update if requested - $updatedApps = @() - if ($AutoUpdate -and $availableUpdates.Count -gt 0) { - Write-InfoMessage "Auto-updating applications..." - foreach ($update in $availableUpdates) { - $success = Update-Application -UpdateInfo $update - if ($success) { - $updatedApps += $update.Name - Write-Success "Updated: $($update.Name)" - } else { - Write-ErrorMessage "Failed to update: $($update.Name)" - } - } - } - - # Check for crashes - $crashes = @() - if ($CheckCrashes) { - $crashes = Get-ApplicationCrashes -Days $CrashDays - if ($crashes.Count -gt 0) { - Write-WarningMessage "Found $($crashes.Count) application crashes in the last $CrashDays days" - } else { - Write-Success "No application crashes found in the last $CrashDays days" - } - } - - # Get resource usage - Write-InfoMessage "Checking application resource usage..." - $resourceUsage = Get-ApplicationResourceUsage -TopCount 10 - - # Compile health data - $healthData = [PSCustomObject]@{ - InstalledApps = $installedApps - RequiredApps = $foundRequiredApps - MissingApps = $missingApps - AvailableUpdates = $availableUpdates - UpdatedApps = $updatedApps - Crashes = $crashes - ResourceUsage = $resourceUsage - CheckDate = Get-Date - } - - # Output results based on format - switch ($OutputFormat) { - 'Console' { - Write-Host "" - Write-Host "========================================" -ForegroundColor Cyan - Write-Host " APPLICATION HEALTH SUMMARY " -ForegroundColor Cyan - Write-Host "========================================" -ForegroundColor Cyan - Write-Host "" - Write-Host "Installed Applications: $($installedApps.Count)" - - if ($RequiredApps) { - Write-Host "" - Write-Host "Required Applications:" -ForegroundColor Yellow - foreach ($app in $RequiredApps) { - $status = if ($missingApps -contains $app) { - "[-] MISSING" - } else { - "[+] Installed" - } - $color = if ($missingApps -contains $app) { "Red" } else { "Green" } - Write-Host " $status - $app" -ForegroundColor $color - } - } - - if ($availableUpdates.Count -gt 0) { - Write-Host "" - Write-Host "Available Updates:" -ForegroundColor Yellow - foreach ($update in $availableUpdates) { - Write-Host " [!] $($update.Name): $($update.CurrentVersion) -> $($update.AvailableVersion)" -ForegroundColor Yellow - } - } - - if ($crashes.Count -gt 0) { - Write-Host "" - Write-Host "Recent Crashes (Last $CrashDays days):" -ForegroundColor Red - $crashSummary = $crashes | Group-Object Application | Sort-Object Count -Descending | Select-Object -First 5 - foreach ($group in $crashSummary) { - Write-Host " [-] $($group.Name): $($group.Count) crash(es)" -ForegroundColor Red - } - } - - if ($resourceUsage.Count -gt 0) { - Write-Host "" - Write-Host "Top Memory Consumers:" -ForegroundColor Cyan - foreach ($proc in $resourceUsage | Select-Object -First 5) { - Write-Host " $($proc.ProcessName): $($proc.MemoryMB) MB" - } - } - } - - 'HTML' { - $htmlFile = Join-Path $OutputPath "ApplicationHealth_$timestamp.html" - Export-HtmlReport -HealthData $healthData -OutputFile $htmlFile - Write-Success "HTML report saved to: $htmlFile" - } - - 'JSON' { - $jsonFile = Join-Path $OutputPath "ApplicationHealth_$timestamp.json" - $healthData | ConvertTo-Json -Depth 10 | Out-File -FilePath $jsonFile -Encoding UTF8 - Write-Success "JSON report saved to: $jsonFile" - } - - 'CSV' { - $csvFile = Join-Path $OutputPath "ApplicationHealth_$timestamp.csv" - $installedApps | Select-Object Name, Version, Publisher, InstallDate, Architecture, Source | - Export-Csv -Path $csvFile -NoTypeInformation -Encoding UTF8 - Write-Success "CSV report saved to: $csvFile" - } - - 'All' { - # HTML - $htmlFile = Join-Path $OutputPath "ApplicationHealth_$timestamp.html" - Export-HtmlReport -HealthData $healthData -OutputFile $htmlFile - Write-Success "HTML report saved to: $htmlFile" - - # JSON - $jsonFile = Join-Path $OutputPath "ApplicationHealth_$timestamp.json" - $healthData | ConvertTo-Json -Depth 10 | Out-File -FilePath $jsonFile -Encoding UTF8 - Write-Success "JSON report saved to: $jsonFile" - - # CSV - $csvFile = Join-Path $OutputPath "ApplicationHealth_$timestamp.csv" - $installedApps | Select-Object Name, Version, Publisher, InstallDate, Architecture, Source | - Export-Csv -Path $csvFile -NoTypeInformation -Encoding UTF8 - Write-Success "CSV report saved to: $csvFile" - - # Console summary - Write-Host "" - Write-Host "Summary: $($installedApps.Count) apps, $($missingApps.Count) missing, $($availableUpdates.Count) updates, $($crashes.Count) crashes" - } - } - - Write-Success "Application health check completed" - - # Return results for pipeline usage - return [PSCustomObject]@{ - HealthData = $healthData - ExitCode = if ($missingApps.Count -gt 0) { 2 } - elseif ($crashes.Count -gt 10 -or $availableUpdates.Count -gt 5) { 1 } - else { 0 } - } -} - -# Run Invoke-ApplicationHealthCheck when invoked as a script. When dot-sourced -# for testing, skip auto-run so test files can load function definitions. -if ($MyInvocation.InvocationName -ne '.') { - $result = Invoke-ApplicationHealthCheck - exit $result.ExitCode -} -#endregion diff --git a/Windows/monitoring/Get-EventLogAnalysis.ps1 b/Windows/monitoring/Get-EventLogAnalysis.ps1 deleted file mode 100644 index 5589090..0000000 --- a/Windows/monitoring/Get-EventLogAnalysis.ps1 +++ /dev/null @@ -1,1276 +0,0 @@ -#!/usr/bin/env pwsh - -<# -.SYNOPSIS - Analyzes Windows Event Logs for security incidents, errors, and system issues. - -.DESCRIPTION - This script provides comprehensive Windows Event Log analysis including: - - Parse Application, Security, and System event logs - - Filter by severity (Critical, Error, Warning, Information) - - Track failed logon attempts and security incidents - - Detect privilege escalation attempts - - Generate security incident reports - - Pattern detection for common issues - - Export to HTML, CSV, or JSON for analysis - - Key features: - - Customizable time range for analysis - - Security-focused event filtering - - Failed authentication tracking - - Service failure detection - - Application crash analysis - - Clear/Warning event detection - - SIEM-friendly output formats - -.PARAMETER LogNames - Event log names to analyze. Default: Application, Security, System - -.PARAMETER Hours - Number of hours to look back. Default: 24 - -.PARAMETER MaxEvents - Maximum events to retrieve per log. Default: 1000 - -.PARAMETER Level - Minimum severity level to include. Valid: Critical, Error, Warning, Information, All - Default: Warning (includes Critical, Error, and Warning) - -.PARAMETER OutputFormat - Output format for reports. Valid values: Console, HTML, JSON, CSV, All. - Default: Console - -.PARAMETER OutputPath - Directory path for output files. - -.PARAMETER IncludeSecurityAnalysis - Include detailed security event analysis (requires admin for Security log). - -.PARAMETER IncludeFailedLogons - Include failed logon attempt analysis. - -.PARAMETER EventIds - Specific event IDs to filter for. - -.PARAMETER SourceFilter - Filter events by source name (wildcard supported). - -.PARAMETER ExcludeSources - Event sources to exclude from analysis. - -.PARAMETER GroupBy - Group results by: Source, EventId, Level, Hour, Day. Default: Source - -.EXAMPLE - .\Get-EventLogAnalysis.ps1 - Analyzes last 24 hours of Warning and above events with console output. - -.EXAMPLE - .\Get-EventLogAnalysis.ps1 -Hours 72 -Level Error -OutputFormat HTML - Analyzes last 72 hours for Error and Critical events, generates HTML report. - -.EXAMPLE - .\Get-EventLogAnalysis.ps1 -IncludeSecurityAnalysis -IncludeFailedLogons - Includes security event analysis and failed logon tracking. - -.EXAMPLE - .\Get-EventLogAnalysis.ps1 -LogNames "Application" -SourceFilter "*SQL*" - Analyzes only Application log for SQL-related events. - -.EXAMPLE - .\Get-EventLogAnalysis.ps1 -EventIds 1000, 1001, 7034 -Hours 168 - Looks for specific event IDs over the last week. - -.NOTES - File Name : Get-EventLogAnalysis.ps1 - Author : Windows & Linux Sysadmin Toolkit - Prerequisite : PowerShell 5.1+, Admin for Security log access - Version : 1.0.0 - Creation Date : 2025-11-30 - - Change Log: - - 1.0.0 (2025-11-30): Initial release - -.LINK - https://github.com/Dashtid/sysadmin-toolkit -#> - -#Requires -Version 5.1 - -[CmdletBinding()] -param( - [Parameter()] - [string[]]$LogNames = @('Application', 'Security', 'System'), - - [Parameter()] - [ValidateRange(1, 8760)] - [int]$Hours = 24, - - [Parameter()] - [ValidateRange(100, 50000)] - [int]$MaxEvents = 1000, - - [Parameter()] - [ValidateSet('Critical', 'Error', 'Warning', 'Information', 'All')] - [string]$Level = 'Warning', - - [Parameter()] - [ValidateSet('Console', 'HTML', 'JSON', 'CSV', 'All')] - [string]$OutputFormat = 'Console', - - [Parameter()] - [string]$OutputPath, - - [Parameter()] - [switch]$IncludeSecurityAnalysis, - - [Parameter()] - [switch]$IncludeFailedLogons, - - [Parameter()] - [int[]]$EventIds, - - [Parameter()] - [string]$SourceFilter, - - [Parameter()] - [string[]]$ExcludeSources, - - [Parameter()] - [ValidateSet('Source', 'EventId', 'Level', 'Hour', 'Day')] - [string]$GroupBy = 'Source' -) - -#region Module Imports -$modulePath = Join-Path -Path $PSScriptRoot -ChildPath "..\lib\CommonFunctions.psm1" -if (Test-Path $modulePath) { - Import-Module $modulePath -Force -} -else { - function Write-Success { param([string]$Message) Write-Host "[+] $Message" -ForegroundColor Green } - function Write-InfoMessage { param([string]$Message) Write-Host "[i] $Message" -ForegroundColor Blue } - function Write-WarningMessage { param([string]$Message) Write-Host "[!] $Message" -ForegroundColor Yellow } - function Write-ErrorMessage { param([string]$Message) Write-Host "[-] $Message" -ForegroundColor Red } - function Get-LogDirectory { return Join-Path $PSScriptRoot "..\..\logs" } - function Test-IsAdministrator { - $currentIdentity = [Security.Principal.WindowsIdentity]::GetCurrent() - $principal = New-Object Security.Principal.WindowsPrincipal($currentIdentity) - return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) - } -} -#endregion - -#region Configuration -$script:StartTime = Get-Date -$script:ScriptVersion = "1.0.0" -$script:IsAdmin = Test-IsAdministrator - -# Set output path -if (-not $OutputPath) { - $OutputPath = Get-LogDirectory -} - -if (-not (Test-Path $OutputPath)) { - New-Item -ItemType Directory -Path $OutputPath -Force | Out-Null -} - -# Level mapping -$script:LevelMapping = @{ - 'Critical' = 1 - 'Error' = 2 - 'Warning' = 3 - 'Information' = 4 - 'Verbose' = 5 -} - -# Important security event IDs -$script:SecurityEventIds = @{ - # Account Logon Events - FailedLogon = @(4625) - SuccessfulLogon = @(4624) - LogonWithExplicitCreds = @(4648) - AccountLockout = @(4740) - - # Privilege Use - PrivilegeEscalation = @(4672, 4673, 4674) - SensitivePrivilegeUse = @(4673) - - # Account Management - UserAccountCreated = @(4720) - UserAccountDeleted = @(4726) - UserAccountChanged = @(4738) - UserAddedToGroup = @(4728, 4732, 4756) - PasswordChange = @(4723, 4724) - PasswordReset = @(4724) - - # Object Access - ObjectAccessAttempt = @(4663) - FileShareAccess = @(5140, 5145) - - # Policy Changes - AuditPolicyChange = @(4719) - AuthPolicyChange = @(4706, 4707) - - # System Events - EventLogCleared = @(1102, 104) - SystemTimeChange = @(4616) - ServiceInstalled = @(4697, 7045) - - # Process Events - ProcessCreation = @(4688) - ProcessTermination = @(4689) -} - -# Common application error event IDs -$script:ApplicationEventIds = @{ - AppCrash = @(1000, 1001, 1002) - AppHang = @(1002) - WER = @(1001) - DotNetRuntime = @(1026) - SideBySide = @(33, 35, 59, 63, 80) -} - -# System event IDs -$script:SystemEventIds = @{ - UnexpectedShutdown = @(6008) - ServiceCrash = @(7034) - ServiceFailed = @(7000, 7001, 7009, 7011, 7022, 7023, 7024, 7026, 7031, 7032, 7034) - Bugcheck = @(1001) - DiskError = @(7, 11, 15, 55) - NTPTimeError = @(12, 14, 22, 37, 50, 129) - KernelPowerError = @(41) - DriverFailed = @(219) -} -#endregion - -#region Event Collection Functions -function Get-FilteredEvents { - <# - .SYNOPSIS - Retrieves filtered events from specified event log. - .DESCRIPTION - EventIds / SourceFilter / ExcludeSources default to the script-level - param values but are explicit here so Pester tests can drive them - without script-scope monkey-patching (which Pester BeforeEach cannot - propagate into dot-sourced functions). - #> - [CmdletBinding()] - param( - [Parameter(Mandatory)] - [string]$LogName, - - [datetime]$StartTime, - - [int]$MaxEvents = 1000, - - [int[]]$LevelValues, - - [int[]]$EventIds = $script:EventIds, - - [string]$SourceFilter = $script:SourceFilter, - - [string[]]$ExcludeSources = $script:ExcludeSources - ) - - $events = @() - - try { - # Build filter hashtable - $filterHash = @{ - LogName = $LogName - StartTime = $StartTime - } - - if ($LevelValues -and $LevelValues.Count -gt 0) { - $filterHash['Level'] = $LevelValues - } - - if ($EventIds -and $EventIds.Count -gt 0) { - $filterHash['Id'] = $EventIds - } - - $rawEvents = Get-WinEvent -FilterHashtable $filterHash -MaxEvents $MaxEvents -ErrorAction SilentlyContinue - - foreach ($event in $rawEvents) { - # Apply source filter if specified - if ($SourceFilter) { - if ($event.ProviderName -notlike $SourceFilter) { - continue - } - } - - # Apply exclusions - if ($ExcludeSources -and $event.ProviderName -in $ExcludeSources) { - continue - } - - $levelName = switch ($event.Level) { - 1 { 'Critical' } - 2 { 'Error' } - 3 { 'Warning' } - 4 { 'Information' } - 5 { 'Verbose' } - 0 { 'Information' } # LogAlways - default { 'Unknown' } - } - - $events += @{ - TimeCreated = $event.TimeCreated - LogName = $event.LogName - Source = $event.ProviderName - EventId = $event.Id - Level = $levelName - LevelValue = $event.Level - Message = $event.Message - MachineName = $event.MachineName - UserId = $event.UserId - ProcessId = $event.ProcessId - ThreadId = $event.ThreadId - Keywords = $event.Keywords - TaskCategory = $event.TaskDisplayName - } - } - } - catch { - if ($_.Exception.Message -match "access denied" -or $_.Exception.Message -match "Access is denied") { - Write-WarningMessage "Access denied to $LogName log (requires administrator privileges)" - } - else { - Write-WarningMessage "Error reading $LogName log: $($_.Exception.Message)" - } - } - - return $events -} - -function Get-SecurityAnalysis { - <# - .SYNOPSIS - Analyzes security events for potential incidents. - #> - [CmdletBinding()] - param( - [Parameter(Mandatory)] - [AllowEmptyCollection()] - [array]$Events - ) - - $analysis = @{ - FailedLogons = @() - SuccessfulLogons = @() - PrivilegeEscalation = @() - AccountChanges = @() - PolicyChanges = @() - SuspiciousActivity = @() - LogCleared = @() - ServiceChanges = @() - } - - $securityEvents = $Events | Where-Object { $_.LogName -eq 'Security' } - - foreach ($event in $securityEvents) { - # Failed Logons - if ($event.EventId -in $script:SecurityEventIds.FailedLogon) { - $analysis.FailedLogons += $event - } - - # Successful Logons - if ($event.EventId -in $script:SecurityEventIds.SuccessfulLogon) { - $analysis.SuccessfulLogons += $event - } - - # Privilege Escalation - if ($event.EventId -in $script:SecurityEventIds.PrivilegeEscalation) { - $analysis.PrivilegeEscalation += $event - } - - # Account Changes - $accountChangeIds = $script:SecurityEventIds.UserAccountCreated + - $script:SecurityEventIds.UserAccountDeleted + - $script:SecurityEventIds.UserAccountChanged + - $script:SecurityEventIds.UserAddedToGroup - if ($event.EventId -in $accountChangeIds) { - $analysis.AccountChanges += $event - } - - # Policy Changes - if ($event.EventId -in ($script:SecurityEventIds.AuditPolicyChange + $script:SecurityEventIds.AuthPolicyChange)) { - $analysis.PolicyChanges += $event - } - - # Log Cleared - if ($event.EventId -in $script:SecurityEventIds.EventLogCleared) { - $analysis.LogCleared += $event - } - - # Service Changes - if ($event.EventId -in $script:SecurityEventIds.ServiceInstalled) { - $analysis.ServiceChanges += $event - } - } - - # Check for suspicious patterns - # Multiple failed logons from same source - $failedLogonGroups = $analysis.FailedLogons | Group-Object -Property { $_.Message -match 'Account Name:\s+(\S+)' | Out-Null; $matches[1] } - foreach ($group in $failedLogonGroups) { - if ($group.Count -ge 5) { - $analysis.SuspiciousActivity += @{ - Type = 'BruteForce' - Description = "Multiple failed logon attempts ($($group.Count)) detected" - Count = $group.Count - Events = $group.Group - } - } - } - - return $analysis -} - -function Get-FailedLogonDetails { - <# - .SYNOPSIS - Extracts detailed information from failed logon events. - #> - [CmdletBinding()] - param( - [Parameter(Mandatory)] - [AllowEmptyCollection()] - [array]$Events - ) - - $failedLogons = @() - - foreach ($event in ($Events | Where-Object { $_.EventId -eq 4625 })) { - $details = @{ - TimeCreated = $event.TimeCreated - EventId = $event.EventId - TargetAccount = '' - TargetDomain = '' - SourceIP = '' - SourceHost = '' - LogonType = '' - FailureReason = '' - Status = '' - SubStatus = '' - } - - if ($event.Message) { - # Extract account name - if ($event.Message -match 'Account Name:\s+(\S+)') { - $details.TargetAccount = $matches[1] - } - - # Extract domain - if ($event.Message -match 'Account Domain:\s+(\S+)') { - $details.TargetDomain = $matches[1] - } - - # Extract source IP - if ($event.Message -match 'Source Network Address:\s+(\S+)') { - $details.SourceIP = $matches[1] - } - - # Extract source workstation - if ($event.Message -match 'Workstation Name:\s+(\S+)') { - $details.SourceHost = $matches[1] - } - - # Extract logon type - if ($event.Message -match 'Logon Type:\s+(\d+)') { - $logonType = $matches[1] - $details.LogonType = switch ($logonType) { - '2' { 'Interactive' } - '3' { 'Network' } - '4' { 'Batch' } - '5' { 'Service' } - '7' { 'Unlock' } - '8' { 'NetworkCleartext' } - '9' { 'NewCredentials' } - '10' { 'RemoteInteractive (RDP)' } - '11' { 'CachedInteractive' } - default { $logonType } - } - } - - # Extract failure reason - if ($event.Message -match 'Failure Reason:\s+(.+?)(?:\r|\n)') { - $details.FailureReason = $matches[1].Trim() - } - - # Extract status codes - if ($event.Message -match 'Status:\s+(0x\w+)') { - $details.Status = $matches[1] - } - if ($event.Message -match 'Sub Status:\s+(0x\w+)') { - $details.SubStatus = $matches[1] - } - } - - $failedLogons += $details - } - - return $failedLogons -} - -function Get-SystemIssues { - <# - .SYNOPSIS - Analyzes system events for issues. - #> - [CmdletBinding()] - param( - [Parameter(Mandatory)] - [AllowEmptyCollection()] - [array]$Events - ) - - $issues = @{ - ServiceFailures = @() - UnexpectedShutdowns = @() - DiskErrors = @() - DriverIssues = @() - KernelErrors = @() - } - - $systemEvents = $Events | Where-Object { $_.LogName -eq 'System' } - - foreach ($event in $systemEvents) { - # Service Failures - if ($event.EventId -in $script:SystemEventIds.ServiceFailed) { - $issues.ServiceFailures += $event - } - - # Unexpected Shutdowns - if ($event.EventId -in $script:SystemEventIds.UnexpectedShutdown) { - $issues.UnexpectedShutdowns += $event - } - - # Disk Errors - if ($event.EventId -in $script:SystemEventIds.DiskError) { - $issues.DiskErrors += $event - } - - # Driver Issues - if ($event.EventId -in $script:SystemEventIds.DriverFailed) { - $issues.DriverIssues += $event - } - - # Kernel/Power Errors - if ($event.EventId -in ($script:SystemEventIds.KernelPowerError + $script:SystemEventIds.Bugcheck)) { - $issues.KernelErrors += $event - } - } - - return $issues -} - -function Get-ApplicationIssues { - <# - .SYNOPSIS - Analyzes application events for crashes and errors. - #> - [CmdletBinding()] - param( - [Parameter(Mandatory)] - [AllowEmptyCollection()] - [array]$Events - ) - - $issues = @{ - Crashes = @() - Hangs = @() - DotNetErrors = @() - SideBySide = @() - } - - $appEvents = $Events | Where-Object { $_.LogName -eq 'Application' } - - foreach ($event in $appEvents) { - # Application Crashes - if ($event.EventId -in $script:ApplicationEventIds.AppCrash) { - $issues.Crashes += $event - } - - # Application Hangs - if ($event.EventId -in $script:ApplicationEventIds.AppHang) { - $issues.Hangs += $event - } - - # .NET Errors - if ($event.EventId -in $script:ApplicationEventIds.DotNetRuntime) { - $issues.DotNetErrors += $event - } - - # Side-by-Side Errors - if ($event.EventId -in $script:ApplicationEventIds.SideBySide) { - $issues.SideBySide += $event - } - } - - return $issues -} -#endregion - -#region Report Generation Functions -function Get-EventLogReport { - <# - .SYNOPSIS - Generates comprehensive event log analysis report. - .DESCRIPTION - Parameters default to the script-level param values. They are explicit - here so Pester tests can drive scope/level/admin/security toggles - without monkey-patching script-scope variables. - #> - [CmdletBinding()] - param( - [int]$Hours = $script:Hours, - [string]$Level = $script:Level, - [string[]]$LogNames = $script:LogNames, - [int]$MaxEvents = $script:MaxEvents, - [bool]$IsAdmin = [bool]$script:IsAdmin, - [bool]$IncludeSecurityAnalysis = [bool]$script:IncludeSecurityAnalysis, - [bool]$IncludeFailedLogons = [bool]$script:IncludeFailedLogons - ) - - $report = @{ - Timestamp = Get-Date -Format 'o' - ComputerName = $env:COMPUTERNAME - AnalysisPeriod = @{ - StartTime = (Get-Date).AddHours(-$Hours) - EndTime = Get-Date - Hours = $Hours - } - Summary = @{ - TotalEvents = 0 - Critical = 0 - Error = 0 - Warning = 0 - Information = 0 - } - EventsByLog = @{} - EventsBySource = @{} - EventsByHour = @{} - TopEventIds = @() - TopSources = @() - AllEvents = @() - SecurityAnalysis = $null - FailedLogons = @() - SystemIssues = $null - ApplicationIssues = $null - Alerts = @() - } - - $startTime = (Get-Date).AddHours(-$Hours) - - # Determine level filter - $levelValues = switch ($Level) { - 'Critical' { @(1) } - 'Error' { @(1, 2) } - 'Warning' { @(1, 2, 3) } - 'Information' { @(1, 2, 3, 4) } - 'All' { @() } - } - - # Collect events from each log - foreach ($logName in $LogNames) { - # Skip Security log if not admin and not explicitly requested - if ($logName -eq 'Security' -and -not $IsAdmin) { - if (-not $IncludeSecurityAnalysis) { - Write-WarningMessage "Skipping Security log (requires administrator privileges)" - continue - } - } - - Write-InfoMessage "Analyzing $logName log..." - $events = Get-FilteredEvents -LogName $logName -StartTime $startTime -MaxEvents $MaxEvents -LevelValues $levelValues - - $report.AllEvents += $events - $report.EventsByLog[$logName] = $events.Count - - Write-InfoMessage " Found $($events.Count) events in $logName" - } - - # Calculate summary - # @() coerces single-match Where-Object to a one-element array so .Count is 1, - # not the matched hashtable's key count (a classic PowerShell unwrap trap). - $report.Summary.TotalEvents = @($report.AllEvents).Count - $report.Summary.Critical = @($report.AllEvents | Where-Object { $_.Level -eq 'Critical' }).Count - $report.Summary.Error = @($report.AllEvents | Where-Object { $_.Level -eq 'Error' }).Count - $report.Summary.Warning = @($report.AllEvents | Where-Object { $_.Level -eq 'Warning' }).Count - $report.Summary.Information = @($report.AllEvents | Where-Object { $_.Level -eq 'Information' }).Count - - # Group by source - $sourceGroups = $report.AllEvents | Group-Object -Property Source | Sort-Object -Property Count -Descending - foreach ($group in $sourceGroups) { - $report.EventsBySource[$group.Name] = $group.Count - } - $report.TopSources = $sourceGroups | Select-Object -First 10 | ForEach-Object { - @{ Source = $_.Name; Count = $_.Count } - } - - # Group by event ID - $eventIdGroups = $report.AllEvents | Group-Object -Property EventId | Sort-Object -Property Count -Descending - $report.TopEventIds = $eventIdGroups | Select-Object -First 15 | ForEach-Object { - $sampleEvent = $_.Group | Select-Object -First 1 - @{ - EventId = $_.Name - Count = $_.Count - Source = $sampleEvent.Source - Level = $sampleEvent.Level - } - } - - # Group by hour - $hourGroups = $report.AllEvents | Group-Object -Property { $_.TimeCreated.ToString('yyyy-MM-dd HH:00') } - foreach ($group in $hourGroups) { - $report.EventsByHour[$group.Name] = $group.Count - } - - # Security analysis - if ($IncludeSecurityAnalysis -and $IsAdmin) { - Write-InfoMessage "Performing security analysis..." - $report.SecurityAnalysis = Get-SecurityAnalysis -Events $report.AllEvents - } - - # Failed logon details - if ($IncludeFailedLogons -and $IsAdmin) { - Write-InfoMessage "Analyzing failed logon attempts..." - $report.FailedLogons = Get-FailedLogonDetails -Events $report.AllEvents - } - - # System issues - Write-InfoMessage "Analyzing system issues..." - $report.SystemIssues = Get-SystemIssues -Events $report.AllEvents - - # Application issues - Write-InfoMessage "Analyzing application issues..." - $report.ApplicationIssues = Get-ApplicationIssues -Events $report.AllEvents - - # Generate alerts - if ($report.Summary.Critical -gt 0) { - $report.Alerts += @{ - Level = 'Critical' - Type = 'CriticalEvents' - Message = "$($report.Summary.Critical) critical events detected in the last $Hours hours" - } - } - - if ($report.SystemIssues.UnexpectedShutdowns.Count -gt 0) { - $report.Alerts += @{ - Level = 'Critical' - Type = 'UnexpectedShutdown' - Message = "$($report.SystemIssues.UnexpectedShutdowns.Count) unexpected shutdown(s) detected" - } - } - - if ($report.SystemIssues.ServiceFailures.Count -gt 0) { - $report.Alerts += @{ - Level = 'Warning' - Type = 'ServiceFailure' - Message = "$($report.SystemIssues.ServiceFailures.Count) service failure(s) detected" - } - } - - if ($report.ApplicationIssues.Crashes.Count -gt 0) { - $report.Alerts += @{ - Level = 'Warning' - Type = 'AppCrash' - Message = "$($report.ApplicationIssues.Crashes.Count) application crash(es) detected" - } - } - - if ($report.SecurityAnalysis -and $report.SecurityAnalysis.LogCleared.Count -gt 0) { - $report.Alerts += @{ - Level = 'Critical' - Type = 'LogCleared' - Message = "Event log was cleared $($report.SecurityAnalysis.LogCleared.Count) time(s) - potential security incident" - } - } - - if ($report.FailedLogons.Count -ge 10) { - $report.Alerts += @{ - Level = 'Warning' - Type = 'FailedLogons' - Message = "$($report.FailedLogons.Count) failed logon attempts detected" - } - } - - return $report -} - -function Write-ConsoleReport { - <# - .SYNOPSIS - Outputs event log analysis to console. - #> - [CmdletBinding()] - param( - [Parameter(Mandatory)] - [hashtable]$Report - ) - - $separator = "=" * 70 - - Write-Host "`n$separator" -ForegroundColor Cyan - Write-Host " EVENT LOG ANALYSIS REPORT" -ForegroundColor Cyan - Write-Host " Generated: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')" -ForegroundColor Cyan - Write-Host " Period: Last $($Report.AnalysisPeriod.Hours) hours" -ForegroundColor Cyan - Write-Host "$separator`n" -ForegroundColor Cyan - - # Summary - Write-Host "SUMMARY" -ForegroundColor White - Write-Host "-" * 50 - Write-Host " Total Events: $($Report.Summary.TotalEvents)" - Write-Host " Critical: " -NoNewline - if ($Report.Summary.Critical -gt 0) { - Write-Host "$($Report.Summary.Critical)" -ForegroundColor Red - } - else { - Write-Host "$($Report.Summary.Critical)" -ForegroundColor Green - } - Write-Host " Error: " -NoNewline - if ($Report.Summary.Error -gt 0) { - Write-Host "$($Report.Summary.Error)" -ForegroundColor Red - } - else { - Write-Host "$($Report.Summary.Error)" -ForegroundColor Green - } - Write-Host " Warning: " -NoNewline - if ($Report.Summary.Warning -gt 0) { - Write-Host "$($Report.Summary.Warning)" -ForegroundColor Yellow - } - else { - Write-Host "$($Report.Summary.Warning)" - } - Write-Host " Information: $($Report.Summary.Information)`n" - - # Events by Log - Write-Host "EVENTS BY LOG" -ForegroundColor White - Write-Host "-" * 50 - foreach ($log in $Report.EventsByLog.Keys) { - Write-Host " $($log.PadRight(20)) $($Report.EventsByLog[$log])" - } - Write-Host "" - - # Top Sources - Write-Host "TOP EVENT SOURCES" -ForegroundColor White - Write-Host "-" * 50 - foreach ($source in $Report.TopSources | Select-Object -First 10) { - Write-Host " $($source.Source.PadRight(35)) $($source.Count)" - } - Write-Host "" - - # Top Event IDs - Write-Host "TOP EVENT IDs" -ForegroundColor White - Write-Host "-" * 50 - foreach ($eventId in $Report.TopEventIds | Select-Object -First 10) { - $levelColor = switch ($eventId.Level) { - 'Critical' { 'Red' } - 'Error' { 'Red' } - 'Warning' { 'Yellow' } - default { 'White' } - } - Write-Host " ID $($eventId.EventId.ToString().PadRight(8))" -NoNewline - Write-Host " [$($eventId.Level.PadRight(11))]" -ForegroundColor $levelColor -NoNewline - Write-Host " $($eventId.Count.ToString().PadLeft(5)) - $($eventId.Source)" - } - Write-Host "" - - # System Issues - if ($Report.SystemIssues) { - Write-Host "SYSTEM ISSUES" -ForegroundColor White - Write-Host "-" * 50 - Write-Host " Service Failures: $($Report.SystemIssues.ServiceFailures.Count)" - Write-Host " Unexpected Shutdowns: $($Report.SystemIssues.UnexpectedShutdowns.Count)" - Write-Host " Disk Errors: $($Report.SystemIssues.DiskErrors.Count)" - Write-Host " Driver Issues: $($Report.SystemIssues.DriverIssues.Count)" - Write-Host " Kernel Errors: $($Report.SystemIssues.KernelErrors.Count)`n" - } - - # Application Issues - if ($Report.ApplicationIssues) { - Write-Host "APPLICATION ISSUES" -ForegroundColor White - Write-Host "-" * 50 - Write-Host " Crashes: $($Report.ApplicationIssues.Crashes.Count)" - Write-Host " Hangs: $($Report.ApplicationIssues.Hangs.Count)" - Write-Host " .NET Errors: $($Report.ApplicationIssues.DotNetErrors.Count)" - Write-Host " SxS Errors: $($Report.ApplicationIssues.SideBySide.Count)`n" - } - - # Failed Logons - if ($Report.FailedLogons.Count -gt 0) { - Write-Host "FAILED LOGON ATTEMPTS" -ForegroundColor White - Write-Host "-" * 50 - - # Group by account - $accountGroups = $Report.FailedLogons | Group-Object -Property TargetAccount | Sort-Object -Property Count -Descending - foreach ($group in ($accountGroups | Select-Object -First 5)) { - Write-Host " $($group.Name): $($group.Count) attempts" -ForegroundColor Yellow - } - - # Group by source IP - $ipGroups = $Report.FailedLogons | Where-Object { $_.SourceIP -and $_.SourceIP -ne '-' } | - Group-Object -Property SourceIP | Sort-Object -Property Count -Descending - if ($ipGroups.Count -gt 0) { - Write-Host "`n Top Source IPs:" -ForegroundColor Cyan - foreach ($group in ($ipGroups | Select-Object -First 5)) { - Write-Host " $($group.Name): $($group.Count) attempts" - } - } - Write-Host "" - } - - # Security Analysis - if ($Report.SecurityAnalysis) { - Write-Host "SECURITY ANALYSIS" -ForegroundColor White - Write-Host "-" * 50 - Write-Host " Privilege Escalation Events: $($Report.SecurityAnalysis.PrivilegeEscalation.Count)" - Write-Host " Account Changes: $($Report.SecurityAnalysis.AccountChanges.Count)" - Write-Host " Policy Changes: $($Report.SecurityAnalysis.PolicyChanges.Count)" - Write-Host " Log Cleared Events: $($Report.SecurityAnalysis.LogCleared.Count)" - Write-Host " Service Changes: $($Report.SecurityAnalysis.ServiceChanges.Count)" - - if ($Report.SecurityAnalysis.SuspiciousActivity.Count -gt 0) { - Write-Host "`n [!] Suspicious Activity Detected:" -ForegroundColor Red - foreach ($activity in $Report.SecurityAnalysis.SuspiciousActivity) { - Write-Host " - $($activity.Description)" -ForegroundColor Red - } - } - Write-Host "" - } - - # Alerts - if ($Report.Alerts.Count -gt 0) { - Write-Host "ALERTS" -ForegroundColor White - Write-Host "-" * 50 - foreach ($alert in $Report.Alerts) { - $alertColor = switch ($alert.Level) { - 'Critical' { 'Red' } - 'Warning' { 'Yellow' } - 'Info' { 'Cyan' } - default { 'White' } - } - $alertIcon = switch ($alert.Level) { - 'Critical' { '[-]' } - 'Warning' { '[!]' } - 'Info' { '[i]' } - default { '[*]' } - } - Write-Host " $alertIcon $($alert.Message)" -ForegroundColor $alertColor - } - Write-Host "" - } - else { - Write-Host "[+] No critical issues detected`n" -ForegroundColor Green - } - - Write-Host $separator -ForegroundColor Cyan -} - -function Export-HTMLReport { - <# - .SYNOPSIS - Generates an HTML event log analysis report. - #> - [CmdletBinding()] - param( - [Parameter(Mandatory)] - [hashtable]$Report, - - [string]$Path - ) - - $timestamp = Get-Date -Format 'yyyy-MM-dd_HH-mm-ss' - $htmlPath = Join-Path $Path "eventlog-analysis_$timestamp.html" - - # Build alerts HTML - $alertsHtml = "" - if ($Report.Alerts.Count -gt 0) { - $alertsHtml = "

Alerts

    " - foreach ($alert in $Report.Alerts) { - $alertClass = switch ($alert.Level) { - 'Critical' { 'critical' } - 'Warning' { 'warning' } - default { 'info' } - } - $alertsHtml += "
  • [$($alert.Level)] $($alert.Message)
  • " - } - $alertsHtml += "
" - } - - # Build top events HTML - $topEventsHtml = "" - foreach ($eventId in $Report.TopEventIds) { - $levelClass = switch ($eventId.Level) { - 'Critical' { 'critical' } - 'Error' { 'error' } - 'Warning' { 'warning' } - default { '' } - } - $topEventsHtml += "$($eventId.EventId)$($eventId.Level)$($eventId.Count)$($eventId.Source)" - } - - # Build recent critical events HTML - $criticalEventsHtml = "" - $criticalEvents = $Report.AllEvents | Where-Object { $_.Level -in @('Critical', 'Error') } | Select-Object -First 20 - foreach ($event in $criticalEvents) { - $levelClass = if ($event.Level -eq 'Critical') { 'critical' } else { 'error' } - $messagePreview = if ($event.Message.Length -gt 100) { $event.Message.Substring(0, 100) + '...' } else { $event.Message } - $criticalEventsHtml += "$($event.TimeCreated.ToString('yyyy-MM-dd HH:mm:ss'))$($event.Source)$($event.EventId)$($event.Level)$([System.Web.HttpUtility]::HtmlEncode($messagePreview))" - } - - $html = @" - - - - - Event Log Analysis - $($Report.ComputerName) - - - -
-

Event Log Analysis Report

-

Computer: $($Report.ComputerName) | Generated: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') | Period: Last $($Report.AnalysisPeriod.Hours) hours

- -
-
-
$($Report.Summary.TotalEvents)
-
Total Events
-
-
-
$($Report.Summary.Critical)
-
Critical
-
-
-
$($Report.Summary.Error)
-
Error
-
-
-
$($Report.Summary.Warning)
-
Warning
-
-
-
$($Report.Summary.Information)
-
Information
-
-
- - $alertsHtml - -
-

Top Event IDs

- - - $topEventsHtml -
Event IDLevelCountSource
-
- - $(if ($criticalEventsHtml) { @" -
-

Recent Critical/Error Events

- - - $criticalEventsHtml -
TimeSourceEvent IDLevelMessage
-
-"@ }) - - -
- - -"@ - - $html | Set-Content -Path $htmlPath -Encoding UTF8 - Write-Success "HTML report saved: $htmlPath" - return $htmlPath -} - -function Export-JSONReport { - <# - .SYNOPSIS - Exports event log analysis to JSON format. - #> - [CmdletBinding()] - param( - [Parameter(Mandatory)] - [hashtable]$Report, - - [string]$Path - ) - - $timestamp = Get-Date -Format 'yyyy-MM-dd_HH-mm-ss' - $jsonPath = Join-Path $Path "eventlog-analysis_$timestamp.json" - - # Create a cleaner export (without full event messages for size) - $exportReport = @{ - Timestamp = $Report.Timestamp - ComputerName = $Report.ComputerName - AnalysisPeriod = $Report.AnalysisPeriod - Summary = $Report.Summary - EventsByLog = $Report.EventsByLog - TopEventIds = $Report.TopEventIds - TopSources = $Report.TopSources - EventsByHour = $Report.EventsByHour - Alerts = $Report.Alerts - SystemIssues = @{ - ServiceFailures = $Report.SystemIssues.ServiceFailures.Count - UnexpectedShutdowns = $Report.SystemIssues.UnexpectedShutdowns.Count - DiskErrors = $Report.SystemIssues.DiskErrors.Count - DriverIssues = $Report.SystemIssues.DriverIssues.Count - KernelErrors = $Report.SystemIssues.KernelErrors.Count - } - ApplicationIssues = @{ - Crashes = $Report.ApplicationIssues.Crashes.Count - Hangs = $Report.ApplicationIssues.Hangs.Count - DotNetErrors = $Report.ApplicationIssues.DotNetErrors.Count - SideBySide = $Report.ApplicationIssues.SideBySide.Count - } - FailedLogonCount = $Report.FailedLogons.Count - } - - if ($Report.SecurityAnalysis) { - $exportReport.SecurityAnalysis = @{ - FailedLogons = $Report.SecurityAnalysis.FailedLogons.Count - PrivilegeEscalation = $Report.SecurityAnalysis.PrivilegeEscalation.Count - AccountChanges = $Report.SecurityAnalysis.AccountChanges.Count - PolicyChanges = $Report.SecurityAnalysis.PolicyChanges.Count - LogCleared = $Report.SecurityAnalysis.LogCleared.Count - ServiceChanges = $Report.SecurityAnalysis.ServiceChanges.Count - SuspiciousActivity = $Report.SecurityAnalysis.SuspiciousActivity.Count - } - } - - $exportReport | ConvertTo-Json -Depth 10 | Set-Content -Path $jsonPath -Encoding UTF8 - Write-Success "JSON report saved: $jsonPath" - return $jsonPath -} - -function Export-CSVReport { - <# - .SYNOPSIS - Exports events to CSV format. - #> - [CmdletBinding()] - param( - [Parameter(Mandatory)] - [hashtable]$Report, - - [string]$Path - ) - - $timestamp = Get-Date -Format 'yyyy-MM-dd_HH-mm-ss' - $csvPath = Join-Path $Path "eventlog-events_$timestamp.csv" - - $csvData = $Report.AllEvents | ForEach-Object { - [PSCustomObject]@{ - TimeCreated = $_.TimeCreated - LogName = $_.LogName - Source = $_.Source - EventId = $_.EventId - Level = $_.Level - TaskCategory = $_.TaskCategory - Message = ($_.Message -replace "`r`n", " " -replace "`n", " ").Substring(0, [Math]::Min(500, $_.Message.Length)) - } - } - - $csvData | Export-Csv -Path $csvPath -NoTypeInformation -Encoding UTF8 - Write-Success "CSV report saved: $csvPath" - return $csvPath -} -#endregion - -#region Main Execution -function Invoke-EventLogAnalysis { - <# - .SYNOPSIS - Runs the full event-log analysis workflow and returns an exit code. - .OUTPUTS - [int] 0 on success, 1 if any critical events were detected or a fatal error occurred. - #> - [CmdletBinding()] - [OutputType([int])] - param() - - try { - Write-InfoMessage "=== Event Log Analyzer v$script:ScriptVersion ===" - Write-InfoMessage "Started: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')" - Write-InfoMessage "Analyzing last $Hours hours" - - if ($script:IsAdmin) { - Write-Success "Running with administrator privileges" - } - else { - Write-WarningMessage "Running without administrator privileges - Security log access limited" - } - - # Generate report - $report = Get-EventLogReport - - # Output based on format - switch ($OutputFormat) { - 'Console' { Write-ConsoleReport -Report $report } - 'HTML' { Export-HTMLReport -Report $report -Path $OutputPath } - 'JSON' { Export-JSONReport -Report $report -Path $OutputPath } - 'CSV' { Export-CSVReport -Report $report -Path $OutputPath } - 'All' { - Write-ConsoleReport -Report $report - Export-HTMLReport -Report $report -Path $OutputPath - Export-JSONReport -Report $report -Path $OutputPath - Export-CSVReport -Report $report -Path $OutputPath - } - } - - $duration = (Get-Date) - $script:StartTime - Write-Success "=== Event log analysis completed in $($duration.TotalSeconds.ToString('0.00'))s ===" - - # Return exit code based on critical events - if ($report.Summary.Critical -gt 0) { - return 1 - } - return 0 - } - catch { - Write-ErrorMessage "Fatal error: $($_.Exception.Message)" - return 1 - } -} - -# Run Invoke-EventLogAnalysis when invoked as a script. When dot-sourced for -# testing, skip auto-run so test files can load function definitions into scope. -if ($MyInvocation.InvocationName -ne '.') { - $exitCode = Invoke-EventLogAnalysis - if ($exitCode -ne 0) { - exit 1 - } -} -#endregion diff --git a/Windows/monitoring/Get-SystemPerformance.ps1 b/Windows/monitoring/Get-SystemPerformance.ps1 deleted file mode 100644 index 4f54b98..0000000 --- a/Windows/monitoring/Get-SystemPerformance.ps1 +++ /dev/null @@ -1,1457 +0,0 @@ -#!/usr/bin/env pwsh - -<# -.SYNOPSIS - Monitors system performance metrics including CPU, memory, disk I/O, and network usage. - -.DESCRIPTION - This script provides comprehensive system performance monitoring with: - - Real-time CPU, RAM, disk, and network metrics - - Configurable threshold-based alerts - - Performance trend tracking over time - - Multiple output formats (Console, HTML, JSON, CSV) - - Historical data collection for analysis - - Integration with Windows Performance Monitor counters - - Metrics collected: - - CPU: Usage percentage, queue length, per-core utilization - - Memory: Available, used, page file usage, cache size - - Disk: Read/write rates, queue length, latency, free space - - Network: Bytes sent/received, packets, errors, bandwidth utilization - -.PARAMETER OutputFormat - Output format for the report. Valid values: Console, HTML, JSON, CSV, Prometheus, All. - Prometheus format exports metrics in Prometheus textfile collector format. - Default: Console - -.PARAMETER OutputPath - Directory path for output files. Default: logs directory in toolkit root. - -.PARAMETER SampleCount - Number of performance samples to collect. Default: 5 - -.PARAMETER SampleInterval - Interval in seconds between samples. Default: 2 - -.PARAMETER Thresholds - Hashtable of custom alert thresholds. If not provided, defaults are used. - Keys: CpuWarning, CpuCritical, MemoryWarning, MemoryCritical, DiskWarning, DiskCritical - -.PARAMETER MonitorDuration - Duration in minutes to monitor (for continuous monitoring mode). Default: 0 (single run) - -.PARAMETER AlertOnly - Only output if thresholds are exceeded. - -.PARAMETER IncludeProcesses - Include top CPU and memory consuming processes in the report. - -.PARAMETER TopProcessCount - Number of top processes to include. Default: 10 - -.EXAMPLE - .\Get-SystemPerformance.ps1 - Runs a basic performance check with console output. - -.EXAMPLE - .\Get-SystemPerformance.ps1 -OutputFormat HTML -OutputPath "C:\Reports" - Generates an HTML performance report in the specified directory. - -.EXAMPLE - .\Get-SystemPerformance.ps1 -OutputFormat All -IncludeProcesses -TopProcessCount 15 - Generates all report formats with top 15 resource-consuming processes. - -.EXAMPLE - .\Get-SystemPerformance.ps1 -MonitorDuration 60 -SampleInterval 30 -OutputFormat JSON - Monitors for 60 minutes, sampling every 30 seconds, outputting JSON. - -.EXAMPLE - $thresholds = @{ CpuWarning = 70; CpuCritical = 90; MemoryWarning = 75; MemoryCritical = 90 } - .\Get-SystemPerformance.ps1 -Thresholds $thresholds -AlertOnly - Only alerts when custom thresholds are exceeded. - -.EXAMPLE - .\Get-SystemPerformance.ps1 -OutputFormat Prometheus -OutputPath "C:\metrics" - Exports metrics in Prometheus textfile collector format for node_exporter. - -.NOTES - File Name : Get-SystemPerformance.ps1 - Author : Windows & Linux Sysadmin Toolkit - Prerequisite : PowerShell 5.1+ (PowerShell 7+ recommended) - Version : 1.0.0 - Creation Date : 2025-11-30 - - Change Log: - - 1.0.0 (2025-11-30): Initial release - -.LINK - https://github.com/Dashtid/sysadmin-toolkit -#> - -#Requires -Version 5.1 - -[CmdletBinding()] -param( - [Parameter()] - [ValidateSet('Console', 'HTML', 'JSON', 'CSV', 'Prometheus', 'All')] - [string]$OutputFormat = 'Console', - - [Parameter()] - [string]$OutputPath, - - [Parameter()] - [ValidateRange(1, 100)] - [int]$SampleCount = 5, - - [Parameter()] - [ValidateRange(1, 300)] - [int]$SampleInterval = 2, - - [Parameter()] - [hashtable]$Thresholds, - - [Parameter()] - [ValidateRange(0, 1440)] - [int]$MonitorDuration = 0, - - [Parameter()] - [switch]$AlertOnly, - - [Parameter()] - [switch]$IncludeProcesses, - - [Parameter()] - [ValidateRange(1, 50)] - [int]$TopProcessCount = 10, - - # Disk Analysis Parameters (merged from Watch-DiskSpace.ps1) - [Parameter()] - [switch]$IncludeDiskAnalysis, - - [Parameter()] - [ValidateRange(1, 100)] - [int]$TopFilesCount = 20, - - [Parameter()] - [ValidateRange(1, 50)] - [int]$TopFoldersCount = 10, - - [Parameter()] - [switch]$AutoCleanup, - - [Parameter()] - [char[]]$DriveLetters, - - [Parameter()] - [char[]]$ExcludeDrives -) - -#region Module Imports -$modulePath = Join-Path -Path $PSScriptRoot -ChildPath "..\lib\CommonFunctions.psm1" -if (Test-Path $modulePath) { - Import-Module $modulePath -Force -} -else { - # Fallback logging functions if module not found - function Write-Success { param([string]$Message) Write-Host "[+] $Message" -ForegroundColor Green } - function Write-InfoMessage { param([string]$Message) Write-Host "[i] $Message" -ForegroundColor Blue } - function Write-WarningMessage { param([string]$Message) Write-Host "[!] $Message" -ForegroundColor Yellow } - function Write-ErrorMessage { param([string]$Message) Write-Host "[-] $Message" -ForegroundColor Red } - function Get-LogDirectory { return Join-Path $PSScriptRoot "..\..\logs" } -} - -$errorHandlingPath = Join-Path -Path $PSScriptRoot -ChildPath "..\lib\ErrorHandling.psm1" -if (Test-Path $errorHandlingPath) { - Import-Module $errorHandlingPath -Force -} -#endregion - -#region Configuration -$script:StartTime = Get-Date -$script:ScriptVersion = "1.0.0" - -# Default thresholds -$script:DefaultThresholds = @{ - CpuWarning = 70 - CpuCritical = 90 - MemoryWarning = 80 - MemoryCritical = 95 - DiskWarning = 80 - DiskCritical = 95 - DiskQueueWarning = 2 - NetworkErrorRate = 1 -} - -# Merge custom thresholds with defaults -if ($Thresholds) { - foreach ($key in $Thresholds.Keys) { - if ($script:DefaultThresholds.ContainsKey($key)) { - $script:DefaultThresholds[$key] = $Thresholds[$key] - } - } -} - -# Set output path -if (-not $OutputPath) { - $OutputPath = Get-LogDirectory -} - -# Ensure output directory exists -if (-not (Test-Path $OutputPath)) { - New-Item -ItemType Directory -Path $OutputPath -Force | Out-Null -} - -# Performance counter paths -$script:CounterPaths = @{ - CPU = @( - '\Processor(_Total)\% Processor Time', - '\Processor(_Total)\% Privileged Time', - '\Processor(_Total)\% User Time', - '\System\Processor Queue Length' - ) - Memory = @( - '\Memory\Available MBytes', - '\Memory\% Committed Bytes In Use', - '\Memory\Pages/sec', - '\Memory\Cache Bytes' - ) - Disk = @( - '\PhysicalDisk(_Total)\% Disk Time', - '\PhysicalDisk(_Total)\Avg. Disk Queue Length', - '\PhysicalDisk(_Total)\Disk Read Bytes/sec', - '\PhysicalDisk(_Total)\Disk Write Bytes/sec', - '\PhysicalDisk(_Total)\Avg. Disk sec/Read', - '\PhysicalDisk(_Total)\Avg. Disk sec/Write' - ) - Network = @( - '\Network Interface(*)\Bytes Total/sec', - '\Network Interface(*)\Bytes Sent/sec', - '\Network Interface(*)\Bytes Received/sec', - '\Network Interface(*)\Packets/sec', - '\Network Interface(*)\Packets Received Errors' - ) -} -#endregion - -#region Helper Functions -function Get-PerformanceMetrics { - <# - .SYNOPSIS - Collects performance metrics using Get-Counter. - #> - [CmdletBinding()] - param( - [int]$Samples = $SampleCount, - [int]$Interval = $SampleInterval - ) - - $metrics = @{ - Timestamp = Get-Date -Format 'o' - CPU = @{} - Memory = @{} - Disk = @{} - Network = @{} - DiskVolumes = @() - Alerts = @() - } - - try { - Write-InfoMessage "Collecting performance counters ($Samples samples, ${Interval}s interval)..." - - # Collect all counters - $allCounters = $script:CounterPaths.CPU + $script:CounterPaths.Memory + $script:CounterPaths.Disk - - $counterData = Get-Counter -Counter $allCounters -SampleInterval $Interval -MaxSamples $Samples -ErrorAction SilentlyContinue - - if ($counterData) { - # Process CPU metrics - $cpuSamples = @() - $queueSamples = @() - - foreach ($sample in $counterData) { - foreach ($reading in $sample.CounterSamples) { - switch -Wildcard ($reading.Path) { - '*Processor(_Total)\% Processor Time' { - $cpuSamples += $reading.CookedValue - } - '*System\Processor Queue Length' { - $queueSamples += $reading.CookedValue - } - } - } - } - - $metrics.CPU = @{ - UsagePercent = [math]::Round(($cpuSamples | Measure-Object -Average).Average, 2) - UsageMin = [math]::Round(($cpuSamples | Measure-Object -Minimum).Minimum, 2) - UsageMax = [math]::Round(($cpuSamples | Measure-Object -Maximum).Maximum, 2) - QueueLength = [math]::Round(($queueSamples | Measure-Object -Average).Average, 2) - ProcessorCount = (Get-CimInstance -ClassName Win32_Processor).NumberOfLogicalProcessors - } - - # Process Memory metrics - $memInfo = Get-CimInstance -ClassName Win32_OperatingSystem - $metrics.Memory = @{ - TotalGB = [math]::Round($memInfo.TotalVisibleMemorySize / 1MB, 2) - AvailableGB = [math]::Round($memInfo.FreePhysicalMemory / 1MB, 2) - UsedGB = [math]::Round(($memInfo.TotalVisibleMemorySize - $memInfo.FreePhysicalMemory) / 1MB, 2) - UsagePercent = [math]::Round((($memInfo.TotalVisibleMemorySize - $memInfo.FreePhysicalMemory) / $memInfo.TotalVisibleMemorySize) * 100, 2) - PageFileUsageGB = [math]::Round(($memInfo.TotalVirtualMemorySize - $memInfo.FreeVirtualMemory) / 1MB, 2) - } - - # Process Disk metrics - $diskSamples = @{ Time = @(); Queue = @(); ReadBytes = @(); WriteBytes = @() } - - foreach ($sample in $counterData) { - foreach ($reading in $sample.CounterSamples) { - switch -Wildcard ($reading.Path) { - '*PhysicalDisk(_Total)\% Disk Time' { - $diskSamples.Time += $reading.CookedValue - } - '*PhysicalDisk(_Total)\Avg. Disk Queue Length' { - $diskSamples.Queue += $reading.CookedValue - } - '*PhysicalDisk(_Total)\Disk Read Bytes/sec' { - $diskSamples.ReadBytes += $reading.CookedValue - } - '*PhysicalDisk(_Total)\Disk Write Bytes/sec' { - $diskSamples.WriteBytes += $reading.CookedValue - } - } - } - } - - $metrics.Disk = @{ - TimePercent = [math]::Round(($diskSamples.Time | Measure-Object -Average).Average, 2) - QueueLength = [math]::Round(($diskSamples.Queue | Measure-Object -Average).Average, 2) - ReadMBps = [math]::Round((($diskSamples.ReadBytes | Measure-Object -Average).Average) / 1MB, 2) - WriteMBps = [math]::Round((($diskSamples.WriteBytes | Measure-Object -Average).Average) / 1MB, 2) - } - - # Get disk volume information - $volumes = Get-CimInstance -ClassName Win32_LogicalDisk -Filter "DriveType=3" - foreach ($vol in $volumes) { - $metrics.DiskVolumes += @{ - DriveLetter = $vol.DeviceID - Label = $vol.VolumeName - TotalGB = [math]::Round($vol.Size / 1GB, 2) - FreeGB = [math]::Round($vol.FreeSpace / 1GB, 2) - UsedGB = [math]::Round(($vol.Size - $vol.FreeSpace) / 1GB, 2) - UsagePercent = [math]::Round((($vol.Size - $vol.FreeSpace) / $vol.Size) * 100, 2) - } - } - } - - # Collect network metrics - $networkAdapters = Get-NetAdapterStatistics -ErrorAction SilentlyContinue - if ($networkAdapters) { - $totalSent = ($networkAdapters | Measure-Object -Property SentBytes -Sum).Sum - $totalReceived = ($networkAdapters | Measure-Object -Property ReceivedBytes -Sum).Sum - $totalErrors = ($networkAdapters | Measure-Object -Property InboundDiscarded, OutboundDiscarded -Sum).Sum - - $metrics.Network = @{ - TotalSentGB = [math]::Round($totalSent / 1GB, 2) - TotalReceivedGB = [math]::Round($totalReceived / 1GB, 2) - TotalErrors = $totalErrors - ActiveAdapters = ($networkAdapters | Where-Object { $_.SentBytes -gt 0 -or $_.ReceivedBytes -gt 0 }).Count - } - } - - # Check thresholds and generate alerts - $metrics.Alerts = Get-ThresholdAlerts -Metrics $metrics - - } - catch { - Write-ErrorMessage "Error collecting performance metrics: $($_.Exception.Message)" - if (Get-Command Write-ContextualError -ErrorAction SilentlyContinue) { - Write-ContextualError -ErrorRecord $_ -Context "collecting performance metrics" -Suggestion "Ensure you have permission to access performance counters" - } - } - - return $metrics -} - -function Get-ThresholdAlerts { - <# - .SYNOPSIS - Checks metrics against thresholds and returns alerts. - #> - [CmdletBinding()] - param( - [Parameter(Mandatory)] - [hashtable]$Metrics - ) - - $alerts = @() - - # CPU alerts - if ($Metrics.CPU.UsagePercent -ge $script:DefaultThresholds.CpuCritical) { - $alerts += @{ - Level = 'Critical' - Type = 'CPU' - Message = "CPU usage is critical: $($Metrics.CPU.UsagePercent)% (threshold: $($script:DefaultThresholds.CpuCritical)%)" - } - } - elseif ($Metrics.CPU.UsagePercent -ge $script:DefaultThresholds.CpuWarning) { - $alerts += @{ - Level = 'Warning' - Type = 'CPU' - Message = "CPU usage is high: $($Metrics.CPU.UsagePercent)% (threshold: $($script:DefaultThresholds.CpuWarning)%)" - } - } - - # Memory alerts - if ($Metrics.Memory.UsagePercent -ge $script:DefaultThresholds.MemoryCritical) { - $alerts += @{ - Level = 'Critical' - Type = 'Memory' - Message = "Memory usage is critical: $($Metrics.Memory.UsagePercent)% (threshold: $($script:DefaultThresholds.MemoryCritical)%)" - } - } - elseif ($Metrics.Memory.UsagePercent -ge $script:DefaultThresholds.MemoryWarning) { - $alerts += @{ - Level = 'Warning' - Type = 'Memory' - Message = "Memory usage is high: $($Metrics.Memory.UsagePercent)% (threshold: $($script:DefaultThresholds.MemoryWarning)%)" - } - } - - # Disk space alerts - foreach ($volume in $Metrics.DiskVolumes) { - if ($volume.UsagePercent -ge $script:DefaultThresholds.DiskCritical) { - $alerts += @{ - Level = 'Critical' - Type = 'Disk' - Message = "Disk $($volume.DriveLetter) usage is critical: $($volume.UsagePercent)% (threshold: $($script:DefaultThresholds.DiskCritical)%)" - } - } - elseif ($volume.UsagePercent -ge $script:DefaultThresholds.DiskWarning) { - $alerts += @{ - Level = 'Warning' - Type = 'Disk' - Message = "Disk $($volume.DriveLetter) usage is high: $($volume.UsagePercent)% (threshold: $($script:DefaultThresholds.DiskWarning)%)" - } - } - } - - # Disk queue alerts - if ($Metrics.Disk.QueueLength -ge $script:DefaultThresholds.DiskQueueWarning) { - $alerts += @{ - Level = 'Warning' - Type = 'Disk' - Message = "Disk queue length is high: $($Metrics.Disk.QueueLength) (threshold: $($script:DefaultThresholds.DiskQueueWarning))" - } - } - - return $alerts -} - -function Get-TopProcesses { - <# - .SYNOPSIS - Gets the top CPU and memory consuming processes. - #> - [CmdletBinding()] - param( - [int]$Count = $TopProcessCount - ) - - $processes = @{ - TopCPU = @() - TopMemory = @() - } - - try { - # Get processes with CPU and memory info - $allProcs = Get-Process -ErrorAction SilentlyContinue | Where-Object { $_.Id -ne 0 } - - # Top CPU consumers - $processes.TopCPU = $allProcs | - Sort-Object -Property CPU -Descending | - Select-Object -First $Count | - ForEach-Object { - @{ - Name = $_.ProcessName - PID = $_.Id - CPU = [math]::Round($_.CPU, 2) - WorkingSet = [math]::Round($_.WorkingSet64 / 1MB, 2) - } - } - - # Top Memory consumers - $processes.TopMemory = $allProcs | - Sort-Object -Property WorkingSet64 -Descending | - Select-Object -First $Count | - ForEach-Object { - @{ - Name = $_.ProcessName - PID = $_.Id - CPU = [math]::Round($_.CPU, 2) - WorkingSetMB = [math]::Round($_.WorkingSet64 / 1MB, 2) - } - } - } - catch { - Write-WarningMessage "Error getting process information: $($_.Exception.Message)" - } - - return $processes -} - -function Get-SystemInfo { - <# - .SYNOPSIS - Gets basic system information for the report header. - #> - [CmdletBinding()] - param() - - $os = Get-CimInstance -ClassName Win32_OperatingSystem - $cs = Get-CimInstance -ClassName Win32_ComputerSystem - $cpu = Get-CimInstance -ClassName Win32_Processor | Select-Object -First 1 - - return @{ - ComputerName = $env:COMPUTERNAME - OSName = $os.Caption - OSVersion = $os.Version - OSBuild = $os.BuildNumber - Manufacturer = $cs.Manufacturer - Model = $cs.Model - ProcessorName = $cpu.Name - ProcessorCores = $cpu.NumberOfCores - LogicalCPUs = $cpu.NumberOfLogicalProcessors - LastBoot = $os.LastBootUpTime - Uptime = (Get-Date) - $os.LastBootUpTime - } -} - -#region Disk Analysis Functions (merged from Watch-DiskSpace.ps1) -function Get-LargestFiles { - <# - .SYNOPSIS - Finds the largest files on a drive. - #> - [CmdletBinding()] - param( - [Parameter(Mandatory)] - [string]$DriveLetter, - - [Parameter()] - [int]$Count = 20 - ) - - $results = @() - - try { - Write-InfoMessage "Scanning for largest files on ${DriveLetter}:\ (this may take a while)..." - - $files = Get-ChildItem -Path "${DriveLetter}:\" -Recurse -File -ErrorAction SilentlyContinue | - Where-Object { $_.Length -gt 100MB } | - Sort-Object -Property Length -Descending | - Select-Object -First $Count - - foreach ($file in $files) { - $results += [PSCustomObject]@{ - Path = $file.FullName - SizeMB = [math]::Round($file.Length / 1MB, 2) - SizeGB = [math]::Round($file.Length / 1GB, 2) - Extension = $file.Extension - Modified = $file.LastWriteTime - Age = [int]((Get-Date) - $file.LastWriteTime).TotalDays - } - } - } catch { - Write-WarningMessage "Error scanning files: $($_.Exception.Message)" - } - - return $results -} - -function Get-LargestFolders { - <# - .SYNOPSIS - Finds the largest folders on a drive. - #> - [CmdletBinding()] - param( - [Parameter(Mandatory)] - [string]$DriveLetter, - - [Parameter()] - [int]$Count = 10 - ) - - $results = @() - $folders = @{} - - try { - Write-InfoMessage "Calculating folder sizes on ${DriveLetter}:\ (this may take a while)..." - - # Get first-level folders - $topFolders = Get-ChildItem -Path "${DriveLetter}:\" -Directory -ErrorAction SilentlyContinue - - foreach ($folder in $topFolders) { - try { - $size = (Get-ChildItem -Path $folder.FullName -Recurse -File -ErrorAction SilentlyContinue | - Measure-Object -Property Length -Sum).Sum - if ($size) { - $folders[$folder.FullName] = $size - } - } catch { - # Skip inaccessible folders - } - } - - # Sort and return top folders - $sortedFolders = $folders.GetEnumerator() | Sort-Object -Property Value -Descending | Select-Object -First $Count - - foreach ($folder in $sortedFolders) { - $results += [PSCustomObject]@{ - Path = $folder.Key - SizeMB = [math]::Round($folder.Value / 1MB, 2) - SizeGB = [math]::Round($folder.Value / 1GB, 2) - } - } - } catch { - Write-WarningMessage "Error calculating folder sizes: $($_.Exception.Message)" - } - - return $results -} - -function Get-CleanupSuggestions { - <# - .SYNOPSIS - Identifies cleanup opportunities on a drive. - #> - [CmdletBinding()] - param( - [Parameter(Mandatory)] - [string]$DriveLetter - ) - - $suggestions = @() - - # Windows temp folder - $windowsTemp = "$env:SystemRoot\Temp" - if ((Test-Path $windowsTemp) -and ($DriveLetter -eq $env:SystemDrive[0])) { - $size = (Get-ChildItem -Path $windowsTemp -Recurse -File -ErrorAction SilentlyContinue | - Measure-Object -Property Length -Sum).Sum - if ($size -gt 10MB) { - $suggestions += [PSCustomObject]@{ - Category = "Temp Files" - Path = $windowsTemp - SizeMB = [math]::Round($size / 1MB, 2) - Recommendation = "Safe to delete - Windows temporary files" - AutoCleanable = $true - } - } - } - - # User temp folder - $userTemp = $env:TEMP - if ((Test-Path $userTemp) -and ($DriveLetter -eq $userTemp[0])) { - $size = (Get-ChildItem -Path $userTemp -Recurse -File -ErrorAction SilentlyContinue | - Measure-Object -Property Length -Sum).Sum - if ($size -gt 10MB) { - $suggestions += [PSCustomObject]@{ - Category = "User Temp Files" - Path = $userTemp - SizeMB = [math]::Round($size / 1MB, 2) - Recommendation = "Safe to delete - User temporary files" - AutoCleanable = $true - } - } - } - - # Windows Update cache - $wuCache = "$env:SystemRoot\SoftwareDistribution\Download" - if ((Test-Path $wuCache) -and ($DriveLetter -eq $env:SystemDrive[0])) { - $size = (Get-ChildItem -Path $wuCache -Recurse -File -ErrorAction SilentlyContinue | - Measure-Object -Property Length -Sum).Sum - if ($size -gt 100MB) { - $suggestions += [PSCustomObject]@{ - Category = "Windows Update Cache" - Path = $wuCache - SizeMB = [math]::Round($size / 1MB, 2) - Recommendation = "Generally safe - Old Windows Update files" - AutoCleanable = $false - } - } - } - - # Recycle Bin - try { - $shell = New-Object -ComObject Shell.Application - $recycleBin = $shell.Namespace(0xa) - $recycleBinSize = 0 - $recycleBin.Items() | ForEach-Object { $recycleBinSize += $_.Size } - if ($recycleBinSize -gt 100MB) { - $suggestions += [PSCustomObject]@{ - Category = "Recycle Bin" - Path = "Recycle Bin" - SizeMB = [math]::Round($recycleBinSize / 1MB, 2) - Recommendation = "Safe to empty - Deleted files" - AutoCleanable = $true - } - } - } catch { - # Ignore errors accessing recycle bin - } - - # Browser caches - $browserPaths = @{ - "Chrome Cache" = "$env:LOCALAPPDATA\Google\Chrome\User Data\Default\Cache" - "Edge Cache" = "$env:LOCALAPPDATA\Microsoft\Edge\User Data\Default\Cache" - "Firefox Cache" = "$env:LOCALAPPDATA\Mozilla\Firefox\Profiles" - } - - foreach ($browser in $browserPaths.GetEnumerator()) { - if (Test-Path $browser.Value) { - $size = (Get-ChildItem -Path $browser.Value -Recurse -File -ErrorAction SilentlyContinue | - Measure-Object -Property Length -Sum).Sum - if ($size -gt 100MB) { - $suggestions += [PSCustomObject]@{ - Category = $browser.Key - Path = $browser.Value - SizeMB = [math]::Round($size / 1MB, 2) - Recommendation = "Safe to delete - Browser cache files" - AutoCleanable = $true - } - } - } - } - - return $suggestions -} - -function Invoke-DiskAutoCleanup { - <# - .SYNOPSIS - Performs automatic cleanup of safe-to-delete files. - #> - [CmdletBinding()] - param( - [Parameter(Mandatory)] - [PSCustomObject[]]$Suggestions - ) - - $cleanedMB = 0 - $cleanableSuggestions = $Suggestions | Where-Object { $_.AutoCleanable } - - foreach ($suggestion in $cleanableSuggestions) { - Write-InfoMessage "Cleaning: $($suggestion.Category)" - - try { - if ($suggestion.Category -eq "Recycle Bin") { - Clear-RecycleBin -Force -ErrorAction SilentlyContinue - } else { - Remove-Item -Path "$($suggestion.Path)\*" -Recurse -Force -ErrorAction SilentlyContinue - } - $cleanedMB += $suggestion.SizeMB - Write-Success "Cleaned $($suggestion.SizeMB) MB from $($suggestion.Category)" - } catch { - Write-WarningMessage "Could not clean $($suggestion.Category): $($_.Exception.Message)" - } - } - - return $cleanedMB -} - -function Get-DiskAnalysis { - <# - .SYNOPSIS - Performs detailed disk analysis for drives with issues. - #> - [CmdletBinding()] - param( - [Parameter(Mandatory)] - [array]$DiskVolumes, - - [int]$FilesCount = 20, - - [int]$FoldersCount = 10, - - [switch]$EnableAutoCleanup - ) - - $analysis = @{ - LargestFiles = @{} - LargestFolders = @{} - CleanupSuggestions = @{} - CleanedMB = 0 - } - - foreach ($disk in $DiskVolumes) { - $driveLetter = $disk.DriveLetter -replace ':', '' - - # Filter by specified drive letters - if ($script:DriveLetters -and $script:DriveLetters -notcontains $driveLetter) { - continue - } - - # Exclude specified drives - if ($script:ExcludeDrives -and $script:ExcludeDrives -contains $driveLetter) { - continue - } - - # Only analyze drives with high usage (warning/critical) - if ($disk.UsagePercent -ge $script:DefaultThresholds.DiskWarning) { - Write-InfoMessage "Analyzing drive ${driveLetter}:..." - $analysis.LargestFiles[$driveLetter] = Get-LargestFiles -DriveLetter $driveLetter -Count $FilesCount - $analysis.LargestFolders[$driveLetter] = Get-LargestFolders -DriveLetter $driveLetter -Count $FoldersCount - $analysis.CleanupSuggestions[$driveLetter] = Get-CleanupSuggestions -DriveLetter $driveLetter - - # Auto cleanup if enabled and critical - if ($EnableAutoCleanup -and $disk.UsagePercent -ge $script:DefaultThresholds.DiskCritical) { - if ($analysis.CleanupSuggestions[$driveLetter]) { - Write-WarningMessage "Auto-cleanup enabled for critical drive ${driveLetter}:" - $analysis.CleanedMB += Invoke-DiskAutoCleanup -Suggestions $analysis.CleanupSuggestions[$driveLetter] - } - } - } - } - - return $analysis -} -#endregion -#endregion - -#region Output Functions -function Write-ConsoleReport { - <# - .SYNOPSIS - Outputs performance report to console. - #> - [CmdletBinding()] - param( - [Parameter(Mandatory)] - [hashtable]$Metrics, - - [hashtable]$SystemInfo, - - [hashtable]$Processes - ) - - $separator = "=" * 60 - - Write-Host "`n$separator" -ForegroundColor Cyan - Write-Host " SYSTEM PERFORMANCE REPORT" -ForegroundColor Cyan - Write-Host " Generated: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')" -ForegroundColor Cyan - Write-Host "$separator`n" -ForegroundColor Cyan - - # System Info - if ($SystemInfo) { - Write-Host "SYSTEM INFORMATION" -ForegroundColor White - Write-Host "-" * 40 - Write-Host " Computer: $($SystemInfo.ComputerName)" - Write-Host " OS: $($SystemInfo.OSName)" - Write-Host " Processor: $($SystemInfo.ProcessorName)" - Write-Host " Cores/LCPUs: $($SystemInfo.ProcessorCores)/$($SystemInfo.LogicalCPUs)" - Write-Host " Uptime: $($SystemInfo.Uptime.Days)d $($SystemInfo.Uptime.Hours)h $($SystemInfo.Uptime.Minutes)m`n" - } - - # CPU - Write-Host "CPU PERFORMANCE" -ForegroundColor White - Write-Host "-" * 40 - $cpuColor = if ($Metrics.CPU.UsagePercent -ge $script:DefaultThresholds.CpuCritical) { 'Red' } - elseif ($Metrics.CPU.UsagePercent -ge $script:DefaultThresholds.CpuWarning) { 'Yellow' } - else { 'Green' } - Write-Host " Usage: " -NoNewline - Write-Host "$($Metrics.CPU.UsagePercent)%" -ForegroundColor $cpuColor - Write-Host " Min/Max: $($Metrics.CPU.UsageMin)% / $($Metrics.CPU.UsageMax)%" - Write-Host " Queue: $($Metrics.CPU.QueueLength)`n" - - # Memory - Write-Host "MEMORY PERFORMANCE" -ForegroundColor White - Write-Host "-" * 40 - $memColor = if ($Metrics.Memory.UsagePercent -ge $script:DefaultThresholds.MemoryCritical) { 'Red' } - elseif ($Metrics.Memory.UsagePercent -ge $script:DefaultThresholds.MemoryWarning) { 'Yellow' } - else { 'Green' } - Write-Host " Usage: " -NoNewline - Write-Host "$($Metrics.Memory.UsagePercent)%" -ForegroundColor $memColor - Write-Host " Used/Total: $($Metrics.Memory.UsedGB) GB / $($Metrics.Memory.TotalGB) GB" - Write-Host " Available: $($Metrics.Memory.AvailableGB) GB`n" - - # Disk I/O - Write-Host "DISK PERFORMANCE" -ForegroundColor White - Write-Host "-" * 40 - Write-Host " Activity: $($Metrics.Disk.TimePercent)%" - Write-Host " Queue: $($Metrics.Disk.QueueLength)" - Write-Host " Read Rate: $($Metrics.Disk.ReadMBps) MB/s" - Write-Host " Write Rate: $($Metrics.Disk.WriteMBps) MB/s`n" - - # Disk Volumes - Write-Host "DISK VOLUMES" -ForegroundColor White - Write-Host "-" * 40 - foreach ($vol in $Metrics.DiskVolumes) { - $volColor = if ($vol.UsagePercent -ge $script:DefaultThresholds.DiskCritical) { 'Red' } - elseif ($vol.UsagePercent -ge $script:DefaultThresholds.DiskWarning) { 'Yellow' } - else { 'Green' } - Write-Host " $($vol.DriveLetter) " -NoNewline - Write-Host "[$($vol.UsagePercent)%]" -ForegroundColor $volColor -NoNewline - Write-Host " $($vol.FreeGB) GB free of $($vol.TotalGB) GB" - } - Write-Host "" - - # Network - if ($Metrics.Network.Keys.Count -gt 0) { - Write-Host "NETWORK PERFORMANCE" -ForegroundColor White - Write-Host "-" * 40 - Write-Host " Total Sent: $($Metrics.Network.TotalSentGB) GB" - Write-Host " Total Received: $($Metrics.Network.TotalReceivedGB) GB" - Write-Host " Active Adapters: $($Metrics.Network.ActiveAdapters)" - Write-Host " Errors: $($Metrics.Network.TotalErrors)`n" - } - - # Top Processes - if ($Processes -and $IncludeProcesses) { - Write-Host "TOP CPU PROCESSES" -ForegroundColor White - Write-Host "-" * 40 - $rank = 1 - foreach ($proc in $Processes.TopCPU) { - Write-Host " $rank. $($proc.Name) (PID: $($proc.PID)) - CPU: $($proc.CPU)s, Mem: $($proc.WorkingSet) MB" - $rank++ - } - Write-Host "" - - Write-Host "TOP MEMORY PROCESSES" -ForegroundColor White - Write-Host "-" * 40 - $rank = 1 - foreach ($proc in $Processes.TopMemory) { - Write-Host " $rank. $($proc.Name) (PID: $($proc.PID)) - Mem: $($proc.WorkingSetMB) MB" - $rank++ - } - Write-Host "" - } - - # Alerts - if ($Metrics.Alerts.Count -gt 0) { - Write-Host "ALERTS" -ForegroundColor White - Write-Host "-" * 40 - foreach ($alert in $Metrics.Alerts) { - $alertColor = if ($alert.Level -eq 'Critical') { 'Red' } else { 'Yellow' } - $alertPrefix = if ($alert.Level -eq 'Critical') { '[-]' } else { '[!]' } - Write-Host " $alertPrefix $($alert.Message)" -ForegroundColor $alertColor - } - Write-Host "" - } - else { - Write-Host "[+] No alerts - all metrics within normal thresholds`n" -ForegroundColor Green - } - - Write-Host $separator -ForegroundColor Cyan -} - -function Export-HTMLReport { - <# - .SYNOPSIS - Generates an HTML performance report. - #> - [CmdletBinding()] - param( - [Parameter(Mandatory)] - [hashtable]$Metrics, - - [hashtable]$SystemInfo, - - [hashtable]$Processes, - - [string]$Path - ) - - $timestamp = Get-Date -Format 'yyyy-MM-dd_HH-mm-ss' - $htmlPath = Join-Path $Path "performance-report_$timestamp.html" - - $alertsHtml = "" - if ($Metrics.Alerts.Count -gt 0) { - $alertsHtml = "

Alerts

    " - foreach ($alert in $Metrics.Alerts) { - $alertClass = if ($alert.Level -eq 'Critical') { 'critical' } else { 'warning' } - $alertsHtml += "
  • [$($alert.Level)] $($alert.Message)
  • " - } - $alertsHtml += "
" - } - - $volumesHtml = "" - foreach ($vol in $Metrics.DiskVolumes) { - $volClass = if ($vol.UsagePercent -ge $script:DefaultThresholds.DiskCritical) { 'critical' } - elseif ($vol.UsagePercent -ge $script:DefaultThresholds.DiskWarning) { 'warning' } - else { 'normal' } - $volumesHtml += "$($vol.DriveLetter)$($vol.TotalGB) GB$($vol.FreeGB) GB$($vol.UsagePercent)%" - } - - $processesHtml = "" - if ($Processes -and $IncludeProcesses) { - $processesHtml = @" -
-

Top CPU Processes

- - -"@ - $rank = 1 - foreach ($proc in $Processes.TopCPU) { - $processesHtml += "" - $rank++ - } - $processesHtml += @" -
RankProcessPIDCPU TimeMemory
$rank$($proc.Name)$($proc.PID)$($proc.CPU)s$($proc.WorkingSet) MB
-

Top Memory Processes

- - -"@ - $rank = 1 - foreach ($proc in $Processes.TopMemory) { - $processesHtml += "" - $rank++ - } - $processesHtml += "
RankProcessPIDMemory (MB)
$rank$($proc.Name)$($proc.PID)$($proc.WorkingSetMB)
" - } - - $html = @" - - - - - System Performance Report - $($SystemInfo.ComputerName) - - - -
-

System Performance Report

-

Computer: $($SystemInfo.ComputerName) | Generated: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')

-

OS: $($SystemInfo.OSName) | Uptime: $($SystemInfo.Uptime.Days)d $($SystemInfo.Uptime.Hours)h $($SystemInfo.Uptime.Minutes)m

- - $alertsHtml - -
-

CPU Performance

-
-
$($Metrics.CPU.UsagePercent)%
-
Current Usage
-
-
-
$($Metrics.CPU.UsageMin)% - $($Metrics.CPU.UsageMax)%
-
Min - Max
-
-
-
$($Metrics.CPU.QueueLength)
-
Queue Length
-
-
- -
-

Memory Performance

-
-
$($Metrics.Memory.UsagePercent)%
-
Usage
-
-
-
$($Metrics.Memory.UsedGB) GB
-
Used
-
-
-
$($Metrics.Memory.AvailableGB) GB
-
Available
-
-
-
$($Metrics.Memory.TotalGB) GB
-
Total
-
-
- -
-

Disk Performance

-
-
$($Metrics.Disk.TimePercent)%
-
Activity
-
-
-
$($Metrics.Disk.QueueLength)
-
Queue Length
-
-
-
$($Metrics.Disk.ReadMBps) MB/s
-
Read Rate
-
-
-
$($Metrics.Disk.WriteMBps) MB/s
-
Write Rate
-
-

Volumes

- - - $volumesHtml -
DriveTotalFreeUsage
-
- -
-

Network

-
-
$($Metrics.Network.TotalSentGB) GB
-
Total Sent
-
-
-
$($Metrics.Network.TotalReceivedGB) GB
-
Total Received
-
-
-
$($Metrics.Network.ActiveAdapters)
-
Active Adapters
-
-
- - $processesHtml - - -
- - -"@ - - $html | Set-Content -Path $htmlPath -Encoding UTF8 - Write-Success "HTML report saved: $htmlPath" - return $htmlPath -} - -function Export-JSONReport { - <# - .SYNOPSIS - Exports metrics to JSON format. - #> - [CmdletBinding()] - param( - [Parameter(Mandatory)] - [hashtable]$Metrics, - - [hashtable]$SystemInfo, - - [hashtable]$Processes, - - [string]$Path - ) - - $timestamp = Get-Date -Format 'yyyy-MM-dd_HH-mm-ss' - $jsonPath = Join-Path $Path "performance-report_$timestamp.json" - - $report = @{ - Timestamp = $Metrics.Timestamp - SystemInfo = $SystemInfo - Metrics = $Metrics - Processes = $Processes - Thresholds = $script:DefaultThresholds - } - - $report | ConvertTo-Json -Depth 10 | Set-Content -Path $jsonPath -Encoding UTF8 - Write-Success "JSON report saved: $jsonPath" - return $jsonPath -} - -function Export-CSVReport { - <# - .SYNOPSIS - Exports metrics to CSV format. - #> - [CmdletBinding()] - param( - [Parameter(Mandatory)] - [hashtable]$Metrics, - - [hashtable]$SystemInfo, - - [string]$Path - ) - - $timestamp = Get-Date -Format 'yyyy-MM-dd_HH-mm-ss' - $csvPath = Join-Path $Path "performance-report_$timestamp.csv" - - $csvData = [PSCustomObject]@{ - Timestamp = $Metrics.Timestamp - ComputerName = $SystemInfo.ComputerName - CPUUsagePercent = $Metrics.CPU.UsagePercent - CPUQueueLength = $Metrics.CPU.QueueLength - MemoryUsagePercent = $Metrics.Memory.UsagePercent - MemoryUsedGB = $Metrics.Memory.UsedGB - MemoryAvailableGB = $Metrics.Memory.AvailableGB - DiskActivityPercent = $Metrics.Disk.TimePercent - DiskQueueLength = $Metrics.Disk.QueueLength - DiskReadMBps = $Metrics.Disk.ReadMBps - DiskWriteMBps = $Metrics.Disk.WriteMBps - NetworkSentGB = $Metrics.Network.TotalSentGB - NetworkReceivedGB = $Metrics.Network.TotalReceivedGB - AlertCount = $Metrics.Alerts.Count - } - - $csvData | Export-Csv -Path $csvPath -NoTypeInformation -Encoding UTF8 - Write-Success "CSV report saved: $csvPath" - return $csvPath -} - -function Export-PrometheusReport { - <# - .SYNOPSIS - Exports metrics in Prometheus textfile collector format. - #> - [CmdletBinding()] - param( - [Parameter(Mandatory)] - [hashtable]$Metrics, - - [hashtable]$SystemInfo, - - [string]$Path - ) - - $timestamp = Get-Date -Format 'yyyy-MM-dd_HH-mm-ss' - $promPath = Join-Path $Path "windows_performance_$timestamp.prom" - - # Build metrics array for Export-PrometheusMetrics - $promMetrics = @() - - # CPU metrics - $promMetrics += @{ - Name = "windows_cpu_usage_percent" - Help = "CPU usage percentage" - Type = "gauge" - Value = $Metrics.CPU.UsagePercent - } - $promMetrics += @{ - Name = "windows_cpu_queue_length" - Help = "Processor queue length" - Type = "gauge" - Value = $Metrics.CPU.QueueLength - } - - # Memory metrics - $promMetrics += @{ - Name = "windows_memory_total_bytes" - Help = "Total physical memory in bytes" - Type = "gauge" - Value = [math]::Round($Metrics.Memory.TotalGB * 1GB) - } - $promMetrics += @{ - Name = "windows_memory_available_bytes" - Help = "Available physical memory in bytes" - Type = "gauge" - Value = [math]::Round($Metrics.Memory.AvailableGB * 1GB) - } - $promMetrics += @{ - Name = "windows_memory_used_bytes" - Help = "Used physical memory in bytes" - Type = "gauge" - Value = [math]::Round($Metrics.Memory.UsedGB * 1GB) - } - $promMetrics += @{ - Name = "windows_memory_usage_percent" - Help = "Memory usage percentage" - Type = "gauge" - Value = $Metrics.Memory.UsagePercent - } - - # Disk I/O metrics - $promMetrics += @{ - Name = "windows_disk_activity_percent" - Help = "Disk time percentage" - Type = "gauge" - Value = $Metrics.Disk.TimePercent - } - $promMetrics += @{ - Name = "windows_disk_queue_length" - Help = "Disk queue length" - Type = "gauge" - Value = $Metrics.Disk.QueueLength - } - $promMetrics += @{ - Name = "windows_disk_read_bytes_per_second" - Help = "Disk read rate in bytes per second" - Type = "gauge" - Value = [math]::Round($Metrics.Disk.ReadMBps * 1MB) - } - $promMetrics += @{ - Name = "windows_disk_write_bytes_per_second" - Help = "Disk write rate in bytes per second" - Type = "gauge" - Value = [math]::Round($Metrics.Disk.WriteMBps * 1MB) - } - - # Disk volume metrics - foreach ($vol in $Metrics.DiskVolumes) { - $drive = $vol.DriveLetter -replace ':', '' - $promMetrics += @{ - Name = "windows_disk_total_bytes" - Help = "Total disk space in bytes" - Type = "gauge" - Labels = @{ drive = $drive } - Value = [math]::Round($vol.TotalGB * 1GB) - } - $promMetrics += @{ - Name = "windows_disk_free_bytes" - Help = "Free disk space in bytes" - Type = "gauge" - Labels = @{ drive = $drive } - Value = [math]::Round($vol.FreeGB * 1GB) - } - $promMetrics += @{ - Name = "windows_disk_usage_percent" - Help = "Disk usage percentage" - Type = "gauge" - Labels = @{ drive = $drive } - Value = $vol.UsagePercent - } - } - - # Network metrics - if ($Metrics.Network.Keys.Count -gt 0) { - $promMetrics += @{ - Name = "windows_network_sent_bytes_total" - Help = "Total bytes sent" - Type = "counter" - Value = [math]::Round($Metrics.Network.TotalSentGB * 1GB) - } - $promMetrics += @{ - Name = "windows_network_received_bytes_total" - Help = "Total bytes received" - Type = "counter" - Value = [math]::Round($Metrics.Network.TotalReceivedGB * 1GB) - } - $promMetrics += @{ - Name = "windows_network_errors_total" - Help = "Total network errors" - Type = "counter" - Value = $Metrics.Network.TotalErrors - } - } - - # Alert count - $promMetrics += @{ - Name = "windows_performance_alerts" - Help = "Number of active performance alerts" - Type = "gauge" - Value = $Metrics.Alerts.Count - } - - # System info as labels on an info metric - if ($SystemInfo) { - $promMetrics += @{ - Name = "windows_system_info" - Help = "System information" - Type = "gauge" - Labels = @{ - computer = $SystemInfo.ComputerName - os = $SystemInfo.OSName -replace ' ', '_' - } - Value = 1 - } - } - - # Export using the CommonFunctions Export-PrometheusMetrics - Export-PrometheusMetrics -Metrics $promMetrics -OutputPath $promPath - Write-Success "Prometheus metrics saved: $promPath" - return $promPath -} -#endregion - -#region Main Execution -try { - Write-InfoMessage "=== System Performance Monitor v$script:ScriptVersion ===" - Write-InfoMessage "Started: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')" - - # Get system information - $systemInfo = Get-SystemInfo - - # Continuous monitoring mode - if ($MonitorDuration -gt 0) { - Write-InfoMessage "Continuous monitoring mode: $MonitorDuration minutes" - $endTime = (Get-Date).AddMinutes($MonitorDuration) - $iteration = 1 - - while ((Get-Date) -lt $endTime) { - Write-InfoMessage "Collection iteration $iteration..." - - $metrics = Get-PerformanceMetrics - $processes = if ($IncludeProcesses) { Get-TopProcesses } else { $null } - - # Skip output if AlertOnly and no alerts - if (-not $AlertOnly -or $metrics.Alerts.Count -gt 0) { - switch ($OutputFormat) { - 'Console' { Write-ConsoleReport -Metrics $metrics -SystemInfo $systemInfo -Processes $processes } - 'HTML' { Export-HTMLReport -Metrics $metrics -SystemInfo $systemInfo -Processes $processes -Path $OutputPath } - 'JSON' { Export-JSONReport -Metrics $metrics -SystemInfo $systemInfo -Processes $processes -Path $OutputPath } - 'CSV' { Export-CSVReport -Metrics $metrics -SystemInfo $systemInfo -Path $OutputPath } - 'Prometheus' { Export-PrometheusReport -Metrics $metrics -SystemInfo $systemInfo -Path $OutputPath } - 'All' { - Write-ConsoleReport -Metrics $metrics -SystemInfo $systemInfo -Processes $processes - Export-HTMLReport -Metrics $metrics -SystemInfo $systemInfo -Processes $processes -Path $OutputPath - Export-JSONReport -Metrics $metrics -SystemInfo $systemInfo -Processes $processes -Path $OutputPath - Export-CSVReport -Metrics $metrics -SystemInfo $systemInfo -Path $OutputPath - } - } - } - - $iteration++ - $remainingTime = ($endTime - (Get-Date)).TotalMinutes - if ($remainingTime -gt 0) { - Write-InfoMessage "Next collection in $SampleInterval seconds... ($([math]::Round($remainingTime, 1)) minutes remaining)" - Start-Sleep -Seconds ($SampleInterval * $SampleCount) - } - } - } - else { - # Single run mode - $metrics = Get-PerformanceMetrics - $processes = if ($IncludeProcesses) { Get-TopProcesses } else { $null } - - # Skip output if AlertOnly and no alerts - if (-not $AlertOnly -or $metrics.Alerts.Count -gt 0) { - switch ($OutputFormat) { - 'Console' { Write-ConsoleReport -Metrics $metrics -SystemInfo $systemInfo -Processes $processes } - 'HTML' { Export-HTMLReport -Metrics $metrics -SystemInfo $systemInfo -Processes $processes -Path $OutputPath } - 'JSON' { Export-JSONReport -Metrics $metrics -SystemInfo $systemInfo -Processes $processes -Path $OutputPath } - 'CSV' { Export-CSVReport -Metrics $metrics -SystemInfo $systemInfo -Path $OutputPath } - 'Prometheus' { Export-PrometheusReport -Metrics $metrics -SystemInfo $systemInfo -Path $OutputPath } - 'All' { - Write-ConsoleReport -Metrics $metrics -SystemInfo $systemInfo -Processes $processes - Export-HTMLReport -Metrics $metrics -SystemInfo $systemInfo -Processes $processes -Path $OutputPath - Export-JSONReport -Metrics $metrics -SystemInfo $systemInfo -Processes $processes -Path $OutputPath - Export-CSVReport -Metrics $metrics -SystemInfo $systemInfo -Path $OutputPath - } - } - } - elseif ($AlertOnly) { - Write-Success "No alerts - all metrics within normal thresholds" - } - } - - $duration = (Get-Date) - $script:StartTime - Write-Success "=== Performance monitoring completed in $($duration.TotalSeconds.ToString('0.00'))s ===" -} -catch { - Write-ErrorMessage "Fatal error: $($_.Exception.Message)" - if (Get-Command Write-ContextualError -ErrorAction SilentlyContinue) { - Write-ContextualError -ErrorRecord $_ -Context "running performance monitor" -Suggestion "Check permissions and system access" - } - exit 1 -} -#endregion diff --git a/Windows/monitoring/README.md b/Windows/monitoring/README.md deleted file mode 100644 index 00b4153..0000000 --- a/Windows/monitoring/README.md +++ /dev/null @@ -1,78 +0,0 @@ -# Windows Monitoring Scripts - -System health and performance monitoring for Windows workstations and servers. - -## Scripts - -| Script | Purpose | -|--------|---------| -| [Get-SystemPerformance.ps1](Get-SystemPerformance.ps1) | CPU, RAM, disk, network metrics | -| [Watch-ServiceHealth.ps1](Watch-ServiceHealth.ps1) | Service status monitoring with auto-restart | -| [Test-NetworkHealth.ps1](Test-NetworkHealth.ps1) | Network connectivity and latency tests | -| [Get-EventLogAnalysis.ps1](Get-EventLogAnalysis.ps1) | Event log filtering and analysis | -| [Get-ApplicationHealth.ps1](Get-ApplicationHealth.ps1) | Application crash and version monitoring | - -## Quick Start - -```powershell -# Basic system check -.\Get-SystemPerformance.ps1 - -# HTML report with processes -.\Get-SystemPerformance.ps1 -OutputFormat HTML -IncludeProcesses - -# Monitor services -.\Watch-ServiceHealth.ps1 -Services "ssh-agent", "W32Time" - -# Network connectivity -.\Test-NetworkHealth.ps1 -Targets "google.com", "github.com" - -# Recent errors -.\Get-EventLogAnalysis.ps1 -LogName System -Level Error -Hours 24 -``` - -## Key Parameters - -### Get-SystemPerformance.ps1 - -| Parameter | Description | Default | -|-----------|-------------|---------| -| `-OutputFormat` | Console, HTML, JSON, CSV, Prometheus | Console | -| `-OutputPath` | Output directory | logs/ | -| `-MonitorDuration` | Minutes to monitor (0 = single) | 0 | -| `-AlertOnly` | Only output on threshold breach | false | -| `-IncludeProcesses` | Include top processes | false | - -### Watch-ServiceHealth.ps1 - -| Parameter | Description | -|-----------|-------------| -| `-Services` | Services to monitor | -| `-AutoRestart` | Restart failed services | -| `-MaxRestartAttempts` | Max restart tries | -| `-MonitorDuration` | Minutes to monitor | - -## Prometheus Integration - -```powershell -.\Get-SystemPerformance.ps1 -OutputFormat Prometheus -OutputPath "C:\node_exporter\textfile" -``` - -## Scheduled Monitoring - -```powershell -$action = New-ScheduledTaskAction -Execute "pwsh.exe" ` - -Argument "-File `"$PWD\Get-SystemPerformance.ps1`" -OutputFormat JSON" -$trigger = New-ScheduledTaskTrigger -Once -At (Get-Date) -RepetitionInterval (New-TimeSpan -Minutes 5) -Register-ScheduledTask -TaskName "SystemMonitor" -Action $action -Trigger $trigger -``` - -## Prerequisites - -- PowerShell 7.0+ -- Administrator privileges (for some metrics) - -Use `Get-Help .\') - } - Verification = $null - } - Add-Type -AssemblyName System.Web -ErrorAction SilentlyContinue - $path = Export-HTMLReport -Report $report -Path $TestDrive - $content = Get-Content -Raw -LiteralPath $path - $content | Should -Match '<script>' - $content | Should -Not -Match '' - } -} - -Describe 'Backup-UserData.ps1 - Export-JSONReport' { - It 'Writes a JSON file with the report contents' { - Mock Write-Success { } - $report = @{ - ComputerName = 'TESTPC' - BackupType = 'Incremental' - Success = $true - Stats = @{ BackedUpFiles = 3 } - } - $path = Export-JSONReport -Report $report -Path $TestDrive - [System.IO.File]::Exists($path) | Should -BeTrue - $parsed = Get-Content -Raw -LiteralPath $path | ConvertFrom-Json - $parsed.ComputerName | Should -Be 'TESTPC' - $parsed.Stats.BackedUpFiles | Should -Be 3 - } -} - -Describe 'Backup-UserData.ps1 - Invoke-UserDataBackup (top level)' { - BeforeEach { - Mock Write-InfoMessage { } - Mock Write-Success { } - Mock Write-WarningMessage { } - Mock Write-ErrorMessage { } - Mock Write-Host { } - Mock Write-Progress { } - Mock Get-BackupMetadata { @{ LastFullBackup = $null; LastIncrementalBackup = $null; BackupHistory = @() } } - Mock Save-BackupMetadata { } - Mock Remove-OldBackups { } - Mock Get-FilesToBackup { @() } - $script:Stats = @{ - TotalFiles = 0; TotalSize = 0; BackedUpFiles = 0; BackedUpSize = 0 - SkippedFiles = 0; FailedFiles = 0; Errors = @() - } - $script:StartTime = Get-Date - } - - It 'Returns 0 on the happy path with no files to back up' { - $result = Invoke-UserDataBackup -Destination $TestDrive -BackupType Full -DryRun -SourceFolders @() - $result | Should -Be 0 - } - - It 'Returns 1 when a fatal error escapes the try block' { - Mock Get-BackupMetadata { throw 'metadata IO error' } - $result = Invoke-UserDataBackup -Destination $TestDrive -BackupType Full -DryRun -SourceFolders @() - $result | Should -Be 1 - Should -Invoke Write-ErrorMessage -ParameterFilter { $Message -match 'Fatal error' } - } - - It 'Logs the DRY RUN warning when -DryRun is in effect' { - Invoke-UserDataBackup -Destination $TestDrive -BackupType Full -DryRun -SourceFolders @() | Out-Null - Should -Invoke Write-WarningMessage -ParameterFilter { $Message -match 'DRY RUN MODE' } - } -} diff --git a/tests/Windows/DeveloperEnvironment.Tests.ps1 b/tests/Windows/DeveloperEnvironment.Tests.ps1 deleted file mode 100644 index 8dd362f..0000000 --- a/tests/Windows/DeveloperEnvironment.Tests.ps1 +++ /dev/null @@ -1,273 +0,0 @@ -# Developer Environment Backup/Restore Tests -# Tests for Backup-DeveloperEnvironment.ps1 and Restore-DeveloperEnvironment.ps1 - -BeforeAll { - $ProjectRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent - - # Script paths - $BackupScript = Join-Path $ProjectRoot "Windows\backup\Backup-DeveloperEnvironment.ps1" - $RestoreScript = Join-Path $ProjectRoot "Windows\backup\Restore-DeveloperEnvironment.ps1" - - # Import test helpers - $TestHelpers = Join-Path $PSScriptRoot "..\TestHelpers.psm1" - if (Test-Path $TestHelpers) { - Import-Module $TestHelpers -Force - } -} - -Describe "Backup-DeveloperEnvironment.ps1" { - Context "Script Validation" { - It "Script file should exist" { - Test-Path $BackupScript | Should -Be $true - } - - It "Should have valid PowerShell syntax" { - $errors = $null - $content = Get-Content $BackupScript -Raw - [System.Management.Automation.PSParser]::Tokenize($content, [ref]$errors) | Out-Null - $errors.Count | Should -Be 0 - } - - It "Should have comment-based help" { - $content = Get-Content $BackupScript -Raw - $content | Should -Match '\.SYNOPSIS' - $content | Should -Match '\.DESCRIPTION' - $content | Should -Match '\.EXAMPLE' - } - - It "Should have required parameters" { - $content = Get-Content $BackupScript -Raw - $content | Should -Match '\$BackupPath' - } - - It "Should support ShouldProcess (WhatIf)" { - $content = Get-Content $BackupScript -Raw - $content | Should -Match 'SupportsShouldProcess' - } - } - - Context "Backup Execution" { - BeforeAll { - $TestBackupPath = Join-Path $TestDrive "DevEnvBackup" - } - - It "Should create backup directory with timestamp" { - # Run with WhatIf to avoid actual file operations - $result = & $BackupScript -BackupPath $TestBackupPath -WhatIf 2>&1 - - # Script should complete without throwing - { & $BackupScript -BackupPath $TestBackupPath -WhatIf } | Should -Not -Throw - } - - It "Should handle missing source files gracefully" { - # Script should not throw even when source files don't exist - { & $BackupScript -BackupPath $TestBackupPath -WhatIf } | Should -Not -Throw - } - } - - Context "Target Configuration" { - It "Should define correct VSCode settings path" { - $content = Get-Content $BackupScript -Raw - $content | Should -Match 'Code\\User\\settings\.json' - } - - It "Should define correct Windows Terminal path" { - $content = Get-Content $BackupScript -Raw - $content | Should -Match 'Microsoft\.WindowsTerminal.*settings\.json' - } - - It "Should define Git config path" { - $content = Get-Content $BackupScript -Raw - $content | Should -Match '\.gitconfig' - } - - It "Should define SSH config path" { - $content = Get-Content $BackupScript -Raw - $content | Should -Match '\.ssh\\config' - } - - It "Should define PowerShell profile path" { - $content = Get-Content $BackupScript -Raw - $content | Should -Match '\$PROFILE' - } - } -} - -Describe "Restore-DeveloperEnvironment.ps1" { - Context "Script Validation" { - It "Script file should exist" { - Test-Path $RestoreScript | Should -Be $true - } - - It "Should have valid PowerShell syntax" { - $errors = $null - $content = Get-Content $RestoreScript -Raw - [System.Management.Automation.PSParser]::Tokenize($content, [ref]$errors) | Out-Null - $errors.Count | Should -Be 0 - } - - It "Should have comment-based help" { - $content = Get-Content $RestoreScript -Raw - $content | Should -Match '\.SYNOPSIS' - $content | Should -Match '\.DESCRIPTION' - $content | Should -Match '\.EXAMPLE' - } - - It "Should have mandatory BackupPath parameter" { - $content = Get-Content $RestoreScript -Raw - $content | Should -Match 'Parameter\(Mandatory\)' - $content | Should -Match '\$BackupPath' - } - - It "Should support ShouldProcess (WhatIf)" { - $content = Get-Content $RestoreScript -Raw - $content | Should -Match 'SupportsShouldProcess' - } - - It "Should validate BackupPath exists" { - $content = Get-Content $RestoreScript -Raw - $content | Should -Match 'ValidateScript' - } - } - - Context "Restore Execution" { - BeforeAll { - # Create a mock backup structure - $MockBackupPath = Join-Path $TestDrive "MockBackup" - New-Item -ItemType Directory -Path $MockBackupPath -Force | Out-Null - - # Create mock manifest - $manifest = @{ - Timestamp = "20251226-120000" - BackupDate = "2025-12-26 12:00:00" - ComputerName = "TESTPC" - UserName = "TestUser" - Items = @( - @{ - Name = "GitConfig" - OriginalPath = Join-Path $TestDrive "restored\.gitconfig" - BackupFile = Join-Path $MockBackupPath "GitConfig" - Description = "Git global configuration" - } - ) - } - - $manifest | ConvertTo-Json -Depth 5 | Out-File (Join-Path $MockBackupPath "manifest.json") - - # Create mock backup file - "[user]`nname = Test User" | Out-File (Join-Path $MockBackupPath "GitConfig") - } - - It "Should check for manifest.json and surface a clear error" { - # Verify the script contains logic to check for manifest.json. - # v1.1.0 refactor replaced 'exit 1' with 'throw' so callers - # (including tests) can catch the failure instead of being - # terminated. - $content = Get-Content $RestoreScript -Raw - $content | Should -Match 'manifest\.json' - $content | Should -Match '\bthrow\b' - $content | Should -Match 'Manifest not found' - } - - It "Should parse manifest correctly" { - # Should not throw with valid manifest - { & $RestoreScript -BackupPath $MockBackupPath -WhatIf } | Should -Not -Throw - } - } - - Context "Safety Features" { - It "Should support creating backup before overwriting" { - $content = Get-Content $RestoreScript -Raw - $content | Should -Match '\$CreateBackupFirst' - $content | Should -Match '\.bak' - } - - It "Should support Force parameter" { - $content = Get-Content $RestoreScript -Raw - $content | Should -Match '\$Force' - } - - It "Should handle VSCode extensions restoration" { - $content = Get-Content $RestoreScript -Raw - $content | Should -Match 'RestoreExtensions' - $content | Should -Match 'code --install-extension' - } - } -} - -Describe "Integration Tests - Backup and Restore" { - BeforeAll { - $TestBackupRoot = Join-Path $TestDrive "IntegrationTest" - $TestSourceDir = Join-Path $TestDrive "SourceFiles" - - # Create source directory - New-Item -ItemType Directory -Path $TestSourceDir -Force | Out-Null - - # Create mock source files - @{ - ".gitconfig" = "[user]`nname = Test User`nemail = test@example.com" - }.GetEnumerator() | ForEach-Object { - $filePath = Join-Path $TestSourceDir $_.Key - $_.Value | Out-File -FilePath $filePath -Encoding UTF8 - } - } - - Context "Manifest Structure" { - It "Manifest should contain required fields" { - # Create a mock manifest to test structure - $manifest = @{ - Timestamp = "20251226-120000" - BackupDate = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss") - ComputerName = $env:COMPUTERNAME - UserName = $env:USERNAME - Items = @() - } - - $manifest.Timestamp | Should -Not -BeNullOrEmpty - $manifest.BackupDate | Should -Not -BeNullOrEmpty - $manifest.ComputerName | Should -Not -BeNullOrEmpty - $manifest.UserName | Should -Not -BeNullOrEmpty - # Check Items key exists (empty array is valid) - $manifest.ContainsKey('Items') | Should -Be $true - } - } - - Context "File Operations" { - It "Should handle file paths with spaces" { - $pathWithSpaces = Join-Path $TestDrive "Path With Spaces" - New-Item -ItemType Directory -Path $pathWithSpaces -Force | Out-Null - - # This should not throw - { & $BackupScript -BackupPath $pathWithSpaces -WhatIf } | Should -Not -Throw - } - } -} - -Describe "Security Tests" { - Context "Backup Script Security" { - It "Should not contain hardcoded credentials" { - $content = Get-Content $BackupScript -Raw - $content | Should -Not -Match 'password\s*=\s*[''"][^''"]+' - $content | Should -Not -Match 'apikey\s*=\s*[''"][^''"]+' - } - - It "Should use safe file operations" { - $content = Get-Content $BackupScript -Raw - # Should use proper PowerShell cmdlets, not shell injection - $content | Should -Not -Match 'Invoke-Expression.*\$' - } - } - - Context "Restore Script Security" { - It "Should not contain hardcoded credentials" { - $content = Get-Content $RestoreScript -Raw - $content | Should -Not -Match 'password\s*=\s*[''"][^''"]+' - $content | Should -Not -Match 'apikey\s*=\s*[''"][^''"]+' - } - - It "Should validate backup path" { - $content = Get-Content $RestoreScript -Raw - $content | Should -Match 'ValidateScript' - } - } -} diff --git a/tests/Windows/GetApplicationHealth.Behavioral.Tests.ps1 b/tests/Windows/GetApplicationHealth.Behavioral.Tests.ps1 deleted file mode 100644 index 3bb2145..0000000 --- a/tests/Windows/GetApplicationHealth.Behavioral.Tests.ps1 +++ /dev/null @@ -1,282 +0,0 @@ -# Behavioral Pester tests for Get-ApplicationHealth.ps1 -# Run: Invoke-Pester -Path .\tests\Windows\GetApplicationHealth.Behavioral.Tests.ps1 - -BeforeAll { - # Native-command stubs so Pester Mock can attach. - function winget { param() } - function choco { param() } - - $ProjectRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent - $ScriptPath = Join-Path $ProjectRoot 'Windows\monitoring\Get-ApplicationHealth.ps1' - . $ScriptPath - - $global:LASTEXITCODE = 0 -} - -Describe 'Get-ApplicationHealth.ps1 - Get-InstalledApplications' { - BeforeEach { - Mock Write-Verbose { } - } - - It 'Maps DisplayName / DisplayVersion / Publisher from registry items' { - Mock Get-ItemProperty { - [PSCustomObject]@{ - DisplayName = 'Notepad++' - DisplayVersion = '8.6.0' - Publisher = 'Notepad++ Team' - InstallDate = '20260101' - InstallLocation = 'C:\Program Files\Notepad++' - UninstallString = 'C:\Program Files\Notepad++\uninstall.exe' - } - } -ParameterFilter { $Path -like 'HKLM:*' -or $Path -like 'HKCU:*' } - Mock Get-AppxPackage { @() } - - $apps = @(Get-InstalledApplications) - ($apps | Where-Object { $_.Name -eq 'Notepad++' })[0].Version | Should -Be '8.6.0' - } - - It 'Tags WOW6432Node entries as x86 and others as x64' { - Mock Get-ItemProperty { - [PSCustomObject]@{ - DisplayName = 'WOWApp' - DisplayVersion = '1.0' - Publisher = 'X' - } - } -ParameterFilter { $Path -like '*WOW6432Node*' } - Mock Get-ItemProperty { - [PSCustomObject]@{ - DisplayName = 'NativeApp' - DisplayVersion = '2.0' - Publisher = 'Y' - } - } -ParameterFilter { -not ($Path -like '*WOW6432Node*') -and ($Path -like 'HKLM:*' -or $Path -like 'HKCU:*') } - Mock Get-AppxPackage { @() } - - $apps = @(Get-InstalledApplications) - @($apps | Where-Object { $_.Name -eq 'WOWApp' })[0].Architecture | Should -Be 'x86' - @($apps | Where-Object { $_.Name -eq 'NativeApp' })[0].Architecture | Should -Be 'x64' - } - - It 'De-duplicates apps with the same Name + Version' { - # Same app returned twice from registry — only one record should land. - $script:Calls = 0 - Mock Get-ItemProperty { - $script:Calls++ - [PSCustomObject]@{ DisplayName = 'DupApp'; DisplayVersion = '1.0'; Publisher = 'X' } - } -ParameterFilter { $Path -like 'HKLM:*' -or $Path -like 'HKCU:*' } - Mock Get-AppxPackage { @() } - - $apps = @(Get-InstalledApplications) - @($apps | Where-Object { $_.Name -eq 'DupApp' }).Count | Should -Be 1 - } - - It 'Includes Windows Store apps with Source=WindowsStore' { - Mock Get-ItemProperty { $null } - Mock Get-AppxPackage { - @([PSCustomObject]@{ - Name = 'Microsoft.Calculator' - Version = '1.0.0.0' - Publisher = 'Microsoft' - InstallLocation = 'C:\X' - Architecture = 'X64' - IsFramework = $false - }) - } - - $apps = @(Get-InstalledApplications) - ($apps | Where-Object { $_.Name -eq 'Microsoft.Calculator' })[0].Source | Should -Be 'WindowsStore' - } -} - -Describe 'Get-ApplicationHealth.ps1 - Test-ApplicationInstalled' { - BeforeEach { - $script:Apps = @( - [PSCustomObject]@{ Name = 'Visual Studio Code' } - [PSCustomObject]@{ Name = 'Notepad++' } - ) - } - - It 'Returns the match for an exact name' { - $found = Test-ApplicationInstalled -AppName 'Notepad++' -InstalledApps $script:Apps - $found.Name | Should -Be 'Notepad++' - } - - It 'Returns the match for a wildcard partial name' { - $found = Test-ApplicationInstalled -AppName 'Visual Studio' -InstalledApps $script:Apps - $found.Name | Should -Be 'Visual Studio Code' - } - - It 'Returns null for an app that is not installed' { - $found = Test-ApplicationInstalled -AppName 'Nonexistent' -InstalledApps $script:Apps - $found | Should -BeNullOrEmpty - } -} - -Describe 'Get-ApplicationHealth.ps1 - Get-WingetUpdates' { - BeforeEach { - Mock Write-InfoMessage { } - Mock Write-WarningMessage { } - } - - It 'Returns an empty array when winget is not on PATH' { - Mock Get-Command { $null } -ParameterFilter { $Name -eq 'winget' } - $updates = @(Get-WingetUpdates) - $updates.Count | Should -Be 0 - } - - It 'Parses winget upgrade output into PSCustomObjects' { - Mock Get-Command { [PSCustomObject]@{ Name = 'winget' } } -ParameterFilter { $Name -eq 'winget' } - Mock winget { - $global:LASTEXITCODE = 0 - @( - "Name Id Version Available Source" - "------------------------------------------------------------------------------------------" - "PowerShell Microsoft.PowerShell 7.4.0 7.4.1 winget" - "Git Git.Git 2.42.0 2.43.0 winget" - "" - "2 upgrades available." - ) - } - - $updates = @(Get-WingetUpdates) - $updates.Count | Should -Be 2 - ($updates | Where-Object { $_.Name -eq 'PowerShell' })[0].AvailableVersion | Should -Be '7.4.1' - } -} - -Describe 'Get-ApplicationHealth.ps1 - Get-ChocolateyUpdates' { - BeforeEach { - Mock Write-InfoMessage { } - Mock Write-Verbose { } - Mock Write-WarningMessage { } - } - - It 'Returns an empty array when choco is not installed' { - Mock Get-Command { $null } -ParameterFilter { $Name -eq 'choco' } - $updates = @(Get-ChocolateyUpdates) - $updates.Count | Should -Be 0 - } - - It 'Parses choco outdated --limit-output into PSCustomObjects' { - Mock Get-Command { [PSCustomObject]@{ Name = 'choco' } } -ParameterFilter { $Name -eq 'choco' } - Mock choco { - $global:LASTEXITCODE = 0 - @( - 'nodejs|18.0.0|20.0.0|false' - 'python|3.10.0|3.12.0|false' - ) - } - - $updates = @(Get-ChocolateyUpdates) - $updates.Count | Should -Be 2 - ($updates | Where-Object { $_.Name -eq 'nodejs' })[0].AvailableVersion | Should -Be '20.0.0' - } -} - -Describe 'Get-ApplicationHealth.ps1 - Get-ApplicationCrashes' { - BeforeEach { - Mock Write-InfoMessage { } - Mock Write-WarningMessage { } - } - - It 'Extracts Application name from Properties[0] for Event 1000 (Application Error)' { - Mock Get-WinEvent { - @([PSCustomObject]@{ - TimeCreated = (Get-Date) - Id = 1000 - Properties = @( - [PSCustomObject]@{ Value = 'crashy.exe' } - [PSCustomObject]@{ Value = '1.2.3' } - [PSCustomObject]@{ Value = 'whatever' } - [PSCustomObject]@{ Value = 'kernel32.dll' } - [PSCustomObject]@{ Value = 'a' } - [PSCustomObject]@{ Value = 'b' } - [PSCustomObject]@{ Value = '0xC0000005' } - ) - }) - } -ParameterFilter { $FilterHashtable.Id -eq 1000 } - Mock Get-WinEvent { @() } -ParameterFilter { $FilterHashtable.Id -ne 1000 } - - $crashes = @(Get-ApplicationCrashes -Days 7) - $crashes.Count | Should -Be 1 - $crashes[0].Application | Should -Be 'crashy.exe' - $crashes[0].FaultingModule | Should -Be 'kernel32.dll' - $crashes[0].ExceptionCode | Should -Be '0xC0000005' - $crashes[0].EventType | Should -Be 'Application Error' - } - - It "Labels Event 1002 entries as 'Application Hang' with ExceptionCode='Hang'" { - Mock Get-WinEvent { - @([PSCustomObject]@{ - TimeCreated = (Get-Date) - Id = 1002 - Properties = @( - [PSCustomObject]@{ Value = 'hangy.exe' } - [PSCustomObject]@{ Value = '2.0' } - ) - }) - } -ParameterFilter { $FilterHashtable.Id -eq 1002 } - Mock Get-WinEvent { @() } -ParameterFilter { $FilterHashtable.Id -ne 1002 } - - $crashes = @(Get-ApplicationCrashes -Days 7) - $crashes.Count | Should -Be 1 - $crashes[0].EventType | Should -Be 'Application Hang' - $crashes[0].ExceptionCode | Should -Be 'Hang' - } - - It 'Returns empty when there are no matching events' { - Mock Get-WinEvent { @() } - $crashes = @(Get-ApplicationCrashes -Days 7) - $crashes.Count | Should -Be 0 - } -} - -Describe 'Get-ApplicationHealth.ps1 - Get-ApplicationResourceUsage' { - BeforeEach { - Mock Write-WarningMessage { } - } - - It 'Returns only processes with WorkingSet64 > 50MB, sorted descending, capped at TopCount' { - $now = Get-Date - Mock Get-Process { - @( - [PSCustomObject]@{ ProcessName = 'small'; Id = 1; WorkingSet64 = 10MB; CPU = 1; HandleCount = 100; StartTime = $now; Responding = $true; Threads = @(1, 2) } - [PSCustomObject]@{ ProcessName = 'big1'; Id = 2; WorkingSet64 = 200MB; CPU = 5; HandleCount = 200; StartTime = $now; Responding = $true; Threads = @(1, 2, 3) } - [PSCustomObject]@{ ProcessName = 'big2'; Id = 3; WorkingSet64 = 150MB; CPU = 3; HandleCount = 150; StartTime = $now; Responding = $true; Threads = @(1) } - ) - } - - $usage = @(Get-ApplicationResourceUsage -TopCount 5) - $usage.Count | Should -Be 2 # 'small' filtered out - $usage[0].ProcessName | Should -Be 'big1' - $usage[0].MemoryMB | Should -Be 200 - } -} - -Describe 'Get-ApplicationHealth.ps1 - Update-Application' { - BeforeEach { - Mock Write-InfoMessage { } - Mock Write-ErrorMessage { } - } - - It 'Calls winget upgrade for Winget package manager' { - Mock winget { $global:LASTEXITCODE = 0; 'OK' } -Verifiable -ParameterFilter { - $args -contains 'upgrade' -and $args -contains '--id' - } - $info = [PSCustomObject]@{ Name = 'Git'; Id = 'Git.Git'; PackageManager = 'Winget' } - Update-Application -UpdateInfo $info | Out-Null - Should -InvokeVerifiable - } - - It 'Calls choco upgrade for Chocolatey package manager and returns true on success' { - Mock choco { $global:LASTEXITCODE = 0; 'OK' } - $info = [PSCustomObject]@{ Name = 'nodejs'; Id = 'nodejs'; PackageManager = 'Chocolatey' } - Update-Application -UpdateInfo $info | Should -Be $true - } - - It 'Returns false when choco exits non-zero' { - Mock choco { $global:LASTEXITCODE = 1; 'err' } - $info = [PSCustomObject]@{ Name = 'nodejs'; Id = 'nodejs'; PackageManager = 'Chocolatey' } - Update-Application -UpdateInfo $info | Should -Be $false - } -} diff --git a/tests/Windows/GetEventLogAnalysis.Behavioral.Tests.ps1 b/tests/Windows/GetEventLogAnalysis.Behavioral.Tests.ps1 deleted file mode 100644 index daab376..0000000 --- a/tests/Windows/GetEventLogAnalysis.Behavioral.Tests.ps1 +++ /dev/null @@ -1,333 +0,0 @@ -# Behavioral Pester tests for Get-EventLogAnalysis.ps1 -# Run: Invoke-Pester -Path .\tests\Windows\GetEventLogAnalysis.Behavioral.Tests.ps1 -# -# The script is dot-sourced (testability guard skips Invoke-EventLogAnalysis on -# dot-source). Helper functions are exercised through Pester Mocks of Get-WinEvent -# and the analysis functions themselves. - -BeforeAll { - Import-Module Microsoft.PowerShell.Diagnostics -ErrorAction SilentlyContinue - $ProjectRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent - $ScriptPath = Join-Path $ProjectRoot 'Windows\monitoring\Get-EventLogAnalysis.ps1' - . $ScriptPath -} - -Describe 'Get-EventLogAnalysis.ps1 - Get-FilteredEvents' { - It 'Maps Get-WinEvent objects into TimeCreated/Source/EventId/Level fields' { - Mock Get-WinEvent { - @([PSCustomObject]@{ - Level = 2 - Id = 1000 - ProviderName = 'AppHost' - TimeCreated = (Get-Date) - Message = 'boom' - LogName = 'Application' - MachineName = 'TEST' - UserId = $null - ProcessId = 42 - ThreadId = 7 - Keywords = 0 - TaskDisplayName = 'General' - }) - } - $events = @(Get-FilteredEvents -LogName 'Application' -StartTime (Get-Date).AddHours(-1) -MaxEvents 100 -LevelValues @(1, 2)) - $events.Count | Should -Be 1 - $events[0].EventId | Should -Be 1000 - $events[0].Level | Should -Be 'Error' - $events[0].Source | Should -Be 'AppHost' - } - - It "Maps Level 1->'Critical', 3->'Warning', 4->'Information', unknown->'Unknown'" { - Mock Get-WinEvent { - @( - [PSCustomObject]@{ Level = 1; Id = 1; ProviderName = 'A'; TimeCreated = (Get-Date); Message = ''; LogName = 'Application'; MachineName = ''; UserId = $null; ProcessId = 0; ThreadId = 0; Keywords = 0; TaskDisplayName = '' } - [PSCustomObject]@{ Level = 3; Id = 2; ProviderName = 'A'; TimeCreated = (Get-Date); Message = ''; LogName = 'Application'; MachineName = ''; UserId = $null; ProcessId = 0; ThreadId = 0; Keywords = 0; TaskDisplayName = '' } - [PSCustomObject]@{ Level = 4; Id = 3; ProviderName = 'A'; TimeCreated = (Get-Date); Message = ''; LogName = 'Application'; MachineName = ''; UserId = $null; ProcessId = 0; ThreadId = 0; Keywords = 0; TaskDisplayName = '' } - [PSCustomObject]@{ Level = 99; Id = 4; ProviderName = 'A'; TimeCreated = (Get-Date); Message = ''; LogName = 'Application'; MachineName = ''; UserId = $null; ProcessId = 0; ThreadId = 0; Keywords = 0; TaskDisplayName = '' } - ) - } - $events = Get-FilteredEvents -LogName 'Application' -StartTime (Get-Date).AddHours(-1) -MaxEvents 100 -LevelValues @() - $events[0].Level | Should -Be 'Critical' - $events[1].Level | Should -Be 'Warning' - $events[2].Level | Should -Be 'Information' - $events[3].Level | Should -Be 'Unknown' - } - - It 'Applies SourceFilter wildcard and skips non-matching events' { - Mock Get-WinEvent { - @( - [PSCustomObject]@{ Level = 2; Id = 1; ProviderName = 'MSSQLSERVER'; TimeCreated = (Get-Date); Message = ''; LogName = 'Application'; MachineName = ''; UserId = $null; ProcessId = 0; ThreadId = 0; Keywords = 0; TaskDisplayName = '' } - [PSCustomObject]@{ Level = 2; Id = 2; ProviderName = 'Outlook'; TimeCreated = (Get-Date); Message = ''; LogName = 'Application'; MachineName = ''; UserId = $null; ProcessId = 0; ThreadId = 0; Keywords = 0; TaskDisplayName = '' } - ) - } - $events = @(Get-FilteredEvents -LogName 'Application' -StartTime (Get-Date).AddHours(-1) -MaxEvents 100 -LevelValues @() -SourceFilter '*SQL*') - $events.Count | Should -Be 1 - $events[0].Source | Should -Be 'MSSQLSERVER' - } - - It 'Applies ExcludeSources and drops matching events' { - Mock Get-WinEvent { - @( - [PSCustomObject]@{ Level = 2; Id = 1; ProviderName = 'NoisySvc'; TimeCreated = (Get-Date); Message = ''; LogName = 'Application'; MachineName = ''; UserId = $null; ProcessId = 0; ThreadId = 0; Keywords = 0; TaskDisplayName = '' } - [PSCustomObject]@{ Level = 2; Id = 2; ProviderName = 'RealError'; TimeCreated = (Get-Date); Message = ''; LogName = 'Application'; MachineName = ''; UserId = $null; ProcessId = 0; ThreadId = 0; Keywords = 0; TaskDisplayName = '' } - ) - } - $events = @(Get-FilteredEvents -LogName 'Application' -StartTime (Get-Date).AddHours(-1) -MaxEvents 100 -LevelValues @() -ExcludeSources @('NoisySvc')) - $events.Count | Should -Be 1 - $events[0].Source | Should -Be 'RealError' - } - - It "Returns empty collection and writes a warning when Get-WinEvent throws 'Access is denied'" { - Mock Get-WinEvent { throw 'Access is denied' } - Mock Write-WarningMessage { } - $events = Get-FilteredEvents -LogName 'Security' -StartTime (Get-Date).AddHours(-1) -MaxEvents 100 -LevelValues @() - $events.Count | Should -Be 0 - Should -Invoke Write-WarningMessage -Times 1 -ParameterFilter { $Message -match 'administrator privileges' } - } - - It 'Passes EventIds into the Get-WinEvent filter hashtable when supplied' { - Mock Get-WinEvent { @() } - $null = Get-FilteredEvents -LogName 'System' -StartTime (Get-Date).AddHours(-1) -MaxEvents 100 -LevelValues @(2) -EventIds @(7034, 6008) - Should -Invoke Get-WinEvent -Times 1 -ParameterFilter { - $FilterHashtable.Id -contains 7034 -and $FilterHashtable.Id -contains 6008 -and $FilterHashtable.Level -contains 2 - } - } -} - -Describe 'Get-EventLogAnalysis.ps1 - Get-SecurityAnalysis' { - It 'Buckets a 4625 event into FailedLogons and a 4624 into SuccessfulLogons' { - $events = @( - @{ LogName = 'Security'; EventId = 4625; Message = 'Account Name: alice' } - @{ LogName = 'Security'; EventId = 4624; Message = 'Account Name: bob' } - ) - $analysis = Get-SecurityAnalysis -Events $events - $analysis.FailedLogons.Count | Should -Be 1 - $analysis.SuccessfulLogons.Count | Should -Be 1 - } - - It 'Buckets 4672 into PrivilegeEscalation and 4720 into AccountChanges' { - $events = @( - @{ LogName = 'Security'; EventId = 4672; Message = '' } - @{ LogName = 'Security'; EventId = 4720; Message = '' } - ) - $analysis = Get-SecurityAnalysis -Events $events - $analysis.PrivilegeEscalation.Count | Should -Be 1 - $analysis.AccountChanges.Count | Should -Be 1 - } - - It 'Detects brute-force pattern when same account has >=5 failed logons' { - # All six events have identical 'Account Name: attacker' so the Group-Object - # scriptblock relying on $matches[1] always resolves to the same key. - $events = 1..6 | ForEach-Object { - @{ LogName = 'Security'; EventId = 4625; Message = 'Account Name: attacker' } - } - $analysis = Get-SecurityAnalysis -Events $events - @($analysis.SuspiciousActivity | Where-Object { $_.Type -eq 'BruteForce' }).Count | Should -Be 1 - } - - It 'Buckets 1102 into LogCleared' { - $events = @(@{ LogName = 'Security'; EventId = 1102; Message = '' }) - $analysis = Get-SecurityAnalysis -Events $events - $analysis.LogCleared.Count | Should -Be 1 - } - - It 'Ignores non-Security log events entirely' { - $events = @( - @{ LogName = 'System'; EventId = 4625; Message = '' } - @{ LogName = 'Application'; EventId = 4720; Message = '' } - ) - $analysis = Get-SecurityAnalysis -Events $events - $analysis.FailedLogons.Count | Should -Be 0 - $analysis.AccountChanges.Count | Should -Be 0 - } -} - -Describe 'Get-EventLogAnalysis.ps1 - Get-FailedLogonDetails' { - It 'Extracts TargetAccount, SourceIP, and LogonType from a 4625 message' { - $msg = "Account Name: alice`nAccount Domain: CORP`nLogon Type: 10`nSource Network Address: 10.0.0.5`n" - $events = @(@{ EventId = 4625; TimeCreated = (Get-Date); Message = $msg }) - $details = @(Get-FailedLogonDetails -Events $events) - $details[0].TargetAccount | Should -Be 'alice' - $details[0].SourceIP | Should -Be '10.0.0.5' - $details[0].LogonType | Should -Be 'RemoteInteractive (RDP)' - $details[0].TargetDomain | Should -Be 'CORP' - } - - It 'Returns empty when no 4625 events are present' { - $events = @(@{ EventId = 4624; TimeCreated = (Get-Date); Message = 'whatever' }) - $details = Get-FailedLogonDetails -Events $events - $details.Count | Should -Be 0 - } - - It "Maps numeric LogonType 2 to 'Interactive' and unknown numbers pass through" { - $events = @( - @{ EventId = 4625; TimeCreated = (Get-Date); Message = "Account Name: a`nLogon Type: 2`n" } - @{ EventId = 4625; TimeCreated = (Get-Date); Message = "Account Name: b`nLogon Type: 99`n" } - ) - $details = @(Get-FailedLogonDetails -Events $events) - $details[0].LogonType | Should -Be 'Interactive' - $details[1].LogonType | Should -Be '99' - } -} - -Describe 'Get-EventLogAnalysis.ps1 - Get-SystemIssues' { - It 'Buckets 7034 into ServiceFailures and 6008 into UnexpectedShutdowns' { - $events = @( - @{ LogName = 'System'; EventId = 7034 } - @{ LogName = 'System'; EventId = 6008 } - ) - $issues = Get-SystemIssues -Events $events - $issues.ServiceFailures.Count | Should -Be 1 - $issues.UnexpectedShutdowns.Count | Should -Be 1 - } - - It 'Buckets DiskError (id 7) and KernelPowerError (id 41) correctly' { - $events = @( - @{ LogName = 'System'; EventId = 7 } - @{ LogName = 'System'; EventId = 41 } - ) - $issues = Get-SystemIssues -Events $events - $issues.DiskErrors.Count | Should -Be 1 - $issues.KernelErrors.Count | Should -Be 1 - } - - It "Ignores events whose LogName is not 'System'" { - $events = @( - @{ LogName = 'Application'; EventId = 7034 } - @{ LogName = 'Application'; EventId = 6008 } - ) - $issues = Get-SystemIssues -Events $events - $issues.ServiceFailures.Count | Should -Be 0 - $issues.UnexpectedShutdowns.Count | Should -Be 0 - } -} - -Describe 'Get-EventLogAnalysis.ps1 - Get-ApplicationIssues' { - It 'Buckets 1000 into Crashes and 1026 into DotNetErrors' { - $events = @( - @{ LogName = 'Application'; EventId = 1000 } - @{ LogName = 'Application'; EventId = 1026 } - ) - $issues = Get-ApplicationIssues -Events $events - $issues.Crashes.Count | Should -Be 1 - $issues.DotNetErrors.Count | Should -Be 1 - } - - It 'Buckets SideBySide event ID 33 correctly' { - $events = @(@{ LogName = 'Application'; EventId = 33 }) - $issues = Get-ApplicationIssues -Events $events - $issues.SideBySide.Count | Should -Be 1 - } -} - -Describe 'Get-EventLogAnalysis.ps1 - Get-EventLogReport' { - BeforeEach { - Mock Write-InfoMessage { } - Mock Write-Success { } - Mock Write-WarningMessage { } - } - - It 'Aggregates Summary counters by Level across all logs' { - Mock Get-FilteredEvents { - @( - @{ Level = 'Critical'; LogName = 'Application'; EventId = 1; Source = 'A'; TimeCreated = (Get-Date); Message = '' } - @{ Level = 'Error'; LogName = 'Application'; EventId = 2; Source = 'A'; TimeCreated = (Get-Date); Message = '' } - @{ Level = 'Warning'; LogName = 'Application'; EventId = 3; Source = 'A'; TimeCreated = (Get-Date); Message = '' } - ) - } - $report = Get-EventLogReport -Hours 24 -Level 'Warning' -LogNames @('Application') -MaxEvents 100 -IsAdmin $true - $report.Summary.TotalEvents | Should -Be 3 - $report.Summary.Critical | Should -Be 1 - $report.Summary.Error | Should -Be 1 - $report.Summary.Warning | Should -Be 1 - } - - It 'Skips Security log and emits a warning when not admin and IncludeSecurityAnalysis is not set' { - Mock Get-FilteredEvents { @() } - $null = Get-EventLogReport -Hours 1 -Level 'Warning' -LogNames @('Security') -MaxEvents 100 -IsAdmin $false - Should -Invoke Write-WarningMessage -ParameterFilter { $Message -match 'Skipping Security log' } - Should -Invoke Get-FilteredEvents -Times 0 - } - - It 'Adds a Critical alert when Summary.Critical > 0' { - Mock Get-FilteredEvents { - @(@{ Level = 'Critical'; LogName = 'Application'; EventId = 1; Source = 'A'; TimeCreated = (Get-Date); Message = '' }) - } - $report = Get-EventLogReport -Hours 1 -Level 'Critical' -LogNames @('Application') -MaxEvents 100 -IsAdmin $true - @($report.Alerts | Where-Object { $_.Type -eq 'CriticalEvents' }).Count | Should -Be 1 - } - - It 'Adds a Critical alert for UnexpectedShutdown when System events include 6008' { - Mock Get-FilteredEvents { - @(@{ Level = 'Error'; LogName = 'System'; EventId = 6008; Source = 'EventLog'; TimeCreated = (Get-Date); Message = '' }) - } - $report = Get-EventLogReport -Hours 1 -Level 'Warning' -LogNames @('System') -MaxEvents 100 -IsAdmin $true - @($report.Alerts | Where-Object { $_.Type -eq 'UnexpectedShutdown' }).Count | Should -Be 1 - } - - It 'Populates SecurityAnalysis when IncludeSecurityAnalysis is set and IsAdmin is true' { - Mock Get-FilteredEvents { - @(@{ Level = 'Information'; LogName = 'Security'; EventId = 4625; Source = 'Audit'; TimeCreated = (Get-Date); Message = 'Account Name: alice' }) - } - $report = Get-EventLogReport -Hours 1 -Level 'Information' -LogNames @('Security') -MaxEvents 100 -IsAdmin $true -IncludeSecurityAnalysis $true - $report.SecurityAnalysis | Should -Not -BeNullOrEmpty - $report.SecurityAnalysis.FailedLogons.Count | Should -Be 1 - } -} - -Describe 'Get-EventLogAnalysis.ps1 - Export-JSONReport / Export-CSVReport / Export-HTMLReport' { - BeforeEach { - $script:Dir = Join-Path $TestDrive ([Guid]::NewGuid().Guid) - New-Item -ItemType Directory -Path $script:Dir -Force | Out-Null - } - - It 'Export-JSONReport writes a JSON file with computer name and summary fields' { - $report = @{ - Timestamp = (Get-Date).ToString('o') - ComputerName = 'TEST' - AnalysisPeriod = @{ Hours = 1; StartTime = (Get-Date); EndTime = (Get-Date) } - Summary = @{ TotalEvents = 1; Critical = 0; Error = 0; Warning = 1; Information = 0 } - EventsByLog = @{ Application = 1 } - TopEventIds = @() - TopSources = @() - EventsByHour = @{} - AllEvents = @() - Alerts = @() - SystemIssues = @{ ServiceFailures = @(); UnexpectedShutdowns = @(); DiskErrors = @(); DriverIssues = @(); KernelErrors = @() } - ApplicationIssues = @{ Crashes = @(); Hangs = @(); DotNetErrors = @(); SideBySide = @() } - FailedLogons = @() - } - $jsonPath = Export-JSONReport -Report $report -Path $script:Dir - Test-Path $jsonPath | Should -Be $true - (Get-Content $jsonPath -Raw | ConvertFrom-Json).ComputerName | Should -Be 'TEST' - } - - It 'Export-CSVReport writes one CSV row per event in AllEvents' { - $report = @{ - ComputerName = 'TEST' - AllEvents = @( - @{ TimeCreated = (Get-Date); LogName = 'Application'; Source = 'AppHost'; EventId = 1000; Level = 'Error'; TaskCategory = 'General'; Message = 'boom' } - @{ TimeCreated = (Get-Date); LogName = 'System'; Source = 'Service Control Manager'; EventId = 7034; Level = 'Error'; TaskCategory = 'None'; Message = 'svc crashed' } - ) - } - $csvPath = Export-CSVReport -Report $report -Path $script:Dir - Test-Path $csvPath | Should -Be $true - (Import-Csv $csvPath).Count | Should -Be 2 - } - - It 'Export-HTMLReport writes an HTML file mentioning the computer name and DOCTYPE' { - $report = @{ - ComputerName = 'TEST' - AnalysisPeriod = @{ Hours = 1; StartTime = (Get-Date); EndTime = (Get-Date) } - Summary = @{ TotalEvents = 0; Critical = 0; Error = 0; Warning = 0; Information = 0 } - Alerts = @() - TopEventIds = @() - AllEvents = @() - } - $htmlPath = Export-HTMLReport -Report $report -Path $script:Dir - Test-Path $htmlPath | Should -Be $true - $content = Get-Content $htmlPath -Raw - $content | Should -Match '' - $content | Should -Match 'TEST' - } -} diff --git a/tests/Windows/GetSystemReport.Behavioral.Tests.ps1 b/tests/Windows/GetSystemReport.Behavioral.Tests.ps1 deleted file mode 100644 index 5ea791e..0000000 --- a/tests/Windows/GetSystemReport.Behavioral.Tests.ps1 +++ /dev/null @@ -1,373 +0,0 @@ -# Behavioral Pester tests for Get-SystemReport.ps1 -# Run: Invoke-Pester -Path .\tests\Windows\GetSystemReport.Behavioral.Tests.ps1 -# -# The script is dot-sourced (testability guard skips Invoke-SystemReport on -# dot-source). Each helper wraps its CIM/registry calls in try/catch so -# unmocked calls failing or returning unexpected shapes just leave that -# section absent from the result hashtable -- tests only assert on what -# they explicitly mocked. - -BeforeAll { - $ProjectRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent - $ScriptPath = Join-Path $ProjectRoot 'Windows\reporting\Get-SystemReport.ps1' - . $ScriptPath -} - -Describe 'Get-SystemReport.ps1 - Get-HardwareInfo' { - BeforeEach { - Mock Write-InfoMessage { } - Mock Write-WarningMessage { } - } - - It 'Maps Win32_ComputerSystem fields to the ComputerSystem hashtable' { - Mock Get-CimInstance { - [PSCustomObject]@{ - Name = 'WORKSTATION-1' - Domain = 'CONTOSO' - Manufacturer = 'Dell Inc.' - Model = 'XPS 15' - SystemType = 'x64-based PC' - TotalPhysicalMemory = 34359738368 # 32 GB - NumberOfProcessors = 1 - NumberOfLogicalProcessors = 16 - } - } -ParameterFilter { $ClassName -eq 'Win32_ComputerSystem' } - - # Stub the other CIM calls to throw — they just leave gaps in the hashtable. - Mock Get-CimInstance { throw 'not mocked' } -ParameterFilter { $ClassName -ne 'Win32_ComputerSystem' } - - $hw = Get-HardwareInfo - $hw.ComputerSystem.Name | Should -Be 'WORKSTATION-1' - $hw.ComputerSystem.Manufacturer | Should -Be 'Dell Inc.' - $hw.ComputerSystem.Model | Should -Be 'XPS 15' - $hw.ComputerSystem.TotalPhysicalMemoryGB | Should -Be 32 - $hw.ComputerSystem.NumberOfLogicalProcessors | Should -Be 16 - } - - It 'Maps Architecture 9 to "x64" for CPUs' { - Mock Get-CimInstance { - @( - [PSCustomObject]@{ - Name = 'Intel(R) Core(TM) i7' - Manufacturer = 'GenuineIntel' - Description = 'Intel64 Family' - MaxClockSpeed = 3600 - NumberOfCores = 8 - NumberOfLogicalProcessors = 16 - L2CacheSize = 256 - L3CacheSize = 16384 - Architecture = 9 - SocketDesignation = 'CPU 1' - } - ) - } -ParameterFilter { $ClassName -eq 'Win32_Processor' } - Mock Get-CimInstance { throw 'not mocked' } -ParameterFilter { $ClassName -ne 'Win32_Processor' } - - $hw = Get-HardwareInfo - $hw.CPU[0].Architecture | Should -Be 'x64' - $hw.CPU[0].NumberOfCores | Should -Be 8 - } - - It 'Maps SMBIOSMemoryType 26 to "DDR4" and computes TotalMemoryGB' { - Mock Get-CimInstance { - @( - [PSCustomObject]@{ - Manufacturer = 'Corsair' - Capacity = 17179869184 # 16 GB - Speed = 3200 - SMBIOSMemoryType = 26 - FormFactor = 8 - DeviceLocator = 'DIMM_A1' - PartNumber = 'XYZ' - } - [PSCustomObject]@{ - Manufacturer = 'Corsair' - Capacity = 17179869184 - Speed = 3200 - SMBIOSMemoryType = 26 - FormFactor = 8 - DeviceLocator = 'DIMM_B1' - PartNumber = 'XYZ' - } - ) - } -ParameterFilter { $ClassName -eq 'Win32_PhysicalMemory' } - Mock Get-CimInstance { throw 'not mocked' } -ParameterFilter { $ClassName -ne 'Win32_PhysicalMemory' } - - $hw = Get-HardwareInfo - $hw.Memory[0].MemoryType | Should -Be 'DDR4' - $hw.Memory[0].FormFactor | Should -Be 'DIMM' - $hw.TotalMemoryGB | Should -Be 32 - } - - It 'Computes FreePercent for logical disks' { - # Win32_DiskDrive returns one drive, Win32_LogicalDisk returns one volume. - Mock Get-CimInstance { - @([PSCustomObject]@{ - Model = 'Samsung 970 EVO' - InterfaceType = 'SCSI' - MediaType = 'Fixed hard disk media' - Size = 1000000000000 - Partitions = 3 - SerialNumber = 'ABC123' - Status = 'OK' - }) - } -ParameterFilter { $ClassName -eq 'Win32_DiskDrive' } - Mock Get-CimInstance { - @([PSCustomObject]@{ - DeviceID = 'C:' - VolumeName = 'OS' - FileSystem = 'NTFS' - Size = 500000000000 - FreeSpace = 250000000000 - }) - } -ParameterFilter { $ClassName -eq 'Win32_LogicalDisk' } - Mock Get-CimInstance { throw 'not mocked' } -ParameterFilter { - $ClassName -notin @('Win32_DiskDrive', 'Win32_LogicalDisk') - } - - $hw = Get-HardwareInfo - $hw.Volumes[0].DriveLetter | Should -Be 'C:' - $hw.Volumes[0].FreePercent | Should -Be 50 - } -} - -Describe 'Get-SystemReport.ps1 - Get-SoftwareInfo' { - BeforeEach { - Mock Write-InfoMessage { } - Mock Write-WarningMessage { } - Mock Write-Verbose { } - } - - It 'Maps Win32_OperatingSystem fields and computes Uptime' { - $boot = (Get-Date).AddDays(-3) - Mock Get-CimInstance { - [PSCustomObject]@{ - Caption = 'Microsoft Windows 11 Pro' - Version = '10.0.22631' - BuildNumber = '22631' - OSArchitecture = '64-bit' - InstallDate = (Get-Date).AddYears(-1) - LastBootUpTime = $boot - RegisteredUser = 'user' - Organization = '' - WindowsDirectory = 'C:\Windows' - SystemDrive = 'C:' - } - } -ParameterFilter { $ClassName -eq 'Win32_OperatingSystem' } - Mock Get-CimInstance { throw 'not mocked' } -ParameterFilter { $ClassName -ne 'Win32_OperatingSystem' } - Mock Get-HotFix { throw 'no hotfixes' } - Mock Get-ItemProperty { $null } - Mock Test-IsAdministrator { $false } - - $sw = Get-SoftwareInfo - $sw.OperatingSystem.Name | Should -Be 'Microsoft Windows 11 Pro' - $sw.OperatingSystem.Version | Should -Be '10.0.22631' - $sw.OperatingSystem.Uptime.Days | Should -Be 3 - } - - It 'Always populates the PowerShell section from $PSVersionTable' { - Mock Get-CimInstance { throw 'not mocked' } - Mock Get-HotFix { throw 'no hotfixes' } - Mock Get-ItemProperty { $null } - Mock Test-IsAdministrator { $false } - - $sw = Get-SoftwareInfo - $sw.PowerShell.Version | Should -Not -BeNullOrEmpty - $sw.PowerShell.Edition | Should -BeIn @('Core', 'Desktop') - } -} - -Describe 'Get-SystemReport.ps1 - Get-NetworkInfo' { - BeforeEach { - Mock Write-InfoMessage { } - Mock Write-WarningMessage { } - } - - It 'Captures proxy settings from HKCU when present' { - Mock Get-NetAdapter { throw 'no adapters' } - Mock Get-DnsClient { throw 'no dns client' } - Mock Get-NetRoute { throw 'no routes' } - Mock Get-NetTCPConnection { throw 'no listeners' } - Mock Get-ItemProperty { - [PSCustomObject]@{ - ProxyEnable = 1 - ProxyServer = 'corp-proxy:8080' - ProxyOverride = '' - AutoConfigURL = $null - } - } -ParameterFilter { $Path -like '*Internet Settings*' } - - $net = Get-NetworkInfo - $net.ProxySettings.ProxyEnabled | Should -Be $true - $net.ProxySettings.ProxyServer | Should -Be 'corp-proxy:8080' - } -} - -Describe 'Get-SystemReport.ps1 - Get-SecurityInfo' { - BeforeEach { - Mock Write-InfoMessage { } - Mock Write-WarningMessage { } - Mock Write-Verbose { } - Mock Test-IsAdministrator { $false } - } - - It 'Maps WindowsDefender RealTimeProtectionEnabled' { - Mock Get-MpComputerStatus { - [PSCustomObject]@{ - AMServiceEnabled = $true - AntispywareEnabled = $true - AntivirusEnabled = $true - RealTimeProtectionEnabled = $true - AntivirusSignatureLastUpdated = (Get-Date) - AntivirusSignatureVersion = '1.400.0.0' - QuickScanEndTime = (Get-Date) - FullScanEndTime = (Get-Date) - } - } - Mock Get-NetFirewallProfile { throw 'no firewall' } - Mock Get-LocalGroupMember { throw 'no admins' } - Mock Get-ItemProperty { $null } - - $sec = Get-SecurityInfo - $sec.WindowsDefender.RealTimeProtectionEnabled | Should -Be $true - } - - It 'Maps UAC ConsentPromptBehaviorAdmin 5 to its descriptive string' { - Mock Get-MpComputerStatus { $null } - Mock Get-NetFirewallProfile { throw 'no firewall' } - Mock Get-LocalGroupMember { throw 'no admins' } - Mock Get-ItemProperty { - [PSCustomObject]@{ - EnableLUA = 1 - ConsentPromptBehaviorAdmin = 5 - PromptOnSecureDesktop = 1 - } - } -ParameterFilter { $Path -like '*Policies\System*' } - Mock Get-ItemProperty { $null } -ParameterFilter { $Path -notlike '*Policies\System*' } - - $sec = Get-SecurityInfo - $sec.UAC.Enabled | Should -Be $true - $sec.UAC.ConsentPromptBehaviorAdmin | Should -Match 'non-Windows' - } - - It 'Reports RemoteDesktop disabled when fDenyTSConnections is 1' { - Mock Get-MpComputerStatus { $null } - Mock Get-NetFirewallProfile { throw 'no firewall' } - Mock Get-LocalGroupMember { throw 'no admins' } - Mock Get-ItemProperty { - [PSCustomObject]@{ fDenyTSConnections = 1 } - } -ParameterFilter { $Path -like '*Terminal Server' } - Mock Get-ItemProperty { - [PSCustomObject]@{ UserAuthentication = 1 } - } -ParameterFilter { $Path -like '*RDP-Tcp' } - Mock Get-ItemProperty { $null } -ParameterFilter { - $Path -notlike '*Terminal Server' -and $Path -notlike '*RDP-Tcp' - } - - $sec = Get-SecurityInfo - $sec.RemoteDesktop.Enabled | Should -Be $false - } -} - -Describe 'Get-SystemReport.ps1 - Get-PerformanceInfo' { - BeforeEach { - Mock Write-InfoMessage { } - Mock Write-WarningMessage { } - } - - It 'Captures CPU usage from Get-Counter and memory totals from Win32_OperatingSystem' { - Mock Get-Counter { - [PSCustomObject]@{ - CounterSamples = @([PSCustomObject]@{ CookedValue = 42.7 }) - } - } - Mock Get-CimInstance { - [PSCustomObject]@{ - TotalVisibleMemorySize = 33554432 # KB = 32 GB - FreePhysicalMemory = 16777216 # KB = 16 GB - } - } -ParameterFilter { $ClassName -eq 'Win32_OperatingSystem' } - Mock Get-Process { @() } - Mock Get-Service { - @( - [PSCustomObject]@{ Status = 'Running' } - [PSCustomObject]@{ Status = 'Running' } - [PSCustomObject]@{ Status = 'Stopped' } - ) - } - - $perf = Get-PerformanceInfo - $perf.CPUUsagePercent | Should -Be 42.7 - $perf.Memory.TotalGB | Should -Be 32 - $perf.Memory.UsedGB | Should -Be 16 - $perf.Memory.UsedPercent | Should -Be 50 - $perf.Services.Running | Should -Be 2 - $perf.Services.Stopped | Should -Be 1 - $perf.Services.Total | Should -Be 3 - } - - It 'Picks the top 10 processes by WorkingSet64' { - Mock Get-Counter { throw 'no counter' } - Mock Get-CimInstance { throw 'not mocked' } - Mock Get-Service { @() } - Mock Get-Process { - $procs = @() - for ($i = 1; $i -le 15; $i++) { - $procs += [PSCustomObject]@{ - ProcessName = "proc$i" - Id = $i - WorkingSet64 = $i * 1MB - CPU = $i - HandleCount = $i * 10 - } - } - $procs - } - - $perf = Get-PerformanceInfo - $perf.TopProcesses.Count | Should -Be 10 - # Sorted descending — process 15 has the largest WorkingSet64. - $perf.TopProcesses[0].Name | Should -Be 'proc15' - } -} - -Describe 'Get-SystemReport.ps1 - Export-HtmlReport' { - It 'Writes an HTML file with DOCTYPE, computer name, and section headers' { - $outFile = Join-Path $TestDrive 'report.html' - $reportData = [PSCustomObject]@{ - ComputerName = 'TESTHOST' - ReportDate = (Get-Date) - Hardware = @{ - ComputerSystem = [PSCustomObject]@{ - Name = 'TESTHOST'; Manufacturer = 'Test'; Model = 'Model'; TotalPhysicalMemoryGB = 8 - NumberOfLogicalProcessors = 4 - } - CPU = @([PSCustomObject]@{ Name = 'Test CPU'; NumberOfCores = 4; Architecture = 'x64' }) - TotalMemoryGB = 8 - Volumes = @() - } - Software = @{ - OperatingSystem = [PSCustomObject]@{ - Name = 'Windows'; Version = '10.0'; BuildNumber = '22631' - Uptime = (New-TimeSpan -Days 1 -Hours 2) - } - InstalledApplicationsCount = 50 - } - Network = @{ Adapters = @() } - Security = @{} - Performance = @{ - CPUUsagePercent = 12.5 - Memory = [PSCustomObject]@{ TotalGB = 8; UsedGB = 4; FreeGB = 4; UsedPercent = 50 } - Services = [PSCustomObject]@{ Total = 100; Running = 80; Stopped = 20 } - } - } - - Export-HtmlReport -ReportData $reportData -OutputFile $outFile - - Test-Path $outFile | Should -Be $true - $content = Get-Content $outFile -Raw - $content | Should -Match '' - $content | Should -Match 'TESTHOST' - $content | Should -Match 'Test CPU' - } -} diff --git a/tests/Windows/GetUserAccountAudit.Behavioral.Tests.ps1 b/tests/Windows/GetUserAccountAudit.Behavioral.Tests.ps1 deleted file mode 100644 index fcf612f..0000000 --- a/tests/Windows/GetUserAccountAudit.Behavioral.Tests.ps1 +++ /dev/null @@ -1,287 +0,0 @@ -# Behavioral Pester tests for Get-UserAccountAudit.ps1 -# Run: Invoke-Pester -Path .\tests\Windows\GetUserAccountAudit.Behavioral.Tests.ps1 - -BeforeAll { - $ProjectRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent - $ScriptPath = Join-Path $ProjectRoot 'Windows\security\Get-UserAccountAudit.ps1' - . $ScriptPath -} - -Describe 'Get-UserAccountAudit.ps1 - Get-LocalAdminMembers' { - BeforeEach { - Mock Write-WarningMessage { } - } - - It 'Returns the trailing component (after backslash) of each admin name' { - Mock Get-LocalGroupMember { - @( - [PSCustomObject]@{ Name = 'WORKSTATION\Administrator' } - [PSCustomObject]@{ Name = 'CORP\jdoe' } - ) - } - $names = @(Get-LocalAdminMembers) - $names | Should -Contain 'Administrator' - $names | Should -Contain 'jdoe' - } - - It 'Returns empty array when Get-LocalGroupMember throws' { - Mock Get-LocalGroupMember { throw 'access denied' } - $names = @(Get-LocalAdminMembers) - $names.Count | Should -Be 0 - } -} - -Describe 'Get-UserAccountAudit.ps1 - Get-UserSecurityIssues' { - It "Flags 'Password never expires' when PasswordNeverExpires and Enabled are both true" { - $u = [PSCustomObject]@{ - UserName = 'alice' - Enabled = $true - PasswordNeverExpires = $true - PasswordNotRequired = $false - IsInactive = $false - PasswordAge = 10 - IsAdmin = $false - } - $issues = @(Get-UserSecurityIssues -UserInfo $u -DaysInactive 90 -MaxCredentialAge 90) - $issues | Should -Contain 'Password never expires' - } - - It "Flags 'Password not required' when PasswordNotRequired and Enabled" { - $u = [PSCustomObject]@{ - UserName = 'alice' - Enabled = $true - PasswordNeverExpires = $false - PasswordNotRequired = $true - IsInactive = $false - PasswordAge = 10 - IsAdmin = $false - } - $issues = @(Get-UserSecurityIssues -UserInfo $u -DaysInactive 90 -MaxCredentialAge 90) - $issues | Should -Contain 'Password not required' - } - - It 'Flags inactivity using the provided DaysInactive label' { - $u = [PSCustomObject]@{ - UserName = 'alice' - Enabled = $true - PasswordNeverExpires = $false - PasswordNotRequired = $false - IsInactive = $true - PasswordAge = 10 - IsAdmin = $false - } - $issues = @(Get-UserSecurityIssues -UserInfo $u -DaysInactive 180 -MaxCredentialAge 90) - ($issues | Where-Object { $_ -match '180' }).Count | Should -BeGreaterOrEqual 1 - } - - It 'Flags an old password using the MaxCredentialAge threshold' { - $u = [PSCustomObject]@{ - UserName = 'alice' - Enabled = $true - PasswordNeverExpires = $false - PasswordNotRequired = $false - IsInactive = $false - PasswordAge = 200 - IsAdmin = $false - } - $issues = @(Get-UserSecurityIssues -UserInfo $u -DaysInactive 90 -MaxCredentialAge 90) - ($issues | Where-Object { $_ -match 'older than 90 days' }).Count | Should -Be 1 - } - - It 'Escalates to CRITICAL when an admin has any issues' { - $u = [PSCustomObject]@{ - UserName = 'admin1' - Enabled = $true - PasswordNeverExpires = $true - PasswordNotRequired = $false - IsInactive = $false - PasswordAge = 10 - IsAdmin = $true - } - $issues = @(Get-UserSecurityIssues -UserInfo $u -DaysInactive 90 -MaxCredentialAge 90) - ($issues | Where-Object { $_ -match 'CRITICAL' }).Count | Should -Be 1 - } - - It 'Flags the enabled built-in Administrator account' { - $u = [PSCustomObject]@{ - UserName = 'Administrator' - Enabled = $true - PasswordNeverExpires = $false - PasswordNotRequired = $false - IsInactive = $false - PasswordAge = 10 - IsAdmin = $false - } - $issues = @(Get-UserSecurityIssues -UserInfo $u -DaysInactive 90 -MaxCredentialAge 90) - $issues | Should -Contain 'Built-in Administrator account is enabled' - } - - It 'Flags the enabled Guest account' { - $u = [PSCustomObject]@{ - UserName = 'Guest' - Enabled = $true - PasswordNeverExpires = $false - PasswordNotRequired = $false - IsInactive = $false - PasswordAge = 10 - IsAdmin = $false - } - $issues = @(Get-UserSecurityIssues -UserInfo $u -DaysInactive 90 -MaxCredentialAge 90) - $issues | Should -Contain 'Guest account is enabled' - } - - It 'Returns empty issues for a healthy enabled non-admin' { - $u = [PSCustomObject]@{ - UserName = 'alice' - Enabled = $true - PasswordNeverExpires = $false - PasswordNotRequired = $false - IsInactive = $false - PasswordAge = 10 - IsAdmin = $false - } - $issues = @(Get-UserSecurityIssues -UserInfo $u -DaysInactive 90 -MaxCredentialAge 90) - $issues.Count | Should -Be 0 - } -} - -Describe 'Get-UserAccountAudit.ps1 - Get-UserAccountDetails' { - BeforeEach { - Mock Write-ErrorMessage { } - Mock Write-WarningMessage { } - } - - It 'Marks accounts in the local Administrators group as IsAdmin=$true' { - Mock Get-LocalAdminMembers { @('Administrator', 'alice') } - Mock Get-LocalUser { - @( - [PSCustomObject]@{ - Name = 'alice'; FullName = 'Alice'; Description = '' - Enabled = $true; LastLogon = (Get-Date) - PasswordLastSet = (Get-Date); PasswordExpires = (Get-Date).AddDays(30) - PasswordNeverExpires = $false; PasswordNotRequired = $false - UserMayChangePassword = $true; PrincipalSource = 'Local' - SID = [PSCustomObject]@{ Value = 'S-1-5-21-1' } - } - [PSCustomObject]@{ - Name = 'bob'; FullName = 'Bob'; Description = '' - Enabled = $true; LastLogon = (Get-Date) - PasswordLastSet = (Get-Date); PasswordExpires = (Get-Date).AddDays(30) - PasswordNeverExpires = $false; PasswordNotRequired = $false - UserMayChangePassword = $true; PrincipalSource = 'Local' - SID = [PSCustomObject]@{ Value = 'S-1-5-21-2' } - } - ) - } - - $details = @(Get-UserAccountDetails) - ($details | Where-Object { $_.UserName -eq 'alice' })[0].IsAdmin | Should -Be $true - ($details | Where-Object { $_.UserName -eq 'bob' })[0].IsAdmin | Should -Be $false - } - - It 'Computes IsInactive=$true for an enabled user that has never logged in' { - Mock Get-LocalAdminMembers { @() } - Mock Get-LocalUser { - @([PSCustomObject]@{ - Name = 'never'; FullName = ''; Description = '' - Enabled = $true; LastLogon = $null - PasswordLastSet = (Get-Date) - PasswordExpires = (Get-Date).AddDays(30) - PasswordNeverExpires = $false; PasswordNotRequired = $false - UserMayChangePassword = $true; PrincipalSource = 'Local' - SID = [PSCustomObject]@{ Value = 'S-1-5-21-3' } - }) - } - - $details = @(Get-UserAccountDetails) - $details[0].IsInactive | Should -Be $true - } - - It 'Skips disabled accounts when -IncludeDisabled is not set' { - Mock Get-LocalAdminMembers { @() } - Mock Get-LocalUser { - @( - [PSCustomObject]@{ - Name = 'enabled'; FullName = ''; Description = '' - Enabled = $true; LastLogon = (Get-Date) - PasswordLastSet = (Get-Date); PasswordExpires = (Get-Date).AddDays(30) - PasswordNeverExpires = $false; PasswordNotRequired = $false - UserMayChangePassword = $true; PrincipalSource = 'Local' - SID = [PSCustomObject]@{ Value = 'S-1-5-21-4' } - } - [PSCustomObject]@{ - Name = 'disabled'; FullName = ''; Description = '' - Enabled = $false; LastLogon = (Get-Date) - PasswordLastSet = (Get-Date); PasswordExpires = (Get-Date).AddDays(30) - PasswordNeverExpires = $false; PasswordNotRequired = $false - UserMayChangePassword = $true; PrincipalSource = 'Local' - SID = [PSCustomObject]@{ Value = 'S-1-5-21-5' } - } - ) - } - - $details = @(Get-UserAccountDetails) - $details.Count | Should -Be 1 - $details[0].UserName | Should -Be 'enabled' - } -} - -Describe 'Get-UserAccountAudit.ps1 - Get-AuditSummary' { - It 'Counts Enabled, Disabled, Admin, Inactive and AccountsWithIssues correctly' { - $results = @( - [PSCustomObject]@{ ComputerName = 'PC1'; Enabled = $true; IsAdmin = $true; IsInactive = $false; PasswordNeverExpires = $false; PasswordNotRequired = $false; SecurityIssues = @() } - [PSCustomObject]@{ ComputerName = 'PC1'; Enabled = $true; IsAdmin = $false; IsInactive = $true; PasswordNeverExpires = $false; PasswordNotRequired = $false; SecurityIssues = @('Inactive for 90+ days') } - [PSCustomObject]@{ ComputerName = 'PC1'; Enabled = $false; IsAdmin = $false; IsInactive = $false; PasswordNeverExpires = $false; PasswordNotRequired = $false; SecurityIssues = @() } - ) - $summary = Get-AuditSummary -AuditResults $results - $summary.TotalAccounts | Should -Be 3 - $summary.EnabledAccounts | Should -Be 2 - $summary.DisabledAccounts | Should -Be 1 - $summary.AdminAccounts | Should -Be 1 - $summary.InactiveAccounts | Should -Be 1 - $summary.AccountsWithIssues | Should -Be 1 - } - - It 'Counts CriticalIssues when any SecurityIssues entry contains "CRITICAL"' { - $results = @( - [PSCustomObject]@{ ComputerName = 'PC1'; Enabled = $true; IsAdmin = $true; IsInactive = $false; PasswordNeverExpires = $true; PasswordNotRequired = $false; SecurityIssues = @('Password never expires', 'CRITICAL: Admin account with security issues') } - ) - $summary = Get-AuditSummary -AuditResults $results - $summary.CriticalIssues | Should -Be 1 - } - - It 'Counts unique ComputersAudited across results' { - $results = @( - [PSCustomObject]@{ ComputerName = 'PC1'; Enabled = $true; IsAdmin = $false; IsInactive = $false; PasswordNeverExpires = $false; PasswordNotRequired = $false; SecurityIssues = @() } - [PSCustomObject]@{ ComputerName = 'PC2'; Enabled = $true; IsAdmin = $false; IsInactive = $false; PasswordNeverExpires = $false; PasswordNotRequired = $false; SecurityIssues = @() } - [PSCustomObject]@{ ComputerName = 'PC1'; Enabled = $true; IsAdmin = $false; IsInactive = $false; PasswordNeverExpires = $false; PasswordNotRequired = $false; SecurityIssues = @() } - ) - $summary = Get-AuditSummary -AuditResults $results - $summary.ComputersAudited | Should -Be 2 - } -} - -Describe 'Get-UserAccountAudit.ps1 - Export-HtmlReport' { - It 'Writes an HTML file with DOCTYPE and the audit period' { - $outFile = Join-Path $TestDrive 'audit.html' - $results = @( - [PSCustomObject]@{ - ComputerName = 'PC1'; UserName = 'alice'; FullName = 'Alice' - Enabled = $true; IsAdmin = $false; IsInactive = $false - LastLogon = (Get-Date); PasswordLastSet = (Get-Date) - PasswordExpires = (Get-Date).AddDays(30); PasswordNeverExpires = $false - PasswordNotRequired = $false; PasswordAge = 10 - AccountSource = 'Local'; SID = 'S-1-5-21-9' - Description = '' - SecurityIssues = @() - } - ) - $summary = Get-AuditSummary -AuditResults $results - Export-HtmlReport -AuditResults $results -Summary $summary -OutputFile $outFile - - Test-Path $outFile | Should -Be $true - $content = Get-Content $outFile -Raw - $content | Should -Match '' - $content | Should -Match 'alice' - } -} diff --git a/tests/Windows/ManageVPN.Behavioral.Tests.ps1 b/tests/Windows/ManageVPN.Behavioral.Tests.ps1 deleted file mode 100644 index 1136dee..0000000 --- a/tests/Windows/ManageVPN.Behavioral.Tests.ps1 +++ /dev/null @@ -1,214 +0,0 @@ -# Behavioral Pester tests for Manage-VPN.ps1 -# Run: Invoke-Pester -Path .\tests\Windows\ManageVPN.Behavioral.Tests.ps1 - -BeforeAll { - function rasdial { param() } - function rasphone { param() } - - $ProjectRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent - $ScriptPath = Join-Path $ProjectRoot 'Windows\network\Manage-VPN.ps1' - . $ScriptPath - - $global:LASTEXITCODE = 0 -} - -Describe 'Manage-VPN.ps1 - Get-VpnProfiles' { - BeforeEach { - Mock Write-ErrorMessage { } - } - - It 'Returns the profiles Get-VpnConnection emits' { - Mock Get-VpnConnection { - @( - [PSCustomObject]@{ Name = 'Office' } - [PSCustomObject]@{ Name = 'Home' } - ) - } - $profiles = @(Get-VpnProfiles) - $profiles.Count | Should -Be 2 - } - - It 'Returns empty array when Get-VpnConnection throws' { - Mock Get-VpnConnection { throw 'access denied' } - $profiles = @(Get-VpnProfiles) - $profiles.Count | Should -Be 0 - } -} - -Describe 'Manage-VPN.ps1 - Get-VpnConnectionStatus' { - It 'Maps Get-VpnConnection fields into a flat PSCustomObject' { - Mock Get-VpnConnection { - [PSCustomObject]@{ - Name = 'Office' - ServerAddress = 'vpn.corp.example' - ConnectionStatus = 'Connected' - TunnelType = 'Ikev2' - AuthenticationMethod = @('Eap', 'MSChapv2') - SplitTunneling = $false - RememberCredential = $true - IdleDisconnectSeconds = 600 - } - } - $status = Get-VpnConnectionStatus -Name 'Office' - $status.Name | Should -Be 'Office' - $status.ConnectionStatus | Should -Be 'Connected' - $status.AuthenticationMethod | Should -Be 'Eap, MSChapv2' - } - - It 'Returns $null when Get-VpnConnection throws' { - Mock Get-VpnConnection { throw 'profile not found' } - Get-VpnConnectionStatus -Name 'Missing' | Should -BeNullOrEmpty - } -} - -Describe 'Manage-VPN.ps1 - Test-VpnConnectivity' { - It "Returns $true when the profile's ConnectionStatus is 'Connected'" { - Mock Get-VpnConnection { - [PSCustomObject]@{ Name = 'Office'; ConnectionStatus = 'Connected' } - } - Test-VpnConnectivity -ProfileName 'Office' | Should -Be $true - } - - It "Returns $false when the profile's ConnectionStatus is 'Disconnected'" { - Mock Get-VpnConnection { - [PSCustomObject]@{ Name = 'Office'; ConnectionStatus = 'Disconnected' } - } - Test-VpnConnectivity -ProfileName 'Office' | Should -Be $false - } - - It 'Returns $false when the profile does not exist' { - Mock Get-VpnConnection { $null } - Test-VpnConnectivity -ProfileName 'Missing' | Should -Be $false - } -} - -Describe 'Manage-VPN.ps1 - Connect-VpnProfile' { - BeforeEach { - Mock Write-InfoMessage { } - Mock Write-Success { } - Mock Write-WarningMessage { } - Mock Write-ErrorMessage { } - Mock Write-VpnLog { } - } - - It 'Returns $false and logs error when profile does not exist' { - Mock Get-VpnConnection { $null } - Connect-VpnProfile -Name 'Missing' | Should -Be $false - Should -Invoke Write-ErrorMessage -Times 1 - } - - It 'Short-circuits to $true (already connected) without calling rasdial' { - Mock Get-VpnConnection { - [PSCustomObject]@{ Name = 'Office'; ConnectionStatus = 'Connected' } - } - Mock rasdial { throw 'should not be called' } - Connect-VpnProfile -Name 'Office' | Should -Be $true - Should -Invoke rasdial -Times 0 - } - - It 'Returns $true when rasdial succeeds (no credentials)' { - Mock Get-VpnConnection { - [PSCustomObject]@{ Name = 'Office'; ConnectionStatus = 'Disconnected' } - } - Mock rasdial { $global:LASTEXITCODE = 0; 'Connected.' } - Connect-VpnProfile -Name 'Office' | Should -Be $true - } -} - -Describe 'Manage-VPN.ps1 - Disconnect-VpnProfile' { - BeforeEach { - Mock Write-InfoMessage { } - Mock Write-Success { } - Mock Write-WarningMessage { } - Mock Write-ErrorMessage { } - Mock Write-VpnLog { } - } - - It 'Returns $false when profile does not exist' { - Mock Get-VpnConnection { $null } - Disconnect-VpnProfile -Name 'Missing' | Should -Be $false - } - - It "Short-circuits to $true when profile is already disconnected" { - Mock Get-VpnConnection { - [PSCustomObject]@{ Name = 'Office'; ConnectionStatus = 'Disconnected' } - } - Mock rasdial { throw 'should not be called' } - Disconnect-VpnProfile -Name 'Office' | Should -Be $true - Should -Invoke rasdial -Times 0 - } - - It 'Returns $true when rasdial /disconnect exits 0' { - Mock Get-VpnConnection { - [PSCustomObject]@{ Name = 'Office'; ConnectionStatus = 'Connected' } - } - Mock rasdial { $global:LASTEXITCODE = 0; 'Command completed successfully.' } - Disconnect-VpnProfile -Name 'Office' | Should -Be $true - } - - It 'Returns $false when rasdial /disconnect exits non-zero' { - Mock Get-VpnConnection { - [PSCustomObject]@{ Name = 'Office'; ConnectionStatus = 'Connected' } - } - Mock rasdial { $global:LASTEXITCODE = 1; 'Error.' } - Disconnect-VpnProfile -Name 'Office' | Should -Be $false - } -} - -Describe 'Manage-VPN.ps1 - New-VpnProfile' { - BeforeEach { - Mock Write-InfoMessage { } - Mock Write-Success { } - Mock Write-WarningMessage { } - Mock Write-ErrorMessage { } - Mock Write-VpnLog { } - Mock Test-IsAdministrator { $true } - } - - It 'Calls Add-VpnConnection with the supplied Name/Server/Type and returns $true' { - Mock Add-VpnConnection { } -Verifiable -ParameterFilter { - $Name -eq 'NewVpn' -and $ServerAddress -eq 'vpn.example.com' -and $TunnelType -eq 'Ikev2' - } - New-VpnProfile -Name 'NewVpn' -Server 'vpn.example.com' -Type 'Ikev2' -Auth 'Eap' | Should -Be $true - Should -InvokeVerifiable - } - - It 'Returns $false when Add-VpnConnection throws' { - Mock Add-VpnConnection { throw 'duplicate profile' } - New-VpnProfile -Name 'NewVpn' -Server 'vpn.example.com' -Type 'Ikev2' -Auth 'Eap' | Should -Be $false - } -} - -Describe 'Manage-VPN.ps1 - Remove-VpnProfile' { - BeforeEach { - Mock Write-InfoMessage { } - Mock Write-Success { } - Mock Write-WarningMessage { } - Mock Write-ErrorMessage { } - Mock Write-VpnLog { } - Mock Test-IsAdministrator { $true } - } - - It 'Returns $false when the profile does not exist' { - Mock Get-VpnConnection { $null } - Remove-VpnProfile -Name 'Missing' | Should -Be $false - } - - It 'Disconnects first when the profile is currently Connected, then removes it' { - Mock Get-VpnConnection { - [PSCustomObject]@{ Name = 'Office'; ConnectionStatus = 'Connected' } - } - Mock Disconnect-VpnProfile { $true } -Verifiable - Mock Remove-VpnConnection { } -Verifiable - Remove-VpnProfile -Name 'Office' | Should -Be $true - Should -InvokeVerifiable - } - - It 'Returns $false when Remove-VpnConnection throws' { - Mock Get-VpnConnection { - [PSCustomObject]@{ Name = 'Office'; ConnectionStatus = 'Disconnected' } - } - Mock Remove-VpnConnection { throw 'cannot remove' } - Remove-VpnProfile -Name 'Office' | Should -Be $false - } -} diff --git a/tests/Windows/ManageWSL.Behavioral.Tests.ps1 b/tests/Windows/ManageWSL.Behavioral.Tests.ps1 deleted file mode 100644 index 1c38021..0000000 --- a/tests/Windows/ManageWSL.Behavioral.Tests.ps1 +++ /dev/null @@ -1,317 +0,0 @@ -# Behavioral Pester tests for Manage-WSL.ps1 -# Run: Invoke-Pester -Path .\tests\Windows\ManageWSL.Behavioral.Tests.ps1 -# -# The script is dot-sourced (testability guard skips Invoke-WslManager on -# dot-source). wsl.exe is a native command -- stub function defined in -# BeforeAll so Pester Mock can attach. Each mock body that the SUT inspects -# via $LASTEXITCODE must set $global:LASTEXITCODE explicitly. - -BeforeAll { - function wsl { param() } - - $ProjectRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent - $ScriptPath = Join-Path $ProjectRoot 'Windows\development\Manage-WSL.ps1' - . $ScriptPath -Action Status - - $global:LASTEXITCODE = 0 -} - -Describe 'Manage-WSL.ps1 - Test-WslInstalled' { - It 'Returns true when wsl.exe is resolvable' { - Mock Get-Command { [PSCustomObject]@{ Name = 'wsl.exe' } } -ParameterFilter { $Name -eq 'wsl.exe' } - Test-WslInstalled | Should -Be $true - } - - It 'Returns false when wsl.exe is not found' { - Mock Get-Command { $null } -ParameterFilter { $Name -eq 'wsl.exe' } - Test-WslInstalled | Should -Be $false - } -} - -Describe 'Manage-WSL.ps1 - Get-WslDistributions' { - It 'Parses verbose list output into PSCustomObjects with IsDefault marker' { - Mock wsl { - $global:LASTEXITCODE = 0 - " NAME STATE VERSION`n* Ubuntu Running 2`n Debian Stopped 2" - } - $distros = @(Get-WslDistributions) - $distros.Count | Should -Be 2 - ($distros | Where-Object { $_.Name -eq 'Ubuntu' }).IsDefault | Should -Be $true - ($distros | Where-Object { $_.Name -eq 'Debian' }).IsDefault | Should -Be $false - } - - It 'Captures State and Version fields' { - Mock wsl { - $global:LASTEXITCODE = 0 - " NAME STATE VERSION`n Ubuntu Running 2" - } - $distros = @(Get-WslDistributions) - $distros[0].State | Should -Be 'Running' - $distros[0].Version | Should -Be 2 - } - - It 'Returns empty array when wsl exits non-zero' { - Mock wsl { $global:LASTEXITCODE = 1; '' } - $distros = @(Get-WslDistributions) - $distros.Count | Should -Be 0 - } -} - -Describe 'Manage-WSL.ps1 - Install-Wsl' { - BeforeEach { - Mock Write-InfoMessage { } - Mock Write-Success { } - Mock Write-WarningMessage { } - Mock Write-ErrorMessage { } - } - - It 'Returns false when not running as administrator' { - Mock Test-IsAdministrator { $false } - Install-Wsl | Should -Be $false - Should -Invoke Write-ErrorMessage -Times 1 - } - - It 'Calls wsl --install -d when Distro is specified' { - Mock Test-IsAdministrator { $true } - Mock wsl { $global:LASTEXITCODE = 0; '' } -Verifiable -ParameterFilter { $args -contains '-d' -and $args -contains 'Ubuntu' } - Install-Wsl -Distro 'Ubuntu' | Out-Null - Should -InvokeVerifiable - } - - It "Returns true when wsl reports 'already installed' on non-zero exit" { - Mock Test-IsAdministrator { $true } - Mock wsl { $global:LASTEXITCODE = 1; 'WSL is already installed' } - Install-Wsl -Distro 'Ubuntu' | Should -Be $true - } -} - -Describe 'Manage-WSL.ps1 - Export-WslDistribution' { - BeforeEach { - Mock Write-InfoMessage { } - Mock Write-Success { } - Mock Write-ErrorMessage { } - } - - It "Returns false when the named distribution does not exist" { - Mock Get-WslDistributions { @() } - Export-WslDistribution -Name 'NonExistent' -Path (Join-Path $TestDrive 'out.tar') | Should -Be $false - } - - It 'Returns true when wsl --export exits 0' { - Mock Get-WslDistributions { @([PSCustomObject]@{ Name = 'Ubuntu' }) } - $exportPath = Join-Path $TestDrive 'ubuntu.tar' - # Create the file so the post-success Get-Item call works. - New-Item -ItemType File -Path $exportPath -Force | Out-Null - Mock wsl { $global:LASTEXITCODE = 0; '' } - Export-WslDistribution -Name 'Ubuntu' -Path $exportPath | Should -Be $true - } - - It 'Returns false when wsl --export exits non-zero' { - Mock Get-WslDistributions { @([PSCustomObject]@{ Name = 'Ubuntu' }) } - Mock wsl { $global:LASTEXITCODE = 1; 'export failed' } - Export-WslDistribution -Name 'Ubuntu' -Path (Join-Path $TestDrive 'fail.tar') | Should -Be $false - } -} - -Describe 'Manage-WSL.ps1 - Import-WslDistribution' { - BeforeEach { - Mock Write-InfoMessage { } - Mock Write-Success { } - Mock Write-ErrorMessage { } - } - - It 'Returns false when the import file does not exist' { - Import-WslDistribution -Name 'X' -Path (Join-Path $TestDrive 'missing.tar') -Location (Join-Path $TestDrive 'install') | Should -Be $false - } - - It 'Returns true when wsl --import exits 0' { - $tar = Join-Path $TestDrive 'good.tar' - New-Item -ItemType File -Path $tar -Force | Out-Null - Mock wsl { $global:LASTEXITCODE = 0; '' } - Import-WslDistribution -Name 'NewDistro' -Path $tar -Location (Join-Path $TestDrive 'install') | Should -Be $true - } - - It 'Returns false when wsl --import exits non-zero' { - $tar = Join-Path $TestDrive 'good2.tar' - New-Item -ItemType File -Path $tar -Force | Out-Null - Mock wsl { $global:LASTEXITCODE = 1; 'import failed' } - Import-WslDistribution -Name 'NewDistro' -Path $tar -Location (Join-Path $TestDrive 'install') | Should -Be $false - } -} - -Describe 'Manage-WSL.ps1 - Remove-WslDistribution' { - BeforeEach { - Mock Write-InfoMessage { } - Mock Write-Success { } - Mock Write-WarningMessage { } - Mock Write-ErrorMessage { } - } - - It 'Returns false when the named distribution does not exist' { - Mock Get-WslDistributions { @() } - Remove-WslDistribution -Name 'NonExistent' | Should -Be $false - } - - It 'Returns true when wsl --unregister exits 0' { - Mock Get-WslDistributions { @([PSCustomObject]@{ Name = 'Old' }) } - Mock wsl { $global:LASTEXITCODE = 0; '' } - Remove-WslDistribution -Name 'Old' | Should -Be $true - } - - It 'Returns false when wsl --unregister exits non-zero' { - Mock Get-WslDistributions { @([PSCustomObject]@{ Name = 'Old' }) } - Mock wsl { $global:LASTEXITCODE = 1; 'unregister failed' } - Remove-WslDistribution -Name 'Old' | Should -Be $false - } -} - -Describe 'Manage-WSL.ps1 - Set-WslConfiguration' { - BeforeEach { - Mock Write-InfoMessage { } - Mock Write-Success { } - Mock Write-ErrorMessage { } - } - - It 'Writes a [wsl2] section with memory= when Memory is provided' { - $cfg = Join-Path $TestDrive 'wslconfig.txt' - Set-WslConfiguration -Memory '4GB' -ConfigPath $cfg | Should -Be $true - $content = Get-Content $cfg -Raw - $content | Should -Match '\[wsl2\]' - $content | Should -Match 'memory=4GB' - } - - It 'Writes processors= when Processors > 0' { - $cfg = Join-Path $TestDrive 'wslconfig2.txt' - Set-WslConfiguration -Processors 4 -ConfigPath $cfg | Should -Be $true - Get-Content $cfg -Raw | Should -Match 'processors=4' - } - - It 'Writes swap= when Swap is provided' { - $cfg = Join-Path $TestDrive 'wslconfig3.txt' - Set-WslConfiguration -Swap '2GB' -ConfigPath $cfg | Should -Be $true - Get-Content $cfg -Raw | Should -Match 'swap=2GB' - } -} - -Describe 'Manage-WSL.ps1 - Start-WslDistribution' { - BeforeEach { - Mock Write-InfoMessage { } - Mock Write-Success { } - Mock Write-ErrorMessage { } - } - - It 'Calls wsl -d when Name is specified' { - Mock wsl { $global:LASTEXITCODE = 0; '' } -Verifiable -ParameterFilter { $args -contains '-d' -and $args -contains 'Ubuntu' } - Start-WslDistribution -Name 'Ubuntu' | Should -Be $true - Should -InvokeVerifiable - } - - It 'Calls wsl without -d when no Name is specified' { - Mock wsl { $global:LASTEXITCODE = 0; '' } -Verifiable -ParameterFilter { -not ($args -contains '-d') } - Start-WslDistribution | Should -Be $true - Should -InvokeVerifiable - } - - It 'Returns false when wsl exits non-zero' { - Mock wsl { $global:LASTEXITCODE = 1; 'error' } - Start-WslDistribution -Name 'Ubuntu' | Should -Be $false - } -} - -Describe 'Manage-WSL.ps1 - Stop-WslInstances' { - BeforeEach { - Mock Write-InfoMessage { } - Mock Write-Success { } - Mock Write-ErrorMessage { } - } - - It 'Returns true when wsl --shutdown exits 0' { - Mock wsl { $global:LASTEXITCODE = 0; '' } - Stop-WslInstances | Should -Be $true - } - - It 'Returns false when wsl --shutdown exits non-zero' { - Mock wsl { $global:LASTEXITCODE = 1; 'fail' } - Stop-WslInstances | Should -Be $false - } -} - -Describe 'Manage-WSL.ps1 - Update-WslKernel' { - BeforeEach { - Mock Write-InfoMessage { } - Mock Write-Success { } - Mock Write-ErrorMessage { } - } - - It 'Returns true when wsl --update exits 0' { - Mock wsl { $global:LASTEXITCODE = 0; '' } - Update-WslKernel | Should -Be $true - } -} - -Describe 'Manage-WSL.ps1 - Set-WslDefault' { - BeforeEach { - Mock Write-InfoMessage { } - Mock Write-Success { } - Mock Write-ErrorMessage { } - } - - It 'Returns false when the named distribution does not exist' { - Mock Get-WslDistributions { @() } - Set-WslDefault -Name 'Ghost' | Should -Be $false - } - - It 'Returns true when wsl --set-default exits 0' { - Mock Get-WslDistributions { @([PSCustomObject]@{ Name = 'Ubuntu' }) } - Mock wsl { $global:LASTEXITCODE = 0; '' } - Set-WslDefault -Name 'Ubuntu' | Should -Be $true - } - - It 'Returns false when wsl --set-default exits non-zero' { - Mock Get-WslDistributions { @([PSCustomObject]@{ Name = 'Ubuntu' }) } - Mock wsl { $global:LASTEXITCODE = 1; 'fail' } - Set-WslDefault -Name 'Ubuntu' | Should -Be $false - } -} - -Describe 'Manage-WSL.ps1 - Invoke-WslTroubleshoot' { - BeforeEach { - Mock Write-InfoMessage { } - Mock Write-Host { } - } - - It 'Records FAIL for WSL Installed when wsl.exe is missing' { - Mock Test-WslInstalled { $false } - Mock Get-WindowsOptionalFeature { [PSCustomObject]@{ State = 'Enabled' } } - Mock Get-CimInstance { [PSCustomObject]@{ VirtualizationFirmwareEnabled = $true } } - Mock Get-WslVersion { 'Unknown' } - Mock Get-WslDistributions { @() } - Mock Get-NetAdapter { @() } - $results = Invoke-WslTroubleshoot - @($results | Where-Object { $_.Check -eq 'WSL Installed' })[0].Status | Should -Be 'FAIL' - } - - It 'Records WARN for Distributions when none are installed' { - Mock Test-WslInstalled { $true } - Mock Get-WindowsOptionalFeature { [PSCustomObject]@{ State = 'Enabled' } } - Mock Get-CimInstance { [PSCustomObject]@{ VirtualizationFirmwareEnabled = $true } } - Mock Get-WslVersion { '2.0.0' } - Mock Get-WslDistributions { @() } - Mock Get-NetAdapter { @() } - $results = Invoke-WslTroubleshoot - @($results | Where-Object { $_.Check -eq 'Distributions' })[0].Status | Should -Be 'WARN' - } - - It 'Records PASS for Distributions when at least one is installed' { - Mock Test-WslInstalled { $true } - Mock Get-WindowsOptionalFeature { [PSCustomObject]@{ State = 'Enabled' } } - Mock Get-CimInstance { [PSCustomObject]@{ VirtualizationFirmwareEnabled = $true } } - Mock Get-WslVersion { '2.0.0' } - Mock Get-WslDistributions { - @([PSCustomObject]@{ Name = 'Ubuntu'; State = 'Running'; Version = 2; IsDefault = $true }) - } - Mock Get-NetAdapter { @([PSCustomObject]@{ Name = 'vEthernet (WSL)' }) } - $results = Invoke-WslTroubleshoot - @($results | Where-Object { $_.Check -eq 'Distributions' })[0].Status | Should -Be 'PASS' - } -} diff --git a/tests/Windows/Monitoring.Tests.ps1 b/tests/Windows/Monitoring.Tests.ps1 deleted file mode 100644 index 5196110..0000000 --- a/tests/Windows/Monitoring.Tests.ps1 +++ /dev/null @@ -1,716 +0,0 @@ -# Pester Tests for Windows Monitoring Scripts -# Run: Invoke-Pester -Path .\tests\Windows\Monitoring.Tests.ps1 -# Created: 2025-11-30 - -BeforeAll { - $ProjectRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent - $MonitoringPath = Join-Path $ProjectRoot "Windows\monitoring" - $BackupPath = Join-Path $ProjectRoot "Windows\backup" - $LibPath = Join-Path $ProjectRoot "Windows\lib" - - # Import test helpers - $TestHelpersPath = Join-Path $PSScriptRoot "..\TestHelpers.psm1" - if (Test-Path $TestHelpersPath) { - Import-Module $TestHelpersPath -Force - } - - # Import CommonFunctions for testing - $CommonFunctionsPath = Join-Path $LibPath "CommonFunctions.psm1" - if (Test-Path $CommonFunctionsPath) { - Import-Module $CommonFunctionsPath -Force - } -} - -AfterAll { - Remove-Module TestHelpers -ErrorAction SilentlyContinue - Remove-Module CommonFunctions -ErrorAction SilentlyContinue -} - -Describe "Monitoring Script Existence" { - Context "Core Monitoring Scripts" { - It "Get-SystemPerformance.ps1 should exist" { - $scriptPath = Join-Path $MonitoringPath "Get-SystemPerformance.ps1" - $scriptPath | Should -Exist - } - - It "Watch-ServiceHealth.ps1 should exist" { - $scriptPath = Join-Path $MonitoringPath "Watch-ServiceHealth.ps1" - $scriptPath | Should -Exist - } - - It "Test-NetworkHealth.ps1 should exist" { - $scriptPath = Join-Path $MonitoringPath "Test-NetworkHealth.ps1" - $scriptPath | Should -Exist - } - - It "Get-EventLogAnalysis.ps1 should exist" { - $scriptPath = Join-Path $MonitoringPath "Get-EventLogAnalysis.ps1" - $scriptPath | Should -Exist - } - } - - Context "Backup Scripts" { - It "Backup-UserData.ps1 should exist" { - $scriptPath = Join-Path $BackupPath "Backup-UserData.ps1" - $scriptPath | Should -Exist - } - } -} - -Describe "Monitoring Script Syntax Validation" { - Context "PowerShell Syntax" { - It "Get-SystemPerformance.ps1 has valid syntax" { - $scriptPath = Join-Path $MonitoringPath "Get-SystemPerformance.ps1" - $tokens = $null - $errors = $null - $null = [System.Management.Automation.Language.Parser]::ParseInput((Get-Content $scriptPath -Raw), [ref]$tokens, [ref]$errors) - $errors.Count | Should -Be 0 - } - - It "Watch-ServiceHealth.ps1 has valid syntax" { - $scriptPath = Join-Path $MonitoringPath "Watch-ServiceHealth.ps1" - $tokens = $null - $errors = $null - $null = [System.Management.Automation.Language.Parser]::ParseInput((Get-Content $scriptPath -Raw), [ref]$tokens, [ref]$errors) - $errors.Count | Should -Be 0 - } - - It "Test-NetworkHealth.ps1 has valid syntax" { - $scriptPath = Join-Path $MonitoringPath "Test-NetworkHealth.ps1" - $tokens = $null - $errors = $null - $null = [System.Management.Automation.Language.Parser]::ParseInput((Get-Content $scriptPath -Raw), [ref]$tokens, [ref]$errors) - $errors.Count | Should -Be 0 - } - - It "Get-EventLogAnalysis.ps1 has valid syntax" { - $scriptPath = Join-Path $MonitoringPath "Get-EventLogAnalysis.ps1" - $tokens = $null - $errors = $null - $null = [System.Management.Automation.Language.Parser]::ParseInput((Get-Content $scriptPath -Raw), [ref]$tokens, [ref]$errors) - $errors.Count | Should -Be 0 - } - - It "Backup-UserData.ps1 has valid syntax" { - $scriptPath = Join-Path $BackupPath "Backup-UserData.ps1" - $tokens = $null - $errors = $null - $null = [System.Management.Automation.Language.Parser]::ParseInput((Get-Content $scriptPath -Raw), [ref]$tokens, [ref]$errors) - $errors.Count | Should -Be 0 - } - } -} - -Describe "Monitoring Script Requirements" { - Context "PowerShell Version Requirements" { - It "Get-SystemPerformance.ps1 requires PowerShell 5.1+" { - $scriptPath = Join-Path $MonitoringPath "Get-SystemPerformance.ps1" - $content = Get-Content $scriptPath -Raw - $content | Should -Match "#Requires -Version 5\.1" - } - - It "Watch-ServiceHealth.ps1 requires PowerShell 5.1+" { - $scriptPath = Join-Path $MonitoringPath "Watch-ServiceHealth.ps1" - $content = Get-Content $scriptPath -Raw - $content | Should -Match "#Requires -Version 5\.1" - } - - It "Test-NetworkHealth.ps1 requires PowerShell 5.1+" { - $scriptPath = Join-Path $MonitoringPath "Test-NetworkHealth.ps1" - $content = Get-Content $scriptPath -Raw - $content | Should -Match "#Requires -Version 5\.1" - } - - It "Get-EventLogAnalysis.ps1 requires PowerShell 5.1+" { - $scriptPath = Join-Path $MonitoringPath "Get-EventLogAnalysis.ps1" - $content = Get-Content $scriptPath -Raw - $content | Should -Match "#Requires -Version 5\.1" - } - - It "Backup-UserData.ps1 requires PowerShell 5.1+" { - $scriptPath = Join-Path $BackupPath "Backup-UserData.ps1" - $content = Get-Content $scriptPath -Raw - $content | Should -Match "#Requires -Version 5\.1" - } - } -} - -Describe "Monitoring Script Documentation" { - Context "Comment-Based Help" { - It "Get-SystemPerformance.ps1 has SYNOPSIS" { - $scriptPath = Join-Path $MonitoringPath "Get-SystemPerformance.ps1" - $content = Get-Content $scriptPath -Raw - $content | Should -Match "\.SYNOPSIS" - } - - It "Get-SystemPerformance.ps1 has DESCRIPTION" { - $scriptPath = Join-Path $MonitoringPath "Get-SystemPerformance.ps1" - $content = Get-Content $scriptPath -Raw - $content | Should -Match "\.DESCRIPTION" - } - - It "Get-SystemPerformance.ps1 has EXAMPLE" { - $scriptPath = Join-Path $MonitoringPath "Get-SystemPerformance.ps1" - $content = Get-Content $scriptPath -Raw - $content | Should -Match "\.EXAMPLE" - } - - It "Watch-ServiceHealth.ps1 has SYNOPSIS" { - $scriptPath = Join-Path $MonitoringPath "Watch-ServiceHealth.ps1" - $content = Get-Content $scriptPath -Raw - $content | Should -Match "\.SYNOPSIS" - } - - It "Test-NetworkHealth.ps1 has SYNOPSIS" { - $scriptPath = Join-Path $MonitoringPath "Test-NetworkHealth.ps1" - $content = Get-Content $scriptPath -Raw - $content | Should -Match "\.SYNOPSIS" - } - - It "Get-EventLogAnalysis.ps1 has SYNOPSIS" { - $scriptPath = Join-Path $MonitoringPath "Get-EventLogAnalysis.ps1" - $content = Get-Content $scriptPath -Raw - $content | Should -Match "\.SYNOPSIS" - } - - It "Backup-UserData.ps1 has SYNOPSIS" { - $scriptPath = Join-Path $BackupPath "Backup-UserData.ps1" - $content = Get-Content $scriptPath -Raw - $content | Should -Match "\.SYNOPSIS" - } - } -} - -Describe "Monitoring Script Parameters" { - Context "Get-SystemPerformance Parameters" { - BeforeAll { - $scriptPath = Join-Path $MonitoringPath "Get-SystemPerformance.ps1" - $scriptContent = Get-Content $scriptPath -Raw - } - - It "Has OutputFormat parameter" { - $scriptContent | Should -Match '\[string\]\$OutputFormat' - } - - It "Has OutputPath parameter" { - $scriptContent | Should -Match '\[string\]\$OutputPath' - } - - It "Has SampleCount parameter" { - $scriptContent | Should -Match '\[int\]\$SampleCount' - } - - It "Has Thresholds parameter" { - $scriptContent | Should -Match '\[hashtable\]\$Thresholds' - } - - It "OutputFormat has valid ValidateSet" { - $scriptContent | Should -Match "ValidateSet\('Console', 'HTML', 'JSON', 'CSV', 'Prometheus', 'All'\)" - } - } - - Context "Watch-ServiceHealth Parameters" { - BeforeAll { - $scriptPath = Join-Path $MonitoringPath "Watch-ServiceHealth.ps1" - $scriptContent = Get-Content $scriptPath -Raw - } - - It "Has Services parameter" { - $scriptContent | Should -Match '\[string\[\]\]\$Services' - } - - It "Has AutoRestart parameter" { - $scriptContent | Should -Match '\[switch\]\$AutoRestart' - } - - It "Has MaxRestartAttempts parameter" { - $scriptContent | Should -Match '\[int\]\$MaxRestartAttempts' - } - - It "Has MonitorInterval parameter" { - $scriptContent | Should -Match '\[int\]\$MonitorInterval' - } - } - - Context "Test-NetworkHealth Parameters" { - BeforeAll { - $scriptPath = Join-Path $MonitoringPath "Test-NetworkHealth.ps1" - $scriptContent = Get-Content $scriptPath -Raw - } - - It "Has Hosts parameter" { - $scriptContent | Should -Match '\[string\[\]\]\$Hosts' - } - - It "Has Ports parameter" { - $scriptContent | Should -Match '\[int\[\]\]\$Ports' - } - - It "Has SkipDNS parameter" { - $scriptContent | Should -Match '\[switch\]\$SkipDNS' - } - - It "Has QuickTest parameter" { - $scriptContent | Should -Match '\[switch\]\$QuickTest' - } - } - - Context "Get-EventLogAnalysis Parameters" { - BeforeAll { - $scriptPath = Join-Path $MonitoringPath "Get-EventLogAnalysis.ps1" - $scriptContent = Get-Content $scriptPath -Raw - } - - It "Has LogNames parameter" { - $scriptContent | Should -Match '\[string\[\]\]\$LogNames' - } - - It "Has Hours parameter" { - $scriptContent | Should -Match '\[int\]\$Hours' - } - - It "Has Level parameter" { - $scriptContent | Should -Match '\[string\]\$Level' - } - - It "Has IncludeSecurityAnalysis parameter" { - $scriptContent | Should -Match '\[switch\]\$IncludeSecurityAnalysis' - } - - It "Has IncludeFailedLogons parameter" { - $scriptContent | Should -Match '\[switch\]\$IncludeFailedLogons' - } - } - - Context "Backup-UserData Parameters" { - BeforeAll { - $scriptPath = Join-Path $BackupPath "Backup-UserData.ps1" - $scriptContent = Get-Content $scriptPath -Raw - } - - It "Has BackupType parameter" { - $scriptContent | Should -Match '\[string\]\$BackupType' - } - - It "Has Destination parameter" { - $scriptContent | Should -Match '\[string\]\$Destination' - } - - It "Has SourceFolders parameter" { - $scriptContent | Should -Match '\[string\[\]\]\$SourceFolders' - } - - It "Has RetentionCount parameter" { - $scriptContent | Should -Match '\[int\]\$RetentionCount' - } - - It "Has VerifyBackup parameter" { - $scriptContent | Should -Match '\[switch\]\$VerifyBackup' - } - - It "Has DryRun parameter" { - $scriptContent | Should -Match '\[switch\]\$DryRun' - } - - It "BackupType has valid ValidateSet" { - $scriptContent | Should -Match "ValidateSet\('Full', 'Incremental', 'Differential'\)" - } - } -} - -Describe "Monitoring Script Output Formats" { - Context "HTML Report Generation" { - It "Get-SystemPerformance has Export-HTMLReport function" { - $scriptPath = Join-Path $MonitoringPath "Get-SystemPerformance.ps1" - $content = Get-Content $scriptPath -Raw - $content | Should -Match "function Export-HTMLReport" - } - - It "Watch-ServiceHealth has Export-HTMLReport function" { - $scriptPath = Join-Path $MonitoringPath "Watch-ServiceHealth.ps1" - $content = Get-Content $scriptPath -Raw - $content | Should -Match "function Export-HTMLReport" - } - - It "Test-NetworkHealth has Export-HTMLReport function" { - $scriptPath = Join-Path $MonitoringPath "Test-NetworkHealth.ps1" - $content = Get-Content $scriptPath -Raw - $content | Should -Match "function Export-HTMLReport" - } - - It "Get-EventLogAnalysis has Export-HTMLReport function" { - $scriptPath = Join-Path $MonitoringPath "Get-EventLogAnalysis.ps1" - $content = Get-Content $scriptPath -Raw - $content | Should -Match "function Export-HTMLReport" - } - } - - Context "JSON Report Generation" { - It "Get-SystemPerformance has Export-JSONReport function" { - $scriptPath = Join-Path $MonitoringPath "Get-SystemPerformance.ps1" - $content = Get-Content $scriptPath -Raw - $content | Should -Match "function Export-JSONReport" - } - - It "Watch-ServiceHealth has Export-JSONReport function" { - $scriptPath = Join-Path $MonitoringPath "Watch-ServiceHealth.ps1" - $content = Get-Content $scriptPath -Raw - $content | Should -Match "function Export-JSONReport" - } - - It "Test-NetworkHealth has Export-JSONReport function" { - $scriptPath = Join-Path $MonitoringPath "Test-NetworkHealth.ps1" - $content = Get-Content $scriptPath -Raw - $content | Should -Match "function Export-JSONReport" - } - } - - Context "Console Report Generation" { - It "Get-SystemPerformance has Write-ConsoleReport function" { - $scriptPath = Join-Path $MonitoringPath "Get-SystemPerformance.ps1" - $content = Get-Content $scriptPath -Raw - $content | Should -Match "function Write-ConsoleReport" - } - - It "Watch-ServiceHealth has Write-ConsoleReport function" { - $scriptPath = Join-Path $MonitoringPath "Watch-ServiceHealth.ps1" - $content = Get-Content $scriptPath -Raw - $content | Should -Match "function Write-ConsoleReport" - } - } -} - -Describe "Monitoring Script Features" { - Context "Get-SystemPerformance Features" { - BeforeAll { - $scriptPath = Join-Path $MonitoringPath "Get-SystemPerformance.ps1" - $scriptContent = Get-Content $scriptPath -Raw - } - - It "Has Get-PerformanceMetrics function" { - $scriptContent | Should -Match "function Get-PerformanceMetrics" - } - - It "Has Get-ThresholdAlerts function" { - $scriptContent | Should -Match "function Get-ThresholdAlerts" - } - - It "Has Get-TopProcesses function" { - $scriptContent | Should -Match "function Get-TopProcesses" - } - - It "Has Get-SystemInfo function" { - $scriptContent | Should -Match "function Get-SystemInfo" - } - - It "Uses Get-Counter for performance metrics" { - $scriptContent | Should -Match "Get-Counter" - } - - It "Monitors CPU metrics" { - $scriptContent | Should -Match "Processor.*% Processor Time" - } - - It "Monitors memory metrics" { - $scriptContent | Should -Match "Memory.*Available MBytes" - } - - It "Monitors disk metrics" { - $scriptContent | Should -Match "PhysicalDisk.*% Disk Time" - } - } - - Context "Watch-ServiceHealth Features" { - BeforeAll { - $scriptPath = Join-Path $MonitoringPath "Watch-ServiceHealth.ps1" - $scriptContent = Get-Content $scriptPath -Raw - } - - It "Has Get-ServiceStatus function" { - $scriptContent | Should -Match "function Get-ServiceStatus" - } - - It "Has Restart-ServiceWithRetry function" { - $scriptContent | Should -Match "function Restart-ServiceWithRetry" - } - - It "Has Get-ServiceHealthReport function" { - $scriptContent | Should -Match "function Get-ServiceHealthReport" - } - - It "Has Test-ServiceShouldMonitor function" { - $scriptContent | Should -Match "function Test-ServiceShouldMonitor" - } - - It "Uses Get-Service cmdlet" { - $scriptContent | Should -Match "Get-Service" - } - - It "Checks for delayed start services" { - $scriptContent | Should -Match "DelayedAutostart" - } - - It "Has default critical services list" { - $scriptContent | Should -Match "DefaultServices" - } - } - - Context "Test-NetworkHealth Features" { - BeforeAll { - $scriptPath = Join-Path $MonitoringPath "Test-NetworkHealth.ps1" - $scriptContent = Get-Content $scriptPath -Raw - } - - It "Has Test-HostConnectivity function" { - $scriptContent | Should -Match "function Test-HostConnectivity" - } - - It "Has Test-PortConnectivity function" { - $scriptContent | Should -Match "function Test-PortConnectivity" - } - - It "Has Test-DNSResolution function" { - $scriptContent | Should -Match "function Test-DNSResolution" - } - - It "Has Invoke-Traceroute function" { - $scriptContent | Should -Match "function Invoke-Traceroute" - } - - It "Has Get-NetworkAdapterInfo function" { - $scriptContent | Should -Match "function Get-NetworkAdapterInfo" - } - - It "Uses Test-Connection for ping" { - $scriptContent | Should -Match "Test-Connection" - } - - It "Uses Resolve-DnsName for DNS" { - $scriptContent | Should -Match "Resolve-DnsName" - } - - It "Has common ports definition" { - $scriptContent | Should -Match "CommonPorts" - } - } - - Context "Get-EventLogAnalysis Features" { - BeforeAll { - $scriptPath = Join-Path $MonitoringPath "Get-EventLogAnalysis.ps1" - $scriptContent = Get-Content $scriptPath -Raw - } - - It "Has Get-FilteredEvents function" { - $scriptContent | Should -Match "function Get-FilteredEvents" - } - - It "Has Get-SecurityAnalysis function" { - $scriptContent | Should -Match "function Get-SecurityAnalysis" - } - - It "Has Get-FailedLogonDetails function" { - $scriptContent | Should -Match "function Get-FailedLogonDetails" - } - - It "Has Get-SystemIssues function" { - $scriptContent | Should -Match "function Get-SystemIssues" - } - - It "Has Get-ApplicationIssues function" { - $scriptContent | Should -Match "function Get-ApplicationIssues" - } - - It "Uses Get-WinEvent cmdlet" { - $scriptContent | Should -Match "Get-WinEvent" - } - - It "Has security event IDs definition" { - $scriptContent | Should -Match "SecurityEventIds" - } - - It "Has system event IDs definition" { - $scriptContent | Should -Match "SystemEventIds" - } - - It "Tracks failed logons (Event ID 4625)" { - $scriptContent | Should -Match "4625" - } - } - - Context "Backup-UserData Features" { - BeforeAll { - $scriptPath = Join-Path $BackupPath "Backup-UserData.ps1" - $scriptContent = Get-Content $scriptPath -Raw - } - - It "Has Get-FilesToBackup function" { - $scriptContent | Should -Match "function Get-FilesToBackup" - } - - It "Has Copy-BackupFiles function" { - $scriptContent | Should -Match "function Copy-BackupFiles" - } - - It "Has Compress-BackupFolder function" { - $scriptContent | Should -Match "function Compress-BackupFolder" - } - - It "Has Test-BackupIntegrity function" { - $scriptContent | Should -Match "function Test-BackupIntegrity" - } - - It "Has Remove-OldBackups function" { - $scriptContent | Should -Match "function Remove-OldBackups" - } - - It "Has Get-BackupMetadata function" { - $scriptContent | Should -Match "function Get-BackupMetadata" - } - - It "Supports ShouldProcess for safety" { - $scriptContent | Should -Match "SupportsShouldProcess" - } - - It "Uses Compress-Archive for compression" { - $scriptContent | Should -Match "Compress-Archive" - } - - It "Has file exclusion patterns" { - $scriptContent | Should -Match "ExcludeFolders" - } - } -} - -Describe "Monitoring Script Error Handling" { - Context "Error Handling Patterns" { - It "Get-SystemPerformance uses try-catch" { - $scriptPath = Join-Path $MonitoringPath "Get-SystemPerformance.ps1" - $content = Get-Content $scriptPath -Raw - $content | Should -Match "try\s*\{" - $content | Should -Match "catch\s*\{" - } - - It "Watch-ServiceHealth uses try-catch" { - $scriptPath = Join-Path $MonitoringPath "Watch-ServiceHealth.ps1" - $content = Get-Content $scriptPath -Raw - $content | Should -Match "try\s*\{" - $content | Should -Match "catch\s*\{" - } - - It "Test-NetworkHealth uses try-catch" { - $scriptPath = Join-Path $MonitoringPath "Test-NetworkHealth.ps1" - $content = Get-Content $scriptPath -Raw - $content | Should -Match "try\s*\{" - $content | Should -Match "catch\s*\{" - } - - It "Get-EventLogAnalysis uses try-catch" { - $scriptPath = Join-Path $MonitoringPath "Get-EventLogAnalysis.ps1" - $content = Get-Content $scriptPath -Raw - $content | Should -Match "try\s*\{" - $content | Should -Match "catch\s*\{" - } - - It "Backup-UserData uses try-catch" { - $scriptPath = Join-Path $BackupPath "Backup-UserData.ps1" - $content = Get-Content $scriptPath -Raw - $content | Should -Match "try\s*\{" - $content | Should -Match "catch\s*\{" - } - } - - Context "Exit Codes" { - It "Get-SystemPerformance has exit code handling" { - $scriptPath = Join-Path $MonitoringPath "Get-SystemPerformance.ps1" - $content = Get-Content $scriptPath -Raw - $content | Should -Match "exit 1" - } - - It "Watch-ServiceHealth has exit code handling" { - $scriptPath = Join-Path $MonitoringPath "Watch-ServiceHealth.ps1" - $content = Get-Content $scriptPath -Raw - $content | Should -Match "exit 1" - } - - It "Test-NetworkHealth has exit code handling" { - $scriptPath = Join-Path $MonitoringPath "Test-NetworkHealth.ps1" - $content = Get-Content $scriptPath -Raw - $content | Should -Match "exit 1" - } - - It "Get-EventLogAnalysis has exit code handling" { - $scriptPath = Join-Path $MonitoringPath "Get-EventLogAnalysis.ps1" - $content = Get-Content $scriptPath -Raw - $content | Should -Match "exit 1" - } - - It "Backup-UserData has exit code handling" { - # Sprint 4.2 refactor moved the main body into Invoke-UserDataBackup and - # replaced inner 'exit 1' with 'return 1'; the testability guard preserves - # the non-zero exit via 'exit $exitCode'. Accept either pattern. - $scriptPath = Join-Path $BackupPath "Backup-UserData.ps1" - $content = Get-Content $scriptPath -Raw - $content | Should -Match '(exit 1|return 1|exit \$exitCode)' - } - } -} - -Describe "Monitoring Script Logging" { - Context "CommonFunctions Integration" { - It "Get-SystemPerformance imports CommonFunctions" { - $scriptPath = Join-Path $MonitoringPath "Get-SystemPerformance.ps1" - $content = Get-Content $scriptPath -Raw - $content | Should -Match "CommonFunctions\.psm1" - } - - It "Watch-ServiceHealth imports CommonFunctions" { - $scriptPath = Join-Path $MonitoringPath "Watch-ServiceHealth.ps1" - $content = Get-Content $scriptPath -Raw - $content | Should -Match "CommonFunctions\.psm1" - } - - It "Test-NetworkHealth imports CommonFunctions" { - $scriptPath = Join-Path $MonitoringPath "Test-NetworkHealth.ps1" - $content = Get-Content $scriptPath -Raw - $content | Should -Match "CommonFunctions\.psm1" - } - - It "Get-EventLogAnalysis imports CommonFunctions" { - $scriptPath = Join-Path $MonitoringPath "Get-EventLogAnalysis.ps1" - $content = Get-Content $scriptPath -Raw - $content | Should -Match "CommonFunctions\.psm1" - } - - It "Backup-UserData imports CommonFunctions" { - $scriptPath = Join-Path $BackupPath "Backup-UserData.ps1" - $content = Get-Content $scriptPath -Raw - $content | Should -Match "CommonFunctions\.psm1" - } - } - - Context "Standard Logging Functions" { - It "Scripts use Write-InfoMessage" { - $scriptPath = Join-Path $MonitoringPath "Get-SystemPerformance.ps1" - $content = Get-Content $scriptPath -Raw - $content | Should -Match "Write-InfoMessage" - } - - It "Scripts use Write-Success" { - $scriptPath = Join-Path $MonitoringPath "Get-SystemPerformance.ps1" - $content = Get-Content $scriptPath -Raw - $content | Should -Match "Write-Success" - } - - It "Scripts use Write-ErrorMessage" { - $scriptPath = Join-Path $MonitoringPath "Get-SystemPerformance.ps1" - $content = Get-Content $scriptPath -Raw - $content | Should -Match "Write-ErrorMessage" - } - - It "Scripts use Write-WarningMessage" { - $scriptPath = Join-Path $MonitoringPath "Get-SystemPerformance.ps1" - $content = Get-Content $scriptPath -Raw - $content | Should -Match "Write-WarningMessage" - } - } -} diff --git a/tests/Windows/RestoreDeveloperEnvironment.Behavioral.Tests.ps1 b/tests/Windows/RestoreDeveloperEnvironment.Behavioral.Tests.ps1 deleted file mode 100644 index 711cacd..0000000 --- a/tests/Windows/RestoreDeveloperEnvironment.Behavioral.Tests.ps1 +++ /dev/null @@ -1,276 +0,0 @@ -# Behavioral Pester tests for Restore-DeveloperEnvironment.ps1 -# Run: Invoke-Pester -Path .\tests\Windows\RestoreDeveloperEnvironment.Behavioral.Tests.ps1 -# -# The script is dot-sourced (testability guard skips Invoke-Restore on dot-source). -# The script's -BackupPath param is Mandatory + ValidateScript, so dot-source -# passes the repo root as a placeholder; tests use TestDrive paths for actual -# restore operations. - -BeforeAll { - $ProjectRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent - $ScriptPath = Join-Path $ProjectRoot 'Windows\backup\Restore-DeveloperEnvironment.ps1' - . $ScriptPath -BackupPath $ProjectRoot - - # Stub the `code` CLI so Pester Mock can intercept extension installs. - function code { param() } -} - -Describe 'Restore-DeveloperEnvironment.ps1 - Read-RestoreManifest' { - BeforeEach { - $script:Dir = Join-Path $TestDrive ([Guid]::NewGuid().Guid) - New-Item -ItemType Directory -Path $script:Dir -Force | Out-Null - } - - Context 'When manifest.json is missing' { - It 'Throws with a clear message' { - { Read-RestoreManifest -Source $script:Dir } | Should -Throw '*Manifest not found*' - } - } - - Context 'When manifest.json is malformed JSON' { - BeforeEach { - 'this is not json' | Out-File (Join-Path $script:Dir 'manifest.json') - } - - It 'Throws with a parse-failure message' { - { Read-RestoreManifest -Source $script:Dir } | Should -Throw '*Failed to parse manifest*' - } - } - - Context 'When manifest.json is valid' { - BeforeEach { - @{ - BackupDate = '2026-05-15' - ComputerName = 'WORKLAPTOP' - UserName = 'david.dashti' - Items = @() - } | ConvertTo-Json | Out-File (Join-Path $script:Dir 'manifest.json') - } - - It 'Returns the parsed object with expected fields' { - $manifest = Read-RestoreManifest -Source $script:Dir - $manifest.BackupDate | Should -Be '2026-05-15' - $manifest.ComputerName | Should -Be 'WORKLAPTOP' - } - } -} - -Describe 'Restore-DeveloperEnvironment.ps1 - Restore-ManifestItem' { - BeforeEach { - $script:Dir = Join-Path $TestDrive ([Guid]::NewGuid().Guid) - New-Item -ItemType Directory -Path $script:Dir -Force | Out-Null - - $script:BackupFile = Join-Path $script:Dir 'source.txt' - $script:OriginalPath = Join-Path $script:Dir 'destination\target.txt' - 'restored content' | Out-File $script:BackupFile - - $script:Item = [PSCustomObject]@{ - Name = 'PowerShell-Profile' - BackupFile = $script:BackupFile - OriginalPath = $script:OriginalPath - } - } - - Context 'When the backup file is missing' { - BeforeEach { - Remove-Item $script:BackupFile -Force - } - - It 'Returns Outcome=Skipped with reason BackupFileMissing' { - $result = Restore-ManifestItem -Item $script:Item - $result.Outcome | Should -Be 'Skipped' - $result.Reason | Should -Be 'BackupFileMissing' - } - } - - Context 'When the item has no OriginalPath' { - BeforeEach { - $script:Item.OriginalPath = $null - } - - It 'Returns Outcome=Skipped with reason NoOriginalPath' { - $result = Restore-ManifestItem -Item $script:Item - $result.Outcome | Should -Be 'Skipped' - $result.Reason | Should -Be 'NoOriginalPath' - } - } - - Context 'When the destination parent does not exist' { - It 'Creates the parent directory and restores' { - $result = Restore-ManifestItem -Item $script:Item - $result.Outcome | Should -Be 'Restored' - Test-Path (Split-Path $script:OriginalPath -Parent) | Should -Be $true - Test-Path $script:OriginalPath | Should -Be $true - } - } - - Context 'When -BackupCurrentFirst is true and original file exists' { - BeforeEach { - New-Item -ItemType Directory -Path (Split-Path $script:OriginalPath -Parent) | Out-Null - 'pre-existing' | Out-File $script:OriginalPath - } - - It 'Creates a .bak copy of the original before overwriting' { - $null = Restore-ManifestItem -Item $script:Item -BackupCurrentFirst $true - Test-Path "$($script:OriginalPath).bak" | Should -Be $true - (Get-Content "$($script:OriginalPath).bak" -Raw).Trim() | Should -Be 'pre-existing' - } - } - - Context 'When -BackupCurrentFirst is false' { - BeforeEach { - New-Item -ItemType Directory -Path (Split-Path $script:OriginalPath -Parent) | Out-Null - 'pre-existing' | Out-File $script:OriginalPath - } - - It 'Does NOT create a .bak copy' { - $null = Restore-ManifestItem -Item $script:Item -BackupCurrentFirst $false - Test-Path "$($script:OriginalPath).bak" | Should -Be $false - } - } - - Context 'With -WhatIf' { - It 'Does not actually copy the file' { - $result = Restore-ManifestItem -Item $script:Item -WhatIf - Test-Path $script:OriginalPath | Should -Be $false - } - } -} - -Describe 'Restore-DeveloperEnvironment.ps1 - Restore-VsCodeExtension' { - BeforeEach { - $script:Dir = Join-Path $TestDrive ([Guid]::NewGuid().Guid) - New-Item -ItemType Directory -Path $script:Dir -Force | Out-Null - - $script:ExtFile = Join-Path $script:Dir 'extensions.txt' - @( - 'ms-python.python' - 'dbaeumer.vscode-eslint' - 'esbenp.prettier-vscode' - ) | Out-File $script:ExtFile - - $script:ExtItem = [PSCustomObject]@{ - Name = 'VSCode-Extensions' - BackupFile = $script:ExtFile - } - } - - Context 'When the extensions backup file is missing' { - BeforeEach { - Remove-Item $script:ExtFile -Force - } - - It 'Returns Skipped=$true without invoking code' { - Mock code { throw 'should not be called' } - $result = Restore-VsCodeExtension -ExtensionsItem $script:ExtItem - $result.Skipped | Should -Be $true - } - } - - Context 'When code CLI is not in PATH' { - BeforeEach { - Mock Get-Command { $null } -ParameterFilter { $Name -eq 'code' } - Mock code { throw 'should not be called' } - } - - It 'Returns Skipped=$true without invoking code' { - $result = Restore-VsCodeExtension -ExtensionsItem $script:ExtItem - $result.Skipped | Should -Be $true - } - } - - Context 'When code CLI is available and three extensions are listed' { - BeforeEach { - Mock Get-Command { [PSCustomObject]@{ Name = 'code' } } -ParameterFilter { $Name -eq 'code' } - Mock code { $global:LASTEXITCODE = 0 } - } - - It 'Calls code --install-extension once per extension' { - $null = Restore-VsCodeExtension -ExtensionsItem $script:ExtItem - Should -Invoke code -Times 3 - } - - It 'Counts only successful installs (LASTEXITCODE 0) in Installed' { - $result = Restore-VsCodeExtension -ExtensionsItem $script:ExtItem - $result.Installed | Should -Be 3 - $result.Total | Should -Be 3 - } - } - - Context 'When some extensions fail to install' { - BeforeEach { - Mock Get-Command { [PSCustomObject]@{ Name = 'code' } } -ParameterFilter { $Name -eq 'code' } - $script:CallCount = 0 - Mock code { - $script:CallCount++ - $global:LASTEXITCODE = if ($script:CallCount -eq 2) { 1 } else { 0 } - } - } - - It 'Continues iterating past failures and reports partial count' { - $result = Restore-VsCodeExtension -ExtensionsItem $script:ExtItem - $result.Installed | Should -Be 2 - $result.Total | Should -Be 3 - } - } - - Context 'With -WhatIf' { - BeforeEach { - Mock Get-Command { [PSCustomObject]@{ Name = 'code' } } -ParameterFilter { $Name -eq 'code' } - Mock code { throw 'should not be called' } - } - - It 'Does not invoke code at all' { - $null = Restore-VsCodeExtension -ExtensionsItem $script:ExtItem -WhatIf - Should -Invoke code -Times 0 - } - } -} - -Describe 'Restore-DeveloperEnvironment.ps1 - Invoke-Restore' { - BeforeEach { - $script:Dir = Join-Path $TestDrive ([Guid]::NewGuid().Guid) - New-Item -ItemType Directory -Path $script:Dir -Force | Out-Null - - @{ - BackupDate = '2026-05-15' - ComputerName = 'TEST' - UserName = 'tester' - Items = @( - @{ Name = 'A'; BackupFile = 'x'; OriginalPath = 'y' }, - @{ Name = 'B'; BackupFile = 'x'; OriginalPath = 'y' }, - @{ Name = 'VSCode-Extensions'; BackupFile = 'z'; OriginalPath = '' } - ) - } | ConvertTo-Json -Depth 4 | Out-File (Join-Path $script:Dir 'manifest.json') - - Mock Restore-ManifestItem { @{ Outcome = 'Restored'; Reason = $null } } - Mock Restore-VsCodeExtension { @{ Installed = 0; Total = 0; Skipped = $false } } - } - - It 'Calls Restore-ManifestItem once per non-extension item' { - $null = Invoke-Restore -Source $script:Dir - Should -Invoke Restore-ManifestItem -Times 2 - } - - It 'Calls Restore-VsCodeExtension when -RestoreVsCodeExtensions is true (default)' { - $null = Invoke-Restore -Source $script:Dir - Should -Invoke Restore-VsCodeExtension -Times 1 - } - - It 'Does NOT call Restore-VsCodeExtension when -RestoreVsCodeExtensions is false' { - $null = Invoke-Restore -Source $script:Dir -RestoreVsCodeExtensions $false - Should -Invoke Restore-VsCodeExtension -Times 0 - } - - It 'Returns counts hashtable with Restored/Skipped/Errors keys' { - $result = Invoke-Restore -Source $script:Dir - $result.Restored | Should -Be 2 - $result.Errors | Should -Be 0 - } - - It 'Throws when the manifest is missing' { - $emptyDir = Join-Path $TestDrive ([Guid]::NewGuid().Guid) - New-Item -ItemType Directory -Path $emptyDir | Out-Null - { Invoke-Restore -Source $emptyDir } | Should -Throw '*Manifest not found*' - } -} diff --git a/tests/Windows/TestNetworkHealth.Behavioral.Tests.ps1 b/tests/Windows/TestNetworkHealth.Behavioral.Tests.ps1 deleted file mode 100644 index e8d19c2..0000000 --- a/tests/Windows/TestNetworkHealth.Behavioral.Tests.ps1 +++ /dev/null @@ -1,353 +0,0 @@ -# Behavioral Pester tests for Test-NetworkHealth.ps1 -# Run: Invoke-Pester -Path .\tests\Windows\TestNetworkHealth.Behavioral.Tests.ps1 -# -# The script is dot-sourced (testability guard skips Invoke-NetworkHealthCheck on -# dot-source). All "external" calls (Test-Connection, Test-NetConnection, -# Resolve-DnsName, Get-NetworkConfiguration, etc.) are built-in PowerShell -# cmdlets or in-script helpers that Pester Mock can intercept directly. - -BeforeAll { - $ProjectRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent - $ScriptPath = Join-Path $ProjectRoot 'Windows\monitoring\Test-NetworkHealth.ps1' - . $ScriptPath -} - -Describe 'Test-NetworkHealth.ps1 - Test-HostConnectivity' { - It 'Returns Success=true with timing stats when ping succeeds' { - Mock Test-Connection { - @( - [PSCustomObject]@{ StatusCode = 0; ResponseTime = 10; Address = '8.8.8.8' } - [PSCustomObject]@{ StatusCode = 0; ResponseTime = 20; Address = '8.8.8.8' } - [PSCustomObject]@{ StatusCode = 0; ResponseTime = 30; Address = '8.8.8.8' } - [PSCustomObject]@{ StatusCode = 0; ResponseTime = 40; Address = '8.8.8.8' } - ) - } - $r = Test-HostConnectivity -HostName '8.8.8.8' -Count 4 - $r.Success | Should -Be $true - $r.MinTime | Should -Be 10 - $r.MaxTime | Should -Be 40 - $r.AvgTime | Should -Be 25 - $r.PacketLoss | Should -Be 0 - $r.IPAddress | Should -Be '8.8.8.8' - } - - It 'Returns Success=false with Error when Test-Connection throws' { - Mock Test-Connection { throw 'No such host is known' } - $r = Test-HostConnectivity -HostName 'nonexistent.invalid' - $r.Success | Should -Be $false - $r.Error | Should -Match 'No such host' - $r.PacketLoss | Should -Be 100 - } - - It 'Calculates packet loss when some pings fail' { - Mock Test-Connection { - @( - [PSCustomObject]@{ StatusCode = 0; ResponseTime = 10; Address = '1.2.3.4' } - [PSCustomObject]@{ StatusCode = 0; ResponseTime = 10; Address = '1.2.3.4' } - [PSCustomObject]@{ StatusCode = 11010; ResponseTime = 0; Address = $null } - [PSCustomObject]@{ StatusCode = 11010; ResponseTime = 0; Address = $null } - ) - } - $r = Test-HostConnectivity -HostName 'flaky.example' -Count 4 - $r.Success | Should -Be $true - $r.PacketLoss | Should -Be 50 - } - - It 'Uses Latency property when ResponseTime is not present (PowerShell 7 shape)' { - Mock Test-Connection { - @( - [PSCustomObject]@{ Status = 'Success'; Latency = 15; Address = '1.1.1.1' } - ) - } - $r = Test-HostConnectivity -HostName '1.1.1.1' -Count 1 - $r.Success | Should -Be $true - $r.AvgTime | Should -Be 15 - } -} - -Describe 'Test-NetworkHealth.ps1 - Test-PortConnectivity' { - It 'Returns Success=true and looks up ServiceName from CommonPorts when port is reachable' { - Mock Test-NetConnection { [PSCustomObject]@{ TcpTestSucceeded = $true } } - $r = Test-PortConnectivity -HostName 'github.com' -Port 443 - $r.Success | Should -Be $true - $r.ServiceName | Should -Be 'HTTPS' - $r.ResponseMs | Should -Not -BeNullOrEmpty - } - - It "Returns Success=false with 'not reachable' error when TcpTestSucceeded is false" { - Mock Test-NetConnection { [PSCustomObject]@{ TcpTestSucceeded = $false } } - $r = Test-PortConnectivity -HostName 'github.com' -Port 22 - $r.Success | Should -Be $false - $r.Error | Should -Match 'not reachable' - $r.ServiceName | Should -Be 'SSH' - } - - It "Reports ServiceName='Unknown' for ports not in CommonPorts table" { - Mock Test-NetConnection { [PSCustomObject]@{ TcpTestSucceeded = $true } } - $r = Test-PortConnectivity -HostName 'host' -Port 9999 - $r.ServiceName | Should -Be 'Unknown' - } -} - -Describe 'Test-NetworkHealth.ps1 - Test-DNSResolution' { - It 'Returns Success=true with IPAddresses populated for an A record' { - Mock Resolve-DnsName { @([PSCustomObject]@{ Type = 'A'; IPAddress = '142.250.190.78' }) } - $r = Test-DNSResolution -Domain 'google.com' - $r.Success | Should -Be $true - $r.IPAddresses | Should -Contain '142.250.190.78' - $r.DNSServer | Should -Be 'System Default' - } - - It 'Includes both A and AAAA addresses in result' { - Mock Resolve-DnsName { - @( - [PSCustomObject]@{ Type = 'A'; IPAddress = '1.2.3.4' } - [PSCustomObject]@{ Type = 'AAAA'; IPAddress = '::1' } - ) - } - $r = Test-DNSResolution -Domain 'example.com' - $r.IPAddresses.Count | Should -Be 2 - $r.IPAddresses | Should -Contain '1.2.3.4' - $r.IPAddresses | Should -Contain '::1' - } - - It 'Records the supplied DNSServer when one is passed' { - Mock Resolve-DnsName { @([PSCustomObject]@{ Type = 'A'; IPAddress = '8.8.4.4' }) } - $r = Test-DNSResolution -Domain 'dns.google' -DNSServer '8.8.8.8' - $r.DNSServer | Should -Be '8.8.8.8' - $r.Success | Should -Be $true - } - - It 'Returns Success=false with Error when Resolve-DnsName throws' { - Mock Resolve-DnsName { throw 'DNS name does not exist' } - $r = Test-DNSResolution -Domain 'no-such-domain.invalid' - $r.Success | Should -Be $false - $r.Error | Should -Match 'DNS name does not exist' - } -} - -Describe 'Test-NetworkHealth.ps1 - Invoke-Traceroute' { - It 'Returns Success=true and a hop list when Test-NetConnection returns TraceRoute and PingSucceeded' { - Mock Test-NetConnection { - [PSCustomObject]@{ - PingSucceeded = $true - TraceRoute = @('10.0.0.1', '10.0.0.2', '8.8.8.8') - } - } - Mock Resolve-DnsName { $null } - Mock Write-InfoMessage { } - $r = Invoke-Traceroute -Target '8.8.8.8' - $r.Success | Should -Be $true - $r.TotalHops | Should -Be 3 - $r.Hops[0].Address | Should -Be '10.0.0.1' - } - - It 'Returns Success=false with Error when Test-NetConnection throws' { - Mock Test-NetConnection { throw 'destination unreachable' } - Mock Write-InfoMessage { } - $r = Invoke-Traceroute -Target 'unreachable.invalid' - $r.Success | Should -Be $false - $r.Error | Should -Match 'destination unreachable' - $r.TotalHops | Should -Be 0 - } -} - -Describe 'Test-NetworkHealth.ps1 - Get-NetworkHealthReport' { - BeforeEach { - Mock Write-InfoMessage { } - Mock Get-NetworkConfiguration { @{ Hostname = 'TEST'; ProxyEnabled = $false; ProxyServer = $null } } - Mock Get-NetworkAdapterInfo { @() } - } - - It 'Counts PassedTests for successful ping/port/DNS and produces NoAdapter alert when no adapters' { - Mock Test-HostConnectivity { @{ Host = $HostName; Success = $true; AvgTime = 5; PacketLoss = 0; IPAddress = '1.2.3.4'; Error = $null } } - Mock Test-PortConnectivity { @{ Host = $HostName; Port = $Port; ServiceName = 'HTTPS'; Success = $true; ResponseMs = 10; Error = $null } } - Mock Test-DNSResolution { @{ Domain = $Domain; Success = $true; IPAddresses = @('1.2.3.4'); ResponseMs = 5; DNSServer = 'Default'; Error = $null } } - Mock Invoke-Traceroute { @{ Target = $Target; Success = $true; Hops = @(); TotalHops = 0 } } - - $r = Get-NetworkHealthReport -HostList @('a', 'b') -PortList @(443) -DomainList @('x.com') -TraceTargets @('8.8.8.8') -DoSkipPortScan $false -DoSkipDNS $false -DoSkipTraceroute $false -DoQuickTest $false - - # 2 ping + 2 port (a,b x 443) + 1 dns = 5 passed - $r.Summary.PassedTests | Should -Be 5 - $r.Summary.FailedTests | Should -Be 0 - @($r.Alerts | Where-Object { $_.Type -eq 'NoAdapter' }).Count | Should -Be 1 - } - - It 'Generates HighLatency warning when AvgTime > 200ms' { - Mock Get-NetworkAdapterInfo { @(@{ Name = 'Eth0' }) } - Mock Test-HostConnectivity { @{ Host = $HostName; Success = $true; AvgTime = 350; PacketLoss = 0; IPAddress = '1.2.3.4' } } - - $r = Get-NetworkHealthReport -HostList @('slow.example') -PortList @() -DomainList @() -TraceTargets @() -DoSkipPortScan $true -DoSkipDNS $true -DoSkipTraceroute $true -DoQuickTest $false - - @($r.Alerts | Where-Object { $_.Type -eq 'HighLatency' }).Count | Should -Be 1 - $r.Summary.Warnings | Should -BeGreaterOrEqual 1 - } - - It 'Generates PacketLoss warning when PacketLoss > 0' { - Mock Get-NetworkAdapterInfo { @(@{}) } - Mock Test-HostConnectivity { @{ Host = $HostName; Success = $true; AvgTime = 10; PacketLoss = 25; IPAddress = '1.2.3.4' } } - - $r = Get-NetworkHealthReport -HostList @('flaky') -PortList @() -DomainList @() -TraceTargets @() -DoSkipPortScan $true -DoSkipDNS $true -DoSkipTraceroute $true -DoQuickTest $false - - @($r.Alerts | Where-Object { $_.Type -eq 'PacketLoss' }).Count | Should -Be 1 - } - - It 'Generates ConnectivityFailed critical alert when ping fails' { - Mock Get-NetworkAdapterInfo { @(@{}) } - Mock Test-HostConnectivity { @{ Host = $HostName; Success = $false; Error = 'timeout' } } - - $r = Get-NetworkHealthReport -HostList @('dead.host') -PortList @() -DomainList @() -TraceTargets @() -DoSkipPortScan $true -DoSkipDNS $true -DoSkipTraceroute $true -DoQuickTest $false - - $r.Summary.FailedTests | Should -Be 1 - @($r.Alerts | Where-Object { $_.Type -eq 'ConnectivityFailed' -and $_.Level -eq 'Critical' }).Count | Should -Be 1 - } - - It 'Skips port scan tests when DoSkipPortScan is true' { - Mock Get-NetworkAdapterInfo { @(@{}) } - Mock Test-HostConnectivity { @{ Host = $HostName; Success = $true; AvgTime = 5; PacketLoss = 0 } } - Mock Test-PortConnectivity { throw 'should not be called' } - - $r = Get-NetworkHealthReport -HostList @('a') -PortList @(443) -DomainList @() -TraceTargets @() -DoSkipPortScan $true -DoSkipDNS $true -DoSkipTraceroute $true -DoQuickTest $false - - $r.PortTests.Count | Should -Be 0 - Should -Invoke Test-PortConnectivity -Times 0 - } - - It 'Generates PortBlocked warning when a port test fails' { - Mock Get-NetworkAdapterInfo { @(@{}) } - Mock Test-HostConnectivity { @{ Host = $HostName; Success = $true; AvgTime = 5; PacketLoss = 0 } } - Mock Test-PortConnectivity { @{ Host = $HostName; Port = $Port; ServiceName = 'SSH'; Success = $false; Error = 'blocked' } } - - $r = Get-NetworkHealthReport -HostList @('host') -PortList @(22) -DomainList @() -TraceTargets @() -DoSkipPortScan $false -DoSkipDNS $true -DoSkipTraceroute $true -DoQuickTest $false - - @($r.Alerts | Where-Object { $_.Type -eq 'PortBlocked' }).Count | Should -Be 1 - $r.Summary.FailedTests | Should -Be 1 - } - - It 'Skips DNS tests when DoSkipDNS is true' { - Mock Get-NetworkAdapterInfo { @(@{}) } - Mock Test-HostConnectivity { @{ Success = $true; AvgTime = 5; PacketLoss = 0 } } - Mock Test-DNSResolution { throw 'should not be called' } - - $r = Get-NetworkHealthReport -HostList @('h') -PortList @() -DomainList @('x.com') -TraceTargets @() -DoSkipPortScan $true -DoSkipDNS $true -DoSkipTraceroute $true -DoQuickTest $false - - $r.DNSTests.Count | Should -Be 0 - Should -Invoke Test-DNSResolution -Times 0 - } - - It 'Adds custom DNSServer tests when DNSServerList is provided' { - Mock Get-NetworkAdapterInfo { @(@{}) } - Mock Test-HostConnectivity { @{ Success = $true; AvgTime = 5; PacketLoss = 0 } } - Mock Test-DNSResolution { @{ Domain = $Domain; Success = $true; IPAddresses = @('1.2.3.4'); DNSServer = ($DNSServer); Error = $null } } - - # 1 domain * (1 default + 2 custom) = 3 DNS tests - $r = Get-NetworkHealthReport -HostList @('h') -PortList @() -DomainList @('google.com') -DNSServerList @('8.8.8.8', '1.1.1.1') -TraceTargets @() -DoSkipPortScan $true -DoSkipDNS $false -DoSkipTraceroute $true -DoQuickTest $false - - $r.DNSTests.Count | Should -Be 3 - } - - It 'Adds Proxy info alert when ProxyEnabled is true' { - Mock Get-NetworkConfiguration { @{ Hostname = 'TEST'; ProxyEnabled = $true; ProxyServer = 'proxy.corp:8080' } } - Mock Get-NetworkAdapterInfo { @(@{}) } - Mock Test-HostConnectivity { @{ Success = $true; AvgTime = 5; PacketLoss = 0 } } - - $r = Get-NetworkHealthReport -HostList @('h') -PortList @() -DomainList @() -TraceTargets @() -DoSkipPortScan $true -DoSkipDNS $true -DoSkipTraceroute $true -DoQuickTest $false - - @($r.Alerts | Where-Object { $_.Type -eq 'Proxy' -and $_.Level -eq 'Info' }).Count | Should -Be 1 - } - - It 'Skips traceroute when DoQuickTest is true even with DoSkipTraceroute=$false' { - Mock Get-NetworkAdapterInfo { @(@{}) } - Mock Test-HostConnectivity { @{ Success = $true; AvgTime = 5; PacketLoss = 0 } } - Mock Invoke-Traceroute { throw 'should not be called' } - - $r = Get-NetworkHealthReport -HostList @('h') -PortList @() -DomainList @() -TraceTargets @('8.8.8.8') -DoSkipPortScan $true -DoSkipDNS $true -DoSkipTraceroute $false -DoQuickTest $true - - $r.TracerouteResults.Count | Should -Be 0 - Should -Invoke Invoke-Traceroute -Times 0 - } -} - -Describe 'Test-NetworkHealth.ps1 - Export-JSONReport' { - It 'Writes a JSON file containing the report fields' { - $dir = Join-Path $TestDrive ([Guid]::NewGuid().Guid) - New-Item -ItemType Directory -Path $dir -Force | Out-Null - $report = @{ - Timestamp = '2026-06-07' - ComputerName = 'TEST' - Summary = @{ TotalTests = 3; PassedTests = 2; FailedTests = 1; Warnings = 0 } - ConnectivityTests = @() - PortTests = @() - DNSTests = @() - TracerouteResults = @() - Alerts = @() - } - - $jsonPath = Export-JSONReport -Report $report -Path $dir - - Test-Path $jsonPath | Should -Be $true - $written = Get-Content $jsonPath -Raw | ConvertFrom-Json - $written.ComputerName | Should -Be 'TEST' - $written.Summary.PassedTests | Should -Be 2 - } -} - -Describe 'Test-NetworkHealth.ps1 - Export-HTMLReport' { - It 'Writes an HTML file that mentions computer name and summary counts' { - $dir = Join-Path $TestDrive ([Guid]::NewGuid().Guid) - New-Item -ItemType Directory -Path $dir -Force | Out-Null - $report = @{ - ComputerName = 'TESTHOST' - Summary = @{ PassedTests = 4; FailedTests = 1; Warnings = 2 } - ConnectivityTests = @(@{ Host = 'a'; IPAddress = '1.2.3.4'; Success = $true; AvgTime = 5; PacketLoss = 0 }) - PortTests = @() - DNSTests = @() - Alerts = @(@{ Level = 'Critical'; Message = 'boom' }) - } - - $htmlPath = Export-HTMLReport -Report $report -Path $dir - - Test-Path $htmlPath | Should -Be $true - $content = Get-Content $htmlPath -Raw - $content | Should -Match 'TESTHOST' - $content | Should -Match '' - $content | Should -Match 'boom' - } -} - -Describe 'Test-NetworkHealth.ps1 - Write-ConsoleReport' { - It 'Runs without throwing on a minimal report and writes to host' { - Mock Write-Host { } - $report = @{ - ComputerName = 'TEST' - Summary = @{ TotalTests = 1; PassedTests = 1; FailedTests = 0; Warnings = 0 } - Adapters = @() - ConnectivityTests = @(@{ Host = 'a'; Success = $true; AvgTime = 5; PacketLoss = 0 }) - PortTests = @() - DNSTests = @() - TracerouteResults = @() - Alerts = @() - } - { Write-ConsoleReport -Report $report } | Should -Not -Throw - Should -Invoke Write-Host -Scope It - } - - It "Writes the 'No issues detected' line when Alerts is empty" { - $script:Captured = New-Object System.Collections.Generic.List[string] - Mock Write-Host { - if ($Object) { $script:Captured.Add([string]$Object) } - } - $report = @{ - ComputerName = 'TEST' - Summary = @{ TotalTests = 1; PassedTests = 1; FailedTests = 0; Warnings = 0 } - Adapters = @() - ConnectivityTests = @() - PortTests = @() - DNSTests = @() - TracerouteResults = @() - Alerts = @() - } - Write-ConsoleReport -Report $report - ($script:Captured -join ' ') | Should -Match 'No issues detected' - } -} diff --git a/tests/Windows/Tier2Scripts.Tests.ps1 b/tests/Windows/Tier2Scripts.Tests.ps1 deleted file mode 100644 index 497b031..0000000 --- a/tests/Windows/Tier2Scripts.Tests.ps1 +++ /dev/null @@ -1,612 +0,0 @@ -#Requires -Version 5.1 -#Requires -Modules Pester -<# -.SYNOPSIS - Pester tests for Tier 2 scripts in the Windows Sysadmin Toolkit. - -.DESCRIPTION - Comprehensive tests for: - - Get-UserAccountAudit.ps1 (User Account Audit) - - Repair-CommonIssues.ps1 (Common Issue Auto-Fixer) - - Get-SystemPerformance.ps1 (includes Disk Space Monitor functionality) - - Get-ApplicationHealth.ps1 (Application Health Monitor) - - Get-SystemReport.ps1 (System Information Reporter) - -.NOTES - Author: Windows & Linux Sysadmin Toolkit - Version: 1.0.0 - Requires: Pester 5.x, PowerShell 5.1+ -#> - -BeforeAll { - # Get the toolkit root path - $TestRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) - - # Script paths - $Script:UserAccountAuditScript = Join-Path $TestRoot "Windows\security\Get-UserAccountAudit.ps1" - $Script:RepairCommonIssuesScript = Join-Path $TestRoot "Windows\troubleshooting\Repair-CommonIssues.ps1" - # Watch-DiskSpace.ps1 merged into Get-SystemPerformance.ps1 - $Script:SystemPerformanceScript = Join-Path $TestRoot "Windows\monitoring\Get-SystemPerformance.ps1" - $Script:ApplicationHealthScript = Join-Path $TestRoot "Windows\monitoring\Get-ApplicationHealth.ps1" - $Script:SystemReportScript = Join-Path $TestRoot "Windows\reporting\Get-SystemReport.ps1" - $Script:CommonFunctionsModule = Join-Path $TestRoot "Windows\lib\CommonFunctions.psm1" - - # Import CommonFunctions module if available - if (Test-Path $Script:CommonFunctionsModule) { - Import-Module $Script:CommonFunctionsModule -Force - } -} - -Describe "Get-UserAccountAudit.ps1" -Tag "Security", "UserAudit" { - Context "Script Existence and Syntax" { - It "Script file should exist" { - $Script:UserAccountAuditScript | Should -Exist - } - - It "Script should have valid PowerShell syntax" { - $errors = $null - $null = [System.Management.Automation.PSParser]::Tokenize((Get-Content $Script:UserAccountAuditScript -Raw), [ref]$errors) - $errors.Count | Should -Be 0 - } - - It "Script should contain required elements" { - $content = Get-Content $Script:UserAccountAuditScript -Raw - $content | Should -Match '#Requires -Version 5.1' - $content | Should -Match '\.SYNOPSIS' - $content | Should -Match '\.DESCRIPTION' - $content | Should -Match '\.EXAMPLE' - $content | Should -Match 'param\s*\(' - } - } - - Context "Parameters" { - BeforeAll { - $scriptInfo = Get-Command $Script:UserAccountAuditScript -ErrorAction SilentlyContinue - $parameters = $scriptInfo.Parameters - } - - It "Should have DaysInactive parameter" { - $parameters.ContainsKey('DaysInactive') | Should -BeTrue - } - - It "Should have PasswordAgeDays parameter" { - $parameters.ContainsKey('PasswordAgeDays') | Should -BeTrue - } - - It "Should have OutputFormat parameter with valid values" { - $parameters.ContainsKey('OutputFormat') | Should -BeTrue - $outputFormatAttr = $parameters['OutputFormat'].Attributes | Where-Object { $_ -is [System.Management.Automation.ValidateSetAttribute] } - $outputFormatAttr.ValidValues | Should -Contain 'Console' - $outputFormatAttr.ValidValues | Should -Contain 'HTML' - $outputFormatAttr.ValidValues | Should -Contain 'JSON' - $outputFormatAttr.ValidValues | Should -Contain 'CSV' - } - - It "Should have IncludeDisabled parameter" { - $parameters.ContainsKey('IncludeDisabled') | Should -BeTrue - } - - It "Should have CheckRemoteComputers parameter" { - $parameters.ContainsKey('CheckRemoteComputers') | Should -BeTrue - } - } - - Context "Script Features" { - It "Should define Get-UserSecurityIssues function" { - $content = Get-Content $Script:UserAccountAuditScript -Raw - $content | Should -Match 'function\s+Get-UserSecurityIssues' - } - - It "Should define Get-UserAccountDetails function" { - $content = Get-Content $Script:UserAccountAuditScript -Raw - $content | Should -Match 'function\s+Get-UserAccountDetails' - } - - It "Should define Get-AuditSummary function" { - $content = Get-Content $Script:UserAccountAuditScript -Raw - $content | Should -Match 'function\s+Get-AuditSummary' - } - - It "Should define Export-HtmlReport function" { - $content = Get-Content $Script:UserAccountAuditScript -Raw - $content | Should -Match 'function\s+Export-HtmlReport' - } - - It "Should check for password never expires" { - $content = Get-Content $Script:UserAccountAuditScript -Raw - $content | Should -Match 'PasswordNeverExpires' - } - - It "Should check for password not required" { - $content = Get-Content $Script:UserAccountAuditScript -Raw - $content | Should -Match 'PasswordNotRequired' - } - - It "Should check admin group membership" { - $content = Get-Content $Script:UserAccountAuditScript -Raw - $content | Should -Match 'Administrators' - } - } -} - -Describe "Repair-CommonIssues.ps1" -Tag "Troubleshooting", "Repair" { - Context "Script Existence and Syntax" { - It "Script file should exist" { - $Script:RepairCommonIssuesScript | Should -Exist - } - - It "Script should have valid PowerShell syntax" { - $errors = $null - $null = [System.Management.Automation.PSParser]::Tokenize((Get-Content $Script:RepairCommonIssuesScript -Raw), [ref]$errors) - $errors.Count | Should -Be 0 - } - - It "Script should require administrator" { - $content = Get-Content $Script:RepairCommonIssuesScript -Raw - $content | Should -Match '#Requires -RunAsAdministrator' - } - - It "Script should contain required elements" { - $content = Get-Content $Script:RepairCommonIssuesScript -Raw - $content | Should -Match '\.SYNOPSIS' - $content | Should -Match '\.DESCRIPTION' - $content | Should -Match 'param\s*\(' - } - } - - Context "Parameters" { - BeforeAll { - $scriptInfo = Get-Command $Script:RepairCommonIssuesScript -ErrorAction SilentlyContinue - $parameters = $scriptInfo.Parameters - } - - It "Should have Fix parameter with valid options" { - $parameters.ContainsKey('Fix') | Should -BeTrue - $fixAttr = $parameters['Fix'].Attributes | Where-Object { $_ -is [System.Management.Automation.ValidateSetAttribute] } - $fixAttr.ValidValues | Should -Contain 'All' - $fixAttr.ValidValues | Should -Contain 'DNS' - $fixAttr.ValidValues | Should -Contain 'Network' - $fixAttr.ValidValues | Should -Contain 'WindowsUpdate' - $fixAttr.ValidValues | Should -Contain 'Cache' - $fixAttr.ValidValues | Should -Contain 'Winsock' - $fixAttr.ValidValues | Should -Contain 'TCPIP' - } - - It "Should have DryRun parameter" { - $parameters.ContainsKey('DryRun') | Should -BeTrue - } - - It "Should have Force parameter" { - $parameters.ContainsKey('Force') | Should -BeTrue - } - - It "Should have CreateRestorePoint parameter" { - $parameters.ContainsKey('CreateRestorePoint') | Should -BeTrue - } - - It "Should support ShouldProcess" { - $content = Get-Content $Script:RepairCommonIssuesScript -Raw - $content | Should -Match 'SupportsShouldProcess\s*=\s*\$true' - } - } - - Context "Repair Functions" { - It "Should define Repair-DNSIssues function" { - $content = Get-Content $Script:RepairCommonIssuesScript -Raw - $content | Should -Match 'function\s+Repair-DNSIssues' - } - - It "Should define Repair-NetworkIssues function" { - $content = Get-Content $Script:RepairCommonIssuesScript -Raw - $content | Should -Match 'function\s+Repair-NetworkIssues' - } - - It "Should define Repair-WinsockIssues function" { - $content = Get-Content $Script:RepairCommonIssuesScript -Raw - $content | Should -Match 'function\s+Repair-WinsockIssues' - } - - It "Should define Repair-WindowsUpdateIssues function" { - $content = Get-Content $Script:RepairCommonIssuesScript -Raw - $content | Should -Match 'function\s+Repair-WindowsUpdateIssues' - } - - It "Should define Repair-CacheIssues function" { - $content = Get-Content $Script:RepairCommonIssuesScript -Raw - $content | Should -Match 'function\s+Repair-CacheIssues' - } - - It "Should define Repair-SystemFiles function" { - $content = Get-Content $Script:RepairCommonIssuesScript -Raw - $content | Should -Match 'function\s+Repair-SystemFiles' - } - - It "Should use Clear-DnsClientCache for DNS fix" { - $content = Get-Content $Script:RepairCommonIssuesScript -Raw - $content | Should -Match 'Clear-DnsClientCache' - } - - It "Should use netsh for Winsock reset" { - $content = Get-Content $Script:RepairCommonIssuesScript -Raw - $content | Should -Match 'netsh winsock reset' - } - } -} - -# Watch-DiskSpace.ps1 functionality merged into Get-SystemPerformance.ps1 -Describe "Get-SystemPerformance.ps1 Disk Analysis" -Tag "Monitoring", "DiskSpace" { - Context "Disk Analysis Parameters (merged from Watch-DiskSpace.ps1)" { - BeforeAll { - $scriptInfo = Get-Command $Script:SystemPerformanceScript -ErrorAction SilentlyContinue - $parameters = $scriptInfo.Parameters - } - - It "Script file should exist" { - $Script:SystemPerformanceScript | Should -Exist - } - - It "Should have IncludeDiskAnalysis parameter" { - $parameters.ContainsKey('IncludeDiskAnalysis') | Should -BeTrue - } - - It "Should have AutoCleanup parameter" { - $parameters.ContainsKey('AutoCleanup') | Should -BeTrue - } - - It "Should have DriveLetters parameter" { - $parameters.ContainsKey('DriveLetters') | Should -BeTrue - } - - It "Should have ExcludeDrives parameter" { - $parameters.ContainsKey('ExcludeDrives') | Should -BeTrue - } - - It "Should have TopFilesCount parameter" { - $parameters.ContainsKey('TopFilesCount') | Should -BeTrue - } - } - - Context "Disk Analysis Functions (merged from Watch-DiskSpace.ps1)" { - It "Should define Get-LargestFiles function" { - $content = Get-Content $Script:SystemPerformanceScript -Raw - $content | Should -Match 'function\s+Get-LargestFiles' - } - - It "Should define Get-LargestFolders function" { - $content = Get-Content $Script:SystemPerformanceScript -Raw - $content | Should -Match 'function\s+Get-LargestFolders' - } - - It "Should define Get-CleanupSuggestions function" { - $content = Get-Content $Script:SystemPerformanceScript -Raw - $content | Should -Match 'function\s+Get-CleanupSuggestions' - } - - It "Should define Get-DiskAnalysis function" { - $content = Get-Content $Script:SystemPerformanceScript -Raw - $content | Should -Match 'function\s+Get-DiskAnalysis' - } - - It "Should check for temp files" { - $content = Get-Content $Script:SystemPerformanceScript -Raw - $content | Should -Match '\$env:TEMP' - } - - It "Should check for browser caches" { - $content = Get-Content $Script:SystemPerformanceScript -Raw - $content | Should -Match 'Chrome.*Cache|Edge.*Cache|Firefox' - } - } -} - -Describe "Get-ApplicationHealth.ps1" -Tag "Monitoring", "Applications" { - Context "Script Existence and Syntax" { - It "Script file should exist" { - $Script:ApplicationHealthScript | Should -Exist - } - - It "Script should have valid PowerShell syntax" { - $errors = $null - $null = [System.Management.Automation.PSParser]::Tokenize((Get-Content $Script:ApplicationHealthScript -Raw), [ref]$errors) - $errors.Count | Should -Be 0 - } - - It "Script should contain required elements" { - $content = Get-Content $Script:ApplicationHealthScript -Raw - $content | Should -Match '#Requires -Version 5.1' - $content | Should -Match '\.SYNOPSIS' - $content | Should -Match '\.DESCRIPTION' - $content | Should -Match 'param\s*\(' - } - } - - Context "Parameters" { - BeforeAll { - $scriptInfo = Get-Command $Script:ApplicationHealthScript -ErrorAction SilentlyContinue - $parameters = $scriptInfo.Parameters - } - - It "Should have RequiredApps parameter" { - $parameters.ContainsKey('RequiredApps') | Should -BeTrue - } - - It "Should have CheckUpdates parameter" { - $parameters.ContainsKey('CheckUpdates') | Should -BeTrue - } - - It "Should have AutoUpdate parameter" { - $parameters.ContainsKey('AutoUpdate') | Should -BeTrue - } - - It "Should have CheckCrashes parameter" { - $parameters.ContainsKey('CheckCrashes') | Should -BeTrue - } - - It "Should have CrashDays parameter" { - $parameters.ContainsKey('CrashDays') | Should -BeTrue - } - - It "Should have OutputFormat parameter" { - $parameters.ContainsKey('OutputFormat') | Should -BeTrue - } - } - - Context "Application Health Features" { - It "Should define Get-InstalledApplications function" { - $content = Get-Content $Script:ApplicationHealthScript -Raw - $content | Should -Match 'function\s+Get-InstalledApplications' - } - - It "Should define Get-WingetUpdates function" { - $content = Get-Content $Script:ApplicationHealthScript -Raw - $content | Should -Match 'function\s+Get-WingetUpdates' - } - - It "Should define Get-ChocolateyUpdates function" { - $content = Get-Content $Script:ApplicationHealthScript -Raw - $content | Should -Match 'function\s+Get-ChocolateyUpdates' - } - - It "Should define Get-ApplicationCrashes function" { - $content = Get-Content $Script:ApplicationHealthScript -Raw - $content | Should -Match 'function\s+Get-ApplicationCrashes' - } - - It "Should define Get-ApplicationResourceUsage function" { - $content = Get-Content $Script:ApplicationHealthScript -Raw - $content | Should -Match 'function\s+Get-ApplicationResourceUsage' - } - - It "Should query registry for installed apps" { - $content = Get-Content $Script:ApplicationHealthScript -Raw - $content | Should -Match 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall' - } - - It "Should check Windows Store apps" { - $content = Get-Content $Script:ApplicationHealthScript -Raw - $content | Should -Match 'Get-AppxPackage' - } - - It "Should check Event Log for crashes" { - $content = Get-Content $Script:ApplicationHealthScript -Raw - $content | Should -Match 'Get-WinEvent' - } - } -} - -Describe "Get-SystemReport.ps1" -Tag "Reporting", "SystemInfo" { - Context "Script Existence and Syntax" { - It "Script file should exist" { - $Script:SystemReportScript | Should -Exist - } - - It "Script should have valid PowerShell syntax" { - $errors = $null - $null = [System.Management.Automation.PSParser]::Tokenize((Get-Content $Script:SystemReportScript -Raw), [ref]$errors) - $errors.Count | Should -Be 0 - } - - It "Script should contain required elements" { - $content = Get-Content $Script:SystemReportScript -Raw - $content | Should -Match '#Requires -Version 5.1' - $content | Should -Match '\.SYNOPSIS' - $content | Should -Match '\.DESCRIPTION' - $content | Should -Match 'param\s*\(' - } - } - - Context "Parameters" { - BeforeAll { - $scriptInfo = Get-Command $Script:SystemReportScript -ErrorAction SilentlyContinue - $parameters = $scriptInfo.Parameters - } - - It "Should have IncludeHardware parameter" { - $parameters.ContainsKey('IncludeHardware') | Should -BeTrue - } - - It "Should have IncludeSoftware parameter" { - $parameters.ContainsKey('IncludeSoftware') | Should -BeTrue - } - - It "Should have IncludeNetwork parameter" { - $parameters.ContainsKey('IncludeNetwork') | Should -BeTrue - } - - It "Should have IncludeSecurity parameter" { - $parameters.ContainsKey('IncludeSecurity') | Should -BeTrue - } - - It "Should have IncludePerformance parameter" { - $parameters.ContainsKey('IncludePerformance') | Should -BeTrue - } - - It "Should have OutputFormat parameter" { - $parameters.ContainsKey('OutputFormat') | Should -BeTrue - } - - It "Should have ComputerName parameter" { - $parameters.ContainsKey('ComputerName') | Should -BeTrue - } - } - - Context "System Report Features" { - It "Should define Get-HardwareInfo function" { - $content = Get-Content $Script:SystemReportScript -Raw - $content | Should -Match 'function\s+Get-HardwareInfo' - } - - It "Should define Get-SoftwareInfo function" { - $content = Get-Content $Script:SystemReportScript -Raw - $content | Should -Match 'function\s+Get-SoftwareInfo' - } - - It "Should define Get-NetworkInfo function" { - $content = Get-Content $Script:SystemReportScript -Raw - $content | Should -Match 'function\s+Get-NetworkInfo' - } - - It "Should define Get-SecurityInfo function" { - $content = Get-Content $Script:SystemReportScript -Raw - $content | Should -Match 'function\s+Get-SecurityInfo' - } - - It "Should define Get-PerformanceInfo function" { - $content = Get-Content $Script:SystemReportScript -Raw - $content | Should -Match 'function\s+Get-PerformanceInfo' - } - - It "Should query Win32_ComputerSystem for hardware" { - $content = Get-Content $Script:SystemReportScript -Raw - $content | Should -Match 'Win32_ComputerSystem' - } - - It "Should query Win32_Processor for CPU" { - $content = Get-Content $Script:SystemReportScript -Raw - $content | Should -Match 'Win32_Processor' - } - - It "Should query Win32_PhysicalMemory for RAM" { - $content = Get-Content $Script:SystemReportScript -Raw - $content | Should -Match 'Win32_PhysicalMemory' - } - - It "Should query Win32_OperatingSystem for OS" { - $content = Get-Content $Script:SystemReportScript -Raw - $content | Should -Match 'Win32_OperatingSystem' - } - - It "Should check Windows Defender status" { - $content = Get-Content $Script:SystemReportScript -Raw - $content | Should -Match 'Get-MpComputerStatus' - } - - It "Should check firewall status" { - $content = Get-Content $Script:SystemReportScript -Raw - $content | Should -Match 'Get-NetFirewallProfile' - } - } -} - -Describe "CommonFunctions Integration" -Tag "Integration" { - Context "Module Import" { - It "All Tier 2 scripts should import CommonFunctions module" { - $scripts = @( - $Script:UserAccountAuditScript, - $Script:RepairCommonIssuesScript, - $Script:SystemPerformanceScript, - $Script:ApplicationHealthScript, - $Script:SystemReportScript - ) - - foreach ($scriptPath in $scripts) { - $content = Get-Content $scriptPath -Raw - $content | Should -Match 'CommonFunctions\.psm1' - } - } - - It "All Tier 2 scripts should have fallback functions" { - $scripts = @( - $Script:UserAccountAuditScript, - $Script:RepairCommonIssuesScript, - $Script:SystemPerformanceScript, - $Script:ApplicationHealthScript, - $Script:SystemReportScript - ) - - foreach ($scriptPath in $scripts) { - $content = Get-Content $scriptPath -Raw - $content | Should -Match 'function\s+Write-Success' - $content | Should -Match 'function\s+Write-InfoMessage' - $content | Should -Match 'function\s+Write-ErrorMessage' - } - } - } - - Context "Output Format Support" { - It "All Tier 2 scripts should support multiple output formats" { - $scripts = @( - $Script:UserAccountAuditScript, - $Script:SystemPerformanceScript, - $Script:ApplicationHealthScript, - $Script:SystemReportScript - ) - - foreach ($scriptPath in $scripts) { - $content = Get-Content $scriptPath -Raw - $content | Should -Match "ValidateSet.*'Console'.*'HTML'.*'JSON'.*'CSV'" - } - } - } - - Context "Exit Codes" { - It "All Tier 2 scripts should return meaningful exit codes" { - # SystemPerformanceScript uses return value instead of ExitCode - $scripts = @( - $Script:UserAccountAuditScript, - $Script:RepairCommonIssuesScript, - $Script:ApplicationHealthScript - ) - - foreach ($scriptPath in $scripts) { - $content = Get-Content $scriptPath -Raw - $content | Should -Match 'ExitCode' - } - } - } -} - -Describe "HTML Report Generation" -Tag "Reporting" { - Context "HTML Report Functions" { - It "All reporting scripts should have Export-HtmlReport function" { - $scripts = @( - $Script:UserAccountAuditScript, - $Script:SystemPerformanceScript, - $Script:ApplicationHealthScript, - $Script:SystemReportScript - ) - - foreach ($scriptPath in $scripts) { - $content = Get-Content $scriptPath -Raw - $content | Should -Match 'function\s+Export-HtmlReport' - } - } - - It "HTML reports should include proper HTML structure" { - $scripts = @( - $Script:UserAccountAuditScript, - $Script:SystemPerformanceScript, - $Script:ApplicationHealthScript, - $Script:SystemReportScript - ) - - foreach ($scriptPath in $scripts) { - $content = Get-Content $scriptPath -Raw - $content | Should -Match '' - $content | Should -Match '' - $content | Should -Match '' - $content | Should -Match '