diff --git a/.gitignore b/.gitignore index 67f4fe3a2..459bba069 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,3 @@ __pycache__/ coverage.out golangci-lint.out report.json - -# Local output from hack/changelog-preview (go run … > sample-changelog.md) -hack/changelog-preview/sample-changelog.md diff --git a/cmd/release-controller-api/http_changelog.go b/cmd/release-controller-api/http_changelog.go index aa47f70ec..d04501b98 100644 --- a/cmd/release-controller-api/http_changelog.go +++ b/cmd/release-controller-api/http_changelog.go @@ -72,7 +72,6 @@ func (c *Controller) getChangeLog(ch chan renderResult, chNodeInfo chan renderRe return } ch <- renderResult{out: out} - return } out, err = rhcos.TransformMarkDownOutput(out, fromTag, toTag, architecture, archExtension) @@ -87,31 +86,25 @@ func (c *Controller) getChangeLog(ch chan renderResult, chNodeInfo chan renderRe return } - toImagePullspec := toImage.GenerateDigestPullSpec() - fromImagePullspec := fromImage.GenerateDigestPullSpec() - - // Request node image info when the changelog links to #node-image-info (CoreOS infobox) or when - // the target payload has discoverable machine-os streams (newer oc may omit RHCOS summary lines). - fetchNode := strings.Contains(out, "#node-image-info") - if !fetchNode { - streams, err := c.releaseInfo.ListMachineOSStreams(toImagePullspec) - if err != nil { - chNodeInfo <- renderResult{err: err} - return - } - fetchNode = len(streams) > 0 - } - if !fetchNode { + // Only request node image info if it'll be rendered. Use the exact + // check that renderChangeLog does to know if to consume from us. + if !strings.Contains(out, "#node-image-info") { chNodeInfo <- renderResult{} return } - nodeMD, err := rhcos.NodeImageSectionMarkdown(c.releaseInfo, fromImagePullspec, toImagePullspec, out) + toImagePullspec := toImage.GenerateDigestPullSpec() + rpmlist, err := c.releaseInfo.RpmList(toImagePullspec) if err != nil { chNodeInfo <- renderResult{err: err} - return } - chNodeInfo <- renderResult{out: nodeMD} + + rpmdiff, err := c.releaseInfo.RpmDiff(fromImage.GenerateDigestPullSpec(), toImagePullspec) + if err != nil { + chNodeInfo <- renderResult{err: err} + } + + chNodeInfo <- renderResult{out: rhcos.RenderNodeImageInfo(out, rpmlist, rpmdiff)} } func (c *Controller) renderChangeLog(w http.ResponseWriter, fromPull string, fromTag string, toPull string, toTag string, format string) { @@ -180,17 +173,9 @@ func (c *Controller) renderChangeLog(w http.ResponseWriter, fromPull string, fro fmt.Fprintf(w, `

%s

`, fmt.Sprintf("Unable to show full changelog: %s", render.err)) } - needsNode := strings.Contains(render.out, "#node-image-info") - if !needsNode && render.err == nil && format != "json" { - toImage, err := releasecontroller.GetImageInfo(c.releaseInfo, c.architecture, toPull) - if err == nil { - streams, err2 := c.releaseInfo.ListMachineOSStreams(toImage.GenerateDigestPullSpec()) - if err2 == nil && len(streams) > 0 { - needsNode = true - } - } - } - if !needsNode { + // only render a CoreOS diff if we need to; we can know this by + // checking if it links to the diff section we create here + if !strings.Contains(render.out, "#node-image-info") { return } diff --git a/hack/changelog-preview/main.go b/hack/changelog-preview/main.go deleted file mode 100644 index bedd4f864..000000000 --- a/hack/changelog-preview/main.go +++ /dev/null @@ -1,81 +0,0 @@ -// changelog-preview runs the same ChangeLog + RHCOS markdown transforms as the release-controller API -// without needing a Kubernetes cluster. Requires `oc` on PATH and registry pull access to the -// release images you pass. -// -// By default it also appends the Node Image Info section (RPM lists and diffs per CoreOS stream -// when applicable), matching the web UI. Use --skip-node-info for changelog-only output. -// -// Example: -// -// go run ./hack/changelog-preview/ \ -// --from quay.io/openshift-release-dev/ocp-release@sha256:... \ -// --to quay.io/openshift-release-dev/ocp-release@sha256:... \ -// --from-tag 4.20.0-0.nightly-2025-01-01-000000 \ -// --to-tag 4.21.0-ec.1 -package main - -import ( - "flag" - "fmt" - "os" - - "github.com/openshift/release-controller/pkg/rhcos" - releasecontroller "github.com/openshift/release-controller/pkg/release-controller" - "k8s.io/client-go/kubernetes" - "k8s.io/client-go/rest" - "sigs.k8s.io/prow/pkg/jira" -) - -func main() { - from := flag.String("from", "", "from release image pull spec (digest or tag@repo)") - to := flag.String("to", "", "to release image pull spec") - fromTag := flag.String("from-tag", "previous", "from tag name (for markdown link substitution)") - toTag := flag.String("to-tag", "current", "to tag name (for markdown link substitution)") - arch := flag.String("arch", "amd64", "release architecture (amd64, arm64, ...)") - skipNode := flag.Bool("skip-node-info", false, "omit Node Image Info (faster; no extra oc rpmdb/image-for calls)") - flag.Parse() - if *from == "" || *to == "" { - fmt.Fprintf(os.Stderr, "usage: changelog-preview --from --to [flags]\n") - os.Exit(2) - } - - var archName, archExt string - switch *arch { - case "amd64": - archName = "x86_64" - case "arm64": - archName = "aarch64" - archExt = fmt.Sprintf("-%s", archName) - default: - archName = *arch - archExt = fmt.Sprintf("-%s", archName) - } - - var nilClient kubernetes.Interface - var nilCfg *rest.Config - info := releasecontroller.NewExecReleaseInfo(nilClient, nilCfg, "", "", func() (string, error) { return "", nil }, jira.Client(nil)) - - out, err := info.ChangeLog(*from, *to, false) - if err != nil { - fmt.Fprintf(os.Stderr, "ChangeLog: %v\n", err) - os.Exit(1) - } - out, err = rhcos.TransformMarkDownOutput(out, *fromTag, *toTag, archName, archExt) - if err != nil { - fmt.Fprintf(os.Stderr, "TransformMarkDownOutput: %v\n", err) - os.Exit(1) - } - - if !*skipNode { - nodeMD, err := rhcos.NodeImageSectionMarkdown(info, *from, *to, out) - if err != nil { - fmt.Fprintf(os.Stderr, "NodeImageSectionMarkdown: %v\n", err) - os.Exit(1) - } - if nodeMD != "" { - out = out + "\n\n## Node Image Info\n\n" + nodeMD - } - } - - fmt.Print(out) -} diff --git a/pkg/release-controller/machine_os_tags.go b/pkg/release-controller/machine_os_tags.go deleted file mode 100644 index 6cd552901..000000000 --- a/pkg/release-controller/machine_os_tags.go +++ /dev/null @@ -1,147 +0,0 @@ -package releasecontroller - -import ( - "encoding/json" - "fmt" - "sort" - "strings" -) - -// MachineOSStreamInfo describes one machine-OS image stream (base tag + optional display name from payload). -type MachineOSStreamInfo struct { - Tag string `json:"tag"` - DisplayName string `json:"displayName,omitempty"` -} - -// releaseInfoImageRefs is the subset of `oc adm release info -o json` needed to list payload tags. -type releaseInfoImageRefs struct { - References struct { - Spec struct { - Tags []struct { - Name string `json:"name"` - Annotations map[string]string `json:"annotations"` - } `json:"tags"` - } `json:"spec"` - } `json:"references"` -} - -const versionDisplayNamesKey = "io.openshift.build.version-display-names" - -// ListMachineOSStreams returns machine-OS base tags discovered by pairing each *coreos* extensions -// image with its base tag. Display names come from io.openshift.build.version-display-names -// (machine-os=...) on the base image, as used in current OCP payloads (e.g. 4.21 nightlies). -// Convention: extensions tag is "-extensions" (rhel-coreos-extensions, rhel-coreos-10-extensions). -func (r *ExecReleaseInfo) ListMachineOSStreams(releaseImage string) ([]MachineOSStreamInfo, error) { - raw, err := r.ReleaseInfo(releaseImage) - if err != nil { - return nil, err - } - return machineOSStreamsFromReleaseJSON(raw) -} - -// machineOSStreamsFromReleaseJSON parses release JSON for tests and shared logic. -func machineOSStreamsFromReleaseJSON(raw string) ([]MachineOSStreamInfo, error) { - var ri releaseInfoImageRefs - if err := json.Unmarshal([]byte(raw), &ri); err != nil { - return nil, err - } - - tagSet := make(map[string]struct{}, len(ri.References.Spec.Tags)) - annByTag := make(map[string]map[string]string, len(ri.References.Spec.Tags)) - for _, t := range ri.References.Spec.Tags { - if t.Name == "" { - continue - } - tagSet[t.Name] = struct{}{} - if len(t.Annotations) > 0 { - annByTag[t.Name] = t.Annotations - } - } - - var bases []string - for name := range tagSet { - if !strings.HasSuffix(name, "-extensions") { - continue - } - if !strings.Contains(name, "coreos") { - continue - } - base := strings.TrimSuffix(name, "-extensions") - if base == "" { - continue - } - if _, ok := tagSet[base]; !ok { - continue - } - bases = append(bases, base) - } - - sortMachineOSTags(bases) - out := make([]MachineOSStreamInfo, 0, len(bases)) - for _, base := range bases { - dn := "" - if a, ok := annByTag[base]; ok { - dn = machineOSDisplayNameFromAnnotations(a) - } - out = append(out, MachineOSStreamInfo{Tag: base, DisplayName: dn}) - } - return out, nil -} - -func machineOSDisplayNameFromAnnotations(annotations map[string]string) string { - v := strings.TrimSpace(annotations[versionDisplayNamesKey]) - if v == "" { - return "" - } - // Typical: "machine-os=Red Hat Enterprise Linux CoreOS" (single pair). - for _, part := range strings.Split(v, ",") { - part = strings.TrimSpace(part) - const prefix = "machine-os=" - if strings.HasPrefix(part, prefix) { - return strings.TrimSpace(strings.TrimPrefix(part, prefix)) - } - } - return "" -} - -// MachineOSTitle returns a markdown subsection title for a stream (display name + tag in backticks). -func MachineOSTitle(s MachineOSStreamInfo) string { - if s.DisplayName != "" { - return fmt.Sprintf("%s (`%s`)", s.DisplayName, s.Tag) - } - switch s.Tag { - case "rhel-coreos": - return "Red Hat Enterprise Linux CoreOS (`rhel-coreos`)" - case "rhel-coreos-10": - return "Red Hat Enterprise Linux CoreOS 10 (`rhel-coreos-10`)" - case "stream-coreos": - return "Stream CoreOS (`stream-coreos`)" - default: - return fmt.Sprintf("Machine OS (`%s`)", s.Tag) - } -} - -func sortMachineOSTags(tags []string) { - sort.SliceStable(tags, func(i, j int) bool { - return machineOSTagLess(tags[i], tags[j]) - }) -} - -func machineOSTagLess(a, b string) bool { - prio := map[string]int{ - "rhel-coreos": 0, - "stream-coreos": 1, - } - pa, okA := prio[a] - pb, okB := prio[b] - switch { - case okA && okB: - return pa < pb - case okA: - return true - case okB: - return false - default: - return a < b - } -} diff --git a/pkg/release-controller/machine_os_tags_test.go b/pkg/release-controller/machine_os_tags_test.go deleted file mode 100644 index 46cddfcf0..000000000 --- a/pkg/release-controller/machine_os_tags_test.go +++ /dev/null @@ -1,78 +0,0 @@ -package releasecontroller - -import ( - "reflect" - "testing" -) - -func TestMachineOSStreamsFromReleaseJSON_nightly421(t *testing.T) { - // Subset of oc adm release info -o json for registry.ci.openshift.org/ocp/release:4.21.0-0.nightly-2026-03-30-143812 - const raw = `{ - "references": { - "spec": { - "tags": [ - { - "name": "rhel-coreos", - "annotations": { - "io.openshift.build.version-display-names": "machine-os=Red Hat Enterprise Linux CoreOS", - "io.openshift.build.versions": "machine-os=9.6.20260327-0" - } - }, - { - "name": "rhel-coreos-10", - "annotations": { - "io.openshift.build.version-display-names": "machine-os=Red Hat Enterprise Linux CoreOS 10.2", - "io.openshift.build.versions": "machine-os=10.2.20260328-0" - } - }, - { - "name": "rhel-coreos-10-extensions", - "annotations": {} - }, - { - "name": "rhel-coreos-extensions", - "annotations": {} - } - ] - } - } -}` - - got, err := machineOSStreamsFromReleaseJSON(raw) - if err != nil { - t.Fatal(err) - } - want := []MachineOSStreamInfo{ - {Tag: "rhel-coreos", DisplayName: "Red Hat Enterprise Linux CoreOS"}, - {Tag: "rhel-coreos-10", DisplayName: "Red Hat Enterprise Linux CoreOS 10.2"}, - } - if !reflect.DeepEqual(got, want) { - t.Errorf("machineOSStreamsFromReleaseJSON() = %#v, want %#v", got, want) - } -} - -func TestMachineOSDisplayNameFromAnnotations(t *testing.T) { - tests := []struct { - ann map[string]string - want string - }{ - {nil, ""}, - {map[string]string{versionDisplayNamesKey: "machine-os=Foo Bar"}, "Foo Bar"}, - {map[string]string{versionDisplayNamesKey: " machine-os=Foo Bar "}, "Foo Bar"}, - {map[string]string{versionDisplayNamesKey: "other=x, machine-os=CoreOS 10"}, "CoreOS 10"}, - } - for _, tt := range tests { - if got := machineOSDisplayNameFromAnnotations(tt.ann); got != tt.want { - t.Errorf("machineOSDisplayNameFromAnnotations(%v) = %q, want %q", tt.ann, got, tt.want) - } - } -} - -func TestMachineOSTitle(t *testing.T) { - if got := MachineOSTitle(MachineOSStreamInfo{Tag: "rhel-coreos", DisplayName: "Red Hat Enterprise Linux CoreOS"}); got != "Red Hat Enterprise Linux CoreOS (`rhel-coreos`)" { - t.Errorf("got %q", got) - } - if got := MachineOSTitle(MachineOSStreamInfo{Tag: "custom-stream", DisplayName: ""}); got != "Machine OS (`custom-stream`)" { - t.Errorf("got %q", got) - } -} diff --git a/pkg/release-controller/release_info.go b/pkg/release-controller/release_info.go index 184568aea..04cded712 100644 --- a/pkg/release-controller/release_info.go +++ b/pkg/release-controller/release_info.go @@ -95,24 +95,6 @@ func NewCachingReleaseInfo(info ReleaseInfo, size int64, architecture string) Re } } } - case "rpmdiffstream": - if len(parts) < 4 { - s, err = "", fmt.Errorf("invalid rpmdiffstream key") - } else if strings.Contains(parts[1], "\x00") || strings.Contains(parts[2], "\x00") || strings.Contains(parts[3], "\x00") { - s, err = "", fmt.Errorf("invalid from/to/component") - } else { - var rpmdiff RpmDiff - rpmdiff, err = info.RpmDiffForStream(parts[1], parts[2], parts[3]) - if err == nil { - var rpmdiffByte []byte - rpmdiffByte, err = json.Marshal(rpmdiff) - if err != nil { - klog.V(4).Infof("Failed to Marshal Rpm Diff stream; from: %s to: %s img: %s; %s", parts[1], parts[2], parts[3], err) - } else { - s = string(rpmdiffByte) - } - } - } case "rpmlist": var rpmlist RpmList rpmlist, err = info.RpmList(parts[1]) @@ -125,50 +107,6 @@ func NewCachingReleaseInfo(info ReleaseInfo, size int64, architecture string) Re s = string(rpmlistByte) } } - case "rpmliststream": - if len(parts) < 4 { - s, err = "", fmt.Errorf("invalid rpmliststream key") - } else if strings.Contains(parts[1], "\x00") || strings.Contains(parts[2], "\x00") || strings.Contains(parts[3], "\x00") { - s, err = "", fmt.Errorf("invalid release/tag") - } else { - var rpmlist RpmList - rpmlist, err = info.RpmListForStream(parts[1], parts[2], parts[3]) - if err == nil { - var rpmlistByte []byte - rpmlistByte, err = json.Marshal(rpmlist) - if err != nil { - klog.V(4).Infof("Failed to Marshal Rpm List for stream %s/%s; %s", parts[2], parts[3], err) - } else { - s = string(rpmlistByte) - } - } - } - case "imagefor": - if len(parts) < 3 { - s, err = "", fmt.Errorf("invalid imagefor key") - } else if strings.Contains(parts[1], "\x00") || strings.Contains(parts[2], "\x00") { - s, err = "", fmt.Errorf("invalid release/component") - } else { - s, err = info.ImageReferenceForComponent(parts[1], parts[2]) - } - case "machineosstreams": - if len(parts) < 2 { - s, err = "", fmt.Errorf("invalid machineosstreams key") - } else if strings.Contains(parts[1], "\x00") { - s, err = "", fmt.Errorf("invalid release") - } else { - var streams []MachineOSStreamInfo - streams, err = info.ListMachineOSStreams(parts[1]) - if err == nil { - var b []byte - b, err = json.Marshal(streams) - if err != nil { - klog.V(4).Infof("Failed to Marshal machine OS streams for %s; %s", parts[1], err) - } else { - s = string(b) - } - } - } case "changelog": if strings.Contains(parts[1], "\x00") || strings.Contains(parts[2], "\x00") || strings.Contains(parts[3], "\x00") { s, err = "", fmt.Errorf("invalid from/to") @@ -224,22 +162,6 @@ func (c *CachingReleaseInfo) RpmDiff(from, to string) (RpmDiff, error) { return rpmdiff, nil } -func (c *CachingReleaseInfo) RpmDiffForStream(fromRelease, toRelease, rpmdbImageName string) (RpmDiff, error) { - var s string - err := c.cache.Get(context.TODO(), strings.Join([]string{"rpmdiffstream", fromRelease, toRelease, rpmdbImageName}, "\x00"), groupcache.StringSink(&s)) - if err != nil { - return RpmDiff{}, err - } - if s == "" { - return RpmDiff{}, nil - } - var rpmdiff RpmDiff - if err = json.Unmarshal([]byte(s), &rpmdiff); err != nil { - return RpmDiff{}, err - } - return rpmdiff, nil -} - func (c *CachingReleaseInfo) RpmList(image string) (RpmList, error) { var s string err := c.cache.Get(context.TODO(), strings.Join([]string{"rpmlist", image}, "\x00"), groupcache.StringSink(&s)) @@ -256,47 +178,6 @@ func (c *CachingReleaseInfo) RpmList(image string) (RpmList, error) { return rpmlist, nil } -func (c *CachingReleaseInfo) RpmListForStream(releaseImage, coreosTagName, extensionsTagName string) (RpmList, error) { - var s string - err := c.cache.Get(context.TODO(), strings.Join([]string{"rpmliststream", releaseImage, coreosTagName, extensionsTagName}, "\x00"), groupcache.StringSink(&s)) - if err != nil { - return RpmList{}, err - } - if s == "" { - return RpmList{}, nil - } - var rpmlist RpmList - if err = json.Unmarshal([]byte(s), &rpmlist); err != nil { - return RpmList{}, err - } - return rpmlist, nil -} - -func (c *CachingReleaseInfo) ImageReferenceForComponent(releaseImage, componentName string) (string, error) { - var s string - err := c.cache.Get(context.TODO(), strings.Join([]string{"imagefor", releaseImage, componentName}, "\x00"), groupcache.StringSink(&s)) - if err != nil { - return "", err - } - return s, nil -} - -func (c *CachingReleaseInfo) ListMachineOSStreams(releaseImage string) ([]MachineOSStreamInfo, error) { - var s string - err := c.cache.Get(context.TODO(), strings.Join([]string{"machineosstreams", releaseImage}, "\x00"), groupcache.StringSink(&s)) - if err != nil { - return nil, err - } - if s == "" { - return nil, nil - } - var streams []MachineOSStreamInfo - if err = json.Unmarshal([]byte(s), &streams); err != nil { - return nil, err - } - return streams, nil -} - func (c *CachingReleaseInfo) ChangeLog(from, to string, json bool) (string, error) { var s string err := c.cache.Get(context.TODO(), strings.Join([]string{"changelog", from, to, strconv.FormatBool(json)}, "\x00"), groupcache.StringSink(&s)) @@ -345,14 +226,7 @@ type ReleaseInfo interface { Bugs(from, to string) ([]BugDetails, error) ChangeLog(from, to string, json bool) (string, error) RpmList(image string) (RpmList, error) - RpmListForStream(releaseImage, coreosTagName, extensionsTagName string) (RpmList, error) RpmDiff(from, to string) (RpmDiff, error) - // RpmDiffForStream runs rpmdb-diff between two release payloads for a specific machine-os component (e.g. rhel-coreos, rhel-coreos-10). - RpmDiffForStream(fromRelease, toRelease, rpmdbImageName string) (RpmDiff, error) - // ImageReferenceForComponent resolves a component tag (e.g. rhel-coreos) to a pull spec via oc adm release info --image-for. - ImageReferenceForComponent(releaseImage, componentName string) (string, error) - // ListMachineOSStreams returns machine-OS streams from the release payload (see machine_os_tags.go). - ListMachineOSStreams(releaseImage string) ([]MachineOSStreamInfo, error) ReleaseInfo(image string) (string, error) UpgradeInfo(image string) (ReleaseUpgradeInfo, error) ImageInfo(image, architecture string) (string, error) @@ -593,77 +467,6 @@ func (r *ExecReleaseInfo) RpmList(image string) (RpmList, error) { return rpmlist, nil } -func (r *ExecReleaseInfo) ImageReferenceForComponent(releaseImage, componentName string) (string, error) { - if _, err := imagereference.Parse(releaseImage); err != nil { - return "", fmt.Errorf("%s is not an image reference: %v", releaseImage, err) - } - if strings.HasPrefix(releaseImage, "-") { - return "", fmt.Errorf("not a valid reference") - } - out, _, err := ocCmd("adm", "release", "info", "--image-for", componentName, releaseImage) - if err != nil { - return "", fmt.Errorf("failed to resolve image for component %q in %s: %v", componentName, releaseImage, err) - } - ref := strings.TrimSpace(string(out)) - if ref == "" { - return "", fmt.Errorf("empty image reference for component %q in %s", componentName, releaseImage) - } - return ref, nil -} - -// RpmListForStream loads the RPM database for a machine-os component inside the release payload -// (--rpmdb on the release image plus --rpmdb-image) and extensions from the named extensions tag. -func (r *ExecReleaseInfo) RpmListForStream(releaseImage, coreosTagName, extensionsTagName string) (RpmList, error) { - if _, err := imagereference.Parse(releaseImage); err != nil { - return RpmList{}, fmt.Errorf("%s is not an image reference: %v", releaseImage, err) - } - if strings.HasPrefix(releaseImage, "-") { - return RpmList{}, fmt.Errorf("not a valid reference") - } - - var rpmlist RpmList - - out, _, err := ocCmd("adm", "release", "info", "--rpmdb-cache=/tmp/rpmdb/", "--output=json", "--rpmdb", "--rpmdb-image="+coreosTagName, releaseImage) - if err != nil { - return RpmList{}, fmt.Errorf("failed to query RPM list for %s (rpmdb-image %s): %v", releaseImage, coreosTagName, err) - } - if err = json.Unmarshal(out, &rpmlist.Packages); err != nil { - return RpmList{}, fmt.Errorf("unmarshaling RPM list: %s", err) - } - - extTag := extensionsTagName - if _, ok := rpmlist.Packages["centos-stream-release"]; ok && extTag == "rhel-coreos-extensions" { - extTag = "stream-coreos-extensions" - } - - extensionsPull, err := r.ImageReferenceForComponent(releaseImage, extTag) - if err != nil { - return RpmList{}, err - } - - tmpdir, err := os.MkdirTemp("", "extensions") - if err != nil { - return RpmList{}, fmt.Errorf("failed to create tmpdir for RPM extensions list for %s", extensionsPull) - } - - defer os.RemoveAll(tmpdir) - // see https://github.com/openshift/os/commit/31816acb1ae377c9c48f1e4bc70fbf63cf4adc2d - _, _, err = ocCmdExt(tmpdir, "image", "extract", extensionsPull+"[-1]", "--file", coreosExtensionsMetadataPath) - if err != nil { - return RpmList{}, fmt.Errorf("failed to query RPM extensions list for %s", extensionsPull) - } - extensions, err := os.ReadFile(filepath.Join(tmpdir, "extensions.json")) - if err != nil { - klog.Warningf("Continuing without extensions information: %v\n", err) - return rpmlist, nil - } - if err = json.Unmarshal(extensions, &rpmlist.Extensions); err != nil { - return RpmList{}, fmt.Errorf("unmarshaling extensions: %s", err) - } - - return rpmlist, nil -} - func (r *ExecReleaseInfo) RpmDiff(from, to string) (RpmDiff, error) { if _, err := imagereference.Parse(from); err != nil { return RpmDiff{}, fmt.Errorf("%s is not an image reference: %v", from, err) @@ -688,33 +491,6 @@ func (r *ExecReleaseInfo) RpmDiff(from, to string) (RpmDiff, error) { return rpmdiff, nil } -func (r *ExecReleaseInfo) RpmDiffForStream(fromRelease, toRelease, rpmdbImageName string) (RpmDiff, error) { - if _, err := imagereference.Parse(fromRelease); err != nil { - return RpmDiff{}, fmt.Errorf("%s is not an image reference: %v", fromRelease, err) - } - if _, err := imagereference.Parse(toRelease); err != nil { - return RpmDiff{}, fmt.Errorf("%s is not an image reference: %v", toRelease, err) - } - if strings.HasPrefix(fromRelease, "-") || strings.HasPrefix(toRelease, "-") { - return RpmDiff{}, fmt.Errorf("not a valid reference") - } - if rpmdbImageName == "" { - return r.RpmDiff(fromRelease, toRelease) - } - - out, _, err := ocCmd("adm", "release", "info", "--rpmdb-cache=/tmp/rpmdb/", "--output=json", "--rpmdb-image="+rpmdbImageName, "--rpmdb-diff", fromRelease, toRelease) - if err != nil { - return RpmDiff{}, fmt.Errorf("could not generate RPM diff for %s to %s (rpmdb-image %s): %v", fromRelease, toRelease, rpmdbImageName, err) - } - - var rpmdiff RpmDiff - if err = json.Unmarshal(out, &rpmdiff); err != nil { - return RpmDiff{}, fmt.Errorf("unmarshaling RPM diff: %s", err) - } - - return rpmdiff, nil -} - type RpmList struct { Packages map[string]string `json:"packages"` Extensions map[string]string `json:"extensions"` diff --git a/pkg/release-controller/semver.go b/pkg/release-controller/semver.go index 2a0a8588a..4b50c707b 100644 --- a/pkg/release-controller/semver.go +++ b/pkg/release-controller/semver.go @@ -164,13 +164,3 @@ func SemverParseTolerant(v string) (semver.Version, error) { func SemverToMajorMinor(sr semver.Version) string { return fmt.Sprintf("%d.%d", sr.Major, sr.Minor) } - -// ReleaseTagIsDualRHCOS reports whether the target release tag should show independent -// RHCOS 9 (rhel-coreos) and RHCOS 10 (rhel-coreos-10) changelogs and node image sections. -func ReleaseTagIsDualRHCOS(toTag string) bool { - v, err := SemverParseTolerant(toTag) - if err != nil { - return false - } - return v.Major == 4 && v.Minor >= 21 -} diff --git a/pkg/release-controller/semver_dual_rhcos_test.go b/pkg/release-controller/semver_dual_rhcos_test.go deleted file mode 100644 index 7317885e2..000000000 --- a/pkg/release-controller/semver_dual_rhcos_test.go +++ /dev/null @@ -1,24 +0,0 @@ -package releasecontroller - -import "testing" - -func TestReleaseTagIsDualRHCOS(t *testing.T) { - tests := []struct { - tag string - want bool - }{ - {"4.21.0-ec.1", true}, - {"4.21.0", true}, - {"4.22.1", true}, - {"4.20.0", false}, - {"4.20.0-ec.0", false}, - {"not-a-version", false}, - } - for _, tt := range tests { - t.Run(tt.tag, func(t *testing.T) { - if got := ReleaseTagIsDualRHCOS(tt.tag); got != tt.want { - t.Errorf("ReleaseTagIsDualRHCOS(%q) = %v, want %v", tt.tag, got, tt.want) - } - }) - } -} diff --git a/pkg/rhcos/node_image.go b/pkg/rhcos/node_image.go deleted file mode 100644 index 5c5cb8e64..000000000 --- a/pkg/rhcos/node_image.go +++ /dev/null @@ -1,63 +0,0 @@ -package rhcos - -import ( - "strings" - - releasecontroller "github.com/openshift/release-controller/pkg/release-controller" -) - -// NodeImageSectionMarkdown returns markdown for the Node Image Info block (package lists, RPM diffs, -// extensions, base-layer reprint). It returns ("", nil) when there is nothing to show: no -// #node-image-info anchor and no machine-os streams on the target release (see ListMachineOSStreams). -// -// Older changelogs embedded #node-image-info via the CoreOS infobox in TransformMarkDownOutput. -// Newer oc releases may omit the "* Red Hat Enterprise Linux CoreOS upgraded from …" summary lines, -// so that anchor is absent even when the payload has rhel-coreos* streams—we still render node -// info when streams are discoverable. -func NodeImageSectionMarkdown(info releasecontroller.ReleaseInfo, fromReleasePullSpec, toReleasePullSpec, changelogMarkdown string) (string, error) { - streams, err := info.ListMachineOSStreams(toReleasePullSpec) - if err != nil { - return "", err - } - - hasAnchor := strings.Contains(changelogMarkdown, "#node-image-info") - if !hasAnchor && len(streams) == 0 { - return "", nil - } - - if len(streams) == 0 { - rpmlist, err := info.RpmList(toReleasePullSpec) - if err != nil { - return "", err - } - - rpmdiff, err := info.RpmDiff(fromReleasePullSpec, toReleasePullSpec) - if err != nil { - return "", err - } - - return RenderNodeImageInfo(changelogMarkdown, rpmlist, rpmdiff), nil - } - - var nodeStreams []CoreOSNodeStream - for _, ms := range streams { - ext := ms.Tag + "-extensions" - list, err := info.RpmListForStream(toReleasePullSpec, ms.Tag, ext) - if err != nil { - return "", err - } - var diff releasecontroller.RpmDiff - if _, errFrom := info.ImageReferenceForComponent(fromReleasePullSpec, ms.Tag); errFrom == nil { - diff, err = info.RpmDiffForStream(fromReleasePullSpec, toReleasePullSpec, ms.Tag) - if err != nil { - return "", err - } - } - nodeStreams = append(nodeStreams, CoreOSNodeStream{ - Title: releasecontroller.MachineOSTitle(ms), - RpmList: list, - RpmDiff: diff, - }) - } - return RenderDualNodeImageInfo(changelogMarkdown, nodeStreams), nil -} diff --git a/pkg/rhcos/rhcos.go b/pkg/rhcos/rhcos.go index 2e82ca729..477a6fd91 100644 --- a/pkg/rhcos/rhcos.go +++ b/pkg/rhcos/rhcos.go @@ -15,9 +15,8 @@ import ( ) const ( - rhelCoreOs = "Red Hat Enterprise Linux CoreOS" - rhelCoreOs10 = "Red Hat Enterprise Linux CoreOS 10" - centosStreamCoreOs = "CentOS Stream CoreOS" + rhelCoreOs = "Red Hat Enterprise Linux CoreOS" + centosStreamCoreOs = "CentOS Stream CoreOS" baseLayerAlertBox = `

The CoreOS links above are for the base CoreOS layer used to build the OpenShift node image and do not contain OpenShift components. This is @@ -41,10 +40,6 @@ var ( reMdRHCoSDiff = regexp.MustCompile(`\* Red Hat Enterprise Linux CoreOS(?: \d+\.\d+)? upgraded from ((\d+)\.[\w\.\-]+) to ((\d+)\.[\w\.\-]+)\n`) reMdRHCoSVersion = regexp.MustCompile(`\* Red Hat Enterprise Linux CoreOS(?: \d+\.\d+)? ((\d+)\.[\w\.\-]+)\n`) - // RHEL 10 node image (rhel-coreos-10); match before generic RHCOS regex (longer prefix first). - reMdRHCoS10Diff = regexp.MustCompile(`\* Red Hat Enterprise Linux CoreOS 10(?: \d+\.\d+)? upgraded from ((\d+)\.[\w\.\-]+) to ((\d+)\.[\w\.\-]+)\n`) - reMdRHCoS10Version = regexp.MustCompile(`\* Red Hat Enterprise Linux CoreOS 10(?: \d+\.\d+)? ((\d+)\.[\w\.\-]+)\n`) - reMdCentOSCoSDiff = regexp.MustCompile(`\* CentOS Stream CoreOS upgraded from ((\d+)\.[\w\.\-]+) to ((\d+)\.[\w\.\-]+)\n`) reMdCentOSCoSVersion = regexp.MustCompile(`\* CentOS Stream CoreOS ((\d+)\.[\w\.\-]+)\n`) @@ -75,48 +70,17 @@ func TransformMarkDownOutput(markdown, fromTag, toTag, architecture, architectur // add link to tag from which current version promoted from markdown = reMdPromotedFrom.ReplaceAllString(markdown, fmt.Sprintf("Release %s was created from [$1:$2](/releasetag/$2)", toTag)) - // Apply CoreOS link transforms for every matching line (OpenShift 4.21+ may list RHCOS 9 and 10 separately). - for { - var m []string - var name string - switch { - case reMdRHCoS10Diff.MatchString(markdown): - m = reMdRHCoS10Diff.FindStringSubmatch(markdown) - name = rhelCoreOs10 - case reMdRHCoSDiff.MatchString(markdown): - m = reMdRHCoSDiff.FindStringSubmatch(markdown) - name = rhelCoreOs - case reMdCentOSCoSDiff.MatchString(markdown): - m = reMdCentOSCoSDiff.FindStringSubmatch(markdown) - name = centosStreamCoreOs - default: - m = nil - } - if m == nil { - break - } - markdown = transformCoreOSUpgradeLinks(name, architecture, architectureExtension, markdown, m) + // TODO: As we get more comfortable with these sorts of transformations, we could make them more generic. + // For now, this will have to do. + if m := reMdRHCoSDiff.FindStringSubmatch(markdown); m != nil { + markdown = transformCoreOSUpgradeLinks(rhelCoreOs, architecture, architectureExtension, markdown, m) + } else if m = reMdCentOSCoSDiff.FindStringSubmatch(markdown); m != nil { + markdown = transformCoreOSUpgradeLinks(centosStreamCoreOs, architecture, architectureExtension, markdown, m) } - for { - var m []string - var name string - switch { - case reMdRHCoS10Version.MatchString(markdown): - m = reMdRHCoS10Version.FindStringSubmatch(markdown) - name = rhelCoreOs10 - case reMdRHCoSVersion.MatchString(markdown): - m = reMdRHCoSVersion.FindStringSubmatch(markdown) - name = rhelCoreOs - case reMdCentOSCoSVersion.MatchString(markdown): - m = reMdCentOSCoSVersion.FindStringSubmatch(markdown) - name = centosStreamCoreOs - default: - m = nil - } - if m == nil { - break - } - markdown = transformCoreOSLinks(name, architecture, architectureExtension, markdown, m) + if m := reMdRHCoSVersion.FindStringSubmatch(markdown); m != nil { + markdown = transformCoreOSLinks(rhelCoreOs, architecture, architectureExtension, markdown, m) + } else if m = reMdCentOSCoSVersion.FindStringSubmatch(markdown); m != nil { + markdown = transformCoreOSLinks(centosStreamCoreOs, architecture, architectureExtension, markdown, m) } return markdown, nil } @@ -130,8 +94,58 @@ func TransformJsonOutput(output, architecture, architectureExtension string) (st for i, component := range changeLogJson.Components { switch component.Name { - case rhelCoreOs, rhelCoreOs10, centosStreamCoreOs: - changeLogJson.Components[i] = enrichCoreOSComponentJSON(component, architecture, architectureExtension) + case rhelCoreOs, centosStreamCoreOs: + var ok bool + var fromStream, toStream string + if len(component.Version) == 0 { + continue + } + if toStream, ok = getRHCoSReleaseStream(component.Version, architectureExtension); ok { + toURL := url.URL{ + Scheme: serviceScheme, + Host: serviceUrl, + Path: "/", + Fragment: component.Version, + RawQuery: (url.Values{ + "stream": []string{toStream}, + "arch": []string{architecture}, + "release": []string{component.Version}, + }).Encode(), + } + component.VersionUrl = toURL.String() + } + + if len(component.From) > 0 { + if fromStream, ok = getRHCoSReleaseStream(component.From, architectureExtension); ok { + fromUrl := url.URL{ + Scheme: serviceScheme, + Host: serviceUrl, + Path: "/", + Fragment: component.From, + RawQuery: (url.Values{ + "stream": []string{fromStream}, + "arch": []string{architecture}, + "release": []string{component.From}, + }).Encode(), + } + component.FromUrl = fromUrl.String() + + diffURL := url.URL{ + Scheme: serviceScheme, + Host: serviceUrl, + Path: "/diff.html", + RawQuery: (url.Values{ + "first_stream": []string{fromStream}, + "first_release": []string{component.From}, + "second_stream": []string{toStream}, + "second_release": []string{component.Version}, + "arch": []string{architecture}, + }).Encode(), + } + component.DiffUrl = diffURL.String() + } + } + changeLogJson.Components[i] = component } } @@ -143,60 +157,6 @@ func TransformJsonOutput(output, architecture, architectureExtension string) (st return string(updated), nil } -func enrichCoreOSComponentJSON(component releasecontroller.ChangeLogComponentInfo, architecture, architectureExtension string) releasecontroller.ChangeLogComponentInfo { - var ok bool - var fromStream, toStream string - if len(component.Version) == 0 { - return component - } - if toStream, ok = getRHCoSReleaseStream(component.Version, architectureExtension); ok { - toURL := url.URL{ - Scheme: serviceScheme, - Host: serviceUrl, - Path: "/", - Fragment: component.Version, - RawQuery: (url.Values{ - "stream": []string{toStream}, - "arch": []string{architecture}, - "release": []string{component.Version}, - }).Encode(), - } - component.VersionUrl = toURL.String() - } - - if len(component.From) > 0 { - if fromStream, ok = getRHCoSReleaseStream(component.From, architectureExtension); ok { - fromUrl := url.URL{ - Scheme: serviceScheme, - Host: serviceUrl, - Path: "/", - Fragment: component.From, - RawQuery: (url.Values{ - "stream": []string{fromStream}, - "arch": []string{architecture}, - "release": []string{component.From}, - }).Encode(), - } - component.FromUrl = fromUrl.String() - - diffURL := url.URL{ - Scheme: serviceScheme, - Host: serviceUrl, - Path: "/diff.html", - RawQuery: (url.Values{ - "first_stream": []string{fromStream}, - "first_release": []string{component.From}, - "second_stream": []string{toStream}, - "second_release": []string{component.Version}, - "arch": []string{architecture}, - }).Encode(), - } - component.DiffUrl = diffURL.String() - } - } - return component -} - func getRHCoSReleaseStream(version, architectureExtension string) (string, bool) { if strings.HasPrefix(version, "4") { if m := reOcpCoreOsVersion.FindStringSubmatch(version); m != nil { @@ -348,107 +308,68 @@ func transformCoreOSLinks(name, architecture, architectureExtension, input strin return strings.ReplaceAll(input, matches[0], replace) } -// CoreOSNodeStream holds RPM package lists and diffs for one rhel-coreos* or stream-coreos image. -type CoreOSNodeStream struct { - Title string - RpmList releasecontroller.RpmList - RpmDiff releasecontroller.RpmDiff -} - func RenderNodeImageInfo(markdown string, rpmList releasecontroller.RpmList, rpmDiff releasecontroller.RpmDiff) string { - return RenderDualNodeImageInfo(markdown, []CoreOSNodeStream{{RpmList: rpmList, RpmDiff: rpmDiff}}) -} + output := new(strings.Builder) -// RenderDualNodeImageInfo renders one or more Node Image Info sections (e.g. multiple machine-OS -// streams in OpenShift 4.21+). -func RenderDualNodeImageInfo(markdown string, streams []CoreOSNodeStream) string { - if len(streams) == 0 { - return "" - } - var out strings.Builder - dual := len(streams) > 1 - for i, s := range streams { - if i > 0 { - out.WriteString("\n\n") - } - renderOneNodeStream(&out, s, dual) - } - out.WriteString(baseLayerFooter(markdown)) - return out.String() -} - -func renderOneNodeStream(out *strings.Builder, stream CoreOSNodeStream, dual bool) { - h := "###" - if stream.Title != "" { - fmt.Fprintf(out, "### %s\n\n", stream.Title) - h = "####" - } else if dual { - h = "####" - } - - fmt.Fprintf(out, "%s Package List\n\n", h) + fmt.Fprintf(output, "### Package List\n\n") importantPkgs := []string{"cri-o", "kernel", "openshift-kubelet", "systemd"} for _, pkg := range importantPkgs { - fmt.Fprintf(out, "* %s-%s\n", pkg, stream.RpmList.Packages[pkg]) + fmt.Fprintf(output, "* %s-%s\n", pkg, rpmList.Packages[pkg]) } - fmt.Fprintf(out, "\n

Full list (%d packages)\n\n", len(stream.RpmList.Packages)) - sortedPkgs := slices.Sorted(maps.Keys(stream.RpmList.Packages)) + fmt.Fprintf(output, "\n
Full list (%d packages)\n\n", len(rpmList.Packages)) + sortedPkgs := slices.Sorted(maps.Keys(rpmList.Packages)) for _, pkg := range sortedPkgs { - fmt.Fprintf(out, "* %s-%s\n", pkg, stream.RpmList.Packages[pkg]) + fmt.Fprintf(output, "* %s-%s\n", pkg, rpmList.Packages[pkg]) } - fmt.Fprintf(out, "
\n\n") + fmt.Fprintf(output, "
\n\n") writeList := func(header string, elements []string) { - fmt.Fprintf(out, "%s %s:\n\n", h, header) + fmt.Fprintf(output, "### %s:\n\n", header) sort.Strings(elements) for _, elem := range elements { - fmt.Fprintf(out, "* %s\n", elem) + fmt.Fprintf(output, "* %s\n", elem) } - fmt.Fprint(out, "\n") + fmt.Fprint(output, "\n") } - if len(stream.RpmDiff.Changed) > 0 { + if len(rpmDiff.Changed) > 0 { elements := []string{} - for pkg, v := range stream.RpmDiff.Changed { + for pkg, v := range rpmDiff.Changed { elements = append(elements, fmt.Sprintf("%s %s → %s", pkg, v.Old, v.New)) } writeList("Changed", elements) } - if len(stream.RpmDiff.Removed) > 0 { + if len(rpmDiff.Removed) > 0 { elements := []string{} - for pkg, v := range stream.RpmDiff.Removed { + for pkg, v := range rpmDiff.Removed { elements = append(elements, fmt.Sprintf("%s %s", pkg, v)) } writeList("Removed", elements) } - if len(stream.RpmDiff.Added) > 0 { + if len(rpmDiff.Added) > 0 { elements := []string{} - for pkg, v := range stream.RpmDiff.Added { + for pkg, v := range rpmDiff.Added { elements = append(elements, fmt.Sprintf("%s %s", pkg, v)) } writeList("Added", elements) } - fmt.Fprintf(out, "\n\n%s Extensions\n\n", h) + fmt.Fprintf(output, "\n\n### Extensions\n\n") - fmt.Fprintf(out, "\n
Full list (%d packages)\n\n", len(stream.RpmList.Extensions)) - sortedPkgs = slices.Sorted(maps.Keys(stream.RpmList.Extensions)) + fmt.Fprintf(output, "\n
Full list (%d packages)\n\n", len(rpmList.Extensions)) + sortedPkgs = slices.Sorted(maps.Keys(rpmList.Extensions)) for _, pkg := range sortedPkgs { - fmt.Fprintf(out, "* %s-%s\n", pkg, stream.RpmList.Extensions[pkg]) + fmt.Fprintf(output, "* %s-%s\n", pkg, rpmList.Extensions[pkg]) } - fmt.Fprintf(out, "
\n\n") -} + fmt.Fprintf(output, "
\n\n") -func baseLayerFooter(markdown string) string { - matches := reMdCoSPost.FindAllStringSubmatch(markdown, -1) - if len(matches) == 0 { - return "" + // Reprint the version/diff line, with the browser links for the build itself. + // But put it last to de-emphasize it. Most people don't need to click on this. + if m := reMdCoSPost.FindStringSubmatch(markdown); m != nil { + fmt.Fprintf(output, "
%s **base layer**: %s\n\n", m[1], m[3]) } - var b strings.Builder - for _, m := range matches { - fmt.Fprintf(&b, "
%s **base layer**: %s\n\n", m[1], m[3]) - } - return b.String() + + return output.String() } diff --git a/pkg/rhcos/rhcos_test.go b/pkg/rhcos/rhcos_test.go index 9547c1ef6..e3296ff4e 100644 --- a/pkg/rhcos/rhcos_test.go +++ b/pkg/rhcos/rhcos_test.go @@ -1,7 +1,6 @@ package rhcos import ( - "strings" "testing" "github.com/google/go-cmp/cmp" @@ -219,47 +218,3 @@ func TestRHCoSVersionRegex(t *testing.T) { }) } } - -func TestRHCoS10DiffRegex(t *testing.T) { - input := "* Red Hat Enterprise Linux CoreOS 10 10.0 upgraded from 10.0.20260101-0 to 10.0.20260201-0\n" - m := reMdRHCoS10Diff.FindStringSubmatch(input) - if m == nil { - t.Fatal("expected match for RHEL 10 upgrade line") - } - if m[1] != "10.0.20260101-0" || m[3] != "10.0.20260201-0" { - t.Fatalf("unexpected submatches: %v", m) - } -} - -func TestTransformMarkDownOutputDualRHCOSLines(t *testing.T) { - input := `## Changes from 4.20.0 -* Red Hat Enterprise Linux CoreOS 9.8 upgraded from 9.8.20260101-0 to 9.8.20260201-0 -* Red Hat Enterprise Linux CoreOS 10 10.0 upgraded from 10.0.20260101-0 to 10.0.20260201-0 -` - out, err := TransformMarkDownOutput(input, "4.20.0", "4.21.0", "x86_64", "") - if err != nil { - t.Fatal(err) - } - if strings.Count(out, "coreos-base-alert") < 2 { - t.Fatalf("expected two CoreOS base layer infoboxes, got:\n%s", out) - } -} - -func TestTransformJsonOutputDualCoreOS(t *testing.T) { - j := `{ - "components": [ - {"name": "Red Hat Enterprise Linux CoreOS", "version": "9.8.20260201-0", "from": "9.8.20260101-0"}, - {"name": "Red Hat Enterprise Linux CoreOS 10", "version": "10.0.20260201-0", "from": "10.0.20260101-0"} - ] -}` - out, err := TransformJsonOutput(j, "x86_64", "") - if err != nil { - t.Fatal(err) - } - if !strings.Contains(out, `"versionUrl"`) { - t.Fatalf("expected versionUrl in output: %s", out) - } - if strings.Count(out, `"versionUrl"`) < 2 { - t.Fatalf("expected two versionUrl fields: %s", out) - } -}