0
Fork 0
mirror of https://github.com/project-zot/zot.git synced 2024-12-16 21:56:37 -05:00

feat(cli): add command to interogate the server version and other details (#1709)

Signed-off-by: Laurentiu Niculae <niculae.laurentiu1@gmail.com>
This commit is contained in:
LaurentiuNiculae 2023-11-22 19:25:21 +02:00 committed by GitHub
parent 0dfff561f9
commit 83f287d1f6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 475 additions and 9 deletions

View file

@ -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")

View file

@ -11,4 +11,5 @@ func enableCli(rootCmd *cobra.Command) {
rootCmd.AddCommand(NewCVECommand(NewSearchService()))
rootCmd.AddCommand(NewRepoCommand(NewSearchService()))
rootCmd.AddCommand(NewSearchCommand(NewSearchService()))
rootCmd.AddCommand(NewServerStatusCommand())
}

View file

@ -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))
}

View file

@ -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
}

View file

@ -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())
})
})
})
}

View file

@ -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
}
}
}`

View file

@ -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"`
}

View file

@ -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"
}
}
},

View file

@ -1382,6 +1382,9 @@
"binaryType": {
"type": "string"
},
"commit": {
"type": "string"
},
"distSpecVersion": {
"type": "string"
},
@ -1392,6 +1395,9 @@
"$ref": "#/definitions/extensions.Auth"
}
}
},
"releaseTag": {
"type": "string"
}
}
},

View file

@ -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: