diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index a4e7c00..dd3aa74 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -11,12 +11,12 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Go - uses: actions/setup-go@v3 + uses: actions/setup-go@v5 with: - go-version: 1.18 + go-version: 1.21 - name: Build run: go build -v ./... diff --git a/.gitignore b/.gitignore index b8abd57..fe0daba 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ coverage.out *.code-workspace +.envrc +.tool-versions diff --git a/CHANGELOG.md b/CHANGELOG.md index f359504..e8f41c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ Lists the changes in the gocmlclient package. +## Version 0.1.0 + +- making a somewhat bigger version bump due to some bigger changes +- moved logging to log/slog +- removed all the caching logic / code. It didn't really work well due to races. In addition, TF doesn't really keep a connection / client over multiple resource calls so the caching was somewhat limited even it would have properly worked (which it did not). +- named configurations (added with CML 2.7) +- added some tests for the named configs + ## Version 0.0.23 - added LinkDestroy() method @@ -34,9 +42,7 @@ fix header and connection error ## Version 0.0.17 - added cache control headers to requests -- return ErrSystemNotReady for Connection refused and 502, also always - reset the client's compatibility error property when versionCheck is called - so that it always queries the backend. +- return ErrSystemNotReady for Connection refused and 502, also always reset the client's compatibility error property when versionCheck is called so that it always queries the backend. - bumped semver to 3.2.1 ## Version 0.0.16 @@ -49,43 +55,32 @@ fix header and connection error ## Version 0.0.15 -- made node configuration a pointer to differentiate between - "no configuration" (null), "empty configuration" and "specific - configuration". With a null configuration, the default configuration - from the node definition will be inserted if there is one +- made node configuration a pointer to differentiate between "no configuration" (null), "empty configuration" and "specific configuration". With a null configuration, the default configuration from the node definition will be inserted if there is one - added Version var/func, moved NewClient() to New() - bump go to 1.19 and vendor deps ## Version 0.0.12 -- Realized that the empty tags removal from 0.0.11 caused a regression. - node tags are always returned/set even when there's no tags... in that - case, the empty list is returned or needs to be provided. See 0.0.3 comment. +- Realized that the empty tags removal from 0.0.11 caused a regression. node tags are always returned/set even when there's no tags... in that case, the empty list is returned or needs to be provided. See 0.0.3 comment. - Test coverage improvement ## Version 0.0.8 to 0.0.11 - Added most of the doc string for exported functions. - reversed the sorting of images for the image definitions. -- sort image definitions by their ID. Lists have the newest (highest version) - image as the first element. +- sort image definitions by their ID. Lists have the newest (highest version) image as the first element. - updated dependencies. -- have InterfaceCreate accept a slot value (not a pointer). A negative slot - indicates "don't specify a slot", this was previously indicated by nil. +- have InterfaceCreate accept a slot value (not a pointer). A negative slot indicates "don't specify a slot", this was previously indicated by nil. - added more values to the ImageDefinition and Nodedefinition structs. - added a link unit test. - more node attributes can be updated when a node is DEFINED_ON_CORE -- NodeCreate removes a node now when the 2nd API call fails. The 2nd call is - needed to update certain attributes which are not accepted in the actual - create API (POST). +- NodeCreate removes a node now when the 2nd API call fails. The 2nd call is needed to update certain attributes which are not accepted in the actual create API (POST). - move the upper version for the version constraint from <2.6.0 to <3.0.0. - omit empty tags on update. ## Version 0.0.5 to 0.0.7 -- refactored the code so that interfaces are read in one go ("data=true"). This - without this, only a list of interface IDs is returned by the API. With this, - the API returns a list of complete interface object. +- refactored the code so that interfaces are read in one go ("data=true"). This without this, only a list of interface IDs is returned by the API. With this, the API returns a list of complete interface object. - Implement the same approach for nodes (0.0.6). - updated dependencies. - Due to the data=true option, restrict the code to only work with 2.4.0 and later. @@ -97,12 +92,9 @@ fix header and connection error ## Version 0.0.3 -- Fixed node tag list update. To delete all tags from a node, an empty tag list - must be serialized in the `PATCH` JSON. This was prevented by having - `omitempty` in the struct. Fixed +- Fixed node tag list update. To delete all tags from a node, an empty tag list must be serialized in the `PATCH` JSON. This was prevented by having `omitempty` in the struct. Fixed - Also moved the `ctest` cmd fro the terraform provider repo to the code base. ## Versions prior to 0.0.3 -Nothing in particular to be noteworthy -- just huge chunks of initial code -refactoring. +Nothing in particular to be noteworthy -- just huge chunks of initial code refactoring. diff --git a/apiclient.go b/apiclient.go index 3d335ca..e80bf1d 100644 --- a/apiclient.go +++ b/apiclient.go @@ -6,7 +6,7 @@ import ( "errors" "fmt" "io" - "log" + "log/slog" "net/http" "net/url" "strings" @@ -64,7 +64,7 @@ func (c *Client) doAPI(ctx context.Context, req *http.Request, depth int32) ([]b } if c.state.get() != stateAuthenticated && c.authRequired(req.URL) { - log.Println("needs auth") + slog.Info("needs auth") c.state.set(stateAuthenticating) if err := c.jsonGet(ctx, authokAPI, nil, depth); err != nil { return nil, err @@ -102,7 +102,7 @@ retry: if res.StatusCode == http.StatusUnauthorized { invalid_token := len(c.apiToken) > 0 c.apiToken = "" - log.Println("need to authenticate") + slog.Info("need to authenticate") c.state.set(stateAuthRequired) if !c.userpass.valid() { errmsg := "no credentials provided" diff --git a/apiclient_test.go b/apiclient_test.go index a516d32..8069443 100644 --- a/apiclient_test.go +++ b/apiclient_test.go @@ -8,8 +8,6 @@ import ( "github.com/stretchr/testify/assert" ) -const useCache bool = false - type testClient struct { client *Client mr *mr.MockResponder @@ -17,7 +15,7 @@ type testClient struct { } func newTestAPIclient() testClient { - c := New("https://controller", true, useCache) + c := New("https://controller", true) mrClient, ctx := mr.NewMockResponder() c.httpClient = mrClient c.SetUsernamePassword("user", "pass") @@ -31,7 +29,7 @@ func newAuthedTestAPIclient() testClient { } func TestClient_methoderror(t *testing.T) { - c := New("", true, useCache) + c := New("", true) err := c.jsonReq(context.Background(), "ü", "###", nil, nil, 0) assert.Error(t, err) } diff --git a/auth.go b/auth.go index 530bd72..152289b 100644 --- a/auth.go +++ b/auth.go @@ -6,7 +6,7 @@ import ( "crypto/x509" "encoding/json" "errors" - "log" + "log/slog" "net/http" "net/url" "strconv" @@ -36,8 +36,8 @@ func (up userPass) valid() bool { return len(up.Username) > 0 && len(up.Password) > 0 } -// technically, authokAPI requires auth, but it's used specifically -// to test whether auth is OK, so it will take a different path +// technically, authokAPI requires auth, but it's used specifically to test +// whether auth is OK, so it will take a different path func (c *Client) authRequired(api *url.URL) bool { url := api.String() return !(strings.HasSuffix(url, authAPI) || @@ -56,16 +56,16 @@ func (c *Client) authenticate(ctx context.Context, userpass userPass, depth int3 if err != nil { return err } - log.Printf("user id %s, is admin: %s", auth.ID, strconv.FormatBool(auth.Admin)) + slog.Info("user auth", "id", auth.ID, "is_admin", strconv.FormatBool(auth.Admin)) c.apiToken = auth.Token return nil } // SetToken sets a specific API token to be used. A token takes precedence over -// a username/password. However, if the token expires, the username/password are -// used to authorize the client again. An error is raised if no token and no -// username/password are provided or if the token expires when no username/password -// are set. +// a username/password. However, if the token expires, the username/password +// are used to authorize the client again. An error is raised if no token and +// no username/password are provided or if the token expires when no +// username/password are set. func (c *Client) SetToken(token string) { c.apiToken = token } diff --git a/auth_test.go b/auth_test.go index dfbc4c9..cb7b44e 100644 --- a/auth_test.go +++ b/auth_test.go @@ -151,20 +151,20 @@ func TestClient_token_auth(t *testing.T) { } func TestClient_SetToken(t *testing.T) { - c := New("https://bla.bla", true, useCache) + c := New("https://bla.bla", true) c.SetToken("qwe") assert.Equal(t, "qwe", c.apiToken) } func TestClient_SetUsernamePassword(t *testing.T) { - c := New("https://bla.bla", true, useCache) + c := New("https://bla.bla", true) c.SetUsernamePassword("user", "pass") assert.Equal(t, "user", c.userpass.Username) assert.Equal(t, "pass", c.userpass.Password) } func TestClient_SetCACert(t *testing.T) { - c := New("https://bla.bla", true, useCache) + c := New("https://bla.bla", true) err := c.SetCACert([]byte("crapdata")) assert.EqualError(t, err, "failed to parse root certificate") testCA := "testdata/ca.pem" diff --git a/cmd/ctest/main.go b/cmd/ctest/main.go index 271ea3a..2535b43 100644 --- a/cmd/ctest/main.go +++ b/cmd/ctest/main.go @@ -2,23 +2,36 @@ package main import ( "context" + "encoding/json" "errors" - "log" + "fmt" + "log/slog" "os" + "time" + "github.com/lmittmann/tint" cmlclient "github.com/rschmied/gocmlclient" ) func main() { + // set global logger with custom options + slog.SetDefault(slog.New( + tint.NewHandler(os.Stderr, &tint.Options{ + AddSource: true, + Level: slog.LevelDebug, + TimeFormat: time.RFC822, + }), + )) + // address and lab id host, found := os.LookupEnv("CML_HOST") if !found { - log.Println("CML_HOST env var not found!") + slog.Error("CML_HOST env var not found!") return } // labID, found := os.LookupEnv("CML_LABID") // if !found { - // log.Println("CML_LABID env var not found!") + // slog.Error("CML_LABID env var not found!") // return // } // _ = labID @@ -28,11 +41,11 @@ func main() { password, pass_found := os.LookupEnv("CML_PASSWORD") token, token_found := os.LookupEnv("CML_TOKEN") if !(token_found || (user_found && pass_found)) { - log.Println("either CML_TOKEN or CML_USERNAME and CML_PASSWORD env vars must be present!") + slog.Error("either CML_TOKEN or CML_USERNAME and CML_PASSWORD env vars must be present!") return } ctx := context.Background() - client := cmlclient.New(host, true, false) + client := cmlclient.New(host, false) // if err := client.Ready(ctx); err != nil { // log.Fatal(err) // } @@ -86,16 +99,49 @@ func main() { // result, err := client.UserGroups(ctx, "cc42bd56-1dc6-445c-b7e7-569b0a8b0c94") err := client.Ready(ctx) if errors.Is(err, cmlclient.ErrSystemNotReady) { - log.Println("it is not ready") + slog.Error("it is not ready") + return } if err != nil && !errors.Is(err, cmlclient.ErrSystemNotReady) { - log.Println(err) + slog.Error("ready", slog.Any("error", err)) + return + } + /* node := &cmlclient.Node{ + // ID: "28ec08ec-483a-415a-a3ed-625b0d45bef0", + // ID: "8116a609-8b68-4e0f-a196-5225da9f05c0", + ID: "0577f1c4-4907-4c49-a4fd-c6daa61b6e78", + LabID: "2b7435f2-b247-4cc8-8509-6b0d0f593c4c", + } + node, err = client.NodeGet(ctx, node, false) + if err != nil { + slog.Error("nodeget", slog.Any("error", err)) return } - // je, err := json.Marshal(result) - // if err != nil { - // log.Println(err) - // } - // fmt.Println(string(je)) + je, err := json.Marshal(node) + if err != nil { + slog.Error("marshal", slog.Any("error", err)) + return + } + fmt.Println(string(je)) */ + + lab, err := client.LabGet(ctx, "2b7435f2-b247-4cc8-8509-6b0d0f593c4c", true) + if err != nil { + slog.Error("get", slog.Any("error", err)) + return + } + + for _, v := range lab.Nodes { + if v.Configuration != nil { + fmt.Printf("[1] %T: %s\n", v.Configuration, *v.Configuration) + } + fmt.Printf("[2] %T: %+v\n", v.Configurations, v.Configurations) + } + return + je, err := json.Marshal(lab) + if err != nil { + slog.Error("marshal", slog.Any("error", err)) + return + } + fmt.Println(string(je)) } diff --git a/cml.go b/cml.go index 2cffac4..daa2efc 100644 --- a/cml.go +++ b/cml.go @@ -19,14 +19,13 @@ type Client struct { compatibilityErr error state *apiClientState mu sync.RWMutex - labCache map[string]*Lab - useCache bool + useNamedConfigs bool version string } -// New returns a new CML client instance. The host must be a valid URL including -// scheme (https://). -func New(host string, insecure, useCache bool) *Client { +// New returns a new CML client instance. The host must be a valid URL +// including scheme (https://). +func New(host string, insecure bool) *Client { tr := http.DefaultTransport.(*http.Transport) tr.TLSClientConfig = &tls.Config{ InsecureSkipVerify: insecure, @@ -44,7 +43,6 @@ func New(host string, insecure, useCache bool) *Client { }, compatibilityErr: nil, state: newState(), - labCache: make(map[string]*Lab), - useCache: useCache, + useNamedConfigs: false, } } diff --git a/error.go b/error.go index 778fced..d3c0fad 100644 --- a/error.go +++ b/error.go @@ -3,6 +3,7 @@ package cmlclient import "errors" var ( - ErrSystemNotReady = errors.New("system not ready") - ErrElementNotFound = errors.New("element not found") + ErrSystemNotReady = errors.New("system not ready") + ErrElementNotFound = errors.New("element not found") + ErrNoNamedConfigSupport = errors.New("backend does not support named configs") ) diff --git a/go.mod b/go.mod index dc29f9a..f7501eb 100644 --- a/go.mod +++ b/go.mod @@ -1,9 +1,10 @@ module github.com/rschmied/gocmlclient -go 1.20 +go 1.21 require ( github.com/Masterminds/semver/v3 v3.2.1 + github.com/lmittmann/tint v1.0.4 github.com/rschmied/mockresponder v1.0.4 github.com/stretchr/testify v1.8.2 golang.org/x/sync v0.7.0 diff --git a/go.sum b/go.sum index 8f3514a..654ae27 100644 --- a/go.sum +++ b/go.sum @@ -3,6 +3,8 @@ github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYr github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/lmittmann/tint v1.0.4 h1:LeYihpJ9hyGvE0w+K2okPTGUdVLfng1+nDNVR4vWISc= +github.com/lmittmann/tint v1.0.4/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rschmied/mockresponder v1.0.4 h1:VFXa9Y9QJ/5oZFhKoqh9u3HQlbjcBfE9pxI+BanMlEs= diff --git a/iface.go b/iface.go index 4828347..b2e6747 100644 --- a/iface.go +++ b/iface.go @@ -68,104 +68,6 @@ func (iface Interface) IsPhysical() bool { return iface.Type == IfaceTypePhysical } -func (c *Client) updateCachedIface(existingIface, _ *Interface) *Interface { - // this is a no-op at this point, we don't allow updating interfaces - return existingIface -} - -func (c *Client) cacheIface(iface *Interface, err error) (*Interface, error) { - if !c.useCache || err != nil { - return iface, err - } - - c.mu.RLock() - lab, ok := c.labCache[iface.LabID] - c.mu.RUnlock() - if !ok { - return iface, err - } - - c.mu.RLock() - node, ok := lab.Nodes[iface.Node] - c.mu.RUnlock() - if !ok { - return iface, err - } - c.mu.RLock() - interfaces := node.Interfaces - c.mu.RUnlock() - for _, nodeIface := range interfaces { - if nodeIface.ID == iface.ID { - return c.updateCachedIface(nodeIface, iface), nil - } - } - - iface.node = node // internal linking - c.mu.Lock() - node.Interfaces = append(node.Interfaces, iface) - c.mu.Unlock() - return iface, nil -} - -func (c *Client) getCachedIface(iface *Interface) (*Interface, bool) { - if !c.useCache { - return nil, false - } - c.mu.RLock() - defer c.mu.RUnlock() - lab, ok := c.labCache[iface.LabID] - if !ok { - return nil, false - } - - node, ok := lab.Nodes[iface.Node] - if !ok { - return iface, false - } - - for _, nodeIface := range node.Interfaces { - if nodeIface != nil && nodeIface.ID == iface.ID { - if nodeIface.node == nil { - nodeIface.node = node - } - return nodeIface, true - } - } - - return iface, false -} - -func (c *Client) deleteCachedIface(iface *Interface, err error) error { - if !c.useCache || err != nil { - return err - } - - c.mu.RLock() - lab, ok := c.labCache[iface.LabID] - c.mu.RUnlock() - if !ok { - return err - } - - c.mu.RLock() - node, ok := lab.Nodes[iface.Node] - c.mu.RUnlock() - if !ok { - return err - } - - c.mu.Lock() - newList := InterfaceList{} - for _, nodeIface := range node.Interfaces { - if nodeIface.ID != iface.ID { - newList = append(newList, nodeIface) - } - } - node.Interfaces = newList - c.mu.Unlock() - return nil -} - func (c *Client) getInterfacesForNode(ctx context.Context, node *Node) error { // with the data=true option, we get not only the list of IDs but the // interfaces themselves as well! @@ -180,9 +82,6 @@ func (c *Client) getInterfacesForNode(ctx context.Context, node *Node) error { sort.Slice(interfaceList, func(i, j int) bool { return interfaceList[i].Slot < interfaceList[j].Slot }) - for _, iface := range interfaceList { - c.cacheIface(iface, nil) - } c.mu.Lock() node.Interfaces = interfaceList c.mu.Unlock() @@ -224,18 +123,14 @@ func (c *Client) getInterfacesForNode(ctx context.Context, node *Node) error { // InterfaceGet returns the interface identified by its `ID` (iface.ID). func (c *Client) InterfaceGet(ctx context.Context, iface *Interface) (*Interface, error) { - if iface, ok := c.getCachedIface(iface); ok { - return iface, nil - } - api := fmt.Sprintf("labs/%s/interfaces/%s", iface.LabID, iface.ID) err := c.jsonGet(ctx, api, iface, 0) - return c.cacheIface(iface, err) + return iface, err } // InterfaceCreate creates an interface in the given lab and node. If the slot -// is >= 0, the request creates all unallocated slots up to and including -// that slot. Conversely, if the slot is < 0 (e.g. -1), the next free slot is used. +// is >= 0, the request creates all unallocated slots up to and including that +// slot. Conversely, if the slot is < 0 (e.g. -1), the next free slot is used. func (c *Client) InterfaceCreate(ctx context.Context, labID, nodeID string, slot int) (*Interface, error) { var slotPtr *int @@ -257,12 +152,13 @@ func (c *Client) InterfaceCreate(ctx context.Context, labID, nodeID string, slot return nil, err } - // This is quite awkward, not even sure if it's a good REST design practice: - // "Returns a JSON object that identifies the interface that was created. In - // the case of bulk interface creation, returns a JSON array of such - // objects." <-- from the API documentation - // A list is returned when slot is defined, even if it's just creating - // one interface + // This is quite awkward, not even sure if it's a good REST design + // practice: "Returns a JSON object that identifies the interface that was + // created. In the case of bulk interface creation, returns a JSON array of + // such objects." <-- from the API documentation + // + // A list is returned when slot is defined, even if it's just creating one + // interface api := fmt.Sprintf("labs/%s/interfaces", labID) if slotPtr == nil { @@ -271,7 +167,7 @@ func (c *Client) InterfaceCreate(ctx context.Context, labID, nodeID string, slot if err != nil { return nil, err } - return c.cacheIface(&result, err) + return &result, err } // this is when a slot has been provided; the API provides now a list of @@ -283,8 +179,5 @@ func (c *Client) InterfaceCreate(ctx context.Context, labID, nodeID string, slot } lastIface := &result[len(result)-1] - for _, li := range result { - c.cacheIface(&li, nil) - } return lastIface, nil } diff --git a/iface_test.go b/iface_test.go index a19735e..526bced 100644 --- a/iface_test.go +++ b/iface_test.go @@ -1,7 +1,6 @@ package cmlclient import ( - "errors" "math/rand" "sync" "testing" @@ -43,7 +42,6 @@ func TestClient_IfaceRuns(t *testing.T) { func TestClient_IfaceWithSlots(t *testing.T) { tc := newAuthedTestAPIclient() - tc.client.useCache = true ifaceList := []byte(`[{ "id": "n2i0", @@ -79,87 +77,8 @@ func TestClient_IfaceWithSlots(t *testing.T) { } } -func TestClient_IfaceDelete(t *testing.T) { - tc := newAuthedTestAPIclient() - tc.client.useCache = true - - tests := []struct { - name string - lab Lab - node Node - ifaceList InterfaceList - preErr error - want bool - }{ - { - "error before", - Lab{ - ID: "different", - Nodes: make(NodeMap), - }, - Node{}, - InterfaceList{}, - errors.New("some error"), - false, - }, - { - "nolab", - Lab{ - ID: "different", - Nodes: make(NodeMap), - }, - Node{}, - InterfaceList{}, - nil, - false, - }, - { - "nolab", - Lab{ - ID: "lab1", - Nodes: make(NodeMap), - }, - Node{ID: "node2"}, - InterfaceList{}, - nil, - false, - }, - { - "good", - Lab{ - ID: "lab1", - Nodes: make(NodeMap), - }, - Node{ID: "node1"}, - InterfaceList{ - &Interface{ID: "iface0"}, - &Interface{ID: "iface1"}, - &Interface{ID: "iface2"}, - &Interface{ID: "iface3"}, - }, - nil, - false, - }, - } - - iface := &Interface{ID: "iface2", LabID: "lab1", Node: "node1"} - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - tt.node.Interfaces = tt.ifaceList - tt.lab.Nodes[tt.node.ID] = &tt.node - tc.client.labCache[tt.lab.ID] = &tt.lab - err := tc.client.deleteCachedIface(iface, tt.preErr) - assert.Equal(t, tt.preErr, err) - if err == nil && tt.name == "good" { - assert.Len(t, tt.node.Interfaces, 3) - } - }) - } -} - func Test_Race(t *testing.T) { tc := newAuthedTestAPIclient() - tc.client.useCache = true iface0 := []byte(`{ "id": "iface0", @@ -236,9 +155,6 @@ func Test_Race(t *testing.T) { } node := Node{ID: "node1", LabID: lab.ID} lab.Nodes[node.ID] = &node - tc.client.labCache[lab.ID] = &lab - // rand.Seed is not needed anymore from 1.20 onward - // rand.Seed(time.Now().UnixNano()) for i := 0; i < 50; i++ { tc.mr.Reset() diff --git a/lab.go b/lab.go index 42f7b73..61466d1 100644 --- a/lab.go +++ b/lab.go @@ -5,7 +5,7 @@ import ( "context" "encoding/json" "fmt" - "log" + "log/slog" "strings" "golang.org/x/sync/errgroup" @@ -80,9 +80,6 @@ type Lab struct { Nodes NodeMap `json:"nodes"` Links linkList `json:"links"` Groups LabGroupList `json:"groups"` - - // private - // filled bool } // CanBeWiped returns `true` when all nodes in the lab are wiped. @@ -118,8 +115,8 @@ func (l *Lab) Booted() bool { return true } -// NodeByLabel returns the node of a lab identified by its `label“ or an -// error if not found. +// NodeByLabel returns the node of a lab identified by its `label“ or an error +// if not found. func (l *Lab) NodeByLabel(ctx context.Context, label string) (*Node, error) { for _, node := range l.Nodes { if node.Label == label { @@ -134,57 +131,6 @@ type LabImport struct { Warnings []string `json:"warnings"` } -func (c *Client) updateCachedLab(existingLab, updatedLab *Lab) *Lab { - // only copy fields which can be updated - c.mu.Lock() - existingLab.Title = updatedLab.Title - existingLab.Description = updatedLab.Description - existingLab.Nodes = updatedLab.Nodes - existingLab.State = updatedLab.State - c.mu.Unlock() - return existingLab -} - -func (c *Client) cacheLab(lab *Lab, err error) (*Lab, error) { - if !c.useCache || err != nil { - return lab, err - } - - c.mu.RLock() - existingLab, ok := c.labCache[lab.ID] - c.mu.RUnlock() - if ok { - return c.updateCachedLab(existingLab, lab), nil - } - - lab.Nodes = make(NodeMap) - c.mu.Lock() - c.labCache[lab.ID] = lab - c.mu.Unlock() - return lab, nil -} - -func (c *Client) getCachedLab(id string, deep bool) (*Lab, bool) { - // no caching when reading deep - if !c.useCache || deep { - return nil, false - } - c.mu.RLock() - lab, ok := c.labCache[id] - c.mu.RUnlock() - return lab, ok -} - -func (c *Client) deleteCachedLab(id string, err error) error { - if !c.useCache || err != nil { - return err - } - c.mu.Lock() - delete(c.labCache, id) - c.mu.Unlock() - return nil -} - // LabCreate creates a new lab on the controller. func (c *Client) LabCreate(ctx context.Context, lab Lab) (*Lab, error) { // TODO: inconsistent attributes lab_title vs title, ... @@ -235,8 +181,7 @@ func (c *Client) LabUpdate(ctx context.Context, lab Lab) (*Lab, error) { } la.Owner = &User{ID: la.OwnerID} - la.Nodes = make(NodeMap) - return c.cacheLab(&la.Lab, nil) + return &la.Lab, nil } // LabImport imports a lab topology into the controller. This is expected to be @@ -284,7 +229,7 @@ func (c *Client) LabWipe(ctx context.Context, id string) error { // LabDestroy deletes the lab identified by the `id` (a UUIDv4). func (c *Client) LabDestroy(ctx context.Context, id string) error { - return c.deleteCachedLab(id, c.jsonDelete(ctx, fmt.Sprintf("labs/%s", id), 0)) + return c.jsonDelete(ctx, fmt.Sprintf("labs/%s", id), 0) } // LabGetByTitle returns the lab identified by its `title`. For the use of @@ -311,12 +256,10 @@ func (c *Client) LabGetByTitle(ctx context.Context, title string, deep bool) (*L } // LabGet returns the lab identified by `id` (a UUIDv4). If `deep` is provided, -// then the nodes, their interfaces and links are also fetched from the controller. -// Also, with `deep`, the L3 IP address info is fetched for the given lab. +// then the nodes, their interfaces and links are also fetched from the +// controller. Also, with `deep`, the L3 IP address info is fetched for the +// given lab. func (c *Client) LabGet(ctx context.Context, id string, deep bool) (*Lab, error) { - if lab, ok := c.getCachedLab(id, deep); ok { - return lab, nil - } api := fmt.Sprintf("labs/%s", id) la := &labAlias{} err := c.jsonGet(ctx, api, la, 0) @@ -325,7 +268,7 @@ func (c *Client) LabGet(ctx context.Context, id string, deep bool) (*Lab, error) } if !deep { la.Owner = &User{ID: la.OwnerID} - return c.cacheLab(&la.Lab, nil) + return &la.Lab, nil } return c.labFill(ctx, la) } @@ -335,7 +278,7 @@ func (c *Client) labFill(ctx context.Context, la *labAlias) (*Lab, error) { g, ctx := errgroup.WithContext(ctx) g.Go(func() error { - defer log.Printf("user done") + defer slog.Debug("user done") la.Owner, err = c.UserGet(ctx, la.OwnerID) if err != nil { return err @@ -344,13 +287,12 @@ func (c *Client) labFill(ctx context.Context, la *labAlias) (*Lab, error) { }) lab := &la.Lab - lab, _ = c.cacheLab(lab, nil) // need to ensure that this block finishes before the others run ch := make(chan struct{}) g.Go(func() error { defer func() { - log.Printf("nodes/interfaces done") + slog.Debug("nodes/interfaces done") // two sync points, we can run the API endpoints but we need to // wait for the node data to be read until we can add the layer3 // info (1) and the link info (2) @@ -371,12 +313,12 @@ func (c *Client) labFill(ctx context.Context, la *labAlias) (*Lab, error) { }) g.Go(func() error { - defer log.Printf("l3info done") + defer slog.Debug("l3info done") l3info, err := c.getL3Info(ctx, lab.ID) if err != nil { return err } - log.Printf("l3info read") + slog.Debug("l3info read") // wait for node data read complete <-ch // map and merge the l3 data... @@ -384,7 +326,6 @@ func (c *Client) labFill(ctx context.Context, la *labAlias) (*Lab, error) { if node, found := lab.Nodes[nid]; found { for mac, l3i := range l3data.Interfaces { for _, iface := range node.Interfaces { - // if iface, found := node.Interfaces[l3i.ID]; found { if iface.MACaddress == mac { iface.IP4 = l3i.IP4 iface.IP6 = l3i.IP6 @@ -394,17 +335,17 @@ func (c *Client) labFill(ctx context.Context, la *labAlias) (*Lab, error) { } } } - log.Printf("l3info loop done") + slog.Debug("l3info loop done") return nil }) g.Go(func() error { - defer log.Printf("links done") + defer slog.Debug("links done") idlist, err := c.getLinkIDsForLab(ctx, lab) if err != nil { return err } - log.Printf("linkidlist read") + slog.Debug("links read") // wait for node data read complete <-ch return c.getLinksForLab(ctx, lab, idlist) @@ -413,8 +354,6 @@ func (c *Client) labFill(ctx context.Context, la *labAlias) (*Lab, error) { if err := g.Wait(); err != nil { return nil, err } - log.Printf("wait done") - // lab.filled = true - // return c.cacheLab(lab, nil) + slog.Debug("wait done") return lab, nil } diff --git a/lab_test.go b/lab_test.go index bb0e388..dbbd576 100644 --- a/lab_test.go +++ b/lab_test.go @@ -494,21 +494,18 @@ func TestClient_StartStopWipeDestroy(t *testing.T) { tc.client.LabDestroy, } - for _, useCache := range []bool{true, false} { - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - tc.client.useCache = useCache - tc.mr.SetData(tt.data) - for _, f := range funcs { - err := f(tc.ctx, "bla") - if tt.want { - assert.Error(t, err) - } else { - assert.NoError(t, err) - } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tc.mr.SetData(tt.data) + for _, f := range funcs { + err := f(tc.ctx, "bla") + if tt.want { + assert.Error(t, err) + } else { + assert.NoError(t, err) } - }) - } + } + }) } } @@ -642,7 +639,6 @@ func TestClient_LabCreate(t *testing.T) { func TestClient_LabUpdate(t *testing.T) { tc := newAuthedTestAPIclient() - tc.client.useCache = true data := mr.MockRespList{ mr.MockResp{ @@ -661,6 +657,22 @@ func TestClient_LabUpdate(t *testing.T) { "groups": [] }`), }, + mr.MockResp{ + Data: []byte(`{ + "state": "DEFINED_ON_CORE", + "created": "2022-10-14T10:05:07+00:00", + "modified": "2022-10-14T10:05:07+00:00", + "lab_title": "Lab at Mon 17:27 PM", + "lab_description": "string", + "lab_notes": "string", + "owner": "00000000-0000-4000-a000-000000000000", + "owner_username": "admin", + "node_count": 0, + "link_count": 0, + "id": "lab99", + "groups": [] + }`), + }, } data2 := mr.MockRespList{ mr.MockResp{ @@ -679,6 +691,22 @@ func TestClient_LabUpdate(t *testing.T) { "groups": [] }`), }, + mr.MockResp{ + Data: []byte(`{ + "state": "DEFINED_ON_CORE", + "created": "2022-10-14T10:05:07+00:00", + "modified": "2022-10-14T12:05:07+00:00", + "lab_title": "new title", + "lab_description": "string", + "lab_notes": "string", + "owner": "00000000-0000-4000-a000-000000000000", + "owner_username": "admin", + "node_count": 0, + "link_count": 0, + "id": "lab99", + "groups": [] + }`), + }, } tests := []struct { @@ -692,9 +720,6 @@ func TestClient_LabUpdate(t *testing.T) { {"bad", Lab{}, mr.MockRespList{mr.MockResp{Code: 405}}, true}, } - lab := &Lab{ID: "lab99"} - tc.client.labCache["lab99"] = lab - for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { tc.mr.SetData(tt.data) @@ -711,96 +736,3 @@ func TestClient_LabUpdate(t *testing.T) { }) } } - -func TestClient_CompleteCache(t *testing.T) { - tc := newAuthedTestAPIclient() - tc.client.useCache = true - - ifacen1i2 := []byte(`{ - "id": "n1i2", - "lab_id": "lab1", - "node": "node1", - "label": "eth1", - "slot": 1, - "type": "physical", - "mac_address": "52:54:00:0c:e0:69", - "is_connected": true, - "state": "STARTED" - }`) - ifacen2i2 := []byte(`{ - "id": "n2i2", - "lab_id": "lab1", - "node": "node2", - "label": "eth1", - "slot": 1, - "type": "physical", - "mac_address": "52:54:00:0c:e0:70", - "is_connected": true, - "state": "STOPPED" - }`) - link2n1n2 := []byte(`{ - "id": "link2", - "interface_a": "n1i2", - "interface_b": "n2i2", - "lab_id": "lab1", - "label": "alpine-0-eth1<->alpine-1-eth1", - "link_capture_key": "", - "node_a": "node1", - "node_b": "node2", - "state": "DEFINED_ON_CORE" - }`) - - data := mr.MockRespList{ - mr.MockResp{Data: demoLab, URL: `/labs/lab1$`}, - mr.MockResp{Data: links, URL: `/links$`}, - mr.MockResp{Data: lab_layer3, URL: `layer3_addresses$`}, - mr.MockResp{Data: ownerUser, URL: `/users/.+$`}, - mr.MockResp{Data: nodes, URL: `/nodes\?data=true$`}, - mr.MockResp{Data: ifacesn1, URL: `/node1/interfaces\?data=true$`}, - mr.MockResp{Data: ifacesn2, URL: `/node2/interfaces\?data=true$`}, - mr.MockResp{Data: linkn1n2, URL: `/links/link1$`}, - - mr.MockResp{Data: ifacesn1, URL: `/node1/interfaces\?data=true$`}, - mr.MockResp{Data: ifacesn2, URL: `/node2/interfaces\?data=true$`}, - - // 2 new interfaces, one new link, followed by a get - mr.MockResp{Data: ifacen1i2, URL: `/interfaces$`}, - mr.MockResp{Data: ifacen2i2, URL: `/interfaces$`}, - mr.MockResp{Data: link2n1n2, URL: `/links$`}, - mr.MockResp{Data: link2n1n2, URL: `/links/link2$`}, - } - - tests := []struct { - name string - lab Lab - data mr.MockRespList - want bool - }{ - {"good", Lab{}, data, false}, - // {"bad", Lab{}, mr.MockRespList{mr.MockResp{Code: 405}}, true}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - tc.mr.SetData(tt.data) - _, err := tc.client.LabGet(tc.ctx, "lab1", true) - assert.NoError(t, err) - link := &Link{ - LabID: "lab1", - SrcNode: "node1", - DstNode: "node2", - SrcSlot: -1, - DstSlot: -1, - } - link, err = tc.client.LinkCreate(tc.ctx, link) - if tt.want { - assert.Error(t, err) - } else { - if assert.NoError(t, err) { - assert.Equal(t, "link2", link.ID) - } - } - assert.True(t, tc.mr.Empty()) - }) - } -} diff --git a/link.go b/link.go index a24ddfd..1deaa91 100644 --- a/link.go +++ b/link.go @@ -81,9 +81,9 @@ func (c *Client) getLinksForLab(ctx context.Context, lab *Lab, linkIDlist IDlist return nil } -// LinkGet returns the link data for the given `labID` and `linkID`. If `deep` is -// set to `true` then bot interface and node data for the given link are also -// fetched from the controller. +// LinkGet returns the link data for the given `labID` and `linkID`. If `deep` +// is set to `true` then bot interface and node data for the given link are +// also fetched from the controller. func (c *Client) LinkGet(ctx context.Context, labID, linkID string, deep bool) (*Link, error) { api := fmt.Sprintf("labs/%s/links/%s", labID, linkID) link := &Link{} @@ -96,7 +96,6 @@ func (c *Client) LinkGet(ctx context.Context, labID, linkID string, deep bool) ( if deep { var err error - // ifaceA, ifaceB *Interface ifaceA := &Interface{ ID: link.SrcID, @@ -108,7 +107,7 @@ func (c *Client) LinkGet(ctx context.Context, labID, linkID string, deep bool) ( if err != nil { return nil, err } - ifaceA.node, err = c.NodeGet(ctx, ifaceA.node, false) + ifaceA.node, err = c.NodeGet(ctx, ifaceA.node) if err != nil { return nil, err } @@ -123,7 +122,7 @@ func (c *Client) LinkGet(ctx context.Context, labID, linkID string, deep bool) ( if err != nil { return nil, err } - ifaceB.node, err = c.NodeGet(ctx, ifaceB.node, false) + ifaceB.node, err = c.NodeGet(ctx, ifaceB.node) if err != nil { return nil, err } @@ -136,16 +135,17 @@ func (c *Client) LinkGet(ctx context.Context, labID, linkID string, deep bool) ( return link, err } -// LinkCreate creates a link based on the the data passed in `link`. Required fields -// are the `LabID` and either a pair of interfaces `SrcID` / `DstID` or a pair of -// nodes `SrcNode` / `DstNode`. With nodes it's also possible to provide specific -// slots in `SrcSlot` / `DstSlot` where the link should be created. -// If one or both of the provided slots aren't available, then new interfaces will -// be craeted. If interface creation fails or the provided Interface IDs can't be -// found, the API returns an error, otherwise the returned Link variable has the -// updated link data. -// Node: -1 for a slot means: use next free slot. Specific slots run from 0 to the -// maximum slot number -1 per the node definition of the node type. +// LinkCreate creates a link based on the data passed in `link`. Required +// fields are the `LabID` and either a pair of interfaces `SrcID` / `DstID` or +// a pair of nodes `SrcNode` / `DstNode`. With nodes it's also possible to +// provide specific slots in `SrcSlot` / `DstSlot` where the link should be +// created. +// If one or both of the provided slots aren't available, then new interfaces +// will be created. If interface creation fails or the provided Interface IDs +// can't be found, the API returns an error, otherwise the returned Link +// variable has the updated link data. +// Node: -1 for a slot means: use next free slot. Specific slots run from 0 to +// the maximum slot number -1 per the node definition of the node type. func (c *Client) LinkCreate(ctx context.Context, link *Link) (*Link, error) { api := fmt.Sprintf("labs/%s/links", link.LabID) @@ -157,43 +157,19 @@ func (c *Client) LinkCreate(ctx context.Context, link *Link) (*Link, error) { if len(link.SrcNode) > 0 && len(link.DstNode) > 0 { nodeA = &Node{LabID: link.LabID, ID: link.SrcNode} - // if c.useCache { - // nodeA, err = c.NodeGet(ctx, nodeA, false) - // if err != nil { - // return nil, err - // } - // } err = c.getInterfacesForNode(ctx, nodeA) if err != nil { return nil, err } nodeB = &Node{LabID: link.LabID, ID: link.DstNode} - // if c.useCache { - // nodeB, err = c.NodeGet(ctx, nodeB, false) - // if err != nil { - // return nil, err - // } - // } err = c.getInterfacesForNode(ctx, nodeB) if err != nil { return nil, err } matches := func(slot int, iface *Interface) bool { - if !iface.IsPhysical() { - return false - } - if slot >= 0 { - if iface.Slot == slot && !iface.IsConnected { - return true - } - } else { - if !iface.IsConnected { - return true - } - } - return false + return iface.IsPhysical() && !iface.IsConnected && (slot < 0 || iface.Slot == slot) } for _, iface := range nodeA.Interfaces { diff --git a/link_test.go b/link_test.go index 45be699..bf66d4b 100644 --- a/link_test.go +++ b/link_test.go @@ -104,23 +104,16 @@ func TestClient_GetLink(t *testing.T) { false, }, } - for _, useCache := range []bool{false, true} { - for _, tt := range tests { - tc.mr.SetData(tt.responses) - tc.client.useCache = useCache - t.Run(tt.name, func(t *testing.T) { - lab, err := tc.client.LinkGet(tc.ctx, "qweaa", "link1", tt.deep) - assert.NoError(t, err) - assert.NotNil(t, lab) - // if tt.deep { - // assert.Len(t, lab.Links, 1) - // assert.Len(t, lab.Nodes, 2) - if !(useCache || tc.mr.Empty()) { - t.Errorf("not all data in mock client consumed: %v", useCache) - } - // } - }) - } + for _, tt := range tests { + tc.mr.SetData(tt.responses) + t.Run(tt.name, func(t *testing.T) { + link, err := tc.client.LinkGet(tc.ctx, "qweaa", "link1", tt.deep) + assert.NoError(t, err) + assert.NotNil(t, link) + if !tc.mr.Empty() { + t.Error("not all data in mock client consumed") + } + }) } } @@ -229,6 +222,30 @@ func TestClient_CreateLink(t *testing.T) { "state": "DEFINED_ON_CORE" }`) + newiface0 := []byte(`{ + "id": "iface0", + "lab_id": "lab1", + "node": "node1", + "label": "eth0", + "slot": 0, + "type": "physical", + "mac_address": "52:54:00:0c:e0:70", + "is_connected": false, + "state": "STOPPED" + }`) + + newiface1 := []byte(`{ + "id": "iface0", + "lab_id": "lab1", + "node": "node1", + "label": "eth0", + "slot": 0, + "type": "physical", + "mac_address": "52:54:00:0c:e0:70", + "is_connected": false, + "state": "STOPPED" + }`) + mockdata := mr.MockRespList{ mr.MockResp{Data: ifaceList1, URL: `node1/interfaces\?data=true$`}, mr.MockResp{Data: ifaceList2, URL: `node2/interfaces\?data=true$`}, @@ -240,6 +257,19 @@ func TestClient_CreateLink(t *testing.T) { mr.MockResp{Data: node1, URL: `/labs/lab1/nodes/node2$`}, } + mockdata2 := mr.MockRespList{ + mr.MockResp{Data: []byte(`[]`), URL: `node1/interfaces\?data=true$`}, + mr.MockResp{Data: []byte(`[]`), URL: `node2/interfaces\?data=true$`}, + mr.MockResp{Data: newiface0, URL: `/labs/lab1/interfaces$`}, + mr.MockResp{Data: newiface1, URL: `/labs/lab1/interfaces$`}, + mr.MockResp{Data: linkn1n2, URL: `/links$`}, + mr.MockResp{Data: linkn1n2, URL: `/links/link1$`}, + mr.MockResp{Data: iface1, URL: `/interfaces/iface1$`}, + mr.MockResp{Data: iface6, URL: `/interfaces/iface6$`}, + mr.MockResp{Data: node1, URL: `/labs/lab1/nodes/node1$`}, + mr.MockResp{Data: node1, URL: `/labs/lab1/nodes/node2$`}, + } + tests := []struct { name string link *Link @@ -257,7 +287,7 @@ func TestClient_CreateLink(t *testing.T) { mockdata, }, { - "link_no_slots", + "link_no_slots_1", &Link{ LabID: "lab1", SrcNode: "node1", @@ -267,24 +297,28 @@ func TestClient_CreateLink(t *testing.T) { }, mockdata, }, + { + "link_no_slots_2", + &Link{ + LabID: "lab1", + SrcNode: "node1", + DstNode: "node2", + SrcSlot: -1, + DstSlot: -1, + }, + mockdata2, + }, } - for _, useCache := range []bool{false, true} { - for _, tt := range tests { - tc.mr.SetData(tt.responses) - tc.client.useCache = useCache - t.Run(tt.name, func(t *testing.T) { - lab, err := tc.client.LinkCreate(tc.ctx, tt.link) - assert.NoError(t, err) - assert.NotNil(t, lab) - // if tt.deep { - // assert.Len(t, lab.Links, 1) - // assert.Len(t, lab.Nodes, 2) - if !(useCache || tc.mr.Empty()) { - t.Errorf("not all data in mock client consumed: %v", useCache) - } - // } - }) - } + for _, tt := range tests { + tc.mr.SetData(tt.responses) + t.Run(tt.name, func(t *testing.T) { + lab, err := tc.client.LinkCreate(tc.ctx, tt.link) + assert.NoError(t, err) + assert.NotNil(t, lab) + if !tc.mr.Empty() { + t.Error("not all data in mock client consumed") + } + }) } } @@ -295,7 +329,6 @@ func TestClient_DestroyLink(t *testing.T) { mr.MockResp{Code: 200}, }) - tc.client.useCache = false err := tc.client.LinkDestroy(tc.ctx, &Link{LabID: "lab1", ID: "link1"}) assert.NoError(t, err) } diff --git a/node.go b/node.go index 7572888..eade499 100644 --- a/node.go +++ b/node.go @@ -58,6 +58,11 @@ const ( NodeStateBooted = "BOOTED" ) +type NodeConfig struct { + Name string `json:"name"` + Content string `json:"content"` +} + type SerialDevice struct { ConsoleKey string `json:"console_key"` DeviceNumber int `json:"device_number"` @@ -73,6 +78,7 @@ type Node struct { NodeDefinition string `json:"node_definition"` ImageDefinition string `json:"image_definition"` Configuration *string `json:"configuration"` + Configurations []NodeConfig `json:"-"` CPUs int `json:"cpus"` CPUlimit int `json:"cpu_limit"` RAM int `json:"ram"` @@ -85,24 +91,24 @@ type Node struct { SerialDevices []SerialDevice `json:"serial_devices"` ComputeID string `json:"compute_id"` - // not exported, needed for internal linking - lab *Lab + // Configurations is not exported, it's overloaded within the API } type nodePatchPostAlias struct { - Label string `json:"label,omitempty"` - X int `json:"x"` - Y int `json:"y"` - HideLinks bool `json:"hide_links"` - NodeDefinition string `json:"node_definition,omitempty"` - ImageDefinition string `json:"image_definition,omitempty"` - Configuration *string `json:"configuration,omitempty"` - CPUs int `json:"cpus,omitempty"` - CPUlimit int `json:"cpu_limit,omitempty"` - RAM int `json:"ram,omitempty"` - DataVolume int `json:"data_volume,omitempty"` - BootDiskSize int `json:"boot_disk_size,omitempty"` - Tags []string `json:"tags"` + Label string `json:"label,omitempty"` + X int `json:"x"` + Y int `json:"y"` + HideLinks bool `json:"hide_links"` + NodeDefinition string `json:"node_definition,omitempty"` + ImageDefinition string `json:"image_definition,omitempty"` + Configuration *string `json:"configuration,omitempty"` + Configurations []NodeConfig `json:"-"` + CPUs int `json:"cpus,omitempty"` + CPUlimit int `json:"cpu_limit,omitempty"` + RAM int `json:"ram,omitempty"` + DataVolume int `json:"data_volume,omitempty"` + BootDiskSize int `json:"boot_disk_size,omitempty"` + Tags []string `json:"tags"` } func newNodeAlias(node *Node, update bool) nodePatchPostAlias { @@ -114,9 +120,9 @@ func newNodeAlias(node *Node, update bool) nodePatchPostAlias { npp.HideLinks = node.HideLinks npp.Tags = node.Tags - // node tags can't be null, either the tag has to be omitted or it has - // to be an empty list. but since we can't use "omitempty" we need to - // ensure it's an empty list if no tags are provided. + // node tags can't be null, either the tag has to be omitted or it has to + // be an empty list. But since we can't use "omitempty" we need to ensure + // it's an empty list if no tags are provided. if node.Tags == nil { npp.Tags = []string{} } @@ -124,6 +130,8 @@ func newNodeAlias(node *Node, update bool) nodePatchPostAlias { // these can be changed but only when the node VM doesn't exist if node.State == NodeStateDefined { npp.Configuration = node.Configuration + npp.Configurations = make([]NodeConfig, len(node.Configurations)) + copy(npp.Configurations, node.Configurations) npp.CPUs = node.CPUs npp.CPUlimit = node.CPUlimit npp.RAM = node.RAM @@ -137,6 +145,8 @@ func newNodeAlias(node *Node, update bool) nodePatchPostAlias { npp.NodeDefinition = node.NodeDefinition } + // slog.Warn("NODE", slog.Any("node", node), slog.Any("npp", npp)) + return npp } @@ -153,91 +163,104 @@ func (nmap NodeMap) MarshalJSON() ([]byte, error) { return json.Marshal(nodeList) } -func (c *Client) updateCachedNode(existingNode, node *Node) *Node { - // only copy fields which can be updated - existingNode.Label = node.Label - existingNode.X = node.X - existingNode.Y = node.Y - existingNode.Tags = node.Tags - existingNode.HideLinks = node.HideLinks - - // these can be changed but only when the node VM doesn't exist - if node.State == NodeStateDefined { - existingNode.Configuration = node.Configuration - existingNode.CPUs = node.CPUs - existingNode.CPUlimit = node.CPUlimit - existingNode.RAM = node.RAM - existingNode.DataVolume = node.DataVolume - existingNode.BootDiskSize = node.BootDiskSize - existingNode.ImageDefinition = node.ImageDefinition +func (n *Node) UnmarshalJSON(data []byte) error { + if string(data) == "null" || string(data) == `""` { + return nil } - // these can never be updated: - // - existingNode.NodeDefinition - return existingNode -} -func (c *Client) cacheNode(node *Node, err error) (*Node, error) { - if !c.useCache || err != nil { - return node, err - } + type nodeAlias Node - c.mu.RLock() - lab, ok := c.labCache[node.LabID] - c.mu.RUnlock() - if !ok { - return node, err + var tmpNode struct { + nodeAlias + Configs any `json:"configuration"` } - c.mu.RLock() - existingNode, ok := lab.Nodes[node.ID] - c.mu.RUnlock() - if ok { - return c.updateCachedNode(existingNode, node), nil + // Unmarshal the JSON into the tmpNode struct. + if err := json.Unmarshal(data, &tmpNode); err != nil { + return err } - if lab.Nodes != nil { - c.mu.Lock() - lab.Nodes[node.ID] = node - c.mu.Unlock() + na := tmpNode.nodeAlias + + switch thing := tmpNode.Configs.(type) { + case nil: + na.Configuration = nil + case string: + na.Configuration = &thing + case []any: + b, err := json.Marshal(thing) + if err != nil { + return err + } + err = json.Unmarshal(b, &na.Configurations) + if err != nil { + return err + } + default: + return fmt.Errorf("unexpected type: %T", thing) } - return node, nil + *n = (Node)(na) + + return nil } -func (c *Client) getCachedNode(lab_id, id string) (*Node, bool) { - if !c.useCache { - return nil, false - } - c.mu.RLock() - lab, ok := c.labCache[lab_id] - c.mu.RUnlock() - if !ok { - return nil, false +func (node *Node) MarshalJSON() ([]byte, error) { + type alias Node + if len(node.Configurations) > 0 { + node.Configuration = nil + return json.Marshal(&struct { + *alias + NamedConfig []NodeConfig `json:"configuration"` + }{ + (*alias)(node), + node.Configurations, + }) } - node, ok := lab.Nodes[id] - return node, ok + return json.Marshal((*alias)(node)) } -func (c *Client) deleteCachedNode(node *Node, err error) error { - if !c.useCache || err != nil { - return err +func (node Node) SameConfig(other Node) bool { + if node.Configuration != nil && other.Configuration != nil && *other.Configuration != *node.Configuration { + return false } - c.mu.RLock() - lab, ok := c.labCache[node.LabID] - c.mu.RUnlock() - if !ok { - return err + if len(node.Configurations) != len(other.Configurations) { + return false } - c.mu.Lock() - delete(lab.Nodes, node.ID) - c.mu.Unlock() - return nil + for idx, cfg := range node.Configurations { + if cfg.Name != other.Configurations[idx].Name { + return false + } + if cfg.Content != other.Configurations[idx].Content { + return false + } + } + return true +} + +func (node nodePatchPostAlias) MarshalJSON() ([]byte, error) { + type alias nodePatchPostAlias + if len(node.Configurations) > 0 { + node.Configuration = nil + return json.Marshal(&struct { + alias + NamedConfig []NodeConfig `json:"configuration"` + }{ + (alias)(node), + node.Configurations, + }) + } + return json.Marshal((alias)(node)) } func (c *Client) getNodesForLab(ctx context.Context, lab *Lab) error { api := fmt.Sprintf("labs/%s/nodes?data=true", lab.ID) + if c.useNamedConfigs { + api += "&operational=true&exclude_configurations=false" + } + nodes := &nodeList{} err := c.jsonGet(ctx, api, nodes, 0) if err != nil { @@ -255,19 +278,11 @@ func (c *Client) getNodesForLab(ctx context.Context, lab *Lab) error { return nil } -// NodeSetConfig sets a configuration for the specified node. At least the `ID` of -// the node and the `labID` must be provided in `node`. The `node` instance will -// be updated with the current values for the node as provided by the controller. -func (c *Client) NodeSetConfig(ctx context.Context, node *Node, configuration string) error { +func (c *Client) nodeSetConfigData(ctx context.Context, node *Node, data any) error { api := fmt.Sprintf("labs/%s/nodes/%s", node.LabID, node.ID) - type nodeConfig struct { - Configuration string `json:"configuration"` - } - buf := &bytes.Buffer{} - nodeCfg := nodeConfig{Configuration: configuration} - err := json.NewEncoder(buf).Encode(nodeCfg) + err := json.NewEncoder(buf).Encode(data) if err != nil { return err } @@ -278,16 +293,38 @@ func (c *Client) NodeSetConfig(ctx context.Context, node *Node, configuration st if err != nil { return err } - _, err = c.cacheNode(c.NodeGet(ctx, node, true)) + _, err = c.NodeGet(ctx, node) return err } +// NodeSetConfig sets a configuration for the specified node. At least the `ID` +// of the node and the `labID` must be provided in `node`. The `node` instance +// will be updated with the current values for the node as provided by the +// controller. +func (c *Client) NodeSetConfig(ctx context.Context, node *Node, configuration string) error { + nodeCfg := struct { + Configuration string `json:"configuration"` + }{configuration} + return c.nodeSetConfigData(ctx, node, nodeCfg) +} + +// NodeSetNamedConfigs sets a list of named configurations for the specified +// node. At least the `ID` of the node and the `labID` must be provided in +// `node`. +func (c *Client) NodeSetNamedConfigs(ctx context.Context, node *Node, configs []NodeConfig) error { + nodeCfg := struct { + NamedConfigs []NodeConfig `json:"configuration"` + }{configs} + return c.nodeSetConfigData(ctx, node, nodeCfg) +} + // NodeUpdate updates the node specified by data in `node` (e.g. ID and LabID) -// with the other data provided. It returns the udpated node. +// with the other data provided. It returns the updated node. func (c *Client) NodeUpdate(ctx context.Context, node *Node) (*Node, error) { api := fmt.Sprintf("labs/%s/nodes/%s", node.LabID, node.ID) postAlias := newNodeAlias(node, true) + buf := &bytes.Buffer{} err := json.NewEncoder(buf).Encode(postAlias) if err != nil { @@ -300,7 +337,7 @@ func (c *Client) NodeUpdate(ctx context.Context, node *Node) (*Node, error) { if err != nil { return nil, err } - return c.cacheNode(c.NodeGet(ctx, node, true)) + return c.NodeGet(ctx, node) } // NodeStart starts the given node. @@ -370,7 +407,7 @@ func (c *Client) NodeCreate(ctx context.Context, node *Node) (*Node, error) { if err != nil { // for consistency, remove the created node that can't be updated // this assumes that the error was because of the provided data and - // not because of e.g. a conncectivity issue between the initial create + // not because of e.g. a connectivity issue between the initial create // and the attempted removal. node.ID = newNode.ID c.NodeDestroy(ctx, node) @@ -381,32 +418,29 @@ func (c *Client) NodeCreate(ctx context.Context, node *Node) (*Node, error) { node.Interfaces = InterfaceList{} // fetch the node again, with all data - return c.cacheNode(c.NodeGet(ctx, node, true)) + return c.NodeGet(ctx, node) } // NodeGet returns the node identified by its `ID` and `LabID` in the provided node. -func (c *Client) NodeGet(ctx context.Context, node *Node, nocache bool) (*Node, error) { - if !nocache { - if node, ok := c.getCachedNode(node.LabID, node.ID); ok { - return node, nil - } - } +func (c *Client) NodeGet(ctx context.Context, node *Node) (*Node, error) { + // SIMPLE-5052 -- results are different for simplified=true vs false for + // the inherited values. In the simplified case, all values are always + // null. - newNode := &Node{} + var err error + newNode := Node{} api := fmt.Sprintf("labs/%s/nodes/%s", node.LabID, node.ID) - err := c.jsonGet(ctx, api, newNode, 0) - // SIMPLE-5052 -- results are different for simplified=true vs false - // for the inherited values. in the simplified case, all values are - // always null. - // There's yet another modified "operational" which also influences - // the returned values - return c.cacheNode(newNode, err) + if c.useNamedConfigs { + api += "?operational=true&exclude_configurations=false" + } + err = c.jsonGet(ctx, api, &newNode, 0) + return &newNode, err } // NodeDestroy deletes the node from the controller. func (c *Client) NodeDestroy(ctx context.Context, node *Node) error { api := fmt.Sprintf("labs/%s/nodes/%s", node.LabID, node.ID) - return c.deleteCachedNode(node, c.jsonDelete(ctx, api, 0)) + return c.jsonDelete(ctx, api, 0) } // NodeWipe removes all runtime data from a node on the controller/compute. diff --git a/node_test.go b/node_test.go index 50b438f..f459207 100644 --- a/node_test.go +++ b/node_test.go @@ -14,6 +14,7 @@ var ( "id": "node1", "lab_id": "lab1", "label": "alpine-0", + "configuration": "helloo", "node_definition": "alpine", "state": "STARTED", "tags": [ "tag1", "tag2" ] @@ -26,11 +27,29 @@ var ( "node_definition": "alpine", "state": "STOPPED" }`) + + node1_namedConfigs = []byte(`{ + "id": "node1", + "lab_id": "lab1", + "label": "alpine-0", + "node_definition": "alpine", + "configuration": [ + { + "name": "config", + "content": "hostname node1" + } + ], + "state": "STARTED", + "tags": [ "tag1", "tag2" ] + }`) ) -func TestClient_NodeMapMarschalJSON(t *testing.T) { +func TestClient_NodeMapMarshalJSON(t *testing.T) { nm := NodeMap{ - "zzz": &Node{ID: "zzz"}, + "zzz": &Node{ID: "zzz", Configurations: []NodeConfig{ + {Name: "main", Content: "bla"}, + {Name: "second", Content: "blabla"}, + }}, "aaa": &Node{ID: "aaa"}, } b, err := nm.MarshalJSON() @@ -165,29 +184,21 @@ func TestClient_NodeUpdate(t *testing.T) { }, } - // node99 := &Node{LabID: "lab99", ID: "node99"} - lab := &Lab{ID: "lab99", Nodes: make(NodeMap)} - // lab.Nodes["node99"] = node99 - tc.client.labCache["lab99"] = lab - - for _, useCache := range []bool{true, false} { - tc.client.useCache = useCache - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - tc.mr.SetData(tt.responses) - node := Node{ - LabID: "lab99", ID: "node99", X: 100, Y: 100, - Tags: []string{"newtag"}, - } - resultNode, err := tc.client.NodeUpdate(tc.ctx, &node) - _ = resultNode - if (err != nil) != tt.wantErr { - t.Errorf("Client.NodeUpdate() error = %v, wantErr %v", err, tt.wantErr) - return - } - assert.True(t, tc.mr.Empty()) - }) - } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tc.mr.SetData(tt.responses) + node := Node{ + LabID: "lab99", ID: "node99", X: 100, Y: 100, + Tags: []string{"newtag"}, + } + resultNode, err := tc.client.NodeUpdate(tc.ctx, &node) + _ = resultNode + if (err != nil) != tt.wantErr { + t.Errorf("Client.NodeUpdate() error = %v, wantErr %v", err, tt.wantErr) + return + } + assert.True(t, tc.mr.Empty()) + }) } } @@ -213,22 +224,169 @@ func TestClient_NodeFuncs(t *testing.T) { node99 := &Node{LabID: "lab99", ID: "node99"} lab := &Lab{ID: "lab99", Nodes: make(NodeMap)} lab.Nodes["node99"] = node99 - tc.client.labCache["lab99"] = lab - - for _, useCache := range []bool{true, false} { - tc.client.useCache = useCache - for tfname, tf := range funcs { - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - tc.mr.SetData(tt.responses) - err := tf(tc.ctx, &Node{ID: "node99", LabID: "lab99"}) - if (err != nil) != tt.wantErr { - t.Errorf("%s error = %v, wantErr %v", tfname, err, tt.wantErr) - return - } - assert.True(t, tc.mr.Empty()) - }) - } + for tfname, tf := range funcs { + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tc.mr.SetData(tt.responses) + err := tf(tc.ctx, &Node{ID: "node99", LabID: "lab99"}) + if (err != nil) != tt.wantErr { + t.Errorf("%s error = %v, wantErr %v", tfname, err, tt.wantErr) + return + } + assert.True(t, tc.mr.Empty()) + }) } } } + +func TestClient_NodeSetNamedConfigs(t *testing.T) { + tc := newAuthedTestAPIclient() + tc.client.useNamedConfigs = true + + dataWithUser := mr.MockRespList{ + mr.MockResp{Data: []byte("\"node1\""), URL: `/labs/lab1/nodes/node1$`}, + mr.MockResp{Data: node1_namedConfigs, URL: `/labs/lab1/nodes/node1.*exclude_configurations=false$`}, + } + + tests := []struct { + name string + configuration []NodeConfig + responses mr.MockRespList + wantErr bool + }{ + { + "good", + []NodeConfig{ + {Name: "Main", Content: "hostname bla"}, + }, + dataWithUser, + false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tc.mr.SetData(tt.responses) + node := Node{LabID: "lab1", ID: "node1"} + err := tc.client.NodeSetNamedConfigs(tc.ctx, &node, tt.configuration) + if (err != nil) != tt.wantErr { + t.Errorf("Client.NodeSetNamedConfig() error = %v, wantErr %v", err, tt.wantErr) + return + } + assert.True(t, tc.mr.Empty()) + }) + } +} + +func TestNode_SameConfig(t *testing.T) { + one1 := "one" + one2 := "one" + two := "two" + + type fields struct { + Configuration *string + Configurations []NodeConfig + } + type args struct { + other Node + } + tests := []struct { + name string + fields fields + other Node + want bool + }{ + {"test", fields{Configuration: nil}, Node{Configuration: nil}, true}, + {"test", fields{Configuration: &one1}, Node{Configuration: &one2}, true}, + {"test", fields{Configuration: &one1}, Node{Configuration: &two}, false}, + {"test", fields{ + Configurations: []NodeConfig{ + { + Name: "bla", + Content: "this", + }, + }, + }, + Node{Configurations: []NodeConfig{}}, + false, + }, + {"test", fields{ + Configurations: []NodeConfig{ + { + Name: "bla", + Content: "this", + }, + }, + }, + Node{Configurations: []NodeConfig{ + { + Name: "bla", + Content: "somethingelse", + }, + }, + }, + false, + }, + {"test", fields{ + Configurations: []NodeConfig{ + { + Name: "bla", + Content: "this", + }, + }, + }, + Node{Configurations: []NodeConfig{ + { + Name: "something", + Content: "this", + }, + }, + }, + false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + node := Node{ + Configuration: tt.fields.Configuration, + Configurations: tt.fields.Configurations, + } + if got := node.SameConfig(tt.other); got != tt.want { + t.Errorf("Node.SameConfig() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestNode_UnmarshalJSON(t *testing.T) { + tests := []struct { + name string + data []byte + wantErr bool + }{ + {"nil-config", []byte(`null`), false}, + {"no-config", []byte(`{"id": "bla"}`), false}, + {"ok-config", []byte(`{"id": "bla", "configuration": "hostname bla"}`), false}, + {"ok-named-config", []byte(`{"id": "bla", "configuration": [{"name": "name", "content": "content"}]}`), false}, + {"invalid-named-config", []byte(`{"id": "bla", "configuration": 10}`), true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var n Node + var err error + if err = json.Unmarshal(tt.data, &n); (err != nil) != tt.wantErr { + t.Errorf("Node.UnmarshalJSON() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } + + // direct call with error + t.Run("manual error", func(t *testing.T) { + var bla Node + err := bla.UnmarshalJSON([]byte(`error`)) + if err == nil { + t.Errorf("Node.UnmarshalJSON() expected error, got nil") + } + }) + +} diff --git a/system.go b/system.go index f138c76..73f3f80 100644 --- a/system.go +++ b/system.go @@ -3,7 +3,7 @@ package cmlclient import ( "context" "fmt" - "log" + "log/slog" "regexp" "github.com/Masterminds/semver/v3" @@ -21,7 +21,10 @@ type systemVersion struct { Ready bool `json:"ready"` } -const versionConstraint = ">=2.4.0,<3.0.0" +const ( + versionConstraint = ">=2.4.0,<3.0.0" + namedConfigsConstraint = ">=2.7.0" +) func versionError(got string) error { return fmt.Errorf( @@ -41,19 +44,16 @@ func (c *Client) versionCheck(ctx context.Context, depth int32) error { return ErrSystemNotReady } - constraint, err := semver.NewConstraint(versionConstraint) - if err != nil { - panic("unparsable semver version constant") - } + constraint, _ := semver.NewConstraint(versionConstraint) re := regexp.MustCompile(`^(\d\.\d\.\d)((-dev0)?\+build.*)?$`) m := re.FindStringSubmatch(sv.Version) if m == nil { return versionError(sv.Version) } - log.Printf("controller version: %s", sv.Version) + slog.Info("controller", "version", sv.Version) if len(m[3]) > 0 { - log.Printf("Warning, this is a DEV version %s", sv.Version) + slog.Warn("this is a DEV version", "version", sv.Version) } stem := m[1] v, err := semver.NewVersion(stem) @@ -65,6 +65,14 @@ func (c *Client) versionCheck(ctx context.Context, depth int32) error { if !ok { return versionError(sv.Version) } + + // unset useNamedConfig if necessary + constraint, _ = semver.NewConstraint(namedConfigsConstraint) + if ok = constraint.Check(v); ok { + slog.Info("named configs supported") + } else { + c.useNamedConfigs = false + } c.version = sv.Version return nil } @@ -74,9 +82,15 @@ func (c *Client) Version() string { return c.version } +// Turns on the use of named configs (only with 2.7.0 and newer) +func (c *Client) UseNamedConfigs() { + slog.Info("USE named configs") + c.useNamedConfigs = true +} + // Ready returns nil if the system is compatible and ready func (c *Client) Ready(ctx context.Context) error { - // we can safely assume depth 0 as the API endpoint does not - // require authentication + // we can safely assume depth 0 as the API endpoint does not require + // authentication return c.versionCheck(ctx, 0) } diff --git a/system_test.go b/system_test.go index 80f2c58..5605a7c 100644 --- a/system_test.go +++ b/system_test.go @@ -8,31 +8,40 @@ import ( ) func TestClient_VersionCheck(t *testing.T) { - c := New("https://bla.bla", true, useCache) + c := New("https://bla.bla", true) mrClient, ctx := mr.NewMockResponder() c.httpClient = mrClient + c.useNamedConfigs = true c.state.set(stateAuthenticated) tests := []struct { - name string - wantJSON string - wantErr bool + name string + wantJSON string + wantErr bool + canNamedCfg bool }{ - {"too old", `{"version": "2.1.0","ready": true}`, true}, - {"garbage", `{"version": "garbage","ready": true}`, true}, - {"too new", `{"version": "2.35.0","ready": true}`, true}, - {"perfect", `{"version": "2.4.0","ready": true}`, false}, - {"actual", `{"version": "2.4.0+build.1","ready": true}`, false}, - {"newer", `{"version": "2.4.1","ready": true}`, false}, - {"dev", `{"version": "2.5.0-dev0+build.3.2f7875762","ready": true}`, false}, - {"v2.5.0", `{"version": "2.5.0+build.5","ready": true}`, false}, + // these three yield an error, useNamedConfigs is untouched + {"too old", `{"version": "2.1.0","ready": true}`, true, true}, + {"garbage", `{"version": "garbage","ready": true}`, true, true}, + {"too new", `{"version": "2.35.0","ready": true}`, true, true}, + // the rest will reset useNamedConfigs, if needed + {"perfect", `{"version": "2.4.0","ready": true}`, false, false}, + {"actual", `{"version": "2.4.0+build.1","ready": true}`, false, false}, + {"newer", `{"version": "2.4.1","ready": true}`, false, false}, + {"dev", `{"version": "2.5.0-dev0+build.3.2f7875762","ready": true}`, false, false}, + {"v2.5.0", `{"version": "2.5.0+build.5","ready": true}`, false, false}, + {"v2.7.0", `{"version": "2.7.0+build.8","ready": true}`, false, true}, } for _, tt := range tests { mrClient.SetData(mr.MockRespList{{Data: []byte(tt.wantJSON)}}) t.Run(tt.name, func(t *testing.T) { + c.UseNamedConfigs() if err := c.versionCheck(ctx, 0); (err != nil) != tt.wantErr { t.Errorf("Client.VersionCheck() error = %v, wantErr %v", err, tt.wantErr) } + if tt.canNamedCfg != c.useNamedConfigs { + t.Errorf("Client.VersionCheck() useNamedConfigs is = %t, want %t", c.useNamedConfigs, tt.canNamedCfg) + } }) if !mrClient.Empty() { t.Error("not all data in mock client consumed") @@ -41,7 +50,7 @@ func TestClient_VersionCheck(t *testing.T) { } func TestClient_NotReady(t *testing.T) { - c := New("https://bla.bla", true, useCache) + c := New("https://bla.bla", true) mrClient, ctx := mr.NewMockResponder() c.httpClient = mrClient c.state.set(stateAuthenticated) diff --git a/vendor/github.com/lmittmann/tint/LICENSE b/vendor/github.com/lmittmann/tint/LICENSE new file mode 100644 index 0000000..3f49589 --- /dev/null +++ b/vendor/github.com/lmittmann/tint/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 lmittmann + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/vendor/github.com/lmittmann/tint/README.md b/vendor/github.com/lmittmann/tint/README.md new file mode 100644 index 0000000..88a77ac --- /dev/null +++ b/vendor/github.com/lmittmann/tint/README.md @@ -0,0 +1,88 @@ +# `tint`: 🌈 **slog.Handler** that writes tinted logs + +[![Go Reference](https://pkg.go.dev/badge/github.com/lmittmann/tint.svg)](https://pkg.go.dev/github.com/lmittmann/tint#section-documentation) +[![Go Report Card](https://goreportcard.com/badge/github.com/lmittmann/tint)](https://goreportcard.com/report/github.com/lmittmann/tint) + + + + + + +
+
+ +Package `tint` implements a zero-dependency [`slog.Handler`](https://pkg.go.dev/log/slog#Handler) +that writes tinted (colorized) logs. Its output format is inspired by the `zerolog.ConsoleWriter` and +[`slog.TextHandler`](https://pkg.go.dev/log/slog#TextHandler). + +The output format can be customized using [`Options`](https://pkg.go.dev/github.com/lmittmann/tint#Options) +which is a drop-in replacement for [`slog.HandlerOptions`](https://pkg.go.dev/log/slog#HandlerOptions). + +``` +go get github.com/lmittmann/tint +``` + +## Usage + +```go +w := os.Stderr + +// create a new logger +logger := slog.New(tint.NewHandler(w, nil)) + +// set global logger with custom options +slog.SetDefault(slog.New( + tint.NewHandler(w, &tint.Options{ + Level: slog.LevelDebug, + TimeFormat: time.Kitchen, + }), +)) +``` + +### Customize Attributes + +`ReplaceAttr` can be used to alter or drop attributes. If set, it is called on +each non-group attribute before it is logged. See [`slog.HandlerOptions`](https://pkg.go.dev/log/slog#HandlerOptions) +for details. + +```go +// create a new logger that doesn't write the time +w := os.Stderr +logger := slog.New( + tint.NewHandler(w, &tint.Options{ + ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr { + if a.Key == slog.TimeKey && len(groups) == 0 { + return slog.Attr{} + } + return a + }, + }), +) +``` + +### Automatically Enable Colors + +Colors are enabled by default and can be disabled using the `Options.NoColor` +attribute. To automatically enable colors based on the terminal capabilities, +use e.g. the [`go-isatty`](https://github.com/mattn/go-isatty) package. + +```go +w := os.Stderr +logger := slog.New( + tint.NewHandler(w, &tint.Options{ + NoColor: !isatty.IsTerminal(w.Fd()), + }), +) +``` + +### Windows Support + +Color support on Windows can be added by using e.g. the +[`go-colorable`](https://github.com/mattn/go-colorable) package. + +```go +w := os.Stderr +logger := slog.New( + tint.NewHandler(colorable.NewColorable(w), nil), +) +``` diff --git a/vendor/github.com/lmittmann/tint/buffer.go b/vendor/github.com/lmittmann/tint/buffer.go new file mode 100644 index 0000000..4d7321a --- /dev/null +++ b/vendor/github.com/lmittmann/tint/buffer.go @@ -0,0 +1,46 @@ +package tint + +import "sync" + +type buffer []byte + +var bufPool = sync.Pool{ + New: func() any { + b := make(buffer, 0, 1024) + return (*buffer)(&b) + }, +} + +func newBuffer() *buffer { + return bufPool.Get().(*buffer) +} + +func (b *buffer) Free() { + // To reduce peak allocation, return only smaller buffers to the pool. + const maxBufferSize = 16 << 10 + if cap(*b) <= maxBufferSize { + *b = (*b)[:0] + bufPool.Put(b) + } +} +func (b *buffer) Write(bytes []byte) (int, error) { + *b = append(*b, bytes...) + return len(bytes), nil +} + +func (b *buffer) WriteByte(char byte) error { + *b = append(*b, char) + return nil +} + +func (b *buffer) WriteString(str string) (int, error) { + *b = append(*b, str...) + return len(str), nil +} + +func (b *buffer) WriteStringIf(ok bool, str string) (int, error) { + if !ok { + return 0, nil + } + return b.WriteString(str) +} diff --git a/vendor/github.com/lmittmann/tint/handler.go b/vendor/github.com/lmittmann/tint/handler.go new file mode 100644 index 0000000..62d153c --- /dev/null +++ b/vendor/github.com/lmittmann/tint/handler.go @@ -0,0 +1,438 @@ +/* +Package tint implements a zero-dependency [slog.Handler] that writes tinted +(colorized) logs. The output format is inspired by the [zerolog.ConsoleWriter] +and [slog.TextHandler]. + +The output format can be customized using [Options], which is a drop-in +replacement for [slog.HandlerOptions]. + +# Customize Attributes + +Options.ReplaceAttr can be used to alter or drop attributes. If set, it is +called on each non-group attribute before it is logged. +See [slog.HandlerOptions] for details. + + w := os.Stderr + logger := slog.New( + tint.NewHandler(w, &tint.Options{ + ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr { + if a.Key == slog.TimeKey && len(groups) == 0 { + return slog.Attr{} + } + return a + }, + }), + ) + +# Automatically Enable Colors + +Colors are enabled by default and can be disabled using the Options.NoColor +attribute. To automatically enable colors based on the terminal capabilities, +use e.g. the [go-isatty] package. + + w := os.Stderr + logger := slog.New( + tint.NewHandler(w, &tint.Options{ + NoColor: !isatty.IsTerminal(w.Fd()), + }), + ) + +# Windows Support + +Color support on Windows can be added by using e.g. the [go-colorable] package. + + w := os.Stderr + logger := slog.New( + tint.NewHandler(colorable.NewColorable(w), nil), + ) + +[zerolog.ConsoleWriter]: https://pkg.go.dev/github.com/rs/zerolog#ConsoleWriter +[go-isatty]: https://pkg.go.dev/github.com/mattn/go-isatty +[go-colorable]: https://pkg.go.dev/github.com/mattn/go-colorable +*/ +package tint + +import ( + "context" + "encoding" + "fmt" + "io" + "log/slog" + "path/filepath" + "runtime" + "strconv" + "sync" + "time" + "unicode" +) + +// ANSI modes +const ( + ansiReset = "\033[0m" + ansiFaint = "\033[2m" + ansiResetFaint = "\033[22m" + ansiBrightRed = "\033[91m" + ansiBrightGreen = "\033[92m" + ansiBrightYellow = "\033[93m" + ansiBrightRedFaint = "\033[91;2m" +) + +const errKey = "err" + +var ( + defaultLevel = slog.LevelInfo + defaultTimeFormat = time.StampMilli +) + +// Options for a slog.Handler that writes tinted logs. A zero Options consists +// entirely of default values. +// +// Options can be used as a drop-in replacement for [slog.HandlerOptions]. +type Options struct { + // Enable source code location (Default: false) + AddSource bool + + // Minimum level to log (Default: slog.LevelInfo) + Level slog.Leveler + + // ReplaceAttr is called to rewrite each non-group attribute before it is logged. + // See https://pkg.go.dev/log/slog#HandlerOptions for details. + ReplaceAttr func(groups []string, attr slog.Attr) slog.Attr + + // Time format (Default: time.StampMilli) + TimeFormat string + + // Disable color (Default: false) + NoColor bool +} + +// NewHandler creates a [slog.Handler] that writes tinted logs to Writer w, +// using the default options. If opts is nil, the default options are used. +func NewHandler(w io.Writer, opts *Options) slog.Handler { + h := &handler{ + w: w, + level: defaultLevel, + timeFormat: defaultTimeFormat, + } + if opts == nil { + return h + } + + h.addSource = opts.AddSource + if opts.Level != nil { + h.level = opts.Level + } + h.replaceAttr = opts.ReplaceAttr + if opts.TimeFormat != "" { + h.timeFormat = opts.TimeFormat + } + h.noColor = opts.NoColor + return h +} + +// handler implements a [slog.Handler]. +type handler struct { + attrsPrefix string + groupPrefix string + groups []string + + mu sync.Mutex + w io.Writer + + addSource bool + level slog.Leveler + replaceAttr func([]string, slog.Attr) slog.Attr + timeFormat string + noColor bool +} + +func (h *handler) clone() *handler { + return &handler{ + attrsPrefix: h.attrsPrefix, + groupPrefix: h.groupPrefix, + groups: h.groups, + w: h.w, + addSource: h.addSource, + level: h.level, + replaceAttr: h.replaceAttr, + timeFormat: h.timeFormat, + noColor: h.noColor, + } +} + +func (h *handler) Enabled(_ context.Context, level slog.Level) bool { + return level >= h.level.Level() +} + +func (h *handler) Handle(_ context.Context, r slog.Record) error { + // get a buffer from the sync pool + buf := newBuffer() + defer buf.Free() + + rep := h.replaceAttr + + // write time + if !r.Time.IsZero() { + val := r.Time.Round(0) // strip monotonic to match Attr behavior + if rep == nil { + h.appendTime(buf, r.Time) + buf.WriteByte(' ') + } else if a := rep(nil /* groups */, slog.Time(slog.TimeKey, val)); a.Key != "" { + if a.Value.Kind() == slog.KindTime { + h.appendTime(buf, a.Value.Time()) + } else { + h.appendValue(buf, a.Value, false) + } + buf.WriteByte(' ') + } + } + + // write level + if rep == nil { + h.appendLevel(buf, r.Level) + buf.WriteByte(' ') + } else if a := rep(nil /* groups */, slog.Any(slog.LevelKey, r.Level)); a.Key != "" { + h.appendValue(buf, a.Value, false) + buf.WriteByte(' ') + } + + // write source + if h.addSource { + fs := runtime.CallersFrames([]uintptr{r.PC}) + f, _ := fs.Next() + if f.File != "" { + src := &slog.Source{ + Function: f.Function, + File: f.File, + Line: f.Line, + } + + if rep == nil { + h.appendSource(buf, src) + buf.WriteByte(' ') + } else if a := rep(nil /* groups */, slog.Any(slog.SourceKey, src)); a.Key != "" { + h.appendValue(buf, a.Value, false) + buf.WriteByte(' ') + } + } + } + + // write message + if rep == nil { + buf.WriteString(r.Message) + buf.WriteByte(' ') + } else if a := rep(nil /* groups */, slog.String(slog.MessageKey, r.Message)); a.Key != "" { + h.appendValue(buf, a.Value, false) + buf.WriteByte(' ') + } + + // write handler attributes + if len(h.attrsPrefix) > 0 { + buf.WriteString(h.attrsPrefix) + } + + // write attributes + r.Attrs(func(attr slog.Attr) bool { + h.appendAttr(buf, attr, h.groupPrefix, h.groups) + return true + }) + + if len(*buf) == 0 { + return nil + } + (*buf)[len(*buf)-1] = '\n' // replace last space with newline + + h.mu.Lock() + defer h.mu.Unlock() + + _, err := h.w.Write(*buf) + return err +} + +func (h *handler) WithAttrs(attrs []slog.Attr) slog.Handler { + if len(attrs) == 0 { + return h + } + h2 := h.clone() + + buf := newBuffer() + defer buf.Free() + + // write attributes to buffer + for _, attr := range attrs { + h.appendAttr(buf, attr, h.groupPrefix, h.groups) + } + h2.attrsPrefix = h.attrsPrefix + string(*buf) + return h2 +} + +func (h *handler) WithGroup(name string) slog.Handler { + if name == "" { + return h + } + h2 := h.clone() + h2.groupPrefix += name + "." + h2.groups = append(h2.groups, name) + return h2 +} + +func (h *handler) appendTime(buf *buffer, t time.Time) { + buf.WriteStringIf(!h.noColor, ansiFaint) + *buf = t.AppendFormat(*buf, h.timeFormat) + buf.WriteStringIf(!h.noColor, ansiReset) +} + +func (h *handler) appendLevel(buf *buffer, level slog.Level) { + switch { + case level < slog.LevelInfo: + buf.WriteString("DBG") + appendLevelDelta(buf, level-slog.LevelDebug) + case level < slog.LevelWarn: + buf.WriteStringIf(!h.noColor, ansiBrightGreen) + buf.WriteString("INF") + appendLevelDelta(buf, level-slog.LevelInfo) + buf.WriteStringIf(!h.noColor, ansiReset) + case level < slog.LevelError: + buf.WriteStringIf(!h.noColor, ansiBrightYellow) + buf.WriteString("WRN") + appendLevelDelta(buf, level-slog.LevelWarn) + buf.WriteStringIf(!h.noColor, ansiReset) + default: + buf.WriteStringIf(!h.noColor, ansiBrightRed) + buf.WriteString("ERR") + appendLevelDelta(buf, level-slog.LevelError) + buf.WriteStringIf(!h.noColor, ansiReset) + } +} + +func appendLevelDelta(buf *buffer, delta slog.Level) { + if delta == 0 { + return + } else if delta > 0 { + buf.WriteByte('+') + } + *buf = strconv.AppendInt(*buf, int64(delta), 10) +} + +func (h *handler) appendSource(buf *buffer, src *slog.Source) { + dir, file := filepath.Split(src.File) + + buf.WriteStringIf(!h.noColor, ansiFaint) + buf.WriteString(filepath.Join(filepath.Base(dir), file)) + buf.WriteByte(':') + buf.WriteString(strconv.Itoa(src.Line)) + buf.WriteStringIf(!h.noColor, ansiReset) +} + +func (h *handler) appendAttr(buf *buffer, attr slog.Attr, groupsPrefix string, groups []string) { + attr.Value = attr.Value.Resolve() + if rep := h.replaceAttr; rep != nil && attr.Value.Kind() != slog.KindGroup { + attr = rep(groups, attr) + attr.Value = attr.Value.Resolve() + } + + if attr.Equal(slog.Attr{}) { + return + } + + if attr.Value.Kind() == slog.KindGroup { + if attr.Key != "" { + groupsPrefix += attr.Key + "." + groups = append(groups, attr.Key) + } + for _, groupAttr := range attr.Value.Group() { + h.appendAttr(buf, groupAttr, groupsPrefix, groups) + } + } else if err, ok := attr.Value.Any().(tintError); ok { + // append tintError + h.appendTintError(buf, err, groupsPrefix) + buf.WriteByte(' ') + } else { + h.appendKey(buf, attr.Key, groupsPrefix) + h.appendValue(buf, attr.Value, true) + buf.WriteByte(' ') + } +} + +func (h *handler) appendKey(buf *buffer, key, groups string) { + buf.WriteStringIf(!h.noColor, ansiFaint) + appendString(buf, groups+key, true) + buf.WriteByte('=') + buf.WriteStringIf(!h.noColor, ansiReset) +} + +func (h *handler) appendValue(buf *buffer, v slog.Value, quote bool) { + switch v.Kind() { + case slog.KindString: + appendString(buf, v.String(), quote) + case slog.KindInt64: + *buf = strconv.AppendInt(*buf, v.Int64(), 10) + case slog.KindUint64: + *buf = strconv.AppendUint(*buf, v.Uint64(), 10) + case slog.KindFloat64: + *buf = strconv.AppendFloat(*buf, v.Float64(), 'g', -1, 64) + case slog.KindBool: + *buf = strconv.AppendBool(*buf, v.Bool()) + case slog.KindDuration: + appendString(buf, v.Duration().String(), quote) + case slog.KindTime: + appendString(buf, v.Time().String(), quote) + case slog.KindAny: + switch cv := v.Any().(type) { + case slog.Level: + h.appendLevel(buf, cv) + case encoding.TextMarshaler: + data, err := cv.MarshalText() + if err != nil { + break + } + appendString(buf, string(data), quote) + case *slog.Source: + h.appendSource(buf, cv) + default: + appendString(buf, fmt.Sprintf("%+v", v.Any()), quote) + } + } +} + +func (h *handler) appendTintError(buf *buffer, err error, groupsPrefix string) { + buf.WriteStringIf(!h.noColor, ansiBrightRedFaint) + appendString(buf, groupsPrefix+errKey, true) + buf.WriteByte('=') + buf.WriteStringIf(!h.noColor, ansiResetFaint) + appendString(buf, err.Error(), true) + buf.WriteStringIf(!h.noColor, ansiReset) +} + +func appendString(buf *buffer, s string, quote bool) { + if quote && needsQuoting(s) { + *buf = strconv.AppendQuote(*buf, s) + } else { + buf.WriteString(s) + } +} + +func needsQuoting(s string) bool { + if len(s) == 0 { + return true + } + for _, r := range s { + if unicode.IsSpace(r) || r == '"' || r == '=' || !unicode.IsPrint(r) { + return true + } + } + return false +} + +type tintError struct{ error } + +// Err returns a tinted (colorized) [slog.Attr] that will be written in red color +// by the [tint.Handler]. When used with any other [slog.Handler], it behaves as +// +// slog.Any("err", err) +func Err(err error) slog.Attr { + if err != nil { + err = tintError{err} + } + return slog.Any(errKey, err) +} diff --git a/vendor/modules.txt b/vendor/modules.txt index 0c9740a..dbddfec 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -4,6 +4,9 @@ github.com/Masterminds/semver/v3 # github.com/davecgh/go-spew v1.1.1 ## explicit github.com/davecgh/go-spew/spew +# github.com/lmittmann/tint v1.0.4 +## explicit; go 1.21 +github.com/lmittmann/tint # github.com/pmezard/go-difflib v1.0.0 ## explicit github.com/pmezard/go-difflib/difflib