From 1d611c5c24ca2c102614147ca511e2ba32afe10c Mon Sep 17 00:00:00 2001 From: Alex Zongaro Date: Tue, 6 Oct 2026 14:44:57 +0200 Subject: [PATCH 1/8] fix(gitlab): point the mirror hints to the new command name The message shown when a project already has a mirror still suggested the old `opsi gitlab update mirroring` command, removed by the command reorder. Co-Authored-By: Claude Sonnet 5.5 --- cmd/gitlabMirrorCreateCmd.go | 2 +- scopes/gitlab/main.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/gitlabMirrorCreateCmd.go b/cmd/gitlabMirrorCreateCmd.go index d3c50fa..d59457f 100644 --- a/cmd/gitlabMirrorCreateCmd.go +++ b/cmd/gitlabMirrorCreateCmd.go @@ -14,7 +14,7 @@ var gitlabMirrorCreateCmd = &cobra.Command{ Long: `Enable the mirroring for one or more existing Gitlab projects. Each project can be given as numeric ID or as full path. The destination project is created on the mirror instance when missing. -Projects that already have a mirror are refused: use "update mirroring" for those.`, +Projects that already have a mirror are refused: use "mirror update" for those.`, Example: ` Enable the mirroring for a project opsi gitlab mirror create corporate/wiki/beliven-wiki diff --git a/scopes/gitlab/main.go b/scopes/gitlab/main.go index 9497430..c077958 100644 --- a/scopes/gitlab/main.go +++ b/scopes/gitlab/main.go @@ -169,7 +169,7 @@ func (g *gitlab) createMirrorForProject(ref string) (string, error) { return project.PathWithNamespace, err } if hasMirror { - return project.PathWithNamespace, errors.New("the project already has a mirror, use `opsi gitlab update mirroring`") + return project.PathWithNamespace, errors.New("the project already has a mirror, use `opsi gitlab mirror update`") } // Create the destination project only when it is not there yet. From 1414459d4bc68de6d558552a07388fbb9a984ec0 Mon Sep 17 00:00:00 2001 From: Alex Zongaro Date: Tue, 6 Oct 2026 14:46:14 +0200 Subject: [PATCH 2/8] feat(gitlab): add the schedule create command Add `opsi gitlab schedule create ...`, which creates a weekly pipeline schedule on one or more existing projects (ID or path). The slot is not chosen by hand: it is the least used one among the working days between 09:00 and 18:00 (Europe/Rome, 15 minutes slots), counting the active schedules of every project, the ones in another time zone included, and the ones created in the same run. So the pipelines are spread instead of starting together. The schedule runs on `main` when the project has it, otherwise on the default branch. A project that already has an active schedule is left alone, and a failing project does not stop the others. Co-Authored-By: Claude Sonnet 5.5 --- cmd/gitlabScheduleCmd.go | 17 ++ cmd/gitlabScheduleCreateCmd.go | 41 ++++ scopes/gitlab/defs.go | 1 + scopes/gitlab/schedules.go | 372 ++++++++++++++++++++++++++++++++ scopes/gitlab/schedules_test.go | 203 +++++++++++++++++ 5 files changed, 634 insertions(+) create mode 100644 cmd/gitlabScheduleCmd.go create mode 100644 cmd/gitlabScheduleCreateCmd.go create mode 100644 scopes/gitlab/schedules.go create mode 100644 scopes/gitlab/schedules_test.go diff --git a/cmd/gitlabScheduleCmd.go b/cmd/gitlabScheduleCmd.go new file mode 100644 index 0000000..42e291c --- /dev/null +++ b/cmd/gitlabScheduleCmd.go @@ -0,0 +1,17 @@ +package cmd + +import ( + "github.com/spf13/cobra" +) + +var gitlabScheduleCmd = &cobra.Command{ + Use: "schedule {verb}", + Args: cobra.ExactArgs(1), + Short: "Manage the default pipeline schedule of Gitlab projects", + Long: "Manage the default pipeline schedule of Gitlab projects", + Run: func(cmd *cobra.Command, args []string) {}, +} + +func init() { + gitlabCmd.AddCommand(gitlabScheduleCmd) +} diff --git a/cmd/gitlabScheduleCreateCmd.go b/cmd/gitlabScheduleCreateCmd.go new file mode 100644 index 0000000..20fd428 --- /dev/null +++ b/cmd/gitlabScheduleCreateCmd.go @@ -0,0 +1,41 @@ +package cmd + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" +) + +var gitlabScheduleCreateCmd = &cobra.Command{ + Use: "create {project}...", + Args: cobra.MinimumNArgs(1), + Short: "Create the default pipeline schedule for existing Gitlab projects", + Long: `Create the default pipeline schedule for one or more existing Gitlab projects. +Each project can be given as numeric ID or as full path. + +The schedule is weekly, on a working day between 09:00 and 18:00 (Europe/Rome). +The day and the time are chosen among the ones used by the fewest schedules, +so the pipelines are spread instead of starting all together. +It runs on the "main" branch when the project has it, otherwise on the default branch. +Projects that already have an active schedule are left alone.`, + Example: ` + Create the schedule for a project + opsi gitlab schedule create corporate/wiki/beliven-wiki + + --- + + Create the schedule for more projects, by path or ID + opsi gitlab schedule create corporate/wiki/beliven-wiki 1234 + `, + Run: func(cmd *cobra.Command, args []string) { + if err := gitlab.CreateSchedule(args); err != nil { + fmt.Println(err) + os.Exit(1) + } + }, +} + +func init() { + gitlabScheduleCmd.AddCommand(gitlabScheduleCreateCmd) +} diff --git a/scopes/gitlab/defs.go b/scopes/gitlab/defs.go index eddd175..0ae5367 100644 --- a/scopes/gitlab/defs.go +++ b/scopes/gitlab/defs.go @@ -29,6 +29,7 @@ type Gitlab interface { UpdateMirroring() error ListGroupAccessTokens(int) error CreateMirror([]string) error + CreateSchedule([]string) error UpdateCleanUpPolicy(string) error } diff --git a/scopes/gitlab/schedules.go b/scopes/gitlab/schedules.go new file mode 100644 index 0000000..8363ad4 --- /dev/null +++ b/scopes/gitlab/schedules.go @@ -0,0 +1,372 @@ +package gitlab + +import ( + "encoding/json" + "errors" + "fmt" + "hash/fnv" + "net/url" + "strconv" + "strings" + "sync" + "time" + + // The time zones are needed to read the existing schedules, also on machines without tzdata + _ "time/tzdata" +) + +// The default pipeline schedule is weekly: one working day and one time of the +// day for each project. The slot is chosen among the ones with the fewest +// schedules, so the pipelines are spread instead of starting all together. +const ( + scheduleDescription = "opsi default schedule" + scheduleTimezone = "Europe/Rome" + scheduleFirstHour = 9 // first slot at 09:00 + scheduleLastHour = 18 // no slot starting at or after 18:00 + scheduleSlotMinutes = 15 + scheduleBranch = "main" +) + +var scheduleDays = []time.Weekday{ + time.Monday, time.Tuesday, time.Wednesday, time.Thursday, time.Friday, +} + +// scheduleSlot is a weekly time, in the scheduleTimezone. +type scheduleSlot struct { + day time.Weekday + hour int + minute int +} + +func (s scheduleSlot) cron() string { + return fmt.Sprintf("%d %d * * %d", s.minute, s.hour, int(s.day)) +} + +func (s scheduleSlot) String() string { + return fmt.Sprintf("%s %02d:%02d", strings.ToLower(s.day.String()), s.hour, s.minute) +} + +// scheduleSlots returns every slot a schedule can be placed on. +func scheduleSlots() []scheduleSlot { + slots := []scheduleSlot{} + for _, day := range scheduleDays { + for hour := scheduleFirstHour; hour < scheduleLastHour; hour++ { + for minute := 0; minute < 60; minute += scheduleSlotMinutes { + slots = append(slots, scheduleSlot{day, hour, minute}) + } + } + } + + return slots +} + +// cronField expands a cron field made of numbers, lists, ranges and "*" +// into the values it matches, within [min, max]. It returns false for +// anything fancier (steps, names), which cannot be placed on a slot. +func cronField(field string, min int, max int) ([]int, bool) { + values := []int{} + + for _, part := range strings.Split(field, ",") { + switch { + case part == "*": + for v := min; v <= max; v++ { + values = append(values, v) + } + case strings.Contains(part, "-"): + bounds := strings.SplitN(part, "-", 2) + from, err1 := strconv.Atoi(bounds[0]) + to, err2 := strconv.Atoi(bounds[1]) + if err1 != nil || err2 != nil || from < min || to > max || from > to { + return nil, false + } + for v := from; v <= to; v++ { + values = append(values, v) + } + default: + v, err := strconv.Atoi(part) + if err != nil || v < min || v > max { + return nil, false + } + values = append(values, v) + } + } + + return values, true +} + +// slotsOfSchedule returns the slots, in the scheduleTimezone, an existing +// schedule runs on. Schedules that run more than once a day are counted on +// each of the matching slots; the ones that cannot be understood are ignored. +func slotsOfSchedule(cron string, timezone string) []scheduleSlot { + fields := strings.Fields(cron) + if len(fields) != 5 || fields[2] != "*" || fields[3] != "*" { + return nil + } + + minutes, ok1 := cronField(fields[0], 0, 59) + hours, ok2 := cronField(fields[1], 0, 23) + days, ok3 := cronField(strings.ReplaceAll(fields[4], "7", "0"), 0, 6) + if !ok1 || !ok2 || !ok3 { + return nil + } + + source, err := time.LoadLocation(timezone) + if err != nil { + source = time.UTC + } + target, _ := time.LoadLocation(scheduleTimezone) + if target == nil { + target = time.UTC + } + + // A fixed reference week (starting on a Sunday) to convert the time zone + referenceSunday := time.Date(2026, time.January, 4, 0, 0, 0, 0, source) + + slots := []scheduleSlot{} + for _, day := range days { + for _, hour := range hours { + for _, minute := range minutes { + at := referenceSunday.AddDate(0, 0, day).Add(time.Duration(hour)*time.Hour + time.Duration(minute)*time.Minute).In(target) + slots = append(slots, scheduleSlot{at.Weekday(), at.Hour(), at.Minute()}) + } + } + } + + return slots +} + +// scheduleLoad counts how many schedules run on each slot. +type scheduleLoad map[scheduleSlot]int + +// slotKey is the slot a time belongs to: the minute is rounded down to the +// slot size, so a schedule at 10:20 weighs on the 10:15 slot. +func slotKey(s scheduleSlot) scheduleSlot { + s.minute = s.minute - s.minute%scheduleSlotMinutes + return s +} + +func (l scheduleLoad) add(cron string, timezone string) { + for _, slot := range slotsOfSchedule(cron, timezone) { + l[slotKey(slot)]++ + } +} + +// pick returns the least loaded slot. Among the equally loaded ones it picks +// by the hash of the seed (the project path), so the choice does not always +// fall on the first slot of the week but stays the same for the same project. +func (l scheduleLoad) pick(seed string) scheduleSlot { + slots := scheduleSlots() + + lowest := -1 + candidates := []scheduleSlot{} + for _, slot := range slots { + load := l[slot] + switch { + case lowest == -1 || load < lowest: + lowest = load + candidates = []scheduleSlot{slot} + case load == lowest: + candidates = append(candidates, slot) + } + } + + hash := fnv.New32a() + hash.Write([]byte(seed)) + + return candidates[int(hash.Sum32())%len(candidates)] +} + +type gitlabScheduleOwner struct { + ID int `json:"id"` + Username string `json:"username"` + State string `json:"state"` +} + +type gitlabPipelineSchedule struct { + ID int `json:"id"` + Description string `json:"description"` + Ref string `json:"ref"` + Cron string `json:"cron"` + CronTimezone string `json:"cron_timezone"` + Active bool `json:"active"` + Owner *gitlabScheduleOwner `json:"owner"` + LastPipeline *struct { + Status string `json:"status"` + } `json:"last_pipeline"` +} + +// listSchedules returns the pipeline schedules of a project. +func (g *gitlab) listSchedules(projectID int) ([]gitlabPipelineSchedule, error) { + schedules := []gitlabPipelineSchedule{} + + for page := 1; ; page++ { + response, err := g.request("GET", fmt.Sprintf("/projects/%d/pipeline_schedules", projectID), nil, map[string]string{ + "page": strconv.Itoa(page), + "per_page": "100", + }) + if err != nil { + return nil, err + } + + var list []gitlabPipelineSchedule + if err := json.Unmarshal(response, &list); err != nil { + return nil, err + } + + schedules = append(schedules, list...) + if len(list) < 100 { + return schedules, nil + } + } +} + +// loadSchedules counts the active schedules of every project. The projects +// that cannot be read are ignored: the load is only used to choose a slot. +func (g *gitlab) loadSchedules() (scheduleLoad, error) { + projects, err := g.listProjects() + if err != nil { + return nil, err + } + + load := scheduleLoad{} + + var ( + mu sync.Mutex + wg sync.WaitGroup + done int + ) + slots := make(chan struct{}, deprovisioningConcurrency) + + for _, project := range projects { + wg.Add(1) + slots <- struct{}{} + go func(id int) { + defer wg.Done() + defer func() { <-slots }() + + schedules, _ := g.listSchedules(id) + + mu.Lock() + defer mu.Unlock() + for _, schedule := range schedules { + if schedule.Active { + load.add(schedule.Cron, schedule.CronTimezone) + } + } + done++ + progress("Checking existing schedules... %d/%d", done, len(projects)) + }(project.ID) + } + + wg.Wait() + endProgress() + + return load, nil +} + +// scheduleRef is the branch the schedule runs on: the production branch +// when the project has it, otherwise the default branch of the project. +func (g *gitlab) scheduleRef(projectID int, defaultBranch string) string { + _, err := g.request("GET", fmt.Sprintf("/projects/%d/repository/branches/%s", projectID, scheduleBranch), nil, nil) + if err == nil { + return scheduleBranch + } + + return defaultBranch +} + +// createSchedule creates the default schedule of a project on the least +// loaded slot and counts it in the load. It returns a description of it. +func (g *gitlab) createSchedule(projectID int, path string, defaultBranch string, load scheduleLoad) (string, error) { + ref := g.scheduleRef(projectID, defaultBranch) + slot := load.pick(path) + + _, err := g.request("POST", fmt.Sprintf("/projects/%d/pipeline_schedules", projectID), map[string]any{ + "description": scheduleDescription, + "ref": ref, + "cron": slot.cron(), + "cron_timezone": scheduleTimezone, + "active": true, + }, nil) + if err != nil { + return "", err + } + + load.add(slot.cron(), scheduleTimezone) + + return fmt.Sprintf("%s, on %s", slot, ref), nil +} + +type scheduleProject struct { + ID int `json:"id"` + PathWithNamespace string `json:"path_with_namespace"` + DefaultBranch string `json:"default_branch"` +} + +func (g *gitlab) findProject(ref string) (scheduleProject, error) { + var project scheduleProject + + response, err := g.request("GET", "/projects/"+url.PathEscape(ref), nil, nil) + if err != nil { + return project, err + } + + err = json.Unmarshal(response, &project) + + return project, err +} + +// CreateSchedule creates the default pipeline schedule on each of the +// projects (numeric ID or full path). A project that already has an active +// schedule is left alone. +func (g *gitlab) CreateSchedule(projects []string) error { + var failures []error + var load scheduleLoad + + for _, ref := range projects { + project, err := g.findProject(ref) + if err != nil { + fmt.Printf("Error when reading %s: %v\n", ref, err) + failures = append(failures, fmt.Errorf("%s: %w", ref, err)) + continue + } + + existing, err := g.listSchedules(project.ID) + if err != nil { + fmt.Printf("Error when reading the schedules of %s: %v\n", project.PathWithNamespace, err) + failures = append(failures, fmt.Errorf("%s: %w", project.PathWithNamespace, err)) + continue + } + + var active *gitlabPipelineSchedule + for i := range existing { + if existing[i].Active { + active = &existing[i] + break + } + } + if active != nil { + fmt.Printf("Skipped %s: it already has an active schedule (%s, on %s)\n", project.PathWithNamespace, active.Cron, active.Ref) + continue + } + + // The load is read once, and only when there is something to create + if load == nil { + load, err = g.loadSchedules() + if err != nil { + return err + } + } + + description, err := g.createSchedule(project.ID, project.PathWithNamespace, project.DefaultBranch, load) + if err != nil { + fmt.Printf("Error when creating the schedule for %s: %v\n", project.PathWithNamespace, err) + failures = append(failures, fmt.Errorf("%s: %w", project.PathWithNamespace, err)) + continue + } + + fmt.Printf("Schedule created for %s: %s\n", project.PathWithNamespace, description) + } + + return errors.Join(failures...) +} diff --git a/scopes/gitlab/schedules_test.go b/scopes/gitlab/schedules_test.go new file mode 100644 index 0000000..d5ab5d7 --- /dev/null +++ b/scopes/gitlab/schedules_test.go @@ -0,0 +1,203 @@ +package gitlab + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" +) + +func TestScheduleSlots(t *testing.T) { + slots := scheduleSlots() + + // 5 working days, 9 hours, 4 slots an hour + if len(slots) != 5*9*4 { + t.Errorf("got %d slots, want %d", len(slots), 5*9*4) + } + for _, slot := range slots { + if slot.day == time.Saturday || slot.day == time.Sunday { + t.Errorf("slot on a weekend day: %v", slot) + } + if slot.hour < scheduleFirstHour || slot.hour >= scheduleLastHour { + t.Errorf("slot out of the time window: %v", slot) + } + } + + if got := (scheduleSlot{time.Wednesday, 10, 45}).cron(); got != "45 10 * * 3" { + t.Errorf("cron = %q", got) + } +} + +func TestSlotsOfSchedule(t *testing.T) { + cases := []struct { + name string + cron string + timezone string + want []scheduleSlot + }{ + {"weekly slot", "45 10 * * 3", "Europe/Rome", []scheduleSlot{{time.Wednesday, 10, 45}}}, + {"every working day", "0 9 * * 1-5", "Europe/Rome", []scheduleSlot{ + {time.Monday, 9, 0}, {time.Tuesday, 9, 0}, {time.Wednesday, 9, 0}, {time.Thursday, 9, 0}, {time.Friday, 9, 0}}}, + {"list of days", "30 14 * * 1,4", "Europe/Rome", []scheduleSlot{{time.Monday, 14, 30}, {time.Thursday, 14, 30}}}, + // January: Rome is UTC+1 + {"other time zone is converted", "0 8 * * 2", "Etc/UTC", []scheduleSlot{{time.Tuesday, 9, 0}}}, + {"conversion can change the day", "30 23 * * 2", "Etc/UTC", []scheduleSlot{{time.Wednesday, 0, 30}}}, + {"unknown time zone is read as UTC", "0 8 * * 2", "Nowhere/Land", []scheduleSlot{{time.Tuesday, 9, 0}}}, + {"steps cannot be placed", "*/15 * * * 1", "Europe/Rome", nil}, + {"monthly cannot be placed", "0 9 1 * *", "Europe/Rome", nil}, + {"garbage", "not a cron", "Europe/Rome", nil}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := slotsOfSchedule(c.cron, c.timezone) + if len(got) != len(c.want) { + t.Fatalf("got %v, want %v", got, c.want) + } + for i := range got { + if got[i] != c.want[i] { + t.Errorf("got %v, want %v", got, c.want) + } + } + }) + } +} + +func TestPickAvoidsLoadedSlots(t *testing.T) { + load := scheduleLoad{} + + // Fill every slot once, but leave one empty + free := scheduleSlot{time.Thursday, 15, 30} + for _, slot := range scheduleSlots() { + if slot != free { + load[slot] = 1 + } + } + + if got := load.pick("any/project"); got != free { + t.Errorf("picked %v, want the only free slot %v", got, free) + } + + // Once the free slot is taken too, every slot has the same load: the pick + // is anywhere, but always the same for the same project + load.add(free.cron(), scheduleTimezone) + first := load.pick("a/project") + if again := load.pick("a/project"); again != first { + t.Errorf("same project got %v then %v", first, again) + } +} + +func TestPickSpreadsProjects(t *testing.T) { + load := scheduleLoad{} + used := map[scheduleSlot]int{} + + // Fewer projects than slots: nobody should share a slot + for i := 0; i < 100; i++ { + slot := load.pick(fmt.Sprintf("group/project-%d", i)) + used[slot]++ + load.add(slot.cron(), scheduleTimezone) + } + + for slot, n := range used { + if n > 1 { + t.Errorf("slot %v used %d times with free slots left", slot, n) + } + } +} + +func TestSlotKeyRoundsDown(t *testing.T) { + if got := slotKey(scheduleSlot{time.Monday, 10, 20}); got != (scheduleSlot{time.Monday, 10, 15}) { + t.Errorf("slotKey = %v", got) + } +} + +type scheduleServer struct { + mu sync.Mutex + created []map[string]any + t *testing.T +} + +func (s *scheduleServer) handler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/projects/corporate/with-schedule" || r.URL.EscapedPath() == "/projects/corporate%2Fwith-schedule": + fmt.Fprint(w, `{"id":2,"path_with_namespace":"corporate/with-schedule","default_branch":"main"}`) + case r.URL.EscapedPath() == "/projects/corporate%2Fstaging-only": + fmt.Fprint(w, `{"id":3,"path_with_namespace":"corporate/staging-only","default_branch":"staging"}`) + case r.URL.EscapedPath() == "/projects/corporate%2Fnew": + fmt.Fprint(w, `{"id":1,"path_with_namespace":"corporate/new","default_branch":"main"}`) + case r.URL.Path == "/projects" && r.Method == "GET": + fmt.Fprint(w, `[{"id":1},{"id":2},{"id":3}]`) + case r.URL.Path == "/projects/2/pipeline_schedules" && r.Method == "GET": + fmt.Fprint(w, `[{"id":9,"description":"x","ref":"refs/heads/main","cron":"0 9 * * 1-5","cron_timezone":"Europe/Rome","active":true}]`) + case strings.HasSuffix(r.URL.Path, "/pipeline_schedules") && r.Method == "GET": + fmt.Fprint(w, `[]`) + case r.URL.Path == "/projects/3/repository/branches/main": + w.WriteHeader(http.StatusNotFound) + fmt.Fprint(w, `{"message":"404 Branch Not Found"}`) + case strings.Contains(r.URL.Path, "/repository/branches/main"): + fmt.Fprint(w, `{"name":"main"}`) + case strings.HasSuffix(r.URL.Path, "/pipeline_schedules") && r.Method == "POST": + var body map[string]any + json.NewDecoder(r.Body).Decode(&body) + body["project"] = r.URL.Path + s.mu.Lock() + s.created = append(s.created, body) + s.mu.Unlock() + w.WriteHeader(http.StatusCreated) + fmt.Fprint(w, `{"id":1}`) + case strings.HasPrefix(r.URL.EscapedPath(), "/projects/missing"): + w.WriteHeader(http.StatusNotFound) + fmt.Fprint(w, `{"message":"404 Project Not Found"}`) + default: + s.t.Errorf("unexpected request %s %s", r.Method, r.URL.EscapedPath()) + } + }) +} + +func TestCreateSchedule(t *testing.T) { + s := &scheduleServer{t: t} + server := httptest.NewServer(s.handler()) + defer server.Close() + + g := &gitlab{apiURL: server.URL, token: "t"} + + err := g.CreateSchedule([]string{"corporate/new", "corporate/with-schedule", "corporate/staging-only", "missing/project"}) + if err == nil || !strings.Contains(err.Error(), "missing/project") || strings.Contains(err.Error(), "corporate/new") { + t.Errorf("only the missing project should fail, got %v", err) + } + + if len(s.created) != 2 { + t.Fatalf("created %d schedules, want 2 (the project with an active schedule is skipped): %v", len(s.created), s.created) + } + + byProject := map[string]map[string]any{} + for _, c := range s.created { + byProject[c["project"].(string)] = c + } + + for project, wantRef := range map[string]string{ + "/projects/1/pipeline_schedules": "main", + "/projects/3/pipeline_schedules": "staging", // no main branch: default branch + } { + c := byProject[project] + if c == nil { + t.Fatalf("no schedule created on %s", project) + } + if c["ref"] != wantRef { + t.Errorf("%s: ref = %v, want %v", project, c["ref"], wantRef) + } + if c["description"] != scheduleDescription || c["cron_timezone"] != scheduleTimezone || c["active"] != true { + t.Errorf("%s: unexpected payload %v", project, c) + } + } + + // The two new schedules must not share a slot + if byProject["/projects/1/pipeline_schedules"]["cron"] == byProject["/projects/3/pipeline_schedules"]["cron"] { + t.Errorf("the two schedules got the same cron %v", byProject["/projects/1/pipeline_schedules"]["cron"]) + } +} From b49b6e9a11bde088b9aed484aff39b01892d3ae5 Mon Sep 17 00:00:00 2001 From: Alex Zongaro Date: Tue, 6 Oct 2026 14:46:54 +0200 Subject: [PATCH 3/8] feat(gitlab): add --schedule to project create `opsi gitlab project create -c` creates the default weekly pipeline schedule at the end of the creation, on the least used slot. Without the flag nothing changes. Co-Authored-By: Claude Sonnet 5.5 --- cmd/gitlabProjectCreateCmd.go | 8 ++++++++ scopes/gitlab/defs.go | 1 + scopes/gitlab/main.go | 15 +++++++++++++++ 3 files changed, 24 insertions(+) diff --git a/cmd/gitlabProjectCreateCmd.go b/cmd/gitlabProjectCreateCmd.go index 721edc5..b0ca20d 100644 --- a/cmd/gitlabProjectCreateCmd.go +++ b/cmd/gitlabProjectCreateCmd.go @@ -37,6 +37,11 @@ var gitlabProjectCreateCmd = &cobra.Command{ --- + Create a project with name "Nightly" with the default weekly pipeline schedule + opsi gitlab project create Nightly -c + + --- + Create a project with name "Anonymous" disabling shared runners opsi gitlab project create Anonymous -r @@ -58,6 +63,7 @@ var gitlabProjectCreateCmd = &cobra.Command{ mirror, _ := cmd.Flags().GetBool("mirror") sharedRunners, _ := cmd.Flags().GetBool("sharedrunners") visibility, _ := cmd.Flags().GetString("visibility") + schedule, _ := cmd.Flags().GetBool("schedule") // Slugify the name if the pathname flag // for the project is not provided @@ -73,6 +79,7 @@ var gitlabProjectCreateCmd = &cobra.Command{ DefaultBranch: defaultBranch, Mirror: mirror, SharedRunners: sharedRunners, + Schedule: schedule, Group: group, } @@ -95,6 +102,7 @@ func init() { gitlabProjectCreateCmd.Flags().StringP("branch-default", "b", "main", "the default main branch. Possible values are master or main") gitlabProjectCreateCmd.Flags().BoolP("mirror", "m", false, "Enable or disable the mirroring repo. Default is false") gitlabProjectCreateCmd.Flags().BoolP("sharedrunners", "r", false, "Enable or disable the shared runners. Default is true") + gitlabProjectCreateCmd.Flags().BoolP("schedule", "c", false, "Create the default weekly pipeline schedule, on the least used slot. Default is false") gitlabProjectCreateCmd.Flags().StringP("visibility", "i", "", "Set the visibility of the project. Allowed values are private, public, internal") // Mark group as required diff --git a/scopes/gitlab/defs.go b/scopes/gitlab/defs.go index 0ae5367..259dc76 100644 --- a/scopes/gitlab/defs.go +++ b/scopes/gitlab/defs.go @@ -217,6 +217,7 @@ type ProjectRequest struct { DefaultBranch string Mirror bool SharedRunners bool + Schedule bool Group int } diff --git a/scopes/gitlab/main.go b/scopes/gitlab/main.go index c077958..27b4bca 100644 --- a/scopes/gitlab/main.go +++ b/scopes/gitlab/main.go @@ -694,6 +694,21 @@ func (g *gitlab) CreateProject(options ProjectRequest) (int, error) { g.enableMirrorForProject(project.ID, options.Path) } + // If requested, schedule the pipeline on the least used slot + if options.Schedule { + load, err := g.loadSchedules() + if err != nil { + return 0, err + } + + description, err := g.createSchedule(project.ID, project.PathWithNamespace, options.DefaultBranch, load) + if err != nil { + return 0, fmt.Errorf("cannot create the schedule: %w", err) + } + + fmt.Printf("Schedule created: %s\n", description) + } + return project.ID, nil } From 9b9a500260f794dff84ee56c76d8e28fa8e4d602 Mon Sep 17 00:00:00 2001 From: Alex Zongaro Date: Tue, 6 Oct 2026 14:47:30 +0200 Subject: [PATCH 4/8] feat(gitlab): add the schedule update command Add `opsi gitlab schedule update ...`, which gives to the user of the token the pipeline schedules of one or more projects that belong to someone else, or to nobody. A schedule runs as its owner, so it stops when the owner is blocked, deactivated or removed; taking the ownership works in every case. Cron, branch and description are not touched. --dry-run only shows the schedules with their owner, and asks no confirmation. Without it the command asks for a confirmation, unless -f. Co-Authored-By: Claude Sonnet 5.5 --- cmd/gitlabScheduleUpdateCmd.go | 50 ++++++++++++++++++++ scopes/gitlab/defs.go | 1 + scopes/gitlab/schedules.go | 82 +++++++++++++++++++++++++++++++++ scopes/gitlab/schedules_test.go | 68 +++++++++++++++++++++++++++ 4 files changed, 201 insertions(+) create mode 100644 cmd/gitlabScheduleUpdateCmd.go diff --git a/cmd/gitlabScheduleUpdateCmd.go b/cmd/gitlabScheduleUpdateCmd.go new file mode 100644 index 0000000..6517384 --- /dev/null +++ b/cmd/gitlabScheduleUpdateCmd.go @@ -0,0 +1,50 @@ +package cmd + +import ( + "fmt" + "opsi/helpers" + "os" + + "github.com/spf13/cobra" +) + +var gitlabScheduleUpdateCmd = &cobra.Command{ + Use: "update {project}...", + Args: cobra.MinimumNArgs(1), + Short: "Take the ownership of the pipeline schedules of Gitlab projects", + Long: `Take the ownership of the pipeline schedules of one or more Gitlab projects. +Each project can be given as numeric ID or as full path. + +A schedule runs as its owner, so it stops when the owner is blocked, deactivated +or removed. This command gives the schedules to the user of the token, if they +belong to someone else. Cron, branch and description are not touched. +Use --dry-run to only see the schedules, with their owner.`, + Example: ` + Show the schedules of a project and who owns them + opsi gitlab schedule update corporate/wiki/beliven-wiki --dry-run + + --- + + Take the ownership of the schedules of more projects, by path or ID + opsi gitlab schedule update corporate/wiki/beliven-wiki 1234 + `, + Run: func(cmd *cobra.Command, args []string) { + // Confirm the action (a dry run changes nothing) + dryRun, _ := cmd.Flags().GetBool("dry-run") + force, _ := cmd.Flags().GetBool("force") + if !force && !dryRun { + helpers.Confirm() + } + + if err := gitlab.UpdateSchedule(args, dryRun); err != nil { + fmt.Println(err) + os.Exit(1) + } + }, +} + +func init() { + gitlabScheduleCmd.AddCommand(gitlabScheduleUpdateCmd) + gitlabScheduleUpdateCmd.Flags().Bool("dry-run", false, "Only show the schedules and their owner") + gitlabScheduleUpdateCmd.Flags().BoolP("force", "f", false, "Not ask confirmation") +} diff --git a/scopes/gitlab/defs.go b/scopes/gitlab/defs.go index 259dc76..8291893 100644 --- a/scopes/gitlab/defs.go +++ b/scopes/gitlab/defs.go @@ -30,6 +30,7 @@ type Gitlab interface { ListGroupAccessTokens(int) error CreateMirror([]string) error CreateSchedule([]string) error + UpdateSchedule([]string, bool) error UpdateCleanUpPolicy(string) error } diff --git a/scopes/gitlab/schedules.go b/scopes/gitlab/schedules.go index 8363ad4..a948b62 100644 --- a/scopes/gitlab/schedules.go +++ b/scopes/gitlab/schedules.go @@ -370,3 +370,85 @@ func (g *gitlab) CreateSchedule(projects []string) error { return errors.Join(failures...) } + +// describeSchedule is the one line description of a schedule, owner included. +func describeSchedule(schedule gitlabPipelineSchedule) string { + state := "active" + if !schedule.Active { + state = "inactive" + } + + owner := "none" + if schedule.Owner != nil { + owner = fmt.Sprintf("%s (%s)", schedule.Owner.Username, schedule.Owner.State) + } + + description := fmt.Sprintf("%s on %s, %s, owner %s", schedule.Cron, strings.TrimPrefix(schedule.Ref, "refs/heads/"), state, owner) + + // The list of the schedules does not always carry the last pipeline + if schedule.LastPipeline != nil { + description += ", last pipeline " + schedule.LastPipeline.Status + } + + return description +} + +// UpdateSchedule gives to the user of the token the ownership of the pipeline +// schedules of the projects (numeric ID or full path), the ones whose owner is +// someone else, is blocked, or does not exist anymore. A schedule runs as its +// owner, so it stops when the owner goes away. The cron, the branch and the +// description are not touched. With dryRun it only shows the schedules. +func (g *gitlab) UpdateSchedule(projects []string, dryRun bool) error { + response, err := g.request("GET", "/user", nil, nil) + if err != nil { + return err + } + + var me gitlabScheduleOwner + if err := json.Unmarshal(response, &me); err != nil { + return err + } + + var failures []error + for _, ref := range projects { + project, err := g.findProject(ref) + if err != nil { + fmt.Printf("Error when reading %s: %v\n", ref, err) + failures = append(failures, fmt.Errorf("%s: %w", ref, err)) + continue + } + + schedules, err := g.listSchedules(project.ID) + if err != nil { + fmt.Printf("Error when reading the schedules of %s: %v\n", project.PathWithNamespace, err) + failures = append(failures, fmt.Errorf("%s: %w", project.PathWithNamespace, err)) + continue + } + + if len(schedules) == 0 { + fmt.Printf("%s: no schedules\n", project.PathWithNamespace) + continue + } + + for _, schedule := range schedules { + line := fmt.Sprintf("%s: %s", project.PathWithNamespace, describeSchedule(schedule)) + + switch { + case schedule.Owner != nil && schedule.Owner.ID == me.ID: + fmt.Printf("%s: already yours\n", line) + case dryRun: + fmt.Printf("%s: would take ownership\n", line) + default: + _, err := g.request("POST", fmt.Sprintf("/projects/%d/pipeline_schedules/%d/take_ownership", project.ID, schedule.ID), nil, nil) + if err != nil { + fmt.Printf("%s: error when taking ownership: %v\n", line, err) + failures = append(failures, fmt.Errorf("%s: %w", project.PathWithNamespace, err)) + continue + } + fmt.Printf("%s: ownership taken\n", line) + } + } + } + + return errors.Join(failures...) +} diff --git a/scopes/gitlab/schedules_test.go b/scopes/gitlab/schedules_test.go index d5ab5d7..3c54ff6 100644 --- a/scopes/gitlab/schedules_test.go +++ b/scopes/gitlab/schedules_test.go @@ -201,3 +201,71 @@ func TestCreateSchedule(t *testing.T) { t.Errorf("the two schedules got the same cron %v", byProject["/projects/1/pipeline_schedules"]["cron"]) } } + +func TestUpdateSchedule(t *testing.T) { + var mu sync.Mutex + taken := []string{} + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/user": + fmt.Fprint(w, `{"id":10,"username":"cicd","state":"active"}`) + case r.URL.EscapedPath() == "/projects/corporate%2Fapp": + fmt.Fprint(w, `{"id":1,"path_with_namespace":"corporate/app","default_branch":"main"}`) + case r.URL.EscapedPath() == "/projects/corporate%2Fempty": + fmt.Fprint(w, `{"id":2,"path_with_namespace":"corporate/empty","default_branch":"main"}`) + case r.URL.Path == "/projects/1/pipeline_schedules": + fmt.Fprint(w, `[ + {"id":1,"cron":"0 9 * * 1","ref":"refs/heads/main","active":true,"owner":{"id":10,"username":"cicd","state":"active"}}, + {"id":2,"cron":"0 10 * * 2","ref":"refs/heads/main","active":true,"owner":{"id":20,"username":"gone","state":"blocked"}}, + {"id":3,"cron":"0 11 * * 3","ref":"refs/heads/main","active":false,"owner":null}, + {"id":4,"cron":"0 12 * * 4","ref":"refs/heads/main","active":true,"owner":{"id":30,"username":"other","state":"active"}}]`) + case r.URL.Path == "/projects/2/pipeline_schedules": + fmt.Fprint(w, `[]`) + case r.Method == "POST" && strings.HasSuffix(r.URL.Path, "/take_ownership"): + if r.URL.Path == "/projects/1/pipeline_schedules/4/take_ownership" { + w.WriteHeader(http.StatusForbidden) + fmt.Fprint(w, `{"message":"403 Forbidden"}`) + return + } + mu.Lock() + taken = append(taken, r.URL.Path) + mu.Unlock() + w.WriteHeader(http.StatusCreated) + fmt.Fprint(w, `{}`) + default: + t.Errorf("unexpected request %s %s", r.Method, r.URL.EscapedPath()) + } + })) + defer server.Close() + + g := &gitlab{apiURL: server.URL, token: "t"} + + // A dry run must not change anything + if err := g.UpdateSchedule([]string{"corporate/app", "corporate/empty"}, true); err != nil { + t.Errorf("dry run failed: %v", err) + } + if len(taken) != 0 { + t.Errorf("dry run took ownership of %v", taken) + } + + // Schedules already owned are left alone, the others are taken + // (including the one with no owner and the inactive one); a failure + // is reported without stopping the rest + err := g.UpdateSchedule([]string{"corporate/app", "corporate/empty"}, false) + if err == nil || !strings.Contains(err.Error(), "corporate/app") { + t.Errorf("expected the failure on schedule 4, got %v", err) + } + + want := "/projects/1/pipeline_schedules/2/take_ownership,/projects/1/pipeline_schedules/3/take_ownership" + if got := strings.Join(taken, ","); got != want { + t.Errorf("took ownership of %q, want %q", got, want) + } +} + +func TestDescribeSchedule(t *testing.T) { + got := describeSchedule(gitlabPipelineSchedule{Cron: "0 9 * * 1", Ref: "refs/heads/main", Active: true}) + if got != "0 9 * * 1 on main, active, owner none" { + t.Errorf("got %q", got) + } +} From 29c0aa49785d56a5ce0838644e06d7304b4c9bee Mon Sep 17 00:00:00 2001 From: Alex Zongaro Date: Tue, 6 Oct 2026 14:50:39 +0200 Subject: [PATCH 5/8] fix(gitlab): only take the schedules that lost their owner `schedule update` took every schedule owned by someone else, so launching it with a personal token would have stolen the schedules of a service user. Take only the schedules whose owner is missing, blocked or deactivated, the ones that stopped running; the schedules of an active user are left alone. Co-Authored-By: Claude Sonnet 5.5 --- cmd/gitlabScheduleUpdateCmd.go | 10 +++++----- scopes/gitlab/schedules.go | 11 +++++++---- scopes/gitlab/schedules_test.go | 16 +++++++++------- 3 files changed, 21 insertions(+), 16 deletions(-) diff --git a/cmd/gitlabScheduleUpdateCmd.go b/cmd/gitlabScheduleUpdateCmd.go index 6517384..76ceff3 100644 --- a/cmd/gitlabScheduleUpdateCmd.go +++ b/cmd/gitlabScheduleUpdateCmd.go @@ -11,13 +11,13 @@ import ( var gitlabScheduleUpdateCmd = &cobra.Command{ Use: "update {project}...", Args: cobra.MinimumNArgs(1), - Short: "Take the ownership of the pipeline schedules of Gitlab projects", - Long: `Take the ownership of the pipeline schedules of one or more Gitlab projects. + Short: "Fix the pipeline schedules of Gitlab projects that lost their owner", + Long: `Fix the pipeline schedules of one or more Gitlab projects that lost their owner. Each project can be given as numeric ID or as full path. A schedule runs as its owner, so it stops when the owner is blocked, deactivated -or removed. This command gives the schedules to the user of the token, if they -belong to someone else. Cron, branch and description are not touched. +or removed. This command gives those schedules to the user of the token. +The schedules of an active user are left alone, as cron, branch and description. Use --dry-run to only see the schedules, with their owner.`, Example: ` Show the schedules of a project and who owns them @@ -25,7 +25,7 @@ Use --dry-run to only see the schedules, with their owner.`, --- - Take the ownership of the schedules of more projects, by path or ID + Fix the schedules of more projects, by path or ID opsi gitlab schedule update corporate/wiki/beliven-wiki 1234 `, Run: func(cmd *cobra.Command, args []string) { diff --git a/scopes/gitlab/schedules.go b/scopes/gitlab/schedules.go index a948b62..e5ddbfe 100644 --- a/scopes/gitlab/schedules.go +++ b/scopes/gitlab/schedules.go @@ -394,10 +394,11 @@ func describeSchedule(schedule gitlabPipelineSchedule) string { } // UpdateSchedule gives to the user of the token the ownership of the pipeline -// schedules of the projects (numeric ID or full path), the ones whose owner is -// someone else, is blocked, or does not exist anymore. A schedule runs as its -// owner, so it stops when the owner goes away. The cron, the branch and the -// description are not touched. With dryRun it only shows the schedules. +// schedules of the projects (numeric ID or full path) that have lost their +// owner: the owner does not exist anymore, is blocked or is deactivated. A +// schedule runs as its owner, so it would not run anymore. The schedules of an +// active user are left alone, as the cron, the branch and the description. +// With dryRun it only shows the schedules. func (g *gitlab) UpdateSchedule(projects []string, dryRun bool) error { response, err := g.request("GET", "/user", nil, nil) if err != nil { @@ -436,6 +437,8 @@ func (g *gitlab) UpdateSchedule(projects []string, dryRun bool) error { switch { case schedule.Owner != nil && schedule.Owner.ID == me.ID: fmt.Printf("%s: already yours\n", line) + case schedule.Owner != nil && schedule.Owner.State == "active": + fmt.Printf("%s: left alone\n", line) case dryRun: fmt.Printf("%s: would take ownership\n", line) default: diff --git a/scopes/gitlab/schedules_test.go b/scopes/gitlab/schedules_test.go index 3c54ff6..e399b34 100644 --- a/scopes/gitlab/schedules_test.go +++ b/scopes/gitlab/schedules_test.go @@ -219,11 +219,13 @@ func TestUpdateSchedule(t *testing.T) { {"id":1,"cron":"0 9 * * 1","ref":"refs/heads/main","active":true,"owner":{"id":10,"username":"cicd","state":"active"}}, {"id":2,"cron":"0 10 * * 2","ref":"refs/heads/main","active":true,"owner":{"id":20,"username":"gone","state":"blocked"}}, {"id":3,"cron":"0 11 * * 3","ref":"refs/heads/main","active":false,"owner":null}, - {"id":4,"cron":"0 12 * * 4","ref":"refs/heads/main","active":true,"owner":{"id":30,"username":"other","state":"active"}}]`) + {"id":4,"cron":"0 12 * * 4","ref":"refs/heads/main","active":true,"owner":{"id":30,"username":"other","state":"active"}}, + {"id":5,"cron":"0 13 * * 5","ref":"refs/heads/main","active":true,"owner":{"id":40,"username":"left","state":"deactivated"}}, + {"id":6,"cron":"0 14 * * 5","ref":"refs/heads/main","active":true,"owner":{"id":50,"username":"banned","state":"blocked"}}]`) case r.URL.Path == "/projects/2/pipeline_schedules": fmt.Fprint(w, `[]`) case r.Method == "POST" && strings.HasSuffix(r.URL.Path, "/take_ownership"): - if r.URL.Path == "/projects/1/pipeline_schedules/4/take_ownership" { + if r.URL.Path == "/projects/1/pipeline_schedules/6/take_ownership" { w.WriteHeader(http.StatusForbidden) fmt.Fprint(w, `{"message":"403 Forbidden"}`) return @@ -249,15 +251,15 @@ func TestUpdateSchedule(t *testing.T) { t.Errorf("dry run took ownership of %v", taken) } - // Schedules already owned are left alone, the others are taken - // (including the one with no owner and the inactive one); a failure - // is reported without stopping the rest + // Only the schedules that lost their owner are taken (blocked or missing + // owner); the ones of an active user, mine included, are left alone. + // A failure is reported without stopping the rest err := g.UpdateSchedule([]string{"corporate/app", "corporate/empty"}, false) if err == nil || !strings.Contains(err.Error(), "corporate/app") { - t.Errorf("expected the failure on schedule 4, got %v", err) + t.Errorf("expected the failure on schedule 6, got %v", err) } - want := "/projects/1/pipeline_schedules/2/take_ownership,/projects/1/pipeline_schedules/3/take_ownership" + want := "/projects/1/pipeline_schedules/2/take_ownership,/projects/1/pipeline_schedules/3/take_ownership,/projects/1/pipeline_schedules/5/take_ownership" if got := strings.Join(taken, ","); got != want { t.Errorf("took ownership of %q, want %q", got, want) } From db0d58f15d215d66dcab0b435c97649e97612a1a Mon Sep 17 00:00:00 2001 From: Alex Zongaro Date: Tue, 6 Oct 2026 14:54:02 +0200 Subject: [PATCH 6/8] feat(gitlab): check all the projects when schedule update has none `opsi gitlab schedule update` without projects checks all of them, 5 at a time, with the progress on a single line. It only looks at the active schedules: it never creates or activates one, so the projects without an active schedule are ignored. A project whose schedules cannot be read (pipelines disabled) is skipped without failing the command, unless it was named explicitly. Co-Authored-By: Claude Sonnet 5.5 --- cmd/gitlabScheduleUpdateCmd.go | 19 ++-- scopes/gitlab/schedules.go | 151 +++++++++++++++++++++++--------- scopes/gitlab/schedules_test.go | 52 ++++++++++- 3 files changed, 173 insertions(+), 49 deletions(-) diff --git a/cmd/gitlabScheduleUpdateCmd.go b/cmd/gitlabScheduleUpdateCmd.go index 76ceff3..ca7cfab 100644 --- a/cmd/gitlabScheduleUpdateCmd.go +++ b/cmd/gitlabScheduleUpdateCmd.go @@ -9,24 +9,31 @@ import ( ) var gitlabScheduleUpdateCmd = &cobra.Command{ - Use: "update {project}...", - Args: cobra.MinimumNArgs(1), + Use: "update [project]...", + Args: cobra.ArbitraryArgs, Short: "Fix the pipeline schedules of Gitlab projects that lost their owner", - Long: `Fix the pipeline schedules of one or more Gitlab projects that lost their owner. -Each project can be given as numeric ID or as full path. + Long: `Fix the active pipeline schedules of Gitlab projects that lost their owner. +Each project can be given as numeric ID or as full path. Without any project, +all the projects are checked. A schedule runs as its owner, so it stops when the owner is blocked, deactivated or removed. This command gives those schedules to the user of the token. The schedules of an active user are left alone, as cron, branch and description. +Nothing is created or activated: projects without an active schedule are ignored. Use --dry-run to only see the schedules, with their owner.`, Example: ` - Show the schedules of a project and who owns them - opsi gitlab schedule update corporate/wiki/beliven-wiki --dry-run + Show the active schedules of all the projects and who owns them + opsi gitlab schedule update --dry-run --- Fix the schedules of more projects, by path or ID opsi gitlab schedule update corporate/wiki/beliven-wiki 1234 + + --- + + Fix the schedules of all the projects + opsi gitlab schedule update `, Run: func(cmd *cobra.Command, args []string) { // Confirm the action (a dry run changes nothing) diff --git a/scopes/gitlab/schedules.go b/scopes/gitlab/schedules.go index e5ddbfe..ce63107 100644 --- a/scopes/gitlab/schedules.go +++ b/scopes/gitlab/schedules.go @@ -393,12 +393,62 @@ func describeSchedule(schedule gitlabPipelineSchedule) string { return description } -// UpdateSchedule gives to the user of the token the ownership of the pipeline -// schedules of the projects (numeric ID or full path) that have lost their -// owner: the owner does not exist anymore, is blocked or is deactivated. A -// schedule runs as its owner, so it would not run anymore. The schedules of an -// active user are left alone, as the cron, the branch and the description. -// With dryRun it only shows the schedules. +// updateProjectSchedules fixes the active schedules of a project that lost +// their owner. It returns the lines to print and the failures. Inactive +// schedules are never considered, and nothing is created or activated. +// A project whose schedules cannot be read (typically the pipelines are +// disabled) has nothing to fix: it is only mentioned when asked by name. +func (g *gitlab) updateProjectSchedules(project scheduleProject, me gitlabScheduleOwner, dryRun bool, byName bool) ([]string, []error) { + lines := []string{} + failures := []error{} + + schedules, err := g.listSchedules(project.ID) + if err != nil && strings.Contains(err.Error(), "403") { + if byName { + lines = append(lines, fmt.Sprintf("%s: the schedules cannot be read (pipelines disabled?)", project.PathWithNamespace)) + } + return lines, failures + } + if err != nil { + lines = append(lines, fmt.Sprintf("Error when reading the schedules of %s: %v", project.PathWithNamespace, err)) + return lines, append(failures, fmt.Errorf("%s: %w", project.PathWithNamespace, err)) + } + + for _, schedule := range schedules { + if !schedule.Active { + continue + } + + line := fmt.Sprintf("%s: %s", project.PathWithNamespace, describeSchedule(schedule)) + + switch { + case schedule.Owner != nil && schedule.Owner.ID == me.ID: + lines = append(lines, line+": already yours") + case schedule.Owner != nil && schedule.Owner.State == "active": + lines = append(lines, line+": left alone") + case dryRun: + lines = append(lines, line+": would take ownership") + default: + _, err := g.request("POST", fmt.Sprintf("/projects/%d/pipeline_schedules/%d/take_ownership", project.ID, schedule.ID), nil, nil) + if err != nil { + lines = append(lines, fmt.Sprintf("%s: error when taking ownership: %v", line, err)) + failures = append(failures, fmt.Errorf("%s: %w", project.PathWithNamespace, err)) + continue + } + lines = append(lines, line+": ownership taken") + } + } + + return lines, failures +} + +// UpdateSchedule gives to the user of the token the ownership of the active +// pipeline schedules that have lost their owner: the owner does not exist +// anymore, is blocked or is deactivated. A schedule runs as its owner, so it +// would not run anymore. The schedules of an active user are left alone, as +// the cron, the branch and the description. It never creates or activates a +// schedule. The projects are given by numeric ID or full path; with none, all +// the projects are checked. With dryRun it only shows the schedules. func (g *gitlab) UpdateSchedule(projects []string, dryRun bool) error { response, err := g.request("GET", "/user", nil, nil) if err != nil { @@ -411,47 +461,68 @@ func (g *gitlab) UpdateSchedule(projects []string, dryRun bool) error { } var failures []error - for _, ref := range projects { - project, err := g.findProject(ref) - if err != nil { - fmt.Printf("Error when reading %s: %v\n", ref, err) - failures = append(failures, fmt.Errorf("%s: %w", ref, err)) - continue - } + targets := []scheduleProject{} - schedules, err := g.listSchedules(project.ID) + if len(projects) == 0 { + all, err := g.listProjects() if err != nil { - fmt.Printf("Error when reading the schedules of %s: %v\n", project.PathWithNamespace, err) - failures = append(failures, fmt.Errorf("%s: %w", project.PathWithNamespace, err)) - continue + return err } - - if len(schedules) == 0 { - fmt.Printf("%s: no schedules\n", project.PathWithNamespace) - continue + for _, project := range all { + targets = append(targets, scheduleProject{ID: project.ID, PathWithNamespace: project.PathWithNamespace}) } - - for _, schedule := range schedules { - line := fmt.Sprintf("%s: %s", project.PathWithNamespace, describeSchedule(schedule)) - - switch { - case schedule.Owner != nil && schedule.Owner.ID == me.ID: - fmt.Printf("%s: already yours\n", line) - case schedule.Owner != nil && schedule.Owner.State == "active": - fmt.Printf("%s: left alone\n", line) - case dryRun: - fmt.Printf("%s: would take ownership\n", line) - default: - _, err := g.request("POST", fmt.Sprintf("/projects/%d/pipeline_schedules/%d/take_ownership", project.ID, schedule.ID), nil, nil) - if err != nil { - fmt.Printf("%s: error when taking ownership: %v\n", line, err) - failures = append(failures, fmt.Errorf("%s: %w", project.PathWithNamespace, err)) - continue - } - fmt.Printf("%s: ownership taken\n", line) + } else { + for _, ref := range projects { + project, err := g.findProject(ref) + if err != nil { + fmt.Printf("Error when reading %s: %v\n", ref, err) + failures = append(failures, fmt.Errorf("%s: %w", ref, err)) + continue } + targets = append(targets, project) } } + results := make([][]string, len(targets)) + var ( + mu sync.Mutex + wg sync.WaitGroup + done int + ) + slots := make(chan struct{}, deprovisioningConcurrency) + + for i, project := range targets { + wg.Add(1) + slots <- struct{}{} + go func(i int, project scheduleProject) { + defer wg.Done() + defer func() { <-slots }() + + lines, errs := g.updateProjectSchedules(project, me, dryRun, len(projects) > 0) + + mu.Lock() + defer mu.Unlock() + results[i] = lines + failures = append(failures, errs...) + done++ + progress("Checking schedules... %d/%d", done, len(targets)) + }(i, project) + } + + wg.Wait() + endProgress() + + found := 0 + for _, lines := range results { + found += len(lines) + for _, line := range lines { + fmt.Println(line) + } + } + + if found == 0 { + fmt.Println("No active schedules found") + } + return errors.Join(failures...) } diff --git a/scopes/gitlab/schedules_test.go b/scopes/gitlab/schedules_test.go index e399b34..f2574f0 100644 --- a/scopes/gitlab/schedules_test.go +++ b/scopes/gitlab/schedules_test.go @@ -251,15 +251,16 @@ func TestUpdateSchedule(t *testing.T) { t.Errorf("dry run took ownership of %v", taken) } - // Only the schedules that lost their owner are taken (blocked or missing - // owner); the ones of an active user, mine included, are left alone. + // Only the active schedules that lost their owner are taken (blocked or + // deactivated owner); the ones of an active user, mine included, are left alone. // A failure is reported without stopping the rest err := g.UpdateSchedule([]string{"corporate/app", "corporate/empty"}, false) if err == nil || !strings.Contains(err.Error(), "corporate/app") { t.Errorf("expected the failure on schedule 6, got %v", err) } - want := "/projects/1/pipeline_schedules/2/take_ownership,/projects/1/pipeline_schedules/3/take_ownership,/projects/1/pipeline_schedules/5/take_ownership" + // The inactive schedule 3 is not touched, even if it has no owner + want := "/projects/1/pipeline_schedules/2/take_ownership,/projects/1/pipeline_schedules/5/take_ownership" if got := strings.Join(taken, ","); got != want { t.Errorf("took ownership of %q, want %q", got, want) } @@ -271,3 +272,48 @@ func TestDescribeSchedule(t *testing.T) { t.Errorf("got %q", got) } } + +func TestUpdateScheduleAllProjects(t *testing.T) { + var mu sync.Mutex + taken := []string{} + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/user": + fmt.Fprint(w, `{"id":10,"username":"cicd","state":"active"}`) + case r.URL.Path == "/projects": + fmt.Fprint(w, `[{"id":1,"path_with_namespace":"a/orphan"},{"id":2,"path_with_namespace":"a/inactive-only"},{"id":3,"path_with_namespace":"a/none"},{"id":4,"path_with_namespace":"a/no-pipelines"}]`) + case r.URL.Path == "/projects/4/pipeline_schedules": + w.WriteHeader(http.StatusForbidden) + fmt.Fprint(w, `{"message":"403 Forbidden"}`) + case r.URL.Path == "/projects/1/pipeline_schedules": + fmt.Fprint(w, `[{"id":1,"cron":"0 9 * * 1","ref":"refs/heads/main","active":true,"owner":{"id":20,"username":"gone","state":"blocked"}}]`) + case r.URL.Path == "/projects/2/pipeline_schedules": + fmt.Fprint(w, `[{"id":2,"cron":"0 9 * * 1","ref":"refs/heads/main","active":false,"owner":null}]`) + case r.URL.Path == "/projects/3/pipeline_schedules": + fmt.Fprint(w, `[]`) + case r.Method == "POST" && strings.HasSuffix(r.URL.Path, "/take_ownership"): + mu.Lock() + taken = append(taken, r.URL.Path) + mu.Unlock() + w.WriteHeader(http.StatusCreated) + fmt.Fprint(w, `{}`) + default: + t.Errorf("unexpected request %s %s", r.Method, r.URL.EscapedPath()) + } + })) + defer server.Close() + + g := &gitlab{apiURL: server.URL, token: "t"} + + // With no projects all of them are checked, but a project is touched only + // when it has an active schedule, and nothing is ever created + // (a project that cannot be read, like the one without pipelines, is no failure) + if err := g.UpdateSchedule(nil, false); err != nil { + t.Fatal(err) + } + + if got := strings.Join(taken, ","); got != "/projects/1/pipeline_schedules/1/take_ownership" { + t.Errorf("took ownership of %q", got) + } +} From 92e2522a97f134ff079fb3f4cb3864cbc54477ea Mon Sep 17 00:00:00 2001 From: Alex Zongaro Date: Tue, 6 Oct 2026 14:58:46 +0200 Subject: [PATCH 7/8] feat(gitlab): show the description of the schedules in schedule update A project can have more schedules with the same cron, so the lines looked like duplicates. Show the description of each schedule next to the project. Co-Authored-By: Claude Sonnet 5.5 --- scopes/gitlab/schedules.go | 13 ++++++++++++- scopes/gitlab/schedules_test.go | 13 +++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/scopes/gitlab/schedules.go b/scopes/gitlab/schedules.go index ce63107..9b1718e 100644 --- a/scopes/gitlab/schedules.go +++ b/scopes/gitlab/schedules.go @@ -393,6 +393,17 @@ func describeSchedule(schedule gitlabPipelineSchedule) string { return description } +// scheduleLine is the line shown for a schedule: a project can have more +// schedules with the same cron, the description tells them apart. +func scheduleLine(projectPath string, schedule gitlabPipelineSchedule) string { + label := projectPath + if schedule.Description != "" { + label = fmt.Sprintf("%s [%s]", projectPath, schedule.Description) + } + + return fmt.Sprintf("%s: %s", label, describeSchedule(schedule)) +} + // updateProjectSchedules fixes the active schedules of a project that lost // their owner. It returns the lines to print and the failures. Inactive // schedules are never considered, and nothing is created or activated. @@ -419,7 +430,7 @@ func (g *gitlab) updateProjectSchedules(project scheduleProject, me gitlabSchedu continue } - line := fmt.Sprintf("%s: %s", project.PathWithNamespace, describeSchedule(schedule)) + line := scheduleLine(project.PathWithNamespace, schedule) switch { case schedule.Owner != nil && schedule.Owner.ID == me.ID: diff --git a/scopes/gitlab/schedules_test.go b/scopes/gitlab/schedules_test.go index f2574f0..1882d9b 100644 --- a/scopes/gitlab/schedules_test.go +++ b/scopes/gitlab/schedules_test.go @@ -266,6 +266,19 @@ func TestUpdateSchedule(t *testing.T) { } } +func TestScheduleLine(t *testing.T) { + schedule := gitlabPipelineSchedule{Description: "nightly build", Cron: "0 9 * * 1", Ref: "refs/heads/main", Active: true} + + if got := scheduleLine("group/app", schedule); got != "group/app [nightly build]: 0 9 * * 1 on main, active, owner none" { + t.Errorf("got %q", got) + } + + schedule.Description = "" + if got := scheduleLine("group/app", schedule); got != "group/app: 0 9 * * 1 on main, active, owner none" { + t.Errorf("got %q", got) + } +} + func TestDescribeSchedule(t *testing.T) { got := describeSchedule(gitlabPipelineSchedule{Cron: "0 9 * * 1", Ref: "refs/heads/main", Active: true}) if got != "0 9 * * 1 on main, active, owner none" { From d9dd26147917fdf762a14cd13c85f65a9bbd2385 Mon Sep 17 00:00:00 2001 From: Alex Zongaro Date: Tue, 6 Oct 2026 15:09:50 +0200 Subject: [PATCH 8/8] refactor(gitlab): refer to the projects of the schedule commands by ID The other commands take the numeric ID of the project (and of the group), so the schedule commands do the same instead of also accepting the path. Support for the path can be added to every command later. Co-Authored-By: Claude Sonnet 5.5 --- cmd/gitlabScheduleCreateCmd.go | 18 ++++++++++++------ cmd/gitlabScheduleUpdateCmd.go | 16 +++++++++++----- cmd/projectIDs.go | 21 +++++++++++++++++++++ scopes/gitlab/defs.go | 4 ++-- scopes/gitlab/schedules.go | 30 ++++++++++++++---------------- scopes/gitlab/schedules_test.go | 20 ++++++++++---------- 6 files changed, 70 insertions(+), 39 deletions(-) create mode 100644 cmd/projectIDs.go diff --git a/cmd/gitlabScheduleCreateCmd.go b/cmd/gitlabScheduleCreateCmd.go index 20fd428..db022b9 100644 --- a/cmd/gitlabScheduleCreateCmd.go +++ b/cmd/gitlabScheduleCreateCmd.go @@ -8,11 +8,11 @@ import ( ) var gitlabScheduleCreateCmd = &cobra.Command{ - Use: "create {project}...", + Use: "create {project_id}...", Args: cobra.MinimumNArgs(1), Short: "Create the default pipeline schedule for existing Gitlab projects", Long: `Create the default pipeline schedule for one or more existing Gitlab projects. -Each project can be given as numeric ID or as full path. +Each project is given by its numeric ID. The schedule is weekly, on a working day between 09:00 and 18:00 (Europe/Rome). The day and the time are chosen among the ones used by the fewest schedules, @@ -21,15 +21,21 @@ It runs on the "main" branch when the project has it, otherwise on the default b Projects that already have an active schedule are left alone.`, Example: ` Create the schedule for a project - opsi gitlab schedule create corporate/wiki/beliven-wiki + opsi gitlab schedule create 239 --- - Create the schedule for more projects, by path or ID - opsi gitlab schedule create corporate/wiki/beliven-wiki 1234 + Create the schedule for more projects + opsi gitlab schedule create 239 1234 `, Run: func(cmd *cobra.Command, args []string) { - if err := gitlab.CreateSchedule(args); err != nil { + ids, err := projectIDs(args) + if err != nil { + fmt.Println(err) + os.Exit(1) + } + + if err := gitlab.CreateSchedule(ids); err != nil { fmt.Println(err) os.Exit(1) } diff --git a/cmd/gitlabScheduleUpdateCmd.go b/cmd/gitlabScheduleUpdateCmd.go index ca7cfab..56699b4 100644 --- a/cmd/gitlabScheduleUpdateCmd.go +++ b/cmd/gitlabScheduleUpdateCmd.go @@ -9,11 +9,11 @@ import ( ) var gitlabScheduleUpdateCmd = &cobra.Command{ - Use: "update [project]...", + Use: "update [project_id]...", Args: cobra.ArbitraryArgs, Short: "Fix the pipeline schedules of Gitlab projects that lost their owner", Long: `Fix the active pipeline schedules of Gitlab projects that lost their owner. -Each project can be given as numeric ID or as full path. Without any project, +Each project is given by its numeric ID. Without any project, all the projects are checked. A schedule runs as its owner, so it stops when the owner is blocked, deactivated @@ -27,8 +27,8 @@ Use --dry-run to only see the schedules, with their owner.`, --- - Fix the schedules of more projects, by path or ID - opsi gitlab schedule update corporate/wiki/beliven-wiki 1234 + Fix the schedules of more projects + opsi gitlab schedule update 239 1234 --- @@ -36,6 +36,12 @@ Use --dry-run to only see the schedules, with their owner.`, opsi gitlab schedule update `, Run: func(cmd *cobra.Command, args []string) { + ids, err := projectIDs(args) + if err != nil { + fmt.Println(err) + os.Exit(1) + } + // Confirm the action (a dry run changes nothing) dryRun, _ := cmd.Flags().GetBool("dry-run") force, _ := cmd.Flags().GetBool("force") @@ -43,7 +49,7 @@ Use --dry-run to only see the schedules, with their owner.`, helpers.Confirm() } - if err := gitlab.UpdateSchedule(args, dryRun); err != nil { + if err := gitlab.UpdateSchedule(ids, dryRun); err != nil { fmt.Println(err) os.Exit(1) } diff --git a/cmd/projectIDs.go b/cmd/projectIDs.go new file mode 100644 index 0000000..a02eb45 --- /dev/null +++ b/cmd/projectIDs.go @@ -0,0 +1,21 @@ +package cmd + +import ( + "fmt" + "strconv" +) + +// projectIDs converts the arguments of a command into Gitlab project IDs. +func projectIDs(args []string) ([]int, error) { + ids := make([]int, 0, len(args)) + + for _, arg := range args { + id, err := strconv.Atoi(arg) + if err != nil || id <= 0 { + return nil, fmt.Errorf("%q is not a valid project ID", arg) + } + ids = append(ids, id) + } + + return ids, nil +} diff --git a/scopes/gitlab/defs.go b/scopes/gitlab/defs.go index 8291893..183b6ce 100644 --- a/scopes/gitlab/defs.go +++ b/scopes/gitlab/defs.go @@ -29,8 +29,8 @@ type Gitlab interface { UpdateMirroring() error ListGroupAccessTokens(int) error CreateMirror([]string) error - CreateSchedule([]string) error - UpdateSchedule([]string, bool) error + CreateSchedule([]int) error + UpdateSchedule([]int, bool) error UpdateCleanUpPolicy(string) error } diff --git a/scopes/gitlab/schedules.go b/scopes/gitlab/schedules.go index 9b1718e..6101bb8 100644 --- a/scopes/gitlab/schedules.go +++ b/scopes/gitlab/schedules.go @@ -5,7 +5,6 @@ import ( "errors" "fmt" "hash/fnv" - "net/url" "strconv" "strings" "sync" @@ -303,10 +302,10 @@ type scheduleProject struct { DefaultBranch string `json:"default_branch"` } -func (g *gitlab) findProject(ref string) (scheduleProject, error) { +func (g *gitlab) findProject(id int) (scheduleProject, error) { var project scheduleProject - response, err := g.request("GET", "/projects/"+url.PathEscape(ref), nil, nil) + response, err := g.request("GET", fmt.Sprintf("/projects/%d", id), nil, nil) if err != nil { return project, err } @@ -317,17 +316,16 @@ func (g *gitlab) findProject(ref string) (scheduleProject, error) { } // CreateSchedule creates the default pipeline schedule on each of the -// projects (numeric ID or full path). A project that already has an active -// schedule is left alone. -func (g *gitlab) CreateSchedule(projects []string) error { +// projects (by ID). A project that already has an active schedule is left alone. +func (g *gitlab) CreateSchedule(projects []int) error { var failures []error var load scheduleLoad - for _, ref := range projects { - project, err := g.findProject(ref) + for _, id := range projects { + project, err := g.findProject(id) if err != nil { - fmt.Printf("Error when reading %s: %v\n", ref, err) - failures = append(failures, fmt.Errorf("%s: %w", ref, err)) + fmt.Printf("Error when reading project #%d: %v\n", id, err) + failures = append(failures, fmt.Errorf("project #%d: %w", id, err)) continue } @@ -458,9 +456,9 @@ func (g *gitlab) updateProjectSchedules(project scheduleProject, me gitlabSchedu // anymore, is blocked or is deactivated. A schedule runs as its owner, so it // would not run anymore. The schedules of an active user are left alone, as // the cron, the branch and the description. It never creates or activates a -// schedule. The projects are given by numeric ID or full path; with none, all +// schedule. The projects are given by ID; with none, all // the projects are checked. With dryRun it only shows the schedules. -func (g *gitlab) UpdateSchedule(projects []string, dryRun bool) error { +func (g *gitlab) UpdateSchedule(projects []int, dryRun bool) error { response, err := g.request("GET", "/user", nil, nil) if err != nil { return err @@ -483,11 +481,11 @@ func (g *gitlab) UpdateSchedule(projects []string, dryRun bool) error { targets = append(targets, scheduleProject{ID: project.ID, PathWithNamespace: project.PathWithNamespace}) } } else { - for _, ref := range projects { - project, err := g.findProject(ref) + for _, id := range projects { + project, err := g.findProject(id) if err != nil { - fmt.Printf("Error when reading %s: %v\n", ref, err) - failures = append(failures, fmt.Errorf("%s: %w", ref, err)) + fmt.Printf("Error when reading project #%d: %v\n", id, err) + failures = append(failures, fmt.Errorf("project #%d: %w", id, err)) continue } targets = append(targets, project) diff --git a/scopes/gitlab/schedules_test.go b/scopes/gitlab/schedules_test.go index 1882d9b..29c901a 100644 --- a/scopes/gitlab/schedules_test.go +++ b/scopes/gitlab/schedules_test.go @@ -124,11 +124,11 @@ type scheduleServer struct { func (s *scheduleServer) handler() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { - case r.URL.Path == "/projects/corporate/with-schedule" || r.URL.EscapedPath() == "/projects/corporate%2Fwith-schedule": + case r.URL.Path == "/projects/2": fmt.Fprint(w, `{"id":2,"path_with_namespace":"corporate/with-schedule","default_branch":"main"}`) - case r.URL.EscapedPath() == "/projects/corporate%2Fstaging-only": + case r.URL.Path == "/projects/3": fmt.Fprint(w, `{"id":3,"path_with_namespace":"corporate/staging-only","default_branch":"staging"}`) - case r.URL.EscapedPath() == "/projects/corporate%2Fnew": + case r.URL.Path == "/projects/1": fmt.Fprint(w, `{"id":1,"path_with_namespace":"corporate/new","default_branch":"main"}`) case r.URL.Path == "/projects" && r.Method == "GET": fmt.Fprint(w, `[{"id":1},{"id":2},{"id":3}]`) @@ -150,7 +150,7 @@ func (s *scheduleServer) handler() http.Handler { s.mu.Unlock() w.WriteHeader(http.StatusCreated) fmt.Fprint(w, `{"id":1}`) - case strings.HasPrefix(r.URL.EscapedPath(), "/projects/missing"): + case r.URL.Path == "/projects/404": w.WriteHeader(http.StatusNotFound) fmt.Fprint(w, `{"message":"404 Project Not Found"}`) default: @@ -166,8 +166,8 @@ func TestCreateSchedule(t *testing.T) { g := &gitlab{apiURL: server.URL, token: "t"} - err := g.CreateSchedule([]string{"corporate/new", "corporate/with-schedule", "corporate/staging-only", "missing/project"}) - if err == nil || !strings.Contains(err.Error(), "missing/project") || strings.Contains(err.Error(), "corporate/new") { + err := g.CreateSchedule([]int{1, 2, 3, 404}) + if err == nil || !strings.Contains(err.Error(), "project #404") || strings.Contains(err.Error(), "corporate/new") { t.Errorf("only the missing project should fail, got %v", err) } @@ -210,9 +210,9 @@ func TestUpdateSchedule(t *testing.T) { switch { case r.URL.Path == "/user": fmt.Fprint(w, `{"id":10,"username":"cicd","state":"active"}`) - case r.URL.EscapedPath() == "/projects/corporate%2Fapp": + case r.URL.Path == "/projects/1": fmt.Fprint(w, `{"id":1,"path_with_namespace":"corporate/app","default_branch":"main"}`) - case r.URL.EscapedPath() == "/projects/corporate%2Fempty": + case r.URL.Path == "/projects/2": fmt.Fprint(w, `{"id":2,"path_with_namespace":"corporate/empty","default_branch":"main"}`) case r.URL.Path == "/projects/1/pipeline_schedules": fmt.Fprint(w, `[ @@ -244,7 +244,7 @@ func TestUpdateSchedule(t *testing.T) { g := &gitlab{apiURL: server.URL, token: "t"} // A dry run must not change anything - if err := g.UpdateSchedule([]string{"corporate/app", "corporate/empty"}, true); err != nil { + if err := g.UpdateSchedule([]int{1, 2}, true); err != nil { t.Errorf("dry run failed: %v", err) } if len(taken) != 0 { @@ -254,7 +254,7 @@ func TestUpdateSchedule(t *testing.T) { // Only the active schedules that lost their owner are taken (blocked or // deactivated owner); the ones of an active user, mine included, are left alone. // A failure is reported without stopping the rest - err := g.UpdateSchedule([]string{"corporate/app", "corporate/empty"}, false) + err := g.UpdateSchedule([]int{1, 2}, false) if err == nil || !strings.Contains(err.Error(), "corporate/app") { t.Errorf("expected the failure on schedule 6, got %v", err) }