diff --git a/cmd/harbor/root/cmd.go b/cmd/harbor/root/cmd.go index 039e39ea6..91cb7548b 100644 --- a/cmd/harbor/root/cmd.go +++ b/cmd/harbor/root/cmd.go @@ -22,6 +22,7 @@ import ( "github.com/goharbor/harbor-cli/cmd/harbor/root/configurations" "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/gc" "github.com/goharbor/harbor-cli/cmd/harbor/root/instance" "github.com/goharbor/harbor-cli/cmd/harbor/root/labels" "github.com/goharbor/harbor-cli/cmd/harbor/root/ldap" @@ -195,6 +196,10 @@ harbor help cmd.GroupID = "system" root.AddCommand(cmd) + cmd = gc.GC() + cmd.GroupID = "system" + root.AddCommand(cmd) + cmd = schedule.Schedule() cmd.GroupID = "system" root.AddCommand(cmd) diff --git a/cmd/harbor/root/gc/cmd.go b/cmd/harbor/root/gc/cmd.go new file mode 100644 index 000000000..5f84badda --- /dev/null +++ b/cmd/harbor/root/gc/cmd.go @@ -0,0 +1,38 @@ +// 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 gc + +import ( + "github.com/spf13/cobra" +) + +func GC() *cobra.Command { + cmd := &cobra.Command{ + Use: "gc", + Short: "Manage Garbage Collection", + Long: "Manage Garbage Collection in Harbor (schedule, history, logs)", + } + + cmd.AddCommand( + ListGCCommand(), + GetGCLogCommand(), + ViewGCScheduleCommand(), + UpdateGCScheduleCommand(), + RunGCCommand(), + StopGCCommand(), + ) + + return cmd +} diff --git a/cmd/harbor/root/gc/list.go b/cmd/harbor/root/gc/list.go new file mode 100644 index 000000000..cf902457b --- /dev/null +++ b/cmd/harbor/root/gc/list.go @@ -0,0 +1,128 @@ +// 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 gc + +import ( + "fmt" + + "github.com/goharbor/harbor-cli/pkg/api" + "github.com/goharbor/harbor-cli/pkg/utils" + "github.com/goharbor/harbor-cli/pkg/views/gc" + log "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var validGCSortFields = []string{ + "creation_time", + "update_time", + "id", + "job_status", +} + +var validGCQueryKeys = []string{ + "id", + "status", +} + +func ListGCCommand() *cobra.Command { + var ( + opts api.ListFlags + sort []string + fuzzy []string + match []string + ranges []string + ) + + cmd := &cobra.Command{ + Use: "list", + Short: "List GC history", + Long: `List GC (Garbage Collection) history in Harbor. + +This command displays a list of GC executions with their status, creation time, +and other details. You can control the output using pagination flags and format options. + +Examples: + # List GC history with default pagination (page 1, 10 items per page) + harbor gc list + + # List GC history with custom pagination + harbor gc list --page 2 --page-size 20 + + # List GC history with sorting by creation time (newest first) + harbor gc list --sort -creation_time + + # List GC history with multiple sort fields + harbor gc list --sort creation_time --sort -update_time + + # Filter GC history by status (exact match) + harbor gc list --match status=Success`, + Args: cobra.ExactArgs(0), + RunE: func(cmd *cobra.Command, args []string) error { + if opts.PageSize > 100 { + return fmt.Errorf("page size should be less than or equal to 100") + } + + if len(sort) > 0 { + sortParam, err := utils.BuildSortParam(sort, validGCSortFields) + if err != nil { + return err + } + opts.Sort = sortParam + } + + if len(fuzzy) != 0 || len(match) != 0 || len(ranges) != 0 { + q, qErr := utils.BuildQueryParam(fuzzy, match, ranges, validGCQueryKeys) + if qErr != nil { + return qErr + } + opts.Q = q + } + + history, err := api.GetGCHistory(opts) + if err != nil { + return fmt.Errorf("failed to get GC history: %v", utils.ParseHarborErrorMsg(err)) + } + + if len(history) == 0 { + log.Info("No GC history found") + return nil + } + + formatFlag := viper.GetString("output-format") + if formatFlag != "" { + err = utils.PrintFormat(history, formatFlag) + if err != nil { + return err + } + } else { + if err := gc.ListGC(history); err != nil { + return err + } + } + return nil + }, + } + + flags := cmd.Flags() + flags.Int64VarP(&opts.Page, "page", "p", 1, "Page number") + flags.Int64VarP(&opts.PageSize, "page-size", "s", 10, "Size of per page") + flags.StringSliceVar(&sort, "sort", nil, "Sort the resource list (e.g. --sort creation_time --sort -update_time)") + flags.StringSliceVar(&fuzzy, "fuzzy", nil, "Fuzzy match filter (key=value)") + flags.StringSliceVar(&match, "match", nil, "Exact match filter (key=value)") + flags.StringSliceVar(&ranges, "range", nil, "Range filter (key=min~max)") + + return cmd +} diff --git a/cmd/harbor/root/gc/log.go b/cmd/harbor/root/gc/log.go new file mode 100644 index 000000000..2e1b1858f --- /dev/null +++ b/cmd/harbor/root/gc/log.go @@ -0,0 +1,70 @@ +// 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 gc + +import ( + "fmt" + + "github.com/goharbor/harbor-cli/pkg/api" + "github.com/goharbor/harbor-cli/pkg/prompt" + "github.com/spf13/cobra" +) + +func GetGCLogCommand() *cobra.Command { + var gcID int64 + + cmd := &cobra.Command{ + Use: "log", + Short: "Get GC job log", + Long: `Get the log of a specific GC (Garbage Collection) job. + +If no GC job ID is provided via the --id flag, an interactive selector +will be displayed to choose from available GC jobs. + +Examples: + # Get GC log by specifying the job ID + harbor gc log --id 42 + + # Get GC log interactively (select from list) + harbor gc log`, + Args: cobra.ExactArgs(0), + RunE: func(cmd *cobra.Command, args []string) error { + var err error + + if gcID < 0 { + return fmt.Errorf("invalid GC job ID: %d. ID must be a positive number", gcID) + } + + if gcID == 0 { + gcID, err = prompt.GetGCJobIDFromUser() + if err != nil { + return err + } + } + + logData, err := api.GetGCJobLog(gcID) + if err != nil { + return fmt.Errorf("failed to get GC log: %v", err) + } + + fmt.Println(logData) + return nil + }, + } + + cmd.Flags().Int64Var(&gcID, "id", 0, "ID of the GC job to get logs for") + + return cmd +} diff --git a/cmd/harbor/root/gc/run.go b/cmd/harbor/root/gc/run.go new file mode 100644 index 000000000..c245c9746 --- /dev/null +++ b/cmd/harbor/root/gc/run.go @@ -0,0 +1,65 @@ +// 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 gc + +import ( + "fmt" + + "github.com/goharbor/go-client/pkg/sdk/v2.0/models" + "github.com/goharbor/harbor-cli/pkg/api" + "github.com/goharbor/harbor-cli/pkg/utils" + log "github.com/sirupsen/logrus" + "github.com/spf13/cobra" +) + +func RunGCCommand() *cobra.Command { + var dryRun, deleteUntagged bool + var workers int + + cmd := &cobra.Command{ + Use: "run", + Short: "Run Garbage Collection manually", + Args: cobra.ExactArgs(0), + RunE: func(cmd *cobra.Command, args []string) error { + scheduleObj := models.ScheduleObj{ + Type: "Manual", + } + + params := map[string]interface{}{ + "dry_run": dryRun, + "delete_untagged": deleteUntagged, + "workers": workers, + } + + scheduleBody := &models.Schedule{ + Schedule: &scheduleObj, + Parameters: params, + } + + err := api.CreateGCSchedule(scheduleBody) + if err != nil { + return fmt.Errorf("failed to start GC: %v", utils.ParseHarborErrorMsg(err)) + } + log.Info("GC started successfully") + return nil + }, + } + + cmd.Flags().BoolVarP(&dryRun, "dry-run", "", false, "Simulate GC without deleting artifacts") + cmd.Flags().BoolVarP(&deleteUntagged, "delete-untagged", "", true, "Delete untagged artifacts") + cmd.Flags().IntVar(&workers, "workers", 1, "Number of workers for GC job") + + return cmd +} diff --git a/cmd/harbor/root/gc/stop.go b/cmd/harbor/root/gc/stop.go new file mode 100644 index 000000000..fa43e8c0f --- /dev/null +++ b/cmd/harbor/root/gc/stop.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 gc + +import ( + "errors" + "fmt" + "strings" + + "github.com/goharbor/harbor-cli/pkg/api" + "github.com/goharbor/harbor-cli/pkg/prompt" + "github.com/goharbor/harbor-cli/pkg/utils" + "github.com/sirupsen/logrus" + "github.com/spf13/cobra" +) + +func StopGCCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "stop", + Short: "Stop a running GC job", + Long: `Stop a running Garbage Collection job in Harbor. + +This command displays a list of currently running or pending GC jobs and +allows you to select one to stop. Only jobs with status "running" or "pending" +can be stopped. + +Examples: + # Stop a running GC job interactively + harbor-cli gc stop + +Notes: + - Only jobs that are currently running or pending can be stopped + - Jobs that have already completed cannot be stopped + - Use 'harbor-cli gc list' to view all GC jobs and their statuses`, + Args: cobra.MaximumNArgs(0), + RunE: func(cmd *cobra.Command, args []string) error { + gcID, err := prompt.GetRunningGCJobIDFromUser() + if err != nil { + if errors.Is(err, prompt.ErrNoRunningGCJobs) { + logrus.Info(err.Error()) + return nil + } + return fmt.Errorf("failed to select GC job: %w", err) + } + + logrus.Infof("Stopping GC job %d", gcID) + err = api.StopGC(gcID) + if err != nil { + errMsg := utils.ParseHarborErrorMsg(err) + if strings.Contains(errMsg, "404") { + return fmt.Errorf("GC job %d not found or already completed", gcID) + } + if strings.Contains(errMsg, "400") { + return fmt.Errorf("GC job %d cannot be stopped (may have already completed)", gcID) + } + return fmt.Errorf("failed to stop GC job %d: %v", gcID, errMsg) + } + + logrus.Infof("Successfully stopped GC job %d", gcID) + return nil + }, + } + + return cmd +} diff --git a/cmd/harbor/root/gc/update_schedule.go b/cmd/harbor/root/gc/update_schedule.go new file mode 100644 index 000000000..fdb9669ea --- /dev/null +++ b/cmd/harbor/root/gc/update_schedule.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 gc + +import ( + "fmt" + "strings" + + "github.com/goharbor/go-client/pkg/sdk/v2.0/models" + "github.com/goharbor/harbor-cli/pkg/api" + "github.com/goharbor/harbor-cli/pkg/utils" + "github.com/goharbor/harbor-cli/pkg/views/gc/update" + "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + "golang.org/x/text/cases" + "golang.org/x/text/language" +) + +var predefinedCron = map[string]string{ + "Hourly": "0 0 * * * *", + "Daily": "0 0 0 * * *", + "Weekly": "0 0 0 * * 0", +} + +func UpdateGCScheduleCommand() *cobra.Command { + var scheduleType string + var cron string + + cmd := &cobra.Command{ + Use: "update-schedule", + Short: "update-schedule [schedule-type: none|hourly|daily|weekly|custom]", + Long: `Configure or update the automatic GC schedule. + +Available schedule types: + - none: Disable automatic GC + - hourly: Run GC every hour + - daily: Run GC once per day + - weekly: Run GC once per week + - custom: Define a custom schedule using a cron expression`, + Aliases: []string{"us"}, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + scheduleType = cases.Title(language.English).String(strings.ToLower(args[0])) + + logrus.Infof("Updating GC schedule to type: %s", scheduleType) + + switch scheduleType { + case "None": + return updateGCScheduleToNone() + case "Hourly", "Daily", "Weekly": + return updateGCPredefinedSchedule(scheduleType) + case "Custom": + return updateGCCustomSchedule(cron) + default: + return fmt.Errorf("invalid schedule type: %s. Valid types are: none, hourly, daily, weekly, custom", args[0]) + } + }, + } + + flags := cmd.Flags() + flags.StringVar(&cron, "cron", "", "Cron expression for custom schedule (include the expression in double quotes)") + + return cmd +} + +func updateGCScheduleToNone() error { + schedule := &models.Schedule{ + Schedule: &models.ScheduleObj{Type: "None"}, + } + err := api.UpdateGCSchedule(schedule) + if err != nil { + return fmt.Errorf("failed to disable GC schedule: %v", utils.ParseHarborErrorMsg(err)) + } + logrus.Info("Successfully disabled GC schedule") + return nil +} + +func updateGCPredefinedSchedule(scheduleType string) error { + schedule := &models.Schedule{ + Schedule: &models.ScheduleObj{ + Type: scheduleType, + Cron: predefinedCron[scheduleType], + }, + } + + err := api.UpdateGCSchedule(schedule) + if err != nil { + return fmt.Errorf("failed to update GC schedule: %v", utils.ParseHarborErrorMsg(err)) + } + logrus.Info("Successfully updated GC schedule") + return nil +} + +func updateGCCustomSchedule(cron string) error { + if cron == "" { + logrus.Info("Opening interactive form for custom schedule configuration") + if err := update.UpdateSchedule(&cron); err != nil { + return err + } + } + + var err error + cron, err = utils.ValidateCron(cron) + if err != nil { + return err + } + + schedule := &models.Schedule{ + Schedule: &models.ScheduleObj{ + Type: "Custom", + Cron: cron, + }, + } + + err = api.UpdateGCSchedule(schedule) + if err != nil { + errMsg := utils.ParseHarborErrorMsg(err) + if strings.Contains(errMsg, "400") { + return fmt.Errorf("invalid cron expression: Harbor rejected the schedule. Use the standard 6-field format (seconds minute hour day month weekday)") + } + return fmt.Errorf("failed to update GC schedule: %v", errMsg) + } + logrus.Infof("Successfully updated GC schedule with custom cron expression: %s", cron) + return nil +} diff --git a/cmd/harbor/root/gc/view_schedule.go b/cmd/harbor/root/gc/view_schedule.go new file mode 100644 index 000000000..b6ab2e42b --- /dev/null +++ b/cmd/harbor/root/gc/view_schedule.go @@ -0,0 +1,57 @@ +// 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 gc + +import ( + "fmt" + + "github.com/goharbor/harbor-cli/pkg/api" + "github.com/goharbor/harbor-cli/pkg/utils" + "github.com/goharbor/harbor-cli/pkg/views/gc/schedule" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +func ViewGCScheduleCommand() *cobra.Command { + return &cobra.Command{ + Use: "schedule", + Short: "Display the GC schedule", + Args: cobra.MaximumNArgs(0), + RunE: func(cmd *cobra.Command, args []string) error { + scheduleWrapper, err := api.GetGCSchedule() + if err != nil { + return fmt.Errorf("failed to get GC schedule: %v", utils.ParseHarborErrorMsg(err)) + } + + if scheduleWrapper == nil || scheduleWrapper.Schedule == nil { + fmt.Println("No GC schedule set.") + return nil + } + + formatFlag := viper.GetString("output-format") + if formatFlag != "" { + err = utils.PrintFormat(scheduleWrapper, formatFlag) + if err != nil { + return err + } + } else { + if err := schedule.ViewGCSchedule(scheduleWrapper); err != nil { + return err + } + } + return nil + }, + } +} diff --git a/doc/cli-docs/harbor-gc-list.md b/doc/cli-docs/harbor-gc-list.md new file mode 100644 index 000000000..4bda629bc --- /dev/null +++ b/doc/cli-docs/harbor-gc-list.md @@ -0,0 +1,61 @@ +--- +title: harbor gc list +weight: 60 +--- +## harbor gc list + +### Description + +##### List GC history + +### Synopsis + +List GC (Garbage Collection) history in Harbor. + +This command displays a list of GC executions with their status, creation time, +and other details. You can control the output using pagination flags and format options. + +Examples: + # List GC history with default pagination (page 1, 10 items per page) + harbor gc list + + # List GC history with custom pagination + harbor gc list --page 2 --page-size 20 + + # List GC history with sorting by creation time (newest first) + harbor gc list --sort -creation_time + + # List GC history with multiple sort fields + harbor gc list --sort creation_time --sort -update_time + + # Filter GC history by status (exact match) + harbor gc list --match status=Success + +```sh +harbor gc list [flags] +``` + +### Options + +```sh + --fuzzy strings Fuzzy match filter (key=value) + -h, --help help for list + --match strings Exact match filter (key=value) + -p, --page int Page number (default 1) + -s, --page-size int Size of per page (default 10) + --range strings Range filter (key=min~max) + --sort strings Sort the resource list (e.g. --sort creation_time --sort -update_time) +``` + +### 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|csv + -v, --verbose verbose output +``` + +### SEE ALSO + +* [harbor gc](harbor-gc.md) - Manage Garbage Collection + diff --git a/doc/cli-docs/harbor-gc-log.md b/doc/cli-docs/harbor-gc-log.md new file mode 100644 index 000000000..afffbd844 --- /dev/null +++ b/doc/cli-docs/harbor-gc-log.md @@ -0,0 +1,47 @@ +--- +title: harbor gc log +weight: 70 +--- +## harbor gc log + +### Description + +##### Get GC job log + +### Synopsis + +Get the log of a specific GC (Garbage Collection) job. + +If no GC job ID is provided via the --id flag, an interactive selector +will be displayed to choose from available GC jobs. + +Examples: + # Get GC log by specifying the job ID + harbor gc log --id 42 + + # Get GC log interactively (select from list) + harbor gc log + +```sh +harbor gc log [flags] +``` + +### Options + +```sh + -h, --help help for log + --id int ID of the GC job to get logs for +``` + +### 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|csv + -v, --verbose verbose output +``` + +### SEE ALSO + +* [harbor gc](harbor-gc.md) - Manage Garbage Collection + diff --git a/doc/cli-docs/harbor-gc-run.md b/doc/cli-docs/harbor-gc-run.md new file mode 100644 index 000000000..b5c74d02f --- /dev/null +++ b/doc/cli-docs/harbor-gc-run.md @@ -0,0 +1,35 @@ +--- +title: harbor gc run +weight: 55 +--- +## harbor gc run + +### Description + +##### Run Garbage Collection manually + +```sh +harbor gc run [flags] +``` + +### Options + +```sh + --delete-untagged Delete untagged artifacts (default true) + --dry-run Simulate GC without deleting artifacts + -h, --help help for run + --workers int Number of workers for GC job (default 1) +``` + +### 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|csv + -v, --verbose verbose output +``` + +### SEE ALSO + +* [harbor gc](harbor-gc.md) - Manage Garbage Collection + diff --git a/doc/cli-docs/harbor-gc-schedule.md b/doc/cli-docs/harbor-gc-schedule.md new file mode 100644 index 000000000..02675e821 --- /dev/null +++ b/doc/cli-docs/harbor-gc-schedule.md @@ -0,0 +1,32 @@ +--- +title: harbor gc schedule +weight: 30 +--- +## harbor gc schedule + +### Description + +##### Display the GC schedule + +```sh +harbor gc schedule [flags] +``` + +### Options + +```sh + -h, --help help for schedule +``` + +### 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|csv + -v, --verbose verbose output +``` + +### SEE ALSO + +* [harbor gc](harbor-gc.md) - Manage Garbage Collection + diff --git a/doc/cli-docs/harbor-gc-stop.md b/doc/cli-docs/harbor-gc-stop.md new file mode 100644 index 000000000..47fa02338 --- /dev/null +++ b/doc/cli-docs/harbor-gc-stop.md @@ -0,0 +1,49 @@ +--- +title: harbor gc stop +weight: 75 +--- +## harbor gc stop + +### Description + +##### Stop a running GC job + +### Synopsis + +Stop a running Garbage Collection job in Harbor. + +This command displays a list of currently running or pending GC jobs and +allows you to select one to stop. Only jobs with status "running" or "pending" +can be stopped. + +Examples: + # Stop a running GC job interactively + harbor-cli gc stop + +Notes: + - Only jobs that are currently running or pending can be stopped + - Jobs that have already completed cannot be stopped + - Use 'harbor-cli gc list' to view all GC jobs and their statuses + +```sh +harbor gc stop [flags] +``` + +### Options + +```sh + -h, --help help for stop +``` + +### 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|csv + -v, --verbose verbose output +``` + +### SEE ALSO + +* [harbor gc](harbor-gc.md) - Manage Garbage Collection + diff --git a/doc/cli-docs/harbor-gc-update-schedule.md b/doc/cli-docs/harbor-gc-update-schedule.md new file mode 100644 index 000000000..16f18b4dc --- /dev/null +++ b/doc/cli-docs/harbor-gc-update-schedule.md @@ -0,0 +1,44 @@ +--- +title: harbor gc update schedule +weight: 70 +--- +## harbor gc update-schedule + +### Description + +##### update-schedule [schedule-type: none|hourly|daily|weekly|custom] + +### Synopsis + +Configure or update the automatic GC schedule. + +Available schedule types: + - none: Disable automatic GC + - hourly: Run GC every hour + - daily: Run GC once per day + - weekly: Run GC once per week + - custom: Define a custom schedule using a cron expression + +```sh +harbor gc update-schedule [flags] +``` + +### Options + +```sh + --cron string Cron expression for custom schedule (include the expression in double quotes) + -h, --help help for update-schedule +``` + +### 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|csv + -v, --verbose verbose output +``` + +### SEE ALSO + +* [harbor gc](harbor-gc.md) - Manage Garbage Collection + diff --git a/doc/cli-docs/harbor-gc.md b/doc/cli-docs/harbor-gc.md new file mode 100644 index 000000000..b26e2e5f1 --- /dev/null +++ b/doc/cli-docs/harbor-gc.md @@ -0,0 +1,38 @@ +--- +title: harbor gc +weight: 75 +--- +## harbor gc + +### Description + +##### Manage Garbage Collection + +### Synopsis + +Manage Garbage Collection in Harbor (schedule, history, logs) + +### Options + +```sh + -h, --help help for gc +``` + +### 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|csv + -v, --verbose verbose output +``` + +### SEE ALSO + +* [harbor](harbor.md) - Official Harbor CLI +* [harbor gc list](harbor-gc-list.md) - List GC history +* [harbor gc log](harbor-gc-log.md) - Get GC job log +* [harbor gc run](harbor-gc-run.md) - Run Garbage Collection manually +* [harbor gc schedule](harbor-gc-schedule.md) - Display the GC schedule +* [harbor gc stop](harbor-gc-stop.md) - Stop a running GC job +* [harbor gc update-schedule](harbor-gc-update-schedule.md) - update-schedule [schedule-type: none|hourly|daily|weekly|custom] + diff --git a/doc/cli-docs/harbor.md b/doc/cli-docs/harbor.md index 5dd4d604e..076723624 100644 --- a/doc/cli-docs/harbor.md +++ b/doc/cli-docs/harbor.md @@ -39,6 +39,7 @@ harbor help * [harbor config](harbor-config.md) - Manage system configurations * [harbor context](harbor-context.md) - Manage locally available contexts * [harbor cve-allowlist](harbor-cve-allowlist.md) - Manage system CVE allowlist +* [harbor gc](harbor-gc.md) - Manage Garbage Collection * [harbor health](harbor-health.md) - Get the health status of Harbor components * [harbor info](harbor-info.md) - Display detailed Harbor system, statistics, and CLI environment information * [harbor instance](harbor-instance.md) - Manage preheat provider instances in Harbor diff --git a/doc/man-docs/man1/harbor-gc-list.1 b/doc/man-docs/man1/harbor-gc-list.1 new file mode 100644 index 000000000..e18599712 --- /dev/null +++ b/doc/man-docs/man1/harbor-gc-list.1 @@ -0,0 +1,84 @@ +.nh +.TH "HARBOR" "1" "Harbor Community" "Harbor User Manuals" + +.SH NAME +harbor-gc-list - List GC history + + +.SH SYNOPSIS +\fBharbor gc list [flags]\fP + + +.SH DESCRIPTION +List GC (Garbage Collection) history in Harbor. + +.PP +This command displays a list of GC executions with their status, creation time, +and other details. You can control the output using pagination flags and format options. + +.PP +Examples: + # List GC history with default pagination (page 1, 10 items per page) + harbor gc list + +.PP +# List GC history with custom pagination + harbor gc list --page 2 --page-size 20 + +.PP +# List GC history with sorting by creation time (newest first) + harbor gc list --sort -creation_time + +.PP +# List GC history with multiple sort fields + harbor gc list --sort creation_time --sort -update_time + +.PP +# Filter GC history by status (exact match) + harbor gc list --match status=Success + + +.SH OPTIONS +\fB--fuzzy\fP=[] + Fuzzy match filter (key=value) + +.PP +\fB-h\fP, \fB--help\fP[=false] + help for list + +.PP +\fB--match\fP=[] + Exact match filter (key=value) + +.PP +\fB-p\fP, \fB--page\fP=1 + Page number + +.PP +\fB-s\fP, \fB--page-size\fP=10 + Size of per page + +.PP +\fB--range\fP=[] + Range filter (key=min~max) + +.PP +\fB--sort\fP=[] + Sort the resource list (e.g. --sort creation_time --sort -update_time) + + +.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|csv + +.PP +\fB-v\fP, \fB--verbose\fP[=false] + verbose output + + +.SH SEE ALSO +\fBharbor-gc(1)\fP \ No newline at end of file diff --git a/doc/man-docs/man1/harbor-gc-log.1 b/doc/man-docs/man1/harbor-gc-log.1 new file mode 100644 index 000000000..416b5ada3 --- /dev/null +++ b/doc/man-docs/man1/harbor-gc-log.1 @@ -0,0 +1,52 @@ +.nh +.TH "HARBOR" "1" "Harbor Community" "Harbor User Manuals" + +.SH NAME +harbor-gc-log - Get GC job log + + +.SH SYNOPSIS +\fBharbor gc log [flags]\fP + + +.SH DESCRIPTION +Get the log of a specific GC (Garbage Collection) job. + +.PP +If no GC job ID is provided via the --id flag, an interactive selector +will be displayed to choose from available GC jobs. + +.PP +Examples: + # Get GC log by specifying the job ID + harbor gc log --id 42 + +.PP +# Get GC log interactively (select from list) + harbor gc log + + +.SH OPTIONS +\fB-h\fP, \fB--help\fP[=false] + help for log + +.PP +\fB--id\fP=0 + ID of the GC job to get logs for + + +.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|csv + +.PP +\fB-v\fP, \fB--verbose\fP[=false] + verbose output + + +.SH SEE ALSO +\fBharbor-gc(1)\fP \ No newline at end of file diff --git a/doc/man-docs/man1/harbor-gc-run.1 b/doc/man-docs/man1/harbor-gc-run.1 new file mode 100644 index 000000000..a07101951 --- /dev/null +++ b/doc/man-docs/man1/harbor-gc-run.1 @@ -0,0 +1,47 @@ +.nh +.TH "HARBOR" "1" "Harbor Community" "Harbor User Manuals" + +.SH NAME +harbor-gc-run - Run Garbage Collection manually + + +.SH SYNOPSIS +\fBharbor gc run [flags]\fP + + +.SH DESCRIPTION +Run Garbage Collection manually + + +.SH OPTIONS +\fB--delete-untagged\fP[=true] + Delete untagged artifacts + +.PP +\fB--dry-run\fP[=false] + Simulate GC without deleting artifacts + +.PP +\fB-h\fP, \fB--help\fP[=false] + help for run + +.PP +\fB--workers\fP=1 + Number of workers for GC job + + +.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|csv + +.PP +\fB-v\fP, \fB--verbose\fP[=false] + verbose output + + +.SH SEE ALSO +\fBharbor-gc(1)\fP \ No newline at end of file diff --git a/doc/man-docs/man1/harbor-gc-schedule.1 b/doc/man-docs/man1/harbor-gc-schedule.1 new file mode 100644 index 000000000..8de3b8579 --- /dev/null +++ b/doc/man-docs/man1/harbor-gc-schedule.1 @@ -0,0 +1,35 @@ +.nh +.TH "HARBOR" "1" "Harbor Community" "Harbor User Manuals" + +.SH NAME +harbor-gc-schedule - Display the GC schedule + + +.SH SYNOPSIS +\fBharbor gc schedule [flags]\fP + + +.SH DESCRIPTION +Display the GC schedule + + +.SH OPTIONS +\fB-h\fP, \fB--help\fP[=false] + help for schedule + + +.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|csv + +.PP +\fB-v\fP, \fB--verbose\fP[=false] + verbose output + + +.SH SEE ALSO +\fBharbor-gc(1)\fP \ No newline at end of file diff --git a/doc/man-docs/man1/harbor-gc-stop.1 b/doc/man-docs/man1/harbor-gc-stop.1 new file mode 100644 index 000000000..71a438cc5 --- /dev/null +++ b/doc/man-docs/man1/harbor-gc-stop.1 @@ -0,0 +1,51 @@ +.nh +.TH "HARBOR" "1" "Harbor Community" "Harbor User Manuals" + +.SH NAME +harbor-gc-stop - Stop a running GC job + + +.SH SYNOPSIS +\fBharbor gc stop [flags]\fP + + +.SH DESCRIPTION +Stop a running Garbage Collection job in Harbor. + +.PP +This command displays a list of currently running or pending GC jobs and +allows you to select one to stop. Only jobs with status "running" or "pending" +can be stopped. + +.PP +Examples: + # Stop a running GC job interactively + harbor-cli gc stop + +.PP +Notes: + - Only jobs that are currently running or pending can be stopped + - Jobs that have already completed cannot be stopped + - Use 'harbor-cli gc list' to view all GC jobs and their statuses + + +.SH OPTIONS +\fB-h\fP, \fB--help\fP[=false] + help for stop + + +.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|csv + +.PP +\fB-v\fP, \fB--verbose\fP[=false] + verbose output + + +.SH SEE ALSO +\fBharbor-gc(1)\fP \ No newline at end of file diff --git a/doc/man-docs/man1/harbor-gc-update-schedule.1 b/doc/man-docs/man1/harbor-gc-update-schedule.1 new file mode 100644 index 000000000..88a7c6578 --- /dev/null +++ b/doc/man-docs/man1/harbor-gc-update-schedule.1 @@ -0,0 +1,47 @@ +.nh +.TH "HARBOR" "1" "Harbor Community" "Harbor User Manuals" + +.SH NAME +harbor-gc-update-schedule - update-schedule [schedule-type: none|hourly|daily|weekly|custom] + + +.SH SYNOPSIS +\fBharbor gc update-schedule [flags]\fP + + +.SH DESCRIPTION +Configure or update the automatic GC schedule. + +.PP +Available schedule types: + - none: Disable automatic GC + - hourly: Run GC every hour + - daily: Run GC once per day + - weekly: Run GC once per week + - custom: Define a custom schedule using a cron expression + + +.SH OPTIONS +\fB--cron\fP="" + Cron expression for custom schedule (include the expression in double quotes) + +.PP +\fB-h\fP, \fB--help\fP[=false] + help for update-schedule + + +.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|csv + +.PP +\fB-v\fP, \fB--verbose\fP[=false] + verbose output + + +.SH SEE ALSO +\fBharbor-gc(1)\fP \ No newline at end of file diff --git a/doc/man-docs/man1/harbor-gc.1 b/doc/man-docs/man1/harbor-gc.1 new file mode 100644 index 000000000..572d41a2e --- /dev/null +++ b/doc/man-docs/man1/harbor-gc.1 @@ -0,0 +1,35 @@ +.nh +.TH "HARBOR" "1" "Harbor Community" "Harbor User Manuals" + +.SH NAME +harbor-gc - Manage Garbage Collection + + +.SH SYNOPSIS +\fBharbor gc [flags]\fP + + +.SH DESCRIPTION +Manage Garbage Collection in Harbor (schedule, history, logs) + + +.SH OPTIONS +\fB-h\fP, \fB--help\fP[=false] + help for gc + + +.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|csv + +.PP +\fB-v\fP, \fB--verbose\fP[=false] + verbose output + + +.SH SEE ALSO +\fBharbor(1)\fP, \fBharbor-gc-list(1)\fP, \fBharbor-gc-log(1)\fP, \fBharbor-gc-run(1)\fP, \fBharbor-gc-schedule(1)\fP, \fBharbor-gc-stop(1)\fP, \fBharbor-gc-update-schedule(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 17de0d7f8..10eb82d7a 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-vulnerability(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-gc(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/gc_handler.go b/pkg/api/gc_handler.go new file mode 100644 index 000000000..4c4853762 --- /dev/null +++ b/pkg/api/gc_handler.go @@ -0,0 +1,134 @@ +// 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/gc" + "github.com/goharbor/go-client/pkg/sdk/v2.0/models" + "github.com/goharbor/harbor-cli/pkg/utils" + log "github.com/sirupsen/logrus" +) + +// GetGCHistory gets the GC history +func GetGCHistory(opts ListFlags) ([]*models.GCHistory, error) { + ctx, client, err := utils.ContextWithClient() + if err != nil { + return nil, err + } + + params := &gc.GetGCHistoryParams{ + Page: &opts.Page, + PageSize: &opts.PageSize, + Q: &opts.Q, + Sort: &opts.Sort, + } + + resp, err := client.GC.GetGCHistory(ctx, params) + if err != nil { + return nil, err + } + + return resp.Payload, nil +} + +// GetGCJobLog gets the log of a specific GC job +func GetGCJobLog(id int64) (string, error) { + ctx, client, err := utils.ContextWithClient() + if err != nil { + return "", err + } + + resp, err := client.GC.GetGCLog(ctx, &gc.GetGCLogParams{ + GCID: id, + }) + if err != nil { + return "", err + } + + return resp.Payload, nil +} + +// GetGCSchedule gets the GC schedule +func GetGCSchedule() (*models.GCHistory, error) { + ctx, client, err := utils.ContextWithClient() + if err != nil { + return nil, err + } + + resp, err := client.GC.GetGCSchedule(ctx, &gc.GetGCScheduleParams{}) + if err != nil { + return nil, err + } + + return resp.Payload, nil +} + +// CreateGCSchedule creates a GC schedule +func CreateGCSchedule(schedule *models.Schedule) error { + ctx, client, err := utils.ContextWithClient() + if err != nil { + return err + } + + _, err = client.GC.CreateGCSchedule(ctx, &gc.CreateGCScheduleParams{ + Schedule: schedule, + }) + + if err != nil { + return err + } + + log.Info("GC schedule created successfully") + return nil +} + +// UpdateGCSchedule updates the GC schedule +// Modified to take *models.Schedule allowing passing parameters +func UpdateGCSchedule(schedule *models.Schedule) error { + ctx, client, err := utils.ContextWithClient() + if err != nil { + return err + } + + _, err = client.GC.UpdateGCSchedule(ctx, &gc.UpdateGCScheduleParams{ + Schedule: schedule, + }) + + if err != nil { + return err + } + + log.Info("GC schedule updated successfully") + return nil +} + +// StopGC stops a running GC job by ID +func StopGC(gcID int64) error { + ctx, client, err := utils.ContextWithClient() + if err != nil { + return err + } + + _, err = client.GC.StopGC(ctx, &gc.StopGCParams{ + GCID: gcID, + }) + + if err != nil { + return err + } + + log.Infof("GC job %d stopped successfully", gcID) + return nil +} diff --git a/pkg/prompt/prompt.go b/pkg/prompt/prompt.go index f21932e5f..df3d4a226 100644 --- a/pkg/prompt/prompt.go +++ b/pkg/prompt/prompt.go @@ -37,6 +37,7 @@ import ( rpolicies "github.com/goharbor/harbor-cli/pkg/views/replication/policies/select" rtasks "github.com/goharbor/harbor-cli/pkg/views/replication/task/select" + gcview "github.com/goharbor/harbor-cli/pkg/views/gc/select" repoView "github.com/goharbor/harbor-cli/pkg/views/repository/select" retview "github.com/goharbor/harbor-cli/pkg/views/retention/select" robotView "github.com/goharbor/harbor-cli/pkg/views/robot/select" @@ -46,6 +47,8 @@ import ( log "github.com/sirupsen/logrus" ) +var ErrNoRunningGCJobs = errors.New("no running GC jobs found to stop") + func GetRegistryNameFromUser() int64 { registryId := make(chan int64) go func() { @@ -467,3 +470,81 @@ func GetRetentionTagRule(retentionID string) int64 { }() return <-retentionIndex } + +func GetGCJobIDFromUser() (int64, error) { + type result struct { + id int64 + err error + } + resultChan := make(chan result) + + go func() { + opts := api.ListFlags{Page: 1, PageSize: 100} + history, err := api.GetGCHistory(opts) + if err != nil { + resultChan <- result{0, err} + return + } + + if len(history) == 0 { + resultChan <- result{0, errors.New("no GC jobs found")} + return + } + + id, err := gcview.GCJobList(history) + if err != nil { + if err == gcview.ErrUserAborted { + resultChan <- result{0, errors.New("user aborted GC job selection")} + } else { + resultChan <- result{0, fmt.Errorf("error during GC job selection: %w", err)} + } + return + } + + resultChan <- result{id, nil} + }() + + res := <-resultChan + return res.id, res.err +} + +func GetRunningGCJobIDFromUser() (int64, error) { + type result struct { + id int64 + err error + } + resultChan := make(chan result) + + go func() { + opts := api.ListFlags{ + Page: 1, + PageSize: 100, + Q: "status={Running Pending In_Progress}", + } + history, err := api.GetGCHistory(opts) + if err != nil { + resultChan <- result{0, err} + return + } + + if len(history) == 0 { + resultChan <- result{0, ErrNoRunningGCJobs} + return + } + + id, err := gcview.GCJobList(history) + if err != nil { + if err == gcview.ErrUserAborted { + resultChan <- result{0, errors.New("user aborted GC job selection")} + } else { + resultChan <- result{0, fmt.Errorf("error during GC job selection: %w", err)} + } + return + } + + resultChan <- result{id, nil} + }() + + res := <-resultChan + return res.id, res.err +} diff --git a/pkg/utils/cron.go b/pkg/utils/cron.go new file mode 100644 index 000000000..17fdbdecc --- /dev/null +++ b/pkg/utils/cron.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 utils + +import ( + "errors" + "fmt" + "strings" + + "github.com/sirupsen/logrus" +) + +// ValidateCron validates and normalizes a cron expression for Harbor. +// Harbor requires 6-field cron format (seconds minute hour day month weekday). +// If a 5-field expression is provided, it prepends '0' for seconds. +func ValidateCron(cron string) (string, error) { + if cron == "" { + return "", errors.New("cron expression cannot be empty") + } + fields := strings.Fields(cron) + if len(fields) < 6 { + if len(fields) == 5 { + logrus.Infof("Converting 5-field cron to 6-field by adding '0' for seconds") + return fmt.Sprintf("0 %s", cron), nil + } + return "", fmt.Errorf("harbor requires 6-field cron format (seconds minute hour day month weekday)") + } + if len(fields) > 6 { + return "", fmt.Errorf("too many fields in cron expression, expected 6 but got %d", len(fields)) + } + return cron, nil +} diff --git a/pkg/utils/cron_test.go b/pkg/utils/cron_test.go new file mode 100644 index 000000000..699cfb0db --- /dev/null +++ b/pkg/utils/cron_test.go @@ -0,0 +1,153 @@ +// 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 utils + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestValidateCron_Empty(t *testing.T) { + result, err := ValidateCron("") + assert.Error(t, err) + assert.Equal(t, "", result) + assert.Contains(t, err.Error(), "cron expression cannot be empty") +} + +func TestValidateCron_Valid6Field(t *testing.T) { + cron := "0 0 12 * * *" + result, err := ValidateCron(cron) + assert.NoError(t, err) + assert.Equal(t, cron, result) +} + +func TestValidateCron_Valid6FieldWithSeconds(t *testing.T) { + cron := "30 15 10 * * 1" + result, err := ValidateCron(cron) + assert.NoError(t, err) + assert.Equal(t, cron, result) +} + +func TestValidateCron_5FieldConversion(t *testing.T) { + fiveFieldCron := "0 12 * * 1" + expectedResult := "0 " + fiveFieldCron + + result, err := ValidateCron(fiveFieldCron) + assert.NoError(t, err) + assert.Equal(t, expectedResult, result) +} + +func TestValidateCron_TooFewFields(t *testing.T) { + cron := "0 12 *" + result, err := ValidateCron(cron) + assert.Error(t, err) + assert.Equal(t, "", result) + assert.Contains(t, err.Error(), "6-field cron format") +} + +func TestValidateCron_TooManyFields(t *testing.T) { + cron := "0 0 12 * * * *" + result, err := ValidateCron(cron) + assert.Error(t, err) + assert.Equal(t, "", result) + assert.Contains(t, err.Error(), "too many fields") +} + +func TestValidateCron_SingleField(t *testing.T) { + cron := "0" + result, err := ValidateCron(cron) + assert.Error(t, err) + assert.Equal(t, "", result) + assert.Contains(t, err.Error(), "6-field cron format") +} + +func TestValidateCron_Weekly(t *testing.T) { + cron := "0 0 * * 0" + result, err := ValidateCron(cron) + assert.NoError(t, err) + assert.Equal(t, "0 0 0 * * 0", result) +} + +func TestValidateCron_Monthly(t *testing.T) { + cron := "0 0 1 * *" + result, err := ValidateCron(cron) + assert.NoError(t, err) + assert.Equal(t, "0 0 0 1 * *", result) +} + +func TestValidateCron_Daily(t *testing.T) { + cron := "0 0 * * *" + result, err := ValidateCron(cron) + assert.NoError(t, err) + assert.Equal(t, "0 0 0 * * *", result) +} + +func TestValidateCron_Hourly(t *testing.T) { + cron := "* * * * *" + result, err := ValidateCron(cron) + assert.NoError(t, err) + assert.Equal(t, "0 * * * * *", result) +} + +func TestValidateCron_NonStandardSeconds(t *testing.T) { + cron := "30 * * * * *" + result, err := ValidateCron(cron) + assert.NoError(t, err) + assert.Equal(t, cron, result) +} + +func TestValidateCron_Midnight(t *testing.T) { + cron := "0 0 0 * * *" + result, err := ValidateCron(cron) + assert.NoError(t, err) + assert.Equal(t, cron, result) +} + +func TestValidateCron_FieldsCount(t *testing.T) { + testCases := []struct { + name string + cron string + expectError bool + }{ + {"0 fields", "", true}, + {"1 field", "0", true}, + {"2 fields", "0 0", true}, + {"3 fields", "0 0 0", true}, + {"4 fields", "0 0 0 0", true}, + {"5 fields - daily", "0 0 * * *", false}, + {"5 fields - weekly", "0 0 * * 0", false}, + {"5 fields - monthly", "0 0 1 * *", false}, + {"6 fields", "0 0 0 * * *", false}, + {"7 fields", "0 0 0 * * * 0", true}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result, err := ValidateCron(tc.cron) + if tc.expectError { + assert.Error(t, err, "Expected error for cron: %s", tc.cron) + assert.Equal(t, "", result) + } else { + assert.NoError(t, err, "Expected no error for cron: %s", tc.cron) + if result != "" { + fields := strings.Fields(result) + assert.Len(t, fields, 6, "Result should have 6 fields") + } + } + }) + } +} diff --git a/pkg/utils/sort.go b/pkg/utils/sort.go new file mode 100644 index 000000000..b9e09990b --- /dev/null +++ b/pkg/utils/sort.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 utils + +import ( + "fmt" + "strings" +) + +// BuildSortParam validates sort fields against allowed fields and returns +// a comma-separated sort string for the API. +// Each sort field can optionally be prefixed with '-' for descending order. +func BuildSortParam(sortFields []string, validFields []string) (string, error) { + for _, field := range sortFields { + // Strip leading '-' (descending indicator) for validation + fieldName := strings.TrimPrefix(field, "-") + if err := validateKey(fieldName, validFields); err != nil { + return "", fmt.Errorf("invalid sort field: %s, supported fields are: %s", fieldName, strings.Join(validFields, ", ")) + } + } + + return strings.Join(sortFields, ","), nil +} diff --git a/pkg/views/gc/schedule/view.go b/pkg/views/gc/schedule/view.go new file mode 100644 index 000000000..b8584dbd7 --- /dev/null +++ b/pkg/views/gc/schedule/view.go @@ -0,0 +1,64 @@ +// 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 schedule + +import ( + "fmt" + + "github.com/charmbracelet/bubbles/table" + tea "github.com/charmbracelet/bubbletea" + "github.com/goharbor/go-client/pkg/sdk/v2.0/models" + "github.com/goharbor/harbor-cli/pkg/utils" + "github.com/goharbor/harbor-cli/pkg/views/base/tablelist" +) + +var columns = []table.Column{ + {Title: "Type", Width: tablelist.WidthM}, + {Title: "Cron", Width: tablelist.WidthL}, + {Title: "Next Scheduled Time", Width: tablelist.WidthXXL}, + {Title: "Creation Time", Width: tablelist.WidthXXL}, + {Title: "Update Time", Width: tablelist.WidthXXL}, +} + +func ViewGCSchedule(schedule *models.GCHistory) error { + var rows []table.Row + + cronStr := "" + nextScheduledTime := "" + scheduleType := "" + if schedule.Schedule != nil { + scheduleType = schedule.Schedule.Type + cronStr = schedule.Schedule.Cron + nextScheduledTime = schedule.Schedule.NextScheduledTime.String() + } + + creationTime, _ := utils.FormatCreatedTime(schedule.CreationTime.String()) + updateTime, _ := utils.FormatCreatedTime(schedule.UpdateTime.String()) + + rows = append(rows, table.Row{ + scheduleType, + cronStr, + nextScheduledTime, + creationTime, + updateTime, + }) + + m := tablelist.NewModel(columns, rows, len(rows)) + + if _, err := tea.NewProgram(m).Run(); err != nil { + return fmt.Errorf("error running program: %w", err) + } + return nil +} diff --git a/pkg/views/gc/select/view.go b/pkg/views/gc/select/view.go new file mode 100644 index 000000000..6f7c26137 --- /dev/null +++ b/pkg/views/gc/select/view.go @@ -0,0 +1,59 @@ +// 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 gcselect + +import ( + "errors" + "fmt" + + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" + "github.com/goharbor/go-client/pkg/sdk/v2.0/models" + "github.com/goharbor/harbor-cli/pkg/utils" + "github.com/goharbor/harbor-cli/pkg/views/base/selection" +) + +var ErrUserAborted = errors.New("user aborted selection") + +func GCJobList(history []*models.GCHistory) (int64, error) { + items := make([]list.Item, len(history)) + jobsMap := make(map[string]int64) + + for i, job := range history { + creationTime, _ := utils.FormatCreatedTime(job.CreationTime.String()) + displayName := fmt.Sprintf("ID: %d | Status: %s | Created: %s", + job.ID, job.JobStatus, creationTime) + items[i] = selection.Item(displayName) + jobsMap[displayName] = job.ID + } + + m := selection.NewModel(items, "GC Job") + + p, err := tea.NewProgram(m, tea.WithAltScreen()).Run() + if err != nil { + return 0, fmt.Errorf("error running selection program: %w", err) + } + + if model, ok := p.(selection.Model); ok { + if model.Aborted { + return 0, ErrUserAborted + } + if model.Choice == "" { + return 0, errors.New("no GC job selected") + } + return jobsMap[model.Choice], nil + } + + return 0, errors.New("unexpected program result") +} diff --git a/pkg/views/gc/update/view.go b/pkg/views/gc/update/view.go new file mode 100644 index 000000000..a2968479f --- /dev/null +++ b/pkg/views/gc/update/view.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 update + +import ( + "errors" + "fmt" + "regexp" + "strings" + + "github.com/charmbracelet/huh" +) + +func UpdateSchedule(cron *string) error { + theme := huh.ThemeCharm() + err := huh.NewForm( + huh.NewGroup( + huh.NewInput(). + Title("Enter the cron expression"). + Description("Standard 6-field cron format: second minute hour day-of-month month day-of-week"). + Placeholder("0 0 0 * * *"). // Daily at midnight with seconds + Value(cron). + Validate(validateCronExpression), + ), + ).WithTheme(theme).Run() + + if err != nil { + return err + } + return nil +} + +func validateCronExpression(cron string) error { + if cron == "" { + return errors.New("cron expression cannot be empty") + } + fields := strings.Fields(cron) + if len(fields) != 6 { + if len(fields) == 5 { + return fmt.Errorf("you entered a 5-field cron expression, but Harbor requires 6 fields (with seconds)\n"+ + "Please add a seconds field at the beginning. For example: '0 %s'", cron) + } + return fmt.Errorf("harbor requires exactly 6 fields in cron expressions (seconds minute hour day month weekday), got %d", len(fields)) + } + cronRegex := regexp.MustCompile(`^(\*|[0-9]|[1-5][0-9]|\*/[0-9]+) (\*|[0-9]|[1-5][0-9]|\*/[0-9]+) (\*|[0-9]|1[0-9]|2[0-3]|\*/[0-9]+) (\*|[1-9]|[12][0-9]|3[01]|\*/[0-9]+) (\*|[1-9]|1[0-2]|\*/[0-9]+) (\*|[0-6]|\*/[0-9]+)$`) + if !cronRegex.MatchString(cron) { + return errors.New("invalid cron expression format\n" + + "Examples:\n" + + " 0 0 0 * * * - Daily at midnight\n" + + " 0 0 */6 * * * - Every 6 hours\n" + + " 0 0 0 * * 0 - Weekly on Sunday at midnight") + } + return nil +} diff --git a/pkg/views/gc/view.go b/pkg/views/gc/view.go new file mode 100644 index 000000000..a7f74d5ba --- /dev/null +++ b/pkg/views/gc/view.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 gc + +import ( + "encoding/json" + "fmt" + "strconv" + + "github.com/charmbracelet/bubbles/table" + tea "github.com/charmbracelet/bubbletea" + "github.com/goharbor/go-client/pkg/sdk/v2.0/models" + "github.com/goharbor/harbor-cli/pkg/utils" + "github.com/goharbor/harbor-cli/pkg/views/base/tablelist" +) + +type GCJobParams struct { + DryRun bool `json:"dry_run"` + DeleteUntagged bool `json:"delete_untagged"` +} + +var columns = []table.Column{ + {Title: "ID", Width: 10}, + {Title: "Status", Width: 15}, + {Title: "Dry Run", Width: 10}, + {Title: "Creation Time", Width: 25}, + {Title: "Update Time", Width: 25}, +} + +func ListGC(history []*models.GCHistory) error { + var rows []table.Row + for _, job := range history { + creationTime, _ := utils.FormatCreatedTime(job.CreationTime.String()) + updateTime, _ := utils.FormatCreatedTime(job.UpdateTime.String()) + dryRun := "false" + + if job.JobParameters != "" { + var params GCJobParams + if err := json.Unmarshal([]byte(job.JobParameters), ¶ms); err == nil { + dryRun = strconv.FormatBool(params.DryRun) + } + } + + // Note: JobParameters is usually a JSON string. For simplicity we display it as is or handle parsing if needed. + // Usually contains {"dry_run": true/false} + + rows = append(rows, table.Row{ + strconv.FormatInt(job.ID, 10), + job.JobStatus, + dryRun, + creationTime, + updateTime, + }) + } + + m := tablelist.NewModel(columns, rows, len(rows)) + + if _, err := tea.NewProgram(m).Run(); err != nil { + return fmt.Errorf("error running program: %w", err) + } + return nil +}