From cd0d503dd81291e4976e47a14ea1990c7a96d990 Mon Sep 17 00:00:00 2001 From: Galdin Raphael Date: Sat, 16 May 2026 09:52:31 +0100 Subject: [PATCH 1/4] Refactor config to match the feature set more accurately --- AGENTS.md | 28 +- README.md | 22 +- chart/values.yaml | 22 +- compose.yaml | 32 ++- config.yaml | 58 +++-- internal/config/config.go | 238 ++++++++++++----- internal/config/config_test.go | 454 ++++++++++++++++++++++----------- main.go | 73 ++++-- 8 files changed, 631 insertions(+), 296 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2c6e691..8e660bd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,8 @@ This repository is a Go service that syncs calendar status and exposes availabil - 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. - 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,13 @@ 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. +- Status sync is enabled only when at least one `status.targets` entry is configured; it fetches `status.sources.ical.url`, stores events, computes the current active event, and syncs enabled targets on `status.sources.ical.interval`. +- Availability sync is enabled when `availability.api.is_enabled` or an `availability.targets` entry is enabled; it fetches `availability.sources.ical.url` on `availability.sources.ical.interval` 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. - 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`. +- `/api/availability` is only registered when `availability.api.is_enabled` is true. +- The availability handler requires an exact `Authorization` header match with `availability.api.key`. ## Build, Test & Lint - `go build ./...` @@ -36,12 +37,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 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.api.is_enabled` is false. - Empty env vars are treated as unset. - Pebble key design includes: - `status` for the current status record @@ -54,10 +56,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 at least one status target is enabled, `status.sources.ical.url` is required; otherwise availability can run without a status calendar. +- If availability API or availability targets are enabled, `availability.sources.ical.url` and availability blocks 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 +71,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 API or availability targets are enabled 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. - 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.targets.cloudflare_pages.is_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..bf24bb7 100644 --- a/README.md +++ b/README.md @@ -5,16 +5,30 @@ 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_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 at least one 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_API_IS_ENABLED=true` to expose `/api/availability` from a separate calendar feed. - `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. +- `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 + +Set `AVAILABILITY_TARGETS_CLOUDFLARE_PAGES_IS_ENABLED=true` to trigger Cloudflare +Pages deploys 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/values.yaml b/chart/values.yaml index d7be4c8..072700b 100644 --- a/chart/values.yaml +++ b/chart/values.yaml @@ -23,11 +23,20 @@ fullnameOverride: "" env: [] # - name: EXAMPLE_ENV_VAR # value: "example-value" + # Status env vars supported by the app: + # - 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 - # 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_IS_ENABLED + # value: "true" # - name: AVAILABILITY_API_KEY # value: "secret-key" # - name: AVAILABILITY_WORKING_HOURS_START @@ -36,6 +45,13 @@ env: [] # value: "17:50" # - name: AVAILABILITY_EXCLUDE_ENGLAND_BANK_HOLIDAYS # value: "true" + # Cloudflare Pages target env vars supported by the app: + # - name: AVAILABILITY_TARGETS_CLOUDFLARE_PAGES_IS_ENABLED + # value: "true" + # - 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..e63e6b3 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_SOURCES_ICAL_URL +# STATUS_SOURCES_ICAL_INTERVAL +# STATUS_TARGETS_GITHUB_TOKEN +# AVAILABILITY_SOURCES_ICAL_URL +# AVAILABILITY_SOURCES_ICAL_INTERVAL +# AVAILABILITY_API_IS_ENABLED # 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_TARGETS_CLOUDFLARE_PAGES_IS_ENABLED +# 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_SOURCES_ICAL_URL: ${STATUS_SOURCES_ICAL_URL} + STATUS_SOURCES_ICAL_INTERVAL: ${STATUS_SOURCES_ICAL_INTERVAL} + STATUS_TARGETS_GITHUB_TOKEN: ${STATUS_TARGETS_GITHUB_TOKEN} + AVAILABILITY_SOURCES_ICAL_URL: ${AVAILABILITY_SOURCES_ICAL_URL} + AVAILABILITY_SOURCES_ICAL_INTERVAL: ${AVAILABILITY_SOURCES_ICAL_INTERVAL} + AVAILABILITY_API_IS_ENABLED: ${AVAILABILITY_API_IS_ENABLED} 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_TARGETS_CLOUDFLARE_PAGES_IS_ENABLED: ${AVAILABILITY_TARGETS_CLOUDFLARE_PAGES_IS_ENABLED} + 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..8ad98d3 100644 --- a/config.yaml +++ b/config.yaml @@ -1,29 +1,49 @@ 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: "" +status: + sources: + ical: + # iCal URL for your status calendar (required when a status target is enabled). + url: https://example.com/status.ics + # Status calendar fetch interval. + interval: 5m + targets: + github: + # GitHub personal access token (optional, enables GitHub status sync). + # Scopes required: user scope for status updates. + # token: ghp_xyz + token: "" availability: - # Disabled by default. Set to true to enable the availability API. - is_enabled: false + sources: + ical: + # Separate iCal URL for availability data. + url: https://example.com/availability.ics + # Availability calendar fetch interval. + interval: 5m + + api: + # Disabled by default. Set to true to expose /api/availability. + is_enabled: false + # Exact Authorization header value required by /api/availability. + key: "" + + targets: + cloudflare_pages: + # 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 AVAILABILITY_TARGETS_CLOUDFLARE_PAGES_DEPLOY_HOOK for secrets. + deploy_hook: "" + # 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: "" # Ordered availability windows, checked from top to bottom. blocks: - name: All day @@ -44,11 +64,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..cfec68e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -16,37 +16,100 @@ 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 { + 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"` + Sources AvailabilitySourcesConfig `koanf:"sources"` + API AvailabilityAPIConfig `koanf:"api"` + Targets AvailabilityTargetsConfig `koanf:"targets"` WorkingHours AvailabilityWorkingHoursConfig `koanf:"working_hours"` ExcludeEnglandBankHolidays bool `koanf:"exclude_england_bank_holidays"` Blocks []AvailabilityBlockConfig `koanf:"blocks"` } +// AvailabilitySourcesConfig configures availability data sources. +type AvailabilitySourcesConfig struct { + ICal ICalSourceConfig `koanf:"ical"` +} + +// AvailabilityAPIConfig configures the optional availability HTTP API. +type AvailabilityAPIConfig struct { + IsEnabled bool `koanf:"is_enabled"` + 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 { + IsEnabled bool `koanf:"is_enabled"` + Interval string `koanf:"interval"` + DeployHook string `koanf:"deploy_hook"` +} + +// Enabled reports whether the Cloudflare Pages target is configured. +func (c CloudflarePagesTargetConfig) Enabled() bool { + return c.IsEnabled +} + +// Enabled reports whether any availability target is configured. +func (t AvailabilityTargetsConfig) Enabled() bool { + return t.CloudflarePages.Enabled() +} + +// Enabled reports whether availability computation is needed. +func (a AvailabilityConfig) Enabled() bool { + return a.API.IsEnabled || a.Targets.Enabled() +} + // AvailabilityWorkingHoursConfig defines the weekday working-hours window. type AvailabilityWorkingHoursConfig struct { Start string `koanf:"start"` // HH:MM, 24-hour clock @@ -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_SOURCES_ICAL_URL": "status.sources.ical.url", + "STATUS_SOURCES_ICAL_INTERVAL": "status.sources.ical.interval", + "STATUS_TARGETS_GITHUB_TOKEN": "status.targets.github.token", + "AVAILABILITY_SOURCES_ICAL_URL": "availability.sources.ical.url", + "AVAILABILITY_SOURCES_ICAL_INTERVAL": "availability.sources.ical.interval", + "AVAILABILITY_API_IS_ENABLED": "availability.api.is_enabled", + "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", + "AVAILABILITY_TARGETS_CLOUDFLARE_PAGES_IS_ENABLED": "availability.targets.cloudflare_pages.is_enabled", + "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.sources.ical.interval": "5m", + "availability.sources.ical.interval": "5m", + "availability.working_hours.start": "09:00", + "availability.working_hours.end": "17:50", + "availability.exclude_england_bank_holidays": false, + "availability.api.is_enabled": false, + "availability.targets.cloudflare_pages.is_enabled": 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,24 +195,66 @@ 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.Targets.Enabled() { + return nil + } + if strings.TrimSpace(s.Sources.ICal.URL) == "" { + return fmt.Errorf("status.sources.ical.url is required when at least one status target is enabled") + } + if _, err := s.Sources.ICal.IntervalDuration("status.sources.ical.interval"); err != nil { + return err + } + 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 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.API.IsEnabled && strings.TrimSpace(a.API.Key) == "" { + return fmt.Errorf("availability.api.key is required when availability API is enabled") + } if a.WorkingHours.Start == "" { return fmt.Errorf("availability.working_hours.start is required when availability is enabled") } if a.WorkingHours.End == "" { return fmt.Errorf("availability.working_hours.end is required when availability is enabled") } - if a.CalendarURL == "" { - return fmt.Errorf("availability.calendar_url is required when availability is enabled") - } - if a.APIKey == "" { - return fmt.Errorf("availability.api_key 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) } @@ -172,35 +275,40 @@ 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 { +// Validate checks the Cloudflare Pages target settings. +func (c CloudflarePagesTargetConfig) Validate() error { + if !c.IsEnabled { return nil } - _, err := b.IntervalDuration() - return err + 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 Cloudflare Pages target is enabled") + } + return nil } -// IntervalDuration returns the validated build interval. -func (b BuildConfig) IntervalDuration() (time.Duration, error) { - if !b.IsEnabled { +// IntervalDuration returns the validated Cloudflare Pages publish interval. +func (c CloudflarePagesTargetConfig) IntervalDuration() (time.Duration, error) { + if !c.IsEnabled { return 0, nil } - if strings.TrimSpace(b.Interval) == "" { - return 0, fmt.Errorf("build.interval is required when build is enabled") + if strings.TrimSpace(c.Interval) == "" { + return 0, fmt.Errorf("availability.targets.cloudflare_pages.interval is required when Cloudflare Pages target 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..5433289 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_SOURCES_ICAL_URL", + "STATUS_SOURCES_ICAL_INTERVAL", + "STATUS_TARGETS_GITHUB_TOKEN", + "AVAILABILITY_SOURCES_ICAL_URL", + "AVAILABILITY_SOURCES_ICAL_INTERVAL", + "AVAILABILITY_API_IS_ENABLED", "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_TARGETS_CLOUDFLARE_PAGES_IS_ENABLED", + "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,8 +68,20 @@ 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.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.Sources.ICal.Interval != "5m" { + t.Errorf("Availability.Sources.ICal.Interval: got %q, want 5m", cfg.Availability.Sources.ICal.Interval) } 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) @@ -76,8 +89,14 @@ func TestLoad_Defaults(t *testing.T) { if cfg.Availability.ExcludeEnglandBankHolidays { t.Error("expected bank holiday exclusion to be disabled by default") } - if cfg.Availability.IsEnabled { - t.Error("expected availability to be disabled by default") + if cfg.Availability.API.IsEnabled { + t.Error("expected availability API to be disabled by default") + } + if cfg.Availability.Targets.CloudflarePages.IsEnabled { + t.Error("expected Cloudflare Pages target 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) } } @@ -87,8 +106,9 @@ 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_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 +120,14 @@ 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.Status.Sources.ICal.Interval != "15m" { + t.Errorf("Status.Sources.ICal.Interval: got %q", cfg.Status.Sources.ICal.Interval) } - if cfg.Targets.GitHub.Token != "gh-abc123" { - t.Errorf("Targets.GitHub.Token: got %q", cfg.Targets.GitHub.Token) + if cfg.Status.Targets.GitHub.Token != "gh-abc123" { + t.Errorf("Status.Targets.GitHub.Token: got %q", cfg.Status.Targets.GitHub.Token) } } @@ -112,25 +135,32 @@ 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_SOURCES_ICAL_URL", "https://availability.example.com/ical.ics") + t.Setenv("AVAILABILITY_SOURCES_ICAL_INTERVAL", "7m") + t.Setenv("AVAILABILITY_API_IS_ENABLED", "true") 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_TARGETS_CLOUDFLARE_PAGES_IS_ENABLED", "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.IsEnabled { - t.Fatal("expected availability to be enabled from env") + 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.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.IsEnabled { + t.Fatal("expected availability API to be enabled from env") + } + 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) @@ -138,6 +168,15 @@ func TestLoad_AvailabilityFromEnv(t *testing.T) { if !cfg.Availability.ExcludeEnglandBankHolidays { t.Error("expected holiday exclusion to be enabled from env") } + if !cfg.Availability.Targets.CloudflarePages.IsEnabled { + t.Fatal("expected Cloudflare Pages target 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,14 @@ 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: + sources: + ical: + url: https://yaml-cal.example.com/ical.ics + interval: 12m + targets: + github: + token: gh-yaml ` writeConfigYAML(t, dir, yaml) @@ -176,11 +219,14 @@ 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.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,9 +237,18 @@ func TestLoad_AvailabilityFromYAML(t *testing.T) { yaml := ` availability: - is_enabled: true - calendar_url: https://availability.example.com/ical.ics - api_key: secret-yaml-key + sources: + ical: + url: https://availability.example.com/ical.ics + interval: 8m + api: + is_enabled: true + key: secret-yaml-key + targets: + cloudflare_pages: + is_enabled: true + interval: 14m + deploy_hook: https://example.com/hook working_hours: start: "09:00" end: "17:50" @@ -212,14 +267,17 @@ availability: if err != nil { t.Fatalf("Load: %v", err) } - if !cfg.Availability.IsEnabled { - t.Fatal("expected availability to be enabled") + 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.Sources.ICal.Interval != "8m" { + t.Errorf("Availability.Sources.ICal.Interval: got %q", cfg.Availability.Sources.ICal.Interval) } - if cfg.Availability.CalendarURL != "https://availability.example.com/ical.ics" { - t.Errorf("Availability.CalendarURL: got %q", cfg.Availability.CalendarURL) + if !cfg.Availability.API.IsEnabled { + t.Fatal("expected availability API to be enabled") } - 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) @@ -227,6 +285,15 @@ availability: if !cfg.Availability.ExcludeEnglandBankHolidays { t.Error("expected holiday exclusion to be enabled from YAML") } + if !cfg.Availability.Targets.CloudflarePages.IsEnabled { + t.Fatal("expected Cloudflare Pages target to be enabled") + } + 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,61 +309,36 @@ func TestLoad_EnvOverridesYAML(t *testing.T) { yaml := ` port: 7777 -calendar_url: https://yaml-cal.example.com/ical.ics +status: + sources: + ical: + url: https://yaml-cal.example.com/ical.ics + interval: 20m + targets: + github: + token: gh-yaml availability: + sources: + ical: + url: https://yaml-availability.example.com/ical.ics + interval: 30m + api: + is_enabled: false + key: yaml-key working_hours: start: "10:00" end: "16:00" exclude_england_bank_holidays: false -targets: - github: - token: gh-yaml ` 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") - - 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) - } - if cfg.CalendarURL != "https://env-cal.example.com/ical.ics" { - t.Errorf("CalendarURL: got %q, want env value", cfg.CalendarURL) - } -} - -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("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_SOURCES_ICAL_URL", "https://env-availability.example.com/ical.ics") + t.Setenv("AVAILABILITY_SOURCES_ICAL_INTERVAL", "9m") + t.Setenv("AVAILABILITY_API_IS_ENABLED", "true") t.Setenv("AVAILABILITY_API_KEY", "env-key") t.Setenv("AVAILABILITY_WORKING_HOURS_START", "08:45") t.Setenv("AVAILABILITY_WORKING_HOURS_END", "17:15") @@ -306,14 +348,29 @@ availability: if err != nil { t.Fatalf("Load: %v", err) } - if !cfg.Availability.IsEnabled { - t.Fatal("expected env to enable availability") + if cfg.Port != 9999 { + t.Errorf("Port: got %d, want 9999", cfg.Port) + } + if cfg.Status.Targets.GitHub.Token != "gh-env" { + t.Errorf("Status.Targets.GitHub.Token: got %q, want gh-env", cfg.Status.Targets.GitHub.Token) + } + 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.CalendarURL != "https://env-availability.example.com/ical.ics" { - t.Errorf("Availability.CalendarURL: got %q", cfg.Availability.CalendarURL) + 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.APIKey != "env-key" { - t.Errorf("Availability.APIKey: got %q", cfg.Availability.APIKey) + 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.API.IsEnabled { + t.Fatal("expected env to enable availability API") + } + 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) @@ -330,7 +387,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 +403,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.CalendarURL != "https://cal.example.com/ical.ics" { - t.Errorf("CalendarURL: got %q", cfg.CalendarURL) + if cfg.Status.Sources.ICal.Interval != "11m" { + t.Errorf("Status.Sources.ICal.Interval: got %q, want 11m", cfg.Status.Sources.ICal.Interval) + } + 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 +425,29 @@ 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{ + Sources: AvailabilitySourcesConfig{ + ICal: ICalSourceConfig{ + URL: "https://example.com/availability.ics", + Interval: "5m", + }, + }, + API: AvailabilityAPIConfig{ + IsEnabled: true, + Key: "secret", + }, + WorkingHours: AvailabilityWorkingHoursConfig{ + Start: "09:00", + End: "17:50", + }, + Blocks: []AvailabilityBlockConfig{ + {Name: "Morning", Start: "09:00", End: "12:00"}, + }, } } @@ -371,70 +458,139 @@ 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("API enabled valid", func(t *testing.T) { + cfg := validAvailabilityConfig() + if err := cfg.Validate(); err != nil { + t.Fatalf("Validate: %v", err) + } + }) + + t.Run("Cloudflare Pages enabled valid without API key", func(t *testing.T) { + cfg := validAvailabilityConfig() + cfg.API = AvailabilityAPIConfig{} + cfg.Targets.CloudflarePages = CloudflarePagesTargetConfig{ + IsEnabled: true, + Interval: "10m", + DeployHook: "https://example.com/hook", } 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("API enabled missing 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 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.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.WorkingHours.Start = "" if err := cfg.Validate(); err == nil { t.Fatal("expected error for missing working hours start") } }) t.Run("enabled incomplete", func(t *testing.T) { - cfg := AvailabilityConfig{IsEnabled: true} + cfg := AvailabilityConfig{API: AvailabilityAPIConfig{IsEnabled: true}} if err := cfg.Validate(); err == nil { t.Fatal("expected error for incomplete enabled availability config") } }) } -func TestBuildValidateAndIntervalDuration(t *testing.T) { +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) + } + }) + + t.Run("status target requires status source", func(t *testing.T) { + cfg := Config{ + Status: StatusConfig{ + 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 target with source", func(t *testing.T) { + cfg := Config{ + Status: StatusConfig{ + 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 != 5*time.Minute { + t.Fatalf("duration: got %v, want 5m", dur) + } + }) + + 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("disabled", func(t *testing.T) { - cfg := BuildConfig{} + cfg := CloudflarePagesTargetConfig{} if err := cfg.Validate(); err != nil { t.Fatalf("Validate: %v", err) } @@ -448,10 +604,10 @@ func TestBuildValidateAndIntervalDuration(t *testing.T) { }) t.Run("enabled valid", func(t *testing.T) { - cfg := BuildConfig{ - IsEnabled: true, - Interval: "10m", - CfDeployHook: "https://example.com/hook", + cfg := CloudflarePagesTargetConfig{ + IsEnabled: true, + Interval: "10m", + DeployHook: "https://example.com/hook", } if err := cfg.Validate(); err != nil { t.Fatalf("Validate: %v", err) @@ -466,10 +622,10 @@ 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{ + IsEnabled: true, + Interval: "30s", + DeployHook: "https://example.com/hook", } if err := cfg.Validate(); err == nil { t.Fatal("expected error for short interval") @@ -477,7 +633,7 @@ func TestBuildValidateAndIntervalDuration(t *testing.T) { }) t.Run("missing hook", func(t *testing.T) { - cfg := BuildConfig{ + cfg := CloudflarePagesTargetConfig{ IsEnabled: true, Interval: "10m", } diff --git a/main.go b/main.go index 66d49f8..90f6461 100644 --- a/main.go +++ b/main.go @@ -6,6 +6,7 @@ import ( "net/http" "os" "os/signal" + "strings" "syscall" "time" @@ -34,12 +35,26 @@ 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() + var statusInterval time.Duration + if cfg.Status.Targets.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) + } + } + cloudflarePagesInterval, err := cfg.Availability.Targets.CloudflarePages.IntervalDuration() if err != nil { - return fmt.Errorf("validate build config: %w", err) + 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 +70,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) + targets := buildTargets(cfg.Status.Targets) + var statusSyncer *calendar.Syncer + if len(targets) > 0 { + 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,28 +92,30 @@ 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) + 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 } @@ -108,7 +127,7 @@ 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) } @@ -124,7 +143,9 @@ 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)) + if cfg.API.IsEnabled { + mux.Handle("GET /api/availability", availability.NewHandler(provider, cfg.API.Key, logger)) + } return availability.NewSyncer(st, provider, availabilityClient, logger), nil } @@ -140,25 +161,25 @@ 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) { +func startDeployLoop(ctx context.Context, cfg config.CloudflarePagesTargetConfig, interval time.Duration, st *store.Store, logger zerolog.Logger) { if !cfg.IsEnabled { return } - client := deploy.NewHookClient(cfg.CfDeployHook) + 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 From 538113e6e2ae817f9c672a166da10bee902e0cce Mon Sep 17 00:00:00 2001 From: Galdin Raphael Date: Sat, 16 May 2026 13:26:45 +0100 Subject: [PATCH 2/4] Refactor config even more for explicit suppressions support --- AGENTS.md | 8 ++-- README.md | 4 +- chart/values.yaml | 6 +-- compose.yaml | 12 +++--- config.yaml | 13 +++--- internal/config/config.go | 79 ++++++++++++++++++---------------- internal/config/config_test.go | 72 ++++++++++++++++--------------- main.go | 6 +-- 8 files changed, 105 insertions(+), 95 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8e660bd..86efdd3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,7 +5,7 @@ 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. +- Optional availability API reads a separate calendar, applies weekday `suppressions.working_hours.start/end`, and returns the first free block per day for the next 10 days. - 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 config is organized by top-level `status` and `availability` domains, each with nested `sources` and `targets` where applicable. @@ -23,9 +23,9 @@ This repository is a Go service that syncs calendar status and exposes availabil ## Sync Flow - Status sync is enabled only when at least one `status.targets` entry is configured; it fetches `status.sources.ical.url`, stores events, computes the current active event, and syncs enabled targets on `status.sources.ical.interval`. - Availability sync is enabled when `availability.api.is_enabled` or an `availability.targets` entry is enabled; it fetches `availability.sources.ical.url` on `availability.sources.ical.interval` 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. +- 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. +- 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.api.is_enabled` is true. - The availability handler requires an exact `Authorization` header match with `availability.api.key`. @@ -40,7 +40,7 @@ This repository is a Go service that syncs calendar status and exposes availabil - 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 `availability.api.is_enabled` is false. diff --git a/README.md b/README.md index bf24bb7..3af0a74 100644 --- a/README.md +++ b/README.md @@ -20,8 +20,8 @@ status calendar fetch interval is configured with Set `AVAILABILITY_API_IS_ENABLED=true` to expose `/api/availability` from a separate calendar feed. -- `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_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`. diff --git a/chart/values.yaml b/chart/values.yaml index 072700b..452ecfd 100644 --- a/chart/values.yaml +++ b/chart/values.yaml @@ -39,11 +39,11 @@ env: [] # value: "true" # - 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_IS_ENABLED diff --git a/compose.yaml b/compose.yaml index e63e6b3..05cf46d 100644 --- a/compose.yaml +++ b/compose.yaml @@ -10,9 +10,9 @@ # AVAILABILITY_SOURCES_ICAL_INTERVAL # AVAILABILITY_API_IS_ENABLED # AVAILABILITY_API_KEY -# AVAILABILITY_WORKING_HOURS_START -# AVAILABILITY_WORKING_HOURS_END -# AVAILABILITY_EXCLUDE_ENGLAND_BANK_HOLIDAYS +# AVAILABILITY_SUPPRESSIONS_WORKING_HOURS_START +# AVAILABILITY_SUPPRESSIONS_WORKING_HOURS_END +# AVAILABILITY_SUPPRESSIONS_EXCLUDE_ENGLAND_BANK_HOLIDAYS # AVAILABILITY_TARGETS_CLOUDFLARE_PAGES_IS_ENABLED # AVAILABILITY_TARGETS_CLOUDFLARE_PAGES_INTERVAL # AVAILABILITY_TARGETS_CLOUDFLARE_PAGES_DEPLOY_HOOK @@ -39,9 +39,9 @@ services: AVAILABILITY_SOURCES_ICAL_INTERVAL: ${AVAILABILITY_SOURCES_ICAL_INTERVAL} AVAILABILITY_API_IS_ENABLED: ${AVAILABILITY_API_IS_ENABLED} 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} + 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_IS_ENABLED: ${AVAILABILITY_TARGETS_CLOUDFLARE_PAGES_IS_ENABLED} 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 8ad98d3..599b732 100644 --- a/config.yaml +++ b/config.yaml @@ -38,12 +38,13 @@ availability: # Cloudflare Pages build hook URL. Prefer AVAILABILITY_TARGETS_CLOUDFLARE_PAGES_DEPLOY_HOOK for secrets. deploy_hook: "" - # 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 + 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 diff --git a/internal/config/config.go b/internal/config/config.go index cfec68e..77976a3 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -64,12 +64,11 @@ func (t StatusTargetsConfig) Enabled() bool { // AvailabilityConfig configures availability sources, serving, targets, and rules. type AvailabilityConfig struct { - Sources AvailabilitySourcesConfig `koanf:"sources"` - API AvailabilityAPIConfig `koanf:"api"` - Targets AvailabilityTargetsConfig `koanf:"targets"` - WorkingHours AvailabilityWorkingHoursConfig `koanf:"working_hours"` - ExcludeEnglandBankHolidays bool `koanf:"exclude_england_bank_holidays"` - Blocks []AvailabilityBlockConfig `koanf:"blocks"` + 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. @@ -110,6 +109,12 @@ func (a AvailabilityConfig) Enabled() bool { return a.API.IsEnabled || a.Targets.Enabled() } +// AvailabilitySuppressionsConfig configures availability suppression rules. +type AvailabilitySuppressionsConfig struct { + WorkingHours AvailabilityWorkingHoursConfig `koanf:"working_hours"` + ExcludeEnglandBankHolidays bool `koanf:"exclude_england_bank_holidays"` +} + // AvailabilityWorkingHoursConfig defines the weekday working-hours window. type AvailabilityWorkingHoursConfig struct { Start string `koanf:"start"` // HH:MM, 24-hour clock @@ -126,21 +131,21 @@ type AvailabilityBlockConfig struct { // 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", - "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_SOURCES_ICAL_URL": "availability.sources.ical.url", - "AVAILABILITY_SOURCES_ICAL_INTERVAL": "availability.sources.ical.interval", - "AVAILABILITY_API_IS_ENABLED": "availability.api.is_enabled", - "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", - "AVAILABILITY_TARGETS_CLOUDFLARE_PAGES_IS_ENABLED": "availability.targets.cloudflare_pages.is_enabled", - "AVAILABILITY_TARGETS_CLOUDFLARE_PAGES_INTERVAL": "availability.targets.cloudflare_pages.interval", - "AVAILABILITY_TARGETS_CLOUDFLARE_PAGES_DEPLOY_HOOK": "availability.targets.cloudflare_pages.deploy_hook", + "PORT": "port", + "PEBBLE_PATH": "pebble_path", + "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_SOURCES_ICAL_URL": "availability.sources.ical.url", + "AVAILABILITY_SOURCES_ICAL_INTERVAL": "availability.sources.ical.interval", + "AVAILABILITY_API_IS_ENABLED": "availability.api.is_enabled", + "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_IS_ENABLED": "availability.targets.cloudflare_pages.is_enabled", + "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. @@ -156,16 +161,16 @@ func Load() (*Config, error) { // 1. Defaults. if err := k.Load(confmap.Provider(map[string]interface{}{ - "port": 8080, - "pebble_path": "./data", - "status.sources.ical.interval": "5m", - "availability.sources.ical.interval": "5m", - "availability.working_hours.start": "09:00", - "availability.working_hours.end": "17:50", - "availability.exclude_england_bank_holidays": false, - "availability.api.is_enabled": false, - "availability.targets.cloudflare_pages.is_enabled": false, - "availability.targets.cloudflare_pages.interval": "10m", + "port": 8080, + "pebble_path": "./data", + "status.sources.ical.interval": "5m", + "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.api.is_enabled": false, + "availability.targets.cloudflare_pages.is_enabled": false, + "availability.targets.cloudflare_pages.interval": "10m", }, "."), nil); err != nil { return nil, fmt.Errorf("load defaults: %w", err) } @@ -249,14 +254,14 @@ func (a AvailabilityConfig) Validate() error { if a.API.IsEnabled && strings.TrimSpace(a.API.Key) == "" { return fmt.Errorf("availability.api.key is required when availability API is enabled") } - if a.WorkingHours.Start == "" { - return fmt.Errorf("availability.working_hours.start 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.WorkingHours.End == "" { - return fmt.Errorf("availability.working_hours.end 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") diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 5433289..fe98fd2 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -32,9 +32,9 @@ var configEnvKeys = []string{ "AVAILABILITY_SOURCES_ICAL_INTERVAL", "AVAILABILITY_API_IS_ENABLED", "AVAILABILITY_API_KEY", - "AVAILABILITY_WORKING_HOURS_START", - "AVAILABILITY_WORKING_HOURS_END", - "AVAILABILITY_EXCLUDE_ENGLAND_BANK_HOLIDAYS", + "AVAILABILITY_SUPPRESSIONS_WORKING_HOURS_START", + "AVAILABILITY_SUPPRESSIONS_WORKING_HOURS_END", + "AVAILABILITY_SUPPRESSIONS_EXCLUDE_ENGLAND_BANK_HOLIDAYS", "AVAILABILITY_TARGETS_CLOUDFLARE_PAGES_IS_ENABLED", "AVAILABILITY_TARGETS_CLOUDFLARE_PAGES_INTERVAL", "AVAILABILITY_TARGETS_CLOUDFLARE_PAGES_DEPLOY_HOOK", @@ -83,10 +83,10 @@ func TestLoad_Defaults(t *testing.T) { 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.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.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.ExcludeEnglandBankHolidays { + if cfg.Availability.Suppressions.ExcludeEnglandBankHolidays { t.Error("expected bank holiday exclusion to be disabled by default") } if cfg.Availability.API.IsEnabled { @@ -139,9 +139,9 @@ func TestLoad_AvailabilityFromEnv(t *testing.T) { t.Setenv("AVAILABILITY_SOURCES_ICAL_INTERVAL", "7m") t.Setenv("AVAILABILITY_API_IS_ENABLED", "true") 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_IS_ENABLED", "true") t.Setenv("AVAILABILITY_TARGETS_CLOUDFLARE_PAGES_INTERVAL", "12m") t.Setenv("AVAILABILITY_TARGETS_CLOUDFLARE_PAGES_DEPLOY_HOOK", "https://example.com/hook") @@ -162,10 +162,10 @@ func TestLoad_AvailabilityFromEnv(t *testing.T) { 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.IsEnabled { @@ -249,10 +249,11 @@ availability: is_enabled: true interval: 14m deploy_hook: https://example.com/hook - working_hours: - start: "09:00" - end: "17:50" - exclude_england_bank_holidays: true + suppressions: + working_hours: + start: "09:00" + end: "17:50" + exclude_england_bank_holidays: true blocks: - name: First half start: "09:00" @@ -279,10 +280,10 @@ availability: 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.IsEnabled { @@ -325,10 +326,11 @@ availability: api: is_enabled: false key: yaml-key - working_hours: - start: "10:00" - end: "16:00" - exclude_england_bank_holidays: false + suppressions: + working_hours: + start: "10:00" + end: "16:00" + exclude_england_bank_holidays: false ` writeConfigYAML(t, dir, yaml) @@ -340,9 +342,9 @@ availability: t.Setenv("AVAILABILITY_SOURCES_ICAL_INTERVAL", "9m") t.Setenv("AVAILABILITY_API_IS_ENABLED", "true") 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") + 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 { @@ -372,10 +374,10 @@ availability: 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") } } @@ -441,9 +443,11 @@ func validAvailabilityConfig() AvailabilityConfig { IsEnabled: true, Key: "secret", }, - WorkingHours: AvailabilityWorkingHoursConfig{ - Start: "09:00", - End: "17:50", + Suppressions: AvailabilitySuppressionsConfig{ + WorkingHours: AvailabilityWorkingHoursConfig{ + Start: "09:00", + End: "17:50", + }, }, Blocks: []AvailabilityBlockConfig{ {Name: "Morning", Start: "09:00", End: "12:00"}, @@ -496,7 +500,7 @@ func TestAvailabilityValidate(t *testing.T) { t.Run("enabled invalid working hours", func(t *testing.T) { cfg := validAvailabilityConfig() - cfg.WorkingHours.Start = "bad-value" + cfg.Suppressions.WorkingHours.Start = "bad-value" if err := cfg.Validate(); err == nil { t.Fatal("expected error for invalid working hours") } @@ -504,7 +508,7 @@ func TestAvailabilityValidate(t *testing.T) { t.Run("enabled missing working hours start", func(t *testing.T) { cfg := validAvailabilityConfig() - cfg.WorkingHours.Start = "" + cfg.Suppressions.WorkingHours.Start = "" if err := cfg.Validate(); err == nil { t.Fatal("expected error for missing working hours start") } diff --git a/main.go b/main.go index 90f6461..3f43c7d 100644 --- a/main.go +++ b/main.go @@ -119,7 +119,7 @@ func registerAvailability(ctx context.Context, cfg config.AvailabilityConfig, st 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) } @@ -132,7 +132,7 @@ func registerAvailability(ctx context.Context, cfg config.AvailabilityConfig, st 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) @@ -142,7 +142,7 @@ func registerAvailability(ctx context.Context, cfg config.AvailabilityConfig, st } } - provider := availability.NewProvider(st, availabilityBlocks, workingHours, cfg.ExcludeEnglandBankHolidays) + provider := availability.NewProvider(st, availabilityBlocks, workingHours, cfg.Suppressions.ExcludeEnglandBankHolidays) if cfg.API.IsEnabled { mux.Handle("GET /api/availability", availability.NewHandler(provider, cfg.API.Key, logger)) } From 3232aef577a8a57587001d9aabac3ee04f08150e Mon Sep 17 00:00:00 2001 From: Galdin Raphael Date: Sat, 16 May 2026 18:22:45 +0100 Subject: [PATCH 3/4] More robust feature gating using config --- AGENTS.md | 23 ++--- README.md | 13 +-- chart/values.yaml | 8 +- compose.yaml | 8 +- config.yaml | 23 ++--- internal/config/config.go | 54 +++++------- internal/config/config_test.go | 152 ++++++++++++++++++++------------- main.go | 27 +++--- 8 files changed, 168 insertions(+), 140 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 86efdd3..4f08a0e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,8 +4,8 @@ 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 `suppressions.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 config is organized by top-level `status` and `availability` domains, each with nested `sources` and `targets` where applicable. @@ -21,12 +21,13 @@ 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 is enabled only when at least one `status.targets` entry is configured; it fetches `status.sources.ical.url`, stores events, computes the current active event, and syncs enabled targets on `status.sources.ical.interval`. -- Availability sync is enabled when `availability.api.is_enabled` or an `availability.targets` entry is enabled; it fetches `availability.sources.ical.url` on `availability.sources.ical.interval` and stores the raw ICS body plus timezone metadata in Pebble. +- 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.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.api.is_enabled` is true. +- `/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 @@ -43,7 +44,7 @@ This repository is a Go service that syncs calendar status and exposes availabil - `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 `availability.api.is_enabled` is false. +- 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 @@ -56,8 +57,8 @@ 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 at least one status target is enabled, `status.sources.ical.url` is required; otherwise availability can run without a status calendar. -- If availability API or availability targets are enabled, `availability.sources.ical.url` and availability blocks are required. +- 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`. @@ -71,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 availability API or availability targets are enabled but availability config is 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: `availability.targets.cloudflare_pages.is_enabled` (bool), `availability.targets.cloudflare_pages.interval` (Go duration string, e.g., "10m"), `availability.targets.cloudflare_pages.deploy_hook` (Pages Build Hook URL). +- 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 3af0a74..23a31f4 100644 --- a/README.md +++ b/README.md @@ -5,20 +5,23 @@ Personal app to sync my calendar status with GitHub and expose availability from ## Quickstart ```bash +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 at least one status target is configured. The -status calendar fetch interval is configured with +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_API_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. @@ -27,8 +30,8 @@ Set `AVAILABILITY_API_IS_ENABLED=true` to expose `/api/availability` from a sepa ## Cloudflare Pages -Set `AVAILABILITY_TARGETS_CLOUDFLARE_PAGES_IS_ENABLED=true` to trigger Cloudflare -Pages deploys when computed availability changes. Configure the build hook with +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/values.yaml b/chart/values.yaml index 452ecfd..a6706ab 100644 --- a/chart/values.yaml +++ b/chart/values.yaml @@ -24,6 +24,8 @@ 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 @@ -31,12 +33,12 @@ env: [] # - name: STATUS_TARGETS_GITHUB_TOKEN # value: "ghp_xyz" # Availability env vars supported by the app: + # - name: AVAILABILITY_ENABLED + # value: "true" # - name: AVAILABILITY_SOURCES_ICAL_URL # value: "https://example.com/availability.ics" # - name: AVAILABILITY_SOURCES_ICAL_INTERVAL # value: "5m" - # - name: AVAILABILITY_API_IS_ENABLED - # value: "true" # - name: AVAILABILITY_API_KEY # value: "secret-key" # - name: AVAILABILITY_SUPPRESSIONS_WORKING_HOURS_START @@ -46,8 +48,6 @@ env: [] # - name: AVAILABILITY_SUPPRESSIONS_EXCLUDE_ENGLAND_BANK_HOLIDAYS # value: "true" # Cloudflare Pages target env vars supported by the app: - # - name: AVAILABILITY_TARGETS_CLOUDFLARE_PAGES_IS_ENABLED - # value: "true" # - name: AVAILABILITY_TARGETS_CLOUDFLARE_PAGES_INTERVAL # value: "10m" # - name: AVAILABILITY_TARGETS_CLOUDFLARE_PAGES_DEPLOY_HOOK diff --git a/compose.yaml b/compose.yaml index 05cf46d..e407e92 100644 --- a/compose.yaml +++ b/compose.yaml @@ -3,17 +3,17 @@ # Optional environment variables (override config.yaml values): # PORT (default: 8080) # PEBBLE_PATH (default: ./data) +# 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_IS_ENABLED # AVAILABILITY_API_KEY # AVAILABILITY_SUPPRESSIONS_WORKING_HOURS_START # AVAILABILITY_SUPPRESSIONS_WORKING_HOURS_END # AVAILABILITY_SUPPRESSIONS_EXCLUDE_ENGLAND_BANK_HOLIDAYS -# AVAILABILITY_TARGETS_CLOUDFLARE_PAGES_IS_ENABLED # AVAILABILITY_TARGETS_CLOUDFLARE_PAGES_INTERVAL # AVAILABILITY_TARGETS_CLOUDFLARE_PAGES_DEPLOY_HOOK # @@ -32,16 +32,16 @@ services: environment: PORT: ${PORT:-8080} PEBBLE_PATH: ${PEBBLE_PATH:-/data} + 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_IS_ENABLED: ${AVAILABILITY_API_IS_ENABLED} 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_IS_ENABLED: ${AVAILABILITY_TARGETS_CLOUDFLARE_PAGES_IS_ENABLED} 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 599b732..2a765fa 100644 --- a/config.yaml +++ b/config.yaml @@ -1,41 +1,42 @@ port: 8080 pebble_path: ./data +# The status feature fetches events from an iCal calendar +# and updates GitHub status accordingly status: + enabled: true sources: ical: - # iCal URL for your status calendar (required when a status target is enabled). url: https://example.com/status.ics - # Status calendar fetch interval. interval: 5m targets: github: - # GitHub personal access token (optional, enables GitHub status sync). + # 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: + enabled: false sources: ical: - # Separate iCal URL for availability data. url: https://example.com/availability.ics - # Availability calendar fetch interval. interval: 5m api: - # Disabled by default. Set to true to expose /api/availability. - is_enabled: false - # Exact Authorization header value required by /api/availability. + # Exact Authorization header value required by /api/availability when availability is enabled. key: "" targets: cloudflare_pages: - # 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 AVAILABILITY_TARGETS_CLOUDFLARE_PAGES_DEPLOY_HOOK for secrets. + # Cloudflare Pages build hook URL required when availability is enabled. + # Prefer AVAILABILITY_TARGETS_CLOUDFLARE_PAGES_DEPLOY_HOOK for secrets. deploy_hook: "" suppressions: diff --git a/internal/config/config.go b/internal/config/config.go index 77976a3..e556b75 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -25,8 +25,9 @@ type Config struct { // StatusConfig configures status sources and targets. type StatusConfig struct { - Sources StatusSourcesConfig `koanf:"sources"` - Targets StatusTargetsConfig `koanf:"targets"` + IsEnabled bool `koanf:"enabled"` + Sources StatusSourcesConfig `koanf:"sources"` + Targets StatusTargetsConfig `koanf:"targets"` } // StatusSourcesConfig configures status data sources. @@ -64,6 +65,7 @@ func (t StatusTargetsConfig) Enabled() bool { // AvailabilityConfig configures availability sources, serving, targets, and rules. type AvailabilityConfig struct { + IsEnabled bool `koanf:"enabled"` Sources AvailabilitySourcesConfig `koanf:"sources"` API AvailabilityAPIConfig `koanf:"api"` Targets AvailabilityTargetsConfig `koanf:"targets"` @@ -76,10 +78,9 @@ type AvailabilitySourcesConfig struct { ICal ICalSourceConfig `koanf:"ical"` } -// AvailabilityAPIConfig configures the optional availability HTTP API. +// AvailabilityAPIConfig configures the availability HTTP API. type AvailabilityAPIConfig struct { - IsEnabled bool `koanf:"is_enabled"` - Key string `koanf:"key"` + Key string `koanf:"key"` } // AvailabilityTargetsConfig holds availability publish targets. @@ -89,24 +90,18 @@ type AvailabilityTargetsConfig struct { // CloudflarePagesTargetConfig configures Cloudflare Pages build hook publishes. type CloudflarePagesTargetConfig struct { - IsEnabled bool `koanf:"is_enabled"` Interval string `koanf:"interval"` DeployHook string `koanf:"deploy_hook"` } -// Enabled reports whether the Cloudflare Pages target is configured. -func (c CloudflarePagesTargetConfig) Enabled() bool { - return c.IsEnabled -} - -// Enabled reports whether any availability target is configured. -func (t AvailabilityTargetsConfig) Enabled() bool { - return t.CloudflarePages.Enabled() +// 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.API.IsEnabled || a.Targets.Enabled() + return a.IsEnabled } // AvailabilitySuppressionsConfig configures availability suppression rules. @@ -133,17 +128,17 @@ type AvailabilityBlockConfig struct { var envMapping = map[string]string{ "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_IS_ENABLED": "availability.api.is_enabled", "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_IS_ENABLED": "availability.targets.cloudflare_pages.is_enabled", "AVAILABILITY_TARGETS_CLOUDFLARE_PAGES_INTERVAL": "availability.targets.cloudflare_pages.interval", "AVAILABILITY_TARGETS_CLOUDFLARE_PAGES_DEPLOY_HOOK": "availability.targets.cloudflare_pages.deploy_hook", } @@ -163,13 +158,13 @@ func Load() (*Config, error) { if err := k.Load(confmap.Provider(map[string]interface{}{ "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.api.is_enabled": false, - "availability.targets.cloudflare_pages.is_enabled": false, "availability.targets.cloudflare_pages.interval": "10m", }, "."), nil); err != nil { return nil, fmt.Errorf("load defaults: %w", err) @@ -213,15 +208,18 @@ func (c Config) Validate() error { // Validate checks status source and target settings. func (s StatusConfig) Validate() error { - if !s.Targets.Enabled() { + if !s.Enabled() { return nil } if strings.TrimSpace(s.Sources.ICal.URL) == "" { - return fmt.Errorf("status.sources.ical.url is required when at least one status target is enabled") + 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 } @@ -251,8 +249,8 @@ func (a AvailabilityConfig) Validate() error { if _, err := a.Sources.ICal.IntervalDuration("availability.sources.ical.interval"); err != nil { return err } - if a.API.IsEnabled && strings.TrimSpace(a.API.Key) == "" { - return fmt.Errorf("availability.api.key is required when availability API is enabled") + if strings.TrimSpace(a.API.Key) == "" { + return fmt.Errorf("availability.api.key 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") @@ -288,25 +286,19 @@ func (a AvailabilityConfig) Validate() error { // Validate checks the Cloudflare Pages target settings. func (c CloudflarePagesTargetConfig) Validate() error { - if !c.IsEnabled { - return nil - } 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 Cloudflare Pages target is enabled") + return fmt.Errorf("availability.targets.cloudflare_pages.deploy_hook is required when availability is enabled") } return nil } // IntervalDuration returns the validated Cloudflare Pages publish interval. func (c CloudflarePagesTargetConfig) IntervalDuration() (time.Duration, error) { - if !c.IsEnabled { - return 0, nil - } if strings.TrimSpace(c.Interval) == "" { - return 0, fmt.Errorf("availability.targets.cloudflare_pages.interval is required when Cloudflare Pages target is enabled") + return 0, fmt.Errorf("availability.targets.cloudflare_pages.interval is required when availability is enabled") } dur, err := time.ParseDuration(c.Interval) if err != nil { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index fe98fd2..9a4f145 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -25,17 +25,17 @@ func chdir(t *testing.T, dir string) { var configEnvKeys = []string{ "PORT", "PEBBLE_PATH", + "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_IS_ENABLED", "AVAILABILITY_API_KEY", "AVAILABILITY_SUPPRESSIONS_WORKING_HOURS_START", "AVAILABILITY_SUPPRESSIONS_WORKING_HOURS_END", "AVAILABILITY_SUPPRESSIONS_EXCLUDE_ENGLAND_BANK_HOLIDAYS", - "AVAILABILITY_TARGETS_CLOUDFLARE_PAGES_IS_ENABLED", "AVAILABILITY_TARGETS_CLOUDFLARE_PAGES_INTERVAL", "AVAILABILITY_TARGETS_CLOUDFLARE_PAGES_DEPLOY_HOOK", } @@ -71,6 +71,9 @@ func TestLoad_Defaults(t *testing.T) { if cfg.Status.Sources.ICal.URL != "" { t.Errorf("Status.Sources.ICal.URL: got %q, want empty", cfg.Status.Sources.ICal.URL) } + if cfg.Status.IsEnabled { + t.Error("expected status 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) } @@ -80,6 +83,9 @@ func TestLoad_Defaults(t *testing.T) { 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) } @@ -89,12 +95,6 @@ func TestLoad_Defaults(t *testing.T) { if cfg.Availability.Suppressions.ExcludeEnglandBankHolidays { t.Error("expected bank holiday exclusion to be disabled by default") } - if cfg.Availability.API.IsEnabled { - t.Error("expected availability API to be disabled by default") - } - if cfg.Availability.Targets.CloudflarePages.IsEnabled { - t.Error("expected Cloudflare Pages target 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) } @@ -106,6 +106,7 @@ func TestLoad_FromEnv(t *testing.T) { t.Setenv("PORT", "9090") t.Setenv("PEBBLE_PATH", "/tmp/mydb") + 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") @@ -123,6 +124,9 @@ func TestLoad_FromEnv(t *testing.T) { 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.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) } @@ -135,14 +139,13 @@ func TestLoad_AvailabilityFromEnv(t *testing.T) { chdir(t, t.TempDir()) clearConfigEnv(t) + 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_IS_ENABLED", "true") t.Setenv("AVAILABILITY_API_KEY", "secret-key") 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_IS_ENABLED", "true") t.Setenv("AVAILABILITY_TARGETS_CLOUDFLARE_PAGES_INTERVAL", "12m") t.Setenv("AVAILABILITY_TARGETS_CLOUDFLARE_PAGES_DEPLOY_HOOK", "https://example.com/hook") @@ -153,12 +156,12 @@ func TestLoad_AvailabilityFromEnv(t *testing.T) { 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.Sources.ICal.Interval != "7m" { t.Errorf("Availability.Sources.ICal.Interval: got %q", cfg.Availability.Sources.ICal.Interval) } - if !cfg.Availability.API.IsEnabled { - t.Fatal("expected availability API to be enabled from env") - } if cfg.Availability.API.Key != "secret-key" { t.Errorf("Availability.API.Key: got %q", cfg.Availability.API.Key) } @@ -168,9 +171,6 @@ func TestLoad_AvailabilityFromEnv(t *testing.T) { if !cfg.Availability.Suppressions.ExcludeEnglandBankHolidays { t.Error("expected holiday exclusion to be enabled from env") } - if !cfg.Availability.Targets.CloudflarePages.IsEnabled { - t.Fatal("expected Cloudflare Pages target to be enabled from env") - } if cfg.Availability.Targets.CloudflarePages.Interval != "12m" { t.Errorf("CloudflarePages.Interval: got %q", cfg.Availability.Targets.CloudflarePages.Interval) } @@ -199,6 +199,7 @@ func TestLoad_FromYAML(t *testing.T) { port: 7777 pebble_path: /yaml/data status: + enabled: true sources: ical: url: https://yaml-cal.example.com/ical.ics @@ -222,6 +223,9 @@ status: 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.Status.Sources.ICal.Interval != "12m" { t.Errorf("Status.Sources.ICal.Interval: got %q", cfg.Status.Sources.ICal.Interval) } @@ -237,16 +241,15 @@ func TestLoad_AvailabilityFromYAML(t *testing.T) { yaml := ` availability: + enabled: true sources: ical: url: https://availability.example.com/ical.ics interval: 8m api: - is_enabled: true key: secret-yaml-key targets: cloudflare_pages: - is_enabled: true interval: 14m deploy_hook: https://example.com/hook suppressions: @@ -271,12 +274,12 @@ availability: 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 YAML") + } if cfg.Availability.Sources.ICal.Interval != "8m" { t.Errorf("Availability.Sources.ICal.Interval: got %q", cfg.Availability.Sources.ICal.Interval) } - if !cfg.Availability.API.IsEnabled { - t.Fatal("expected availability API to be enabled") - } if cfg.Availability.API.Key != "secret-yaml-key" { t.Errorf("Availability.API.Key: got %q", cfg.Availability.API.Key) } @@ -286,9 +289,6 @@ availability: if !cfg.Availability.Suppressions.ExcludeEnglandBankHolidays { t.Error("expected holiday exclusion to be enabled from YAML") } - if !cfg.Availability.Targets.CloudflarePages.IsEnabled { - t.Fatal("expected Cloudflare Pages target to be enabled") - } if cfg.Availability.Targets.CloudflarePages.Interval != "14m" { t.Errorf("CloudflarePages.Interval: got %q", cfg.Availability.Targets.CloudflarePages.Interval) } @@ -311,6 +311,7 @@ func TestLoad_EnvOverridesYAML(t *testing.T) { yaml := ` port: 7777 status: + enabled: false sources: ical: url: https://yaml-cal.example.com/ical.ics @@ -319,12 +320,12 @@ status: github: token: gh-yaml availability: + enabled: false sources: ical: url: https://yaml-availability.example.com/ical.ics interval: 30m api: - is_enabled: false key: yaml-key suppressions: working_hours: @@ -335,12 +336,13 @@ availability: writeConfigYAML(t, dir, yaml) t.Setenv("PORT", "9999") + 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_IS_ENABLED", "true") 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") @@ -356,6 +358,9 @@ availability: if cfg.Status.Targets.GitHub.Token != "gh-env" { t.Errorf("Status.Targets.GitHub.Token: got %q, want gh-env", cfg.Status.Targets.GitHub.Token) } + 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) } @@ -365,12 +370,12 @@ availability: 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.Sources.ICal.Interval != "9m" { t.Errorf("Availability.Sources.ICal.Interval: got %q, want env value", cfg.Availability.Sources.ICal.Interval) } - if !cfg.Availability.API.IsEnabled { - t.Fatal("expected env to enable availability API") - } if cfg.Availability.API.Key != "env-key" { t.Errorf("Availability.API.Key: got %q", cfg.Availability.API.Key) } @@ -433,6 +438,7 @@ func TestLoad_MissingYAML(t *testing.T) { func validAvailabilityConfig() AvailabilityConfig { return AvailabilityConfig{ + IsEnabled: true, Sources: AvailabilitySourcesConfig{ ICal: ICalSourceConfig{ URL: "https://example.com/availability.ics", @@ -440,8 +446,13 @@ func validAvailabilityConfig() AvailabilityConfig { }, }, API: AvailabilityAPIConfig{ - IsEnabled: true, - Key: "secret", + Key: "secret", + }, + Targets: AvailabilityTargetsConfig{ + CloudflarePages: CloudflarePagesTargetConfig{ + Interval: "10m", + DeployHook: "https://example.com/hook", + }, }, Suppressions: AvailabilitySuppressionsConfig{ WorkingHours: AvailabilityWorkingHoursConfig{ @@ -462,27 +473,25 @@ func TestAvailabilityValidate(t *testing.T) { } }) - t.Run("API enabled valid", func(t *testing.T) { + 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("Cloudflare Pages enabled valid without API key", func(t *testing.T) { + t.Run("enabled valid", func(t *testing.T) { cfg := validAvailabilityConfig() - cfg.API = AvailabilityAPIConfig{} - cfg.Targets.CloudflarePages = CloudflarePagesTargetConfig{ - IsEnabled: true, - Interval: "10m", - DeployHook: "https://example.com/hook", - } if err := cfg.Validate(); err != nil { t.Fatalf("Validate: %v", err) } }) - t.Run("API enabled missing key", func(t *testing.T) { + t.Run("enabled missing API key", func(t *testing.T) { cfg := validAvailabilityConfig() cfg.API.Key = "" if err := cfg.Validate(); err == nil { @@ -490,6 +499,14 @@ func TestAvailabilityValidate(t *testing.T) { } }) + 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 = "" @@ -515,7 +532,7 @@ func TestAvailabilityValidate(t *testing.T) { }) t.Run("enabled incomplete", func(t *testing.T) { - cfg := AvailabilityConfig{API: AvailabilityAPIConfig{IsEnabled: true}} + cfg := AvailabilityConfig{IsEnabled: true} if err := cfg.Validate(); err == nil { t.Fatal("expected error for incomplete enabled availability config") } @@ -532,9 +549,23 @@ func TestConfigValidate(t *testing.T) { } }) + 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"}, }, @@ -548,9 +579,27 @@ func TestConfigValidate(t *testing.T) { } }) + 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", @@ -593,23 +642,8 @@ func TestICalSourceIntervalDuration(t *testing.T) { } func TestCloudflarePagesTargetValidateAndIntervalDuration(t *testing.T) { - t.Run("disabled", func(t *testing.T) { - cfg := CloudflarePagesTargetConfig{} - if err := cfg.Validate(); err != nil { - t.Fatalf("Validate: %v", err) - } - dur, err := cfg.IntervalDuration() - if err != nil { - t.Fatalf("IntervalDuration: %v", err) - } - if dur != 0 { - t.Fatalf("duration: got %v, want 0", dur) - } - }) - - t.Run("enabled valid", func(t *testing.T) { + t.Run("valid", func(t *testing.T) { cfg := CloudflarePagesTargetConfig{ - IsEnabled: true, Interval: "10m", DeployHook: "https://example.com/hook", } @@ -627,7 +661,6 @@ func TestCloudflarePagesTargetValidateAndIntervalDuration(t *testing.T) { t.Run("interval too short", func(t *testing.T) { cfg := CloudflarePagesTargetConfig{ - IsEnabled: true, Interval: "30s", DeployHook: "https://example.com/hook", } @@ -638,8 +671,7 @@ func TestCloudflarePagesTargetValidateAndIntervalDuration(t *testing.T) { t.Run("missing hook", func(t *testing.T) { cfg := CloudflarePagesTargetConfig{ - IsEnabled: true, - Interval: "10m", + 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 3f43c7d..a8f51a0 100644 --- a/main.go +++ b/main.go @@ -39,7 +39,7 @@ func run(logger zerolog.Logger) error { return fmt.Errorf("validate config: %w", err) } var statusInterval time.Duration - if cfg.Status.Targets.Enabled() { + 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) @@ -52,9 +52,12 @@ func run(logger zerolog.Logger) error { return fmt.Errorf("validate availability source config: %w", err) } } - cloudflarePagesInterval, err := cfg.Availability.Targets.CloudflarePages.IntervalDuration() - if err != nil { - return fmt.Errorf("validate Cloudflare Pages target 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. @@ -70,9 +73,9 @@ func run(logger zerolog.Logger) error { ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer cancel() - targets := buildTargets(cfg.Status.Targets) var statusSyncer *calendar.Syncer - if len(targets) > 0 { + 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) @@ -108,7 +111,9 @@ func run(logger zerolog.Logger) error { } // Start deploy loop if enabled. - startDeployLoop(ctx, cfg.Availability.Targets.CloudflarePages, cloudflarePagesInterval, 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) @@ -143,9 +148,7 @@ func registerAvailability(ctx context.Context, cfg config.AvailabilityConfig, st } provider := availability.NewProvider(st, availabilityBlocks, workingHours, cfg.Suppressions.ExcludeEnglandBankHolidays) - if cfg.API.IsEnabled { - mux.Handle("GET /api/availability", availability.NewHandler(provider, cfg.API.Key, logger)) - } + mux.Handle("GET /api/availability", availability.NewHandler(provider, cfg.API.Key, logger)) return availability.NewSyncer(st, provider, availabilityClient, logger), nil } @@ -162,10 +165,6 @@ func parseAvailabilityBlocks(blocks []config.AvailabilityBlockConfig) ([]availab } func startDeployLoop(ctx context.Context, cfg config.CloudflarePagesTargetConfig, interval time.Duration, st *store.Store, logger zerolog.Logger) { - if !cfg.IsEnabled { - return - } - client := deploy.NewHookClient(cfg.DeployHook) deployer := deploy.NewDeployer(client, st, logger) go func() { From 2d2da12556db6baeed1d3ee483dabb19fecfdfdd Mon Sep 17 00:00:00 2001 From: Galdin Raphael Date: Sat, 16 May 2026 18:23:55 +0100 Subject: [PATCH 4/4] Bump chart version to v0.2.8 --- chart/Chart.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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"