Skip to content

Commit 6421bc8

Browse files
Add Dqlite remove endpoint (KU-1719) (#57)
* Add /v2/dqlite/remove endpoint (#55) * validate capi-auth-token on dqlite/remove (#56)
1 parent 65c83cf commit 6421bc8

File tree

12 files changed

+361
-47
lines changed

12 files changed

+361
-47
lines changed

pkg/api/v2/consts.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
package v2
2+
3+
const (
4+
// CAPIAuthTokenHeader is the header used to pass the CAPI auth token.
5+
CAPIAuthTokenHeader = "capi-auth-token"
6+
)

pkg/api/v2/register.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,4 +53,27 @@ func (a *API) RegisterServer(server *http.ServeMux, middleware func(f http.Handl
5353
}
5454
httputil.Response(w, map[string]string{"status": "OK"})
5555
}))
56+
57+
// POST v2/dqlite/remove
58+
server.HandleFunc(fmt.Sprintf("%s/dqlite/remove", HTTPPrefix), middleware(func(w http.ResponseWriter, r *http.Request) {
59+
if r.Method != http.MethodPost {
60+
w.WriteHeader(http.StatusMethodNotAllowed)
61+
return
62+
}
63+
64+
req := RemoveFromDqliteRequest{}
65+
if err := httputil.UnmarshalJSON(r, &req); err != nil {
66+
httputil.Error(w, http.StatusBadRequest, fmt.Errorf("failed to unmarshal JSON: %w", err))
67+
return
68+
}
69+
70+
token := r.Header.Get(CAPIAuthTokenHeader)
71+
72+
if rc, err := a.RemoveFromDqlite(r.Context(), req, token); err != nil {
73+
httputil.Error(w, rc, fmt.Errorf("failed to remove from dqlite: %w", err))
74+
return
75+
}
76+
77+
httputil.Response(w, nil)
78+
}))
5679
}

pkg/api/v2/remove.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package v2
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"net/http"
7+
8+
snaputil "github.com/canonical/microk8s-cluster-agent/pkg/snap/util"
9+
)
10+
11+
// RemoveFromDqliteRequest represents a request to remove a node from the dqlite cluster.
12+
type RemoveFromDqliteRequest struct {
13+
// RemoveEndpoint is the endpoint of the node to remove from the dqlite cluster.
14+
RemoveEndpoint string `json:"remove_endpoint"`
15+
}
16+
17+
// RemoveFromDqlite implements the "POST /v2/dqlite/remove" endpoint and removes a node from the dqlite cluster.
18+
func (a *API) RemoveFromDqlite(ctx context.Context, req RemoveFromDqliteRequest, token string) (int, error) {
19+
isValid, err := a.Snap.IsCAPIAuthTokenValid(token)
20+
if err != nil {
21+
return http.StatusInternalServerError, fmt.Errorf("failed to validate CAPI auth token: %w", err)
22+
}
23+
24+
if !isValid {
25+
return http.StatusUnauthorized, fmt.Errorf("invalid CAPI auth token %q", token)
26+
}
27+
28+
if err := snaputil.RemoveNodeFromDqlite(ctx, a.Snap, req.RemoveEndpoint); err != nil {
29+
return http.StatusInternalServerError, fmt.Errorf("failed to remove node from dqlite: %w", err)
30+
}
31+
32+
return http.StatusOK, nil
33+
}

pkg/api/v2/remove_test.go

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
package v2_test
2+
3+
import (
4+
"context"
5+
"errors"
6+
"net/http"
7+
"testing"
8+
9+
. "github.com/onsi/gomega"
10+
11+
v2 "github.com/canonical/microk8s-cluster-agent/pkg/api/v2"
12+
"github.com/canonical/microk8s-cluster-agent/pkg/snap/mock"
13+
)
14+
15+
func TestRemove(t *testing.T) {
16+
t.Run("RemoveFails", func(t *testing.T) {
17+
cmdErr := errors.New("failed to run command")
18+
apiv2 := &v2.API{
19+
Snap: &mock.Snap{
20+
RunCommandErr: cmdErr,
21+
CAPIAuthTokenValid: true,
22+
},
23+
}
24+
25+
rc, err := apiv2.RemoveFromDqlite(context.Background(), v2.RemoveFromDqliteRequest{RemoveEndpoint: "1.1.1.1:1234"}, "token")
26+
27+
g := NewWithT(t)
28+
g.Expect(err).To(MatchError(cmdErr))
29+
g.Expect(rc).To(Equal(http.StatusInternalServerError))
30+
})
31+
32+
t.Run("InvalidToken", func(t *testing.T) {
33+
apiv2 := &v2.API{
34+
Snap: &mock.Snap{
35+
CAPIAuthTokenValid: false, // explicitly set to false
36+
},
37+
}
38+
39+
rc, err := apiv2.RemoveFromDqlite(context.Background(), v2.RemoveFromDqliteRequest{RemoveEndpoint: "1.1.1.1:1234"}, "token")
40+
41+
g := NewWithT(t)
42+
g.Expect(err).To(HaveOccurred())
43+
g.Expect(rc).To(Equal(http.StatusUnauthorized))
44+
})
45+
46+
t.Run("TokenFileNotFound", func(t *testing.T) {
47+
tokenErr := errors.New("token file not found")
48+
apiv2 := &v2.API{
49+
Snap: &mock.Snap{
50+
CAPIAuthTokenError: tokenErr,
51+
},
52+
}
53+
54+
rc, err := apiv2.RemoveFromDqlite(context.Background(), v2.RemoveFromDqliteRequest{RemoveEndpoint: "1.1.1.1:1234"}, "token")
55+
56+
g := NewWithT(t)
57+
g.Expect(err).To(MatchError(tokenErr))
58+
g.Expect(rc).To(Equal(http.StatusInternalServerError))
59+
})
60+
61+
t.Run("RemovesSuccessfully", func(t *testing.T) {
62+
apiv2 := &v2.API{
63+
Snap: &mock.Snap{
64+
CAPIAuthTokenValid: true,
65+
},
66+
}
67+
68+
rc, err := apiv2.RemoveFromDqlite(context.Background(), v2.RemoveFromDqliteRequest{RemoveEndpoint: "1.1.1.1:1234"}, "token")
69+
70+
g := NewWithT(t)
71+
g.Expect(err).ToNot(HaveOccurred())
72+
g.Expect(rc).To(Equal(http.StatusOK))
73+
})
74+
}

pkg/snap/interface.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,18 @@ import (
77

88
// Snap is how the cluster agent interacts with the snap.
99
type Snap interface {
10+
// GetSnapPath returns the path to a file or directory in the snap directory.
11+
GetSnapPath(parts ...string) string
12+
// GetSnapDataPath returns the path to a file or directory in the snap's data directory.
13+
GetSnapDataPath(parts ...string) string
14+
// GetSnapCommonPath returns the path to a file or directory in the snap's common directory.
15+
GetSnapCommonPath(parts ...string) string
16+
// GetCAPIPath returns the path to a file or directory in the CAPI directory.
17+
GetCAPIPath(parts ...string) string
18+
19+
// RunCommand runs a shell command.
20+
RunCommand(ctx context.Context, commands ...string) error
21+
1022
// GetGroupName is the group microk8s is using.
1123
// The group name is "microk8s" for classic snaps and "snap_microk8s" for strict snaps.
1224
GetGroupName() string
@@ -88,6 +100,9 @@ type Snap interface {
88100
// GetKnownToken returns the token for a known user from the known_users.csv file.
89101
GetKnownToken(username string) (string, error)
90102

103+
// IsCAPIAuthTokenValid returns true if token is a valid CAPI auth token.
104+
IsCAPIAuthTokenValid(token string) (bool, error)
105+
91106
// SignCertificate signs the certificate signing request, and returns the certificate in PEM format.
92107
SignCertificate(ctx context.Context, csrPEM []byte) ([]byte, error)
93108

pkg/snap/mock/mock.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"fmt"
66
"io"
7+
"path/filepath"
78
"strings"
89

910
"github.com/canonical/microk8s-cluster-agent/pkg/snap"
@@ -23,8 +24,21 @@ type JoinClusterCall struct {
2324
Worker bool
2425
}
2526

27+
// RunCommandCall contains the arguments passed to a specific call of the RunCommand method.
28+
type RunCommandCall struct {
29+
Commands []string
30+
}
31+
2632
// Snap is a generic mock for the snap.Snap interface.
2733
type Snap struct {
34+
SnapDir string
35+
SnapDataDir string
36+
SnapCommonDir string
37+
CAPIDir string
38+
39+
RunCommandCalledWith []RunCommandCall
40+
RunCommandErr error
41+
2842
GroupName string
2943

3044
EnableAddonCalledWith []string
@@ -72,6 +86,9 @@ type Snap struct {
7286
KubeletTokens map[string]string // map hostname to token
7387
KnownTokens map[string]string // map username to token
7488

89+
CAPIAuthTokenValid bool
90+
CAPIAuthTokenError error
91+
7592
SignCertificateCalledWith []string // string(csrPEM)
7693
SignedCertificate string
7794

@@ -86,6 +103,32 @@ type Snap struct {
86103
JoinClusterCalledWith []JoinClusterCall
87104
}
88105

106+
// GetSnapPath is a mock implementation for the snap.Snap interface.
107+
func (s *Snap) GetSnapPath(parts ...string) string {
108+
return filepath.Join(append([]string{s.SnapDir}, parts...)...)
109+
}
110+
111+
// GetSnapDataPath is a mock implementation for the snap.Snap interface.
112+
func (s *Snap) GetSnapDataPath(parts ...string) string {
113+
return filepath.Join(append([]string{s.SnapDataDir}, parts...)...)
114+
}
115+
116+
// GetSnapCommonPath is a mock implementation for the snap.Snap interface.
117+
func (s *Snap) GetSnapCommonPath(parts ...string) string {
118+
return filepath.Join(append([]string{s.SnapCommonDir}, parts...)...)
119+
}
120+
121+
// GetCAPIPath is a mock implementation for the snap.Snap interface.
122+
func (s *Snap) GetCAPIPath(parts ...string) string {
123+
return filepath.Join(append([]string{s.CAPIDir}, parts...)...)
124+
}
125+
126+
// RunCommand is a mock implementation for the snap.Snap interface.
127+
func (s *Snap) RunCommand(_ context.Context, commands ...string) error {
128+
s.RunCommandCalledWith = append(s.RunCommandCalledWith, RunCommandCall{Commands: commands})
129+
return s.RunCommandErr
130+
}
131+
89132
// GetGroupName is a mock implementation for the snap.Snap interface.
90133
func (s *Snap) GetGroupName() string {
91134
return s.GroupName
@@ -284,6 +327,11 @@ func (s *Snap) GetKnownToken(username string) (string, error) {
284327
return "", fmt.Errorf("no known token for user %s", username)
285328
}
286329

330+
// IsCAPIAuthTokenValid is a mock implementation for the snap.Snap interface.
331+
func (s *Snap) IsCAPIAuthTokenValid(token string) (bool, error) {
332+
return s.CAPIAuthTokenValid, s.CAPIAuthTokenError
333+
}
334+
287335
// RunUpgrade is a mock implementation for the snap.Snap interface.
288336
func (s *Snap) RunUpgrade(ctx context.Context, upgrade string, phase string) error {
289337
s.RunUpgradeCalledWith = append(s.RunUpgradeCalledWith, fmt.Sprintf("%s %s", upgrade, phase))

pkg/snap/options.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,10 @@ func WithCommandRunner(f func(context.Context, ...string) error) func(s *snap) {
2222
s.runCommand = f
2323
}
2424
}
25+
26+
// WithCAPIPath configures the path to the CAPI directory.
27+
func WithCAPIPath(path string) func(s *snap) {
28+
return func(s *snap) {
29+
s.capiPath = path
30+
}
31+
}

0 commit comments

Comments
 (0)