From 20c1148a84a1f7415beaf0ab31b8641bd58f3f63 Mon Sep 17 00:00:00 2001 From: mcoulombe Date: Fri, 13 Jun 2025 09:09:44 -0400 Subject: [PATCH] resource_tailscale_oauth_client: add import support resource_tailscale_key: handle expiry on imports to avoid systematic recreates Fixes #515 Signed-off-by: mcoulombe --- docs/resources/oauth_client.md | 9 +++++++++ .../tailscale_oauth_client/import.sh | 2 ++ go.mod | 2 +- go.sum | 4 ++-- tailscale/resource_oauth_client.go | 12 +++++++++++- tailscale/resource_oauth_client_test.go | 10 ++++++++++ tailscale/resource_tailnet_key.go | 4 ++++ tailscale/resource_tailnet_key_test.go | 19 +++++++++++++------ 8 files changed, 52 insertions(+), 10 deletions(-) create mode 100644 examples/resources/tailscale_oauth_client/import.sh diff --git a/docs/resources/oauth_client.md b/docs/resources/oauth_client.md index fa738bc2..200b4462 100644 --- a/docs/resources/oauth_client.md +++ b/docs/resources/oauth_client.md @@ -38,3 +38,12 @@ resource "tailscale_oauth_client" "sample_client" { - `id` (String) The client ID, also known as the key id. Used with the client secret to generate access tokens. - `key` (String, Sensitive) The client secret, also known as the key. Used with the client ID to generate access tokens. - `user_id` (String) ID of the user who created this key, empty for OAuth clients created by other OAuth clients. + +## Import + +Import is supported using the following syntax: + +```shell +# Note: Sensitive fields such as the secret key are not returned by the API and will be unset in the Terraform state after import. +terraform import tailscale_oauth_client.example k1234511CNTRL +``` diff --git a/examples/resources/tailscale_oauth_client/import.sh b/examples/resources/tailscale_oauth_client/import.sh new file mode 100644 index 00000000..bbe6c7ba --- /dev/null +++ b/examples/resources/tailscale_oauth_client/import.sh @@ -0,0 +1,2 @@ +# Note: Sensitive fields such as the secret key are not returned by the API and will be unset in the Terraform state after import. +terraform import tailscale_oauth_client.example k1234511CNTRL \ No newline at end of file diff --git a/go.mod b/go.mod index f3e18c6b..abbbd710 100644 --- a/go.mod +++ b/go.mod @@ -12,7 +12,7 @@ require ( github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a golang.org/x/tools v0.34.0 tailscale.com v1.84.2 - tailscale.com/client/tailscale/v2 v2.0.0-20250602205246-d51fc603f5ea + tailscale.com/client/tailscale/v2 v2.0.0-20250616133344-8dcb33eb281b ) require github.com/pkg/errors v0.9.1 diff --git a/go.sum b/go.sum index 696eaa3d..40f2a4a5 100644 --- a/go.sum +++ b/go.sum @@ -310,5 +310,5 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= tailscale.com v1.84.2 h1:v6aM4RWUgYiV52LRAx6ET+dlGnvO/5lnqPXb7/pMnR0= tailscale.com v1.84.2/go.mod h1:6/S63NMAhmncYT/1zIPDJkvCuZwMw+JnUuOfSPNazpo= -tailscale.com/client/tailscale/v2 v2.0.0-20250602205246-d51fc603f5ea h1:lXgaPz+scY0fqkoXfy6TpX9lpP4+dRa3Sv+YHQujFOk= -tailscale.com/client/tailscale/v2 v2.0.0-20250602205246-d51fc603f5ea/go.mod h1:nzqx3Hs59z2W8Gnmq2ChavPButcyvtxAxRpNc+ZVy7s= +tailscale.com/client/tailscale/v2 v2.0.0-20250616133344-8dcb33eb281b h1:Cyso/184f0BUfEDUWVmM65t6byUEB8wuTBGpB8Tv1PA= +tailscale.com/client/tailscale/v2 v2.0.0-20250616133344-8dcb33eb281b/go.mod h1:nzqx3Hs59z2W8Gnmq2ChavPButcyvtxAxRpNc+ZVy7s= diff --git a/tailscale/resource_oauth_client.go b/tailscale/resource_oauth_client.go index 779d4167..d7e690d6 100644 --- a/tailscale/resource_oauth_client.go +++ b/tailscale/resource_oauth_client.go @@ -21,7 +21,9 @@ func resourceOAuthClient() *schema.Resource { CreateContext: resourceOAuthClientCreate, DeleteContext: resourceOAuthClientDelete, UpdateContext: nil, - // Importer: &schema.ResourceImporter{StateContext: schema.ImportStatePassthroughContext}, no import support - the key is not returned by the API so it'd serve no purpose + Importer: &schema.ResourceImporter{ + StateContext: schema.ImportStatePassthroughContext, + }, Schema: map[string]*schema.Schema{ "description": { Type: schema.TypeString, @@ -90,6 +92,14 @@ func resourceOAuthClientRead(ctx context.Context, d *schema.ResourceData, m inte return diagnosticsError(err, "Failed to set description") } + if err = d.Set("scopes", key.Scopes); err != nil { + return diagnosticsError(err, "Failed to set 'scopes'") + } + + if err = d.Set("tags", key.Tags); err != nil { + return diagnosticsError(err, "Failed to set 'tags'") + } + if err = d.Set("created_at", key.Created.Format(time.RFC3339)); err != nil { return diagnosticsError(err, "Failed to set created_at") } diff --git a/tailscale/resource_oauth_client_test.go b/tailscale/resource_oauth_client_test.go index 7e6ddfea..ab3787a1 100644 --- a/tailscale/resource_oauth_client_test.go +++ b/tailscale/resource_oauth_client_test.go @@ -60,10 +60,14 @@ func TestAccTailscaleOAuthClient(t *testing.T) { var expectedOAuthClientCreated tailscale.Key expectedOAuthClientCreated.Description = "Test client" expectedOAuthClientCreated.KeyType = "client" + expectedOAuthClientCreated.Scopes = []string{"auth_keys", "devices:core"} + expectedOAuthClientCreated.Tags = []string{"tag:test"} var expectedOAuthClientUpdated tailscale.Key expectedOAuthClientUpdated.Description = "Updated description" expectedOAuthClientUpdated.KeyType = "client" + expectedOAuthClientUpdated.Scopes = []string{"auth_keys:read"} + expectedOAuthClientUpdated.Tags = nil checkProperties := func(expected *tailscale.Key) func(client *tailscale.Client, rs *terraform.ResourceState) error { return func(client *tailscale.Client, rs *terraform.ResourceState) error { @@ -153,6 +157,12 @@ func TestAccTailscaleOAuthClient(t *testing.T) { resource.TestCheckResourceAttrSet(resourceName, "user_id"), ), }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"key"}, // sensitive material not returned by the API + }, }, }) } diff --git a/tailscale/resource_tailnet_key.go b/tailscale/resource_tailnet_key.go index cb58a499..ef3058af 100644 --- a/tailscale/resource_tailnet_key.go +++ b/tailscale/resource_tailnet_key.go @@ -255,6 +255,10 @@ func resourceTailnetKeyRead(ctx context.Context, d *schema.ResourceData, m inter return diagnosticsError(err, "Failed to set ephemeral") } + if err = d.Set("expiry", key.ExpirySeconds); err != nil { + return diagnosticsError(err, "Failed to set expiry") + } + if err = d.Set("created_at", key.Created.Format(time.RFC3339)); err != nil { return diagnosticsError(err, "Failed to set created_at") } diff --git a/tailscale/resource_tailnet_key_test.go b/tailscale/resource_tailnet_key_test.go index ee27389e..bd203f3f 100644 --- a/tailscale/resource_tailnet_key_test.go +++ b/tailscale/resource_tailnet_key_test.go @@ -64,11 +64,12 @@ func testTailnetKeyStruct(reusable bool) tailscale.Key { }`), &keyCapabilities) keyCapabilities.Devices.Create.Reusable = reusable return tailscale.Key{ - ID: "test", - KeyType: "auth", - Key: "thisisatestkey", - Description: "Example key", - Capabilities: keyCapabilities, + ID: "test", + KeyType: "auth", + Key: "thisisatestkey", + Description: "Example key", + ExpirySeconds: toPtr(time.Duration(3600)), + Capabilities: keyCapabilities, } } @@ -230,6 +231,7 @@ func TestAccTailscaleTailnetKey(t *testing.T) { var expectedKey tailscale.Key expectedKey.KeyType = "auth" expectedKey.Description = "Test key" + expectedKey.ExpirySeconds = toPtr(time.Duration(3600)) expectedKey.Capabilities.Devices.Create.Reusable = true expectedKey.Capabilities.Devices.Create.Ephemeral = true expectedKey.Capabilities.Devices.Create.Preauthorized = true @@ -238,6 +240,7 @@ func TestAccTailscaleTailnetKey(t *testing.T) { var expectedKeyUpdated tailscale.Key expectedKeyUpdated.KeyType = "auth" expectedKeyUpdated.Description = "Test key changed" + expectedKeyUpdated.ExpirySeconds = toPtr(time.Duration(7200)) expectedKeyUpdated.Capabilities.Devices.Create.Reusable = false expectedKeyUpdated.Capabilities.Devices.Create.Ephemeral = false expectedKeyUpdated.Capabilities.Devices.Create.Preauthorized = false @@ -293,8 +296,12 @@ func TestAccTailscaleTailnetKey(t *testing.T) { ResourceName: resourceName, ImportState: true, ImportStateVerify: true, - ImportStateVerifyIgnore: []string{"key", "expiry"}, + ImportStateVerifyIgnore: []string{"key"}, // sensitive material not returned by the API }, }, }) } + +func toPtr[T any](v T) *T { + return &v +}