Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 129 additions & 1 deletion cmd/harbor/root/logs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -107,10 +133,112 @@ 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)

// --to-time alone is not allowed; if provided, --from-time must also be present
if from == "" && to != "" {
return "", fmt.Errorf("--to-time cannot be used without --from-time")
}

// If --from-time is present, use it with either provided --to-time or default to current time
if from != "" {
normalizedFrom, err := normalizeAuditTime(from)
if err != nil {
return "", fmt.Errorf("invalid --from-time: %w", err)
}

normalizedTo := to
if to == "" {
normalizedTo = time.Now().Format("2006-01-02 15:04:05")
} else {
var err error
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

Expand Down
153 changes: 153 additions & 0 deletions cmd/harbor/root/logs_test.go
Original file line number Diff line number Diff line change
@@ -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 root

import (
"regexp"
"testing"
)

func TestBuildAuditLogQuery(t *testing.T) {
tests := []struct {
name string
baseQuery string
operation string
resourceType string
resource string
username string
fromTime string
toTime string
expectedRx string // regex pattern to match (for times that vary)
wantErr bool
}{
{
name: "returns base query only",
baseQuery: "operation=push",
expectedRx: "^operation=push$",
},
{
name: "builds query with convenience filters",
baseQuery: "operation_result=true",
operation: "create_artifact",
resourceType: "artifact",
resource: "library/nginx",
username: "admin",
expectedRx: "^operation_result=true,operation=create_artifact,resource_type=artifact,resource=library/nginx,username=admin$",
},
{
name: "builds range query with both times specified",
fromTime: "2025-01-01T01:02:03Z",
toTime: "2025-01-01 05:06:07",
expectedRx: "^op_time=\\[2025-01-01 01:02:03~2025-01-01 05:06:07\\]$",
},
{
name: "from-time alone defaults to-time to current time",
fromTime: "2025-01-01T01:02:03Z",
expectedRx: "^op_time=\\[2025-01-01 01:02:03~.*\\]$", // matches any end time
},
{
name: "to-time alone is rejected",
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,
},
{
name: "fails for invalid to time",
fromTime: "2025-01-01T01:02:03Z",
toTime: "invalid-time",
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)
}

matched, _ := regexp.MatchString(tt.expectedRx, query)
if !matched {
t.Fatalf("expected query to match regex %q, got %q", tt.expectedRx, 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)
}
})
}
}