diff --git a/.expeditor/run_go_tests.sh b/.expeditor/run_go_tests.sh new file mode 100644 index 00000000..43682027 --- /dev/null +++ b/.expeditor/run_go_tests.sh @@ -0,0 +1,6 @@ +#!/bin/bash + +go get . +go mod tidy + +go test -v ./... \ No newline at end of file diff --git a/.expeditor/verify.pipeline.yml b/.expeditor/verify.pipeline.yml index 9ccfc3de..4a365ef6 100644 --- a/.expeditor/verify.pipeline.yml +++ b/.expeditor/verify.pipeline.yml @@ -38,3 +38,14 @@ steps: host_os: windows shell: ["powershell", "-Command"] image: rubydistros/windows-2019:3.1 + +- label: run-go-tests-1.22.4-ubuntu + commands: + - cd /workdir/components/go + # - go get . + - go mod tidy + - go test ./... + expeditor: + executor: + docker: + image: golang:1.22-bullseye diff --git a/components/go/example/main.go b/components/go/example/main.go new file mode 100644 index 00000000..195c7f9e --- /dev/null +++ b/components/go/example/main.go @@ -0,0 +1,13 @@ +package main + +import ( + "fmt" + + cheflicensing "github.com/chef/chef-licensing/components/go/pkg" + "github.com/chef/chef-licensing/components/go/pkg/config" +) + +func main() { + config.SetConfig("Workstation", "x6f3bc76-a94f-4b6c-bc97-4b7ed2b045c0", "https://licensing-acceptance.chef.co/License", "chef") + fmt.Println(cheflicensing.FetchAndPersist()) +} diff --git a/components/go/go.mod b/components/go/go.mod new file mode 100644 index 00000000..b177299f --- /dev/null +++ b/components/go/go.mod @@ -0,0 +1,42 @@ +module github.com/chef/chef-licensing/components/go + +go 1.21.5 + +require ( + github.com/cqroot/prompt v0.9.3 + github.com/gookit/color v1.5.4 + // github.com/progress-platform-services/chef-platform-license-management v0.3.1 + github.com/theckman/yacspin v0.13.12 + gopkg.in/yaml.v2 v2.4.0 +// github.com/progress-platform-services/chef-platform-license-management/public/client/license-management +) + +require ( + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/bubbles v0.16.1 // indirect + github.com/charmbracelet/bubbletea v0.24.2 // indirect + github.com/charmbracelet/lipgloss v0.9.1 // indirect + github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect + github.com/cqroot/multichoose v0.1.1 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/fatih/color v1.13.0 // indirect + github.com/google/go-cmp v0.6.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.15 // indirect + github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/reflow v0.3.0 // indirect + github.com/muesli/termenv v0.15.2 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/stretchr/testify v1.9.0 // indirect + github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 // indirect + golang.org/x/sync v0.7.0 // indirect + golang.org/x/sys v0.22.0 // indirect + golang.org/x/term v0.22.0 + golang.org/x/text v0.16.0 // indirect +) diff --git a/components/go/go.sum b/components/go/go.sum new file mode 100644 index 00000000..be65f99a --- /dev/null +++ b/components/go/go.sum @@ -0,0 +1,79 @@ +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbles v0.16.1 h1:6uzpAAaT9ZqKssntbvZMlksWHruQLNxg49H5WdeuYSY= +github.com/charmbracelet/bubbles v0.16.1/go.mod h1:2QCp9LFlEsBQMvIYERr7Ww2H2bA7xen1idUDIzm/+Xc= +github.com/charmbracelet/bubbletea v0.24.2 h1:uaQIKx9Ai6Gdh5zpTbGiWpytMU+CfsPp06RaW2cx/SY= +github.com/charmbracelet/bubbletea v0.24.2/go.mod h1:XdrNrV4J8GiyshTtx3DNuYkR1FDaJmO3l2nejekbsgg= +github.com/charmbracelet/lipgloss v0.9.1 h1:PNyd3jvaJbg4jRHKWXnCj1akQm4rh8dbEzN1p/u1KWg= +github.com/charmbracelet/lipgloss v0.9.1/go.mod h1:1mPmG4cxScwUQALAAnacHaigiiHB9Pmr+v1VEawJl6I= +github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY= +github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= +github.com/cqroot/multichoose v0.1.1 h1:diGuKYKea9ePOTwUyUDor9zKRqKFWXGkYGqUa9+firU= +github.com/cqroot/multichoose v0.1.1/go.mod h1:BJzIGqbQZNADPDuA3IzhmTMpRc2F3fZKysMRYP+Ydw8= +github.com/cqroot/prompt v0.9.3 h1:00Sjiasl1QL7ttEphJ+1xAl0fKQi+7s2F3aY0x7wnz4= +github.com/cqroot/prompt v0.9.3/go.mod h1:NZvCTeuvR9ew9Hkk7xlrZ9xdVH4AmkO9R0eeBkzOHXQ= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w= +github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/gookit/color v1.5.4 h1:FZmqs7XOyGgCAxmWyPslpiok1k05wmY3SJTytgvYFs0= +github.com/gookit/color v1.5.4/go.mod h1:pZJOeOS8DM43rXbp4AZo1n9zCU2qjpcRko0b6/QJi9w= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34= +github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= +github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= +github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/theckman/yacspin v0.13.12 h1:CdZ57+n0U6JMuh2xqjnjRq5Haj6v1ner2djtLQRzJr4= +github.com/theckman/yacspin v0.13.12/go.mod h1:Rd2+oG2LmQi5f3zC3yeZAOl245z8QOvrH4OPOJNZxLg= +github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 h1:QldyIu/L63oPpyvQmHgvgickp1Yw510KJOqX7H24mg8= +github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778/go.mod h1:2MuV+tbUrU1zIOPMxZ5EncGwgmMJsa+9ucAQZXxsObs= +golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= +golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.22.0 h1:BbsgPEJULsl2fV/AT3v15Mjva5yXKQDyKf+TbDz7QJk= +golang.org/x/term v0.22.0/go.mod h1:F3qCibpT5AMpCRfhfT53vVJwhLtIVHhB9XDjfFvnMI4= +golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= +golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/components/go/pkg/api/api_client.go b/components/go/pkg/api/api_client.go new file mode 100644 index 00000000..b5e74c7e --- /dev/null +++ b/components/go/pkg/api/api_client.go @@ -0,0 +1,139 @@ +package api + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "net/url" + "sync" + + config "github.com/chef/chef-licensing/components/go/pkg/config" + // licenseclient "github.com/progress-platform-services/chef-platform-license-management/public/client/license-management/license" +) + +const ( + CLIENT_VERSION = "v1" +) + +type APIClient struct { + URL string + HTTPClient *http.Client + Headers map[string]string +} + +var ( + apiClient *APIClient + once sync.Once +) + +func (c *APIClient) BaseURL() string { + baseUrl, err := url.Parse(fmt.Sprintf("%s/%s/", c.URL, CLIENT_VERSION)) + if err != nil { + log.Fatal("Error parsing the provided URL: ", err) + } + return baseUrl.String() +} + +// func NewAPIClient() licenseclient.LicenseClient { +// cfg := config.GetConfig() +// conf := pconfig.DefaultHttpConfig(cfg.LicenseServerURL) +// logger, err := plogger.NewLogger(plogger.LoggerConfig{LogLevel: "debug", LogToStdout: true}) +// fmt.Println("Loggflaslfjalskfjlaksfjlkj") +// logger.Warn("Test log") +// if err != nil { +// log.Fatal("Unable to create a logger", err) +// } +// agent, err := pagent.NewAgent(conf, pagent.BasicClient, pauthtype.NoAuth{}, pcache.NewLocalCache(time.Second*60, time.Second*60), logger) +// if err != nil { +// log.Fatal("Unable to create the api client ", err) +// } +// return licenseclient.NewLicenseClient(conf, agent, nil, false, nil, nil, logger) +// } + +func NewClient() *APIClient { + cfg := config.GetConfig() + + apiClient = &APIClient{ + URL: cfg.LicenseServerURL, + HTTPClient: &http.Client{}, + Headers: map[string]string{ + "Content-Type": "application/json", + }, + } + return apiClient +} + +func GetClient() *APIClient { + once.Do(func() { + apiClient = NewClient() + }) + + return apiClient +} + +func (c *APIClient) SetHeader(key, value string) { + c.Headers[key] = value +} + +func (c *APIClient) doGETRequest(endpoint string, queryParams map[string]string) (*http.Response, error) { + urlObj, err := url.Parse(endpoint) + if err != nil { + return nil, err + } + + if queryParams != nil { + q := urlObj.Query() + for key, value := range queryParams { + q.Add(key, value) + } + urlObj.RawQuery = q.Encode() + } + return c.doRequest("GET", urlObj.String(), nil) +} + +func (c *APIClient) doPOSTRequest(endpoint string, body interface{}) (*http.Response, error) { + var reqBody io.Reader + var err error + + if body != nil { + reqBody, err = c.encodeJSON(body) + if err != nil { + return nil, err + } + } + return c.doRequest("POST", endpoint, reqBody) +} + +func (c *APIClient) doRequest(method, endpoint string, body io.Reader) (*http.Response, error) { + url := c.BaseURL() + endpoint + req, err := http.NewRequest(method, url, body) + if err != nil { + return nil, err + } + + for key, value := range c.Headers { + req.Header.Set(key, value) + } + return c.HTTPClient.Do(req) +} + +func (c *APIClient) decodeJSON(resp *http.Response, v interface{}) { + defer resp.Body.Close() + err := json.NewDecoder(resp.Body).Decode(v) + if err != nil { + log.Fatal("Failed to parse the response from the server:", err) + } +} + +func (c *APIClient) encodeJSON(v interface{}) (io.Reader, error) { + buf := new(bytes.Buffer) + err := json.NewEncoder(buf).Encode(v) + if err != nil { + return nil, err + } + + return buf, nil +} diff --git a/components/go/pkg/api/api_client_test.go b/components/go/pkg/api/api_client_test.go new file mode 100644 index 00000000..d75c8e26 --- /dev/null +++ b/components/go/pkg/api/api_client_test.go @@ -0,0 +1,74 @@ +package api_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/chef/chef-licensing/components/go/pkg/api" + "github.com/chef/chef-licensing/components/go/pkg/config" +) + +func TestNewClient(t *testing.T) { + setConfig() + + client := api.NewClient() + if client.URL != "https://testing.license.chef.io" { + t.Errorf("expected BaseURL to be %s, got %s", "https://testing.license.chef.io", client.URL) + } + if client.BaseURL() != "https://testing.license.chef.io/v1/" { + t.Errorf("expected BaseURL to be %s, got %s", "https://testing.license.chef.io/v1/", client.BaseURL()) + } + if client.HTTPClient == nil { + t.Error("expected HTTPClient to be initialized, got nil") + } + _, ok := interface{}(client.HTTPClient).(*http.Client) + if !ok { + t.Error("expected HTTPClient to be of type *http.Client") + } +} + +func TestGetClient(t *testing.T) { + setConfig() + + client1 := api.GetClient() + client2 := api.GetClient() + + if client1 != client2 { + t.Error("client created multiple times") + } +} + +func TestSetHeader(t *testing.T) { + setConfig() + client := api.GetClient() + client.SetHeader("testkey", "testval") + if client.Headers["testkey"] != "testval" { + t.Errorf("expected to set the headers %s:%s, but failed: %v", "testkey", "testval", client.Headers) + } +} + +func MockAPIResponse(mockResponse string, status int) *httptest.Server { + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(status) + w.Write([]byte(mockResponse)) + })) + setConfig(mockServer.URL) + + return mockServer +} + +func setConfig(urls ...string) { + var url string + if len(urls) > 0 { + url = urls[0] + } else { + url = "https://testing.license.chef.io" + } + config.NewConfig(&config.LicenseConfig{ + ProductName: "Workstation", + EntitlementID: "workstation-1234", + LicenseServerURL: url, + ExecutableName: "test", + }) +} diff --git a/components/go/pkg/api/client.go b/components/go/pkg/api/client.go new file mode 100644 index 00000000..0e3bf70e --- /dev/null +++ b/components/go/pkg/api/client.go @@ -0,0 +1,148 @@ +package api + +import ( + "encoding/json" + "errors" + "io" + "log" + "net/http" + "strings" + "time" + + config "github.com/chef/chef-licensing/components/go/pkg/config" +) + +type apiResponse struct { + Data bool `json:"data"` + Message string `json:"message"` + StatusCode int `json:"status_code"` +} + +type clientAPIResponse struct { + Data struct { + Client LicenseClient `json:"client"` + } `json:"data"` + Message string `json:"message"` + StatusCode int `json:"status_code"` +} + +type LicenseClient struct { + LicenseType string `json:"license"` + Status string `json:"status"` + ChangesTo string `json:"changesTo"` + ChangesOn string `json:"changesOn"` + ChangesIn int `json:"changesIn"` + Usage string `json:"usage"` + Used int `json:"used"` + Limit int `json:"limit"` + Measure string `json:"measure"` +} + +func (client LicenseClient) HaveGrace() bool { + return client.Status == "Grace" +} + +func (client LicenseClient) IsExpired() bool { + return client.Status == "Expired" +} + +func (client LicenseClient) IsExhausted() bool { + return client.Status == "Exhausted" +} + +func (client LicenseClient) IsActive() bool { + return client.Status == "Active" +} + +func (client LicenseClient) IsTrial() bool { + return client.LicenseType == "trial" +} + +func (client LicenseClient) IsFree() bool { + return client.LicenseType == "free" +} + +func (client LicenseClient) IsCommercial() bool { + return client.LicenseType == "commercial" +} + +func (client LicenseClient) LicenseExpirationDate() time.Time { + expiresOn, err := time.Parse(time.RFC3339, client.ChangesOn) + if err != nil { + log.Fatal("Unknown expiration time received from the server: ", err) + } + + return expiresOn +} + +func (client LicenseClient) ExpirationInDays() int { + expirationIn := int(time.Until(client.LicenseExpirationDate()).Hours() / 24) + return expirationIn +} + +func (client LicenseClient) IsAboutToExpire() (out bool) { + expiration := client.ExpirationInDays() + return client.Status == "Active" && client.ChangesTo == "Expired" && expiration >= 1 && expiration <= 7 +} + +func (client LicenseClient) IsExpiringOrExpired() bool { + return client.HaveGrace() || client.IsExpired() || client.IsAboutToExpire() +} + +func (c APIClient) GetLicenseClient(keys []string, options ...bool) (*LicenseClient, error) { + params := map[string]string{ + "licenseId": strings.Join(keys, ","), + "entitlementId": config.GetConfig().EntitlementID, + } + + resp, err := c.doGETRequest("client", params) + if err != nil { + return nil, err + } + + var suppress bool + if len(options) > 0 { + suppress = options[0] + } + + body := getResponseBody(resp) + client, parseErr := parseClientResponse(body) + if !suppress && parseErr != nil { + log.Fatal(parseErr) + } + + return client, parseErr +} + +func parseClientResponse(body []byte) (*LicenseClient, error) { + var resp clientAPIResponse + err := json.Unmarshal(body, &resp) + if err == nil && resp.Data.Client.LicenseType != "" { + return &resp.Data.Client, nil + } + + var invalidResp apiResponse + err = json.Unmarshal(body, &invalidResp) + respErr := errors.New("unable to parse the server response") + if err == nil && !invalidResp.Data { + if strings.Contains(invalidResp.Message, "not entitled") { + respErr = errors.New("software is not entitled") + } else { + respErr = errors.New(invalidResp.Message) + } + } else if err != nil { + respErr = err + } + + return nil, respErr +} + +func getResponseBody(resp *http.Response) []byte { + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + log.Fatal(err) + } + + return body +} diff --git a/components/go/pkg/api/client_test.go b/components/go/pkg/api/client_test.go new file mode 100644 index 00000000..5a141068 --- /dev/null +++ b/components/go/pkg/api/client_test.go @@ -0,0 +1,89 @@ +package api_test + +import ( + "net/http" + "testing" + + "github.com/chef/chef-licensing/components/go/pkg/api" +) + +const VALID_CLIENT = ` + { + "data": { + "client": { + "license": "free", + "status": "Active", + "changesTo": "Expired", + "changesOn": "2025-06-10T00:00:00Z", + "changesIn": 5, + "usage": "Active", + "used": 0, + "limit": 1, + "measure": "node" + } + }, + "message": "", + "status_code": 200 + } +` +const INVALID_CLIENT = ` + { + "data": false, + "message": "invalid licenseId", + "status_code": 400 + } +` + +func TestGetLicneseClient(t *testing.T) { + mockServer := MockAPIResponse(VALID_CLIENT, http.StatusOK) + defer mockServer.Close() + + client := api.NewClient() + + licenseClient, err := client.GetLicenseClient([]string{"key-123"}) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + t.Log(licenseClient) + + _, ok := interface{}(licenseClient).(*api.LicenseClient) + if !ok { + t.Errorf("expected the response to be of type *api.LicenseClient, got %T", licenseClient) + } + + if licenseClient.LicenseType != "free" { + t.Errorf("expected the license type to be free, got %v", licenseClient.LicenseType) + } + if licenseClient.IsCommercial() { + t.Errorf("expected the license not to be commerical, got %v", licenseClient.LicenseType) + } + if !licenseClient.IsFree() { + t.Errorf("expected the license to be free, got %v", licenseClient.LicenseType) + } + if licenseClient.Status != "Active" { + t.Errorf("expected the status to be Active, got %v", licenseClient.Status) + } + if licenseClient.IsExpiringOrExpired() { + t.Error("expected the it be expiring or expired") + } + if licenseClient.IsExhausted() { + t.Errorf("expected not to be exhausted, got %v", licenseClient.Status) + } + +} + +func TestFailedLicenseClient(t *testing.T) { + mockServer := MockAPIResponse(INVALID_CLIENT, http.StatusBadRequest) + defer mockServer.Close() + + client := api.NewClient() + + licenseClient, err := client.GetLicenseClient([]string{"key-123"}, true) + if err == nil { + t.Fatalf("expected the api to fail, but succeeded and returned %v", licenseClient) + } + if err.Error() != "invalid licenseId" { + t.Fatalf("expected `invalid licenseId` error, got %s", err.Error()) + } +} diff --git a/components/go/pkg/api/describe.go b/components/go/pkg/api/describe.go new file mode 100644 index 00000000..be721e69 --- /dev/null +++ b/components/go/pkg/api/describe.go @@ -0,0 +1,68 @@ +package api + +import ( + "strings" + + config "github.com/chef/chef-licensing/components/go/pkg/config" +) + +type LicenseDetail struct { + LicenseKey string `json:"licenseKey"` + SerialNumber string `json:"serialNumber"` + LicenseType string `json:"licenseType"` + Name string `json:"name"` + Start string `json:"start"` + End string `json:"end"` + Status string `json:"status"` + Limits []Limit `json:"limits"` +} + +type Limit struct { + Software string `json:"software"` + ID string `json:"id"` + Amount int `json:"amount"` + Measure string `json:"measure"` + Used int `json:"used"` + Status string `json:"status"` +} + +type Asset struct { + ID string `json:"id"` + Name string `json:"name"` + Entitled bool `json:"entitled"` + From []struct { + LicenseKey string `json:"license"` + Status string `json:"status"` + } `json:"from"` +} + +type LicenseDescribe struct { + Licenses []LicenseDetail `json:"license"` + Softwares []Asset `json:"Software"` + Features []Asset `json:"Features"` + Assets interface{} `json:"Assets"` + Services interface{} `json:"Services"` +} + +type describeResponse struct { + Data LicenseDescribe `json:"data"` + Message string `json:"message"` + StatusCode int `json:"status"` +} + +func (c APIClient) GetLicenseDescribe(keys []string) (*LicenseDescribe, error) { + params := map[string]string{ + "licenseId": strings.Join(keys, ","), + "entitlementId": config.GetConfig().EntitlementID, + } + + resp, err := c.doGETRequest("desc", params) + if err != nil { + return nil, err + } + + var data describeResponse + c.decodeJSON(resp, &data) + + return &data.Data, nil +} diff --git a/components/go/pkg/api/describe_test.go b/components/go/pkg/api/describe_test.go new file mode 100644 index 00000000..73270730 --- /dev/null +++ b/components/go/pkg/api/describe_test.go @@ -0,0 +1,140 @@ +package api_test + +import ( + "net/http" + "testing" + + "github.com/chef/chef-licensing/components/go/pkg/api" + "github.com/chef/chef-licensing/components/go/pkg/config" +) + +const VALID_DESCRIBE = ` + { + "data": { + "license": [ + { + "licenseKey": "key-123456", + "serialNumber": "NA", + "licenseType": "trial", + "name": "Lorem Ipsum", + "start": "2024-05-30T00:00:00Z", + "end": "2024-06-29T00:00:00Z", + "status": "Active", + "limits": [ + { + "software": "Workstation", + "id": "workstation-1234", + "amount": 10, + "measure": "node", + "used": 0, + "status": "Active" + } + ] + } + ], + "Assets": null, + "Software": [ + { + "id": "workstation-1234", + "name": "Workstation", + "entitled": true, + "from": [ + { + "license": "key-123456", + "status": "Active" + } + ] + } + ], + "Features": [ + { + "id": "feature-123", + "name": "Inspec-Parallel", + "entitled": true, + "from": [ + { + "license": "key-123456", + "status": "Active" + } + ] + } + ], + "Services": null + }, + "message": "", + "status": 200 + } +` +const INVALID_DESCRIBE = ` + { + "data": { + "license": [ + { + "licenseKey": "key-654321", + "serialNumber": "Invalid", + "licenseType": "Invalid", + "name": "", + "start": "0001-01-01T00:00:00Z", + "end": "0001-01-01T00:00:00Z", + "status": "Invalid", + "limits": null + } + ], + "Assets": null, + "Software": null, + "Features": null, + "Services": null + }, + "message": "", + "status": 200 + } +` + +func TestGetLicenseDescribe(t *testing.T) { + mockServer := MockAPIResponse(VALID_DESCRIBE, http.StatusOK) + defer mockServer.Close() + + apiClient := api.NewClient() + + licenseID := "key-123456" + describe, err := apiClient.GetLicenseDescribe([]string{licenseID}) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + t.Log(describe) + _, ok := interface{}(describe).(*api.LicenseDescribe) + if !ok { + t.Errorf("expected the response to be of type *api.LicenseDescribe, got %T", describe) + } + if describe.Licenses[0].LicenseKey != licenseID { + t.Errorf("expected the licensekey to be %v, got %v", licenseID, describe.Licenses[0].LicenseKey) + } + if len(describe.Softwares) == 0 { + t.Errorf("expected to return Softwares, got %v", describe.Softwares) + } + limit := describe.Licenses[0].Limits[0] + conf := config.GetConfig() + if limit.Software != "Workstation" { + t.Errorf("expected the software to %v, got %v", conf.ProductName, limit.Software) + } + if limit.ID != "workstation-1234" { + t.Errorf("expected the software to %v, got %v", conf.EntitlementID, limit.ID) + } +} + +func TestGetLicenseDescribeFailure(t *testing.T) { + mockServer := MockAPIResponse(INVALID_DESCRIBE, http.StatusOK) + defer mockServer.Close() + + apiClient := api.NewClient() + + licenseID := "key-123456" + describe, err := apiClient.GetLicenseDescribe([]string{licenseID}) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if describe.Licenses[0].LicenseType != "Invalid" { + t.Errorf("expected the license id to be %v, got %v", "Invalid", describe.Licenses[0].LicenseType) + } +} diff --git a/components/go/pkg/api/feature_entitlement.go b/components/go/pkg/api/feature_entitlement.go new file mode 100644 index 00000000..2fab133c --- /dev/null +++ b/components/go/pkg/api/feature_entitlement.go @@ -0,0 +1,60 @@ +package api + +import ( + "fmt" + "net/http" +) + +type FeatureEntitlement struct { + Entitled bool `json:"entitled"` + EntitledBy map[string]bool `json:"entitledBy"` +} + +type featureResponse struct { + Data FeatureEntitlement `json:"data"` + StatusCode int `json:"status"` +} + +func (c APIClient) GetFeatureByName(featureName string, keys []string) (*FeatureEntitlement, error) { + params := struct { + Keys []string `json:"licenseIds"` + FeatureName string `json:"featureName"` + }{ + Keys: keys, + FeatureName: featureName, + } + + resp, err := c.doPOSTRequest("featurebyname", params) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("received non-200 response: %d", resp.StatusCode) + } + + var data featureResponse + c.decodeJSON(resp, &data) + return &data.Data, nil +} + +func (c APIClient) GetFeatureByGUID(featureID string, keys []string) (*FeatureEntitlement, error) { + params := struct { + Keys []string `json:"licenseIds"` + FeatureID string `json:"featureGuid"` + }{ + Keys: keys, + FeatureID: featureID, + } + + resp, err := c.doPOSTRequest("featurebyid", params) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("received non-200 response: %d", resp.StatusCode) + } + + var data featureResponse + c.decodeJSON(resp, &data) + return &data.Data, nil +} diff --git a/components/go/pkg/api/feature_entitlement_test.go b/components/go/pkg/api/feature_entitlement_test.go new file mode 100644 index 00000000..c23882da --- /dev/null +++ b/components/go/pkg/api/feature_entitlement_test.go @@ -0,0 +1,62 @@ +package api_test + +import ( + "net/http" + "testing" + + "github.com/chef/chef-licensing/components/go/pkg/api" +) + +const FEATURE_VALID_RESPONSE = ` + { + "data": { + "entitled": true, + "entitledBy": { + "key-123456": true + }, + "limits": {} + }, + "status": 200 + } +` + +func TestGetFeatureByName(t *testing.T) { + mockServer := MockAPIResponse(FEATURE_VALID_RESPONSE, http.StatusOK) + defer mockServer.Close() + + apiClient := api.NewClient() + + licenseID := "key-123456" + data, err := apiClient.GetFeatureByName("feature", []string{licenseID}) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + t.Log(data) + if !data.Entitled { + t.Errorf("expected entitlement to be %v, got %v", true, data.Entitled) + } + if !data.EntitledBy["key-123456"] { + t.Errorf("expected entitlement to be %v, got %v", true, data.EntitledBy["key-123456"]) + } + +} + +func TestGetFeatureByGUID(t *testing.T) { + mockServer := MockAPIResponse(FEATURE_VALID_RESPONSE, http.StatusOK) + defer mockServer.Close() + + apiClient := api.NewClient() + + licenseID := "key-123456" + data, err := apiClient.GetFeatureByGUID("feature-123", []string{licenseID}) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + t.Log(data) + if !data.Entitled { + t.Errorf("expected entitlement to be %v, got %v", true, data.Entitled) + } + if !data.EntitledBy["key-123456"] { + t.Errorf("expected entitlement to be %v, got %v", true, data.EntitledBy["key-123456"]) + } +} diff --git a/components/go/pkg/api/list_licenses.go b/components/go/pkg/api/list_licenses.go new file mode 100644 index 00000000..4a178125 --- /dev/null +++ b/components/go/pkg/api/list_licenses.go @@ -0,0 +1,26 @@ +package api + +import ( + "errors" + "net/http" +) + +type listLicenseResponse struct { + Data []string `json:"Data"` + Message string `json:"message"` + StatusCode int `json:"status_code"` +} + +func (c APIClient) ListLicensesAPI() ([]string, error) { + resp, err := c.doGETRequest("listLicenses", map[string]string{}) + if err != nil { + return []string{}, err + } + if resp.StatusCode == http.StatusNotFound { + return []string{}, errors.New("not found") + } + + var out listLicenseResponse + c.decodeJSON(resp, &out) + return out.Data, nil +} diff --git a/components/go/pkg/api/list_licenses_test.go b/components/go/pkg/api/list_licenses_test.go new file mode 100644 index 00000000..e50f2c6e --- /dev/null +++ b/components/go/pkg/api/list_licenses_test.go @@ -0,0 +1,52 @@ +package api_test + +import ( + "net/http" + "testing" + + "github.com/chef/chef-licensing/components/go/pkg/api" +) + +const LIST_VALID_RESPONSE = ` + { + "data": ["key-123"], + "message": "", + "status_code": 200 + } +` +const LIST_INVALID_RESPONSE = ` + { + "data": null, + "message": "You are not authorized to access this resource", + "status_code": 404 + } +` + +func TestListLicensesAPISuccess(t *testing.T) { + mockServer := MockAPIResponse(LIST_VALID_RESPONSE, http.StatusOK) + defer mockServer.Close() + + client := api.NewClient() + + resp, err := client.ListLicensesAPI() + if err != nil { + t.Errorf("expected the client to not return error, got %v", err) + } + if resp[0] != "key-123" { + t.Errorf("expected the api to return %v, got %v", "key-123", resp[0]) + } +} + +func TestListLicensesAPIFailure(t *testing.T) { + mockServer := MockAPIResponse(LIST_INVALID_RESPONSE, http.StatusNotFound) + defer mockServer.Close() + + client := api.NewClient() + _, err := client.ListLicensesAPI() + if err == nil { + t.Errorf("expected the api to return error, got none") + } + if err.Error() != "not found" { + t.Errorf("expected the api to return <%v>, got <%v>", "not found", err.Error()) + } +} diff --git a/components/go/pkg/api/software_entitlement.go b/components/go/pkg/api/software_entitlement.go new file mode 100644 index 00000000..0b900bf4 --- /dev/null +++ b/components/go/pkg/api/software_entitlement.go @@ -0,0 +1,46 @@ +package api + +import ( + "fmt" + "net/http" +) + +type entitlementsResponse struct { + Data map[string][]Entitlement `json:"data"` + StatusCode int `json:"status"` +} + +type Entitlement struct { + Name string `json:"name"` + ID string `json:"id"` + Measure string `json:"measure"` + Limit int `json:"limit"` + Grace struct { + Limit int `json:"limit"` + Duration int `json:"duration"` + } `json:"grace"` + Period struct { + Start string `json:"start"` + End string `json:"end"` + } `json:"period"` +} + +func (c APIClient) GetAllEntitlementsByLisenceID(keys []string) (*map[string][]Entitlement, error) { + params := struct { + Keys []string `json:"licenseIds"` + }{ + Keys: keys, + } + + resp, err := c.doPOSTRequest("entitlements", params) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("received non-200 response: %d", resp.StatusCode) + } + + var data entitlementsResponse + c.decodeJSON(resp, &data) + return &data.Data, nil +} diff --git a/components/go/pkg/api/software_entitlement_test.go b/components/go/pkg/api/software_entitlement_test.go new file mode 100644 index 00000000..79325acb --- /dev/null +++ b/components/go/pkg/api/software_entitlement_test.go @@ -0,0 +1,48 @@ +package api_test + +import ( + "net/http" + "testing" + + "github.com/chef/chef-licensing/components/go/pkg/api" +) + +const SOFTWARE_ENTITLEMENT_VALID_RESPONSE = ` + { + "data": { + "key-123456": [ + { + "name": "Workstation", + "id": "workstation-12345", + "measure": "node", + "limit": 10, + "grace": { + "limit": 0, + "duration": 0 + }, + "period": { + "start": "2024-06-26", + "end": "2024-07-26" + } + } + ] + }, + "status": 200 + } +` + +func TestGetAllEntitlementsByLisenceID(t *testing.T) { + mockServer := MockAPIResponse(SOFTWARE_ENTITLEMENT_VALID_RESPONSE, http.StatusOK) + defer mockServer.Close() + + apiClient := api.NewClient() + + licenseID := "key-123456" + data, err := apiClient.GetAllEntitlementsByLisenceID([]string{licenseID}) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if data == nil { + t.Error("expected entitlements, got nil") + } +} diff --git a/components/go/pkg/api/validate.go b/components/go/pkg/api/validate.go new file mode 100644 index 00000000..f64c3221 --- /dev/null +++ b/components/go/pkg/api/validate.go @@ -0,0 +1,44 @@ +package api + +import ( + "errors" + "log" + "net/http" +) + +type validateResponse struct { + Data bool `json:"data"` + Message string `json:"message"` + StatusCode int `json:"status_code"` +} + +func (v validateResponse) IsValid() bool { + return v.Data +} + +func (c APIClient) ValidateLicenseAPI(key string, options ...bool) (bool, error) { + opts := map[string]string{ + "licenseId": key, + } + + resp, err := c.doGETRequest("validate", opts) + if err != nil { + return false, err + } + + var data validateResponse + c.decodeJSON(resp, &data) + var suppress bool + if len(options) > 0 { + suppress = options[0] + } + + if resp.StatusCode != http.StatusOK { + err = errors.New(data.Message) + if !suppress { + log.Fatal(err) + } + } + + return data.IsValid(), err +} diff --git a/components/go/pkg/api/validate_test.go b/components/go/pkg/api/validate_test.go new file mode 100644 index 00000000..56809132 --- /dev/null +++ b/components/go/pkg/api/validate_test.go @@ -0,0 +1,59 @@ +package api_test + +import ( + "net/http" + "testing" + + "github.com/chef/chef-licensing/components/go/pkg/api" +) + +const VALID_RESPONSE = ` + { + "data": true, + "message": "License Id is valid", + "status_code": 200 + } +` +const INVALID_RESPONSE = ` + { + "data": false, + "message": "license not found", + "status_code": 400 + } +` + +func TestValidateLicenseSuccess(t *testing.T) { + mockServer := MockAPIResponse(VALID_RESPONSE, http.StatusOK) + defer mockServer.Close() + + client := api.NewClient() + + valid, err := client.ValidateLicenseAPI("key-123456") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + _, ok := interface{}(valid).(bool) + if !ok { + t.Errorf("expected the response to be of type bool, got %T", valid) + } + + if !valid { + t.Errorf("expected the api to return %v, got %v", true, valid) + } +} + +func TestValidateLicenseFailure(t *testing.T) { + mockServer := MockAPIResponse(INVALID_RESPONSE, http.StatusBadRequest) + defer mockServer.Close() + + client := api.NewClient() + valid, err := client.ValidateLicenseAPI("key-123456", true) + if err == nil { + t.Fatalf("expected errors, got %v", err) + } + + if valid { + t.Errorf("expected the response to be %v, got %v", false, valid) + } +} diff --git a/components/go/pkg/chef_licensing.go b/components/go/pkg/chef_licensing.go new file mode 100644 index 00000000..75444b4a --- /dev/null +++ b/components/go/pkg/chef_licensing.go @@ -0,0 +1,20 @@ +package cheflicensing + +import ( + "github.com/chef/chef-licensing/components/go/pkg/api" + keyfetcher "github.com/chef/chef-licensing/components/go/pkg/key_fetcher" +) + +func FetchAndPersist() []string { + return keyfetcher.FetchAndPersist() +} + +func CheckSoftwareEntitlement() (bool, error) { + keys := keyfetcher.FetchLicenseKeys() + _, error := api.GetClient().GetLicenseClient(keys, true) + if error == nil { + return true, nil + } else { + return false, error + } +} diff --git a/components/go/pkg/config/config.go b/components/go/pkg/config/config.go new file mode 100644 index 00000000..77f7bdba --- /dev/null +++ b/components/go/pkg/config/config.go @@ -0,0 +1,27 @@ +package config + +type LicenseConfig struct { + ProductName string + EntitlementID string + LicenseServerURL string + ExecutableName string +} + +var cfg *LicenseConfig + +func NewConfig(c *LicenseConfig) { + cfg = c +} + +func SetConfig(name, entitlementID, URL, executable string) { + cfg = &LicenseConfig{ + ProductName: name, + EntitlementID: entitlementID, + LicenseServerURL: URL, + ExecutableName: executable, + } +} + +func GetConfig() *LicenseConfig { + return cfg +} diff --git a/components/go/pkg/config/config_test.go b/components/go/pkg/config/config_test.go new file mode 100644 index 00000000..56828f73 --- /dev/null +++ b/components/go/pkg/config/config_test.go @@ -0,0 +1,40 @@ +package config_test + +import ( + "testing" + + "github.com/chef/chef-licensing/components/go/pkg/config" +) + +func TestNewConfig(t *testing.T) { + c := config.LicenseConfig{ + ProductName: "testing", + EntitlementID: "testing-1234", + LicenseServerURL: "https://testing.license.chef.io", + ExecutableName: "test", + } + config.NewConfig(&c) + assertions(t) +} + +func TestSetConfig(t *testing.T) { + config.SetConfig("testing", "testing-1234", "https://testing.license.chef.io", "test") + + assertions(t) +} + +func assertions(t *testing.T) { + conf := config.GetConfig() + if conf.ProductName != "testing" { + t.Errorf("expected the productName to be %v, got %v", "testing", conf.ProductName) + } + if conf.EntitlementID != "testing-1234" { + t.Errorf("expected the EntitlementID to be %v, got %v", "testing-1234", conf.EntitlementID) + } + if conf.ExecutableName != "test" { + t.Errorf("expected the ExecutableName to be %v, got %v", "test", conf.ExecutableName) + } + if conf.LicenseServerURL != "https://testing.license.chef.io" { + t.Errorf("expected the LicenseServerURL to be %v, got %v", "https://testing.license.chef.io", conf.LicenseServerURL) + } +} diff --git a/components/go/pkg/key_fetcher/action.go b/components/go/pkg/key_fetcher/action.go new file mode 100644 index 00000000..54552875 --- /dev/null +++ b/components/go/pkg/key_fetcher/action.go @@ -0,0 +1,309 @@ +package keyfetcher + +import ( + "context" + "errors" + "fmt" + "log" + "os" + "reflect" + "strconv" + "time" + + "github.com/chef/chef-licensing/components/go/pkg/api" + "github.com/chef/chef-licensing/components/go/pkg/spinner" + "github.com/cqroot/prompt" + inputPrompt "github.com/cqroot/prompt/input" + "github.com/gookit/color" +) + +type PromptAttribute struct { + TimeoutWarningColor string `yaml:"timeout_warning_color"` + TimeoutDuration int `yaml:"timeout_duration"` + TimeoutMessage string `yaml:"timeout_message"` + TimeoutContinue bool `yaml:"timeout_continue"` +} + +type ActionDetail struct { + Messages []string `yaml:"messages"` + Options []string `yaml:"options,omitempty"` + Action string `yaml:"action"` + PromptType string `yaml:"prompt_type"` + PromptAttribute PromptAttribute `yaml:"prompt_attributes"` + Paths []string `yaml:"paths"` + ResponsePathMap map[string]string `yaml:"response_path_map"` + Choice string `yaml:"choice"` +} + +var lastUserInput string + +func (ad ActionDetail) PerformInteraction() (nextID string) { + var methodName string + if ad.PromptType != "" { + methodName = ad.PromptType + } else if ad.Action != "" { + methodName = ad.Action + } + + meth := reflect.ValueOf(ad).MethodByName(methodName) + returnVals := meth.Call(nil) + + if len(returnVals) > 0 { + if returnValue, ok := returnVals[0].Interface().(string); ok { + nextID = returnValue + } + } else { + log.Fatal("Something went wrong with the interactions") + } + + return +} + +func (ad ActionDetail) Say() string { + renderMessages(ad.Messages) + return ad.Paths[0] +} + +func (ad ActionDetail) TimeoutSelect() string { + attribute := ad.PromptAttribute + timeoutContext, cancel := context.WithTimeout(context.Background(), time.Second*time.Duration(attribute.TimeoutDuration)) + defer cancel() + + done := make(chan struct{}) + var val string + var err error + go func() { + val, err = prompt.New().Ask(ad.Messages[0]). + Choose(ad.Options) + checkPromptErr(err) + close(done) + }() + + select { + case <-done: + if err == nil { + fmt.Printf("Selected option: %s\n", val) + return ad.ResponsePathMap[val] + } + case <-timeoutContext.Done(): + fmt.Printf(printInColor(attribute.TimeoutWarningColor, attribute.TimeoutMessage, false, true)) + fmt.Printf("Timeout!\n") + if !attribute.TimeoutContinue { + os.Exit(1) + } else { + return ad.ResponsePathMap["Skip"] + } + } + return "" +} + +func (ad ActionDetail) Ask() string { + val, err := prompt.New().Ask(ad.Messages[0]). + Input("license-key", inputPrompt.WithValidateFunc(validateLicenseFormat)) + if err != nil { + if errors.Is(err, prompt.ErrUserQuit) { + fmt.Fprintln(os.Stderr, "Error:", err) + os.Exit(1) + } else if errors.Is(err, ErrInvalidKeyFormat) { + fmt.Fprintln(os.Stderr, err) + } else { + panic(err) + } + } + SetLastUserInput(val) + PromptInput.LicenseID = val + + return ad.Paths[0] +} + +func (ad ActionDetail) Select() string { + val1, err := prompt.New().Ask(ad.Messages[0]). + Choose(ad.Options) + checkPromptErr(err) + + return ad.ResponsePathMap[val1] +} + +func (ad ActionDetail) SayAndSelect() string { + renderMessages(ad.Messages) + val1, err := prompt.New().Ask(ad.Choice).Choose(ad.Options) + checkPromptErr(err) + + return ad.ResponsePathMap[val1] +} + +func (ad ActionDetail) Warn() string { + renderMessages(ad.Messages) + + return ad.Paths[0] +} + +func (ad ActionDetail) Error() string { + renderMessages(ad.Messages) + + return ad.Paths[0] +} + +func (ad ActionDetail) Ok() string { + renderMessages(ad.Messages) + + return ad.Paths[0] +} + +func (ad ActionDetail) DoesLicenseHaveValidPattern() string { + isValid := ValidateKeyFormat(GetLastUserInput()) + if isValid { + return ad.ResponsePathMap["true"] + } else { + color.Warn.Println(ErrInvalidKeyFormat) + return ad.ResponsePathMap["false"] + } +} + +func (ad ActionDetail) IsLicenseValidOnServer() string { + spn, err := spinner.GetSpinner("License Validation") + if err != nil { + fmt.Printf("Unable to start the spinner\n") + } + spinner.StartSpinner(spn, "In Progress") + + isValid, message := api.GetClient().ValidateLicenseAPI(GetLastUserInput(), true) + + var stopChar string + var stopColor string + if isValid { + stopChar = "✓" + stopColor = "green" + } else { + stopChar = "✖" + stopColor = "red" + PromptInput.FailureMessage = message.Error() + } + spinner.StopSpinner(spn, "Done", stopChar, stopColor) + return ad.ResponsePathMap[strconv.FormatBool(isValid)] +} + +func (ad ActionDetail) FetchInvalidLicenseMessage() string { + if PromptInput.FailureMessage == "" { + _, message := api.GetClient().ValidateLicenseAPI(GetLastUserInput(), true) + PromptInput.FailureMessage = message.Error() + } + return ad.Paths[0] +} + +func (ad ActionDetail) IsLicenseAllowed() string { + client, error := api.GetClient().GetLicenseClient([]string{GetLastUserInput()}) + if error != nil { + log.Fatal(error) + } + licenseType := client.LicenseType + PromptInput.LicenseType = licenseType + if licenseType == "commercial" { + PromptInput.IsCommercial = true + } + + var isRestricted bool + if IsLicenseRestricted(licenseType) { + // Existing license keys needs to be fetcher to show details of existing license of license type which is restricted. + // However, if user is trying to add Free Tier License, and user has active trial license, we fetch the trial license key + var existingLicenseKeysInFile []string + if licenseType == "free" && DoesUserHasActiveTrialLicense() { + existingLicenseKeysInFile = FetchLicenseKeysBasedOnType(":trial") + } else { + existingLicenseKeysInFile = FetchLicenseKeysBasedOnType(":" + licenseType) + } + PromptInput.LicenseID = existingLicenseKeysInFile[len(existingLicenseKeysInFile)-1] + } else { + isRestricted = true + } + return ad.ResponsePathMap[strconv.FormatBool(isRestricted)] +} + +func (ad ActionDetail) DetermineRestrictionType() string { + var resType string + if PromptInput.LicenseType == "free" && DoesUserHasActiveTrialLicense() { + resType = "active_trial_restriction" + } else { + resType = PromptInput.LicenseType + "_restriction" + } + + return ad.ResponsePathMap[resType] +} + +func (ad ActionDetail) DisplayLicenseInfo() string { + PrintLicenseKeyOverview([]string{GetLastUserInput()}) + return ad.Paths[0] +} + +func (ad ActionDetail) FetchLicenseTypeRestricted() string { + var val string + if IsLicenseRestricted("trial") && IsLicenseRestricted("free") { + val = "trial_and_free" + } else if IsLicenseRestricted("trial") { + val = "trial" + } else { + val = "free" + } + return ad.ResponsePathMap[val] +} + +func (ad ActionDetail) CheckLicenseExpirationStatus() string { + licenseClient := getLicense() + var status string + if licenseClient.IsExpired() || licenseClient.HaveGrace() { + status = "expired" + } else if licenseClient.IsAboutToExpire() { + PromptInput.LicenseExpirationDate = licenseClient.LicenseExpirationDate().Format(time.UnixDate) + PromptInput.ExpirationInDays = strconv.Itoa(licenseClient.ExpirationInDays()) + status = "about_to_expire" + } else if licenseClient.IsExhausted() && (licenseClient.IsCommercial() || licenseClient.IsFree()) { + status = "exhausted_license" + } else { + status = "active" + } + + return ad.ResponsePathMap[status] +} + +func (ad ActionDetail) FetchLicenseId() string { + return ad.Paths[0] +} + +func (ad ActionDetail) IsCommercialLicense() string { + val := PromptInput.IsCommercial + return ad.ResponsePathMap[strconv.FormatBool(val)] +} + +func (ad ActionDetail) IsRunAllowedOnLicenseExhausted() string { + val := PromptInput.IsCommercial + + return ad.ResponsePathMap[strconv.FormatBool(val)] +} + +func (ad ActionDetail) FilterLicenseTypeOptions() string { + var val string + if IsLicenseRestricted("trial") && IsLicenseRestricted("free") || DoesUserHasActiveTrialLicense() { + val = "ask_for_commercial_only" + } else if IsLicenseRestricted("trial") { + val = "ask_for_license_except_trial" + } else if IsLicenseRestricted("free") { + val = "ask_for_license_except_free" + } else { + val = "ask_for_all_license_type" + } + + return ad.ResponsePathMap[val] +} + +func (ad ActionDetail) SetLicenseInfo() string { + SetLastUserInput(PromptInput.LicenseID) + return ad.Paths[0] +} + +func SetLastUserInput(val string) { + lastUserInput = val +} + +func GetLastUserInput() string { + return lastUserInput +} diff --git a/components/go/pkg/key_fetcher/action_test.go b/components/go/pkg/key_fetcher/action_test.go new file mode 100644 index 00000000..d6c8a880 --- /dev/null +++ b/components/go/pkg/key_fetcher/action_test.go @@ -0,0 +1,259 @@ +package keyfetcher_test + +import ( + "bytes" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "github.com/chef/chef-licensing/components/go/pkg/config" + keyfetcher "github.com/chef/chef-licensing/components/go/pkg/key_fetcher" + "gopkg.in/yaml.v2" +) + +type actionFunction func() string + +const YAML_DATA = ` +interactions: + test_say: + messages: ["First Message"] + prompt_type: "Say" + paths: [next_path_after_say] + + ask_for_license_timeout: + messages: ["Please choose one of the options below"] + options: ["Option A", "Option B"] + prompt_type: "TimeoutSelect" + prompt_attributes: + timeout_continue: true + timeout_duration: 1 + timeout_message: "Prompt timed out. Use non-interactive flags or enter an answer within 60 seconds." + paths: [next_path_after_timeout] + warn: + messages: ["This is a warn"] + paths: [path_after_warn] + error: + messages: ["This is a error"] + paths: [path_after_error] + ok: + messages: ["This is a ok"] + paths: [path_after_ok] +` +const VALID_CLIENT_RESPONSE = ` +{ + "data": { + "client": { + "license": "trial", + "status": "Active", + "changesTo": "Expired", + "changesOn": "%s", + "changesIn": 5, + "usage": "Active", + "used": 0, + "limit": 1, + "measure": "node" + } + }, + "message": "", + "status_code": 200 +} +` + +func TestSayAction(t *testing.T) { + actions := loadInteractions() + + detail := actions["test_say"] + + stdOut, funcOut := readFromSTDOUT(detail.Say) + if stdOut != "\nFirst Message" { + t.Errorf("expected %q but got %q", "First Message", stdOut) + } + if funcOut != "next_path_after_say" { + t.Errorf("expected %q but got %q", "next_path_after_say", funcOut) + } +} + +func TestTimeoutSelect(t *testing.T) { + actions := loadInteractions() + + detail := actions["ask_for_license_timeout"] + _, funcOut := readFromSTDOUT(detail.TimeoutSelect) + + if funcOut != "" { + t.Errorf("expected %q but got %q", "", funcOut) + } +} + +func TestWarnAction(t *testing.T) { + actions := loadInteractions() + detail := actions["warn"] + + stdOut, funcOut := readFromSTDOUT(detail.Warn) + if stdOut != "\nThis is a warn" { + t.Errorf("expected %q but got %q", "This is a warn", stdOut) + } + if funcOut != "path_after_warn" { + t.Errorf("expected %q but got %q", "path_after_warn", funcOut) + } +} + +func TestErrorAction(t *testing.T) { + actions := loadInteractions() + detail := actions["error"] + + stdOut, funcOut := readFromSTDOUT(detail.Error) + if stdOut != "\nThis is a error" { + t.Errorf("expected %q but got %q", "This is a error", stdOut) + } + if funcOut != "path_after_error" { + t.Errorf("expected %q but got %q", "path_after_error", funcOut) + } +} + +func TestOkAction(t *testing.T) { + actions := loadInteractions() + detail := actions["ok"] + + stdOut, funcOut := readFromSTDOUT(detail.Ok) + if stdOut != "\nThis is a ok" { + t.Errorf("expected %q but got %q", "This is a ok", stdOut) + } + if funcOut != "path_after_ok" { + t.Errorf("expected %q but got %q", "path_after_ok", funcOut) + } +} + +func TestDoesLicenseHaveValidPattern(t *testing.T) { + ad := keyfetcher.ActionDetail{ + Action: "DoesLicenseHaveValidPattern", + ResponsePathMap: map[string]string{ + "true": "valid_pattern", + "false": "invalid_pattern", + }, + } + // Set the license to an invalid pattern and test the scenario + keyfetcher.SetLastUserInput("test-1234") + out := ad.PerformInteraction() + if out != "invalid_pattern" { + t.Errorf("expected the pattern to be %v, got %v", "invalid_pattern", out) + } + + // Set the license to a valid pattern and test the success scenario + keyfetcher.SetLastUserInput("3ff52c37-e41f-4f6c-ad4d-365192205968") + out = ad.PerformInteraction() + if out != "valid_pattern" { + t.Errorf("expected the pattern to be %v, got %v", "valid_pattern", out) + } +} + +func TestIsLicenseAllowed(t *testing.T) { + keyfetcher.SetFileHandler(keyfetcher.MockFileHandler{ + Content: []byte(""), + Error: nil, + }) + + valid_response := fmt.Sprintf(VALID_CLIENT_RESPONSE, time.Now().Add(time.Hour*60).Format("2006-01-02T15:04:05-07:00")) + + mockServer := mockAPIResponse(valid_response, http.StatusOK) + defer mockServer.Close() + + ad := keyfetcher.ActionDetail{ + Action: "IsLicenseAllowed", + ResponsePathMap: map[string]string{ + "true": "license_allowed", + "false": "license_not_allowed", + }, + } + + // Set the license to an invalid pattern and test the scenario + keyfetcher.SetLastUserInput("3ff52c37-e41f-4f6c-ad4d-365192205968") + out := ad.PerformInteraction() + + if out != "license_allowed" { + t.Errorf("expected the function to return %v, got %v", "license_allowed", out) + } +} + +func TestDeterminteRestrictionType(t *testing.T) { + keyfetcher.UpdatePromptInputs(map[string]string{ + "LicenseType": "trial", + }) + + ad := keyfetcher.ActionDetail{ + Action: "DetermineRestrictionType", + ResponsePathMap: map[string]string{ + "trial_restriction": "trial_already_exist_message", + "free_restriction": "free_license_already_exist_message", + "active_trial_restriction": "active_trial_exist_message", + }, + } + out := ad.PerformInteraction() + if out != "trial_already_exist_message" { + t.Errorf("expected the restriction to be %s, got %s", "trial_already_exist_message", out) + } +} + +func TestFetchLicenseTypeRestricted(t *testing.T) { + keyfetcher.SetFileHandler(keyfetcher.MockFileHandler{ + Content: []byte(""), + Error: nil, + }) + + ad := keyfetcher.ActionDetail{ + Action: "FetchLicenseTypeRestricted", + ResponsePathMap: map[string]string{ + "trial": "trial_restriction_message", + "free": "free_restriction_message", + "trial_and_free": "only_commercial_allowed_message", + }, + } + out := ad.PerformInteraction() + if out != "free_restriction_message" { + t.Errorf("expected the license type restriction to be %s, got %s", "free_restriction_message", out) + } +} + +func loadInteractions() map[string]keyfetcher.ActionDetail { + var intr keyfetcher.Interaction + yaml.Unmarshal([]byte(YAML_DATA), &intr) + + return intr.Actions +} + +func readFromSTDOUT(function actionFunction) (string, string) { + originalStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + output := function() + + w.Close() + os.Stdout = originalStdout + + var buf bytes.Buffer + io.Copy(&buf, r) + + return buf.String(), output +} + +func mockAPIResponse(mockResponse string, status int) *httptest.Server { + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(status) + w.Write([]byte(mockResponse)) + })) + setConfig(mockServer.URL) + return mockServer +} + +func setConfig(url string) { + config.NewConfig(&config.LicenseConfig{ + ProductName: "Workstation", + EntitlementID: "workstation-1234", + LicenseServerURL: url, + ExecutableName: "test", + }) +} diff --git a/components/go/pkg/key_fetcher/conditions.go b/components/go/pkg/key_fetcher/conditions.go new file mode 100644 index 00000000..4a3ab86c --- /dev/null +++ b/components/go/pkg/key_fetcher/conditions.go @@ -0,0 +1,85 @@ +package keyfetcher + +import ( + "slices" + + "github.com/chef/chef-licensing/components/go/pkg/api" +) + +func IsLicenseRestricted(licenseType string) (out bool) { + allowed := allowedLicencesForAddition() + if !slices.Contains(allowed, licenseType) { + out = true + } + + return +} + +func DoesUserHasActiveTrialLicense() (out bool) { + content := *readLicenseKeyFile() + for _, license := range content.Licenses { + client, _ := api.GetClient().GetLicenseClient([]string{license.LicenseKey}) + if license.LicenseType == ":trial" && client.IsActive() { + out = true + } + } + + return +} + +func HasUnrestrictedLicenseAdded(newKeys []string, licenseType string) bool { + if IsLicenseRestricted(licenseType) { + // Existing license keys of same license type are fetched to compare if old license key or a new one is added. + // However, if user is trying to add Free Tier License, and user has active trial license, we fetch the trial license key + var existingLicenseKeysInFile []string + if licenseType == "free" && DoesUserHasActiveTrialLicense() { + existingLicenseKeysInFile = FetchLicenseKeysBasedOnType(":trial") + } else if userHasActiveTrialOrFreeLicense() { + // Handling license addition restriction scenarios only if the current license is an active license + existingLicenseKeysInFile = FetchLicenseKeysBasedOnType(":" + licenseType) + } + // Only prompt when a new trial license is added + if len(existingLicenseKeysInFile) > 0 { + if existingLicenseKeysInFile[len(existingLicenseKeysInFile)-1] != newKeys[0] { + promptLicenseAdditionRestricted(licenseType, existingLicenseKeysInFile) + return false + } + } + + return true + } else { + persistAndConcat(newKeys, licenseType) + return true + } +} + +func allowedLicencesForAddition() []string { + var license_types = []string{"free", "trial", "commercial"} + currentTypes := currentLicenseTypes() + + if slices.Contains(currentTypes, ":trial") { + removeItem(&license_types, "trial") + } + if slices.Contains(currentTypes, ":free") || DoesUserHasActiveTrialLicense() { + removeItem(&license_types, "free") + } + + return license_types +} +func currentLicenseTypes() (out []string) { + content := *readLicenseKeyFile() + for _, license := range content.Licenses { + out = append(out, license.LicenseType) + } + return +} + +func removeItem(target *[]string, item string) { + var out []string + for _, str := range *target { + if str != item { + out = append(out, str) + } + } + *target = out +} diff --git a/components/go/pkg/key_fetcher/conditions_test.go b/components/go/pkg/key_fetcher/conditions_test.go new file mode 100644 index 00000000..876d148f --- /dev/null +++ b/components/go/pkg/key_fetcher/conditions_test.go @@ -0,0 +1,83 @@ +package keyfetcher_test + +import ( + "fmt" + "net/http" + "testing" + "time" + + keyfetcher "github.com/chef/chef-licensing/components/go/pkg/key_fetcher" +) + +const TRIAL_ONLY_FILE = ` +:licenses: +- :license_key: tmns-123456 + :license_type: :trial + :update_time: "2024-07-10T00:29:50+05:30" +:file_format_version: 4.0.0 +:license_server_url: https://testing.license.chef.co/License +` + +func TestIsLienseRestricted(t *testing.T) { + output := keyfetcher.IsLicenseRestricted("free") + + if output { + t.Errorf("expected to have no restriction, got %v", output) + } +} + +// func TestDoesUserHasActiveTrialLicenseSuccess(t *testing.T) { +// valid_response := fmt.Sprintf(VALID_CLIENT_RESPONSE, time.Now().Add(time.Hour*60).Format("2006-01-02T15:04:05-07:00")) +// mockServer := mockAPIResponse(valid_response, http.StatusOK) +// defer mockServer.Close() +// // Give some time to the mock server to start +// time.Sleep(100 * time.Millisecond) + +// keyfetcher.SetFileHandler(keyfetcher.MockFileHandler{ +// Content: []byte(TRIAL_ONLY_FILE), +// Error: nil, +// }) + +// out := keyfetcher.DoesUserHasActiveTrialLicense() +// if !out { +// t.Errorf("expected to return true, got %v", out) +// } +// } + +func TestDoesUserHasActiveTrialFailure(t *testing.T) { + valid_response := fmt.Sprintf(VALID_CLIENT_RESPONSE, time.Now().Add(time.Hour*60).Format("2006-01-02T15:04:05-07:00")) + mockServer := mockAPIResponse(valid_response, http.StatusOK) + defer mockServer.Close() + keyfetcher.SetFileHandler(keyfetcher.MockFileHandler{ + Content: []byte(""), + }) + + out := keyfetcher.DoesUserHasActiveTrialLicense() + if out { + t.Errorf("expected to return false, got %v", out) + } +} + +func TestHasUnrestrictedLienseAdded(t *testing.T) { + setConfig("http://testing.chef.io") + keyfetcher.SetFileHandler(keyfetcher.MockFileHandler{ + Content: []byte(""), + }) + + out := keyfetcher.HasUnrestrictedLicenseAdded([]string{"key-123"}, "trial") + if !out { + t.Errorf("expected it to be %v, got %v", true, out) + } +} + +// func TestHasUnrestrictedLienseAddedFailure(t *testing.T) { +// setConfig("http://testing.chef.io") +// keyfetcher.SetFileHandler(keyfetcher.MockFileHandler{ +// Content: []byte(TRIAL_ONLY_FILE), +// }) + +// out := keyfetcher.HasUnrestrictedLicenseAdded([]string{"key-123"}, "free") +// if out { +// t.Errorf("expected it to be %v, got %v", false, out) +// } +// } diff --git a/components/go/pkg/key_fetcher/file_fetcher.go b/components/go/pkg/key_fetcher/file_fetcher.go new file mode 100644 index 00000000..754c32d5 --- /dev/null +++ b/components/go/pkg/key_fetcher/file_fetcher.go @@ -0,0 +1,162 @@ +package keyfetcher + +import ( + "log" + "os" + "path/filepath" + "slices" + "time" + + "github.com/chef/chef-licensing/components/go/pkg/api" + "github.com/chef/chef-licensing/components/go/pkg/config" + "gopkg.in/yaml.v2" +) + +const ( + FILE_VERSION = "4.0.0" +) + +var LICENSE_TYPES []string = []string{"free", "trial", "commercial"} + +type LicenseFileData struct { + Licenses []LicenseData `yaml:":licenses"` + FileFormatVersion string `yaml:":file_format_version"` + LicenseServerURL string `yaml:":license_server_url"` +} + +type LicenseData struct { + LicenseKey string `yaml:":license_key"` + LicenseType string `yaml:":license_type"` + UpdateTime string `yaml:":update_time"` +} + +func FetchLicenseKeys() (out []string) { + content := readLicenseKeyFile() + for _, key := range content.Licenses { + out = append(out, key.LicenseKey) + } + return +} + +func FetchLicenseKeysBasedOnType(licenseType string) (out []string) { + content := readLicenseKeyFile() + for _, key := range content.Licenses { + if key.LicenseType == licenseType { + out = append(out, key.LicenseKey) + } + } + return +} + +func readLicenseKeyFile() *LicenseFileData { + li := &LicenseFileData{} + filePath := licenseFilePath() + handler := *GetFileHandler() + exists := handler.CheckFilePresence(filePath) + if !exists { + return li + } + + data, err := handler.ReadFile(filePath) + if err != nil { + log.Fatal(err) + } + + err = yaml.Unmarshal(data, &li) + if err != nil { + log.Fatal(err) + } + return li +} + +func userHasActiveTrialOrFreeLicense() bool { + li := readLicenseKeyFile() + if len(li.Licenses) > 0 { + allLicenseKeys := licenseFileFetch() + licenseClient, _ := api.GetClient().GetLicenseClient(allLicenseKeys) + return (licenseClient.LicenseType == "trial" || licenseClient.LicenseType == "free") && licenseClient.IsActive() + } else { + return false + } +} + +func licenseFileFetch() []string { + licenseKey := []string{} + li := *readLicenseKeyFile() + + for i := 0; i < len(li.Licenses); i++ { + licenseKey = append(licenseKey, li.Licenses[i].LicenseKey) + } + + return licenseKey +} + +func licenseFilePath() string { + home, _ := os.UserHomeDir() + return filepath.Join(home, ".chef/licenses.yaml") +} + +func persistAndConcat(newKeys []string, licenseType string) { + if !slices.Contains(LICENSE_TYPES, licenseType) { + log.Fatal("License type " + licenseType + " is not a valid license type.") + } + + license := LicenseData{ + LicenseKey: newKeys[0], + LicenseType: ":" + licenseType, + UpdateTime: time.Now().Format("2006-01-02T15:04:05-07:00"), + } + + fileContent := readLicenseKeyFile() + + var found bool + for _, key := range fileContent.Licenses { + if key.LicenseKey == license.LicenseKey { + found = true + } + } + + if !found { + fileContent.Licenses = append(fileContent.Licenses, license) + } + updateDefaultsOnLicenseFile(fileContent) + saveLicenseFile(fileContent) + appendLicenseKey(newKeys[0]) +} + +func updateDefaultsOnLicenseFile(content *LicenseFileData) { + if content.FileFormatVersion == "" { + content.FileFormatVersion = FILE_VERSION + } + + if content.LicenseServerURL == "" { + config := config.GetConfig() + content.LicenseServerURL = config.LicenseServerURL + } +} + +func saveLicenseFile(content *LicenseFileData) { + filepath := licenseFilePath() + + data, err := yaml.Marshal(&content) + if err != nil { + log.Fatalf("error: %v", err) + } + + err = (*GetFileHandler()).WriteFile(filepath, data, 0644) + if err != nil { + log.Fatalf("error: %v", err) + } +} + +func FetchLicenseTypeBasedOnKey(license_keys []string) string { + content := readLicenseKeyFile() + var licenseType string + for _, key := range content.Licenses { + if key.LicenseKey == license_keys[0] { + licenseType = key.LicenseType + } + + } + return licenseType +} diff --git a/components/go/pkg/key_fetcher/file_fetcher_test.go b/components/go/pkg/key_fetcher/file_fetcher_test.go new file mode 100644 index 00000000..444fe543 --- /dev/null +++ b/components/go/pkg/key_fetcher/file_fetcher_test.go @@ -0,0 +1,74 @@ +package keyfetcher_test + +import ( + "testing" + + keyfetcher "github.com/chef/chef-licensing/components/go/pkg/key_fetcher" +) + +const trial_license = ` +--- +:licenses: + - :license_key: tmns-123456 + :license_type: :trial + :update_time: "2024-07-10T00:29:50+05:30" +:file_format_version: 4.0.0 +:license_server_url: https://testing.license.chef.co/License +` + +func TestFetchLicenseKeysBasedOnType(t *testing.T) { + keyfetcher.SetFileHandler(keyfetcher.MockFileHandler{ + Content: []byte(trial_license), + Error: nil, + Present: true, + }) + + out := keyfetcher.FetchLicenseKeysBasedOnType(":trial") + if len(out) == 0 { + t.Logf("the function output is: %s", out) + t.Errorf("expected to return licenses: %v, got: %v", "tmns-123456", out) + return + } + if out[0] != "tmns-123456" { + t.Errorf("expected to return the %v, got %v", "tmns-123456", out[0]) + } +} + +func TestFetchLicenseKeysBasedOnTypeInCaseOfNone(t *testing.T) { + keyfetcher.SetFileHandler(keyfetcher.MockFileHandler{ + Content: []byte(""), + Error: nil, + Present: false, + }) + out := keyfetcher.FetchLicenseKeysBasedOnType(":trial") + if len(out) != 0 { + t.Errorf("expected it to return empty list, got %v", out) + } +} + +func TestFetchLicenseTypeBasedOnKey(t *testing.T) { + keyfetcher.SetFileHandler(keyfetcher.MockFileHandler{ + Content: []byte(TRIAL_ONLY_FILE), + Error: nil, + Present: true, + }) + out := keyfetcher.FetchLicenseTypeBasedOnKey([]string{"tmns-123456"}) + if out != ":trial" { + t.Logf("out was: %s", out) + t.Logf("handler is : %v, type is %T", *keyfetcher.GetFileHandler(), *keyfetcher.GetFileHandler()) + t.Errorf("expected it to return %v, got %v", ":trial", out) + } +} + +func TestFetchLicenseKeys(t *testing.T) { + keyfetcher.SetFileHandler(keyfetcher.MockFileHandler{ + Content: []byte(TRIAL_ONLY_FILE), + Error: nil, + Present: true, + }) + + out := keyfetcher.FetchLicenseKeys() + if out[0] != "tmns-123456" { + t.Errorf("expected it to return %v, got %v", []string{"tmns-123456"}, out) + } +} diff --git a/components/go/pkg/key_fetcher/file_handler.go b/components/go/pkg/key_fetcher/file_handler.go new file mode 100644 index 00000000..9fbc08e2 --- /dev/null +++ b/components/go/pkg/key_fetcher/file_handler.go @@ -0,0 +1,61 @@ +package keyfetcher + +import "os" + +type FileHandler interface { + CheckFilePresence(filename string) bool + ReadFile(filename string) ([]byte, error) + WriteFile(filename string, data []byte, perm os.FileMode) error +} + +type LicenseFileHandler struct{} + +func (LicenseFileHandler) ReadFile(filename string) ([]byte, error) { + return os.ReadFile(filename) +} + +func (LicenseFileHandler) WriteFile(filename string, data []byte, perm os.FileMode) error { + return os.WriteFile(filename, data, perm) +} + +func (LicenseFileHandler) CheckFilePresence(filename string) bool { + _, err := os.Stat("/Users/asaidala/.chef/licenses.yaml") + if os.IsNotExist(err) { + return false + } else { + return true + } +} + +type MockFileHandler struct { + Content []byte + Error error + Present bool +} + +func (m MockFileHandler) ReadFile(filename string) ([]byte, error) { + return m.Content, m.Error +} + +func (m MockFileHandler) WriteFile(filename string, data []byte, perm os.FileMode) error { + return m.Error +} + +func (m MockFileHandler) CheckFilePresence(filename string) bool { + return m.Present +} + +var fileHandler *FileHandler + +func SetFileHandler(handler FileHandler) { + fileHandler = &handler +} + +func GetFileHandler() *FileHandler { + // Set the LicenseFileHandler as default + if fileHandler == nil { + SetFileHandler(LicenseFileHandler{}) + } + + return fileHandler +} diff --git a/components/go/pkg/key_fetcher/interactions.yml b/components/go/pkg/key_fetcher/interactions.yml new file mode 100644 index 00000000..90d46cdf --- /dev/null +++ b/components/go/pkg/key_fetcher/interactions.yml @@ -0,0 +1,442 @@ +--- +:file_format_version: "1.0.0" + +interactions: + start: + messages: + - | + ------------------------------------------------------------ + License ID Validation + + To continue using Chef {{.ProductName}}, a license ID is required. + (Free Tier, Trial, or Commercial) + + If you generated a license previously, you might + have received it in an email. + + If you are a commercial user, you can also find it in the + {{printHyperlink "https://community.progress.com/s/products/chef"}} portal. + ------------------------------------------------------------ + - | + prompt_type: "Say" + paths: [ask_if_user_has_license_id] + + ask_if_user_has_license_id: + messages: ["Please choose one of the options below"] + options: ["I already have a license ID", "I don't have a license ID and would like to generate a new license ID", "Skip"] + prompt_type: "TimeoutSelect" + prompt_attributes: + timeout_warning_color: red + timeout_duration: 60 + timeout_message: "Prompt timed out. Use non-interactive flags or enter an answer within 60 seconds.\n" + paths: [ask_for_license_id, info_of_license_types, skip_message] + response_path_map: + "I already have a license ID": ask_for_license_id + "I don't have a license ID and would like to generate a new license ID": info_of_license_types + "Skip": skip_message + + skip_message: + messages: ['', '! {{printInColor "yellow" "[WARNING]"}} A license is required to continue using this product.', ''] + prompt_type: Warn + paths: [skip_licensing] + + skip_licensing: + messages: ["Are you sure to skip this step?"] + prompt_type: "Select" + options: ["Skip", "Generate a new license ID", "I already have a license ID"] + paths: [ask_for_license_id, info_of_license_types, skipped] + response_path_map: + "I already have a license ID": ask_for_license_id + "Generate a new license ID": info_of_license_types + "Skip": skipped + + skipped: + messages: ["License ID validation skipped!"] + prompt_type: "Say" + paths: [exit_with_message] + + ask_for_license_id: + messages: ["Please enter your license ID: "] + prompt_type: "Ask" + paths: [validate_license_id_pattern] + + validate_license_id_pattern: + action: DoesLicenseHaveValidPattern + paths: [validate_license_id_with_api, ask_for_license_id] + response_path_map: + "true": validate_license_id_with_api + "false": ask_for_license_id + description: "DoesLicenseHaveValidPattern is a method defined in ActionDetails" + + validate_license_id_with_api: + action: IsLicenseValidOnServer + paths: [validate_license_restriction, fetch_invalid_license_msg] + response_path_map: + "true": validate_license_restriction + "false": fetch_invalid_license_msg + description: "IsLicenseValidOnServer is a method defined in ActionDetails" + + fetch_invalid_license_msg: + action: FetchInvalidLicenseMessage + paths: [validation_failure] + + validation_failure: + messages: ['{{printInColor "red" "✖"}} [Error] License validation failed: {{.FailureMessage}}.'] + prompt_type: "Error" + paths: [retry_message] + + retry_message: + messages: ["Please try again."] + prompt_type: "Say" + paths: [ask_for_license_id] + + validate_license_restriction: + action: IsLicenseAllowed + paths: [validate_license_expiration, prompt_error_license_addition_restricted] + response_path_map: + "true": validate_license_expiration + "false": prompt_error_license_addition_restricted + + validate_license_expiration: + action: CheckLicenseExpirationStatus + paths: [validation_success, prompt_license_about_to_expire, prompt_license_expired, prompt_license_exhausted] + response_path_map: + "active": validation_success + "about_to_expire": prompt_license_about_to_expire + "expired": prompt_license_expired + "exhausted_license": prompt_license_exhausted + + prompt_error_license_addition_restricted: + messages: ['{{printInColor "red" "✖"}} [Error] License validation failed.\n'] + prompt_type: "Error" + paths: [license_restriction_header_text] + + license_restriction_header_text: + action: DetermineRestrictionType + paths: [trial_already_exist_message, free_license_already_exist_message, active_trial_exist_message] + response_path_map: + "trial_restriction": trial_already_exist_message + "free_restriction": free_license_already_exist_message + "active_trial_restriction": active_trial_exist_message + + trial_already_exist_message: + messages: ["A Trial License already exists with following details: \n"] + prompt_type: "Say" + paths: [add_license_info_in_restriction_flow] + + free_license_already_exist_message: + messages: ["A Free Tier License already exists with following details: \n"] + prompt_type: "Say" + paths: [add_license_info_in_restriction_flow] + + active_trial_exist_message: + messages: ["An active Trial License already exists with following details \n"] + prompt_type: "Say" + paths: [add_license_info_in_restriction_flow] + + add_license_info_in_restriction_flow: + action: DisplayLicenseInfo + paths: [license_restriction_foot_text] + + license_restriction_foot_text: + action: FetchLicenseTypeRestricted + paths: [trial_restriction_message, free_restriction_message, only_commercial_allowed_message] + response_path_map: + "trial": trial_restriction_message + "free": free_restriction_message + "trial_and_free": only_commercial_allowed_message + + trial_restriction_message: + prompt_type: "Say" + messages: + - Please generate a Free Tier or Commercial License by running {{printLicenseAddCommand}}. + paths: [exit_with_message] + + free_restriction_message: + prompt_type: "Say" + messages: + - Please generate a Trial or Commercial License by running {{printLicenseAddCommand}}. + paths: [exit_with_message] + + only_commercial_allowed_message: + prompt_type: "Say" + messages: + - Please generate a Commercial License by running {{printLicenseAddCommand}}. + paths: [exit_with_message] + + validation_success: + messages: ['{{printInColor "green" "✔"}} {{printInColor "green" "[Success] License validated successfully."}}'] + prompt_type: "Ok" + paths: [display_license_info] + + display_license_info: + action: DisplayLicenseInfo + paths: [fetch_license_id] + + fetch_license_id: + action: FetchLicenseId + paths: [is_commercial_license] + + is_commercial_license: + action: IsCommercialLicense + response_path_map: + "true": exit + "false": warn_non_commercial_license + paths: [warn_non_commercial_license, exit] + + warn_non_commercial_license: + messages: + - | + ------------------------------------------------------------ + {{printInColor "yellow" "! [WARNING]"}} Non-Commercial License + + You are using a {{.LicenseType}} version - not meant for commercial usage. + + If you are using it for commercial purposes, please reach + out to our sales team at {{printHyperlink "chef-sales@progress.com"}} to get + commercial license to be compliant with Progress Chef MLSA. + ------------------------------------------------------------ + prompt_type: "Say" + paths: [exit] + + ask_for_license_id: + messages: ["Please enter your license ID: "] + prompt_type: "Ask" + paths: [validate_license_id_pattern] + + skipped: + messages: ["License ID validation skipped!"] + prompt_type: "Say" + paths: [exit_with_message] + + exit_with_message: + messages: ["Thank you.\n"] + prompt_type: "Say" + paths: [exit] + + prompt_license_expired: + messages: + - | + ------------------------------------------------------------ + {{printInColor "yellow" "! [WARNING]"}} {{.LicenseType}} License Expired + + We hope you've been enjoying Chef {{.ProductName}}! + However, it seems like your license has expired. + + Reach out to our sales team at {{printHyperlink "chef-sales@progress.com"}} + to move to commercial tier. + + To get a new license, run {{printLicenseAddCommand}} + and select a license type. + ------------------------------------------------------------ + prompt_type: "Say" + paths: [fetch_license_id] + + prompt_license_about_to_expire: + messages: + - | + ------------------------------------------------------------ + {{printInColor "yellow" "! [WARNING]"}} Your license is about to expire in {{printBoldText .ExpirationInDays "days."}} + + To avoid service disruptions, get a Commercial License + before {{printBoldText .LicenseExpirationDate ""}} + ------------------------------------------------------------ + prompt_type: "Say" + paths: [fetch_license_id] + + prompt_license_exhausted: + messages: + - | + ------------------------------------------------------------ + {{printInColor "yellow" "! [WARNING]"}} {{.LicenseType}} License Exhausted + + We hope you've been enjoying Chef {{.ProductName}}! + However, it seems like you have exceeded your entitled usage limit on {{.UnitMeasure}}. + + Reach out to our sales team at {{printHyperlink "chef-sales@progress.com"}} + to move to commercial-tier. + ------------------------------------------------------------ + prompt_type: "Say" + paths: [is_run_allowed_on_license_exhausted] + + is_run_allowed_on_license_exhausted: + action: IsRunAllowedOnLicenseExhausted + response_path_map: + "true": fetch_license_id + "false": exit + paths: [fetch_license_id, exit] + + info_of_license_types: + messages: + - | + Thank you for taking interest in Chef {{.ProductName}}. + + We have the following types of licenses. + prompt_type: "Say" + paths: [filter_license_type_options] + + filter_license_type_options: + action: FilterLicenseTypeOptions + paths: [ask_for_all_license_type, ask_for_license_except_trial, ask_for_commercial_only, ask_for_license_except_free] + response_path_map: + "ask_for_all_license_type": ask_for_all_license_type + "ask_for_license_except_trial": ask_for_license_except_trial + "ask_for_commercial_only": ask_for_commercial_only + "ask_for_license_except_free": ask_for_license_except_free + + ask_for_commercial_only: + prompt_type: "Select" + messages: ["Select the type of license below and then enter user details"] + options: ["1. Commercial License", "2. Quit license generation."] + paths: [commercial_license_selection, exit] + response_path_map: + "1. Commercial License": commercial_license_selection + "2. Quit license generation": exit + + ask_for_license_except_trial: + prompt_type: "Select" + messages: ["Select the type of license below and then enter user details"] + options: [ + "1. Free Tier License\n Validity: Unlimited\n No. of units: 10 nodes\n", + "2. Commercial License\n", + "3. Quit license generation" + ] + paths: [free_trial_license_selection, commercial_license_selection, exit] + response_path_map: + "1. Free Tier License\n Validity: Unlimited\n No. of units: 10 nodes\n": free_trial_license_selection + "1. Free Tier License\n Validity: Unlimited\n No. of units: 10 targets\n": free_trial_license_selection + "2. Commercial License\n": commercial_license_selection + "3. Quit license generation": exit + + ask_for_license_except_free: + prompt_type: "Select" + messages: ["Select the type of license below and then enter user details\n"] + options: [ + "1. Trial License\n Validity: 30 Days\n No. of units: Unlimited nodes\n", + "2. Commercial License\n", + "3. Quit license generation" + ] + paths: [free_trial_license_selection, commercial_license_selection, exit] + response_path_map: + "1. Trial License\n Validity: 30 Days\n No. of units: Unlimited nodes\n": free_trial_license_selection + "1. Trial License\n Validity: 30 Days\n No. of units: Unlimited targets\n": free_trial_license_selection + "2. Commercial License\n": commercial_license_selection + "3. Quit license generation": exit + + ask_for_all_license_type: + prompt_type: "Select" + messages: ["Select the type of license below and then enter user details"] + options: [ + "1. Free Tier License\n Validity: Unlimited\n No. of units: 10 nodes\n", + "2. Trial License\n Validity: 30 Days\n No. of units: Unlimited nodes\n", + "3. Commercial License\n", + "4. Quit license generation" + ] + paths: [free_trial_license_selection, commercial_license_selection, exit] + response_path_map: + "1. Free Tier License\n Validity: Unlimited\n No. of units: 10 nodes\n": free_trial_license_selection + "1. Free Tier License\n Validity: Unlimited\n No. of units: 10 targets\n": free_trial_license_selection + "2. Trial License\n Validity: 30 Days\n No. of units: Unlimited nodes\n": free_trial_license_selection + "2. Trial License\n Validity: 30 Days\n No. of units: Unlimited targets\n": free_trial_license_selection + "3. Commercial License\n": commercial_license_selection + "4. Quit license generation": exit + + free_trial_license_selection: + messages: + - | + {{printInColor "yellow" "!"}} Kindly complete the user registration at {{printInColor "blue" "https://www.chef.io/license-generation-free-trial" true}} + + Once you submit the details, you will receive the license ID on the email id you provided. + + choice: Select an option + options: [ + Validate license now, + Quit and validate license later + ] + prompt_type: "SayAndSelect" + paths: [validate_license_later_message, ask_for_license_id] + response_path_map: + "Validate license now": ask_for_license_id + "Quit and validate license later": validate_license_later_message + + # TODO: Update the link for other ways to validate the license document. + validate_license_later_message: + messages: + - | + You can enter the license later on by selecting {{printInColor "" "I already have a license ID" false true}} when prompted for license. + To learn about more ways to enter the license, kindly visit {{printInColor "blue" "www.docs.chef.io" true}}. + prompt_type: "Say" + paths: [exit] + + commercial_license_selection: + messages: ['Get in touch with the Sales Team by filling out the form available at {{printInColor "blue" "https://www.chef.io/contact-us" true}}', ''] + options: [Quit] + choice: Select an option + prompt_type: "SayAndSelect" + response_path_map: + Quit: exit_with_message + + prompt_license_addition_restriction: + action: SetLicenseInfo + paths: [prompt_error_license_addition_restricted] + + prompt_error_license_addition_restricted: + messages: ['{{printInColor "red" "✖"}} [Error] License validation failed.'] + prompt_type: "Error" + paths: [license_restriction_header_text] + + license_restriction_header_text: + action: DetermineRestrictionType + paths: [trial_already_exist_message, free_license_already_exist_message, active_trial_exist_message] + response_path_map: + "trial_restriction": trial_already_exist_message + "free_restriction": free_license_already_exist_message + "active_trial_restriction": active_trial_exist_message + + trial_already_exist_message: + messages: ["A Trial License already exists with following details:"] + prompt_type: "Say" + paths: [add_license_info_in_restriction_flow] + + free_license_already_exist_message: + messages: ["A Free Tier License already exists with following details:"] + prompt_type: "Say" + paths: [add_license_info_in_restriction_flow] + + active_trial_exist_message: + messages: ["An active Trial License already exists with following details."] + prompt_type: "Say" + paths: [add_license_info_in_restriction_flow] + + add_license_info_in_restriction_flow: + action: DisplayLicenseInfo + paths: [license_restriction_foot_text] + + license_restriction_foot_text: + action: FetchLicenseTypeRestricted + paths: [trial_restriction_message, free_restriction_message, only_commercial_allowed_message] + response_path_map: + "trial": trial_restriction_message + "free": free_restriction_message + "trial_and_free": only_commercial_allowed_message + + trial_restriction_message: + prompt_type: "Say" + messages: + - | + Please generate a Free Tier or Commercial License by running {{printLicenseAddCommand}}. + paths: [exit_with_message] + + free_restriction_message: + prompt_type: "Say" + messages: + - | + Please generate a Trial or Commercial License by running {{printLicenseAddCommand}}. + paths: [exit_with_message] + + only_commercial_allowed_message: + prompt_type: "Say" + messages: + - | + Please generate a Commercial License by running {{printLicenseAddCommand}}. + paths: [exit_with_message] \ No newline at end of file diff --git a/components/go/pkg/key_fetcher/key_fetcher.go b/components/go/pkg/key_fetcher/key_fetcher.go new file mode 100644 index 00000000..b1bb12cc --- /dev/null +++ b/components/go/pkg/key_fetcher/key_fetcher.go @@ -0,0 +1,101 @@ +package keyfetcher + +import ( + "fmt" + "regexp" + "strconv" + "time" + + "github.com/chef/chef-licensing/components/go/pkg/api" + "github.com/chef/chef-licensing/components/go/pkg/spinner" +) + +const ( + LICENSE_KEY_REGEX = `^([a-z]{4}-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}-[0-9]{1,4})$` + LICENSE_KEY_PATTERN_DESC = "Hexadecimal" + SERIAL_KEY_REGEX = `^([A-Z0-9]{26})$` + SERIAL_KEY_PATTERN_DESC = "26 character alphanumeric string" + COMMERCIAL_KEY_REGEX = `^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$` + QUIT_KEY_REGEX = "(q|Q)" +) + +var ErrInvalidKeyFormat = fmt.Errorf(fmt.Sprintf("Malformed License Key passed on command line - should be %s or %s", LICENSE_KEY_PATTERN_DESC, SERIAL_KEY_PATTERN_DESC)) + +func ValidateKeyFormat(key string) (matches bool) { + var regexes []*regexp.Regexp + patterns := []string{LICENSE_KEY_REGEX, SERIAL_KEY_REGEX, COMMERCIAL_KEY_REGEX} + + for _, pattern := range patterns { + regex := regexp.MustCompile(pattern) + regexes = append(regexes, regex) + } + + for _, regex := range regexes { + if regex.MatchString(key) { + matches = true + break + } + } + + return +} + +func promptLicenseAdditionRestricted(licenseType string, existingLicenseKeysInFile []string) { + // fmt.Printf("License Key fetcher - prompting license addition restriction\n") + UpdatePromptInputs(map[string]string{ + "LicenseID": existingLicenseKeysInFile[len(existingLicenseKeysInFile)-1], + "LicenseType": licenseType, + }) + StartInteractions("prompt_license_addition_restriction") +} + +func isLicenseActive(keys []string) (out bool, promptStartID string) { + conf := make(map[string]string) + if len(keys) == 0 { + return + } + + spn, err := spinner.GetSpinner("License Validation") + if err != nil { + fmt.Printf("Unable to start the spinner\n") + } + spinner.StartSpinner(spn, "In Progress") + + licenseClient, _ := api.GetClient().GetLicenseClient(keys) + if licenseClient == nil { + return false, "" + } + + // Intentional lag of 2 seconds when license is expiring or expired + if licenseClient.IsExpiringOrExpired() { + time.Sleep(2 * time.Second) + } + + if licenseClient.IsExpired() || licenseClient.HaveGrace() { + promptStartID = "prompt_license_expired" + out = false + } else if licenseClient.IsAboutToExpire() { + promptStartID = "prompt_license_about_to_expire" + out = false + conf["ExpirationInDays"] = strconv.Itoa(licenseClient.ExpirationInDays()) + conf["LicenseExpirationDate"] = licenseClient.LicenseExpirationDate().Format(time.UnixDate) + } else if licenseClient.IsExhausted() && (licenseClient.IsCommercial() || licenseClient.IsFree()) { + promptStartID = "prompt_license_exhausted" + out = false + } else { + // If license is not expired or expiring, return true. But if the license is not commercial, warn the user. + if !licenseClient.IsCommercial() { + promptStartID = "warn_non_commercial_license" + } + out = true + } + spinner.StopSpinner(spn, "", "", "") + cacheClientToPromptInput(licenseClient, conf) + + return out, promptStartID +} + +func cacheClientToPromptInput(client *api.LicenseClient, conf map[string]string) { + conf["LicenseType"] = client.LicenseType + UpdatePromptInputs(conf) +} diff --git a/components/go/pkg/key_fetcher/key_fetcher_test.go b/components/go/pkg/key_fetcher/key_fetcher_test.go new file mode 100644 index 00000000..8ea9c982 --- /dev/null +++ b/components/go/pkg/key_fetcher/key_fetcher_test.go @@ -0,0 +1,21 @@ +package keyfetcher_test + +import ( + "testing" + + keyfetcher "github.com/chef/chef-licensing/components/go/pkg/key_fetcher" +) + +func TestValidateKeyFormat(t *testing.T) { + if keyfetcher.ValidateKeyFormat("invalid-key") { + t.Errorf("expected %v to be invalid key", "invalid-key") + } + + if !keyfetcher.ValidateKeyFormat("tmns-b887451a-625d-4033-8259-108d2364c401-4278") { + t.Errorf("expected %v to be a valid license-key", "tmns-b887451a-625d-4033-8259-108d2364c401-4278") + } + + if !keyfetcher.ValidateKeyFormat("5fd13cca-14b7-42b9-ad47-d52a0630c1ae") { + t.Errorf("expected %v to be a valid license-key", "5fd13cca-14b7-42b9-ad47-d52a0630c1ae") + } +} diff --git a/components/go/pkg/key_fetcher/license_key_fetcher.go b/components/go/pkg/key_fetcher/license_key_fetcher.go new file mode 100644 index 00000000..70ff002a --- /dev/null +++ b/components/go/pkg/key_fetcher/license_key_fetcher.go @@ -0,0 +1,191 @@ +package keyfetcher + +import ( + "flag" + "fmt" + "log" + "os" + "strings" + + "github.com/chef/chef-licensing/components/go/pkg/api" + "github.com/chef/chef-licensing/components/go/pkg/spinner" +) + +var licenseKeys []string + +func FetchAndPersist() []string { + if isLocalServer() { + return OnPremFetchAndPersist() + } else { + return GlobalFetchAndPersist() + } +} + +func OnPremFetchAndPersist() []string { + newKeys := fetchFromArg() + if len(newKeys) > 0 { + log.Fatal("'--chef-license-key ' option is not supported with airgapped environment. You cannot add license from airgapped environment.") + } + + if len(licenseKeys) != 0 { + isActive, startID := isLicenseActive(getLicenseKeys()) + if isActive { + return licenseKeys + } else if spinner.IsTTY() { + fetchInteractively(startID) + } + } + + licenseClient, _ := api.GetClient().GetLicenseClient(licenseKeys) + if licenseClient != nil && (!licenseClient.IsExpired() && !licenseClient.IsExhausted()) || licenseClient.IsCommercial() { + return licenseKeys + } + + log.Fatal("Unable to obtain a License Key.") + return licenseKeys +} + +func GlobalFetchAndPersist() []string { + // Load the existing licenseKeys from the license file + for _, key := range licenseFileFetch() { + appendLicenseKey(key) + } + + newKeys := fetchFromArg() + licenseType := validateAndFetchLicenseType(newKeys) + if licenseType != "" && !HasUnrestrictedLicenseAdded(newKeys, licenseType) { + appendLicenseKey(newKeys[0]) + return licenseKeys + } + + newKeys = fetchFromEnv() + licenseType = validateAndFetchLicenseType(newKeys) + if licenseType != "" && !HasUnrestrictedLicenseAdded(newKeys, licenseType) { + appendLicenseKey(newKeys[0]) + return licenseKeys + } + + // Return keys if license keys are active and not expired or expiring + // Return keys if there is any error in /client API call, and do not block the flow. + isActive, startID := isLicenseActive(getLicenseKeys()) + fileClient, _ := api.GetClient().GetLicenseClient(getLicenseKeys(), true) + if len(getLicenseKeys()) > 0 && isActive && fileClient.IsCommercial() { + return getLicenseKeys() + } + + if spinner.IsTTY() { + newKeys = fetchInteractively(startID) + if len(newKeys) > 0 { + licenseClient, _ := api.GetClient().GetLicenseClient(newKeys) + persistAndConcat(newKeys, licenseClient.LicenseType) + if (!licenseClient.IsExpired() && !licenseClient.IsExhausted()) || licenseClient.IsCommercial() { + fmt.Printf("License Key: %s\n", licenseKeys[0]) + return licenseKeys + } + } + } else { + newKeys = []string{} + } + + if len(newKeys) == 0 && fileClient != nil && ((!fileClient.IsExpired() && !fileClient.IsExhausted()) || fileClient.IsCommercial()) { + return licenseKeys + } + + log.Fatal("Unable to obtain a License Key.") + return licenseKeys +} + +func FetchLicenseType(licenseKeys []string) string { + client, _ := api.GetClient().GetLicenseClient(licenseKeys) + return client.LicenseType +} + +func getLicenseKeys() []string { + return licenseKeys +} + +func appendLicenseKey(key string) { + licenseKeys = append(licenseKeys, key) +} + +func fetchFromArg() (out []string) { + var licenseKey string + flag.StringVar(&licenseKey, "chef-license-key", "", "Chef license key") + + flag.Parse() + args := flag.Args() + if len(args) != 0 { + licenseKey = getFlagArgs(args) + } + if licenseKey != "" { + out = append(out, licenseKey) + } + + return +} + +func getFlagArgs(args []string) string { + var licensekey string + for i := 0; i < len(args); i++ { + if args[i] == "--chef-license-key" { + if len(args) > i+1 { + licensekey = args[i+1] + os.Args = append(os.Args[:i+1], os.Args[i+3:]...) + } else { + licensekey = "" + os.Args = append(os.Args[:i+1], os.Args[i+2:]...) + } + } else if strings.Contains(args[i], "--chef-license-key=") { + checkFlag := strings.Split(args[i], "=") + if checkFlag[0] == "--chef-license-key" { + if len(checkFlag[1]) > 0 { + licensekey = checkFlag[1] + } else { + licensekey = "" + } + os.Args = append(os.Args[:i+1], os.Args[i+2:]...) + } + } + } + return licensekey +} + +func fetchFromEnv() (out []string) { + key, ok := os.LookupEnv("CHEF_LICENSE_KEY") + if ok && key != "" { + out = append(out, key) + } + + return +} + +func fetchInteractively(startID string) []string { + return StartInteractions(startID) +} + +func validateAndFetchLicenseType(keys []string) (licenseType string) { + if len(keys) == 0 { + return + } + isValid, _ := api.GetClient().ValidateLicenseAPI(keys[0]) + if isValid { + licenseType = FetchLicenseType(keys) + } + + return licenseType +} + +func isLocalServer() bool { + keys, err := api.GetClient().ListLicensesAPI() + if err != nil && err.Error() == "not found" { + return false + } else if err != nil { + log.Fatal("Something went wrong with the licensing server: ", err) + } + + for _, key := range keys { + appendLicenseKey(key) + } + + return true +} diff --git a/components/go/pkg/key_fetcher/list_keys.go b/components/go/pkg/key_fetcher/list_keys.go new file mode 100644 index 00000000..10b66221 --- /dev/null +++ b/components/go/pkg/key_fetcher/list_keys.go @@ -0,0 +1,66 @@ +package keyfetcher + +import ( + "fmt" + "log" + "time" + + "github.com/chef/chef-licensing/components/go/pkg/api" + "github.com/gookit/color" +) + +func PrintLicenseKeyOverview(keys []string) { + describe, _ := api.GetClient().GetLicenseDescribe(keys) + var validity string + + for _, license := range describe.Licenses { + validity = calculateValidity(license) + color.Printf("\n------------------------------------------------------------\n") + color.Bold.Println("License Details") + format := "%-15s : %-20s\n" + if len(license.Limits) > 0 { + color.Printf(format, "Asset Name", license.Limits[0].Software) + } + color.Printf(format, "License ID", license.LicenseKey) + color.Printf(format, "Type", license.LicenseType) + color.Printf(format, "Status", license.Status) + color.Printf(format, "Validity", validity) + + color.Printf(format, "No. Of Units", calculateUnits(license)) + color.Printf("------------------------------------------------------------") + } + +} + +func calculateValidity(license api.LicenseDetail) (validity string) { + if license.LicenseType == "free" { + validity = "Unlimited" + } else { + expiresOn, err := time.Parse(time.RFC3339, license.End) + if err != nil { + log.Fatal("Unknown expiration time received from the server: ", err) + } + + expirationIn := int(time.Until(expiresOn).Hours() / 24) + validity = fmt.Sprintf("%d Day", expirationIn) + if expirationIn > 1 { + validity += "s" + } + } + + return +} + +func calculateUnits(license api.LicenseDetail) (units string) { + if len(license.Limits) == 0 { + units = "" + } else { + limit := license.Limits[0].Amount + if limit == -1 { + units = "Unlimited Units" + } else { + units = fmt.Sprintf("%d Units", limit) + } + } + return +} diff --git a/components/go/pkg/key_fetcher/list_keys_test.go b/components/go/pkg/key_fetcher/list_keys_test.go new file mode 100644 index 00000000..e9573fc8 --- /dev/null +++ b/components/go/pkg/key_fetcher/list_keys_test.go @@ -0,0 +1,66 @@ +package keyfetcher_test + +const DESCRIBE_API_RESPONSE = ` +{ + "data": { + "license": [ + { + "licenseKey": "key-123456", + "serialNumber": "NA", + "licenseType": "trial", + "name": "Lorem Ipsum", + "start": "2024-05-30T00:00:00Z", + "end": "2024-06-29T00:00:00Z", + "status": "Active", + "limits": [ + { + "software": "Workstation", + "id": "workstation-1234", + "amount": 10, + "measure": "node", + "used": 0, + "status": "Active" + } + ] + } + ], + "Assets": null, + "Software": [ + { + "id": "workstation-1234", + "name": "Workstation", + "entitled": true, + "from": [ + { + "license": "key-123456", + "status": "Active" + } + ] + } + ], + "Features": [ + { + "id": "feature-123", + "name": "Inspec-Parallel", + "entitled": true, + "from": [ + { + "license": "key-123456", + "status": "Active" + } + ] + } + ], + "Services": null + }, + "message": "", + "status": 200 +} +` + +// func TestPrintLicenseKeyOverview(t *testing.T) { +// mockServer := mockAPIResponse(DESCRIBE_API_RESPONSE, http.StatusOK) +// defer mockServer.Close() + +// keyfetcher.PrintLicenseKeyOverview([]string{"key-123456"}) +// } diff --git a/components/go/pkg/key_fetcher/prompt.go b/components/go/pkg/key_fetcher/prompt.go new file mode 100644 index 00000000..45bfc608 --- /dev/null +++ b/components/go/pkg/key_fetcher/prompt.go @@ -0,0 +1,185 @@ +package keyfetcher + +import ( + "errors" + "fmt" + "log" + "os" + "reflect" + "text/template" + + "github.com/chef/chef-licensing/components/go/pkg/api" + "github.com/chef/chef-licensing/components/go/pkg/config" + "github.com/chef/chef-licensing/components/go/pkg/spinner" + "github.com/cqroot/prompt" + "github.com/gookit/color" + "gopkg.in/yaml.v2" +) + +func StartInteractions(startID string) (keys []string) { + if startID == "" { + startID = "start" + } + initializePromptInputs() + // var performedInteractions []string + currentID := startID + previousID := "" + interactions := getIntractions() + + for { + action := interactions[currentID] + if currentID == "" || currentID == "exit" { + break + } + // performedInteractions = append(performedInteractions, currentID) + previousID = currentID + currentID = action.PerformInteraction() + } + if currentID != "exit" { + log.Fatal("Something went wrong in the flow. The last interaction was " + previousID) + } + if GetLastUserInput() != "" { + keys = append(keys, GetLastUserInput()) + } + + // fmt.Println("Completed", performedInteractions) + return +} + +func UpdatePromptInputs(conf map[string]string) { + v := reflect.ValueOf(&PromptInput).Elem() + for key, value := range conf { + field := v.FieldByName(key) + if !field.IsValid() { + continue + } + if !field.CanSet() { + continue + } + + field.SetString(value) + } +} + +func checkPromptErr(err error) { + if err != nil { + if errors.Is(err, prompt.ErrUserQuit) { + fmt.Fprintln(os.Stderr, "Error:", err) + os.Exit(1) + } else { + panic(err) + } + } +} + +func initializePromptInputs() { + m := make(map[string]string) + conf := config.GetConfig() + m["ProductName"] = conf.ProductName + m["ChefExecutableName"] = conf.ExecutableName + if conf.ExecutableName == "chef" { + m["UnitMeasure"] = "nodes" + } else { + m["UnitMeasure"] = "targets" + } + UpdatePromptInputs(m) +} + +func getIntractions() map[string]ActionDetail { + var intr Interaction + err := yaml.Unmarshal(interactionsYAML, &intr) + if err != nil { + log.Fatal(err) + } + return intr.Actions +} + +func renderMessages(messages []string) { + if len(messages) == 0 { + return + } + + for _, message := range messages { + tmpl, err := template.New("actionMessage").Funcs(template.FuncMap{ + "printHyperlink": printHyperlink, + "printInColor": printInColor, + "printBoldText": printBoldText, + "printLicenseAddCommand": printLicenseAddCommand, + }).Parse(message) + if err != nil { + log.Fatalf("error parsing template: %v", err) + } + fmt.Printf("\n") + + err = tmpl.Execute(os.Stdout, PromptInput) + if err != nil { + log.Fatalf("error executing template: %v", err) + } + } +} + +func printHyperlink(url string) string { + return color.Style{color.FgGreen, color.OpUnderscore}.Sprintf(url) +} + +func printInColor(selColor, text string, options ...bool) string { + output := color.Style{} + var underline bool + var bold bool + + if len(options) == 1 { + underline = options[0] + } + if len(options) > 1 { + bold = options[1] + } + + switch selColor { + case "red": + output = append(output, color.FgRed) + case "green": + output = append(output, color.FgGreen) + case "blue": + output = append(output, color.FgBlue) + case "yellow": + output = append(output, color.FgYellow) + } + + if underline { + output = append(output, color.OpUnderscore) + } + if bold { + output = append(output, color.OpBold) + } + + return output.Sprintf(text) +} + +func printBoldText(text1, text2 string) string { + return color.Bold.Sprintf(text1 + " " + text2) +} + +func printLicenseAddCommand() string { + return printInColor("", PromptInput.ChefExecutableName+" license add", false, true) +} + +func validateLicenseFormat(key string) error { + isValid := ValidateKeyFormat(key) + if isValid { + return nil + } else { + return fmt.Errorf("%s: %w", key, ErrInvalidKeyFormat) + } +} + +func getLicense() api.LicenseClient { + spn, err := spinner.GetSpinner("License Validation") + if err != nil { + fmt.Printf("Unable to start the spinner\n") + } + spinner.StartSpinner(spn, "In Progress") + client, _ := api.GetClient().GetLicenseClient([]string{PromptInput.LicenseID}) + spinner.StopSpinner(spn, "", "", "") + + return *client +} diff --git a/components/go/pkg/key_fetcher/prompt_config.go b/components/go/pkg/key_fetcher/prompt_config.go new file mode 100644 index 00000000..6c605219 --- /dev/null +++ b/components/go/pkg/key_fetcher/prompt_config.go @@ -0,0 +1,26 @@ +package keyfetcher + +import _ "embed" + +//go:embed interactions.yml +var interactionsYAML []byte + +type Interaction struct { + FileFormatVersion string `yaml:":file_format_version"` + Actions map[string]ActionDetail `yaml:"interactions"` +} + +type TemplateConfig struct { + ProductName string + UnitMeasure string + ChefExecutableName string + FailureMessage string + IsCommercial bool + LicenseType string + LicenseID string + LicenseExpirationDate string + ExpirationInDays string + StartID string +} + +var PromptInput TemplateConfig diff --git a/components/go/pkg/spinner/spinner.go b/components/go/pkg/spinner/spinner.go new file mode 100644 index 00000000..a1f9fad2 --- /dev/null +++ b/components/go/pkg/spinner/spinner.go @@ -0,0 +1,46 @@ +package spinner + +import ( + "os" + "time" + + "github.com/theckman/yacspin" + "golang.org/x/term" +) + +func GetSpinner(suffix string) (*yacspin.Spinner, error) { + if !IsTTY() { + // In case the TTY is not available, suppress the spinner + suffix = "" + } + + SpinnerConfig := yacspin.Config{ + Frequency: 100 * time.Millisecond, + CharSet: yacspin.CharSets[59], + Suffix: suffix, + SuffixAutoColon: true, + } + + return yacspin.New(SpinnerConfig) +} + +func StartSpinner(spinner *yacspin.Spinner, message string) { + if IsTTY() { + spinner.Message(message) + _ = spinner.Start() + } + +} + +func StopSpinner(spinner *yacspin.Spinner, stopMessage, stopChar, stopColor string) { + if IsTTY() { + spinner.StopMessage(stopMessage) + spinner.StopCharacter(stopChar) + spinner.StopColors(stopColor) + _ = spinner.Stop() + } +} + +func IsTTY() bool { + return term.IsTerminal(int(os.Stdout.Fd())) +}