diff --git a/.github/workflows/builder.yaml b/.github/workflows/builder.yaml index ab97930..b253931 100644 --- a/.github/workflows/builder.yaml +++ b/.github/workflows/builder.yaml @@ -48,6 +48,9 @@ jobs: - name: Build and push slack-notify run: docker build -f internal/slack-notify/Dockerfile . -t ${REGISTRY}/${{ github.repository }}/slack-notify:${SHORT_GITHUB_SHA} && docker push ${REGISTRY}/${{ github.repository }}/slack-notify:${SHORT_GITHUB_SHA} + - name: Build and push slack-order + run: docker build -f internal/slack-order/Dockerfile . -t ${REGISTRY}/${{ github.repository }}/slack-order:${SHORT_GITHUB_SHA} && docker push ${REGISTRY}/${{ github.repository }}/slack-order:${SHORT_GITHUB_SHA} + - name: Build function image run: docker build . -t ${REGISTRY}/${{ github.repository }}:${SHORT_GITHUB_SHA} diff --git a/example/composition.yaml b/example/composition.yaml index 67b828e..38a0155 100644 --- a/example/composition.yaml +++ b/example/composition.yaml @@ -17,5 +17,6 @@ spec: providerConfigRef: "kndp-kubernetes-provider-config" deploymentImage: "ghcr.io/kndpio/function-poll/slack-collector:d7a4b" cronJobImage: "ghcr.io/kndpio/function-poll/slack-notify:d7a4b" + jobImage: "ghcr.io/kndpio/function-poll/slack-order:d7a4b" deploymentName: "slack-collector" serviceAccountName: "slack-collector" \ No newline at end of file diff --git a/example/xr.yaml b/example/xr.yaml index 123fcd9..18b9c61 100644 --- a/example/xr.yaml +++ b/example/xr.yaml @@ -4,12 +4,14 @@ metadata: name: meal spec: deliveryTime: 0 - dueOrderTime: 15 + dueOrderTime: 20 dueTakeTime: 0 - voters: [] title: "meal" - schedule: "0 * * * *" - messages: + schedule: "49 14 * * *" + messages: question: "how are you?" response: "thank you for response." result: "here are the voting results:" + color: "#cfd1d0" +status: + lastNotificationTime: 1 \ No newline at end of file diff --git a/fn.go b/fn.go index a4308bb..f327bbb 100644 --- a/fn.go +++ b/fn.go @@ -2,7 +2,9 @@ package main import ( "context" + "encoding/json" "os" + "strconv" "time" "github.com/slack-go/slack" @@ -59,7 +61,6 @@ func (f *Function) RunFunction(_ context.Context, req *fnv1beta1.RunFunctionRequ desired, _ := request.GetDesiredComposedResources(req) rsp := response.To(req, response.DefaultTTL) - xr, _ := request.GetObservedCompositeResource(req) users, err := slackchannel.ProcessSlackMembers(api, channelID, f.log) if err != nil { @@ -69,17 +70,88 @@ func (f *Function) RunFunction(_ context.Context, req *fnv1beta1.RunFunctionRequ pollName, _ := xr.Resource.GetString("metadata.name") schedule, _ := xr.Resource.GetString("spec.schedule") question, _ := xr.Resource.GetString("spec.messages.question") - d, _ := xr.Resource.GetBool("status.done") resultText, _ := xr.Resource.GetString("spec.messages.result") + color, _ := xr.Resource.GetString("spec.messages.color") + votersDetails, _ := xr.Resource.GetValue("status.voters") + votersJSON, _ := json.Marshal(votersDetails) + votersString := string(votersJSON) if checkDueOrderTimeAndVoteCount(xr, currentTimestamp, users) { - if !d { - xr.Resource.SetBool("status.done", true) - slackchannel.SlackOrder(input, api, xr, f.log, resultText) + _, results := slackchannel.PatchVoters(xr, f.log) + job := composed.Unstructured{ + Unstructured: unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "kubernetes.crossplane.io/v1alpha2", + "kind": "Object", + "metadata": map[string]interface{}{ + "name": "slack-notify-job", + }, + "spec": map[string]interface{}{ + "forProvider": map[string]interface{}{ + "manifest": map[string]interface{}{ + "apiVersion": "batch/v1", + "kind": "Job", + "metadata": map[string]interface{}{ + "name": "slack-notify-job", + "namespace": "default", + }, + "spec": map[string]interface{}{ + "template": map[string]interface{}{ + "spec": map[string]interface{}{ + "restartPolicy": "OnFailure", + "serviceAccountName": input.ServiceAccountName, + "containers": []interface{}{ + map[string]interface{}{ + "name": "poll-container", + "image": input.JobImage, + "env": []interface{}{ + map[string]interface{}{ + "name": "RESULT_TEXT", + "value": resultText, + }, + map[string]interface{}{ + "name": "RESULTS", + "value": strconv.Itoa(results), + }, + map[string]interface{}{ + "name": "POLL_NAME", + "value": pollName, + }, + map[string]interface{}{ + "name": "POLL_TITLE", + "value": pollTitle, + }, + map[string]interface{}{ + "name": "POLL_VOTERS_DETAILS", + "value": votersString, + }, + map[string]interface{}{ + "name": "COLOR", + "value": color, + }, + }, + "envFrom": []interface{}{ + map[string]interface{}{ + "secretRef": map[string]interface{}{ + "name": secretName, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + "providerConfigRef": map[string]interface{}{"name": input.ProviderConfigRef}, + }, + }, + }, } + desired[resource.Name(job.GetName())] = &resource.DesiredComposed{Resource: &job} xr.Resource.SetManagedFields(nil) response.SetDesiredCompositeResource(rsp, xr) - } else { xr.Resource.SetManagedFields(nil) response.SetDesiredCompositeResource(rsp, xr) @@ -120,10 +192,16 @@ func (f *Function) RunFunction(_ context.Context, req *fnv1beta1.RunFunctionRequ map[string]interface{}{ "name": "poll-container", "image": input.DeploymentImage, + "env": []interface{}{ + map[string]interface{}{ + "name": "COLOR", + "value": color, + }, + }, "envFrom": []interface{}{ map[string]interface{}{ "secretRef": map[string]interface{}{ - "name": secretName + "creds", + "name": secretName, }, }, }, @@ -280,11 +358,15 @@ func (f *Function) RunFunction(_ context.Context, req *fnv1beta1.RunFunctionRequ "name": "POLL_TITLE", "value": pollTitle, }, + map[string]interface{}{ + "name": "COLOR", + "value": color, + }, }, "envFrom": []interface{}{ map[string]interface{}{ "secretRef": map[string]interface{}{ - "name": secretName + "creds", + "name": secretName, }, }, }, diff --git a/input/v1beta1/input.go b/input/v1beta1/input.go index eaecf36..9b43a4c 100644 --- a/input/v1beta1/input.go +++ b/input/v1beta1/input.go @@ -27,4 +27,5 @@ type Input struct { DeploymentImage string `json:"deploymentImage"` ServiceAccountName string `json:"serviceAccountName"` CronJobImage string `json:"cronJobImage"` + JobImage string `json:"jobImage"` } diff --git a/internal/slack-collector/main.go b/internal/slack-collector/main.go index 50a8fe0..d4b88e4 100644 --- a/internal/slack-collector/main.go +++ b/internal/slack-collector/main.go @@ -49,6 +49,7 @@ type Message struct { Question string `json:"question"` Response string `json:"response"` Result string `json:"result"` + Color string `json:"color"` } // Poll represents the structure of a poll. @@ -60,13 +61,12 @@ type Poll struct { DueOrderTime int64 `json:"dueOrderTime"` DueTakeTime int64 `json:"dueTakeTime"` Schedule string `json:"schedule"` - Voters []Voter `json:"voters"` Title string `json:"title"` Messages Message `json:"messages"` } `json:"spec"` Status struct { - Done bool `json:"done"` - LastNotificationTime int64 `json:"lastNotificationTime"` + Voters []Voter `json:"voters"` + LastNotificationTime int64 `json:"lastNotificationTime"` } `json:"status"` } @@ -74,6 +74,7 @@ var ( api = slack.New(os.Getenv("SLACK_API_TOKEN")) path = os.Getenv("SLACK_COLLECTOR_PATH") port = os.Getenv("SLACK_COLLECTOR_PORT") + color = os.Getenv("COLOR") response string ) @@ -117,9 +118,9 @@ func patchVoterStatus(user, pollSlackName, selectedOption string, dynamicClient } response = pollResource.Spec.Messages.Response foundUser := false - for i := range pollResource.Spec.Voters { - if pollResource.Spec.Voters[i].Name == user { - pollResource.Spec.Voters[i].Status = selectedOption + for i := range pollResource.Status.Voters { + if pollResource.Status.Voters[i].Name == user { + pollResource.Status.Voters[i].Status = selectedOption foundUser = true break } @@ -130,15 +131,29 @@ func patchVoterStatus(user, pollSlackName, selectedOption string, dynamicClient Name: user, Status: selectedOption, } - pollResource.Spec.Voters = append(pollResource.Spec.Voters, newVoter) + pollResource.Status.Voters = append(pollResource.Status.Voters, newVoter) } pollResource.GetObjectMeta().SetManagedFields(nil) - pollBytes, _ := json.Marshal(pollResource) - _, err = dynamicClient.Resource(resourceId).Namespace("").Patch(ctx, pollResource.GetObjectMeta().GetName(), types.MergePatchType, pollBytes, metav1.PatchOptions{FieldManager: "slack-collector"}) + + statusBytes, _ := json.Marshal(map[string]interface{}{ + "status": map[string]interface{}{ + "voters": pollResource.Status.Voters, + }, + }) + + _, err = dynamicClient.Resource(resourceId).Namespace("").Patch( + context.Background(), + pollResource.GetObjectMeta().GetName(), + types.MergePatchType, + statusBytes, + metav1.PatchOptions{FieldManager: "slack-collector"}, + "/status", + ) if err != nil { - fmt.Println("Error patching poll resource", err) + fmt.Println("Error patching poll status", err) } + return nil } @@ -169,7 +184,7 @@ func getK8sResource(dynamicClient dynamic.Interface, ctx context.Context, pollSl func respondMsg(userID string, userName string, selectedOption string, pollName string) { attachment := slack.Attachment{ - Color: "#f9a41b", + Color: color, CallbackID: pollName, Text: response + "\n Selected: " + selectedOption, Fields: []slack.AttachmentField{}, diff --git a/internal/slack-notify/main.go b/internal/slack-notify/main.go index f93f88f..2495e54 100644 --- a/internal/slack-notify/main.go +++ b/internal/slack-notify/main.go @@ -22,6 +22,7 @@ var ( pollName = os.Getenv("POLL_NAME") pollTitle = os.Getenv("POLL_TITLE") slackNotifyMessage = os.Getenv("SLACK_NOTIFY_MESSAGE") + color = os.Getenv("COLOR") ) // Voter represents the structure of an Voter reference. @@ -35,6 +36,7 @@ type Message struct { Question string `json:"question"` Response string `json:"response"` Result string `json:"result"` + Color string `json:"color"` } // Poll represents the structure of a poll. @@ -46,13 +48,12 @@ type Poll struct { DueOrderTime int64 `json:"dueOrderTime"` DueTakeTime int64 `json:"dueTakeTime"` Schedule string `json:"schedule"` - Voters []Voter `json:"voters"` Title string `json:"title"` Messages Message `json:"messages"` } `json:"spec"` Status struct { - Done bool `json:"done"` - LastNotificationTime int64 `json:"lastNotificationTime"` + Voters []Voter `json:"voters"` + LastNotificationTime int64 `json:"lastNotificationTime"` } `json:"status"` } @@ -101,18 +102,16 @@ func main() { statusBytes, _ := json.Marshal(map[string]interface{}{ "status": map[string]interface{}{ - "done": false, "lastNotificationTime": time.Now().Unix(), }, }) - // Use the "/status" subresource to update just the status _, err = client.Resource(resourceId).Namespace("").Patch( context.Background(), pollResource.GetObjectMeta().GetName(), types.MergePatchType, statusBytes, - metav1.PatchOptions{FieldManager: "slack-collector"}, + metav1.PatchOptions{FieldManager: "slack-notify"}, "/status", ) if err != nil { @@ -136,7 +135,7 @@ func main() { } attachment := slack.Attachment{ - Color: "#f9a41b", + Color: color, CallbackID: pollName, Title: pollTitle, TitleLink: pollTitle, diff --git a/internal/slack-order/Dockerfile b/internal/slack-order/Dockerfile new file mode 100644 index 0000000..ddee1fc --- /dev/null +++ b/internal/slack-order/Dockerfile @@ -0,0 +1,12 @@ +FROM golang:1.23-alpine as build +LABEL org.opencontainers.image.source=https://github.com/kndpio/function-poll + +WORKDIR /build +COPY ./internal/slack-order . +RUN go mod tidy +RUN go build -o slack-order + +FROM golang:1.23-alpine +WORKDIR /app +COPY --from=build /build/slack-order /app/ +CMD [ "./slack-order" ] diff --git a/internal/slack-order/go.mod b/internal/slack-order/go.mod new file mode 100644 index 0000000..03098dd --- /dev/null +++ b/internal/slack-order/go.mod @@ -0,0 +1,13 @@ +module slack-order + +go 1.23.1 + +require github.com/slack-go/slack v0.14.0 + +require ( + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/google/go-cmp v0.6.0 // indirect + github.com/gorilla/websocket v1.5.0 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/stretchr/testify v1.9.0 // indirect +) diff --git a/internal/slack-order/go.sum b/internal/slack-order/go.sum new file mode 100644 index 0000000..03ee230 --- /dev/null +++ b/internal/slack-order/go.sum @@ -0,0 +1,22 @@ +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-test/deep v1.0.4 h1:u2CU3YKy9I2pmu9pX0eq50wCgjfGIt539SqR7FbHiho= +github.com/go-test/deep v1.0.4/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= +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.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= +github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/slack-go/slack v0.14.0 h1:6c0UTfbRnvRssZUsZ2qe0Iu07VAMPjRqOa6oX8ewF4k= +github.com/slack-go/slack v0.14.0/go.mod h1:hlGi5oXA+Gt+yWTPP0plCdRKmjsDxecdHxYQdlMQKOw= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.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/internal/slack-order/main.go b/internal/slack-order/main.go new file mode 100644 index 0000000..b049650 --- /dev/null +++ b/internal/slack-order/main.go @@ -0,0 +1,73 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/slack-go/slack" +) + +var ( + token = os.Getenv("SLACK_API_TOKEN") + resultsChannelID = os.Getenv("SLACK_RESULTS_CHANEL_ID") + votersDetails = os.Getenv("POLL_VOTERS_DETAILS") + pollName = os.Getenv("POLL_NAME") + pollTitle = os.Getenv("POLL_TITLE") + resultText = os.Getenv("RESULT_TEXT") + results = os.Getenv("RESULTS") + color = os.Getenv("COLOR") +) + +// Voter represents the structure of a voter. +type Voter struct { + Name string `json:"name"` + Status string `json:"status"` +} + +// SlackOrder sends an order notification via Slack. +func SlackOrder(api *slack.Client) { + var voters []Voter + if err := json.Unmarshal([]byte(votersDetails), &voters); err != nil { + fmt.Println("Error parsing voters details:", err) + return + } + var votersFormatted []string + for _, voter := range voters { + votersFormatted = append(votersFormatted, fmt.Sprintf("%s: %s", voter.Name, voter.Status)) + } + votersList := strings.Join(votersFormatted, "\n") + completeText := fmt.Sprintf( + "*%s*\nApproved: *%s*\n\n*Voter Details:*\n%s", + resultText, + results, + votersList, + ) + + attachment := slack.Attachment{ + Color: color, + CallbackID: pollTitle, + Title: pollTitle, + Text: completeText, + MarkdownIn: []string{"text"}, + } + + channelID, timestamp, err := api.PostMessage( + resultsChannelID, + slack.MsgOptionText("", false), + slack.MsgOptionAttachments(attachment), + slack.MsgOptionAsUser(true), + ) + + if err != nil { + fmt.Println("Error sending slack message:", err) + } else { + fmt.Println("Message successfully sent to channel", channelID, "at", timestamp) + } +} + +func main() { + api := slack.New(token) + SlackOrder(api) +} diff --git a/internal/slackchannel/slackchannel.go b/internal/slackchannel/slackchannel.go index 5d816fb..41996a9 100644 --- a/internal/slackchannel/slackchannel.go +++ b/internal/slackchannel/slackchannel.go @@ -2,8 +2,6 @@ package slackchannel import ( - "os" - "strconv" "strings" "github.com/slack-go/slack" @@ -12,12 +10,6 @@ import ( "github.com/crossplane/function-sdk-go/logging" "github.com/crossplane/function-sdk-go/resource" - "github.com/crossplane/function-template-go/input/v1beta1" -) - -var ( - channelId = os.Getenv("SLACK_CHANEL_ID") - pollTitle string ) // Voter represents the structure of an Voter reference. @@ -31,6 +23,7 @@ type Message struct { Question string `json:"question"` Response string `json:"response"` Result string `json:"result"` + Color string `json:"color"` } // Poll represents the structure of a poll. @@ -42,24 +35,23 @@ type Poll struct { DueOrderTime int64 `json:"dueOrderTime"` DueTakeTime int64 `json:"dueTakeTime"` Schedule string `json:"schedule"` - Voters []Voter `json:"voters"` Title string `json:"title"` Messages Message `json:"messages"` } `json:"spec"` Status struct { - Done bool `json:"done"` - LastNotificationTime int64 `json:"lastNotificationTime"` + Voters []Voter `json:"voters"` + LastNotificationTime int64 `json:"lastNotificationTime"` } `json:"status"` } -// UserVoted checks if the user should receive a message based on status -func UserVoted(voters []Voter, userName string) bool { - for _, Voter := range voters { - if Voter.Name == userName { - return Voter.Status == "" +func countUsers(voters []Voter) int { + count := 0 + for _, voter := range voters { + if strings.EqualFold(voter.Status, "yes") { + count++ } } - return true + return count } // ProcessSlackMembers gets and process slack members @@ -83,51 +75,14 @@ func ProcessSlackMembers(api *slack.Client, channelID string, logger logging.Log return realUsers, nil } -func countUsers(voters []Voter) int { - count := 0 - for _, voter := range voters { - if strings.EqualFold(voter.Status, "yes") { - count++ - } - } - return count -} - -// SlackOrder sends an order notification via Slack. -func SlackOrder(input *v1beta1.Input, api *slack.Client, xr *resource.Composite, logger logging.Logger, resultText string) *resource.Composite { - pollTitle, _ = xr.Resource.GetString("spec.title") - +// PatchVoters patch status.voters key with an empty array. +func PatchVoters(xr *resource.Composite, logger logging.Logger) (*resource.Composite, int) { poll := Poll{} if err := runtime.DefaultUnstructuredConverter.FromUnstructured(xr.Resource.Object, &poll); err != nil { logger.Info("error converting Unstructured to Poll:", err) } - textContent := countUsers(poll.Spec.Voters) - - attachment := slack.Attachment{ - Color: "#f9a41b", - CallbackID: pollTitle, - Title: pollTitle, - TitleLink: pollTitle, - Text: resultText + strconv.Itoa(textContent), - MarkdownIn: []string{}, - } - - channelID, timestamp, err := api.PostMessage( - channelId, - slack.MsgOptionText("", false), - slack.MsgOptionAttachments(attachment), - slack.MsgOptionAsUser(true), - ) - - if err != nil { - logger.Info("error sending slack message", "warning", err) - } else { - logger.Info("message successfully sent to channel", channelID, timestamp) - } - poll.Spec.Voters = nil - xr.Resource.Object, err = runtime.DefaultUnstructuredConverter.ToUnstructured(&poll) - if err != nil { - logger.Info("error converting Poll to Unstructured:", err) - } - return xr + voters := countUsers(poll.Status.Voters) + poll.Status.Voters = []Voter{} + xr.Resource.Object, _ = runtime.DefaultUnstructuredConverter.ToUnstructured(&poll) + return xr, voters } diff --git a/package/poll.yaml b/package/poll.yaml index 917651c..ee20f55 100644 --- a/package/poll.yaml +++ b/package/poll.yaml @@ -18,15 +18,6 @@ spec: spec: type: object properties: - voters: - type: array - items: - properties: - name: - type: string - status: - type: string - type: object dueOrderTime: type: integer dueTakeTime: @@ -46,10 +37,19 @@ spec: type: string result: type: string + color: + type: string status: type: object properties: - done: - type: boolean + voters: + type: array + items: + properties: + name: + type: string + status: + type: string + type: object lastNotificationTime: type: integer