diff --git a/.deadlines.yml b/.deadlines.yml index 4ad8754..e655246 100644 --- a/.deadlines.yml +++ b/.deadlines.yml @@ -1,3 +1,17 @@ +- group: "[HW] Distbuild" + start: 09-04-2020 18:00 + deadline: 30-04-2020 23:59 + tasks: + - task: disttest + score: 0 + +- group: Distbuild blocks + start: 09-04-2020 18:00 + deadline: 23-04-2020 23:59 + tasks: + - task: distbuild + score: 300 + - group: HTTP and Context start: 02-04-2020 18:00 deadline: 12-04-2020 23:59 diff --git a/distbuild/README.md b/distbuild/README.md new file mode 100644 index 0000000..dbca566 --- /dev/null +++ b/distbuild/README.md @@ -0,0 +1,145 @@ +# distbuild + +В этом задании вам нужно будет реализовать систему распределённой сборки. + +Система сборки получает на вход граф сборки и файлы с исходным кодом. Результатом сборки +являются исполняемые файлы и stderr/stdout запущенных процессов. + +## Граф сборки + +Граф сборки состоит из джобов. Каждый джоб описывает команды, которые нужно запустить на одной машине, +вместе со всеми входными файлами, которые нужны этим командам для работы. + +Джобы в графе сборки запускают произвольные команды. Например, вызывать компилятор, линкер или +запускать тесты. + +Команды внутри джоба могут читать файлы с файловой системы. Мы будем различать два вида файлов: + - Файлы с исходным кодом с машины пользователя. + - Файлы, которые породили другие джобы. + +Команды внутри джоба могут писать результаты своей работы в файлы на диске. Выходные файлы +обязаны находиться внутри его выходной директории. Директория с результатом работы джоба называется +артефактом. + +```go +package build + +import "crypto/sha1" + +// ID задаёт уникальный идентификатор джоба. +// +// Мы будем использовать sha1 хеш, поэтому ID будет занимать 20 байт. +type ID [sha1.Size]byte + +// Job описывает одну вершину графа сборки. +type Job struct { + // ID задаёт уникальный идентификатор джоба. + // + // ID вычисляется как хеш от всех входных файлов, команд запуска и хешей зависимых джобов. + // + // Выход джоба целиком определяется его ID. Это важное свойство позволяет кешировать + // результаты сборки. + ID ID + + // Name задаёт человекочитаемое имя джоба. + // + // Например: + // build gitlab.com/slon/disbuild/pkg/b + // vet gitlab.com/slon/disbuild/pkg/a + // test gitlab.com/slon/disbuild/pkg/test + Name string + + // Inputs задаёт список файлов из директории с исходным кодом, + // которые нужны для работы этого джоба. + // + // В типичном случае, тут будут перечислены все .go файлы одного пакета. + Inputs []string + + // Deps задаёт список джобов, выходы которых нужны для работы этого джоба. + Deps []ID + + // Cmds описывает список команд, которые нужно выполнить в рамках этого джоба. + Cmds []Cmd +} +``` + +## Архитектура системы + +Наша система будет состоять из трех компонент. + * Клиент - процесс запускающий сборку. + * Воркер - процесс запускающий команды компиляции и тестирования. + * Координатор - центральный процесс в системе, общается с клиентами и воркерами. Раздаёт задачи + воркерам. + +Типичная сборка выглядит так: +1. Клиент подключается к координатору, посылает ему граф сборки и входные файлы для графа сборки. +2. Кооринатор сохраняет граф сборки в памяти и начинает его исполнение. +3. Воркеры начинают выполнять вершины графа, пересылая друг другу выходные директории джобов. +4. Результаты работы джобов скачиваются на клиента. + +# Как решать эту задачу + +Задача разбита на шаги. В начале, вам нужно будет реализовать небольшой набор независимых пакетов, +которые реализует нужные примитивы. Код в этих пакетах покрыт юниттестами. В каждом пакете находится +файл README.md, объясняющий подзадачу. + +Рекомендуемый порядок выполнения: + +- [`distbuild/pkg/build`](./pkg/build) - определение графа сборки. В этом пакете ничего писать не нужно, + нужно ознакомиться с существующим кодом. +- [`distbuild/pkg/tarstream`](./pkg/tarstream) - передача директории через сокет. +- [`distbuild/pkg/api`](./pkg/api) - протокол общения между компонентами. +- [`distbuild/pkg/artifact`](./pkg/artifact) - кеш артефактов и протокол передачи артефактов между воркерами. +- [`distbuild/pkg/filecache`](./pkg/filecache) - кеш файлов и протокол передачи файлов между компонентами. +- [`distbuild/pkg/scheduler`](./pkg/scheduler) - планировщик с эвристикой локальности. + +После того, как все кубики будут готовы, нужно будет соединить их вместе, реализовав `distbuild/pkg/worker`, +`distbuild/pkg/client` и `distbuild/pkg/dist`. Код в этих пакетах нужно отлаживать на +интеграционных тестах в [`distbuild/disttest`](../disttest). + +Код тестов в этом задании менять нельзя. Это значит, что вы не можете менять интерфейсы в тех местах, где +код покрыт тестами. + +
+ Сколько кода нужно написать? + + ``` +prime@bee ~/C/shad-go> find distbuild -iname '*.go' | grep -v test | grep -v mock | grep -v pkg/build | xargs wc -l + 23 distbuild/pkg/worker/state.go + 111 distbuild/pkg/worker/worker.go + 45 distbuild/pkg/worker/download.go + 281 distbuild/pkg/worker/job.go + 69 distbuild/pkg/api/heartbeat.go + 121 distbuild/pkg/api/build_client.go + 53 distbuild/pkg/api/build.go + 60 distbuild/pkg/api/heartbeat_handler.go + 142 distbuild/pkg/api/build_handler.go + 56 distbuild/pkg/api/heartbeat_client.go + 288 distbuild/pkg/scheduler/scheduler.go + 119 distbuild/pkg/dist/build.go + 120 distbuild/pkg/dist/coordinator.go + 98 distbuild/pkg/tarstream/stream.go + 42 distbuild/pkg/artifact/client.go + 191 distbuild/pkg/artifact/cache.go + 54 distbuild/pkg/artifact/handler.go + 124 distbuild/pkg/client/build.go + 83 distbuild/pkg/filecache/client.go + 99 distbuild/pkg/filecache/handler.go + 111 distbuild/pkg/filecache/filecache.go + 2290 total + ``` +
+ +# Критерии оценки + +Решение должно проходить все тесты, так же как в обычной задаче. + +Задача разбита на две части: +- `distbuild` проверяет решение всех "кубиков". Эта задача расчитывается как обычная семинарская. +- `disttest` проверяет интеграционные тесты. Эта задача оценивается как домашка. После успешной попытки, в таблице gdoc + будет стоять 0. После этого, проверяющие должны будут просмотреть решение и заменить оценку в таблице на 1. + Это будет значить, что домашнее задание засчитано. Code Review не будет, проверка нужна только чтобы удостовериться что + посылка честно проходит все тесты. Отдельный Merge Request создавать не нужно. + +Чтобы запустить проверку внутри `disttest`, сделайте коммит добавляющий незначащий перенос строки в какой-нибудь файл +из этой директории. diff --git a/distbuild/pkg/api/README.md b/distbuild/pkg/api/README.md new file mode 100644 index 0000000..6eaf493 --- /dev/null +++ b/distbuild/pkg/api/README.md @@ -0,0 +1,32 @@ +# api + +Пакет api реализует протокол, по которому общаются компоненты системы. + +Этот пакет не занимается передачей файлов и артефактов, соответствующие функции находятся в +пакетах `filecache` и `artifact`. + +## Worker <-> Coordinator + +- Worker и Coordinator общаются через один запрос `POST /heartbeat`. +- Worker посылает `HeartbeatRequest` и получает в ответ `HeartbeatResponse`. +- Запрос и ответ передаются в формате json. +- Ошибка обработки heartbeat передаётся как текстовая строка. + +## Client <-> Coordinator + +Client и Coordinator общаются через два вызова. + +- `POST /build` - стартует новый билд. + * Client посылает в Body запроса json c описанием сборки. + * Coordinator стримит в body ответа json сообщения описывающие прогресс сборки. + * Первым сообщением в ответе Coordinator присылает `buildID`. + * _Тут можно было бы использовать websocket, но нас устраивает более простое решение._ + +- `POST /signal?build_id=12345` - посылает сигнал бегущему билду. + * Запрос и ответ передаются в формате json. + +# Замечания + +- Конструкторы клиентов и хендлеров принимают первым параметром `*zap.Logger`. Запишите в лог события + получения/отправки запроса и все ошибки. Это поможет вам отлаживать интеграционные тесты + в следующей части задания. \ No newline at end of file diff --git a/distbuild/pkg/api/build.go b/distbuild/pkg/api/build.go new file mode 100644 index 0000000..56e4924 --- /dev/null +++ b/distbuild/pkg/api/build.go @@ -0,0 +1,53 @@ +package api + +import ( + "context" + + "gitlab.com/slon/shad-go/distbuild/pkg/build" +) + +type BuildRequest struct { + Graph build.Graph +} + +type BuildStarted struct { + ID build.ID + MissingFiles []build.ID +} + +type StatusUpdate struct { + JobFinished *JobResult + BuildFailed *BuildFailed + BuildFinished *BuildFinished +} + +type BuildFailed struct { + Error string +} + +type BuildFinished struct { +} + +type UploadDone struct{} + +type SignalRequest struct { + UploadDone *UploadDone +} + +type SignalResponse struct { +} + +type StatusWriter interface { + Started(rsp *BuildStarted) error + Updated(update *StatusUpdate) error +} + +type Service interface { + StartBuild(ctx context.Context, request *BuildRequest, w StatusWriter) error + SignalBuild(ctx context.Context, buildID build.ID, signal *SignalRequest) (*SignalResponse, error) +} + +type StatusReader interface { + Close() error + Next() (*StatusUpdate, error) +} diff --git a/distbuild/pkg/api/build_client.go b/distbuild/pkg/api/build_client.go new file mode 100644 index 0000000..399b6ea --- /dev/null +++ b/distbuild/pkg/api/build_client.go @@ -0,0 +1,26 @@ +// +build !solution + +package api + +import ( + "context" + + "go.uber.org/zap" + + "gitlab.com/slon/shad-go/distbuild/pkg/build" +) + +type BuildClient struct { +} + +func NewBuildClient(l *zap.Logger, endpoint string) *BuildClient { + panic("implement me") +} + +func (c *BuildClient) StartBuild(ctx context.Context, request *BuildRequest) (*BuildStarted, StatusReader, error) { + panic("implement me") +} + +func (c *BuildClient) SignalBuild(ctx context.Context, buildID build.ID, signal *SignalRequest) (*SignalResponse, error) { + panic("implement me") +} diff --git a/distbuild/pkg/api/build_handler.go b/distbuild/pkg/api/build_handler.go new file mode 100644 index 0000000..fd08441 --- /dev/null +++ b/distbuild/pkg/api/build_handler.go @@ -0,0 +1,20 @@ +// +build !solution + +package api + +import ( + "net/http" + + "go.uber.org/zap" +) + +func NewBuildService(l *zap.Logger, s Service) *BuildHandler { + panic("implement me") +} + +type BuildHandler struct { +} + +func (h *BuildHandler) Register(mux *http.ServeMux) { + panic("implement me") +} diff --git a/distbuild/pkg/api/build_test.go b/distbuild/pkg/api/build_test.go new file mode 100644 index 0000000..bec6d4c --- /dev/null +++ b/distbuild/pkg/api/build_test.go @@ -0,0 +1,159 @@ +package api_test + +import ( + "context" + "fmt" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/require" + "go.uber.org/zap/zaptest" + + "gitlab.com/slon/shad-go/distbuild/pkg/api" + mock "gitlab.com/slon/shad-go/distbuild/pkg/api/mock" + "gitlab.com/slon/shad-go/distbuild/pkg/build" +) + +//go:generate mockgen -package mock -destination mock/mock.go . Service + +type env struct { + ctrl *gomock.Controller + mock *mock.MockService + server *httptest.Server + client *api.BuildClient +} + +func (e *env) stop() { + e.server.Close() + e.ctrl.Finish() +} + +func newEnv(t *testing.T) (*env, func()) { + env := &env{} + env.ctrl = gomock.NewController(t) + env.mock = mock.NewMockService(env.ctrl) + + log := zaptest.NewLogger(t) + + mux := http.NewServeMux() + + handler := api.NewBuildService(log, env.mock) + handler.Register(mux) + + env.server = httptest.NewServer(mux) + + env.client = api.NewBuildClient(log, env.server.URL) + + return env, env.stop +} + +func TestBuildSignal(t *testing.T) { + env, stop := newEnv(t) + defer stop() + + ctx := context.Background() + + buildIDa := build.ID{01} + buildIDb := build.ID{02} + req := &api.SignalRequest{} + rsp := &api.SignalResponse{} + + env.mock.EXPECT().SignalBuild(gomock.Any(), buildIDa, req).Return(rsp, nil) + env.mock.EXPECT().SignalBuild(gomock.Any(), buildIDb, req).Return(nil, fmt.Errorf("foo bar error")) + + _, err := env.client.SignalBuild(ctx, buildIDa, req) + require.NoError(t, err) + + _, err = env.client.SignalBuild(ctx, buildIDb, req) + require.Error(t, err) + require.Contains(t, err.Error(), "foo bar error") +} + +func TestBuildStartError(t *testing.T) { + env, stop := newEnv(t) + defer stop() + + ctx := context.Background() + + env.mock.EXPECT().StartBuild(gomock.Any(), gomock.Any(), gomock.Any()).Return(fmt.Errorf("foo bar error")) + + _, _, err := env.client.StartBuild(ctx, &api.BuildRequest{}) + require.Contains(t, err.Error(), "foo bar error") +} + +func TestBuildRunning(t *testing.T) { + env, stop := newEnv(t) + defer stop() + + ctx := context.Background() + + buildID := build.ID{02} + + req := &api.BuildRequest{ + Graph: build.Graph{SourceFiles: map[build.ID]string{{01}: "a.txt"}}, + } + + started := &api.BuildStarted{ID: buildID} + finished := &api.StatusUpdate{BuildFinished: &api.BuildFinished{}} + + env.mock.EXPECT().StartBuild(gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, req *api.BuildRequest, w api.StatusWriter) error { + if err := w.Started(started); err != nil { + return err + } + + if err := w.Updated(finished); err != nil { + return err + } + + return fmt.Errorf("foo bar error") + }) + + rsp, r, err := env.client.StartBuild(ctx, req) + require.NoError(t, err) + + require.Equal(t, started, rsp) + + u, err := r.Next() + require.NoError(t, err) + require.Equal(t, finished, u) + + u, err = r.Next() + require.NoError(t, err) + require.Contains(t, u.BuildFailed.Error, "foo bar error") + + _, err = r.Next() + require.Equal(t, err, io.EOF) +} + +func TestBuildResultsStreaming(t *testing.T) { + // Test is hanging? + // See https://golang.org/pkg/net/http/#Flusher + + env, stop := newEnv(t) + defer stop() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + buildID := build.ID{02} + req := &api.BuildRequest{} + started := &api.BuildStarted{ID: buildID} + + env.mock.EXPECT().StartBuild(gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(ctx context.Context, req *api.BuildRequest, w api.StatusWriter) error { + if err := w.Started(started); err != nil { + return err + } + + <-ctx.Done() + return ctx.Err() + }) + + rsp, _, err := env.client.StartBuild(ctx, req) + require.NoError(t, err) + require.Equal(t, started, rsp) +} diff --git a/distbuild/pkg/api/heartbeat.go b/distbuild/pkg/api/heartbeat.go new file mode 100644 index 0000000..dd6b09b --- /dev/null +++ b/distbuild/pkg/api/heartbeat.go @@ -0,0 +1,69 @@ +package api + +import ( + "context" + + "gitlab.com/slon/shad-go/distbuild/pkg/build" +) + +// JobResult описывает результат работы джоба. +type JobResult struct { + ID build.ID + + Stdout, Stderr []byte + + ExitCode int + + // Error описывает сообщение об ошибке, из-за которого джоб не удалось выполнить. + // + // Если Error == nil, значит джоб завершился успешно. + Error *string +} + +type WorkerID string + +func (w WorkerID) String() string { + return string(w) +} + +type HeartbeatRequest struct { + // WorkerID задаёт персистентный идентификатор данного воркера. + // + // WorkerID так же выступает в качестве endpoint-а, к которому можно подключиться по HTTP. + // + // В наших тестов, идентификатор будет иметь вид "localhost:%d". + WorkerID WorkerID + + // RunningJobs перечисляет список джобов, которые выполняются на этом воркере + // в данный момент. + RunningJobs []build.ID + + // FreeSlots сообщает, сколько еще процессов можно запустить на этом воркере. + FreeSlots int + + // JobResult сообщает координатору, какие джобы завершили исполнение на этом воркере + // на этой итерации цикла. + FinishedJob []JobResult + + // AddedArtifacts говорит, какие артефакты появились в кеше на этой итерации цикла. + AddedArtifacts []build.ID +} + +// JobSpec описывает джоб, который нужно запустить. +type JobSpec struct { + // SourceFiles задаёт список файлов, который должны присутствовать в директории с исходным кодом при запуске этого джоба. + SourceFiles map[build.ID]string + + // Artifacts задаёт воркеров, с которых можно скачать артефакты необходимые этом джобу. + Artifacts map[build.ID]WorkerID + + build.Job +} + +type HeartbeatResponse struct { + JobsToRun map[build.ID]JobSpec +} + +type HeartbeatService interface { + Heartbeat(ctx context.Context, req *HeartbeatRequest) (*HeartbeatResponse, error) +} diff --git a/distbuild/pkg/api/heartbeat_client.go b/distbuild/pkg/api/heartbeat_client.go new file mode 100644 index 0000000..53f154e --- /dev/null +++ b/distbuild/pkg/api/heartbeat_client.go @@ -0,0 +1,20 @@ +// +build !solution + +package api + +import ( + "context" + + "go.uber.org/zap" +) + +type HeartbeatClient struct { +} + +func NewHeartbeatClient(l *zap.Logger, endpoint string) *HeartbeatClient { + panic("implement me") +} + +func (c *HeartbeatClient) Heartbeat(ctx context.Context, req *HeartbeatRequest) (*HeartbeatResponse, error) { + panic("implement me") +} diff --git a/distbuild/pkg/api/heartbeat_handler.go b/distbuild/pkg/api/heartbeat_handler.go new file mode 100644 index 0000000..8f4a4b7 --- /dev/null +++ b/distbuild/pkg/api/heartbeat_handler.go @@ -0,0 +1,20 @@ +// +build !solution + +package api + +import ( + "net/http" + + "go.uber.org/zap" +) + +type HeartbeatHandler struct { +} + +func NewHeartbeatHandler(l *zap.Logger, s HeartbeatService) *HeartbeatHandler { + panic("implement me") +} + +func (h *HeartbeatHandler) Register(mux *http.ServeMux) { + panic("implement me") +} diff --git a/distbuild/pkg/api/heartbeat_test.go b/distbuild/pkg/api/heartbeat_test.go new file mode 100644 index 0000000..746f62a --- /dev/null +++ b/distbuild/pkg/api/heartbeat_test.go @@ -0,0 +1,56 @@ +package api_test + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/require" + "go.uber.org/zap/zaptest" + + "gitlab.com/slon/shad-go/distbuild/pkg/api" + "gitlab.com/slon/shad-go/distbuild/pkg/api/mock" + "gitlab.com/slon/shad-go/distbuild/pkg/build" +) + +//go:generate mockgen -package mock -destination mock/heartbeat.go . HeartbeatService + +func TestHeartbeat(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + l := zaptest.NewLogger(t) + m := mock.NewMockHeartbeatService(ctrl) + mux := http.NewServeMux() + api.NewHeartbeatHandler(l, m).Register(mux) + + server := httptest.NewServer(mux) + defer server.Close() + + client := api.NewHeartbeatClient(l, server.URL) + + req := &api.HeartbeatRequest{ + WorkerID: "worker0", + } + rsp := &api.HeartbeatResponse{ + JobsToRun: map[build.ID]api.JobSpec{ + {0x01}: {Job: build.Job{Name: "cc a.c"}}, + }, + } + + gomock.InOrder( + m.EXPECT().Heartbeat(gomock.Any(), gomock.Eq(req)).Times(1).Return(rsp, nil), + m.EXPECT().Heartbeat(gomock.Any(), gomock.Eq(req)).Times(1).Return(nil, fmt.Errorf("build error: foo bar")), + ) + + clientRsp, err := client.Heartbeat(context.Background(), req) + require.NoError(t, err) + require.Equal(t, rsp, clientRsp) + + _, err = client.Heartbeat(context.Background(), req) + require.Error(t, err) + require.Contains(t, err.Error(), "build error: foo bar") +} diff --git a/distbuild/pkg/api/mock/heartbeat.go b/distbuild/pkg/api/mock/heartbeat.go new file mode 100644 index 0000000..e8dc165 --- /dev/null +++ b/distbuild/pkg/api/mock/heartbeat.go @@ -0,0 +1,50 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: gitlab.com/slon/shad-go/distbuild/pkg/api (interfaces: HeartbeatService) + +// Package mock is a generated GoMock package. +package mock + +import ( + context "context" + gomock "github.com/golang/mock/gomock" + api "gitlab.com/slon/shad-go/distbuild/pkg/api" + reflect "reflect" +) + +// MockHeartbeatService is a mock of HeartbeatService interface +type MockHeartbeatService struct { + ctrl *gomock.Controller + recorder *MockHeartbeatServiceMockRecorder +} + +// MockHeartbeatServiceMockRecorder is the mock recorder for MockHeartbeatService +type MockHeartbeatServiceMockRecorder struct { + mock *MockHeartbeatService +} + +// NewMockHeartbeatService creates a new mock instance +func NewMockHeartbeatService(ctrl *gomock.Controller) *MockHeartbeatService { + mock := &MockHeartbeatService{ctrl: ctrl} + mock.recorder = &MockHeartbeatServiceMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use +func (m *MockHeartbeatService) EXPECT() *MockHeartbeatServiceMockRecorder { + return m.recorder +} + +// Heartbeat mocks base method +func (m *MockHeartbeatService) Heartbeat(arg0 context.Context, arg1 *api.HeartbeatRequest) (*api.HeartbeatResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Heartbeat", arg0, arg1) + ret0, _ := ret[0].(*api.HeartbeatResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Heartbeat indicates an expected call of Heartbeat +func (mr *MockHeartbeatServiceMockRecorder) Heartbeat(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Heartbeat", reflect.TypeOf((*MockHeartbeatService)(nil).Heartbeat), arg0, arg1) +} diff --git a/distbuild/pkg/api/mock/mock.go b/distbuild/pkg/api/mock/mock.go new file mode 100644 index 0000000..05c31d8 --- /dev/null +++ b/distbuild/pkg/api/mock/mock.go @@ -0,0 +1,65 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: gitlab.com/slon/shad-go/distbuild/pkg/api (interfaces: Service) + +// Package mock is a generated GoMock package. +package mock + +import ( + context "context" + gomock "github.com/golang/mock/gomock" + api "gitlab.com/slon/shad-go/distbuild/pkg/api" + build "gitlab.com/slon/shad-go/distbuild/pkg/build" + reflect "reflect" +) + +// MockService is a mock of Service interface +type MockService struct { + ctrl *gomock.Controller + recorder *MockServiceMockRecorder +} + +// MockServiceMockRecorder is the mock recorder for MockService +type MockServiceMockRecorder struct { + mock *MockService +} + +// NewMockService creates a new mock instance +func NewMockService(ctrl *gomock.Controller) *MockService { + mock := &MockService{ctrl: ctrl} + mock.recorder = &MockServiceMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use +func (m *MockService) EXPECT() *MockServiceMockRecorder { + return m.recorder +} + +// SignalBuild mocks base method +func (m *MockService) SignalBuild(arg0 context.Context, arg1 build.ID, arg2 *api.SignalRequest) (*api.SignalResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SignalBuild", arg0, arg1, arg2) + ret0, _ := ret[0].(*api.SignalResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// SignalBuild indicates an expected call of SignalBuild +func (mr *MockServiceMockRecorder) SignalBuild(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SignalBuild", reflect.TypeOf((*MockService)(nil).SignalBuild), arg0, arg1, arg2) +} + +// StartBuild mocks base method +func (m *MockService) StartBuild(arg0 context.Context, arg1 *api.BuildRequest, arg2 api.StatusWriter) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "StartBuild", arg0, arg1, arg2) + ret0, _ := ret[0].(error) + return ret0 +} + +// StartBuild indicates an expected call of StartBuild +func (mr *MockServiceMockRecorder) StartBuild(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StartBuild", reflect.TypeOf((*MockService)(nil).StartBuild), arg0, arg1, arg2) +} diff --git a/distbuild/pkg/artifact/README.md b/distbuild/pkg/artifact/README.md new file mode 100644 index 0000000..567a4f3 --- /dev/null +++ b/distbuild/pkg/artifact/README.md @@ -0,0 +1,27 @@ +# artifact + +Пакет `artifact` реализует кеш хранения артефактов и протокол для передачи артефактов между воркерами. + +Артефакт - это директория, содержащая в себе результат работы джоба. Артефакт может состоять из произвольного +набора файлов и директорий. + +Основной тип `artifact.Cache` занимается хранением артефактов на диске и контролем одновременного доступа. +Все методы `artifact.Cache` должны быть *concurrency safe*. + +Одна горутина может начать писать артефакт. Начало записи берёт лок на запись. Никто другой не может работать с артефактом, +на который взят лок на запись. Горутина должна позвать `commit` или `abort` после того, как она закончила работать с артефактом. + +`commit` помечает артефакт в кеш. `abort` отменяет запись артефакта, удаляя все данные. + +Горутина может начать читать артефакт, позвав метод `Get`. Много горутин могут читать артефакт одновременно. +Горутина должна позвать `unlock`, после того как она закончила работать с артефактом. + +## Скачивание артефакта + +`*artifact.Handler` должен реализовывать один метод `GET /artifact?id=1234`. Хендлер отвечает на +запрос содержимым артефакта в формате `tarstream`. + +Функция `Download` должна скачивать артефакт из удалённого кеша в локальный. + +Обратите внимание, что конструктор хендлера принимает `*zap.Logger`. Запишите в этот логгер интересные события, +это поможет при отладке в следующих частях задачи. diff --git a/distbuild/pkg/artifact/cache.go b/distbuild/pkg/artifact/cache.go new file mode 100644 index 0000000..eaa3b8f --- /dev/null +++ b/distbuild/pkg/artifact/cache.go @@ -0,0 +1,39 @@ +// +build !solution + +package artifact + +import ( + "errors" + + "gitlab.com/slon/shad-go/distbuild/pkg/build" +) + +var ( + ErrNotFound = errors.New("artifact not found") + ErrExists = errors.New("artifact exists") + ErrWriteLocked = errors.New("artifact is locked for write") + ErrReadLocked = errors.New("artifact is locked for read") +) + +type Cache struct { +} + +func NewCache(root string) (*Cache, error) { + panic("implement me") +} + +func (c *Cache) Range(artifactFn func(artifact build.ID) error) error { + panic("implement me") +} + +func (c *Cache) Remove(artifact build.ID) error { + panic("implement me") +} + +func (c *Cache) Create(artifact build.ID) (path string, commit, abort func() error, err error) { + panic("implement me") +} + +func (c *Cache) Get(artifact build.ID) (path string, unlock func(), err error) { + panic("implement me") +} diff --git a/distbuild/pkg/artifact/cache_test.go b/distbuild/pkg/artifact/cache_test.go new file mode 100644 index 0000000..b4a3801 --- /dev/null +++ b/distbuild/pkg/artifact/cache_test.go @@ -0,0 +1,100 @@ +package artifact_test + +import ( + "errors" + "io/ioutil" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + "gitlab.com/slon/shad-go/distbuild/pkg/artifact" + "gitlab.com/slon/shad-go/distbuild/pkg/build" +) + +type testCache struct { + *artifact.Cache + tmpDir string +} + +func (c *testCache) cleanup() { + _ = os.RemoveAll(c.tmpDir) +} + +func newTestCache(t *testing.T) *testCache { + tmpDir, err := ioutil.TempDir("", "") + require.NoError(t, err) + + cache, err := artifact.NewCache(tmpDir) + if err != nil { + _ = os.RemoveAll(tmpDir) + } + require.NoError(t, err) + + return &testCache{Cache: cache, tmpDir: tmpDir} +} + +func TestCache(t *testing.T) { + c := newTestCache(t) + defer c.cleanup() + + idA := build.ID{'a'} + + path, commit, _, err := c.Create(idA) + require.NoError(t, err) + + _, _, _, err = c.Create(idA) + require.Truef(t, errors.Is(err, artifact.ErrWriteLocked), "%v", err) + + _, err = os.Create(filepath.Join(path, "a.txt")) + require.NoError(t, err) + + require.NoError(t, commit()) + + path, unlock, err := c.Get(idA) + require.NoError(t, err) + defer unlock() + + _, err = os.Stat(filepath.Join(path, "a.txt")) + require.NoError(t, err) + + require.Truef(t, errors.Is(c.Remove(idA), artifact.ErrReadLocked), "%v", err) + + idB := build.ID{'b'} + _, _, err = c.Get(idB) + require.Truef(t, errors.Is(err, artifact.ErrNotFound), "%v", err) + + require.NoError(t, c.Range(func(artifact build.ID) error { + require.Equal(t, idA, artifact) + return nil + })) +} + +func TestAbortWrite(t *testing.T) { + c := newTestCache(t) + defer c.cleanup() + + idA := build.ID{'a'} + + _, _, abort, err := c.Create(idA) + require.NoError(t, err) + require.NoError(t, abort()) + + _, _, err = c.Get(idA) + require.Truef(t, errors.Is(err, artifact.ErrNotFound), "%v", err) +} + +func TestArtifactExists(t *testing.T) { + c := newTestCache(t) + defer c.cleanup() + + idA := build.ID{'a'} + + _, commit, _, err := c.Create(idA) + require.NoError(t, err) + require.NoError(t, commit()) + + _, _, _, err = c.Create(idA) + require.Truef(t, errors.Is(err, artifact.ErrExists), "%v", err) +} diff --git a/distbuild/pkg/artifact/client.go b/distbuild/pkg/artifact/client.go new file mode 100644 index 0000000..7b77298 --- /dev/null +++ b/distbuild/pkg/artifact/client.go @@ -0,0 +1,14 @@ +// +build !solution + +package artifact + +import ( + "context" + + "gitlab.com/slon/shad-go/distbuild/pkg/build" +) + +// Download artifact from remote cache into local cache. +func Download(ctx context.Context, endpoint string, c *Cache, artifactID build.ID) error { + panic("implement me") +} diff --git a/distbuild/pkg/artifact/client_test.go b/distbuild/pkg/artifact/client_test.go new file mode 100644 index 0000000..92535a1 --- /dev/null +++ b/distbuild/pkg/artifact/client_test.go @@ -0,0 +1,53 @@ +package artifact_test + +import ( + "context" + "io/ioutil" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + "go.uber.org/zap/zaptest" + + "gitlab.com/slon/shad-go/distbuild/pkg/artifact" + "gitlab.com/slon/shad-go/distbuild/pkg/build" +) + +func TestArtifactTransfer(t *testing.T) { + remoteCache := newTestCache(t) + defer remoteCache.cleanup() + localCache := newTestCache(t) + defer localCache.cleanup() + + id := build.ID{0x01} + + dir, commit, _, err := remoteCache.Create(id) + require.NoError(t, err) + require.NoError(t, ioutil.WriteFile(filepath.Join(dir, "a.txt"), []byte("foobar"), 0777)) + require.NoError(t, commit()) + + l := zaptest.NewLogger(t) + + h := artifact.NewHandler(l, remoteCache.Cache) + mux := http.NewServeMux() + h.Register(mux) + + server := httptest.NewServer(mux) + defer server.Close() + + ctx := context.Background() + require.NoError(t, artifact.Download(ctx, server.URL, localCache.Cache, id)) + + dir, unlock, err := localCache.Get(id) + require.NoError(t, err) + defer unlock() + + content, err := ioutil.ReadFile(filepath.Join(dir, "a.txt")) + require.NoError(t, err) + require.Equal(t, []byte("foobar"), content) + + err = artifact.Download(ctx, server.URL, localCache.Cache, build.ID{0x02}) + require.Error(t, err) +} diff --git a/distbuild/pkg/artifact/handler.go b/distbuild/pkg/artifact/handler.go new file mode 100644 index 0000000..8997520 --- /dev/null +++ b/distbuild/pkg/artifact/handler.go @@ -0,0 +1,20 @@ +// +build !solution + +package artifact + +import ( + "net/http" + + "go.uber.org/zap" +) + +type Handler struct { +} + +func NewHandler(l *zap.Logger, c *Cache) *Handler { + panic("implement me") +} + +func (h *Handler) Register(mux *http.ServeMux) { + panic("implement me") +} diff --git a/distbuild/pkg/build/README.md b/distbuild/pkg/build/README.md new file mode 100644 index 0000000..9877a27 --- /dev/null +++ b/distbuild/pkg/build/README.md @@ -0,0 +1,4 @@ +# build + +Пакет `build` содержит описание графа сборки и набор хелпер-функций для работы с графом. Вам не нужно +писать новый код в этом пакете, но нужно научиться пользоваться тем кодом который вам дан. diff --git a/distbuild/pkg/build/cmd.go b/distbuild/pkg/build/cmd.go new file mode 100644 index 0000000..866a5c4 --- /dev/null +++ b/distbuild/pkg/build/cmd.go @@ -0,0 +1,69 @@ +package build + +import ( + "fmt" + "strings" + "text/template" +) + +type JobContext struct { + SourceDir string + OutputDir string + Deps map[ID]string +} + +// Render replaces variable references with their real value. +func (c *Cmd) Render(ctx JobContext) (*Cmd, error) { + var errs []error + + var fixedCtx struct { + SourceDir string + OutputDir string + Deps map[string]string + } + fixedCtx.SourceDir = ctx.SourceDir + fixedCtx.OutputDir = ctx.OutputDir + fixedCtx.Deps = map[string]string{} + + for k, v := range ctx.Deps { + fixedCtx.Deps[k.String()] = v + } + + render := func(str string) string { + t, err := template.New("").Parse(str) + if err != nil { + errs = append(errs, err) + return "" + } + + var b strings.Builder + if err := t.Execute(&b, fixedCtx); err != nil { + errs = append(errs, err) + return "" + } + + return b.String() + } + + renderList := func(l []string) []string { + var result []string + for _, in := range l { + result = append(result, render(in)) + } + return result + } + + var rendered Cmd + + rendered.CatOutput = render(c.CatOutput) + rendered.CatTemplate = render(c.CatTemplate) + rendered.WorkingDirectory = render(c.WorkingDirectory) + rendered.Exec = renderList(c.Exec) + rendered.Environ = renderList(c.Environ) + + if len(errs) != 0 { + return nil, fmt.Errorf("error rendering cmd: %w", errs[0]) + } + + return &rendered, nil +} diff --git a/distbuild/pkg/build/cmd_test.go b/distbuild/pkg/build/cmd_test.go new file mode 100644 index 0000000..a45ba70 --- /dev/null +++ b/distbuild/pkg/build/cmd_test.go @@ -0,0 +1,31 @@ +package build + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestCmdRender(t *testing.T) { + tmpl := Cmd{ + CatOutput: "{{.OutputDir}}/import.map", + CatTemplate: `bytes={{index .Deps "6100000000000000000000000000000000000000"}}/lib.a`, + } + + ctx := JobContext{ + OutputDir: "/distbuild/jobs/b", + Deps: map[ID]string{ + {'a'}: "/distbuild/jobs/a", + }, + } + + result, err := tmpl.Render(ctx) + require.NoError(t, err) + + expected := &Cmd{ + CatOutput: "/distbuild/jobs/b/import.map", + CatTemplate: "bytes=/distbuild/jobs/a/lib.a", + } + + require.Equal(t, expected, result) +} diff --git a/distbuild/pkg/build/graph.go b/distbuild/pkg/build/graph.go new file mode 100644 index 0000000..c598883 --- /dev/null +++ b/distbuild/pkg/build/graph.go @@ -0,0 +1,70 @@ +package build + +// Job описывает одну вершину графа сборки. +type Job struct { + // ID задаёт уникальный идентификатор джоба. + // + // ID вычисляется как хеш от всех входных файлов, команд запуска и хешей зависимых джобов. + // + // Выход джоба целиком определяется его ID. Это важное свойство позволяет кешировать + // результаты сборки. + ID ID + + // Name задаёт человекочитаемое имя джоба. + // + // Например: + // build gitlab.com/slon/disbuild/pkg/b + // vet gitlab.com/slon/disbuild/pkg/a + // test gitlab.com/slon/disbuild/pkg/test + Name string + + // Inputs задаёт список файлов из директории с исходным кодом, + // которые нужны для работы этого джоба. + // + // В типичном случае, тут будут перечислены все .go файлы одного пакета. + Inputs []string + + // Deps задаёт список джобов, выходы которых нужны для работы этого джоба. + Deps []ID + + // Cmds описывает список команд, которые нужно выполнить в рамках этого джоба. + Cmds []Cmd +} + +// Cmd описывает одну команду сборки. +// +// Есть несколько видов команд. Все виды команд описываются одной структурой. +// Реальный тип определяется тем, какие поля структуры заполнены. +// +// exec - выполняет произвольную команду +// cat - записывает строку в файл +// +// Все строки в описании команды могут содержать в себе на переменные. Перед выполнением +// реальной команды, переменные заменяются на их реальные значения. +// +// {{.OutputDir}} - абсолютный путь до выходной директории джоба. +// {{.SourceDir}} - абсолютный путь до директории с исходными файлами. +// {{index .Deps "f374b81d81f641c8c3d5d5468081ef83b2c7dae9"}} - абсолютный путь до директории, +// содержащей выход джоба с id f374b81d81f641c8c3d5d5468081ef83b2c7dae9. +type Cmd struct { + // Exec описывает команду, которую нужно выполнить. + Exec []string + + // Environ описывает переменные окружения, которые необходимы для работы команды из Exec. + Environ []string + + // WorkingDirectory задаёт рабочую директорию для команды из Exec. + WorkingDirectory string + + // CatTemplate задаёт шаблон строки, которую нужно записать в файл. + CatTemplate string + + // CatOutput задаёт выходной файл для команды типа cat. + CatOutput string +} + +type Graph struct { + SourceFiles map[ID]string + + Jobs []Job +} diff --git a/distbuild/pkg/build/id.go b/distbuild/pkg/build/id.go new file mode 100644 index 0000000..bbad3c4 --- /dev/null +++ b/distbuild/pkg/build/id.go @@ -0,0 +1,52 @@ +package build + +import ( + "crypto/rand" + "crypto/sha1" + "encoding" + "encoding/hex" + "fmt" + "path/filepath" +) + +type ID [sha1.Size]byte + +var ( + _ = encoding.TextMarshaler(ID{}) + _ = encoding.TextUnmarshaler(&ID{}) +) + +func (id ID) String() string { + return hex.EncodeToString(id[:]) +} + +func (id ID) Path() string { + return filepath.Join(hex.EncodeToString(id[:1]), hex.EncodeToString(id[:])) +} + +func (id ID) MarshalText() ([]byte, error) { + return []byte(hex.EncodeToString(id[:])), nil +} + +func (id *ID) UnmarshalText(b []byte) error { + raw, err := hex.DecodeString(string(b)) + if err != nil { + return err + } + + if len(raw) != len(id) { + return fmt.Errorf("invalid id size: %q", b) + } + + copy(id[:], raw) + return nil +} + +func NewID() ID { + var id ID + _, err := rand.Read(id[:]) + if err != nil { + panic(fmt.Sprintf("crypto/rand is unavailable: %v", err)) + } + return id +} diff --git a/distbuild/pkg/build/top_sort.go b/distbuild/pkg/build/top_sort.go new file mode 100644 index 0000000..78ddc98 --- /dev/null +++ b/distbuild/pkg/build/top_sort.go @@ -0,0 +1,31 @@ +package build + +// TopSort sorts jobs in topological order assuming dependency graph contains no cycles. +func TopSort(jobs []Job) []Job { + var sorted []Job + visited := make([]bool, len(jobs)) + + jobIDIndex := map[ID]int{} + for i, j := range jobs { + jobIDIndex[j.ID] = i + } + + var visit func(jobIndex int) + visit = func(jobIndex int) { + if visited[jobIndex] { + return + } + + visited[jobIndex] = true + for _, dep := range jobs[jobIndex].Deps { + visit(jobIDIndex[dep]) + } + sorted = append(sorted, jobs[jobIndex]) + } + + for i := range jobs { + visit(i) + } + + return sorted +} diff --git a/distbuild/pkg/build/top_sort_test.go b/distbuild/pkg/build/top_sort_test.go new file mode 100644 index 0000000..9133068 --- /dev/null +++ b/distbuild/pkg/build/top_sort_test.go @@ -0,0 +1,29 @@ +package build + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestTopSort(t *testing.T) { + jobs := []Job{ + { + ID: ID{'a'}, + Deps: []ID{{'b'}}, + }, + { + ID: ID{'b'}, + Deps: []ID{{'c'}}, + }, + { + ID: ID{'c'}, + }, + } + + sorted := TopSort(jobs) + require.Equal(t, 3, len(sorted)) + require.Equal(t, ID{'c'}, sorted[0].ID) + require.Equal(t, ID{'b'}, sorted[1].ID) + require.Equal(t, ID{'a'}, sorted[2].ID) +} diff --git a/distbuild/pkg/client/README.md b/distbuild/pkg/client/README.md new file mode 100644 index 0000000..274a5bb --- /dev/null +++ b/distbuild/pkg/client/README.md @@ -0,0 +1,12 @@ +# client + +Пакет `client` реализует клиента системы распределённой сборки. Клиент запускается локально, и имеет доступ к +директории с исходным кодом. + +Клиент получает на вход `build.Graph` и запускает сборку на координаторе. + +После того, как координатор создал новую сборку, клиент заливает недостающие файлы и посылает сигнал о завершении стадии заливки. + +После этого, клиент следит за прогрессом сборки, дожидается завершения и выходит. + +Клиент тестируется интеграционными тестами из пакета `disttest`. diff --git a/distbuild/pkg/client/build.go b/distbuild/pkg/client/build.go new file mode 100644 index 0000000..0f77a32 --- /dev/null +++ b/distbuild/pkg/client/build.go @@ -0,0 +1,34 @@ +// +build !solution + +package client + +import ( + "context" + + "go.uber.org/zap" + + "gitlab.com/slon/shad-go/distbuild/pkg/build" +) + +type Client struct { +} + +func NewClient( + l *zap.Logger, + apiEndpoint string, + sourceDir string, +) *Client { + panic("implement me") +} + +type BuildListener interface { + OnJobStdout(jobID build.ID, stdout []byte) error + OnJobStderr(jobID build.ID, stderr []byte) error + + OnJobFinished(jobID build.ID) error + OnJobFailed(jobID build.ID, code int, error string) error +} + +func (c *Client) Build(ctx context.Context, graph build.Graph, lsn BuildListener) error { + panic("implement me") +} diff --git a/distbuild/pkg/dist/README.md b/distbuild/pkg/dist/README.md new file mode 100644 index 0000000..177e370 --- /dev/null +++ b/distbuild/pkg/dist/README.md @@ -0,0 +1,5 @@ +# dist + +Пакет `dist` реализует координатора системы распределённой сборки. + +Основная функциональность воркера тестируется интеграционными тестами из пакета `disttest`. diff --git a/distbuild/pkg/dist/coordinator.go b/distbuild/pkg/dist/coordinator.go new file mode 100644 index 0000000..a49b807 --- /dev/null +++ b/distbuild/pkg/dist/coordinator.go @@ -0,0 +1,32 @@ +// +build !solution + +package dist + +import ( + "net/http" + "time" + + "go.uber.org/zap" + + "gitlab.com/slon/shad-go/distbuild/pkg/filecache" + "gitlab.com/slon/shad-go/distbuild/pkg/scheduler" +) + +type Coordinator struct { +} + +var defaultConfig = scheduler.Config{ + CacheTimeout: time.Millisecond * 10, + DepsTimeout: time.Millisecond * 100, +} + +func NewCoordinator( + log *zap.Logger, + fileCache *filecache.Cache, +) *Coordinator { + panic("implement me") +} + +func (c *Coordinator) ServeHTTP(w http.ResponseWriter, r *http.Request) { + panic("implement me") +} diff --git a/distbuild/pkg/filecache/README.md b/distbuild/pkg/filecache/README.md new file mode 100644 index 0000000..c3dfb47 --- /dev/null +++ b/distbuild/pkg/filecache/README.md @@ -0,0 +1,18 @@ +# filecache + +Пакет `filecache` занимается хранением кеша файлов и определяет протокол передачи файлов между частями системы. + +`filecache.Cache` управляет файлами и занимается контролем одновременного доступа. Вы можете реализовать этот +тип поверх `*artifact.Cache`, поведение требуется точно такое же. + +## Передача файлов + +Тип `filecache.Handler` реализует handler, позволяющий заливать и скачивать файлы из кеша. + +- Вызов `GET /file?id=123` должен возвращать содержимое файла с `id=123`. +- Вызов `PUT /file?id=123` должен заливать содержимое файла с `id=123`. + +**Обратите внимание:** Несколько клиентов могут начать заливать в кеш один и тот же набор файлов. В наивной реализации, +первый клиент залочит файл на запись, а следующие упадут с ошибкой. Ваш код должен обрабатывать эту ситуацию корректно, +то есть последующие запросы должны дожидаться, пока первый запрос завершится. Для реализации этой логики +поведения вам поможет пакет [singleflight](https://godoc.org/golang.org/x/sync/singleflight). diff --git a/distbuild/pkg/filecache/client.go b/distbuild/pkg/filecache/client.go new file mode 100644 index 0000000..497c894 --- /dev/null +++ b/distbuild/pkg/filecache/client.go @@ -0,0 +1,26 @@ +// +build !solution + +package filecache + +import ( + "context" + + "go.uber.org/zap" + + "gitlab.com/slon/shad-go/distbuild/pkg/build" +) + +type Client struct { +} + +func NewClient(l *zap.Logger, endpoint string) *Client { + panic("implement me") +} + +func (c *Client) Upload(ctx context.Context, id build.ID, localPath string) error { + panic("implement me") +} + +func (c *Client) Download(ctx context.Context, localCache *Cache, id build.ID) error { + panic("implement me") +} diff --git a/distbuild/pkg/filecache/client_test.go b/distbuild/pkg/filecache/client_test.go new file mode 100644 index 0000000..79c13c4 --- /dev/null +++ b/distbuild/pkg/filecache/client_test.go @@ -0,0 +1,143 @@ +package filecache_test + +import ( + "bytes" + "context" + "io/ioutil" + "net/http" + "net/http/httptest" + "path/filepath" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap/zaptest" + + "gitlab.com/slon/shad-go/distbuild/pkg/build" + "gitlab.com/slon/shad-go/distbuild/pkg/filecache" +) + +type env struct { + cache *testCache + server *httptest.Server + client *filecache.Client +} + +func newEnv(t *testing.T) *env { + l := zaptest.NewLogger(t) + mux := http.NewServeMux() + + cache := newCache(t) + defer func() { + if cache != nil { + cache.cleanup() + } + }() + + handler := filecache.NewHandler(l, cache.Cache) + handler.Register(mux) + + server := httptest.NewServer(mux) + + client := filecache.NewClient(l, server.URL) + + env := &env{ + cache: cache, + server: server, + client: client, + } + + cache = nil + return env +} + +func (e *env) stop() { + e.server.Close() + e.cache.cleanup() +} + +func TestFileUpload(t *testing.T) { + env := newEnv(t) + defer env.stop() + + content := bytes.Repeat([]byte("foobar"), 1024*1024) + + tmpFilePath := filepath.Join(env.cache.tmpDir, "foo.txt") + require.NoError(t, ioutil.WriteFile(tmpFilePath, content, 0666)) + + ctx := context.Background() + + t.Run("UploadSingleFile", func(t *testing.T) { + id := build.ID{0x01} + + require.NoError(t, env.client.Upload(ctx, id, tmpFilePath)) + + path, unlock, err := env.cache.Get(id) + require.NoError(t, err) + defer unlock() + + content, err := ioutil.ReadFile(path) + require.NoError(t, err) + require.Equal(t, content, content) + }) + + t.Run("RepeatedUpload", func(t *testing.T) { + id := build.ID{0x02} + + require.NoError(t, env.client.Upload(ctx, id, tmpFilePath)) + require.NoError(t, env.client.Upload(ctx, id, tmpFilePath)) + }) + + t.Run("ConcurrentUpload", func(t *testing.T) { + const ( + N = 10 + G = 10 + ) + + for i := 0; i < N; i++ { + var wg sync.WaitGroup + wg.Add(G) + + id := build.ID{0x03, byte(i)} + for j := 0; j < G; j++ { + go func() { + defer wg.Done() + + assert.NoError(t, env.client.Upload(ctx, id, tmpFilePath)) + }() + } + + wg.Wait() + } + }) +} + +func TestFileDownload(t *testing.T) { + env := newEnv(t) + defer env.stop() + + localCache := newCache(t) + defer localCache.cleanup() + + id := build.ID{0x01} + + w, abort, err := env.cache.Write(id) + require.NoError(t, err) + defer func() { _ = abort() }() + + _, err = w.Write([]byte("foobar")) + require.NoError(t, err) + require.NoError(t, w.Close()) + + ctx := context.Background() + require.NoError(t, env.client.Download(ctx, localCache.Cache, id)) + + path, unlock, err := localCache.Get(id) + require.NoError(t, err) + defer unlock() + + content, err := ioutil.ReadFile(path) + require.NoError(t, err) + require.Equal(t, []byte("foobar"), content) +} diff --git a/distbuild/pkg/filecache/filecache.go b/distbuild/pkg/filecache/filecache.go new file mode 100644 index 0000000..e5876fc --- /dev/null +++ b/distbuild/pkg/filecache/filecache.go @@ -0,0 +1,40 @@ +// +build !solution + +package filecache + +import ( + "errors" + "io" + + "gitlab.com/slon/shad-go/distbuild/pkg/build" +) + +var ( + ErrNotFound = errors.New("file not found") + ErrExists = errors.New("file exists") + ErrWriteLocked = errors.New("file is locked for write") + ErrReadLocked = errors.New("file is locked for read") +) + +type Cache struct { +} + +func New(rootDir string) (*Cache, error) { + panic("implement me") +} + +func (c *Cache) Range(fileFn func(file build.ID) error) error { + panic("implement me") +} + +func (c *Cache) Remove(file build.ID) error { + panic("implement me") +} + +func (c *Cache) Write(file build.ID) (w io.WriteCloser, abort func() error, err error) { + panic("implement me") +} + +func (c *Cache) Get(file build.ID) (path string, unlock func(), err error) { + panic("implement me") +} diff --git a/distbuild/pkg/filecache/filecache_test.go b/distbuild/pkg/filecache/filecache_test.go new file mode 100644 index 0000000..1d0d8a7 --- /dev/null +++ b/distbuild/pkg/filecache/filecache_test.go @@ -0,0 +1,58 @@ +package filecache_test + +import ( + "errors" + "io/ioutil" + "os" + "testing" + + "github.com/stretchr/testify/require" + + "gitlab.com/slon/shad-go/distbuild/pkg/build" + "gitlab.com/slon/shad-go/distbuild/pkg/filecache" +) + +type testCache struct { + *filecache.Cache + tmpDir string +} + +func newCache(t *testing.T) *testCache { + tmpDir, err := ioutil.TempDir("", "filecache") + require.NoError(t, err) + + c, err := filecache.New(tmpDir) + require.NoError(t, err) + + return &testCache{Cache: c, tmpDir: tmpDir} +} + +func (c *testCache) cleanup() { + _ = os.Remove(c.tmpDir) +} + +func TestFileCache(t *testing.T) { + cache := newCache(t) + + _, abort, err := cache.Write(build.ID{01}) + require.NoError(t, err) + require.NoError(t, abort()) + + _, _, err = cache.Get(build.ID{01}) + require.Truef(t, errors.Is(err, filecache.ErrNotFound), "%v", err) + + f, _, err := cache.Write(build.ID{02}) + require.NoError(t, err) + + _, err = f.Write([]byte("foo bar")) + require.NoError(t, err) + require.NoError(t, f.Close()) + + path, unlock, err := cache.Get(build.ID{02}) + require.NoError(t, err) + defer unlock() + + content, err := ioutil.ReadFile(path) + require.NoError(t, err) + require.Equal(t, []byte("foo bar"), content) +} diff --git a/distbuild/pkg/filecache/handler.go b/distbuild/pkg/filecache/handler.go new file mode 100644 index 0000000..514fdea --- /dev/null +++ b/distbuild/pkg/filecache/handler.go @@ -0,0 +1,20 @@ +// +build !solution + +package filecache + +import ( + "net/http" + + "go.uber.org/zap" +) + +type Handler struct { +} + +func NewHandler(l *zap.Logger, cache *Cache) *Handler { + panic("implement me") +} + +func (h *Handler) Register(mux *http.ServeMux) { + panic("implement me") +} diff --git a/distbuild/pkg/scheduler/README.md b/distbuild/pkg/scheduler/README.md new file mode 100644 index 0000000..8ffac5b --- /dev/null +++ b/distbuild/pkg/scheduler/README.md @@ -0,0 +1,41 @@ +# scheduler + +Пакет `scheduler` реализует планировщик системы. `scheduler.Scheduler` хранит полное состояние кластера +и принимает решение на каком воркере и какой джоб нужно запустить. + +Шедулер является точкой координации между воркерами и билдами. Бегущие билды обращаются к шедулеру, +передавая джобы в функцию `ScheduleJob`. Воркеры забирают джобы из шедулера вызывая функцию `PickJob`. + +Вы можете отложить реализацию полной версии шедулера на последний шаг, и реализовать упрощённую версию +на одном глобальном канале. Такой реализации будет достаточно, чтобы работали все интеграционные тесты с одним +воркером. + +## Алгоритм планирования + +Планировщик поддерживает множество очередей: + 1. Одна глобальная очередь + 2. По две локальные очереди на воркер. + +При запросе нового джоба воркер выбирает случайную джобу из трех очередей - глобальной, и двух локальных относящихся +к этому воркеру. Случайная очередь выбирается одним вызовом `select {}`. + +Ожидающий исполнения джоб всегда находится в первой локальной очереди воркеров, на которых есть +результаты работы этого джоба. + +Если джоб ждёт выполнения дольше `CacheTimeout` или если в момент `SchedulerJob` джоба не было в кеше ни на одном +из воркеров, то он попадает во все вторые локальные очереди воркеров, на которых есть хотя бы один артефакт +из множества зависимостей этого джоба. + +Определения первой и второй локальной очереди не зависят от того, в каком порядке в шедулер пришли джобы +и информация о кеше артефактов. То есть, если джоб уже находится в глобальной очереди, и в этот момент приходит +информация, что этот джоб находится в кеше на воркере `W0`, то джоб должен быть добавлен +в первую локальную очередь `W0`. + +Если джоб ждёт выполнения дольше `DepTimeout`, то он помещается в глобальную очередь. + +## Тестирование + +Вместо реального времени, юниттесты шедулера используют библиотеку `clockwork`. Это накладывает ограничения +на детали вашей реализации. Ожидание `CacheTimeout` и `DepTimeout` должно быть реализовано как `select {}` на +канале, который вернула функция `timeAfter`. Мы считаем что `CacheTimeout > DepTimeout`, и ожидание этих +таймаутов происходит последовательно в одной горутине. \ No newline at end of file diff --git a/distbuild/pkg/scheduler/export_test.go b/distbuild/pkg/scheduler/export_test.go new file mode 100644 index 0000000..9675c6f --- /dev/null +++ b/distbuild/pkg/scheduler/export_test.go @@ -0,0 +1,3 @@ +package scheduler + +var TimeAfter = &timeAfter diff --git a/distbuild/pkg/scheduler/scheduler.go b/distbuild/pkg/scheduler/scheduler.go new file mode 100644 index 0000000..9c4444d --- /dev/null +++ b/distbuild/pkg/scheduler/scheduler.go @@ -0,0 +1,53 @@ +// +build !solution + +package scheduler + +import ( + "context" + "time" + + "go.uber.org/zap" + + "gitlab.com/slon/shad-go/distbuild/pkg/api" + "gitlab.com/slon/shad-go/distbuild/pkg/build" +) + +var timeAfter = time.After + +type PendingJob struct { + Job *api.JobSpec + Finished chan struct{} + Result *api.JobResult +} + +type Config struct { + CacheTimeout time.Duration + DepsTimeout time.Duration +} + +type Scheduler struct { +} + +func NewScheduler(l *zap.Logger, config Config) *Scheduler { + panic("implement me") +} + +func (c *Scheduler) LocateArtifact(id build.ID) (api.WorkerID, bool) { + panic("implement me") +} + +func (c *Scheduler) RegisterWorker(workerID api.WorkerID) { + panic("implement me") +} + +func (c *Scheduler) OnJobComplete(workerID api.WorkerID, jobID build.ID, res *api.JobResult) bool { + panic("implement me") +} + +func (c *Scheduler) ScheduleJob(job *api.JobSpec) *PendingJob { + panic("implement me") +} + +func (c *Scheduler) PickJob(ctx context.Context, workerID api.WorkerID) *PendingJob { + panic("implement me") +} diff --git a/distbuild/pkg/scheduler/scheduler_test.go b/distbuild/pkg/scheduler/scheduler_test.go new file mode 100644 index 0000000..9ccca3b --- /dev/null +++ b/distbuild/pkg/scheduler/scheduler_test.go @@ -0,0 +1,137 @@ +package scheduler_test + +import ( + "context" + "testing" + "time" + + "github.com/jonboulle/clockwork" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/goleak" + "go.uber.org/zap/zaptest" + + "gitlab.com/slon/shad-go/distbuild/pkg/api" + "gitlab.com/slon/shad-go/distbuild/pkg/build" + "gitlab.com/slon/shad-go/distbuild/pkg/scheduler" +) + +const ( + workerID0 api.WorkerID = "w0" +) + +var ( + config = scheduler.Config{ + CacheTimeout: time.Second, + DepsTimeout: time.Minute, + } +) + +type testScheduler struct { + *scheduler.Scheduler + clockwork.FakeClock +} + +func newTestScheduler(t *testing.T) *testScheduler { + log := zaptest.NewLogger(t) + + s := &testScheduler{ + FakeClock: clockwork.NewFakeClock(), + Scheduler: scheduler.NewScheduler(log, config), + } + + *scheduler.TimeAfter = s.FakeClock.After + return s +} + +func (s *testScheduler) stop(t *testing.T) { + *scheduler.TimeAfter = time.After + goleak.VerifyNone(t) +} + +func TestScheduler_SingleJob(t *testing.T) { + s := newTestScheduler(t) + defer s.stop(t) + + job0 := &api.JobSpec{Job: build.Job{ID: build.NewID()}} + pendingJob0 := s.ScheduleJob(job0) + + s.BlockUntil(1) + s.Advance(config.DepsTimeout) // At this point job must be in global queue. + + s.RegisterWorker(workerID0) + pickerJob := s.PickJob(context.Background(), workerID0) + + require.Equal(t, pendingJob0, pickerJob) + + result := &api.JobResult{ID: job0.ID, ExitCode: 0} + s.OnJobComplete(workerID0, job0.ID, result) + + select { + case <-pendingJob0.Finished: + require.Equal(t, pendingJob0.Result, result) + + default: + t.Fatalf("job0 is not finished") + } +} + +func TestScheduler_PickJobCancelation(t *testing.T) { + s := newTestScheduler(t) + defer s.stop(t) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + s.RegisterWorker(workerID0) + require.Nil(t, s.PickJob(ctx, workerID0)) +} + +func TestScheduler_CacheLocalScheduling(t *testing.T) { + s := newTestScheduler(t) + defer s.stop(t) + + cachedJob := &api.JobSpec{Job: build.Job{ID: build.NewID()}} + uncachedJob := &api.JobSpec{Job: build.Job{ID: build.NewID()}} + + s.RegisterWorker(workerID0) + s.OnJobComplete(workerID0, cachedJob.ID, &api.JobResult{}) + + pendingUncachedJob := s.ScheduleJob(uncachedJob) + pendingCachedJob := s.ScheduleJob(cachedJob) + + s.BlockUntil(2) // both jobs should be blocked + + firstPickedJob := s.PickJob(context.Background(), workerID0) + assert.Equal(t, pendingCachedJob, firstPickedJob) + + s.Advance(config.DepsTimeout) // At this point uncachedJob is put into global queue. + + secondPickedJob := s.PickJob(context.Background(), workerID0) + assert.Equal(t, pendingUncachedJob, secondPickedJob) +} + +func TestScheduler_DependencyLocalScheduling(t *testing.T) { + s := newTestScheduler(t) + defer s.stop(t) + + job0 := &api.JobSpec{Job: build.Job{ID: build.NewID()}} + s.RegisterWorker(workerID0) + s.OnJobComplete(workerID0, job0.ID, &api.JobResult{}) + + job1 := &api.JobSpec{Job: build.Job{ID: build.NewID(), Deps: []build.ID{job0.ID}}} + job2 := &api.JobSpec{Job: build.Job{ID: build.NewID()}} + + pendingJob2 := s.ScheduleJob(job2) + pendingJob1 := s.ScheduleJob(job1) + + s.BlockUntil(2) // both jobs should be blocked on DepsTimeout + + firstPickedJob := s.PickJob(context.Background(), workerID0) + require.Equal(t, pendingJob1, firstPickedJob) + + s.Advance(config.DepsTimeout) // At this point job2 is put into global queue. + + secondPickedJob := s.PickJob(context.Background(), workerID0) + require.Equal(t, pendingJob2, secondPickedJob) +} diff --git a/distbuild/pkg/tarstream/README.md b/distbuild/pkg/tarstream/README.md new file mode 100644 index 0000000..e8f06c4 --- /dev/null +++ b/distbuild/pkg/tarstream/README.md @@ -0,0 +1,21 @@ +# tarstream + +Вам нужно уметь передавать директорию с артефактами между воркерами. Для этого, вам нужно +реализовать две операции: + +```go +package tarstream + +import "io" + +// Send рекурсивно обходит директорию и сериализует её содержимое в поток w. +func Send(dir string, w io.Writer) error + +// Receive читает поток r и материализует содержимое потока внутри dir. +func Receive(dir string, r io.Reader) error +``` + +- Функции должны корректно обрабатывать директории и обычные файлы. +- executable бит на файлах должен сохраняться. +- Используйте формат [tar](https://golang.org/pkg/archive/tar/) +- Используйте [filepath.Walk](https://golang.org/pkg/path/filepath/) для рекурсивного обхода. diff --git a/distbuild/pkg/tarstream/stream.go b/distbuild/pkg/tarstream/stream.go new file mode 100644 index 0000000..2e7be8e --- /dev/null +++ b/distbuild/pkg/tarstream/stream.go @@ -0,0 +1,15 @@ +// +build !solution + +package tarstream + +import ( + "io" +) + +func Send(dir string, w io.Writer) error { + panic("implement me") +} + +func Receive(dir string, r io.Reader) error { + panic("implement me") +} diff --git a/distbuild/pkg/tarstream/stream_test.go b/distbuild/pkg/tarstream/stream_test.go new file mode 100644 index 0000000..bd03fbb --- /dev/null +++ b/distbuild/pkg/tarstream/stream_test.go @@ -0,0 +1,60 @@ +package tarstream + +import ( + "bytes" + "io/ioutil" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestTarStream(t *testing.T) { + tmpDir, err := ioutil.TempDir("", "tarstream") + require.NoError(t, err) + + t.Logf("running inside %s", tmpDir) + + from := filepath.Join(tmpDir, "from") + to := filepath.Join(tmpDir, "to") + + require.NoError(t, os.Mkdir(from, 0777)) + require.NoError(t, os.Mkdir(to, 0777)) + + var buf bytes.Buffer + + require.NoError(t, os.Mkdir(filepath.Join(from, "a"), 0777)) + require.NoError(t, os.MkdirAll(filepath.Join(from, "b", "c", "d"), 0777)) + require.NoError(t, ioutil.WriteFile(filepath.Join(from, "a", "x.bin"), []byte("xxx"), 0777)) + require.NoError(t, ioutil.WriteFile(filepath.Join(from, "b", "c", "y.txt"), []byte("yyy"), 0666)) + + require.NoError(t, Send(from, &buf)) + + require.NoError(t, Receive(to, &buf)) + + checkDir := func(path string) { + st, err := os.Stat(path) + require.NoError(t, err) + require.True(t, st.IsDir()) + } + + checkDir(filepath.Join(to, "a")) + checkDir(filepath.Join(to, "b", "c", "d")) + + checkFile := func(path string, content []byte, mode os.FileMode) { + t.Helper() + + st, err := os.Stat(path) + require.NoError(t, err) + + require.Equal(t, mode.String(), st.Mode().String()) + + b, err := ioutil.ReadFile(path) + require.NoError(t, err) + require.Equal(t, content, b) + } + + checkFile(filepath.Join(from, "a", "x.bin"), []byte("xxx"), 0755) + checkFile(filepath.Join(from, "b", "c", "y.txt"), []byte("yyy"), 0644) +} diff --git a/distbuild/pkg/worker/README.md b/distbuild/pkg/worker/README.md new file mode 100644 index 0000000..26eeb60 --- /dev/null +++ b/distbuild/pkg/worker/README.md @@ -0,0 +1,6 @@ +# worker + +Пакет `worker` реализует воркера в системе распределённой сборки. Воркер ходит с heartbeat-ами +к координатору, получает с него джобы, выполняет их и посылает результаты назад на координатор. + +Основная функциональность воркера тестируется интеграционными тестами из пакета `disttest`. diff --git a/distbuild/pkg/worker/worker.go b/distbuild/pkg/worker/worker.go new file mode 100644 index 0000000..a76a458 --- /dev/null +++ b/distbuild/pkg/worker/worker.go @@ -0,0 +1,35 @@ +// +build !solution + +package worker + +import ( + "context" + "net/http" + + "go.uber.org/zap" + + "gitlab.com/slon/shad-go/distbuild/pkg/api" + "gitlab.com/slon/shad-go/distbuild/pkg/artifact" + "gitlab.com/slon/shad-go/distbuild/pkg/filecache" +) + +type Worker struct { +} + +func New( + workerID api.WorkerID, + coordinatorEndpoint string, + log *zap.Logger, + fileCache *filecache.Cache, + artifacts *artifact.Cache, +) *Worker { + panic("implement me") +} + +func (w *Worker) ServeHTTP(rw http.ResponseWriter, r *http.Request) { + panic("implement me") +} + +func (w *Worker) Run(ctx context.Context) error { + panic("implement me") +} diff --git a/disttest/.gitignore b/disttest/.gitignore new file mode 100644 index 0000000..6aa2721 --- /dev/null +++ b/disttest/.gitignore @@ -0,0 +1 @@ +workdir diff --git a/disttest/README.md b/disttest/README.md new file mode 100644 index 0000000..5e54685 --- /dev/null +++ b/disttest/README.md @@ -0,0 +1,19 @@ +# disttest + +Пакет `disttest` содержит интеграционные тесты. + +Тесты запускают все компоненты внутри одного процесса. Это сделано для удобства отладки. В случае +паники в любом месте, весь тест упадёт целиком. Все логи пишутся в один файл, так что всегда сразу понятен +порядок событий. А к зависшему тесту можно подключиться в отладчике прямо из goland. + +- `fixture.go` содержит код инициализации и остановки. Вам не нужно его менять. В `testdata/{{ .TestName }}` + хранится директория с исходным кодом, которую использует клиент в соответствующем тесте. `workdir/{{ .TestName }}` + сохраняет файлы после работы теста. +- `single_worker_test.go` содержит тесты с одним воркером. Каждый тест проверяет отдельную функциональность. + Отлаживайте тесты по одному, в порядке усложнения. +- `three_workers_test.go` содержит тесты с тремя воркерами. Приступайте к их отладке, после того как тесты с одним + воркером полностью пройдут. + +Все тесты останавливают окружение отменяя корневой контекст. Если ваш код где-то неправильно обрабатывает +отмену контекста, то тест может зависать на остановке. Вы можете отладить такое зависание, подключившись +к зависшему тесту в дебагере, или послав SIGQUIT зависшему процессу. diff --git a/disttest/fixture.go b/disttest/fixture.go new file mode 100644 index 0000000..4fcf0fd --- /dev/null +++ b/disttest/fixture.go @@ -0,0 +1,160 @@ +package disttest + +import ( + "context" + "errors" + "fmt" + "net" + "net/http" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + "go.uber.org/goleak" + + "gitlab.com/slon/shad-go/distbuild/pkg/api" + "gitlab.com/slon/shad-go/distbuild/pkg/artifact" + "gitlab.com/slon/shad-go/distbuild/pkg/client" + "gitlab.com/slon/shad-go/distbuild/pkg/dist" + "gitlab.com/slon/shad-go/distbuild/pkg/filecache" + "gitlab.com/slon/shad-go/distbuild/pkg/worker" + "gitlab.com/slon/shad-go/tools/testtool" + + "go.uber.org/zap" +) + +type env struct { + RootDir string + Logger *zap.Logger + + Ctx context.Context + + Client *client.Client + Coordinator *dist.Coordinator + Workers []*worker.Worker + + HTTP *http.Server +} + +const ( + logToStderr = true +) + +type Config struct { + WorkerCount int +} + +func newEnv(t *testing.T, config *Config) (e *env, cancel func()) { + cwd, err := os.Getwd() + require.NoError(t, err) + + absCWD, err := filepath.Abs(cwd) + require.NoError(t, err) + + env := &env{ + RootDir: filepath.Join(absCWD, "workdir", t.Name()), + } + + require.NoError(t, os.RemoveAll(env.RootDir)) + require.NoError(t, os.MkdirAll(env.RootDir, 0777)) + + cfg := zap.NewDevelopmentConfig() + cfg.OutputPaths = []string{filepath.Join(env.RootDir, "test.log")} + + if logToStderr { + cfg.OutputPaths = append(cfg.OutputPaths, "stderr") + } + + env.Logger, err = cfg.Build() + require.NoError(t, err) + + t.Helper() + t.Logf("test is running inside %s; see test.log file for more info", filepath.Join("workdir", t.Name())) + + port, err := testtool.GetFreePort() + require.NoError(t, err) + addr := "127.0.0.1:" + port + coordinatorEndpoint := "http://" + addr + "/coordinator" + + var cancelRootContext func() + env.Ctx, cancelRootContext = context.WithCancel(context.Background()) + + env.Client = client.NewClient( + env.Logger.Named("client"), + coordinatorEndpoint, + filepath.Join(absCWD, "testdata", t.Name())) + + coordinatorCache, err := filecache.New(filepath.Join(env.RootDir, "coordinator", "filecache")) + require.NoError(t, err) + + env.Coordinator = dist.NewCoordinator( + env.Logger.Named("coordinator"), + coordinatorCache, + ) + + router := http.NewServeMux() + router.Handle("/coordinator/", http.StripPrefix("/coordinator", env.Coordinator)) + + for i := 0; i < config.WorkerCount; i++ { + workerName := fmt.Sprintf("worker%d", i) + workerDir := filepath.Join(env.RootDir, workerName) + + var fileCache *filecache.Cache + fileCache, err = filecache.New(filepath.Join(workerDir, "filecache")) + require.NoError(t, err) + + var artifacts *artifact.Cache + artifacts, err = artifact.NewCache(filepath.Join(workerDir, "artifacts")) + require.NoError(t, err) + + workerPrefix := fmt.Sprintf("/worker/%d", i) + workerID := api.WorkerID("http://" + addr + workerPrefix) + + w := worker.New( + workerID, + coordinatorEndpoint, + env.Logger.Named(workerName), + fileCache, + artifacts, + ) + + env.Workers = append(env.Workers, w) + + router.Handle(workerPrefix+"/", http.StripPrefix(workerPrefix, w)) + } + + env.HTTP = &http.Server{ + Addr: addr, + Handler: router, + } + + lsn, err := net.Listen("tcp", env.HTTP.Addr) + require.NoError(t, err) + + go func() { + err := env.HTTP.Serve(lsn) + if err != http.ErrServerClosed { + env.Logger.Fatal("http server stopped", zap.Error(err)) + } + }() + + for _, w := range env.Workers { + go func(w *worker.Worker) { + err := w.Run(env.Ctx) + if errors.Is(err, context.Canceled) { + return + } + + env.Logger.Fatal("worker stopped", zap.Error(err)) + }(w) + } + + return env, func() { + cancelRootContext() + _ = env.HTTP.Shutdown(context.Background()) + _ = env.Logger.Sync() + + goleak.VerifyNone(t) + } +} diff --git a/disttest/recorder.go b/disttest/recorder.go new file mode 100644 index 0000000..3bef98d --- /dev/null +++ b/disttest/recorder.go @@ -0,0 +1,57 @@ +package disttest + +import ( + "gitlab.com/slon/shad-go/distbuild/pkg/build" +) + +type JobResult struct { + Stdout string + Stderr string + + Code *int + Error string +} + +type Recorder struct { + Jobs map[build.ID]*JobResult +} + +func NewRecorder() *Recorder { + return &Recorder{ + Jobs: map[build.ID]*JobResult{}, + } +} + +func (r *Recorder) job(jobID build.ID) *JobResult { + j, ok := r.Jobs[jobID] + if !ok { + j = &JobResult{} + r.Jobs[jobID] = j + } + return j +} + +func (r *Recorder) OnJobStdout(jobID build.ID, stdout []byte) error { + j := r.job(jobID) + j.Stdout += string(stdout) + return nil +} + +func (r *Recorder) OnJobStderr(jobID build.ID, stderr []byte) error { + j := r.job(jobID) + j.Stderr += string(stderr) + return nil +} + +func (r *Recorder) OnJobFinished(jobID build.ID) error { + j := r.job(jobID) + j.Code = new(int) + return nil +} + +func (r *Recorder) OnJobFailed(jobID build.ID, code int, error string) error { + j := r.job(jobID) + j.Code = &code + j.Error = error + return nil +} diff --git a/disttest/single_worker_test.go b/disttest/single_worker_test.go new file mode 100644 index 0000000..0906060 --- /dev/null +++ b/disttest/single_worker_test.go @@ -0,0 +1,136 @@ +package disttest + +import ( + "fmt" + "io/ioutil" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "gitlab.com/slon/shad-go/distbuild/pkg/build" +) + +var singleWorkerConfig = &Config{WorkerCount: 1} + +var echoGraph = build.Graph{ + Jobs: []build.Job{ + { + ID: build.ID{'a'}, + Name: "echo", + Cmds: []build.Cmd{ + {Exec: []string{"echo", "OK"}}, + }, + }, + }, +} + +func TestSingleCommand(t *testing.T) { + env, cancel := newEnv(t, singleWorkerConfig) + defer cancel() + + recorder := NewRecorder() + require.NoError(t, env.Client.Build(env.Ctx, echoGraph, recorder)) + + assert.Len(t, recorder.Jobs, 1) + assert.Equal(t, &JobResult{Stdout: "OK\n", Code: new(int)}, recorder.Jobs[build.ID{'a'}]) +} + +func TestJobCaching(t *testing.T) { + env, cancel := newEnv(t, singleWorkerConfig) + defer cancel() + + tmpFile, err := ioutil.TempFile("", "") + require.NoError(t, err) + + graph := build.Graph{ + Jobs: []build.Job{ + { + ID: build.ID{'a'}, + Name: "echo", + Cmds: []build.Cmd{ + {CatTemplate: "OK\n", CatOutput: tmpFile.Name()}, // No-hermetic, for testing purposes. + {Exec: []string{"echo", "OK"}}, + }, + }, + }, + } + + recorder := NewRecorder() + require.NoError(t, env.Client.Build(env.Ctx, graph, recorder)) + + assert.Len(t, recorder.Jobs, 1) + assert.Equal(t, &JobResult{Stdout: "OK\n", Code: new(int)}, recorder.Jobs[build.ID{'a'}]) + + // Second build must get results from cache. + require.NoError(t, env.Client.Build(env.Ctx, graph, NewRecorder())) + + require.NoError(t, ioutil.WriteFile(tmpFile.Name(), []byte("NOTOK\n"), 0666)) + + output, err := ioutil.ReadAll(tmpFile) + require.NoError(t, err) + require.Equal(t, []byte("NOTOK\n"), output) +} + +var sourceFilesGraph = build.Graph{ + SourceFiles: map[build.ID]string{ + {'a'}: "a.txt", + {'c'}: "b/c.txt", + }, + Jobs: []build.Job{ + { + ID: build.ID{'a'}, + Name: "echo", + Cmds: []build.Cmd{ + {Exec: []string{"cat", "{{.SourceDir}}/a.txt"}}, + {Exec: []string{"bash", "-c", "cat {{.SourceDir}}/b/c.txt > /dev/stderr"}}, + }, + Inputs: []string{ + "a.txt", + "b/c.txt", + }, + }, + }, +} + +func TestSourceFiles(t *testing.T) { + env, cancel := newEnv(t, singleWorkerConfig) + defer cancel() + + recorder := NewRecorder() + require.NoError(t, env.Client.Build(env.Ctx, sourceFilesGraph, recorder)) + + assert.Len(t, recorder.Jobs, 1) + assert.Equal(t, &JobResult{Stdout: "foo", Stderr: "bar", Code: new(int)}, recorder.Jobs[build.ID{'a'}]) +} + +var artifactTransferGraph = build.Graph{ + Jobs: []build.Job{ + { + ID: build.ID{'a'}, + Name: "write", + Cmds: []build.Cmd{ + {CatTemplate: "OK", CatOutput: "{{.OutputDir}}/out.txt"}, + }, + }, + { + ID: build.ID{'b'}, + Name: "cat", + Cmds: []build.Cmd{ + {Exec: []string{"cat", fmt.Sprintf("{{index .Deps %q}}/out.txt", build.ID{'a'})}}, + }, + Deps: []build.ID{{'a'}}, + }, + }, +} + +func TestArtifactTransferBetweenJobs(t *testing.T) { + env, cancel := newEnv(t, singleWorkerConfig) + defer cancel() + + recorder := NewRecorder() + require.NoError(t, env.Client.Build(env.Ctx, artifactTransferGraph, recorder)) + + assert.Len(t, recorder.Jobs, 2) + assert.Equal(t, &JobResult{Stdout: "OK", Code: new(int)}, recorder.Jobs[build.ID{'b'}]) +} diff --git a/disttest/testdata/TestSourceFiles/a.txt b/disttest/testdata/TestSourceFiles/a.txt new file mode 100644 index 0000000..1910281 --- /dev/null +++ b/disttest/testdata/TestSourceFiles/a.txt @@ -0,0 +1 @@ +foo \ No newline at end of file diff --git a/disttest/testdata/TestSourceFiles/b/c.txt b/disttest/testdata/TestSourceFiles/b/c.txt new file mode 100644 index 0000000..ba0e162 --- /dev/null +++ b/disttest/testdata/TestSourceFiles/b/c.txt @@ -0,0 +1 @@ +bar \ No newline at end of file diff --git a/disttest/three_workers_test.go b/disttest/three_workers_test.go new file mode 100644 index 0000000..82c4694 --- /dev/null +++ b/disttest/three_workers_test.go @@ -0,0 +1,64 @@ +package disttest + +import ( + "fmt" + "os" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "gitlab.com/slon/shad-go/distbuild/pkg/build" +) + +var threeWorkerConfig = &Config{WorkerCount: 3} + +func TestArtifactTransferBetweenWorkers(t *testing.T) { + env, cancel := newEnv(t, threeWorkerConfig) + defer cancel() + + baseJob := build.Job{ + ID: build.ID{'a'}, + Name: "write", + Cmds: []build.Cmd{ + {CatTemplate: "OK", CatOutput: "{{.OutputDir}}/out.txt"}, + }, + } + + var wg sync.WaitGroup + wg.Add(3) + + startTime := time.Now() + + for i := 0; i < 3; i++ { + depJobID := build.ID{'b', byte(i)} + depJob := build.Job{ + ID: depJobID, + Name: "cat", + Cmds: []build.Cmd{ + {Exec: []string{"cat", fmt.Sprintf("{{index .Deps %q}}/out.txt", build.ID{'a'})}}, + {Exec: []string{"sleep", "1"}, Environ: os.Environ()}, // DepTimeout is 100ms. + }, + Deps: []build.ID{{'a'}}, + } + + graph := build.Graph{Jobs: []build.Job{baseJob, depJob}} + go func() { + defer wg.Done() + + recorder := NewRecorder() + if !assert.NoError(t, env.Client.Build(env.Ctx, graph, recorder)) { + return + } + + assert.Len(t, recorder.Jobs, 2) + assert.Equal(t, &JobResult{Stdout: "OK", Code: new(int)}, recorder.Jobs[depJobID]) + }() + } + + wg.Wait() + + testDuration := time.Since(startTime) + assert.True(t, testDuration < time.Second*5/2, "test duration should be less than 2.5 seconds") +} diff --git a/go.mod b/go.mod index 069904d..4671a2d 100644 --- a/go.mod +++ b/go.mod @@ -10,9 +10,11 @@ require ( github.com/gorilla/handlers v1.4.2 github.com/gorilla/mux v1.7.4 github.com/gorilla/websocket v1.4.2 + github.com/jonboulle/clockwork v0.1.0 github.com/spf13/cobra v0.0.5 github.com/stretchr/testify v1.4.0 go.uber.org/goleak v1.0.0 + go.uber.org/zap v1.14.0 golang.org/x/net v0.0.0-20190628185345-da137c7871d7 golang.org/x/perf v0.0.0-20191209155426-36b577b0eb03 golang.org/x/sync v0.0.0-20190423024810-112230192c58 diff --git a/go.sum b/go.sum index e7f6555..1cedfbe 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,5 @@ cloud.google.com/go v0.0.0-20170206221025-ce650573d812/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/GoogleCloudPlatform/cloudsql-proxy v0.0.0-20190129172621-c8b1d7a94ddf/go.mod h1:aJ4qN3TfrelA6NZ6AXsXRfmEVaYin3EDbSPJrKS8OXo= github.com/aclements/go-gg v0.0.0-20170118225347-6dbb4e4fefb0/go.mod h1:55qNq4vcpkIuHowELi5C8e+1yUHtoLoOUR9QU5j7Tes= @@ -27,6 +28,7 @@ github.com/gonum/lapack v0.0.0-20181123203213-e4cdc5a0bff9/go.mod h1:XA3DeT6rxh2 github.com/gonum/matrix v0.0.0-20181209220409-c518dec07be9/go.mod h1:0EXg4mc1CNP0HCqCz+K4ts155PXIlUywf0wqN+GfPZw= github.com/google/go-cmp v0.4.0 h1:xsAVV57WRhGj6kEIi8ReJzQlHHqcBYCElAvkovg3B/4= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/googleapis/gax-go v0.0.0-20161107002406-da06d194a00e/go.mod h1:SFVmujtThgffbyetf+mdk2eWhX2bMyUtNHzFKcPA9HY= github.com/gorilla/handlers v1.4.2 h1:0QniY0USkHQ1RGCLfKxeNHK9bkDHGRYGNDFBCS+YARg= github.com/gorilla/handlers v1.4.2/go.mod h1:Qkdc/uu4tH4g6mTK6auzZ766c4CA0Ng8+o/OAirnOIQ= @@ -37,6 +39,9 @@ github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/ad github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= +github.com/jonboulle/clockwork v0.1.0 h1:VKV+ZcuP6l3yW9doeqz6ziZGgcynBVQO+obU0+0hcPo= +github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= @@ -47,8 +52,10 @@ github.com/mattn/go-sqlite3 v0.0.0-20161215041557-2d44decb4941/go.mod h1:FPy6Kqz github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= @@ -61,17 +68,28 @@ github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DM github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0= github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= +go.uber.org/atomic v1.5.0 h1:OI5t8sDa1Or+q8AeE+yKeB/SDYioSHAgcVljj9JIETY= +go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= go.uber.org/goleak v1.0.0 h1:qsup4IcBdlmsnGfqyLl4Ntn3C2XCCuKAE7DwHpScyUo= go.uber.org/goleak v1.0.0/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A= +go.uber.org/multierr v1.3.0 h1:sFPn2GLc3poCkfrpIXGhBD2X0CMIo4Q/zSULXrj/+uc= +go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4= +go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee h1:0mgffUl7nfd+FpvXMVz4IDEaUSmT1ysygQC7qYo7sG4= +go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA= +go.uber.org/zap v1.14.0 h1:/pduUoebOeeJzTDFuoMgC6nRkiasr1sBCIEorly7m4o= +go.uber.org/zap v1.14.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM= golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/lint v0.0.0-20190930215403-16217165b5de h1:5hukYrvBGR8/eNkX5mdUezrA6JiaEZDtJb9Ei+1LlBs= golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= @@ -93,6 +111,9 @@ golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fq golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200125223703-d33eef8e6825 h1:aNQeSIHKi0RWpKA5NO0CqyLjx6Beh5l0LLUEnndEjz0= golang.org/x/tools v0.0.0-20200125223703-d33eef8e6825/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= @@ -108,9 +129,12 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +honnef.co/go/tools v0.0.1-2019.2.3 h1:3JgtbtFHMiCmsznwGVTUWbgGov+pVqnlf1dEJTNAXeM= +honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=