Files
weatherreporter/internal/app/app.go

772 lines
22 KiB
Go

// Package app owns application orchestration and top-level use cases.
package app
import (
"context"
"errors"
"fmt"
"path/filepath"
"time"
distributoradapter "gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/distributor"
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium"
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/weatherapi"
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/changes"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/state"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
type ReportKind string
const (
ReportDaily ReportKind = "daily"
ReportTomorrow ReportKind = "tomorrow"
ReportThreeDay ReportKind = "three-day"
ReportWeekend ReportKind = "weekend"
ReportStorm ReportKind = "storm"
)
type BatchKind string
const (
BatchMorning BatchKind = "morning"
BatchEvening BatchKind = "evening"
)
type GenerateRequest struct {
Config config.Config
Report ReportKind
OutputPath string
Now time.Time
Date time.Time
StormStart time.Time
StormEnd time.Time
Notifier Notifier
}
type BatchRequest struct {
Config config.Config
Batch BatchKind
Now time.Time
OutputDir string
Renderer Renderer
Store state.Store
Notifier Notifier
}
type FetchBundleRequest struct {
Config config.Config
OutputPath string
}
type BriefingRequest struct {
Config config.Config
Resolved report.Resolved
OutputPath string
}
type ReportRequest struct {
Config config.Config
Resolved report.Resolved
OutputPath string
Renderer Renderer
Store state.Store
Notifier Notifier
}
type BriefingResult struct {
Package briefing.Package
OutputPath string
}
type ReportResult struct {
Briefing briefing.Package
BriefingPath string
DataPackage promptinput.Package
DataPackagePath string
PreflightPath string
ReportPath string
OutputPath string
Metadata state.Metadata
MetadataPath string
PriorSnapshot *state.PriorSnapshot
RecentChanges []changes.Change
RenderResult *scriptorium.RenderResult
RunResult *scriptorium.RunResult
Notification *NotificationResult
}
type BatchResult struct {
Batch BatchKind `json:"batch"`
StartedAt time.Time `json:"startedAt"`
FinishedAt time.Time `json:"finishedAt"`
Total int `json:"total"`
Succeeded int `json:"succeeded"`
Failed int `json:"failed"`
Reports []BatchReportResult `json:"reports"`
}
type BatchReportResult struct {
ReportID report.ID `json:"reportId"`
ReportName string `json:"reportName"`
PromptID string `json:"promptId"`
RunID string `json:"runId"`
Status string `json:"status"`
Error string `json:"error,omitempty"`
NotificationStatus string `json:"notificationStatus,omitempty"`
NotificationRunID string `json:"notificationRunId,omitempty"`
NotificationError string `json:"notificationError,omitempty"`
GeneratedAt time.Time `json:"generatedAt"`
ValidPeriod timeutil.Period `json:"validPeriod"`
BriefingPath string `json:"briefingPath,omitempty"`
DataPackagePath string `json:"dataPackagePath,omitempty"`
PreflightPath string `json:"preflightPath,omitempty"`
ReportPath string `json:"reportPath,omitempty"`
OutputPath string `json:"outputPath,omitempty"`
MetadataPath string `json:"metadataPath,omitempty"`
}
type BatchError struct {
Result *BatchResult
}
func (e BatchError) Error() string {
if e.Result == nil {
return "batch failed"
}
return fmt.Sprintf("batch %s failed: %d of %d reports failed", e.Result.Batch, e.Result.Failed, e.Result.Total)
}
type Renderer interface {
Render(context.Context, scriptorium.RenderRequest) (*scriptorium.RenderResult, error)
Run(context.Context, scriptorium.RunRequest) (*scriptorium.RunResult, error)
}
type Notifier interface {
Notify(context.Context, NotificationRequest) (*NotificationResult, error)
}
type NotificationRequest struct {
ReportID report.ID
RunID string
BundleID string
IdempotencyKey string
ReportPath string
BundlePath string
}
type NotificationResult struct {
BundleID string
IdempotencyKey string
RunID string
Status string
}
type NotificationError struct {
Request NotificationRequest
Err error
}
func (e *NotificationError) Error() string {
if e == nil || e.Err == nil {
return "notification failed"
}
return e.Err.Error()
}
func (e *NotificationError) Unwrap() error {
if e == nil {
return nil
}
return e.Err
}
func Generate(ctx context.Context, req GenerateRequest) error {
now := req.Now
if now.IsZero() {
now = time.Now()
}
resolved, err := ResolveGenerate(req, now)
if err != nil {
return err
}
if resolved.Definition.Generated {
_, err := GenerateReport(ctx, ReportRequest{
Config: req.Config,
Resolved: resolved,
OutputPath: req.OutputPath,
Notifier: req.Notifier,
})
return err
}
return fmt.Errorf("generate is not implemented")
}
func RunBatch(ctx context.Context, req BatchRequest) error {
result, err := RunBatchDetailed(ctx, req)
if err != nil {
return err
}
if result.Failed > 0 {
return BatchError{Result: result}
}
return nil
}
func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, error) {
now := req.Now
if now.IsZero() {
now = time.Now()
}
resolvedReports, err := ResolveBatch(req, now)
if err != nil {
return nil, err
}
if req.Batch == BatchEvening || req.Batch == BatchMorning {
store := req.Store
if store == nil {
defaultStore, err := defaultStore(req.Config)
if err != nil {
return nil, err
}
store = defaultStore
}
startedAt := now
result := &BatchResult{Batch: req.Batch, StartedAt: startedAt}
for _, resolved := range resolvedReports {
if !resolved.Definition.Generated {
return nil, fmt.Errorf("run is not implemented")
}
}
for _, resolved := range resolvedReports {
item := batchReportResult(resolved)
if paths, err := store.Paths(resolved); err == nil {
item.BriefingPath = paths.Briefing
item.DataPackagePath = paths.DataPackage
item.PreflightPath = paths.Preflight
item.ReportPath = paths.RenderedReport
item.MetadataPath = paths.Metadata
}
outputPath := batchOutputPath(req.OutputDir, resolved.Definition)
reportResult, err := GenerateReport(ctx, ReportRequest{
Config: req.Config,
Resolved: resolved,
OutputPath: outputPath,
Renderer: req.Renderer,
Store: store,
Notifier: req.Notifier,
})
if err != nil {
item.Status = "failed"
item.Error = err.Error()
var notificationErr *NotificationError
if errors.As(err, &notificationErr) {
item.NotificationStatus = "failed"
item.NotificationError = notificationErr.Error()
}
result.Failed++
} else {
item.Status = "succeeded"
item.BriefingPath = reportResult.BriefingPath
item.DataPackagePath = reportResult.DataPackagePath
item.PreflightPath = reportResult.PreflightPath
item.ReportPath = reportResult.ReportPath
item.OutputPath = reportResult.OutputPath
item.MetadataPath = reportResult.MetadataPath
if reportResult.Notification != nil {
item.NotificationStatus = reportResult.Notification.Status
item.NotificationRunID = reportResult.Notification.RunID
}
result.Succeeded++
}
result.Reports = append(result.Reports, item)
}
result.Total = len(result.Reports)
result.FinishedAt = time.Now()
return result, nil
}
return nil, fmt.Errorf("run is not implemented")
}
func batchReportResult(resolved report.Resolved) BatchReportResult {
metadata := resolved.Metadata()
return BatchReportResult{
ReportID: resolved.Definition.ID,
ReportName: resolved.Definition.Name,
PromptID: resolved.Definition.PromptID,
RunID: metadata.RunID,
GeneratedAt: metadata.GeneratedAt,
ValidPeriod: metadata.ValidPeriod,
}
}
func batchOutputPath(outputDir string, definition report.Definition) string {
if outputDir == "" || definition.BatchOutputName == "" {
return ""
}
return filepath.Join(outputDir, definition.BatchOutputName)
}
func ResolveGenerate(req GenerateRequest, now time.Time) (report.Resolved, error) {
location, err := timeutil.LoadLocation(req.Config.WeatherAPI.Timezone)
if err != nil {
return report.Resolved{}, err
}
id, err := reportIDForCommand(req.Report)
if err != nil {
return report.Resolved{}, err
}
return report.DefaultRegistry().Resolve(id, report.ResolveRequest{
Now: now,
Location: location,
Date: req.Date,
StormStart: req.StormStart,
StormEnd: req.StormEnd,
})
}
func ResolveBatch(req BatchRequest, now time.Time) ([]report.Resolved, error) {
location, err := timeutil.LoadLocation(req.Config.WeatherAPI.Timezone)
if err != nil {
return nil, err
}
batch, err := reportBatchForCommand(req.Batch)
if err != nil {
return nil, err
}
return report.DefaultRegistry().BatchReports(batch, report.ResolveRequest{
Now: now,
Location: location,
})
}
func reportIDForCommand(kind ReportKind) (report.ID, error) {
switch kind {
case ReportDaily:
return report.DailyToday, nil
case ReportTomorrow:
return report.DailyTomorrow, nil
case ReportThreeDay:
return report.ThreeDay, nil
case ReportWeekend:
return report.Weekend, nil
case ReportStorm:
return report.Storm, nil
default:
return "", fmt.Errorf("unknown report command %q", kind)
}
}
func reportBatchForCommand(kind BatchKind) (report.Batch, error) {
switch kind {
case BatchMorning:
return report.Morning, nil
case BatchEvening:
return report.Evening, nil
default:
return "", fmt.Errorf("unknown batch command %q", kind)
}
}
func FetchBundle(ctx context.Context, req FetchBundleRequest) (*forecast.Bundle, error) {
client, err := weatherapi.New(req.Config)
if err != nil {
return nil, err
}
bundle, err := client.FetchBundle(ctx)
if err != nil {
return nil, err
}
return bundle, nil
}
func FetchAndSaveBundle(ctx context.Context, req FetchBundleRequest) (*forecast.Bundle, error) {
if req.OutputPath == "" {
return nil, fmt.Errorf("output path is required")
}
bundle, err := FetchBundle(ctx, req)
if err != nil {
return nil, err
}
if err := weatherapi.SaveBundle(req.OutputPath, bundle); err != nil {
return nil, err
}
return bundle, nil
}
func GenerateBriefing(ctx context.Context, req BriefingRequest) (*BriefingResult, error) {
bundle, err := FetchBundle(ctx, FetchBundleRequest{Config: req.Config})
if err != nil {
return nil, err
}
pkg, err := BuildBriefing(req, bundle)
if err != nil {
return nil, err
}
outputPath := req.OutputPath
if outputPath == "" {
store, err := defaultStore(req.Config)
if err != nil {
return nil, err
}
paths, err := store.Paths(req.Resolved)
if err != nil {
return nil, err
}
outputPath = paths.Briefing
}
if err := briefing.Save(outputPath, pkg); err != nil {
return nil, err
}
return &BriefingResult{Package: pkg, OutputPath: outputPath}, nil
}
func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, error) {
store := req.Store
if store == nil {
defaultStore, err := defaultStore(req.Config)
if err != nil {
return nil, err
}
store = defaultStore
}
paths, err := store.Paths(req.Resolved)
if err != nil {
return nil, err
}
priorSnapshot, err := store.FindPriorSnapshot(ctx, req.Resolved)
if err != nil {
return nil, err
}
bundle, err := FetchBundle(ctx, FetchBundleRequest{Config: req.Config})
if err != nil {
return nil, err
}
briefingPackage, err := BuildBriefing(BriefingRequest{
Config: req.Config,
Resolved: req.Resolved,
}, bundle)
if err != nil {
return nil, err
}
briefingPath, err := store.SaveBriefing(ctx, req.Resolved, briefingPackage)
if err != nil {
return nil, err
}
recentChanges, err := recentChanges(ctx, store, priorSnapshot, briefingPackage, req.Config.RecentChange)
if err != nil {
return nil, err
}
dataPackage, err := promptinput.BuildWithRecentChanges(briefingPackage, recentChanges)
if err != nil {
return nil, err
}
dataPackagePath, err := store.SaveDataPackage(ctx, req.Resolved, dataPackage)
if err != nil {
return nil, err
}
renderer := req.Renderer
if renderer == nil {
renderer = scriptorium.Runner{
Binary: req.Config.Scriptorium.Binary,
ConfigPath: req.Config.Scriptorium.ConfigPath,
Profile: req.Config.Scriptorium.Profile,
Timeout: req.Config.Scriptorium.Timeout,
ExtraArgs: req.Config.Scriptorium.ExtraArgs,
}
}
renderResult, renderErr := renderer.Render(ctx, scriptorium.RenderRequest{
PromptID: req.Resolved.Definition.PromptID,
DataPackagePath: dataPackagePath,
})
preflightPath := paths.Preflight
if renderResult != nil {
var err error
preflightPath, err = store.SavePreflight(ctx, req.Resolved, preflightArtifact(renderResult))
if err != nil {
return nil, err
}
}
metadata := state.BuildMetadata(req.Resolved, briefingPackage, state.ArtifactPaths{
Briefing: briefingPath,
Metadata: paths.Metadata,
DataPackage: dataPackagePath,
Preflight: preflightPath,
RenderedReport: paths.RenderedReport,
})
metadataPath, metadataErr := store.SaveMetadata(ctx, metadata)
if metadataErr != nil {
return nil, metadataErr
}
if renderErr != nil {
return nil, renderErr
}
reportPath, err := store.PrepareRenderedReport(ctx, req.Resolved)
if err != nil {
return nil, err
}
runResult, runErr := renderer.Run(ctx, scriptorium.RunRequest{
PromptID: req.Resolved.Definition.PromptID,
DataPackagePath: dataPackagePath,
OutputPath: reportPath,
})
if runErr == nil && req.OutputPath != "" && req.OutputPath != reportPath {
if err := fileutil.CopyFileAtomic(reportPath, req.OutputPath); err != nil {
return nil, err
}
}
outputPath := reportPath
if req.OutputPath != "" {
outputPath = req.OutputPath
}
metadata.RenderedReportPath = reportPath
metadataPath, metadataErr = store.SaveMetadata(ctx, metadata)
if metadataErr != nil {
return nil, metadataErr
}
if runErr != nil {
return nil, runErr
}
notification, err := notifyReport(ctx, req.Config, req.Resolved, reportPath, metadata, req.Notifier)
if err != nil {
return nil, err
}
return &ReportResult{
Briefing: briefingPackage,
BriefingPath: briefingPath,
DataPackage: dataPackage,
DataPackagePath: dataPackagePath,
PreflightPath: preflightPath,
ReportPath: reportPath,
OutputPath: outputPath,
Metadata: metadata,
MetadataPath: metadataPath,
PriorSnapshot: priorSnapshot,
RecentChanges: recentChanges,
RenderResult: renderResult,
RunResult: runResult,
Notification: notification,
}, nil
}
func notifyReport(ctx context.Context, cfg config.Config, resolved report.Resolved, reportPath string, metadata state.Metadata, notifier Notifier) (*NotificationResult, error) {
notifier, enabled := reportNotifier(cfg, notifier)
if !enabled {
return nil, nil
}
notificationRequest, err := buildNotificationRequest(cfg, resolved, reportPath, metadata)
if err != nil {
return nil, err
}
result, err := notifier.Notify(ctx, notificationRequest)
if err != nil {
return nil, &NotificationError{
Request: notificationRequest,
Err: fmt.Errorf("notify report %q run %q from managed report %q: %w", resolved.Definition.ID, metadata.RunID, reportPath, err),
}
}
return result, nil
}
func reportNotifier(cfg config.Config, notifier Notifier) (Notifier, bool) {
if !cfg.Notify.Distributor.Enabled {
return noopNotifier{}, false
}
if notifier != nil {
return notifier, true
}
return distributorNotifier{
client: distributoradapter.New(cfg.Notify.Distributor),
}, true
}
func buildNotificationRequest(cfg config.Config, resolved report.Resolved, reportPath string, metadata state.Metadata) (NotificationRequest, error) {
values := config.DistributorTemplateValues{
LocationID: cfg.Location.ID,
ReportID: string(resolved.Definition.ID),
RunID: metadata.RunID,
ArtifactGroup: resolved.Definition.ArtifactGroup,
BatchOutputName: resolved.Definition.BatchOutputName,
}
bundleID, err := config.RenderDistributorBundleID(cfg.Notify.Distributor.BundleIDTemplate, values)
if err != nil {
return NotificationRequest{}, err
}
values.BundleID = bundleID
idempotencyKey, err := config.RenderDistributorIdempotencyKey(cfg.Notify.Distributor.IdempotencyKeyTemplate, values)
if err != nil {
return NotificationRequest{}, err
}
bundlePath, err := config.RenderDistributorReportPath(cfg.Notify.Distributor.ReportPathTemplate, values)
if err != nil {
return NotificationRequest{}, err
}
return NotificationRequest{
ReportID: resolved.Definition.ID,
RunID: metadata.RunID,
BundleID: bundleID,
IdempotencyKey: idempotencyKey,
ReportPath: reportPath,
BundlePath: bundlePath,
}, nil
}
type noopNotifier struct{}
func (noopNotifier) Notify(context.Context, NotificationRequest) (*NotificationResult, error) {
return nil, nil
}
type distributorNotifier struct {
client *distributoradapter.Client
}
func (n distributorNotifier) Notify(ctx context.Context, req NotificationRequest) (*NotificationResult, error) {
result, err := n.client.Upload(ctx, distributoradapter.UploadRequest{
BundleID: req.BundleID,
IdempotencyKey: req.IdempotencyKey,
SourcePath: req.ReportPath,
BundlePath: req.BundlePath,
})
if err != nil {
return nil, err
}
return &NotificationResult{
BundleID: req.BundleID,
IdempotencyKey: req.IdempotencyKey,
RunID: result.RunID,
Status: result.Status,
}, nil
}
func BuildBriefing(req BriefingRequest, bundle *forecast.Bundle) (briefing.Package, error) {
location, err := timeutil.LoadLocation(req.Config.WeatherAPI.Timezone)
if err != nil {
return briefing.Package{}, err
}
dayparts := make([]forecast.DaypartDefinition, 0, len(req.Config.Dayparts))
for _, daypart := range req.Config.Dayparts {
dayparts = append(dayparts, forecast.DaypartDefinition{
Name: daypart.Name,
Start: daypart.Start,
End: daypart.End,
})
}
switch req.Resolved.Definition.ID {
case report.DailyToday, report.DailyTomorrow:
summary, err := forecast.BuildDailySummary(bundle, req.Resolved.ValidPeriod.Start, location, dayparts)
if err != nil {
return briefing.Package{}, err
}
return briefing.BuildDaily(briefing.BuildContext{
Resolved: req.Resolved,
Bundle: bundle,
Units: req.Config.WeatherAPI.Units,
Timezone: req.Config.WeatherAPI.Timezone,
Location: briefingLocation(req.Config),
}, summary)
case report.ThreeDay, report.Weekend:
summaries, err := forecast.BuildPeriodDailySummaries(bundle, req.Resolved.ValidPeriod, location, dayparts)
if err != nil {
return briefing.Package{}, err
}
if req.Resolved.Definition.ID == report.Weekend {
return briefing.BuildWeekend(briefing.BuildContext{
Resolved: req.Resolved,
Bundle: bundle,
Units: req.Config.WeatherAPI.Units,
Timezone: req.Config.WeatherAPI.Timezone,
Location: briefingLocation(req.Config),
}, summaries)
}
return briefing.BuildThreeDay(briefing.BuildContext{
Resolved: req.Resolved,
Bundle: bundle,
Units: req.Config.WeatherAPI.Units,
Timezone: req.Config.WeatherAPI.Timezone,
Location: briefingLocation(req.Config),
}, summaries)
case report.Storm:
return briefing.BuildStorm(briefing.BuildContext{
Resolved: req.Resolved,
Bundle: bundle,
Units: req.Config.WeatherAPI.Units,
Timezone: req.Config.WeatherAPI.Timezone,
Location: briefingLocation(req.Config),
})
default:
return briefing.Package{}, fmt.Errorf("briefing is not implemented for report %q", req.Resolved.Definition.ID)
}
}
func briefingLocation(cfg config.Config) *briefing.LocationContext {
location := briefing.LocationContext{
ID: cfg.Location.ID,
Name: cfg.Location.Name,
Region: cfg.Location.Region,
Timezone: cfg.WeatherAPI.Timezone,
}
if location.ID == "" && location.Name == "" && location.Region == "" && location.Timezone == "" {
return nil
}
return &location
}
func defaultStore(cfg config.Config) (*state.FilesystemStore, error) {
return state.NewFilesystemStore(cfg.Workspace)
}
func recentChanges(ctx context.Context, store state.Store, priorSnapshot *state.PriorSnapshot, current briefing.Package, cfg config.RecentChangeConfig) ([]changes.Change, error) {
if priorSnapshot == nil {
return nil, nil
}
previous, err := store.LoadBriefing(ctx, priorSnapshot.BriefingPath)
if err != nil {
return nil, err
}
thresholds := changes.Thresholds{
TemperatureDegrees: cfg.TemperatureDegrees,
PrecipProbabilityPoints: cfg.PrecipProbabilityPoints,
WindGustMilesPerHour: cfg.WindGustMilesPerHour,
PrecipTimingShiftMinutes: cfg.PrecipTimingShiftMinutes,
}
switch current.Metadata.ReportID {
case report.DailyToday, report.DailyTomorrow:
return changes.CompareDaily(previous, current, thresholds)
case report.ThreeDay:
return changes.CompareThreeDay(previous, current, thresholds)
case report.Weekend:
return changes.CompareWeekend(previous, current, thresholds)
default:
return nil, nil
}
}
func preflightArtifact(result *scriptorium.RenderResult) state.PreflightArtifact {
if result == nil {
return state.PreflightArtifact{}
}
return state.PreflightArtifact{
Command: append([]string(nil), result.Command...),
Stdout: result.Stdout,
Stderr: result.Stderr,
StdoutTruncated: result.StdoutTruncated,
StderrTruncated: result.StderrTruncated,
ExitCode: result.ExitCode,
}
}