diff --git a/errors/errors.go b/errors/errors.go index a9757813..0022a53b 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") + ErrURLNotFound = errors.New("url 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 1c0171a7..aee53339 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(NewServerStatusCommand()) } diff --git a/pkg/cli/client/client.go b/pkg/cli/client/client.go index 9388ea42..7839d5f9 100644 --- a/pkg/cli/client/client.go +++ b/pkg/cli/client/client.go @@ -119,13 +119,20 @@ func doHTTPRequest(req *http.Request, verifyTLS bool, debug bool, defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - if resp.StatusCode == http.StatusUnauthorized { - return nil, zerr.ErrUnauthorizedAccess + var err error + + switch resp.StatusCode { + case http.StatusNotFound: + err = zerr.ErrURLNotFound + case http.StatusUnauthorized: + err = zerr.ErrUnauthorizedAccess + default: + err = zerr.ErrBadHTTPStatusCode } bodyBytes, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("%w: Expected: %d, Got: %d, Body: '%s'", zerr.ErrBadHTTPStatusCode, http.StatusOK, + return nil, fmt.Errorf("%w: Expected: %d, Got: %d, Body: '%s'", err, http.StatusOK, resp.StatusCode, string(bodyBytes)) } diff --git a/pkg/cli/client/server_info_cmd.go b/pkg/cli/client/server_info_cmd.go new file mode 100644 index 00000000..db6bf420 --- /dev/null +++ b/pkg/cli/client/server_info_cmd.go @@ -0,0 +1,191 @@ +//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" +) + +const ( + StatusOnline = "online" + StatusOffline = "offline" + StatusUnknown = "unknown" +) + +func NewServerStatusCommand() *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 GetServerStatus(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 +} + +func GetServerStatus(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 = fmt.Sprintf("unauthorised access, %s", getCredentialsSuggestion(username)) + case errors.Is(err, zerr.ErrBadHTTPStatusCode), errors.Is(err, zerr.ErrURLNotFound): + 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.ErrURLNotFound): + serverInfo.Status = StatusOnline + serverInfo.ErrorMsg = fmt.Sprintf("%s%s endpoint is not available", constants.RoutePrefix, constants.ExtMgmt) + case errors.Is(err, zerr.ErrUnauthorizedAccess): + serverInfo.Status = StatusOnline + serverInfo.ErrorMsg = fmt.Sprintf("unauthorised access, %s", getCredentialsSuggestion(username)) + case errors.Is(err, zerr.ErrBadHTTPStatusCode): + serverInfo.Status = StatusOnline + 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) +} + +func getCredentialsSuggestion(username string) string { + if username == "" { + return "endpoint requires valid user credentials (add the flag '--user [user]:[password]')" + } + + return "given credentials are invalid" +} + +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 + } + + 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 00000000..3682b569 --- /dev/null +++ b/pkg/cli/client/server_info_cmd_test.go @@ -0,0 +1,248 @@ +//go:build search +// +build search + +package client //nolint:testpackage + +import ( + "bytes" + "fmt" + "net/http" + "os" + "regexp" + "strings" + "testing" + + . "github.com/smartystreets/goconvey/convey" + + zerr "zotregistry.io/zot/errors" + "zotregistry.io/zot/pkg/api" + "zotregistry.io/zot/pkg/api/config" + "zotregistry.io/zot/pkg/api/constants" + extconf "zotregistry.io/zot/pkg/extensions/config" + test "zotregistry.io/zot/pkg/test/common" +) + +func TestServerStatusCommand(t *testing.T) { + Convey("ServerStatusCommand", 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 TestServerStatusCommandErrors(t *testing.T) { + Convey("ServerStatusCommand", 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 = GetServerStatus(SearchConfig{ + ServURL: "a: ds", + ResultWriter: os.Stdout, + }) + So(err, ShouldNotBeNil) + + // fail Get request + err = GetServerStatus(SearchConfig{ + ServURL: "http://127.0.0.1:8000", + ResultWriter: os.Stdout, + }) + So(err, ShouldBeNil) + }) + + Convey("HTTP errors", t, func() { + port := test.GetFreePort() + result := bytes.NewBuffer([]byte{}) + searchConfig := SearchConfig{ + SearchService: mockService{}, + ServURL: fmt.Sprintf("http://127.0.0.1:%v", port), + User: "", + OutputFormat: "text", + ResultWriter: result, + } + + Convey("v2 is Unauthorised", func() { + server := StartTestHTTPServer(HTTPRoutes{ + RouteHandler{ + Route: "/v2/", + HandlerFunc: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + }, + AllowedMethods: []string{http.MethodGet}, + }, + }, port) + defer server.Close() + + err := GetServerStatus(searchConfig) + So(err, ShouldBeNil) + So(result.String(), ShouldContainSubstring, "unauthorised access, endpoint requires valid user credentials") + + // with bad user set + searchConfig.User = "test:test" + err = GetServerStatus(searchConfig) + So(err, ShouldBeNil) + So(result.String(), ShouldContainSubstring, "unauthorised access, given credentials are invalid") + }) + + Convey("v2 bad http status code", func() { + server := StartTestHTTPServer(HTTPRoutes{ + RouteHandler{ + Route: "/v2/", + HandlerFunc: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + }, + AllowedMethods: []string{http.MethodGet}, + }, + }, port) + defer server.Close() + + err := GetServerStatus(searchConfig) + So(err, ShouldBeNil) + So(result.String(), ShouldContainSubstring, zerr.ErrAPINotSupported.Error()) + }) + + Convey("MGMT errors", func() { + Convey("URL not found", func() { + server := StartTestHTTPServer(HTTPRoutes{ + RouteHandler{ + Route: "/v2/", + HandlerFunc: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }, + AllowedMethods: []string{http.MethodGet}, + }, + }, port) + defer server.Close() + + err := GetServerStatus(searchConfig) + So(err, ShouldBeNil) + So(result.String(), ShouldContainSubstring, "endpoint is not available") + }) + + Convey("Unauthorized Access", func() { + server := StartTestHTTPServer(HTTPRoutes{ + RouteHandler{ + Route: "/v2/", + HandlerFunc: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }, + AllowedMethods: []string{http.MethodGet}, + }, + RouteHandler{ + Route: constants.RoutePrefix + constants.ExtMgmt, + HandlerFunc: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + }, + AllowedMethods: []string{http.MethodGet}, + }, + }, port) + defer server.Close() + + err := GetServerStatus(searchConfig) + So(err, ShouldBeNil) + So(result.String(), ShouldContainSubstring, "unauthorised access") + }) + + Convey("Bad status code", func() { + server := StartTestHTTPServer(HTTPRoutes{ + RouteHandler{ + Route: "/v2/", + HandlerFunc: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }, + AllowedMethods: []string{http.MethodGet}, + }, + RouteHandler{ + Route: constants.RoutePrefix + constants.ExtMgmt, + HandlerFunc: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + }, + AllowedMethods: []string{http.MethodGet}, + }, + }, port) + defer server.Close() + + err := GetServerStatus(searchConfig) + So(err, ShouldBeNil) + So(result.String(), ShouldContainSubstring, zerr.ErrAPINotSupported.Error()) + }) + }) + }) +} diff --git a/pkg/cli/server/extensions_test.go b/pkg/cli/server/extensions_test.go index eab785ff..57cd32fe 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 e86cdc21..544a9009 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 fbc20677..5ccb8a2b 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 50e77cbc..1b2b28f1 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 c0d0a465..2139546f 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: