diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..c730720 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,15 @@ +name: CI +on: [push] + +jobs: + tests: + name: Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Setup Go + uses: actions/setup-go@v4 + with: + go-version: '1.21.x' + - name: Tests + run: make test diff --git a/Makefile b/Makefile index e4b747a..435ae52 100644 --- a/Makefile +++ b/Makefile @@ -18,3 +18,7 @@ proto: .PHONY: run-server run-server: proto reflex -s -t 120s -r '\.go' -- bash -c "make bin/alfred-server && bin/alfred-server" + +.PHONY: test +test: + go test -v ./... diff --git a/client/job/jobfile.go b/client/job/jobfile.go index 7ff7677..ac9f20c 100644 --- a/client/job/jobfile.go +++ b/client/job/jobfile.go @@ -2,11 +2,18 @@ package job import ( "fmt" + "os" + "path" + "regexp" + "strconv" + "time" ) const JobfileVersion = "1" type Jobfile struct { + path string + Version string Name string Image JobfileImage @@ -27,7 +34,10 @@ type JobfileService struct { } type JobfileServiceHealth struct { - Cmd []string + Cmd []string + Timeout string + Interval string + Retries string } func (jobfile Jobfile) Validate() error { @@ -36,16 +46,68 @@ func (jobfile Jobfile) Validate() error { } if jobfile.Name == "" { - return fmt.Errorf("job name is required") + return fmt.Errorf("name is required") } if jobfile.Image.Dockerfile == "" { return fmt.Errorf("image.dockerfile is required") } + if _, err := os.Stat(path.Join(jobfile.path, jobfile.Image.Dockerfile)); os.IsNotExist(err) { + return fmt.Errorf("image.dockerfile must be an existing file on disk") + } if jobfile.Image.Context == "" { return fmt.Errorf("image.context is required") } + if _, err := os.Stat(path.Join(jobfile.path, jobfile.Image.Context)); os.IsNotExist(err) { + return fmt.Errorf("image.context must be an existing folder on disk") + } + + serviceNameRegex := regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9._-]+$`) + serviceEnvKeyRegex := regexp.MustCompile(`^[A-Z][A-Z0-9_]+$`) + + for name, service := range jobfile.Services { + if !serviceNameRegex.MatchString(name) { + return fmt.Errorf("services names must be valid identifiers") + } + + if service.Image == "" { + return fmt.Errorf("services[%s].image is required", name) + } + + for key, _ := range service.Env { + if !serviceEnvKeyRegex.MatchString(key) { + return fmt.Errorf("services[%s].env[%s] must be a valid environment variable identifier", name, key) + } + } + + // If none of the health fields are specified, skip validation + if service.Health.Cmd == nil && service.Health.Timeout == "" && service.Health.Interval == "" && service.Health.Retries == "" { + continue + } + + if len(service.Health.Cmd) < 1 { + return fmt.Errorf("services[%s].health.cmd is required", name) + } + + if service.Health.Interval != "" { + if _, err := time.ParseDuration(service.Health.Interval); err != nil { + return fmt.Errorf("services[%s].health.interval is not a valid duration: %w", name, err) + } + } + + if service.Health.Timeout != "" { + if _, err := time.ParseDuration(service.Health.Timeout); err != nil { + return fmt.Errorf("services[%s].health.timeout is not a valid duration: %w", name, err) + } + } + + if service.Health.Retries != "" { + if _, err := strconv.ParseInt(service.Health.Retries, 10, 64); err != nil { + return fmt.Errorf("services[%s].health.retries is not a valid numeric: %w", name, err) + } + } + } return nil } diff --git a/client/job/jobfile_test.go b/client/job/jobfile_test.go new file mode 100644 index 0000000..60b434b --- /dev/null +++ b/client/job/jobfile_test.go @@ -0,0 +1,55 @@ +package job + +import ( + "github.com/samber/lo" + "github.com/stretchr/testify/assert" + "gopkg.in/yaml.v3" + "os" + "path" + "testing" +) + +var flagtests = []struct { + file string + expected string +}{ + {"tests/jobfile/valid_minimalist.yaml", ""}, + {"tests/jobfile/valid_full_featured.yaml", ""}, + + {"tests/jobfile/invalid_missing_name.yaml", "name is required"}, + {"tests/jobfile/invalid_missing_image_dockerfile.yaml", "image.dockerfile is required"}, + {"tests/jobfile/invalid_missing_image_context.yaml", "image.context is required"}, + {"tests/jobfile/invalid_missing_services_image.yaml", "services[mysql].image is required"}, + {"tests/jobfile/invalid_missing_services_health_cmd.yaml", "services[mysql].health.cmd is required"}, + + {"tests/jobfile/invalid_version.yaml", "unsupported version '42'"}, + {"tests/jobfile/invalid_image_dockerfile.yaml", "image.dockerfile must be an existing file on disk"}, + {"tests/jobfile/invalid_image_context.yaml", "image.context must be an existing folder on disk"}, + {"tests/jobfile/invalid_services_map.yaml", "yaml: unmarshal errors:\n line 7: cannot unmarshal !!seq into map[string]job.JobfileService"}, + {"tests/jobfile/invalid_services_keys.yaml", "services names must be valid identifiers"}, + {"tests/jobfile/invalid_services_env_keys.yaml", "services[mysql].env[0/2] must be a valid environment variable identifier"}, +} + +func TestJobValidate(t *testing.T) { + var buf []byte + var err error + + for _, tt := range flagtests { + t.Run(tt.file, func(t *testing.T) { + buf = lo.Must(os.ReadFile(tt.file)) + + var jobfile Jobfile + if err = yaml.Unmarshal(buf, &jobfile); err != nil { + assert.Equal(t, tt.expected, err.Error()) + return + } + jobfile.path = path.Dir(tt.file) + if err = jobfile.Validate(); err != nil { + assert.Equal(t, tt.expected, err.Error()) + return + } + + assert.Equal(t, "", tt.expected) + }) + } +} diff --git a/client/job/reader.go b/client/job/reader.go index f1390c3..addfbc2 100644 --- a/client/job/reader.go +++ b/client/job/reader.go @@ -2,14 +2,16 @@ package job import ( "fmt" + "github.com/gammadia/alfred/proto" + "github.com/samber/lo" + "google.golang.org/protobuf/types/known/durationpb" + "gopkg.in/yaml.v3" "os" "os/exec" "path" + "strconv" "strings" - - "github.com/gammadia/alfred/proto" - "github.com/samber/lo" - "gopkg.in/yaml.v3" + "time" ) func Read(p string, overrides Overrides) (job *proto.Job, err error) { @@ -28,6 +30,7 @@ func Read(p string, overrides Overrides) (job *proto.Job, err error) { err = fmt.Errorf("unmarshal: %w", err) return } + jobfile.path = workDir if err = jobfile.Validate(); err != nil { err = fmt.Errorf("validate: %w", err) return @@ -62,7 +65,7 @@ func Read(p string, overrides Overrides) (job *proto.Job, err error) { // Services for name, service := range jobfile.Services { - job.Services = append(job.Services, &proto.Job_Service{ + jobService := &proto.Job_Service{ Name: name, Image: service.Image, Env: lo.MapToSlice(service.Env, func(key string, value string) *proto.Job_Env { @@ -75,7 +78,20 @@ func Read(p string, overrides Overrides) (job *proto.Job, err error) { Cmd: service.Health.Cmd[0], Args: service.Health.Cmd[1:], }), - }) + } + + if service.Health.Timeout != "" { + jobService.Health.Timeout = durationpb.New(lo.Must(time.ParseDuration(service.Health.Timeout))) + } + if service.Health.Interval != "" { + jobService.Health.Interval = durationpb.New(lo.Must(time.ParseDuration(service.Health.Interval))) + } + if service.Health.Retries != "" { + retries := lo.Must(strconv.ParseUint(service.Health.Retries, 10, 64)) + jobService.Health.Retries = &retries + } + + job.Services = append(job.Services, jobService) } return diff --git a/client/job/tests/jobfile/a.dockerfile b/client/job/tests/jobfile/a.dockerfile new file mode 100644 index 0000000..b09b037 --- /dev/null +++ b/client/job/tests/jobfile/a.dockerfile @@ -0,0 +1 @@ +FROM alpine:latest diff --git a/client/job/tests/jobfile/invalid_image_context.yaml b/client/job/tests/jobfile/invalid_image_context.yaml new file mode 100644 index 0000000..43b9f65 --- /dev/null +++ b/client/job/tests/jobfile/invalid_image_context.yaml @@ -0,0 +1,5 @@ +version: "1" +name: "test" +image: + dockerfile: "a.dockerfile" + context: "./toto" diff --git a/client/job/tests/jobfile/invalid_image_dockerfile.yaml b/client/job/tests/jobfile/invalid_image_dockerfile.yaml new file mode 100644 index 0000000..235af75 --- /dev/null +++ b/client/job/tests/jobfile/invalid_image_dockerfile.yaml @@ -0,0 +1,5 @@ +version: "1" +name: "test" +image: + dockerfile: "toto.dockerfile" + context: "." diff --git a/client/job/tests/jobfile/invalid_missing_image_context.yaml b/client/job/tests/jobfile/invalid_missing_image_context.yaml new file mode 100644 index 0000000..b9fd757 --- /dev/null +++ b/client/job/tests/jobfile/invalid_missing_image_context.yaml @@ -0,0 +1,4 @@ +version: "1" +name: "test" +image: + dockerfile: "a.dockerfile" diff --git a/client/job/tests/jobfile/invalid_missing_image_dockerfile.yaml b/client/job/tests/jobfile/invalid_missing_image_dockerfile.yaml new file mode 100644 index 0000000..81a380e --- /dev/null +++ b/client/job/tests/jobfile/invalid_missing_image_dockerfile.yaml @@ -0,0 +1,4 @@ +version: "1" +name: "test" +image: + context: "." diff --git a/client/job/tests/jobfile/invalid_missing_name.yaml b/client/job/tests/jobfile/invalid_missing_name.yaml new file mode 100644 index 0000000..86f7c0f --- /dev/null +++ b/client/job/tests/jobfile/invalid_missing_name.yaml @@ -0,0 +1,4 @@ +version: "1" +image: + dockerfile: "a.dockerfile" + context: "." diff --git a/client/job/tests/jobfile/invalid_missing_services_health_cmd.yaml b/client/job/tests/jobfile/invalid_missing_services_health_cmd.yaml new file mode 100644 index 0000000..b280c00 --- /dev/null +++ b/client/job/tests/jobfile/invalid_missing_services_health_cmd.yaml @@ -0,0 +1,10 @@ +version: "1" +name: "test" +image: + dockerfile: "a.dockerfile" + context: "." +services: + mysql: + image: mysql + health: + retries: 1 diff --git a/client/job/tests/jobfile/invalid_missing_services_image.yaml b/client/job/tests/jobfile/invalid_missing_services_image.yaml new file mode 100644 index 0000000..43be3ee --- /dev/null +++ b/client/job/tests/jobfile/invalid_missing_services_image.yaml @@ -0,0 +1,9 @@ +version: "1" +name: "test" +image: + dockerfile: "a.dockerfile" + context: "." +services: + mysql: + env: + TOTO: toto diff --git a/client/job/tests/jobfile/invalid_services_env_keys.yaml b/client/job/tests/jobfile/invalid_services_env_keys.yaml new file mode 100644 index 0000000..6113cd6 --- /dev/null +++ b/client/job/tests/jobfile/invalid_services_env_keys.yaml @@ -0,0 +1,10 @@ +version: "1" +name: "test" +image: + dockerfile: "a.dockerfile" + context: "." +services: + mysql: + image: mysql + env: + 0/2: foo diff --git a/client/job/tests/jobfile/invalid_services_health_timeout.yaml b/client/job/tests/jobfile/invalid_services_health_timeout.yaml new file mode 100644 index 0000000..b2a21bc --- /dev/null +++ b/client/job/tests/jobfile/invalid_services_health_timeout.yaml @@ -0,0 +1,10 @@ +version: "1" +name: "test" +image: + dockerfile: "a.dockerfile" + context: "." +services: + mysql: + image: mysql + health: + timeout: diff --git a/client/job/tests/jobfile/invalid_services_keys.yaml b/client/job/tests/jobfile/invalid_services_keys.yaml new file mode 100644 index 0000000..2114f68 --- /dev/null +++ b/client/job/tests/jobfile/invalid_services_keys.yaml @@ -0,0 +1,8 @@ +version: "1" +name: "test" +image: + dockerfile: "a.dockerfile" + context: "." +services: + 0/2: + image: mysql diff --git a/client/job/tests/jobfile/invalid_services_map.yaml b/client/job/tests/jobfile/invalid_services_map.yaml new file mode 100644 index 0000000..2b88d35 --- /dev/null +++ b/client/job/tests/jobfile/invalid_services_map.yaml @@ -0,0 +1,8 @@ +version: "1" +name: "test" +image: + dockerfile: "a.dockerfile" + context: "." +services: + - mysql + - redis diff --git a/client/job/tests/jobfile/invalid_version.yaml b/client/job/tests/jobfile/invalid_version.yaml new file mode 100644 index 0000000..80dd0db --- /dev/null +++ b/client/job/tests/jobfile/invalid_version.yaml @@ -0,0 +1,5 @@ +version: "42" +name: "test" +image: + dockerfile: "a.dockerfile" + context: "." diff --git a/client/job/tests/jobfile/valid_full_featured.yaml b/client/job/tests/jobfile/valid_full_featured.yaml new file mode 100644 index 0000000..7f18c3e --- /dev/null +++ b/client/job/tests/jobfile/valid_full_featured.yaml @@ -0,0 +1,24 @@ +version: "1" +name: "test" +image: + dockerfile: "a.dockerfile" + context: "." +services: + redis: + image: "redis:7.0.5-alpine" + health: + cmd: ["redis-cli", "ping"] + timeout: "5s" + wait: "1s" + retries: 10 + mysql: + image: "mysql:8.0.31" + env: + MYSQL_ROOT_PASSWORD: "root" + health: + cmd: ["mysqladmin", "-u", "root", "-proot", "-h", "127.0.0.1", "ping"] + timeout: "10s" + interval: "2s" + retries: 20 +tasks: | + sloth instance --app tipee list -o json | jq -r '.[] | select(.features.tests) | .id' | tail -n 1 diff --git a/client/job/tests/jobfile/valid_minimalist.yaml b/client/job/tests/jobfile/valid_minimalist.yaml new file mode 100644 index 0000000..d727418 --- /dev/null +++ b/client/job/tests/jobfile/valid_minimalist.yaml @@ -0,0 +1,5 @@ +version: "1" +name: "test" +image: + dockerfile: "a.dockerfile" + context: "." diff --git a/go.mod b/go.mod index bbc5e43..bbcaf53 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,7 @@ require ( github.com/spf13/cobra v1.7.0 github.com/spf13/pflag v1.0.5 github.com/spf13/viper v1.17.0 + github.com/stretchr/testify v1.8.4 golang.org/x/crypto v0.14.0 google.golang.org/grpc v1.59.0 google.golang.org/protobuf v1.31.0 @@ -21,6 +22,7 @@ require ( require ( github.com/Microsoft/go-winio v0.6.1 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/distribution/reference v0.5.0 // indirect github.com/docker/distribution v2.8.3+incompatible // indirect github.com/docker/go-connections v0.4.0 // indirect @@ -44,6 +46,7 @@ require ( github.com/opencontainers/image-spec v1.0.2 // indirect github.com/pelletier/go-toml/v2 v2.1.0 // indirect github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/rivo/uniseg v0.4.4 // indirect github.com/sagikazarmark/locafero v0.3.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect diff --git a/hack/job.yaml b/hack/job.yaml index 7d457ae..9c8ee91 100644 --- a/hack/job.yaml +++ b/hack/job.yaml @@ -1,18 +1,24 @@ version: "1" -name: test +name: "test" image: - dockerfile: job.dockerfile - context: . + dockerfile: "job.dockerfile" + context: "." services: - #redis: - #image: redis:7.0.5-alpine - #health: - # cmd: [redis-cli, ping] + redis: + image: "redis:7.0.5-alpine" + health: + cmd: ["redis-cli", "ping"] + timeout: "5s" + wait: "1s" + retries: 10 mysql: - image: mysql:8.0.31 + image: "mysql:8.0.31" env: MYSQL_ROOT_PASSWORD: "root" health: cmd: ["mysqladmin", "-u", "root", "-proot", "-h", "127.0.0.1", "ping"] + timeout: "10s" + interval: "2s" + retries: 20 tasks: | sloth instance --app tipee list -o json | jq -r '.[] | select(.features.tests) | .id' | tail -n 1 diff --git a/proto/alfred.pb.go b/proto/alfred.pb.go index 800fc18..f384144 100644 --- a/proto/alfred.pb.go +++ b/proto/alfred.pb.go @@ -9,6 +9,7 @@ package proto import ( protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" + durationpb "google.golang.org/protobuf/types/known/durationpb" reflect "reflect" sync "sync" ) @@ -599,8 +600,11 @@ type Job_Service_Health struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - Cmd string `protobuf:"bytes,1,opt,name=cmd,proto3" json:"cmd,omitempty"` - Args []string `protobuf:"bytes,2,rep,name=args,proto3" json:"args,omitempty"` + Cmd string `protobuf:"bytes,1,opt,name=cmd,proto3" json:"cmd,omitempty"` + Args []string `protobuf:"bytes,2,rep,name=args,proto3" json:"args,omitempty"` + Timeout *durationpb.Duration `protobuf:"bytes,3,opt,name=timeout,proto3,oneof" json:"timeout,omitempty"` + Interval *durationpb.Duration `protobuf:"bytes,4,opt,name=interval,proto3,oneof" json:"interval,omitempty"` + Retries *uint64 `protobuf:"varint,5,opt,name=retries,proto3,oneof" json:"retries,omitempty"` } func (x *Job_Service_Health) Reset() { @@ -649,6 +653,27 @@ func (x *Job_Service_Health) GetArgs() []string { return nil } +func (x *Job_Service_Health) GetTimeout() *durationpb.Duration { + if x != nil { + return x.Timeout + } + return nil +} + +func (x *Job_Service_Health) GetInterval() *durationpb.Duration { + if x != nil { + return x.Interval + } + return nil +} + +func (x *Job_Service_Health) GetRetries() uint64 { + if x != nil && x.Retries != nil { + return *x.Retries + } + return 0 +} + type LoadImageMessage_Init struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -793,7 +818,9 @@ var File_proto_alfred_proto protoreflect.FileDescriptor var file_proto_alfred_proto_rawDesc = []byte{ 0x0a, 0x12, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x61, 0x6c, 0x66, 0x72, 0x65, 0x64, 0x2e, 0x70, - 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x05, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0xef, 0x02, 0x0a, 0x03, + 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x05, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x1e, 0x67, 0x6f, 0x6f, + 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x64, 0x75, 0x72, + 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0xaa, 0x04, 0x0a, 0x03, 0x4a, 0x6f, 0x62, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x69, 0x6d, 0x61, 0x67, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x69, 0x6d, 0x61, 0x67, 0x65, 0x12, 0x2e, 0x0a, @@ -801,7 +828,7 @@ var file_proto_alfred_proto_rawDesc = []byte{ 0x12, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x4a, 0x6f, 0x62, 0x2e, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x52, 0x08, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x12, 0x14, 0x0a, 0x05, 0x74, 0x61, 0x73, 0x6b, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x09, 0x52, 0x05, 0x74, 0x61, - 0x73, 0x6b, 0x73, 0x1a, 0xc8, 0x01, 0x0a, 0x07, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, + 0x73, 0x6b, 0x73, 0x1a, 0x83, 0x03, 0x0a, 0x07, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x69, 0x6d, 0x61, 0x67, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x69, 0x6d, 0x61, 0x67, 0x65, 0x12, 0x20, 0x0a, 0x03, 0x65, 0x6e, 0x76, @@ -810,67 +837,79 @@ var file_proto_alfred_proto_rawDesc = []byte{ 0x65, 0x61, 0x6c, 0x74, 0x68, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x4a, 0x6f, 0x62, 0x2e, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x48, 0x00, 0x52, 0x06, 0x68, 0x65, 0x61, 0x6c, 0x74, 0x68, - 0x88, 0x01, 0x01, 0x1a, 0x2e, 0x0a, 0x06, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x12, 0x10, 0x0a, - 0x03, 0x63, 0x6d, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x63, 0x6d, 0x64, 0x12, - 0x12, 0x0a, 0x04, 0x61, 0x72, 0x67, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x09, 0x52, 0x04, 0x61, - 0x72, 0x67, 0x73, 0x42, 0x09, 0x0a, 0x07, 0x5f, 0x68, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x1a, 0x2d, - 0x0a, 0x03, 0x45, 0x6e, 0x76, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, - 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x22, 0x9a, 0x02, - 0x0a, 0x10, 0x4c, 0x6f, 0x61, 0x64, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x4d, 0x65, 0x73, 0x73, 0x61, - 0x67, 0x65, 0x12, 0x32, 0x0a, 0x04, 0x69, 0x6e, 0x69, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, - 0x32, 0x1c, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x4c, 0x6f, 0x61, 0x64, 0x49, 0x6d, 0x61, - 0x67, 0x65, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x2e, 0x49, 0x6e, 0x69, 0x74, 0x48, 0x00, - 0x52, 0x04, 0x69, 0x6e, 0x69, 0x74, 0x12, 0x32, 0x0a, 0x04, 0x64, 0x61, 0x74, 0x61, 0x18, 0x02, - 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x4c, 0x6f, 0x61, - 0x64, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x2e, 0x44, 0x61, - 0x74, 0x61, 0x48, 0x00, 0x52, 0x04, 0x64, 0x61, 0x74, 0x61, 0x12, 0x32, 0x0a, 0x04, 0x64, 0x6f, - 0x6e, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, - 0x2e, 0x4c, 0x6f, 0x61, 0x64, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, - 0x65, 0x2e, 0x44, 0x6f, 0x6e, 0x65, 0x48, 0x00, 0x52, 0x04, 0x64, 0x6f, 0x6e, 0x65, 0x1a, 0x21, - 0x0a, 0x04, 0x49, 0x6e, 0x69, 0x74, 0x12, 0x19, 0x0a, 0x08, 0x69, 0x6d, 0x61, 0x67, 0x65, 0x5f, - 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x69, 0x6d, 0x61, 0x67, 0x65, 0x49, - 0x64, 0x1a, 0x34, 0x0a, 0x04, 0x44, 0x61, 0x74, 0x61, 0x12, 0x14, 0x0a, 0x05, 0x63, 0x68, 0x75, - 0x6e, 0x6b, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x05, 0x63, 0x68, 0x75, 0x6e, 0x6b, 0x12, - 0x16, 0x0a, 0x06, 0x6c, 0x65, 0x6e, 0x67, 0x74, 0x68, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, - 0x06, 0x6c, 0x65, 0x6e, 0x67, 0x74, 0x68, 0x1a, 0x06, 0x0a, 0x04, 0x44, 0x6f, 0x6e, 0x65, 0x42, - 0x09, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0xb0, 0x01, 0x0a, 0x11, 0x4c, - 0x6f, 0x61, 0x64, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, - 0x12, 0x37, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, - 0x32, 0x1f, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x4c, 0x6f, 0x61, 0x64, 0x49, 0x6d, 0x61, - 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x53, 0x74, 0x61, 0x74, 0x75, - 0x73, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x22, 0x0a, 0x0a, 0x63, 0x68, 0x75, - 0x6e, 0x6b, 0x5f, 0x73, 0x69, 0x7a, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x48, 0x00, 0x52, - 0x09, 0x63, 0x68, 0x75, 0x6e, 0x6b, 0x53, 0x69, 0x7a, 0x65, 0x88, 0x01, 0x01, 0x22, 0x2f, 0x0a, - 0x06, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x0f, 0x0a, 0x0b, 0x55, 0x4e, 0x53, 0x50, 0x45, - 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x0c, 0x0a, 0x08, 0x43, 0x4f, 0x4e, 0x54, - 0x49, 0x4e, 0x55, 0x45, 0x10, 0x01, 0x12, 0x06, 0x0a, 0x02, 0x4f, 0x4b, 0x10, 0x02, 0x42, 0x0d, - 0x0a, 0x0b, 0x5f, 0x63, 0x68, 0x75, 0x6e, 0x6b, 0x5f, 0x73, 0x69, 0x7a, 0x65, 0x22, 0x32, 0x0a, - 0x12, 0x53, 0x63, 0x68, 0x65, 0x64, 0x75, 0x6c, 0x65, 0x4a, 0x6f, 0x62, 0x52, 0x65, 0x71, 0x75, - 0x65, 0x73, 0x74, 0x12, 0x1c, 0x0a, 0x03, 0x6a, 0x6f, 0x62, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, - 0x32, 0x0a, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x4a, 0x6f, 0x62, 0x52, 0x03, 0x6a, 0x6f, - 0x62, 0x22, 0x15, 0x0a, 0x13, 0x53, 0x63, 0x68, 0x65, 0x64, 0x75, 0x6c, 0x65, 0x4a, 0x6f, 0x62, - 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x0d, 0x0a, 0x0b, 0x50, 0x69, 0x6e, 0x67, - 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x40, 0x0a, 0x0c, 0x50, 0x69, 0x6e, 0x67, 0x52, - 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, - 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, - 0x6e, 0x12, 0x16, 0x0a, 0x06, 0x63, 0x6f, 0x6d, 0x6d, 0x69, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x06, 0x63, 0x6f, 0x6d, 0x6d, 0x69, 0x74, 0x32, 0xc3, 0x01, 0x0a, 0x06, 0x41, 0x6c, - 0x66, 0x72, 0x65, 0x64, 0x12, 0x42, 0x0a, 0x09, 0x4c, 0x6f, 0x61, 0x64, 0x49, 0x6d, 0x61, 0x67, - 0x65, 0x12, 0x17, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x4c, 0x6f, 0x61, 0x64, 0x49, 0x6d, - 0x61, 0x67, 0x65, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x18, 0x2e, 0x70, 0x72, 0x6f, - 0x74, 0x6f, 0x2e, 0x4c, 0x6f, 0x61, 0x64, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, - 0x6f, 0x6e, 0x73, 0x65, 0x28, 0x01, 0x30, 0x01, 0x12, 0x44, 0x0a, 0x0b, 0x53, 0x63, 0x68, 0x65, - 0x64, 0x75, 0x6c, 0x65, 0x4a, 0x6f, 0x62, 0x12, 0x19, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, - 0x53, 0x63, 0x68, 0x65, 0x64, 0x75, 0x6c, 0x65, 0x4a, 0x6f, 0x62, 0x52, 0x65, 0x71, 0x75, 0x65, - 0x73, 0x74, 0x1a, 0x1a, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x53, 0x63, 0x68, 0x65, 0x64, - 0x75, 0x6c, 0x65, 0x4a, 0x6f, 0x62, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2f, - 0x0a, 0x04, 0x50, 0x69, 0x6e, 0x67, 0x12, 0x12, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x50, - 0x69, 0x6e, 0x67, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x13, 0x2e, 0x70, 0x72, 0x6f, - 0x74, 0x6f, 0x2e, 0x50, 0x69, 0x6e, 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, - 0x22, 0x5a, 0x20, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x67, 0x61, - 0x6d, 0x6d, 0x61, 0x64, 0x69, 0x61, 0x2f, 0x61, 0x6c, 0x66, 0x72, 0x65, 0x64, 0x2f, 0x70, 0x72, - 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x88, 0x01, 0x01, 0x1a, 0xe8, 0x01, 0x0a, 0x06, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x12, 0x10, + 0x0a, 0x03, 0x63, 0x6d, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x63, 0x6d, 0x64, + 0x12, 0x12, 0x0a, 0x04, 0x61, 0x72, 0x67, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x09, 0x52, 0x04, + 0x61, 0x72, 0x67, 0x73, 0x12, 0x38, 0x0a, 0x07, 0x74, 0x69, 0x6d, 0x65, 0x6f, 0x75, 0x74, 0x18, + 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, + 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x44, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, + 0x48, 0x00, 0x52, 0x07, 0x74, 0x69, 0x6d, 0x65, 0x6f, 0x75, 0x74, 0x88, 0x01, 0x01, 0x12, 0x3a, + 0x0a, 0x08, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x76, 0x61, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, + 0x32, 0x19, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, + 0x75, 0x66, 0x2e, 0x44, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x48, 0x01, 0x52, 0x08, 0x69, + 0x6e, 0x74, 0x65, 0x72, 0x76, 0x61, 0x6c, 0x88, 0x01, 0x01, 0x12, 0x1d, 0x0a, 0x07, 0x72, 0x65, + 0x74, 0x72, 0x69, 0x65, 0x73, 0x18, 0x05, 0x20, 0x01, 0x28, 0x04, 0x48, 0x02, 0x52, 0x07, 0x72, + 0x65, 0x74, 0x72, 0x69, 0x65, 0x73, 0x88, 0x01, 0x01, 0x42, 0x0a, 0x0a, 0x08, 0x5f, 0x74, 0x69, + 0x6d, 0x65, 0x6f, 0x75, 0x74, 0x42, 0x0b, 0x0a, 0x09, 0x5f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x76, + 0x61, 0x6c, 0x42, 0x0a, 0x0a, 0x08, 0x5f, 0x72, 0x65, 0x74, 0x72, 0x69, 0x65, 0x73, 0x42, 0x09, + 0x0a, 0x07, 0x5f, 0x68, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x1a, 0x2d, 0x0a, 0x03, 0x45, 0x6e, 0x76, + 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, + 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x22, 0x9a, 0x02, 0x0a, 0x10, 0x4c, 0x6f, 0x61, + 0x64, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x32, 0x0a, + 0x04, 0x69, 0x6e, 0x69, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x70, 0x72, + 0x6f, 0x74, 0x6f, 0x2e, 0x4c, 0x6f, 0x61, 0x64, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x4d, 0x65, 0x73, + 0x73, 0x61, 0x67, 0x65, 0x2e, 0x49, 0x6e, 0x69, 0x74, 0x48, 0x00, 0x52, 0x04, 0x69, 0x6e, 0x69, + 0x74, 0x12, 0x32, 0x0a, 0x04, 0x64, 0x61, 0x74, 0x61, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, + 0x1c, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x4c, 0x6f, 0x61, 0x64, 0x49, 0x6d, 0x61, 0x67, + 0x65, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x2e, 0x44, 0x61, 0x74, 0x61, 0x48, 0x00, 0x52, + 0x04, 0x64, 0x61, 0x74, 0x61, 0x12, 0x32, 0x0a, 0x04, 0x64, 0x6f, 0x6e, 0x65, 0x18, 0x03, 0x20, + 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x4c, 0x6f, 0x61, 0x64, + 0x49, 0x6d, 0x61, 0x67, 0x65, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x2e, 0x44, 0x6f, 0x6e, + 0x65, 0x48, 0x00, 0x52, 0x04, 0x64, 0x6f, 0x6e, 0x65, 0x1a, 0x21, 0x0a, 0x04, 0x49, 0x6e, 0x69, + 0x74, 0x12, 0x19, 0x0a, 0x08, 0x69, 0x6d, 0x61, 0x67, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x07, 0x69, 0x6d, 0x61, 0x67, 0x65, 0x49, 0x64, 0x1a, 0x34, 0x0a, 0x04, + 0x44, 0x61, 0x74, 0x61, 0x12, 0x14, 0x0a, 0x05, 0x63, 0x68, 0x75, 0x6e, 0x6b, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x0c, 0x52, 0x05, 0x63, 0x68, 0x75, 0x6e, 0x6b, 0x12, 0x16, 0x0a, 0x06, 0x6c, 0x65, + 0x6e, 0x67, 0x74, 0x68, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x06, 0x6c, 0x65, 0x6e, 0x67, + 0x74, 0x68, 0x1a, 0x06, 0x0a, 0x04, 0x44, 0x6f, 0x6e, 0x65, 0x42, 0x09, 0x0a, 0x07, 0x6d, 0x65, + 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0xb0, 0x01, 0x0a, 0x11, 0x4c, 0x6f, 0x61, 0x64, 0x49, 0x6d, + 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x37, 0x0a, 0x06, 0x73, + 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x1f, 0x2e, 0x70, 0x72, + 0x6f, 0x74, 0x6f, 0x2e, 0x4c, 0x6f, 0x61, 0x64, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x06, 0x73, 0x74, + 0x61, 0x74, 0x75, 0x73, 0x12, 0x22, 0x0a, 0x0a, 0x63, 0x68, 0x75, 0x6e, 0x6b, 0x5f, 0x73, 0x69, + 0x7a, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x48, 0x00, 0x52, 0x09, 0x63, 0x68, 0x75, 0x6e, + 0x6b, 0x53, 0x69, 0x7a, 0x65, 0x88, 0x01, 0x01, 0x22, 0x2f, 0x0a, 0x06, 0x53, 0x74, 0x61, 0x74, + 0x75, 0x73, 0x12, 0x0f, 0x0a, 0x0b, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, + 0x44, 0x10, 0x00, 0x12, 0x0c, 0x0a, 0x08, 0x43, 0x4f, 0x4e, 0x54, 0x49, 0x4e, 0x55, 0x45, 0x10, + 0x01, 0x12, 0x06, 0x0a, 0x02, 0x4f, 0x4b, 0x10, 0x02, 0x42, 0x0d, 0x0a, 0x0b, 0x5f, 0x63, 0x68, + 0x75, 0x6e, 0x6b, 0x5f, 0x73, 0x69, 0x7a, 0x65, 0x22, 0x32, 0x0a, 0x12, 0x53, 0x63, 0x68, 0x65, + 0x64, 0x75, 0x6c, 0x65, 0x4a, 0x6f, 0x62, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1c, + 0x0a, 0x03, 0x6a, 0x6f, 0x62, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0a, 0x2e, 0x70, 0x72, + 0x6f, 0x74, 0x6f, 0x2e, 0x4a, 0x6f, 0x62, 0x52, 0x03, 0x6a, 0x6f, 0x62, 0x22, 0x15, 0x0a, 0x13, + 0x53, 0x63, 0x68, 0x65, 0x64, 0x75, 0x6c, 0x65, 0x4a, 0x6f, 0x62, 0x52, 0x65, 0x73, 0x70, 0x6f, + 0x6e, 0x73, 0x65, 0x22, 0x0d, 0x0a, 0x0b, 0x50, 0x69, 0x6e, 0x67, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x22, 0x40, 0x0a, 0x0c, 0x50, 0x69, 0x6e, 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x16, 0x0a, 0x06, + 0x63, 0x6f, 0x6d, 0x6d, 0x69, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x63, 0x6f, + 0x6d, 0x6d, 0x69, 0x74, 0x32, 0xc3, 0x01, 0x0a, 0x06, 0x41, 0x6c, 0x66, 0x72, 0x65, 0x64, 0x12, + 0x42, 0x0a, 0x09, 0x4c, 0x6f, 0x61, 0x64, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x12, 0x17, 0x2e, 0x70, + 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x4c, 0x6f, 0x61, 0x64, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x4d, 0x65, + 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x18, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x4c, 0x6f, + 0x61, 0x64, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x28, + 0x01, 0x30, 0x01, 0x12, 0x44, 0x0a, 0x0b, 0x53, 0x63, 0x68, 0x65, 0x64, 0x75, 0x6c, 0x65, 0x4a, + 0x6f, 0x62, 0x12, 0x19, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x53, 0x63, 0x68, 0x65, 0x64, + 0x75, 0x6c, 0x65, 0x4a, 0x6f, 0x62, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1a, 0x2e, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x53, 0x63, 0x68, 0x65, 0x64, 0x75, 0x6c, 0x65, 0x4a, 0x6f, + 0x62, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2f, 0x0a, 0x04, 0x50, 0x69, 0x6e, + 0x67, 0x12, 0x12, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x50, 0x69, 0x6e, 0x67, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x13, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x50, 0x69, + 0x6e, 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x22, 0x5a, 0x20, 0x67, 0x69, + 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x67, 0x61, 0x6d, 0x6d, 0x61, 0x64, 0x69, + 0x61, 0x2f, 0x61, 0x6c, 0x66, 0x72, 0x65, 0x64, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( @@ -902,6 +941,7 @@ var file_proto_alfred_proto_goTypes = []interface{}{ (*LoadImageMessage_Init)(nil), // 11: proto.LoadImageMessage.Init (*LoadImageMessage_Data)(nil), // 12: proto.LoadImageMessage.Data (*LoadImageMessage_Done)(nil), // 13: proto.LoadImageMessage.Done + (*durationpb.Duration)(nil), // 14: google.protobuf.Duration } var file_proto_alfred_proto_depIdxs = []int32{ 8, // 0: proto.Job.services:type_name -> proto.Job.Service @@ -912,17 +952,19 @@ var file_proto_alfred_proto_depIdxs = []int32{ 1, // 5: proto.ScheduleJobRequest.job:type_name -> proto.Job 9, // 6: proto.Job.Service.env:type_name -> proto.Job.Env 10, // 7: proto.Job.Service.health:type_name -> proto.Job.Service.Health - 2, // 8: proto.Alfred.LoadImage:input_type -> proto.LoadImageMessage - 4, // 9: proto.Alfred.ScheduleJob:input_type -> proto.ScheduleJobRequest - 6, // 10: proto.Alfred.Ping:input_type -> proto.PingRequest - 3, // 11: proto.Alfred.LoadImage:output_type -> proto.LoadImageResponse - 5, // 12: proto.Alfred.ScheduleJob:output_type -> proto.ScheduleJobResponse - 7, // 13: proto.Alfred.Ping:output_type -> proto.PingResponse - 11, // [11:14] is the sub-list for method output_type - 8, // [8:11] is the sub-list for method input_type - 8, // [8:8] is the sub-list for extension type_name - 8, // [8:8] is the sub-list for extension extendee - 0, // [0:8] is the sub-list for field type_name + 14, // 8: proto.Job.Service.Health.timeout:type_name -> google.protobuf.Duration + 14, // 9: proto.Job.Service.Health.interval:type_name -> google.protobuf.Duration + 2, // 10: proto.Alfred.LoadImage:input_type -> proto.LoadImageMessage + 4, // 11: proto.Alfred.ScheduleJob:input_type -> proto.ScheduleJobRequest + 6, // 12: proto.Alfred.Ping:input_type -> proto.PingRequest + 3, // 13: proto.Alfred.LoadImage:output_type -> proto.LoadImageResponse + 5, // 14: proto.Alfred.ScheduleJob:output_type -> proto.ScheduleJobResponse + 7, // 15: proto.Alfred.Ping:output_type -> proto.PingResponse + 13, // [13:16] is the sub-list for method output_type + 10, // [10:13] is the sub-list for method input_type + 10, // [10:10] is the sub-list for extension type_name + 10, // [10:10] is the sub-list for extension extendee + 0, // [0:10] is the sub-list for field type_name } func init() { file_proto_alfred_proto_init() } @@ -1095,6 +1137,7 @@ func file_proto_alfred_proto_init() { } file_proto_alfred_proto_msgTypes[2].OneofWrappers = []interface{}{} file_proto_alfred_proto_msgTypes[7].OneofWrappers = []interface{}{} + file_proto_alfred_proto_msgTypes[9].OneofWrappers = []interface{}{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ diff --git a/proto/alfred.proto b/proto/alfred.proto index 8bc1583..5385e7c 100644 --- a/proto/alfred.proto +++ b/proto/alfred.proto @@ -4,6 +4,8 @@ option go_package = "github.com/gammadia/alfred/proto"; package proto; +import "google/protobuf/duration.proto"; + service Alfred { rpc LoadImage (stream LoadImageMessage) returns (stream LoadImageResponse); rpc ScheduleJob (ScheduleJobRequest) returns (ScheduleJobResponse); @@ -25,6 +27,10 @@ message Job { message Health { string cmd = 1; repeated string args = 2; + + optional google.protobuf.Duration timeout = 3; + optional google.protobuf.Duration interval = 4; + optional uint64 retries = 5; } } diff --git a/provisioner/internal/docker.go b/provisioner/internal/docker.go index 4ca8709..359e675 100644 --- a/provisioner/internal/docker.go +++ b/provisioner/internal/docker.go @@ -162,13 +162,14 @@ func RunContainer(ctx context.Context, log *slog.Logger, docker *client.Client, return } - // TODO: move to job definition - interval := 10 * time.Second - timeout := 5 * time.Second - retries := 3 + interval := lo.Ternary(service.Health.Interval != nil, service.Health.Interval.AsDuration(), 10*time.Second) + timeout := lo.Ternary(service.Health.Timeout != nil, service.Health.Timeout.AsDuration(), 5*time.Second) + retries := lo.Ternary(service.Health.Retries != nil, int(*service.Health.Retries), 3) for i := 0; i < retries; i++ { - time.Sleep(interval) + // Always wait 1 second before running the health check, and potentially more between retries + time.Sleep(lo.Ternary(i > 0, interval, 1*time.Second)) + healthCheckLog := serviceLog.With(slog.Group("retry", "attempt", i+1, "interval", interval)) exec, err := docker.ContainerExecCreate(ctx, containerId, types.ExecConfig{ diff --git a/provisioner/local/config.go b/provisioner/local/config.go index ca56e0b..38a77b6 100644 --- a/provisioner/local/config.go +++ b/provisioner/local/config.go @@ -6,9 +6,9 @@ import ( type Config struct { // Logger to use - Logger *slog.Logger + Logger *slog.Logger `json:"-"` // Maximum number of nodes that can be provisioned - MaxNodes int + MaxNodes int `json:"provisioner-max-nodes"` // Maximum number of tasks that can be run on a single node - MaxTasksPerNode int + MaxTasksPerNode int `json:"provisioner-max-tasks-per-node"` } diff --git a/provisioner/local/provisioner.go b/provisioner/local/provisioner.go index 212931a..f4a4c9c 100644 --- a/provisioner/local/provisioner.go +++ b/provisioner/local/provisioner.go @@ -3,6 +3,7 @@ package local import ( "context" "fmt" + "github.com/gammadia/alfred/namegen" "log/slog" "github.com/docker/docker/client" @@ -47,19 +48,21 @@ func (p *Provisioner) MaxTasksPerNode() int { return p.config.MaxTasksPerNode } -func (p *Provisioner) Provision() (scheduler.Node, error) { +func (p *Provisioner) Provision(nodeName namegen.ID) (scheduler.Node, error) { p.nextNodeNumber += 1 ctx, cancel := context.WithCancel(p.ctx) - return &Node{ + node := &Node{ ctx: ctx, cancel: cancel, docker: p.docker, nodeNumber: p.nextNodeNumber, - log: p.config.Logger.With(slog.Group("node", "name", fmt.Sprintf("local-%d", p.nextNodeNumber))), - }, nil + } + node.log = p.config.Logger.With(slog.Group("node", "name", node.Name())) + + return node, nil } func (p *Provisioner) Shutdown() { diff --git a/provisioner/openstack/config.go b/provisioner/openstack/config.go index 7531111..92f8f50 100644 --- a/provisioner/openstack/config.go +++ b/provisioner/openstack/config.go @@ -8,21 +8,21 @@ import ( type Config struct { // Logger to use - Logger *slog.Logger + Logger *slog.Logger `json:"-"` // Maximum number of nodes that can be provisioned - MaxNodes int + MaxNodes int `json:"provisioner-max-nodes"` // Maximum number of tasks that can be run on a single node - MaxTasksPerNode int + MaxTasksPerNode int `json:"provisioner-max-tasks-per-node"` // Image to use for nodes - Image string + Image string `json:"openstack-image"` // Machine flavor to use for nodes - Flavor string + Flavor string `json:"openstack-flavor"` // Networks to attach to nodes - Networks []servers.Network + Networks []servers.Network `json:"openstack-network,omitempty"` // Security groups to attach to nodes - SecurityGroups []string + SecurityGroups []string `json:"openstack-security-groups,omitempty"` // Username to use when connecting to nodes - SshUsername string + SshUsername string `json:"openstack-ssh-username"` // Docker host to use when connecting to nodes - DockerHost string + DockerHost string `json:"openstack-docker-host"` } diff --git a/provisioner/openstack/provisioner.go b/provisioner/openstack/provisioner.go index 4c5ec48..eabe8fc 100644 --- a/provisioner/openstack/provisioner.go +++ b/provisioner/openstack/provisioner.go @@ -106,8 +106,8 @@ func (p *Provisioner) MaxTasksPerNode() int { return p.config.MaxTasksPerNode } -func (p *Provisioner) Provision() (scheduler.Node, error) { - name := fmt.Sprintf("alfred-%s", namegen.Get()) +func (p *Provisioner) Provision(nodeName namegen.ID) (scheduler.Node, error) { + name := fmt.Sprintf("alfred-%s", nodeName) server, err := servers.Create(p.client, keypairs.CreateOptsExt{ CreateOptsBuilder: servers.CreateOpts{ diff --git a/scheduler/config.go b/scheduler/config.go index 7297036..4c088d4 100644 --- a/scheduler/config.go +++ b/scheduler/config.go @@ -6,7 +6,7 @@ import ( ) type Config struct { - Logger *slog.Logger - ProvisioningDelay time.Duration - ProvisioningFailureCooldown time.Duration + Logger *slog.Logger `json:"-"` + ProvisioningDelay time.Duration `json:"provisioning-delay"` + ProvisioningFailureCooldown time.Duration `json:"provisioning-failure-cooldown"` } diff --git a/scheduler/node.go b/scheduler/node.go index d17397d..952cda1 100644 --- a/scheduler/node.go +++ b/scheduler/node.go @@ -1,6 +1,7 @@ package scheduler import ( + "github.com/gammadia/alfred/namegen" "log/slog" "time" ) @@ -27,5 +28,6 @@ type nodeState struct { tasks []*Task log *slog.Logger + nodeName namegen.ID earliestStart time.Time } diff --git a/scheduler/provisioner.go b/scheduler/provisioner.go index cd60e07..e23fe16 100644 --- a/scheduler/provisioner.go +++ b/scheduler/provisioner.go @@ -1,12 +1,14 @@ package scheduler +import "github.com/gammadia/alfred/namegen" + type Provisioner interface { // MaxNodes returns the maximum number of nodes that can be provisioned // This is a hard limit. Nodes in the Terminating state are still counted. MaxNodes() int MaxTasksPerNode() int - Provision() (Node, error) // TODO: we need cancellation + Provision(nodeName namegen.ID) (Node, error) // TODO: we need cancellation // Shutdown shuts down the provisioner, terminating all nodes. Shutdown() diff --git a/scheduler/scheduler.go b/scheduler/scheduler.go index 4b7facd..c3bb036 100644 --- a/scheduler/scheduler.go +++ b/scheduler/scheduler.go @@ -1,6 +1,7 @@ package scheduler import ( + "encoding/json" "fmt" "log/slog" "math" @@ -53,6 +54,8 @@ func New(provisioner Provisioner, config Config) *Scheduler { wg: sync.WaitGroup{}, } + scheduler.log.Debug("Scheduler config", "config", string(lo.Must(json.Marshal(config)))) + return scheduler } @@ -205,16 +208,17 @@ func (s *Scheduler) resizePool() { s.provisionedNodes += 1 s.lastNodeProvisionedAt = lo.Must(lo.Coalesce(s.lastNodeProvisionedAt, time.Now())).Add(delay) + nodeName := namegen.Get() nodeState := &nodeState{ node: nil, status: NodeStatusPending, tasks: make([]*Task, s.provisioner.MaxTasksPerNode()), - log: s.log.With("component", "node"), + log: s.log.With("component", "node").With(slog.Group("node", "name", nodeName)), + nodeName: nodeName, earliestStart: s.lastNodeProvisionedAt, } s.nodes = append(s.nodes, nodeState) - s.log.Info("Provisioning a new node") go s.watchNodeProvisioning(nodeState) } @@ -224,11 +228,14 @@ func (s *Scheduler) watchNodeProvisioning(nodeState *nodeState) { now := time.Now() if nodeState.status == NodeStatusPending && now.Before(nodeState.earliestStart) { wait := nodeState.earliestStart.Sub(now) + nodeState.log.Info("Waiting before provisioning node", "wait", wait) time.Sleep(wait) } + nodeState.log.Info("Provisioning node") + nodeState.status = NodeStatusProvisioning - if node, err := s.provisioner.Provision(); err != nil { + if node, err := s.provisioner.Provision(nodeState.nodeName); err != nil { nodeState.log.Error("Provisioning of node failed", "error", err) nodeState.status = NodeStatusFailed @@ -237,7 +244,6 @@ func (s *Scheduler) watchNodeProvisioning(nodeState *nodeState) { s.requestTick() }) } else { - nodeState.log = nodeState.log.With(slog.Group("node", "name", node.Name())) nodeState.node = node nodeState.status = NodeStatusOnline nodeState.log.Info("Node is online") diff --git a/server/scheduler.go b/server/scheduler.go index 8c9a6ab..62dcc9c 100644 --- a/server/scheduler.go +++ b/server/scheduler.go @@ -1,6 +1,7 @@ package main import ( + "encoding/json" "fmt" "github.com/gammadia/alfred/provisioner/local" @@ -31,16 +32,21 @@ func createScheduler() error { } func createProvisioner() (s.Provisioner, error) { - logger := log.Base.With("component", "provisioner") - switch p := viper.GetString(flags.Provisioner); p { + p := viper.GetString(flags.Provisioner) + logger := log.Base.With("component", "provisioner", "type", p) + switch p { case "local": - return local.New(local.Config{ + config := local.Config{ Logger: logger, MaxNodes: viper.GetInt(flags.ProvisionerMaxNodes), MaxTasksPerNode: viper.GetInt(flags.ProvisionerMaxTasksPerNode), - }) + } + + logger.Debug("Provisioner config", "config", string(lo.Must(json.Marshal(config)))) + + return local.New(config) case "openstack": - return openstack.New(openstack.Config{ + config := openstack.Config{ Logger: logger, MaxNodes: viper.GetInt(flags.ProvisionerMaxNodes), MaxTasksPerNode: viper.GetInt(flags.ProvisionerMaxTasksPerNode), @@ -55,7 +61,11 @@ func createProvisioner() (s.Provisioner, error) { SecurityGroups: viper.GetStringSlice(flags.OpenstackSecurityGroups), SshUsername: viper.GetString(flags.OpenstackSshUsername), DockerHost: viper.GetString(flags.OpenstackDockerHost), - }) + } + + logger.Debug("Provisioner config", "config", string(lo.Must(json.Marshal(config)))) + + return openstack.New(config) default: return nil, fmt.Errorf("unknown provisioner") }