Merge branch 'distbuild'
This commit is contained in:
commit
7784d2bc9d
55 changed files with 2492 additions and 0 deletions
|
@ -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
|
||||
|
|
145
distbuild/README.md
Normal file
145
distbuild/README.md
Normal file
|
@ -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).
|
||||
|
||||
Код тестов в этом задании менять нельзя. Это значит, что вы не можете менять интерфейсы в тех местах, где
|
||||
код покрыт тестами.
|
||||
|
||||
<details>
|
||||
<summary markdown="span">Сколько кода нужно написать?</summary>
|
||||
|
||||
```
|
||||
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
|
||||
```
|
||||
</details>
|
||||
|
||||
# Критерии оценки
|
||||
|
||||
Решение должно проходить все тесты, так же как в обычной задаче.
|
||||
|
||||
Задача разбита на две части:
|
||||
- `distbuild` проверяет решение всех "кубиков". Эта задача расчитывается как обычная семинарская.
|
||||
- `disttest` проверяет интеграционные тесты. Эта задача оценивается как домашка. После успешной попытки, в таблице gdoc
|
||||
будет стоять 0. После этого, проверяющие должны будут просмотреть решение и заменить оценку в таблице на 1.
|
||||
Это будет значить, что домашнее задание засчитано. Code Review не будет, проверка нужна только чтобы удостовериться что
|
||||
посылка честно проходит все тесты. Отдельный Merge Request создавать не нужно.
|
||||
|
||||
Чтобы запустить проверку внутри `disttest`, сделайте коммит добавляющий незначащий перенос строки в какой-нибудь файл
|
||||
из этой директории.
|
32
distbuild/pkg/api/README.md
Normal file
32
distbuild/pkg/api/README.md
Normal file
|
@ -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`. Запишите в лог события
|
||||
получения/отправки запроса и все ошибки. Это поможет вам отлаживать интеграционные тесты
|
||||
в следующей части задания.
|
53
distbuild/pkg/api/build.go
Normal file
53
distbuild/pkg/api/build.go
Normal file
|
@ -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)
|
||||
}
|
26
distbuild/pkg/api/build_client.go
Normal file
26
distbuild/pkg/api/build_client.go
Normal file
|
@ -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")
|
||||
}
|
20
distbuild/pkg/api/build_handler.go
Normal file
20
distbuild/pkg/api/build_handler.go
Normal file
|
@ -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")
|
||||
}
|
159
distbuild/pkg/api/build_test.go
Normal file
159
distbuild/pkg/api/build_test.go
Normal file
|
@ -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)
|
||||
}
|
69
distbuild/pkg/api/heartbeat.go
Normal file
69
distbuild/pkg/api/heartbeat.go
Normal file
|
@ -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)
|
||||
}
|
20
distbuild/pkg/api/heartbeat_client.go
Normal file
20
distbuild/pkg/api/heartbeat_client.go
Normal file
|
@ -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")
|
||||
}
|
20
distbuild/pkg/api/heartbeat_handler.go
Normal file
20
distbuild/pkg/api/heartbeat_handler.go
Normal file
|
@ -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")
|
||||
}
|
56
distbuild/pkg/api/heartbeat_test.go
Normal file
56
distbuild/pkg/api/heartbeat_test.go
Normal file
|
@ -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")
|
||||
}
|
50
distbuild/pkg/api/mock/heartbeat.go
Normal file
50
distbuild/pkg/api/mock/heartbeat.go
Normal file
|
@ -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)
|
||||
}
|
65
distbuild/pkg/api/mock/mock.go
Normal file
65
distbuild/pkg/api/mock/mock.go
Normal file
|
@ -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)
|
||||
}
|
27
distbuild/pkg/artifact/README.md
Normal file
27
distbuild/pkg/artifact/README.md
Normal file
|
@ -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`. Запишите в этот логгер интересные события,
|
||||
это поможет при отладке в следующих частях задачи.
|
39
distbuild/pkg/artifact/cache.go
Normal file
39
distbuild/pkg/artifact/cache.go
Normal file
|
@ -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")
|
||||
}
|
100
distbuild/pkg/artifact/cache_test.go
Normal file
100
distbuild/pkg/artifact/cache_test.go
Normal file
|
@ -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)
|
||||
}
|
14
distbuild/pkg/artifact/client.go
Normal file
14
distbuild/pkg/artifact/client.go
Normal file
|
@ -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")
|
||||
}
|
53
distbuild/pkg/artifact/client_test.go
Normal file
53
distbuild/pkg/artifact/client_test.go
Normal file
|
@ -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)
|
||||
}
|
20
distbuild/pkg/artifact/handler.go
Normal file
20
distbuild/pkg/artifact/handler.go
Normal file
|
@ -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")
|
||||
}
|
4
distbuild/pkg/build/README.md
Normal file
4
distbuild/pkg/build/README.md
Normal file
|
@ -0,0 +1,4 @@
|
|||
# build
|
||||
|
||||
Пакет `build` содержит описание графа сборки и набор хелпер-функций для работы с графом. Вам не нужно
|
||||
писать новый код в этом пакете, но нужно научиться пользоваться тем кодом который вам дан.
|
69
distbuild/pkg/build/cmd.go
Normal file
69
distbuild/pkg/build/cmd.go
Normal file
|
@ -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
|
||||
}
|
31
distbuild/pkg/build/cmd_test.go
Normal file
31
distbuild/pkg/build/cmd_test.go
Normal file
|
@ -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)
|
||||
}
|
70
distbuild/pkg/build/graph.go
Normal file
70
distbuild/pkg/build/graph.go
Normal file
|
@ -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
|
||||
}
|
52
distbuild/pkg/build/id.go
Normal file
52
distbuild/pkg/build/id.go
Normal file
|
@ -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
|
||||
}
|
31
distbuild/pkg/build/top_sort.go
Normal file
31
distbuild/pkg/build/top_sort.go
Normal file
|
@ -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
|
||||
}
|
29
distbuild/pkg/build/top_sort_test.go
Normal file
29
distbuild/pkg/build/top_sort_test.go
Normal file
|
@ -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)
|
||||
}
|
12
distbuild/pkg/client/README.md
Normal file
12
distbuild/pkg/client/README.md
Normal file
|
@ -0,0 +1,12 @@
|
|||
# client
|
||||
|
||||
Пакет `client` реализует клиента системы распределённой сборки. Клиент запускается локально, и имеет доступ к
|
||||
директории с исходным кодом.
|
||||
|
||||
Клиент получает на вход `build.Graph` и запускает сборку на координаторе.
|
||||
|
||||
После того, как координатор создал новую сборку, клиент заливает недостающие файлы и посылает сигнал о завершении стадии заливки.
|
||||
|
||||
После этого, клиент следит за прогрессом сборки, дожидается завершения и выходит.
|
||||
|
||||
Клиент тестируется интеграционными тестами из пакета `disttest`.
|
34
distbuild/pkg/client/build.go
Normal file
34
distbuild/pkg/client/build.go
Normal file
|
@ -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")
|
||||
}
|
5
distbuild/pkg/dist/README.md
vendored
Normal file
5
distbuild/pkg/dist/README.md
vendored
Normal file
|
@ -0,0 +1,5 @@
|
|||
# dist
|
||||
|
||||
Пакет `dist` реализует координатора системы распределённой сборки.
|
||||
|
||||
Основная функциональность воркера тестируется интеграционными тестами из пакета `disttest`.
|
32
distbuild/pkg/dist/coordinator.go
vendored
Normal file
32
distbuild/pkg/dist/coordinator.go
vendored
Normal file
|
@ -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")
|
||||
}
|
18
distbuild/pkg/filecache/README.md
Normal file
18
distbuild/pkg/filecache/README.md
Normal file
|
@ -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).
|
26
distbuild/pkg/filecache/client.go
Normal file
26
distbuild/pkg/filecache/client.go
Normal file
|
@ -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")
|
||||
}
|
143
distbuild/pkg/filecache/client_test.go
Normal file
143
distbuild/pkg/filecache/client_test.go
Normal file
|
@ -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)
|
||||
}
|
40
distbuild/pkg/filecache/filecache.go
Normal file
40
distbuild/pkg/filecache/filecache.go
Normal file
|
@ -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")
|
||||
}
|
58
distbuild/pkg/filecache/filecache_test.go
Normal file
58
distbuild/pkg/filecache/filecache_test.go
Normal file
|
@ -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)
|
||||
}
|
20
distbuild/pkg/filecache/handler.go
Normal file
20
distbuild/pkg/filecache/handler.go
Normal file
|
@ -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")
|
||||
}
|
41
distbuild/pkg/scheduler/README.md
Normal file
41
distbuild/pkg/scheduler/README.md
Normal file
|
@ -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`, и ожидание этих
|
||||
таймаутов происходит последовательно в одной горутине.
|
3
distbuild/pkg/scheduler/export_test.go
Normal file
3
distbuild/pkg/scheduler/export_test.go
Normal file
|
@ -0,0 +1,3 @@
|
|||
package scheduler
|
||||
|
||||
var TimeAfter = &timeAfter
|
53
distbuild/pkg/scheduler/scheduler.go
Normal file
53
distbuild/pkg/scheduler/scheduler.go
Normal file
|
@ -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")
|
||||
}
|
137
distbuild/pkg/scheduler/scheduler_test.go
Normal file
137
distbuild/pkg/scheduler/scheduler_test.go
Normal file
|
@ -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)
|
||||
}
|
21
distbuild/pkg/tarstream/README.md
Normal file
21
distbuild/pkg/tarstream/README.md
Normal file
|
@ -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/) для рекурсивного обхода.
|
15
distbuild/pkg/tarstream/stream.go
Normal file
15
distbuild/pkg/tarstream/stream.go
Normal file
|
@ -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")
|
||||
}
|
60
distbuild/pkg/tarstream/stream_test.go
Normal file
60
distbuild/pkg/tarstream/stream_test.go
Normal file
|
@ -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)
|
||||
}
|
6
distbuild/pkg/worker/README.md
Normal file
6
distbuild/pkg/worker/README.md
Normal file
|
@ -0,0 +1,6 @@
|
|||
# worker
|
||||
|
||||
Пакет `worker` реализует воркера в системе распределённой сборки. Воркер ходит с heartbeat-ами
|
||||
к координатору, получает с него джобы, выполняет их и посылает результаты назад на координатор.
|
||||
|
||||
Основная функциональность воркера тестируется интеграционными тестами из пакета `disttest`.
|
35
distbuild/pkg/worker/worker.go
Normal file
35
distbuild/pkg/worker/worker.go
Normal file
|
@ -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")
|
||||
}
|
1
disttest/.gitignore
vendored
Normal file
1
disttest/.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
workdir
|
19
disttest/README.md
Normal file
19
disttest/README.md
Normal file
|
@ -0,0 +1,19 @@
|
|||
# disttest
|
||||
|
||||
Пакет `disttest` содержит интеграционные тесты.
|
||||
|
||||
Тесты запускают все компоненты внутри одного процесса. Это сделано для удобства отладки. В случае
|
||||
паники в любом месте, весь тест упадёт целиком. Все логи пишутся в один файл, так что всегда сразу понятен
|
||||
порядок событий. А к зависшему тесту можно подключиться в отладчике прямо из goland.
|
||||
|
||||
- `fixture.go` содержит код инициализации и остановки. Вам не нужно его менять. В `testdata/{{ .TestName }}`
|
||||
хранится директория с исходным кодом, которую использует клиент в соответствующем тесте. `workdir/{{ .TestName }}`
|
||||
сохраняет файлы после работы теста.
|
||||
- `single_worker_test.go` содержит тесты с одним воркером. Каждый тест проверяет отдельную функциональность.
|
||||
Отлаживайте тесты по одному, в порядке усложнения.
|
||||
- `three_workers_test.go` содержит тесты с тремя воркерами. Приступайте к их отладке, после того как тесты с одним
|
||||
воркером полностью пройдут.
|
||||
|
||||
Все тесты останавливают окружение отменяя корневой контекст. Если ваш код где-то неправильно обрабатывает
|
||||
отмену контекста, то тест может зависать на остановке. Вы можете отладить такое зависание, подключившись
|
||||
к зависшему тесту в дебагере, или послав SIGQUIT зависшему процессу.
|
160
disttest/fixture.go
Normal file
160
disttest/fixture.go
Normal file
|
@ -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)
|
||||
}
|
||||
}
|
57
disttest/recorder.go
Normal file
57
disttest/recorder.go
Normal file
|
@ -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
|
||||
}
|
136
disttest/single_worker_test.go
Normal file
136
disttest/single_worker_test.go
Normal file
|
@ -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'}])
|
||||
}
|
1
disttest/testdata/TestSourceFiles/a.txt
vendored
Normal file
1
disttest/testdata/TestSourceFiles/a.txt
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
foo
|
1
disttest/testdata/TestSourceFiles/b/c.txt
vendored
Normal file
1
disttest/testdata/TestSourceFiles/b/c.txt
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
bar
|
64
disttest/three_workers_test.go
Normal file
64
disttest/three_workers_test.go
Normal file
|
@ -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")
|
||||
}
|
2
go.mod
2
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
|
||||
|
|
24
go.sum
24
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=
|
||||
|
|
Loading…
Reference in a new issue