-
Notifications
You must be signed in to change notification settings - Fork 38
feat(groups): Allow group membership management #2142
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
Changes from all commits
e4704f8
df150d3
5c8a978
7d4871a
9f65b4a
c306f15
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,33 @@ | ||
// | ||
// Copyright 2025 The Chainloop 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 cmd | ||
|
||
import ( | ||
"github.com/spf13/cobra" | ||
) | ||
|
||
func newGroupCmd() *cobra.Command { | ||
cmd := &cobra.Command{ | ||
Use: "group", | ||
Aliases: []string{"groups", "grp", "g"}, | ||
Short: "Group management", | ||
Hidden: true, | ||
} | ||
|
||
cmd.AddCommand(newGroupCreateCmd(), newGroupDescribeCmd(), newGroupUpdateCmd(), newGroupListCmd(), newGroupDeleteCmd(), newGroupMembersCmd()) | ||
|
||
return cmd | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,80 @@ | ||
// | ||
// Copyright 2025 The Chainloop 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 cmd | ||
|
||
import ( | ||
"fmt" | ||
|
||
"github.com/chainloop-dev/chainloop/app/cli/internal/action" | ||
|
||
"github.com/jedib0t/go-pretty/v6/table" | ||
"github.com/spf13/cobra" | ||
) | ||
|
||
func newGroupCreateCmd() *cobra.Command { | ||
var name, description string | ||
|
||
cmd := &cobra.Command{ | ||
Use: "create", | ||
Short: "Create a new group", | ||
Example: ` chainloop group create --name [groupName] | ||
|
||
# With a description | ||
chainloop group create --name developers --description "Group for developers" | ||
`, | ||
RunE: func(cmd *cobra.Command, _ []string) error { | ||
resp, err := action.NewGroupCreate(actionOpts).Run(cmd.Context(), name, description) | ||
if err != nil { | ||
return err | ||
} | ||
|
||
// Print the group details | ||
if err := encodeOutput(resp, groupItemTableOutput); err != nil { | ||
return fmt.Errorf("failed to print group: %w", err) | ||
} | ||
|
||
logger.Info().Msg("Group created successfully") | ||
|
||
return nil | ||
}, | ||
} | ||
|
||
cmd.Flags().StringVar(&name, "name", "", "group name") | ||
err := cmd.MarkFlagRequired("name") | ||
cobra.CheckErr(err) | ||
cmd.Flags().StringVar(&description, "description", "", "group description") | ||
cmd.Flags().SortFlags = false | ||
|
||
return cmd | ||
} | ||
|
||
// Format function for group output | ||
func groupItemTableOutput(data *action.GroupCreateItem) error { | ||
t := newTableWriter() | ||
|
||
t.AppendHeader(table.Row{"ID", "Name", "Description", "Created At", "Updated At"}) | ||
t.AppendRow(table.Row{ | ||
data.ID, | ||
data.Name, | ||
data.Description, | ||
data.CreatedAt, | ||
data.UpdatedAt, | ||
}) | ||
|
||
t.Render() | ||
|
||
return nil | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,59 @@ | ||
// | ||
// Copyright 2025 The Chainloop 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 cmd | ||
|
||
import ( | ||
"fmt" | ||
|
||
"github.com/chainloop-dev/chainloop/app/cli/internal/action" | ||
"github.com/spf13/cobra" | ||
) | ||
|
||
func newGroupDeleteCmd() *cobra.Command { | ||
var groupName string | ||
var force bool | ||
|
||
cmd := &cobra.Command{ | ||
Use: "delete", | ||
Short: "Delete a group", | ||
RunE: func(cmd *cobra.Command, _ []string) error { | ||
ctx := cmd.Context() | ||
|
||
// Ask for confirmation, unless the --yes flag is set | ||
if !flagYes { | ||
logger.Warn().Msgf("Are you sure you want to delete the group '%s'?", groupName) | ||
|
||
if err := confirmDeletion(); err != nil { | ||
return err | ||
} | ||
} | ||
|
||
if err := action.NewGroupDelete(actionOpts).Run(ctx, groupName); err != nil { | ||
return fmt.Errorf("removing group: %w", err) | ||
} | ||
|
||
logger.Info().Msgf("Group '%s' has been removed", groupName) | ||
return nil | ||
}, | ||
} | ||
|
||
cmd.Flags().StringVar(&groupName, "name", "", "Name of the group to remove") | ||
cmd.Flags().BoolVar(&force, "force", false, "Skip confirmation prompt") | ||
err := cmd.MarkFlagRequired("name") | ||
cobra.CheckErr(err) | ||
|
||
return cmd | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,51 @@ | ||
// | ||
// Copyright 2025 The Chainloop 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 cmd | ||
|
||
import ( | ||
"fmt" | ||
|
||
"github.com/chainloop-dev/chainloop/app/cli/internal/action" | ||
"github.com/spf13/cobra" | ||
) | ||
|
||
func newGroupDescribeCmd() *cobra.Command { | ||
var groupName string | ||
|
||
cmd := &cobra.Command{ | ||
Use: "describe", | ||
Short: "Get detailed information about a specific group", | ||
RunE: func(cmd *cobra.Command, _ []string) error { | ||
group, err := action.NewGroupDescribe(actionOpts).Run(cmd.Context(), groupName) | ||
if err != nil { | ||
return fmt.Errorf("describing group: %w", err) | ||
} | ||
|
||
// Print the group details | ||
if err := encodeOutput(group, groupItemTableOutput); err != nil { | ||
return fmt.Errorf("failed to print group: %w", err) | ||
} | ||
|
||
return nil | ||
}, | ||
} | ||
|
||
cmd.Flags().StringVar(&groupName, "name", "", "Name of the group to describe") | ||
err := cmd.MarkFlagRequired("name") | ||
cobra.CheckErr(err) | ||
|
||
return cmd | ||
} |
Original file line number | Diff line number | Diff line change | ||
---|---|---|---|---|
@@ -0,0 +1,158 @@ | ||||
// | ||||
// Copyright 2025 The Chainloop 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 cmd | ||||
|
||||
import ( | ||||
"fmt" | ||||
"time" | ||||
|
||||
"github.com/chainloop-dev/chainloop/app/cli/cmd/options" | ||||
"github.com/chainloop-dev/chainloop/app/cli/internal/action" | ||||
"github.com/jedib0t/go-pretty/v6/table" | ||||
"github.com/spf13/cobra" | ||||
) | ||||
|
||||
func newGroupListCmd() *cobra.Command { | ||||
var paginationOpts = &options.OffsetPaginationOpts{} | ||||
var groupName, description, memberEmail string | ||||
|
||||
cmd := &cobra.Command{ | ||||
Use: "list", | ||||
Aliases: []string{"ls"}, | ||||
Short: "List existing Groups", | ||||
Example: ` # Let the default pagination apply | ||||
chainloop group list | ||||
|
||||
# Specify the page and page size | ||||
chainloop group list --page 2 --limit 10 | ||||
|
||||
# Output in json format to paginate using scripts | ||||
chainloop group list --page 2 --limit 10 --output json | ||||
|
||||
# Filter by group name | ||||
chainloop group list --group-name developers | ||||
|
||||
# Filter by description | ||||
chainloop group list --description "team members" | ||||
|
||||
# Filter by member email | ||||
chainloop group list --member-email user@example.com | ||||
|
||||
# Show the full report | ||||
chainloop group list --full | ||||
`, | ||||
PreRunE: func(_ *cobra.Command, _ []string) error { | ||||
if paginationOpts.Page < 1 { | ||||
return fmt.Errorf("--page must be greater or equal than 1") | ||||
} | ||||
if paginationOpts.Limit < 1 { | ||||
return fmt.Errorf("--limit must be greater or equal than 1") | ||||
} | ||||
|
||||
return nil | ||||
}, | ||||
RunE: func(cmd *cobra.Command, _ []string) error { | ||||
filterOpts := &action.GroupListFilterOpts{ | ||||
GroupName: groupName, | ||||
Description: description, | ||||
MemberEmail: memberEmail, | ||||
} | ||||
|
||||
res, err := action.NewGroupList(actionOpts).Run(cmd.Context(), paginationOpts.Page, paginationOpts.Limit, filterOpts) | ||||
if err != nil { | ||||
return err | ||||
} | ||||
|
||||
if err := encodeOutput(res, GroupListTableOutput); err != nil { | ||||
return err | ||||
} | ||||
|
||||
pgResponse := res.Pagination | ||||
|
||||
if pgResponse.TotalPages >= paginationOpts.Page { | ||||
inPage := min(paginationOpts.Limit, len(res.Groups)) | ||||
lowerBound := (paginationOpts.Page - 1) * paginationOpts.Limit | ||||
logger.Info().Msg(fmt.Sprintf("Showing [%d-%d] out of %d", lowerBound+1, lowerBound+inPage, pgResponse.TotalCount)) | ||||
} | ||||
|
||||
if pgResponse.TotalCount > pgResponse.Page*pgResponse.PageSize { | ||||
logger.Info().Msg(fmt.Sprintf("Next page available: %d", pgResponse.Page+1)) | ||||
} | ||||
|
||||
return nil | ||||
}, | ||||
} | ||||
|
||||
cmd.Flags().BoolVar(&full, "full", false, "show the full report") | ||||
cmd.Flags().StringVar(&groupName, "group-name", "", "filter by group name") | ||||
cmd.Flags().StringVar(&description, "description", "", "filter by description") | ||||
cmd.Flags().StringVar(&memberEmail, "member-email", "", "filter by member email") | ||||
paginationOpts.AddFlags(cmd) | ||||
|
||||
return cmd | ||||
} | ||||
|
||||
func GroupListTableOutput(groupListResult *action.GroupListResult) error { | ||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. it might not need to be exposed |
||||
if len(groupListResult.Groups) == 0 { | ||||
fmt.Println("there are no groups yet") | ||||
return nil | ||||
} | ||||
|
||||
headerRow := table.Row{"ID", "Name", "Description", "Created At"} | ||||
headerRowFull := table.Row{"ID", "Name", "Description", "Created At", "Updated At"} | ||||
|
||||
t := newTableWriter() | ||||
if full { | ||||
t.AppendHeader(headerRowFull) | ||||
} else { | ||||
t.AppendHeader(headerRow) | ||||
} | ||||
|
||||
for _, g := range groupListResult.Groups { | ||||
var row table.Row | ||||
|
||||
if !full { | ||||
row = table.Row{ | ||||
g.ID, g.Name, g.Description, | ||||
formatTime(g.CreatedAt), | ||||
} | ||||
} else { | ||||
row = table.Row{ | ||||
g.ID, g.Name, g.Description, | ||||
formatTime(g.CreatedAt), formatTime(g.UpdatedAt), | ||||
} | ||||
} | ||||
|
||||
t.AppendRow(row) | ||||
} | ||||
t.Render() | ||||
|
||||
return nil | ||||
} | ||||
|
||||
// Helper function to format time string | ||||
func formatTime(timeStr string) string { | ||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this doesn't exist already? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I haven't seen it, we parse the time individually, see the example of chainloop/app/cli/cmd/workflow_list.go Line 128 in e461bc7
|
||||
if timeStr == "" { | ||||
return "" | ||||
} | ||||
|
||||
t, err := time.Parse(time.RFC3339, timeStr) | ||||
if err != nil { | ||||
return timeStr | ||||
} | ||||
|
||||
return t.Format(time.RFC822) | ||||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
is force used?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think it is from a previous implementation, removing it.