15467: Added tests for KeepServices
[arvados.git] / services / crunch-dispatch-slurm / crunch-dispatch-slurm.go
1 // Copyright (C) The Arvados Authors. All rights reserved.
2 //
3 // SPDX-License-Identifier: AGPL-3.0
4
5 package main
6
7 // Dispatcher service for Crunch that submits containers to the slurm queue.
8
9 import (
10         "bytes"
11         "context"
12         "flag"
13         "fmt"
14         "log"
15         "math"
16         "net/url"
17         "os"
18         "regexp"
19         "strings"
20         "time"
21
22         "git.curoverse.com/arvados.git/lib/config"
23         "git.curoverse.com/arvados.git/lib/dispatchcloud"
24         "git.curoverse.com/arvados.git/sdk/go/arvados"
25         "git.curoverse.com/arvados.git/sdk/go/arvadosclient"
26         "git.curoverse.com/arvados.git/sdk/go/dispatch"
27         "github.com/coreos/go-systemd/daemon"
28         "github.com/ghodss/yaml"
29         "github.com/sirupsen/logrus"
30 )
31
32 type logger interface {
33         dispatch.Logger
34         Fatalf(string, ...interface{})
35 }
36
37 const initialNiceValue int64 = 10000
38
39 var (
40         version = "dev"
41 )
42
43 type Dispatcher struct {
44         *dispatch.Dispatcher
45         logger  logrus.FieldLogger
46         cluster *arvados.Cluster
47         sqCheck *SqueueChecker
48         slurm   Slurm
49
50         Client arvados.Client
51 }
52
53 func main() {
54         logger := logrus.StandardLogger()
55         if os.Getenv("DEBUG") != "" {
56                 logger.SetLevel(logrus.DebugLevel)
57         }
58         logger.Formatter = &logrus.JSONFormatter{
59                 TimestampFormat: "2006-01-02T15:04:05.000000000Z07:00",
60         }
61         disp := &Dispatcher{logger: logger}
62         err := disp.Run(os.Args[0], os.Args[1:])
63         if err != nil {
64                 logrus.Fatalf("%s", err)
65         }
66 }
67
68 func (disp *Dispatcher) Run(prog string, args []string) error {
69         if err := disp.configure(prog, args); err != nil {
70                 return err
71         }
72         disp.setup()
73         return disp.run()
74 }
75
76 // configure() loads config files. Tests skip this.
77 func (disp *Dispatcher) configure(prog string, args []string) error {
78         if disp.logger == nil {
79                 disp.logger = logrus.StandardLogger()
80         }
81         flags := flag.NewFlagSet(prog, flag.ExitOnError)
82         flags.Usage = func() { usage(flags) }
83
84         loader := config.NewLoader(nil, disp.logger)
85         loader.SetupFlags(flags)
86
87         dumpConfig := flag.Bool(
88                 "dump-config",
89                 false,
90                 "write current configuration to stdout and exit")
91         getVersion := flags.Bool(
92                 "version",
93                 false,
94                 "Print version information and exit.")
95
96         args = loader.MungeLegacyConfigArgs(logrus.StandardLogger(), args, "-legacy-crunch-dispatch-slurm-config")
97
98         // Parse args; omit the first arg which is the command name
99         err := flags.Parse(args)
100
101         if err == flag.ErrHelp {
102                 return nil
103         }
104
105         // Print version information if requested
106         if *getVersion {
107                 fmt.Printf("crunch-dispatch-slurm %s\n", version)
108                 return nil
109         }
110
111         disp.logger.Printf("crunch-dispatch-slurm %s started", version)
112
113         cfg, err := loader.Load()
114         if err != nil {
115                 return err
116         }
117
118         if disp.cluster, err = cfg.GetCluster(""); err != nil {
119                 return fmt.Errorf("config error: %s", err)
120         }
121
122         disp.Client.APIHost = disp.cluster.Services.Controller.ExternalURL.Host
123         disp.Client.AuthToken = disp.cluster.SystemRootToken
124         disp.Client.Insecure = disp.cluster.TLS.Insecure
125
126         if disp.Client.APIHost != "" || disp.Client.AuthToken != "" {
127                 // Copy real configs into env vars so [a]
128                 // MakeArvadosClient() uses them, and [b] they get
129                 // propagated to crunch-run via SLURM.
130                 os.Setenv("ARVADOS_API_HOST", disp.Client.APIHost)
131                 os.Setenv("ARVADOS_API_TOKEN", disp.Client.AuthToken)
132                 os.Setenv("ARVADOS_API_HOST_INSECURE", "")
133                 if disp.Client.Insecure {
134                         os.Setenv("ARVADOS_API_HOST_INSECURE", "1")
135                 }
136                 ks := ""
137                 if len(disp.cluster.Containers.SLURM.KeepServices) > 0 {
138                         for _, svc := range disp.cluster.Containers.SLURM.KeepServices {
139                                 for k, _ := range svc.InternalURLs {
140                                         u := url.URL(k)
141                                         ks += u.String()
142                                         ks += " "
143                                 }
144                         }
145                 }
146                 os.Setenv("ARVADOS_KEEP_SERVICES", strings.TrimSuffix(ks, " "))
147                 os.Setenv("ARVADOS_EXTERNAL_CLIENT", "")
148         } else {
149                 disp.logger.Warnf("Client credentials missing from config, so falling back on environment variables (deprecated).")
150         }
151
152         if *dumpConfig {
153                 out, err := yaml.Marshal(cfg)
154                 if err != nil {
155                         return err
156                 }
157                 _, err = os.Stdout.Write(out)
158                 if err != nil {
159                         return err
160                 }
161         }
162
163         return nil
164 }
165
166 // setup() initializes private fields after configure().
167 func (disp *Dispatcher) setup() {
168         arv, err := arvadosclient.MakeArvadosClient()
169         if err != nil {
170                 disp.logger.Fatalf("Error making Arvados client: %v", err)
171         }
172         arv.Retries = 25
173
174         disp.slurm = NewSlurmCLI()
175         disp.sqCheck = &SqueueChecker{
176                 Logger:         disp.logger,
177                 Period:         time.Duration(disp.cluster.Containers.CloudVMs.PollInterval),
178                 PrioritySpread: disp.cluster.Containers.SLURM.PrioritySpread,
179                 Slurm:          disp.slurm,
180         }
181         disp.Dispatcher = &dispatch.Dispatcher{
182                 Arv:            arv,
183                 Logger:         disp.logger,
184                 BatchSize:      disp.cluster.API.MaxItemsPerResponse,
185                 RunContainer:   disp.runContainer,
186                 PollPeriod:     time.Duration(disp.cluster.Containers.CloudVMs.PollInterval),
187                 MinRetryPeriod: time.Duration(disp.cluster.Containers.MinRetryPeriod),
188         }
189 }
190
191 func (disp *Dispatcher) run() error {
192         defer disp.sqCheck.Stop()
193
194         if disp.cluster != nil && len(disp.cluster.InstanceTypes) > 0 {
195                 go SlurmNodeTypeFeatureKludge(disp.cluster)
196         }
197
198         if _, err := daemon.SdNotify(false, "READY=1"); err != nil {
199                 log.Printf("Error notifying init daemon: %v", err)
200         }
201         go disp.checkSqueueForOrphans()
202         return disp.Dispatcher.Run(context.Background())
203 }
204
205 var containerUuidPattern = regexp.MustCompile(`^[a-z0-9]{5}-dz642-[a-z0-9]{15}$`)
206
207 // Check the next squeue report, and invoke TrackContainer for all the
208 // containers in the report. This gives us a chance to cancel slurm
209 // jobs started by a previous dispatch process that never released
210 // their slurm allocations even though their container states are
211 // Cancelled or Complete. See https://dev.arvados.org/issues/10979
212 func (disp *Dispatcher) checkSqueueForOrphans() {
213         for _, uuid := range disp.sqCheck.All() {
214                 if !containerUuidPattern.MatchString(uuid) {
215                         continue
216                 }
217                 err := disp.TrackContainer(uuid)
218                 if err != nil {
219                         log.Printf("checkSqueueForOrphans: TrackContainer(%s): %s", uuid, err)
220                 }
221         }
222 }
223
224 func (disp *Dispatcher) slurmConstraintArgs(container arvados.Container) []string {
225         mem := int64(math.Ceil(float64(container.RuntimeConstraints.RAM+
226                 container.RuntimeConstraints.KeepCacheRAM+
227                 int64(disp.cluster.Containers.ReserveExtraRAM)) / float64(1048576)))
228
229         disk := dispatchcloud.EstimateScratchSpace(&container)
230         disk = int64(math.Ceil(float64(disk) / float64(1048576)))
231         return []string{
232                 fmt.Sprintf("--mem=%d", mem),
233                 fmt.Sprintf("--cpus-per-task=%d", container.RuntimeConstraints.VCPUs),
234                 fmt.Sprintf("--tmp=%d", disk),
235         }
236 }
237
238 func (disp *Dispatcher) sbatchArgs(container arvados.Container) ([]string, error) {
239         var args []string
240         args = append(args, disp.cluster.Containers.SLURM.SbatchArgumentsList...)
241         args = append(args, "--job-name="+container.UUID, fmt.Sprintf("--nice=%d", initialNiceValue), "--no-requeue")
242
243         if disp.cluster == nil {
244                 // no instance types configured
245                 args = append(args, disp.slurmConstraintArgs(container)...)
246         } else if it, err := dispatchcloud.ChooseInstanceType(disp.cluster, &container); err == dispatchcloud.ErrInstanceTypesNotConfigured {
247                 // ditto
248                 args = append(args, disp.slurmConstraintArgs(container)...)
249         } else if err != nil {
250                 return nil, err
251         } else {
252                 // use instancetype constraint instead of slurm mem/cpu/tmp specs
253                 args = append(args, "--constraint=instancetype="+it.Name)
254         }
255
256         if len(container.SchedulingParameters.Partitions) > 0 {
257                 args = append(args, "--partition="+strings.Join(container.SchedulingParameters.Partitions, ","))
258         }
259
260         return args, nil
261 }
262
263 func (disp *Dispatcher) submit(container arvados.Container, crunchRunCommand []string) error {
264         // append() here avoids modifying crunchRunCommand's
265         // underlying array, which is shared with other goroutines.
266         crArgs := append([]string(nil), crunchRunCommand...)
267         crArgs = append(crArgs, container.UUID)
268         crScript := strings.NewReader(execScript(crArgs))
269
270         sbArgs, err := disp.sbatchArgs(container)
271         if err != nil {
272                 return err
273         }
274         log.Printf("running sbatch %+q", sbArgs)
275         return disp.slurm.Batch(crScript, sbArgs)
276 }
277
278 // Submit a container to the slurm queue (or resume monitoring if it's
279 // already in the queue).  Cancel the slurm job if the container's
280 // priority changes to zero or its state indicates it's no longer
281 // running.
282 func (disp *Dispatcher) runContainer(_ *dispatch.Dispatcher, ctr arvados.Container, status <-chan arvados.Container) {
283         ctx, cancel := context.WithCancel(context.Background())
284         defer cancel()
285
286         if ctr.State == dispatch.Locked && !disp.sqCheck.HasUUID(ctr.UUID) {
287                 log.Printf("Submitting container %s to slurm", ctr.UUID)
288                 cmd := []string{disp.cluster.Containers.CrunchRunCommand}
289                 cmd = append(cmd, disp.cluster.Containers.CrunchRunArgumentsList...)
290                 if err := disp.submit(ctr, cmd); err != nil {
291                         var text string
292                         if err, ok := err.(dispatchcloud.ConstraintsNotSatisfiableError); ok {
293                                 var logBuf bytes.Buffer
294                                 fmt.Fprintf(&logBuf, "cannot run container %s: %s\n", ctr.UUID, err)
295                                 if len(err.AvailableTypes) == 0 {
296                                         fmt.Fprint(&logBuf, "No instance types are configured.\n")
297                                 } else {
298                                         fmt.Fprint(&logBuf, "Available instance types:\n")
299                                         for _, t := range err.AvailableTypes {
300                                                 fmt.Fprintf(&logBuf,
301                                                         "Type %q: %d VCPUs, %d RAM, %d Scratch, %f Price\n",
302                                                         t.Name, t.VCPUs, t.RAM, t.Scratch, t.Price,
303                                                 )
304                                         }
305                                 }
306                                 text = logBuf.String()
307                                 disp.UpdateState(ctr.UUID, dispatch.Cancelled)
308                         } else {
309                                 text = fmt.Sprintf("Error submitting container %s to slurm: %s", ctr.UUID, err)
310                         }
311                         log.Print(text)
312
313                         lr := arvadosclient.Dict{"log": arvadosclient.Dict{
314                                 "object_uuid": ctr.UUID,
315                                 "event_type":  "dispatch",
316                                 "properties":  map[string]string{"text": text}}}
317                         disp.Arv.Create("logs", lr, nil)
318
319                         disp.Unlock(ctr.UUID)
320                         return
321                 }
322         }
323
324         log.Printf("Start monitoring container %v in state %q", ctr.UUID, ctr.State)
325         defer log.Printf("Done monitoring container %s", ctr.UUID)
326
327         // If the container disappears from the slurm queue, there is
328         // no point in waiting for further dispatch updates: just
329         // clean up and return.
330         go func(uuid string) {
331                 for ctx.Err() == nil && disp.sqCheck.HasUUID(uuid) {
332                 }
333                 cancel()
334         }(ctr.UUID)
335
336         for {
337                 select {
338                 case <-ctx.Done():
339                         // Disappeared from squeue
340                         if err := disp.Arv.Get("containers", ctr.UUID, nil, &ctr); err != nil {
341                                 log.Printf("error getting final container state for %s: %s", ctr.UUID, err)
342                         }
343                         switch ctr.State {
344                         case dispatch.Running:
345                                 disp.UpdateState(ctr.UUID, dispatch.Cancelled)
346                         case dispatch.Locked:
347                                 disp.Unlock(ctr.UUID)
348                         }
349                         return
350                 case updated, ok := <-status:
351                         if !ok {
352                                 log.Printf("container %s is done: cancel slurm job", ctr.UUID)
353                                 disp.scancel(ctr)
354                         } else if updated.Priority == 0 {
355                                 log.Printf("container %s has state %q, priority %d: cancel slurm job", ctr.UUID, updated.State, updated.Priority)
356                                 disp.scancel(ctr)
357                         } else {
358                                 p := int64(updated.Priority)
359                                 if p <= 1000 {
360                                         // API is providing
361                                         // user-assigned priority. If
362                                         // ctrs have equal priority,
363                                         // run the older one first.
364                                         p = int64(p)<<50 - (updated.CreatedAt.UnixNano() >> 14)
365                                 }
366                                 disp.sqCheck.SetPriority(ctr.UUID, p)
367                         }
368                 }
369         }
370 }
371 func (disp *Dispatcher) scancel(ctr arvados.Container) {
372         err := disp.slurm.Cancel(ctr.UUID)
373         if err != nil {
374                 log.Printf("scancel: %s", err)
375                 time.Sleep(time.Second)
376         } else if disp.sqCheck.HasUUID(ctr.UUID) {
377                 log.Printf("container %s is still in squeue after scancel", ctr.UUID)
378                 time.Sleep(time.Second)
379         }
380 }