diff --git a/.github/.dependabot.yml b/.github/.dependabot.yml index 0b171a2..f86b7a9 100644 --- a/.github/.dependabot.yml +++ b/.github/.dependabot.yml @@ -21,3 +21,13 @@ updates: prefix: "Go" reviewers: - "TykTechnologies/engineering" + + - package-ecosystem: gomod + directory: /eol-notifier + schedule: + interval: weekly + commit-message: + include: scope + prefix: "Go" + reviewers: + - "TykTechnologies/engineering" diff --git a/.github/eol-notifier/dependencies.yaml b/.github/eol-notifier/dependencies.yaml new file mode 100644 index 0000000..7b5838c --- /dev/null +++ b/.github/eol-notifier/dependencies.yaml @@ -0,0 +1,67 @@ +# Dependencies tracked by .github/workflows/eol-notifier.yaml. +# +# Each entry maps a dependency to an endoflife.date product slug. Several +# entries may share a slug: services the API does not track ride on the +# lifecycle of their upstream OSS engine and are marked upstream_proxy. +--- +thresholds_months: [12, 6, 1] + +dependencies: + # Upstream OSS engines, tracked directly. + - name: Redis + product: redis + track: [eol] + - name: Valkey + product: valkey + track: [eol, eoas] + - name: PostgreSQL + product: postgresql + track: [eol] + - name: MongoDB + product: mongodb + track: [eol] + - name: MySQL + product: mysql + track: [eol, eoas] + - name: MariaDB + product: mariadb + track: [eol, eoes] + + # HashiCorp tooling. A version gets its end-of-life date on the day a newer + # release drops it out of support, so the date is never in the future: expect + # new-version alerts from these, not the 12/6/1 month countdown. + - name: HashiCorp Vault + product: hashicorp-vault + track: [eol] + - name: HashiCorp Consul + product: consul + track: [eol] + + # Managed services with their own published lifecycle. + - name: Amazon ElastiCache (Redis) + product: amazon-elasticache-redis + track: [eol, eoes] + - name: Amazon RDS PostgreSQL + product: amazon-rds-postgresql + track: [eol, eoes] + - name: Amazon DocumentDB + product: amazon-documentdb + track: [eol, eoes] + - name: Azure Database for PostgreSQL + product: azure-database-for-postgresql + track: [eol] + + # Not tracked by endoflife.date. The upstream engine stands in for them, so + # the reported dates are indicative and need confirming with the provider. + - name: GCP MemoryStore + product: redis + track: [eol] + upstream_proxy: true + - name: GCP Cloud SQL + product: postgresql + track: [eol] + upstream_proxy: true + - name: Azure DocumentDB + product: mongodb + track: [eol] + upstream_proxy: true diff --git a/.github/workflows/ci-test.yml b/.github/workflows/ci-test.yml index 0db693a..5881a44 100644 --- a/.github/workflows/ci-test.yml +++ b/.github/workflows/ci-test.yml @@ -8,11 +8,13 @@ on: paths: - 'branch-suggestion/**' - 'jira-linter/**' + - 'eol-notifier/**' - '.github/workflows/ci-test.yml' pull_request: paths: - 'branch-suggestion/**' - 'jira-linter/**' + - 'eol-notifier/**' - '.github/workflows/ci-test.yml' jobs: @@ -64,3 +66,19 @@ jobs: - name: Run tests working-directory: jira-linter run: go test ./... + + test-eol-notifier: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Setup Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 + with: + go-version: '1.24.7' + + - name: Run tests + working-directory: eol-notifier + run: go test ./... diff --git a/.github/workflows/eol-notifier.yaml b/.github/workflows/eol-notifier.yaml new file mode 100644 index 0000000..ff0cb5b --- /dev/null +++ b/.github/workflows/eol-notifier.yaml @@ -0,0 +1,116 @@ +name: Dependency EoL Notifier + +on: + schedule: + - cron: '0 7 * * *' # Daily at 07:00 UTC + workflow_dispatch: + inputs: + dry_run: + description: 'Render the digest in the job log without posting to Slack' + type: boolean + default: false + +# The recorded state lives on its own branch, so nothing is pushed to main. +env: + STATE_BRANCH: eol-notifier-state + STATE_REF: refs/eol-notifier/previous + STATE_FILE: state.json + +# Two runs would build their commits on the same parent, and the second push +# would be rejected, so a manual run waits for the scheduled one to finish. +concurrency: + group: eol-notifier-state + cancel-in-progress: false + +permissions: + contents: write + +jobs: + notify: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Restore recorded release cycles + run: | + # A missing branch and an unreachable origin must not look the same: + # treating a network failure as "no state yet" would suppress every + # new-version alert and then try to push a parentless commit. + set +e + git ls-remote --exit-code --heads origin "$STATE_BRANCH" > /dev/null + status=$? + set -e + + case "$status" in + 0) + git fetch origin "+refs/heads/$STATE_BRANCH:$STATE_REF" + if git cat-file -p "$STATE_REF:$STATE_FILE" > "$RUNNER_TEMP/$STATE_FILE"; then + echo "--> restored state from $STATE_BRANCH" + else + rm -f "$RUNNER_TEMP/$STATE_FILE" + echo "::warning::$STATE_BRANCH has no $STATE_FILE, recording a baseline" + fi + ;; + 2) + echo "--> no $STATE_BRANCH branch yet, this run records a baseline" + ;; + *) + echo "::error::could not reach origin to look up $STATE_BRANCH" + exit 1 + ;; + esac + + - name: Check dependency lifecycles + id: notifier + uses: ./eol-notifier + with: + config-path: .github/eol-notifier/dependencies.yaml + state-path: ${{ runner.temp }}/state.json + slack-webhook-url: ${{ secrets.EOL_SLACK_WEBHOOK_URL }} + dry-run: ${{ inputs.dry_run || false }} + + # Runs even when the step above failed. A failed product fetch still posts + # the rest of the digest and still writes state, so skipping this would + # re-announce the same versions tomorrow. When nothing was written the file + # is unchanged and no commit is made. + - name: Record observed release cycles + if: ${{ !inputs.dry_run && !cancelled() }} + env: + GIT_AUTHOR_NAME: github-actions[bot] + GIT_AUTHOR_EMAIL: 41898282+github-actions[bot]@users.noreply.github.com + GIT_COMMITTER_NAME: github-actions[bot] + GIT_COMMITTER_EMAIL: 41898282+github-actions[bot]@users.noreply.github.com + run: | + if [ ! -f "$RUNNER_TEMP/$STATE_FILE" ]; then + echo "--> no state was written, nothing to record" + exit 0 + fi + + # main requires a reviewed PR, so the state cannot be committed there. + # The commit is assembled with plumbing and pushed straight to the state + # branch, which leaves the checked-out working tree alone and needs no + # second worktree. + blob=$(git hash-object -w "$RUNNER_TEMP/$STATE_FILE") + + parent="" + if git rev-parse --verify --quiet "$STATE_REF" > /dev/null; then + parent=$(git rev-parse "$STATE_REF") + previous=$(git rev-parse "$STATE_REF:$STATE_FILE" 2> /dev/null || true) + if [ "$previous" = "$blob" ]; then + echo "--> state unchanged, nothing to commit" + exit 0 + fi + fi + + tree=$(printf '100644 blob %s\t%s\n' "$blob" "$STATE_FILE" | git mktree) + + message="chore(eol-notifier): record observed release cycles" + if [ -n "$parent" ]; then + commit=$(git commit-tree "$tree" -p "$parent" -m "$message") + else + commit=$(git commit-tree "$tree" -m "$message") + fi + + git push origin "$commit:refs/heads/$STATE_BRANCH" + echo "--> recorded state as $commit on $STATE_BRANCH" diff --git a/docs/workflows/eol-notifier.md b/docs/workflows/eol-notifier.md new file mode 100644 index 0000000..6c21167 --- /dev/null +++ b/docs/workflows/eol-notifier.md @@ -0,0 +1,107 @@ +## Dependency EoL Notifier + +A scheduled job that reads the [endoflife.date](https://endoflife.date) API every +day at 07:00 UTC and posts one message to Slack. The message goes to the channel +of the webhook held in `EOL_SLACK_WEBHOOK_URL`, which is `#service-EoL-dependency`. + +The job sends two kinds of alert: + +- **Approaching end of life** — a tracked version reaches the end of a lifecycle + phase in exactly 12, 6 or 1 month. +- **New versions** — the API lists a version that the run before did not see. + +When there is nothing to report, the job posts no message at all. + +Acting on an alert is manual work. Adding a version to the test matrix, or taking +one out of it, needs a PR against this repository. For a removal, the dependency +policy asks for commercial approval first. + +### Configuration + +The tracked dependencies are listed in +[.github/eol-notifier/dependencies.yaml](/.github/eol-notifier/dependencies.yaml). +Each entry maps one dependency to an endoflife.date product slug: + +```yaml +thresholds_months: [12, 6, 1] + +dependencies: + - name: Amazon RDS PostgreSQL + product: amazon-rds-postgresql + track: [eol, eoes] + + - name: GCP Cloud SQL + product: postgresql + upstream_proxy: true +``` + +`thresholds_months` sets how long before the end date an alert goes out. It is +optional, and `[12, 6, 1]` is the default. + +`track` selects the lifecycle phases to watch: `eol`, `eoas` (active support) or +`eoes` (extended support). It is optional and defaults to `[eol]`. Not every +product publishes every phase. + +`upstream_proxy` marks a service that endoflife.date does not track, such as GCP +MemoryStore or Azure DocumentDB. For those, the job follows the upstream engine +instead, so the date is only a hint. The alert marks the entry, and the footer of +the message says the date has to be confirmed with the cloud provider. + +The job reads this file before it calls the API. An unknown phase name, an empty +product or a repeated dependency name stops the run before any request is sent. + +### Recorded state + +To know that a version is *new*, the job needs to know what the run before it +saw. It keeps that in a `state.json` file on a branch of its own, called +`eol-notifier-state`. It is a branch and not a file on `main`, because `main` +needs a reviewed PR and the job cannot push there. + +The branch holds that one file and nothing else. The job commits only when the +content changes, so expect a few commits a year, not one a day. It creates the +branch itself on the first run. + +If you delete the branch, the next run starts from zero. It records the versions +the API lists that day and reports none of them as new. Versions that appeared +while the branch was gone become part of that new record, so they are never +announced. A dependency added to the config behaves the same way: its first run +only records. + +End-of-life alerts do not use the state file. They are also the part with no +second chance. The alert goes out on the single day that is exactly 12, 6 or 1 +month before the end date, which is what the policy asks for, so an alert is lost +when the run of that day is skipped or fails. The next threshold still fires, so +a missed 12-month alert is followed by the 6-month one. If you expected an alert +and it never arrived, check the run history of the workflow. + +### Products that give no notice + +Some vendors set the end date only once it has arrived. HashiCorp gives a Vault or +Consul version its end-of-life date on the day a newer release pushes it out of +support: for Vault that is the next release, for Consul the third one after it. +The date is that same day, so it is never in the future and there is nothing to +count down from. For those two products the 12, 6 and 1 month alerts never fire. + +The new-version alert covers them instead. A new release means an older one lost +support that day, so check the older versions when such a message arrives. + +### Failures + +A product the API does not return is logged and skipped. The job still posts the +alerts for the other products, and then fails, so the problem shows up in the +Actions tab. The state file is still written in that case. Without that, the same +new versions would be announced again the next day. + +### Requirements + +The repository secret `EOL_SLACK_WEBHOOK_URL` must hold an incoming webhook for +the target channel. The job cannot post without it. + +### Manual runs + +Start the workflow by hand with `workflow_dispatch`. With `dry_run: true` it +writes the message into the job log, posts nothing to Slack and leaves the state +file alone. A dry run shows only what is due on that day. It does not report the +lifecycle of every tracked product. + +Adoption: Internal use for the scheduled job on this repository. diff --git a/eol-notifier/.gitignore b/eol-notifier/.gitignore new file mode 100644 index 0000000..3150330 --- /dev/null +++ b/eol-notifier/.gitignore @@ -0,0 +1 @@ +/notifier \ No newline at end of file diff --git a/eol-notifier/README.md b/eol-notifier/README.md new file mode 100644 index 0000000..842dd27 --- /dev/null +++ b/eol-notifier/README.md @@ -0,0 +1,174 @@ +# EoL Notifier + +This action reads the [endoflife.date](https://endoflife.date) API and sends one +Slack message per run. It sends an alert when: + +- a version we track gets close to the end of one of its support phases, or +- a new version appears. + +The action only sends alerts. It does not change any test matrix. Adding or +removing a version is still a manual PR in this repository, and only after the +approval that the dependency policy asks for. + +## Usage + +```yaml +- name: Check dependency lifecycles + uses: TykTechnologies/github-actions/eol-notifier@main + with: + config-path: .github/eol-notifier/dependencies.yaml + state-path: ${{ runner.temp }}/state.json + slack-webhook-url: ${{ secrets.EOL_SLACK_WEBHOOK_URL }} +``` + +| Input | Required | Default | Description | +|---------------------|----------|---------|---------------------------------------------------------------------------------| +| `config-path` | yes | | YAML file with the list of dependencies to track | +| `state-path` | yes | | JSON file with the versions the last run saw and the alerts already sent | +| `slack-webhook-url` | no | | Slack incoming webhook for the channel. Needed unless `dry-run` is `true` | +| `dry-run` | no | `false` | Print the message in the job log, send nothing, and do not touch the state file | + +The caller owns both files. So a second workflow can track other dependencies +with its own config file and its own state file. + +The action reads and writes `state-path`, but it does not keep the file between +runs. That is the workflow's job. In this repository the file lives on a branch +called `eol-notifier-state`, because `main` needs a reviewed PR and the job +cannot push to it. See [.github/workflows/eol-notifier.yaml](/.github/workflows/eol-notifier.yaml). + +## Config + +```yaml +thresholds_months: [12, 6, 1] + +dependencies: + - name: PostgreSQL + product: postgresql + track: [eol] + + - name: Amazon RDS PostgreSQL + product: amazon-rds-postgresql + track: [eol, eoes] + + - name: GCP Cloud SQL + product: postgresql + upstream_proxy: true +``` + +`thresholds_months` sets how many months before the end date to send an alert. +The default is `[12, 6, 1]`. + +Each entry under `dependencies` takes these keys: + +- `name` is the name shown in the alert. It must be unique. +- `product` is the product name that endoflife.date uses in its URL. Two + dependencies can use the same product. The action then calls the API once and + puts both names in the same alert. +- `track` lists the support phases to watch. The default is `[eol]`. + - `eol` is the end of life, or the end of security support. Every product has + this phase, but a version keeps no date until the vendor announces one. + - `eoas` is the end of active support. For example `redis` and `valkey`. + - `eoes` is the end of extended support. For example `amazon-rds-postgresql`. +- `upstream_proxy` is for a service that `endoflife.date` does not track. The + action then uses the dates of the open source engine under it. GCP MemoryStore + uses `redis`, GCP Cloud SQL uses `postgresql`, and Azure DocumentDB uses + `mongodb`. The alert marks these dates as a hint only, because a cloud + provider usually supports a version for a different length of time than the + open source project. + +The action checks the config before it makes any network call. It fails the run +if a phase name is unknown, a product is empty, or a key is misspelled. It also +fails if the list of dependencies is empty, if a name or a phase is listed twice, +or if a threshold is repeated or is not above zero. + +## How it works + +### Alerts before the end of support + +The action looks at every phase it tracks, but only where the API gives an end +date. Some versions have no date yet, and the action skips those. It sends an +alert when the date is 12, 6 or 1 month away. + +It counts backwards from the end date. If the target month is shorter, it uses +the last day of that month. For example, one month before 31 March is +28 February, and 29 February in a leap year. + +An alert stays due from the day it comes up until the day it is sent. So a day, +a week or a year with no run costs nothing: the first run after the gap sends +everything that came up while the action was down. The state file lists the +alerts already sent, so each one still goes out once and once only. + +If more than one warning for the same version came up during the gap, the action +sends the closest one only. After a year with no runs, "12 months left" is not +true any more, so saying it would be worse than saying nothing. The warnings it +stood in for are marked as sent and do not come back. + +### Alerts after the end of support + +When the end date itself passes, the action sends one alert saying the phase has +ended. It replaces the countdown for that version, because a version that is +already out of support is not one month from anything. + +This alert is sent once, on the first run after the date passes. If +endoflife.date later moves the date, the new date is a new alert. + +### Alerts for new versions + +The state file lists the versions that the last run saw. If the API shows a +version that is not in the file, the action sends an alert. + +Sometimes a version is already end of life when it first appears. This is an old +version that someone added to endoflife.date later, so it is not news. The action +saves it and sends no new-version alert, only the alert that says the phase has +ended. + +A product the state file has never seen is a baseline. The action saves its +versions and announces none of them. It also counts the phases that already +ended as sent, without posting them: a dependency added to the config does not +empty years of past dates into the channel. + +A version still counting down is different. If its 12, 6 or 1 month warning has +come up, the action sends it on the first run, even though that run is the first +one. A version weeks away from the end of its support is the first thing the +channel needs to hear about a new dependency, not something to file away. + +The action saves a version as seen only after Slack accepts the message that +names it. So a version is never saved as seen if its alert did not arrive. A dry +run never writes the file. + +### Long digests + +Slack takes a limited number of blocks in one message. If the digest does not +fit, the action splits it and posts every part. Nothing is cut. A part that +Slack rejects is not saved as sent, so the next run posts it again. + +### Errors + +If the action cannot read a product, it tries once more and then skips it. It +still sends the alerts for the other products. The run then exits with a non-zero +code and lists the products it could not read. + +An end date the action cannot read works the same way. It skips that version, +still checks the versions around it, and exits with a non-zero code naming the +version and the date it saw. A version with no end date at all is not an error. +There is nothing to count down to yet, so the action passes over it in silence. + +Both cases delay a version the alert it was due. The run fails so that the delay +is visible. Nothing is lost: an alert stays due until it is sent, so the next run +that can read the product sends it. The state file is still written, so the +alerts that did arrive are not sent again. + +If there is nothing to report, the action sends no message at all. + +## Run it locally + +```console +$ go test ./... +$ go run ./cmd/notifier \ + --config ../.github/eol-notifier/dependencies.yaml \ + --state /tmp/eol-state.json \ + --dry-run +``` + +With `--dry-run` the action prints the message it would send and does not touch +the state file. Without it, set `EN_SLACK_WEBHOOK_URL` in the environment. diff --git a/eol-notifier/action.yaml b/eol-notifier/action.yaml new file mode 100644 index 0000000..ea424f1 --- /dev/null +++ b/eol-notifier/action.yaml @@ -0,0 +1,45 @@ +name: 'Dependency EoL Notifier' +description: 'Polls the endoflife.date API and posts dependency lifecycle alerts to a Slack channel' + +inputs: + config-path: + description: 'Path to the YAML file listing the dependencies to track.' + required: true + state-path: + description: 'Path to the JSON file recording previously seen release cycles and the alerts already delivered. A missing file seeds a baseline without announcing anything.' + required: true + slack-webhook-url: + description: 'Slack incoming webhook URL for the target channel. Required unless dry-run is true.' + required: false + dry-run: + description: 'Render the digest in the job log instead of posting it, and leave the state file untouched.' + required: false + default: 'false' + +runs: + using: 'composite' + steps: + - name: Set up Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 + with: + go-version: '1.24.7' + + - name: Build EoL Notifier + shell: bash + run: | + cd ${{ github.action_path }} + go install ./cmd/notifier + + - name: Run EoL Notifier + shell: bash + run: | + ARGS=(--config "$CONFIG_PATH" --state "$STATE_PATH") + if [ "$DRY_RUN" = "true" ]; then + ARGS+=(--dry-run) + fi + notifier "${ARGS[@]}" + env: + CONFIG_PATH: ${{ inputs.config-path }} + STATE_PATH: ${{ inputs.state-path }} + DRY_RUN: ${{ inputs.dry-run }} + EN_SLACK_WEBHOOK_URL: ${{ inputs.slack-webhook-url }} diff --git a/eol-notifier/cmd/notifier/config.go b/eol-notifier/cmd/notifier/config.go new file mode 100644 index 0000000..a53d7cc --- /dev/null +++ b/eol-notifier/cmd/notifier/config.go @@ -0,0 +1,169 @@ +package main + +import ( + "bytes" + "fmt" + "os" + "strings" + + "gopkg.in/yaml.v3" +) + +// Lifecycle phases exposed by the endoflife.date v1 API. Not every product +// carries every phase; see the per-product "labels" object in the response. +const ( + phaseEOL = "eol" // end of life / security support + phaseEOAS = "eoas" // end of active support + phaseEOES = "eoes" // end of extended support +) + +// phaseOrder fixes the order alerts are emitted in, so output is deterministic. +var phaseOrder = []string{phaseEOL, phaseEOAS, phaseEOES} + +// defaultThresholdsMonths matches the EoL policy: flag a version 12, 6 and 1 +// month before it reaches the end of a lifecycle phase. +var defaultThresholdsMonths = []int{12, 6, 1} + +// Config holds the environment configuration, loaded with the EN_ prefix. +type Config struct { + SlackWebhookURL string `envconfig:"SLACK_WEBHOOK_URL"` +} + +// Dependency is a single-tracked dependency. Several dependencies may share a +// product: GCP MemoryStore is not tracked by endoflife.date, so it rides on the +// upstream redis lifecycle. +type Dependency struct { + // Name is how the dependency is referred to in the alert. + Name string `yaml:"name"` + // Product is the endoflife.date product slug to poll. + Product string `yaml:"product"` + // Track lists the lifecycle phases to alert on. Defaults to [eol]. + Track []string `yaml:"track"` + // UpstreamProxy marks a dependency whose lifecycle is not published by its + // vendor, so the upstream OSS engine is used as a stand-in. Its dates are + // indicative only and are labelled as such in the alert. + UpstreamProxy bool `yaml:"upstream_proxy"` +} + +// DependencyConfig is the YAML config file supplied by the caller. +type DependencyConfig struct { + ThresholdsMonths []int `yaml:"thresholds_months"` + Dependencies []Dependency `yaml:"dependencies"` +} + +// loadConfig loads and validates the environment configuration. +func loadConfig(process func(string, interface{}) error, dryRun bool) (*Config, error) { + var config Config + if err := process("EN", &config); err != nil { + return nil, fmt.Errorf("failed to load environment configuration: %w", err) + } + + if !dryRun && strings.TrimSpace(config.SlackWebhookURL) == "" { + return nil, fmt.Errorf("Slack webhook URL is required to post alerts (EN_SLACK_WEBHOOK_URL)") + } + + return &config, nil +} + +// loadDependencyConfig reads and validates the YAML dependency config. +func loadDependencyConfig(path string) (*DependencyConfig, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read dependency config %s: %w", path, err) + } + + var config DependencyConfig + decoder := yaml.NewDecoder(bytes.NewReader(data)) + decoder.KnownFields(true) + if err := decoder.Decode(&config); err != nil { + return nil, fmt.Errorf("failed to parse dependency config %s: %w", path, err) + } + + if err := config.validate(); err != nil { + return nil, fmt.Errorf("failed to validate dependency config %s: %w", path, err) + } + + return &config, nil +} + +// validate applies defaults and rejects configs that would produce confusing or +// silently empty alerts. It runs before any network call. +func (c *DependencyConfig) validate() error { + if len(c.ThresholdsMonths) == 0 { + c.ThresholdsMonths = append([]int(nil), defaultThresholdsMonths...) + } + + seenThreshold := make(map[int]bool, len(c.ThresholdsMonths)) + for _, months := range c.ThresholdsMonths { + if months <= 0 { + return fmt.Errorf("threshold must be a positive number of months, got %d", months) + } + if seenThreshold[months] { + return fmt.Errorf("duplicate threshold %d", months) + } + seenThreshold[months] = true + } + + if len(c.Dependencies) == 0 { + return fmt.Errorf("at least one dependency must be configured") + } + + seenName := make(map[string]bool, len(c.Dependencies)) + for i := range c.Dependencies { + dep := &c.Dependencies[i] + + dep.Name = strings.TrimSpace(dep.Name) + if dep.Name == "" { + return fmt.Errorf("dependency %d has an empty name", i+1) + } + if seenName[dep.Name] { + return fmt.Errorf("duplicate dependency name %q", dep.Name) + } + seenName[dep.Name] = true + + dep.Product = strings.TrimSpace(dep.Product) + if dep.Product == "" { + return fmt.Errorf("dependency %q has an empty product", dep.Name) + } + + if len(dep.Track) == 0 { + dep.Track = []string{phaseEOL} + } + seenPhase := make(map[string]bool, len(dep.Track)) + for _, phase := range dep.Track { + if !isKnownPhase(phase) { + return fmt.Errorf( + "dependency %q tracks unknown phase %q, must be one of: %s", + dep.Name, phase, strings.Join(phaseOrder, ", "), + ) + } + if seenPhase[phase] { + return fmt.Errorf("dependency %q tracks phase %q more than once", dep.Name, phase) + } + seenPhase[phase] = true + } + } + + return nil +} + +// tracks reports whether the dependency is configured for the given phase. +func (d Dependency) tracks(phase string) bool { + for _, tracked := range d.Track { + if tracked == phase { + return true + } + } + + return false +} + +func isKnownPhase(phase string) bool { + for _, known := range phaseOrder { + if phase == known { + return true + } + } + + return false +} diff --git a/eol-notifier/cmd/notifier/config_test.go b/eol-notifier/cmd/notifier/config_test.go new file mode 100644 index 0000000..9ab4b2e --- /dev/null +++ b/eol-notifier/cmd/notifier/config_test.go @@ -0,0 +1,230 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" +) + +func writeFile(t *testing.T, name, contents string) string { + t.Helper() + + path := filepath.Join(t.TempDir(), name) + if err := os.WriteFile(path, []byte(contents), 0o600); err != nil { + t.Fatalf("failed to write %s: %v", name, err) + } + + return path +} + +func TestLoadDependencyConfig(t *testing.T) { + tests := []struct { + name string + contents string + wantErr bool + }{ + { + name: "valid config", + contents: ` +thresholds_months: [12, 6, 1] +dependencies: + - name: Redis + product: redis + track: [eol] + - name: GCP MemoryStore + product: redis + upstream_proxy: true +`, + }, + { + name: "minimal config relies on defaults", + contents: ` +dependencies: + - name: Redis + product: redis +`, + }, + { + name: "unknown phase is rejected", + contents: ` +dependencies: + - name: Redis + product: redis + track: [eol, discontinued] +`, + wantErr: true, + }, + { + name: "duplicate dependency name is rejected", + contents: ` +dependencies: + - name: Redis + product: redis + - name: Redis + product: valkey +`, + wantErr: true, + }, + { + name: "empty product is rejected", + contents: ` +dependencies: + - name: Redis + product: '' +`, + wantErr: true, + }, + { + name: "empty name is rejected", + contents: ` +dependencies: + - name: ' ' + product: redis +`, + wantErr: true, + }, + { + name: "no dependencies is rejected", + contents: "thresholds_months: [12]\n", + wantErr: true, + }, + { + name: "non-positive threshold is rejected", + contents: ` +thresholds_months: [12, 0] +dependencies: + - name: Redis + product: redis +`, + wantErr: true, + }, + { + name: "duplicate threshold is rejected", + contents: ` +thresholds_months: [6, 6] +dependencies: + - name: Redis + product: redis +`, + wantErr: true, + }, + { + name: "misspelled key is rejected", + contents: ` +dependencies: + - name: Redis + product: redis + upstream-proxy: true +`, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + path := writeFile(t, "dependencies.yaml", tt.contents) + + config, err := loadDependencyConfig(path) + if (err != nil) != tt.wantErr { + t.Fatalf("loadDependencyConfig() error = %v, wantErr %v", err, tt.wantErr) + } + if tt.wantErr { + return + } + + if len(config.ThresholdsMonths) == 0 { + t.Error("thresholds were not defaulted") + } + for _, dependency := range config.Dependencies { + if len(dependency.Track) == 0 { + t.Errorf("dependency %q has no tracked phase after defaulting", dependency.Name) + } + } + }) + } +} + +func TestLoadDependencyConfigMissingFile(t *testing.T) { + if _, err := loadDependencyConfig(filepath.Join(t.TempDir(), "absent.yaml")); err == nil { + t.Fatal("expected an error for a missing config file") + } +} + +// TestRepositoryConfigIsValid keeps the config this repository actually ships in +// step with the validation rules. +func TestRepositoryConfigIsValid(t *testing.T) { + path := filepath.Join("..", "..", "..", ".github", "eol-notifier", "dependencies.yaml") + if _, err := os.Stat(path); err != nil { + t.Skipf("repository config not present: %v", err) + } + + config, err := loadDependencyConfig(path) + if err != nil { + t.Fatalf("failed to load the repository config: %v", err) + } + + for _, want := range []string{"redis", "valkey", "postgresql", "mongodb"} { + found := false + for _, dependency := range config.Dependencies { + if dependency.Product == want { + found = true + break + } + } + if !found { + t.Errorf("repository config does not track %q", want) + } + } +} + +func TestLoadConfig(t *testing.T) { + tests := []struct { + name string + webhookURL string + dryRun bool + wantErr bool + }{ + {name: "webhook present", webhookURL: "https://hooks.slack.test/abc"}, + {name: "webhook missing", wantErr: true}, + {name: "webhook missing but dry run", dryRun: true}, + {name: "blank webhook", webhookURL: " ", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + process := func(prefix string, spec interface{}) error { + if prefix != "EN" { + t.Errorf("envconfig prefix = %q, want EN", prefix) + } + config, ok := spec.(*Config) + if !ok { + return fmt.Errorf("unexpected spec type %T", spec) + } + config.SlackWebhookURL = tt.webhookURL + + return nil + } + + config, err := loadConfig(process, tt.dryRun) + if (err != nil) != tt.wantErr { + t.Fatalf("loadConfig() error = %v, wantErr %v", err, tt.wantErr) + } + if err == nil && config.SlackWebhookURL != tt.webhookURL { + t.Errorf("webhook URL = %q, want %q", config.SlackWebhookURL, tt.webhookURL) + } + }) + } +} + +func TestLoadConfigPropagatesEnvironmentErrors(t *testing.T) { + process := func(string, interface{}) error { + return fmt.Errorf("boom") + } + + _, err := loadConfig(process, false) + if err == nil || !strings.Contains(err.Error(), "boom") { + t.Fatalf("loadConfig() error = %v, want the underlying failure to surface", err) + } +} diff --git a/eol-notifier/cmd/notifier/endoflife.go b/eol-notifier/cmd/notifier/endoflife.go new file mode 100644 index 0000000..58d9c55 --- /dev/null +++ b/eol-notifier/cmd/notifier/endoflife.go @@ -0,0 +1,181 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "time" +) + +const defaultBaseURL = "https://endoflife.date/api/v1" + +// Release is a single release cycle of a product. The *From fields are nullable +// in the API: a cycle can exist with no announced end date yet, and not every +// product publishes every phase. +type Release struct { + Name string `json:"name"` + Label string `json:"label"` + ReleaseDate string `json:"releaseDate"` + IsLTS bool `json:"isLts"` + IsMaintained bool `json:"isMaintained"` + + EOLFrom *string `json:"eolFrom"` + IsEOL bool `json:"isEol"` + EOASFrom *string `json:"eoasFrom"` + IsEOAS bool `json:"isEoas"` + EOESFrom *string `json:"eoesFrom"` + IsEOES bool `json:"isEoes"` +} + +// phase returns the date the given lifecycle phase ends and whether the product +// publishes the phase for this release. Whether that date has passed is not +// reported: a phase that is over is still owed an alert saying so, and the date +// itself answers the question. +func (r Release) phase(phase string) (date string, ok bool) { + var from *string + switch phase { + case phaseEOL: + from = r.EOLFrom + case phaseEOAS: + from = r.EOASFrom + case phaseEOES: + from = r.EOESFrom + default: + return "", false + } + + if from == nil || *from == "" { + return "", false + } + + return *from, true +} + +// Product is the endoflife.date representation of a tracked product. +type Product struct { + Name string `json:"name"` + // Label is the human-readable product name, e.g. "PostgreSQL". + Label string `json:"label"` + // Labels maps a lifecycle phase to the vendor's own wording for it, e.g. + // {"eol": "Security Support", "eoes": "Extended Support"}. + Labels map[string]string `json:"labels"` + Links ProductLinks `json:"links"` + Releases []Release `json:"releases"` +} + +// ProductLinks holds the public endoflife.date page for a product, used to link +// the alert back to the source. +type ProductLinks struct { + HTML string `json:"html"` +} + +// phaseLabel returns the vendor's wording for a phase, falling back to a +// generic description when the product does not name it. +func (p *Product) phaseLabel(phase string) string { + if label := p.Labels[phase]; label != "" { + return label + } + + switch phase { + case phaseEOAS: + return "Active Support" + case phaseEOES: + return "Extended Support" + default: + return "Support" + } +} + +type productResponse struct { + Result Product `json:"result"` +} + +// Client polls the endoflife.date API. BaseURL and HTTPClient are fields rather +// than constants, so tests can point it at a httptest server. +type Client struct { + BaseURL string + HTTPClient *http.Client + // RetryDelay is the pause before the single retry attempt. + RetryDelay time.Duration +} + +// NewClient returns a Client with production defaults. +func NewClient() *Client { + return &Client{ + BaseURL: defaultBaseURL, + HTTPClient: &http.Client{Timeout: 30 * time.Second}, + RetryDelay: 2 * time.Second, + } +} + +// FetchProduct retrieves a single product, retrying once on a transport error +// or a 5xx response. Client errors such as a 404 for an unknown slug are +// returned immediately, since retrying cannot help. +func (c *Client) FetchProduct(ctx context.Context, product string) (*Product, error) { + var lastErr error + + for attempt := 0; attempt < 2; attempt++ { + if attempt > 0 { + logWarn("retrying %s after error: %v", product, lastErr) + select { + case <-ctx.Done(): + return nil, fmt.Errorf("failed to fetch product %s: %w", product, ctx.Err()) + case <-time.After(c.RetryDelay): + } + } + + result, retryable, err := c.fetchOnce(ctx, product) + if err == nil { + return result, nil + } + + lastErr = err + if !retryable { + break + } + } + + return nil, fmt.Errorf("failed to fetch product %s: %w", product, lastErr) +} + +func (c *Client) fetchOnce(ctx context.Context, product string) (*Product, bool, error) { + endpoint := fmt.Sprintf("%s/products/%s", c.BaseURL, url.PathEscape(product)) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, false, fmt.Errorf("failed to build request: %w", err) + } + req.Header.Set("Accept", "application/json") + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return nil, true, fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + retryable := resp.StatusCode >= http.StatusInternalServerError || + resp.StatusCode == http.StatusTooManyRequests + + return nil, retryable, fmt.Errorf("unexpected status %s", resp.Status) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, true, fmt.Errorf("failed to read response body: %w", err) + } + + var parsed productResponse + if err := json.Unmarshal(body, &parsed); err != nil { + return nil, false, fmt.Errorf("failed to decode response: %w", err) + } + + if len(parsed.Result.Releases) == 0 { + return nil, false, fmt.Errorf("response contains no releases") + } + + return &parsed.Result, false, nil +} diff --git a/eol-notifier/cmd/notifier/endoflife_test.go b/eol-notifier/cmd/notifier/endoflife_test.go new file mode 100644 index 0000000..1e97825 --- /dev/null +++ b/eol-notifier/cmd/notifier/endoflife_test.go @@ -0,0 +1,226 @@ +package main + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +// fixtureServer serves captured endoflife.date responses from testdata, so no +// test reaches the real API. +func fixtureServer(t *testing.T, requests *int) *httptest.Server { + t.Helper() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if requests != nil { + *requests++ + } + + product := filepath.Base(r.URL.Path) + body, err := os.ReadFile(filepath.Join("testdata", product+".json")) + if err != nil { + w.WriteHeader(http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(body) + })) + t.Cleanup(server.Close) + + return server +} + +func testClient(baseURL string) *Client { + return &Client{BaseURL: baseURL, HTTPClient: http.DefaultClient} +} + +func TestFetchProductDecodesNullableFields(t *testing.T) { + server := fixtureServer(t, nil) + client := testClient(server.URL) + + tests := []struct { + name string + product string + release string + phase string + wantOK bool + wantDate string + phaseLabel string + }{ + { + name: "plain end of life date", + product: "postgresql", + release: "18", + phase: phaseEOL, + wantOK: true, + wantDate: "2030-11-14", + phaseLabel: "Support Status", + }, + { + name: "extended support date", + product: "amazon-rds-postgresql", + release: "18", + phase: phaseEOES, + wantOK: true, + wantDate: "2034-02-28", + phaseLabel: "Extended Support", + }, + { + name: "null end of life date is reported as absent", + product: "redis", + release: "8.8", + phase: phaseEOL, + wantOK: false, + phaseLabel: "Security Support", + }, + { + name: "elapsed phase still reports its date", + product: "redis", + release: "8.2", + phase: phaseEOL, + wantOK: true, + wantDate: "2026-05-25", + phaseLabel: "Security Support", + }, + { + name: "product without the phase reports it as absent", + product: "postgresql", + release: "18", + phase: phaseEOES, + wantOK: false, + phaseLabel: "Extended Support", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + product, err := client.FetchProduct(context.Background(), tt.product) + if err != nil { + t.Fatalf("FetchProduct(%q) error = %v", tt.product, err) + } + + var release Release + for _, candidate := range product.Releases { + if candidate.Name == tt.release { + release = candidate + } + } + if release.Name == "" { + t.Fatalf("fixture %s has no release %s", tt.product, tt.release) + } + + date, ok := release.phase(tt.phase) + if ok != tt.wantOK { + t.Fatalf("phase(%q) ok = %v, want %v", tt.phase, ok, tt.wantOK) + } + if ok && date != tt.wantDate { + t.Errorf("phase(%q) date = %q, want %q", tt.phase, date, tt.wantDate) + } + if got := product.phaseLabel(tt.phase); got != tt.phaseLabel { + t.Errorf("phaseLabel(%q) = %q, want %q", tt.phase, got, tt.phaseLabel) + } + }) + } +} + +func TestFetchProductRetriesServerErrors(t *testing.T) { + tests := []struct { + name string + statuses []int + wantErr bool + wantRequests int + }{ + {name: "succeeds on the retry", statuses: []int{http.StatusBadGateway}, wantRequests: 2}, + {name: "retries a rate limit", statuses: []int{http.StatusTooManyRequests}, wantRequests: 2}, + {name: "gives up after one retry", statuses: []int{http.StatusBadGateway, http.StatusBadGateway}, wantErr: true, wantRequests: 2}, + {name: "does not retry a client error", statuses: []int{http.StatusNotFound}, wantErr: true, wantRequests: 1}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + body, err := os.ReadFile(filepath.Join("testdata", "postgresql.json")) + if err != nil { + t.Fatalf("failed to read fixture: %v", err) + } + + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + if requests < len(tt.statuses) { + w.WriteHeader(tt.statuses[requests]) + requests++ + return + } + requests++ + _, _ = w.Write(body) + })) + defer server.Close() + + client := testClient(server.URL) + _, err = client.FetchProduct(context.Background(), "postgresql") + + if (err != nil) != tt.wantErr { + t.Fatalf("FetchProduct() error = %v, wantErr %v", err, tt.wantErr) + } + if requests != tt.wantRequests { + t.Errorf("made %d request(s), want %d", requests, tt.wantRequests) + } + }) + } +} + +func TestFetchProductRejectsUnusableResponses(t *testing.T) { + tests := []struct { + name string + body string + wantErr string + }{ + {name: "malformed json", body: "{", wantErr: "failed to decode response"}, + {name: "no releases", body: `{"result":{"name":"redis","releases":[]}}`, wantErr: "no releases"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(tt.body)) + })) + defer server.Close() + + _, err := testClient(server.URL).FetchProduct(context.Background(), "redis") + if err == nil { + t.Fatal("expected an error for an unusable response") + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Errorf("error = %v, want it to mention %q", err, tt.wantErr) + } + }) + } +} + +func TestFetchProductsPollsEachProductOnce(t *testing.T) { + requests := 0 + server := fixtureServer(t, &requests) + + config := testConfig(t, + Dependency{Name: "Redis", Product: "redis"}, + Dependency{Name: "PostgreSQL", Product: "postgresql"}, + Dependency{Name: "GCP MemoryStore", Product: "redis", UpstreamProxy: true}, + Dependency{Name: "Nonexistent", Product: "does-not-exist"}, + ) + + products, failed := fetchProducts(context.Background(), testClient(server.URL), productOrder(config)) + + if requests != 3 { + t.Errorf("made %d request(s), want 3 (redis fetched once for two dependencies)", requests) + } + if len(products) != 2 { + t.Errorf("fetched %d product(s), want 2", len(products)) + } + if len(failed) != 1 || failed[0] != "does-not-exist" { + t.Errorf("failed = %v, want [does-not-exist]", failed) + } +} diff --git a/eol-notifier/cmd/notifier/lifecycle.go b/eol-notifier/cmd/notifier/lifecycle.go new file mode 100644 index 0000000..03d1d34 --- /dev/null +++ b/eol-notifier/cmd/notifier/lifecycle.go @@ -0,0 +1,373 @@ +package main + +import ( + "fmt" + "sort" + "strconv" + "strings" + "time" +) + +const dateLayout = "2006-01-02" + +// endedMarker stands in for the threshold count in the key of an alert that +// reports a phase as over rather than approaching. +const endedMarker = "ended" + +// DependencyRef names a dependency affected by an alert. Several dependencies +// can share one product, so alerts are grouped and list every dependency the +// product stands in for. +type DependencyRef struct { + Name string + UpstreamProxy bool +} + +// EOLAlert reports that a release cycle reaches the end of a lifecycle phase in +// MonthsLeft months, or that it already has when Ended is set. +type EOLAlert struct { + Product string + ProductLabel string + ProductURL string + Release string + Phase string + PhaseLabel string + Date time.Time + MonthsLeft int + Ended bool + Dependencies []DependencyRef + // keys identify the alert in the state file, so it is delivered once and + // stays due until it has been. There is more than one when this alert stands + // in for thresholds it superseded, which are spent along with it. + keys []string +} + +// NewVersionAlert reports a release cycle that the API now lists but the +// previous run did not see. +type NewVersionAlert struct { + Product string + ProductLabel string + ProductURL string + Release string + ReleaseDate string + IsLTS bool + Dependencies []DependencyRef +} + +// Report is everything a single run found. +type Report struct { + NewVersions []NewVersionAlert + EOL []EOLAlert + // BaselineKeys lists, per product, the alert keys a product with no recorded + // history was already due. They are recorded as delivered without being + // posted: a product added to the config must not empty years of elapsed + // thresholds into the channel. + BaselineKeys map[string][]string + // Seed is true when no previous state existed, in which case new-version + // detection is suppressed to avoid announcing every historical release. + Seed bool +} + +// Empty reports whether there is nothing worth posting. +func (r Report) Empty() bool { + return r.alertCount() == 0 +} + +// alertCount is how many alerts the report carries, of either kind. +func (r Report) alertCount() int { + return len(r.NewVersions) + len(r.EOL) +} + +// hasProxy reports whether any alert relies on an upstream engine standing in +// for an untracked managed service. +func (r Report) hasProxy() bool { + for _, alert := range r.NewVersions { + if hasProxyRef(alert.Dependencies) { + return true + } + } + for _, alert := range r.EOL { + if hasProxyRef(alert.Dependencies) { + return true + } + } + + return false +} + +func hasProxyRef(refs []DependencyRef) bool { + for _, ref := range refs { + if ref.UpstreamProxy { + return true + } + } + + return false +} + +// detectAlerts compares the fetched products against the configured thresholds +// and what previous runs recorded. Products missing from products (because their +// fetch failed) are skipped. now is passed in rather than read from the clock so +// the whole detection path is testable. +// +// An alert is due from the day it comes up until the day it is delivered, not on +// one day only: a run that does not happen must not cost the channel a warning, +// however long the gap. What has already been delivered is read from the state, +// so an alert still fires exactly once. +// +// seed only drives the wording of the report; whether a product's releases count +// as new is decided per product, so adding a dependency to the config does not +// announce that product's entire back catalogue. What it does announce is every +// warning that product is still counting down to, missed days included: a +// version weeks from the end of its support is the first thing a new tracker +// owes the channel, not something to swallow as history. +// +// Releases carrying a date the API states in a form we cannot read are returned +// through the second value rather than only logged. Dropping one quietly would +// leave the job green with nobody a reason to look. +func detectAlerts(config *DependencyConfig, products map[string]*Product, state State, now time.Time, seed bool) (Report, []string) { + today := truncateToDay(now) + report := Report{BaselineKeys: map[string][]string{}, Seed: seed} + var unreadable []string + + for _, name := range productOrder(config) { + product, ok := products[name] + if !ok { + continue + } + + seen := state.seen(name) + sent := state.sent(name) + + // A product with no recorded history is a baseline: its releases are not + // news, and the phases that ended before anyone was watching are not + // either. What it is still counting down to is news, however, and is + // announced like any other warning. This covers both the first ever run + // and a product newly added to the config. + baseline := !state.has(name) + if baseline && !seed { + log("%s has no recorded history, recording its current releases as a baseline", name) + } + + for _, release := range product.Releases { + if !baseline && !seen[release.Name] && !release.IsEOL { + report.NewVersions = append(report.NewVersions, NewVersionAlert{ + Product: name, + ProductLabel: product.Label, + ProductURL: product.Links.HTML, + Release: releaseLabel(release), + ReleaseDate: release.ReleaseDate, + IsLTS: release.IsLTS, + Dependencies: dependenciesFor(config, name, ""), + }) + } + + for _, phase := range phaseOrder { + dependencies := dependenciesFor(config, name, phase) + if len(dependencies) == 0 { + continue + } + + value, ok := release.phase(phase) + if !ok { + continue + } + + date, err := time.Parse(dateLayout, value) + if err != nil { + logWarn("skipping %s %s: unparseable %s date %q", name, release.Name, phase, value) + unreadable = append(unreadable, fmt.Sprintf("%s %s (%s date %q)", name, release.Name, phase, value)) + continue + } + + owed := unsent(dueAlerts(release.Name, phase, date, today, config.ThresholdsMonths), sent) + if len(owed) == 0 { + continue + } + + // dueAlerts returns the ended alert on its own, so the first entry + // answers for the whole phase. + if baseline && owed[0].ended { + report.BaselineKeys[name] = append(report.BaselineKeys[name], owed[0].key) + continue + } + + due, superseded := mostUrgent(owed) + + report.EOL = append(report.EOL, EOLAlert{ + Product: name, + ProductLabel: product.Label, + ProductURL: product.Links.HTML, + Release: releaseLabel(release), + Phase: phase, + PhaseLabel: product.phaseLabel(phase), + Date: date, + MonthsLeft: due.months, + Ended: due.ended, + Dependencies: dependencies, + keys: append([]string{due.key}, superseded...), + }) + } + } + } + + sort.SliceStable(report.EOL, func(i, j int) bool { + if report.EOL[i].Ended != report.EOL[j].Ended { + return report.EOL[i].Ended + } + + return report.EOL[i].MonthsLeft > report.EOL[j].MonthsLeft + }) + + return report, unreadable +} + +// dueAlert is one alert a phase owes: a threshold that has come up, or the phase +// having ended outright. +type dueAlert struct { + months int + ended bool + key string +} + +// dueAlerts returns what a phase owes as of today, whether it came up today or +// on a day no run happened. Once the date itself has passed the phase has simply +// ended, and the thresholds counting down to it would only say something untrue, +// so the ended alert stands for them. +// +// The date is part of every key, so a date the API later revises is due afresh +// rather than silenced by the alert sent for the date it replaced. +func dueAlerts(cycle, phase string, date, today time.Time, thresholds []int) []dueAlert { + stamp := date.Format(dateLayout) + + if !date.After(today) { + return []dueAlert{{ended: true, key: alertKey(cycle, phase, endedMarker, stamp)}} + } + + var due []dueAlert + for _, months := range thresholds { + if monthsBefore(date, months).After(today) { + continue + } + + due = append(due, dueAlert{months: months, key: alertKey(cycle, phase, strconv.Itoa(months), stamp)}) + } + + return due +} + +// unsent drops the alerts already delivered. +func unsent(due []dueAlert, sent map[string]bool) []dueAlert { + owed := make([]dueAlert, 0, len(due)) + for _, candidate := range due { + if sent[candidate.key] { + continue + } + owed = append(owed, candidate) + } + + return owed +} + +// mostUrgent picks the one alert to send when several thresholds for the same +// phase are owed at once, which happens on the first run and after a gap in +// runs. Only the closest one is still true by then - "twelve months" is a lie +// once the six-month day has passed too - so the rest are returned as +// superseded. They are spent along with the alert that stands in for them +// rather than left to come up again. +func mostUrgent(due []dueAlert) (dueAlert, []string) { + pick := 0 + for index, candidate := range due { + if candidate.months < due[pick].months { + pick = index + } + } + + var superseded []string + for index, candidate := range due { + if index != pick { + superseded = append(superseded, candidate.key) + } + } + + return due[pick], superseded +} + +// alertKey identifies one alert across runs. +func alertKey(cycle, phase, marker, date string) string { + return strings.Join([]string{cycle, phase, marker, date}, "|") +} + +// monthsBefore returns the date exactly months months before date, clamped to +// the last day of the target month. Clamping matters: Go's AddDate turns +// 2027-03-31 minus one month into 2027-03-03, which would hold the one-month +// warning back to three days into the month it was meant to open. +func monthsBefore(date time.Time, months int) time.Time { + year, month, day := date.Date() + + target := time.Date(year, month-time.Month(months), 1, 0, 0, 0, 0, time.UTC) + if last := daysInMonth(target); day > last { + day = last + } + + return time.Date(target.Year(), target.Month(), day, 0, 0, 0, 0, time.UTC) +} + +// daysInMonth returns the number of days in the month of date. Day 0 of the +// following month is the last day of this one. +func daysInMonth(date time.Time) int { + return time.Date(date.Year(), date.Month()+1, 0, 0, 0, 0, 0, time.UTC).Day() +} + +// truncateToDay drops the time of day so dates can be compared for equality. +func truncateToDay(t time.Time) time.Time { + year, month, day := t.UTC().Date() + + return time.Date(year, month, day, 0, 0, 0, 0, time.UTC) +} + +// productOrder lists the distinct products in the order they first appear in +// the config, so a shared product is fetched and reported once. +func productOrder(config *DependencyConfig) []string { + seen := make(map[string]bool, len(config.Dependencies)) + order := make([]string, 0, len(config.Dependencies)) + + for _, dependency := range config.Dependencies { + if seen[dependency.Product] { + continue + } + seen[dependency.Product] = true + order = append(order, dependency.Product) + } + + return order +} + +// dependenciesFor returns the dependencies backed by a product. An empty phase +// matches every dependency, which is what new-version alerts want; otherwise +// only the dependencies configured to track that phase are returned. +func dependenciesFor(config *DependencyConfig, product, phase string) []DependencyRef { + var refs []DependencyRef + + for _, dependency := range config.Dependencies { + if dependency.Product != product { + continue + } + if phase != "" && !dependency.tracks(phase) { + continue + } + + refs = append(refs, DependencyRef{Name: dependency.Name, UpstreamProxy: dependency.UpstreamProxy}) + } + + return refs +} + +// releaseLabel prefers the API's display label, which is occasionally friendlier +// than the raw cycle name. +func releaseLabel(release Release) string { + if release.Label != "" { + return release.Label + } + + return release.Name +} diff --git a/eol-notifier/cmd/notifier/lifecycle_test.go b/eol-notifier/cmd/notifier/lifecycle_test.go new file mode 100644 index 0000000..f16fdd8 --- /dev/null +++ b/eol-notifier/cmd/notifier/lifecycle_test.go @@ -0,0 +1,580 @@ +package main + +import ( + "fmt" + "slices" + "strings" + "testing" + "time" +) + +func mustDate(t *testing.T, value string) time.Time { + t.Helper() + + parsed, err := time.Parse(dateLayout, value) + if err != nil { + t.Fatalf("failed to parse test date %q: %v", value, err) + } + + return parsed +} + +func strPtr(value string) *string { + return &value +} + +// tracked builds a state that has seen a product before, so detection treats it +// as tracked rather than as a baseline with nothing to announce. +func tracked(product string, releases ...string) State { + return State{product: {Releases: releases}} +} + +// firedMonths lists what a report alerts on, an ended phase counting as 0, so +// tests can assert on which thresholds came up rather than only how many. +func firedMonths(report Report) []int { + months := make([]int, 0, len(report.EOL)) + for _, alert := range report.EOL { + if alert.Ended { + months = append(months, 0) + continue + } + months = append(months, alert.MonthsLeft) + } + + return months +} + +// testConfig builds a validated config, so tests exercise the same defaulting +// the real config file goes through. +func testConfig(t *testing.T, dependencies ...Dependency) *DependencyConfig { + t.Helper() + + config := &DependencyConfig{Dependencies: dependencies} + if err := config.validate(); err != nil { + t.Fatalf("failed to validate test config: %v", err) + } + + return config +} + +func TestMonthsBefore(t *testing.T) { + tests := []struct { + name string + date string + months int + want string + }{ + {"twelve months", "2027-11-11", 12, "2026-11-11"}, + {"six months", "2027-11-11", 6, "2027-05-11"}, + {"one month", "2027-11-11", 1, "2027-10-11"}, + {"clamps onto a shorter month", "2027-03-31", 1, "2027-02-28"}, + {"clamps onto a leap february", "2028-03-31", 1, "2028-02-29"}, + {"leap day keeps its day number", "2028-02-29", 1, "2028-01-29"}, + {"crosses the year boundary", "2027-01-15", 12, "2026-01-15"}, + {"crosses the year boundary and clamps", "2027-01-31", 2, "2026-11-30"}, + {"twelve months from a leap day", "2028-02-29", 12, "2027-02-28"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := monthsBefore(mustDate(t, tt.date), tt.months) + if want := mustDate(t, tt.want); !got.Equal(want) { + t.Errorf("monthsBefore(%s, %d) = %s, want %s", + tt.date, tt.months, got.Format(dateLayout), tt.want) + } + }) + } +} + +// TestEveryAlertFiresExactlyOnce drives the detection over a stretch of days, +// recording what each run delivered, and covers both halves of the guarantee: +// nothing is announced twice, and nothing is lost when the day an alert came up +// had no run at all. The dates are the ones that motivated the clamping in +// monthsBefore, where a threshold is most likely to land on the wrong day. +func TestEveryAlertFiresExactlyOnce(t *testing.T) { + eolDates := []string{"2027-01-31", "2027-02-28", "2027-03-31", "2028-02-29", "2027-08-31"} + + // Runs every day, and runs that skip four days in five: the outcome must not + // depend on which days the action happened to be up. + for _, step := range []int{1, 5} { + for _, eol := range eolDates { + t.Run(fmt.Sprintf("%s every %d day(s)", eol, step), func(t *testing.T) { + config := testConfig(t, Dependency{Name: "PostgreSQL", Product: "postgresql"}) + products := map[string]*Product{ + "postgresql": { + Name: "postgresql", + Label: "PostgreSQL", + Releases: []Release{{Name: "15", EOLFrom: strPtr(eol)}}, + }, + } + + state := tracked("postgresql", "15") + fired := make(map[int]int) + + end := mustDate(t, eol).AddDate(0, 0, 10) + for day := monthsBefore(mustDate(t, eol), 12).AddDate(0, 0, -10); !day.After(end); day = day.AddDate(0, 0, step) { + report, _ := detectAlerts(config, products, state, day, false) + for _, months := range firedMonths(report) { + fired[months]++ + } + for _, alert := range report.EOL { + state.markSent(alert.Product, alert.keys) + } + } + + for _, months := range append([]int{0}, config.ThresholdsMonths...) { + if fired[months] != 1 { + t.Errorf("the %s alert fired %d time(s), want exactly 1", thresholdName(months), fired[months]) + } + } + }) + } + } +} + +func thresholdName(months int) string { + if months == 0 { + return "ended" + } + + return fmt.Sprintf("%d month", months) +} + +func TestDetectAlertsEOL(t *testing.T) { + postgres := func(releases ...Release) map[string]*Product { + return map[string]*Product{ + "postgresql": { + Name: "postgresql", + Label: "PostgreSQL", + Labels: map[string]string{phaseEOL: "Support Status"}, + Releases: releases, + }, + } + } + + // The alerts a run would have delivered on the way to 2027-10-11. + alreadySent := []string{ + alertKey("15", phaseEOL, "12", "2027-11-11"), + alertKey("15", phaseEOL, "6", "2027-11-11"), + } + + tests := []struct { + name string + products map[string]*Product + sent []string + today string + want []int + }{ + { + name: "fires twelve months ahead", + products: postgres(Release{Name: "15", EOLFrom: strPtr("2027-11-11")}), + today: "2026-11-11", + want: []int{12}, + }, + { + name: "fires one month ahead", + products: postgres(Release{Name: "15", EOLFrom: strPtr("2027-11-11")}), + sent: alreadySent, + today: "2027-10-11", + want: []int{1}, + }, + { + name: "silent a day early", + products: postgres(Release{Name: "15", EOLFrom: strPtr("2027-11-11")}), + sent: alreadySent, + today: "2027-10-10", + want: nil, + }, + { + name: "silent once delivered", + products: postgres(Release{Name: "15", EOLFrom: strPtr("2027-11-11")}), + sent: append(alreadySent, alertKey("15", phaseEOL, "1", "2027-11-11")), + today: "2027-10-12", + want: nil, + }, + { + // The action was down for the days all three thresholds came up. Only + // the closest one is still true, so it is the one sent; saying "twelve + // months" a month before the end would be worse than saying nothing. + name: "catches up with the closest threshold only", + products: postgres(Release{Name: "15", EOLFrom: strPtr("2027-11-11")}), + today: "2027-10-11", + want: []int{1}, + }, + { + name: "skips a release with no announced date", + products: postgres(Release{Name: "18", EOLFrom: nil}), + today: "2026-11-11", + want: nil, + }, + { + // A phase that is over is reported as over rather than skipped: it is + // the one thing a channel that missed the countdown still needs. + name: "reports a phase that has already passed", + products: postgres(Release{Name: "13", EOLFrom: strPtr("2026-05-25"), IsEOL: true}), + today: "2026-11-11", + want: []int{0}, + }, + { + name: "skips a product whose fetch failed", + products: map[string]*Product{}, + today: "2026-11-11", + want: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + config := testConfig(t, Dependency{Name: "PostgreSQL", Product: "postgresql"}) + state := tracked("postgresql", "13", "15", "18") + state.markSent("postgresql", tt.sent) + + report, _ := detectAlerts(config, tt.products, state, mustDate(t, tt.today), false) + + if got := firedMonths(report); !slices.Equal(got, tt.want) { + t.Fatalf("fired %v, want %v", got, tt.want) + } + if len(tt.want) > 0 && report.EOL[0].PhaseLabel != "Support Status" { + t.Errorf("phase label = %q, want the product's own wording", report.EOL[0].PhaseLabel) + } + }) + } +} + +// TestDetectAlertsSpendsSupersededThresholds covers the thresholds the closest +// one stood in for. They were never posted, but they are spent all the same: +// leaving them owed would make every later run announce a countdown that had +// already run out. +func TestDetectAlertsSpendsSupersededThresholds(t *testing.T) { + config := testConfig(t, Dependency{Name: "PostgreSQL", Product: "postgresql"}) + products := map[string]*Product{ + "postgresql": { + Name: "postgresql", + Label: "PostgreSQL", + Releases: []Release{{Name: "15", EOLFrom: strPtr("2027-11-11")}}, + }, + } + state := tracked("postgresql", "15") + + // All three thresholds came up while nothing was running. + report, _ := detectAlerts(config, products, state, mustDate(t, "2027-10-11"), false) + + if got := firedMonths(report); !slices.Equal(got, []int{1}) { + t.Fatalf("fired %v, want only the closest threshold", got) + } + + want := []string{ + alertKey("15", phaseEOL, "1", "2027-11-11"), + alertKey("15", phaseEOL, "12", "2027-11-11"), + alertKey("15", phaseEOL, "6", "2027-11-11"), + } + if got := report.EOL[0].keys; !slices.Equal(got, want) { + t.Fatalf("alert keys = %v, want %v", got, want) + } + + // Recording what that one alert answered for leaves nothing behind. + for _, alert := range report.EOL { + state.markSent(alert.Product, alert.keys) + } + + next, _ := detectAlerts(config, products, state, mustDate(t, "2027-10-12"), false) + if len(next.EOL) != 0 { + t.Errorf("the next run announced %d alert(s), want none", len(next.EOL)) + } +} + +// TestDetectAlertsRefiresAfterDateRevision covers endoflife.date moving a date: +// the alert delivered for the old date says nothing about the new one, so the +// new one is due on its own. +func TestDetectAlertsRefiresAfterDateRevision(t *testing.T) { + config := testConfig(t, Dependency{Name: "PostgreSQL", Product: "postgresql"}) + products := map[string]*Product{ + "postgresql": { + Name: "postgresql", + Label: "PostgreSQL", + Releases: []Release{{Name: "15", EOLFrom: strPtr("2027-09-30")}}, + }, + } + + state := tracked("postgresql", "15") + state.markSent("postgresql", []string{alertKey("15", phaseEOL, "12", "2027-11-11")}) + + report, _ := detectAlerts(config, products, state, mustDate(t, "2026-11-11"), false) + + if got := firedMonths(report); !slices.Equal(got, []int{12}) { + t.Fatalf("fired %v, want the twelve-month alert for the revised date", got) + } + if want := mustDate(t, "2027-09-30"); !report.EOL[0].Date.Equal(want) { + t.Errorf("alert date = %s, want the revised date", report.EOL[0].Date.Format(dateLayout)) + } +} + +// TestDetectAlertsBaselineAnnouncesLiveWarnings covers a product with no +// recorded history: on the first run, or when a dependency is added to the +// config. The phases that ended before anyone was watching are spent and are +// recorded without being posted. A version still counting down is the opposite: +// its warning is announced, missed trigger day or not, because a version weeks +// from the end of its support is exactly what the channel is there for. +func TestDetectAlertsBaselineAnnouncesLiveWarnings(t *testing.T) { + config := testConfig(t, Dependency{Name: "PostgreSQL", Product: "postgresql"}) + products := map[string]*Product{ + "postgresql": { + Name: "postgresql", + Label: "PostgreSQL", + Releases: []Release{ + {Name: "13", EOLFrom: strPtr("2025-11-13"), IsEOL: true}, + {Name: "15", EOLFrom: strPtr("2027-11-11")}, + }, + }, + } + + // Nine days after 15's twelve-month warning came up, so it was missed rather + // than due today. + report, _ := detectAlerts(config, products, State{}, mustDate(t, "2026-11-20"), true) + + if got := firedMonths(report); !slices.Equal(got, []int{12}) { + t.Fatalf("fired %v, want the warning 15 is still counting down to", got) + } + if report.EOL[0].Release != "15" { + t.Errorf("alert is for release %q, want 15", report.EOL[0].Release) + } + + // 13 was out of support before this product was ever polled. Nobody needs to + // hear it now, and recording it keeps it that way. + want := []string{alertKey("13", phaseEOL, endedMarker, "2025-11-13")} + if got := report.BaselineKeys["postgresql"]; !slices.Equal(got, want) { + t.Errorf("baseline keys = %v, want %v", got, want) + } +} + +// TestDetectAlertsReportsUnreadableDates covers a date the API states in a form +// the layout cannot read. The release loses its alert either way, so the run has +// to report it: a silent skip leaves a green job and an empty channel, which is +// indistinguishable from having nothing to say. +func TestDetectAlertsReportsUnreadableDates(t *testing.T) { + products := map[string]*Product{ + "postgresql": { + Name: "postgresql", + Label: "PostgreSQL", + Releases: []Release{ + {Name: "15", EOLFrom: strPtr("November 2027")}, + {Name: "16", EOLFrom: strPtr("2027-11-11")}, + }, + }, + } + config := testConfig(t, Dependency{Name: "PostgreSQL", Product: "postgresql"}) + + report, unreadable := detectAlerts(config, products, tracked("postgresql", "15", "16"), mustDate(t, "2026-11-11"), false) + + if len(unreadable) != 1 { + t.Fatalf("got %d unreadable date(s), want 1: %v", len(unreadable), unreadable) + } + for _, want := range []string{"postgresql", "15", phaseEOL, "November 2027"} { + if !strings.Contains(unreadable[0], want) { + t.Errorf("unreadable entry %q does not mention %q", unreadable[0], want) + } + } + + // One bad date is not a reason to stop reading the releases around it. + if len(report.EOL) != 1 { + t.Fatalf("got %d EoL alert(s), want the readable release still reported", len(report.EOL)) + } + if report.EOL[0].Release != "16" { + t.Errorf("alert is for release %q, want 16", report.EOL[0].Release) + } +} + +func TestDetectAlertsTracksConfiguredPhasesOnly(t *testing.T) { + products := map[string]*Product{ + "amazon-rds-postgresql": { + Name: "amazon-rds-postgresql", + Label: "Amazon RDS for PostgreSQL", + Labels: map[string]string{phaseEOL: "Security Support", phaseEOES: "Extended Support"}, + Releases: []Release{{ + Name: "15", + EOLFrom: strPtr("2028-02-29"), + EOESFrom: strPtr("2031-02-28"), + }}, + }, + } + + // By 2030 the security support the rows below run past is long delivered. + spent := []string{ + alertKey("15", phaseEOL, "12", "2028-02-29"), + alertKey("15", phaseEOL, "6", "2028-02-29"), + alertKey("15", phaseEOL, "1", "2028-02-29"), + alertKey("15", phaseEOL, endedMarker, "2028-02-29"), + } + + tests := []struct { + name string + track []string + sent []string + today string + want string + }{ + {"eol only, on the eol trigger", []string{phaseEOL}, nil, "2027-02-28", phaseEOL}, + {"eol only, ignores extended support", []string{phaseEOL}, spent, "2030-02-28", ""}, + {"extended support tracked", []string{phaseEOL, phaseEOES}, spent, "2030-02-28", phaseEOES}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + config := testConfig(t, Dependency{ + Name: "Amazon RDS PostgreSQL", + Product: "amazon-rds-postgresql", + Track: tt.track, + }) + state := tracked("amazon-rds-postgresql", "15") + state.markSent("amazon-rds-postgresql", tt.sent) + + report, _ := detectAlerts(config, products, state, mustDate(t, tt.today), false) + + if tt.want == "" { + if len(report.EOL) != 0 { + t.Fatalf("got %d alert(s), want none", len(report.EOL)) + } + return + } + + if len(report.EOL) != 1 { + t.Fatalf("got %d alert(s), want 1", len(report.EOL)) + } + if report.EOL[0].Phase != tt.want { + t.Errorf("phase = %q, want %q", report.EOL[0].Phase, tt.want) + } + }) + } +} + +// TestDetectAlertsGroupsSharedProduct covers the upstream-fallback mapping: a +// managed service the API does not track rides on the upstream engine, and both +// dependencies must appear on one alert rather than producing two. +func TestDetectAlertsGroupsSharedProduct(t *testing.T) { + config := testConfig(t, + Dependency{Name: "Redis", Product: "redis"}, + Dependency{Name: "GCP MemoryStore", Product: "redis", UpstreamProxy: true}, + ) + products := map[string]*Product{ + "redis": { + Name: "redis", + Label: "Redis", + Releases: []Release{{Name: "7.2", EOLFrom: strPtr("2027-11-11")}}, + }, + } + + state := tracked("redis", "7.2") + state.markSent("redis", []string{alertKey("7.2", phaseEOL, "12", "2027-11-11")}) + + report, _ := detectAlerts(config, products, state, mustDate(t, "2027-05-11"), false) + + if len(report.EOL) != 1 { + t.Fatalf("got %d alert(s), want 1 grouped alert", len(report.EOL)) + } + + alert := report.EOL[0] + if alert.MonthsLeft != 6 { + t.Errorf("months left = %d, want 6", alert.MonthsLeft) + } + if len(alert.Dependencies) != 2 { + t.Fatalf("got %d dependencies, want Redis and GCP MemoryStore", len(alert.Dependencies)) + } + if alert.Dependencies[0].Name != "Redis" || alert.Dependencies[0].UpstreamProxy { + t.Errorf("first dependency = %+v, want Redis tracked directly", alert.Dependencies[0]) + } + if alert.Dependencies[1].Name != "GCP MemoryStore" || !alert.Dependencies[1].UpstreamProxy { + t.Errorf("second dependency = %+v, want GCP MemoryStore marked as a proxy", alert.Dependencies[1]) + } + if !report.hasProxy() { + t.Error("report does not report a proxy, so the caveat would be omitted") + } +} + +func TestDetectAlertsNewVersions(t *testing.T) { + products := map[string]*Product{ + "postgresql": { + Name: "postgresql", + Label: "PostgreSQL", + Releases: []Release{ + {Name: "18", ReleaseDate: "2025-09-25", EOLFrom: strPtr("2030-11-14")}, + {Name: "17", ReleaseDate: "2024-09-26", EOLFrom: strPtr("2029-11-08")}, + {Name: "13", ReleaseDate: "2020-09-24", EOLFrom: strPtr("2025-11-13"), IsEOL: true}, + }, + }, + } + + tests := []struct { + name string + state State + seed bool + want []string + }{ + { + name: "seed run announces nothing", + state: State{}, + seed: true, + want: nil, + }, + { + name: "unseen release is announced", + state: tracked("postgresql", "17"), + want: []string{"18"}, + }, + { + name: "everything already seen is silent", + state: tracked("postgresql", "13", "17", "18"), + want: nil, + }, + { + // A release that was already dead when it first appeared is a + // backfill, not news. It still gets an end-of-life alert saying so. + name: "backfilled dead release is not announced as new", + state: tracked("postgresql", "17", "18"), + want: nil, + }, + { + // Adding a dependency to the config must not announce that product's + // entire back catalogue on the next run. + name: "product with no recorded history is a baseline", + state: tracked("redis", "8.8"), + want: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + config := testConfig(t, Dependency{Name: "PostgreSQL", Product: "postgresql"}) + + report, _ := detectAlerts(config, products, tt.state, mustDate(t, "2026-07-29"), tt.seed) + + if len(report.NewVersions) != len(tt.want) { + t.Fatalf("got %d new version(s), want %d", len(report.NewVersions), len(tt.want)) + } + for i, want := range tt.want { + if report.NewVersions[i].Release != want { + t.Errorf("new version %d = %q, want %q", i, report.NewVersions[i].Release, want) + } + } + }) + } +} + +func TestProductOrderDeduplicates(t *testing.T) { + config := testConfig(t, + Dependency{Name: "Redis", Product: "redis"}, + Dependency{Name: "PostgreSQL", Product: "postgresql"}, + Dependency{Name: "GCP MemoryStore", Product: "redis", UpstreamProxy: true}, + ) + + got := productOrder(config) + want := []string{"redis", "postgresql"} + + if len(got) != len(want) { + t.Fatalf("got %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("got %v, want %v", got, want) + } + } +} diff --git a/eol-notifier/cmd/notifier/main.go b/eol-notifier/cmd/notifier/main.go new file mode 100644 index 0000000..3db5f25 --- /dev/null +++ b/eol-notifier/cmd/notifier/main.go @@ -0,0 +1,262 @@ +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "os" + "strings" + "time" + + "github.com/kelseyhightower/envconfig" +) + +// runTimeout bounds the whole run: a handful of small GETs plus one webhook post. +const runTimeout = 5 * time.Minute + +// options carries everything a run depends on. The clock and both clients are +// fields, so tests can drive the whole flow against fake servers. +type options struct { + configPath string + statePath string + dryRun bool + now time.Time + client *Client + slack *slackClient +} + +func main() { + if err := run(); err != nil { + logError("%v", err) + os.Exit(1) + } +} + +func run() error { + configPath := flag.String("config", "", "Path to the dependency config YAML") + statePath := flag.String("state", "", "Path to the JSON file recording previously seen releases") + dryRun := flag.Bool("dry-run", false, "Render the digest to stdout instead of posting it, and leave the state file untouched") + flag.Parse() + + if strings.TrimSpace(*configPath) == "" { + return fmt.Errorf("failed to start, --config is required") + } + if strings.TrimSpace(*statePath) == "" { + return fmt.Errorf("failed to start, --state is required") + } + + config, err := loadConfig(envconfig.Process, *dryRun) + if err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), runTimeout) + defer cancel() + + return execute(ctx, options{ + configPath: *configPath, + statePath: *statePath, + dryRun: *dryRun, + now: time.Now(), + client: NewClient(), + slack: newSlackClient(config.SlackWebhookURL), + }) +} + +func execute(ctx context.Context, opts options) error { + dependencyConfig, err := loadDependencyConfig(opts.configPath) + if err != nil { + return err + } + + state, existed, err := loadState(opts.statePath) + if err != nil { + return err + } + if !existed { + log("no state file at %s, recording a baseline without announcing new versions", opts.statePath) + } + + products, failed := fetchProducts(ctx, opts.client, productOrder(dependencyConfig)) + + report, unreadable := detectAlerts(dependencyConfig, products, state, opts.now, !existed) + log("found %d new version(s) and %d end-of-life alert(s)", len(report.NewVersions), len(report.EOL)) + + delivered, publishErr := publish(ctx, opts.slack, report, opts.dryRun) + + // Only what reached the channel is recorded, and it is recorded even when a + // later part of the digest failed: an alert the channel never heard is due + // again tomorrow, and one it did hear must not be repeated. + if opts.dryRun { + log("dry run, leaving %s untouched", opts.statePath) + } else { + recordRun(state, products, report, delivered) + if err := saveState(opts.statePath, state); err != nil { + return err + } + } + + return runProblems(publishErr, failed, unreadable) +} + +// recordRun folds a finished run into the state: the alerts that were delivered, +// the ones a product with no history was due but never had posted, and the +// release cycles of the products that owe the channel nothing. +func recordRun(state State, products map[string]*Product, report Report, delivered []Report) { + for product, keys := range report.BaselineKeys { + state.markSent(product, keys) + } + + for _, part := range delivered { + for _, alert := range part.EOL { + state.markSent(alert.Product, alert.keys) + } + } + + owed := undeliveredProducts(report, delivered) + for name, product := range products { + if owed[name] { + continue + } + state.record(name, product.Releases) + } +} + +// undeliveredProducts names the products still owed a new-version alert. Their +// release cycles must stay unrecorded: a release marked as seen is never +// announced again, so recording one whose alert never landed would lose it. +func undeliveredProducts(report Report, delivered []Report) map[string]bool { + landed := make(map[string]bool) + for _, part := range delivered { + for _, alert := range part.NewVersions { + landed[alert.Product+"|"+alert.Release] = true + } + } + + owed := make(map[string]bool) + for _, alert := range report.NewVersions { + if !landed[alert.Product+"|"+alert.Release] { + owed[alert.Product] = true + } + } + + return owed +} + +// runProblems turns the problems a run survived into its exit status. They are +// reported only here, after the digest went out and the state was written: the +// alerts that did work should still reach the channel, but each of these delays +// a release its alert, so the job has to go red or the delay is invisible. +func runProblems(publishErr error, failed, unreadable []string) error { + var problems []string + + if publishErr != nil { + problems = append(problems, publishErr.Error()) + } + if len(failed) > 0 { + problems = append(problems, fmt.Sprintf( + "failed to fetch %d product(s): %s", len(failed), strings.Join(failed, ", "), + )) + } + if len(unreadable) > 0 { + problems = append(problems, fmt.Sprintf( + "skipped %d release(s) with an unreadable date: %s", len(unreadable), strings.Join(unreadable, ", "), + )) + } + + if len(problems) == 0 { + return nil + } + + return errors.New(strings.Join(problems, "; ")) +} + +// fetchProducts polls every distinct product once. A product that cannot be +// fetched is reported rather than fatal, so one broken slug does not suppress +// alerts for everything else. +func fetchProducts(ctx context.Context, client *Client, names []string) (map[string]*Product, []string) { + products := make(map[string]*Product, len(names)) + var failed []string + + for _, name := range names { + product, err := client.FetchProduct(ctx, name) + if err != nil { + logWarn("%v", err) + failed = append(failed, name) + continue + } + + log("fetched %s (%d release cycles)", name, len(product.Releases)) + products[name] = product + } + + return products, failed +} + +// publish delivers the digest, in as many messages as Slack's block limit +// requires, and returns the parts that landed. A part that fails costs only +// itself: the parts before it are reported as delivered and the rest stay due. +// Nothing to report means nothing is posted at all. +func publish(ctx context.Context, slack *slackClient, report Report, dryRun bool) ([]Report, error) { + if report.Empty() { + log("nothing to report, no Slack message sent") + return nil, nil + } + + parts := splitReport(report) + if len(parts) > 1 { + log("digest does not fit one Slack message, posting it in %d parts", len(parts)) + } + + var delivered []Report + for _, part := range parts { + payload, err := encodeMessage(buildMessage(part)) + if err != nil { + return delivered, err + } + + if dryRun { + log("dry run, would post to Slack:\n%s", payload) + continue + } + + if err := slack.Post(ctx, payload); err != nil { + return delivered, err + } + delivered = append(delivered, part) + } + + if !dryRun { + log("posted digest to Slack in %d message(s)", len(delivered)) + } + + return delivered, nil +} + +func log(msg string, args ...interface{}) { + _, _ = fmt.Fprintf(os.Stdout, "[INFO] %s\n", format(msg, args...)) +} + +func logWarn(msg string, args ...interface{}) { + _, _ = fmt.Fprintf(os.Stdout, "[WARN] %s\n", format(msg, args...)) +} + +func logError(msg string, args ...interface{}) { + line := format(msg, args...) + + _, _ = fmt.Fprintf(os.Stdout, "[ERROR] %s\n", line) + _, _ = fmt.Fprintln(os.Stderr, line) +} + +// format expands the arguments only when there are some. Without this guard a +// message carrying a literal % - an error text passed straight in, say - would be +// read as a format string and printed as %!s(MISSING) noise, corrupting the only +// diagnostic the operator gets. +func format(msg string, args ...interface{}) string { + if len(args) == 0 { + return msg + } + + return fmt.Sprintf(msg, args...) +} diff --git a/eol-notifier/cmd/notifier/main_test.go b/eol-notifier/cmd/notifier/main_test.go new file mode 100644 index 0000000..7d5fe52 --- /dev/null +++ b/eol-notifier/cmd/notifier/main_test.go @@ -0,0 +1,411 @@ +package main + +import ( + "context" + "encoding/json" + "net/http" + "os" + "path/filepath" + "slices" + "strings" + "testing" +) + +const e2eConfig = ` +dependencies: + - name: PostgreSQL + product: postgresql + track: [eol] + - name: Redis + product: redis + track: [eol] + - name: GCP MemoryStore + product: redis + track: [eol] + upstream_proxy: true +` + +// e2eOptions wires a full run against fake endoflife.date and Slack servers. +func e2eOptions(t *testing.T, dir string, slack *captureServer, today string, config string) options { + t.Helper() + + configPath := filepath.Join(dir, "dependencies.yaml") + if err := os.WriteFile(configPath, []byte(config), 0o600); err != nil { + t.Fatalf("failed to write config: %v", err) + } + + return options{ + configPath: configPath, + statePath: filepath.Join(dir, "state.json"), + now: mustDate(t, today), + client: testClient(fixtureServer(t, nil).URL), + slack: slack.client(), + } +} + +// e2eState seeds the state a healthy run would have left on day: every release +// recorded, and every alert due by then already delivered. The releases named by +// unseen are then dropped, standing in for cycles the API listed since. +func e2eState(t *testing.T, day string, unseen ...string) State { + t.Helper() + + slack := newCaptureServer(t, http.StatusOK) + opts := e2eOptions(t, t.TempDir(), slack, day, e2eConfig) + + if err := execute(context.Background(), opts); err != nil { + t.Fatalf("failed to seed state: %v", err) + } + if slack.requests != 0 { + t.Fatalf("the seeding run posted %d message(s), so it is not a clean slate", slack.requests) + } + + state := readState(t, opts.statePath) + for product, recorded := range state { + recorded.Releases = slices.DeleteFunc(recorded.Releases, func(name string) bool { + return slices.Contains(unseen, name) + }) + state[product] = recorded + } + + return state +} + +func readState(t *testing.T, path string) State { + t.Helper() + + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("failed to read state file: %v", err) + } + + state := State{} + if err := json.Unmarshal(data, &state); err != nil { + t.Fatalf("failed to parse state file: %v", err) + } + + return state +} + +// TestExecuteSeedRun covers the first run: it must record a baseline rather than +// announcing every historical release cycle. +func TestExecuteSeedRun(t *testing.T) { + dir := t.TempDir() + slack := newCaptureServer(t, http.StatusOK) + opts := e2eOptions(t, dir, slack, "2026-07-29", e2eConfig) + + if err := execute(context.Background(), opts); err != nil { + t.Fatalf("execute() error = %v", err) + } + + if slack.requests != 0 { + t.Errorf("posted %d message(s), want none on a seed run with no thresholds due", slack.requests) + } + + state := readState(t, opts.statePath) + if !state.seen("postgresql")["18"] || !state.seen("redis")["8.8"] { + t.Errorf("baseline was not recorded: %v", state) + } + + // redis 8.2 was already out of support when the action first saw it. The + // alert saying so is spent, not owed, and has to be recorded as such or the + // next run would announce it. + if !state.sent("redis")[alertKey("8.2", phaseEOL, endedMarker, "2026-05-25")] { + t.Errorf("the baseline did not record its elapsed alerts: %v", state["redis"].Sent) + } +} + +// TestExecuteSeedRunAnnouncesLiveWarnings covers the first run against a product +// already inside one of its warning windows. Recording the warning silently +// would leave it in a file nobody reads while the channel, which is where people +// actually find out, hears nothing. +func TestExecuteSeedRunAnnouncesLiveWarnings(t *testing.T) { + dir := t.TempDir() + slack := newCaptureServer(t, http.StatusOK) + // postgresql 15's twelve-month warning came up the day before, and no run + // has ever happened to deliver it. + opts := e2eOptions(t, dir, slack, "2026-11-12", e2eConfig) + + if err := execute(context.Background(), opts); err != nil { + t.Fatalf("execute() error = %v", err) + } + + if slack.requests != 1 { + t.Fatalf("posted %d message(s), want the warning announced", slack.requests) + } + + var message slackMessage + if err := json.Unmarshal(slack.body, &message); err != nil { + t.Fatalf("posted body is not a valid Slack message: %v", err) + } + rendered := blockText(message) + + for _, want := range []string{"*In 12 months*", "*15* — Support Status ends 2027-11-11"} { + if !strings.Contains(rendered, want) { + t.Errorf("digest is missing %q:\n%s", want, rendered) + } + } + + // The back catalogue still stays out of it: no new-version alerts, and the + // phase that ended before the first run is recorded rather than announced. + if strings.Contains(rendered, "New versions detected") { + t.Errorf("the first run announced new versions:\n%s", rendered) + } + if strings.Contains(rendered, "Already ended") { + t.Errorf("the first run announced a phase that ended before it:\n%s", rendered) + } + if !readState(t, opts.statePath).sent("redis")[alertKey("8.2", phaseEOL, endedMarker, "2026-05-25")] { + t.Error("the spent alert was not recorded, so the next run would announce it") + } +} + +// TestExecuteReportsNewVersionAndEOL is the main path: a release the previous +// run did not see, alongside a release hitting a threshold today. +func TestExecuteReportsNewVersionAndEOL(t *testing.T) { + dir := t.TempDir() + slack := newCaptureServer(t, http.StatusOK) + // postgresql 15 reaches end of life on 2027-11-11, twelve months from today. + opts := e2eOptions(t, dir, slack, "2026-11-11", e2eConfig) + + if err := saveState(opts.statePath, e2eState(t, "2026-11-10", "18")); err != nil { + t.Fatalf("failed to seed state: %v", err) + } + + if err := execute(context.Background(), opts); err != nil { + t.Fatalf("execute() error = %v", err) + } + + if slack.requests != 1 { + t.Fatalf("posted %d message(s), want 1", slack.requests) + } + + var message slackMessage + if err := json.Unmarshal(slack.body, &message); err != nil { + t.Fatalf("posted body is not a valid Slack message: %v", err) + } + rendered := blockText(message) + + for _, want := range []string{ + "New versions detected", + "*18*", + "*In 12 months*", + "Support Status ends 2027-11-11", + "affects PostgreSQL", + } { + if !strings.Contains(rendered, want) { + t.Errorf("digest is missing %q:\n%s", want, rendered) + } + } + + if !readState(t, opts.statePath).seen("postgresql")["18"] { + t.Error("state was not updated after a successful post") + } +} + +// TestExecuteKeepsStateWhenSlackFails guards the invariant that makes the state +// file safe: a release must never be recorded as seen if its alert never landed. +func TestExecuteKeepsStateWhenSlackFails(t *testing.T) { + dir := t.TempDir() + slack := newCaptureServer(t, http.StatusInternalServerError) + opts := e2eOptions(t, dir, slack, "2026-11-11", e2eConfig) + + if err := saveState(opts.statePath, e2eState(t, "2026-11-10", "18")); err != nil { + t.Fatalf("failed to seed state: %v", err) + } + + if err := execute(context.Background(), opts); err == nil { + t.Fatal("execute() succeeded despite Slack rejecting the message") + } + + if readState(t, opts.statePath).seen("postgresql")["18"] { + t.Error("state recorded a release whose alert was never delivered") + } +} + +func TestExecuteDryRunLeavesStateUntouched(t *testing.T) { + dir := t.TempDir() + slack := newCaptureServer(t, http.StatusOK) + opts := e2eOptions(t, dir, slack, "2026-11-11", e2eConfig) + opts.dryRun = true + + if err := execute(context.Background(), opts); err != nil { + t.Fatalf("execute() error = %v", err) + } + + if slack.requests != 0 { + t.Errorf("posted %d message(s), want none on a dry run", slack.requests) + } + if _, err := os.Stat(opts.statePath); !os.IsNotExist(err) { + t.Errorf("dry run wrote a state file at %s", opts.statePath) + } +} + +// TestExecuteFailsOnUnreadableDate turns a date the API states in a form we +// cannot parse into a failed run. The release loses its alert on the single day +// it was due, and a green job would leave nobody a reason to look. +func TestExecuteFailsOnUnreadableDate(t *testing.T) { + dir := t.TempDir() + slack := newCaptureServer(t, http.StatusOK) + + config := e2eConfig + ` + - name: Broken Dates + product: broken-dates + track: [eol] +` + opts := e2eOptions(t, dir, slack, "2026-11-11", config) + + // broken-dates is absent from the seeded state, so it is a baseline product + // and the run fails over the date alone rather than over its releases also + // looking new. + if err := saveState(opts.statePath, e2eState(t, "2026-11-10", "18")); err != nil { + t.Fatalf("failed to seed state: %v", err) + } + + err := execute(context.Background(), opts) + if err == nil { + t.Fatal("execute() succeeded despite a date it could not read") + } + for _, want := range []string{"broken-dates", "November 2027"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error = %v, want it to mention %q", err, want) + } + } + + // The failure must not cost the alerts that were readable. + if slack.requests != 1 { + t.Errorf("posted %d message(s), want the readable alerts still reported", slack.requests) + } + if !readState(t, opts.statePath).seen("postgresql")["18"] { + t.Error("state was not updated despite a delivered digest") + } +} + +// TestExecuteContinuesAfterFetchFailure keeps one unreachable product from +// silencing the alerts for everything else, while still failing the run. +func TestExecuteContinuesAfterFetchFailure(t *testing.T) { + dir := t.TempDir() + slack := newCaptureServer(t, http.StatusOK) + + config := e2eConfig + ` + - name: Missing + product: does-not-exist + track: [eol] +` + opts := e2eOptions(t, dir, slack, "2026-11-11", config) + + if err := saveState(opts.statePath, e2eState(t, "2026-11-10", "18")); err != nil { + t.Fatalf("failed to seed state: %v", err) + } + + err := execute(context.Background(), opts) + if err == nil { + t.Fatal("execute() succeeded despite an unreachable product") + } + if !strings.Contains(err.Error(), "does-not-exist") { + t.Errorf("error = %v, want it to name the unreachable product", err) + } + + if slack.requests != 1 { + t.Errorf("posted %d message(s), want the healthy products still reported", slack.requests) + } + + // The delivered alerts must still be recorded, or the failing run would + // re-announce the same versions on every future run. + if !readState(t, opts.statePath).seen("postgresql")["18"] { + t.Error("state was not updated for the products that were reachable") + } +} + +// TestExecuteCatchesUpAfterAnOutage is the point of the whole design: a year +// with no runs at all must not cost the channel a single alert. postgresql 15 +// reached its end of life during the gap, and 16 came up on its twelve-month +// warning; both are owed on the first run back. +func TestExecuteCatchesUpAfterAnOutage(t *testing.T) { + dir := t.TempDir() + slack := newCaptureServer(t, http.StatusOK) + opts := e2eOptions(t, dir, slack, "2027-11-12", e2eConfig) + + if err := saveState(opts.statePath, e2eState(t, "2026-11-10")); err != nil { + t.Fatalf("failed to seed state: %v", err) + } + + if err := execute(context.Background(), opts); err != nil { + t.Fatalf("execute() error = %v", err) + } + + if slack.requests != 1 { + t.Fatalf("posted %d message(s), want 1", slack.requests) + } + + var message slackMessage + if err := json.Unmarshal(slack.body, &message); err != nil { + t.Fatalf("posted body is not a valid Slack message: %v", err) + } + rendered := blockText(message) + + for _, want := range []string{ + "*Already ended*", + "*15* — Support Status ended 2027-11-11", + "*In 12 months*", + "*16* — Support Status ends 2028-11-09", + } { + if !strings.Contains(rendered, want) { + t.Errorf("digest is missing %q:\n%s", want, rendered) + } + } + + // The alerts it caught up on must now be spent, or the next run repeats them. + state := readState(t, opts.statePath) + for _, key := range []string{ + alertKey("15", phaseEOL, endedMarker, "2027-11-11"), + alertKey("16", phaseEOL, "12", "2028-11-09"), + } { + if !state.sent("postgresql")[key] { + t.Errorf("alert %q was delivered but not recorded", key) + } + } +} + +// TestRecordRunKeepsUndeliveredAlertsDue covers a digest that only partly +// landed. What reached the channel is recorded and what did not stays owed, so +// the next run repeats nothing and loses nothing. +func TestRecordRunKeepsUndeliveredAlertsDue(t *testing.T) { + report := Report{ + NewVersions: []NewVersionAlert{ + {Product: "postgresql", Release: "18"}, + {Product: "redis", Release: "8.8"}, + }, + EOL: []EOLAlert{ + {Product: "postgresql", keys: []string{"15|eol|12|2027-11-11"}}, + {Product: "redis", keys: []string{"8.2|eol|1|2026-05-25"}}, + }, + BaselineKeys: map[string][]string{"mysql": {"8.0|eol|ended|2026-04-30"}}, + } + delivered := []Report{{ + NewVersions: report.NewVersions[:1], + EOL: report.EOL[:1], + }} + products := map[string]*Product{ + "postgresql": {Releases: []Release{{Name: "18"}}}, + "redis": {Releases: []Release{{Name: "8.8"}}}, + } + + state := State{} + recordRun(state, products, report, delivered) + + if !state.sent("postgresql")["15|eol|12|2027-11-11"] { + t.Error("a delivered alert was not recorded, so it would be sent again") + } + if state.sent("redis")["8.2|eol|1|2026-05-25"] { + t.Error("an alert that never landed was recorded as delivered") + } + if !state.sent("mysql")["8.0|eol|ended|2026-04-30"] { + t.Error("a baseline product's spent alerts were not recorded") + } + + if !state.seen("postgresql")["18"] { + t.Error("a product whose alerts all landed was not recorded as seen") + } + if state.seen("redis")["8.8"] { + t.Error("a release was recorded as seen although its alert never landed") + } +} diff --git a/eol-notifier/cmd/notifier/slack.go b/eol-notifier/cmd/notifier/slack.go new file mode 100644 index 0000000..eb84a00 --- /dev/null +++ b/eol-notifier/cmd/notifier/slack.go @@ -0,0 +1,360 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "sort" + "strings" + "time" +) + +const ( + // Slack rejects a section whose text exceeds 3000 characters, and a message + // carrying more than 50 blocks. Long digests are split to stay under both. + slackMaxSectionChars = 2900 + slackMaxBlocks = 50 +) + +type slackText struct { + Type string `json:"type"` + Text string `json:"text"` + Emoji bool `json:"emoji,omitempty"` +} + +type slackBlock struct { + Type string `json:"type"` + Text *slackText `json:"text,omitempty"` + Elements []slackText `json:"elements,omitempty"` +} + +type slackMessage struct { + Text string `json:"text"` + Blocks []slackBlock `json:"blocks"` +} + +// slackClient posts to an incoming webhook. The fields exist so tests can point +// it at an httptest server. +type slackClient struct { + WebhookURL string + HTTPClient *http.Client +} + +func newSlackClient(webhookURL string) *slackClient { + return &slackClient{ + WebhookURL: webhookURL, + HTTPClient: &http.Client{Timeout: 30 * time.Second}, + } +} + +// Post sends an already-encoded message to the webhook. +func (c *slackClient) Post(ctx context.Context, payload []byte) error { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.WebhookURL, bytes.NewReader(payload)) + if err != nil { + return fmt.Errorf("failed to build Slack request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return fmt.Errorf("failed to post to Slack: %w", err) + } + defer resp.Body.Close() + + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("Slack returned %s: %s", resp.Status, strings.TrimSpace(string(body))) + } + + return nil +} + +// splitReport breaks a report into parts that each render within Slack's block +// limit. Splitting the report rather than the rendered blocks keeps every part a +// report in its own right, so the caller still knows which alerts a part carries +// and can record exactly what was delivered. +func splitReport(report Report) []Report { + if report.alertCount() <= 1 || len(buildMessage(report).Blocks) <= slackMaxBlocks { + return []Report{report} + } + + first, second := report.split() + + return append(splitReport(first), splitReport(second)...) +} + +// split splits a report's alerts down the middle, new versions first, so both +// parts carry roughly the same number of lines. It is only called on a report +// holding at least two alerts, so both parts are strictly smaller. +func (r Report) split() (Report, Report) { + // BaselineKeys rides along because the footer is built per part and has to + // carry the same caveat on each. Only the original report is recorded, so + // the copies cost nothing. + first := Report{BaselineKeys: r.BaselineKeys, Seed: r.Seed} + second := Report{BaselineKeys: r.BaselineKeys, Seed: r.Seed} + + if cut := r.alertCount() / 2; cut <= len(r.NewVersions) { + first.NewVersions = r.NewVersions[:cut] + second.NewVersions = r.NewVersions[cut:] + second.EOL = r.EOL + } else { + first.NewVersions = r.NewVersions + first.EOL = r.EOL[:cut-len(r.NewVersions)] + second.EOL = r.EOL[cut-len(r.NewVersions):] + } + + return first, second +} + +// buildMessage renders a report as a single Block Kit digest. A report too large +// for Slack's limits is split by splitReport before it gets here, so nothing is +// dropped to make it fit. +func buildMessage(report Report) slackMessage { + message := slackMessage{ + Text: summaryLine(report), + Blocks: []slackBlock{headerBlock("Dependency lifecycle digest")}, + } + + if len(report.NewVersions) > 0 { + message.Blocks = append(message.Blocks, sectionBlocks("*🆕 New versions detected*", newVersionLines(report))...) + } + + if len(report.EOL) > 0 { + if len(message.Blocks) > 1 { + message.Blocks = append(message.Blocks, slackBlock{Type: "divider"}) + } + message.Blocks = append(message.Blocks, sectionBlocks("*⚠️ Approaching end of life*", eolLines(report))...) + } + + message.Blocks = append(message.Blocks, contextBlock(footerText(report))) + + return message +} + +// summaryLine is the notification/fallback text shown outside the message body. +func summaryLine(report Report) string { + parts := make([]string, 0, 2) + if count := len(report.NewVersions); count > 0 { + parts = append(parts, fmt.Sprintf("%d new %s", count, noun(count, "version", "versions"))) + } + if count := len(report.EOL); count > 0 { + parts = append(parts, fmt.Sprintf("%d %s approaching end of life", count, noun(count, "release", "releases"))) + } + + if len(parts) == 0 { + return "Dependency lifecycle digest" + } + + return "Dependency lifecycle digest: " + strings.Join(parts, ", ") +} + +func newVersionLines(report Report) []string { + lines := make([]string, 0, len(report.NewVersions)) + + for _, alert := range report.NewVersions { + line := fmt.Sprintf("• %s *%s*", productLink(alert.ProductLabel, alert.Product, alert.ProductURL), escape(alert.Release)) + if alert.IsLTS { + line += " _(LTS)_" + } + if alert.ReleaseDate != "" { + line += fmt.Sprintf(" released %s", alert.ReleaseDate) + } + lines = append(lines, line+" — "+affects(alert.Dependencies)) + } + + return lines +} + +func eolLines(report Report) []string { + var ended []EOLAlert + byThreshold := make(map[int][]EOLAlert) + thresholds := make([]int, 0) + + for _, alert := range report.EOL { + if alert.Ended { + ended = append(ended, alert) + continue + } + if _, ok := byThreshold[alert.MonthsLeft]; !ok { + thresholds = append(thresholds, alert.MonthsLeft) + } + byThreshold[alert.MonthsLeft] = append(byThreshold[alert.MonthsLeft], alert) + } + sort.Sort(sort.Reverse(sort.IntSlice(thresholds))) + + var lines []string + if len(ended) > 0 { + lines = append(lines, "*Already ended*") + for _, alert := range ended { + lines = append(lines, eolLine(alert, "ended")) + } + } + + for _, months := range thresholds { + lines = append(lines, fmt.Sprintf("*In %d %s*", months, noun(months, "month", "months"))) + for _, alert := range byThreshold[months] { + lines = append(lines, eolLine(alert, "ends")) + } + } + + return lines +} + +// eolLine renders one alert. The verb separates a phase that is over from one +// still counting down. +func eolLine(alert EOLAlert, verb string) string { + return fmt.Sprintf( + "• %s *%s* — %s %s %s — %s", + productLink(alert.ProductLabel, alert.Product, alert.ProductURL), + escape(alert.Release), + escape(alert.PhaseLabel), + verb, + alert.Date.Format(dateLayout), + affects(alert.Dependencies), + ) +} + +func footerText(report Report) string { + parts := []string{ + "Adding or removing a version is a manual PR against `TykTechnologies/github-actions`.", + } + + if report.hasProxy() { + parts = append(parts, + "Entries marked _upstream proxy_ are not tracked by endoflife.date; "+ + "the date shown is the upstream engine's and must be confirmed with the cloud provider.", + ) + } + + // A baseline product withholds two things at once, and a digest that admits + // only to the first reads as "nothing here is out of support yet", which is + // the opposite of what the withheld half says. + if report.Seed || len(report.BaselineKeys) > 0 { + parts = append(parts, + "First run for one or more products: their existing versions, and the support "+ + "phases that had already ended before tracking began, are not listed.", + ) + } + + return strings.Join(parts, " ") +} + +// affects renders the dependencies an alert applies to, flagging the ones whose +// lifecycle is only inferred from the upstream engine. +func affects(dependencies []DependencyRef) string { + names := make([]string, 0, len(dependencies)) + for _, dependency := range dependencies { + name := escape(dependency.Name) + if dependency.UpstreamProxy { + name += " _(upstream proxy)_" + } + names = append(names, name) + } + + return "affects " + strings.Join(names, ", ") +} + +func productLink(label, product, url string) string { + if label == "" { + label = product + } + if url == "" { + return escape(label) + } + + return fmt.Sprintf("<%s|%s>", url, escape(label)) +} + +// sectionBlocks packs lines into as many section blocks as Slack's size limit +// requires, with the heading on the first one. +func sectionBlocks(heading string, lines []string) []slackBlock { + var ( + blocks []slackBlock + builder strings.Builder + ) + builder.WriteString(heading) + + flush := func() { + blocks = append(blocks, slackBlock{ + Type: "section", + Text: &slackText{Type: "mrkdwn", Text: builder.String()}, + }) + builder.Reset() + } + + for _, line := range lines { + line = truncateLine(line) + + if builder.Len() > 0 && builder.Len()+len(line)+1 > slackMaxSectionChars { + flush() + } + if builder.Len() > 0 { + builder.WriteString("\n") + } + builder.WriteString(line) + } + + if builder.Len() > 0 { + flush() + } + + return blocks +} + +// truncateLine keeps a single line within the section limit. Without this a very +// long line - many dependencies sharing one product, say - would be written into +// a freshly flushed builder and push that section over Slack's cap, which makes +// Slack reject the whole payload. +func truncateLine(line string) string { + const ellipsis = "…" + + if len(line) <= slackMaxSectionChars { + return line + } + + limit := slackMaxSectionChars - len(ellipsis) + cut := 0 + for index := range line { + if index > limit { + break + } + cut = index + } + + return line[:cut] + ellipsis +} + +func headerBlock(text string) slackBlock { + return slackBlock{Type: "header", Text: &slackText{Type: "plain_text", Text: text, Emoji: true}} +} + +func contextBlock(text string) slackBlock { + return slackBlock{Type: "context", Elements: []slackText{{Type: "mrkdwn", Text: text}}} +} + +// escape neutralises the three characters Slack treats as markup control +// characters in mrkdwn text. +func escape(text string) string { + return strings.NewReplacer("&", "&", "<", "<", ">", ">").Replace(text) +} + +func noun(count int, singular, plural string) string { + if count == 1 { + return singular + } + + return plural +} + +// encodeMessage renders the payload posted to the webhook. +func encodeMessage(message slackMessage) ([]byte, error) { + payload, err := json.Marshal(message) + if err != nil { + return nil, fmt.Errorf("failed to encode Slack message: %w", err) + } + + return payload, nil +} diff --git a/eol-notifier/cmd/notifier/slack_test.go b/eol-notifier/cmd/notifier/slack_test.go new file mode 100644 index 0000000..90546f6 --- /dev/null +++ b/eol-notifier/cmd/notifier/slack_test.go @@ -0,0 +1,499 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "unicode/utf8" +) + +// captureServer stands in for the Slack webhook and records what was posted. A +// digest can take more than one request, so every body is kept. +type captureServer struct { + *httptest.Server + requests int + bodies [][]byte + body []byte + header http.Header + status int + // failFrom is the 1-based request from which the server starts rejecting, + // for the runs where Slack goes away partway through a split digest. + failFrom int +} + +func newCaptureServer(t *testing.T, status int) *captureServer { + t.Helper() + + capture := &captureServer{status: status} + capture.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capture.requests++ + capture.header = r.Header.Clone() + capture.body, _ = io.ReadAll(r.Body) + capture.bodies = append(capture.bodies, capture.body) + + if capture.failFrom > 0 && capture.requests >= capture.failFrom { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte("nope")) + + return + } + + w.WriteHeader(capture.status) + _, _ = w.Write([]byte("ok")) + })) + t.Cleanup(capture.Close) + + return capture +} + +func (c *captureServer) client() *slackClient { + return &slackClient{WebhookURL: c.URL, HTTPClient: http.DefaultClient} +} + +func testReport(t *testing.T) Report { + t.Helper() + + return Report{ + NewVersions: []NewVersionAlert{{ + Product: "postgresql", + ProductLabel: "PostgreSQL", + ProductURL: "https://endoflife.date/postgresql", + Release: "18", + ReleaseDate: "2025-09-25", + Dependencies: []DependencyRef{ + {Name: "PostgreSQL"}, + {Name: "GCP Cloud SQL", UpstreamProxy: true}, + }, + }}, + EOL: []EOLAlert{ + { + Product: "postgresql", + ProductLabel: "PostgreSQL", + ProductURL: "https://endoflife.date/postgresql", + Release: "14", + Phase: phaseEOL, + PhaseLabel: "Support Status", + Date: mustDate(t, "2026-11-12"), + MonthsLeft: 12, + Dependencies: []DependencyRef{{Name: "PostgreSQL"}}, + }, + { + Product: "redis", + ProductLabel: "Redis", + ProductURL: "https://endoflife.date/redis", + Release: "7.2", + Phase: phaseEOL, + PhaseLabel: "Security Support", + Date: mustDate(t, "2026-02-28"), + MonthsLeft: 1, + Dependencies: []DependencyRef{{Name: "Redis"}, {Name: "GCP MemoryStore", UpstreamProxy: true}}, + }, + }, + } +} + +// render builds the digest and flattens it, for the assertions that only care +// about the text. +func render(report Report) string { + return blockText(buildMessage(report)) +} + +// bigReport builds a digest of count alerts whose lines are lineLength long. A +// line long enough to fill a section on its own is how a digest is pushed past +// the block limit without needing thousands of alerts. +func bigReport(t *testing.T, count, lineLength int) Report { + t.Helper() + + var report Report + for i := 0; i < count; i++ { + release := fmt.Sprintf("%d", i) + report.EOL = append(report.EOL, EOLAlert{ + Product: "postgresql", + ProductLabel: "PostgreSQL", + Release: release, + PhaseLabel: "Support Status", + Date: mustDate(t, "2026-11-12"), + MonthsLeft: 6, + Dependencies: []DependencyRef{{Name: strings.Repeat("x", lineLength)}}, + keys: []string{alertKey(release, phaseEOL, "6", "2026-11-12")}, + }) + } + + return report +} + +// blockText flattens the rendered message so assertions can look for content +// without depending on how it was split into blocks. +func blockText(message slackMessage) string { + var builder strings.Builder + + for _, block := range message.Blocks { + if block.Text != nil { + builder.WriteString(block.Text.Text + "\n") + } + for _, element := range block.Elements { + builder.WriteString(element.Text + "\n") + } + } + + return builder.String() +} + +func TestBuildMessage(t *testing.T) { + message := buildMessage(testReport(t)) + rendered := blockText(message) + + wants := []string{ + "Dependency lifecycle digest", + "New versions detected", + "", + "released 2025-09-25", + "Approaching end of life", + "*In 12 months*", + "*In 1 month*", + "Support Status ends 2026-11-12", + "Security Support ends 2026-02-28", + "affects Redis, GCP MemoryStore _(upstream proxy)_", + "must be confirmed with the cloud provider", + "manual PR against `TykTechnologies/github-actions`", + } + + for _, want := range wants { + if !strings.Contains(rendered, want) { + t.Errorf("rendered message is missing %q:\n%s", want, rendered) + } + } + + if message.Blocks[0].Type != "header" { + t.Errorf("first block = %q, want header", message.Blocks[0].Type) + } + if !strings.Contains(message.Text, "1 new version") { + t.Errorf("fallback text = %q, want a summary", message.Text) + } +} + +func TestBuildMessageOmitsProxyCaveatWhenUnused(t *testing.T) { + report := Report{EOL: []EOLAlert{{ + ProductLabel: "PostgreSQL", + Release: "14", + PhaseLabel: "Support Status", + Date: mustDate(t, "2026-11-12"), + MonthsLeft: 6, + Dependencies: []DependencyRef{{Name: "PostgreSQL"}}, + }}} + + if rendered := render(report); strings.Contains(rendered, "upstream proxy") { + t.Errorf("proxy caveat rendered for a report with no proxies:\n%s", rendered) + } +} + +func TestBuildMessageExplainsSeedRun(t *testing.T) { + report := testReport(t) + report.Seed = true + + if rendered := render(report); !strings.Contains(rendered, "First run") { + t.Errorf("seed run is not explained:\n%s", rendered) + } +} + +// TestBuildMessageExplainsNewlyTrackedProduct covers a dependency added to the +// config long after the first run. Its already-ended phases are withheld exactly +// as on a first run, so the digest has to say so - without the caveat, a reader +// takes the warnings listed for the whole picture. +func TestBuildMessageExplainsNewlyTrackedProduct(t *testing.T) { + report := testReport(t) + report.BaselineKeys = map[string][]string{"mysql": {"5.7|eol|ended|2023-10-31"}} + + rendered := render(report) + if !strings.Contains(rendered, "First run for one or more products") { + t.Errorf("the withheld phases are not explained:\n%s", rendered) + } + if !strings.Contains(rendered, "had already ended") { + t.Errorf("the caveat does not mention the withheld end-of-life phases:\n%s", rendered) + } +} + +// TestBuildMessageOmitsCaveatWhenNothingWithheld keeps the caveat off the +// digests that withheld nothing. +func TestBuildMessageOmitsCaveatWhenNothingWithheld(t *testing.T) { + if rendered := render(testReport(t)); strings.Contains(rendered, "First run") { + t.Errorf("a routine digest carries the first-run caveat:\n%s", rendered) + } +} + +func TestBuildMessagePacksLinesIntoSections(t *testing.T) { + message := buildMessage(bigReport(t, 120, 60)) + + sections := 0 + for _, block := range message.Blocks { + if block.Type == "section" { + sections++ + } + if block.Text != nil && len(block.Text.Text) > 3000 { + t.Fatalf("section of %d characters exceeds the Slack limit", len(block.Text.Text)) + } + } + + if sections < 2 { + t.Errorf("got %d section(s), want the lines split across several", sections) + } +} + +// TestSplitReportKeepsEveryAlert covers the digest too large for one message. +// Every part has to be postable on its own, and between them they have to carry +// the whole report: a part dropped to make the digest fit is an alert nobody +// ever sees. +func TestSplitReportKeepsEveryAlert(t *testing.T) { + report := bigReport(t, 60, slackMaxSectionChars) + report.NewVersions = testReport(t).NewVersions + report.BaselineKeys = map[string][]string{"mysql": {"5.7|eol|ended|2023-10-31"}} + + parts := splitReport(report) + + if len(parts) < 2 { + t.Fatalf("a %d-alert digest was left in %d part(s)", report.alertCount(), len(parts)) + } + + seen := make(map[string]bool) + for _, part := range parts { + if blocks := len(buildMessage(part).Blocks); blocks > slackMaxBlocks { + t.Errorf("a part has %d blocks, want at most %d", blocks, slackMaxBlocks) + } + // Each part is read on its own, so each has to carry the caveat. + if !strings.Contains(blockText(buildMessage(part)), "First run for one or more products") { + t.Error("a part of a split digest lost the first-run caveat") + } + for _, alert := range part.EOL { + seen[alert.keys[0]] = true + } + for _, alert := range part.NewVersions { + seen[alert.Product+"|"+alert.Release] = true + } + } + + if len(seen) != report.alertCount() { + t.Errorf("the parts carry %d of the report's %d alerts", len(seen), report.alertCount()) + } +} + +// TestBuildMessageRendersEndedPhases covers the alert for a phase that is over. +// It is the one alert that cannot be worded as a countdown. +func TestBuildMessageRendersEndedPhases(t *testing.T) { + report := Report{EOL: []EOLAlert{ + { + ProductLabel: "Redis", + Release: "7.2", + PhaseLabel: "Security Support", + Date: mustDate(t, "2026-08-01"), + Ended: true, + Dependencies: []DependencyRef{{Name: "Redis"}}, + }, + { + ProductLabel: "PostgreSQL", + Release: "15", + PhaseLabel: "Support Status", + Date: mustDate(t, "2027-02-11"), + MonthsLeft: 6, + Dependencies: []DependencyRef{{Name: "PostgreSQL"}}, + }, + }} + + rendered := render(report) + + for _, want := range []string{ + "*Already ended*", + "Redis *7.2* — Security Support ended 2026-08-01", + "*In 6 months*", + "PostgreSQL *15* — Support Status ends 2027-02-11", + } { + if !strings.Contains(rendered, want) { + t.Errorf("rendered message is missing %q:\n%s", want, rendered) + } + } + + if strings.Index(rendered, "Already ended") > strings.Index(rendered, "In 6 months") { + t.Errorf("the ended phase is listed after the countdown:\n%s", rendered) + } +} + +// TestBuildMessageTruncatesOverlongLine covers a single line that cannot fit in +// any section on its own. Slack rejects the whole payload when one section goes +// over its limit, so the line has to be cut - on a rune boundary, since the lines +// carry multi-byte characters. +func TestBuildMessageTruncatesOverlongLine(t *testing.T) { + report := Report{EOL: []EOLAlert{{ + ProductLabel: "PostgreSQL", + Release: "14", + PhaseLabel: "Support Status", + Date: mustDate(t, "2026-11-12"), + MonthsLeft: 6, + Dependencies: []DependencyRef{{Name: strings.Repeat("ü", 4000)}}, + }}} + + message := buildMessage(report) + + truncated := false + for _, block := range message.Blocks { + if block.Text == nil { + continue + } + if len(block.Text.Text) > 3000 { + t.Fatalf("section of %d bytes exceeds the Slack limit", len(block.Text.Text)) + } + if !utf8.ValidString(block.Text.Text) { + t.Fatal("section was cut in the middle of a rune") + } + if strings.Contains(block.Text.Text, "…") { + truncated = true + } + } + + if !truncated { + t.Error("the overlong line was not marked as truncated") + } +} + +func TestSlackPost(t *testing.T) { + tests := []struct { + name string + status int + wantErr bool + }{ + {name: "accepted", status: http.StatusOK}, + {name: "rejected", status: http.StatusBadRequest, wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + capture := newCaptureServer(t, tt.status) + + payload, err := encodeMessage(buildMessage(testReport(t))) + if err != nil { + t.Fatalf("encodeMessage() error = %v", err) + } + + err = capture.client().Post(context.Background(), payload) + if (err != nil) != tt.wantErr { + t.Fatalf("Post() error = %v, wantErr %v", err, tt.wantErr) + } + + if capture.requests != 1 { + t.Fatalf("made %d request(s), want 1", capture.requests) + } + if got := capture.header.Get("Content-Type"); got != "application/json" { + t.Errorf("Content-Type = %q, want application/json", got) + } + + var decoded slackMessage + if err := json.Unmarshal(capture.body, &decoded); err != nil { + t.Fatalf("posted body is not a valid Slack message: %v", err) + } + if len(decoded.Blocks) == 0 { + t.Error("posted message carries no blocks") + } + }) + } +} + +func TestPublishSkipsEmptyReports(t *testing.T) { + capture := newCaptureServer(t, http.StatusOK) + + delivered, err := publish(context.Background(), capture.client(), Report{}, false) + if err != nil { + t.Fatalf("publish() error = %v", err) + } + + if capture.requests != 0 { + t.Errorf("made %d request(s), want none for an empty report", capture.requests) + } + if len(delivered) != 0 { + t.Errorf("reported %d part(s) as delivered, want none", len(delivered)) + } +} + +func TestPublishHonoursDryRun(t *testing.T) { + capture := newCaptureServer(t, http.StatusOK) + + delivered, err := publish(context.Background(), capture.client(), testReport(t), true) + if err != nil { + t.Fatalf("publish() error = %v", err) + } + + if capture.requests != 0 { + t.Errorf("made %d request(s), want none on a dry run", capture.requests) + } + // Nothing was delivered, so nothing may be recorded as delivered: a dry run + // that marked alerts as sent would silence them for good. + if len(delivered) != 0 { + t.Errorf("reported %d part(s) as delivered on a dry run", len(delivered)) + } +} + +// TestPublishPostsEveryPart covers a digest too large for one message: every +// part has to be posted, not just the first. +func TestPublishPostsEveryPart(t *testing.T) { + capture := newCaptureServer(t, http.StatusOK) + report := bigReport(t, 60, slackMaxSectionChars) + + delivered, err := publish(context.Background(), capture.client(), report, false) + if err != nil { + t.Fatalf("publish() error = %v", err) + } + + if capture.requests < 2 { + t.Fatalf("made %d request(s), want the digest posted in parts", capture.requests) + } + if len(delivered) != capture.requests { + t.Errorf("reported %d part(s) as delivered over %d request(s)", len(delivered), capture.requests) + } + + alerts := 0 + for _, part := range delivered { + alerts += part.alertCount() + } + if alerts != report.alertCount() { + t.Errorf("delivered %d of %d alerts", alerts, report.alertCount()) + } +} + +// TestPublishReportsWhatLandedBeforeFailing covers Slack going away halfway +// through a split digest. The parts that landed have to be reported as +// delivered, or they would be posted again on the next run. +func TestPublishReportsWhatLandedBeforeFailing(t *testing.T) { + capture := newCaptureServer(t, http.StatusOK) + capture.failFrom = 2 + + delivered, err := publish(context.Background(), capture.client(), bigReport(t, 60, slackMaxSectionChars), false) + if err == nil { + t.Fatal("publish() succeeded despite Slack rejecting a part") + } + + if len(delivered) != 1 { + t.Errorf("reported %d part(s) as delivered, want only the one that landed", len(delivered)) + } +} + +func TestEscape(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + {"ampersand", "AT&T", "AT&T"}, + {"angle brackets", "