Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .cursor/rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,4 @@ Content guidelines:
- Avoid describing details that are best seen in the diff

Footer:
- Include `Assisted-by: Cursor/{model-name}` footer
- Include `Assisted-by: Cursor/{product-name}` footer (e.g., "Claude Opus 4.5", not API model ID)
5 changes: 5 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Force LF line endings for templates and test golden files to ensure
# consistent behavior across platforms (Windows CI was failing due to
# CRLF conversion).
*.tmpl text eol=lf
*.html text eol=lf
223 changes: 223 additions & 0 deletions docs/html.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
# HTML Report Generation

This document describes the design for generating HTML reports from command
output.

## Overview

Each command (`validate-application`, `validate-clusters`, `test-run`,
`gather-application`) generates a YAML report. We want to also generate HTML
reports that are human-readable and shareable.

## Requirements

1. Each command has its own report structure with specific data
2. HTML reports share common styling and layout
3. External tools can unmarshal YAML reports without custom parsing
4. Templates should be standard Go templates for maintainability
5. HTML reports are self-contained (embedded CSS)

## Report Types

Each command has its own report type with clear structure:

| Command | Report Type | Specific Data |
|---------|-------------|---------------|
| validate-application | `application.Report` | Application, ApplicationStatus |
| validate-clusters | `clusters.Report` | ClustersStatus |
| test-run | `test.Report` | Config, TestResults |
| gather-application | `gather.Report` | Application, GatherResults |

All reports embed `report.Report` (for validate/gather) or `report.Base` (for
test) which contains common fields.

Report-specific fields use value types (not pointers) since they are always
present:

```go
// validate/application/report.go
type Report struct {
*report.Report
Application report.Application `json:"application"`
ApplicationStatus report.ApplicationStatus `json:"applicationStatus"`
}
```

The `Name` field identifies the report type for unmarshaling (similar to
Kubernetes `kind`). We may rename it to `Kind` in the future.

## YAML Unmarshaling

External tools can unmarshal reports by:

1. Using the specific report type directly if known
2. Using a helper function that detects the type from the `Name` field

## HTML Template Structure

### File Organization

Templates use `.tmpl` extension for proper IDE support (Go template syntax
highlighting).

```
pkg/report/
templates/
report.tmpl # Main report structure (shared)
style.tmpl # Embedded CSS
html.go # Template(), custom functions, HeaderData

pkg/validate/application/
report.go # Report type
templates/
content.tmpl # Defines "content" template
html.go # templateData, HeaderData, WriteHTML
```

### Main Report Template

The shared `report.tmpl` defines the complete HTML structure. Each command
only needs to define its `content` template:

```html
<!DOCTYPE html>
<html>
<head>
<title>{{.HeaderData.Title}}</title>
<style>
{{ includeCSS "style" . | indent 8 }}
</style>
</head>
<body>
<header>
<h1>{{.HeaderData.Title}}</h1>
</header>
<main>
<section>
{{ includeHTML "content" . | indent 8 }}
</section>
<section>
<h2>Report Details</h2>
<p>Common information for all reports.</p>
</section>
</main>
</body>
</html>
```

### Command-Specific Content

Each command defines only its unique content:

```html
{{define "content" -}}
<h2>Application Status</h2>
<p>Hub, primary cluster, and secondary cluster validation details here.</p>
{{- end}}
```

### Including Templates with Proper Indentation

Go's built-in `{{template}}` doesn't support indentation - included content
appears at column 0. We provide custom functions for WYSIWYG indentation:

- `includeHTML "name" data` - executes a template, returns `template.HTML`
- `includeCSS "name" data` - executes a template, returns `template.CSS`
- `indent N` - adds N spaces after each newline (preserves type)

The indent value matches the visual column position in the template source:

```html
<section>
{{ includeHTML "content" . | indent 4 }}
</section>
```

### Template Data

Each report uses a `templateData` wrapper that provides `HeaderData()`:

```go
// validate/application/html.go

type templateData struct {
*Report
}

func (d *templateData) HeaderData() report.HeaderData {
return report.HeaderData{
Title: "Application Validation Report",
Subtitle: fmt.Sprintf("%s / %s", d.Application.Name, d.Application.Namespace),
}
}
```

### The shared report template

The report package provides the `Template()` function loading the common
`report.tmpl` template and functions such as `includeHTML` and `indent`.

```go
//go:embed templates/*.tmpl
var templates embed.FS

func Template() (*template.Template, error) {
var tmpl *template.Template

funcs := template.FuncMap{
"includeHTML": func(name string, data any) (template.HTML, error) {
return includeHTML(tmpl, name, data)
},
"includeCSS": func(name string, data any) (template.CSS, error) {
return includeCSS(tmpl, name, data)
},
"indent": indentEscaped,
}

var err error
tmpl, err = template.New("").Funcs(funcs).ParseFS(templates, "templates/*.tmpl")

return tmpl, err
}
```

### Command specific template

Command package provide `Template()` function loading the report template and
adding the command specific templates.

```go
//go:embed templates/*.tmpl
var templates embed.FS

func Template() (*template.Template, error) {
tmpl, err := report.Template()
if err != nil {
return nil, err
}
return tmpl.ParseFS(templates, "templates/*.tmpl")
}
```

### Command HTML writing

Command report provides the `WriteHTML()` function using the command template
to write report HTML.

```go
func (r *Report) WriteHTML(w io.Writer) error {
tmpl, err := Template()
if err != nil {
return err
}
return tmpl.ExecuteTemplate(w, "report.tmpl", &templateData{r})
}
```

## Adding a New Report

To add HTML support for a new command (e.g., `gather-application`):

1. Create `templates/content.tmpl` defining the `content` template
2. Create `html.go` with `templateData` type and `Template()` function
3. Add `WriteHTML()` method to the report type
114 changes: 114 additions & 0 deletions pkg/report/html.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
// SPDX-FileCopyrightText: The RamenDR authors
// SPDX-License-Identifier: Apache-2.0

package report

import (
"embed"
"fmt"
"html/template"
"strings"

"github.com/ramendr/ramenctl/pkg/time"
)

//go:embed templates/*.tmpl
var templates embed.FS

// Template returns a new template set with shared definitions.
func Template() (*template.Template, error) {
var tmpl *template.Template

funcs := template.FuncMap{
"formatTime": formatTime,
"includeHTML": func(name string, data any) (template.HTML, error) {
return includeHTML(tmpl, name, data)
},
"includeCSS": func(name string, data any) (template.CSS, error) {
return includeCSS(tmpl, name, data)
},
"indent": indentEscaped,
}

// Must assign to tmpl since the include closures capture it.
var err error
tmpl, err = template.New("").Funcs(funcs).ParseFS(templates, "templates/*.tmpl")

return tmpl, err
}

// formatTime formats a time value for display in reports.
func formatTime(t time.Time) string {
return t.Format("2006-01-02 15:04:05 MST")
}

// Template functions for including nested templates with proper indentation.
//
// The includeHTML and includeCSS functions execute a named template and return
// the result as a typed safe value. The indent function adds leading spaces to
// all lines except the first, allowing WYSIWYG template indentation:
//
// <div>
// {{ includeHTML "section" . | indent 4 }}
// </div>
//
// The template writer controls the first line's indentation; indent handles the
// rest. These functions must be used in the correct context:
// - includeHTML: only in HTML context (element content, attribute values)
// - includeCSS: only in CSS context (inside <style> tags)
//
// # Safety
//
// The functions return typed values (template.HTML, template.CSS) that
// html/template recognizes as safe for their respective contexts:
//
// - includeHTML in HTML context: Safe. The included template is executed by
// html/template which auto-escapes interpolated values during execution.
// The template.HTML wrapper prevents double-escaping by the outer template.
//
// - includeCSS in CSS context: Safe. The included template is executed by
// html/template which applies CSS-appropriate escaping during execution.
// The template.CSS wrapper prevents double-escaping by the outer template.
//
// - Wrong context (e.g., includeHTML in <style>): Blocked. html/template's
// context-aware escaping detects the type mismatch and produces "ZgotmplZ"
// as a safe placeholder, preventing potential injection.

// includeHTML executes a template and returns safe HTML. See safety discussion above.
func includeHTML(tmpl *template.Template, name string, data any) (template.HTML, error) {
var buf strings.Builder
if err := tmpl.ExecuteTemplate(&buf, name, data); err != nil {
return "", err
}
return template.HTML(buf.String()), nil // #nosec G203
}

// includeCSS executes a template and returns safe CSS. See safety discussion above.
func includeCSS(tmpl *template.Template, name string, data any) (template.CSS, error) {
var buf strings.Builder
if err := tmpl.ExecuteTemplate(&buf, name, data); err != nil {
return "", err
}
return template.CSS(buf.String()), nil // #nosec G203
}

// indentEscaped adds leading spaces to all lines except the first. It preserves
// the input type (template.HTML or template.CSS) without escaping. Must be
// called with output from includeHTML or includeCSS.
func indentEscaped(spaces int, s any) (any, error) {
indent := strings.Repeat(" ", spaces)
switch v := s.(type) {
case template.HTML:
return template.HTML(strings.ReplaceAll(string(v), "\n", "\n"+indent)), nil // #nosec G203
case template.CSS:
return template.CSS(strings.ReplaceAll(string(v), "\n", "\n"+indent)), nil // #nosec G203
default:
return nil, fmt.Errorf("indent: unsupported type %T", s)
}
}

// HeaderData provides data for the report template.
type HeaderData struct {
Title string // Report title, e.g. "Application Validation Report"
Subtitle string // Additional context, e.g. "myapp / mynamespace"
}
Loading