Skip to content

Commit abff96b

Browse files
committed
Add replicas endpoint
1 parent 6fa941f commit abff96b

File tree

13 files changed

+143
-40
lines changed

13 files changed

+143
-40
lines changed

coderd/rbac/object.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,10 @@ var (
146146
ResourceDeploymentFlags = Object{
147147
Type: "deployment_flags",
148148
}
149+
150+
ResourceReplicas = Object{
151+
Type: "replicas",
152+
}
149153
)
150154

151155
// Object is used to create objects for authz checks when you have none in

codersdk/deployment.go

Lines changed: 0 additions & 26 deletions
This file was deleted.

codersdk/replicas.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package codersdk
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"net/http"
7+
"time"
8+
9+
"github.com/google/uuid"
10+
"golang.org/x/xerrors"
11+
)
12+
13+
type Replica struct {
14+
// ID is the unique identifier for the replica.
15+
ID uuid.UUID `json:"id"`
16+
// Hostname is the hostname of the replica.
17+
Hostname string `json:"hostname"`
18+
// CreatedAt is when the replica was first seen.
19+
CreatedAt time.Time `json:"created_at"`
20+
// RelayAddress is the accessible address to relay DERP connections.
21+
RelayAddress string `json:"relay_address"`
22+
// RegionID is the region of the replica.
23+
RegionID int32 `json:"region_id"`
24+
// Error is the error.
25+
Error string `json:"error"`
26+
}
27+
28+
// Replicas fetches the list of replicas.
29+
func (c *Client) Replicas(ctx context.Context) ([]Replica, error) {
30+
res, err := c.Request(ctx, http.MethodGet, "/api/v2/replicas", nil)
31+
if err != nil {
32+
return nil, xerrors.Errorf("execute request: %w", err)
33+
}
34+
defer res.Body.Close()
35+
36+
if res.StatusCode != http.StatusOK {
37+
return nil, readBodyAsError(res)
38+
}
39+
40+
var replicas []Replica
41+
return replicas, json.NewDecoder(res.Body).Decode(&replicas)
42+
}

codersdk/workspaceagents.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,13 +331,17 @@ func (c *Client) ListenWorkspaceAgentTailnet(ctx context.Context) (net.Conn, err
331331
return websocket.NetConn(ctx, conn, websocket.MessageBinary), nil
332332
}
333333

334+
// @typescript-ignore DialWorkspaceAgentOptions
334335
type DialWorkspaceAgentOptions struct {
335336
Logger slog.Logger
336337
// BlockEndpoints forced a direct connection through DERP.
337338
BlockEndpoints bool
338339
}
339340

340341
func (c *Client) DialWorkspaceAgent(ctx context.Context, agentID uuid.UUID, options *DialWorkspaceAgentOptions) (*AgentConn, error) {
342+
if options == nil {
343+
options = &DialWorkspaceAgentOptions{}
344+
}
341345
res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/workspaceagents/%s/connection", agentID), nil)
342346
if err != nil {
343347
return nil, err

enterprise/cli/features_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ func TestFeaturesList(t *testing.T) {
5757
var entitlements codersdk.Entitlements
5858
err := json.Unmarshal(buf.Bytes(), &entitlements)
5959
require.NoError(t, err, "unmarshal JSON output")
60-
assert.Len(t, entitlements.Features, 6)
60+
assert.Len(t, entitlements.Features, 7)
6161
assert.Empty(t, entitlements.Warnings)
6262
assert.Equal(t, codersdk.EntitlementNotEntitled,
6363
entitlements.Features[codersdk.FeatureUserLimit].Entitlement)

enterprise/coderd/coderd.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,10 @@ func New(ctx context.Context, options *Options) (*API, error) {
5959

6060
api.AGPL.APIHandler.Group(func(r chi.Router) {
6161
r.Get("/entitlements", api.serveEntitlements)
62+
r.Route("/replicas", func(r chi.Router) {
63+
r.Use(apiKeyMiddleware)
64+
r.Get("/", api.replicas)
65+
})
6266
r.Route("/licenses", func(r chi.Router) {
6367
r.Use(apiKeyMiddleware)
6468
r.Post("/", api.postLicense)

enterprise/coderd/coderd_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ func TestEntitlements(t *testing.T) {
8585
assert.False(t, res.HasLicense)
8686
al = res.Features[codersdk.FeatureAuditLog]
8787
assert.Equal(t, codersdk.EntitlementNotEntitled, al.Entitlement)
88-
assert.True(t, al.Enabled)
88+
assert.False(t, al.Enabled)
8989
})
9090
t.Run("Pubsub", func(t *testing.T) {
9191
t.Parallel()

enterprise/coderd/coderdenttest/coderdenttest_test.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,10 @@ func TestAuthorizeAllEndpoints(t *testing.T) {
5858
AssertAction: rbac.ActionRead,
5959
AssertObject: rbac.ResourceLicense,
6060
}
61+
assertRoute["GET:/api/v2/replicas"] = coderdtest.RouteCheck{
62+
AssertAction: rbac.ActionRead,
63+
AssertObject: rbac.ResourceReplicas,
64+
}
6165
assertRoute["DELETE:/api/v2/licenses/{id}"] = coderdtest.RouteCheck{
6266
AssertAction: rbac.ActionDelete,
6367
AssertObject: rbac.ResourceLicense,

enterprise/coderd/replicas.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,36 @@
11
package coderd
2+
3+
import (
4+
"net/http"
5+
6+
"github.com/coder/coder/coderd/database"
7+
"github.com/coder/coder/coderd/httpapi"
8+
"github.com/coder/coder/coderd/rbac"
9+
"github.com/coder/coder/codersdk"
10+
)
11+
12+
// replicas returns the number of replicas that are active in Coder.
13+
func (api *API) replicas(rw http.ResponseWriter, r *http.Request) {
14+
if !api.AGPL.Authorize(r, rbac.ActionRead, rbac.ResourceReplicas) {
15+
httpapi.ResourceNotFound(rw)
16+
return
17+
}
18+
19+
replicas := api.replicaManager.All()
20+
res := make([]codersdk.Replica, 0, len(replicas))
21+
for _, replica := range replicas {
22+
res = append(res, convertReplica(replica))
23+
}
24+
httpapi.Write(r.Context(), rw, http.StatusOK, res)
25+
}
26+
27+
func convertReplica(replica database.Replica) codersdk.Replica {
28+
return codersdk.Replica{
29+
ID: replica.ID,
30+
Hostname: replica.Hostname,
31+
CreatedAt: replica.CreatedAt,
32+
RelayAddress: replica.RelayAddress,
33+
RegionID: replica.RegionID,
34+
Error: replica.Error.String,
35+
}
36+
}

enterprise/coderd/replicas_test.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,10 @@ func TestReplicas(t *testing.T) {
6363
},
6464
})
6565
secondClient.SessionToken = firstClient.SessionToken
66+
replicas, err := secondClient.Replicas(context.Background())
67+
require.NoError(t, err)
68+
require.Len(t, replicas, 2)
69+
6670
agentID := setupWorkspaceAgent(t, firstClient, firstUser)
6771
conn, err := secondClient.DialWorkspaceAgent(context.Background(), agentID, &codersdk.DialWorkspaceAgentOptions{
6872
BlockEndpoints: true,
@@ -76,5 +80,6 @@ func TestReplicas(t *testing.T) {
7680
return err == nil
7781
}, testutil.WaitLong, testutil.IntervalFast)
7882
_ = conn.Close()
83+
7984
})
8085
}

enterprise/replicasync/replicasync.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,14 @@ func New(ctx context.Context, logger slog.Logger, db database.Store, pubsub data
101101
if err != nil {
102102
return nil, xerrors.Errorf("run replica: %w", err)
103103
}
104+
peers := server.Regional()
105+
if len(peers) > 0 {
106+
self := server.Self()
107+
if self.RelayAddress == "" {
108+
return nil, xerrors.Errorf("a relay address must be specified when running multiple replicas in the same region")
109+
}
110+
}
111+
104112
err = server.subscribe(ctx)
105113
if err != nil {
106114
return nil, xerrors.Errorf("subscribe: %w", err)

enterprise/replicasync/replicasync_test.go

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import (
55
"net/http"
66
"net/http/httptest"
77
"sync"
8-
"sync/atomic"
98
"testing"
109
"time"
1110

@@ -66,6 +65,25 @@ func TestReplica(t *testing.T) {
6665
_ = server.Close()
6766
require.NoError(t, err)
6867
})
68+
t.Run("ErrorsWithoutRelayAddress", func(t *testing.T) {
69+
// Ensures that the replica reports a successful status for
70+
// accessing all of its peers.
71+
t.Parallel()
72+
db, pubsub := dbtestutil.NewDB(t)
73+
_, err := db.InsertReplica(context.Background(), database.InsertReplicaParams{
74+
ID: uuid.New(),
75+
CreatedAt: database.Now(),
76+
StartedAt: database.Now(),
77+
UpdatedAt: database.Now(),
78+
Hostname: "something",
79+
})
80+
require.NoError(t, err)
81+
_, err = replicasync.New(context.Background(), slogtest.Make(t, nil), db, pubsub, replicasync.Options{
82+
ID: uuid.New(),
83+
})
84+
require.Error(t, err)
85+
require.Equal(t, "a relay address must be specified when running multiple replicas in the same region", err.Error())
86+
})
6987
t.Run("ConnectsToPeerReplica", func(t *testing.T) {
7088
// Ensures that the replica reports a successful status for
7189
// accessing all of its peers.
@@ -85,7 +103,8 @@ func TestReplica(t *testing.T) {
85103
})
86104
require.NoError(t, err)
87105
server, err := replicasync.New(context.Background(), slogtest.Make(t, nil), db, pubsub, replicasync.Options{
88-
ID: uuid.New(),
106+
ID: uuid.New(),
107+
RelayAddress: "http://169.254.169.254",
89108
})
90109
require.NoError(t, err)
91110
require.Len(t, server.Regional(), 1)
@@ -96,12 +115,6 @@ func TestReplica(t *testing.T) {
96115
t.Run("ConnectsToFakePeerWithError", func(t *testing.T) {
97116
t.Parallel()
98117
db, pubsub := dbtestutil.NewDB(t)
99-
var count atomic.Int32
100-
cancel, err := pubsub.Subscribe(replicasync.PubsubEvent, func(ctx context.Context, message []byte) {
101-
count.Add(1)
102-
})
103-
require.NoError(t, err)
104-
defer cancel()
105118
peer, err := db.InsertReplica(context.Background(), database.InsertReplicaParams{
106119
ID: uuid.New(),
107120
CreatedAt: database.Now(),
@@ -113,16 +126,15 @@ func TestReplica(t *testing.T) {
113126
})
114127
require.NoError(t, err)
115128
server, err := replicasync.New(context.Background(), slogtest.Make(t, nil), db, pubsub, replicasync.Options{
116-
ID: uuid.New(),
117-
PeerTimeout: 1 * time.Millisecond,
129+
ID: uuid.New(),
130+
PeerTimeout: 1 * time.Millisecond,
131+
RelayAddress: "http://169.254.169.254",
118132
})
119133
require.NoError(t, err)
120134
require.Len(t, server.Regional(), 1)
121135
require.Equal(t, peer.ID, server.Regional()[0].ID)
122136
require.True(t, server.Self().Error.Valid)
123137
require.Contains(t, server.Self().Error.String, "Failed to dial peers")
124-
// Once for the initial creation of a replica, and another time for the error.
125-
require.Equal(t, int32(2), count.Load())
126138
_ = server.Close()
127139
})
128140
t.Run("RefreshOnPublish", func(t *testing.T) {

site/src/api/typesGenerated.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,7 @@ export interface DeploymentFlags {
268268
readonly derp_server_region_code: StringFlag
269269
readonly derp_server_region_name: StringFlag
270270
readonly derp_server_stun_address: StringArrayFlag
271+
readonly derp_server_relay_address: StringFlag
271272
readonly derp_config_url: StringFlag
272273
readonly derp_config_path: StringFlag
273274
readonly prom_enabled: BoolFlag
@@ -522,6 +523,16 @@ export interface PutExtendWorkspaceRequest {
522523
readonly deadline: string
523524
}
524525

526+
// From codersdk/replicas.go
527+
export interface Replica {
528+
readonly id: string
529+
readonly hostname: string
530+
readonly created_at: string
531+
readonly relay_address: string
532+
readonly region_id: number
533+
readonly error: string
534+
}
535+
525536
// From codersdk/error.go
526537
export interface Response {
527538
readonly message: string

0 commit comments

Comments
 (0)