From f52aef93b6f9453fa62ff23071a72e031313aacb Mon Sep 17 00:00:00 2001 From: Laurentiu Niculae Date: Thu, 17 Aug 2023 19:09:03 +0300 Subject: [PATCH] feat(cli): add command to interogate the server version and other details Signed-off-by: Laurentiu Niculae --- errors/errors.go | 5 +- pkg/cli/client/cli.go | 1 + pkg/cli/client/client.go | 5 +- pkg/cli/client/server_info_cmd.go | 182 +++++++++++++++++++++++++ pkg/cli/client/server_info_cmd_test.go | 124 +++++++++++++++++ pkg/cli/server/extensions_test.go | 5 +- pkg/extensions/extension_mgmt.go | 5 +- swagger/docs.go | 6 + swagger/swagger.json | 6 + swagger/swagger.yaml | 4 + 10 files changed, 336 insertions(+), 7 deletions(-) create mode 100644 pkg/cli/client/server_info_cmd.go create mode 100644 pkg/cli/client/server_info_cmd_test.go diff --git a/errors/errors.go b/errors/errors.go index a975781342..13a40cd53d 100644 --- a/errors/errors.go +++ b/errors/errors.go @@ -78,9 +78,10 @@ var ( ErrInvalidURL = errors.New("cli: invalid URL format") ErrExtensionNotEnabled = errors.New("cli: functionality is not built/configured in the current server") ErrUnauthorizedAccess = errors.New("auth: unauthorized access. check credentials") + ErrPageNotFound = errors.New("page not found") ErrCannotResetConfigKey = errors.New("cli: cannot reset given config key") ErrConfigNotFound = errors.New("cli: config with the given name does not exist") - ErrNoURLProvided = errors.New("cli: no URL provided in argument or via config") + ErrNoURLProvided = errors.New("cli: no URL provided by flag or via config") ErrIllegalConfigKey = errors.New("cli: given config key is not allowed") ErrScanNotSupported = errors.New("search: scanning of image media type not supported") ErrCLITimeout = errors.New("cli: Query timed out while waiting for results") @@ -157,6 +158,8 @@ var ( ErrGQLEndpointNotFound = errors.New("cli: the server doesn't have a gql endpoint") ErrGQLQueryNotSupported = errors.New("cli: query is not supported or has different arguments") ErrBadHTTPStatusCode = errors.New("cli: the response doesn't contain the expected status code") + ErrFormatNotSupported = errors.New("cli: the given output format is not supported") + ErrAPINotSupported = errors.New("registry at the given address doesn't implement the correct API") ErrFileAlreadyCancelled = errors.New("storageDriver: file already cancelled") ErrFileAlreadyClosed = errors.New("storageDriver: file already closed") ErrFileAlreadyCommitted = errors.New("storageDriver: file already committed") diff --git a/pkg/cli/client/cli.go b/pkg/cli/client/cli.go index 1c0171a7aa..fb57412bba 100644 --- a/pkg/cli/client/cli.go +++ b/pkg/cli/client/cli.go @@ -11,4 +11,5 @@ func enableCli(rootCmd *cobra.Command) { rootCmd.AddCommand(NewCVECommand(NewSearchService())) rootCmd.AddCommand(NewRepoCommand(NewSearchService())) rootCmd.AddCommand(NewSearchCommand(NewSearchService())) + rootCmd.AddCommand(NewServerInfoCommand()) } diff --git a/pkg/cli/client/client.go b/pkg/cli/client/client.go index 9388ea42ef..7398b204a9 100644 --- a/pkg/cli/client/client.go +++ b/pkg/cli/client/client.go @@ -119,7 +119,10 @@ func doHTTPRequest(req *http.Request, verifyTLS bool, debug bool, defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - if resp.StatusCode == http.StatusUnauthorized { + switch resp.StatusCode { + case http.StatusNotFound: + return nil, zerr.ErrPageNotFound + case http.StatusUnauthorized: return nil, zerr.ErrUnauthorizedAccess } diff --git a/pkg/cli/client/server_info_cmd.go b/pkg/cli/client/server_info_cmd.go new file mode 100644 index 0000000000..8dac03b22e --- /dev/null +++ b/pkg/cli/client/server_info_cmd.go @@ -0,0 +1,182 @@ +//go:build search +// +build search + +package client + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/spf13/cobra" + "gopkg.in/yaml.v2" + + zerr "zotregistry.io/zot/errors" + "zotregistry.io/zot/pkg/api/constants" +) + +func NewServerInfoCommand() *cobra.Command { + serverInfoCmd := &cobra.Command{ + Use: "status", + Short: "Information about the server configuration and build information", + Long: `Information about the server configuration and build information`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + searchConfig, err := GetSearchConfigFromFlags(cmd, NewSearchService()) + if err != nil { + return err + } + + return GetServerInfo(searchConfig) + }, + } + + serverInfoCmd.PersistentFlags().String(URLFlag, "", + "Specify zot server URL if config-name is not mentioned") + serverInfoCmd.PersistentFlags().StringP(ConfigFlag, "c", "", + "Specify the registry configuration to use for connection") + serverInfoCmd.PersistentFlags().StringP(UserFlag, "u", "", + `User Credentials of zot server in "username:password" format`) + serverInfoCmd.Flags().StringP(OutputFormatFlag, "f", "text", "Specify the output format [text|json|yaml]") + + return serverInfoCmd +} + +const ( + StatusOnline = "online" + StatusOffline = "offline" + StatusUnknown = "unknown" +) + +func GetServerInfo(config SearchConfig) error { + ctx := context.Background() + username, password := getUsernameAndPassword(config.User) + + checkAPISupportEndpoint, err := combineServerAndEndpointURL(config.ServURL, constants.RoutePrefix) + if err != nil { + return err + } + + _, err = makeGETRequest(ctx, checkAPISupportEndpoint, username, password, config.VerifyTLS, config.Debug, + nil, config.ResultWriter) + if err != nil { + serverInfo := ServerInfo{} + + switch { + case errors.Is(err, zerr.ErrUnauthorizedAccess): + serverInfo.Status = StatusUnknown + serverInfo.ErrorMsg = "unauthorised access. given credentials are incorrect" + case errors.Is(err, zerr.ErrBadHTTPStatusCode), errors.Is(err, zerr.ErrPageNotFound): + serverInfo.Status = StatusOffline + serverInfo.ErrorMsg = fmt.Sprintf("%s: request at %s failed", zerr.ErrAPINotSupported.Error(), + checkAPISupportEndpoint) + default: + serverInfo.Status = StatusOffline + serverInfo.ErrorMsg = err.Error() + } + + return PrintServerInfo(serverInfo, config) + } + + mgmtEndpoint, err := combineServerAndEndpointURL(config.ServURL, fmt.Sprintf("%s%s", + constants.RoutePrefix, constants.ExtMgmt)) + if err != nil { + return err + } + + serverInfo := ServerInfo{} + + _, err = makeGETRequest(ctx, mgmtEndpoint, username, password, config.VerifyTLS, config.Debug, + &serverInfo, config.ResultWriter) + + switch { + case err == nil: + serverInfo.Status = StatusOnline + case errors.Is(err, zerr.ErrPageNotFound): + serverInfo.Status = StatusOnline + serverInfo.ErrorMsg = "MGMT endpoint is not enabled" + case errors.Is(err, zerr.ErrUnauthorizedAccess): + serverInfo.Status = StatusOnline + serverInfo.ErrorMsg = "credentials provided have been rejected" + case errors.Is(err, zerr.ErrBadHTTPStatusCode): + serverInfo.Status = StatusOnline + serverInfo.ErrorMsg = err.Error() + default: + serverInfo.Status = StatusOffline + serverInfo.ErrorMsg = err.Error() + } + + return PrintServerInfo(serverInfo, config) +} + +func PrintServerInfo(serverInfo ServerInfo, config SearchConfig) error { + outputResult, err := serverInfo.ToStringFormat(config.OutputFormat) + if err != nil { + return err + } + + fmt.Fprintln(config.ResultWriter, outputResult) + + return nil +} + +type ServerInfo struct { + Status string `json:"status,omitempty" mapstructure:"status"` + ErrorMsg string `json:"error,omitempty" mapstructure:"error"` + DistSpecVersion string `json:"distSpecVersion,omitempty" mapstructure:"distSpecVersion"` + Commit string `json:"commit,omitempty" mapstructure:"commit"` + BinaryType string `json:"binaryType,omitempty" mapstructure:"binaryType"` + ReleaseTag string `json:"releaseTag,omitempty" mapstructure:"releaseTag"` +} + +func (si *ServerInfo) ToStringFormat(format string) (string, error) { + switch format { + case "text", "": + return si.ToText() + case "json": + return si.ToJSON() + case "yaml", "yml": + return si.ToYAML() + default: + return "", zerr.ErrFormatNotSupported + } +} + +func (si *ServerInfo) ToText() (string, error) { + flagsList := strings.Split(strings.Trim(si.BinaryType, "-"), "-") + flags := strings.Join(flagsList, ", ") + + var output string + + if si.ErrorMsg != "" { + serverStatus := fmt.Sprintf("Server Status: %s\n"+ + "Error: %s", si.Status, si.ErrorMsg) + + output = serverStatus + } else { + serverStatus := fmt.Sprintf("Server Status: %s", si.Status) + serverInfo := fmt.Sprintf("Server Version: %s\n"+ + "Dist Spec Version: %s\n"+ + "Built with: %s", + si.ReleaseTag, si.DistSpecVersion, flags, + ) + + output = serverStatus + "\n" + serverInfo + "\n" + } + + return output, nil +} + +func (si *ServerInfo) ToJSON() (string, error) { + blob, err := json.MarshalIndent(*si, "", " ") + + return string(blob), err +} + +func (si *ServerInfo) ToYAML() (string, error) { + body, err := yaml.Marshal(*si) + + return string(body), err +} diff --git a/pkg/cli/client/server_info_cmd_test.go b/pkg/cli/client/server_info_cmd_test.go new file mode 100644 index 0000000000..25428e937c --- /dev/null +++ b/pkg/cli/client/server_info_cmd_test.go @@ -0,0 +1,124 @@ +//go:build search +// +build search + +package client //nolint:testpackage + +import ( + "bytes" + "fmt" + "os" + "regexp" + "strings" + "testing" + + . "github.com/smartystreets/goconvey/convey" + + "zotregistry.io/zot/pkg/api" + "zotregistry.io/zot/pkg/api/config" + extconf "zotregistry.io/zot/pkg/extensions/config" + test "zotregistry.io/zot/pkg/test/common" +) + +func TestServerInfoCommand(t *testing.T) { + Convey("ServerInfoCommand", t, func() { + port := test.GetFreePort() + baseURL := test.GetBaseURL(port) + conf := config.New() + conf.HTTP.Port = port + conf.Storage.GC = false + defaultVal := true + conf.Extensions = &extconf.ExtensionConfig{ + Search: &extconf.SearchConfig{BaseConfig: extconf.BaseConfig{Enable: &defaultVal}}, + } + + ctlr := api.NewController(conf) + ctlr.Config.Storage.RootDirectory = t.TempDir() + cm := test.NewControllerManager(ctlr) + + cm.StartAndWait(conf.HTTP.Port) + defer cm.StopServer() + + configPath := makeConfigFile(fmt.Sprintf(`{"configs":[{"_name":"status-test","url":"%s","showspinner":false}]}`, + baseURL)) + defer os.Remove(configPath) + + args := []string{"status", "--config", "status-test"} + cmd := NewCliRootCmd() + buff := bytes.NewBufferString("") + cmd.SetOut(buff) + cmd.SetErr(buff) + cmd.SetArgs(args) + err := cmd.Execute() + So(err, ShouldBeNil) + space := regexp.MustCompile(`\s+`) + str := space.ReplaceAllString(buff.String(), " ") + actual := strings.TrimSpace(str) + So(actual, ShouldContainSubstring, config.ReleaseTag) + So(actual, ShouldContainSubstring, config.BinaryType) + + // JSON + args = []string{"status", "--config", "status-test", "--format", "json"} + cmd = NewCliRootCmd() + buff = bytes.NewBufferString("") + cmd.SetOut(buff) + cmd.SetErr(buff) + cmd.SetArgs(args) + err = cmd.Execute() + So(err, ShouldBeNil) + space = regexp.MustCompile(`\s+`) + str = space.ReplaceAllString(buff.String(), " ") + actual = strings.TrimSpace(str) + So(actual, ShouldContainSubstring, config.ReleaseTag) + So(actual, ShouldContainSubstring, config.BinaryType) + + // YAML + args = []string{"status", "--config", "status-test", "--format", "yaml"} + cmd = NewCliRootCmd() + buff = bytes.NewBufferString("") + cmd.SetOut(buff) + cmd.SetErr(buff) + cmd.SetArgs(args) + err = cmd.Execute() + So(err, ShouldBeNil) + space = regexp.MustCompile(`\s+`) + str = space.ReplaceAllString(buff.String(), " ") + actual = strings.TrimSpace(str) + So(actual, ShouldContainSubstring, config.ReleaseTag) + So(actual, ShouldContainSubstring, config.BinaryType) + + // bad type + args = []string{"status", "--config", "status-test", "--format", "badType"} + cmd = NewCliRootCmd() + buff = bytes.NewBufferString("") + cmd.SetOut(buff) + cmd.SetErr(buff) + cmd.SetArgs(args) + err = cmd.Execute() + So(err, ShouldNotBeNil) + }) +} + +func TestServerInfoCommandErrors(t *testing.T) { + Convey("ServerInfoCommand", t, func() { + args := []string{"status"} + cmd := NewCliRootCmd() + buff := bytes.NewBufferString("") + cmd.SetOut(buff) + cmd.SetErr(buff) + cmd.SetArgs(args) + err := cmd.Execute() + So(err, ShouldNotBeNil) + + // invalid URL + err = GetServerInfo(SearchConfig{ + ServURL: "a: ds", + }) + So(err, ShouldNotBeNil) + + // fail Get request + err = GetServerInfo(SearchConfig{ + ServURL: "http://127.0.0.1:8000", + }) + So(err, ShouldNotBeNil) + }) +} diff --git a/pkg/cli/server/extensions_test.go b/pkg/cli/server/extensions_test.go index eab785ff9d..57cd32fec7 100644 --- a/pkg/cli/server/extensions_test.go +++ b/pkg/cli/server/extensions_test.go @@ -1179,7 +1179,7 @@ func TestServeMgmtExtension(t *testing.T) { So(found, ShouldBeTrue) }) - Convey("Mgmt disabled - search unconfigured", t, func(c C) { + Convey("Mgmt disabled - Search unconfigured", t, func(c C) { content := `{ "storage": { "rootDirectory": "%s" @@ -1193,9 +1193,6 @@ func TestServeMgmtExtension(t *testing.T) { "output": "%s" }, "extensions": { - "search": { - "enable": false - } } }` diff --git a/pkg/extensions/extension_mgmt.go b/pkg/extensions/extension_mgmt.go index e86cdc2144..544a90095b 100644 --- a/pkg/extensions/extension_mgmt.go +++ b/pkg/extensions/extension_mgmt.go @@ -43,8 +43,11 @@ type Auth struct { type StrippedConfig struct { DistSpecVersion string `json:"distSpecVersion" mapstructure:"distSpecVersion"` + Commit string `json:"commit" mapstructure:"commit"` + ReleaseTag string `json:"releaseTag" mapstructure:"releaseTag"` BinaryType string `json:"binaryType" mapstructure:"binaryType"` - HTTP struct { + + HTTP struct { Auth *Auth `json:"auth,omitempty" mapstructure:"auth"` } `json:"http" mapstructure:"http"` } diff --git a/swagger/docs.go b/swagger/docs.go index fbc2067726..5ccb8a2b35 100644 --- a/swagger/docs.go +++ b/swagger/docs.go @@ -1391,6 +1391,9 @@ const docTemplate = `{ "binaryType": { "type": "string" }, + "commit": { + "type": "string" + }, "distSpecVersion": { "type": "string" }, @@ -1401,6 +1404,9 @@ const docTemplate = `{ "$ref": "#/definitions/extensions.Auth" } } + }, + "releaseTag": { + "type": "string" } } }, diff --git a/swagger/swagger.json b/swagger/swagger.json index 50e77cbc27..1b2b28f107 100644 --- a/swagger/swagger.json +++ b/swagger/swagger.json @@ -1382,6 +1382,9 @@ "binaryType": { "type": "string" }, + "commit": { + "type": "string" + }, "distSpecVersion": { "type": "string" }, @@ -1392,6 +1395,9 @@ "$ref": "#/definitions/extensions.Auth" } } + }, + "releaseTag": { + "type": "string" } } }, diff --git a/swagger/swagger.yaml b/swagger/swagger.yaml index c0d0a465f1..2139546ff5 100644 --- a/swagger/swagger.yaml +++ b/swagger/swagger.yaml @@ -154,6 +154,8 @@ definitions: properties: binaryType: type: string + commit: + type: string distSpecVersion: type: string http: @@ -161,6 +163,8 @@ definitions: auth: $ref: '#/definitions/extensions.Auth' type: object + releaseTag: + type: string type: object github_com_opencontainers_image-spec_specs-go_v1.Descriptor: properties: