Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add custom per-repo interval in AppRepo #5599

Merged
merged 24 commits into from
Nov 17, 2022

Conversation

antgamdia
Copy link
Contributor

@antgamdia antgamdia commented Nov 2, 2022

Description of the change

This PR is adding a new field (Interval) in the AppRepository CRD. This field will override the global default crontab used for every repository, which is aligned with the recent Repositories API we defined.

The PR includes every required change across the components, namely:

  • Add Interval field in the CR definition.
  • Add this field when spinning up jobs in the apprepository-controller and use it; otherwise, use the default one.
  • Use this value in the AddRepo/UpdateRepo methods of the Repos API (Helm plugin)
  • Remove any workarounds preventing the UI from setting the interval in Helm repos.

Benefits

Helm repos can have customizable intervals now.

Possible drawbacks

IRL test is still pending. I don't know if adding a field in the CRD is breaking something, though we already added some fields in the past with no impact, but want to re-check just in case.

Applicable issues

Additional information

After the IRL test, some notes:

  • Helm does not upgrade the CRDs, so you have to delete and install them.
    • If the CRD is outdated, then the interval is not being saved, falling back to the previous, classical behavior.
  • One caveat is that the upgrade process: since we are creating a CronJob and passing the crontab value on creation via the apprepository controller, from the Repos API we don't have a clear way to edit the cronjob once generated.
    • In this PR we've addressed it by just disabling the field when upgrading

Signed-off-by: Antonio Gamez Diaz <agamez@vmware.com>
Signed-off-by: Antonio Gamez Diaz <agamez@vmware.com>
Signed-off-by: Antonio Gamez Diaz <agamez@vmware.com>
Signed-off-by: Antonio Gamez Diaz <agamez@vmware.com>
Signed-off-by: Antonio Gamez Diaz <agamez@vmware.com>
Signed-off-by: Antonio Gamez Diaz <agamez@vmware.com>
Signed-off-by: Antonio Gamez Diaz <agamez@vmware.com>
Signed-off-by: Antonio Gamez Diaz <agamez@vmware.com>
Signed-off-by: Antonio Gamez Diaz <agamez@vmware.com>
@netlify
Copy link

netlify bot commented Nov 2, 2022

Deploy Preview for kubeapps-dev ready!

Built without sensitive environment variables

Name Link
🔨 Latest commit 90b9ab0
🔍 Latest deploy log https://app.netlify.com/sites/kubeapps-dev/deploys/6376b3348c4c1900089a5916
😎 Deploy Preview https://deploy-preview-5599--kubeapps-dev.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify site settings.

Signed-off-by: Antonio Gamez Diaz <agamez@vmware.com>
Signed-off-by: Antonio Gamez Diaz <agamez@vmware.com>
Signed-off-by: Antonio Gamez Diaz <agamez@vmware.com>
Signed-off-by: Antonio Gamez Diaz <agamez@vmware.com>
@antgamdia antgamdia marked this pull request as ready for review November 3, 2022 16:44
Copy link
Contributor

@absoludity absoludity left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks Antonio, nice clean change :)

One caveat is that the upgrade process: since we are creating a CronJob and passing the crontab value on creation via the apprepository controller, from the Repos API we don't have a clear way to edit the cronjob once generated.

* In this PR we've addressed it by just disabling the field when upgrading

I think this should be explained in a bit more detail in the code, or in an issue, that can be referenced in the code. I'd currently have to dig a bit to understand why we can't delete and re-create the CronJob when updating the AppRepository CRD.

cronTime, err = intervalToCron(apprepo.Spec.Interval)
}
// If the interval is invalid, use the default global crontab
if err != nil {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm - I know we don't yet have any way to report this back to the user (other than failing the operation, which isn't great either), so I reckon it'd be useful to at least log this error so that we can help debug if there's an issue?

// Set to replace as short-circuit in k8s <1.12
// TODO re-evaluate ConcurrentPolicy when 1.12+ is mainstream (i.e 1.14)
// https://github.com/kubernetes/kubernetes/issues/54870
ConcurrencyPolicy: "Replace",
ConcurrencyPolicy: batchv1.ReplaceConcurrent,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dunno, but does this change to the concurrency policy mean we can remove the above comment? EDIT: hah, or it's not a change at all, just switch to constant :)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exactly, I just replaced the Replace string with the constant coming from the k8s package. Below the different options:

const (
	// AllowConcurrent allows CronJobs to run concurrently.
	AllowConcurrent ConcurrencyPolicy = "Allow"

	// ForbidConcurrent forbids concurrent runs, skipping next run if previous
	// hasn't finished yet.
	ForbidConcurrent ConcurrencyPolicy = "Forbid"

	// ReplaceConcurrent cancels currently running job and replaces it with a new one.
	ReplaceConcurrent ConcurrencyPolicy = "Replace"
)

{
name: "good interval, every two hours",
interval: "2h",
expectedCron: "*/120 * * * *",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is that actually valid? I thought Cron was stateless (and so effectively reset at the turn of each hour for a minutes range here). I'm not 100%, but worth checking (see some notes on the wikipedia article - not a source of truth obviously).

Could be worth using https://pkg.go.dev/github.com/robfig/cron#Every , not sure.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

➕ I don't know either if that's even valid, but besides that, I think that if we allow values higher than 60 min, the intervalToCron function should turn this kind of value to * */2 * * *, which is a more readable cron expression.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for your comments and pointers, I've had to revisit my knowledge about how cron works internally.

The expression I was building is syntactically valid, that's why I thought it was valid (=meaningful). However, it is semantically pointless given how the cron works.
Kubernetes is using the vixie-cron implementation, the POSIX-compliant implementation developed for BSD (back in 1988).

NOTE: There are some other implementations, like dcron, fcron, bcron and cronie. The latter, is a vixie-cron fork maintained by Fedora where, for instance, does validate the step field and therefore makes the discussed expression invalid syntactically.

Well, so coming back to the vixie implementation, the step can be any number:

https://github.com/vixie/cron/blob/2e298fc87bf6f9220d09e60162ebf909a607c4fb/entry.c#L518-L521

But, it is going to, silently, drop the invalid values:

https://github.com/vixie/cron/blob/2e298fc87bf6f9220d09e60162ebf909a607c4fb/entry.c#L580-L584

In short, as you pointed out, in an interval 0-59, the only possible values for step are [1-59].

Unfortunately, this is something that k8s is NOT validating:

k get cronjob        
NAME                  SCHEDULE       SUSPEND   ACTIVE   LAST SCHEDULE   AGE
kubernetes-cron-job   */61 * * * *   False     0        <none>          20m

Note the "last schedule" equals to "none"

The library you pointed out seems to be a scheduler itself, as it does not generate the crontab thing, but actually runs a timer to calculate the next executions.

So, it seems we might need to make the function a bit smarter :S and limit the values + perform the proper conversions between time units.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The library you pointed out seems to be a scheduler itself, as it does not generate the crontab thing, but actually runs a timer to calculate the next executions.

Yes, sorry - I realised the library was for a scheduler, but had thought that function I linked to was for converting a duration into a crontab format, but seems it is not.

Type: "helm",
Url: "http://example.com",
NamespaceScoped: true,
Interval: "99m",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Again, not sure that's valid. We could even simplify things by just using a drop-down selection in the UX (and validating valid values of 1, 2, ..., 15, 20, 30 etc.

requestCustomizer: func(request *corev1.UpdatePackageRepositoryRequest) *corev1.UpdatePackageRepositoryRequest {
request.Interval = "1s"
request.Interval = "99m"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Again, maybe use 15m as a valid interval.

func intervalToCron(duration string) (string, error) {
if duration == "" {
return "", fmt.Errorf("duration cannot be empty")
} else {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The else branch here is redundant. IMHO the code would be easier to follow if you get rid of it.

} else {
if d, err := time.ParseDuration(duration); err != nil {
return "", err
} else {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The same here.

{
name: "good interval, every two hours",
interval: "2h",
expectedCron: "*/120 * * * *",
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

➕ I don't know either if that's even valid, but besides that, I think that if we allow values higher than 60 min, the intervalToCron function should turn this kind of value to * */2 * * *, which is a more readable cron expression.

Signed-off-by: Antonio Gamez Diaz <agamez@vmware.com>
Signed-off-by: Antonio Gamez Diaz <agamez@vmware.com>
Signed-off-by: Antonio Gamez Diaz <agamez@vmware.com>
@antgamdia
Copy link
Contributor Author

antgamdia commented Nov 8, 2022

I think this should be explained in a bit more detail in the code, or in an issue, that can be referenced in the code. I'd currently have to dig a bit to understand why we can't delete and re-create the CronJob when updating the AppRepository CRD.

After re-reading it, you're right, there's something wrong. The CR controller should be smart enough to detect a change in the definition to recreate the cronjob... the problem is in how we consider two AppRepos are equals. Below the excerpt of the code:

if oldApp.Spec.URL != newApp.Spec.URL || oldApp.Spec.ResyncRequests != newApp.Spec.ResyncRequests {
  controller.enqueueAppRepo(newApp)
}

That's why the "update" branch of the controller wasn't being triggered. Fixing this in the next commit. Thansk for the heads-up!

Signed-off-by: Antonio Gamez Diaz <agamez@vmware.com>
Comment on lines -139 to +142
if oldApp.Spec.URL != newApp.Spec.URL || oldApp.Spec.ResyncRequests != newApp.Spec.ResyncRequests {
if !reflect.DeepEqual(oldApp.Spec, newApp.Spec) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@absoludity, do you know if there's any reason for checking the equality manually instead of just using reflect.DeepEqual as I'm proposing herein? I guess that a change in the AppRepo spec should trigger an update in the controller, doesn't it?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nope. I can't see anything on that AppRepo Spec struct that, if changed, should not trigger an update. Agree 100%.

Comment on lines 438 to 457
if cronMins < 60 {
// https://kubernetes.io/docs/concepts/workloads/controllers/cron-jobs/#cron-schedule-syntax
// minute(0-59) hour(0-23) dayOfMonth(1-31) month(1-12) dayOfWeek(0-6)
return fmt.Sprintf("*/%v * * * *", cronMins), nil // every cronMins minutes
}

cronHours := math.Ceil(d.Hours()) // round up to nearest hour
if cronHours < 24 {
return fmt.Sprintf("0 */%v * * *", cronHours), nil // every cronHours hours
}

cronDays := math.Ceil(cronHours / 24) // get the days
if cronDays < 32 {
return fmt.Sprintf("0 0 */%v * *", cronDays), nil // every cronHoursDays days
}

cronMonths := math.Ceil(cronDays / 31) // get the months
if cronMonths < 13 {
return fmt.Sprintf("0 0 1 */%v *", cronMonths), nil // every cronHoursMonths months
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New logic for roughly estimating the cron line from a duration

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice - thanks for the update. Just note, small typo in the inline comments on lines 451 and 456 (s/Hours//)

Comment on lines +481 to +486
if gron.IsValid(apprepo.Spec.Interval) {
cronTime = apprepo.Spec.Interval
} else {
// otherwise, convert it
cronTime, err = intervalToCron(apprepo.Spec.Interval)
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since people might want to specify their own cron lines, this Helm plugin is accepting a valid cron expression and, if invalid, it will try to pasre it as a go duration. This brings more flexibility, but it differs from the API spec a little bit. WDYT?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmmm, handy I think. I'd be ok with that (and updating the API spec to match, if we want - still backwards compatible so a nice addition, imo). Nice one!

Comment on lines -139 to +142
if oldApp.Spec.URL != newApp.Spec.URL || oldApp.Spec.ResyncRequests != newApp.Spec.ResyncRequests {
if !reflect.DeepEqual(oldApp.Spec, newApp.Spec) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nope. I can't see anything on that AppRepo Spec struct that, if changed, should not trigger an update. Agree 100%.

Comment on lines 438 to 457
if cronMins < 60 {
// https://kubernetes.io/docs/concepts/workloads/controllers/cron-jobs/#cron-schedule-syntax
// minute(0-59) hour(0-23) dayOfMonth(1-31) month(1-12) dayOfWeek(0-6)
return fmt.Sprintf("*/%v * * * *", cronMins), nil // every cronMins minutes
}

cronHours := math.Ceil(d.Hours()) // round up to nearest hour
if cronHours < 24 {
return fmt.Sprintf("0 */%v * * *", cronHours), nil // every cronHours hours
}

cronDays := math.Ceil(cronHours / 24) // get the days
if cronDays < 32 {
return fmt.Sprintf("0 0 */%v * *", cronDays), nil // every cronHoursDays days
}

cronMonths := math.Ceil(cronDays / 31) // get the months
if cronMonths < 13 {
return fmt.Sprintf("0 0 1 */%v *", cronMonths), nil // every cronHoursMonths months
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice - thanks for the update. Just note, small typo in the inline comments on lines 451 and 456 (s/Hours//)

Comment on lines +481 to +486
if gron.IsValid(apprepo.Spec.Interval) {
cronTime = apprepo.Spec.Interval
} else {
// otherwise, convert it
cronTime, err = intervalToCron(apprepo.Spec.Interval)
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmmm, handy I think. I'd be ok with that (and updating the API spec to match, if we want - still backwards compatible so a nice addition, imo). Nice one!

Signed-off-by: Antonio Gamez Diaz <agamez@vmware.com>
Signed-off-by: Antonio Gamez Diaz <agamez@vmware.com>
Comment on lines +88 to 92
// Some plugins may, addionally, support other formats, for instance,
// a cron expression.
// e.g., "*/10 * * * *" will be equivalent to "10m"
// Optional. Defaults to 10m if not specified
string interval = 7;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've added this note to document the fact that other plugins might support other formats while also supporting the "time+unit" one.

@antgamdia
Copy link
Contributor Author

Something is wrong with the CI, out of the blue, I'm getting a problem with the test in Flux :S
I'd say it is unrelated to this PR, so will re-try in a while.

image

@absoludity absoludity merged commit 2671868 into vmware-tanzu:main Nov 17, 2022
@antgamdia antgamdia deleted the 3661-appRepoCustomInterval branch December 18, 2023 10:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Kubeapps Helm AppRepository should support configurable sync interval
4 participants