diff --git a/tailscale/provider_framework.go b/tailscale/provider_framework.go index 493b427f..d3315366 100644 --- a/tailscale/provider_framework.go +++ b/tailscale/provider_framework.go @@ -193,6 +193,7 @@ func (p *tailscaleProvider) Resources(_ context.Context) []func() resource.Resou NewPostureIntegrationResource, NewServiceResource, NewTailnetKeyResource, + NewTailnetSettingsResource, NewWebhookResource, } } diff --git a/tailscale/provider_sdk.go b/tailscale/provider_sdk.go index 281bc61f..27aaff01 100644 --- a/tailscale/provider_sdk.go +++ b/tailscale/provider_sdk.go @@ -103,7 +103,6 @@ func Provider(options ...ProviderOption) *schema.Provider { }, }, ResourcesMap: map[string]*schema.Resource{ - "tailscale_tailnet_settings": resourceTailnetSettings(), "tailscale_federated_identity": resourceFederatedIdentity(), }, } diff --git a/tailscale/resource_tailnet_settings.go b/tailscale/resource_tailnet_settings.go index baba351f..a2e79579 100644 --- a/tailscale/resource_tailnet_settings.go +++ b/tailscale/resource_tailnet_settings.go @@ -5,172 +5,335 @@ package tailscale import ( "context" + "fmt" + "regexp" - "github.com/hashicorp/terraform-plugin-sdk/v2/diag" - "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" - "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/int64planmodifier" + "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" + "github.com/hashicorp/terraform-plugin-framework/types" "tailscale.com/client/tailscale/v2" ) -func resourceTailnetSettings() *schema.Resource { - return &schema.Resource{ - Description: "The tailnet_settings resource allows you to configure settings for your tailnet. See https://tailscale.com/api#tag/tailnetsettings for more information.", - ReadContext: resourceTailnetSettingsRead, - CreateContext: resourceTailnetSettingsCreate, - UpdateContext: resourceTailnetSettingsUpdate, - DeleteContext: resourceTailnetSettingsDelete, - Importer: &schema.ResourceImporter{ - StateContext: schema.ImportStatePassthroughContext, - }, - Schema: map[string]*schema.Schema{ - "acls_externally_managed_on": { - Type: schema.TypeBool, +var ( + _ resource.Resource = &tailnetSettingsResource{} + _ resource.ResourceWithConfigure = &tailnetSettingsResource{} + _ resource.ResourceWithImportState = &tailnetSettingsResource{} + _ resource.ResourceWithModifyPlan = &tailnetSettingsResource{} +) + +type tailnetSettingsResourceModel struct { + ID types.String `tfsdk:"id"` + ACLsExternallyManagedOn types.Bool `tfsdk:"acls_externally_managed_on"` + ACLsExternalLink types.String `tfsdk:"acls_external_link"` + DevicesApprovalOn types.Bool `tfsdk:"devices_approval_on"` + DevicesAutoUpdatesOn types.Bool `tfsdk:"devices_auto_updates_on"` + DevicesKeyDurationDays types.Int64 `tfsdk:"devices_key_duration_days"` + UsersApprovalOn types.Bool `tfsdk:"users_approval_on"` + UsersRoleAllowedToJoinExternalTailnet types.String `tfsdk:"users_role_allowed_to_join_external_tailnet"` + NetworkFlowLoggingOn types.Bool `tfsdk:"network_flow_logging_on"` + RegionalRoutingOn types.Bool `tfsdk:"regional_routing_on"` + PostureIdentityCollectionOn types.Bool `tfsdk:"posture_identity_collection_on"` + HTTPSEnabled types.Bool `tfsdk:"https_enabled"` +} + +func NewTailnetSettingsResource() resource.Resource { + return &tailnetSettingsResource{} +} + +type tailnetSettingsResource struct { + ResourceBase + ResourceImportedByID +} + +func (s *tailnetSettingsResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_tailnet_settings" +} + +func (s *tailnetSettingsResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "The tailnet_settings resource allows you to configure settings for your tailnet. See https://tailscale.com/api#tag/tailnetsettings for more information.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}, + }, + "acls_externally_managed_on": schema.BoolAttribute{ Description: "Prevent users from editing policies in the admin console to avoid conflicts with external management workflows like GitOps or Terraform.", Optional: true, Computed: true, + PlanModifiers: []planmodifier.Bool{ + boolplanmodifier.UseStateForUnknown(), + }, }, - "acls_external_link": { - Type: schema.TypeString, - Description: "Link to your external ACL definition or management system. Must be a valid URL.", - ValidateFunc: validation.IsURLWithHTTPorHTTPS, - Optional: true, - Computed: true, + "acls_external_link": schema.StringAttribute{ + Description: "Link to your external ACL definition or management system. Must be a valid URL.", + Optional: true, + Computed: true, + Validators: []validator.String{ + stringvalidator.RegexMatches( + regexp.MustCompile(`^https?://`), + "must be a valid URL with http or https scheme", + ), + }, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, }, - "devices_approval_on": { - Type: schema.TypeBool, + "devices_approval_on": schema.BoolAttribute{ Description: "Whether device approval is enabled for the tailnet", Optional: true, Computed: true, + PlanModifiers: []planmodifier.Bool{ + boolplanmodifier.UseStateForUnknown(), + }, }, - "devices_auto_updates_on": { - Type: schema.TypeBool, + "devices_auto_updates_on": schema.BoolAttribute{ Description: "Whether auto updates are enabled for devices that belong to this tailnet", Optional: true, Computed: true, + PlanModifiers: []planmodifier.Bool{ + boolplanmodifier.UseStateForUnknown(), + }, }, - "devices_key_duration_days": { - Type: schema.TypeInt, + "devices_key_duration_days": schema.Int64Attribute{ Description: "The key expiry duration for devices on this tailnet", Optional: true, Computed: true, + PlanModifiers: []planmodifier.Int64{ + int64planmodifier.UseStateForUnknown(), + }, }, - "users_approval_on": { - Type: schema.TypeBool, + "users_approval_on": schema.BoolAttribute{ Description: "Whether user approval is enabled for this tailnet", Optional: true, Computed: true, + PlanModifiers: []planmodifier.Bool{ + boolplanmodifier.UseStateForUnknown(), + }, }, - "users_role_allowed_to_join_external_tailnet": { - Type: schema.TypeString, + "users_role_allowed_to_join_external_tailnet": schema.StringAttribute{ Description: "Which user roles are allowed to join external tailnets", Optional: true, Computed: true, - ValidateFunc: validation.StringInSlice( - []string{ + Validators: []validator.String{ + stringvalidator.OneOf( string(tailscale.RoleAllowedToJoinExternalTailnetsNone), string(tailscale.RoleAllowedToJoinExternalTailnetsMember), string(tailscale.RoleAllowedToJoinExternalTailnetsAdmin), - }, - false, - ), + ), + }, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, }, - "network_flow_logging_on": { - Type: schema.TypeBool, + "network_flow_logging_on": schema.BoolAttribute{ Description: "Whether network flow logs are enabled for the tailnet", Optional: true, Computed: true, + PlanModifiers: []planmodifier.Bool{ + boolplanmodifier.UseStateForUnknown(), + }, }, - "regional_routing_on": { - Type: schema.TypeBool, + "regional_routing_on": schema.BoolAttribute{ Description: "Whether regional routing is enabled for the tailnet", Optional: true, Computed: true, + PlanModifiers: []planmodifier.Bool{ + boolplanmodifier.UseStateForUnknown(), + }, }, - "posture_identity_collection_on": { - Type: schema.TypeBool, + "posture_identity_collection_on": schema.BoolAttribute{ Description: "Whether identity collection is enabled for device posture integrations for the tailnet", Optional: true, Computed: true, + PlanModifiers: []planmodifier.Bool{ + boolplanmodifier.UseStateForUnknown(), + }, }, - "https_enabled": { - Type: schema.TypeBool, + "https_enabled": schema.BoolAttribute{ Description: "Whether provisioning of HTTPS certificates is enabled for the tailnet", Optional: true, Computed: true, + PlanModifiers: []planmodifier.Bool{ + boolplanmodifier.UseStateForUnknown(), + }, }, }, } } -func resourceTailnetSettingsRead(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { - client := m.(*tailscale.Client) +func (s *tailnetSettingsResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state tailnetSettingsResourceModel + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } - settings, err := client.TailnetSettings().Get(ctx) + err := s.readSettings(ctx, &state) if err != nil { - return diagnosticsError(err, "Failed to fetch tailnet settings") - } - - settingsMap := map[string]any{ - "acls_externally_managed_on": settings.ACLsExternallyManagedOn, - "acls_external_link": settings.ACLsExternalLink, - "devices_approval_on": settings.DevicesApprovalOn, - "devices_auto_updates_on": settings.DevicesAutoUpdatesOn, - "devices_key_duration_days": settings.DevicesKeyDurationDays, - "users_approval_on": settings.UsersApprovalOn, - "users_role_allowed_to_join_external_tailnet": string(settings.UsersRoleAllowedToJoinExternalTailnets), - "network_flow_logging_on": settings.NetworkFlowLoggingOn, - "regional_routing_on": settings.RegionalRoutingOn, - "posture_identity_collection_on": settings.PostureIdentityCollectionOn, - "https_enabled": settings.HTTPSEnabled, - } - return setProperties(d, settingsMap) + resp.Diagnostics.AddError("Failed to fetch tailnet settings", fmt.Sprintf("Error reading tailnet settings: %s", err)) + return + } + + diags = resp.State.Set(ctx, &state) + resp.Diagnostics.Append(diags...) } -func resourceTailnetSettingsCreate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { - if err := resourceTailnetSettingsDoUpdate(ctx, d, m); err != nil { +func (s *tailnetSettingsResource) readSettings(ctx context.Context, state *tailnetSettingsResourceModel) error { + settings, err := s.Client.TailnetSettings().Get(ctx) + if err != nil { return err } - d.SetId(createUUID()) - return resourceTailnetSettingsRead(ctx, d, m) + + state.ID = types.StringValue("singleton") + state.ACLsExternallyManagedOn = types.BoolValue(settings.ACLsExternallyManagedOn) + state.ACLsExternalLink = types.StringValue(settings.ACLsExternalLink) + state.DevicesApprovalOn = types.BoolValue(settings.DevicesApprovalOn) + state.DevicesAutoUpdatesOn = types.BoolValue(settings.DevicesAutoUpdatesOn) + state.DevicesKeyDurationDays = types.Int64Value(int64(settings.DevicesKeyDurationDays)) + state.UsersApprovalOn = types.BoolValue(settings.UsersApprovalOn) + state.UsersRoleAllowedToJoinExternalTailnet = types.StringValue(string(settings.UsersRoleAllowedToJoinExternalTailnets)) + state.NetworkFlowLoggingOn = types.BoolValue(settings.NetworkFlowLoggingOn) + state.RegionalRoutingOn = types.BoolValue(settings.RegionalRoutingOn) + state.PostureIdentityCollectionOn = types.BoolValue(settings.PostureIdentityCollectionOn) + state.HTTPSEnabled = types.BoolValue(settings.HTTPSEnabled) + + return nil } -func resourceTailnetSettingsUpdate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { - if err := resourceTailnetSettingsDoUpdate(ctx, d, m); err != nil { - return err +func (s *tailnetSettingsResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan tailnetSettingsResourceModel + diags := req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // We don't have existing state to pass in to resourceTailnetSettingsDoUpdate, + // create a state with all unknown values so we'll update any fields set in + // the plan - and only fields set in the plan. + pretendState := tailnetSettingsResourceModel{ + ID: types.StringUnknown(), + ACLsExternallyManagedOn: types.BoolUnknown(), + ACLsExternalLink: types.StringUnknown(), + DevicesApprovalOn: types.BoolUnknown(), + DevicesAutoUpdatesOn: types.BoolUnknown(), + DevicesKeyDurationDays: types.Int64Unknown(), + UsersApprovalOn: types.BoolUnknown(), + UsersRoleAllowedToJoinExternalTailnet: types.StringUnknown(), + NetworkFlowLoggingOn: types.BoolUnknown(), + RegionalRoutingOn: types.BoolUnknown(), + PostureIdentityCollectionOn: types.BoolUnknown(), + HTTPSEnabled: types.BoolUnknown(), + } + + if err := s.resourceTailnetSettingsDoUpdate(ctx, plan, pretendState); err != nil { + resp.Diagnostics.AddError("Failed to update tailnet settings", fmt.Sprintf("Error updating tailnet settings: %s", err)) + return + } + + err := s.readSettings(ctx, &plan) + if err != nil { + resp.Diagnostics.AddError("Failed to fetch tailnet settings", fmt.Sprintf("Error reading tailnet settings: %s", err)) + return } - return resourceTailnetSettingsRead(ctx, d, m) + + diags = resp.State.Set(ctx, &plan) + resp.Diagnostics.Append(diags...) } -func resourceTailnetSettingsDoUpdate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { - var role *tailscale.RoleAllowedToJoinExternalTailnets - _role, ok := d.GetOk("users_role_allowed_to_join_external_tailnet") - if ok { - role = tailscale.PointerTo(tailscale.RoleAllowedToJoinExternalTailnets(_role.(string))) +func (s *tailnetSettingsResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan tailnetSettingsResourceModel + diags := req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return } - settings := tailscale.UpdateTailnetSettingsRequest{ - ACLsExternallyManagedOn: optional[bool](d, "acls_externally_managed_on"), - ACLsExternalLink: optional[string](d, "acls_external_link"), - DevicesApprovalOn: optional[bool](d, "devices_approval_on"), - DevicesAutoUpdatesOn: optional[bool](d, "devices_auto_updates_on"), - DevicesKeyDurationDays: optional[int](d, "devices_key_duration_days"), - UsersApprovalOn: optional[bool](d, "users_approval_on"), - UsersRoleAllowedToJoinExternalTailnets: role, - NetworkFlowLoggingOn: optional[bool](d, "network_flow_logging_on"), - RegionalRoutingOn: optional[bool](d, "regional_routing_on"), - PostureIdentityCollectionOn: optional[bool](d, "posture_identity_collection_on"), - HTTPSEnabled: optional[bool](d, "https_enabled"), + + var state tailnetSettingsResourceModel + diags = req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return } - client := m.(*tailscale.Client) - if err := client.TailnetSettings().Update(ctx, settings); err != nil { - return diagnosticsError(err, "Failed to update tailnet settings") + if err := s.resourceTailnetSettingsDoUpdate(ctx, plan, state); err != nil { + resp.Diagnostics.AddError("Failed to update tailnet settings", fmt.Sprintf("Error updating tailnet settings: %s", err)) + return } - return nil + if err := s.readSettings(ctx, &plan); err != nil { + resp.Diagnostics.AddError("Failed to fetch tailnet settings", fmt.Sprintf("Error reading tailnet settings: %s", err)) + return + } + + diags = resp.State.Set(ctx, &plan) + resp.Diagnostics.Append(diags...) } -func resourceTailnetSettingsDelete(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { +func (s *tailnetSettingsResource) resourceTailnetSettingsDoUpdate(ctx context.Context, plan tailnetSettingsResourceModel, state tailnetSettingsResourceModel) error { + boolIfDiff := func(plan types.Bool, state types.Bool) *bool { + if plan.Equal(state) { + return nil + } + return plan.ValueBoolPointer() + } + + strIfDiff := func(plan types.String, state types.String) *string { + if plan.Equal(state) { + return nil + } + return plan.ValueStringPointer() + } + + roleIfDiff := func(plan types.String, state types.String) *tailscale.RoleAllowedToJoinExternalTailnets { + return (*tailscale.RoleAllowedToJoinExternalTailnets)(strIfDiff(plan, state)) + } + + intIfDiff := func(plan types.Int64, state types.Int64) *int { + if plan.Equal(state) { + return nil + } + v := int(plan.ValueInt64()) + return &v + } + + // Only make the updates we need to: ignore any fields where the plan and state are already the same. + settingsRequest := tailscale.UpdateTailnetSettingsRequest{ + ACLsExternallyManagedOn: boolIfDiff(plan.ACLsExternallyManagedOn, state.ACLsExternallyManagedOn), + ACLsExternalLink: strIfDiff(plan.ACLsExternalLink, state.ACLsExternalLink), + DevicesApprovalOn: boolIfDiff(plan.DevicesApprovalOn, state.DevicesApprovalOn), + DevicesAutoUpdatesOn: boolIfDiff(plan.DevicesAutoUpdatesOn, state.DevicesAutoUpdatesOn), + DevicesKeyDurationDays: intIfDiff(plan.DevicesKeyDurationDays, state.DevicesKeyDurationDays), + UsersApprovalOn: boolIfDiff(plan.UsersApprovalOn, state.UsersApprovalOn), + UsersRoleAllowedToJoinExternalTailnets: roleIfDiff(plan.UsersRoleAllowedToJoinExternalTailnet, state.UsersRoleAllowedToJoinExternalTailnet), + NetworkFlowLoggingOn: boolIfDiff(plan.NetworkFlowLoggingOn, state.NetworkFlowLoggingOn), + RegionalRoutingOn: boolIfDiff(plan.RegionalRoutingOn, state.RegionalRoutingOn), + PostureIdentityCollectionOn: boolIfDiff(plan.PostureIdentityCollectionOn, state.PostureIdentityCollectionOn), + HTTPSEnabled: boolIfDiff(plan.HTTPSEnabled, state.HTTPSEnabled), + } + + return s.Client.TailnetSettings().Update(ctx, settingsRequest) +} + +func (s *tailnetSettingsResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { // We don't know what the default values for Tailnet settings should be, so deleting is a noop. - return nil +} + +func (s *tailnetSettingsResource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) { + if req.Plan.Raw.IsNull() { + resp.Diagnostics.AddWarning( + "Resource Destruction Considerations", + "Destroying this resource will only remove the resource from the Terraform state and will not undo or change any tailnet settings. "+ + "Use a tailscale_tailnet_settings resource to explicitly change any settings you want to set back, or manually update them via the admin console.", + ) + } } diff --git a/tailscale/resource_tailnet_settings_test.go b/tailscale/resource_tailnet_settings_test.go index e526d3c1..5ad94a04 100644 --- a/tailscale/resource_tailnet_settings_test.go +++ b/tailscale/resource_tailnet_settings_test.go @@ -11,6 +11,7 @@ import ( "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" "tailscale.com/client/tailscale/v2" + "tailscale.com/types/ptr" ) func TestAccTailscaleTailnetSettings(t *testing.T) { @@ -46,6 +47,11 @@ func TestAccTailscaleTailnetSettings(t *testing.T) { resource "tailscale_tailnet_settings" "test_settings" { }` + const testTailnetSettingsOneSet = ` + resource "tailscale_tailnet_settings" "test_settings" { + acls_externally_managed_on = true + }` + checkProperties := func(expected *tailscale.TailnetSettings) func(client *tailscale.Client, rs *terraform.ResourceState) error { return func(client *tailscale.Client, rs *terraform.ResourceState) error { actual, err := client.TailnetSettings().Get(context.Background()) @@ -146,6 +152,33 @@ func TestAccTailscaleTailnetSettings(t *testing.T) { resource.TestCheckResourceAttr(resourceName, "https_enabled", "false"), ), }, + { + Config: testTailnetSettingsOneSet, + Check: resource.ComposeTestCheckFunc( + checkResourceRemoteProperties(resourceName, + checkProperties(&tailscale.TailnetSettings{ + ACLsExternallyManagedOn: true, + ACLsExternalLink: "https://bar.com", + DevicesApprovalOn: false, + DevicesAutoUpdatesOn: false, + DevicesKeyDurationDays: 10, + UsersApprovalOn: false, + UsersRoleAllowedToJoinExternalTailnets: tailscale.RoleAllowedToJoinExternalTailnetsAdmin, + PostureIdentityCollectionOn: false, + HTTPSEnabled: false, + }), + ), + resource.TestCheckResourceAttr(resourceName, "acls_externally_managed_on", "true"), + resource.TestCheckResourceAttr(resourceName, "acls_external_link", "https://bar.com"), + resource.TestCheckResourceAttr(resourceName, "devices_approval_on", "false"), + resource.TestCheckResourceAttr(resourceName, "devices_auto_updates_on", "false"), + resource.TestCheckResourceAttr(resourceName, "devices_key_duration_days", "10"), + resource.TestCheckResourceAttr(resourceName, "users_approval_on", "false"), + resource.TestCheckResourceAttr(resourceName, "users_role_allowed_to_join_external_tailnet", "admin"), + resource.TestCheckResourceAttr(resourceName, "posture_identity_collection_on", "false"), + resource.TestCheckResourceAttr(resourceName, "https_enabled", "false"), + ), + }, { ResourceName: resourceName, ImportState: true, @@ -153,4 +186,93 @@ func TestAccTailscaleTailnetSettings(t *testing.T) { }, }, }) + + // Test that the resource remains unchanged when upgrading + checkResourceIsUnchangedInPluginFramework(t, + testTailnetSettingsCreate, + resource.ComposeTestCheckFunc( + checkResourceRemoteProperties(resourceName, + checkProperties(&tailscale.TailnetSettings{ + ACLsExternallyManagedOn: true, + ACLsExternalLink: "https://foo.com", + DevicesApprovalOn: true, + DevicesAutoUpdatesOn: true, + DevicesKeyDurationDays: 5, + UsersApprovalOn: true, + UsersRoleAllowedToJoinExternalTailnets: tailscale.RoleAllowedToJoinExternalTailnetsMember, + PostureIdentityCollectionOn: true, + HTTPSEnabled: true, + }), + ), + resource.TestCheckResourceAttr(resourceName, "acls_externally_managed_on", "true"), + resource.TestCheckResourceAttr(resourceName, "acls_external_link", "https://foo.com"), + resource.TestCheckResourceAttr(resourceName, "devices_approval_on", "true"), + resource.TestCheckResourceAttr(resourceName, "devices_auto_updates_on", "true"), + resource.TestCheckResourceAttr(resourceName, "devices_key_duration_days", "5"), + resource.TestCheckResourceAttr(resourceName, "users_approval_on", "true"), + resource.TestCheckResourceAttr(resourceName, "users_role_allowed_to_join_external_tailnet", "member"), + resource.TestCheckResourceAttr(resourceName, "posture_identity_collection_on", "true"), + resource.TestCheckResourceAttr(resourceName, "https_enabled", "true"), + )) + + resource.Test(t, resource.TestCase{ + Steps: []resource.TestStep{ + { + ExternalProviders: map[string]resource.ExternalProvider{ + "tailscale": { + VersionConstraint: "0.28.0", + Source: "tailscale/tailscale", + }, + }, + PreConfig: func() { + client := testAccProvider.Meta().(*tailscale.Client) + + // Set all these optional fields to true / something, so if the + // unset value is misinterpreted as the empty value, a change will be + // made and the test will fail. + settingsRequest := tailscale.UpdateTailnetSettingsRequest{ + ACLsExternallyManagedOn: ptr.To(true), + ACLsExternalLink: ptr.To("https://foo.com"), + DevicesApprovalOn: ptr.To(true), + DevicesAutoUpdatesOn: ptr.To(true), + DevicesKeyDurationDays: ptr.To(5), + HTTPSEnabled: ptr.To(true), + NetworkFlowLoggingOn: ptr.To(true), + PostureIdentityCollectionOn: ptr.To(true), + RegionalRoutingOn: ptr.To(true), + UsersApprovalOn: ptr.To(true), + UsersRoleAllowedToJoinExternalTailnets: ptr.To(tailscale.RoleAllowedToJoinExternalTailnets("member")), + } + err := client.TailnetSettings().Update(context.Background(), settingsRequest) + if err != nil { + panic(err) + } + }, + Config: testTailnetSettingsEmpty, + }, + { + ProtoV5ProviderFactories: testAccProviderFactories(t), + Config: testTailnetSettingsEmpty, + PlanOnly: true, + Check: resource.ComposeTestCheckFunc( + checkResourceRemoteProperties(resourceName, + checkProperties(&tailscale.TailnetSettings{ + ACLsExternallyManagedOn: true, + ACLsExternalLink: "https://foo.com", + DevicesApprovalOn: true, + DevicesAutoUpdatesOn: true, + DevicesKeyDurationDays: 5, + HTTPSEnabled: true, + NetworkFlowLoggingOn: true, + PostureIdentityCollectionOn: true, + RegionalRoutingOn: true, + UsersApprovalOn: true, + UsersRoleAllowedToJoinExternalTailnets: "member", + }), + ), + ), + }, + }, + }) + }