Skip to content

Commit

Permalink
tailscale: allow configuring user data source using login_name
Browse files Browse the repository at this point in the history
This is useful because clients typically have no knowledge of user IDs.

Updates tailscale/corp#21867

Signed-off-by: Percy Wegmann <percy@tailscale.com>
  • Loading branch information
oxtoacart committed Sep 9, 2024
1 parent 5260c20 commit 289a1c0
Show file tree
Hide file tree
Showing 5 changed files with 77 additions and 35 deletions.
4 changes: 2 additions & 2 deletions docs/data-sources/user.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,10 @@ data "tailscale_user" "32571345" {
<!-- schema generated by tfplugindocs -->
## Schema

### Required
### Optional

- `id` (String) The unique identifier for the user.
- `login_name` (String) The emailish login name of the user.

### Read-Only

Expand All @@ -32,7 +33,6 @@ data "tailscale_user" "32571345" {
- `device_count` (Number) Number of devices the user owns.
- `display_name` (String) The name of the user.
- `last_seen` (String) The later of either: a) The last time any of the user's nodes were connected to the network or b) The last time the user authenticated to any tailscale service, including the admin panel.
- `login_name` (String) The emailish login name of the user.
- `profile_pic_url` (String) The profile pic URL for the user.
- `role` (String) The role of the user.
- `status` (String) The status of the user.
Expand Down
63 changes: 43 additions & 20 deletions tailscale/data_source_user.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package tailscale

import (
"context"
"errors"
"time"

"github.com/hashicorp/terraform-plugin-sdk/v2/diag"
Expand All @@ -11,22 +10,12 @@ import (
tsclient "github.com/tailscale/tailscale-client-go/v2"
)

var userSchema = map[string]*schema.Schema{
"id": {
Type: schema.TypeString,
Description: "The unique identifier for the user.",
Required: true,
},
var commonUserSchema = map[string]*schema.Schema{
"display_name": {
Type: schema.TypeString,
Description: "The name of the user.",
Computed: true,
},
"login_name": {
Type: schema.TypeString,
Description: "The emailish login name of the user.",
Computed: true,
},
"profile_pic_url": {
Type: schema.TypeString,
Description: "The profile pic URL for the user.",
Expand Down Expand Up @@ -78,32 +67,66 @@ func dataSourceUser() *schema.Resource {
return &schema.Resource{
Description: "The user data source describes a single user in a tailnet",
ReadContext: dataSourceUserRead,
Schema: userSchema,
Schema: combinedSchemas(commonUserSchema, map[string]*schema.Schema{
"id": {
Type: schema.TypeString,
Description: "The unique identifier for the user.",
Optional: true,
ExactlyOneOf: []string{"id", "login_name"},
},
"login_name": {
Type: schema.TypeString,
Description: "The emailish login name of the user.",
Optional: true,
ExactlyOneOf: []string{"id", "login_name"},
},
}),
}
}

func dataSourceUserRead(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics {
client := m.(*tsclient.Client)

id, hasID := d.Get("id").(string)
if !hasID {
return diagnosticsError(errors.New("data_source_user missing user ID"), "data_source_user missing user ID")
if id := d.Id(); id != "" {
user, err := client.Users().Get(ctx, id)
if err != nil {
return diagnosticsError(err, "Failed to fetch user with id %s", id)
}
return setProperties(d, userToMap(user))
}

user, err := client.Users().Get(ctx, id)
loginName, ok := d.GetOk("login_name")
if !ok {
return diag.Errorf("please specify an id or login_name for the user")
}

users, err := client.Users().List(ctx, nil, nil)
if err != nil {
return diagnosticsError(err, "Failed to fetch user with id %s", id)
return diagnosticsError(err, "Failed to fetch users")
}

var selected *tsclient.User
for _, user := range users {
if user.LoginName == loginName.(string) {
selected = &user
break
}
}

if selected == nil {
return diag.Errorf("Could not find user with login name %s", loginName)
}

d.SetId(user.ID)
return setProperties(d, userToMap(user))
d.SetId(selected.ID)
return setProperties(d, userToMap(selected))
}

// userToMap converts the given user into a map representing the user as a
// resource in Terraform. This omits the "id" which is expected to be set
// using [schema.ResourceData.SetId].
func userToMap(user *tsclient.User) map[string]any {
return map[string]any{
"id": user.ID,
"display_name": user.DisplayName,
"login_name": user.LoginName,
"profile_pic_url": user.ProfilePicURL,
Expand Down
14 changes: 12 additions & 2 deletions tailscale/data_source_users.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,18 @@ func dataSourceUsers() *schema.Resource {
Type: schema.TypeList,
Description: "The list of users in the tailnet",
Elem: &schema.Resource{
Schema: userSchema,
Schema: combinedSchemas(commonUserSchema, map[string]*schema.Schema{
"id": {
Type: schema.TypeString,
Description: "The unique identifier for the user.",
Computed: true,
},
"login_name": {
Type: schema.TypeString,
Description: "The emailish login name of the user.",
Computed: true,
},
}),
},
},
},
Expand Down Expand Up @@ -77,7 +88,6 @@ func dataSourceUsersRead(ctx context.Context, d *schema.ResourceData, m interfac
userMaps := make([]map[string]interface{}, 0, len(users))
for _, user := range users {
m := userToMap(&user)
m["id"] = user.ID
userMaps = append(userMaps, m)
}

Expand Down
21 changes: 10 additions & 11 deletions tailscale/data_source_users_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,27 +33,26 @@ func TestAccTailscaleUsers(t *testing.T) {
return fmt.Errorf("unable to list users: %s", err)
}

usersByID := make(map[string]map[string]any)
usersByLoginName := make(map[string]map[string]any)
for _, user := range users {
m := userToMap(&user)
m["id"] = user.ID
usersByID[user.ID] = m
usersByLoginName[user.LoginName] = m
}

rs := s.RootModule().Resources[resourceName].Primary

// first find indexes for users
userIndexes := make(map[string]string)
for k, v := range rs.Attributes {
if strings.HasSuffix(k, ".id") {
if strings.HasSuffix(k, ".login_name") {
idx := strings.Split(k, ".")[1]
userIndexes[idx] = v
}
}

// make sure we got the right number of users
if len(userIndexes) != len(usersByID) {
return fmt.Errorf("wrong number of users in datasource, want %d, got %d", len(usersByID), len(userIndexes))
if len(userIndexes) != len(usersByLoginName) {
return fmt.Errorf("wrong number of users in datasource, want %d, got %d", len(usersByLoginName), len(userIndexes))
}

// now compare datasource attributes to expected values
Expand All @@ -68,18 +67,18 @@ func TestAccTailscaleUsers(t *testing.T) {
continue
}
idx := parts[1]
id := userIndexes[idx]
expected := fmt.Sprint(usersByID[id][prop])
loginName := userIndexes[idx]
expected := fmt.Sprint(usersByLoginName[loginName][prop])
if v != expected {
return fmt.Errorf("wrong value of %s for user %s, want %q, got %q", prop, id, expected, v)
return fmt.Errorf("wrong value of %s for user %s, want %q, got %q", prop, loginName, expected, v)
}
}
}

// Now set up user datasources for each user. This is used in the following test
// of the tailscale_user datasource.
for id := range usersByID {
userDataSources.WriteString(fmt.Sprintf("\ndata \"tailscale_user\" \"%s\" {\n id = \"%s\"\n}\n", id, id))
for loginName, user := range usersByLoginName {
userDataSources.WriteString(fmt.Sprintf("\ndata \"tailscale_user\" \"%s\" {\n login_name = \"%s\"\n}\n", user["id"], loginName))
}

return nil
Expand Down
10 changes: 10 additions & 0 deletions tailscale/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package tailscale
import (
"context"
"fmt"
"maps"
"net/url"
"os"
"time"
Expand Down Expand Up @@ -288,3 +289,12 @@ func optional[T any](d *schema.ResourceData, key string) *T {
func isAcceptanceTesting() bool {
return os.Getenv("TF_ACC") != ""
}

// combinedSchemas creates a schema that combines two supplied schemas.
// Properties in schema b overwrite the same properties in schema b.
func combinedSchemas(a, b map[string]*schema.Schema) map[string]*schema.Schema {
out := make(map[string]*schema.Schema, len(a)+len(b))
maps.Copy(out, a)
maps.Copy(out, b)
return out
}

0 comments on commit 289a1c0

Please sign in to comment.