Skip to content

Commit f2dacf8

Browse files
authored
feat(cli): handle org selection client-side (#1669)
Signed-off-by: Miguel Martinez <[email protected]>
1 parent d92660a commit f2dacf8

File tree

18 files changed

+362
-129
lines changed

18 files changed

+362
-129
lines changed

app/cli/cmd/config.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import (
2626

2727
// Map of all the possible configuration options that we expect viper to handle
2828
var confOptions = struct {
29-
authToken, controlplaneAPI, CASAPI, controlplaneCA, CASCA, insecure *confOpt
29+
authToken, controlplaneAPI, CASAPI, controlplaneCA, CASCA, insecure, organization *confOpt
3030
}{
3131
insecure: &confOpt{
3232
viperKey: "api-insecure",
@@ -50,6 +50,10 @@ var confOptions = struct {
5050
viperKey: "artifact-cas.api-ca",
5151
flagName: "artifact-cas-ca",
5252
},
53+
organization: &confOpt{
54+
viperKey: "organization",
55+
flagName: "org",
56+
},
5357
}
5458

5559
type confOpt struct {

app/cli/cmd/organization_create.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ package cmd
1717

1818
import (
1919
"context"
20+
"fmt"
2021

2122
"github.com/chainloop-dev/chainloop/app/cli/internal/action"
2223
"github.com/spf13/cobra"
@@ -34,6 +35,11 @@ func newOrganizationCreateCmd() *cobra.Command {
3435
return err
3536
}
3637

38+
// set the local state
39+
if err := setLocalOrganization(name); err != nil {
40+
return fmt.Errorf("writing config file: %w", err)
41+
}
42+
3743
logger.Info().Msgf("Organization %q created!", org.Name)
3844
return nil
3945
},

app/cli/cmd/organization_leave.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,11 @@ func newOrganizationLeaveCmd() *cobra.Command {
6666
return fmt.Errorf("deleting membership: %w", err)
6767
}
6868

69+
// set the local state
70+
if err := setLocalOrganization(""); err != nil {
71+
return fmt.Errorf("writing config file: %w", err)
72+
}
73+
6974
logger.Info().Msg("Membership deleted")
7075
return nil
7176
},

app/cli/cmd/organization_list.go

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import (
2222
"github.com/chainloop-dev/chainloop/app/cli/internal/action"
2323
"github.com/jedib0t/go-pretty/v6/table"
2424
"github.com/spf13/cobra"
25+
"github.com/spf13/viper"
2526
)
2627

2728
const UserWithNoOrganizationMsg = "you are not part of any organization, please run \"chainloop organization create --name ORG_NAME\" to create one"
@@ -50,11 +51,15 @@ func orgMembershipTableOutput(items []*action.MembershipItem) error {
5051
return nil
5152
}
5253

54+
// Get the current organization from viper configuration
55+
currentOrg := viper.GetString(confOptions.organization.viperKey)
56+
5357
t := newTableWriter()
54-
t.AppendHeader(table.Row{"Name", "Current", "Role", "Joined At"})
58+
t.AppendHeader(table.Row{"Name", "Current", "Default", "Role", "Joined At"})
5559

5660
for _, i := range items {
57-
t.AppendRow(table.Row{i.Org.Name, i.Current, i.Role, i.CreatedAt.Format(time.RFC822)})
61+
current := i.Org.Name == currentOrg
62+
t.AppendRow(table.Row{i.Org.Name, current, i.Default, i.Role, i.CreatedAt.Format(time.RFC822)})
5863
t.AppendSeparator()
5964
}
6065

app/cli/cmd/organization_set.go

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,18 @@ import (
2424

2525
func newOrganizationSet() *cobra.Command {
2626
var orgName string
27+
var setDefault bool
2728

2829
cmd := &cobra.Command{
2930
Use: "set",
30-
Short: "Set the current organization associated with this user",
31+
Short: "Set the current organization to be used by this CLI",
32+
Example: `
33+
# Set the current organization to be used by this CLI
34+
$ chainloop org set --name my-org
35+
36+
# Optionally set the organization as the default one for all clients by storing the preference server-side
37+
$ chainloop org set --name my-org --default
38+
`,
3139
RunE: func(cmd *cobra.Command, _ []string) error {
3240
ctx := cmd.Context()
3341
// To find the membership ID, we need to iterate and filter by org
@@ -38,17 +46,27 @@ func newOrganizationSet() *cobra.Command {
3846
return fmt.Errorf("organization %s not found", orgName)
3947
}
4048

41-
m, err := action.NewMembershipSet(actionOpts).Run(ctx, membership.ID)
42-
if err != nil {
43-
return err
49+
// set the local state
50+
if err := setLocalOrganization(orgName); err != nil {
51+
return fmt.Errorf("writing config file: %w", err)
52+
}
53+
54+
// change the state server side
55+
if setDefault {
56+
var err error
57+
membership, err = action.NewMembershipSet(actionOpts).Run(ctx, membership.ID)
58+
if err != nil {
59+
return err
60+
}
4461
}
4562

4663
logger.Info().Msg("Organization switched!")
47-
return encodeOutput([]*action.MembershipItem{m}, orgMembershipTableOutput)
64+
return encodeOutput([]*action.MembershipItem{membership}, orgMembershipTableOutput)
4865
},
4966
}
5067

5168
cmd.Flags().StringVar(&orgName, "name", "", "organization name to make the switch")
69+
cmd.Flags().BoolVar(&setDefault, "default", false, "set this organization as the default one for all clients")
5270
cobra.CheckErr(cmd.MarkFlagRequired("name"))
5371

5472
return cmd

app/cli/cmd/root.go

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import (
3030
"github.com/chainloop-dev/chainloop/app/cli/internal/action"
3131
"github.com/chainloop-dev/chainloop/app/cli/internal/telemetry"
3232
"github.com/chainloop-dev/chainloop/app/cli/internal/telemetry/posthog"
33+
v1 "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1"
3334
"github.com/chainloop-dev/chainloop/internal/grpcconn"
3435
"github.com/golang-jwt/jwt/v4"
3536
"github.com/rs/zerolog"
@@ -72,13 +73,29 @@ type parsedToken struct {
7273
// Environment variable prefix for vipers
7374
const envPrefix = "CHAINLOOP"
7475

76+
func Execute(l zerolog.Logger) error {
77+
rootCmd := NewRootCmd(l)
78+
if err := rootCmd.Execute(); err != nil {
79+
// The local file is pointing to the wrong organization, we remove it
80+
if v1.IsUserNotMemberOfOrgErrorNotInOrg(err) {
81+
if err := setLocalOrganization(""); err != nil {
82+
logger.Debug().Err(err).Msg("failed to remove organization from config")
83+
}
84+
}
85+
86+
return err
87+
}
88+
89+
return nil
90+
}
91+
7592
func NewRootCmd(l zerolog.Logger) *cobra.Command {
7693
rootCmd := &cobra.Command{
7794
Use: appName,
7895
Short: "Chainloop Command Line Interface",
7996
SilenceErrors: true,
8097
SilenceUsage: true,
81-
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
98+
PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
8299
var err error
83100
logger, err = initLogger(l)
84101
if err != nil {
@@ -105,11 +122,31 @@ func NewRootCmd(l zerolog.Logger) *cobra.Command {
105122
}
106123

107124
controlplaneURL := viper.GetString(confOptions.controlplaneAPI.viperKey)
125+
126+
// If no organization is set in local configuration, we load it from server and save it
127+
if viper.GetString(confOptions.organization.viperKey) == "" {
128+
conn, err := grpcconn.New(controlplaneURL, apiToken, opts...)
129+
if err != nil {
130+
return err
131+
}
132+
133+
currentContext, err := action.NewConfigCurrentContext(newActionOpts(logger, conn)).Run()
134+
if err == nil && currentContext.CurrentMembership != nil {
135+
if err := setLocalOrganization(currentContext.CurrentMembership.Org.Name); err != nil {
136+
return fmt.Errorf("writing config file: %w", err)
137+
}
138+
}
139+
}
140+
141+
// reload the connection now that we have the org name
142+
if orgName := viper.GetString(confOptions.organization.viperKey); orgName != "" {
143+
opts = append(opts, grpcconn.WithOrgName(orgName))
144+
}
145+
108146
conn, err := grpcconn.New(controlplaneURL, apiToken, opts...)
109147
if err != nil {
110148
return err
111149
}
112-
113150
actionOpts = newActionOpts(logger, conn)
114151

115152
if !isTelemetryDisabled() {
@@ -152,7 +189,7 @@ func NewRootCmd(l zerolog.Logger) *cobra.Command {
152189

153190
return nil
154191
},
155-
PersistentPostRunE: func(cmd *cobra.Command, args []string) error {
192+
PersistentPostRunE: func(_ *cobra.Command, _ []string) error {
156193
return cleanup(actionOpts.CPConnection)
157194
},
158195
}
@@ -189,6 +226,9 @@ func NewRootCmd(l zerolog.Logger) *cobra.Command {
189226
// Override the oauth authentication requirement for the CLI by providing an API token
190227
rootCmd.PersistentFlags().StringVarP(&apiToken, "token", "t", "", fmt.Sprintf("API token. NOTE: Alternatively use the env variable %s", tokenEnvVarName))
191228

229+
rootCmd.PersistentFlags().StringP(confOptions.organization.flagName, "n", "", "organization name")
230+
cobra.CheckErr(viper.BindPFlag(confOptions.organization.viperKey, rootCmd.PersistentFlags().Lookup(confOptions.organization.flagName)))
231+
192232
rootCmd.AddCommand(newWorkflowCmd(), newAuthCmd(), NewVersionCmd(),
193233
newAttestationCmd(), newArtifactCmd(), newConfigCmd(),
194234
newIntegrationCmd(), newOrganizationCmd(), newCASBackendCmd(),
@@ -453,3 +493,9 @@ func hashControlPlaneURL() string {
453493
func apiInsecure() bool {
454494
return viper.GetBool(confOptions.insecure.viperKey)
455495
}
496+
497+
// setLocalOrganization updates the local organization configuration
498+
func setLocalOrganization(orgName string) error {
499+
viper.Set(confOptions.organization.viperKey, orgName)
500+
return viper.WriteConfig()
501+
}

app/cli/internal/action/membership_list.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ type OrgItem struct {
3434

3535
type MembershipItem struct {
3636
ID string `json:"id"`
37-
Current bool `json:"current"`
37+
Default bool `json:"current"`
3838
CreatedAt *time.Time `json:"joinedAt"`
3939
UpdatedAt *time.Time `json:"updatedAt"`
4040
Org *OrgItem
@@ -96,7 +96,7 @@ func pbMembershipItemToAction(in *pb.OrgMembershipItem) *MembershipItem {
9696
CreatedAt: toTimePtr(in.GetCreatedAt().AsTime()),
9797
UpdatedAt: toTimePtr(in.GetCreatedAt().AsTime()),
9898
Org: pbOrgItemToAction(in.Org),
99-
Current: in.Current,
99+
Default: in.Current,
100100
Role: pbRoleToString(in.Role),
101101
User: pbUserItemToAction(in.User),
102102
}

app/cli/main.go

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,9 @@ import (
3434
func main() {
3535
// Couldn't find an easier way to disable the timestamp
3636
logger := zerolog.New(zerolog.ConsoleWriter{Out: os.Stderr, FormatTimestamp: func(interface{}) string { return "" }})
37-
rootCmd := cmd.NewRootCmd(logger)
3837

3938
// Run the command
40-
if err := rootCmd.Execute(); err != nil {
39+
if err := cmd.Execute(logger); err != nil {
4140
msg, exitCode := errorInfo(err, logger)
4241
logger.Error().Msg(msg)
4342
os.Exit(exitCode)
@@ -90,8 +89,8 @@ func errorInfo(err error, logger zerolog.Logger) (string, int) {
9089
msg = "your authentication token has expired, please run chainloop auth login again"
9190
case isWrappedErr(st, jwtMiddleware.ErrMissingJwtToken):
9291
msg = "authentication required, please run \"chainloop auth login\""
93-
case v1.IsUserWithNoMembershipErrorNotInOrg(err):
94-
msg = cmd.UserWithNoOrganizationMsg
92+
case v1.IsUserNotMemberOfOrgErrorNotInOrg(err), v1.IsUserWithNoMembershipErrorNotInOrg(err):
93+
msg = "the organization you are trying to access does not exist, please run \"chainloop auth login\""
9594
case errors.As(err, &cmd.GracefulError{}):
9695
// Graceful recovery if the flag is set and the received error is marked as recoverable
9796
if cmd.GracefulExit {

0 commit comments

Comments
 (0)