diff --git a/.github/dependabot.yml b/.github/dependabot.yml index d8adc8527..442087c8b 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,6 +1,11 @@ version: 2 updates: - - package-ecosystem: gomod + - package-ecosystem: "gomod" directory: "/" schedule: - interval: weekly + interval: "weekly" + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" diff --git a/MAINTAINERS.md b/MAINTAINERS.md new file mode 100644 index 000000000..98ba7ea19 --- /dev/null +++ b/MAINTAINERS.md @@ -0,0 +1,10 @@ +# Maintainers + +This file lists the current sub-project maintainers for `harbor-cli`. + +## Current maintainers + +- Vadim Bauer (`@Vad1mo`) +- Prasanth Baskar (`@bupd`) +- Patrick Eschenbach (`@qcserestipy`) +- Nucleo Fusion (`@NucleoFusion`) diff --git a/cmd/harbor/root/artifact/cmd.go b/cmd/harbor/root/artifact/cmd.go index 0a70203e8..6141b6813 100644 --- a/cmd/harbor/root/artifact/cmd.go +++ b/cmd/harbor/root/artifact/cmd.go @@ -15,6 +15,8 @@ package artifact import ( "github.com/goharbor/harbor-cli/cmd/harbor/root/artifact/label" + artifactscan "github.com/goharbor/harbor-cli/cmd/harbor/root/artifact/scan" + artifacttags "github.com/goharbor/harbor-cli/cmd/harbor/root/artifact/tags" "github.com/spf13/cobra" ) @@ -30,8 +32,8 @@ func Artifact() *cobra.Command { ListArtifactCommand(), ViewArtifactCommmand(), DeleteArtifactCommand(), - ScanArtifactCommand(), - ArtifactTagsCmd(), + artifactscan.ScanArtifactCommand(), + artifacttags.ArtifactTagsCmd(), label.LabelsArtifactCommmand(), ) diff --git a/cmd/harbor/root/artifact/scan.go b/cmd/harbor/root/artifact/scan.go deleted file mode 100644 index 4040e62f7..000000000 --- a/cmd/harbor/root/artifact/scan.go +++ /dev/null @@ -1,107 +0,0 @@ -// Copyright Project Harbor 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 artifact - -import ( - "fmt" - - "github.com/goharbor/harbor-cli/pkg/api" - "github.com/goharbor/harbor-cli/pkg/prompt" - "github.com/goharbor/harbor-cli/pkg/utils" - log "github.com/sirupsen/logrus" - "github.com/spf13/cobra" -) - -func ScanArtifactCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "scan", - Short: "Scan an artifact", - Long: `Scan an artifact in Harbor Repository`, - Example: `harbor artifact scan start //`, - } - - cmd.AddCommand( - StartScanArtifactCommand(), - StopScanArtifactCommand(), - // LogScanArtifactCommand(), - ) - - return cmd -} - -func StartScanArtifactCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "start", - Short: "Start a scan of an artifact", - Long: `Start a scan of an artifact in Harbor Repository`, - Example: `harbor artifact scan start //`, - RunE: func(cmd *cobra.Command, args []string) error { - var err error - var projectName, repoName, reference string - - if len(args) > 0 { - projectName, repoName, reference, err = utils.ParseProjectRepoReference(args[0]) - if err != nil { - return fmt.Errorf("failed to parse project/repo/reference: %v", err) - } - } else { - projectName, err = prompt.GetProjectNameFromUser() - if err != nil { - return fmt.Errorf("failed to get project name: %v", utils.ParseHarborErrorMsg(err)) - } - repoName = prompt.GetRepoNameFromUser(projectName) - reference = prompt.GetReferenceFromUser(repoName, projectName) - } - err = api.StartScanArtifact(projectName, repoName, reference) - if err != nil { - return fmt.Errorf("failed to start scan of artifact: %v", err) - } - return nil - }, - } - return cmd -} - -func StopScanArtifactCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "stop", - Short: "Stop a scan of an artifact", - Long: `Stop a scan of an artifact in Harbor Repository`, - Example: `harbor artifact scan stop //`, - Run: func(cmd *cobra.Command, args []string) { - var err error - var projectName, repoName, reference string - - if len(args) > 0 { - projectName, repoName, reference, err = utils.ParseProjectRepoReference(args[0]) - if err != nil { - log.Errorf("failed to parse project/repo/reference: %v", err) - } - } else { - projectName, err = prompt.GetProjectNameFromUser() - if err != nil { - log.Errorf("failed to get project name: %v", utils.ParseHarborErrorMsg(err)) - } - repoName = prompt.GetRepoNameFromUser(projectName) - reference = prompt.GetReferenceFromUser(repoName, projectName) - } - - err = api.StopScanArtifact(projectName, repoName, reference) - if err != nil { - log.Errorf("failed to stop scan of artifact: %v", err) - } - }, - } - return cmd -} diff --git a/cmd/harbor/root/artifact/scan/scan.go b/cmd/harbor/root/artifact/scan/scan.go new file mode 100644 index 000000000..949fa200a --- /dev/null +++ b/cmd/harbor/root/artifact/scan/scan.go @@ -0,0 +1,52 @@ +// Copyright Project Harbor 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 artifactscan + +import ( + "github.com/goharbor/harbor-cli/pkg/prompt" + "github.com/goharbor/harbor-cli/pkg/utils" + "github.com/spf13/cobra" +) + +func ScanArtifactCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "scan", + Short: "Scan an artifact", + Long: `Scan an artifact in Harbor Repository`, + Example: `harbor artifact scan start //`, + } + + cmd.AddCommand( + StartScanArtifactCommand(), + StopScanArtifactCommand(), + // LogScanArtifactCommand(), + ) + + return cmd +} + +func parseArgs(args []string) (string, string, string, error) { + if len(args) > 0 { + return utils.ParseProjectRepoReference(args[0]) + } else { + projectName, err := prompt.GetProjectNameFromUser() + if err != nil { + return "", "", "", err + } + repoName := prompt.GetRepoNameFromUser(projectName) + reference := prompt.GetReferenceFromUser(repoName, projectName) + + return projectName, repoName, reference, nil + } +} diff --git a/cmd/harbor/root/artifact/scan/start.go b/cmd/harbor/root/artifact/scan/start.go new file mode 100644 index 000000000..a1aa732aa --- /dev/null +++ b/cmd/harbor/root/artifact/scan/start.go @@ -0,0 +1,43 @@ +// Copyright Project Harbor 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 artifactscan + +import ( + "fmt" + + "github.com/goharbor/harbor-cli/pkg/api" + "github.com/spf13/cobra" +) + +func StartScanArtifactCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "start", + Short: "Start a scan of an artifact", + Long: `Start a scan of an artifact in Harbor Repository`, + Example: `harbor artifact scan start //`, + RunE: func(cmd *cobra.Command, args []string) error { + projectName, repoName, reference, err := parseArgs(args) + if err != nil { + return err + } + + err = api.StartScanArtifact(projectName, repoName, reference) + if err != nil { + return fmt.Errorf("failed to start scan of artifact: %v", err) + } + return nil + }, + } + return cmd +} diff --git a/cmd/harbor/root/artifact/scan/stop.go b/cmd/harbor/root/artifact/scan/stop.go new file mode 100644 index 000000000..9ec132c94 --- /dev/null +++ b/cmd/harbor/root/artifact/scan/stop.go @@ -0,0 +1,44 @@ +// Copyright Project Harbor 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 artifactscan + +import ( + "fmt" + + "github.com/goharbor/harbor-cli/pkg/api" + "github.com/spf13/cobra" +) + +func StopScanArtifactCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "stop", + Short: "Stop a scan of an artifact", + Long: `Stop a scan of an artifact in Harbor Repository`, + Example: `harbor artifact scan stop //`, + RunE: func(cmd *cobra.Command, args []string) error { + projectName, repoName, reference, err := parseArgs(args) + if err != nil { + return err + } + + err = api.StopScanArtifact(projectName, repoName, reference) + if err != nil { + return fmt.Errorf("failed to stop scan of artifact: %v", err) + } + + return nil + }, + } + return cmd +} diff --git a/cmd/harbor/root/artifact/tags.go b/cmd/harbor/root/artifact/tags.go deleted file mode 100644 index 4f9a60f50..000000000 --- a/cmd/harbor/root/artifact/tags.go +++ /dev/null @@ -1,160 +0,0 @@ -// Copyright Project Harbor 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 artifact - -import ( - "github.com/goharbor/go-client/pkg/sdk/v2.0/client/artifact" - "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/artifact/tags/create" - "github.com/goharbor/harbor-cli/pkg/views/artifact/tags/list" - log "github.com/sirupsen/logrus" - "github.com/spf13/cobra" - "github.com/spf13/viper" -) - -func ArtifactTagsCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "tags", - Short: "Manage tags of an artifact", - Example: ` harbor artifact tags list //`, - } - - cmd.AddCommand( - ListTagsCmd(), - DeleteTagsCmd(), - CreateTagsCmd(), - ) - - return cmd -} - -func CreateTagsCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "create", - Short: "Create a tag of an artifact", - Example: `harbor artifact tags create // `, - Run: func(cmd *cobra.Command, args []string) { - var err error - var projectName, repoName, reference string - var tagName string - if len(args) > 0 { - projectName, repoName, reference, err = utils.ParseProjectRepoReference(args[0]) - if err != nil { - log.Errorf("failed to parse project/repo/reference: %v", err) - } - tagName = args[1] - } else { - projectName, err = prompt.GetProjectNameFromUser() - if err != nil { - log.Errorf("failed to get project name: %v", utils.ParseHarborErrorMsg(err)) - } - repoName = prompt.GetRepoNameFromUser(projectName) - reference = prompt.GetReferenceFromUser(repoName, projectName) - create.CreateTagView(&tagName) - } - err = api.CreateTag(projectName, repoName, reference, tagName) - if err != nil { - log.Errorf("failed to create tag: %v", err) - } - }, - } - - return cmd -} - -func ListTagsCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "list", - Short: "List tags of an artifact", - Example: `harbor artifact tags list //`, - Run: func(cmd *cobra.Command, args []string) { - var err error - var tags *artifact.ListTagsOK - var projectName, repoName, reference string - - if len(args) > 0 { - projectName, repoName, reference, err = utils.ParseProjectRepoReference(args[0]) - if err != nil { - log.Errorf("failed to parse project/repo/reference: %v", err) - } - } else { - projectName, err = prompt.GetProjectNameFromUser() - if err != nil { - log.Errorf("failed to get project name: %v", utils.ParseHarborErrorMsg(err)) - } - repoName = prompt.GetRepoNameFromUser(projectName) - if repoName == "" { - return - } - reference = prompt.GetReferenceFromUser(repoName, projectName) - } - - tags, err = api.ListTags(projectName, repoName, reference) - - if err != nil { - log.Errorf("failed to list tags: %v", err) - return - } - - FormatFlag := viper.GetString("output-format") - if FormatFlag != "" { - err = utils.PrintFormat(tags, FormatFlag) - if err != nil { - log.Error(err) - return - } - } else { - list.ListTags(tags.Payload) - } - }, - } - - return cmd -} - -func DeleteTagsCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "delete", - Short: "Delete a tag of an artifact", - Example: `harbor artifact tags delete // `, - Run: func(cmd *cobra.Command, args []string) { - var err error - var projectName, repoName, reference string - var tagName string - if len(args) > 0 { - projectName, repoName, reference, err = utils.ParseProjectRepoReference(args[0]) - if err != nil { - log.Errorf("failed to parse project/repo/reference: %v", err) - } - tagName = args[1] - } else { - projectName, err = prompt.GetProjectNameFromUser() - if err != nil { - log.Errorf("failed to get project name: %v", utils.ParseHarborErrorMsg(err)) - } - repoName = prompt.GetRepoNameFromUser(projectName) - reference = prompt.GetReferenceFromUser(repoName, projectName) - tagName = prompt.GetTagFromUser(repoName, projectName, reference) - } - err = api.DeleteTag(projectName, repoName, reference, tagName) - if err != nil { - log.Errorf("failed to delete tag: %v", err) - } - }, - } - - return cmd -} diff --git a/cmd/harbor/root/artifact/tags/create.go b/cmd/harbor/root/artifact/tags/create.go new file mode 100644 index 000000000..9ebeb312e --- /dev/null +++ b/cmd/harbor/root/artifact/tags/create.go @@ -0,0 +1,63 @@ +// Copyright Project Harbor 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 artifacttags + +import ( + "fmt" + + "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/artifact/tags/create" + "github.com/spf13/cobra" +) + +func CreateTagsCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "create", + Short: "Create a tag of an artifact", + Example: `harbor artifact tags create // `, + RunE: func(cmd *cobra.Command, args []string) error { + var err error + var projectName, repoName, reference string + var tagName string + + if len(args) > 0 { + projectName, repoName, reference, err = utils.ParseProjectRepoReference(args[0]) + if err != nil { + return fmt.Errorf("failed to parse project/repo/reference: %v", err) + } + + tagName = args[1] + } else { + projectName, err = prompt.GetProjectNameFromUser() + if err != nil { + return fmt.Errorf("failed to get project name: %v", utils.ParseHarborErrorMsg(err)) + } + + repoName = prompt.GetRepoNameFromUser(projectName) + reference = prompt.GetReferenceFromUser(repoName, projectName) + create.CreateTagView(&tagName) + } + err = api.CreateTag(projectName, repoName, reference, tagName) + if err != nil { + return fmt.Errorf("failed to create tag: %v", err) + } + + return nil + }, + } + + return cmd +} diff --git a/cmd/harbor/root/artifact/tags/delete.go b/cmd/harbor/root/artifact/tags/delete.go new file mode 100644 index 000000000..8d6090af2 --- /dev/null +++ b/cmd/harbor/root/artifact/tags/delete.go @@ -0,0 +1,62 @@ +// Copyright Project Harbor 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 artifacttags + +import ( + "fmt" + + "github.com/goharbor/harbor-cli/pkg/api" + "github.com/goharbor/harbor-cli/pkg/prompt" + "github.com/goharbor/harbor-cli/pkg/utils" + "github.com/spf13/cobra" +) + +func DeleteTagsCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "delete", + Short: "Delete a tag of an artifact", + Example: `harbor artifact tags delete // `, + RunE: func(cmd *cobra.Command, args []string) error { + var err error + var projectName, repoName, reference string + var tagName string + if len(args) > 0 { + projectName, repoName, reference, err = utils.ParseProjectRepoReference(args[0]) + if err != nil { + return fmt.Errorf("failed to parse project/repo/reference: %v", err) + } + + tagName = args[1] + } else { + projectName, err = prompt.GetProjectNameFromUser() + if err != nil { + return fmt.Errorf("failed to get project name: %v", utils.ParseHarborErrorMsg(err)) + } + + repoName = prompt.GetRepoNameFromUser(projectName) + reference = prompt.GetReferenceFromUser(repoName, projectName) + tagName = prompt.GetTagFromUser(repoName, projectName, reference) + } + + err = api.DeleteTag(projectName, repoName, reference, tagName) + if err != nil { + return fmt.Errorf("failed to delete tag: %v", err) + } + + return nil + }, + } + + return cmd +} diff --git a/cmd/harbor/root/artifact/tags/list.go b/cmd/harbor/root/artifact/tags/list.go new file mode 100644 index 000000000..9d0574605 --- /dev/null +++ b/cmd/harbor/root/artifact/tags/list.go @@ -0,0 +1,77 @@ +// Copyright Project Harbor 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 artifacttags + +import ( + "fmt" + + "github.com/goharbor/go-client/pkg/sdk/v2.0/client/artifact" + "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/artifact/tags/list" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +func ListTagsCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "List tags of an artifact", + Example: `harbor artifact tags list //`, + RunE: func(cmd *cobra.Command, args []string) error { + var err error + var tags *artifact.ListTagsOK + var projectName, repoName, reference string + + if len(args) > 0 { + projectName, repoName, reference, err = utils.ParseProjectRepoReference(args[0]) + if err != nil { + return fmt.Errorf("failed to parse project/repo/reference: %v", err) + } + } else { + projectName, err = prompt.GetProjectNameFromUser() + if err != nil { + return fmt.Errorf("failed to get project name: %v", utils.ParseHarborErrorMsg(err)) + } + + repoName = prompt.GetRepoNameFromUser(projectName) + if repoName == "" { + return fmt.Errorf("invalid repository name provided") + } + reference = prompt.GetReferenceFromUser(repoName, projectName) + } + + tags, err = api.ListTags(projectName, repoName, reference) + if err != nil { + return fmt.Errorf("failed to list tags: %v", err) + } + + FormatFlag := viper.GetString("output-format") + if FormatFlag != "" { + err = utils.PrintFormat(tags, FormatFlag) + if err != nil { + return err + } + } else { + list.ListTags(tags.Payload) + } + + return nil + }, + } + + return cmd +} diff --git a/cmd/harbor/root/artifact/tags/tags.go b/cmd/harbor/root/artifact/tags/tags.go new file mode 100644 index 000000000..1433f1d45 --- /dev/null +++ b/cmd/harbor/root/artifact/tags/tags.go @@ -0,0 +1,34 @@ +// Copyright Project Harbor 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 artifacttags + +import ( + "github.com/spf13/cobra" +) + +func ArtifactTagsCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "tags", + Short: "Manage tags of an artifact", + Example: ` harbor artifact tags list //`, + } + + cmd.AddCommand( + ListTagsCmd(), + DeleteTagsCmd(), + CreateTagsCmd(), + ) + + return cmd +} diff --git a/cmd/harbor/root/cmd.go b/cmd/harbor/root/cmd.go index 9eaad4f00..cf9190d6a 100644 --- a/cmd/harbor/root/cmd.go +++ b/cmd/harbor/root/cmd.go @@ -23,6 +23,7 @@ import ( "github.com/goharbor/harbor-cli/cmd/harbor/root/context" "github.com/goharbor/harbor-cli/cmd/harbor/root/cve" "github.com/goharbor/harbor-cli/cmd/harbor/root/instance" + "github.com/goharbor/harbor-cli/cmd/harbor/root/jobservice" "github.com/goharbor/harbor-cli/cmd/harbor/root/labels" "github.com/goharbor/harbor-cli/cmd/harbor/root/ldap" "github.com/goharbor/harbor-cli/cmd/harbor/root/project" @@ -36,6 +37,7 @@ import ( "github.com/goharbor/harbor-cli/cmd/harbor/root/schedule" "github.com/goharbor/harbor-cli/cmd/harbor/root/tag" "github.com/goharbor/harbor-cli/cmd/harbor/root/user" + "github.com/goharbor/harbor-cli/cmd/harbor/root/vulnerability" "github.com/goharbor/harbor-cli/cmd/harbor/root/webhook" "github.com/goharbor/harbor-cli/pkg/utils" "github.com/sirupsen/logrus" @@ -198,6 +200,14 @@ harbor help cmd.GroupID = "system" root.AddCommand(cmd) + cmd = jobservice.JobService() + cmd.GroupID = "system" + root.AddCommand(cmd) + + cmd = vulnerability.Vulnerability() + cmd.GroupID = "system" + root.AddCommand(cmd) + // Utils cmd = versionCommand() cmd.GroupID = "utils" diff --git a/cmd/harbor/root/jobservice/cmd.go b/cmd/harbor/root/jobservice/cmd.go new file mode 100644 index 000000000..2c8aeeee5 --- /dev/null +++ b/cmd/harbor/root/jobservice/cmd.go @@ -0,0 +1,53 @@ +// Copyright Project Harbor 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 jobservice + +import ( + "github.com/goharbor/harbor-cli/cmd/harbor/root/jobservice/jobs" + "github.com/goharbor/harbor-cli/cmd/harbor/root/jobservice/pools" + "github.com/goharbor/harbor-cli/cmd/harbor/root/jobservice/queues" + "github.com/goharbor/harbor-cli/cmd/harbor/root/jobservice/schedules" + "github.com/goharbor/harbor-cli/cmd/harbor/root/jobservice/workers" + "github.com/spf13/cobra" +) + +// JobService creates the jobservice command +func JobService() *cobra.Command { + cmd := &cobra.Command{ + Use: "jobservice", + Short: "Manage Harbor job service (admin only)", + Long: `Manage Harbor job service components including worker pools, job queues, schedules, and job logs. +This requires system admin privileges. + +Examples: + harbor jobservice pools list + harbor jobservice workers list --pool all + harbor jobservice queues list + harbor jobservice schedules list`, + Example: ` harbor jobservice pools list + harbor jobservice workers free --job-id abc123 + harbor jobservice queues pause --type REPLICATION + harbor jobservice schedules status`, + } + + cmd.AddCommand( + pools.PoolsCommand(), + workers.WorkersCommand(), + queues.QueuesCommand(), + schedules.SchedulesCommand(), + jobs.JobsCommand(), + ) + + return cmd +} diff --git a/cmd/harbor/root/jobservice/jobs/log.go b/cmd/harbor/root/jobservice/jobs/log.go new file mode 100644 index 000000000..bf30d4987 --- /dev/null +++ b/cmd/harbor/root/jobservice/jobs/log.go @@ -0,0 +1,74 @@ +// Copyright Project Harbor 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 jobs + +import ( + "fmt" + + "github.com/goharbor/harbor-cli/pkg/api" + "github.com/spf13/cobra" +) + +// JobsCommand creates the jobs subcommand +func JobsCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "jobs", + Short: "Manage job logs", + Long: "View logs for specific jobs.", + } + + cmd.AddCommand(LogCommand()) + + return cmd +} + +// LogCommand retrieves and displays job logs +func LogCommand() *cobra.Command { + var jobID string + + cmd := &cobra.Command{ + Use: "log", + Short: "View job log", + Long: "Display the log for a specific job by job ID.", + Example: "harbor jobservice jobs log --job-id abc123def456", + RunE: func(cmd *cobra.Command, args []string) error { + if jobID == "" { + return fmt.Errorf("--job-id must be specified") + } + + fmt.Printf("Retrieving log for job %s...\n\n", jobID) + + log, err := api.GetJobLog(jobID) + if err != nil { + return fmt.Errorf("failed to retrieve job log: %w", err) + } + + if log == "" { + fmt.Println("No log content available for this job.") + return nil + } + + fmt.Println("=== Job Log ===") + fmt.Println(log) + fmt.Println("=== End of Log ===") + return nil + }, + } + + flags := cmd.Flags() + flags.StringVar(&jobID, "job-id", "", "Job ID (required)") + cmd.MarkFlagRequired("job-id") + + return cmd +} diff --git a/cmd/harbor/root/jobservice/pools/list.go b/cmd/harbor/root/jobservice/pools/list.go new file mode 100644 index 000000000..417ad88ff --- /dev/null +++ b/cmd/harbor/root/jobservice/pools/list.go @@ -0,0 +1,67 @@ +// Copyright Project Harbor 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 pools + +import ( + "fmt" + + "github.com/goharbor/harbor-cli/pkg/api" + "github.com/goharbor/harbor-cli/pkg/utils" + poolviews "github.com/goharbor/harbor-cli/pkg/views/jobservice/pools" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +// PoolsCommand creates the pools subcommand. +func PoolsCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "pools", + Short: "Manage worker pools", + Long: "List and manage worker pools for the Harbor job service.", + } + + cmd.AddCommand(ListCommand()) + return cmd +} + +// ListCommand lists all worker pools. +func ListCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "List all worker pools", + Long: "Display all worker pools with their details.", + Example: "harbor jobservice pools list", + RunE: func(cmd *cobra.Command, args []string) error { + response, err := api.GetWorkerPools() + if err != nil { + return fmt.Errorf("failed to retrieve worker pools: %w", err) + } + + if response == nil || response.Payload == nil || len(response.Payload) == 0 { + fmt.Println("No worker pools found.") + return nil + } + + formatFlag := viper.GetString("output-format") + if formatFlag != "" { + return utils.PrintFormat(response.Payload, formatFlag) + } + + poolviews.ListPools(response.Payload) + return nil + }, + } + + return cmd +} diff --git a/cmd/harbor/root/jobservice/queues/list.go b/cmd/harbor/root/jobservice/queues/list.go new file mode 100644 index 000000000..b0b024f0a --- /dev/null +++ b/cmd/harbor/root/jobservice/queues/list.go @@ -0,0 +1,334 @@ +// Copyright Project Harbor 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 queues + +import ( + "errors" + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/huh" + "github.com/goharbor/harbor-cli/pkg/api" + "github.com/goharbor/harbor-cli/pkg/utils" + "github.com/goharbor/harbor-cli/pkg/views/jobservice/queues" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +// QueuesCommand creates the queues subcommand +func QueuesCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "queues", + Short: "Manage job queues", + Long: "List job queues and perform actions on them (stop/pause/resume).", + } + + cmd.AddCommand(ListCommand(), StopCommand(), PauseCommand(), ResumeCommand()) + + return cmd +} + +// ListCommand lists all job queues +func ListCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "List all job queues", + Long: "Display all job queues with their pending job counts and latency.", + Example: "harbor jobservice queues list", + RunE: func(cmd *cobra.Command, args []string) error { + response, err := api.ListJobQueues() + if err != nil { + return fmt.Errorf("failed to retrieve job queues: %w", err) + } + + if response == nil || response.Payload == nil || len(response.Payload) == 0 { + fmt.Println("No job queues found.") + return nil + } + + formatFlag := viper.GetString("output-format") + if formatFlag != "" { + return utils.PrintFormat(response.Payload, formatFlag) + } + + queues.ListQueues(response.Payload) + return nil + }, + } + + return cmd +} + +// StopCommand stops a job queue +func StopCommand() *cobra.Command { + var jobTypes []string + var interactive bool + + cmd := &cobra.Command{ + Use: "stop", + Short: "Stop job queue(s)", + Long: "Stop a job queue or all queues.", + Example: "harbor jobservice queues stop --type REPLICATION\nharbor jobservice queues stop --type REPLICATION --type RETENTION\nharbor jobservice queues stop --type all", + RunE: func(cmd *cobra.Command, args []string) error { + if len(jobTypes) == 0 && !interactive { + interactive = true + } + + if interactive { + selectedTypes, err := selectQueueTypes("stop") + if err != nil { + return err + } + jobTypes = selectedTypes + } + + if len(jobTypes) == 0 { + return fmt.Errorf("at least one job type must be specified with --type or interactive mode") + } + + return executeQueueAction("stop", jobTypes) + }, + } + + flags := cmd.Flags() + flags.StringSliceVar(&jobTypes, "type", nil, "Job type(s) to stop (repeat flag or comma-separate values; use 'all' for all queues)") + flags.BoolVarP(&interactive, "interactive", "i", false, "Interactive mode to select queue") + + return cmd +} + +// PauseCommand pauses a job queue +func PauseCommand() *cobra.Command { + var jobTypes []string + var interactive bool + + cmd := &cobra.Command{ + Use: "pause", + Short: "Pause job queue(s)", + Long: "Pause a job queue or all queues.", + Example: "harbor jobservice queues pause --type REPLICATION\nharbor jobservice queues pause --type REPLICATION --type RETENTION\nharbor jobservice queues pause --type all", + RunE: func(cmd *cobra.Command, args []string) error { + if len(jobTypes) == 0 && !interactive { + interactive = true + } + + if interactive { + selectedTypes, err := selectQueueTypes("pause") + if err != nil { + return err + } + jobTypes = selectedTypes + } + + if len(jobTypes) == 0 { + return fmt.Errorf("at least one job type must be specified with --type or interactive mode") + } + + return executeQueueAction("pause", jobTypes) + }, + } + + flags := cmd.Flags() + flags.StringSliceVar(&jobTypes, "type", nil, "Job type(s) to pause (repeat flag or comma-separate values; use 'all' for all queues)") + flags.BoolVarP(&interactive, "interactive", "i", false, "Interactive mode to select queue") + + return cmd +} + +// ResumeCommand resumes a job queue +func ResumeCommand() *cobra.Command { + var jobTypes []string + var interactive bool + + cmd := &cobra.Command{ + Use: "resume", + Short: "Resume job queue(s)", + Long: "Resume a paused job queue or all queues.", + Example: "harbor jobservice queues resume --type REPLICATION\nharbor jobservice queues resume --type REPLICATION --type RETENTION\nharbor jobservice queues resume --type all", + RunE: func(cmd *cobra.Command, args []string) error { + if len(jobTypes) == 0 && !interactive { + interactive = true + } + + if interactive { + selectedTypes, err := selectQueueTypes("resume") + if err != nil { + return err + } + jobTypes = selectedTypes + } + + if len(jobTypes) == 0 { + return fmt.Errorf("at least one job type must be specified with --type or interactive mode") + } + + return executeQueueAction("resume", jobTypes) + }, + } + + flags := cmd.Flags() + flags.StringSliceVar(&jobTypes, "type", nil, "Job type(s) to resume (repeat flag or comma-separate values; use 'all' for all queues)") + flags.BoolVarP(&interactive, "interactive", "i", false, "Interactive mode to select queue") + + return cmd +} + +// selectQueueTypes shows an interactive multi-selector for queue types +func selectQueueTypes(action string) ([]string, error) { + response, err := api.ListJobQueues() + if err != nil { + return nil, fmt.Errorf("failed to retrieve job queues: %w", err) + } + + if response == nil || response.Payload == nil || len(response.Payload) == 0 { + return nil, fmt.Errorf("no job queues available") + } + + filteredQueues := make([]*struct { + JobType string + Count int64 + }, 0, len(response.Payload)) + + for _, queue := range response.Payload { + if queue == nil { + continue + } + if shouldIncludeQueueForAction(action, queue.Paused) { + filteredQueues = append(filteredQueues, &struct { + JobType string + Count int64 + }{ + JobType: queue.JobType, + Count: queue.Count, + }) + } + } + + if len(filteredQueues) == 0 { + switch action { + case "resume": + return nil, fmt.Errorf("no paused queues available to resume") + case "pause": + return nil, fmt.Errorf("all queues are already paused") + default: + return nil, fmt.Errorf("no job queues available to %s", action) + } + } + + options := make([]huh.Option[string], len(filteredQueues)+1) + options[0] = huh.NewOption("all", "all") + + for i, queue := range filteredQueues { + label := fmt.Sprintf("%s (pending: %d)", queue.JobType, queue.Count) + options[i+1] = huh.NewOption(label, queue.JobType) + } + + var selected []string + theme := huh.ThemeCharm() + keymap := huh.NewDefaultKeyMap() + keymap.Quit = key.NewBinding( + key.WithKeys("ctrl+c", "q"), + key.WithHelp("q", "quit"), + ) + + err = huh.NewForm( + huh.NewGroup( + huh.NewMultiSelect[string](). + Title(fmt.Sprintf("Select queue type(s) to %s (press q to cancel)", action)). + Options(options...). + Value(&selected), + ), + ).WithTheme(theme).WithKeyMap(keymap).Run() + + if err != nil { + if errors.Is(err, huh.ErrUserAborted) { + return nil, errors.New("operation cancelled") + } + return nil, err + } + + selected = normalizeJobTypes(selected) + if len(selected) == 0 { + return nil, fmt.Errorf("at least one queue type must be selected") + } + + return selected, nil +} + +func shouldIncludeQueueForAction(action string, paused bool) bool { + switch strings.ToLower(action) { + case "resume": + return paused + case "pause": + return !paused + default: + return true + } +} + +func executeQueueAction(action string, jobTypes []string) error { + normalizedTypes := normalizeJobTypes(jobTypes) + if len(normalizedTypes) == 0 { + return fmt.Errorf("at least one job type must be provided") + } + + for _, jobType := range normalizedTypes { + fmt.Printf("%s queue type '%s'...\n", actionLabel(action), jobType) + err := api.ActionJobQueue(strings.ToUpper(jobType), action) + if err != nil { + return fmt.Errorf("failed to %s queue '%s': %w", action, jobType, err) + } + fmt.Printf("✓ Queue '%s' %sd successfully.\n", jobType, action) + } + + return nil +} + +func normalizeJobTypes(jobTypes []string) []string { + cleanedTypes := make([]string, 0, len(jobTypes)) + seen := make(map[string]struct{}, len(jobTypes)) + + for _, rawType := range jobTypes { + for _, splitType := range strings.Split(rawType, ",") { + trimmedType := strings.TrimSpace(splitType) + if trimmedType == "" { + continue + } + + if strings.EqualFold(trimmedType, "all") { + return []string{"all"} + } + + key := strings.ToLower(trimmedType) + if _, exists := seen[key]; exists { + continue + } + + seen[key] = struct{}{} + cleanedTypes = append(cleanedTypes, trimmedType) + } + } + + return cleanedTypes +} + +func actionLabel(action string) string { + if action == "" { + return "Updating" + } + + lower := strings.ToLower(action) + return strings.ToUpper(lower[:1]) + lower[1:] +} diff --git a/cmd/harbor/root/jobservice/schedules/list.go b/cmd/harbor/root/jobservice/schedules/list.go new file mode 100644 index 000000000..8f6972408 --- /dev/null +++ b/cmd/harbor/root/jobservice/schedules/list.go @@ -0,0 +1,161 @@ +// Copyright Project Harbor 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 schedules + +import ( + "fmt" + + "github.com/goharbor/harbor-cli/pkg/api" + "github.com/goharbor/harbor-cli/pkg/utils" + "github.com/goharbor/harbor-cli/pkg/views/jobservice/schedules" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +// SchedulesCommand creates the schedules subcommand +func SchedulesCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "schedules", + Short: "Manage job schedules", + Long: "List schedules and manage global scheduler status.", + } + + cmd.AddCommand( + ListCommand(), + StatusCommand(), + PauseAllCommand(), + ResumeAllCommand(), + ) + + return cmd +} + +// ListCommand lists all schedules +func ListCommand() *cobra.Command { + var page int64 = 1 + var pageSize int64 = 20 + + cmd := &cobra.Command{ + Use: "list", + Short: "List all schedules", + Long: "Display all job schedules with pagination support.", + Example: "harbor jobservice schedules list --page 1 --page-size 20", + RunE: func(cmd *cobra.Command, args []string) error { + if page < 1 { + return fmt.Errorf("page must be >= 1") + } + if pageSize < 1 || pageSize > 100 { + return fmt.Errorf("page-size must be between 1 and 100") + } + + response, err := api.ListSchedules(page, pageSize) + if err != nil { + return fmt.Errorf("failed to retrieve schedules: %w", err) + } + + if response == nil || response.Payload == nil || len(response.Payload) == 0 { + fmt.Println("No schedules found.") + return nil + } + + formatFlag := viper.GetString("output-format") + if formatFlag != "" { + return utils.PrintFormat(response.Payload, formatFlag) + } + + totalCount := response.XTotalCount + schedules.ListSchedules(response.Payload, page, pageSize, totalCount) + return nil + }, + } + + flags := cmd.Flags() + flags.Int64Var(&page, "page", 1, "Page number") + flags.Int64Var(&pageSize, "page-size", 20, "Number of items per page") + + return cmd +} + +// StatusCommand shows the global scheduler status +func StatusCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "status", + Short: "Show scheduler status", + Long: "Display whether the global scheduler is paused or running.", + Example: "harbor jobservice schedules status", + RunE: func(cmd *cobra.Command, args []string) error { + response, err := api.GetSchedulePaused() + if err != nil { + return fmt.Errorf("failed to retrieve scheduler status: %w", err) + } + + if response == nil || response.Payload == nil { + fmt.Println("Unable to determine scheduler status.") + return nil + } + + formatFlag := viper.GetString("output-format") + if formatFlag != "" { + return utils.PrintFormat(response.Payload, formatFlag) + } + + schedules.PrintScheduleStatus(response.Payload) + return nil + }, + } + + return cmd +} + +// PauseAllCommand pauses all schedules +func PauseAllCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "pause-all", + Short: "Pause all schedules", + Long: "Pause the global scheduler and all schedules.", + Example: "harbor jobservice schedules pause-all", + RunE: func(cmd *cobra.Command, args []string) error { + fmt.Println("Pausing all schedules...") + err := api.ActionJobQueue("SCHEDULER", "pause") + if err != nil { + return fmt.Errorf("failed to pause all schedules: %w", err) + } + fmt.Println("✓ All schedules paused successfully.") + return nil + }, + } + + return cmd +} + +// ResumeAllCommand resumes all schedules +func ResumeAllCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "resume-all", + Short: "Resume all schedules", + Long: "Resume the global scheduler and all schedules.", + Example: "harbor jobservice schedules resume-all", + RunE: func(cmd *cobra.Command, args []string) error { + fmt.Println("Resuming all schedules...") + err := api.ActionJobQueue("SCHEDULER", "resume") + if err != nil { + return fmt.Errorf("failed to resume all schedules: %w", err) + } + fmt.Println("✓ All schedules resumed successfully.") + return nil + }, + } + + return cmd +} diff --git a/cmd/harbor/root/jobservice/workers/list.go b/cmd/harbor/root/jobservice/workers/list.go new file mode 100644 index 000000000..8acf924d4 --- /dev/null +++ b/cmd/harbor/root/jobservice/workers/list.go @@ -0,0 +1,77 @@ +// Copyright Project Harbor 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 workers + +import ( + "fmt" + + "github.com/goharbor/harbor-cli/pkg/api" + "github.com/goharbor/harbor-cli/pkg/utils" + workersviews "github.com/goharbor/harbor-cli/pkg/views/jobservice/workers" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var outputFormat string + +// ListCommand lists all workers +func ListCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "list [POOL_ID]", + Short: "List job service workers", + Long: `List job service workers for a specific worker pool. + +If no pool ID is specified, it will use 'all' to get workers from all pools. + +Examples: + harbor jobservice workers list # List workers from all pools + harbor jobservice workers list default # List workers from 'default' pool + harbor jobservice workers list my-pool # List workers from 'my-pool'`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return listWorkers(cmd, args) + }, + } + + cmd.Flags().StringVarP(&outputFormat, "output", "o", "table", + "output format (table, json, yaml)") + + return cmd +} + +func listWorkers(cmd *cobra.Command, args []string) error { + poolID := "all" // default to all pools + if len(args) > 0 { + poolID = args[0] + } + + resp, err := api.GetWorkers(poolID) + if err != nil { + return fmt.Errorf("failed to get workers: %w", err) + } + + if resp == nil || resp.Payload == nil || len(resp.Payload) == 0 { + fmt.Println("No workers found.") + return nil + } + + formatFlag := viper.GetString("output-format") + if formatFlag != "" { + return utils.PrintFormat(resp.Payload, formatFlag) + } + + workersviews.ListWorkers(resp.Payload) + return nil +} diff --git a/cmd/harbor/root/jobservice/workers/workers.go b/cmd/harbor/root/jobservice/workers/workers.go new file mode 100644 index 000000000..9aaa0610b --- /dev/null +++ b/cmd/harbor/root/jobservice/workers/workers.go @@ -0,0 +1,34 @@ +// Copyright Project Harbor 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 workers + +import ( + "github.com/spf13/cobra" +) + +// WorkersCommand creates the workers subcommand +func WorkersCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "workers", + Short: "Manage job service workers", + Long: "Manage job service workers using the job service API", + } + + cmd.AddCommand( + ListCommand(), + ) + + return cmd +} diff --git a/cmd/harbor/root/logs.go b/cmd/harbor/root/logs.go index dfefcd8cd..8f6bcef77 100644 --- a/cmd/harbor/root/logs.go +++ b/cmd/harbor/root/logs.go @@ -34,6 +34,12 @@ func Logs() *cobra.Command { var opts api.ListFlags var follow bool var refreshInterval string + var operationFilter string + var resourceTypeFilter string + var resourceFilter string + var usernameFilter string + var fromTimeFilter string + var toTimeFilter string cmd := &cobra.Command{ Use: "logs", @@ -42,6 +48,13 @@ func Logs() *cobra.Command { Long: `Get recent logs of the projects which the user is a member of. This command retrieves the audit logs for the projects the user is a member of. It supports pagination, sorting, and filtering through query parameters. The logs can be followed in real-time with the --follow flag, and the output can be formatted as JSON with the --output-format flag. +Convenience filter flags are available to build query expressions: +- --operation +- --resource-type +- --resource +- --username +- --from-time and --to-time (for op_time range) + harbor-cli logs --page 1 --page-size 10 --query "operation=push" --sort "op_time:desc" harbor-cli logs --follow --refresh-interval 2s @@ -58,9 +71,22 @@ harbor-cli logs --output-format json`, fmt.Println("The --refresh-interval flag is only applicable when using --follow. It will be ignored.") } + query, err := buildAuditLogQuery( + opts.Q, + operationFilter, + resourceTypeFilter, + resourceFilter, + usernameFilter, + fromTimeFilter, + toTimeFilter, + ) + if err != nil { + return err + } + opts.Q = query + if follow { var interval time.Duration = 5 * time.Second - var err error if refreshInterval != "" { interval, err = time.ParseDuration(refreshInterval) if err != nil { @@ -107,10 +133,103 @@ harbor-cli logs --output-format json`, flags.BoolVarP(&follow, "follow", "f", false, "Follow log output (tail -f behavior)") flags.StringVarP(&refreshInterval, "refresh-interval", "n", "", "Interval to refresh logs when following (default: 5s)") + flags.StringVar(&operationFilter, "operation", "", "Filter by operation") + flags.StringVar(&resourceTypeFilter, "resource-type", "", "Filter by resource type") + flags.StringVar(&resourceFilter, "resource", "", "Filter by resource name") + flags.StringVar(&usernameFilter, "username", "", "Filter by username") + flags.StringVar(&fromTimeFilter, "from-time", "", "Start timestamp for op_time range (RFC3339 or 'YYYY-MM-DD HH:MM:SS')") + flags.StringVar(&toTimeFilter, "to-time", "", "End timestamp for op_time range (RFC3339 or 'YYYY-MM-DD HH:MM:SS')") + + cmd.AddCommand(LogsEventTypesCommand()) return cmd } +func LogsEventTypesCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "events", + Short: "List supported Harbor audit log event types", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + response, err := api.AuditLogEventTypes() + if err != nil { + return fmt.Errorf("failed to retrieve audit log event types: %w", err) + } + + formatFlag := viper.GetString("output-format") + if formatFlag != "" { + return utils.PrintFormat(response.Payload, formatFlag) + } + + for _, eventType := range response.Payload { + fmt.Println(eventType) + } + return nil + }, + } + + return cmd +} + +func buildAuditLogQuery(baseQuery, operation, resourceType, resource, username, fromTime, toTime string) (string, error) { + parts := []string{} + + baseQuery = strings.TrimSpace(baseQuery) + if baseQuery != "" { + parts = append(parts, baseQuery) + } + + if strings.TrimSpace(operation) != "" { + parts = append(parts, fmt.Sprintf("operation=%s", operation)) + } + if strings.TrimSpace(resourceType) != "" { + parts = append(parts, fmt.Sprintf("resource_type=%s", resourceType)) + } + if strings.TrimSpace(resource) != "" { + parts = append(parts, fmt.Sprintf("resource=%s", resource)) + } + if strings.TrimSpace(username) != "" { + parts = append(parts, fmt.Sprintf("username=%s", username)) + } + + from := strings.TrimSpace(fromTime) + to := strings.TrimSpace(toTime) + if (from != "" && to == "") || (from == "" && to != "") { + return "", fmt.Errorf("both --from-time and --to-time must be provided together") + } + + if from != "" && to != "" { + normalizedFrom, err := normalizeAuditTime(from) + if err != nil { + return "", fmt.Errorf("invalid --from-time: %w", err) + } + + normalizedTo, err := normalizeAuditTime(to) + if err != nil { + return "", fmt.Errorf("invalid --to-time: %w", err) + } + + parts = append(parts, fmt.Sprintf("op_time=[%s~%s]", normalizedFrom, normalizedTo)) + } + + return strings.Join(parts, ","), nil +} + +func normalizeAuditTime(input string) (string, error) { + layouts := []string{ + time.RFC3339, + "2006-01-02 15:04:05", + } + + for _, layout := range layouts { + if parsed, err := time.Parse(layout, input); err == nil { + return parsed.Format("2006-01-02 15:04:05"), nil + } + } + + return "", fmt.Errorf("expected RFC3339 or 'YYYY-MM-DD HH:MM:SS'") +} + func followLogs(opts api.ListFlags, interval time.Duration) { var lastLogTime *time.Time diff --git a/cmd/harbor/root/logs_test.go b/cmd/harbor/root/logs_test.go new file mode 100644 index 000000000..3b142c4d0 --- /dev/null +++ b/cmd/harbor/root/logs_test.go @@ -0,0 +1,138 @@ +// Copyright Project Harbor 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 root + +import "testing" + +func TestBuildAuditLogQuery(t *testing.T) { + tests := []struct { + name string + baseQuery string + operation string + resourceType string + resource string + username string + fromTime string + toTime string + expected string + wantErr bool + }{ + { + name: "returns base query only", + baseQuery: "operation=push", + expected: "operation=push", + }, + { + name: "builds query with convenience filters", + baseQuery: "operation_result=true", + operation: "create_artifact", + resourceType: "artifact", + resource: "library/nginx", + username: "admin", + expected: "operation_result=true,operation=create_artifact,resource_type=artifact,resource=library/nginx,username=admin", + }, + { + name: "builds range query with normalized times", + fromTime: "2025-01-01T01:02:03Z", + toTime: "2025-01-01 05:06:07", + expected: "op_time=[2025-01-01 01:02:03~2025-01-01 05:06:07]", + }, + { + name: "fails when one range bound is missing", + toTime: "2025-01-01 05:06:07", + wantErr: true, + }, + { + name: "fails for invalid from time", + fromTime: "invalid-time", + toTime: "2025-01-01 05:06:07", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + query, err := buildAuditLogQuery( + tt.baseQuery, + tt.operation, + tt.resourceType, + tt.resource, + tt.username, + tt.fromTime, + tt.toTime, + ) + + if tt.wantErr { + if err == nil { + t.Fatalf("expected error, got nil") + } + return + } + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if query != tt.expected { + t.Fatalf("expected query %q, got %q", tt.expected, query) + } + }) + } +} + +func TestNormalizeAuditTime(t *testing.T) { + tests := []struct { + name string + input string + expected string + wantErr bool + }{ + { + name: "accepts RFC3339", + input: "2025-01-01T01:02:03Z", + expected: "2025-01-01 01:02:03", + }, + { + name: "accepts plain datetime", + input: "2025-01-01 01:02:03", + expected: "2025-01-01 01:02:03", + }, + { + name: "fails for invalid input", + input: "2025/01/01", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := normalizeAuditTime(tt.input) + + if tt.wantErr { + if err == nil { + t.Fatalf("expected error, got nil") + } + return + } + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if got != tt.expected { + t.Fatalf("expected normalized time %q, got %q", tt.expected, got) + } + }) + } +} diff --git a/cmd/harbor/root/user/elevate.go b/cmd/harbor/root/user/elevate.go index 39f9e362b..56a91aed6 100644 --- a/cmd/harbor/root/user/elevate.go +++ b/cmd/harbor/root/user/elevate.go @@ -24,6 +24,60 @@ import ( "github.com/spf13/cobra" ) +var ( + getUsersIDByName = api.GetUsersIdByName + getUserIDFromUser = prompt.GetUserIdFromUser + confirmElevation = views.ConfirmElevation + elevateUserAPI = api.ElevateUser +) + +func ElevateUser(args []string) error { + var err error + var userId int64 + if len(args) > 0 { + userId, err = getUsersIDByName(args[0]) + if err != nil { + err = fmt.Errorf("failed to get user id for '%s': %v", args[0], err) + log.Error(err.Error()) + return err + } + if userId == 0 { + err = fmt.Errorf("User with name '%s' not found", args[0]) + log.Error(err.Error()) + return err + } + } else { + userId, err = getUserIDFromUser() + if err != nil { + log.Errorf("failed to get user id: %v", err) + return err + } + } + confirm, err := confirmElevation() + if err != nil { + err = fmt.Errorf("failed to confirm elevation: %v", err) + log.Error(err.Error()) + return err + } + if !confirm { + err = errors.New("User did not confirm elevation. Aborting command.") + log.Error(err.Error()) + return err + } + + err = elevateUserAPI(userId) + if err != nil { + if isUnauthorizedError(err) { + err = errors.New("Permission denied: Admin privileges are required to execute this command.") + } else { + err = fmt.Errorf("failed to elevate user: %v", err) + } + log.Error(err.Error()) + return err + } + return nil +} + func ElevateUserCmd() *cobra.Command { cmd := &cobra.Command{ Use: "elevate", @@ -31,50 +85,7 @@ func ElevateUserCmd() *cobra.Command { Long: "elevate user to admin role", Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - var err error - var userId int64 - if len(args) > 0 { - userId, err = api.GetUsersIdByName(args[0]) - if err != nil { - err = fmt.Errorf("failed to get user id for '%s': %v", args[0], err) - log.Error(err.Error()) - return err - } - if userId == 0 { - err = fmt.Errorf("User with name '%s' not found", args[0]) - log.Error(err.Error()) - return err - } - } else { - userId, err = prompt.GetUserIdFromUser() - if err != nil { - log.Errorf("failed to get user id: %v", err) - return err - } - } - confirm, err := views.ConfirmElevation() - if err != nil { - err = fmt.Errorf("failed to confirm elevation: %v", err) - log.Error(err.Error()) - return err - } - if !confirm { - err = errors.New("User did not confirm elevation. Aborting command.") - log.Error(err.Error()) - return err - } - - err = api.ElevateUser(userId) - if err != nil { - if isUnauthorizedError(err) { - err = errors.New("Permission denied: Admin privileges are required to execute this command.") - } else { - err = fmt.Errorf("failed to elevate user: %v", err) - } - log.Error(err.Error()) - return err - } - return nil + return ElevateUser(args) }, } diff --git a/cmd/harbor/root/user/elevate_test.go b/cmd/harbor/root/user/elevate_test.go new file mode 100644 index 000000000..57124d719 --- /dev/null +++ b/cmd/harbor/root/user/elevate_test.go @@ -0,0 +1,211 @@ +// Copyright Project Harbor 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 user + +import ( + "bytes" + "fmt" + "strings" + "testing" + + log "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" +) + +type mockUserElevator struct { + id map[string]int64 + admins map[int64]bool + expectAuthError bool + confirmElevationFromUser bool + confirmElevationErr error +} + +func (m *mockUserElevator) getUserIDByName(username string) (int64, error) { + if v, ok := m.id[username]; ok { + return v, nil + } + return 0, fmt.Errorf("username %s not found", username) +} + +func (m *mockUserElevator) getUserIDFromUser() (int64, error) { + return 999, nil +} + +func (m *mockUserElevator) confirmElevation() (bool, error) { + return m.confirmElevationFromUser, m.confirmElevationErr +} + +func (m *mockUserElevator) elevateUser(userID int64) error { + if m.expectAuthError { + return fmt.Errorf("403") + } + if _, ok := m.admins[userID]; !ok { + m.admins[userID] = true + return nil + } + return fmt.Errorf("user %d is already an admin", userID) +} + +func newmockUserElevator(userCnt int, expectAuthError bool, confirmElevationFromUser bool, confirmElevationErr error) *mockUserElevator { + m := &mockUserElevator{ + expectAuthError: expectAuthError, + confirmElevationFromUser: confirmElevationFromUser, + confirmElevationErr: confirmElevationErr, + id: make(map[string]int64), + admins: make(map[int64]bool), + } + for i := 0; i < userCnt; i++ { + m.id[fmt.Sprintf("test%d", i+1)] = int64(i + 1) + } + getUserIDFromUser = m.getUserIDFromUser + getUsersIDByName = m.getUserIDByName + confirmElevation = m.confirmElevation + elevateUserAPI = m.elevateUser + return m +} + +func TestElevateUser(t *testing.T) { + origGetUsersId := getUsersIDByName + origPrompt := getUserIDFromUser + origConfirm := confirmElevation + origElevate := elevateUserAPI + defer func() { + getUsersIDByName = origGetUsersId + getUserIDFromUser = origPrompt + confirmElevation = origConfirm + elevateUserAPI = origElevate + }() + tests := []struct { + name string + setup func() *mockUserElevator + args []string + expectedAdminID []int64 + expectedErr string + }{ + { + name: "successfully elevate user by username", + setup: func() *mockUserElevator { + return newmockUserElevator(5, false, true, nil) + }, + args: []string{"test1"}, + expectedAdminID: []int64{1}, + expectedErr: "", + }, + { + name: "elevate user via interactive prompt", + setup: func() *mockUserElevator { + m := newmockUserElevator(5, false, true, nil) + m.id["promptuser"] = 999 + return m + }, + args: []string{}, + expectedAdminID: []int64{999}, + expectedErr: "", + }, + { + name: "user not found logs error", + setup: func() *mockUserElevator { + return newmockUserElevator(5, false, true, nil) + }, + args: []string{"nonexistent"}, + expectedAdminID: []int64{}, + expectedErr: "failed to get user id", + }, + { + name: "user id is zero logs not found", + setup: func() *mockUserElevator { + m := newmockUserElevator(5, false, true, nil) + m.id["ghost"] = 0 + return m + }, + args: []string{"ghost"}, + expectedAdminID: []int64{}, + expectedErr: "not found", + }, + { + name: "permission denied error", + setup: func() *mockUserElevator { + return newmockUserElevator(5, true, true, nil) + }, + args: []string{"test1"}, + expectedAdminID: []int64{}, + expectedErr: "permission denied", + }, + { + name: "user declines elevation confirmation", + setup: func() *mockUserElevator { + return newmockUserElevator(5, false, false, nil) + }, + args: []string{"test1"}, + expectedAdminID: []int64{}, + expectedErr: "did not confirm", + }, + { + name: "confirmation prompt returns error", + setup: func() *mockUserElevator { + return newmockUserElevator(5, false, false, fmt.Errorf("terminal error")) + }, + args: []string{"test1"}, + expectedAdminID: []int64{}, + expectedErr: "failed to confirm", + }, + { + name: "elevate user that is already admin", + setup: func() *mockUserElevator { + m := newmockUserElevator(5, false, true, nil) + m.admins[1] = true + return m + }, + args: []string{"test1"}, + expectedAdminID: []int64{1}, + expectedErr: "already an admin", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var buf bytes.Buffer + originalLogOutput := log.StandardLogger().Out + log.SetOutput(&buf) + defer log.SetOutput(originalLogOutput) + + m := tt.setup() + + _ = ElevateUser(tt.args) + + logs := buf.String() + logs = strings.ToLower(logs) + + if tt.expectedErr != "" { + assert.Contains(t, logs, tt.expectedErr, "Expected error logs to contain %s but got %s", tt.expectedErr, logs) + } else { + assert.Empty(t, logs, "Expected no error logs but got: %s", logs) + } + + for _, id := range tt.expectedAdminID { + isAdmin, exists := m.admins[id] + assert.True(t, exists && isAdmin, "User with ID %d should be an admin", id) + } + }) + } +} + +func TestElevateUserCmd(t *testing.T) { + cmd := ElevateUserCmd() + + assert.Equal(t, "elevate", cmd.Use) + assert.Equal(t, "elevate user", cmd.Short) + assert.Equal(t, "elevate user to admin role", cmd.Long) + assert.NotNil(t, cmd.Args, "Args validator should be set") +} diff --git a/cmd/harbor/root/user/password.go b/cmd/harbor/root/user/password.go index d005ed86c..f944afa76 100644 --- a/cmd/harbor/root/user/password.go +++ b/cmd/harbor/root/user/password.go @@ -59,7 +59,7 @@ func UserPasswordChangeCmd() *cobra.Command { reset.ChangePasswordView(resetView) - err = api.ResetPassword(userId, opts) + err = api.ResetPassword(userId, *resetView) if err != nil { if isUnauthorizedError(err) { log.Error("Permission denied: Admin privileges are required to execute this command.") diff --git a/cmd/harbor/root/user/password_test.go b/cmd/harbor/root/user/password_test.go new file mode 100644 index 000000000..3d33feb75 --- /dev/null +++ b/cmd/harbor/root/user/password_test.go @@ -0,0 +1,52 @@ +// Copyright Project Harbor 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 user + +import ( + "testing" + + "github.com/spf13/cobra" +) + +func TestUserPasswordChangeCmd_Metadata(t *testing.T) { + cmd := UserPasswordChangeCmd() + + if cmd == nil { + t.Fatal("command should not be nil") + } + + if cmd.Use != "password" { + t.Fatalf("expected command 'password', got %s", cmd.Use) + } + + if cmd.Short == "" { + t.Fatal("Short description should not be empty") + } +} + +func TestUserPasswordChangeCmd_RunExists(t *testing.T) { + cmd := UserPasswordChangeCmd() + + if cmd.Run == nil { + t.Fatal("Run function should be defined") + } +} + +func TestUserPasswordChangeCmd_IsCobraCommand(t *testing.T) { + cmd := UserPasswordChangeCmd() + + if _, ok := interface{}(cmd).(*cobra.Command); !ok { + t.Fatal("expected cobra command") + } +} diff --git a/cmd/harbor/root/vulnerability/cmd.go b/cmd/harbor/root/vulnerability/cmd.go new file mode 100644 index 000000000..d432c4a54 --- /dev/null +++ b/cmd/harbor/root/vulnerability/cmd.go @@ -0,0 +1,32 @@ +// Copyright Project Harbor 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 vulnerability + +import "github.com/spf13/cobra" + +func Vulnerability() *cobra.Command { + cmd := &cobra.Command{ + Use: "vulnerability", + Aliases: []string{"vuln"}, + Short: "Manage vulnerabilities in Security Hub", + Long: "List vulnerabilities and view vulnerability summary from Harbor Security Hub", + Example: ` harbor vulnerability summary`, + } + + cmd.AddCommand( + GetVulnerabilitySummaryCommand(), + ) + + return cmd +} diff --git a/cmd/harbor/root/vulnerability/summary.go b/cmd/harbor/root/vulnerability/summary.go new file mode 100644 index 000000000..390a4c872 --- /dev/null +++ b/cmd/harbor/root/vulnerability/summary.go @@ -0,0 +1,66 @@ +// Copyright Project Harbor 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 vulnerability + +import ( + "fmt" + + "github.com/goharbor/harbor-cli/pkg/api" + "github.com/goharbor/harbor-cli/pkg/utils" + vulnsummary "github.com/goharbor/harbor-cli/pkg/views/vulnerability/summary" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +func GetVulnerabilitySummaryCommand() *cobra.Command { + var withDangerousArtifact bool + var withDangerousCVE bool + + cmd := &cobra.Command{ + Use: "summary", + Short: "Get Security Hub vulnerability summary", + Long: "Show vulnerability summary across Harbor artifacts from Security Hub.", + Example: ` harbor vulnerability summary + harbor vulnerability summary --artifact + harbor vulnerability summary --cve + harbor vulnerability summary --artifact --cve`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + response, err := api.GetVulnerabilitySummary(withDangerousArtifact, withDangerousCVE) + if err != nil { + return fmt.Errorf("failed to get vulnerability summary: %v", utils.ParseHarborErrorMsg(err)) + } + + summary := response.Payload + + FormatFlag := viper.GetString("output-format") + if FormatFlag != "" { + err = utils.PrintFormat(summary, FormatFlag) + if err != nil { + return err + } + } else { + vulnsummary.ViewVulnerabilitySummary(summary, withDangerousArtifact, withDangerousCVE) + } + + return nil + }, + } + + flags := cmd.Flags() + flags.BoolVarP(&withDangerousArtifact, "artifact", "", false, "Include top 5 dangerous artifact details in summary") + flags.BoolVarP(&withDangerousCVE, "cve", "", false, "Include top 5 dangerous CVE details in summary") + + return cmd +} diff --git a/doc/cli-docs/harbor-vulnerability-summary.md b/doc/cli-docs/harbor-vulnerability-summary.md new file mode 100644 index 000000000..6a1bb5ac8 --- /dev/null +++ b/doc/cli-docs/harbor-vulnerability-summary.md @@ -0,0 +1,47 @@ +--- +title: harbor vulnerability summary +weight: 35 +--- +## harbor vulnerability summary + +### Description + +##### Get Security Hub vulnerability summary + +### Synopsis + +Show vulnerability summary across Harbor artifacts from Security Hub. + +```sh +harbor vulnerability summary [flags] +``` + +### Examples + +```sh + harbor vulnerability summary + harbor vulnerability summary --artifact + harbor vulnerability summary --cve + harbor vulnerability summary --artifact --cve +``` + +### Options + +```sh + --artifact Include top 5 dangerous artifact details in summary + --cve Include top 5 dangerous CVE details in summary + -h, --help help for summary +``` + +### Options inherited from parent commands + +```sh + -c, --config string config file (default is $HOME/.config/harbor-cli/config.yaml) + -o, --output-format string Output format. One of: json|yaml + -v, --verbose verbose output +``` + +### SEE ALSO + +* [harbor vulnerability](harbor-vulnerability.md) - Manage vulnerabilities in Security Hub + diff --git a/doc/cli-docs/harbor-vulnerability.md b/doc/cli-docs/harbor-vulnerability.md new file mode 100644 index 000000000..3ba923b4d --- /dev/null +++ b/doc/cli-docs/harbor-vulnerability.md @@ -0,0 +1,39 @@ +--- +title: harbor vulnerability +weight: 70 +--- +## harbor vulnerability + +### Description + +##### Manage vulnerabilities in Security Hub + +### Synopsis + +List vulnerabilities and view vulnerability summary from Harbor Security Hub + +### Examples + +```sh + harbor vulnerability summary +``` + +### Options + +```sh + -h, --help help for vulnerability +``` + +### Options inherited from parent commands + +```sh + -c, --config string config file (default is $HOME/.config/harbor-cli/config.yaml) + -o, --output-format string Output format. One of: json|yaml + -v, --verbose verbose output +``` + +### SEE ALSO + +* [harbor](harbor.md) - Official Harbor CLI +* [harbor vulnerability summary](harbor-vulnerability-summary.md) - Get Security Hub vulnerability summary + diff --git a/doc/cli-docs/harbor.md b/doc/cli-docs/harbor.md index 5754fa6a1..fe1916f83 100644 --- a/doc/cli-docs/harbor.md +++ b/doc/cli-docs/harbor.md @@ -59,5 +59,6 @@ harbor help * [harbor tag](harbor-tag.md) - Manage tags in Harbor registry * [harbor user](harbor-user.md) - Manage users * [harbor version](harbor-version.md) - Version of Harbor CLI +* [harbor vulnerability](harbor-vulnerability.md) - Manage vulnerabilities in Security Hub * [harbor webhook](harbor-webhook.md) - Manage webhook policies in Harbor diff --git a/doc/man-docs/man1/harbor-vulnerability-summary.1 b/doc/man-docs/man1/harbor-vulnerability-summary.1 new file mode 100644 index 000000000..49d908f2e --- /dev/null +++ b/doc/man-docs/man1/harbor-vulnerability-summary.1 @@ -0,0 +1,52 @@ +.nh +.TH "HARBOR" "1" "Harbor Community" "Harbor User Manuals" + +.SH NAME +harbor-vulnerability-summary - Get Security Hub vulnerability summary + + +.SH SYNOPSIS +\fBharbor vulnerability summary [flags]\fP + + +.SH DESCRIPTION +Show vulnerability summary across Harbor artifacts from Security Hub. + + +.SH OPTIONS +\fB--artifact\fP[=false] + Include top 5 dangerous artifact details in summary + +.PP +\fB--cve\fP[=false] + Include top 5 dangerous CVE details in summary + +.PP +\fB-h\fP, \fB--help\fP[=false] + help for summary + + +.SH OPTIONS INHERITED FROM PARENT COMMANDS +\fB-c\fP, \fB--config\fP="" + config file (default is $HOME/.config/harbor-cli/config.yaml) + +.PP +\fB-o\fP, \fB--output-format\fP="" + Output format. One of: json|yaml + +.PP +\fB-v\fP, \fB--verbose\fP[=false] + verbose output + + +.SH EXAMPLE +.EX + harbor vulnerability summary + harbor vulnerability summary --artifact + harbor vulnerability summary --cve + harbor vulnerability summary --artifact --cve +.EE + + +.SH SEE ALSO +\fBharbor-vulnerability(1)\fP \ No newline at end of file diff --git a/doc/man-docs/man1/harbor-vulnerability.1 b/doc/man-docs/man1/harbor-vulnerability.1 new file mode 100644 index 000000000..161ab03a4 --- /dev/null +++ b/doc/man-docs/man1/harbor-vulnerability.1 @@ -0,0 +1,41 @@ +.nh +.TH "HARBOR" "1" "Harbor Community" "Harbor User Manuals" + +.SH NAME +harbor-vulnerability - Manage vulnerabilities in Security Hub + + +.SH SYNOPSIS +\fBharbor vulnerability [flags]\fP + + +.SH DESCRIPTION +List vulnerabilities and view vulnerability summary from Harbor Security Hub + + +.SH OPTIONS +\fB-h\fP, \fB--help\fP[=false] + help for vulnerability + + +.SH OPTIONS INHERITED FROM PARENT COMMANDS +\fB-c\fP, \fB--config\fP="" + config file (default is $HOME/.config/harbor-cli/config.yaml) + +.PP +\fB-o\fP, \fB--output-format\fP="" + Output format. One of: json|yaml + +.PP +\fB-v\fP, \fB--verbose\fP[=false] + verbose output + + +.SH EXAMPLE +.EX + harbor vulnerability summary +.EE + + +.SH SEE ALSO +\fBharbor(1)\fP, \fBharbor-vulnerability-summary(1)\fP \ No newline at end of file diff --git a/doc/man-docs/man1/harbor.1 b/doc/man-docs/man1/harbor.1 index 559f29bf5..cd0884420 100644 --- a/doc/man-docs/man1/harbor.1 +++ b/doc/man-docs/man1/harbor.1 @@ -43,4 +43,4 @@ harbor help .SH SEE ALSO -\fBharbor-artifact(1)\fP, \fBharbor-config(1)\fP, \fBharbor-context(1)\fP, \fBharbor-cve-allowlist(1)\fP, \fBharbor-health(1)\fP, \fBharbor-info(1)\fP, \fBharbor-instance(1)\fP, \fBharbor-label(1)\fP, \fBharbor-ldap(1)\fP, \fBharbor-login(1)\fP, \fBharbor-logs(1)\fP, \fBharbor-password(1)\fP, \fBharbor-project(1)\fP, \fBharbor-quota(1)\fP, \fBharbor-registry(1)\fP, \fBharbor-replication(1)\fP, \fBharbor-repo(1)\fP, \fBharbor-robot(1)\fP, \fBharbor-scan-all(1)\fP, \fBharbor-scanner(1)\fP, \fBharbor-schedule(1)\fP, \fBharbor-tag(1)\fP, \fBharbor-user(1)\fP, \fBharbor-version(1)\fP, \fBharbor-webhook(1)\fP \ No newline at end of file +\fBharbor-artifact(1)\fP, \fBharbor-config(1)\fP, \fBharbor-context(1)\fP, \fBharbor-cve-allowlist(1)\fP, \fBharbor-health(1)\fP, \fBharbor-info(1)\fP, \fBharbor-instance(1)\fP, \fBharbor-label(1)\fP, \fBharbor-ldap(1)\fP, \fBharbor-login(1)\fP, \fBharbor-logs(1)\fP, \fBharbor-password(1)\fP, \fBharbor-project(1)\fP, \fBharbor-quota(1)\fP, \fBharbor-registry(1)\fP, \fBharbor-replication(1)\fP, \fBharbor-repo(1)\fP, \fBharbor-robot(1)\fP, \fBharbor-scan-all(1)\fP, \fBharbor-scanner(1)\fP, \fBharbor-schedule(1)\fP, \fBharbor-tag(1)\fP, \fBharbor-user(1)\fP, \fBharbor-version(1)\fP, \fBharbor-vulnerability(1)\fP, \fBharbor-webhook(1)\fP \ No newline at end of file diff --git a/pkg/api/jobservice_handler.go b/pkg/api/jobservice_handler.go new file mode 100644 index 000000000..9f59ee15a --- /dev/null +++ b/pkg/api/jobservice_handler.go @@ -0,0 +1,137 @@ +// Copyright Project Harbor 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 api + +import ( + "github.com/goharbor/go-client/pkg/sdk/v2.0/client/jobservice" + "github.com/goharbor/go-client/pkg/sdk/v2.0/client/schedule" + "github.com/goharbor/go-client/pkg/sdk/v2.0/models" + "github.com/goharbor/harbor-cli/pkg/utils" +) + +// GetWorkerPools retrieves all worker pools +func GetWorkerPools() (*jobservice.GetWorkerPoolsOK, error) { + ctx, client, err := utils.ContextWithClient() + if err != nil { + return nil, err + } + + response, err := client.Jobservice.GetWorkerPools(ctx, &jobservice.GetWorkerPoolsParams{}) + if err != nil { + return nil, err + } + + return response, nil +} + +// GetWorkers retrieves workers for a pool +func GetWorkers(poolID string) (*jobservice.GetWorkersOK, error) { + ctx, client, err := utils.ContextWithClient() + if err != nil { + return nil, err + } + + response, err := client.Jobservice.GetWorkers(ctx, &jobservice.GetWorkersParams{ + PoolID: poolID, + }) + if err != nil { + return nil, err + } + + return response, nil +} + +// ListJobQueues retrieves all job queues +func ListJobQueues() (*jobservice.ListJobQueuesOK, error) { + ctx, client, err := utils.ContextWithClient() + if err != nil { + return nil, err + } + + response, err := client.Jobservice.ListJobQueues(ctx, &jobservice.ListJobQueuesParams{}) + if err != nil { + return nil, err + } + + return response, nil +} + +// ActionJobQueue performs an action on a job queue (stop/pause/resume) +func ActionJobQueue(jobType, action string) error { + ctx, client, err := utils.ContextWithClient() + if err != nil { + return err + } + + _, err = client.Jobservice.ActionPendingJobs(ctx, &jobservice.ActionPendingJobsParams{ + JobType: jobType, + ActionRequest: &models.ActionRequest{ + Action: action, + }, + }) + + return err +} + +// GetJobLog retrieves the log for a job by ID +func GetJobLog(jobID string) (string, error) { + ctx, client, err := utils.ContextWithClient() + if err != nil { + return "", err + } + + response, err := client.Jobservice.ActionGetJobLog(ctx, &jobservice.ActionGetJobLogParams{ + JobID: jobID, + }) + if err != nil { + return "", err + } + + return response.Payload, nil +} + +// ListSchedules retrieves schedules with pagination +func ListSchedules(page, pageSize int64) (*schedule.ListSchedulesOK, error) { + ctx, client, err := utils.ContextWithClient() + if err != nil { + return nil, err + } + + response, err := client.Schedule.ListSchedules(ctx, &schedule.ListSchedulesParams{ + Page: &page, + PageSize: &pageSize, + }) + if err != nil { + return nil, err + } + + return response, nil +} + +// GetSchedulePaused retrieves the global scheduler paused status +func GetSchedulePaused() (*schedule.GetSchedulePausedOK, error) { + ctx, client, err := utils.ContextWithClient() + if err != nil { + return nil, err + } + + response, err := client.Schedule.GetSchedulePaused(ctx, &schedule.GetSchedulePausedParams{ + JobType: "all", + }) + if err != nil { + return nil, err + } + + return response, nil +} diff --git a/pkg/api/vulnerability_handler.go b/pkg/api/vulnerability_handler.go new file mode 100644 index 000000000..e759a30f7 --- /dev/null +++ b/pkg/api/vulnerability_handler.go @@ -0,0 +1,36 @@ +// Copyright Project Harbor 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 api + +import ( + "github.com/goharbor/go-client/pkg/sdk/v2.0/client/securityhub" + "github.com/goharbor/harbor-cli/pkg/utils" +) + +func GetVulnerabilitySummary(withDangerousArtifact, withDangerousCVE bool) (*securityhub.GetSecuritySummaryOK, error) { + ctx, client, err := utils.ContextWithClient() + if err != nil { + return nil, err + } + + response, err := client.Securityhub.GetSecuritySummary(ctx, &securityhub.GetSecuritySummaryParams{ + WithDangerousArtifact: &withDangerousArtifact, + WithDangerousCVE: &withDangerousCVE, + }) + if err != nil { + return nil, err + } + + return response, nil +} diff --git a/pkg/utils/helper.go b/pkg/utils/helper.go index 33b8d4a62..cd50ba3b0 100644 --- a/pkg/utils/helper.go +++ b/pkg/utils/helper.go @@ -25,6 +25,13 @@ import ( "unicode" ) +func pluralise(value int, unit string) string { + if value == 1 { + return fmt.Sprintf("%d %s ago", value, unit) + } + return fmt.Sprintf("%d %ss ago", value, unit) +} + func FormatCreatedTime(timestamp string) (string, error) { t, err := time.Parse(time.RFC3339Nano, timestamp) if err != nil { @@ -38,11 +45,11 @@ func FormatCreatedTime(timestamp string) (string, error) { days := int(duration.Hours() / 24) if minutes < 60 { - return fmt.Sprintf("%d minute ago", minutes), nil + return pluralise(minutes, "minute"), nil } else if hours < 24 { - return fmt.Sprintf("%d hour ago", hours), nil + return pluralise(hours, "hour"), nil } else { - return fmt.Sprintf("%d day ago", days), nil + return pluralise(days, "day"), nil } } diff --git a/pkg/views/health/view.go b/pkg/views/health/view.go index d31efad79..2efc53f79 100644 --- a/pkg/views/health/view.go +++ b/pkg/views/health/view.go @@ -51,9 +51,9 @@ func styleStatus(status string) string { var colored string if status == "healthy" { - colored = views.GreenANSI + status + views.ResetANSI + colored = views.GreenStyle.Render(status) } else { - colored = views.RedANSI + status + views.ResetANSI + colored = views.RedStyle.Render(status) } return colored } diff --git a/pkg/views/jobservice/pools/view.go b/pkg/views/jobservice/pools/view.go new file mode 100644 index 000000000..b7007941d --- /dev/null +++ b/pkg/views/jobservice/pools/view.go @@ -0,0 +1,51 @@ +// Copyright Project Harbor 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 pools + +import ( + "fmt" + + "github.com/goharbor/go-client/pkg/sdk/v2.0/models" +) + +// ListPools displays worker pools in a formatted table. +func ListPools(items []*models.WorkerPool) { + if len(items) == 0 { + fmt.Println("No worker pools found.") + return + } + + fmt.Printf("%-20s %-8s %-30s %-30s %-12s %-30s\n", "POOL_ID", "PID", "START_AT", "HEARTBEAT_AT", "CONCURRENCY", "HOST") + fmt.Printf("%-20s %-8s %-30s %-30s %-12s %-30s\n", "-------", "---", "--------", "------------", "-----------", "----") + + for _, pool := range items { + if pool == nil { + continue + } + + startAt := fmt.Sprintf("%v", pool.StartAt) + heartbeatAt := fmt.Sprintf("%v", pool.HeartbeatAt) + + fmt.Printf("%-20s %-8d %-30s %-30s %-12d %-30s\n", + pool.WorkerPoolID, + pool.Pid, + startAt, + heartbeatAt, + pool.Concurrency, + pool.Host, + ) + } + + fmt.Printf("\nTotal: %d worker pool(s)\n", len(items)) +} diff --git a/pkg/views/jobservice/queues/view.go b/pkg/views/jobservice/queues/view.go new file mode 100644 index 000000000..1f4ae8344 --- /dev/null +++ b/pkg/views/jobservice/queues/view.go @@ -0,0 +1,40 @@ +// Copyright Project Harbor 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 queues + +import ( + "fmt" + + "github.com/goharbor/go-client/pkg/sdk/v2.0/models" +) + +// ListQueues displays job queues in a formatted table. +func ListQueues(items []*models.JobQueue) { + if len(items) == 0 { + fmt.Println("No job queues found.") + return + } + + fmt.Printf("%-25s %-12s %-12s %-10s\n", "JOB_TYPE", "COUNT", "LATENCY(s)", "PAUSED") + fmt.Printf("%-25s %-12s %-12s %-10s\n", "--------", "-----", "----------", "------") + + for _, queue := range items { + if queue == nil { + continue + } + fmt.Printf("%-25s %-12d %-12d %-10t\n", queue.JobType, queue.Count, queue.Latency, queue.Paused) + } + + fmt.Printf("\nTotal: %d queue(s)\n", len(items)) +} diff --git a/pkg/views/jobservice/schedules/view.go b/pkg/views/jobservice/schedules/view.go new file mode 100644 index 000000000..6ccee13e4 --- /dev/null +++ b/pkg/views/jobservice/schedules/view.go @@ -0,0 +1,55 @@ +// Copyright Project Harbor 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 schedules + +import ( + "fmt" + + "github.com/goharbor/go-client/pkg/sdk/v2.0/models" +) + +// ListSchedules displays schedule tasks with pagination metadata. +func ListSchedules(items []*models.ScheduleTask, page, pageSize, totalCount int64) { + if len(items) == 0 { + fmt.Println("No schedules found.") + return + } + + fmt.Printf("%-8s %-18s %-22s %-30s %-20s\n", "ID", "VENDOR_TYPE", "VENDOR_ID", "CRON", "UPDATE_TIME") + fmt.Printf("%-8s %-18s %-22s %-30s %-20s\n", "--", "-----------", "---------", "----", "-----------") + + for _, task := range items { + if task == nil { + continue + } + fmt.Printf("%-8d %-18s %-22d %-30s %-20s\n", task.ID, task.VendorType, task.VendorID, task.Cron, task.UpdateTime.String()) + } + + fmt.Printf("\nPage: %d Page Size: %d Returned: %d Total: %d\n", page, pageSize, len(items), totalCount) +} + +// PrintScheduleStatus displays the scheduler paused/running state. +func PrintScheduleStatus(status *models.SchedulerStatus) { + if status == nil { + fmt.Println("Scheduler status: unknown") + return + } + + if status.Paused { + fmt.Println("Scheduler status: paused") + return + } + + fmt.Println("Scheduler status: running") +} diff --git a/pkg/views/jobservice/workers/view.go b/pkg/views/jobservice/workers/view.go new file mode 100644 index 000000000..4acbf0213 --- /dev/null +++ b/pkg/views/jobservice/workers/view.go @@ -0,0 +1,73 @@ +// Copyright Project Harbor 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 workers + +import ( + "fmt" + + "github.com/goharbor/go-client/pkg/sdk/v2.0/models" +) + +// ListWorkers displays workers in a formatted table +func ListWorkers(workers []*models.Worker) { + if len(workers) == 0 { + fmt.Println("No workers found.") + return + } + + fmt.Printf("%-40s %-20s %-20s %-40s %-30s %-30s\n", + "ID", "POOL_ID", "JOB_NAME", "JOB_ID", "START_AT", "CHECKIN_AT") + fmt.Printf("%-40s %-20s %-20s %-40s %-30s %-30s\n", + "--", "-------", "--------", "------", "--------", "---------") + + busyCount := 0 + for _, worker := range workers { + id := worker.ID + if id == "" { + id = "-" + } + + poolID := worker.PoolID + if poolID == "" { + poolID = "-" + } + + jobName := worker.JobName + if jobName == "" { + jobName = "-" + } + + jobID := worker.JobID + if jobID == "" { + jobID = "-" + } else { + busyCount++ + } + + startAt := "-" + if worker.StartAt != nil { + startAt = fmt.Sprintf("%v", worker.StartAt) + } + + checkinAt := "-" + if worker.CheckinAt != nil { + checkinAt = fmt.Sprintf("%v", worker.CheckinAt) + } + + fmt.Printf("%-40s %-20s %-20s %-40s %-30s %-30s\n", + id, poolID, jobName, jobID, startAt, checkinAt) + } + + fmt.Printf("\nTotal: %d worker(s), Busy: %d\n", len(workers), busyCount) +} diff --git a/pkg/views/robot/list/view.go b/pkg/views/robot/list/view.go index 2f8e67204..b23a4947b 100644 --- a/pkg/views/robot/list/view.go +++ b/pkg/views/robot/list/view.go @@ -44,9 +44,9 @@ func ListRobots(robots []*models.Robot) { var expires string if robot.Disable { - enabledStatus = views.GreenANSI + "Disabled" + views.ResetANSI + enabledStatus = views.GreenStyle.Render("Disabled") } else { - enabledStatus = views.GreenANSI + "Enabled" + views.ResetANSI + enabledStatus = views.GreenStyle.Render("Enabled") } var TotalPermissions int = 0 diff --git a/pkg/views/robot/view/view.go b/pkg/views/robot/view/view.go index 663f1b923..444c83587 100644 --- a/pkg/views/robot/view/view.go +++ b/pkg/views/robot/view/view.go @@ -82,9 +82,9 @@ func ViewRobot(robot *models.Robot) { var expires string if robot.Disable { - enabledStatus = views.GreenANSI + "Disabled" + views.ResetANSI + enabledStatus = views.GreenStyle.Render("Disabled") } else { - enabledStatus = views.GreenANSI + "Enabled" + views.ResetANSI + enabledStatus = views.GreenStyle.Render("Enabled") } TotalPermissions := strconv.FormatInt(int64(len(robot.Permissions[0].Access)), 10) @@ -112,7 +112,7 @@ func ViewRobot(robot *models.Robot) { os.Exit(1) } - fmt.Printf("\n%sRobot Permissions:%s\n\n", views.BoldANSI, views.ResetANSI) + fmt.Printf("\n%s\n\n", views.BoldStyle.Render("Robot Permissions:")) var permissionsColumns []table.Column var resourceStrings []string @@ -122,12 +122,12 @@ func ViewRobot(robot *models.Robot) { permissionsColumns = systemPermissionsColumns resourceStrings = systemResourceStrings systemLevel = true - fmt.Printf("%sSystem-level robot with access across projects%s\n\n", views.BoldANSI, views.ResetANSI) + fmt.Printf("\n%s\n\n", views.BoldStyle.Render("System-level robot with access across projects")) } else { permissionsColumns = projectPermissionsColumns resourceStrings = projectResourceStrings systemLevel = false - fmt.Printf("%sProject-level robot for project: %s%s\n\n", views.BoldANSI, robot.Permissions[0].Namespace, views.ResetANSI) + fmt.Printf("\n%s\n\n", views.BoldStyle.Render(fmt.Sprintf("System-level robot with access across projects: %s", robot.Permissions[0].Namespace))) } var permissionRows []table.Row @@ -194,11 +194,11 @@ func ViewRobot(robot *models.Robot) { os.Exit(1) } - fmt.Printf("\n%sProject-specific Permissions:%s\n", views.BoldANSI, views.ResetANSI) + fmt.Printf("\n%s\n\n", views.BoldStyle.Render("Project-specific Permissions:")) for _, perm := range robot.Permissions { if perm.Kind == "project" && perm.Namespace != "/" { - fmt.Printf("\n%sProject: %s%s\n\n", views.BoldANSI, perm.Namespace, views.ResetANSI) + fmt.Printf("\n%s\n\n", views.BoldStyle.Render(fmt.Sprintf("Project: %s", perm.Namespace))) projectRows := createProjectPermissionRows(perm, perms.Project) pt := tablelist.NewModel(projectPermissionsColumns, projectRows, len(projectRows)) if _, err := tea.NewProgram(pt).Run(); err != nil { diff --git a/pkg/views/styles.go b/pkg/views/styles.go index 49a2ecc79..240ef23bc 100644 --- a/pkg/views/styles.go +++ b/pkg/views/styles.go @@ -26,6 +26,12 @@ var ( SelectedItemStyle = lipgloss.NewStyle().PaddingLeft(2).Foreground(lipgloss.Color("170")) PaginationStyle = list.DefaultStyles().PaginationStyle.PaddingLeft(4) HelpStyle = list.DefaultStyles().HelpStyle.PaddingLeft(4).PaddingBottom(1) + GreenStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("2")) // ANSI 32 + RedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("1")) // ANSI 31 + BoldStyle = lipgloss.NewStyle().Bold(true) // ANSI 1 + YellowStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("3")) // ANSI 33 + BlueStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("4")) // ANSI 34 + GrayStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) // ANSI 37 ) var BaseStyle = lipgloss.NewStyle(). @@ -36,12 +42,5 @@ func RedText(strs ...string) string { for _, str := range strs { msg.WriteString(str) } - return RedANSI + msg.String() + ResetANSI + return RedStyle.Render(msg.String()) } - -const ( - GreenANSI = "\033[32m" - RedANSI = "\033[31m" - BoldANSI = "\033[1m" - ResetANSI = "\033[0m" -) diff --git a/pkg/views/vulnerability/summary/view.go b/pkg/views/vulnerability/summary/view.go new file mode 100644 index 000000000..3017b7253 --- /dev/null +++ b/pkg/views/vulnerability/summary/view.go @@ -0,0 +1,161 @@ +// Copyright Project Harbor 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 summary + +import ( + "fmt" + "strconv" + "strings" + + "github.com/charmbracelet/bubbles/table" + "github.com/charmbracelet/lipgloss" + "github.com/goharbor/go-client/pkg/sdk/v2.0/models" + "github.com/goharbor/harbor-cli/pkg/api" + "github.com/goharbor/harbor-cli/pkg/views" + "github.com/goharbor/harbor-cli/pkg/views/base/tablelist" +) + +const barWidth = 30 + +func ViewVulnerabilitySummary(summary *models.SecuritySummary, showDangerousArtifacts bool, showDangerousCVEs bool) { + if summary == nil { + fmt.Println("No vulnerability summary data available.") + return + } + + var sb strings.Builder + + fmt.Fprintf(&sb, "%d artifact(s), %d scanned, %d not scanned\n\n", summary.TotalArtifact, summary.ScannedCnt, summary.TotalArtifact-summary.ScannedCnt) + + fmt.Fprintf(&sb, "Total Vulnerabilities: %s (Fixable: %s)\n\n", + views.BoldStyle.Render(strconv.FormatInt(summary.TotalVuls, 10)), + views.BoldStyle.Render(strconv.FormatInt(summary.FixableCnt, 10)), + ) + + renderBarChart(&sb, summary) + + if showDangerousArtifacts { + sb.WriteString("Top 5 Most Dangerous Artifacts\n") + renderDangerousArtifacts(&sb, summary.DangerousArtifacts) + } + + if showDangerousCVEs { + sb.WriteString("Top 5 Most Dangerous CVEs\n") + renderDangerousCVEs(&sb, summary.DangerousCves) + } + + fmt.Print(sb.String()) +} + +func renderBarChart(sb *strings.Builder, summary *models.SecuritySummary) { + entries := []struct { + label string + count int64 + color lipgloss.Style + }{ + {"Critical", summary.CriticalCnt, views.RedStyle}, + {"High", summary.HighCnt, views.YellowStyle}, + {"Medium", summary.MediumCnt, views.BlueStyle}, + {"Low", summary.LowCnt, views.GrayStyle}, + {"Unknown", summary.UnknownCnt, views.GrayStyle}, + {"None", summary.NoneCnt, views.GrayStyle}, + } + + for _, e := range entries { + filled := 0 + pct := 0.0 + if summary.TotalVuls > 0 { + pct = float64(e.count) / float64(summary.TotalVuls) * 100 + filled = int(pct / 100 * float64(barWidth)) + if e.count > 0 && filled == 0 { + filled = 1 + } + } + empty := barWidth - filled + bar := e.color.Render(strings.Repeat("█", filled)) + strings.Repeat("─", empty) + fmt.Fprintf(sb, " %-10s %s %3.0f%% (%d CVEs)\n\n", e.label, bar, pct, e.count) + } + sb.WriteString("\n") +} + +func renderDangerousArtifacts(sb *strings.Builder, artifacts []*models.DangerousArtifact) { + columns := []table.Column{ + {Title: "Project", Width: tablelist.WidthL}, + {Title: "Repository", Width: tablelist.WidthXXL}, + {Title: "Digest", Width: tablelist.WidthL}, + {Title: "Critical", Width: tablelist.WidthS}, + {Title: "High", Width: tablelist.WidthS}, + {Title: "Medium", Width: tablelist.WidthS}, + } + + if len(artifacts) == 0 { + sb.WriteString("No dangerous artifacts found\n") + return + } + + var rows []table.Row + for _, artifact := range artifacts { + projectName := strconv.FormatInt(artifact.ProjectID, 10) + project, err := api.GetProject(projectName, true) + if err == nil && project.Payload != nil { + projectName = project.Payload.Name + } + + digest := artifact.Digest + if len(digest) > 16 { + digest = digest[:16] + "..." + } + + rows = append(rows, table.Row{ + projectName, + artifact.RepositoryName, + digest, + strconv.FormatInt(artifact.CriticalCnt, 10), + strconv.FormatInt(artifact.HighCnt, 10), + strconv.FormatInt(artifact.MediumCnt, 10), + }) + } + + m := tablelist.NewModel(columns, rows, len(rows)) + sb.WriteString(m.View()) +} + +func renderDangerousCVEs(sb *strings.Builder, cves []*models.DangerousCVE) { + columns := []table.Column{ + {Title: "CVE ID", Width: tablelist.WidthXL}, + {Title: "Severity", Width: tablelist.WidthS}, + {Title: "CVSS3", Width: tablelist.WidthS}, + {Title: "Package", Width: tablelist.WidthL}, + {Title: "Version", Width: tablelist.WidthXXL}, + } + + if len(cves) == 0 { + sb.WriteString("No dangerous CVEs found\n") + return + } + + var rows []table.Row + for _, cve := range cves { + rows = append(rows, table.Row{ + cve.CVEID, + cve.Severity, + fmt.Sprintf("%.1f", cve.CvssScoreV3), + cve.Package, + cve.Version, + }) + } + + m := tablelist.NewModel(columns, rows, len(rows)) + sb.WriteString(m.View()) +}