Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Jcastets/organizations #113

Merged
merged 4 commits into from
Aug 2, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 34 additions & 17 deletions pkg/koyeb/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,12 @@ const (
ctx_logs_client
ctx_mapper
ctx_renderer
ctx_organization
)

// SetupCLIContext is called by the root command to setup the context for all subcommands.
func SetupCLIContext(cmd *cobra.Command) error {
// When `organization` is not empty, it should contain the ID of the organization to switch the context to.
func SetupCLIContext(cmd *cobra.Command, organization string) error {
apiClient, err := getApiClient()
if err != nil {
return err
Expand All @@ -36,31 +38,46 @@ func SetupCLIContext(cmd *cobra.Command) error {
ctx = context.WithValue(ctx, ctx_logs_client, logsApiClient)
ctx = context.WithValue(ctx, ctx_mapper, idmapper.NewMapper(ctx, apiClient))
ctx = context.WithValue(ctx, ctx_renderer, renderer.NewRenderer(outputFormat))
ctx = context.WithValue(ctx, ctx_organization, organization)
cmd.SetContext(ctx)

if organization != "" {
token, err := GetOrganizationToken(apiClient.OrganizationApi, ctx, organization)
if err != nil {
return err
}
ctx = context.WithValue(ctx, koyeb.ContextAccessToken, token)
cmd.SetContext(ctx)
}
return nil
}

type CLIContext struct {
Context context.Context
Client *koyeb.APIClient
LogsClient *LogsAPIClient
Mapper *idmapper.Mapper
Token string
Renderer renderer.Renderer
Context context.Context
Client *koyeb.APIClient
LogsClient *LogsAPIClient
Mapper *idmapper.Mapper
Token string
Renderer renderer.Renderer
Organization string
}

// GetCLIContext transforms the untyped context passed to cobra commands into a CLIContext.
func GetCLIContext(ctx context.Context) *CLIContext {
return &CLIContext{
Context: ctx,
Client: ctx.Value(ctx_client).(*koyeb.APIClient),
LogsClient: ctx.Value(ctx_logs_client).(*LogsAPIClient),
Mapper: ctx.Value(ctx_mapper).(*idmapper.Mapper),
Token: ctx.Value(koyeb.ContextAccessToken).(string),
Renderer: ctx.Value(ctx_renderer).(renderer.Renderer),
Organization: ctx.Value(ctx_organization).(string),
}
}

// WithCLIContext is a decorator that provides a CLIContext to cobra commands.
func WithCLIContext(fn func(ctx *CLIContext, cmd *cobra.Command, args []string) error) func(cmd *cobra.Command, args []string) error {
return func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
cliContext := CLIContext{
Context: ctx,
Client: ctx.Value(ctx_client).(*koyeb.APIClient),
LogsClient: ctx.Value(ctx_logs_client).(*LogsAPIClient),
Mapper: ctx.Value(ctx_mapper).(*idmapper.Mapper),
Token: ctx.Value(koyeb.ContextAccessToken).(string),
Renderer: ctx.Value(ctx_renderer).(renderer.Renderer),
}
return fn(&cliContext, cmd, args)
return fn(GetCLIContext(cmd.Context()), cmd, args)
}
}
35 changes: 21 additions & 14 deletions pkg/koyeb/idmapper/idmapper.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,14 @@ import (
)

type Mapper struct {
app *AppMapper
domain *DomainMapper
service *ServiceMapper
deployment *DeploymentMapper
regional *RegionalDeploymentMapper
instance *InstanceMapper
secret *SecretMapper
app *AppMapper
domain *DomainMapper
service *ServiceMapper
deployment *DeploymentMapper
regional *RegionalDeploymentMapper
instance *InstanceMapper
secret *SecretMapper
organization *OrganizationMapper
}

func NewMapper(ctx context.Context, client *koyeb.APIClient) *Mapper {
Expand All @@ -24,15 +25,17 @@ func NewMapper(ctx context.Context, client *koyeb.APIClient) *Mapper {
regionalMapper := NewRegionalDeploymentMapper(ctx, client)
instanceMapper := NewInstanceMapper(ctx, client)
secretMapper := NewSecretMapper(ctx, client)
organizationMapper := NewOrganizationMapper(ctx, client)

return &Mapper{
app: appMapper,
domain: domainMapper,
service: serviceMapper,
deployment: deploymentMapper,
regional: regionalMapper,
instance: instanceMapper,
secret: secretMapper,
app: appMapper,
domain: domainMapper,
service: serviceMapper,
deployment: deploymentMapper,
regional: regionalMapper,
instance: instanceMapper,
secret: secretMapper,
organization: organizationMapper,
}
}

Expand Down Expand Up @@ -63,3 +66,7 @@ func (mapper *Mapper) Instance() *InstanceMapper {
func (mapper *Mapper) Secret() *SecretMapper {
return mapper.secret
}

func (mapper *Mapper) Organization() *OrganizationMapper {
return mapper.organization
}
124 changes: 124 additions & 0 deletions pkg/koyeb/idmapper/organization.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
package idmapper

import (
"context"
"strconv"

"github.com/koyeb/koyeb-api-client-go/api/v1/koyeb"
"github.com/koyeb/koyeb-cli/pkg/koyeb/errors"
)

type OrganizationMapper struct {
ctx context.Context
client *koyeb.APIClient
fetched bool
sidMap *IDMap
nameMap *IDMap
}

func NewOrganizationMapper(ctx context.Context, client *koyeb.APIClient) *OrganizationMapper {
return &OrganizationMapper{
ctx: ctx,
client: client,
fetched: false,
sidMap: NewIDMap(),
nameMap: NewIDMap(),
}
}

func (mapper *OrganizationMapper) ResolveID(val string) (string, error) {
if IsUUIDv4(val) {
return val, nil
}

if !mapper.fetched {
err := mapper.fetch()
if err != nil {
return "", err
}
}

id, ok := mapper.sidMap.GetID(val)
if ok {
return id, nil
}

id, ok = mapper.nameMap.GetID(val)
if ok {
return id, nil
}

return "", errors.NewCLIErrorForMapperResolve(
"organization",
val,
[]string{"organization full UUID", "organization short ID (8 characters)", "organization name"},
)
}

func (mapper *OrganizationMapper) getCurrentUserId() (string, error) {
res, resp, err := mapper.client.ProfileApi.GetCurrentUser(mapper.ctx).Execute()
if err != nil {
return "", errors.NewCLIErrorFromAPIError("Your authentication token is not linked to a user", err, resp)
}
return *res.GetUser().Id, nil
}

func (mapper *OrganizationMapper) fetch() error {
radix := NewRadixTree()

userId, err := mapper.getCurrentUserId()
if err != nil {
return err
}

page := int64(0)
offset := int64(0)
limit := int64(100)
for {
res, resp, err := mapper.client.OrganizationMembersApi.
ListOrganizationMembers(mapper.ctx).
UserId(userId).
Limit(strconv.FormatInt(limit, 10)).
Offset(strconv.FormatInt(offset, 10)).
Execute()
if err != nil {
return errors.NewCLIErrorFromAPIError(
"Error listing organizations to resolve the provided identifier to an object ID",
err,
resp,
)
}

members := res.GetMembers()
for i := range members {
member := &members[i]
radix.Insert(getKey(member.Organization.GetId()), member)
}

page++
offset = page * limit
if offset >= res.GetCount() {
break
}
}

minLength := radix.MinimalLength(8)
err = radix.ForEach(func(key Key, value Value) error {
member := value.(*koyeb.OrganizationMember)
id := member.Organization.GetId()
name := member.Organization.GetName()
sid := getShortID(id, minLength)

mapper.sidMap.Set(id, sid)
mapper.nameMap.Set(id, name)

return nil
})
if err != nil {
return err
}

mapper.fetched = true

return nil
}
13 changes: 9 additions & 4 deletions pkg/koyeb/koyeb.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ var (
forceASCII bool
debugFull bool
debug bool
organization string

loginCmd = &cobra.Command{
Use: "login",
Expand Down Expand Up @@ -75,7 +76,7 @@ func GetRootCommand() *cobra.Command {
return err
}
DetectUpdates()
return SetupCLIContext(cmd)
return SetupCLIContext(cmd, organization)
},
}

Expand All @@ -89,16 +90,19 @@ func GetRootCommand() *cobra.Command {
rootCmd.PersistentFlags().BoolP("full", "", false, "do not truncate output")
rootCmd.PersistentFlags().String("url", "https://app.koyeb.com", "url of the api")
rootCmd.PersistentFlags().String("token", "", "API token")
rootCmd.PersistentFlags().StringVar(&organization, "organization", "", "organization ID")

// viper.BindPFlag returns an error only if the second argument is nil, which is never the case here, so we ignore the error
viper.BindPFlag("url", rootCmd.PersistentFlags().Lookup("url")) //nolint:errcheck
viper.BindPFlag("token", rootCmd.PersistentFlags().Lookup("token")) //nolint:errcheck
viper.BindPFlag("debug", rootCmd.PersistentFlags().Lookup("debug")) //nolint:errcheck
viper.BindPFlag("url", rootCmd.PersistentFlags().Lookup("url")) //nolint:errcheck
viper.BindPFlag("token", rootCmd.PersistentFlags().Lookup("token")) //nolint:errcheck
viper.BindPFlag("debug", rootCmd.PersistentFlags().Lookup("debug")) //nolint:errcheck
viper.BindPFlag("organization", rootCmd.PersistentFlags().Lookup("organization")) //nolint:errcheck

rootCmd.AddCommand(loginCmd)
rootCmd.AddCommand(versionCmd)
rootCmd.AddCommand(completionCmd)

rootCmd.AddCommand(NewOrganizationCmd())
rootCmd.AddCommand(NewSecretCmd())
rootCmd.AddCommand(NewAppCmd())
rootCmd.AddCommand(NewDomainCmd())
Expand Down Expand Up @@ -250,5 +254,6 @@ func initConfig(rootCmd *cobra.Command) error {
apiurl = viper.GetString("url")
token = viper.GetString("token")
debug = viper.GetBool("debug")
organization = viper.GetString("organization")
return nil
}
72 changes: 72 additions & 0 deletions pkg/koyeb/organizations.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package koyeb

import (
"context"
"fmt"

"github.com/koyeb/koyeb-api-client-go/api/v1/koyeb"
"github.com/koyeb/koyeb-cli/pkg/koyeb/errors"
"github.com/spf13/cobra"
)

func NewOrganizationCmd() *cobra.Command {
h := NewOrganizationHandler()
rootCmd := &cobra.Command{
Use: "organizations ACTION",
Aliases: []string{"organizations", "organization", "orgas", "orga", "orgs", "org", "organisations", "organisation"},
Short: "Organization",
}
listCmd := &cobra.Command{
Use: "list",
Short: "List organizations",
RunE: WithCLIContext(h.List),
}
rootCmd.AddCommand(listCmd)

switchCmd := &cobra.Command{
Use: "switch",
Short: "Switch the CLI context to another organization",
RunE: WithCLIContext(h.Switch),
}
rootCmd.AddCommand(switchCmd)
return rootCmd
}

func NewOrganizationHandler() *OrganizationHandler {
return &OrganizationHandler{}
}

type OrganizationHandler struct {
}

func ResolveOrganizationArgs(ctx *CLIContext, val string) (string, error) {
organizationMapper := ctx.Mapper.Organization()
id, err := organizationMapper.ResolveID(val)
if err != nil {
return "", err
}
return id, nil
}

// GetOrganizationToken calls /v1/organizations/{organizationId}/switch which returns a token to access the resources of organizationId
func GetOrganizationToken(api koyeb.OrganizationApi, ctx context.Context, organizationId string) (string, error) {
//SwitchOrganization requires to pass an empty body
body := make(map[string]interface{})
res, resp, err := api.SwitchOrganization(ctx, organizationId).Body(body).Execute()
if err != nil {
errBuf := make([]byte, 1024)
// if the body can't be read, it won't be displayed in the error below
resp.Body.Read(errBuf) // nolint:errcheck
return "", &errors.CLIError{
What: "Error while switching the current organization",
Why: fmt.Sprintf("the API endpoint which switches the current organization returned an error %d", resp.StatusCode),
Additional: []string{
"You provided an organization id with the --organization flag, or the `organization` field is set in your configuration file.",
"The value provided is likely incorrect, or you don't have access to this organization.",
},
Orig: fmt.Errorf("HTTP/%d\n\n%s", resp.StatusCode, errBuf),
Solution: "List your organizations with `koyeb --organization=\"\" organization list`, then switch to the organization you want to use with `koyeb --organization=\"\" organization switch <id>`. Finally you can run your commands again, without the --organization flag.",
}
}
return *res.Token.Id, nil
}
Loading