Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Backport to 1.29] Add Dqlite Remove Endpoint #60

Merged
merged 1 commit into from
Oct 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -88,6 +105,32 @@ type Snap struct {
EtcdCA, EtcdCert, EtcdKey string
}

// 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 @@ -286,6 +329,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
Loading