github.com/maier/nomad@v0.4.1-0.20161110003312-a9e3d0b8549d/nomad/job_endpoint.go (about) 1 package nomad 2 3 import ( 4 "context" 5 "fmt" 6 "strings" 7 "time" 8 9 "github.com/armon/go-metrics" 10 "github.com/hashicorp/consul/lib" 11 "github.com/hashicorp/go-memdb" 12 "github.com/hashicorp/go-multierror" 13 "github.com/hashicorp/nomad/client/driver" 14 "github.com/hashicorp/nomad/nomad/structs" 15 "github.com/hashicorp/nomad/nomad/watch" 16 "github.com/hashicorp/nomad/scheduler" 17 ) 18 19 const ( 20 // RegisterEnforceIndexErrPrefix is the prefix to use in errors caused by 21 // enforcing the job modify index during registers. 22 RegisterEnforceIndexErrPrefix = "Enforcing job modify index" 23 ) 24 25 var ( 26 // vaultConstraint is the implicit constraint added to jobs requesting a 27 // Vault token 28 vaultConstraint = &structs.Constraint{ 29 LTarget: "${attr.vault.version}", 30 RTarget: ">= 0.6.1", 31 Operand: structs.ConstraintVersion, 32 } 33 ) 34 35 // Job endpoint is used for job interactions 36 type Job struct { 37 srv *Server 38 } 39 40 // Register is used to upsert a job for scheduling 41 func (j *Job) Register(args *structs.JobRegisterRequest, reply *structs.JobRegisterResponse) error { 42 if done, err := j.srv.forward("Job.Register", args, args, reply); done { 43 return err 44 } 45 defer metrics.MeasureSince([]string{"nomad", "job", "register"}, time.Now()) 46 47 // Validate the arguments 48 if args.Job == nil { 49 return fmt.Errorf("missing job for registration") 50 } 51 52 // Initialize the job fields (sets defaults and any necessary init work). 53 args.Job.Canonicalize() 54 55 // Add implicit constraints 56 setImplicitConstraints(args.Job) 57 58 // Validate the job. 59 if err := validateJob(args.Job); err != nil { 60 return err 61 } 62 63 if args.EnforceIndex { 64 // Lookup the job 65 snap, err := j.srv.fsm.State().Snapshot() 66 if err != nil { 67 return err 68 } 69 job, err := snap.JobByID(args.Job.ID) 70 if err != nil { 71 return err 72 } 73 jmi := args.JobModifyIndex 74 if job != nil { 75 if jmi == 0 { 76 return fmt.Errorf("%s 0: job already exists", RegisterEnforceIndexErrPrefix) 77 } else if jmi != job.JobModifyIndex { 78 return fmt.Errorf("%s %d: job exists with conflicting job modify index: %d", 79 RegisterEnforceIndexErrPrefix, jmi, job.JobModifyIndex) 80 } 81 } else if jmi != 0 { 82 return fmt.Errorf("%s %d: job does not exist", RegisterEnforceIndexErrPrefix, jmi) 83 } 84 } 85 86 // Ensure that the job has permissions for the requested Vault tokens 87 policies := args.Job.VaultPolicies() 88 if len(policies) != 0 { 89 vconf := j.srv.config.VaultConfig 90 if !vconf.IsEnabled() { 91 return fmt.Errorf("Vault not enabled and Vault policies requested") 92 } 93 94 // Have to check if the user has permissions 95 if !vconf.AllowsUnauthenticated() { 96 if args.Job.VaultToken == "" { 97 return fmt.Errorf("Vault policies requested but missing Vault Token") 98 } 99 100 vault := j.srv.vault 101 s, err := vault.LookupToken(context.Background(), args.Job.VaultToken) 102 if err != nil { 103 return err 104 } 105 106 allowedPolicies, err := PoliciesFrom(s) 107 if err != nil { 108 return err 109 } 110 111 // If we are given a root token it can access all policies 112 if !lib.StrContains(allowedPolicies, "root") { 113 flatPolicies := structs.VaultPoliciesSet(policies) 114 subset, offending := structs.SliceStringIsSubset(allowedPolicies, flatPolicies) 115 if !subset { 116 return fmt.Errorf("Passed Vault Token doesn't allow access to the following policies: %s", 117 strings.Join(offending, ", ")) 118 } 119 } 120 } 121 } 122 123 // Clear the Vault token 124 args.Job.VaultToken = "" 125 126 // Commit this update via Raft 127 _, index, err := j.srv.raftApply(structs.JobRegisterRequestType, args) 128 if err != nil { 129 j.srv.logger.Printf("[ERR] nomad.job: Register failed: %v", err) 130 return err 131 } 132 133 // Populate the reply with job information 134 reply.JobModifyIndex = index 135 136 // If the job is periodic, we don't create an eval. 137 if args.Job.IsPeriodic() { 138 return nil 139 } 140 141 // Create a new evaluation 142 eval := &structs.Evaluation{ 143 ID: structs.GenerateUUID(), 144 Priority: args.Job.Priority, 145 Type: args.Job.Type, 146 TriggeredBy: structs.EvalTriggerJobRegister, 147 JobID: args.Job.ID, 148 JobModifyIndex: index, 149 Status: structs.EvalStatusPending, 150 } 151 update := &structs.EvalUpdateRequest{ 152 Evals: []*structs.Evaluation{eval}, 153 WriteRequest: structs.WriteRequest{Region: args.Region}, 154 } 155 156 // Commit this evaluation via Raft 157 // XXX: There is a risk of partial failure where the JobRegister succeeds 158 // but that the EvalUpdate does not. 159 _, evalIndex, err := j.srv.raftApply(structs.EvalUpdateRequestType, update) 160 if err != nil { 161 j.srv.logger.Printf("[ERR] nomad.job: Eval create failed: %v", err) 162 return err 163 } 164 165 // Populate the reply with eval information 166 reply.EvalID = eval.ID 167 reply.EvalCreateIndex = evalIndex 168 reply.Index = evalIndex 169 return nil 170 } 171 172 // setImplicitConstraints adds implicit constraints to the job based on the 173 // features it is requesting. 174 func setImplicitConstraints(j *structs.Job) { 175 // Get the required Vault Policies 176 policies := j.VaultPolicies() 177 178 // Get the required signals 179 signals := j.RequiredSignals() 180 181 // Hot path 182 if len(signals) == 0 && len(policies) == 0 { 183 return 184 } 185 186 // Add Vault constraints 187 for _, tg := range j.TaskGroups { 188 _, ok := policies[tg.Name] 189 if !ok { 190 // Not requesting Vault 191 continue 192 } 193 194 found := false 195 for _, c := range tg.Constraints { 196 if c.Equal(vaultConstraint) { 197 found = true 198 break 199 } 200 } 201 202 if !found { 203 tg.Constraints = append(tg.Constraints, vaultConstraint) 204 } 205 } 206 207 // Add signal constraints 208 for _, tg := range j.TaskGroups { 209 tgSignals, ok := signals[tg.Name] 210 if !ok { 211 // Not requesting Vault 212 continue 213 } 214 215 // Flatten the signals 216 required := structs.MapStringStringSliceValueSet(tgSignals) 217 sigConstraint := getSignalConstraint(required) 218 219 found := false 220 for _, c := range tg.Constraints { 221 if c.Equal(sigConstraint) { 222 found = true 223 break 224 } 225 } 226 227 if !found { 228 tg.Constraints = append(tg.Constraints, sigConstraint) 229 } 230 } 231 } 232 233 // getSignalConstraint builds a suitable constraint based on the required 234 // signals 235 func getSignalConstraint(signals []string) *structs.Constraint { 236 return &structs.Constraint{ 237 Operand: structs.ConstraintSetContains, 238 LTarget: "${attr.os.signals}", 239 RTarget: strings.Join(signals, ","), 240 } 241 } 242 243 // Summary retreives the summary of a job 244 func (j *Job) Summary(args *structs.JobSummaryRequest, 245 reply *structs.JobSummaryResponse) error { 246 if done, err := j.srv.forward("Job.Summary", args, args, reply); done { 247 return err 248 } 249 defer metrics.MeasureSince([]string{"nomad", "job_summary", "get_job_summary"}, time.Now()) 250 // Setup the blocking query 251 opts := blockingOptions{ 252 queryOpts: &args.QueryOptions, 253 queryMeta: &reply.QueryMeta, 254 watch: watch.NewItems(watch.Item{JobSummary: args.JobID}), 255 run: func() error { 256 snap, err := j.srv.fsm.State().Snapshot() 257 if err != nil { 258 return err 259 } 260 261 // Look for job summary 262 out, err := snap.JobSummaryByID(args.JobID) 263 if err != nil { 264 return err 265 } 266 267 // Setup the output 268 reply.JobSummary = out 269 if out != nil { 270 reply.Index = out.ModifyIndex 271 } else { 272 // Use the last index that affected the job_summary table 273 index, err := snap.Index("job_summary") 274 if err != nil { 275 return err 276 } 277 reply.Index = index 278 } 279 280 // Set the query response 281 j.srv.setQueryMeta(&reply.QueryMeta) 282 return nil 283 }} 284 return j.srv.blockingRPC(&opts) 285 } 286 287 // Evaluate is used to force a job for re-evaluation 288 func (j *Job) Evaluate(args *structs.JobEvaluateRequest, reply *structs.JobRegisterResponse) error { 289 if done, err := j.srv.forward("Job.Evaluate", args, args, reply); done { 290 return err 291 } 292 defer metrics.MeasureSince([]string{"nomad", "job", "evaluate"}, time.Now()) 293 294 // Validate the arguments 295 if args.JobID == "" { 296 return fmt.Errorf("missing job ID for evaluation") 297 } 298 299 // Lookup the job 300 snap, err := j.srv.fsm.State().Snapshot() 301 if err != nil { 302 return err 303 } 304 job, err := snap.JobByID(args.JobID) 305 if err != nil { 306 return err 307 } 308 if job == nil { 309 return fmt.Errorf("job not found") 310 } 311 312 if job.IsPeriodic() { 313 return fmt.Errorf("can't evaluate periodic job") 314 } 315 316 // Create a new evaluation 317 eval := &structs.Evaluation{ 318 ID: structs.GenerateUUID(), 319 Priority: job.Priority, 320 Type: job.Type, 321 TriggeredBy: structs.EvalTriggerJobRegister, 322 JobID: job.ID, 323 JobModifyIndex: job.ModifyIndex, 324 Status: structs.EvalStatusPending, 325 } 326 update := &structs.EvalUpdateRequest{ 327 Evals: []*structs.Evaluation{eval}, 328 WriteRequest: structs.WriteRequest{Region: args.Region}, 329 } 330 331 // Commit this evaluation via Raft 332 _, evalIndex, err := j.srv.raftApply(structs.EvalUpdateRequestType, update) 333 if err != nil { 334 j.srv.logger.Printf("[ERR] nomad.job: Eval create failed: %v", err) 335 return err 336 } 337 338 // Setup the reply 339 reply.EvalID = eval.ID 340 reply.EvalCreateIndex = evalIndex 341 reply.JobModifyIndex = job.ModifyIndex 342 reply.Index = evalIndex 343 return nil 344 } 345 346 // Deregister is used to remove a job the cluster. 347 func (j *Job) Deregister(args *structs.JobDeregisterRequest, reply *structs.JobDeregisterResponse) error { 348 if done, err := j.srv.forward("Job.Deregister", args, args, reply); done { 349 return err 350 } 351 defer metrics.MeasureSince([]string{"nomad", "job", "deregister"}, time.Now()) 352 353 // Validate the arguments 354 if args.JobID == "" { 355 return fmt.Errorf("missing job ID for evaluation") 356 } 357 358 // Lookup the job 359 snap, err := j.srv.fsm.State().Snapshot() 360 if err != nil { 361 return err 362 } 363 job, err := snap.JobByID(args.JobID) 364 if err != nil { 365 return err 366 } 367 368 // Commit this update via Raft 369 _, index, err := j.srv.raftApply(structs.JobDeregisterRequestType, args) 370 if err != nil { 371 j.srv.logger.Printf("[ERR] nomad.job: Deregister failed: %v", err) 372 return err 373 } 374 375 // Populate the reply with job information 376 reply.JobModifyIndex = index 377 378 // If the job is periodic, we don't create an eval. 379 if job != nil && job.IsPeriodic() { 380 return nil 381 } 382 383 // Create a new evaluation 384 // XXX: The job priority / type is strange for this, since it's not a high 385 // priority even if the job was. The scheduler itself also doesn't matter, 386 // since all should be able to handle deregistration in the same way. 387 eval := &structs.Evaluation{ 388 ID: structs.GenerateUUID(), 389 Priority: structs.JobDefaultPriority, 390 Type: structs.JobTypeService, 391 TriggeredBy: structs.EvalTriggerJobDeregister, 392 JobID: args.JobID, 393 JobModifyIndex: index, 394 Status: structs.EvalStatusPending, 395 } 396 update := &structs.EvalUpdateRequest{ 397 Evals: []*structs.Evaluation{eval}, 398 WriteRequest: structs.WriteRequest{Region: args.Region}, 399 } 400 401 // Commit this evaluation via Raft 402 _, evalIndex, err := j.srv.raftApply(structs.EvalUpdateRequestType, update) 403 if err != nil { 404 j.srv.logger.Printf("[ERR] nomad.job: Eval create failed: %v", err) 405 return err 406 } 407 408 // Populate the reply with eval information 409 reply.EvalID = eval.ID 410 reply.EvalCreateIndex = evalIndex 411 reply.Index = evalIndex 412 return nil 413 } 414 415 // GetJob is used to request information about a specific job 416 func (j *Job) GetJob(args *structs.JobSpecificRequest, 417 reply *structs.SingleJobResponse) error { 418 if done, err := j.srv.forward("Job.GetJob", args, args, reply); done { 419 return err 420 } 421 defer metrics.MeasureSince([]string{"nomad", "job", "get_job"}, time.Now()) 422 423 // Setup the blocking query 424 opts := blockingOptions{ 425 queryOpts: &args.QueryOptions, 426 queryMeta: &reply.QueryMeta, 427 watch: watch.NewItems(watch.Item{Job: args.JobID}), 428 run: func() error { 429 430 // Look for the job 431 snap, err := j.srv.fsm.State().Snapshot() 432 if err != nil { 433 return err 434 } 435 out, err := snap.JobByID(args.JobID) 436 if err != nil { 437 return err 438 } 439 440 // Setup the output 441 reply.Job = out 442 if out != nil { 443 reply.Index = out.ModifyIndex 444 } else { 445 // Use the last index that affected the nodes table 446 index, err := snap.Index("jobs") 447 if err != nil { 448 return err 449 } 450 reply.Index = index 451 } 452 453 // Set the query response 454 j.srv.setQueryMeta(&reply.QueryMeta) 455 return nil 456 }} 457 return j.srv.blockingRPC(&opts) 458 } 459 460 // List is used to list the jobs registered in the system 461 func (j *Job) List(args *structs.JobListRequest, 462 reply *structs.JobListResponse) error { 463 if done, err := j.srv.forward("Job.List", args, args, reply); done { 464 return err 465 } 466 defer metrics.MeasureSince([]string{"nomad", "job", "list"}, time.Now()) 467 468 // Setup the blocking query 469 opts := blockingOptions{ 470 queryOpts: &args.QueryOptions, 471 queryMeta: &reply.QueryMeta, 472 watch: watch.NewItems(watch.Item{Table: "jobs"}), 473 run: func() error { 474 // Capture all the jobs 475 snap, err := j.srv.fsm.State().Snapshot() 476 if err != nil { 477 return err 478 } 479 var iter memdb.ResultIterator 480 if prefix := args.QueryOptions.Prefix; prefix != "" { 481 iter, err = snap.JobsByIDPrefix(prefix) 482 } else { 483 iter, err = snap.Jobs() 484 } 485 if err != nil { 486 return err 487 } 488 489 var jobs []*structs.JobListStub 490 for { 491 raw := iter.Next() 492 if raw == nil { 493 break 494 } 495 job := raw.(*structs.Job) 496 summary, err := snap.JobSummaryByID(job.ID) 497 if err != nil { 498 return fmt.Errorf("unable to look up summary for job: %v", job.ID) 499 } 500 jobs = append(jobs, job.Stub(summary)) 501 } 502 reply.Jobs = jobs 503 504 // Use the last index that affected the jobs table 505 index, err := snap.Index("jobs") 506 if err != nil { 507 return err 508 } 509 reply.Index = index 510 511 // Set the query response 512 j.srv.setQueryMeta(&reply.QueryMeta) 513 return nil 514 }} 515 return j.srv.blockingRPC(&opts) 516 } 517 518 // Allocations is used to list the allocations for a job 519 func (j *Job) Allocations(args *structs.JobSpecificRequest, 520 reply *structs.JobAllocationsResponse) error { 521 if done, err := j.srv.forward("Job.Allocations", args, args, reply); done { 522 return err 523 } 524 defer metrics.MeasureSince([]string{"nomad", "job", "allocations"}, time.Now()) 525 526 // Setup the blocking query 527 opts := blockingOptions{ 528 queryOpts: &args.QueryOptions, 529 queryMeta: &reply.QueryMeta, 530 watch: watch.NewItems(watch.Item{AllocJob: args.JobID}), 531 run: func() error { 532 // Capture the allocations 533 snap, err := j.srv.fsm.State().Snapshot() 534 if err != nil { 535 return err 536 } 537 allocs, err := snap.AllocsByJob(args.JobID) 538 if err != nil { 539 return err 540 } 541 542 // Convert to stubs 543 if len(allocs) > 0 { 544 reply.Allocations = make([]*structs.AllocListStub, 0, len(allocs)) 545 for _, alloc := range allocs { 546 reply.Allocations = append(reply.Allocations, alloc.Stub()) 547 } 548 } 549 550 // Use the last index that affected the allocs table 551 index, err := snap.Index("allocs") 552 if err != nil { 553 return err 554 } 555 reply.Index = index 556 557 // Set the query response 558 j.srv.setQueryMeta(&reply.QueryMeta) 559 return nil 560 561 }} 562 return j.srv.blockingRPC(&opts) 563 } 564 565 // Evaluations is used to list the evaluations for a job 566 func (j *Job) Evaluations(args *structs.JobSpecificRequest, 567 reply *structs.JobEvaluationsResponse) error { 568 if done, err := j.srv.forward("Job.Evaluations", args, args, reply); done { 569 return err 570 } 571 defer metrics.MeasureSince([]string{"nomad", "job", "evaluations"}, time.Now()) 572 573 // Setup the blocking query 574 opts := blockingOptions{ 575 queryOpts: &args.QueryOptions, 576 queryMeta: &reply.QueryMeta, 577 watch: watch.NewItems(watch.Item{EvalJob: args.JobID}), 578 run: func() error { 579 // Capture the evals 580 snap, err := j.srv.fsm.State().Snapshot() 581 if err != nil { 582 return err 583 } 584 585 reply.Evaluations, err = snap.EvalsByJob(args.JobID) 586 if err != nil { 587 return err 588 } 589 590 // Use the last index that affected the evals table 591 index, err := snap.Index("evals") 592 if err != nil { 593 return err 594 } 595 reply.Index = index 596 597 // Set the query response 598 j.srv.setQueryMeta(&reply.QueryMeta) 599 return nil 600 }} 601 602 return j.srv.blockingRPC(&opts) 603 } 604 605 // Plan is used to cause a dry-run evaluation of the Job and return the results 606 // with a potential diff containing annotations. 607 func (j *Job) Plan(args *structs.JobPlanRequest, reply *structs.JobPlanResponse) error { 608 if done, err := j.srv.forward("Job.Plan", args, args, reply); done { 609 return err 610 } 611 defer metrics.MeasureSince([]string{"nomad", "job", "plan"}, time.Now()) 612 613 // Validate the arguments 614 if args.Job == nil { 615 return fmt.Errorf("Job required for plan") 616 } 617 618 // Initialize the job fields (sets defaults and any necessary init work). 619 args.Job.Canonicalize() 620 621 // Add implicit constraints 622 setImplicitConstraints(args.Job) 623 624 // Validate the job. 625 if err := validateJob(args.Job); err != nil { 626 return err 627 } 628 629 // Acquire a snapshot of the state 630 snap, err := j.srv.fsm.State().Snapshot() 631 if err != nil { 632 return err 633 } 634 635 // Get the original job 636 oldJob, err := snap.JobByID(args.Job.ID) 637 if err != nil { 638 return err 639 } 640 641 var index uint64 642 var updatedIndex uint64 643 if oldJob != nil { 644 index = oldJob.JobModifyIndex 645 updatedIndex = oldJob.JobModifyIndex + 1 646 } 647 648 // Insert the updated Job into the snapshot 649 snap.UpsertJob(updatedIndex, args.Job) 650 651 // Create an eval and mark it as requiring annotations and insert that as well 652 eval := &structs.Evaluation{ 653 ID: structs.GenerateUUID(), 654 Priority: args.Job.Priority, 655 Type: args.Job.Type, 656 TriggeredBy: structs.EvalTriggerJobRegister, 657 JobID: args.Job.ID, 658 JobModifyIndex: updatedIndex, 659 Status: structs.EvalStatusPending, 660 AnnotatePlan: true, 661 } 662 663 // Create an in-memory Planner that returns no errors and stores the 664 // submitted plan and created evals. 665 planner := &scheduler.Harness{ 666 State: &snap.StateStore, 667 } 668 669 // Create the scheduler and run it 670 sched, err := scheduler.NewScheduler(eval.Type, j.srv.logger, snap, planner) 671 if err != nil { 672 return err 673 } 674 675 if err := sched.Process(eval); err != nil { 676 return err 677 } 678 679 // Annotate and store the diff 680 if plans := len(planner.Plans); plans != 1 { 681 return fmt.Errorf("scheduler resulted in an unexpected number of plans: %v", plans) 682 } 683 annotations := planner.Plans[0].Annotations 684 if args.Diff { 685 jobDiff, err := oldJob.Diff(args.Job, true) 686 if err != nil { 687 return fmt.Errorf("failed to create job diff: %v", err) 688 } 689 690 if err := scheduler.Annotate(jobDiff, annotations); err != nil { 691 return fmt.Errorf("failed to annotate job diff: %v", err) 692 } 693 reply.Diff = jobDiff 694 } 695 696 // Grab the failures 697 if len(planner.Evals) != 1 { 698 return fmt.Errorf("scheduler resulted in an unexpected number of eval updates: %v", planner.Evals) 699 } 700 updatedEval := planner.Evals[0] 701 702 // If it is a periodic job calculate the next launch 703 if args.Job.IsPeriodic() && args.Job.Periodic.Enabled { 704 reply.NextPeriodicLaunch = args.Job.Periodic.Next(time.Now().UTC()) 705 } 706 707 reply.FailedTGAllocs = updatedEval.FailedTGAllocs 708 reply.JobModifyIndex = index 709 reply.Annotations = annotations 710 reply.CreatedEvals = planner.CreateEvals 711 reply.Index = index 712 return nil 713 } 714 715 // validateJob validates a Job and task drivers and returns an error if there is 716 // a validation problem or if the Job is of a type a user is not allowed to 717 // submit. 718 func validateJob(job *structs.Job) error { 719 validationErrors := new(multierror.Error) 720 if err := job.Validate(); err != nil { 721 multierror.Append(validationErrors, err) 722 } 723 724 // Get the signals required 725 signals := job.RequiredSignals() 726 727 // Validate the driver configurations. 728 for _, tg := range job.TaskGroups { 729 // Get the signals for the task group 730 tgSignals, tgOk := signals[tg.Name] 731 732 for _, task := range tg.Tasks { 733 d, err := driver.NewDriver( 734 task.Driver, 735 driver.NewEmptyDriverContext(), 736 ) 737 if err != nil { 738 msg := "failed to create driver for task %q in group %q for validation: %v" 739 multierror.Append(validationErrors, fmt.Errorf(msg, tg.Name, task.Name, err)) 740 continue 741 } 742 743 if err := d.Validate(task.Config); err != nil { 744 formatted := fmt.Errorf("group %q -> task %q -> config: %v", tg.Name, task.Name, err) 745 multierror.Append(validationErrors, formatted) 746 } 747 748 // The task group didn't have any task that required signals 749 if !tgOk { 750 continue 751 } 752 753 // This task requires signals. Ensure the driver is capable 754 if required, ok := tgSignals[task.Name]; ok { 755 abilities := d.Abilities() 756 if !abilities.SendSignals { 757 formatted := fmt.Errorf("group %q -> task %q: driver %q doesn't support sending signals. Requested signals are %v", 758 tg.Name, task.Name, task.Driver, strings.Join(required, ", ")) 759 multierror.Append(validationErrors, formatted) 760 } 761 } 762 } 763 } 764 765 if job.Type == structs.JobTypeCore { 766 multierror.Append(validationErrors, fmt.Errorf("job type cannot be core")) 767 } 768 769 return validationErrors.ErrorOrNil() 770 }