Skip to content

Commit

Permalink
Add Dqlite remove endpoint (KU-1719) (#57)
Browse files Browse the repository at this point in the history
* Add /v2/dqlite/remove endpoint (#55)
* validate capi-auth-token on dqlite/remove (#56)
  • Loading branch information
HomayoonAlimohammadi committed Oct 16, 2024
1 parent 65c83cf commit 6421bc8
Show file tree
Hide file tree
Showing 12 changed files with 361 additions and 47 deletions.
6 changes: 6 additions & 0 deletions pkg/api/v2/consts.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package v2

const (
// CAPIAuthTokenHeader is the header used to pass the CAPI auth token.
CAPIAuthTokenHeader = "capi-auth-token"
)
23 changes: 23 additions & 0 deletions pkg/api/v2/register.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,4 +53,27 @@ func (a *API) RegisterServer(server *http.ServeMux, middleware func(f http.Handl
}
httputil.Response(w, map[string]string{"status": "OK"})
}))

// POST v2/dqlite/remove
server.HandleFunc(fmt.Sprintf("%s/dqlite/remove", HTTPPrefix), middleware(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}

req := RemoveFromDqliteRequest{}
if err := httputil.UnmarshalJSON(r, &req); err != nil {
httputil.Error(w, http.StatusBadRequest, fmt.Errorf("failed to unmarshal JSON: %w", err))
return
}

token := r.Header.Get(CAPIAuthTokenHeader)

if rc, err := a.RemoveFromDqlite(r.Context(), req, token); err != nil {
httputil.Error(w, rc, fmt.Errorf("failed to remove from dqlite: %w", err))
return
}

httputil.Response(w, nil)
}))
}
33 changes: 33 additions & 0 deletions pkg/api/v2/remove.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package v2

import (
"context"
"fmt"
"net/http"

snaputil "github.com/canonical/microk8s-cluster-agent/pkg/snap/util"
)

// RemoveFromDqliteRequest represents a request to remove a node from the dqlite cluster.
type RemoveFromDqliteRequest struct {
// RemoveEndpoint is the endpoint of the node to remove from the dqlite cluster.
RemoveEndpoint string `json:"remove_endpoint"`
}

// RemoveFromDqlite implements the "POST /v2/dqlite/remove" endpoint and removes a node from the dqlite cluster.
func (a *API) RemoveFromDqlite(ctx context.Context, req RemoveFromDqliteRequest, token string) (int, error) {
isValid, err := a.Snap.IsCAPIAuthTokenValid(token)
if err != nil {
return http.StatusInternalServerError, fmt.Errorf("failed to validate CAPI auth token: %w", err)
}

if !isValid {
return http.StatusUnauthorized, fmt.Errorf("invalid CAPI auth token %q", token)
}

if err := snaputil.RemoveNodeFromDqlite(ctx, a.Snap, req.RemoveEndpoint); err != nil {
return http.StatusInternalServerError, fmt.Errorf("failed to remove node from dqlite: %w", err)
}

return http.StatusOK, nil
}
74 changes: 74 additions & 0 deletions pkg/api/v2/remove_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package v2_test

import (
"context"
"errors"
"net/http"
"testing"

. "github.com/onsi/gomega"

v2 "github.com/canonical/microk8s-cluster-agent/pkg/api/v2"
"github.com/canonical/microk8s-cluster-agent/pkg/snap/mock"
)

func TestRemove(t *testing.T) {
t.Run("RemoveFails", func(t *testing.T) {
cmdErr := errors.New("failed to run command")
apiv2 := &v2.API{
Snap: &mock.Snap{
RunCommandErr: cmdErr,
CAPIAuthTokenValid: true,
},
}

rc, err := apiv2.RemoveFromDqlite(context.Background(), v2.RemoveFromDqliteRequest{RemoveEndpoint: "1.1.1.1:1234"}, "token")

g := NewWithT(t)
g.Expect(err).To(MatchError(cmdErr))
g.Expect(rc).To(Equal(http.StatusInternalServerError))
})

t.Run("InvalidToken", func(t *testing.T) {
apiv2 := &v2.API{
Snap: &mock.Snap{
CAPIAuthTokenValid: false, // explicitly set to false
},
}

rc, err := apiv2.RemoveFromDqlite(context.Background(), v2.RemoveFromDqliteRequest{RemoveEndpoint: "1.1.1.1:1234"}, "token")

g := NewWithT(t)
g.Expect(err).To(HaveOccurred())
g.Expect(rc).To(Equal(http.StatusUnauthorized))
})

t.Run("TokenFileNotFound", func(t *testing.T) {
tokenErr := errors.New("token file not found")
apiv2 := &v2.API{
Snap: &mock.Snap{
CAPIAuthTokenError: tokenErr,
},
}

rc, err := apiv2.RemoveFromDqlite(context.Background(), v2.RemoveFromDqliteRequest{RemoveEndpoint: "1.1.1.1:1234"}, "token")

g := NewWithT(t)
g.Expect(err).To(MatchError(tokenErr))
g.Expect(rc).To(Equal(http.StatusInternalServerError))
})

t.Run("RemovesSuccessfully", func(t *testing.T) {
apiv2 := &v2.API{
Snap: &mock.Snap{
CAPIAuthTokenValid: true,
},
}

rc, err := apiv2.RemoveFromDqlite(context.Background(), v2.RemoveFromDqliteRequest{RemoveEndpoint: "1.1.1.1:1234"}, "token")

g := NewWithT(t)
g.Expect(err).ToNot(HaveOccurred())
g.Expect(rc).To(Equal(http.StatusOK))
})
}
15 changes: 15 additions & 0 deletions pkg/snap/interface.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,18 @@ import (

// Snap is how the cluster agent interacts with the snap.
type Snap interface {
// GetSnapPath returns the path to a file or directory in the snap directory.
GetSnapPath(parts ...string) string
// GetSnapDataPath returns the path to a file or directory in the snap's data directory.
GetSnapDataPath(parts ...string) string
// GetSnapCommonPath returns the path to a file or directory in the snap's common directory.
GetSnapCommonPath(parts ...string) string
// GetCAPIPath returns the path to a file or directory in the CAPI directory.
GetCAPIPath(parts ...string) string

// RunCommand runs a shell command.
RunCommand(ctx context.Context, commands ...string) error

// GetGroupName is the group microk8s is using.
// The group name is "microk8s" for classic snaps and "snap_microk8s" for strict snaps.
GetGroupName() string
Expand Down Expand Up @@ -88,6 +100,9 @@ type Snap interface {
// GetKnownToken returns the token for a known user from the known_users.csv file.
GetKnownToken(username string) (string, error)

// IsCAPIAuthTokenValid returns true if token is a valid CAPI auth token.
IsCAPIAuthTokenValid(token string) (bool, error)

// SignCertificate signs the certificate signing request, and returns the certificate in PEM format.
SignCertificate(ctx context.Context, csrPEM []byte) ([]byte, error)

Expand Down
48 changes: 48 additions & 0 deletions pkg/snap/mock/mock.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"fmt"
"io"
"path/filepath"
"strings"

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

// RunCommandCall contains the arguments passed to a specific call of the RunCommand method.
type RunCommandCall struct {
Commands []string
}

// Snap is a generic mock for the snap.Snap interface.
type Snap struct {
SnapDir string
SnapDataDir string
SnapCommonDir string
CAPIDir string

RunCommandCalledWith []RunCommandCall
RunCommandErr error

GroupName string

EnableAddonCalledWith []string
Expand Down Expand Up @@ -72,6 +86,9 @@ type Snap struct {
KubeletTokens map[string]string // map hostname to token
KnownTokens map[string]string // map username to token

CAPIAuthTokenValid bool
CAPIAuthTokenError error

SignCertificateCalledWith []string // string(csrPEM)
SignedCertificate string

Expand All @@ -86,6 +103,32 @@ type Snap struct {
JoinClusterCalledWith []JoinClusterCall
}

// GetSnapPath is a mock implementation for the snap.Snap interface.
func (s *Snap) GetSnapPath(parts ...string) string {
return filepath.Join(append([]string{s.SnapDir}, parts...)...)
}

// GetSnapDataPath is a mock implementation for the snap.Snap interface.
func (s *Snap) GetSnapDataPath(parts ...string) string {
return filepath.Join(append([]string{s.SnapDataDir}, parts...)...)
}

// GetSnapCommonPath is a mock implementation for the snap.Snap interface.
func (s *Snap) GetSnapCommonPath(parts ...string) string {
return filepath.Join(append([]string{s.SnapCommonDir}, parts...)...)
}

// GetCAPIPath is a mock implementation for the snap.Snap interface.
func (s *Snap) GetCAPIPath(parts ...string) string {
return filepath.Join(append([]string{s.CAPIDir}, parts...)...)
}

// RunCommand is a mock implementation for the snap.Snap interface.
func (s *Snap) RunCommand(_ context.Context, commands ...string) error {
s.RunCommandCalledWith = append(s.RunCommandCalledWith, RunCommandCall{Commands: commands})
return s.RunCommandErr
}

// GetGroupName is a mock implementation for the snap.Snap interface.
func (s *Snap) GetGroupName() string {
return s.GroupName
Expand Down Expand Up @@ -284,6 +327,11 @@ func (s *Snap) GetKnownToken(username string) (string, error) {
return "", fmt.Errorf("no known token for user %s", username)
}

// IsCAPIAuthTokenValid is a mock implementation for the snap.Snap interface.
func (s *Snap) IsCAPIAuthTokenValid(token string) (bool, error) {
return s.CAPIAuthTokenValid, s.CAPIAuthTokenError
}

// RunUpgrade is a mock implementation for the snap.Snap interface.
func (s *Snap) RunUpgrade(ctx context.Context, upgrade string, phase string) error {
s.RunUpgradeCalledWith = append(s.RunUpgradeCalledWith, fmt.Sprintf("%s %s", upgrade, phase))
Expand Down
7 changes: 7 additions & 0 deletions pkg/snap/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,10 @@ func WithCommandRunner(f func(context.Context, ...string) error) func(s *snap) {
s.runCommand = f
}
}

// WithCAPIPath configures the path to the CAPI directory.
func WithCAPIPath(path string) func(s *snap) {
return func(s *snap) {
s.capiPath = path
}
}
Loading

0 comments on commit 6421bc8

Please sign in to comment.