Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
8 changes: 8 additions & 0 deletions docs/source/sctool/partials/sctool_start.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ options:
shorthand: c
usage: |
The target cluster `name or ID` (envvar SCYLLA_MANAGER_CLUSTER).
- name: enable
default_value: "false"
usage: |
Enable the task if it was disabled.
- name: help
shorthand: h
default_value: "false"
Expand All @@ -17,6 +21,10 @@ options:
default_value: "false"
usage: |
Do not resume the last run.
- name: soft
default_value: "false"
usage: |
The task will be started only if its last run hasn't finished successfully or when it finished successfully and it missed its activation scheduled after that.
inherited_options:
- name: api-cert-file
usage: |
Expand Down
8 changes: 8 additions & 0 deletions go.work
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
go 1.25.1

use (
.
./v3/pkg/managerclient
./v3/pkg/util
./v3/swagger
)
35 changes: 35 additions & 0 deletions go.work.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
bazil.org/fuse v0.0.0-20200524192727-fb710f7dfd05/go.mod h1:h0h5FBYpXThbvSfTqthw+0I4nmHnhTHkO5BoOHsBWqg=
cloud.google.com/go/compute v1.19.1 h1:am86mquDUgjGNWxiGn+5PGLbmgiWXlE/yNWpIpNvuXY=
github.com/a8m/tree v0.0.0-20201026183218-fce18e2a750e/go.mod h1:FSdwKX97koS5efgm8WevNf7XS3PqtyFkKDDXrz778cg=
github.com/alecthomas/kingpin/v2 v2.4.0/go.mod h1:0gyi0zQnjuFk8xrkNKamJoyUo382HRL7ATRpFZCw6tE=
github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE=
github.com/anacrolix/dms v1.1.0/go.mod h1:msPKAoppoNRfrYplJqx63FZ+VipDZ4Xsj3KzIQxyU7k=
github.com/atotto/clipboard v0.1.2/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/billziss-gh/cgofuse v1.4.0/go.mod h1:LJjoaUojlVjgo5GQoEJTcJNqZJeRU0nCR84CyxKt2YM=
github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/go-kit/log v0.2.1/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0=
github.com/go-logfmt/logfmt v0.5.1/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
github.com/hanwen/go-fuse/v2 v2.0.3/go.mod h1:0EQM6aH2ctVpvZ6a+onrQ/vaykxh2GH7hy3e13vzTUY=
github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0/go.mod h1:1NbS8ALrpOvjt0rHPNLyCIeMtbizbir8U//inJ+zuB8=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/nsf/termbox-go v0.0.0-20200418040025-38ba6e5628f1/go.mod h1:IuKpRQcYE1Tfu+oAQqaLisqDeXgjyyltCfsaoYN18NQ=
github.com/sevlyar/go-daemon v0.1.5/go.mod h1:6dJpPatBT9eUwM5VCw9Bt6CdX9Tk6UWvhW3MebLDRKE=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4=
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU=
goftp.io/server v0.4.0/go.mod h1:hFZeR656ErRt3ojMKt7H10vQ5nuWV1e0YeUTeorlR6k=
golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc=
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/telemetry v0.0.0-20251008203120-078029d740a8/go.mod h1:Pi4ztBfryZoJEkyFTI5/Ocsu2jXyDr6iSdgJiYE/uwE=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ func TestSctoolClusterListCredentialsIntegrationAPITest(t *testing.T) {
"--alternator-access-key-id", "id", "--alternator-secret-access-key", "key",
},
updateArgs: []string{"--delete-alternator-credentials"},
expectedOutputPattern: `<cluster_id> .*\| .*\| .*\| .*\| Alternator`,
expectedOutputPattern: `<cluster_id> .*\| .*\| .*\| .*\| CQL`,
},
} {
t.Run(tc.name, func(t *testing.T) {
Expand Down
11 changes: 10 additions & 1 deletion pkg/command/start/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ type command struct {

cluster string
noContinue bool
enable bool
soft bool
}

func NewCommand(client *managerclient.Client) *cobra.Command {
Expand All @@ -46,12 +48,19 @@ func (cmd *command) init() {
w := flag.Wrap(cmd.Flags())
w.Cluster(&cmd.cluster)
w.Unwrap().BoolVar(&cmd.noContinue, "no-continue", false, "")
w.Unwrap().BoolVar(&cmd.enable, "enable", false, "")
w.Unwrap().BoolVar(&cmd.soft, "soft", false, "")
}

func (cmd *command) run(args []string) error {
taskType, taskID, err := cmd.client.TaskSplit(cmd.Context(), cmd.cluster, args[0])
if err != nil {
return err
}
return cmd.client.StartTask(cmd.Context(), cmd.cluster, taskType, taskID, !cmd.noContinue)
params := managerclient.StartTaskParams{
Continue: !cmd.noContinue,
Enable: cmd.enable,
Soft: cmd.soft,
}
return cmd.client.StartTaskWithParams(cmd.Context(), cmd.cluster, taskType, taskID, params)
}
146 changes: 146 additions & 0 deletions pkg/command/start/cmd_integration_api_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
// Copyright (C) 2025 ScyllaDB

//go:build all || api_integration

package start

import (
"context"
"testing"
"time"

"github.com/go-openapi/strfmt"
"github.com/scylladb/scylla-manager/v3/pkg/managerclient"
"github.com/scylladb/scylla-manager/v3/pkg/testutils"
"github.com/scylladb/scylla-manager/v3/pkg/util/timeutc"
"github.com/scylladb/scylla-manager/v3/swagger/gen/scylla-manager/models"
)

const (
authToken = "token"
clusterIntroHost = "192.168.200.11"
)

func TestSoftStartIntegrationAPITest(t *testing.T) {
client, err := managerclient.NewClient("http://localhost:5080/api/v1")
if err != nil {
t.Fatalf("Unable to create managerclient to consume manager HTTP API, err = {%v}", err)
}

clusterID, err := client.CreateCluster(context.Background(), &models.Cluster{
AuthToken: authToken,
Host: clusterIntroHost,
})
if err != nil {
t.Fatalf("Unable to create cluster, err = {%v}", err)
}

defer func() {
if err := client.DeleteCluster(context.Background(), clusterID); err != nil {
t.Fatalf("Failed to delete cluster, err = {%v}", err)
}
}()

t.Log("Create task with schedule in the far future")
sd := strfmt.DateTime(timeutc.Now().Add(time.Hour))
taskID, err := client.CreateTask(context.Background(), clusterID, &managerclient.Task{
Type: managerclient.RepairTask,
Enabled: true,
Properties: make(map[string]interface{}),
Schedule: &managerclient.Schedule{
Cron: "* * * * *",
StartDate: &sd,
},
})
if err != nil {
t.Fatalf("Failed to create task, err = {%v}", err)
}

t.Log("Disable task")
if err := client.StopTask(context.Background(), clusterID, managerclient.RepairTask, taskID, true); err != nil {
t.Fatal(err)
}

t.Log("Enable and soft start task for the first time")
err = client.StartTaskWithParams(context.Background(), clusterID, managerclient.RepairTask, taskID, managerclient.StartTaskParams{
Enable: true,
Soft: true,
})
if err != nil {
t.Fatal(err)
}

t.Log("Wait for task to start running")
waitTaskStatus(t, client, clusterID, managerclient.RepairTask, taskID.String(), managerclient.TaskStatusRunning, time.Second, 5*time.Second)

t.Log("Stop task")
if err := client.StopTask(context.Background(), clusterID, managerclient.RepairTask, taskID, false); err != nil {
t.Fatal(err)
}

t.Log("Wait for task to stop")
waitTaskStatus(t, client, clusterID, managerclient.RepairTask, taskID.String(), managerclient.TaskStatusStopped, time.Second, 5*time.Second)

t.Log("Ensure that the task has no success")
task := getTask(t, client, clusterID, managerclient.RepairTask, taskID.String())
if task.LastSuccess != nil || task.SuccessCount != 0 {
t.Fatal("Expected no task success")
}

t.Log("Soft start task after stop")
err = client.StartTaskWithParams(context.Background(), clusterID, managerclient.RepairTask, taskID, managerclient.StartTaskParams{
Soft: true,
})
if err != nil {
t.Fatal(err)
}

t.Log("Wait for task to start running")
waitTaskStatus(t, client, clusterID, managerclient.RepairTask, taskID.String(), managerclient.TaskStatusRunning, time.Second, 5*time.Second)

t.Log("Wait for task to be done")
waitTaskStatus(t, client, clusterID, managerclient.RepairTask, taskID.String(), managerclient.TaskStatusDone, time.Second, 5*time.Minute)

t.Log("Ensure that task has success")
task = getTask(t, client, clusterID, managerclient.RepairTask, taskID.String())
if task.LastSuccess == nil || task.SuccessCount != 1 {
t.Fatal("Expected 1 task success")
}

t.Log("Soft start task after success")
err = client.StartTaskWithParams(context.Background(), clusterID, managerclient.RepairTask, taskID, managerclient.StartTaskParams{
Soft: true,
})
if err != nil {
t.Fatal(err)
}

t.Log("Ensure that task was not started again")
task = getTask(t, client, clusterID, managerclient.RepairTask, taskID.String())
if task.Status != managerclient.TaskStatusDone {
t.Fatalf("Expected task to be done, got %q", task.Status)
}
if task.LastSuccess == nil || task.SuccessCount != 1 {
t.Fatal("Expected 1 task success")
}
}

func getTask(t *testing.T, client managerclient.Client, clusterID, taskType, taskID string) *models.TaskListItem {
t.Helper()

tasks, err := client.ListTasks(context.Background(), clusterID, taskType, true, "", taskID)
if err != nil {
t.Fatal(err)
}
if len(tasks.TaskListItemSlice) != 1 {
t.Fatalf("Expected one task, got %d", len(tasks.TaskListItemSlice))
}
return tasks.TaskListItemSlice[0]
}

func waitTaskStatus(t *testing.T, client managerclient.Client, clusterID, taskType, taskID, status string, interval, wait time.Duration) {
testutils.WaitCond(t, func() bool {
task := getTask(t, client, clusterID, taskType, taskID)
return task.Status == status
}, interval, wait)
}
6 changes: 6 additions & 0 deletions pkg/command/start/res.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,9 @@ long: |

no-continue: |
Do not resume the last run.

enable: |
Enable the task if it was disabled.

soft: |
The task will be started only if its last run hasn't finished successfully or when it finished successfully and it missed its activation scheduled after that.
2 changes: 1 addition & 1 deletion pkg/restapi/services.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,8 @@ type SchedService interface {
DeleteTask(ctx context.Context, t *scheduler.Task) error
ListTasks(ctx context.Context, clusterID uuid.UUID, filter scheduler.ListFilter) ([]*scheduler.TaskListItem, error)
StartTask(ctx context.Context, t *scheduler.Task) error
StartTaskNoContinue(ctx context.Context, t *scheduler.Task) error
StopTask(ctx context.Context, t *scheduler.Task) error
SetTaskNoContinue(taskID uuid.UUID, force bool)
GetRun(ctx context.Context, t *scheduler.Task, runID uuid.UUID) (*scheduler.Run, error)
GetNthLastRun(ctx context.Context, t *scheduler.Task, n int) (*scheduler.Run, error)
GetLastRuns(ctx context.Context, t *scheduler.Task, n int) ([]*scheduler.Run, error)
Expand Down
90 changes: 75 additions & 15 deletions pkg/restapi/task.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"github.com/scylladb/scylla-manager/v3/pkg/service/restore"
"github.com/scylladb/scylla-manager/v3/pkg/service/scheduler"
"github.com/scylladb/scylla-manager/v3/pkg/util"
"github.com/scylladb/scylla-manager/v3/pkg/util/timeutc"
"github.com/scylladb/scylla-manager/v3/pkg/util/uuid"
)

Expand Down Expand Up @@ -365,33 +366,80 @@ func (h *taskHandler) deleteTask(w http.ResponseWriter, r *http.Request) {
func (h *taskHandler) startTask(w http.ResponseWriter, r *http.Request) {
t := mustTaskFromCtx(r)

noContinue, err := h.noContinue(r)
cont, err := parseBoolParam(r, "continue")
if err != nil {
respondBadRequest(w, r, err)
return
}

if noContinue {
err = h.Scheduler.StartTaskNoContinue(r.Context(), t)
} else {
err = h.Scheduler.StartTask(r.Context(), t)
enable, err := parseBoolParam(r, "enable")
if err != nil {
respondBadRequest(w, r, err)
return
}
soft, err := parseBoolParam(r, "soft")
if err != nil {
respondError(w, r, errors.Wrapf(err, "start task %q", t.ID))
respondBadRequest(w, r, err)
return
}
}

func (h *taskHandler) noContinue(r *http.Request) (bool, error) {
v := r.FormValue("continue")
if v == "" {
return false, nil
if !cont {
// Soft started tasks might not be started right away.
// Because of that, we need ot force their no continue
// to skip timestamp validation.
h.Scheduler.SetTaskNoContinue(t.ID, soft)
}

b, err := strconv.ParseBool(v)
if enable {
t.Enabled = true
// Putting the task might start it (according to its schedule),
// but starting an already running task is a no-op, so that's not a problem.
// We need to put task before handling soft start, because the checks that
// it performs are based on lousy timestamp comparison, so we want to first
// schedule the task according to its schedule (which also works on timestamps),
// and only after that evaluate whether we need to additionally start it or not.
// Without that, we could skip task activation in corner cases.
if err := h.Scheduler.PutTask(r.Context(), t); err != nil {
respondError(w, r, errors.Wrapf(err, "enable task %q", t.ID))
return
}
}

if soft {
ok, err := h.shouldSoftStartTask(r.Context(), t)
if err != nil {
respondError(w, r, errors.Wrapf(err, "check soft start for task %q", t.ID))
return
}
if !ok {
// When soft start is used, task should
// be started only if it missed its activation.
return
}
}

if err = h.Scheduler.StartTask(r.Context(), t); err != nil {
respondError(w, r, errors.Wrapf(err, "start task %q", t.ID))
return
}
}

// shouldSoftStartTask checks if task should be soft started by taking a look at its last run.
// If the last run ended with anything else than scheduler.StatusDone, the task should be started.
// Otherwise, the task should be started only if it missed its activation scheduled at the end of the last successfully run.
// This means that new, stopped, out of maintenance window, failed tasks will always be started.
func (h *taskHandler) shouldSoftStartTask(ctx context.Context, t *scheduler.Task) (bool, error) {
run, err := h.Scheduler.GetNthLastRun(ctx, t, 0)
if err != nil {
return false, errors.Wrap(err, "parse continue param")
if errors.Is(err, util.ErrNotFound) {
return true, nil
}
return false, errors.Wrap(err, "get last task run")
}
return !b, nil
if run.Status == scheduler.StatusDone && run.EndTime != nil {
next := t.Sched.Trigger().Next(*run.EndTime)
return !next.IsZero() && next.Before(timeutc.Now()), nil
}
return true, nil
}

func (h *taskHandler) stopTask(w http.ResponseWriter, r *http.Request) {
Expand Down Expand Up @@ -550,3 +598,15 @@ func tryReadOffset(s string) (int, error) {
}
return -1, nil
}

func parseBoolParam(r *http.Request, name string) (bool, error) {
v := r.URL.Query().Get(name)
if v == "" {
return false, nil
}
b, err := strconv.ParseBool(v)
if err != nil {
return false, util.ErrValidate(errors.Wrapf(err, "parse %q query param", name))
}
return b, nil
}
Loading
Loading