diff --git a/AGENTS.md b/AGENTS.md index 2c6e691..4f08a0e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,11 +4,12 @@ This repository is a Go service that syncs calendar status and exposes availabil ## Project Overview - Personal app for syncing calendar-driven status to GitHub. -- Status sync fans out to enabled targets rather than being tied to GitHub alone. -- Optional availability API reads a separate calendar, applies weekday `working_hours.start/end`, and returns the first free block per day for the next 10 days. +- Status sync fans out to configured targets rather than being tied to GitHub alone. +- Optional availability feature reads a separate calendar, applies weekday `suppressions.working_hours.start/end`, and returns the first free block per day for the next 10 days through its API. - Both features are polling-based and use Pebble for persistence. - Pebble stores status and snapshots so lookups stay O(1) for the hot path. -- The code is organized so status sync, availability sync, and HTTP serving are separate concerns. +- The config is organized by top-level `status` and `availability` domains, each with nested `sources` and `targets` where applicable. +- The code is organized so status fetch/publish, availability fetch/publish, and HTTP serving are separate concerns. ## Architecture - `main.go` loads config, opens Pebble, builds the HTTP mux, and starts the sync loops after startup validation. @@ -20,13 +21,14 @@ This repository is a Go service that syncs calendar status and exposes availabil - `internal/config` loads defaults, `config.yaml`, and environment variables. ## Sync Flow -- Status sync fetches the status calendar, stores events, computes the current active event, and syncs enabled targets every 5 minutes. -- Availability sync fetches the availability calendar and stores the raw ICS body plus timezone metadata in Pebble. -- If `availability.exclude_england_bank_holidays` is enabled, the app fetches GOV.UK bank holidays once at startup and stores the parsed holiday dates locally. +- Status sync is enabled only when `status.enabled` is true; it fetches `status.sources.ical.url`, stores events, computes the current active event, and syncs configured targets on `status.sources.ical.interval`. +- Availability sync is enabled only when `availability.enabled` is true; it fetches `availability.sources.ical.url` on `availability.sources.ical.interval`, stores the raw ICS body plus timezone metadata in Pebble, exposes the API, and starts availability targets. +- If a feature-level `enabled` flag is false, both fetching and publishing for that feature must stay disabled regardless of nested configuration. +- If `availability.suppressions.exclude_england_bank_holidays` is enabled, the app fetches GOV.UK bank holidays once at startup and stores the parsed holiday dates locally. - That startup seed is required for the availability endpoint when holiday exclusion is enabled, so startup should fail if the holiday feed cannot be read or parsed. -- Weekday availability uses `availability.working_hours.start/end` as a suppression window; if `availability.exclude_england_bank_holidays` is enabled, that suppression is lifted on England-and-Wales bank holidays from GOV.UK. -- `/api/availability` is only registered when `availability.is_enabled` is true. -- The availability handler requires an exact `Authorization` header match with `availability.api_key`. +- Weekday availability uses `availability.suppressions.working_hours.start/end` as a suppression window; if `availability.suppressions.exclude_england_bank_holidays` is enabled, that suppression is lifted on England-and-Wales bank holidays from GOV.UK. +- `/api/availability` is only registered when `availability.enabled` is true. +- The availability handler requires an exact `Authorization` header match with `availability.api.key`. ## Build, Test & Lint - `go build ./...` @@ -36,12 +38,13 @@ This repository is a Go service that syncs calendar status and exposes availabil ## Key Conventions - Keep status and availability calendars separate. -- Status calendar URL and availability calendar URL are separate config values. +- Status calendar URL and availability calendar URL are separate config values: `status.sources.ical.url` and `availability.sources.ical.url`. +- Status and availability fetch intervals are separate config values and default to `5m`. - Time blocks come from config and are checked in order. -- `availability.working_hours.start` defaults to `09:00` and `availability.working_hours.end` defaults to `17:50`; together they are treated as weekday working time, not as an availability block. +- `availability.suppressions.working_hours.start` defaults to `09:00` and `availability.suppressions.working_hours.end` defaults to `17:50`; together they are treated as weekday working time, not as an availability block. - Availability data is stored as the fetched raw ICS body plus metadata, not as live network state. - When enabled, bank holiday data is fetched from `https://www.gov.uk/bank-holidays.json` at startup and cached in Pebble. -- The availability route is disabled when the feature is not configured. +- The availability route is disabled when `availability.enabled` is false. - Empty env vars are treated as unset. - Pebble key design includes: - `status` for the current status record @@ -54,10 +57,12 @@ This repository is a Go service that syncs calendar status and exposes availabil - The holiday snapshot stores the raw GOV.UK JSON body, parsed dates, and fetch timestamp so availability can be computed offline. - Status is single-tenant. - Time zone handling should use the feed timezone when available, with UTC fallback. +- If `status.enabled` is true, `status.sources.ical.url` and at least one status target are required; otherwise availability can run without a status calendar. +- If `availability.enabled` is true, `availability.sources.ical.url`, availability blocks, `availability.api.key`, and `availability.targets.cloudflare_pages.deploy_hook` are required. ## Adding a New Status Target - Add a target under `internal/{platform}` implementing `target.Target`. -- Extend `TargetsConfig` and `envMapping` in `internal/config/config.go`. +- Extend `StatusTargetsConfig` and `envMapping` in `internal/config/config.go`. - Register the target in `buildTargets()` in `main.go`. - Add tests and update docs. @@ -67,13 +72,13 @@ This repository is a Go service that syncs calendar status and exposes availabil - Cancelled events are stored but do not count as active. - Availability computation checks today plus the next 9 days and returns the first free configured block per day. - On weekdays, blocks that overlap working hours are suppressed unless the day is a bank holiday and holiday exclusion is enabled. -- If the availability config is enabled but incomplete, startup should fail fast. +- If `availability.enabled` is true but availability config is incomplete, startup should fail fast. - The app is designed for a single user; multi-user support would require key design changes. ## Cloudflare Pages auto-deploy -- Feature: When enabled, the application triggers a Cloudflare Pages deployment at a regular interval. +- Feature: When `availability.enabled` is true, the application triggers a Cloudflare Pages deployment at a regular interval. - Change Tracking: To save build minutes, deployments are only triggered if the availability calendar has changed since the last successful deployment. This is managed by the `availability.Syncer`, which compares the current computed availability JSON with the `availability_last_deployed` JSON in Pebble. If a change is detected (including time-based changes), the new JSON is stored in `availability_dirty`, signaling the `Deployer` to trigger a build. -- Config keys: `build.is_enabled` (bool), `build.interval` (Go duration string, e.g., "10m"), `build.cf_deploy_hook` (Pages Build Hook URL). -- Scheduling: Deploys are scheduled to always fall offset by one minute after the hour. Example: with `build.interval = 10m` deploys occur at HH:01, HH:11, HH:21, ... This reduces the chance of syncing stale calendar events that commonly start at round minutes (e.g., HH:20, HH:30). -- Security: Do not commit `build.cf_deploy_hook` into source control; provide it via `config.yaml` or the `BUILD_CF_DEPLOY_HOOK` environment variable. +- Config keys: `availability.enabled` (bool), `availability.targets.cloudflare_pages.interval` (Go duration string, e.g., "10m"), `availability.targets.cloudflare_pages.deploy_hook` (Pages Build Hook URL). +- Scheduling: Deploys are scheduled to always fall offset by one minute after the hour. Example: with `availability.targets.cloudflare_pages.interval = 10m` deploys occur at HH:01, HH:11, HH:21, ... This reduces the chance of publishing stale calendar events that commonly start at round minutes (e.g., HH:20, HH:30). +- Security: Do not commit `availability.targets.cloudflare_pages.deploy_hook` into source control; provide it via `config.yaml` or the `AVAILABILITY_TARGETS_CLOUDFLARE_PAGES_DEPLOY_HOOK` environment variable. diff --git a/README.md b/README.md index 8d166ad..23a31f4 100644 --- a/README.md +++ b/README.md @@ -5,16 +5,33 @@ Personal app to sync my calendar status with GitHub and expose availability from ## Quickstart ```bash -export CALENDAR_URL="https://calendar.google.com/calendar/ical/...%40group.calendar.google.com/public/basic.ics" -export GITHUB_TOKEN="ghp_..." +export STATUS_ENABLED=true +export STATUS_SOURCES_ICAL_URL="https://calendar.google.com/calendar/ical/...%40group.calendar.google.com/public/basic.ics" +export STATUS_TARGETS_GITHUB_TOKEN="ghp_..." podman compose up ``` +Status sync starts only when `STATUS_ENABLED=true` and a status target is +configured. The status calendar fetch interval is configured with +`STATUS_SOURCES_ICAL_INTERVAL` / `status.sources.ical.interval`, defaulting to +`5m`. + ## Availability -Set `AVAILABILITY_IS_ENABLED=true` to expose `/api/availability` from a separate calendar feed. +Set `AVAILABILITY_ENABLED=true` to fetch availability, expose +`/api/availability`, and trigger Cloudflare Pages deploys when availability +changes. + +- `AVAILABILITY_SUPPRESSIONS_WORKING_HOURS_START` defaults to `09:00` and `AVAILABILITY_SUPPRESSIONS_WORKING_HOURS_END` defaults to `17:50`; weekday blocks that overlap that window are suppressed unless the day is a bank holiday. +- Set `AVAILABILITY_SUPPRESSIONS_EXCLUDE_ENGLAND_BANK_HOLIDAYS=true` to lift that weekday suppression on England-and-Wales bank holidays from GOV.UK. Holiday data is fetched at startup and cached in Pebble. +- `AVAILABILITY_SOURCES_ICAL_URL` controls the availability feed, and `AVAILABILITY_API_KEY` controls the exact `Authorization` header required by the endpoint. +- `AVAILABILITY_SOURCES_ICAL_INTERVAL` / `availability.sources.ical.interval` controls the availability calendar fetch interval, defaulting to `5m`. + +## Cloudflare Pages -- `AVAILABILITY_WORKING_HOURS_START` defaults to `09:00` and `AVAILABILITY_WORKING_HOURS_END` defaults to `17:50`; weekday blocks that overlap that window are suppressed unless the day is a bank holiday. -- Set `AVAILABILITY_EXCLUDE_ENGLAND_BANK_HOLIDAYS=true` to lift that weekday suppression on England-and-Wales bank holidays from GOV.UK. Holiday data is fetched at startup and cached in Pebble. -- `AVAILABILITY_CALENDAR_URL` and `AVAILABILITY_API_KEY` still control the availability feed and the exact `Authorization` header required by the endpoint. +When availability is enabled, Cloudflare Pages deploys are required and run when +computed availability changes. Configure the build hook with +`AVAILABILITY_TARGETS_CLOUDFLARE_PAGES_DEPLOY_HOOK`; the publish interval is +`AVAILABILITY_TARGETS_CLOUDFLARE_PAGES_INTERVAL` / +`availability.targets.cloudflare_pages.interval`, defaulting to `10m`. diff --git a/chart/Chart.yaml b/chart/Chart.yaml index 1877c92..0e4db45 100644 --- a/chart/Chart.yaml +++ b/chart/Chart.yaml @@ -4,5 +4,5 @@ description: A Helm chart for Kubernetes type: application -version: 0.2.7 -appVersion: "v0.2.7" +version: 0.2.8 +appVersion: "v0.2.8" diff --git a/chart/values.yaml b/chart/values.yaml index d7be4c8..a6706ab 100644 --- a/chart/values.yaml +++ b/chart/values.yaml @@ -23,19 +23,35 @@ fullnameOverride: "" env: [] # - name: EXAMPLE_ENV_VAR # value: "example-value" + # Status env vars supported by the app: + # - name: STATUS_ENABLED + # value: "true" + # - name: STATUS_SOURCES_ICAL_URL + # value: "https://example.com/status.ics" + # - name: STATUS_SOURCES_ICAL_INTERVAL + # value: "5m" + # - name: STATUS_TARGETS_GITHUB_TOKEN + # value: "ghp_xyz" # Availability env vars supported by the app: - # - name: AVAILABILITY_IS_ENABLED + # - name: AVAILABILITY_ENABLED # value: "true" - # - name: AVAILABILITY_CALENDAR_URL + # - name: AVAILABILITY_SOURCES_ICAL_URL # value: "https://example.com/availability.ics" + # - name: AVAILABILITY_SOURCES_ICAL_INTERVAL + # value: "5m" # - name: AVAILABILITY_API_KEY # value: "secret-key" - # - name: AVAILABILITY_WORKING_HOURS_START + # - name: AVAILABILITY_SUPPRESSIONS_WORKING_HOURS_START # value: "09:00" - # - name: AVAILABILITY_WORKING_HOURS_END + # - name: AVAILABILITY_SUPPRESSIONS_WORKING_HOURS_END # value: "17:50" - # - name: AVAILABILITY_EXCLUDE_ENGLAND_BANK_HOLIDAYS + # - name: AVAILABILITY_SUPPRESSIONS_EXCLUDE_ENGLAND_BANK_HOLIDAYS # value: "true" + # Cloudflare Pages target env vars supported by the app: + # - name: AVAILABILITY_TARGETS_CLOUDFLARE_PAGES_INTERVAL + # value: "10m" + # - name: AVAILABILITY_TARGETS_CLOUDFLARE_PAGES_DEPLOY_HOOK + # value: "https://api.cloudflare.com/client/v4/pages/webhooks/deploy_hooks/..." # This section builds out the service account more information can be found here: https://kubernetes.io/docs/concepts/security/service-accounts/ serviceAccount: diff --git a/compose.yaml b/compose.yaml index 7afb055..e407e92 100644 --- a/compose.yaml +++ b/compose.yaml @@ -3,17 +3,19 @@ # Optional environment variables (override config.yaml values): # PORT (default: 8080) # PEBBLE_PATH (default: ./data) -# CALENDAR_URL (iCal URL — required if not in config.yaml) -# GITHUB_TOKEN (GitHub personal access token) -# AVAILABILITY_IS_ENABLED -# AVAILABILITY_CALENDAR_URL +# STATUS_ENABLED +# STATUS_SOURCES_ICAL_URL +# STATUS_SOURCES_ICAL_INTERVAL +# STATUS_TARGETS_GITHUB_TOKEN +# AVAILABILITY_ENABLED +# AVAILABILITY_SOURCES_ICAL_URL +# AVAILABILITY_SOURCES_ICAL_INTERVAL # AVAILABILITY_API_KEY -# AVAILABILITY_WORKING_HOURS_START -# AVAILABILITY_WORKING_HOURS_END -# AVAILABILITY_EXCLUDE_ENGLAND_BANK_HOLIDAYS -# BUILD_IS_ENABLED -# BUILD_INTERVAL -# BUILD_CF_DEPLOY_HOOK +# AVAILABILITY_SUPPRESSIONS_WORKING_HOURS_START +# AVAILABILITY_SUPPRESSIONS_WORKING_HOURS_END +# AVAILABILITY_SUPPRESSIONS_EXCLUDE_ENGLAND_BANK_HOLIDAYS +# AVAILABILITY_TARGETS_CLOUDFLARE_PAGES_INTERVAL +# AVAILABILITY_TARGETS_CLOUDFLARE_PAGES_DEPLOY_HOOK # # Usage: # podman compose up -d @@ -30,14 +32,16 @@ services: environment: PORT: ${PORT:-8080} PEBBLE_PATH: ${PEBBLE_PATH:-/data} - CALENDAR_URL: ${CALENDAR_URL} - GITHUB_TOKEN: ${GITHUB_TOKEN} - AVAILABILITY_IS_ENABLED: ${AVAILABILITY_IS_ENABLED} - AVAILABILITY_CALENDAR_URL: ${AVAILABILITY_CALENDAR_URL} + STATUS_ENABLED: ${STATUS_ENABLED} + STATUS_SOURCES_ICAL_URL: ${STATUS_SOURCES_ICAL_URL} + STATUS_SOURCES_ICAL_INTERVAL: ${STATUS_SOURCES_ICAL_INTERVAL} + STATUS_TARGETS_GITHUB_TOKEN: ${STATUS_TARGETS_GITHUB_TOKEN} + AVAILABILITY_ENABLED: ${AVAILABILITY_ENABLED} + AVAILABILITY_SOURCES_ICAL_URL: ${AVAILABILITY_SOURCES_ICAL_URL} + AVAILABILITY_SOURCES_ICAL_INTERVAL: ${AVAILABILITY_SOURCES_ICAL_INTERVAL} AVAILABILITY_API_KEY: ${AVAILABILITY_API_KEY} - AVAILABILITY_WORKING_HOURS_START: ${AVAILABILITY_WORKING_HOURS_START} - AVAILABILITY_WORKING_HOURS_END: ${AVAILABILITY_WORKING_HOURS_END} - AVAILABILITY_EXCLUDE_ENGLAND_BANK_HOLIDAYS: ${AVAILABILITY_EXCLUDE_ENGLAND_BANK_HOLIDAYS} - BUILD_IS_ENABLED: ${BUILD_IS_ENABLED} - BUILD_INTERVAL: ${BUILD_INTERVAL} - BUILD_CF_DEPLOY_HOOK: ${BUILD_CF_DEPLOY_HOOK} + AVAILABILITY_SUPPRESSIONS_WORKING_HOURS_START: ${AVAILABILITY_SUPPRESSIONS_WORKING_HOURS_START} + AVAILABILITY_SUPPRESSIONS_WORKING_HOURS_END: ${AVAILABILITY_SUPPRESSIONS_WORKING_HOURS_END} + AVAILABILITY_SUPPRESSIONS_EXCLUDE_ENGLAND_BANK_HOLIDAYS: ${AVAILABILITY_SUPPRESSIONS_EXCLUDE_ENGLAND_BANK_HOLIDAYS} + AVAILABILITY_TARGETS_CLOUDFLARE_PAGES_INTERVAL: ${AVAILABILITY_TARGETS_CLOUDFLARE_PAGES_INTERVAL} + AVAILABILITY_TARGETS_CLOUDFLARE_PAGES_DEPLOY_HOOK: ${AVAILABILITY_TARGETS_CLOUDFLARE_PAGES_DEPLOY_HOOK} diff --git a/config.yaml b/config.yaml index 1d0b85e..2a765fa 100644 --- a/config.yaml +++ b/config.yaml @@ -1,29 +1,51 @@ port: 8080 pebble_path: ./data -# iCal URL for your calendar (required) -calendar_url: https://example.com/calendar.ics - -targets: - github: - # GitHub personal access token (optional, enables GitHub status sync) - # Scopes required: user scope for status updates - # token: ghp_xyz - token: "" +# The status feature fetches events from an iCal calendar +# and updates GitHub status accordingly +status: + enabled: true + sources: + ical: + url: https://example.com/status.ics + interval: 5m + targets: + github: + # GitHub personal access token required when status is enabled. + # Scopes required: user scope for status updates. + # token: ghp_xyz + token: "" +# The availability feature fetches events from an iCal calendar, +# infers my availability based on the events & the following configuration, +# expoes the availability via an API used by my static website during build, +# and automatically triggers a redeployment of my website when availability changes. availability: - # Disabled by default. Set to true to enable the availability API. - is_enabled: false - # Weekday working hours, used to suppress availability unless it's a bank holiday. - working_hours: - start: "09:00" - end: "17:50" - # Set to true to fetch GOV.UK England-and-Wales bank holidays and lift weekday suppression on those dates. - exclude_england_bank_holidays: true - # Separate iCal URL for availability data. - calendar_url: https://example.com/availability.ics - # Exact Authorization header value required by /api/availability. - api_key: "" + enabled: false + sources: + ical: + url: https://example.com/availability.ics + interval: 5m + + api: + # Exact Authorization header value required by /api/availability when availability is enabled. + key: "" + + targets: + cloudflare_pages: + # Deploy checks are aligned to HH:01, HH:11, HH:21, ... when interval is 10m. + interval: 10m + # Cloudflare Pages build hook URL required when availability is enabled. + # Prefer AVAILABILITY_TARGETS_CLOUDFLARE_PAGES_DEPLOY_HOOK for secrets. + deploy_hook: "" + + suppressions: + # Weekday working hours, used to suppress availability unless it's a bank holiday. + working_hours: + start: "09:00" + end: "17:50" + # Set to true to fetch GOV.UK England-and-Wales bank holidays and lift weekday suppression on those dates. + exclude_england_bank_holidays: true # Ordered availability windows, checked from top to bottom. blocks: - name: All day @@ -44,11 +66,3 @@ availability: - name: Evening start: "17:30" end: "22:00" - -build: - # Disabled by default. Set to true to trigger Cloudflare Pages deploys when availability changes. - is_enabled: false - # Deploy checks are aligned to HH:01, HH:11, HH:21, ... when interval is 10m. - interval: 10m - # Cloudflare Pages build hook URL. Prefer BUILD_CF_DEPLOY_HOOK for secrets. - cf_deploy_hook: "" diff --git a/internal/config/config.go b/internal/config/config.go index 57d29cc..e556b75 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -16,35 +16,98 @@ import ( // Config holds application configuration. type Config struct { - Port int `koanf:"port"` - PebblePath string `koanf:"pebble_path"` - CalendarURL string `koanf:"calendar_url"` // iCal URL (e.g. https://calendar.google.com/calendar/ical/.../public/basic.ics) + Port int `koanf:"port"` + PebblePath string `koanf:"pebble_path"` - Targets TargetsConfig `koanf:"targets"` + Status StatusConfig `koanf:"status"` Availability AvailabilityConfig `koanf:"availability"` - Build BuildConfig `koanf:"build"` } -// TargetsConfig holds the configuration for each supported status target. +// StatusConfig configures status sources and targets. +type StatusConfig struct { + IsEnabled bool `koanf:"enabled"` + Sources StatusSourcesConfig `koanf:"sources"` + Targets StatusTargetsConfig `koanf:"targets"` +} + +// StatusSourcesConfig configures status data sources. +type StatusSourcesConfig struct { + ICal ICalSourceConfig `koanf:"ical"` +} + +// ICalSourceConfig configures an iCal feed source. +type ICalSourceConfig struct { + URL string `koanf:"url"` + Interval string `koanf:"interval"` +} + +// StatusTargetsConfig holds the configuration for each supported status target. // A target is enabled when its token is non-empty. // Add a new field here to support additional targets in the future. -type TargetsConfig struct { +type StatusTargetsConfig struct { GitHub GitHubTargetConfig `koanf:"github"` } // GitHubTargetConfig configures the GitHub status target. type GitHubTargetConfig struct { - Token string `koanf:"token"` // personal access token — requires user scope + Token string `koanf:"token"` // personal access token; requires user scope +} + +// Enabled reports whether the GitHub status target is configured. +func (g GitHubTargetConfig) Enabled() bool { + return strings.TrimSpace(g.Token) != "" +} + +// Enabled reports whether any status target is configured. +func (t StatusTargetsConfig) Enabled() bool { + return t.GitHub.Enabled() } -// AvailabilityConfig configures the optional availability feature. +// AvailabilityConfig configures availability sources, serving, targets, and rules. type AvailabilityConfig struct { - IsEnabled bool `koanf:"is_enabled"` - CalendarURL string `koanf:"calendar_url"` - APIKey string `koanf:"api_key"` + IsEnabled bool `koanf:"enabled"` + Sources AvailabilitySourcesConfig `koanf:"sources"` + API AvailabilityAPIConfig `koanf:"api"` + Targets AvailabilityTargetsConfig `koanf:"targets"` + Suppressions AvailabilitySuppressionsConfig `koanf:"suppressions"` + Blocks []AvailabilityBlockConfig `koanf:"blocks"` +} + +// AvailabilitySourcesConfig configures availability data sources. +type AvailabilitySourcesConfig struct { + ICal ICalSourceConfig `koanf:"ical"` +} + +// AvailabilityAPIConfig configures the availability HTTP API. +type AvailabilityAPIConfig struct { + Key string `koanf:"key"` +} + +// AvailabilityTargetsConfig holds availability publish targets. +type AvailabilityTargetsConfig struct { + CloudflarePages CloudflarePagesTargetConfig `koanf:"cloudflare_pages"` +} + +// CloudflarePagesTargetConfig configures Cloudflare Pages build hook publishes. +type CloudflarePagesTargetConfig struct { + Interval string `koanf:"interval"` + DeployHook string `koanf:"deploy_hook"` +} + +// Enabled reports whether status sync is enabled. +func (s StatusConfig) Enabled() bool { + return s.IsEnabled +} + +// Enabled reports whether availability computation is needed. +func (a AvailabilityConfig) Enabled() bool { + return a.IsEnabled +} + +// AvailabilitySuppressionsConfig configures availability suppression rules. +type AvailabilitySuppressionsConfig struct { WorkingHours AvailabilityWorkingHoursConfig `koanf:"working_hours"` ExcludeEnglandBankHolidays bool `koanf:"exclude_england_bank_holidays"` - Blocks []AvailabilityBlockConfig `koanf:"blocks"` } // AvailabilityWorkingHoursConfig defines the weekday working-hours window. @@ -60,29 +123,24 @@ type AvailabilityBlockConfig struct { End string `koanf:"end"` // HH:MM, 24-hour clock } -// BuildConfig configures automatic builds/deploys. -type BuildConfig struct { - IsEnabled bool `koanf:"is_enabled"` - Interval string `koanf:"interval"` - CfDeployHook string `koanf:"cf_deploy_hook"` -} - // envMapping maps environment variable names to koanf config keys. // Only variables listed here are loaded; all others are ignored. var envMapping = map[string]string{ - "PORT": "port", - "PEBBLE_PATH": "pebble_path", - "CALENDAR_URL": "calendar_url", - "GITHUB_TOKEN": "targets.github.token", - "AVAILABILITY_IS_ENABLED": "availability.is_enabled", - "AVAILABILITY_CALENDAR_URL": "availability.calendar_url", - "AVAILABILITY_API_KEY": "availability.api_key", - "AVAILABILITY_WORKING_HOURS_START": "availability.working_hours.start", - "AVAILABILITY_WORKING_HOURS_END": "availability.working_hours.end", - "AVAILABILITY_EXCLUDE_ENGLAND_BANK_HOLIDAYS": "availability.exclude_england_bank_holidays", - "BUILD_IS_ENABLED": "build.is_enabled", - "BUILD_INTERVAL": "build.interval", - "BUILD_CF_DEPLOY_HOOK": "build.cf_deploy_hook", + "PORT": "port", + "PEBBLE_PATH": "pebble_path", + "STATUS_ENABLED": "status.enabled", + "STATUS_SOURCES_ICAL_URL": "status.sources.ical.url", + "STATUS_SOURCES_ICAL_INTERVAL": "status.sources.ical.interval", + "STATUS_TARGETS_GITHUB_TOKEN": "status.targets.github.token", + "AVAILABILITY_ENABLED": "availability.enabled", + "AVAILABILITY_SOURCES_ICAL_URL": "availability.sources.ical.url", + "AVAILABILITY_SOURCES_ICAL_INTERVAL": "availability.sources.ical.interval", + "AVAILABILITY_API_KEY": "availability.api.key", + "AVAILABILITY_SUPPRESSIONS_WORKING_HOURS_START": "availability.suppressions.working_hours.start", + "AVAILABILITY_SUPPRESSIONS_WORKING_HOURS_END": "availability.suppressions.working_hours.end", + "AVAILABILITY_SUPPRESSIONS_EXCLUDE_ENGLAND_BANK_HOLIDAYS": "availability.suppressions.exclude_england_bank_holidays", + "AVAILABILITY_TARGETS_CLOUDFLARE_PAGES_INTERVAL": "availability.targets.cloudflare_pages.interval", + "AVAILABILITY_TARGETS_CLOUDFLARE_PAGES_DEPLOY_HOOK": "availability.targets.cloudflare_pages.deploy_hook", } // configFile is the optional YAML config file loaded between defaults and env vars. @@ -98,18 +156,21 @@ func Load() (*Config, error) { // 1. Defaults. if err := k.Load(confmap.Provider(map[string]interface{}{ - "port": 8080, - "pebble_path": "./data", - "availability.working_hours.start": "09:00", - "availability.working_hours.end": "17:50", - "availability.exclude_england_bank_holidays": false, - "build.is_enabled": false, - "build.interval": "10m", + "port": 8080, + "pebble_path": "./data", + "status.enabled": false, + "status.sources.ical.interval": "5m", + "availability.enabled": false, + "availability.sources.ical.interval": "5m", + "availability.suppressions.working_hours.start": "09:00", + "availability.suppressions.working_hours.end": "17:50", + "availability.suppressions.exclude_england_bank_holidays": false, + "availability.targets.cloudflare_pages.interval": "10m", }, "."), nil); err != nil { return nil, fmt.Errorf("load defaults: %w", err) } - // 2. config.yaml — optional. + // 2. config.yaml - optional. if _, err := os.Stat(configFile); err == nil { if err := k.Load(file.Provider(configFile), yaml.Parser()); err != nil { return nil, fmt.Errorf("load %s: %w", configFile, err) @@ -134,26 +195,71 @@ func Load() (*Config, error) { return &cfg, nil } -// Validate checks the configured feature-specific settings that cannot be -// validated by koanf decoding alone. +// Validate checks cross-feature configuration and feature-specific settings. +func (c Config) Validate() error { + if err := c.Status.Validate(); err != nil { + return err + } + if err := c.Availability.Validate(); err != nil { + return err + } + return nil +} + +// Validate checks status source and target settings. +func (s StatusConfig) Validate() error { + if !s.Enabled() { + return nil + } + if strings.TrimSpace(s.Sources.ICal.URL) == "" { + return fmt.Errorf("status.sources.ical.url is required when status is enabled") + } + if _, err := s.Sources.ICal.IntervalDuration("status.sources.ical.interval"); err != nil { + return err + } + if !s.Targets.Enabled() { + return fmt.Errorf("at least one status target is required when status is enabled") + } + return nil +} + +// IntervalDuration returns the validated source polling interval. +func (s ICalSourceConfig) IntervalDuration(path string) (time.Duration, error) { + if strings.TrimSpace(s.Interval) == "" { + return 0, fmt.Errorf("%s is required", path) + } + dur, err := time.ParseDuration(s.Interval) + if err != nil { + return 0, fmt.Errorf("%s: %w", path, err) + } + if dur < time.Minute { + return 0, fmt.Errorf("%s must be at least 1m", path) + } + return dur, nil +} + +// Validate checks the configured availability settings. func (a AvailabilityConfig) Validate() error { - if !a.IsEnabled { + if !a.Enabled() { return nil } - if a.WorkingHours.Start == "" { - return fmt.Errorf("availability.working_hours.start is required when availability is enabled") + if strings.TrimSpace(a.Sources.ICal.URL) == "" { + return fmt.Errorf("availability.sources.ical.url is required when availability is enabled") + } + if _, err := a.Sources.ICal.IntervalDuration("availability.sources.ical.interval"); err != nil { + return err } - if a.WorkingHours.End == "" { - return fmt.Errorf("availability.working_hours.end is required when availability is enabled") + if strings.TrimSpace(a.API.Key) == "" { + return fmt.Errorf("availability.api.key is required when availability is enabled") } - if a.CalendarURL == "" { - return fmt.Errorf("availability.calendar_url is required when availability is enabled") + if a.Suppressions.WorkingHours.Start == "" { + return fmt.Errorf("availability.suppressions.working_hours.start is required when availability is enabled") } - if a.APIKey == "" { - return fmt.Errorf("availability.api_key is required when availability is enabled") + if a.Suppressions.WorkingHours.End == "" { + return fmt.Errorf("availability.suppressions.working_hours.end is required when availability is enabled") } - if _, _, err := timeutil.ParseClockRange(a.WorkingHours.Start, a.WorkingHours.End); err != nil { - return fmt.Errorf("availability.working_hours: %w", err) + if _, _, err := timeutil.ParseClockRange(a.Suppressions.WorkingHours.Start, a.Suppressions.WorkingHours.End); err != nil { + return fmt.Errorf("availability.suppressions.working_hours: %w", err) } if len(a.Blocks) == 0 { return fmt.Errorf("availability.blocks is required when availability is enabled") @@ -172,35 +278,34 @@ func (a AvailabilityConfig) Validate() error { return fmt.Errorf("availability.blocks[%d]: %w", i, err) } } + if err := a.Targets.CloudflarePages.Validate(); err != nil { + return err + } return nil } -// Validate checks the configured build settings. -func (b BuildConfig) Validate() error { - if !b.IsEnabled { - return nil +// Validate checks the Cloudflare Pages target settings. +func (c CloudflarePagesTargetConfig) Validate() error { + if _, err := c.IntervalDuration(); err != nil { + return err + } + if strings.TrimSpace(c.DeployHook) == "" { + return fmt.Errorf("availability.targets.cloudflare_pages.deploy_hook is required when availability is enabled") } - _, err := b.IntervalDuration() - return err + return nil } -// IntervalDuration returns the validated build interval. -func (b BuildConfig) IntervalDuration() (time.Duration, error) { - if !b.IsEnabled { - return 0, nil - } - if strings.TrimSpace(b.Interval) == "" { - return 0, fmt.Errorf("build.interval is required when build is enabled") +// IntervalDuration returns the validated Cloudflare Pages publish interval. +func (c CloudflarePagesTargetConfig) IntervalDuration() (time.Duration, error) { + if strings.TrimSpace(c.Interval) == "" { + return 0, fmt.Errorf("availability.targets.cloudflare_pages.interval is required when availability is enabled") } - dur, err := time.ParseDuration(b.Interval) + dur, err := time.ParseDuration(c.Interval) if err != nil { - return 0, fmt.Errorf("build.interval: %w", err) + return 0, fmt.Errorf("availability.targets.cloudflare_pages.interval: %w", err) } if dur < time.Minute { - return 0, fmt.Errorf("build.interval must be at least 1m") - } - if strings.TrimSpace(b.CfDeployHook) == "" { - return 0, fmt.Errorf("build.cf_deploy_hook is required when build is enabled") + return 0, fmt.Errorf("availability.targets.cloudflare_pages.interval must be at least 1m") } return dur, nil } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index f34553e..9a4f145 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -25,17 +25,19 @@ func chdir(t *testing.T, dir string) { var configEnvKeys = []string{ "PORT", "PEBBLE_PATH", - "CALENDAR_URL", - "GITHUB_TOKEN", - "AVAILABILITY_IS_ENABLED", - "AVAILABILITY_CALENDAR_URL", + "STATUS_ENABLED", + "STATUS_SOURCES_ICAL_URL", + "STATUS_SOURCES_ICAL_INTERVAL", + "STATUS_TARGETS_GITHUB_TOKEN", + "AVAILABILITY_ENABLED", + "AVAILABILITY_SOURCES_ICAL_URL", + "AVAILABILITY_SOURCES_ICAL_INTERVAL", "AVAILABILITY_API_KEY", - "AVAILABILITY_WORKING_HOURS_START", - "AVAILABILITY_WORKING_HOURS_END", - "AVAILABILITY_EXCLUDE_ENGLAND_BANK_HOLIDAYS", - "BUILD_IS_ENABLED", - "BUILD_INTERVAL", - "BUILD_CF_DEPLOY_HOOK", + "AVAILABILITY_SUPPRESSIONS_WORKING_HOURS_START", + "AVAILABILITY_SUPPRESSIONS_WORKING_HOURS_END", + "AVAILABILITY_SUPPRESSIONS_EXCLUDE_ENGLAND_BANK_HOLIDAYS", + "AVAILABILITY_TARGETS_CLOUDFLARE_PAGES_INTERVAL", + "AVAILABILITY_TARGETS_CLOUDFLARE_PAGES_DEPLOY_HOOK", } func clearConfigEnv(t *testing.T) { @@ -53,7 +55,6 @@ func writeConfigYAML(t *testing.T, dir, body string) { } func TestLoad_Defaults(t *testing.T) { - // Work in a temp dir with no config.yaml. chdir(t, t.TempDir()) clearConfigEnv(t) @@ -67,18 +68,36 @@ func TestLoad_Defaults(t *testing.T) { if cfg.PebblePath != "./data" { t.Errorf("PebblePath: got %q, want %q", cfg.PebblePath, "./data") } - if cfg.CalendarURL != "" { - t.Errorf("CalendarURL: got %q, want empty (no default)", cfg.CalendarURL) + if cfg.Status.Sources.ICal.URL != "" { + t.Errorf("Status.Sources.ICal.URL: got %q, want empty", cfg.Status.Sources.ICal.URL) } - if cfg.Availability.WorkingHours.Start != "09:00" || cfg.Availability.WorkingHours.End != "17:50" { - t.Errorf("Availability.WorkingHours: got %+v, want start 09:00 end 17:50", cfg.Availability.WorkingHours) + if cfg.Status.IsEnabled { + t.Error("expected status to be disabled by default") } - if cfg.Availability.ExcludeEnglandBankHolidays { - t.Error("expected bank holiday exclusion to be disabled by default") + if cfg.Status.Sources.ICal.Interval != "5m" { + t.Errorf("Status.Sources.ICal.Interval: got %q, want 5m", cfg.Status.Sources.ICal.Interval) + } + if cfg.Status.Targets.GitHub.Token != "" { + t.Errorf("Status.Targets.GitHub.Token: got %q, want empty", cfg.Status.Targets.GitHub.Token) + } + if cfg.Availability.Sources.ICal.URL != "" { + t.Errorf("Availability.Sources.ICal.URL: got %q, want empty", cfg.Availability.Sources.ICal.URL) } if cfg.Availability.IsEnabled { t.Error("expected availability to be disabled by default") } + if cfg.Availability.Sources.ICal.Interval != "5m" { + t.Errorf("Availability.Sources.ICal.Interval: got %q, want 5m", cfg.Availability.Sources.ICal.Interval) + } + if cfg.Availability.Suppressions.WorkingHours.Start != "09:00" || cfg.Availability.Suppressions.WorkingHours.End != "17:50" { + t.Errorf("Availability.Suppressions.WorkingHours: got %+v, want start 09:00 end 17:50", cfg.Availability.Suppressions.WorkingHours) + } + if cfg.Availability.Suppressions.ExcludeEnglandBankHolidays { + t.Error("expected bank holiday exclusion to be disabled by default") + } + if cfg.Availability.Targets.CloudflarePages.Interval != "10m" { + t.Errorf("CloudflarePages.Interval: got %q, want 10m", cfg.Availability.Targets.CloudflarePages.Interval) + } } func TestLoad_FromEnv(t *testing.T) { @@ -87,8 +106,10 @@ func TestLoad_FromEnv(t *testing.T) { t.Setenv("PORT", "9090") t.Setenv("PEBBLE_PATH", "/tmp/mydb") - t.Setenv("CALENDAR_URL", "https://calendar.example.com/ical.ics") - t.Setenv("GITHUB_TOKEN", "gh-abc123") + t.Setenv("STATUS_ENABLED", "true") + t.Setenv("STATUS_SOURCES_ICAL_URL", "https://calendar.example.com/ical.ics") + t.Setenv("STATUS_SOURCES_ICAL_INTERVAL", "15m") + t.Setenv("STATUS_TARGETS_GITHUB_TOKEN", "gh-abc123") cfg, err := Load() if err != nil { @@ -100,11 +121,17 @@ func TestLoad_FromEnv(t *testing.T) { if cfg.PebblePath != "/tmp/mydb" { t.Errorf("PebblePath: got %q, want %q", cfg.PebblePath, "/tmp/mydb") } - if cfg.CalendarURL != "https://calendar.example.com/ical.ics" { - t.Errorf("CalendarURL: got %q", cfg.CalendarURL) + if cfg.Status.Sources.ICal.URL != "https://calendar.example.com/ical.ics" { + t.Errorf("Status.Sources.ICal.URL: got %q", cfg.Status.Sources.ICal.URL) } - if cfg.Targets.GitHub.Token != "gh-abc123" { - t.Errorf("Targets.GitHub.Token: got %q", cfg.Targets.GitHub.Token) + if !cfg.Status.IsEnabled { + t.Fatal("expected status to be enabled from env") + } + if cfg.Status.Sources.ICal.Interval != "15m" { + t.Errorf("Status.Sources.ICal.Interval: got %q", cfg.Status.Sources.ICal.Interval) + } + if cfg.Status.Targets.GitHub.Token != "gh-abc123" { + t.Errorf("Status.Targets.GitHub.Token: got %q", cfg.Status.Targets.GitHub.Token) } } @@ -112,32 +139,44 @@ func TestLoad_AvailabilityFromEnv(t *testing.T) { chdir(t, t.TempDir()) clearConfigEnv(t) - t.Setenv("AVAILABILITY_IS_ENABLED", "true") - t.Setenv("AVAILABILITY_CALENDAR_URL", "https://availability.example.com/ical.ics") + t.Setenv("AVAILABILITY_ENABLED", "true") + t.Setenv("AVAILABILITY_SOURCES_ICAL_URL", "https://availability.example.com/ical.ics") + t.Setenv("AVAILABILITY_SOURCES_ICAL_INTERVAL", "7m") t.Setenv("AVAILABILITY_API_KEY", "secret-key") - t.Setenv("AVAILABILITY_WORKING_HOURS_START", "08:30") - t.Setenv("AVAILABILITY_WORKING_HOURS_END", "17:15") - t.Setenv("AVAILABILITY_EXCLUDE_ENGLAND_BANK_HOLIDAYS", "true") + t.Setenv("AVAILABILITY_SUPPRESSIONS_WORKING_HOURS_START", "08:30") + t.Setenv("AVAILABILITY_SUPPRESSIONS_WORKING_HOURS_END", "17:15") + t.Setenv("AVAILABILITY_SUPPRESSIONS_EXCLUDE_ENGLAND_BANK_HOLIDAYS", "true") + t.Setenv("AVAILABILITY_TARGETS_CLOUDFLARE_PAGES_INTERVAL", "12m") + t.Setenv("AVAILABILITY_TARGETS_CLOUDFLARE_PAGES_DEPLOY_HOOK", "https://example.com/hook") cfg, err := Load() if err != nil { t.Fatalf("Load: %v", err) } + if cfg.Availability.Sources.ICal.URL != "https://availability.example.com/ical.ics" { + t.Errorf("Availability.Sources.ICal.URL: got %q", cfg.Availability.Sources.ICal.URL) + } if !cfg.Availability.IsEnabled { t.Fatal("expected availability to be enabled from env") } - if cfg.Availability.CalendarURL != "https://availability.example.com/ical.ics" { - t.Errorf("Availability.CalendarURL: got %q", cfg.Availability.CalendarURL) + if cfg.Availability.Sources.ICal.Interval != "7m" { + t.Errorf("Availability.Sources.ICal.Interval: got %q", cfg.Availability.Sources.ICal.Interval) } - if cfg.Availability.APIKey != "secret-key" { - t.Errorf("Availability.APIKey: got %q", cfg.Availability.APIKey) + if cfg.Availability.API.Key != "secret-key" { + t.Errorf("Availability.API.Key: got %q", cfg.Availability.API.Key) } - if cfg.Availability.WorkingHours.Start != "08:30" || cfg.Availability.WorkingHours.End != "17:15" { - t.Errorf("Availability.WorkingHours: got %+v", cfg.Availability.WorkingHours) + if cfg.Availability.Suppressions.WorkingHours.Start != "08:30" || cfg.Availability.Suppressions.WorkingHours.End != "17:15" { + t.Errorf("Availability.Suppressions.WorkingHours: got %+v", cfg.Availability.Suppressions.WorkingHours) } - if !cfg.Availability.ExcludeEnglandBankHolidays { + if !cfg.Availability.Suppressions.ExcludeEnglandBankHolidays { t.Error("expected holiday exclusion to be enabled from env") } + if cfg.Availability.Targets.CloudflarePages.Interval != "12m" { + t.Errorf("CloudflarePages.Interval: got %q", cfg.Availability.Targets.CloudflarePages.Interval) + } + if cfg.Availability.Targets.CloudflarePages.DeployHook != "https://example.com/hook" { + t.Errorf("CloudflarePages.DeployHook: got %q", cfg.Availability.Targets.CloudflarePages.DeployHook) + } } func TestLoad_InvalidPort(t *testing.T) { @@ -159,10 +198,15 @@ func TestLoad_FromYAML(t *testing.T) { yaml := ` port: 7777 pebble_path: /yaml/data -calendar_url: https://yaml-cal.example.com/ical.ics -targets: - github: - token: gh-yaml +status: + enabled: true + sources: + ical: + url: https://yaml-cal.example.com/ical.ics + interval: 12m + targets: + github: + token: gh-yaml ` writeConfigYAML(t, dir, yaml) @@ -176,11 +220,17 @@ targets: if cfg.PebblePath != "/yaml/data" { t.Errorf("PebblePath: got %q", cfg.PebblePath) } - if cfg.CalendarURL != "https://yaml-cal.example.com/ical.ics" { - t.Errorf("CalendarURL: got %q", cfg.CalendarURL) + if cfg.Status.Sources.ICal.URL != "https://yaml-cal.example.com/ical.ics" { + t.Errorf("Status.Sources.ICal.URL: got %q", cfg.Status.Sources.ICal.URL) + } + if !cfg.Status.IsEnabled { + t.Fatal("expected status to be enabled from YAML") } - if cfg.Targets.GitHub.Token != "gh-yaml" { - t.Errorf("Targets.GitHub.Token: got %q", cfg.Targets.GitHub.Token) + if cfg.Status.Sources.ICal.Interval != "12m" { + t.Errorf("Status.Sources.ICal.Interval: got %q", cfg.Status.Sources.ICal.Interval) + } + if cfg.Status.Targets.GitHub.Token != "gh-yaml" { + t.Errorf("Status.Targets.GitHub.Token: got %q", cfg.Status.Targets.GitHub.Token) } } @@ -191,13 +241,22 @@ func TestLoad_AvailabilityFromYAML(t *testing.T) { yaml := ` availability: - is_enabled: true - calendar_url: https://availability.example.com/ical.ics - api_key: secret-yaml-key - working_hours: - start: "09:00" - end: "17:50" - exclude_england_bank_holidays: true + enabled: true + sources: + ical: + url: https://availability.example.com/ical.ics + interval: 8m + api: + key: secret-yaml-key + targets: + cloudflare_pages: + interval: 14m + deploy_hook: https://example.com/hook + suppressions: + working_hours: + start: "09:00" + end: "17:50" + exclude_england_bank_holidays: true blocks: - name: First half start: "09:00" @@ -212,21 +271,30 @@ availability: if err != nil { t.Fatalf("Load: %v", err) } + if cfg.Availability.Sources.ICal.URL != "https://availability.example.com/ical.ics" { + t.Errorf("Availability.Sources.ICal.URL: got %q", cfg.Availability.Sources.ICal.URL) + } if !cfg.Availability.IsEnabled { - t.Fatal("expected availability to be enabled") + t.Fatal("expected availability to be enabled from YAML") } - if cfg.Availability.CalendarURL != "https://availability.example.com/ical.ics" { - t.Errorf("Availability.CalendarURL: got %q", cfg.Availability.CalendarURL) + if cfg.Availability.Sources.ICal.Interval != "8m" { + t.Errorf("Availability.Sources.ICal.Interval: got %q", cfg.Availability.Sources.ICal.Interval) } - if cfg.Availability.APIKey != "secret-yaml-key" { - t.Errorf("Availability.APIKey: got %q", cfg.Availability.APIKey) + if cfg.Availability.API.Key != "secret-yaml-key" { + t.Errorf("Availability.API.Key: got %q", cfg.Availability.API.Key) } - if cfg.Availability.WorkingHours.Start != "09:00" || cfg.Availability.WorkingHours.End != "17:50" { - t.Errorf("Availability.WorkingHours: got %+v", cfg.Availability.WorkingHours) + if cfg.Availability.Suppressions.WorkingHours.Start != "09:00" || cfg.Availability.Suppressions.WorkingHours.End != "17:50" { + t.Errorf("Availability.Suppressions.WorkingHours: got %+v", cfg.Availability.Suppressions.WorkingHours) } - if !cfg.Availability.ExcludeEnglandBankHolidays { + if !cfg.Availability.Suppressions.ExcludeEnglandBankHolidays { t.Error("expected holiday exclusion to be enabled from YAML") } + if cfg.Availability.Targets.CloudflarePages.Interval != "14m" { + t.Errorf("CloudflarePages.Interval: got %q", cfg.Availability.Targets.CloudflarePages.Interval) + } + if cfg.Availability.Targets.CloudflarePages.DeployHook != "https://example.com/hook" { + t.Errorf("CloudflarePages.DeployHook: got %q", cfg.Availability.Targets.CloudflarePages.DeployHook) + } if len(cfg.Availability.Blocks) != 2 { t.Fatalf("expected 2 blocks, got %d", len(cfg.Availability.Blocks)) } @@ -242,83 +310,79 @@ func TestLoad_EnvOverridesYAML(t *testing.T) { yaml := ` port: 7777 -calendar_url: https://yaml-cal.example.com/ical.ics +status: + enabled: false + sources: + ical: + url: https://yaml-cal.example.com/ical.ics + interval: 20m + targets: + github: + token: gh-yaml availability: - working_hours: - start: "10:00" - end: "16:00" - exclude_england_bank_holidays: false -targets: - github: - token: gh-yaml + enabled: false + sources: + ical: + url: https://yaml-availability.example.com/ical.ics + interval: 30m + api: + key: yaml-key + suppressions: + working_hours: + start: "10:00" + end: "16:00" + exclude_england_bank_holidays: false ` writeConfigYAML(t, dir, yaml) - // Env var must win over yaml. - t.Setenv("GITHUB_TOKEN", "gh-env") t.Setenv("PORT", "9999") - t.Setenv("CALENDAR_URL", "https://env-cal.example.com/ical.ics") + t.Setenv("STATUS_ENABLED", "true") + t.Setenv("STATUS_TARGETS_GITHUB_TOKEN", "gh-env") + t.Setenv("STATUS_SOURCES_ICAL_URL", "https://env-cal.example.com/ical.ics") + t.Setenv("STATUS_SOURCES_ICAL_INTERVAL", "7m") + t.Setenv("AVAILABILITY_ENABLED", "true") + t.Setenv("AVAILABILITY_SOURCES_ICAL_URL", "https://env-availability.example.com/ical.ics") + t.Setenv("AVAILABILITY_SOURCES_ICAL_INTERVAL", "9m") + t.Setenv("AVAILABILITY_API_KEY", "env-key") + t.Setenv("AVAILABILITY_SUPPRESSIONS_WORKING_HOURS_START", "08:45") + t.Setenv("AVAILABILITY_SUPPRESSIONS_WORKING_HOURS_END", "17:15") + t.Setenv("AVAILABILITY_SUPPRESSIONS_EXCLUDE_ENGLAND_BANK_HOLIDAYS", "true") cfg, err := Load() if err != nil { t.Fatalf("Load: %v", err) } - if cfg.Targets.GitHub.Token != "gh-env" { - t.Errorf("Targets.GitHub.Token: got %q, want gh-env (env should beat yaml)", cfg.Targets.GitHub.Token) - } if cfg.Port != 9999 { - t.Errorf("Port: got %d, want 9999 (env should beat yaml)", cfg.Port) + t.Errorf("Port: got %d, want 9999", cfg.Port) } - if cfg.CalendarURL != "https://env-cal.example.com/ical.ics" { - t.Errorf("CalendarURL: got %q, want env value", cfg.CalendarURL) + if cfg.Status.Targets.GitHub.Token != "gh-env" { + t.Errorf("Status.Targets.GitHub.Token: got %q, want gh-env", cfg.Status.Targets.GitHub.Token) } -} - -func TestLoad_AvailabilityEnvOverridesYAML(t *testing.T) { - dir := t.TempDir() - chdir(t, dir) - clearConfigEnv(t) - - yaml := ` -availability: - is_enabled: false - calendar_url: https://yaml-availability.example.com/ical.ics - api_key: yaml-key - working_hours: - start: "10:00" - end: "16:00" - exclude_england_bank_holidays: false - blocks: - - name: Morning - start: "09:00" - end: "12:00" -` - writeConfigYAML(t, dir, yaml) - - t.Setenv("AVAILABILITY_IS_ENABLED", "true") - t.Setenv("AVAILABILITY_CALENDAR_URL", "https://env-availability.example.com/ical.ics") - t.Setenv("AVAILABILITY_API_KEY", "env-key") - t.Setenv("AVAILABILITY_WORKING_HOURS_START", "08:45") - t.Setenv("AVAILABILITY_WORKING_HOURS_END", "17:15") - t.Setenv("AVAILABILITY_EXCLUDE_ENGLAND_BANK_HOLIDAYS", "true") - - cfg, err := Load() - if err != nil { - t.Fatalf("Load: %v", err) + if !cfg.Status.IsEnabled { + t.Fatal("expected env to enable status") + } + if cfg.Status.Sources.ICal.URL != "https://env-cal.example.com/ical.ics" { + t.Errorf("Status.Sources.ICal.URL: got %q, want env value", cfg.Status.Sources.ICal.URL) + } + if cfg.Status.Sources.ICal.Interval != "7m" { + t.Errorf("Status.Sources.ICal.Interval: got %q, want env value", cfg.Status.Sources.ICal.Interval) + } + if cfg.Availability.Sources.ICal.URL != "https://env-availability.example.com/ical.ics" { + t.Errorf("Availability.Sources.ICal.URL: got %q, want env value", cfg.Availability.Sources.ICal.URL) } if !cfg.Availability.IsEnabled { t.Fatal("expected env to enable availability") } - if cfg.Availability.CalendarURL != "https://env-availability.example.com/ical.ics" { - t.Errorf("Availability.CalendarURL: got %q", cfg.Availability.CalendarURL) + if cfg.Availability.Sources.ICal.Interval != "9m" { + t.Errorf("Availability.Sources.ICal.Interval: got %q, want env value", cfg.Availability.Sources.ICal.Interval) } - if cfg.Availability.APIKey != "env-key" { - t.Errorf("Availability.APIKey: got %q", cfg.Availability.APIKey) + if cfg.Availability.API.Key != "env-key" { + t.Errorf("Availability.API.Key: got %q", cfg.Availability.API.Key) } - if cfg.Availability.WorkingHours.Start != "08:45" || cfg.Availability.WorkingHours.End != "17:15" { - t.Errorf("Availability.WorkingHours: got %+v", cfg.Availability.WorkingHours) + if cfg.Availability.Suppressions.WorkingHours.Start != "08:45" || cfg.Availability.Suppressions.WorkingHours.End != "17:15" { + t.Errorf("Availability.Suppressions.WorkingHours: got %+v", cfg.Availability.Suppressions.WorkingHours) } - if !cfg.Availability.ExcludeEnglandBankHolidays { + if !cfg.Availability.Suppressions.ExcludeEnglandBankHolidays { t.Error("expected env to enable bank holiday exclusion") } } @@ -330,7 +394,14 @@ func TestLoad_YAMLOverridesDefaults(t *testing.T) { yaml := ` port: 3000 -calendar_url: https://cal.example.com/ical.ics +status: + sources: + ical: + interval: 11m +availability: + sources: + ical: + interval: 13m ` writeConfigYAML(t, dir, yaml) @@ -339,19 +410,20 @@ calendar_url: https://cal.example.com/ical.ics t.Fatalf("Load: %v", err) } if cfg.Port != 3000 { - t.Errorf("Port: got %d, want 3000 (yaml should beat default)", cfg.Port) + t.Errorf("Port: got %d, want 3000", cfg.Port) + } + if cfg.Status.Sources.ICal.Interval != "11m" { + t.Errorf("Status.Sources.ICal.Interval: got %q, want 11m", cfg.Status.Sources.ICal.Interval) } - if cfg.CalendarURL != "https://cal.example.com/ical.ics" { - t.Errorf("CalendarURL: got %q", cfg.CalendarURL) + if cfg.Availability.Sources.ICal.Interval != "13m" { + t.Errorf("Availability.Sources.ICal.Interval: got %q, want 13m", cfg.Availability.Sources.ICal.Interval) } - // Other defaults should remain. if cfg.PebblePath != "./data" { t.Errorf("PebblePath: got %q, want default ./data", cfg.PebblePath) } } func TestLoad_MissingYAML(t *testing.T) { - // No config.yaml in the temp dir — should load without error. chdir(t, t.TempDir()) clearConfigEnv(t) @@ -360,7 +432,37 @@ func TestLoad_MissingYAML(t *testing.T) { t.Fatalf("Load without config.yaml: %v", err) } if cfg.Port != 8080 { - t.Errorf("Port: got %d, want 8080 (default)", cfg.Port) + t.Errorf("Port: got %d, want 8080", cfg.Port) + } +} + +func validAvailabilityConfig() AvailabilityConfig { + return AvailabilityConfig{ + IsEnabled: true, + Sources: AvailabilitySourcesConfig{ + ICal: ICalSourceConfig{ + URL: "https://example.com/availability.ics", + Interval: "5m", + }, + }, + API: AvailabilityAPIConfig{ + Key: "secret", + }, + Targets: AvailabilityTargetsConfig{ + CloudflarePages: CloudflarePagesTargetConfig{ + Interval: "10m", + DeployHook: "https://example.com/hook", + }, + }, + Suppressions: AvailabilitySuppressionsConfig{ + WorkingHours: AvailabilityWorkingHoursConfig{ + Start: "09:00", + End: "17:50", + }, + }, + Blocks: []AvailabilityBlockConfig{ + {Name: "Morning", Start: "09:00", End: "12:00"}, + }, } } @@ -371,54 +473,59 @@ func TestAvailabilityValidate(t *testing.T) { } }) - t.Run("enabled valid", func(t *testing.T) { - cfg := AvailabilityConfig{ - IsEnabled: true, - CalendarURL: "https://example.com/ical.ics", - APIKey: "secret", - WorkingHours: AvailabilityWorkingHoursConfig{ - Start: "09:00", - End: "17:50", - }, - Blocks: []AvailabilityBlockConfig{ - {Name: "Morning", Start: "09:00", End: "12:00"}, - }, + t.Run("disabled ignores incomplete config", func(t *testing.T) { + cfg := validAvailabilityConfig() + cfg.IsEnabled = false + cfg.Sources.ICal.URL = "" + cfg.API.Key = "" + cfg.Targets.CloudflarePages.DeployHook = "" + if err := cfg.Validate(); err != nil { + t.Fatalf("Validate: %v", err) } + }) + + t.Run("enabled valid", func(t *testing.T) { + cfg := validAvailabilityConfig() if err := cfg.Validate(); err != nil { t.Fatalf("Validate: %v", err) } }) - t.Run("enabled invalid working hours", func(t *testing.T) { - cfg := AvailabilityConfig{ - IsEnabled: true, - CalendarURL: "https://example.com/ical.ics", - APIKey: "secret", - WorkingHours: AvailabilityWorkingHoursConfig{ - Start: "bad-value", - End: "17:50", - }, - Blocks: []AvailabilityBlockConfig{ - {Name: "Morning", Start: "09:00", End: "12:00"}, - }, + t.Run("enabled missing API key", func(t *testing.T) { + cfg := validAvailabilityConfig() + cfg.API.Key = "" + if err := cfg.Validate(); err == nil { + t.Fatal("expected error for missing API key") + } + }) + + t.Run("enabled missing Cloudflare Pages deploy hook", func(t *testing.T) { + cfg := validAvailabilityConfig() + cfg.Targets.CloudflarePages.DeployHook = "" + if err := cfg.Validate(); err == nil { + t.Fatal("expected error for missing deploy hook") + } + }) + + t.Run("enabled missing source", func(t *testing.T) { + cfg := validAvailabilityConfig() + cfg.Sources.ICal.URL = "" + if err := cfg.Validate(); err == nil { + t.Fatal("expected error for missing availability source") } + }) + + t.Run("enabled invalid working hours", func(t *testing.T) { + cfg := validAvailabilityConfig() + cfg.Suppressions.WorkingHours.Start = "bad-value" if err := cfg.Validate(); err == nil { t.Fatal("expected error for invalid working hours") } }) t.Run("enabled missing working hours start", func(t *testing.T) { - cfg := AvailabilityConfig{ - IsEnabled: true, - CalendarURL: "https://example.com/ical.ics", - APIKey: "secret", - WorkingHours: AvailabilityWorkingHoursConfig{ - End: "17:50", - }, - Blocks: []AvailabilityBlockConfig{ - {Name: "Morning", Start: "09:00", End: "12:00"}, - }, - } + cfg := validAvailabilityConfig() + cfg.Suppressions.WorkingHours.Start = "" if err := cfg.Validate(); err == nil { t.Fatal("expected error for missing working hours start") } @@ -432,26 +539,113 @@ func TestAvailabilityValidate(t *testing.T) { }) } -func TestBuildValidateAndIntervalDuration(t *testing.T) { - t.Run("disabled", func(t *testing.T) { - cfg := BuildConfig{} +func TestConfigValidate(t *testing.T) { + t.Run("availability only does not require status source", func(t *testing.T) { + cfg := Config{ + Availability: validAvailabilityConfig(), + } if err := cfg.Validate(); err != nil { t.Fatalf("Validate: %v", err) } - dur, err := cfg.IntervalDuration() + }) + + t.Run("disabled status ignores configured target", func(t *testing.T) { + cfg := Config{ + Status: StatusConfig{ + Targets: StatusTargetsConfig{ + GitHub: GitHubTargetConfig{Token: "gh-token"}, + }, + }, + } + if err := cfg.Validate(); err != nil { + t.Fatalf("Validate: %v", err) + } + }) + + t.Run("status target requires status source", func(t *testing.T) { + cfg := Config{ + Status: StatusConfig{ + IsEnabled: true, + Sources: StatusSourcesConfig{ + ICal: ICalSourceConfig{Interval: "5m"}, + }, + Targets: StatusTargetsConfig{ + GitHub: GitHubTargetConfig{Token: "gh-token"}, + }, + }, + } + if err := cfg.Validate(); err == nil { + t.Fatal("expected error for missing status source") + } + }) + + t.Run("status enabled requires target", func(t *testing.T) { + cfg := Config{ + Status: StatusConfig{ + IsEnabled: true, + Sources: StatusSourcesConfig{ + ICal: ICalSourceConfig{ + URL: "https://example.com/status.ics", + Interval: "5m", + }, + }, + }, + } + if err := cfg.Validate(); err == nil { + t.Fatal("expected error for missing status target") + } + }) + + t.Run("status target with source", func(t *testing.T) { + cfg := Config{ + Status: StatusConfig{ + IsEnabled: true, + Sources: StatusSourcesConfig{ + ICal: ICalSourceConfig{ + URL: "https://example.com/status.ics", + Interval: "5m", + }, + }, + Targets: StatusTargetsConfig{ + GitHub: GitHubTargetConfig{Token: "gh-token"}, + }, + }, + } + if err := cfg.Validate(); err != nil { + t.Fatalf("Validate: %v", err) + } + }) +} + +func TestICalSourceIntervalDuration(t *testing.T) { + t.Run("valid", func(t *testing.T) { + dur, err := (ICalSourceConfig{Interval: "5m"}).IntervalDuration("status.sources.ical.interval") if err != nil { t.Fatalf("IntervalDuration: %v", err) } - if dur != 0 { - t.Fatalf("duration: got %v, want 0", dur) + if dur != 5*time.Minute { + t.Fatalf("duration: got %v, want 5m", dur) } }) - t.Run("enabled valid", func(t *testing.T) { - cfg := BuildConfig{ - IsEnabled: true, - Interval: "10m", - CfDeployHook: "https://example.com/hook", + t.Run("missing", func(t *testing.T) { + if _, err := (ICalSourceConfig{}).IntervalDuration("status.sources.ical.interval"); err == nil { + t.Fatal("expected error for missing interval") + } + }) + + t.Run("too short", func(t *testing.T) { + if _, err := (ICalSourceConfig{Interval: "30s"}).IntervalDuration("status.sources.ical.interval"); err == nil { + t.Fatal("expected error for short interval") + } + }) +} + +func TestCloudflarePagesTargetValidateAndIntervalDuration(t *testing.T) { + t.Run("valid", func(t *testing.T) { + cfg := CloudflarePagesTargetConfig{ + Interval: "10m", + DeployHook: "https://example.com/hook", } if err := cfg.Validate(); err != nil { t.Fatalf("Validate: %v", err) @@ -466,10 +660,9 @@ func TestBuildValidateAndIntervalDuration(t *testing.T) { }) t.Run("interval too short", func(t *testing.T) { - cfg := BuildConfig{ - IsEnabled: true, - Interval: "30s", - CfDeployHook: "https://example.com/hook", + cfg := CloudflarePagesTargetConfig{ + Interval: "30s", + DeployHook: "https://example.com/hook", } if err := cfg.Validate(); err == nil { t.Fatal("expected error for short interval") @@ -477,9 +670,8 @@ func TestBuildValidateAndIntervalDuration(t *testing.T) { }) t.Run("missing hook", func(t *testing.T) { - cfg := BuildConfig{ - IsEnabled: true, - Interval: "10m", + cfg := CloudflarePagesTargetConfig{ + Interval: "10m", } if err := cfg.Validate(); err == nil { t.Fatal("expected error for missing deploy hook") diff --git a/main.go b/main.go index 66d49f8..a8f51a0 100644 --- a/main.go +++ b/main.go @@ -6,6 +6,7 @@ import ( "net/http" "os" "os/signal" + "strings" "syscall" "time" @@ -34,12 +35,29 @@ func run(logger zerolog.Logger) error { if err != nil { return fmt.Errorf("load config: %w", err) } - if err := cfg.Availability.Validate(); err != nil { - return fmt.Errorf("validate availability config: %w", err) + if err := cfg.Validate(); err != nil { + return fmt.Errorf("validate config: %w", err) } - buildInterval, err := cfg.Build.IntervalDuration() - if err != nil { - return fmt.Errorf("validate build config: %w", err) + var statusInterval time.Duration + if cfg.Status.Enabled() { + statusInterval, err = cfg.Status.Sources.ICal.IntervalDuration("status.sources.ical.interval") + if err != nil { + return fmt.Errorf("validate status source config: %w", err) + } + } + var availabilityInterval time.Duration + if cfg.Availability.Enabled() { + availabilityInterval, err = cfg.Availability.Sources.ICal.IntervalDuration("availability.sources.ical.interval") + if err != nil { + return fmt.Errorf("validate availability source config: %w", err) + } + } + var cloudflarePagesInterval time.Duration + if cfg.Availability.Enabled() { + cloudflarePagesInterval, err = cfg.Availability.Targets.CloudflarePages.IntervalDuration() + if err != nil { + return fmt.Errorf("validate Cloudflare Pages target config: %w", err) + } } // Clear any persisted data on startup to ensure a fresh sync from the calendar. @@ -55,14 +73,16 @@ func run(logger zerolog.Logger) error { ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer cancel() - calClient, err := calendar.NewClient(cfg.CalendarURL) - if err != nil { - return fmt.Errorf("create calendar client: %w", err) + var statusSyncer *calendar.Syncer + if cfg.Status.Enabled() { + targets := buildTargets(cfg.Status.Targets) + calClient, err := calendar.NewClient(cfg.Status.Sources.ICal.URL) + if err != nil { + return fmt.Errorf("create status calendar client: %w", err) + } + statusSyncer = calendar.NewSyncer(st, calClient, targets, logger) } - targets := buildTargets(cfg) - syncer := calendar.NewSyncer(st, calClient, targets, logger) - // Health-check endpoint. mux := http.NewServeMux() mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { @@ -75,32 +95,36 @@ func run(logger zerolog.Logger) error { } // Start the sync loops only after all startup validation succeeds. - go func() { - if err := syncer.Run(ctx, 5*time.Minute); err != nil { - logger.Error().Err(err).Msg("sync loop exited") - } - }() + if statusSyncer != nil { + go func() { + if err := statusSyncer.Run(ctx, statusInterval); err != nil { + logger.Error().Err(err).Msg("status source loop exited") + } + }() + } if availabilitySyncer != nil { go func() { - if err := availabilitySyncer.Run(ctx, 5*time.Minute); err != nil { - logger.Error().Err(err).Msg("availability sync loop exited") + if err := availabilitySyncer.Run(ctx, availabilityInterval); err != nil { + logger.Error().Err(err).Msg("availability source loop exited") } }() } // Start deploy loop if enabled. - startDeployLoop(ctx, cfg.Build, buildInterval, st, logger) + if cfg.Availability.Enabled() { + startDeployLoop(ctx, cfg.Availability.Targets.CloudflarePages, cloudflarePagesInterval, st, logger) + } srv := server.New(cfg.Port, mux, logger) return srv.Start(ctx) } func registerAvailability(ctx context.Context, cfg config.AvailabilityConfig, st *store.Store, mux *http.ServeMux, logger zerolog.Logger) (*availability.Syncer, error) { - if !cfg.IsEnabled { + if !cfg.Enabled() { return nil, nil } - workingHours, err := availability.ParseWorkingHours(cfg.WorkingHours.Start, cfg.WorkingHours.End) + workingHours, err := availability.ParseWorkingHours(cfg.Suppressions.WorkingHours.Start, cfg.Suppressions.WorkingHours.End) if err != nil { return nil, fmt.Errorf("parse availability working hours: %w", err) } @@ -108,12 +132,12 @@ func registerAvailability(ctx context.Context, cfg config.AvailabilityConfig, st if err != nil { return nil, fmt.Errorf("parse availability blocks: %w", err) } - availabilityClient, err := feed.NewClient(cfg.CalendarURL, 30*time.Second) + availabilityClient, err := feed.NewClient(cfg.Sources.ICal.URL, 30*time.Second) if err != nil { return nil, fmt.Errorf("create availability client: %w", err) } - if cfg.ExcludeEnglandBankHolidays { + if cfg.Suppressions.ExcludeEnglandBankHolidays { holidayClient, err := availability.NewHolidayClient() if err != nil { return nil, fmt.Errorf("create bank holiday client: %w", err) @@ -123,8 +147,8 @@ func registerAvailability(ctx context.Context, cfg config.AvailabilityConfig, st } } - provider := availability.NewProvider(st, availabilityBlocks, workingHours, cfg.ExcludeEnglandBankHolidays) - mux.Handle("GET /api/availability", availability.NewHandler(provider, cfg.APIKey, logger)) + provider := availability.NewProvider(st, availabilityBlocks, workingHours, cfg.Suppressions.ExcludeEnglandBankHolidays) + mux.Handle("GET /api/availability", availability.NewHandler(provider, cfg.API.Key, logger)) return availability.NewSyncer(st, provider, availabilityClient, logger), nil } @@ -140,25 +164,21 @@ func parseAvailabilityBlocks(blocks []config.AvailabilityBlockConfig) ([]availab return parsed, nil } -func startDeployLoop(ctx context.Context, cfg config.BuildConfig, interval time.Duration, st *store.Store, logger zerolog.Logger) { - if !cfg.IsEnabled { - return - } - - client := deploy.NewHookClient(cfg.CfDeployHook) +func startDeployLoop(ctx context.Context, cfg config.CloudflarePagesTargetConfig, interval time.Duration, st *store.Store, logger zerolog.Logger) { + client := deploy.NewHookClient(cfg.DeployHook) deployer := deploy.NewDeployer(client, st, logger) go func() { if err := deployer.Run(ctx, interval); err != nil { - logger.Error().Err(err).Msg("build deploy loop exited") + logger.Error().Err(err).Msg("Cloudflare Pages target loop exited") } }() } // buildTargets constructs the list of enabled status targets from config. // A target is enabled when its token is non-empty. -func buildTargets(cfg *config.Config) []target.Target { +func buildTargets(cfg config.StatusTargetsConfig) []target.Target { var targets []target.Target - if t := cfg.Targets.GitHub.Token; t != "" { + if t := strings.TrimSpace(cfg.GitHub.Token); t != "" { targets = append(targets, githubTarget.NewTarget(t)) } return targets