Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,12 @@ endef
# Create a target for each component
$(foreach C, $(HELPER_BINARIES), $(eval $(call helper_target_template,$(C))))

# Unlike the other devex/cmd helpers (built but left for the engineer to install/run by name),
# check-aws-marketplace-skew is meant to be run directly via `make` — build it and execute it in
# one step. Extra flags can be passed via ARGS, e.g. `make check-aws-marketplace-skew ARGS="--json"`.
check-aws-marketplace-skew: _build-helper-check-aws-marketplace-skew
@_output/linux/$(GOARCH)/check-aws-marketplace-skew $(ARGS)

define verify_e2e_target_template =
.PHONY: $(1)
$(1): _verify-e2e-$(1)
Expand Down
90 changes: 90 additions & 0 deletions devex/cmd/check-aws-marketplace-skew/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# check-aws-marketplace-skew

A `make` target an engineer runs occasionally to check whether published AWS Marketplace RHCOS
AMIs have drifted out of MCO's boot-image skew band, so a stale or mismatched Marketplace image
can be caught before customers hit it.

For each known Marketplace product code (OCP/OKE/OPP × x86_64/arm64, plus their EMEA x86_64
variants — ROSA Classic is excluded, as it's being sunset), it fetches every published AMI and
checks whether **at least one** falls within an acceptable version band:

- **Floor**: `RHCOSVersionBootImageSkewLimit`/`OCPVersionBootImageSkewLimit`
(`pkg/controller/common/constants.go`) as they stood ~4 months ago, reconstructed from
`openshift/machine-config-operator`'s GitHub history for `--branch` — not persisted state — so
Marketplace gets a grace period to catch up after MCO bumps its skew limit.
- **Ceiling**: the pinned RHCOS version for `--branch`, fetched live from `openshift/installer`'s
`data/data/coreos/coreos-rhel-10.json` on the matching branch.

The check is existential, not singular: Marketplace can keep multiple AMI versions live at once, so
the current/default one being out of band doesn't necessarily mean there's no compliant option.

## Usage

```console
$ make check-aws-marketplace-skew
Skew-limit floor: RHCOS 9.2 (OCP 4.13.0)
Installer ceiling (x86_64): 10.2
Installer ceiling (arm64): 10.2

PRODUCT PRODUCT ID RESULT MATCHED AMI DETAIL
OCP x86_64 59ead7de-2540-4653-a8b0-fa7926d5c845 PASS ami-0123456789abcdef0 9.6.20260210-0
OKE x86_64 963b36c3-de6f-48ed-b802-2b38b2a2cdeb PASS ami-0fedcba9876543210 9.6.20260210-0
...
```

Pass flags through with `ARGS`, e.g. `make check-aws-marketplace-skew ARGS="--json"`.

Flags:

- `--region` (default `us-east-1`): AWS region to query `DescribeImages` in. A single region is
sufficient — the version signal lives in the AMI `Name`/`Description` text, which is consistent
across every region a Marketplace listing replicates to.
- `--profile`: named AWS profile to use. Defaults to whatever the standard credential chain
resolves — if `aws` CLI commands already work for you, this tool will too.
- `--branch` (default `main`): the release branch to check, applied on both sides — the MCO
skew-limit floor is reconstructed from `openshift/machine-config-operator`'s history for this
branch, and the RHCOS ceiling is fetched from `openshift/installer`'s copy of the same branch
name. Both are fetched live from GitHub; no local checkout of either repo is needed, so this
works the same regardless of what `origin` points at locally (e.g. a personal fork that doesn't
mirror release branches).
- `--json`: emit a structured JSON report instead of a human-readable table.

Exit code is non-zero if any product code fails its band check.

## Credentials

Requires the `aws` CLI to be installed and on `PATH` — this tool shells out to
`aws ec2 describe-images` rather than using the AWS Go SDK, so it has no AWS SDK dependency of its
own and just inherits whatever credentials already make `aws` CLI commands work for you
(environment variables, shared config/credentials file, SSO sessions). Use `--profile` to select a
named profile, same as `aws --profile`.

The skew-limit floor also makes one `api.github.com` call per run (listing commits touching
`pkg/controller/common/constants.go`), which is unauthenticated by default and subject to GitHub's
60-requests/hour-per-IP limit — easy to hit on a shared NAT. Set `GITHUB_TOKEN` (the standard env
var honored by `gh`, GitHub Actions, etc.) to raise that to 5000/hour; no new flag needed. The
`raw.githubusercontent.com` fetches (installer ceiling, and the floor's file-at-commit lookup)
aren't subject to this same limit and don't need a token.

## Known gaps

- There are no AWS credentials in this repo's own CI, so `DescribeImages`/`CheckProduct` aren't
exercised end-to-end by `go test` — those tests use fixtures instead. Verified manually against
real Marketplace AMIs.
- Similarly, calls against the *real* GitHub endpoints aren't exercised by `go test` — this
package's tests run as part of the whole repo's presubmit suite, and a real network dependency
there would trade one devex tool's coverage for flakiness across every PR. `githubCommitsForPath`
itself (pagination, stop-on-short-page, 404 handling) *is* unit-tested against a local
`httptest.Server`, as are the pure parsing/selection functions (`parseGitHubCommitsJSON`,
`selectHistoricalCommit`, `parseSkewLimitConstants`, `parseStreamCeiling`) — only the thin
HTTP-call wrappers (`fetchGitHubCommitsPage`'s live request, `fetchRawGitHubFile`) are verified
manually against the real API instead.
- The skew-limit floor reconstruction fetches at most 500 commits (5 pages of 100 — GitHub's own
per-page cap) touching `pkg/controller/common/constants.go` on `--branch`, stopping early once a
page comes back short. Real history has 63 commits touching that file today, so this is
comfortable headroom, but if that count ever passes 500 for a branch, the "no commit predates the
grace window" fallback would pick the oldest of the *fetched* commits rather than the file's true
oldest revision.
- `openshift/installer`'s stream metadata filename is RHEL-major-version-specific
(`coreos-rhel-10.json`) — will need a manual update when RHEL 11 lands. Not building dynamic
discovery for this now.
167 changes: 167 additions & 0 deletions devex/cmd/check-aws-marketplace-skew/github.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
package main

import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"time"
)

// githubRawURLTemplate points at raw.githubusercontent.com, which serves a file's content at any
// ref (branch, tag, or commit SHA) without needing a local checkout of the repo.
const githubRawURLTemplate = "https://raw.githubusercontent.com/%s/%s/%s/%s"

// githubAPIBaseURL is a var (not a const) so tests can point it at an httptest.Server instead of
// the real GitHub API.
var githubAPIBaseURL = "https://api.github.com"

// githubCommitsAPITemplate lists commits touching a path, most recent first.
const githubCommitsAPITemplate = "%s/repos/%s/%s/commits?sha=%s&path=%s&per_page=%d&page=%d"

const (
// githubCommitsPerPage is GitHub's own hard cap — the API rejects/clamps anything higher.
githubCommitsPerPage = 100
// githubCommitsMaxPages bounds the total history considered to githubCommitsPerPage *
// githubCommitsMaxPages = 500 commits. Fetching stops as soon as a page comes back short
// (the true end of history for that path), so real MCO history (63 commits touching
// pkg/controller/common/constants.go today) still costs a single request — this cap only
// matters for branches/files with much deeper history than that.
githubCommitsMaxPages = 5
)

// RefNotFoundError indicates a GitHub API request returned HTTP 404. Path is set only when the
// request was for a specific file (raw.githubusercontent.com), where a 404 can mean either the
// ref or the path doesn't exist and the response body gives no way to tell which; for the commits
// API, a 404 unambiguously means the ref doesn't exist, so Path is left empty there.
type RefNotFoundError struct {
Owner, Repo, Ref, Path, URL string
}

func (e *RefNotFoundError) Error() string {
if e.Path != "" {
return fmt.Sprintf("%s/%s: ref %q or path %q not found (%s returned 404)", e.Owner, e.Repo, e.Ref, e.Path, e.URL)
}
return fmt.Sprintf("%s/%s has no branch %q (%s returned 404)", e.Owner, e.Repo, e.Ref, e.URL)
}

// fetchRawGitHubFile fetches path's content at ref from owner/repo over HTTPS, with no local
// checkout of that repo required.
func fetchRawGitHubFile(ctx context.Context, owner, repo, ref, path string) ([]byte, error) {
url := fmt.Sprintf(githubRawURLTemplate, owner, repo, ref, path)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}

resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to fetch %s: %w", url, err)
}
defer resp.Body.Close()

if resp.StatusCode == http.StatusNotFound {
return nil, &RefNotFoundError{Owner: owner, Repo: repo, Ref: ref, Path: path, URL: url}
}
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("fetching %s returned HTTP %d: %s", url, resp.StatusCode, string(body))
}

body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body from %s: %w", url, err)
}
return body, nil
}

// ghCommit is the subset of a GitHub API commit resource this tool needs.
type ghCommit struct {
SHA string
Date time.Time
}

// githubCommitsForPath lists commits touching path on branch in owner/repo, most recent first, via
// the GitHub REST API rather than a local git checkout. Paginates up to githubCommitsMaxPages,
// stopping as soon as a page comes back short of githubCommitsPerPage (the true end of history).
func githubCommitsForPath(ctx context.Context, owner, repo, branch, path string) ([]ghCommit, error) {
var all []ghCommit
for page := 1; page <= githubCommitsMaxPages; page++ {
commits, err := fetchGitHubCommitsPage(ctx, owner, repo, branch, path, page)
if err != nil {
return nil, err
}
all = append(all, commits...)
if len(commits) < githubCommitsPerPage {
break
}
}
return all, nil
}

// fetchGitHubCommitsPage fetches a single page of githubCommitsForPath's results.
func fetchGitHubCommitsPage(ctx context.Context, owner, repo, branch, path string, page int) ([]ghCommit, error) {
url := fmt.Sprintf(githubCommitsAPITemplate, githubAPIBaseURL, owner, repo, branch, path, githubCommitsPerPage, page)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/vnd.github+json")
// api.github.com (unlike raw.githubusercontent.com) enforces a 60/hour unauthenticated rate
// limit per IP, shared across everything on that IP — cheap to blow through on a shared NAT.
// GITHUB_TOKEN is the standard env var convention (gh CLI, GitHub Actions, etc.); honoring it
// here raises the limit to 5000/hour with zero new flags for the common case of already having
// one set.
if token := os.Getenv("GITHUB_TOKEN"); token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}

resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to fetch %s: %w", url, err)
}
defer resp.Body.Close()

body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body from %s: %w", url, err)
}

if resp.StatusCode == http.StatusNotFound {
return nil, &RefNotFoundError{Owner: owner, Repo: repo, Ref: branch, URL: url}
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("fetching %s returned HTTP %d: %s", url, resp.StatusCode, string(body))
}

commits, err := parseGitHubCommitsJSON(body)
if err != nil {
return nil, fmt.Errorf("branch %s: %w", branch, err)
}
return commits, nil
}

// parseGitHubCommitsJSON extracts SHA/committer-date pairs from a GitHub "list commits" API
// response, split out from githubCommitsForPath so it's testable against a fixture without a live
// network call.
func parseGitHubCommitsJSON(body []byte) ([]ghCommit, error) {
var raw []struct {
SHA string `json:"sha"`
Commit struct {
Committer struct {
Date time.Time `json:"date"`
} `json:"committer"`
} `json:"commit"`
}
if err := json.Unmarshal(body, &raw); err != nil {
return nil, fmt.Errorf("failed to parse commits list: %w", err)
}

commits := make([]ghCommit, 0, len(raw))
for _, c := range raw {
commits = append(commits, ghCommit{SHA: c.SHA, Date: c.Commit.Committer.Date})
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return commits, nil
}
Loading