From 8cd2870c6ef93fe20e305d9fa774cbfb7c560487 Mon Sep 17 00:00:00 2001 From: Burak Sezer Date: Sat, 1 Aug 2026 22:48:05 +0300 Subject: [PATCH 1/9] [TT-17817] initial implementation of eol-notifier --- .github/.dependabot.yml | 10 + .github/eol-notifier/dependencies.yaml | 51 +++ .github/workflows/ci-test.yml | 18 + .github/workflows/eol-notifier.yaml | 116 +++++ docs/workflows/eol-notifier.md | 96 +++++ eol-notifier/.gitignore | 1 + eol-notifier/README.md | 142 +++++++ eol-notifier/action.yaml | 45 ++ eol-notifier/cmd/notifier/config.go | 169 ++++++++ eol-notifier/cmd/notifier/config_test.go | 230 ++++++++++ eol-notifier/cmd/notifier/endoflife.go | 188 +++++++++ eol-notifier/cmd/notifier/endoflife_test.go | 239 +++++++++++ eol-notifier/cmd/notifier/lifecycle.go | 251 +++++++++++ eol-notifier/cmd/notifier/lifecycle_test.go | 398 ++++++++++++++++++ eol-notifier/cmd/notifier/main.go | 214 ++++++++++ eol-notifier/cmd/notifier/main_test.go | 247 +++++++++++ eol-notifier/cmd/notifier/slack.go | 306 ++++++++++++++ eol-notifier/cmd/notifier/slack_test.go | 357 ++++++++++++++++ eol-notifier/cmd/notifier/state.go | 82 ++++ eol-notifier/cmd/notifier/state_test.go | 93 ++++ .../testdata/amazon-rds-postgresql.json | 106 +++++ .../cmd/notifier/testdata/broken-dates.json | 35 ++ .../cmd/notifier/testdata/postgresql.json | 126 ++++++ eol-notifier/cmd/notifier/testdata/redis.json | 165 ++++++++ eol-notifier/go.mod | 8 + eol-notifier/go.sum | 6 + 26 files changed, 3699 insertions(+) create mode 100644 .github/eol-notifier/dependencies.yaml create mode 100644 .github/workflows/eol-notifier.yaml create mode 100644 docs/workflows/eol-notifier.md create mode 100644 eol-notifier/.gitignore create mode 100644 eol-notifier/README.md create mode 100644 eol-notifier/action.yaml create mode 100644 eol-notifier/cmd/notifier/config.go create mode 100644 eol-notifier/cmd/notifier/config_test.go create mode 100644 eol-notifier/cmd/notifier/endoflife.go create mode 100644 eol-notifier/cmd/notifier/endoflife_test.go create mode 100644 eol-notifier/cmd/notifier/lifecycle.go create mode 100644 eol-notifier/cmd/notifier/lifecycle_test.go create mode 100644 eol-notifier/cmd/notifier/main.go create mode 100644 eol-notifier/cmd/notifier/main_test.go create mode 100644 eol-notifier/cmd/notifier/slack.go create mode 100644 eol-notifier/cmd/notifier/slack_test.go create mode 100644 eol-notifier/cmd/notifier/state.go create mode 100644 eol-notifier/cmd/notifier/state_test.go create mode 100644 eol-notifier/cmd/notifier/testdata/amazon-rds-postgresql.json create mode 100644 eol-notifier/cmd/notifier/testdata/broken-dates.json create mode 100644 eol-notifier/cmd/notifier/testdata/postgresql.json create mode 100644 eol-notifier/cmd/notifier/testdata/redis.json create mode 100644 eol-notifier/go.mod create mode 100644 eol-notifier/go.sum 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..36ef7b0 --- /dev/null +++ b/.github/eol-notifier/dependencies.yaml @@ -0,0 +1,51 @@ +# 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] + + # 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..639ad8a --- /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..79907cc --- /dev/null +++ b/docs/workflows/eol-notifier.md @@ -0,0 +1,96 @@ +## 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. + +### 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..4d7b853 --- /dev/null +++ b/eol-notifier/README.md @@ -0,0 +1,142 @@ +# 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 that the last run saw | +| `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 + publishes this date. + - `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 name is used twice, a product is empty, or a key is +misspelled. + +## 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 today is exactly 12, 6 or 1 month before the date. + +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. So each date sends each alert on one day only. Nothing is sent +twice, and nothing is missed in a short month. + +### 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 but sends no alert. + +If there is no state file, the action saves the current versions and sends no +new-version alerts. This stops the first run from posting every old version into +the channel. + +The action writes the state file only after Slack accepts the message. So a +version is never saved as seen if its alert did not arrive. A dry run never +writes the file. + +### 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 cost a version the alert it was due, and that alert only comes on one +day. The run fails so that the loss is visible. The state file is still written, +so the alerts that did arrive are not sent again the next day. + +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..0b144f5 --- /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. A missing file seeds a baseline without announcing new versions.' + 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..acf1492 --- /dev/null +++ b/eol-notifier/cmd/notifier/endoflife.go @@ -0,0 +1,188 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "time" +) + +const ( + defaultBaseURL = "https://endoflife.date/api/v1" + maxResponseBytes = 1 << 20 // 1 MiB, comfortably above the largest product +) + +// 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, whether that date has +// already passed, and whether the product publishes the phase for this release. +func (r Release) phase(phase string) (date string, past bool, ok bool) { + var from *string + switch phase { + case phaseEOL: + from, past = r.EOLFrom, r.IsEOL + case phaseEOAS: + from, past = r.EOASFrom, r.IsEOAS + case phaseEOES: + from, past = r.EOESFrom, r.IsEOES + default: + return "", false, false + } + + if from == nil || *from == "" { + return "", false, false + } + + return *from, past, 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 an 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) + } + + // One byte past the limit, so hitting it is detectable. Silently truncating + // would hand half a JSON document to the decoder, and an oversized response + // would then be reported as permanently malformed. + body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes+1)) + if err != nil { + return nil, true, fmt.Errorf("failed to read response body: %w", err) + } + if len(body) > maxResponseBytes { + return nil, false, fmt.Errorf("response is larger than the %d byte limit", maxResponseBytes) + } + + 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..efddd30 --- /dev/null +++ b/eol-notifier/cmd/notifier/endoflife_test.go @@ -0,0 +1,239 @@ +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 + wantPast bool + 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 is flagged", + product: "redis", + release: "8.2", + phase: phaseEOL, + wantOK: true, + wantDate: "2026-05-25", + wantPast: true, + 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, past, 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 past != tt.wantPast { + t.Errorf("phase(%q) past = %v, want %v", tt.phase, past, tt.wantPast) + } + 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"}, + { + // A body past the read limit has to be named as oversized. Cutting it at + // the limit would leave half a JSON document, which reads as a permanently + // malformed response and hides what actually went wrong. + name: "larger than the read limit", + body: `{"result":{"name":"` + strings.Repeat("x", maxResponseBytes) + `"}}`, + wantErr: "larger than the", + }, + } + + 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..14ab7f4 --- /dev/null +++ b/eol-notifier/cmd/notifier/lifecycle.go @@ -0,0 +1,251 @@ +package main + +import ( + "fmt" + "sort" + "time" +) + +const dateLayout = "2006-01-02" + +// 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 +// exactly MonthsLeft months. +type EOLAlert struct { + Product string + ProductLabel string + ProductURL string + Release string + Phase string + PhaseLabel string + Date time.Time + MonthsLeft int + Dependencies []DependencyRef +} + +// 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 + // 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 len(r.NewVersions) == 0 && len(r.EOL) == 0 +} + +// 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 the previously seen state. 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. +// +// 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. +// +// Releases carrying a date the API states in a form we cannot read are returned +// through the second value rather than only logged. Such a release is due its +// alert on a single day, so dropping it quietly would lose that alert while the +// job stayed green and nobody had 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{Seed: seed} + var unreadable []string + + for _, name := range productOrder(config) { + product, ok := products[name] + if !ok { + continue + } + + seen := state.seen(name) + + // A product with no recorded history is a baseline, not news. 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, past, ok := release.phase(phase) + if !ok || past { + 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 + } + + for _, months := range config.ThresholdsMonths { + if !monthsBefore(date, months).Equal(today) { + continue + } + + 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: months, + Dependencies: dependencies, + }) + } + } + } + } + + sort.SliceStable(report.EOL, func(i, j int) bool { + return report.EOL[i].MonthsLeft > report.EOL[j].MonthsLeft + }) + + return report, unreadable +} + +// 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 make a single EoL +// date trigger the same threshold on more than one day. +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..af0dce8 --- /dev/null +++ b/eol-notifier/cmd/notifier/lifecycle_test.go @@ -0,0 +1,398 @@ +package main + +import ( + "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 +} + +// 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) + } + }) + } +} + +// TestThresholdFiresOnExactlyOneDay is the guard against the failure mode that +// motivated the clamping: a single EoL date must not alert on more than one day, +// and must not slip through unannounced either. +func TestThresholdFiresOnExactlyOneDay(t *testing.T) { + eolDates := []string{"2027-01-31", "2027-02-28", "2027-03-31", "2028-02-29", "2027-08-31"} + + for _, eol := range eolDates { + t.Run(eol, 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)}}, + }, + } + + for _, months := range config.ThresholdsMonths { + trigger := monthsBefore(mustDate(t, eol), months) + + fired := 0 + for offset := -20; offset <= 20; offset++ { + day := trigger.AddDate(0, 0, offset) + report, _ := detectAlerts(config, products, State{}, day, false) + for _, alert := range report.EOL { + if alert.MonthsLeft == months { + fired++ + } + } + } + + if fired != 1 { + t.Errorf("threshold %d months fired on %d days around %s, want exactly 1", + months, fired, trigger.Format(dateLayout)) + } + } + }) + } +} + +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, + }, + } + } + + tests := []struct { + name string + products map[string]*Product + today string + want int + }{ + { + name: "fires twelve months ahead", + products: postgres(Release{Name: "15", EOLFrom: strPtr("2027-11-11")}), + today: "2026-11-11", + want: 1, + }, + { + name: "fires one month ahead", + products: postgres(Release{Name: "15", EOLFrom: strPtr("2027-11-11")}), + today: "2027-10-11", + want: 1, + }, + { + name: "silent a day early", + products: postgres(Release{Name: "15", EOLFrom: strPtr("2027-11-11")}), + today: "2027-10-10", + want: 0, + }, + { + name: "silent a day late", + products: postgres(Release{Name: "15", EOLFrom: strPtr("2027-11-11")}), + today: "2027-10-12", + want: 0, + }, + { + name: "skips a release with no announced date", + products: postgres(Release{Name: "18", EOLFrom: nil}), + today: "2026-11-11", + want: 0, + }, + { + name: "skips a phase that has already passed", + products: postgres(Release{Name: "13", EOLFrom: strPtr("2027-11-11"), IsEOL: true}), + today: "2026-11-11", + want: 0, + }, + { + name: "skips a product whose fetch failed", + products: map[string]*Product{}, + today: "2026-11-11", + want: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + config := testConfig(t, Dependency{Name: "PostgreSQL", Product: "postgresql"}) + + report, _ := detectAlerts(config, tt.products, State{}, mustDate(t, tt.today), false) + + if got := len(report.EOL); got != tt.want { + t.Fatalf("got %d EoL alert(s), want %d", got, tt.want) + } + if tt.want > 0 && report.EOL[0].PhaseLabel != "Support Status" { + t.Errorf("phase label = %q, want the product's own wording", report.EOL[0].PhaseLabel) + } + }) + } +} + +// 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, State{}, 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"), + }}, + }, + } + + tests := []struct { + name string + track []string + today string + want string + }{ + {"eol only, on the eol trigger", []string{phaseEOL}, "2027-02-28", phaseEOL}, + {"eol only, ignores extended support", []string{phaseEOL}, "2030-02-28", ""}, + {"extended support tracked", []string{phaseEOL, phaseEOES}, "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, + }) + + 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")}}, + }, + } + + 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: State{"postgresql": {"17"}}, + want: []string{"18"}, + }, + { + name: "everything already seen is silent", + state: State{"postgresql": {"13", "17", "18"}}, + want: nil, + }, + { + name: "backfilled dead release is not announced", + state: State{"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: State{"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..b774f56 --- /dev/null +++ b/eol-notifier/cmd/notifier/main.go @@ -0,0 +1,214 @@ +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)) + + if err := publish(ctx, opts.slack, report, opts.dryRun); err != nil { + return err + } + + // State is written only after a successful publish: recording a release the + // channel never heard about would silence it forever. + if opts.dryRun { + log("dry run, leaving %s untouched", opts.statePath) + } else { + for name, product := range products { + state.record(name, product.Releases) + } + if err := saveState(opts.statePath, state); err != nil { + return err + } + } + + return runProblems(failed, unreadable) +} + +// 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 a product that could +// not be read and a date that could not be parsed both cost a release its alert +// on the one day it was due, so the job has to go red or the loss is invisible. +func runProblems(failed, unreadable []string) error { + var problems []string + + 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 renders and delivers the digest. Nothing to report means nothing is +// posted at all. +func publish(ctx context.Context, slack *slackClient, report Report, dryRun bool) error { + if report.Empty() { + log("nothing to report, no Slack message sent") + return nil + } + + message, truncated := buildMessage(report) + + payload, err := encodeMessage(message) + if err != nil { + return err + } + + // The truncated digest tells its readers the full list is in the run log, so + // it has to actually be there, dry run or not. + if truncated { + logWarn("digest did not fit Slack's block limit, posting a cut version. full payload:\n%s", payload) + } + + if dryRun { + log("dry run, would post to Slack:\n%s", payload) + return nil + } + + if err := slack.Post(ctx, payload); err != nil { + return err + } + + log("posted digest to Slack") + + return 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..ec4322f --- /dev/null +++ b/eol-notifier/cmd/notifier/main_test.go @@ -0,0 +1,247 @@ +package main + +import ( + "context" + "encoding/json" + "net/http" + "os" + "path/filepath" + "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(), + } +} + +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) + } +} + +// 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) + + seed := State{ + "postgresql": {"15", "16", "17"}, + "redis": {"8.2", "8.4", "8.6", "8.8"}, + } + if err := saveState(opts.statePath, seed); 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) + + seed := State{"postgresql": {"15", "16", "17"}, "redis": {"8.2", "8.4", "8.6", "8.8"}} + if err := saveState(opts.statePath, seed); 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 seeded too, so the run fails over the date alone rather + // than over the release also looking new. + seed := State{ + "postgresql": {"15", "16", "17"}, + "redis": {"8.2", "8.4", "8.6", "8.8"}, + "broken-dates": {"1"}, + } + if err := saveState(opts.statePath, seed); 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) + + seed := State{"postgresql": {"15", "16", "17"}, "redis": {"8.2", "8.4", "8.6", "8.8"}} + if err := saveState(opts.statePath, seed); 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") + } +} diff --git a/eol-notifier/cmd/notifier/slack.go b/eol-notifier/cmd/notifier/slack.go new file mode 100644 index 0000000..a824002 --- /dev/null +++ b/eol-notifier/cmd/notifier/slack.go @@ -0,0 +1,306 @@ +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 +} + +// buildMessage renders a report as a single Block Kit digest. The second return +// value reports whether the digest had to be cut to fit Slack's block limit, so +// the caller can put the whole thing in the run log the notice points at. +func buildMessage(report Report) (slackMessage, bool) { + 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))) + + if len(message.Blocks) > slackMaxBlocks { + kept := message.Blocks[:slackMaxBlocks-1] + message.Blocks = append(kept, contextBlock( + "Digest truncated to fit Slack's block limit; the full list is in the workflow run log.", + )) + + return message, true + } + + return message, false +} + +// 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 { + byThreshold := make(map[int][]EOLAlert) + thresholds := make([]int, 0) + for _, alert := range report.EOL { + 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 + 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, fmt.Sprintf( + "• %s *%s* — %s ends %s — %s", + productLink(alert.ProductLabel, alert.Product, alert.ProductURL), + escape(alert.Release), + escape(alert.PhaseLabel), + alert.Date.Format(dateLayout), + affects(alert.Dependencies), + )) + } + } + + return lines +} + +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.", + ) + } + + if report.Seed { + parts = append(parts, "First run: there is no previous state to compare against, so new versions 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..3c07ffa --- /dev/null +++ b/eol-notifier/cmd/notifier/slack_test.go @@ -0,0 +1,357 @@ +package main + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "unicode/utf8" +) + +// captureServer stands in for the Slack webhook and records what was posted. +type captureServer struct { + *httptest.Server + requests int + body []byte + header http.Header + status 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) + + 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 do not care +// whether it had to be truncated. +func render(report Report) string { + message, _ := buildMessage(report) + + return blockText(message) +} + +// 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, truncated := buildMessage(testReport(t)) + rendered := blockText(message) + + if truncated { + t.Error("a two-entry digest was reported as truncated") + } + + 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) + } +} + +func TestBuildMessageSplitsLongDigests(t *testing.T) { + var report Report + for i := 0; i < 120; i++ { + report.EOL = append(report.EOL, EOLAlert{ + ProductLabel: "PostgreSQL", + Release: "14", + PhaseLabel: "Support Status", + Date: mustDate(t, "2026-11-12"), + MonthsLeft: 6, + Dependencies: []DependencyRef{{Name: strings.Repeat("x", 60)}}, + }) + } + + message, _ := buildMessage(report) + + 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 digest split across several", sections) + } + if len(message.Blocks) > slackMaxBlocks { + t.Errorf("message has %d blocks, want at most %d", len(message.Blocks), slackMaxBlocks) + } +} + +// TestBuildMessageReportsTruncation covers the digest that does not fit Slack's +// block limit. The notice it carries tells readers the full list is in the run +// log, so buildMessage has to tell the caller to put it there. +func TestBuildMessageReportsTruncation(t *testing.T) { + var report Report + for i := 0; i < 60; i++ { + report.EOL = append(report.EOL, EOLAlert{ + ProductLabel: "PostgreSQL", + Release: "14", + PhaseLabel: "Support Status", + Date: mustDate(t, "2026-11-12"), + MonthsLeft: 6, + Dependencies: []DependencyRef{{Name: strings.Repeat("x", slackMaxSectionChars)}}, + }) + } + + message, truncated := buildMessage(report) + + if !truncated { + t.Fatalf("a digest of %d blocks was not reported as truncated", len(message.Blocks)) + } + if len(message.Blocks) > slackMaxBlocks { + t.Errorf("message has %d blocks, want at most %d", len(message.Blocks), slackMaxBlocks) + } + if !strings.Contains(blockText(message), "the full list is in the workflow run log") { + t.Error("the truncated digest does not say where the full list is") + } +} + +// 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) + + message, _ := buildMessage(testReport(t)) + + payload, err := encodeMessage(message) + 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) + + if err := publish(context.Background(), capture.client(), Report{}, false); 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) + } +} + +func TestPublishHonoursDryRun(t *testing.T) { + capture := newCaptureServer(t, http.StatusOK) + + if err := publish(context.Background(), capture.client(), testReport(t), true); 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) + } +} + +func TestEscape(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + {"ampersand", "AT&T", "AT&T"}, + {"angle brackets", "