Skip to content

Commit a7cdec5

Browse files
authored
Feature server implementation (coder#3899)
* Feature server implementation Signed-off-by: Spike Curtis <spike@coder.com> * Fix imports Signed-off-by: Spike Curtis <spike@coder.com> Signed-off-by: Spike Curtis <spike@coder.com>
1 parent 1b6f9e5 commit a7cdec5

File tree

4 files changed

+402
-11
lines changed

4 files changed

+402
-11
lines changed

coderd/features.go

Lines changed: 60 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@ package coderd
22

33
import (
44
"net/http"
5+
"reflect"
56

7+
"golang.org/x/xerrors"
8+
9+
"github.com/coder/coder/coderd/audit"
610
"github.com/coder/coder/coderd/httpapi"
711
"github.com/coder/coder/codersdk"
812
)
@@ -11,11 +15,10 @@ import (
1115
type FeaturesService interface {
1216
EntitlementsAPI(w http.ResponseWriter, r *http.Request)
1317

14-
// TODO
15-
// Get returns the implementations for feature interfaces. Parameter `s `must be a pointer to a
18+
// Get returns the implementations for feature interfaces. Parameter `s` must be a pointer to a
1619
// struct type containing feature interfaces as fields. The FeatureService sets all fields to
1720
// the correct implementations depending on whether the features are turned on.
18-
// Get(s any) error
21+
Get(s any) error
1922
}
2023

2124
type featuresService struct{}
@@ -34,3 +37,57 @@ func (featuresService) EntitlementsAPI(rw http.ResponseWriter, _ *http.Request)
3437
HasLicense: false,
3538
})
3639
}
40+
41+
// Get returns the implementations for feature interfaces. Parameter `s` must be a pointer to a
42+
// struct type containing feature interfaces as fields. The AGPL featureService always returns the
43+
// "disabled" version of the feature interface because it doesn't include any enterprise features
44+
// by definition.
45+
func (featuresService) Get(ps any) error {
46+
if reflect.TypeOf(ps).Kind() != reflect.Pointer {
47+
return xerrors.New("input must be pointer to struct")
48+
}
49+
vs := reflect.ValueOf(ps).Elem()
50+
if vs.Kind() != reflect.Struct {
51+
return xerrors.New("input must be pointer to struct")
52+
}
53+
for i := 0; i < vs.NumField(); i++ {
54+
vf := vs.Field(i)
55+
tf := vf.Type()
56+
if tf.Kind() != reflect.Interface {
57+
return xerrors.Errorf("fields of input struct must be interfaces: %s", tf.String())
58+
}
59+
err := setImplementation(vf, tf)
60+
if err != nil {
61+
return err
62+
}
63+
}
64+
return nil
65+
}
66+
67+
// setImplementation finds the correct implementation for the field's type, and sets it on the
68+
// struct. It returns an error if unsuccessful
69+
func setImplementation(vf reflect.Value, tf reflect.Type) error {
70+
// when we get more than a few features it might make sense to have a data structure for finding
71+
// the correct implementation that's faster than just a linear search, but for now just spin
72+
// through the implementations we have.
73+
vd := reflect.ValueOf(DisabledImplementations)
74+
for j := 0; j < vd.NumField(); j++ {
75+
vdf := vd.Field(j)
76+
if vdf.Type() == tf {
77+
vf.Set(vdf)
78+
return nil
79+
}
80+
}
81+
return xerrors.Errorf("unable to find implementation of interface %s", tf.String())
82+
}
83+
84+
// FeatureInterfaces contains a field for each interface controlled by an enterprise feature.
85+
type FeatureInterfaces struct {
86+
Auditor audit.Auditor
87+
}
88+
89+
// DisabledImplementations includes all the implementations of turned-off features. There are no
90+
// turned-on implementations in AGPL code.
91+
var DisabledImplementations = FeatureInterfaces{
92+
Auditor: audit.NewNop(),
93+
}

coderd/features_internal_test.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"github.com/stretchr/testify/assert"
1010
"github.com/stretchr/testify/require"
1111

12+
"github.com/coder/coder/coderd/audit"
1213
"github.com/coder/coder/codersdk"
1314
)
1415

@@ -36,3 +37,64 @@ func TestEntitlements(t *testing.T) {
3637
}
3738
})
3839
}
40+
41+
func TestFeaturesServiceGet(t *testing.T) {
42+
t.Parallel()
43+
t.Run("Auditor", func(t *testing.T) {
44+
t.Parallel()
45+
uut := featuresService{}
46+
target := struct {
47+
Auditor audit.Auditor
48+
}{}
49+
err := uut.Get(&target)
50+
require.NoError(t, err)
51+
assert.NotNil(t, target.Auditor)
52+
})
53+
54+
t.Run("NotPointer", func(t *testing.T) {
55+
t.Parallel()
56+
uut := featuresService{}
57+
target := struct {
58+
Auditor audit.Auditor
59+
}{}
60+
err := uut.Get(target)
61+
require.Error(t, err)
62+
assert.Nil(t, target.Auditor)
63+
})
64+
65+
t.Run("UnknownInterface", func(t *testing.T) {
66+
t.Parallel()
67+
uut := featuresService{}
68+
target := struct {
69+
test testInterface
70+
}{}
71+
err := uut.Get(&target)
72+
require.Error(t, err)
73+
assert.Nil(t, target.test)
74+
})
75+
76+
t.Run("PointerToNonStruct", func(t *testing.T) {
77+
t.Parallel()
78+
uut := featuresService{}
79+
var target audit.Auditor
80+
err := uut.Get(&target)
81+
require.Error(t, err)
82+
assert.Nil(t, target)
83+
})
84+
85+
t.Run("StructWithNonInterfaces", func(t *testing.T) {
86+
t.Parallel()
87+
uut := featuresService{}
88+
target := struct {
89+
N int64
90+
Auditor audit.Auditor
91+
}{}
92+
err := uut.Get(&target)
93+
require.Error(t, err)
94+
assert.Nil(t, target.Auditor)
95+
})
96+
}
97+
98+
type testInterface interface {
99+
Test() error
100+
}

enterprise/coderd/features.go

Lines changed: 70 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,23 @@ import (
55
"crypto/ed25519"
66
"fmt"
77
"net/http"
8+
"reflect"
89
"sync"
910
"time"
1011

12+
"github.com/coder/coder/enterprise/audit/backends"
13+
1114
"github.com/cenkalti/backoff/v4"
15+
"golang.org/x/xerrors"
1216

1317
"cdr.dev/slog"
1418

1519
agpl "github.com/coder/coder/coderd"
20+
agplAudit "github.com/coder/coder/coderd/audit"
1621
"github.com/coder/coder/coderd/database"
1722
"github.com/coder/coder/coderd/httpapi"
1823
"github.com/coder/coder/codersdk"
24+
"github.com/coder/coder/enterprise/audit"
1925
)
2026

2127
type Enablements struct {
@@ -29,6 +35,13 @@ type featuresService struct {
2935
keys map[string]ed25519.PublicKey
3036
enablements Enablements
3137
resyncInterval time.Duration
38+
// enabledImplementations includes an "enabled" implementation of every feature. This is
39+
// initialized at start of day and remains static. The consequence of this is that these things
40+
// are hanging around using memory even if not licensed or in use, but it greatly simplifies the
41+
// logic because we don't have to bother creating and destroying them as entitlements change.
42+
// If we have a particularly memory-hungry feature in future, we might wish to reconsider this
43+
// choice.
44+
enabledImplementations agpl.FeatureInterfaces
3245

3346
mu sync.RWMutex
3447
entitlements entitlements
@@ -44,11 +57,18 @@ func newFeaturesService(
4457
enablements Enablements,
4558
) agpl.FeaturesService {
4659
fs := &featuresService{
47-
logger: logger,
48-
database: db,
49-
pubsub: pubsub,
50-
keys: keys,
51-
enablements: enablements,
60+
logger: logger,
61+
database: db,
62+
pubsub: pubsub,
63+
keys: keys,
64+
enablements: enablements,
65+
enabledImplementations: agpl.FeatureInterfaces{
66+
Auditor: audit.NewAuditor(
67+
audit.DefaultFilter,
68+
backends.NewPostgres(db, true),
69+
backends.NewSlog(logger),
70+
),
71+
},
5272
resyncInterval: 10 * time.Minute,
5373
entitlements: entitlements{
5474
activeUsers: numericalEntitlement{
@@ -259,3 +279,48 @@ func max(a, b int64) int64 {
259279
}
260280
return b
261281
}
282+
283+
func (s *featuresService) Get(ps any) error {
284+
if reflect.TypeOf(ps).Kind() != reflect.Pointer {
285+
return xerrors.New("input must be pointer to struct")
286+
}
287+
vs := reflect.ValueOf(ps).Elem()
288+
if vs.Kind() != reflect.Struct {
289+
return xerrors.New("input must be pointer to struct")
290+
}
291+
// grab a local copy of entitlements so that we have a consistent set, but aren't keeping it
292+
// locked from updates while we process.
293+
s.mu.RLock()
294+
ent := s.entitlements
295+
s.mu.RUnlock()
296+
297+
for i := 0; i < vs.NumField(); i++ {
298+
vf := vs.Field(i)
299+
tf := vf.Type()
300+
if tf.Kind() != reflect.Interface {
301+
return xerrors.Errorf("fields of input struct must be interfaces: %s", tf.String())
302+
}
303+
304+
err := s.setImplementation(ent, vf, tf)
305+
if err != nil {
306+
return err
307+
}
308+
}
309+
return nil
310+
}
311+
312+
func (s *featuresService) setImplementation(ent entitlements, vf reflect.Value, tf reflect.Type) error {
313+
// c.f. https://stackoverflow.com/questions/7132848/how-to-get-the-reflect-type-of-an-interface
314+
switch tf {
315+
case reflect.TypeOf((*agplAudit.Auditor)(nil)).Elem():
316+
// Audit logging
317+
if !s.enablements.AuditLogs || ent.auditLogs.state == notEntitled {
318+
vf.Set(reflect.ValueOf(agpl.DisabledImplementations.Auditor))
319+
return nil
320+
}
321+
vf.Set(reflect.ValueOf(s.enabledImplementations.Auditor))
322+
return nil
323+
default:
324+
return xerrors.Errorf("unable to find implementation of interface %s", tf.String())
325+
}
326+
}

0 commit comments

Comments
 (0)