Improve account info command to check status about an organization

Show current consumption (seats, private repos and teams)

Signed-off-by: Silvin Lubecki <silvin.lubecki@docker.com>
This commit is contained in:
Silvin Lubecki 2021-01-04 10:26:20 +01:00
Родитель 3633a9001a
Коммит a54d65065e
12 изменённых файлов: 308 добавлений и 35 удалений

Просмотреть файл

@ -25,6 +25,7 @@ import (
"github.com/docker/cli/cli/command"
"github.com/docker/go-units"
"github.com/spf13/cobra"
"golang.org/x/sync/errgroup"
"github.com/docker/hub-tool/internal/ansi"
"github.com/docker/hub-tool/internal/format"
@ -43,9 +44,9 @@ type infoOptions struct {
func newInfoCmd(streams command.Streams, hubClient *hub.Client, parent string) *cobra.Command {
var opts infoOptions
cmd := &cobra.Command{
Use: infoName + " [OPTIONS]",
Use: infoName + " [OPTIONS] [ORGANIZATION]",
Short: "Print the account information",
Args: cli.NoArgs,
Args: cli.RequiresMaxArgs(1),
DisableFlagsInUseLine: true,
Annotations: map[string]string{
"sudo": "true",
@ -54,55 +55,107 @@ func newInfoCmd(streams command.Streams, hubClient *hub.Client, parent string) *
metrics.Send(parent, infoName)
},
RunE: func(cmd *cobra.Command, args []string) error {
return runInfo(streams, hubClient, opts)
if len(args) > 0 {
return runOrgInfo(streams, hubClient, opts, args[0])
}
return runUserInfo(streams, hubClient, opts)
},
}
opts.AddFormatFlag(cmd.Flags())
return cmd
}
func runInfo(streams command.Streams, hubClient *hub.Client, opts infoOptions) error {
func runOrgInfo(streams command.Streams, hubClient *hub.Client, opts infoOptions, orgName string) error {
var (
org *hub.Account
consumption *hub.Consumption
)
g := errgroup.Group{}
g.Go(func() error {
var err error
org, err = hubClient.GetOrganizationInfo(orgName)
return checkForbiddenError(err)
})
g.Go(func() error {
var err error
consumption, err = hubClient.GetOrgConsumption(orgName)
return checkForbiddenError(err)
})
if err := g.Wait(); err != nil {
return err
}
plan, err := hubClient.GetHubPlan(org.ID)
if err != nil {
return checkForbiddenError(err)
}
return opts.Print(streams.Out(), account{org, plan, consumption}, printAccount)
}
func runUserInfo(streams command.Streams, hubClient *hub.Client, opts infoOptions) error {
user, err := hubClient.GetUserInfo()
if err != nil {
return err
return checkForbiddenError(err)
}
consumption, err := hubClient.GetUserConsumption(user.Name)
if err != nil {
return checkForbiddenError(err)
}
plan, err := hubClient.GetHubPlan(user.ID)
if err != nil {
return err
return checkForbiddenError(err)
}
return opts.Print(streams.Out(), account{user, plan}, printAccount)
return opts.Print(streams.Out(), account{user, plan, consumption}, printAccount)
}
func checkForbiddenError(err error) error {
if hub.IsForbiddenError(err) {
return fmt.Errorf(ansi.Error("failed to get organization information, you need to be the organization Owner"))
}
return err
}
func printAccount(out io.Writer, value interface{}) error {
account := value.(account)
// print user info
fmt.Fprintf(out, ansi.Key("Username:")+"\t%s\n", account.User.UserName)
fmt.Fprintf(out, ansi.Key("Full name:")+"\t%s\n", account.User.FullName)
fmt.Fprintf(out, ansi.Key("Company:")+"\t%s\n", account.User.Company)
fmt.Fprintf(out, ansi.Key("Location:")+"\t%s\n", account.User.Location)
fmt.Fprintf(out, ansi.Key("Joined:")+"\t\t%s ago\n", units.HumanDuration(time.Since(account.User.Joined)))
fmt.Fprintf(out, ansi.Key("Name:")+"\t\t%s\n", account.Account.Name)
fmt.Fprintf(out, ansi.Key("Full name:")+"\t%s\n", account.Account.FullName)
fmt.Fprintf(out, ansi.Key("Company:")+"\t%s\n", account.Account.Company)
fmt.Fprintf(out, ansi.Key("Location:")+"\t%s\n", account.Account.Location)
fmt.Fprintf(out, ansi.Key("Joined:")+"\t\t%s ago\n", units.HumanDuration(time.Since(account.Account.Joined)))
fmt.Fprintf(out, ansi.Key("Plan:")+"\t\t%s\n", ansi.Emphasise(account.Plan.Name))
// print plan info
fmt.Fprintf(out, ansi.Key("Limits:")+"\n")
fmt.Fprintf(out, ansi.Key(" Seats:")+"\t\t%v\n", account.Plan.Limits.Seats)
fmt.Fprintf(out, ansi.Key(" Private repositories:")+"\t%v\n", account.Plan.Limits.PrivateRepos)
fmt.Fprintf(out, ansi.Key(" Parallel builds:")+"\t%v\n", account.Plan.Limits.ParallelBuilds)
fmt.Fprintf(out, ansi.Key(" Seats:")+"\t\t%v\n", getCurrentLimit(account.Consumption.Seats, account.Plan.Limits.Seats))
fmt.Fprintf(out, ansi.Key(" Private repositories:")+"\t%v\n", getCurrentLimit(account.Consumption.PrivateRepositories, account.Plan.Limits.PrivateRepos))
fmt.Fprintf(out, ansi.Key(" Teams:")+"\t\t%v\n", getCurrentLimit(account.Consumption.Teams, account.Plan.Limits.Teams))
fmt.Fprintf(out, ansi.Key(" Collaborators:")+"\t%v\n", getLimit(account.Plan.Limits.Collaborators))
fmt.Fprintf(out, ansi.Key(" Teams:")+"\t\t%v\n", getLimit(account.Plan.Limits.Teams))
fmt.Fprintf(out, ansi.Key(" Parallel builds:")+"\t%v\n", getLimit(account.Plan.Limits.ParallelBuilds))
return nil
}
func getLimit(number int) string {
if number == 9999 {
func getCurrentLimit(current, limit int) string {
if limit == 9999 {
return ansi.Emphasise("unlimited")
}
return fmt.Sprintf("%v", number)
return fmt.Sprintf("%v/%v", current, limit)
}
func getLimit(limit int) string {
if limit == 9999 {
return ansi.Emphasise("unlimited")
}
return fmt.Sprintf("%v", limit)
}
type account struct {
User *hub.User
Plan *hub.Plan
Account *hub.Account
Plan *hub.Plan
Consumption *hub.Consumption
}

Просмотреть файл

@ -29,9 +29,9 @@ import (
func TestInfoOutput(t *testing.T) {
account := account{
User: &hub.User{
Account: &hub.Account{
ID: "id",
UserName: "my-user-name",
Name: "my-user-name",
FullName: "My Full Name",
Location: "MyLocation",
Company: "My Company",
@ -47,6 +47,11 @@ func TestInfoOutput(t *testing.T) {
ParallelBuilds: 3,
},
},
Consumption: &hub.Consumption{
Seats: 0,
PrivateRepositories: 1,
Teams: 2,
},
}
buf := bytes.NewBuffer(nil)
err := printAccount(buf, account)

Просмотреть файл

@ -1,12 +1,12 @@
Username: my-user-name
Name: my-user-name
Full name: My Full Name
Company: My Company
Location: MyLocation
Joined: Less than a second ago
Plan: free
Limits:
Seats: 1
Private repositories: 2
Parallel builds: 3
Collaborators: unlimited
Seats: 0/1
Private repositories: 1/2
Teams: unlimited
Collaborators: unlimited
Parallel builds: 3

Просмотреть файл

@ -78,7 +78,7 @@ type listOptions struct {
func newListCmd(streams command.Streams, hubClient *hub.Client, parent string) *cobra.Command {
var opts listOptions
cmd := &cobra.Command{
Use: listName + " [ORGANIZATION]",
Use: listName + " [OPTIONS] [ORGANIZATION]",
Aliases: []string{"list"},
Short: "List all the repositories from your account or an organization",
Args: cli.RequiresMaxArgs(1),

Просмотреть файл

@ -300,6 +300,9 @@ func (c *Client) doRequest(req *http.Request, reqOps ...RequestOp) ([]byte, erro
}
log.Tracef("HTTP response: %+v", resp)
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
if resp.StatusCode == http.StatusForbidden {
return nil, &forbiddenError{}
}
buf, err := ioutil.ReadAll(resp.Body)
log.Debugf("bad status code %q: %s", resp.Status, buf)
if err == nil {

Просмотреть файл

@ -0,0 +1,99 @@
/*
Copyright 2020 Docker Hub Tool authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package hub
import (
"context"
"golang.org/x/sync/errgroup"
)
//Consumption represents current user or org consumption
type Consumption struct {
Seats int
PrivateRepositories int
Teams int
}
//GetOrgConsumption return the current organization consumption
func (c *Client) GetOrgConsumption(org string) (*Consumption, error) {
var (
members int
privateRepos int
teams int
)
c.fetchAllElements = true
eg, _ := errgroup.WithContext(context.Background())
eg.Go(func() error {
count, err := c.GetMembersCount(org)
if err != nil {
return err
}
members = count
return nil
})
eg.Go(func() error {
count, err := c.GetTeamsCount(org)
if err != nil {
return err
}
teams = count
return nil
})
eg.Go(func() error {
repos, _, err := c.GetRepositories(org)
if err != nil {
return err
}
for _, r := range repos {
if r.IsPrivate {
privateRepos++
}
}
return nil
})
if err := eg.Wait(); err != nil {
return nil, err
}
return &Consumption{
Seats: members,
PrivateRepositories: privateRepos,
Teams: teams,
}, nil
}
//GetUserConsumption return the current user consumption
func (c *Client) GetUserConsumption(user string) (*Consumption, error) {
c.fetchAllElements = true
privateRepos := 0
repos, _, err := c.GetRepositories(user)
if err != nil {
return nil, err
}
for _, r := range repos {
if r.IsPrivate {
privateRepos++
}
}
return &Consumption{
Seats: 1,
PrivateRepositories: privateRepos,
Teams: 0,
}, nil
}

Просмотреть файл

@ -44,3 +44,15 @@ func IsInvalidTokenError(err error) bool {
_, ok := err.(*invalidTokenError)
return ok
}
type forbiddenError struct{}
func (f forbiddenError) Error() string {
return "operation not permitted"
}
// IsForbiddenError check if the error type is a forbidden error
func IsForbiddenError(err error) bool {
_, ok := err.(*forbiddenError)
return ok
}

Просмотреть файл

@ -32,3 +32,8 @@ func TestIsInvalidTokenError(t *testing.T) {
assert.Assert(t, IsInvalidTokenError(&invalidTokenError{}))
assert.Assert(t, !IsInvalidTokenError(errors.New("")))
}
func TestIsForbiddenError(t *testing.T) {
assert.Assert(t, IsForbiddenError(&forbiddenError{}))
assert.Assert(t, !IsForbiddenError(errors.New("")))
}

Просмотреть файл

@ -65,6 +65,32 @@ func (c *Client) GetMembers(organization string) ([]Member, error) {
return members, nil
}
// GetMembersCount return the number of members in an organization
func (c *Client) GetMembersCount(organization string) (int, error) {
u, err := url.Parse(c.domain + fmt.Sprintf(MembersURL, organization))
if err != nil {
return 0, err
}
q := url.Values{}
q.Add("page_size", "1")
q.Add("page", "1")
u.RawQuery = q.Encode()
req, err := http.NewRequest("GET", u.String(), nil)
if err != nil {
return 0, err
}
response, err := c.doRequest(req, withHubToken(c.token))
if err != nil {
return 0, err
}
var hubResponse hubMemberResponse
if err := json.Unmarshal(response, &hubResponse); err != nil {
return 0, err
}
return hubResponse.Count, nil
}
// GetMembersPerTeam returns the members of a team in an organization
func (c *Client) GetMembersPerTeam(organization, team string) ([]Member, error) {
u := c.domain + fmt.Sprintf(MembersPerTeamURL, organization, team)

Просмотреть файл

@ -31,6 +31,8 @@ import (
const (
// OrganizationsURL path to the Hub API listing the organizations
OrganizationsURL = "/v2/user/orgs/"
// OrganizationInfoURL path to the Hub API returning organization info
OrganizationInfoURL = "/v2/orgs/%s"
)
//Organization represents a Docker Hub organization
@ -70,6 +72,35 @@ func (c *Client) GetOrganizations(ctx context.Context) ([]Organization, error) {
return organizations, nil
}
//GetOrganizationInfo returns organization info
func (c *Client) GetOrganizationInfo(orgname string) (*Account, error) {
u, err := url.Parse(c.domain + fmt.Sprintf(OrganizationInfoURL, orgname))
if err != nil {
return nil, err
}
req, err := http.NewRequest("GET", u.String(), nil)
if err != nil {
return nil, err
}
response, err := c.doRequest(req, withHubToken(c.token))
if err != nil {
return nil, err
}
var hubResponse hubOrgInfoResponse
if err := json.Unmarshal(response, &hubResponse); err != nil {
return nil, err
}
return &Account{
ID: hubResponse.ID,
Name: hubResponse.OrgName,
FullName: hubResponse.FullName,
Location: hubResponse.Location,
Company: hubResponse.Company,
Joined: hubResponse.DateJoined,
}, nil
}
func (c *Client) getOrganizationsPage(ctx context.Context, url string) ([]Organization, string, error) {
req, err := http.NewRequest("GET", url, nil)
if err != nil {
@ -160,3 +191,16 @@ type hubOrganizationResult struct {
ProfileURL string `json:"profile_url"`
ID string `json:"id"`
}
type hubOrgInfoResponse struct {
ID string `json:"id"`
OrgName string `json:"orgname"`
FullName string `json:"full_name"`
Location string `json:"location"`
Company string `json:"company"`
GravatarEmail string `json:"gravatar_email"`
GravatarURL string `json:"gravatar_url"`
ProfileURL string `json:"profile_url"`
DateJoined time.Time `json:"date_joined"`
Type string `json:"type"`
}

Просмотреть файл

@ -67,6 +67,32 @@ func (c *Client) GetTeams(organization string) ([]Team, error) {
return teams, nil
}
//GetTeamsCount returns the number of teams in an organization
func (c *Client) GetTeamsCount(organization string) (int, error) {
u, err := url.Parse(c.domain + fmt.Sprintf(GroupsURL, organization))
if err != nil {
return 0, err
}
q := url.Values{}
q.Add("page_size", "1")
q.Add("page", "1")
u.RawQuery = q.Encode()
req, err := http.NewRequest("GET", u.String(), nil)
if err != nil {
return 0, err
}
response, err := c.doRequest(req, withHubToken(c.token))
if err != nil {
return 0, err
}
var hubResponse hubGroupResponse
if err := json.Unmarshal(response, &hubResponse); err != nil {
return 0, err
}
return hubResponse.Count, nil
}
func (c *Client) getTeamsPage(url, organization string) ([]Team, string, error) {
req, err := http.NewRequest("GET", url, nil)
if err != nil {

Просмотреть файл

@ -28,10 +28,10 @@ const (
UserURL = "/v2/user/"
)
//User represents the user information
type User struct {
//Account represents a user or organization information
type Account struct {
ID string
UserName string
Name string
FullName string
Location string
Company string
@ -39,7 +39,7 @@ type User struct {
}
//GetUserInfo returns the information on the user retrieved from Hub
func (c *Client) GetUserInfo() (*User, error) {
func (c *Client) GetUserInfo() (*Account, error) {
u, err := url.Parse(c.domain + UserURL)
if err != nil {
return nil, err
@ -56,9 +56,9 @@ func (c *Client) GetUserInfo() (*User, error) {
if err := json.Unmarshal(response, &hubResponse); err != nil {
return nil, err
}
return &User{
return &Account{
ID: hubResponse.ID,
UserName: hubResponse.UserName,
Name: hubResponse.UserName,
FullName: hubResponse.FullName,
Location: hubResponse.Location,
Company: hubResponse.Company,