Skip to content

Commit

Permalink
U-4519 Manage on-call calendar rotations (#141)
Browse files Browse the repository at this point in the history
  • Loading branch information
PetrHeinz authored Feb 4, 2025
1 parent 6e3f745 commit 30fcef3
Show file tree
Hide file tree
Showing 12 changed files with 340 additions and 24 deletions.
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ GOLANGCI_LINT := golangci-lint run --disable-all \
-E staticcheck \
-E typecheck \
-E unused
VERSION := 0.16.0
VERSION := 0.17.0
.PHONY: test build

help:
Expand Down
13 changes: 13 additions & 0 deletions docs/data-sources/betteruptime_on_call_calendar.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,21 @@ On-call calendar lookup.

- **default_calendar** (Boolean) Whether the on-call calendar is the default on-call calendar.
- **id** (String) The ID of the on-call calendar.
- **on_call_rotation** (List of Object) Configuration block for the on-call rotation schedule. Ignored when omitted - on-call can be controlled in Better Stack. (see [below for nested schema](#nestedatt--on_call_rotation))
- **on_call_users** (List of Object) Array of on-call persons. (see [below for nested schema](#nestedatt--on_call_users))

<a id="nestedatt--on_call_rotation"></a>
### Nested Schema for `on_call_rotation`

Read-Only:

- **end_rotations_at** (String)
- **rotation_interval** (String)
- **rotation_length** (Number)
- **start_rotations_at** (String)
- **users** (List of String)


<a id="nestedatt--on_call_users"></a>
### Nested Schema for `on_call_users`

Expand Down
13 changes: 13 additions & 0 deletions docs/resources/betteruptime_on_call_calendar.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ https://betterstack.com/docs/uptime/api/on-call-calendar/

### Optional

- **on_call_rotation** (Block List, Max: 1) Configuration block for the on-call rotation schedule. Ignored when omitted - on-call can be controlled in Better Stack. (see [below for nested schema](#nestedblock--on_call_rotation))
- **team_name** (String) Used to specify the team the resource should be created in when using global tokens.

### Read-Only
Expand All @@ -29,6 +30,18 @@ https://betterstack.com/docs/uptime/api/on-call-calendar/
- **id** (String) The ID of the on-call calendar.
- **on_call_users** (List of Object) Array of on-call persons. (see [below for nested schema](#nestedatt--on_call_users))

<a id="nestedblock--on_call_rotation"></a>
### Nested Schema for `on_call_rotation`

Required:

- **end_rotations_at** (String) End time of the rotation in RFC 3339 format (e.g. `2026-01-01T00:00:00Z`)
- **rotation_interval** (String) The interval unit for rotation_length. Must be one of: `hour`, `day`, `week`.
- **rotation_length** (Number) The length of each rotation shift. See `rotation_interval` for units.
- **start_rotations_at** (String) Start time of the rotation in RFC 3339 format (e.g. `2026-01-01T00:00:00Z`)
- **users** (List of String) List of email addresses for users participating in the rotation.


<a id="nestedatt--on_call_users"></a>
### Nested Schema for `on_call_users`

Expand Down
1 change: 1 addition & 0 deletions examples/on_call_calendars/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ git clone https://github.com/BetterStackHQ/terraform-provider-better-uptime && \
echo '# See variables.tf for more.
betteruptime_api_token = "XXXXXXXXXXXXXXXXXXXXXXXX"
betteruptime_secondary_calendar_name = "My secondary calendar"
betteruptime_rotation_users = ["[email protected]", "[email protected]", "[email protected]"]
' > terraform.tfvars

terraform init
Expand Down
7 changes: 7 additions & 0 deletions examples/on_call_calendars/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,11 @@ data "betteruptime_on_call_calendar" "secondary" {

resource "betteruptime_on_call_calendar" "new" {
name = "My Terraform calendar"
on_call_rotation {
users = var.betteruptime_rotation_users
rotation_length = 1
rotation_interval = "day"
start_rotations_at = "2025-01-01T00:00:00Z"
end_rotations_at = "2026-01-01T00:00:00Z"
}
}
8 changes: 8 additions & 0 deletions examples/on_call_calendars/variables.tf
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,11 @@ The name of your secondary on-call calendar.
EOF
default = "Secondary calendar"
}

variable "betteruptime_rotation_users" {
type = list(string)
description = <<EOF
Emails of users to use in new on-call rotation.
EOF
default = ["[email protected]", "[email protected]"]
}
2 changes: 1 addition & 1 deletion examples/on_call_calendars/versions.tf
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ terraform {
required_providers {
betteruptime = {
source = "BetterStackHQ/better-uptime"
version = ">= 0.9.0"
version = ">= 0.17.0"
}
}
}
23 changes: 18 additions & 5 deletions internal/provider/data_on_call_calendar.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"fmt"
"io"
"net/http"
"net/url"

"github.com/hashicorp/terraform-plugin-sdk/v2/diag"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
Expand All @@ -28,6 +29,7 @@ func newOnCallCalendarDataSource() *schema.Resource {
cp.Default = nil
cp.DefaultFunc = nil
cp.DiffSuppressFunc = nil
cp.MaxItems = 0
}
s[k] = &cp
}
Expand Down Expand Up @@ -81,10 +83,15 @@ func onCallDefaultCalendar(ctx context.Context, d *schema.ResourceData, meta int
return diag.FromErr(err)
}
d.SetId(res.Data.ID)
if derr := onCallCalendarCopyAttrs(d, &res.Data.Attributes, res.Data.Relationships, res.Included); derr != nil {
return derr

var outRotation onCallRotation
if err, ok := resourceRead(ctx, meta, fmt.Sprintf("/api/v2/on-calls/%s/rotation", url.PathEscape(res.Data.ID)), &outRotation); err != nil {
return err
} else if !ok {
return onCallCalendarCopyAttrs(d, &res.Data.Attributes, res.Data.Relationships, res.Included, nil)
}
return nil

return onCallCalendarCopyAttrs(d, &res.Data.Attributes, res.Data.Relationships, res.Included, &outRotation)
}

type onCallCalendarsPageHTTPResponse struct {
Expand Down Expand Up @@ -136,9 +143,15 @@ func onCallCalendarLookup(ctx context.Context, d *schema.ResourceData, meta inte
return diag.Errorf("There are multiple on-call calendars with the same name: %s", calendarName)
}
d.SetId(e.ID)
if derr := onCallCalendarCopyAttrs(d, &e.Attributes, e.Relationships, res.Included); derr != nil {
return derr

var outRotation onCallRotation
if err, ok := resourceRead(ctx, meta, fmt.Sprintf("/api/v2/on-calls/%s/rotation", url.PathEscape(e.ID)), &outRotation); err != nil {
return err
} else if !ok {
return onCallCalendarCopyAttrs(d, &e.Attributes, e.Relationships, res.Included, nil)
}

return onCallCalendarCopyAttrs(d, &e.Attributes, e.Relationships, res.Included, &outRotation)
}
}
page++
Expand Down
6 changes: 6 additions & 0 deletions internal/provider/data_on_call_calendar_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ func TestOnCallCalendarData(t *testing.T) {
_, _ = w.Write([]byte(`{"data":[{"id":"123","attributes":{"name":"Primary","default_calendar":true},"relationships":{"on_call_users":{"data":[{"id":"123456","type":"user"}]}}}],"included":[{"id":"123456","type":"user","attributes":{"first_name":"John","last_name":"Smith","email":"[email protected]","phone_numbers":[]}}],"pagination":{"next":"..."}}`))
case r.Method == http.MethodGet && r.RequestURI == prefix+"?page=2":
_, _ = w.Write([]byte(`{"data":[{"id":"456","attributes":{"name":"Secondary","default_calendar":false},"relationships":{"on_call_users":{"data":[{"id":"456789","type":"user"}]}}}],"included":[{"id":"456789","type":"user","attributes":{"first_name":"Jane","last_name":"Doe","email":"[email protected]","phone_numbers":["+44 808 157 0192"]}}],"pagination":{"next":null}}`))
case r.Method == http.MethodGet && r.RequestURI == prefix+"/456/rotation":
w.WriteHeader(http.StatusNotFound)
default:
t.Fatal("Unexpected " + r.Method + " " + r.RequestURI)
t.Fail()
Expand Down Expand Up @@ -62,6 +64,7 @@ func TestOnCallCalendarData(t *testing.T) {
resource.TestCheckResourceAttr("data.betteruptime_on_call_calendar.this", "on_call_users.0.last_name", "Doe"),
resource.TestCheckResourceAttr("data.betteruptime_on_call_calendar.this", "on_call_users.0.email", "[email protected]"),
resource.TestCheckResourceAttr("data.betteruptime_on_call_calendar.this", "on_call_users.0.phone_numbers.0", "+44 808 157 0192"),
resource.TestCheckResourceAttr("data.betteruptime_on_call_calendar.this", "on_call_rotation.#", "0"),
),
},
},
Expand All @@ -80,6 +83,8 @@ func TestDefaultOnCallCalendarData(t *testing.T) {
switch {
case r.Method == http.MethodGet && r.RequestURI == "/api/v2/on-calls/default":
_, _ = w.Write([]byte(`{"data":{"id":"123","attributes":{"name":"Primary","default_calendar":true},"relationships":{"on_call_users":{"data":[{"id":"123456","type":"user"}]}}},"included":[{"id":"123456","type":"user","attributes":{"first_name":"John","last_name":"Smith","email":"[email protected]","phone_numbers":[]}}]}`))
case r.Method == http.MethodGet && r.RequestURI == "/api/v2/on-calls/123/rotation":
w.WriteHeader(http.StatusNotFound)
default:
t.Fatal("Unexpected " + r.Method + " " + r.RequestURI)
t.Fail()
Expand Down Expand Up @@ -112,6 +117,7 @@ func TestDefaultOnCallCalendarData(t *testing.T) {
resource.TestCheckResourceAttr("data.betteruptime_on_call_calendar.this", "on_call_users.0.first_name", "John"),
resource.TestCheckResourceAttr("data.betteruptime_on_call_calendar.this", "on_call_users.0.last_name", "Smith"),
resource.TestCheckResourceAttr("data.betteruptime_on_call_calendar.this", "on_call_users.0.email", "[email protected]"),
resource.TestCheckResourceAttr("data.betteruptime_on_call_calendar.this", "on_call_rotation.#", "0"),
),
},
},
Expand Down
147 changes: 143 additions & 4 deletions internal/provider/resource_on_call_calendar.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ import (
"fmt"
"net/url"
"reflect"
"time"

"github.com/hashicorp/go-cty/cty"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation"

"github.com/hashicorp/terraform-plugin-sdk/v2/diag"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
Expand Down Expand Up @@ -80,6 +84,50 @@ var onCallCalendarSchema = map[string]*schema.Schema{
},
},
},
"on_call_rotation": {
Description: "Configuration block for the on-call rotation schedule. Ignored when omitted - on-call can be controlled in Better Stack.",
Type: schema.TypeList,
Optional: true,
Computed: true,
MaxItems: 1,
Elem: &schema.Resource{
Schema: map[string]*schema.Schema{
"users": {
Description: "List of email addresses for users participating in the rotation.",
Type: schema.TypeList,
Required: true,
Elem: &schema.Schema{
Type: schema.TypeString,
},
},
"rotation_length": {
Description: "The length of each rotation shift. See `rotation_interval` for units.",
Type: schema.TypeInt,
Required: true,
},
"rotation_interval": {
Description: "The interval unit for rotation_length. Must be one of: `hour`, `day`, `week`.",
Type: schema.TypeString,
Required: true,
ValidateFunc: validation.StringInSlice([]string{"hour", "day", "week"}, false),
},
"start_rotations_at": {
Description: "Start time of the rotation in RFC 3339 format (e.g. `2026-01-01T00:00:00Z`)",
Type: schema.TypeString,
Required: true,
ValidateDiagFunc: validateRFC3339DateTime,
DiffSuppressFunc: diffSuppressRFC3339DateTime,
},
"end_rotations_at": {
Description: "End time of the rotation in RFC 3339 format (e.g. `2026-01-01T00:00:00Z`)",
Type: schema.TypeString,
Required: true,
ValidateDiagFunc: validateRFC3339DateTime,
DiffSuppressFunc: diffSuppressRFC3339DateTime,
},
},
},
},
}

func newOnCallCalendarResource() *schema.Resource {
Expand All @@ -103,6 +151,14 @@ type onCallCalendar struct {
TeamName *string `json:"team_name,omitempty"`
}

type onCallRotation struct {
Users *[]string `mapstructure:"users,omitempty" json:"users,omitempty"`
RotationLength *int `mapstructure:"rotation_length,omitempty" json:"rotation_length,omitempty"`
RotationInterval *string `mapstructure:"rotation_interval,omitempty" json:"rotation_interval,omitempty"`
StartRotationsAt *string `mapstructure:"start_rotations_at,omitempty" json:"start_rotations_at,omitempty"`
EndRotationsAt *string `mapstructure:"end_rotations_at,omitempty" json:"end_rotations_at,omitempty"`
}

type onCallRelationships struct {
OnCallUsers struct {
Data []struct {
Expand Down Expand Up @@ -140,7 +196,7 @@ func onCallCalendarRef(cal *onCallCalendar) []struct {
}
}

func onCallCalendarCopyAttrs(d *schema.ResourceData, cal *onCallCalendar, rel onCallRelationships, inc []onCallIncluded) diag.Diagnostics {
func onCallCalendarCopyAttrs(d *schema.ResourceData, cal *onCallCalendar, rel onCallRelationships, inc []onCallIncluded, rot *onCallRotation) diag.Diagnostics {
var derr diag.Diagnostics
for _, e := range onCallCalendarRef(cal) {
if !isFieldAttribute(e.k) {
Expand All @@ -166,9 +222,50 @@ func onCallCalendarCopyAttrs(d *schema.ResourceData, cal *onCallCalendar, rel on
derr = append(derr, diag.FromErr(err)[0])
}
}

// Only set rotation if it exists
var rotationList []onCallRotation
if rot != nil {
rotationList = []onCallRotation{*rot}
}
if err := d.Set("on_call_rotation", rotationList); err != nil {
derr = append(derr, diag.FromErr(err)[0])
}

return derr
}

func validateRFC3339DateTime(i interface{}, p cty.Path) diag.Diagnostics {
v, ok := i.(string)
if !ok {
return diag.Errorf("expected type to be string")
}

if _, err := time.Parse(time.RFC3339, v); err != nil {
return diag.Errorf("expected RFC 3339 datetime (e.g. 2026-01-01T00:00:00Z), got %s: %v", v, err)
}

return nil
}

func diffSuppressRFC3339DateTime(k, old, new string, d *schema.ResourceData) bool {
if old == "" || new == "" {
return false
}

oldTime, err := time.Parse(time.RFC3339, old)
if err != nil {
return false
}

newTime, err := time.Parse(time.RFC3339, new)
if err != nil {
return false
}

return oldTime.UTC().Equal(newTime.UTC())
}

func resourceOnCallCalendarCreate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics {
var in onCallCalendar
for _, e := range onCallCalendarRef(&in) {
Expand All @@ -189,7 +286,23 @@ func resourceOnCallCalendarCreate(ctx context.Context, d *schema.ResourceData, m
return err
}
d.SetId(out.Data.ID)
return onCallCalendarCopyAttrs(d, &out.Data.Attributes, out.Data.Relationships, out.Included)

if inRotations, ok := d.GetOk("on_call_rotation"); ok && len(inRotations.([]interface{})) > 0 {
var outRotation onCallRotation
if err := resourceCreate(ctx, meta, fmt.Sprintf("/api/v2/on-calls/%s/rotation", url.PathEscape(d.Id())), inRotations.([]interface{})[0], &outRotation); err != nil {
return err
}
return onCallCalendarCopyAttrs(d, &out.Data.Attributes, out.Data.Relationships, out.Included, &outRotation)
}

var outRotation onCallRotation
if err, ok := resourceRead(ctx, meta, fmt.Sprintf("/api/v2/on-calls/%s/rotation", url.PathEscape(d.Id())), &outRotation); err != nil {
return err
} else if !ok {
return onCallCalendarCopyAttrs(d, &out.Data.Attributes, out.Data.Relationships, out.Included, nil)
}

return onCallCalendarCopyAttrs(d, &out.Data.Attributes, out.Data.Relationships, out.Included, &outRotation)
}

func resourceOnCallCalendarRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics {
Expand All @@ -208,7 +321,15 @@ func resourceOnCallCalendarRead(ctx context.Context, d *schema.ResourceData, met
d.SetId("") // Force "create" on 404
return nil
}
return onCallCalendarCopyAttrs(d, &out.Data.Attributes, out.Data.Relationships, out.Included)

var outRotation onCallRotation
if err, ok := resourceRead(ctx, meta, fmt.Sprintf("/api/v2/on-calls/%s/rotation", url.PathEscape(d.Id())), &outRotation); err != nil {
return err
} else if !ok {
return onCallCalendarCopyAttrs(d, &out.Data.Attributes, out.Data.Relationships, out.Included, nil)
}

return onCallCalendarCopyAttrs(d, &out.Data.Attributes, out.Data.Relationships, out.Included, &outRotation)
}

func resourceOnCallCalendarUpdate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics {
Expand All @@ -231,7 +352,25 @@ func resourceOnCallCalendarUpdate(ctx context.Context, d *schema.ResourceData, m
if err := resourceUpdate(ctx, meta, fmt.Sprintf("/api/v2/on-calls/%s", url.PathEscape(d.Id())), &in, &out); err != nil {
return err
}
return onCallCalendarCopyAttrs(d, &out.Data.Attributes, out.Data.Relationships, out.Included)

if d.HasChange("on_call_rotation") {
if inRotations, ok := d.GetOk("on_call_rotation"); ok && len(inRotations.([]interface{})) > 0 {
var outRotation onCallRotation
if err := resourceCreate(ctx, meta, fmt.Sprintf("/api/v2/on-calls/%s/rotation", url.PathEscape(d.Id())), inRotations.([]interface{})[0], &outRotation); err != nil {
return err
}
return onCallCalendarCopyAttrs(d, &out.Data.Attributes, out.Data.Relationships, out.Included, &outRotation)
}
}

var outRotation onCallRotation
if err, ok := resourceRead(ctx, meta, fmt.Sprintf("/api/v2/on-calls/%s/rotation", url.PathEscape(d.Id())), &outRotation); err != nil {
return err
} else if !ok {
return onCallCalendarCopyAttrs(d, &out.Data.Attributes, out.Data.Relationships, out.Included, nil)
}

return onCallCalendarCopyAttrs(d, &out.Data.Attributes, out.Data.Relationships, out.Included, &outRotation)
}

func resourceOnCallCalendarDelete(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics {
Expand Down
Loading

0 comments on commit 30fcef3

Please sign in to comment.