xetup/internal/report/report.go
X9 Dev eabf207e3f
All checks were successful
release / build-and-release (push) Successful in 43s
feat(report): file failed runs as Forgejo issues + attach zipped log to email
On any ERROR step, xetup now files an issue in the private x9/xetup-runs
tracker (title "FAILED <host> - <version> - <date>", label "failed", body =
step table + tail-capped Deploy.log) via the xetup-bot write:issue token,
baked in through -ldflags. Best-effort and non-blocking; successful runs stay
silent and the email is the fallback.

The deployment email now attaches the zipped Deploy.log and links to this
run's issue plus the run history filtered to this machine.

- internal/buildinfo: ldflags-injected Version + RunsToken
- internal/runreport: issue filing, retries, tail-capped log
- internal/report: multipart/mixed with zip attachment, tracker links;
  buildMessage split out and covered by report_test.go
- release.yml: inject Version (tag/SHA) + FORGEJO_RUNS_TOKEN via ldflags

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 16:30:08 +02:00

266 lines
8.7 KiB
Go

// Package report sends a deployment summary email via SMTP.
package report
import (
"archive/zip"
"bytes"
"encoding/base64"
"fmt"
"net/smtp"
"net/url"
"os"
"path/filepath"
"strings"
"time"
)
// SMTP2Go relay configuration for X9.cz deployment reports.
const (
smtpHost = "mail-eu.smtp2go.com"
smtpPort = "2525"
smtpUser = "xetup"
smtpPass = "M9ahxHOnJ8fM0CEF"
mailFrom = "xetup@x9.cz"
mailTo = "net@x9.cz"
)
// runsTracker is the Forgejo repo where failed runs are filed as issues.
// The email links here (filtered to this machine) so the technician can jump
// straight to the run history / bug tracker.
const runsTracker = "https://git.xetup.x9.cz/x9/xetup-runs/issues"
// localReportPath is where a local HTML copy of the report is always saved.
const localReportPath = `C:\X9\report.html`
// mimeBoundary separates the HTML body from the zipped-log attachment. Fixed
// (not random) because Math/rand is avoided project-wide and a single message
// never needs a unique one - the string just must not appear in the content.
const mimeBoundary = "xetup-mixed-8f3a2c1d0e7b46f9"
// StepResult holds one row of the deployment report.
type StepResult struct {
Num string
Name string
Status string // OK, ERROR, SKIPPED, CANCELLED
Elapsed time.Duration
}
// Send builds the deployment report, saves a local HTML copy to C:\X9\, and
// emails it via SMTP with retries. The full Deploy.log is attached as a zip
// (best-effort - omitted if unreadable). issueURL, when non-empty, is a link
// to this run's failure issue in the Forgejo tracker and is shown in the body.
// Returns the last SMTP error if all attempts fail (the local copy is always
// written regardless).
func Send(results []StepResult, issueURL, logPath string) error {
hostname, _ := os.Hostname()
now := time.Now().Format("2006-01-02 15:04")
subject := fmt.Sprintf("xetup report %s", hostname)
body := buildHTML(results, hostname, now, issueURL)
// Always save local copy so technician has a record even if SMTP fails
_ = os.MkdirAll(filepath.Dir(localReportPath), 0755)
if err := os.WriteFile(localReportPath, []byte(body), 0644); err != nil {
fmt.Fprintf(os.Stderr, "[WARN] Failed to save local report: %v\n", err)
}
// Zip the log for attachment (best-effort; nil attachment => plain email)
attachName, attachData := zipLog(logPath, hostname)
// Retry SMTP up to 3 times with exponential backoff (1s, 5s, 15s)
delays := []time.Duration{0, 1 * time.Second, 5 * time.Second}
var lastErr error
for attempt, delay := range delays {
if delay > 0 {
time.Sleep(delay)
}
if err := sendMail(subject, body, attachName, attachData); err != nil {
lastErr = err
fmt.Fprintf(os.Stderr, "[WARN] Email attempt %d/3 failed: %v\n", attempt+1, err)
continue
}
return nil
}
fmt.Fprintf(os.Stderr, "[ERROR] All email attempts failed. Local copy saved: %s\n", localReportPath)
return lastErr
}
// zipLog reads logPath and returns a zip archive of it (name, bytes). On any
// error it returns ("", nil) so the caller sends a plain email without failing.
func zipLog(logPath, hostname string) (string, []byte) {
data, err := os.ReadFile(logPath)
if err != nil {
fmt.Fprintf(os.Stderr, "[WARN] Cannot read log for attachment: %v\n", err)
return "", nil
}
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
w, err := zw.Create("Deploy.log")
if err == nil {
_, err = w.Write(data)
}
if err == nil {
err = zw.Close()
}
if err != nil {
fmt.Fprintf(os.Stderr, "[WARN] Cannot zip log for attachment: %v\n", err)
return "", nil
}
name := hostname + "-Deploy.log.zip"
return name, buf.Bytes()
}
func sendMail(subject, body, attachName string, attach []byte) error {
msg := buildMessage(subject, body, attachName, attach)
auth := smtp.PlainAuth("", smtpUser, smtpPass, smtpHost)
return smtp.SendMail(
smtpHost+":"+smtpPort,
auth,
mailFrom,
[]string{mailTo},
msg,
)
}
// buildMessage assembles the raw RFC 822 message: a plain text/html message
// when attach is nil, otherwise multipart/mixed with the zipped log attached.
func buildMessage(subject, body, attachName string, attach []byte) []byte {
var msg bytes.Buffer
fmt.Fprintf(&msg, "From: %s\r\n", mailFrom)
fmt.Fprintf(&msg, "To: %s\r\n", mailTo)
fmt.Fprintf(&msg, "Subject: %s\r\n", subject)
msg.WriteString("MIME-Version: 1.0\r\n")
if attach == nil {
// Simple single-part HTML message.
msg.WriteString("Content-Type: text/html; charset=UTF-8\r\n\r\n")
msg.WriteString(body)
} else {
// multipart/mixed: HTML body + zipped log attachment.
fmt.Fprintf(&msg, "Content-Type: multipart/mixed; boundary=%s\r\n\r\n", mimeBoundary)
fmt.Fprintf(&msg, "--%s\r\n", mimeBoundary)
msg.WriteString("Content-Type: text/html; charset=UTF-8\r\n\r\n")
msg.WriteString(body)
msg.WriteString("\r\n")
fmt.Fprintf(&msg, "--%s\r\n", mimeBoundary)
msg.WriteString("Content-Type: application/zip\r\n")
msg.WriteString("Content-Transfer-Encoding: base64\r\n")
fmt.Fprintf(&msg, "Content-Disposition: attachment; filename=\"%s\"\r\n\r\n", attachName)
writeBase64(&msg, attach)
msg.WriteString("\r\n")
fmt.Fprintf(&msg, "--%s--\r\n", mimeBoundary)
}
return msg.Bytes()
}
// writeBase64 writes data as base64 wrapped at 76 columns (RFC 2045).
func writeBase64(w *bytes.Buffer, data []byte) {
enc := base64.StdEncoding.EncodeToString(data)
for len(enc) > 76 {
w.WriteString(enc[:76])
w.WriteString("\r\n")
enc = enc[76:]
}
w.WriteString(enc)
w.WriteString("\r\n")
}
func buildHTML(results []StepResult, hostname, dateTime, issueURL string) string {
var ok, errs, skipped int
var rows strings.Builder
for _, r := range results {
color := "#333"
icon := ""
switch r.Status {
case "OK":
ok++
color = "#2e7d32"
icon = "&#10003;"
case "ERROR":
errs++
color = "#c62828"
icon = "&#10007;"
default:
skipped++
color = "#9e9e9e"
icon = "&#8211;"
}
elapsed := ""
if r.Elapsed > 0 {
elapsed = r.Elapsed.Round(time.Second).String()
}
fmt.Fprintf(&rows,
`<tr><td style="padding:4px 8px;color:%s;font-size:16px;text-align:center">%s</td>`+
`<td style="padding:4px 8px;color:#666">%s</td>`+
`<td style="padding:4px 8px">%s</td>`+
`<td style="padding:4px 8px;color:%s;font-weight:bold">%s</td>`+
`<td style="padding:4px 8px;color:#999;text-align:right">%s</td></tr>`,
color, icon, r.Num, r.Name, color, r.Status, elapsed)
}
summaryColor := "#2e7d32"
summaryText := "Deployment OK"
if errs > 0 {
summaryColor = "#c62828"
summaryText = fmt.Sprintf("Deployment finished with %d error(s)", errs)
}
return fmt.Sprintf(`<!DOCTYPE html>
<html><head><meta charset="UTF-8"></head>
<body style="font-family:Segoe UI,Arial,sans-serif;margin:0;padding:20px;background:#f5f5f5">
<div style="max-width:640px;margin:0 auto;background:#fff;border-radius:8px;overflow:hidden;box-shadow:0 2px 8px rgba(0,0,0,0.1)">
<div style="background:#223B47;padding:20px 24px;color:#fff">
<h1 style="margin:0;font-size:20px">xetup report</h1>
<p style="margin:6px 0 0;opacity:0.8">%s &mdash; %s</p>
</div>
<table style="width:100%%;border-collapse:collapse;margin:16px 0">
<tr style="background:#f9f9f9">
<th style="padding:6px 8px;text-align:center;width:30px"></th>
<th style="padding:6px 8px;text-align:left;width:40px">Krok</th>
<th style="padding:6px 8px;text-align:left">Nazev</th>
<th style="padding:6px 8px;text-align:left;width:70px">Status</th>
<th style="padding:6px 8px;text-align:right;width:60px">Cas</th>
</tr>
%s
</table>
<div style="padding:16px 24px;background:%s;color:#fff;text-align:center;font-weight:bold">
%s &mdash; OK: %d &nbsp; CHYBY: %d &nbsp; PRESKOCENO: %d
</div>
%s
</div>
<p style="text-align:center;color:#999;font-size:12px;margin-top:16px">
Odeslano z xetup.exe &mdash; log v priloze (%s-Deploy.log.zip) i lokalne: C:\Windows\Setup\Scripts\Deploy.log
</p>
</body></html>`,
hostname, dateTime,
rows.String(),
summaryColor, summaryText, ok, errs, skipped,
trackerLinks(hostname, issueURL),
hostname)
}
// trackerLinks renders the tracker links block: a direct link to this run's
// failure issue (when present) plus a link filtered to this machine's runs.
func trackerLinks(hostname, issueURL string) string {
filter := fmt.Sprintf("%s?q=%s&type=all&state=all", runsTracker, url.QueryEscape(hostname))
var b strings.Builder
b.WriteString(`<div style="padding:12px 24px;font-size:13px;color:#555;border-top:1px solid #eee">`)
if issueURL != "" {
fmt.Fprintf(&b,
`<p style="margin:0 0 6px"><b>Chyby tohoto behu:</b> `+
`<a href="%s" style="color:#c62828">%s</a></p>`,
issueURL, issueURL)
}
fmt.Fprintf(&b,
`<p style="margin:0">Vsechny behy tohoto stroje v trackeru: `+
`<a href="%s" style="color:#223B47">%s</a></p>`,
filter, filter)
b.WriteString(`</div>`)
return b.String()
}