From 55b1f523a6cbc461f54244286a14bac69a221e49 Mon Sep 17 00:00:00 2001 From: Philipp Stehle Date: Fri, 20 Jan 2023 16:22:12 +0100 Subject: [PATCH] Add Jitter to "Update Go" action schedule Fixes #966 --- octo/jitter/init_test.go | 30 +++++++++++++++ octo/jitter/jitter.go | 64 ++++++++++++++++++++++++++++++ octo/jitter/jitter_test.go | 79 ++++++++++++++++++++++++++++++++++++++ octo/update_go.go | 10 ++++- 4 files changed, 181 insertions(+), 2 deletions(-) create mode 100644 octo/jitter/init_test.go create mode 100644 octo/jitter/jitter.go create mode 100644 octo/jitter/jitter_test.go diff --git a/octo/jitter/init_test.go b/octo/jitter/init_test.go new file mode 100644 index 00000000..08d85979 --- /dev/null +++ b/octo/jitter/init_test.go @@ -0,0 +1,30 @@ +/* + * Copyright 2023 the original author or 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 + * + * https://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 jitter_test + +import ( + "testing" + + "github.com/sclevine/spec" + "github.com/sclevine/spec/report" +) + +func TestUnit(t *testing.T) { + suite := spec.New("jitter", spec.Report(report.Terminal{})) + suite("Tests", testJitter) + suite.Run(t) +} diff --git a/octo/jitter/jitter.go b/octo/jitter/jitter.go new file mode 100644 index 00000000..914d7d0a --- /dev/null +++ b/octo/jitter/jitter.go @@ -0,0 +1,64 @@ +/* + * Copyright 2023 the original author or 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 + * + * https://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 jitter + +import ( + "crypto/md5" + "encoding/binary" + "math/rand" + "strconv" + + "github.com/paketo-buildpacks/pipeline-builder/octo/actions/event" +) + +type Jitterer struct { + rng *rand.Rand +} + +func New(seedString string) Jitterer { + // use a string to calculate a deterministic seed for the random number generator + // the actual implementation does not really matter, we just need to condense a string into an 64-bit number + // also: cryptocraphic security is not important here, we just don't want all cron jobs to run at the same time + sum := md5.Sum([]byte(seedString)) + seed := binary.LittleEndian.Uint64(sum[0:8]) ^ binary.BigEndian.Uint64(sum[8:16]) + return Jitterer{ + rng: rand.New(rand.NewSource(int64(seed))), + } +} + +func (j Jitterer) jitter(min, max int) string { + return strconv.Itoa(min + j.rng.Intn(max-min+1)) +} + +func (j Jitterer) Jitter(cron event.Cron) event.Cron { + if cron.Minute == "" { + cron.Minute = j.jitter(0, 59) + } + if cron.Hour == "" { + cron.Hour = j.jitter(0, 23) + } + if cron.DayOfMonth == "" { + cron.DayOfMonth = j.jitter(1, 28) + } + if cron.Month == "" { + cron.Month = j.jitter(1, 12) + } + if cron.DayOfWeek == "" { + cron.DayOfWeek = j.jitter(0, 6) + } + return cron +} diff --git a/octo/jitter/jitter_test.go b/octo/jitter/jitter_test.go new file mode 100644 index 00000000..5c3308ad --- /dev/null +++ b/octo/jitter/jitter_test.go @@ -0,0 +1,79 @@ +/* + * Copyright 2018-2023 the original author or 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 + * + * https://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 jitter_test + +import ( + "testing" + + . "github.com/onsi/gomega" + "github.com/paketo-buildpacks/pipeline-builder/octo/actions/event" + "github.com/paketo-buildpacks/pipeline-builder/octo/jitter" + "github.com/sclevine/spec" +) + +func testJitter(t *testing.T, context spec.G, it spec.S) { + var ( + Expect = NewWithT(t).Expect + ) + + context("Jitterer", func() { + it("jittering should change unset values", func() { + jitterer := jitter.New("my-seed") + jittered := jitterer.Jitter(event.Cron{}) + + Expect(jittered.DayOfMonth).NotTo(BeEmpty()) + Expect(jittered.DayOfWeek).NotTo(BeEmpty()) + Expect(jittered.Hour).NotTo(BeEmpty()) + Expect(jittered.Minute).NotTo(BeEmpty()) + Expect(jittered.Month).NotTo(BeEmpty()) + }) + + it("jittering should not change set values", func() { + jitterer := jitter.New("my-seed") + jittered := jitterer.Jitter(event.Cron{ + DayOfMonth: "42", + DayOfWeek: "42", + Hour: "42", + Minute: "42", + Month: "42", + }) + + Expect(jittered.DayOfMonth).To(Equal("42")) + Expect(jittered.DayOfWeek).To(Equal("42")) + Expect(jittered.Hour).To(Equal("42")) + Expect(jittered.Minute).To(Equal("42")) + Expect(jittered.Month).To(Equal("42")) + }) + + it("jittering with the same seed should produce the same result", func() { + jitterer := jitter.New("my-seed") + jitteredA := jitterer.Jitter(event.Cron{}) + jitterer = jitter.New("my-seed") + jitteredB := jitterer.Jitter(event.Cron{}) + Expect(jitteredA).To(Equal(jitteredB)) + }) + + it("jittering with different seeds should produce a different result (with high probability)", func() { + jitterer := jitter.New("my-seed") + jitteredA := jitterer.Jitter(event.Cron{}) + jitterer = jitter.New("my-other-seed") + jitteredB := jitterer.Jitter(event.Cron{}) + Expect(jitteredA).NotTo(Equal(jitteredB)) + }) + + }) +} diff --git a/octo/update_go.go b/octo/update_go.go index b13f2bdf..99f7072f 100644 --- a/octo/update_go.go +++ b/octo/update_go.go @@ -1,5 +1,5 @@ /* - * Copyright 2018-2020 the original author or authors. + * Copyright 2018-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -22,6 +22,7 @@ import ( "github.com/paketo-buildpacks/pipeline-builder/octo/actions" "github.com/paketo-buildpacks/pipeline-builder/octo/actions/event" + "github.com/paketo-buildpacks/pipeline-builder/octo/jitter" ) func ContributeUpdateGo(descriptor Descriptor) (*Contribution, error) { @@ -41,10 +42,15 @@ func ContributeUpdateGo(descriptor Descriptor) (*Contribution, error) { return nil, nil } + seed := fmt.Sprintf("Update Go %s", os.Getenv("GITHUB_REPOSITORY")) + cron := jitter. + New(seed). + Jitter(event.Cron{Hour: "2", DayOfWeek: "1", Month: "*", DayOfMonth: "*"}) + w := actions.Workflow{ Name: "Update Go", On: map[event.Type]event.Event{ - event.ScheduleType: event.Schedule{{Minute: "0", Hour: "2", DayOfWeek: "1"}}, + event.ScheduleType: event.Schedule{cron}, event.WorkflowDispatchType: event.WorkflowDispatch{}, }, Jobs: map[string]actions.Job{