diff --git a/cmd/harbor/root/project/cmd.go b/cmd/harbor/root/project/cmd.go index b9987cca..d45df6fb 100644 --- a/cmd/harbor/root/project/cmd.go +++ b/cmd/harbor/root/project/cmd.go @@ -31,6 +31,7 @@ func Project() *cobra.Command { ViewCommand(), LogsProjectCommmand(), SearchProjectCommand(), + Robot(), ) return cmd diff --git a/cmd/harbor/root/project/robot.go b/cmd/harbor/root/project/robot.go new file mode 100644 index 00000000..9d1d2bec --- /dev/null +++ b/cmd/harbor/root/project/robot.go @@ -0,0 +1,24 @@ +package project + +import ( + "github.com/goharbor/harbor-cli/cmd/harbor/root/project/robot" + "github.com/spf13/cobra" +) + +func Robot() *cobra.Command { + cmd := &cobra.Command{ + Use: "robot", + Short: "Manage robot accounts", + Example: ` harbor project robot list`, + } + cmd.AddCommand( + robot.ListRobotCommand(), + robot.DeleteRobotCommand(), + robot.ViewRobotCommand(), + robot.CreateRobotCommand(), + robot.UpdateRobotCommand(), + robot.RefreshSecretCommand(), + ) + + return cmd +} diff --git a/cmd/harbor/root/project/robot/create.go b/cmd/harbor/root/project/robot/create.go new file mode 100644 index 00000000..fc9ca036 --- /dev/null +++ b/cmd/harbor/root/project/robot/create.go @@ -0,0 +1,106 @@ +package robot + +import ( + "fmt" + + "github.com/atotto/clipboard" + "github.com/goharbor/go-client/pkg/sdk/v2.0/models" + "github.com/goharbor/harbor-cli/pkg/api" + "github.com/goharbor/harbor-cli/pkg/prompt" + "github.com/goharbor/harbor-cli/pkg/utils" + "github.com/goharbor/harbor-cli/pkg/views/robot/create" + log "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +func CreateRobotCommand() *cobra.Command { + var ( + opts create.CreateView + projectName string + all bool + ) + + cmd := &cobra.Command{ + Use: "create", + Short: "create robot", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + var err error + + if opts.ProjectName == "" { + opts.ProjectName = prompt.GetProjectNameFromUser() + if opts.ProjectName == "" { + log.Fatalf("Project Name Cannot be empty") + } + } + + if len(args) == 0 { + if opts.Name == "" || opts.Duration == 0 { + create.CreateRobotView(&opts) + } + permissions := []models.Permission{} + + if all { + perms, _ := api.GetPermissions() + permission := perms.Payload.Project + + choices := []models.Permission{} + for _, perm := range permission { + choices = append(choices, *perm) + } + permissions = choices + } else { + permissions = prompt.GetRobotPermissionsFromUser() + } + + // []Permission to []*Access + var accesses []*models.Access + for _, perm := range permissions { + access := &models.Access{ + Action: perm.Action, + Resource: perm.Resource, + } + accesses = append(accesses, access) + } + // convert []models.permission to []*model.Access + perm := &create.RobotPermission{ + Namespace: projectName, + Access: accesses, + } + opts.Permissions = []*create.RobotPermission{perm} + } + response, err := api.CreateRobot(opts, "project") + if err != nil { + log.Fatalf("failed to create robot: %v", err) + } + + FormatFlag := viper.GetString("output-format") + if FormatFlag != "" { + name := response.Payload.Name + res, _ := api.GetRobot(response.Payload.ID) + utils.SavePayloadJSON(name, res.Payload) + return + } + + name, secret := response.Payload.Name, response.Payload.Secret + create.CreateRobotSecretView(name, secret) + err = clipboard.WriteAll(response.Payload.Secret) + fmt.Println("secret copied to clipboard.") + }, + } + flags := cmd.Flags() + flags.BoolVarP( + &all, + "all-permission", + "a", + false, + "Select all permissions for the robot account", + ) + flags.StringVarP(&opts.ProjectName, "project", "", "", "set project name") + flags.StringVarP(&opts.Name, "name", "", "", "name of the robot account") + flags.StringVarP(&opts.Description, "description", "", "", "description of the robot account") + flags.Int64VarP(&opts.Duration, "duration", "", 0, "set expiration of robot account in days") + + return cmd +} diff --git a/cmd/harbor/root/project/robot/delete.go b/cmd/harbor/root/project/robot/delete.go new file mode 100644 index 00000000..d2641fe9 --- /dev/null +++ b/cmd/harbor/root/project/robot/delete.go @@ -0,0 +1,41 @@ +package robot + +import ( + "strconv" + + "github.com/goharbor/harbor-cli/pkg/api" + "github.com/goharbor/harbor-cli/pkg/prompt" + log "github.com/sirupsen/logrus" + + "github.com/spf13/cobra" +) + +// to-do improve DeleteRobotCommand and multi select & delete +func DeleteRobotCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "delete [robotID]", + Short: "delete robot by id", + Args: cobra.MaximumNArgs(1), + Run: func(cmd *cobra.Command, args []string) { + var ( + robotID int64 + err error + ) + if len(args) == 1 { + robotID, err = strconv.ParseInt(args[0], 10, 64) + if err != nil { + log.Fatalf("failed to parse robot ID: %v", err) + } + } else { + projectID := prompt.GetProjectIDFromUser() + robotID = prompt.GetRobotIDFromUser(projectID) + } + err = api.DeleteRobot(robotID) + if err != nil { + log.Fatalf("failed to Delete robots") + } + }, + } + + return cmd +} diff --git a/cmd/harbor/root/project/robot/list.go b/cmd/harbor/root/project/robot/list.go new file mode 100644 index 00000000..db1b3fd7 --- /dev/null +++ b/cmd/harbor/root/project/robot/list.go @@ -0,0 +1,63 @@ +package robot + +import ( + "log" + "strconv" + + "github.com/goharbor/harbor-cli/pkg/api" + "github.com/goharbor/harbor-cli/pkg/constants" + "github.com/goharbor/harbor-cli/pkg/prompt" + "github.com/goharbor/harbor-cli/pkg/utils" + "github.com/goharbor/harbor-cli/pkg/views/robot/list" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +// ListRobotCommand creates a new `harbor project robot list` command +func ListRobotCommand() *cobra.Command { + var opts api.ListFlags + + projectQString := constants.ProjectQString + cmd := &cobra.Command{ + Use: "list [projectID]", + Short: "list robot", + Args: cobra.MaximumNArgs(1), + Run: func(cmd *cobra.Command, args []string) { + if len(args) > 0 { + opts.Q = projectQString + args[0] + } else if opts.Q != "" { + opts.Q = projectQString + opts.Q + } else { + projectID := prompt.GetProjectIDFromUser() + opts.Q = projectQString + strconv.FormatInt(projectID, 10) + } + + robots, err := api.ListRobot(opts) + if err != nil { + log.Fatalf("failed to get robots list: %v", err) + } + + FormatFlag := viper.GetString("output-format") + if FormatFlag != "" { + utils.PrintPayloadInJSONFormat(robots) + return + } + + list.ListRobots(robots.Payload) + }, + } + + flags := cmd.Flags() + flags.Int64VarP(&opts.Page, "page", "", 1, "Page number") + flags.Int64VarP(&opts.PageSize, "page-size", "", 10, "Size of per page") + flags.StringVarP(&opts.Q, "query", "q", "", "Query string to query resources") + flags.StringVarP( + &opts.Sort, + "sort", + "", + "", + "Sort the resource list in ascending or descending order", + ) + + return cmd +} diff --git a/cmd/harbor/root/project/robot/refresh.go b/cmd/harbor/root/project/robot/refresh.go new file mode 100644 index 00000000..46321e91 --- /dev/null +++ b/cmd/harbor/root/project/robot/refresh.go @@ -0,0 +1,87 @@ +package robot + +import ( + "fmt" + "strconv" + + "github.com/atotto/clipboard" + "github.com/goharbor/harbor-cli/pkg/api" + "github.com/goharbor/harbor-cli/pkg/prompt" + "github.com/goharbor/harbor-cli/pkg/utils" + "github.com/goharbor/harbor-cli/pkg/views/robot/create" + log "github.com/sirupsen/logrus" + + "github.com/spf13/cobra" +) + +func RefreshSecretCommand() *cobra.Command { + var ( + robotID int64 + secret string + secretStdin bool + ) + cmd := &cobra.Command{ + Use: "refresh [robotID]", + Short: "refresh robot secret by id", + Args: cobra.MaximumNArgs(1), + Run: func(cmd *cobra.Command, args []string) { + var err error + if len(args) == 1 { + robotID, err = strconv.ParseInt(args[0], 10, 64) + if err != nil { + log.Fatalf("failed to parse robot ID: %v", err) + } + } else { + projectID := prompt.GetProjectIDFromUser() + robotID = prompt.GetRobotIDFromUser(projectID) + } + + if secret != "" { + err = utils.ValidatePassword(secret) + if err != nil { + log.Fatalf("Invalid secret: %v\n", err) + } + } + if secretStdin { + secret = getSecret() + } + + response, err := api.RefreshSecret(secret, robotID) + if err != nil { + log.Fatalf("failed to refresh robot secret: %v\n", err) + } + + log.Info("Secret updated successfully.") + + if response.Payload.Secret != "" { + secret = response.Payload.Secret + create.CreateRobotSecretView("", secret) + + err = clipboard.WriteAll(response.Payload.Secret) + if err != nil { + log.Fatalf("failed to write the secret to the clipboard: %v", err) + } + fmt.Println("secret copied to clipboard.") + } + }, + } + + flags := cmd.Flags() + flags.StringVarP(&secret, "secret", "", "", "secret") + flags.BoolVarP(&secretStdin, "secret-stdin", "", false, "Take the robot secret from stdin") + + return cmd +} + +// getSecret from commandline +func getSecret() string { + secret, err := utils.GetSecretStdin("Enter your secret: ") + if err != nil { + log.Fatalf("Error reading secret: %v\n", err) + } + + if err := utils.ValidatePassword(secret); err != nil { + log.Fatalf("Invalid secret: %v\n", err) + } + return secret +} diff --git a/cmd/harbor/root/project/robot/update.go b/cmd/harbor/root/project/robot/update.go new file mode 100644 index 00000000..896062cf --- /dev/null +++ b/cmd/harbor/root/project/robot/update.go @@ -0,0 +1,120 @@ +package robot + +import ( + "strconv" + + "github.com/goharbor/go-client/pkg/sdk/v2.0/models" + "github.com/goharbor/harbor-cli/pkg/api" + "github.com/goharbor/harbor-cli/pkg/prompt" + "github.com/goharbor/harbor-cli/pkg/views/robot/update" + log "github.com/sirupsen/logrus" + + "github.com/spf13/cobra" +) + +func UpdateRobotCommand() *cobra.Command { + var ( + robotID int64 + opts update.UpdateView + all bool + ) + + cmd := &cobra.Command{ + Use: "update [robotID]", + Short: "update robot by id", + Args: cobra.MaximumNArgs(1), + Run: func(cmd *cobra.Command, args []string) { + var err error + if len(args) == 1 { + robotID, err = strconv.ParseInt(args[0], 10, 64) + if err != nil { + log.Fatalf("failed to parse robot ID: %v", err) + } + + } else { + projectID := prompt.GetProjectIDFromUser() + robotID = prompt.GetRobotIDFromUser(projectID) + } + + robot, err := api.GetRobot(robotID) + if err != nil { + log.Fatalf("failed to get robot: %v", err) + } + + bot := robot.Payload + + opts = update.UpdateView{ + CreationTime: bot.CreationTime, + Description: bot.Description, + Disable: bot.Disable, + Duration: bot.Duration, + Editable: bot.Editable, + ID: bot.ID, + Level: bot.Level, + Name: bot.Name, + Secret: bot.Secret, + } + + // declare empty permissions to hold permissions + permissions := []models.Permission{} + + if all { + perms, _ := api.GetPermissions() + permission := perms.Payload.Project + + choices := []models.Permission{} + for _, perm := range permission { + choices = append(choices, *perm) + } + permissions = choices + } else { + permissions = prompt.GetRobotPermissionsFromUser() + } + + // []Permission to []*Access + var accesses []*models.Access + for _, perm := range permissions { + access := &models.Access{ + Action: perm.Action, + Resource: perm.Resource, + } + accesses = append(accesses, access) + } + // convert []models.permission to []*model.Access + perm := &update.RobotPermission{ + Kind: bot.Permissions[0].Kind, + Namespace: bot.Permissions[0].Namespace, + Access: accesses, + } + opts.Permissions = []*update.RobotPermission{perm} + + err = updateRobotView(&opts) + if err != nil { + log.Fatalf("failed to Update robot: %v", err) + } + }, + } + + flags := cmd.Flags() + flags.BoolVarP( + &all, + "all-permission", + "a", + false, + "Select all permissions for the robot account", + ) + flags.StringVarP(&opts.Name, "name", "", "", "name of the robot account") + flags.StringVarP(&opts.Description, "description", "", "", "description of the robot account") + flags.Int64VarP(&opts.Duration, "duration", "", 0, "set expiration of robot account in days") + + return cmd +} + +func updateRobotView(updateView *update.UpdateView) error { + if updateView == nil { + updateView = &update.UpdateView{} + } + + update.UpdateRobotView(updateView) + return api.UpdateRobot(updateView) +} diff --git a/cmd/harbor/root/project/robot/view.go b/cmd/harbor/root/project/robot/view.go new file mode 100644 index 00000000..e3ac7cff --- /dev/null +++ b/cmd/harbor/root/project/robot/view.go @@ -0,0 +1,50 @@ +package robot + +import ( + "strconv" + + "github.com/goharbor/go-client/pkg/sdk/v2.0/client/robot" + "github.com/goharbor/go-client/pkg/sdk/v2.0/models" + "github.com/goharbor/harbor-cli/pkg/api" + "github.com/goharbor/harbor-cli/pkg/prompt" + "github.com/goharbor/harbor-cli/pkg/views/robot/list" + log "github.com/sirupsen/logrus" + + "github.com/spf13/cobra" +) + +func ViewRobotCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "view [robotID]", + Short: "get robot by id", + Args: cobra.MaximumNArgs(1), + Run: func(cmd *cobra.Command, args []string) { + var ( + robot *robot.GetRobotByIDOK + robotID int64 + err error + ) + + if len(args) == 1 { + robotID, err = strconv.ParseInt(args[0], 10, 64) + if err != nil { + log.Fatalf("failed to parse robot ID: %v", err) + } + } else { + projectID := prompt.GetProjectIDFromUser() + robotID = prompt.GetRobotIDFromUser(projectID) + } + + robot, err = api.GetRobot(robotID) + if err != nil { + log.Fatalf("failed to get robot: %v", err) + } + + // Convert to a list and display + robots := []*models.Robot{robot.Payload} + list.ListRobots(robots) + }, + } + + return cmd +} diff --git a/pkg/api/robot_handler.go b/pkg/api/robot_handler.go new file mode 100644 index 00000000..287eb140 --- /dev/null +++ b/pkg/api/robot_handler.go @@ -0,0 +1,184 @@ +package api + +import ( + "github.com/goharbor/go-client/pkg/sdk/v2.0/client/permissions" + "github.com/goharbor/go-client/pkg/sdk/v2.0/client/robot" + "github.com/goharbor/go-client/pkg/sdk/v2.0/models" + "github.com/goharbor/harbor-cli/pkg/utils" + "github.com/goharbor/harbor-cli/pkg/views/robot/create" + "github.com/goharbor/harbor-cli/pkg/views/robot/update" + log "github.com/sirupsen/logrus" +) + +func ListRobot(opts ListFlags) (*robot.ListRobotOK, error) { + ctx, client, err := utils.ContextWithClient() + if err != nil { + return nil, err + } + + response, err := client.Robot.ListRobot( + ctx, + &robot.ListRobotParams{ + Page: &opts.Page, + PageSize: &opts.PageSize, + Q: &opts.Q, + Sort: &opts.Sort, + }, + ) + if err != nil { + return nil, err + } + + return response, nil +} + +func GetRobot(robotID int64) (*robot.GetRobotByIDOK, error) { + ctx, client, err := utils.ContextWithClient() + if err != nil { + return nil, err + } + response, err := client.Robot.GetRobotByID(ctx, &robot.GetRobotByIDParams{RobotID: robotID}) + if err != nil { + return nil, err + } + + return response, nil +} + +func DeleteRobot(robotID int64) error { + ctx, client, err := utils.ContextWithClient() + if err != nil { + return err + } + _, err = client.Robot.DeleteRobot(ctx, &robot.DeleteRobotParams{RobotID: robotID}) + if err != nil { + return err + } + + log.Info("robot deleted successfully") + + return nil +} + +func CreateRobot(opts create.CreateView, kind string) (*robot.CreateRobotCreated, error) { + ctx, client, err := utils.ContextWithClient() + if err != nil { + return nil, err + } + + // Create a slice to store converted permissions + permissions := opts.Permissions + convertedPerms := make([]*models.RobotPermission, 0, len(permissions)) + + // Loop through original permissions and convert them + for _, perm := range permissions { + convertedPerm := &models.RobotPermission{ + Access: perm.Access, + Kind: kind, + Namespace: opts.ProjectName, + } + convertedPerms = append(convertedPerms, convertedPerm) + } + response, err := client.Robot.CreateRobot( + ctx, + &robot.CreateRobotParams{ + Robot: &models.RobotCreate{ + Description: opts.Description, + Disable: false, + Duration: opts.Duration, + Level: kind, + Name: opts.Name, + Permissions: convertedPerms, + }, + }, + ) + if err != nil { + return nil, err + } + + log.Info("robot created successfully.") + return response, nil +} + +// update robot with robotID +func UpdateRobot(opts *update.UpdateView) error { + ctx, client, err := utils.ContextWithClient() + if err != nil { + log.Errorf("Error: %v", err) + return err + } + + // Create a slice to store converted permissions + permissions := opts.Permissions + convertedPerms := make([]*models.RobotPermission, 0, len(permissions)) + + // Loop through original permissions and convert them + for _, perm := range permissions { + convertedPerm := &models.RobotPermission{ + Access: perm.Access, + Kind: opts.Permissions[0].Kind, + Namespace: opts.Permissions[0].Namespace, + } + convertedPerms = append(convertedPerms, convertedPerm) + } + _, err = client.Robot.UpdateRobot( + ctx, + &robot.UpdateRobotParams{ + Robot: &models.Robot{ + Description: opts.Description, + Duration: opts.Duration, + Editable: opts.Editable, + Disable: opts.Disable, + ID: opts.ID, + Level: opts.Level, + Name: opts.Name, + Permissions: convertedPerms, + }, + RobotID: opts.ID, + }, + ) + if err != nil { + log.Errorf("Error in updating Robot: %v", err) + return err + } + + log.Info("robot updated successfully.") + return nil +} + +func GetPermissions() (*permissions.GetPermissionsOK, error) { + ctx, client, err := utils.ContextWithClient() + if err != nil { + return nil, err + } + response, err := client.Permissions.GetPermissions( + ctx, + &permissions.GetPermissionsParams{}, + ) + if err != nil { + return nil, err + } + + return response, nil +} + +func RefreshSecret(secret string, robotID int64) (*robot.RefreshSecOK, error) { + ctx, client, err := utils.ContextWithClient() + if err != nil { + return nil, err + } + + robotSec := &models.RobotSec{ + Secret: secret, + } + + response, err := client.Robot.RefreshSec(ctx, &robot.RefreshSecParams{ + RobotSec: robotSec, + RobotID: robotID, + }) + if err != nil { + return nil, err + } + + return response, nil +} diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go index 39557532..ceca02c6 100644 --- a/pkg/constants/constants.go +++ b/pkg/constants/constants.go @@ -12,3 +12,10 @@ // See the License for the specific language governing permissions and // limitations under the License. package constants + +const ( + HarborCredentialName = "HARBORCREDENTIALNAME" + CredentialNameOption = "credential-name" + CredentialNameHelp = "Name of the credential to use for authentication (defaults to the current logged in session)" + ProjectQString = "Level%3Dproject%2CProjectID%3D" +) diff --git a/pkg/prompt/prompt.go b/pkg/prompt/prompt.go index 10dfc217..acc412cc 100644 --- a/pkg/prompt/prompt.go +++ b/pkg/prompt/prompt.go @@ -14,13 +14,18 @@ package prompt import ( + "strconv" + + "github.com/goharbor/go-client/pkg/sdk/v2.0/models" "github.com/goharbor/harbor-cli/pkg/api" + "github.com/goharbor/harbor-cli/pkg/constants" aview "github.com/goharbor/harbor-cli/pkg/views/artifact/select" tview "github.com/goharbor/harbor-cli/pkg/views/artifact/tags/select" lview "github.com/goharbor/harbor-cli/pkg/views/label/select" pview "github.com/goharbor/harbor-cli/pkg/views/project/select" rview "github.com/goharbor/harbor-cli/pkg/views/registry/select" repoView "github.com/goharbor/harbor-cli/pkg/views/repository/select" + robotView "github.com/goharbor/harbor-cli/pkg/views/robot/select" uview "github.com/goharbor/harbor-cli/pkg/views/user/select" log "github.com/sirupsen/logrus" ) @@ -45,6 +50,16 @@ func GetProjectNameFromUser() string { return <-projectName } +func GetProjectIDFromUser() int64 { + projectID := make(chan int64) + go func() { + response, _ := api.ListProject() + pview.ProjectListID(response.Payload, projectID) + }() + + return <-projectID +} + func GetRepoNameFromUser(projectName string) string { repositoryName := make(chan string) @@ -107,3 +122,24 @@ func GetLabelIdFromUser(opts api.ListFlags) int64 { return <-labelId } + +func GetRobotPermissionsFromUser() []models.Permission { + permissions := make(chan []models.Permission) + go func() { + response, _ := api.GetPermissions() + robotView.ListPermissions(response.Payload, permissions) + }() + return <-permissions +} + +func GetRobotIDFromUser(projectID int64) int64 { + robotID := make(chan int64) + var opts api.ListFlags + opts.Q = constants.ProjectQString + strconv.FormatInt(projectID, 10) + + go func() { + response, _ := api.ListRobot(opts) + robotView.ListRobot(response.Payload, robotID) + }() + return <-robotID +} diff --git a/pkg/utils/utils.go b/pkg/utils/utils.go index bc027c71..c543ddc2 100644 --- a/pkg/utils/utils.go +++ b/pkg/utils/utils.go @@ -16,10 +16,13 @@ package utils import ( "encoding/json" "fmt" + "os" "regexp" "strings" + "syscall" log "github.com/sirupsen/logrus" + "golang.org/x/term" "gopkg.in/yaml.v3" ) @@ -74,3 +77,29 @@ func SanitizeServerAddress(server string) string { server = re.ReplaceAllString(server, "-") return server } + +func SavePayloadJSON(filename string, payload any) { + // Marshal the payload into a JSON string with indentation + jsonStr, err := json.MarshalIndent(payload, "", " ") + if err != nil { + panic(err) + } + // Define the filename + filename = filename + ".json" + err = os.WriteFile(filename, jsonStr, 0644) + if err != nil { + panic(err) + } + fmt.Printf("JSON data has been written to %s\n", filename) +} + +// Get Password as Stdin +func GetSecretStdin(prompt string) (string, error) { + fmt.Print(prompt) + bytePassword, err := term.ReadPassword(int(syscall.Stdin)) + if err != nil { + return "", err + } + fmt.Println() // move to the next line after input + return strings.TrimSpace(string(bytePassword)), nil +} diff --git a/pkg/views/base/multiselect/model.go b/pkg/views/base/multiselect/model.go new file mode 100644 index 00000000..569591d3 --- /dev/null +++ b/pkg/views/base/multiselect/model.go @@ -0,0 +1,195 @@ +package multiselect + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/goharbor/go-client/pkg/sdk/v2.0/models" +) + +const useHighPerformanceRenderer = false + +var ( + titleStyle = func() lipgloss.Style { + b := lipgloss.RoundedBorder() + b.Right = "├" + return lipgloss.NewStyle().BorderStyle(b).Padding(0, 1) + }() + + selectedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("43")) + itemStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("46")) + blockStyle = lipgloss.NewStyle(). + Background(lipgloss.Color("81")). + Foreground(lipgloss.Color("#000000")). + Bold(true). + Padding(0, 1, 0) + + infoStyle = func() lipgloss.Style { + b := lipgloss.RoundedBorder() + b.Left = "┤" + return titleStyle.BorderStyle(b) + }() +) + +type Model struct { + content string + ready bool + viewport viewport.Model + choices []models.Permission + cursor int + selected map[int]struct{} + selects *[]models.Permission +} + +func (m Model) Init() tea.Cmd { + return nil +} + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var ( + cmd tea.Cmd + cmds []tea.Cmd + ) + + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "q", "esc": + return m, tea.Quit + case "y": + m.GetSelectedPermissions() + return m, tea.Quit + case "up", "k": + if m.cursor > 0 { + m.cursor-- + } + case "down", "j": + if m.cursor < len(m.choices)-1 { + m.cursor++ + } + case "enter", " ": + _, ok := m.selected[m.cursor] + if ok { + delete(m.selected, m.cursor) + } else { + m.selected[m.cursor] = struct{}{} + } + } + + case tea.WindowSizeMsg: + headerHeight := lipgloss.Height(m.headerView()) + footerHeight := lipgloss.Height(m.footerView()) + verticalMarginHeight := headerHeight + footerHeight + + if !m.ready { + m.viewport = viewport.New(msg.Width, msg.Height-verticalMarginHeight) + m.viewport.YPosition = headerHeight + m.viewport.HighPerformanceRendering = useHighPerformanceRenderer + m.viewport.SetContent(m.listView()) + m.ready = true + m.viewport.YPosition = headerHeight - 1 + } else { + m.viewport.Width = msg.Width + m.viewport.Height = msg.Height - verticalMarginHeight - 1 + } + + if useHighPerformanceRenderer { + cmds = append(cmds, viewport.Sync(m.viewport)) + } + } + + m.viewport.SetContent(m.listView()) + m.viewport, cmd = m.viewport.Update(msg) + cmds = append(cmds, cmd) + + return m, tea.Batch(cmds...) +} + +func (m Model) View() string { + if !m.ready { + return "\n Initializing..." + } + return fmt.Sprintf("%s\n%s\n%s", m.headerView(), m.viewport.View(), m.footerView()) +} + +func (m Model) headerView() string { + title := titleStyle.Render("Select Permissions for Robot Account") + line := strings.Repeat("─", max(0, m.viewport.Width-lipgloss.Width(title))) + return lipgloss.JoinHorizontal(lipgloss.Center, title, line) +} + +func (m Model) footerView() string { + help := lipgloss.NewStyle().Foreground(lipgloss.Color("238")).Render( + fmt.Sprint( + " up/down: navigate • ", "enter: select permissions • ", "q: quit • ", " y: confirm\t", + ), + ) + info := infoStyle.Render(fmt.Sprintf("%3.f%%", m.viewport.ScrollPercent()*100)) + line := strings.Repeat("─", max(0, m.viewport.Width-lipgloss.Width(info)-lipgloss.Width(help))) + return lipgloss.JoinHorizontal(lipgloss.Center, help, line, info) +} + +func (m Model) listView() string { + s := "Select Robot Permissions\n\n" + var prev string + for i, choice := range m.choices { + // Render the row ith appropriate action message + choiceRes := choice.Resource + choiceAct := choice.Action + now := choice.Resource + if prev != now { + prev = now + s += blockStyle.Render(prev) + s += "\n\n" + } + cursor := " " // no cursor + if m.cursor == i { + choiceRes = itemStyle.Render(choice.Resource) + choiceAct = itemStyle.Render(choice.Action) + cursor = ">" // cursor! + } + checked := " " // not selected + if _, ok := m.selected[i]; ok { + choiceRes = selectedStyle.Render(choice.Resource) + choiceAct = selectedStyle.Render(choice.Action) + checked = "x" // selected! + } + s += fmt.Sprintf( + "%s [%s] %s %s\n\n", + cursor, + checked, + choiceAct, + choiceRes, + ) + } + s += "\nPress q to quit.\n" + + return s +} + +func (m Model) GetSelectedPermissions() *[]models.Permission { + selectedPermissions := make([]models.Permission, 0, len(m.selected)) + for index := range m.selected { + selectedPermissions = append(selectedPermissions, m.choices[index]) + } + *m.selects = selectedPermissions + return m.selects +} + +func max(a, b int) int { + if a > b { + return a + } + return b +} + +func NewModel(choices []models.Permission, selects *[]models.Permission) Model { + return Model{ + choices: choices, + selected: make(map[int]struct{}), + selects: selects, + } +} diff --git a/pkg/views/project/select/view.go b/pkg/views/project/select/view.go index 00053425..31fbdf5b 100644 --- a/pkg/views/project/select/view.go +++ b/pkg/views/project/select/view.go @@ -32,7 +32,6 @@ func ProjectList(project []*models.Project, choice chan<- string) { m := selection.NewModel(items, "Project") p, err := tea.NewProgram(m, tea.WithAltScreen()).Run() - if err != nil { fmt.Println("Error running program:", err) os.Exit(1) @@ -42,3 +41,26 @@ func ProjectList(project []*models.Project, choice chan<- string) { choice <- p.Choice } } + +func ProjectListID(project []*models.Project, choice chan<- int64) { + itemList := make([]list.Item, len(project)) + + items := map[string]int32{} + + for i, p := range project { + itemList[i] = selection.Item(p.Name) + items[p.Name] = p.ProjectID + } + + m := selection.NewModel(itemList, "Project") + + p, err := tea.NewProgram(m, tea.WithAltScreen()).Run() + if err != nil { + fmt.Println("Error running program:", err) + os.Exit(1) + } + + if p, ok := p.(selection.Model); ok { + choice <- int64(items[p.Choice]) + } +} diff --git a/pkg/views/robot/create/view.go b/pkg/views/robot/create/view.go new file mode 100644 index 00000000..1108b9eb --- /dev/null +++ b/pkg/views/robot/create/view.go @@ -0,0 +1,91 @@ +package create + +import ( + "errors" + "strconv" + + "github.com/charmbracelet/huh" + "github.com/goharbor/go-client/pkg/sdk/v2.0/models" + log "github.com/sirupsen/logrus" +) + +type CreateView struct { + Description string `json:"description,omitempty"` + Disable bool `json:"disable,omitempty"` + Duration int64 `json:"duration,omitempty"` + Level string `json:"level,omitempty"` + Name string `json:"name,omitempty"` + Permissions []*RobotPermission `json:"permissions"` + Secret string `json:"secret,omitempty"` + ProjectName string +} + +type RobotPermission struct { + Access []*models.Access `json:"access"` + Kind string `json:"kind,omitempty"` + Namespace string `json:"namespace,omitempty"` +} + +type Access struct { + Action string `json:"action,omitempty"` + Effect string `json:"effect,omitempty"` + Resource string `json:"resource,omitempty"` +} + +func CreateRobotView(createView *CreateView) { + var duration string + duration = strconv.FormatInt(createView.Duration, 10) + + theme := huh.ThemeCharm() + err := huh.NewForm( + huh.NewGroup( + huh.NewInput(). + Title("Robot Name"). + Value(&createView.Name). + Validate(func(str string) error { + if str == "" { + return errors.New("Name cannot be empty") + } + return nil + }), + huh.NewInput(). + Title("Description"). + Value(&createView.Description), + huh.NewInput(). + Title("Expiration"). + Value(&duration). + Validate(func(str string) error { + if str == "" { + return errors.New("Expiration cannot be empty") + } + dur, err := strconv.ParseInt(str, 10, 64) + if err != nil { + return errors.New("invalid expiration time: Enter expiration time in days") + } + createView.Duration = dur + return nil + }), + ), + ).WithTheme(theme).Run() + if err != nil { + log.Fatal(err) + } +} + +func CreateRobotSecretView(name string, secret string) { + theme := huh.ThemeCharm() + err := huh.NewForm( + huh.NewGroup( + huh.NewInput(). + Title("Robot Name"). + Value(&name), + huh.NewInput(). + Title("Robot Secret"). + Description("Copy the secret or press enter to copy to clipboard."). + Value(&secret), + ), + ).WithTheme(theme).Run() + if err != nil { + log.Fatal(err) + } +} diff --git a/pkg/views/robot/list/view.go b/pkg/views/robot/list/view.go new file mode 100644 index 00000000..adab0c4c --- /dev/null +++ b/pkg/views/robot/list/view.go @@ -0,0 +1,92 @@ +package list + +import ( + "fmt" + "os" + "strconv" + "time" + + "github.com/charmbracelet/bubbles/table" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/goharbor/go-client/pkg/sdk/v2.0/models" + "github.com/goharbor/harbor-cli/pkg/utils" + "github.com/goharbor/harbor-cli/pkg/views" + "github.com/goharbor/harbor-cli/pkg/views/base/tablelist" +) + +var columns = []table.Column{ + {Title: "ID", Width: 4}, + {Title: "Name", Width: 30}, + {Title: "Status", Width: 18}, + {Title: "Permissions", Width: 12}, + {Title: "Creation Time", Width: 16}, + {Title: "Expires in", Width: 14}, + {Title: "Description", Width: 12}, +} + +func ListRobots(robots []*models.Robot) { + var rows []table.Row + + for _, robot := range robots { + var enabledStatus string + var expires string + + if robot.Disable { + enabledStatus = views.RedStyle.Render("Disabled") + } else { + enabledStatus = views.GreenStyle.Render("Enabled") + } + + TotalPermissions := strconv.FormatInt(int64(len(robot.Permissions[0].Access)), 10) + + if robot.ExpiresAt == -1 { + expires = "Never" + } else { + expires = remainingTime(robot.ExpiresAt) + } + + createdTime, _ := utils.FormatCreatedTime(robot.CreationTime.String()) + rows = append(rows, table.Row{ + strconv.FormatInt(robot.ID, 10), + robot.Name, + enabledStatus, + TotalPermissions, + createdTime, + expires, + robot.Description, + }) + } + + m := tablelist.NewModel(columns, rows, len(rows)) + + if _, err := tea.NewProgram(m).Run(); err != nil { + fmt.Println("Error running program:", err) + os.Exit(1) + } +} + +func remainingTime(unixTimestamp int64) string { + // Get the current time + now := time.Now() + // Convert the Unix timestamp to time.Time + expirationTime := time.Unix(unixTimestamp, 0) + // Calculate the duration between now and the expiration time + duration := expirationTime.Sub(now) + + // Calculate days, hours, minutes, and seconds + days := int(duration.Hours() / 24) + hours := int(duration.Hours()) % 24 + minutes := int(duration.Minutes()) % 60 + + // Format the output string + return fmt.Sprintf("%dd %dh %dm", days, hours, minutes) +} + +func getStatusStyle(status string) lipgloss.Style { + statusStyle := views.RedStyle + if status == "healthy" { + statusStyle = views.GreenStyle + } + return statusStyle +} diff --git a/pkg/views/robot/select/view.go b/pkg/views/robot/select/view.go new file mode 100644 index 00000000..c99afc92 --- /dev/null +++ b/pkg/views/robot/select/view.go @@ -0,0 +1,56 @@ +package robot + +import ( + "fmt" + "os" + + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" + "github.com/goharbor/go-client/pkg/sdk/v2.0/models" + "github.com/goharbor/harbor-cli/pkg/views/base/multiselect" + "github.com/goharbor/harbor-cli/pkg/views/base/selection" +) + +func ListPermissions(perms *models.Permissions, ch chan<- []models.Permission) { + permissions := perms.Project + choices := []models.Permission{} + + // Iterate over permissions and append each item to choices + for _, perm := range permissions { + choices = append(choices, *perm) + } + + selects := &[]models.Permission{} + + m := multiselect.NewModel(choices, selects) + + _, err := tea.NewProgram(m, tea.WithAltScreen()).Run() + if err != nil { + fmt.Println("Error running program:", err) + } + // Get selected permissions + ch <- *selects +} + +func ListRobot(robots []*models.Robot, choice chan<- int64) { + itemsList := make([]list.Item, len(robots)) + + items := map[string]int64{} + + for i, r := range robots { + items[r.Name] = r.ID + itemsList[i] = selection.Item(r.Name) + } + + m := selection.NewModel(itemsList, "Robot") + + p, err := tea.NewProgram(m, tea.WithAltScreen()).Run() + if err != nil { + fmt.Println("Error running program:", err) + os.Exit(1) + } + + if p, ok := p.(selection.Model); ok { + choice <- items[p.Choice] + } +} diff --git a/pkg/views/robot/update/view.go b/pkg/views/robot/update/view.go new file mode 100644 index 00000000..a91efade --- /dev/null +++ b/pkg/views/robot/update/view.go @@ -0,0 +1,74 @@ +package update + +import ( + "errors" + "strconv" + + "github.com/charmbracelet/huh" + "github.com/go-openapi/strfmt" + "github.com/goharbor/go-client/pkg/sdk/v2.0/models" + log "github.com/sirupsen/logrus" +) + +type UpdateView struct { + CreationTime strfmt.DateTime `json:"creation_time,omitempty"` + Description string `json:"description,omitempty"` + Disable bool `json:"disable,omitempty"` + Duration int64 `json:"duration,omitempty"` + Editable bool `json:"editable"` + ExpiresAt int64 `json:"expires_at,omitempty"` + ID int64 `json:"id,omitempty"` + Level string `json:"level,omitempty"` + Name string `json:"name,omitempty"` + Permissions []*RobotPermission `json:"permissions"` + Secret string `json:"secret,omitempty"` + UpdateTime strfmt.DateTime `json:"update_time,omitempty"` +} + +type RobotPermission struct { + Access []*models.Access `json:"access"` + Kind string `json:"kind,omitempty"` + Namespace string `json:"namespace,omitempty"` +} + +type Access struct { + Action string `json:"action,omitempty"` + Effect string `json:"effect,omitempty"` + Resource string `json:"resource,omitempty"` +} + +func UpdateRobotView(updateView *UpdateView) { + var duration string + duration = strconv.FormatInt(updateView.Duration, 10) + + theme := huh.ThemeCharm() + err := huh.NewForm( + huh.NewGroup( + huh.NewInput(). + Title("Description"). + Value(&updateView.Description), + huh.NewInput(). + Title("Expiration"). + Value(&duration). + Validate(func(str string) error { + if str == "" { + return errors.New("Expiration cannot be empty") + } + dur, err := strconv.ParseInt(str, 10, 64) + if err != nil { + return errors.New("invalid expiration time: Enter expiration time in days") + } + updateView.Duration = dur + return nil + }), + huh.NewConfirm(). + Title("Disable"). + Value(&updateView.Disable). + Affirmative("yes"). + Negative("no"), + ), + ).WithTheme(theme).Run() + if err != nil { + log.Fatal(err) + } +} diff --git a/pkg/views/styles.go b/pkg/views/styles.go index 1f0ab7b5..e86dfc4f 100644 --- a/pkg/views/styles.go +++ b/pkg/views/styles.go @@ -26,6 +26,7 @@ var ( HelpStyle = list.DefaultStyles().HelpStyle.PaddingLeft(4).PaddingBottom(1) RedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF0000")) GreenStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#04B575")) + WhiteStyle = list.DefaultStyles() ) var BaseStyle = lipgloss.NewStyle().