Skip to content

Commit

Permalink
🚀 Add support for Notifications (#107)
Browse files Browse the repository at this point in the history
Co-authored-by: John Houston <jhouston@hashicorp.com>
  • Loading branch information
arybolovlev and jrhouston authored Mar 27, 2023
1 parent a6fe725 commit 36c78c8
Show file tree
Hide file tree
Showing 16 changed files with 1,395 additions and 10 deletions.
4 changes: 4 additions & 0 deletions api/v1alpha2/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,7 @@ package v1alpha2
func PointerOf[A any](a A) *A {
return &a
}

func RemoveFromSlice[A any](slice []A, i int) []A {
return append(slice[:i], slice[i+1:]...)
}
22 changes: 21 additions & 1 deletion api/v1alpha2/helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@

package v1alpha2

import "testing"
import (
"reflect"
"testing"
)

func TestPointerOf(t *testing.T) {
s := "this"
Expand All @@ -24,3 +27,20 @@ func TestPointerOf(t *testing.T) {
t.Error("Failed to get int64 pointer")
}
}

func TestRemoveFromSlice(t *testing.T) {
input := [][]any{
{1, 2, 3},
{"a", "b", "c"},
}
want := [][]any{
{1, 3},
{"a", "c"},
}
for i, s := range input {
r := RemoveFromSlice(s, 1)
if !reflect.DeepEqual(want[i], r) {
t.Errorf("Failed to remove an element from slice %d", i)
}
}
}
64 changes: 64 additions & 0 deletions api/v1alpha2/workspace_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
package v1alpha2

import (
tfc "github.com/hashicorp/go-tfe"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
Expand Down Expand Up @@ -271,6 +272,62 @@ type SSHKey struct {
Name string `json:"name,omitempty"`
}

// NotificationTrigger represents the different TFC notifications that can be sent as a run's progress transitions between different states.
// This must be aligned with go-tfe type `NotificationTriggerType`.
//
// +kubebuilder:validation:Enum="run:applying";"assessment:check_failure";"run:completed";"run:created";"assessment:drifted";"run:errored";"assessment:failed";"run:needs_attention";"run:planning"
type NotificationTrigger string

// Notifications allow you to send messages to other applications based on run and workspace events.
// More information:
// - https://developer.hashicorp.com/terraform/cloud-docs/workspaces/settings/notifications
type Notification struct {
// Notification name.
//
//+kubebuilder:validation:MinLength=1
Name string `json:"name"`
// The type of the notification.
// Valid values: `email`, `generic`, `microsoft-teams`, `slack`.
//
//+kubebuilder:validation:Enum=email;generic;microsoft-teams;slack
Type tfc.NotificationDestinationType `json:"type"`
// Whether the notification configuration should be enabled or not. Default: `true`.
//
//+kubebuilder:default=true
//+optional
Enabled bool `json:"enabled,omitempty"`
// The token of the notification.
//
//+kubebuilder:validation:MinLength=1
//+optional
Token string `json:"token,omitempty"`
// The list of run events that will trigger notifications.
// Trigger represents the different TFC notifications that can be sent as a run's progress transitions between different states.
// There are two categories of triggers:
// - Health Events: `assessment:check_failure`, `assessment:drifted`, `assessment:failed`.
// - Run Events: `run:applying`, `run:completed`, `run:created`, `run:errored`, `run:needs_attention`, `run:planning`.
//
//+kubebuilder:validation:MinItems=1
//+optional
Triggers []NotificationTrigger `json:"triggers,omitempty"`
// The URL of the notification.
//
//+kubebuilder:validation:Pattern="^https?://.*"
//+optional
URL string `json:"url,omitempty"`
// The list of email addresses that will receive notification emails.
// It is only available for Terraform Enterprise users. It is not available in Terraform Cloud.
//
//+kubebuilder:validation:MinItems=1
//+optional
EmailAddresses []string `json:"emailAddresses,omitempty"`
// The list of users belonging to the organization that will receive notification emails.
//
//+kubebuilder:validation:MinItems=1
//+optional
EmailUsers []string `json:"emailUsers,omitempty"`
}

// WorkspaceSpec defines the desired state of Workspace.
type WorkspaceSpec struct {
// Workspace name.
Expand Down Expand Up @@ -404,6 +461,13 @@ type WorkspaceSpec struct {
//
//+optional
SSHKey *SSHKey `json:"sshKey,omitempty"`
// Notifications allow you to send messages to other applications based on run and workspace events.
// More information:
// - https://developer.hashicorp.com/terraform/cloud-docs/workspaces/settings/notifications
//
//+kubebuilder:validation:MinItems=1
//+optional
Notifications []Notification `json:"notifications,omitempty"`
}

type RunStatus struct {
Expand Down
164 changes: 164 additions & 0 deletions api/v1alpha2/workspace_validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ package v1alpha2
import (
"fmt"

"github.com/hashicorp/go-tfe"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/util/validation/field"
Expand All @@ -15,6 +16,7 @@ func (w *Workspace) ValidateSpec() error {
var allErrs field.ErrorList

allErrs = append(allErrs, w.validateSpecAgentPool()...)
allErrs = append(allErrs, w.validateSpecNotifications()...)
allErrs = append(allErrs, w.validateSpecRemoteStateSharing()...)
allErrs = append(allErrs, w.validateSpecRunTasks()...)
allErrs = append(allErrs, w.validateSpecRunTriggers()...)
Expand Down Expand Up @@ -60,6 +62,168 @@ func (w *Workspace) validateSpecAgentPool() field.ErrorList {
return allErrs
}

func (w *Workspace) validateSpecNotifications() field.ErrorList {
allErrs := field.ErrorList{}
spec := w.Spec.Notifications

if spec == nil {
return allErrs
}

nn := make(map[string]int)

for i, n := range spec {
f := field.NewPath("spec").Child("notifications").Child(fmt.Sprintf("[%d]", i))
if _, ok := nn[n.Name]; ok {
allErrs = append(allErrs, field.Duplicate(f.Child("Name"), n.Name))
}
nn[n.Name] = i
switch n.Type {
case tfe.NotificationDestinationTypeEmail:
allErrs = append(allErrs, w.validateSpecNotificationsEmail(n, f)...)
case tfe.NotificationDestinationTypeGeneric:
allErrs = append(allErrs, w.validateSpecNotificationsGeneric(n, f)...)
case tfe.NotificationDestinationTypeMicrosoftTeams:
allErrs = append(allErrs, w.validateSpecNotificationsMicrosoftTeams(n, f)...)
case tfe.NotificationDestinationTypeSlack:
allErrs = append(allErrs, w.validateSpecNotificationsSlack(n, f)...)
}
}

return allErrs
}

func (w *Workspace) validateSpecNotificationsEmail(n Notification, f *field.Path) field.ErrorList {
allErrs := field.ErrorList{}
t := tfe.NotificationDestinationTypeEmail

if n.Token != "" {
allErrs = append(allErrs, field.Required(
f,
fmt.Sprintf("token cannot be set for type %q", t)),
)
}
if n.URL != "" {
allErrs = append(allErrs, field.Required(
f,
fmt.Sprintf("url cannot be set for type %q", t)),
)
}
if len(n.EmailAddresses) == 0 && len(n.EmailUsers) == 0 {
allErrs = append(allErrs, field.Invalid(
f,
"",
fmt.Sprintf("at least one of emailAddresses or emailUsers must be set for type %q", t)),
)
}

return allErrs
}

func (w *Workspace) validateSpecNotificationsGeneric(n Notification, f *field.Path) field.ErrorList {
allErrs := field.ErrorList{}
t := tfe.NotificationDestinationTypeGeneric

if n.Token == "" {
allErrs = append(allErrs, field.Required(
f,
fmt.Sprintf("token must be set for type %q", t)),
)
}
if n.URL == "" {
allErrs = append(allErrs, field.Required(
f,
fmt.Sprintf("url must be set for type %q", t)),
)
}
if len(n.EmailAddresses) != 0 {
allErrs = append(allErrs, field.Invalid(
f,
"",
fmt.Sprintf("emailAddresses cannot be set for type %q", t)),
)
}
if len(n.EmailUsers) != 0 {
allErrs = append(allErrs, field.Invalid(
f,
"",
fmt.Sprintf("emailUsers cannot be set for type %q", t)),
)
}

return allErrs
}

func (w *Workspace) validateSpecNotificationsMicrosoftTeams(n Notification, f *field.Path) field.ErrorList {
allErrs := field.ErrorList{}
t := tfe.NotificationDestinationTypeMicrosoftTeams

if n.URL == "" {
allErrs = append(allErrs, field.Required(
f,
fmt.Sprintf("url must be set for type %q", t)),
)
}
if n.Token != "" {
allErrs = append(allErrs, field.Invalid(
f,
"",
fmt.Sprintf("token cannot be set for type %q", t)),
)
}
if len(n.EmailAddresses) != 0 {
allErrs = append(allErrs, field.Invalid(
f,
"",
fmt.Sprintf("emailAddresses cannot be set for type %q", t)),
)
}
if len(n.EmailUsers) != 0 {
allErrs = append(allErrs, field.Invalid(
f,
"",
fmt.Sprintf("emailUsers cannot be set for type %q", t)),
)
}

return allErrs
}

func (w *Workspace) validateSpecNotificationsSlack(n Notification, f *field.Path) field.ErrorList {
allErrs := field.ErrorList{}
t := tfe.NotificationDestinationTypeSlack

if n.URL == "" {
allErrs = append(allErrs, field.Required(
f,
fmt.Sprintf("url must be set for type %q", t)),
)
}
if n.Token != "" {
allErrs = append(allErrs, field.Invalid(
f,
"",
fmt.Sprintf("token cannot be set for type %q", t)),
)
}
if len(n.EmailAddresses) != 0 {
allErrs = append(allErrs, field.Invalid(
f,
"",
fmt.Sprintf("emailAddresses cannot be set for type %q", t)),
)
}
if len(n.EmailUsers) != 0 {
allErrs = append(allErrs, field.Invalid(
f,
"",
fmt.Sprintf("emailUsers cannot be set for type %q", t)),
)
}

return allErrs
}

func (w *Workspace) validateSpecRemoteStateSharing() field.ErrorList {
allErrs := field.ErrorList{}
spec := w.Spec.RemoteStateSharing
Expand Down
Loading

0 comments on commit 36c78c8

Please sign in to comment.