Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/resources/escalate_rule.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ resource "flashduty_escalate_rule" "critical" {
- `aggr_window` (Number) Aggregation window in seconds (0-3600).
- `description` (String) The description of the rule.
- `filters` (Attributes List) Alert matching filter conditions (OR between groups, AND within conditions). (see [below for nested schema](#nestedatt--filters))
- `priority` (Number) The priority of the escalation rule for ordering.
- `priority` (Number) The priority of the escalation rule for ordering. Defaults to `1`.
- `time_filters` (Attributes List) Time-based filter conditions for when this rule applies. (see [below for nested schema](#nestedatt--time_filters))

### Read-Only
Expand Down
16 changes: 10 additions & 6 deletions internal/provider/flashduty_channel_resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/int64default"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
"github.com/hashicorp/terraform-plugin-framework/types"
Expand Down Expand Up @@ -143,6 +144,8 @@ func (r *ChannelResource) Schema(ctx context.Context, req resource.SchemaRequest
},
"time_window": schema.Int64Attribute{
Optional: true,
Computed: true,
Default: int64default.StaticInt64(0),
MarkdownDescription: "Time window in minutes (0-60). 0 means merge until incident closes.",
},
"cases": schema.ListNestedAttribute{
Expand Down Expand Up @@ -186,6 +189,8 @@ func (r *ChannelResource) Schema(ctx context.Context, req resource.SchemaRequest
},
"all_equals_required": schema.BoolAttribute{
Optional: true,
Computed: true,
Default: booldefault.StaticBool(false),
MarkdownDescription: "Whether all grouping dimensions must be present. Default `false`.",
},
"i_keys": schema.ListAttribute{
Expand Down Expand Up @@ -512,12 +517,11 @@ func (r *ChannelResource) readGroup(ctx context.Context, g *client.ChannelGroup,
Method: types.StringValue(g.Method),
}

if g.TimeWindow != 0 {
result.TimeWindow = types.Int64Value(int64(g.TimeWindow))
}
if g.AllEqualsRequired {
result.AllEqualsRequired = types.BoolValue(true)
}
result.TimeWindow = types.Int64Value(int64(g.TimeWindow))
result.AllEqualsRequired = types.BoolValue(g.AllEqualsRequired)
// i_score_threshold has a documented valid range of 0.5-1.0, so 0 is
// never a legal, distinct value the server would return for a
// configured group - it signals "not applicable" and stays Null.
if g.IScoreThreshold != 0 {
result.IScoreThreshold = types.Float64Value(g.IScoreThreshold)
}
Expand Down
189 changes: 189 additions & 0 deletions internal/provider/flashduty_read_path_zero_value_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
package provider

import (
"context"
"testing"

"terraform-provider-flashduty/internal/client"

"github.com/hashicorp/terraform-plugin-framework/diag"
)

// These tests guard against a class of bug where a server-side false/0 is
// mapped to a Null attribute value instead of a concrete BoolValue(false) /
// Int64Value(0). In terraform-plugin-framework, Null is the zero value of
// types.Bool / types.Int64, so an `if cond { set value }` with no else
// silently drops the case where the server's value is the type's zero
// value, producing permanent drift between state and a config that
// explicitly set the attribute to its zero value.

func TestChannelReadGroupZeroValues(t *testing.T) {
r := &ChannelResource{}
var diags diag.Diagnostics

g := &client.ChannelGroup{
Method: "p",
AllEqualsRequired: false,
TimeWindow: 0,
IScoreThreshold: 0,
}

result := r.readGroup(context.Background(), g, &diags)
if diags.HasError() {
t.Fatalf("unexpected diagnostics: %v", diags)
}

if result.AllEqualsRequired.IsNull() {
t.Error("AllEqualsRequired: got Null, want BoolValue(false)")
} else if result.AllEqualsRequired.ValueBool() != false {
t.Errorf("AllEqualsRequired: got %v, want false", result.AllEqualsRequired.ValueBool())
}

if result.TimeWindow.IsNull() {
t.Error("TimeWindow: got Null, want Int64Value(0)")
} else if result.TimeWindow.ValueInt64() != 0 {
t.Errorf("TimeWindow: got %v, want 0", result.TimeWindow.ValueInt64())
}

// IScoreThreshold has a documented valid range of 0.5-1.0, so 0 is not
// a legal, distinct value the server would return for a configured
// group - it stays Null on zero (not fixed).
if !result.IScoreThreshold.IsNull() {
t.Errorf("IScoreThreshold: got %v, want Null (0 is out of the documented 0.5-1.0 range)", result.IScoreThreshold.ValueFloat64())
}
}

func TestRouteMapRouteToModelZeroValues(t *testing.T) {
r := &RouteResource{}
var diags diag.Diagnostics

route := &client.Route{
Cases: []client.RouteCase{
{
If: []client.RouteFilter{{Key: "title", Oper: "IN", Vals: []string{"x"}}},
Fallthrough: false,
},
},
}

model := &RouteResourceModel{}
r.mapRouteToModel(context.Background(), route, model, &diags)
if diags.HasError() {
t.Fatalf("unexpected diagnostics: %v", diags)
}

got := model.Cases[0].Fallthrough
if got.IsNull() {
t.Error("Fallthrough: got Null, want BoolValue(false)")
} else if got.ValueBool() != false {
t.Errorf("Fallthrough: got %v, want false", got.ValueBool())
}
}

func TestScheduleReadLayersZeroValues(t *testing.T) {
r := &ScheduleResource{}
var diags diag.Diagnostics

layers := []client.ScheduleLayer{
{
LayerName: "L1",
Mode: 0,
LayerStart: 1704038400,
RotationUnit: "day",
RotationValue: 1,
FairRotation: false,
HandoffTime: 0,
RestrictMode: 0,
MaskContinuousEnabled: false,
LayerEnd: 0,
},
}

result := r.readLayers(context.Background(), layers, &diags)
if diags.HasError() {
t.Fatalf("unexpected diagnostics: %v", diags)
}
if len(result) != 1 {
t.Fatalf("got %d layers, want 1", len(result))
}
l := result[0]

if l.FairRotation.IsNull() {
t.Error("FairRotation: got Null, want BoolValue(false)")
} else if l.FairRotation.ValueBool() != false {
t.Errorf("FairRotation: got %v, want false", l.FairRotation.ValueBool())
}

if l.HandoffTime.IsNull() {
t.Error("HandoffTime: got Null, want Int64Value(0)")
} else if l.HandoffTime.ValueInt64() != 0 {
t.Errorf("HandoffTime: got %v, want 0", l.HandoffTime.ValueInt64())
}

if l.RestrictMode.IsNull() {
t.Error("RestrictMode: got Null, want Int64Value(0)")
} else if l.RestrictMode.ValueInt64() != 0 {
t.Errorf("RestrictMode: got %v, want 0", l.RestrictMode.ValueInt64())
}

if l.MaskContinuousEnabled.IsNull() {
t.Error("MaskContinuousEnabled: got Null, want BoolValue(false)")
} else if l.MaskContinuousEnabled.ValueBool() != false {
t.Errorf("MaskContinuousEnabled: got %v, want false", l.MaskContinuousEnabled.ValueBool())
}

// LayerEnd is a Unix timestamp, not a duration. 0 has no meaning as a
// real end time distinct from "no end configured", so it stays Null
// on zero (not fixed).
if !l.LayerEnd.IsNull() {
t.Errorf("LayerEnd: got %v, want Null (0 is not a legal end timestamp)", l.LayerEnd.ValueInt64())
}
}

func TestScheduleReadNotifyZeroAdvanceInTime(t *testing.T) {
r := &ScheduleResource{}
var diags diag.Diagnostics

// A real notify block, explicitly configured with advance_in_time = 0,
// but with other settings present so the server would never omit it.
notify := &client.ScheduleNotify{
AdvanceInTime: 0,
By: &client.NotifyBy{
FollowPreference: true,
},
}

result := r.readNotify(context.Background(), notify, &diags)
if diags.HasError() {
t.Fatalf("unexpected diagnostics: %v", diags)
}
if result == nil {
t.Fatal("got nil ScheduleNotifyModel, want non-nil (By is set)")
}

if result.AdvanceInTime.IsNull() {
t.Error("AdvanceInTime: got Null, want Int64Value(0)")
} else if result.AdvanceInTime.ValueInt64() != 0 {
t.Errorf("AdvanceInTime: got %v, want 0", result.AdvanceInTime.ValueInt64())
}
}

// A genuinely empty notify block (server returns a non-nil struct with
// every field at its zero value, because the resource was never
// configured with `notify`) must still collapse to nil - it must not be
// resurrected as a non-nil block full of zero values just because
// AdvanceInTime is no longer Null-on-zero.
func TestScheduleReadNotifyFullyEmptyCollapsesToNil(t *testing.T) {
r := &ScheduleResource{}
var diags diag.Diagnostics

notify := &client.ScheduleNotify{}

result := r.readNotify(context.Background(), notify, &diags)
if diags.HasError() {
t.Fatalf("unexpected diagnostics: %v", diags)
}
if result != nil {
t.Errorf("got %+v, want nil for a fully empty notify block", result)
}
}
7 changes: 4 additions & 3 deletions internal/provider/flashduty_route_resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"github.com/hashicorp/terraform-plugin-framework/diag"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
Expand Down Expand Up @@ -121,6 +122,8 @@ func (r *RouteResource) Schema(ctx context.Context, req resource.SchemaRequest,
"fallthrough": schema.BoolAttribute{
MarkdownDescription: "Whether to continue matching after this rule.",
Optional: true,
Computed: true,
Default: booldefault.StaticBool(false),
},
"routing_mode": schema.StringAttribute{
MarkdownDescription: "Routing mode (standard, name_mapping).",
Expand Down Expand Up @@ -368,9 +371,7 @@ func (r *RouteResource) mapRouteToModel(ctx context.Context, route *client.Route
for i, routeCase := range route.Cases {
caseModel := RouteCaseModel{}

if routeCase.Fallthrough {
caseModel.Fallthrough = types.BoolValue(true)
}
caseModel.Fallthrough = types.BoolValue(routeCase.Fallthrough)
if routeCase.RoutingMode != "" {
caseModel.RoutingMode = types.StringValue(routeCase.RoutingMode)
}
Expand Down
47 changes: 28 additions & 19 deletions internal/provider/flashduty_schedule_resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"github.com/hashicorp/terraform-plugin-framework/path"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/int64default"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
Expand Down Expand Up @@ -164,14 +165,20 @@ func (r *ScheduleResource) Schema(ctx context.Context, req resource.SchemaReques
},
"fair_rotation": schema.BoolAttribute{
Optional: true,
Computed: true,
Default: booldefault.StaticBool(false),
MarkdownDescription: "Whether to enable fair rotation.",
},
"handoff_time": schema.Int64Attribute{
Optional: true,
Computed: true,
Default: int64default.StaticInt64(0),
MarkdownDescription: "The handoff time in seconds.",
},
"restrict_mode": schema.Int64Attribute{
Optional: true,
Computed: true,
Default: int64default.StaticInt64(0),
MarkdownDescription: "The restriction mode. 0 = none.",
},
"restrict_periods": schema.ListNestedAttribute{
Expand Down Expand Up @@ -203,6 +210,8 @@ func (r *ScheduleResource) Schema(ctx context.Context, req resource.SchemaReques
},
"mask_continuous_enabled": schema.BoolAttribute{
Optional: true,
Computed: true,
Default: booldefault.StaticBool(false),
MarkdownDescription: "Whether continuous mask is enabled.",
},
"groups": schema.ListNestedAttribute{
Expand Down Expand Up @@ -245,6 +254,8 @@ func (r *ScheduleResource) Schema(ctx context.Context, req resource.SchemaReques
Attributes: map[string]schema.Attribute{
"advance_in_time": schema.Int64Attribute{
Optional: true,
Computed: true,
Default: int64default.StaticInt64(0),
MarkdownDescription: "Advance notification time in seconds.",
},
"fixed_time": schema.SingleNestedAttribute{
Expand Down Expand Up @@ -539,21 +550,16 @@ func (r *ScheduleResource) readLayers(_ context.Context, layers []client.Schedul
RotationValue: types.Int64Value(int64(layer.RotationValue)),
}

// layer_end is a Unix timestamp, not a duration: 0 has no meaning
// as a real end time distinct from "no end configured", so it
// stays Null on zero.
if layer.LayerEnd != 0 {
l.LayerEnd = types.Int64Value(layer.LayerEnd)
}
if layer.FairRotation {
l.FairRotation = types.BoolValue(true)
}
if layer.HandoffTime != 0 {
l.HandoffTime = types.Int64Value(int64(layer.HandoffTime))
}
if layer.RestrictMode != 0 {
l.RestrictMode = types.Int64Value(int64(layer.RestrictMode))
}
if layer.MaskContinuousEnabled {
l.MaskContinuousEnabled = types.BoolValue(true)
}
l.FairRotation = types.BoolValue(layer.FairRotation)
l.HandoffTime = types.Int64Value(int64(layer.HandoffTime))
l.RestrictMode = types.Int64Value(int64(layer.RestrictMode))
l.MaskContinuousEnabled = types.BoolValue(layer.MaskContinuousEnabled)

for _, rp := range layer.RestrictPeriods {
l.RestrictPeriods = append(l.RestrictPeriods, ScheduleRestrictPeriodModel{
Expand Down Expand Up @@ -655,10 +661,17 @@ func (r *ScheduleResource) readNotify(_ context.Context, notify *client.Schedule
return nil
}

result := &ScheduleNotifyModel{}
// The server returns a non-nil ScheduleNotify struct even when the
// resource was never configured with a `notify` block. Detect that
// case from the raw response (not the mapped model, since
// AdvanceInTime is always given a concrete value below) and collapse
// it to nil so an unconfigured `notify` doesn't reappear in state.
if notify.AdvanceInTime == 0 && notify.FixedTime == nil && notify.By == nil && len(notify.Webhooks) == 0 {
return nil
}

if notify.AdvanceInTime != 0 {
result.AdvanceInTime = types.Int64Value(int64(notify.AdvanceInTime))
result := &ScheduleNotifyModel{
AdvanceInTime: types.Int64Value(int64(notify.AdvanceInTime)),
}

if notify.FixedTime != nil {
Expand Down Expand Up @@ -698,9 +711,5 @@ func (r *ScheduleResource) readNotify(_ context.Context, notify *client.Schedule
result.Webhooks = append(result.Webhooks, whModel)
}

if result.AdvanceInTime.IsNull() && result.FixedTime == nil && result.By == nil && len(result.Webhooks) == 0 {
return nil
}

return result
}
Loading