Skip to content

Commit

Permalink
client: Merge client code with kube-upgrade
Browse files Browse the repository at this point in the history
Merge the fleetlock client code with the one for kube-upgrade, as it depends
on this one anyway.
This way all the client code is in one easy to use package.

Signed-off-by: Heathcliff <[email protected]>
  • Loading branch information
heathcliff26 committed Dec 12, 2024
1 parent fad47e9 commit 8002ca7
Show file tree
Hide file tree
Showing 4 changed files with 371 additions and 0 deletions.
145 changes: 145 additions & 0 deletions pkg/server/client/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
package client

import (
"fmt"
"net/http"
"sync"
)

type FleetlockClient struct {
url string
group string
appID string

mutex sync.RWMutex
}

// Create a new client for fleetlock
func NewClient(url, group string) (*FleetlockClient, error) {
c, err := NewEmptyClient()
if err != nil {
return nil, err
}

err = c.SetURL(url)
if err != nil {
return nil, err
}

err = c.SetGroup(group)
if err != nil {
return nil, err
}

return c, nil
}

// Create a new fleetlock client without url or group set
func NewEmptyClient() (*FleetlockClient, error) {
appID, err := GetZincateAppID()
if err != nil {
return nil, fmt.Errorf("failed to create zincati app id: %v", err)
}

return &FleetlockClient{
appID: appID,
}, nil
}

// Aquire a lock for this machine
func (c *FleetlockClient) Lock() error {
ok, res, err := c.doRequest("/v1/pre-reboot")
if err != nil {
return err
} else if ok {
return nil
}
return fmt.Errorf("failed to aquire lock kind=\"%s\" reason=\"%s\"", res.Kind, res.Value)
}

// Release the hold lock
func (c *FleetlockClient) Release() error {
ok, res, err := c.doRequest("/v1/steady-state")
if err != nil {
return err
} else if ok {
return nil
}
return fmt.Errorf("failed to release lock kind=\"%s\" reason=\"%s\"", res.Kind, res.Value)
}

func (c *FleetlockClient) doRequest(path string) (bool, FleetLockResponse, error) {
c.mutex.RLock()
defer c.mutex.RUnlock()

body, err := PrepareRequest(c.group, c.appID)
if err != nil {
return false, FleetLockResponse{}, fmt.Errorf("failed to prepare request body: %v", err)
}
req, err := http.NewRequest(http.MethodPost, c.url+path, body)
if err != nil {
return false, FleetLockResponse{}, fmt.Errorf("failed to create http post request: %v", err)
}
req.Header.Set("fleet-lock-protocol", "true")
req.Header.Set("Content-Type", "application/json")

res, err := http.DefaultClient.Do(req)
if err != nil {
return false, FleetLockResponse{}, fmt.Errorf("failed to send request to server: %v", err)
}

resBody, err := ParseResponse(res.Body)
if err != nil {
return false, FleetLockResponse{}, fmt.Errorf("failed to prepare response body: %v", err)
}

return res.StatusCode == http.StatusOK, resBody, nil
}

// Get the fleetlock server url
func (c *FleetlockClient) GetURL() string {
if c == nil {
return ""
}

c.mutex.RLock()
defer c.mutex.RUnlock()

return c.url
}

// Change the fleetlock server url
func (c *FleetlockClient) SetURL(url string) error {
c.mutex.Lock()
defer c.mutex.Unlock()

if url == "" {
return fmt.Errorf("the fleetlock server url can't be empty")
}
c.url = TrimTrailingSlash(url)
return nil
}

// Get the fleetlock group
func (c *FleetlockClient) GetGroup() string {
if c == nil {
return ""
}

c.mutex.RLock()
defer c.mutex.RUnlock()

return c.group
}

// Change the fleetlock group
func (c *FleetlockClient) SetGroup(group string) error {
c.mutex.Lock()
defer c.mutex.Unlock()

if group == "" {
return fmt.Errorf("the fleetlock group can't be empty")
}
c.group = group
return nil
}
164 changes: 164 additions & 0 deletions pkg/server/client/client_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
package client

import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"

"github.com/stretchr/testify/assert"
)

func TestNewClient(t *testing.T) {
tMatrix := []struct {
Name string
Url, Group string
Success bool
}{
{
Name: "MissingUrl",
Group: "default",
},
{
Name: "MissingGroup",
Url: "https://fleetlock.example.com",
},
{
Name: "Success",
Group: "default",
Url: "https://fleetlock.example.com",
Success: true,
},
}
for _, tCase := range tMatrix {
t.Run(tCase.Name, func(t *testing.T) {
assert := assert.New(t)

res, err := NewClient(tCase.Url, tCase.Group)

if tCase.Success {
if !assert.NoError(err, "Should succeed") || !assert.NotNil(res, "Should return a client") {
t.FailNow()
}
assert.Equal(tCase.Url, res.url, "Client should have the url set")
assert.Equal(tCase.Group, res.group, "Client should have the group set")
assert.NotEmpty(res.appID, "Client should have the appID set")
} else {
assert.Error(err, "Client creation should not succeed")
assert.Nil(res, "Should not return a client")
}
})
}
}

func TestNewEmptyClient(t *testing.T) {
assert := assert.New(t)

res, err := NewEmptyClient()

if !assert.NoError(err, "Should succeed") || !assert.NotNil(res, "Should return a client") {
t.FailNow()
}

assert.Empty(res.url, "Client should not have the url set")
assert.Empty(res.group, "Client should not have the group set")
assert.NotEmpty(res.appID, "Client should have the appID set")
}

func TestLock(t *testing.T) {
assert := assert.New(t)

c, srv := NewFakeServer(t, http.StatusOK, "/v1/pre-reboot")
defer srv.Close()

err := c.Lock()
assert.NoError(err, "Should succeed")

c2, srv2 := NewFakeServer(t, http.StatusLocked, "/v1/pre-reboot")
defer srv2.Close()

err = c2.Lock()
assert.Error(err, "Should not succeed")
}

func TestRelease(t *testing.T) {
assert := assert.New(t)

c, srv := NewFakeServer(t, http.StatusOK, "/v1/steady-state")
defer srv.Close()

err := c.Release()
assert.NoError(err, "Should succeed")

c2, srv2 := NewFakeServer(t, http.StatusLocked, "/v1/steady-state")
defer srv2.Close()

err = c2.Release()
assert.Error(err, "Should not succeed")
}

func TestGetAndSet(t *testing.T) {
t.Run("URL", func(t *testing.T) {
assert := assert.New(t)

var c *FleetlockClient
assert.Empty(c.GetURL(), "Should not panic when reading URL from nil pointer")

c = &FleetlockClient{}

assert.NoError(c.SetURL("https://fleetlock.example.com"), "Should set URL without error")
assert.Equal("https://fleetlock.example.com", c.GetURL(), "URL should match")

assert.NoError(c.SetURL("https://fleetlock.example.com/"), "Should set URL without trailing slash")
assert.Equal("https://fleetlock.example.com", c.GetURL(), "URL should not have trailing /")

assert.Error(c.SetURL(""), "Should not accept empty URL")
})
t.Run("Group", func(t *testing.T) {
assert := assert.New(t)

var c *FleetlockClient
assert.Empty(c.GetGroup(), "Should not panic when reading group from nil pointer")

c = &FleetlockClient{}

assert.NoError(c.SetGroup("default"), "Should set group without error")
assert.Equal("default", c.GetGroup(), "group should match")

assert.Error(c.SetGroup(""), "Should not accept empty group")
})
}

func NewFakeServer(t *testing.T, statusCode int, path string) (*FleetlockClient, *httptest.Server) {
assert := assert.New(t)

srv := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
assert.Equal(path, req.URL.String(), "Request use the correct request URL")
assert.Equal(http.MethodPost, req.Method, "Should be POST request")
assert.Equal("true", strings.ToLower(req.Header.Get("fleet-lock-protocol")), "fleet-lock-protocol header should be set")

params, err := ParseRequest(req.Body)
assert.NoError(err, "Request should have the correct format")
assert.Equal("testGroup", params.Client.Group, "Should have Group set")
assert.Equal("testID", params.Client.ID, "Should have ID set")

rw.WriteHeader(statusCode)
b, err := json.MarshalIndent(FleetLockResponse{
Kind: "ok",
Value: "Success",
}, "", " ")
if !assert.NoError(err, "Error in fake server: failed to prepare response") {
return
}

_, err = rw.Write(b)
assert.NoError(err, "Error in fake server: failed to send response")
}))
c := &FleetlockClient{
url: srv.URL,
group: "testGroup",
appID: "testID",
}
return c, srv
}
40 changes: 40 additions & 0 deletions pkg/server/client/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ import (
"bytes"
"encoding/json"
"io"
"log/slog"
"os"
"strings"

systemdutils "github.com/heathcliff26/fleetlock/pkg/systemd-utils"
)

// Parse an http request body and extract the parameters
Expand Down Expand Up @@ -40,3 +45,38 @@ func PrepareRequest(group, id string) (io.Reader, error) {
}
return bytes.NewReader(body), nil
}

// Read the machine-id from /etc/machine-id
func GetMachineID() (string, error) {
b, err := os.ReadFile("/etc/machine-id")
if err != nil {
return "", err
}
machineID := strings.TrimRight(string(b), "\r\n")
return machineID, nil
}

// Find the machine-id of the current node and generate a zincati appID from it.
func GetZincateAppID() (string, error) {
machineID, err := GetMachineID()
if err != nil {
return "", err
}

appID, err := systemdutils.ZincatiMachineID(machineID)
if err != nil {
return "", err
}
return appID, nil
}

// When having // in a URL, it somehow converts the request from POST to GET.
// See: https://github.com/golang/go/issues/69063
// In general it could lead to unintended behaviour.
func TrimTrailingSlash(url string) string {
res, found := strings.CutSuffix(url, "/")
if found {
slog.Info("Removed trailing slash in URL, as this could lead to undefined behaviour")
}
return res
}
22 changes: 22 additions & 0 deletions pkg/server/client/utils_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package client

import (
"testing"

"github.com/stretchr/testify/assert"
)

func TestGetZincateAppID(t *testing.T) {
assert := assert.New(t)

id, err := GetZincateAppID()
assert.NoError(err, "Should succeed")
assert.NotEmpty(id, "Should return id")
}

func TestTrimTrailingSlash(t *testing.T) {
assert := assert.New(t)

assert.Equal("https://fleetlock.example.com", TrimTrailingSlash("https://fleetlock.example.com"), "Should not change URL")
assert.Equal("https://fleetlock.example.com", TrimTrailingSlash("https://fleetlock.example.com/"), "Should remove trailing /")
}

0 comments on commit 8002ca7

Please sign in to comment.