Skip to content

Commit f195a20

Browse files
authored
Avoid serializing empty ACL fields (#39)
This should avoid meaningless diffs and unnecessary empty values while managing ACL contents via Terraform. Fixes #31
1 parent b1040ba commit f195a20

File tree

3 files changed

+109
-43
lines changed

3 files changed

+109
-43
lines changed

tailscale/client.go

Lines changed: 31 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -230,7 +230,7 @@ func (c *Client) DNSNameservers(ctx context.Context) ([]string, error) {
230230

231231
type (
232232
ACL struct {
233-
ACLs []ACLEntry `json:"acls" hujson:"ACLs,omitempty"`
233+
ACLs []ACLEntry `json:"acls,omitempty" hujson:"ACLs,omitempty"`
234234
AutoApprovers *ACLAutoApprovers `json:"autoapprovers,omitempty" hujson:"AutoApprovers,omitempty"`
235235
Groups map[string][]string `json:"groups,omitempty" hujson:"Groups,omitempty"`
236236
Hosts map[string]string `json:"hosts,omitempty" hujson:"Hosts,omitempty"`
@@ -245,25 +245,25 @@ type (
245245
}
246246

247247
ACLAutoApprovers struct {
248-
Routes map[string][]string `json:"routes" hujson:"Routes"`
249-
ExitNode []string `json:"exitNode" hujson:"ExitNode"`
248+
Routes map[string][]string `json:"routes,omitempty" hujson:"Routes,omitempty"`
249+
ExitNode []string `json:"exitNode,omitempty" hujson:"ExitNode,omitempty"`
250250
}
251251

252252
ACLEntry struct {
253-
Action string `json:"action" hujson:"Action"`
254-
Ports []string `json:"ports" hujson:"Ports"`
255-
Users []string `json:"users" hujson:"Users"`
256-
Source []string `json:"src" hujson:"Src"`
257-
Destination []string `json:"dst" hujson:"Dst"`
258-
Protocol string `json:"proto" hujson:"Proto"`
253+
Action string `json:"action,omitempty" hujson:"Action,omitempty"`
254+
Ports []string `json:"ports,omitempty" hujson:"Ports,omitempty"`
255+
Users []string `json:"users,omitempty" hujson:"Users,omitempty"`
256+
Source []string `json:"src,omitempty" hujson:"Src,omitempty"`
257+
Destination []string `json:"dst,omitempty" hujson:"Dst,omitempty"`
258+
Protocol string `json:"proto,omitempty" hujson:"Proto,omitempty"`
259259
}
260260

261261
ACLTest struct {
262-
User string `json:"user" hujson:"User"`
263-
Allow []string `json:"allow" hujson:"Allow"`
264-
Deny []string `json:"deny" hujson:"Deny"`
265-
Source string `json:"src" hujson:"Src"`
266-
Accept []string `json:"accept" hujson:"Accept"`
262+
User string `json:"user,omitempty" hujson:"User,omitempty"`
263+
Allow []string `json:"allow,omitempty" hujson:"Allow,omitempty"`
264+
Deny []string `json:"deny,omitempty" hujson:"Deny,omitempty"`
265+
Source string `json:"src,omitempty" hujson:"Src,omitempty"`
266+
Accept []string `json:"accept,omitempty" hujson:"Accept,omitempty"`
267267
}
268268

269269
ACLDERPMap struct {
@@ -294,11 +294,11 @@ type (
294294
}
295295

296296
ACLSSH struct {
297-
Action string `json:"action" hujson:"Action"`
298-
Users []string `json:"users" hujson:"Users"`
299-
Source []string `json:"src" hujson:"Src"`
300-
Destination []string `json:"dst" hujson:"Dst"`
301-
CheckPeriod Duration `json:"checkPeriod" hujson:"CheckPeriod"`
297+
Action string `json:"action,omitempty" hujson:"Action,omitempty"`
298+
Users []string `json:"users,omitempty" hujson:"Users,omitempty"`
299+
Source []string `json:"src,omitempty" hujson:"Src,omitempty"`
300+
Destination []string `json:"dst,omitempty" hujson:"Dst,omitempty"`
301+
CheckPeriod Duration `json:"checkPeriod,omitempty" hujson:"CheckPeriod,omitempty"`
302302
}
303303

304304
NodeAttrGrant struct {
@@ -694,31 +694,25 @@ func ErrorData(err error) []APIErrorData {
694694

695695
// The Duration type wraps a time.Duration, allowing it to be JSON marshalled as a string like "20h" rather than
696696
// a numeric value.
697-
type Duration struct {
698-
time.Duration
699-
}
697+
type Duration time.Duration
700698

701-
// MarshalJSON is an implementation of json.Marshal.
702-
func (d Duration) MarshalJSON() ([]byte, error) {
703-
return json.Marshal(d.Duration.String())
699+
func (d Duration) String() string {
700+
return time.Duration(d).String()
704701
}
705702

706-
// UnmarshalJSON unmarshals the content of data as a time.Duration, a blank string will keep the duration at its zero value.
707-
func (d *Duration) UnmarshalJSON(data []byte) error {
708-
if string(data) == `""` {
709-
return nil
710-
}
703+
func (d Duration) MarshalText() ([]byte, error) {
704+
return []byte(d.String()), nil
705+
}
711706

712-
var str string
713-
if err := json.Unmarshal(data, &str); err != nil {
714-
return err
707+
func (d *Duration) UnmarshalText(b []byte) error {
708+
text := string(b)
709+
if text == "" {
710+
text = "0s"
715711
}
716-
717-
dur, err := time.ParseDuration(str)
712+
pd, err := time.ParseDuration(text)
718713
if err != nil {
719714
return err
720715
}
721-
722-
d.Duration = dur
716+
*d = Duration(pd)
723717
return nil
724718
}

tailscale/client_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ func TestACL_Unmarshal(t *testing.T) {
116116
Source: []string{"tag:logging"},
117117
Destination: []string{"tag:prod"},
118118
Users: []string{"root", "autogroup:nonroot"},
119-
CheckPeriod: tailscale.Duration{Duration: time.Hour * 20},
119+
CheckPeriod: tailscale.Duration(time.Hour * 20),
120120
},
121121
},
122122
},
@@ -195,7 +195,7 @@ func TestACL_Unmarshal(t *testing.T) {
195195
Source: []string{"tag:logging"},
196196
Destination: []string{"tag:prod"},
197197
Users: []string{"root", "autogroup:nonroot"},
198-
CheckPeriod: tailscale.Duration{Duration: time.Hour * 20},
198+
CheckPeriod: tailscale.Duration(time.Hour * 20),
199199
},
200200
},
201201
Tests: []tailscale.ACLTest{

tailscale/time_test.go

Lines changed: 76 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package tailscale_test
22

33
import (
4+
"encoding/json"
45
"testing"
56
"time"
67

@@ -57,8 +58,79 @@ func TestMarshalingTimestamps(t *testing.T) {
5758
}
5859
}
5960

60-
func TestWrapsStdDuration(t *testing.T) {
61-
expectedDuration := tailscale.Duration{}
62-
newDuration := time.Duration(0)
63-
assert.Equal(t, expectedDuration.Duration, newDuration)
61+
func TestDurationUnmarshal(t *testing.T) {
62+
t.Parallel()
63+
64+
tt := []struct {
65+
Name string
66+
Content string
67+
Expected tailscale.Duration
68+
}{
69+
{
70+
Name: "It should handle empty string as zero value",
71+
Content: `""`,
72+
Expected: tailscale.Duration(0),
73+
},
74+
{
75+
Name: "It should handle null as zero value",
76+
Content: `null`,
77+
Expected: tailscale.Duration(0),
78+
},
79+
{
80+
Name: "It should handle 0s as zero value",
81+
Content: `"0s"`,
82+
Expected: tailscale.Duration(0),
83+
},
84+
{
85+
Name: "It should parse duration strings",
86+
Content: `"15s"`,
87+
Expected: tailscale.Duration(15 * time.Second),
88+
},
89+
}
90+
91+
for _, tc := range tt {
92+
t.Run(tc.Name, func(t *testing.T) {
93+
var actual tailscale.Duration
94+
95+
assert.NoError(t, json.Unmarshal([]byte(tc.Content), &actual))
96+
assert.Equal(t, tc.Expected, actual)
97+
})
98+
}
99+
}
100+
101+
func TestDurationMarshal(t *testing.T) {
102+
t.Parallel()
103+
104+
tt := []struct {
105+
Name string
106+
Content any
107+
Expected string
108+
}{
109+
{
110+
Name: "It should marshal zero duration as 0s",
111+
Content: struct{ D tailscale.Duration }{tailscale.Duration(0)},
112+
Expected: `{"D":"0s"}`,
113+
},
114+
{
115+
Name: "It should not marshal zero duration if omitempty",
116+
Content: struct {
117+
D tailscale.Duration `json:"d,omitempty"`
118+
}{tailscale.Duration(0)},
119+
Expected: `{}`,
120+
},
121+
{
122+
Name: "It should marshal duration correctly",
123+
Content: struct{ D tailscale.Duration }{tailscale.Duration(15 * time.Second)},
124+
Expected: `{"D":"15s"}`,
125+
},
126+
}
127+
128+
for _, tc := range tt {
129+
t.Run(tc.Name, func(t *testing.T) {
130+
actual, err := json.Marshal(tc.Content)
131+
132+
assert.NoError(t, err)
133+
assert.Equal(t, tc.Expected, string(actual))
134+
})
135+
}
64136
}

0 commit comments

Comments
 (0)