diff --git a/.github/workflows/nightly-build-slack-bot.yml b/.github/workflows/nightly-build-slack-bot.yml new file mode 100644 index 000000000..edc3d6a6a --- /dev/null +++ b/.github/workflows/nightly-build-slack-bot.yml @@ -0,0 +1,34 @@ +name: Nightly-Build slack bot + +on: + schedule: + - cron: '30 5 * * *' + workflow_dispatch: + +defaults: + run: + working-directory: ./automation/hco-nightly-reporter + +jobs: + build-and-run-slack-bot: + name: Nightly-build slack bot + if: github.repository == 'kubevirt/hyperconverged-cluster-operator' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: ./automation/hco-nightly-reporter/go.mod + + - name: Build + run: go build -v . + + - name: run + env: + HCO_CHANNEL_ID: ${{ secrets.HCO_SLACK_CHANNEL_ID }} + HCO_GROUP_ID: ${{ secrets.HCO_SLACK_GROUP_ID }} + HCO_REPORTER_SLACK_TOKEN: ${{ secrets.HCO_REPORTER_SLACK_TOKEN }} + run: + ./hco-nightly-reporter diff --git a/automation/hco-nightly-reporter/go.mod b/automation/hco-nightly-reporter/go.mod new file mode 100644 index 000000000..21cabc5a2 --- /dev/null +++ b/automation/hco-nightly-reporter/go.mod @@ -0,0 +1,12 @@ +module github.com/hyperconverged-cluster-operator/automation/hco-nightly-reporter + +go 1.23 + +require github.com/slack-go/slack v0.15.0 + +require ( + github.com/go-test/deep v1.1.1 // indirect + github.com/google/go-cmp v0.6.0 // indirect + github.com/gorilla/websocket v1.5.3 // indirect + github.com/stretchr/testify v1.10.0 // indirect +) diff --git a/automation/hco-nightly-reporter/go.sum b/automation/hco-nightly-reporter/go.sum new file mode 100644 index 000000000..f8da2aa49 --- /dev/null +++ b/automation/hco-nightly-reporter/go.sum @@ -0,0 +1,21 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-test/deep v1.0.4/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= +github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= +github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/slack-go/slack v0.15.0 h1:LE2lj2y9vqqiOf+qIIy0GvEoxgF1N5yLGZffmEZykt0= +github.com/slack-go/slack v0.15.0/go.mod h1:hlGi5oXA+Gt+yWTPP0plCdRKmjsDxecdHxYQdlMQKOw= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/automation/hco-nightly-reporter/main.go b/automation/hco-nightly-reporter/main.go new file mode 100644 index 000000000..17341d641 --- /dev/null +++ b/automation/hco-nightly-reporter/main.go @@ -0,0 +1,268 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "time" + + "github.com/slack-go/slack" +) + +const ( + basicPrawURL = "https://storage.googleapis.com/kubevirt-prow/logs/periodic-hco-push-nightly-build-main" + latestBuildURL = basicPrawURL + "/latest-build.txt" + finishedURLTemplate = basicPrawURL + "/%s/finished.json" + jobURLTemplate = basicPrawURL + "/%s/prowjob.json" + + timeFormat = "2006-01-02, 15:04:05" +) + +type finished struct { + Timestamp int64 `json:"timestamp"` + Passed bool `json:"passed"` + Result string `json:"result"` + Revision string `json:"revision"` +} + +func (f finished) getBuildTime() time.Time { + return time.Unix(f.Timestamp, 0).UTC() +} + +var ( + token string + channelId string + groupId string +) + +func init() { + var ok bool + token, ok = os.LookupEnv("HCO_REPORTER_SLACK_TOKEN") + if !ok { + fmt.Fprintln(os.Stderr, "HCO_REPORTER_SLACK_TOKEN environment variable not set") + os.Exit(1) + } + + channelId, ok = os.LookupEnv("HCO_CHANNEL_ID") + if !ok { + fmt.Fprintln(os.Stderr, "HCO_CHANNEL_ID environment variable not set") + os.Exit(1) + } + + groupId, ok = os.LookupEnv("HCO_GROUP_ID") + if !ok { + fmt.Fprintln(os.Stderr, "HCO_GROUP_ID environment variable not set") + os.Exit(1) + } +} + +func main() { + ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) + defer cancel() + + blocks, jobURL, err := generateMessage(ctx) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + err = sendMessageToSlackChannel(blocks) + + if err != nil { + writeSendError(err, jobURL) + os.Exit(1) + } + + fmt.Println("Successfully sent message to the channel") +} + +func writeSendError(err error, jobURL string) { + fmt.Fprintln(os.Stderr, "failed to send the message to the channel; ", err.Error()) + if serr, ok := err.(slack.SlackErrorResponse); ok { + for _, msg := range serr.ResponseMetadata.Messages { + fmt.Fprintln(os.Stderr, msg) + } + } + + if len(jobURL) > 0 { + fmt.Fprintln(os.Stderr, "job URL: ", jobURL) + } +} + +func generateMessage(ctx context.Context) ([]slack.Block, string, error) { + client := http.DefaultClient + client.Timeout = time.Second * 3 + + latestBuild, err := getLatestBuild(ctx, client) + if err != nil { + return nil, "", fmt.Errorf("failed to latest job ID; %s", err.Error()) + } + + buildStatus, err := getBuildStatus(ctx, latestBuild) + if err != nil { + return nil, "", fmt.Errorf("failed to fetch the build status; %s", err.Error()) + } + + buildTime := time.Unix(buildStatus.Timestamp, 0).UTC() + if time.Since(buildTime).Hours() > 24 { + return generateNoBuildMessage(buildTime), "", nil + } + + jobURL, err := getJob(ctx, latestBuild) + if err != nil { + return nil, "", fmt.Errorf("failed to fetch the job info; %s", err.Error()) + } + + return generateStatusMessage(buildStatus, jobURL), jobURL, nil +} + +func sendMessageToSlackChannel(blocks []slack.Block) error { + s := slack.New(token) + _, _, err := s.PostMessage(channelId, slack.MsgOptionBlocks(blocks...)) + return err +} + +func generateMsgHeader() slack.Block { + return slack.NewHeaderBlock( + slack.NewTextBlockObject( + "plain_text", "Nightly Build Status", false, false, + ), + ) +} + +func generateMentionBlock(blockId string) slack.Block { + return slack.NewRichTextBlock(blockId, slack.NewRichTextSection( + slack.NewRichTextSectionUserGroupElement(groupId), + )) +} + +func generateNoBuildMessage(buildTime time.Time) []slack.Block { + return []slack.Block{ + generateMsgHeader(), + slack.NewDividerBlock(), + slack.NewRichTextBlock("1", slack.NewRichTextSection( + slack.NewRichTextSectionEmojiElement("failed", 3, nil), + )), + slack.NewRichTextBlock("2", slack.NewRichTextSection( + slack.NewRichTextSectionTextElement( + "No new build today", nil, + ), + )), + slack.NewRichTextBlock("3", slack.NewRichTextSection( + slack.NewRichTextSectionTextElement( + fmt.Sprintf("Last build was at %v", buildTime.Format(timeFormat)), + nil, + ), + )), + generateMentionBlock("4"), + slack.NewDividerBlock(), + } +} + +func generateStatusMessage(buildStatus *finished, jobURL string) []slack.Block { + var status, emoji string + if buildStatus.Passed { + status = "passed" + emoji = "solid-success" + } else { + status = "failed" + emoji = "failed" + } + + ts := buildStatus.getBuildTime().Format(timeFormat) + + blocks := []slack.Block{ + generateMsgHeader(), + slack.NewDividerBlock(), + slack.NewRichTextBlock("1", slack.NewRichTextSection( + slack.NewRichTextSectionEmojiElement(emoji, 3, nil), + )), + slack.NewRichTextBlock("2", slack.NewRichTextSection( + slack.NewRichTextSectionTextElement( + "Status: ", nil, + ), + slack.NewRichTextSectionLinkElement(jobURL, status, &slack.RichTextSectionTextStyle{Bold: true}), + )), + slack.NewRichTextBlock("3", slack.NewRichTextSection( + slack.NewRichTextSectionTextElement( + "Build time: "+ts+" UTC", nil, + ), + )), + } + + if !buildStatus.Passed { + blocks = append(blocks, slack.NewDividerBlock()) + blocks = append(blocks, generateMentionBlock("4")) + } + return blocks +} + +func getLatestBuild(ctx context.Context, client *http.Client) (string, error) { + req, err := http.NewRequest(http.MethodGet, latestBuildURL, nil) + if err != nil { + return "", err + } + + resp, err := client.Do(req.WithContext(ctx)) + if err != nil { + return "", err + } + + defer resp.Body.Close() + + latestBuildBytes, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + return string(latestBuildBytes), nil +} + +func getBuildStatus(ctx context.Context, latestBuild string) (*finished, error) { + req, err := http.NewRequest(http.MethodGet, fmt.Sprintf(finishedURLTemplate, latestBuild), nil) + if err != nil { + return nil, err + } + + finishedResp, err := http.DefaultClient.Do(req.WithContext(ctx)) + if err != nil { + return nil, err + } + + defer finishedResp.Body.Close() + + f := &finished{} + dec := json.NewDecoder(finishedResp.Body) + if err = dec.Decode(&f); err != nil { + return nil, err + } + return f, nil +} + +func getJob(ctx context.Context, latestBuild string) (string, error) { + req, err := http.NewRequest(http.MethodGet, fmt.Sprintf(jobURLTemplate, latestBuild), nil) + if err != nil { + return "", err + } + + jobResp, err := http.DefaultClient.Do(req.WithContext(ctx)) + if err != nil { + return "", err + } + + defer jobResp.Body.Close() + + job := struct { + Status struct { + URL string `json:"url,omitempty"` + } `json:"status"` + }{} + dec := json.NewDecoder(jobResp.Body) + err = dec.Decode(&job) + if err != nil { + return "", err + } + return job.Status.URL, nil +}