diff --git a/Dockerfile.ansible b/Dockerfile.ansible index aa33d97..5fac374 100644 --- a/Dockerfile.ansible +++ b/Dockerfile.ansible @@ -83,6 +83,9 @@ RUN ansible-galaxy collection install \ community.crypto \ microsoft.sql +# ── Install gomplate (template renderer) ───────────────── +RUN apk add --no-cache gomplate + # ── Purge build-only dependencies ───────────────────────── # apk del cascades to shared deps like util-linux (mount/umount). # Re-add it with network access (not --no-network here). diff --git a/README.md b/README.md index e328576..59ea3a7 100644 --- a/README.md +++ b/README.md @@ -57,10 +57,8 @@ playbooks/ └── test_hitl.yml # Human-in-the-Loop with ansible.builtin.pause reports/ -├── render.go # Go renderer (text/template + external .gohtml) ├── render_report.py # Python renderer (terminal + markdown output) -├── report.gohtml # Go template for terminal box-drawing report -├── go.mod # Go module definition +├── report.gohtml # Go template rendered by gomplate (terminal box-drawing report) └── sample-output.json # Example output for offline renderer tests inventory.ini # Ansible inventory — all platform groups defined run.sh # End-to-end wrapper: ansible → find JSON → render @@ -85,7 +83,7 @@ run.sh # End-to-end wrapper: ansible → find JSON ▼ run.sh / render manually: python3 reports/render_report.py reports/-.json - go run reports/render.go reports/-.json reports/report.gohtml + gomplate --context .=reports/-.json --file reports/report.gohtml ``` ### Target Integrations @@ -109,7 +107,7 @@ run.sh # End-to-end wrapper: ansible → find JSON - Ansible ≥ 2.9 with the collections listed above (pre-installed in the Docker image) - Python ≥ 3.6 (for the Python report renderer) -- Go ≥ 1.21 (optional, for the Go renderer) +- [gomplate](https://docs.gomplate.ca/installing/) (optional, single static binary, for the `.gohtml` template renderer) - For Windows / VMware / Cisco targets: the Python libraries listed above ### Step 1: Configure Inventory @@ -160,8 +158,8 @@ python3 reports/render_report.py reports/localhost-2026-08-14.json # Markdown (for GitHub / GitLab wikis, PR comments): python3 reports/render_report.py reports/localhost-2026-08-14.json --format md -# Go template renderer: -cd reports && go run render.go ../reports/localhost-2026-08-14.json report.gohtml +# gomplate template renderer: +gomplate --context .=reports/localhost-2026-08-14.json --file reports/report.gohtml ``` --- @@ -580,29 +578,31 @@ python3 reports/render_report.py reports/hostname-2026-08-14.json --format md > Terminal output groups results by FR category with a failure-detail section. Markdown output produces GFM tables plus per-failure sections with remediation. -### Go Renderer (`reports/render.go`) +### gomplate Renderer (`reports/report.gohtml`) -Requires Go >= 1.21. Uses `text/template` with an external `.gohtml` file: +Requires [gomplate](https://docs.gomplate.ca/installing/) (a single static +binary — no Go toolchain, no compilation). Renders a `.gohtml` file against +the JSON report loaded as the template's root context: ```bash cd reports -go run render.go ../reports/hostname-2026-08-14.json report.gohtml +gomplate --context .=../reports/hostname-2026-08-14.json --file report.gohtml ``` -The template file is standalone — customise it without recompiling. Registered -template functions: +The template file is standalone — customise it without recompiling anything. +Fields are accessed with plain dot notation (e.g. `.meta.target`), and the +template only relies on gomplate's built-in functions: | Function | Purpose | |---|---| -| `passIcon` | Maps `passed` value to ✅ PASS / ❌ FAIL / ❓ MANUAL | -| `severityIcon` | Maps severity to 🔴/🟠/🟡/🟢 | -| `title` | Capitalises first letter of each word | -| `divf` | Float division for compliance rate percentage | -| `add`, `sub` | Integer arithmetic | +| `passIcon` (in-template) | Maps `passed` value to ✅ PASS / ❌ FAIL / ❓ MANUAL | +| `severityIcon` (in-template) | Maps severity to 🔴/🟠/🟡/🟢 | +| `strings.Title` | Capitalises first letter of each word | +| `math.Div`, `math.Mul` | Compliance rate percentage | Custom templates: ```bash -go run reports/render.go reports/*.json my-custom.gohtml +gomplate --context .=reports/-.json --file my-custom.gohtml ``` --- @@ -702,9 +702,7 @@ QEMU VM boots in ~6 seconds | `playbooks/templates/test_service_check.yml` | Copy-and-fill template: `service_facts` | | `playbooks/templates/test_hitl.yml` | Copy-and-fill template: `pause` + `delegate_to: localhost` | | `reports/render_report.py` | Python renderer: terminal box-drawing and Markdown output | -| `reports/render.go` | Go renderer: `text/template` on external `.gohtml` | -| `reports/report.gohtml` | Go template producing the terminal box-drawing report | -| `reports/go.mod` | Go module (requires `golang.org/x/text`) | +| `reports/report.gohtml` | Go template producing the terminal box-drawing report, rendered by `gomplate` | | `reports/sample-output.json` | Hand-crafted example report for offline renderer testing | | `inventory.ini` | Ansible inventory with all platform groups and connection vars | | `run.sh` | End-to-end wrapper: run ansible → find latest JSON → render | diff --git a/reports/go.mod b/reports/go.mod deleted file mode 100644 index f1ed2d3..0000000 --- a/reports/go.mod +++ /dev/null @@ -1,5 +0,0 @@ -module github.com/example/ansible-testing - -go 1.21 - -require golang.org/x/text v0.14.0 diff --git a/reports/render.go b/reports/render.go deleted file mode 100644 index c501a60..0000000 --- a/reports/render.go +++ /dev/null @@ -1,101 +0,0 @@ -// render.go — Go-based IEC 62443-3-3 SL2 compliance report renderer. -// -// Loads a JSON report produced by ansible-test and renders it through -// an external Go template file (report.gohtml by default). -// -// Usage: -// go run render.go [template.gohtml] -// -// The template receives the full JSON document as an untyped map. -// Registered template functions: -// passIcon — maps passed value to ✅/❌/🔍/❓ -// severityIcon — maps severity string to 🔴/🟠/🟡/🟢/⚪ -// title — capitalizes first letter of each word -// divf — float64 division (a/b*100) for percentages -// add, sub — integer arithmetic -// -// Dependencies: -// golang.org/x/text v0.14.0 (for cases.Title) - -package main - -import ( - "encoding/json" - "fmt" - "os" - "text/template" - - "golang.org/x/text/cases" - "golang.org/x/text/language" -) - -func main() { - if len(os.Args) < 2 { - fmt.Fprintf(os.Stderr, "Usage: gomplate-report [template.gohtml]\n") - os.Exit(1) - } - - jsonPath := os.Args[1] - tmplPath := "reports/report.gohtml" - if len(os.Args) >= 3 { - tmplPath = os.Args[2] - } - - data, err := os.ReadFile(jsonPath) - if err != nil { - fmt.Fprintf(os.Stderr, "Error reading JSON: %v\n", err) - os.Exit(1) - } - - var report map[string]any - if err := json.Unmarshal(data, &report); err != nil { - fmt.Fprintf(os.Stderr, "Error parsing JSON: %v\n", err) - os.Exit(1) - } - - funcMap := template.FuncMap{ - "passIcon": func(v any) string { - b, ok := v.(bool) - if !ok { - return "❓ MANUAL" - } - if b { - return "✅ PASS" - } - return "❌ FAIL" - }, - "severityIcon": func(s string) string { - switch s { - case "critical": - return "🔴" - case "high": - return "🟠" - case "medium": - return "🟡" - case "low": - return "🟢" - } - return "⚪" - }, - "title": cases.Title(language.English).String, - "divf": func(a, b int) float64 { - if b == 0 { - return 0 - } - return float64(a) / float64(b) * 100.0 - }, - "add": func(a, b int) int { return a + b }, - "sub": func(a, b int) int { return a - b }, - } - - tmpl, err := template.New("report.gohtml").Funcs(funcMap).ParseFiles(tmplPath) - if err != nil { - fmt.Fprintf(os.Stderr, "Error parsing template: %v\n", err) - os.Exit(1) - } - - if err := tmpl.ExecuteTemplate(os.Stdout, "report.gohtml", report); err != nil { - fmt.Fprintf(os.Stderr, "Error rendering: %v\n", err) - os.Exit(1) - } -} diff --git a/reports/report.gohtml b/reports/report.gohtml index 499a55d..f93535a 100644 --- a/reports/report.gohtml +++ b/reports/report.gohtml @@ -1,59 +1,63 @@ {{- /* report.gohtml — IEC 62443-3-3 SL2 Compliance Report Template -An external Go template rendered by reports/render.go. Receives the full -JSON document as an untyped map[string]any. Access fields via (index . "key"). +Rendered directly by gomplate (https://gomplate.ca) — no custom Go binary +required. The JSON report is loaded as the root context, so fields are +accessed with plain dot notation (e.g. .meta.target). -Template functions (registered by render.go): - passIcon v → "✅ PASS" / "❌ FAIL" / "❓ MANUAL" - severityIcon s → 🔴/🟠/🟡/🟢/⚪ - title s → "Hello World" (capitalizes words) - divf a b → a / b * 100.0 (percentage) - add a b / sub a b → integer arithmetic +Only gomplate's built-in functions (math.*, strings.*) are used, plus two +in-line sub-templates (passIcon/severityIcon) defined below. -To customize: copy this file, modify, run: - go run reports/render.go reports/*.json my-custom.gohtml +Usage: + gomplate --context .=reports/-.json --file reports/report.gohtml + +To customize: copy this file, modify, and point --file at the copy. */ -}} +{{- define "passIcon" -}} +{{- if eq . true }}✅ PASS{{ else if eq . false }}❌ FAIL{{ else }}❓ MANUAL{{ end -}} +{{- end -}} +{{- define "severityIcon" -}} +{{- if eq . "critical" }}🔴{{ else if eq . "high" }}🟠{{ else if eq . "medium" }}🟡{{ else if eq . "low" }}🟢{{ else }}⚪{{ end -}} +{{- end -}} ╔══════════════════════════════════════════════════════════════════════════╗ ║ IEC 62443-3-3 SECURITY LEVEL 2 — COMPLIANCE REPORT ║ ╠══════════════════════════════════════════════════════════════════════════╣ -║ Target: {{ printf "%-56s" (index .meta "target") }}║ -║ Standard: {{ printf "%-56s" (index .meta "standard") }}║ -║ Level: {{ printf "%-56s" (index .meta "security_level") }}║ -║ Timestamp: {{ printf "%-56s" (index .meta "timestamp") }}║ -║ Executed by: {{ printf "%-55s" (index .meta "executed_by") }}║ +║ Target: {{ printf "%-56s" .meta.target }}║ +║ Standard: {{ printf "%-56s" .meta.standard }}║ +║ Level: {{ printf "%-56s" .meta.security_level }}║ +║ Timestamp: {{ printf "%-56s" .meta.timestamp }}║ +║ Executed by: {{ printf "%-55s" .meta.executed_by }}║ ╠══════════════════════════════════════════════════════════════════════════╣ ║ EXECUTIVE SUMMARY ║ ╠══════════════════════════════════════════════════════════════════════════╣ ║ ║ ║ TOTAL PASSED FAILED REVIEW COMPLIANCE ║ ║ ───── ────── ────── ────── ────────── ║ -║ {{ printf "%-8d" (index .summary "total") }} {{ printf "%-9d" (index .summary "passed") }} {{ printf "%-9d" (index .summary "failed") }} {{ printf "%-9d" (index .summary "skipped") }} {{ if gt (index .summary "total") 0 }}{{ printf "%.1f%%" (divf (index .summary "passed") (index .summary "total")) }}{{ else }}N/A{{ end }} +║ {{ printf "%-8d" .summary.total }} {{ printf "%-9d" .summary.passed }} {{ printf "%-9d" .summary.failed }} {{ printf "%-9d" .summary.skipped }} {{ if gt .summary.total 0 }}{{ printf "%.1f%%" (mul (div .summary.passed .summary.total) 100) }}{{ else }}N/A{{ end }} ║ ║ ╠══════════════════════════════════════════════════════════════════════════╣ ║ RESULTS BY REQUIREMENT ║ ╠══════════════════════════════════════════════════════════════════════════╣ ║ ║ -{{- $results := index . "results" }} -{{- range $i, $r := $results }} -║ {{ severityIcon (index $r "severity") }} [{{ index $r "test_id" }}] {{ passIcon (index $r "passed") }} {{ index $r "description" }} -║ Requirement: {{ index $r "requirement" }} -║ Expected: {{ index $r "expected" }} -║ Actual: {{ index $r "actual" }} -{{- if not (index $r "passed") }} -║ Fix: {{ index $r "remediation" }} +{{- range $i, $r := .results }} +║ {{ template "severityIcon" $r.severity }} [{{ $r.test_id }}] {{ template "passIcon" $r.passed }} {{ $r.description }} +║ Requirement: {{ $r.requirement }} +║ Expected: {{ $r.expected }} +║ Actual: {{ $r.actual }} +{{- if not $r.passed }} +║ Fix: {{ $r.remediation }} {{- end }} ║ ║ {{- end }} ╠══════════════════════════════════════════════════════════════════════════╣ ║ FAILURE DETAIL ║ ╠══════════════════════════════════════════════════════════════════════════╣ -{{- $failures := index . "failures" }} +{{- $failures := .failures }} {{- if $failures }} {{- range $i, $f := $failures }} -║ ❌ {{ index $f "test_id" }} — {{ index $f "description" }} -║ Severity: {{ index $f "severity" | title }} -║ Remediation: {{ index $f "remediation" }} +║ ❌ {{ $f.test_id }} — {{ $f.description }} +║ Severity: {{ $f.severity | strings.Title }} +║ Remediation: {{ $f.remediation }} ║ ║ {{- end }} {{- else }} diff --git a/run.sh b/run.sh index d25b7f7..5514acf 100644 --- a/run.sh +++ b/run.sh @@ -50,18 +50,17 @@ fi echo "" echo "[2/3] Latest report: $(basename "$LATEST_JSON")" -# ── Phase 3: Render with Go template ──────────────────────────── -echo "[3/3] Rendering report with Go template..." +# ── Phase 3: Render with gomplate ─────────────────────────────── +echo "[3/3] Rendering report with gomplate..." echo "" -cd "$REPORT_DIR" -if ! go run render.go "$LATEST_JSON" report.gohtml 2>/dev/null; then - # Fallback: if Go isn't available, just cat the JSON +if ! gomplate --context ".=$LATEST_JSON" --file "$REPORT_DIR/report.gohtml" 2>/dev/null; then + # Fallback: if gomplate isn't available, just cat the JSON echo "---" - echo "(Go not available; showing raw JSON summary)" + echo "(gomplate not available; showing raw JSON summary)" python3 -c " import json, sys -with open('$(basename "$LATEST_JSON")') as f: +with open('$LATEST_JSON') as f: r = json.load(f) s = r['summary'] print(f'Total: {s[\"total\"]} | Passed: {s[\"passed\"]} | Failed: {s[\"failed\"]} | Rate: {s[\"passed\"]/s[\"total\"]*100:.1f}%')