diff --git a/controllers/v1beta1/build_helpers.go b/controllers/v1beta1/build_helpers.go index bdd7d2f..3d9346f 100644 --- a/controllers/v1beta1/build_helpers.go +++ b/controllers/v1beta1/build_helpers.go @@ -212,7 +212,7 @@ func (r *LagoonBuildReconciler) getOrCreateNamespace(ctx context.Context, namesp return fmt.Errorf("Error getting harbor version, check your harbor configuration. Error was: %v", err) } if lagoonHarbor.UseV2Functions(curVer) { - hProject, err := lagoonHarbor.CreateProjectV2(ctx, lagoonBuild.Spec.Project.Name) + hProject, err := lagoonHarbor.CreateProjectV2(ctx, *namespace) if err != nil { return fmt.Errorf("Error creating harbor project: %v", err) } diff --git a/go.mod b/go.mod index 88770ed..0ddf376 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.20 require ( github.com/cheshir/go-mq/v2 v2.0.1 github.com/coreos/go-semver v0.3.1 + github.com/cxmcc/unixsums v0.0.0-20131125091133-89564297d82f github.com/go-logr/logr v1.2.4 github.com/google/go-cmp v0.5.9 github.com/hashicorp/go-version v1.6.0 diff --git a/go.sum b/go.sum index 53473ad..9e9ffa3 100644 --- a/go.sum +++ b/go.sum @@ -334,6 +334,8 @@ github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsr github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.11/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/cxmcc/unixsums v0.0.0-20131125091133-89564297d82f h1:PkAFGgVtJnasAxOaiEY1RYPx8W+7X7l66vi8T2apKCM= +github.com/cxmcc/unixsums v0.0.0-20131125091133-89564297d82f/go.mod h1:XJq7OckzkOtlgeEKFwkH2gFbc1+1WRFUBf7QnvfyrzQ= github.com/cyphar/filepath-securejoin v0.2.2/go.mod h1:FpkQEhXnPnOthhzymB7CGsFk2G9VLXONKD9G7QGMM+4= github.com/cyphar/filepath-securejoin v0.2.3/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4= github.com/d2g/dhcp4 v0.0.0-20170904100407-a1d1b6c41b1c/go.mod h1:Ct2BUK8SB0YC1SMSibvLzxjeJLnrYEVLULFNiHY9YfQ= diff --git a/internal/harbor/harbor22x.go b/internal/harbor/harbor22x.go index bea48ad..29a06aa 100644 --- a/internal/harbor/harbor22x.go +++ b/internal/harbor/harbor22x.go @@ -16,7 +16,8 @@ import ( ) // CreateProjectV2 will create a project if one doesn't exist, but will update as required. -func (h *Harbor) CreateProjectV2(ctx context.Context, projectName string) (*harborclientv5model.Project, error) { +func (h *Harbor) CreateProjectV2(ctx context.Context, namespace corev1.Namespace) (*harborclientv5model.Project, error) { + projectName := namespace.Labels["lagoon.sh/project"] exists, err := h.ClientV5.ProjectExists(ctx, projectName) if err != nil { h.Log.Info(fmt.Sprintf("Error checking project %s exists, err: %v", projectName, err)) @@ -278,6 +279,14 @@ func (h *Harbor) DeleteRepository(ctx context.Context, projectName, branch strin if err != nil { h.Log.Info(fmt.Sprintf("Error deleting harbor repository %s", repo.Name)) } + h.Log.Info( + fmt.Sprintf( + "Deleted harbor repository %s in project %s, environment %s", + repo.Name, + projectName, + environmentName, + ), + ) } } if len(listRepositories) > 100 { @@ -293,6 +302,14 @@ func (h *Harbor) DeleteRepository(ctx context.Context, projectName, branch strin if err != nil { h.Log.Info(fmt.Sprintf("Error deleting harbor repository %s", repo.Name)) } + h.Log.Info( + fmt.Sprintf( + "Deleted harbor repository %s in project %s, environment %s", + repo.Name, + projectName, + environmentName, + ), + ) } } } @@ -321,6 +338,14 @@ func (h *Harbor) DeleteRobotAccount(ctx context.Context, projectName, branch str h.Log.Info(fmt.Sprintf("Error deleting project %s robot account %s", projectName, robot.Name)) return } + h.Log.Info( + fmt.Sprintf( + "Deleted harbor robot account %s in project %s, environment %s", + robot.Name, + projectName, + environmentName, + ), + ) } } } diff --git a/internal/harbor/harbor_credentialrotation.go b/internal/harbor/harbor_credentialrotation.go index 5256e31..1acb2f0 100644 --- a/internal/harbor/harbor_credentialrotation.go +++ b/internal/harbor/harbor_credentialrotation.go @@ -94,7 +94,7 @@ func (h *Harbor) RotateRobotCredential(ctx context.Context, cl client.Client, ns return false, fmt.Errorf("error checking harbor version: %v", err) } if h.UseV2Functions(curVer) { - hProject, err := h.CreateProjectV2(ctx, ns.Labels["lagoon.sh/project"]) + hProject, err := h.CreateProjectV2(ctx, ns) if err != nil { return false, fmt.Errorf("error getting or creating project: %v", err) } diff --git a/internal/helpers/helpers_cron.go b/internal/helpers/helpers_cron.go new file mode 100644 index 0000000..a7fbb7b --- /dev/null +++ b/internal/helpers/helpers_cron.go @@ -0,0 +1,331 @@ +package helpers + +import ( + "fmt" + "math" + "regexp" + "strconv" + "strings" + + "github.com/cxmcc/unixsums/cksum" +) + +func ConvertCrontab(seedstring, cron string) (string, error) { + // Seed is used to generate pseudo random numbers. + // The seed is based on the seedstring, so will not change + seed := cksum.Cksum([]byte(fmt.Sprintf("%s\n", seedstring))) + var minutes, hours, days, months, dayweek string + splitCron := strings.Split(cron, " ") + // check the provided cron splits into 5 + if len(splitCron) == 5 { + for idx, val := range splitCron { + if idx == 0 { + match1, _ := regexp.MatchString("^(M|H)$", val) + if match1 { + // If just an `M` or `H` (for backwards compatibility) is defined, we + // generate a pseudo random minute. + minutes = strconv.Itoa(int(math.Mod(float64(seed), 60))) + continue + } + match2, _ := regexp.MatchString("^(M|H|\\*)/([0-5]?[0-9])$", val) + if match2 { + // A Minute like M/15 (or H/15 or */15 for backwards compatibility) is defined, create a list of minutes with a random start + // like 4,19,34,49 or 6,21,36,51 + params := getCaptureBlocks("^(?PM|H|\\*)/(?P[0-5]?[0-9])$", val) + step, err := strconv.Atoi(params["P2"]) + if err != nil { + return "", fmt.Errorf("cron definition '%s' is invalid, unable to determine minutes value", cron) + } + counter := int(math.Mod(float64(seed), float64(step))) + var minutesArr []string + for counter < 60 { + minutesArr = append(minutesArr, fmt.Sprintf("%d", counter)) + counter += step + } + minutes = strings.Join(minutesArr, ",") + continue + } + if isInCSVRange(val, 0, 59) { + // A minute like 0,10,15,30,59 + minutes = val + continue + } + if isInRange(val, 0, 59) { + // A minute like 0-59 + minutes = val + continue + } + if val == "*" { + // otherwise pass the * through + minutes = val + continue + } + // if the value is not valid, return an error with where the issue is + if minutes == "" { + return "", fmt.Errorf("cron definition '%s' is invalid, unable to determine minutes value", cron) + } + } + if idx == 1 { + match1, _ := regexp.MatchString("^H$", val) + if match1 { + // If just an `H` is defined, we generate a pseudo random hour. + hours = strconv.Itoa(int(math.Mod(float64(seed), 24))) + continue + } + match2, _ := regexp.MatchString("^H\\(([01]?[0-9]|2[0-3])-([01]?[0-9]|2[0-3])\\)$", val) + if match2 { + // If H is defined with a given range, example: H(2-4), we generate a random hour between 2-4 + params := getCaptureBlocks("^H\\((?P[01]?[0-9]|2[0-3])-(?P[01]?[0-9]|2[0-3])\\)$", val) + hFrom, err := strconv.Atoi(params["P1"]) + if err != nil { + return "", fmt.Errorf("cron definition '%s' is invalid, unable to determine hours value", cron) + } + hTo, err := strconv.Atoi(params["P2"]) + if err != nil { + return "", fmt.Errorf("cron definition '%s' is invalid, unable to determine hours value", cron) + } + if hFrom < hTo { + // Example: HOUR_FROM: 2, HOUR_TO: 4 + // Calculate the difference between the two hours (in example will be 2) + maxDiff := float64(hTo - hFrom) + // Generate a difference based on the SEED (in example will be 0, 1 or 2) + diff := int(math.Mod(float64(seed), maxDiff)) + // Add the generated difference to the FROM hour (in example will be 2, 3 or 4) + hours = strconv.Itoa(hFrom + diff) + continue + } + if hFrom > hTo { + // If the FROM is larger than the TO, we have a range like 22-2 + // Calculate the difference between the two hours with a 24 hour jump (in example will be 4) + maxDiff := float64(24 - hFrom + hTo) + // Generate a difference based on the SEED (in example will be 0, 1, 2, 3 or 4) + diff := int(math.Mod(float64(seed), maxDiff)) + // Add the generated difference to the FROM hour (in example will be 22, 23, 24, 25 or 26) + if hFrom+diff >= 24 { + // If the hour is higher than 24, we subtract 24 to handle the midnight change + hours = strconv.Itoa(hFrom + diff - 24) + continue + } + hours = strconv.Itoa(hFrom + diff) + continue + } + if hFrom == hTo { + hours = strconv.Itoa(hFrom) + continue + } + } + if isInCSVRange(val, 0, 23) { + hours = val + continue + } + if isInRange(val, 0, 23) { + hours = val + continue + } + if val == "*" { + hours = val + continue + } + // if the value is not valid, return an error with where the issue is + if hours == "" { + return "", fmt.Errorf("cron definition '%s' is invalid, unable to determine hours value", cron) + } + } + if idx == 2 { + match1, _ := regexp.MatchString("^D$", val) + if match1 { + // If just an `D` is defined, we generate a pseudo random day of the month, but only generated up to 31 + // so february is never skipped + days = strconv.Itoa(int(math.Mod(float64(seed), 32))) + // days can't be 0, support 1-31 only + if days == "0" { + days = "1" + } + continue + } + match2, _ := regexp.MatchString("^D\\(([01]?[0-9]|2[0-9]|3[0-1])-([01]?[0-9]|2[0-9]|3[0-1])\\)$", val) + if match2 { + // If D is defined with a given range, example: D(2-4), we generate a random day of the month between 2-4 + params := getCaptureBlocks("^D\\((?P[01]?[0-9]|2[0-9]|3[0-1])-(?P[01]?[0-9]|2[0-9]|3[0-1])\\)$", val) + hFrom, err := strconv.Atoi(params["P1"]) + if err != nil { + return "", fmt.Errorf("cron definition '%s' is invalid, unable to determine day of month value", cron) + } + if hFrom == 0 { + return "", fmt.Errorf("cron definition '%s' is invalid, unable to determine day of month value, starting day can't be 0", cron) + } + hTo, err := strconv.Atoi(params["P2"]) + if err != nil { + return "", fmt.Errorf("cron definition '%s' is invalid, unable to determine day of month value", cron) + } + if hFrom < hTo { + maxDiff := float64(hTo - hFrom) + diff := int(math.Mod(float64(seed), maxDiff)) + days = strconv.Itoa(hFrom + diff) + continue + } + if hFrom > hTo { + maxDiff := float64(29 - hFrom + hTo) + diff := int(math.Mod(float64(seed), maxDiff)) + if hFrom+diff >= 29 { + days = strconv.Itoa(hFrom + diff - 29) + continue + } + days = strconv.Itoa(hFrom + diff) + continue + } + if hFrom == hTo { + days = strconv.Itoa(hFrom) + continue + } + } + if isInCSVRange(val, 1, 31) { + days = val + continue + } + if isInRange(val, 1, 31) { + days = val + continue + } + if val == "*" { + days = val + continue + } + // if the value is not valid, return an error with where the issue is + if days == "" { + return "", fmt.Errorf("cron definition '%s' is invalid, unable to determine days value", cron) + } + } + if idx == 3 { + if isInCSVRange(val, 1, 12) { + months = val + continue + } + if isInRange(val, 1, 12) { + months = val + continue + } + if val == "*" { + months = val + continue + } + // if the value is not valid, return an error with where the issue is + if months == "" { + return "", fmt.Errorf("cron definition '%s' is invalid, unable to determine months value", cron) + } + } + if idx == 4 { + match1, _ := regexp.MatchString("^D$", val) + if match1 { + // If just an `D` is defined, we generate a pseudo random day of the week. + dayweek = strconv.Itoa(int(math.Mod(float64(seed), 6))) + continue + } + match2, _ := regexp.MatchString("^D\\(([0-6])-([0-6])\\)$", val) + if match2 { + // If D is defined with a given range, example: D(2-4), we generate a random day of the week between 2-4 + params := getCaptureBlocks("^D\\((?P[0-6])-(?P[0-6])\\)$", val) + hFrom, err := strconv.Atoi(params["P1"]) + if err != nil { + return "", fmt.Errorf("cron definition '%s' is invalid, unable to determine day value", cron) + } + hTo, err := strconv.Atoi(params["P2"]) + if err != nil { + return "", fmt.Errorf("cron definition '%s' is invalid, unable to determine day value", cron) + } + if hFrom < hTo { + maxDiff := float64(hTo - hFrom) + diff := int(math.Mod(float64(seed), maxDiff)) + dayweek = strconv.Itoa(hFrom + diff) + continue + } + if hFrom > hTo { + maxDiff := float64(6 - hFrom + hTo) + diff := int(math.Mod(float64(seed), maxDiff)) + if hFrom+diff >= 6 { + dayweek = strconv.Itoa(hFrom + diff - 6) + continue + } + dayweek = strconv.Itoa(hFrom + diff) + continue + } + if hFrom == hTo { + dayweek = strconv.Itoa(hFrom) + continue + } + } + if isInCSVRange(val, 0, 6) { + dayweek = val + continue + } + if isInRange(val, 0, 6) { + dayweek = val + continue + } + if val == "*" { + dayweek = val + continue + } + // if the value is not valid, return an error with where the issue is + if dayweek == "" { + return "", fmt.Errorf("cron definition '%s' is invalid, unable to determine day(week) value", cron) + } + } + } + return fmt.Sprintf("%v %v %v %v %v", minutes, hours, days, months, dayweek), nil + } + return "", fmt.Errorf("cron definition '%s' is invalid", cron) +} + +func getCaptureBlocks(regex, val string) (captureMap map[string]string) { + var regexComp = regexp.MustCompile(regex) + match := regexComp.FindStringSubmatch(val) + captureMap = make(map[string]string) + for i, name := range regexComp.SubexpNames() { + if i > 0 && i <= len(match) { + captureMap[name] = match[i] + } + } + return captureMap +} + +// check if the provided cron time definition is a valid `1,2,4,8` type range +func isInCSVRange(s string, min, max int) bool { + items := strings.Split(s, ",") + for _, val := range items { + num, err := strconv.Atoi(val) + if err != nil { + // not a number, return false + return false + } + if num < min || num > max { + // outside range, return false + return false + } + } + return true +} + +// check if the provided cron time definition is a valid `1-2` type range +func isInRange(s string, min, max int) bool { + items := strings.Split(s, "-") + if len(items) > 2 || len(items) < 1 { + // too many or not enough items split by - + return false + } + hFrom, err := strconv.Atoi(items[0]) + if err != nil { + // not a number or error checking if it is, return false + return false + } + hTo, err := strconv.Atoi(items[1]) + if err != nil { + // not a number or error checking if it is, return false + return false + } + if hFrom > hTo || hFrom < min || hFrom > max || hTo < min || hTo > max { + // numbers in range are not in valid format of LOW-HIGH + return false + } + return true +} diff --git a/internal/helpers/helpers_cron_test.go b/internal/helpers/helpers_cron_test.go new file mode 100644 index 0000000..187189a --- /dev/null +++ b/internal/helpers/helpers_cron_test.go @@ -0,0 +1,236 @@ +package helpers + +import ( + "testing" +) + +func TestConvertCrontab(t *testing.T) { + type args struct { + namespace string + cron string + } + tests := []struct { + name string + args args + want string + wantErr bool + }{ + { + name: "test1", + args: args{ + namespace: "example-com-main", + cron: "M * * * *", + }, + want: "31 * * * *", + }, + { + name: "test2", + args: args{ + namespace: "example-com-main", + cron: "M/5 * * * *", + }, + want: "1,6,11,16,21,26,31,36,41,46,51,56 * * * *", + }, + { + name: "test3", + args: args{ + namespace: "example-com-main", + cron: "M H(2-4) * * *", + }, + want: "31 3 * * *", + }, + { + name: "test4", + args: args{ + namespace: "example-com-main", + cron: "M H(22-2) * * *", + }, + want: "31 1 * * *", + }, + { + name: "test5", + args: args{ + namespace: "example-com-main", + cron: "M/15 H(22-2) * * *", + }, + want: "1,16,31,46 1 * * *", + }, + { + name: "test6 - invalid minutes definition", + args: args{ + namespace: "example-com-main", + cron: "M/H5 H(22-2) * * *", + }, + wantErr: true, + }, + { + name: "test7 - invalid hour definiton", + args: args{ + namespace: "example-com-main", + cron: "M/15 H(H2-2) * * *", + }, + wantErr: true, + }, + { + name: "test8", + args: args{ + namespace: "example-com-main", + cron: "M/15 H(22-2) 3,5 * *", + }, + want: "1,16,31,46 1 3,5 * *", + }, + { + name: "test9", + args: args{ + namespace: "example-com-main", + cron: "M/15 H(22-2) * 10-12 *", + }, + want: "1,16,31,46 1 * 10-12 *", + }, + { + name: "test10 - invalid dayofweek range", + args: args{ + namespace: "example-com-main", + cron: "M/15 H(22-2) * * 1-8", + }, + wantErr: true, + }, + { + name: "test11", + args: args{ + namespace: "example-com-main", + cron: "15 * * * 1,2,3,6", + }, + want: "15 * * * 1,2,3,6", + }, + { + name: "test12", + args: args{ + namespace: "example-com-main", + cron: "15 * 1-31 * *", + }, + want: "15 * 1-31 * *", + }, + { + name: "test13 - invalid day range", + args: args{ + namespace: "example-com-main", + cron: "15 * 1-32 * *", + }, + wantErr: true, + }, + { + name: "test14 - set hours", + args: args{ + namespace: "example-com-main", + cron: "M/15 23 * * 0-5", + }, + want: "1,16,31,46 23 * * 0-5", + }, + { + name: "test15 - set day", + args: args{ + namespace: "example-com-main", + cron: "M/15 * 31 * 0-5", + }, + want: "1,16,31,46 * 31 * 0-5", + }, + { + name: "test16 - set month", + args: args{ + namespace: "example-com-main", + cron: "M/15 * * 11 0-5", + }, + want: "1,16,31,46 * * 11 0-5", + }, + { + name: "test17 - pick day of week between range", + args: args{ + namespace: "example-com-main", + cron: "M/15 * * * D(0-4)", + }, + want: "1,16,31,46 * * * 3", + }, + { + name: "test18 - pick day of week between range", + args: args{ + namespace: "example-com-main", + cron: "M/15 * * * D(2-6)", + }, + want: "1,16,31,46 * * * 5", + }, + { + name: "test19 - pick day of week random", + args: args{ + namespace: "example-com-main", + cron: "M/15 * * * D", + }, + want: "1,16,31,46 * * * 1", + }, + { + name: "test20 - pick day of month between range", + args: args{ + namespace: "example-com-main", + cron: "M/15 * D(1-31) * *", + }, + want: "1,16,31,46 * 2 * *", + }, + { + name: "test21 - pick day of month between range", + args: args{ + namespace: "example-com-main", + cron: "M/15 * D(15-25) * *", + }, + want: "1,16,31,46 * 16 * *", + }, + { + name: "test22 - pick day of month random", + args: args{ + namespace: "example-com-main", + cron: "M/15 * D * *", + }, + want: "1,16,31,46 * 27 * *", + }, + { + name: "test23 - pick day of month between range with invalid end day (only support 1-31)", + args: args{ + namespace: "example-com-main", + cron: "M/15 * D(1-32) * *", + }, + wantErr: true, + }, + { + name: "test24 - pick day of week between range with invalid end day (only support 0-6)", + args: args{ + namespace: "example-com-main", + cron: "M/15 * * * D(0-7)", + }, + wantErr: true, + }, + { + name: "test26 - pick day of month between range with invalid start day (only support 1-31)", + args: args{ + namespace: "example-com-main", + cron: "M/15 * D(0-28) * *", + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ConvertCrontab(tt.args.namespace, tt.args.cron) + if err != nil { + if !tt.wantErr { + t.Errorf("ConvertCrontab() error = %v, wantErr %v", err, tt.wantErr) + } + } + if got != tt.want { + if !tt.wantErr { + t.Errorf("ConvertCrontab() = %v, want %v", got, tt.want) + } else { + t.Errorf("ConvertCrontab() = %v, wantErr %v", got, tt.wantErr) + } + } + }) + } +} diff --git a/internal/messenger/consumer.go b/internal/messenger/consumer.go index a0b9085..b374729 100644 --- a/internal/messenger/consumer.go +++ b/internal/messenger/consumer.go @@ -294,14 +294,6 @@ func (m *Messenger) Consumer(targetName string) { //error { jobSpec := &lagoonv1beta1.LagoonTaskSpec{} json.Unmarshal(message.Body(), jobSpec) // check which key has been received - namespace := helpers.GenerateNamespaceName( - jobSpec.Project.NamespacePattern, // the namespace pattern or `openshiftProjectPattern` from Lagoon is never received by the controller - jobSpec.Environment.Name, - jobSpec.Project.Name, - m.NamespacePrefix, - m.ControllerNamespace, - m.RandomNamespacePrefix, - ) switch jobSpec.Key { case "deploytarget:build:cancel", "kubernetes:build:cancel": opLog.Info( @@ -309,11 +301,11 @@ func (m *Messenger) Consumer(targetName string) { //error { "Received build cancellation for project %s, environment %s - %s", jobSpec.Project.Name, jobSpec.Environment.Name, - namespace, + m.genNamespace(jobSpec), ), ) m.Cache.Add(jobSpec.Misc.Name, jobSpec.Project.Name) - err := m.CancelBuild(namespace, jobSpec) + err := m.CancelBuild(m.genNamespace(jobSpec), jobSpec) if err != nil { //@TODO: send msg back to lagoon and update task to failed? message.Ack(false) // ack to remove from queue @@ -325,11 +317,11 @@ func (m *Messenger) Consumer(targetName string) { //error { "Received task cancellation for project %s, environment %s - %s", jobSpec.Project.Name, jobSpec.Environment.Name, - namespace, + m.genNamespace(jobSpec), ), ) m.Cache.Add(jobSpec.Task.TaskName, jobSpec.Project.Name) - err := m.CancelTask(namespace, jobSpec) + err := m.CancelTask(m.genNamespace(jobSpec), jobSpec) if err != nil { //@TODO: send msg back to lagoon and update task to failed? message.Ack(false) // ack to remove from queue @@ -343,7 +335,7 @@ func (m *Messenger) Consumer(targetName string) { //error { jobSpec.Environment.Name, ), ) - err := m.ResticRestore(namespace, jobSpec) + err := m.ResticRestore(m.genNamespace(jobSpec), jobSpec) if err != nil { opLog.Error(err, fmt.Sprintf( @@ -363,7 +355,7 @@ func (m *Messenger) Consumer(targetName string) { //error { jobSpec.Project.Name, ), ) - err := m.IngressRouteMigration(namespace, jobSpec) + err := m.IngressRouteMigration(m.genNamespace(jobSpec), jobSpec) if err != nil { opLog.Error(err, fmt.Sprintf( @@ -383,7 +375,7 @@ func (m *Messenger) Consumer(targetName string) { //error { jobSpec.Project.Name, ), ) - err := m.AdvancedTask(namespace, jobSpec) + err := m.AdvancedTask(m.genNamespace(jobSpec), jobSpec) if err != nil { opLog.Error(err, fmt.Sprintf( @@ -403,7 +395,7 @@ func (m *Messenger) Consumer(targetName string) { //error { jobSpec.Project.Name, ), ) - err := m.ActiveStandbySwitch(namespace, jobSpec) + err := m.ActiveStandbySwitch(m.genNamespace(jobSpec), jobSpec) if err != nil { opLog.Error(err, fmt.Sprintf( @@ -416,6 +408,18 @@ func (m *Messenger) Consumer(targetName string) { //error { message.Ack(false) // ack to remove from queue return } + case "deploytarget:harborpolicy:update": + err := m.HarborPolicy(ctx, jobSpec) + if err != nil { + opLog.Error(err, + fmt.Sprintf( + "Harbor policy update for project %s failed", + jobSpec.Project.Name, + ), + ) + message.Ack(false) // ack to remove from queue + return + } default: // if we get something that we don't know about, spit out the entire message opLog.Info( @@ -433,3 +437,14 @@ func (m *Messenger) Consumer(targetName string) { //error { } <-forever } + +func (m *Messenger) genNamespace(jobSpec *lagoonv1beta1.LagoonTaskSpec) string { + return helpers.GenerateNamespaceName( + jobSpec.Project.NamespacePattern, // the namespace pattern or `openshiftProjectPattern` from Lagoon is never received by the controller + jobSpec.Environment.Name, + jobSpec.Project.Name, + m.NamespacePrefix, + m.ControllerNamespace, + m.RandomNamespacePrefix, + ) +} diff --git a/internal/messenger/messenger.go b/internal/messenger/messenger.go index 2ce0eee..44e567c 100644 --- a/internal/messenger/messenger.go +++ b/internal/messenger/messenger.go @@ -3,6 +3,7 @@ package messenger import ( "github.com/cheshir/go-mq/v2" "github.com/hashicorp/golang-lru/v2/expirable" + "github.com/uselagoon/remote-controller/internal/harbor" "github.com/uselagoon/remote-controller/internal/utilities/deletions" "sigs.k8s.io/controller-runtime/pkg/client" ) @@ -37,6 +38,7 @@ type Messenger struct { DeletionHandler *deletions.Deletions EnableDebug bool SupportK8upV2 bool + Harbor harbor.Harbor Cache *expirable.LRU[string, string] } @@ -53,6 +55,7 @@ func New(config mq.Config, deletionHandler *deletions.Deletions, enableDebug bool, supportK8upV2 bool, + harbor harbor.Harbor, cache *expirable.LRU[string, string], ) *Messenger { return &Messenger{ @@ -68,6 +71,7 @@ func New(config mq.Config, DeletionHandler: deletionHandler, EnableDebug: enableDebug, SupportK8upV2: supportK8upV2, + Harbor: harbor, Cache: cache, } } diff --git a/internal/messenger/tasks_harborpolicy.go b/internal/messenger/tasks_harborpolicy.go new file mode 100644 index 0000000..3ef7ec4 --- /dev/null +++ b/internal/messenger/tasks_harborpolicy.go @@ -0,0 +1,183 @@ +package messenger + +import ( + "context" + "encoding/json" + "fmt" + + harborclientv5model "github.com/mittwald/goharbor-client/v5/apiv2/model" + lagoonv1beta1 "github.com/uselagoon/remote-controller/apis/lagoon/v1beta1" + "github.com/uselagoon/remote-controller/internal/harbor" + "github.com/uselagoon/remote-controller/internal/helpers" + ctrl "sigs.k8s.io/controller-runtime" +) + +type RetentionEvent struct { + Type string `json:"type"` // defines the action type + EventType string `json:"eventType"` // defines the eventtype field in the event notification + Data Data `json:"data"` // contains the payload for the action, this could be any json so using a map +} + +type Data struct { + Project Project `json:"project"` + Policy HarborRetentionPolicy `json:"policy"` +} + +type Project struct { + ID int `json:"id"` + Name string `json:"name"` +} + +type HarborRetentionPolicy struct { + Enabled bool `json:"enabled"` + Rules []HarborRetentionRule `json:"rules"` + Schedule string `json:"schedule"` +} + +type HarborRetentionRule struct { + Name string `json:"name"` + Pattern string `json:"pattern"` + LatestPulled uint64 `json:"latestPulled"` +} + +// HarborPolicy handles harbor retention policy changes. +func (m *Messenger) HarborPolicy(ctx context.Context, jobSpec *lagoonv1beta1.LagoonTaskSpec) error { + opLog := ctrl.Log.WithName("handlers").WithName("LagoonTasks") + retPol := &RetentionEvent{} + if err := json.Unmarshal(jobSpec.Misc.MiscResource, retPol); err != nil { + return err + } + lagoonHarbor, err := harbor.New(m.Harbor) + if err != nil { + return err + } + projectName := retPol.Data.Project.Name + project, err := lagoonHarbor.ClientV5.GetProject(ctx, projectName) + if err != nil { + opLog.Info(fmt.Sprintf("Error getting project %s, err: %v", projectName, err)) + return err + } + // handle the creation and updating of retention policies as required + // create the retention policy as required + var retentionPolicy *harborclientv5model.RetentionPolicy + switch retPol.EventType { + case "updatePolicy": + opLog.Info( + fmt.Sprintf( + "Received harbor policy update for project %s", + projectName, + ), + ) + // if this is updating or adding a policy, handle that here + retentionPolicy, err = m.generateRetentionPolicy(int64(project.ProjectID), projectName, retPol.Data.Policy) + if err != nil { + opLog.Info(fmt.Sprintf("Error generating retention policy for project %s, err: %v", projectName, err)) + return err + } + case "removePolicy": + opLog.Info( + fmt.Sprintf( + "Received harbor policy removal for project %s", + projectName, + ), + ) + // add an empty retention policy + retentionPolicy = m.generateEmptyRetentionPolicy(int64(project.ProjectID)) + default: + return fmt.Errorf("unable to determine policy type") + } + // get the existing one if one exists + existingPolicy, err := lagoonHarbor.ClientV5.GetRetentionPolicyByProject(ctx, projectName) + if err != nil { + opLog.Info(fmt.Sprintf("Error getting retention policy %s: %v", project.Name, err)) + return err + } + if existingPolicy != nil { + retentionPolicy.ID = existingPolicy.ID + r1, _ := json.Marshal(existingPolicy) + r2, _ := json.Marshal(retentionPolicy) + // if the policy differs, then we need to update it with our new policy + if string(r1) != string(r2) { + err := lagoonHarbor.ClientV5.UpdateRetentionPolicy(ctx, retentionPolicy) + if err != nil { + f, _ := json.Marshal(err) + opLog.Info(fmt.Sprintf("Error updating retention policy %s: %v", project.Name, string(f))) + return err + } + } + } else { + // create it if it doesn't + if err := lagoonHarbor.ClientV5.NewRetentionPolicy(ctx, retentionPolicy); err != nil { + opLog.Info(fmt.Sprintf("Error creating retention policy %s: %v", project.Name, err)) + return err + } + } + return nil +} + +func (m *Messenger) generateRetentionPolicy(projectID int64, projectName string, policy HarborRetentionPolicy) (*harborclientv5model.RetentionPolicy, error) { + // generate a somewhat random schedule from the retention schedule template, using the harbor projectname as the seed + schedule, err := helpers.ConvertCrontab(projectName, policy.Schedule) + if err != nil { + return nil, fmt.Errorf("error generating retention schedule %s: %v", projectName, err) + } + retPol := &harborclientv5model.RetentionPolicy{ + Algorithm: "or", + Scope: &harborclientv5model.RetentionPolicyScope{ + Level: "project", + Ref: projectID, + }, + Rules: []*harborclientv5model.RetentionRule{}, + Trigger: &harborclientv5model.RetentionRuleTrigger{ + Kind: "Schedule", + Settings: map[string]string{ + "cron": fmt.Sprintf("0 %s", schedule), // harbor needs seconds :\ just add a 0 pad to the start of the schedule + }, + }, + } + for _, rule := range policy.Rules { + retPol.Rules = append(retPol.Rules, + &harborclientv5model.RetentionRule{ + Action: "retain", + Params: map[string]interface{}{ + "latestPulledN": rule.LatestPulled, + }, + ScopeSelectors: map[string][]harborclientv5model.RetentionSelector{ + "repository": { + { + Decoration: "repoMatches", + Kind: "doublestar", + Pattern: rule.Pattern, + }, + }, + }, + TagSelectors: []*harborclientv5model.RetentionSelector{ + { + Decoration: "matches", + Extras: "{\"untagged\":true}", + Kind: "doublestar", + Pattern: "**", + }, + }, + Template: "latestPulledN", + }, + ) + } + return retPol, nil +} + +func (m *Messenger) generateEmptyRetentionPolicy(projectID int64) *harborclientv5model.RetentionPolicy { + return &harborclientv5model.RetentionPolicy{ + Algorithm: "or", + Rules: []*harborclientv5model.RetentionRule{}, + Scope: &harborclientv5model.RetentionPolicyScope{ + Level: "project", + Ref: projectID, + }, + Trigger: &harborclientv5model.RetentionRuleTrigger{ + Kind: "Schedule", + Settings: map[string]string{ + "cron": "", + }, + }} +} diff --git a/main.go b/main.go index 60f4463..94db016 100644 --- a/main.go +++ b/main.go @@ -655,6 +655,7 @@ func main() { deletion, enableDebug, lffSupportK8UPv2, + harborConfig, cache, )