diff --git a/cmd/release/cmd/generate.go b/cmd/release/cmd/generate.go index 3a55f306..3ea2724a 100644 --- a/cmd/release/cmd/generate.go +++ b/cmd/release/cmd/generate.go @@ -22,23 +22,28 @@ var ( k3sPrevMilestone string k3sMilestone string - concurrencyLimit int - imagesListURL string - ignoreImages []string - checkImages []string - registry string - rancherMissingImagesJSONOutput bool - rke2PrevMilestone string - rke2Milestone string - rancherArtifactsIndexWriteToPath string - rancherArtifactsIndexIgnoreVersions []string - rancherImagesDigestsOutputFile string - rancherImagesDigestsRegistry string - rancherImagesDigestsImagesURL string - rancherSyncImages []string - rancherSourceRegistry string - rancherTargetRegistry string - rancherSyncConfigOutputPath string + concurrencyLimit int + imagesListURL string + ignoreImages []string + checkImages []string + registry string + rancherMissingImagesJSONOutput bool + rke2PrevMilestone string + rke2Milestone string + rancherArtifactsIndexWriteToPath string + rancherArtifactsIndexIgnoreVersions []string + rancherImagesDigestsOutputFile string + rancherImagesDigestsRegistry string + rancherImagesDigestsImagesURL string + rancherSyncImages []string + rancherSourceRegistry string + rancherTargetRegistry string + rancherSyncConfigOutputPath string + rancherReleaseAnnouncementTag string + rancherReleaseAnnouncementPreviousTag string + rancherReleaseAnnouncementActionRunID string + rancherReleaseAnnouncementPrimeOnly bool + rancherReleaseAnnouncementFinalRC bool ) // generateCmd represents the generate command @@ -172,6 +177,34 @@ var rancherGenerateImagesSyncConfigSubCmd = &cobra.Command{ }, } +var rancherGenerateReleaseMessageSubCmd = &cobra.Command{ + Use: "release-message", + Short: "Generate the release announcement message", + RunE: func(cmd *cobra.Command, args []string) error { + versionKey := rancherReleaseAnnouncementTag + + // strip the pre release suffix (v2.9.2-alpha1 -> v2.9.2) + if strings.ContainsRune(rancherReleaseAnnouncementTag, '-') { + versionKey = strings.Split(rancherReleaseAnnouncementTag, "-")[0] + } + + rancherRelease, found := rootConfig.Rancher.Versions[versionKey] + if !found { + return errors.New("verify your config file, version not found: " + versionKey) + } + + ctx := context.Background() + client := repository.NewGithub(ctx, rootConfig.Auth.GithubToken) + + message, err := rancher.GenerateAnnounceReleaseMessage(ctx, client, rancherReleaseAnnouncementTag, rancherReleaseAnnouncementPreviousTag, rancherRelease.RancherRepoOwner, rancherReleaseAnnouncementActionRunID, rancherReleaseAnnouncementPrimeOnly, rancherReleaseAnnouncementFinalRC) + if err != nil { + return err + } + fmt.Println(message) + return nil + }, +} + func init() { rootCmd.AddCommand(generateCmd) @@ -182,6 +215,7 @@ func init() { rancherGenerateSubCmd.AddCommand(rancherGenerateMissingImagesListSubCmd) rancherGenerateSubCmd.AddCommand(rancherGenerateDockerImagesDigestsSubCmd) rancherGenerateSubCmd.AddCommand(rancherGenerateImagesSyncConfigSubCmd) + rancherGenerateSubCmd.AddCommand(rancherGenerateReleaseMessageSubCmd) generateCmd.AddCommand(k3sGenerateSubCmd) generateCmd.AddCommand(rke2GenerateSubCmd) @@ -260,4 +294,23 @@ func init() { fmt.Println(err.Error()) os.Exit(1) } + + // rancher generate release-message + rancherGenerateReleaseMessageSubCmd.Flags().StringVarP(&rancherReleaseAnnouncementTag, "tag", "t", "", "Tag that will be announced") + rancherGenerateReleaseMessageSubCmd.Flags().StringVarP(&rancherReleaseAnnouncementPreviousTag, "previous-tag", "p", "", "Last tag before the current one") + rancherGenerateReleaseMessageSubCmd.Flags().StringVarP(&rancherReleaseAnnouncementActionRunID, "action-run-id", "a", "", "Run ID for the latest push-release.yml action") + rancherGenerateReleaseMessageSubCmd.Flags().BoolVarP(&rancherReleaseAnnouncementPrimeOnly, "prime-only", "o", false, "Version is prime-only and the artifacts are at prime.ribs.rancher.io") + rancherGenerateReleaseMessageSubCmd.Flags().BoolVarP(&rancherReleaseAnnouncementFinalRC, "final-rc", "f", false, "Version is the final RC, the announce message won't contain images or components with RC") + if err := rancherGenerateReleaseMessageSubCmd.MarkFlagRequired("tag"); err != nil { + fmt.Println(err.Error()) + os.Exit(1) + } + if err := rancherGenerateReleaseMessageSubCmd.MarkFlagRequired("previous-tag"); err != nil { + fmt.Println(err.Error()) + os.Exit(1) + } + if err := rancherGenerateReleaseMessageSubCmd.MarkFlagRequired("action-run-id"); err != nil { + fmt.Println(err.Error()) + os.Exit(1) + } } diff --git a/release/rancher/rancher.go b/release/rancher/rancher.go index b4119657..915404d0 100644 --- a/release/rancher/rancher.go +++ b/release/rancher/rancher.go @@ -153,6 +153,18 @@ type regsyncSync struct { Tags regsyncTags `yaml:"tags"` } +type releaseAnnnouncement struct { + Tag string + PreviousTag string + RancherRepoOwner string + CommitSHA string + ActionRunID string + ImagesWithRC []string + ComponentsWithRC []string + UIVersion string + CLIVersion string +} + func listS3Objects(ctx context.Context, s3Client *s3.Client, bucketName string, prefix string) ([]string, error) { var keys []string var continuationToken *string @@ -446,7 +458,7 @@ func GenerateMissingImagesList(imagesListURL, registry string, concurrencyLimit if imagesListURL == "" { return nil, errors.New("if no images are provided, an images list URL must be provided") } - rancherImages, err := rancherPrimeArtifact(imagesListURL) + rancherImages, err := remoteTextFileToSlice(imagesListURL) if err != nil { return nil, errors.New("failed to get rancher images: " + err.Error()) } @@ -650,6 +662,135 @@ func GenerateDockerImageDigests(outputFile, imagesFileURL, registry string, verb return createAssetFile(outputFile, imagesDigests) } +func GenerateAnnounceReleaseMessage(ctx context.Context, ghClient *github.Client, tag, previousTag, rancherRepoOwner, actionRunID string, primeOnly, finalRC bool) (string, error) { + ref, _, err := ghClient.Git.GetRef(ctx, rancherRepoOwner, rancherRepo, "refs/tags/"+tag) + if err != nil { + return "", err + } + if ref.Object.SHA == nil { + return "", errors.New("release commit sha is nil") + } + + commitSHA := ref.Object.GetSHA() + + r := releaseAnnnouncement{ + Tag: tag, + PreviousTag: previousTag, + RancherRepoOwner: rancherRepoOwner, + CommitSHA: commitSHA, + ActionRunID: actionRunID, + } + + announceTemplate := announceReleaseFinalRCTemplate + + if finalRC { + dockerfileURL := "https://raw.githubusercontent.com/" + rancherRepoOwner + "/rancher/" + commitSHA + "/package/Dockerfile" + dockerfile, err := remoteTextFileToSlice(dockerfileURL) + if err != nil { + return "", err + } + uiVersion, cliVersion, err := rancherUICLIVersions(dockerfile) + if err != nil { + return "", err + } + r.UIVersion = uiVersion + r.CLIVersion = cliVersion + } else { // every alpha and rc before the final RC + announceTemplate = announceReleasePreReleaseTemplate + + componentsURL := "https://github.com/" + rancherRepoOwner + "/rancher/releases/download/" + tag + "/rancher-components.txt" + if primeOnly { + componentsURL = rancherArtifactsBaseURL + "/rancher/" + tag + "/rancher-components.txt" + } + rancherComponents, err := remoteTextFileToSlice(componentsURL) + if err != nil { + return "", err + } + + imagesWithRC, componentsWithRC, err := rancherImagesComponentsWithRC(rancherComponents) + if err != nil { + return "", err + } + r.ImagesWithRC = imagesWithRC + r.ComponentsWithRC = componentsWithRC + } + + tmpl := template.New("announce-release") + tmpl, err = tmpl.Parse(announceTemplate) + if err != nil { + return "", errors.New("failed to parse announce template: " + err.Error()) + } + buff := bytes.NewBuffer(nil) + if err := tmpl.ExecuteTemplate(buff, "announceRelease", r); err != nil { + return "", err + } + return buff.String(), nil +} + +// rancherUICLIVersions scans a dockerfile line by line and returns the ui and cli versions, or an error if any of them are not found +func rancherUICLIVersions(dockerfile []string) (string, string, error) { + var uiVersion string + var cliVersion string + for _, line := range dockerfile { + if strings.Contains(line, "ENV CATTLE_UI_VERSION ") { + uiVersion = strings.TrimPrefix(line, "ENV CATTLE_UI_VERSION ") + continue + } + if strings.Contains(line, "ENV CATTLE_CLI_VERSION ") { + cliVersion = strings.TrimPrefix(line, "ENV CATTLE_CLI_VERSION ") + continue + } + if len(uiVersion) > 0 && len(cliVersion) > 0 { + break + } + } + if uiVersion == "" || cliVersion == "" { + return "", "", errors.New("missing ui or cli version") + } + return uiVersion, cliVersion, nil +} + +// rancherImagesComponentsWithRC scans the rancher-components.txt file content and returns images and components, or an error +func rancherImagesComponentsWithRC(rancherComponents []string) ([]string, []string, error) { + if len(rancherComponents) < 2 { + return nil, nil, errors.New("rancher-components.txt should have at least two lines (images and components headers)") + } + images := make([]string, 0) + components := make([]string, 0) + + var isImage bool + for _, line := range rancherComponents { + // always skip empty lines + if line == "" || line == " " { + continue + } + + // if a line contains # it is a header for a section + isHeader := strings.Contains(line, "#") + + if isHeader { + imagesHeader := strings.Contains(line, "Images") + componentsHeader := strings.Contains(line, "Components") + // if it's a header, but not for images or components, ignore it and everything else after it + if !imagesHeader && !componentsHeader { + break + } + // isImage's value will persist between iterations + // if imagesHeader is true, it means that all following lines are images + // if it's false, it means that all following images are components + isImage = imagesHeader + continue + } + + if isImage { + images = append(images, line) + } else { + components = append(components, line) + } + } + return images, components, nil +} + func dockerImagesDigests(imagesFileURL, registry string) (imageDigest, error) { imagesList, err := artifactImageList(imagesFileURL, registry) if err != nil { @@ -825,7 +966,7 @@ func registryAuth(authURL, service, image string) (string, error) { defer res.Body.Close() if res.StatusCode != http.StatusOK { - return "", errors.New("expected status code to be 200, got: " + strconv.Itoa(res.StatusCode)) + return "", errors.New("expected status code to be 200, got: " + res.Status) } var auth registryAuthToken @@ -836,13 +977,17 @@ func registryAuth(authURL, service, image string) (string, error) { return auth.Token, nil } -func rancherPrimeArtifact(url string) ([]string, error) { +func remoteTextFileToSlice(url string) ([]string, error) { httpClient := ecmHTTP.NewClient(time.Second * 15) res, err := httpClient.Get(url) if err != nil { return nil, err } - defer res.Body.Close() + defer res.Body.Close() + + if res.StatusCode != 200 { + return nil, errors.New("expected status code to be 200, got: " + res.Status) + } var file []string scanner := bufio.NewScanner(res.Body) @@ -958,3 +1103,19 @@ const checkRancherRCDepsTemplate = `{{- define "componentsFile" -}} * {{ .Content }} ({{ .File }}, line {{ .Line }}) {{- end}} {{ end }}` + +const announceReleaseHeaderTemplate = "`{{ .Tag }}` is available based on this commit ([link](https://github.com/{{ .RancherRepoOwner }}/rancher/commit/{{ .CommitSHA }}))!\n" + + "* Link of commits between last 2 RCs. ([link](https://github.com/{{ .RancherRepoOwner }}/rancher/compare/{{ .PreviousTag }}...{{ .Tag }}))\n" + + "* Completed GHA build ([link](https://github.com/{{ .RancherRepoOwner }}/rancher/actions/runs/{{ .ActionRunID }})).\n" + +const announceReleasePreReleaseTemplate = `{{ define "announceRelease" }}` + announceReleaseHeaderTemplate + + "* Images with -rc:\n" + + "{{ range .ImagesWithRC }}" + + " * {{ . }}\n{{ end }}" + + "* Components with -rc:\n" + + "{{ range .ComponentsWithRC }}" + + " * {{ . }}\n{{ end }}{{ end }}" + +const announceReleaseFinalRCTemplate = `{{ define "announceRelease" }}` + announceReleaseHeaderTemplate + + "* UI Version: `{{ .UIVersion }}`\n" + + "* CLI Version: `{{ .CLIVersion }}`{{ end }}" diff --git a/release/rancher/rancher_test.go b/release/rancher/rancher_test.go index 58965300..a2be1f84 100644 --- a/release/rancher/rancher_test.go +++ b/release/rancher/rancher_test.go @@ -1,6 +1,8 @@ package rancher -import "testing" +import ( + "testing" +) const ( rancherRepoImage = "rancher/rancher" @@ -86,3 +88,65 @@ func TestGenerateRegsyncConfig(t *testing.T) { t.Error("rancher agent image should be: '" + sourceRancherAgentImage + "' instead, got: '" + config.Sync[1].Source + "'") } } + +func TestRancherUICLIVersions(t *testing.T) { + ui := "2.9.2-alpha3" + cli := "v2.9.0" + dockerfile := []string{ + "empty line", + "ENV CATTLE_UI_VERSION " + ui, + "ENV CATTLE_DASHBOARD_UI_VERSION v2.9.2-alpha3", + "ENV CATTLE_CLI_VERSION " + cli, + "", + "another empty line", + } + uiVersion, cliVersion, err := rancherUICLIVersions(dockerfile) + if err != nil { + t.Error(err) + } + if uiVersion != ui { + t.Error("wrong ui version, expected '" + ui + "', instead, got: " + uiVersion) + } + if cliVersion != cli { + t.Error("wrong cli version, expected '" + cli + "', instead, got: " + cliVersion) + } +} + +func TestRancherImagesComponentsWithRC(t *testing.T) { + cisOperatorImage := "rancher/cis-operator v1.0.15-rc.2" + fleetImage := "rancher/fleet v0.9.9-rc.1" + systemAgentComponent := "SYSTEM_AGENT_VERSION v0.3.9-rc.4" + winsAgentComponent := "WINS_AGENT_VERSION v0.4.18-rc1" + + rancherComponents := []string{ + "# Images with -rc", + cisOperatorImage, + fleetImage, + "# Components with -rc", + systemAgentComponent, + winsAgentComponent, + "", + "# Min version components with -rc", + "", + "# Chart/KDM sources", + "* SYSTEM_CHART_DEFAULT_BRANCH: dev-v2.8 (`scripts/package-env`)", + } + + images, components, err := rancherImagesComponentsWithRC(rancherComponents) + if err != nil { + t.Error(err) + } + + if images[0] != cisOperatorImage { + t.Error("image mismatch, expected '" + cisOperatorImage + "', instead, got: " + images[0]) + } + if images[1] != fleetImage { + t.Error("image mismatch, expected '" + fleetImage + "', instead, got: " + images[1]) + } + if components[0] != systemAgentComponent { + t.Error("image mismatch, expected '" + systemAgentComponent + "', instead, got: " + components[0]) + } + if components[1] != winsAgentComponent { + t.Error("image mismatch, expected '" + winsAgentComponent + "', instead, got: " + components[1]) + } +}