diff --git a/.gitignore b/.gitignore index fb75cd87..7ecc2efb 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,6 @@ vendor # app /config.yml release-notes.txt -duci +/duci duci.exe dist \ No newline at end of file diff --git a/application/cmd/health.go b/application/cmd/health.go deleted file mode 100644 index 443bd281..00000000 --- a/application/cmd/health.go +++ /dev/null @@ -1,29 +0,0 @@ -package cmd - -import ( - "github.com/duck8823/duci/application/service/docker" - "github.com/duck8823/duci/infrastructure/logger" - "github.com/google/uuid" - "github.com/spf13/cobra" - "os" -) - -var healthCmd = createCmd("health", "Health check", healthCheck) - -func healthCheck(cmd *cobra.Command, _ []string) { - readConfiguration(cmd) - - dockerService, err := docker.New() - if err != nil { - logger.Errorf(uuid.New(), "Failed to set configuration.\n%+v", err) - os.Exit(1) - } - - if err := dockerService.Status(); err != nil { - logger.Errorf(uuid.New(), "Unhealthy.\n%s", err) - os.Exit(1) - } else { - logger.Info(uuid.New(), "ok.") - os.Exit(0) - } -} diff --git a/application/config.go b/application/config.go index 4cf98726..edbbf044 100644 --- a/application/config.go +++ b/application/config.go @@ -31,6 +31,11 @@ func (s maskString) MarshalJSON() ([]byte, error) { return []byte(`"***"`), nil } +// ToString returns unmasked string. +func (s maskString) String() string { + return string(s) +} + // Configuration of application. type Configuration struct { Server *Server `yaml:"server" json:"server"` diff --git a/application/config_test.go b/application/config_test.go index 1740a4da..2b2366f7 100644 --- a/application/config_test.go +++ b/application/config_test.go @@ -56,7 +56,7 @@ func TestConfiguration_Set(t *testing.T) { expected := "hello world" // and - os.Setenv("TEST_CONF_ENV", expected) + _ = os.Setenv("TEST_CONF_ENV", expected) // err := application.Config.Set("testdata/config_with_env.yml") @@ -131,3 +131,19 @@ func TestMaskString_MarshalJSON(t *testing.T) { t.Errorf("wont masked string, but got '%s'", actual) } } + +func TestMaskString_String(t *testing.T) { + // given + want := "hoge" + + // and + sut := application.MaskString(want) + + // when + got := sut.String() + + // then + if !cmp.Equal(got, want) { + t.Errorf("must be equal, but %+v", cmp.Diff(got, want)) + } +} diff --git a/application/context.go b/application/context.go new file mode 100644 index 00000000..ea2b7165 --- /dev/null +++ b/application/context.go @@ -0,0 +1,37 @@ +package application + +import ( + "context" + "fmt" + "github.com/duck8823/duci/domain/model/job" + "github.com/duck8823/duci/domain/model/job/target/github" + "net/url" +) + +var ctxKey = "duci_job" + +// BuildJob represents once of job +type BuildJob struct { + ID job.ID + TargetSource *github.TargetSource + TaskName string + TargetURL *url.URL +} + +// ContextWithJob set parent context BuildJob and returns it. +func ContextWithJob(parent context.Context, job *BuildJob) context.Context { + return context.WithValue(parent, &ctxKey, job) +} + +// BuildJobFromContext extract BuildJob from context +func BuildJobFromContext(ctx context.Context) (*BuildJob, error) { + val := ctx.Value(&ctxKey) + if val == nil { + return nil, fmt.Errorf("context value '%s' should not be null", ctxKey) + } + buildJob, ok := val.(*BuildJob) + if !ok { + return nil, fmt.Errorf("invalid type in context '%s'", ctxKey) + } + return buildJob, nil +} diff --git a/application/context/context.go b/application/context/context.go deleted file mode 100644 index c17395a0..00000000 --- a/application/context/context.go +++ /dev/null @@ -1,59 +0,0 @@ -package context - -import ( - "context" - "github.com/google/uuid" - "net/url" - "time" -) - -// Context is a context with UUID, TaskName and URL. -type Context interface { - context.Context - UUID() uuid.UUID - TaskName() string - URL() *url.URL -} - -type jobContext struct { - context.Context - uuid uuid.UUID - taskName string - url *url.URL -} - -// New returns a new jobContext. -func New(taskName string, id uuid.UUID, url *url.URL) Context { - return &jobContext{ - Context: context.Background(), - uuid: id, - taskName: taskName, - url: url, - } -} - -// UUID returns UUID from jobContext. -func (c *jobContext) UUID() uuid.UUID { - return c.uuid -} - -// TaskName returns URL from jobContext. -func (c *jobContext) TaskName() string { - return c.taskName -} - -// URL returns task name from jobContext. -func (c *jobContext) URL() *url.URL { - return c.url -} - -// WithTimeout returns copy of parent with timeout and CancelFunc. -func WithTimeout(parent Context, timeout time.Duration) (Context, context.CancelFunc) { - ctx, cancel := context.WithTimeout(parent, timeout) - return &jobContext{ - Context: ctx, - uuid: parent.UUID(), - taskName: parent.TaskName(), - url: parent.URL(), - }, cancel -} diff --git a/application/context/context_test.go b/application/context/context_test.go deleted file mode 100644 index d9163a0a..00000000 --- a/application/context/context_test.go +++ /dev/null @@ -1,58 +0,0 @@ -package context_test - -import ( - ct "context" - "github.com/duck8823/duci/application/context" - "github.com/google/uuid" - "net/url" - "testing" - "time" -) - -func TestContextWithUUID_UUID(t *testing.T) { - // given - ctx := context.New("test/task", uuid.New(), &url.URL{}) - var empty uuid.UUID - - // expect - if ctx.UUID() == empty { - t.Error("UUID() must not empty.") - } -} - -func TestWithTimeout(t *testing.T) { - t.Run("when timeout", func(t *testing.T) { - // when - ctx, cancel := context.WithTimeout(context.New("test/task", uuid.New(), &url.URL{}), 5*time.Millisecond) - defer cancel() - - go func() { - time.Sleep(30 * time.Millisecond) - cancel() - }() - - <-ctx.Done() - - // then - if ctx.Err() != ct.DeadlineExceeded { - t.Errorf("not expected error. wont: %+v, but got %+v", ct.DeadlineExceeded, ctx.Err()) - } - }) - - t.Run("when cancel", func(t *testing.T) { - // when - ctx, cancel := context.WithTimeout(context.New("test/task", uuid.New(), &url.URL{}), 5*time.Millisecond) - defer cancel() - - go func() { - cancel() - }() - - <-ctx.Done() - - // then - if ctx.Err() != ct.Canceled { - t.Errorf("not expected error. wont: %+v, but got %+v", ct.Canceled, ctx.Err()) - } - }) -} diff --git a/application/context_test.go b/application/context_test.go new file mode 100644 index 00000000..2ed3fd9d --- /dev/null +++ b/application/context_test.go @@ -0,0 +1,70 @@ +package application_test + +import ( + "context" + "github.com/duck8823/duci/application" + "github.com/duck8823/duci/domain/model/job" + "github.com/google/go-cmp/cmp" + "github.com/google/uuid" + "testing" +) + +func TestContextWithJob(t *testing.T) { + // given + want := &application.BuildJob{ + ID: job.ID(uuid.New()), + } + + // and + ctx := application.ContextWithJob(context.Background(), want) + + // when + got := ctx.Value(application.GetCtxKey()) + + // then + if !cmp.Equal(got, want) { + t.Errorf("must be equal, but %+v", cmp.Diff(got, want)) + } +} + +func TestBuildJobFromContext(t *testing.T) { + t.Run("with value", func(t *testing.T) { + // given + want := &application.BuildJob{ + ID: job.ID(uuid.New()), + } + + sut := context.WithValue(context.Background(), application.GetCtxKey(), want) + + // when + got, err := application.BuildJobFromContext(sut) + + // then + if err != nil { + t.Errorf("error must be nil, but got %+v", err) + } + + // and + if !cmp.Equal(got, want) { + t.Errorf("must be equal, but %+v", cmp.Diff(got, want)) + } + }) + + t.Run("without value", func(t *testing.T) { + sut := context.Background() + + // when + got, err := application.BuildJobFromContext(sut) + + // then + if err == nil { + t.Error("error must not be nil") + } + + // and + if got != nil { + t.Errorf("must be nil, but got %+v", got) + } + }) + +} diff --git a/application/duci/duci.go b/application/duci/duci.go new file mode 100644 index 00000000..7d5457c9 --- /dev/null +++ b/application/duci/duci.go @@ -0,0 +1,136 @@ +package duci + +import ( + "context" + "fmt" + "github.com/duck8823/duci/application" + "github.com/duck8823/duci/application/service/executor" + jobService "github.com/duck8823/duci/application/service/job" + "github.com/duck8823/duci/domain/model/job" + "github.com/duck8823/duci/domain/model/job/target/github" + "github.com/duck8823/duci/domain/model/runner" + "github.com/duck8823/duci/internal/logger" + "github.com/pkg/errors" + "time" +) + +type duci struct { + executor.Executor + jobService jobService.Service + github github.GitHub +} + +// New returns duci instance +func New() (executor.Executor, error) { + jobService, err := jobService.GetInstance() + if err != nil { + return nil, errors.WithStack(err) + } + github, err := github.GetInstance() + if err != nil { + return nil, errors.WithStack(err) + } + builder, err := executor.DefaultExecutorBuilder() + if err != nil { + return nil, errors.WithStack(err) + } + + duci := &duci{ + jobService: jobService, + github: github, + } + duci.Executor = builder. + StartFunc(duci.Start). + EndFunc(duci.End). + LogFunc(duci.AppendLog). + Build() + + return duci, nil +} + +// Start represents a function of start job +func (d *duci) Start(ctx context.Context) { + buildJob, err := application.BuildJobFromContext(ctx) + if err != nil { + // TODO: output error message + return + } + if err := d.jobService.Start(buildJob.ID); err != nil { + if err := d.jobService.Append(buildJob.ID, job.LogLine{Timestamp: time.Now(), Message: err.Error()}); err != nil { + logger.Error(err) + } + return + } + if err := d.github.CreateCommitStatus(ctx, github.CommitStatus{ + TargetSource: buildJob.TargetSource, + State: github.PENDING, + Description: "pending", + Context: buildJob.TaskName, + TargetURL: buildJob.TargetURL, + }); err != nil { + logger.Error(err) + } +} + +// AppendLog is a function that print and store log +func (d *duci) AppendLog(ctx context.Context, log job.Log) { + buildJob, err := application.BuildJobFromContext(ctx) + if err != nil { + // TODO: output error message + return + } + for line, err := log.ReadLine(); err == nil; line, err = log.ReadLine() { + println(line.Message) + if err := d.jobService.Append(buildJob.ID, *line); err != nil { + logger.Error(err) + } + } +} + +// End represents a function +func (d *duci) End(ctx context.Context, e error) { + buildJob, err := application.BuildJobFromContext(ctx) + if err != nil { + // TODO: output error message + return + } + if err := d.jobService.Finish(buildJob.ID); err != nil { + if err := d.jobService.Append(buildJob.ID, job.LogLine{Timestamp: time.Now(), Message: err.Error()}); err != nil { + logger.Error(err) + } + return + } + + switch e { + case nil: + if err := d.github.CreateCommitStatus(ctx, github.CommitStatus{ + TargetSource: buildJob.TargetSource, + State: github.SUCCESS, + Description: "success", + Context: buildJob.TaskName, + TargetURL: buildJob.TargetURL, + }); err != nil { + logger.Error(err) + } + case runner.ErrFailure: + if err := d.github.CreateCommitStatus(ctx, github.CommitStatus{ + TargetSource: buildJob.TargetSource, + State: github.FAILURE, + Description: "failure", + Context: buildJob.TaskName, + TargetURL: buildJob.TargetURL, + }); err != nil { + logger.Error(err) + } + default: + if err := d.github.CreateCommitStatus(ctx, github.CommitStatus{ + TargetSource: buildJob.TargetSource, + State: github.ERROR, + Description: github.Description(fmt.Sprintf("error: %s", e.Error())), + Context: buildJob.TaskName, + TargetURL: buildJob.TargetURL, + }); err != nil { + logger.Error(err) + } + } +} diff --git a/application/duci/duci_test.go b/application/duci/duci_test.go new file mode 100644 index 00000000..7545fded --- /dev/null +++ b/application/duci/duci_test.go @@ -0,0 +1,462 @@ +package duci_test + +import ( + "context" + "errors" + "github.com/duck8823/duci/application" + "github.com/duck8823/duci/application/duci" + jobService "github.com/duck8823/duci/application/service/job" + "github.com/duck8823/duci/application/service/job/mock_job" + "github.com/duck8823/duci/domain/model/job" + "github.com/duck8823/duci/domain/model/job/target/github" + "github.com/duck8823/duci/domain/model/job/target/github/mock_github" + "github.com/duck8823/duci/domain/model/runner" + "github.com/duck8823/duci/internal/container" + "github.com/golang/mock/gomock" + "github.com/google/uuid" + "net/url" + "testing" +) + +func TestNew(t *testing.T) { + t.Run("when there are instances in container", func(t *testing.T) { + // given + container.Override(new(jobService.Service)) + container.Override(new(github.GitHub)) + defer container.Clear() + + // when + got, err := duci.New() + + // then + if err != nil { + t.Errorf("error must be nil, but got %+v", err) + } + + // and + if got == nil { + t.Errorf("duci must not be nil") + } + }) + + t.Run("when instance not enough in container", func(t *testing.T) { + // where + for _, tt := range []struct { + name string + in []interface{} + }{ + { + name: "with only job_service.Service instance", + in: []interface{}{new(jobService.Service)}, + }, + { + name: "with only github.GitHub instance", + in: []interface{}{new(github.GitHub)}, + }, + } { + t.Run(tt.name, func(t *testing.T) { + // given + container.Clear() + + for _, ins := range tt.in { + container.Override(ins) + } + defer container.Clear() + + // when + got, err := duci.New() + + // then + if err == nil { + t.Error("error must not be nil") + } + + // and + if got != nil { + t.Errorf("duci must be nil, but got %+v", got) + } + }) + } + }) +} + +func TestDuci_Start(t *testing.T) { + t.Run("with no error", func(t *testing.T) { + // given + buildJob := &application.BuildJob{ + ID: job.ID(uuid.New()), + TargetSource: &github.TargetSource{}, + TaskName: "task/name", + TargetURL: duci.URLMust(url.Parse("http://example.com")), + } + ctx := application.ContextWithJob(context.Background(), buildJob) + + // and + ctrl := gomock.NewController(t) + + service := mock_job_service.NewMockService(ctrl) + service.EXPECT(). + Start(gomock.Eq(buildJob.ID)). + Times(1). + Return(nil) + + hub := mock_github.NewMockGitHub(ctrl) + hub.EXPECT(). + CreateCommitStatus(gomock.Eq(ctx), gomock.Eq(github.CommitStatus{ + TargetSource: buildJob.TargetSource, + State: github.PENDING, + Description: "pending", + Context: buildJob.TaskName, + TargetURL: buildJob.TargetURL, + })). + Times(1). + Return(nil) + + // and + sut := &duci.Duci{} + defer sut.SetJobService(service)() + defer sut.SetGitHub(hub)() + + // when + sut.Start(ctx) + + // then + ctrl.Finish() + }) + + t.Run("when invalid build job value", func(t *testing.T) { + // given + ctx := context.WithValue(context.Background(), duci.String("duci_job"), "invalid value") + + // and + ctrl := gomock.NewController(t) + + service := mock_job_service.NewMockService(ctrl) + service.EXPECT(). + Start(gomock.Any()). + Times(0) + service.EXPECT(). + Append(gomock.Any(), gomock.Any()). + Times(0) + + hub := mock_github.NewMockGitHub(ctrl) + hub.EXPECT(). + CreateCommitStatus(gomock.Any(), gomock.Any()). + Times(0) + + // and + sut := &duci.Duci{} + defer sut.SetJobService(service)() + defer sut.SetGitHub(hub)() + + // when + sut.Start(ctx) + + // then + ctrl.Finish() + }) + + t.Run("when failed to job_service.Service#Start", func(t *testing.T) { + // given + buildJob := &application.BuildJob{ + ID: job.ID(uuid.New()), + TargetSource: &github.TargetSource{}, + TaskName: "task/name", + TargetURL: duci.URLMust(url.Parse("http://example.com")), + } + ctx := application.ContextWithJob(context.Background(), buildJob) + + // and + ctrl := gomock.NewController(t) + + service := mock_job_service.NewMockService(ctrl) + service.EXPECT(). + Start(gomock.Any()). + Times(1). + Return(errors.New("test error")) + service.EXPECT(). + Append(gomock.Any(), gomock.Any()). + Times(1). + Return(nil) + + hub := mock_github.NewMockGitHub(ctrl) + hub.EXPECT(). + CreateCommitStatus(gomock.Any(), gomock.Any()). + Times(0) + + // and + sut := &duci.Duci{} + defer sut.SetJobService(service)() + defer sut.SetGitHub(hub)() + + // when + sut.Start(ctx) + + // then + ctrl.Finish() + }) +} + +func TestDuci_AppendLog(t *testing.T) { + t.Run("with no error", func(t *testing.T) { + // given + buildJob := &application.BuildJob{ + ID: job.ID(uuid.New()), + TargetSource: &github.TargetSource{}, + TaskName: "task/name", + TargetURL: duci.URLMust(url.Parse("http://example.com")), + } + ctx := application.ContextWithJob(context.Background(), buildJob) + + log := &duci.MockLog{Msgs: []string{"Hello", "World"}} + + // and + ctrl := gomock.NewController(t) + service := mock_job_service.NewMockService(ctrl) + service.EXPECT(). + Append(gomock.Eq(buildJob.ID), gomock.Any()). + Times(len(log.Msgs)). + Return(nil) + + // and + sut := &duci.Duci{} + defer sut.SetJobService(service)() + + // when + sut.AppendLog(ctx, log) + + // then + ctrl.Finish() + }) + + t.Run("when invalid build job value", func(t *testing.T) { + // given + ctx := context.WithValue(context.Background(), duci.String("duci_job"), "invalid value") + log := &duci.MockLog{Msgs: []string{"Hello", "World"}} + + // and + ctrl := gomock.NewController(t) + service := mock_job_service.NewMockService(ctrl) + service.EXPECT(). + Append(gomock.Any(), gomock.Any()). + Times(0) + + // and + sut := &duci.Duci{} + defer sut.SetJobService(service)() + + // when + sut.AppendLog(ctx, log) + + // then + ctrl.Finish() + }) +} + +func TestDuci_End(t *testing.T) { + t.Run("when error is nil", func(t *testing.T) { + // given + buildJob := &application.BuildJob{ + ID: job.ID(uuid.New()), + TargetSource: &github.TargetSource{}, + TaskName: "task/name", + TargetURL: duci.URLMust(url.Parse("http://example.com")), + } + ctx := application.ContextWithJob(context.Background(), buildJob) + var err error = nil + + // and + want := github.CommitStatus{ + TargetSource: buildJob.TargetSource, + State: github.SUCCESS, + Description: "success", + Context: buildJob.TaskName, + TargetURL: buildJob.TargetURL, + } + + // and + ctrl := gomock.NewController(t) + + service := mock_job_service.NewMockService(ctrl) + service.EXPECT(). + Finish(gomock.Any()). + Times(1). + Return(nil) + hub := mock_github.NewMockGitHub(ctrl) + hub.EXPECT(). + CreateCommitStatus(gomock.Eq(ctx), gomock.Eq(want)). + Times(1). + Return(nil) + + // and + sut := &duci.Duci{} + defer sut.SetJobService(service)() + defer sut.SetGitHub(hub)() + + // when + sut.End(ctx, err) + + // then + ctrl.Finish() + }) + + t.Run("when error is runner.Failure", func(t *testing.T) { + // given + buildJob := &application.BuildJob{ + ID: job.ID(uuid.New()), + TargetSource: &github.TargetSource{}, + TaskName: "task/name", + TargetURL: duci.URLMust(url.Parse("http://example.com")), + } + ctx := application.ContextWithJob(context.Background(), buildJob) + err := runner.ErrFailure + + // and + want := github.CommitStatus{ + TargetSource: buildJob.TargetSource, + State: github.FAILURE, + Description: "failure", + Context: buildJob.TaskName, + TargetURL: buildJob.TargetURL, + } + + // and + ctrl := gomock.NewController(t) + + service := mock_job_service.NewMockService(ctrl) + service.EXPECT(). + Finish(gomock.Any()). + Times(1). + Return(nil) + hub := mock_github.NewMockGitHub(ctrl) + hub.EXPECT(). + CreateCommitStatus(gomock.Eq(ctx), gomock.Eq(want)). + Times(1). + Return(nil) + + // and + sut := &duci.Duci{} + defer sut.SetJobService(service)() + defer sut.SetGitHub(hub)() + + // when + sut.End(ctx, err) + + // then + ctrl.Finish() + }) + + t.Run("when error is not nil", func(t *testing.T) { + // given + buildJob := &application.BuildJob{ + ID: job.ID(uuid.New()), + TargetSource: &github.TargetSource{}, + TaskName: "task/name", + TargetURL: duci.URLMust(url.Parse("http://example.com")), + } + ctx := application.ContextWithJob(context.Background(), buildJob) + err := errors.New("test error") + + // and + want := github.CommitStatus{ + TargetSource: buildJob.TargetSource, + State: github.ERROR, + Description: github.Description("error: test error"), + Context: buildJob.TaskName, + TargetURL: buildJob.TargetURL, + } + + // and + ctrl := gomock.NewController(t) + + service := mock_job_service.NewMockService(ctrl) + service.EXPECT(). + Finish(gomock.Any()). + Times(1). + Return(nil) + hub := mock_github.NewMockGitHub(ctrl) + hub.EXPECT(). + CreateCommitStatus(gomock.Eq(ctx), gomock.Eq(want)). + Times(1). + Return(nil) + + // and + sut := &duci.Duci{} + defer sut.SetJobService(service)() + defer sut.SetGitHub(hub)() + + // when + sut.End(ctx, err) + + // then + ctrl.Finish() + }) + + t.Run("when invalid build job value", func(t *testing.T) { + // given + ctx := context.WithValue(context.Background(), duci.String("duci_job"), "invalid value") + + // and + ctrl := gomock.NewController(t) + + service := mock_job_service.NewMockService(ctrl) + service.EXPECT(). + Finish(gomock.Any()). + Times(0) + hub := mock_github.NewMockGitHub(ctrl) + hub.EXPECT(). + CreateCommitStatus(gomock.Any(), gomock.Any()). + Times(0) + + // and + sut := &duci.Duci{} + defer sut.SetJobService(service)() + defer sut.SetGitHub(hub)() + + // when + sut.End(ctx, nil) + + // then + ctrl.Finish() + }) + + t.Run("when failed to job_service.Service#Finish", func(t *testing.T) { + // given + buildJob := &application.BuildJob{ + ID: job.ID(uuid.New()), + TargetSource: &github.TargetSource{}, + TaskName: "task/name", + TargetURL: duci.URLMust(url.Parse("http://example.com")), + } + ctx := application.ContextWithJob(context.Background(), buildJob) + + // and + ctrl := gomock.NewController(t) + + service := mock_job_service.NewMockService(ctrl) + service.EXPECT(). + Finish(gomock.Any()). + Times(1). + Return(errors.New("test error")) + service.EXPECT(). + Append(gomock.Any(), gomock.Any()). + Times(1). + Return(nil) + + hub := mock_github.NewMockGitHub(ctrl) + hub.EXPECT(). + CreateCommitStatus(gomock.Any(), gomock.Any()). + Times(0) + + // and + sut := &duci.Duci{} + defer sut.SetJobService(service)() + defer sut.SetGitHub(hub)() + + // when + sut.End(ctx, nil) + + // then + ctrl.Finish() + }) +} diff --git a/application/duci/export_test.go b/application/duci/export_test.go new file mode 100644 index 00000000..62d7424b --- /dev/null +++ b/application/duci/export_test.go @@ -0,0 +1,52 @@ +package duci + +import ( + jobService "github.com/duck8823/duci/application/service/job" + "github.com/duck8823/duci/domain/model/job" + "github.com/duck8823/duci/domain/model/job/target/github" + "io" + "net/url" + "time" +) + +type Duci = duci + +func (d *Duci) SetJobService(service jobService.Service) (reset func()) { + tmp := d.jobService + d.jobService = service + return func() { + d.jobService = tmp + } +} + +func (d *Duci) SetGitHub(hub github.GitHub) (reset func()) { + tmp := d.github + d.github = hub + return func() { + d.github = tmp + } +} + +func URLMust(url *url.URL, err error) *url.URL { + if err != nil { + panic(err) + } + return url +} + +type MockLog struct { + Msgs []string +} + +func (l *MockLog) ReadLine() (*job.LogLine, error) { + if len(l.Msgs) == 0 { + return nil, io.EOF + } + msg := l.Msgs[0] + l.Msgs = l.Msgs[1:] + return &job.LogLine{Timestamp: time.Now(), Message: msg}, nil +} + +func String(val string) *string { + return &val +} diff --git a/application/export_test.go b/application/export_test.go index 03d3b5b1..eb5ca9f6 100644 --- a/application/export_test.go +++ b/application/export_test.go @@ -1,6 +1,14 @@ package application -import "github.com/tcnksm/go-latest" +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "github.com/tcnksm/go-latest" + "os" + "testing" +) type MaskString = maskString @@ -27,3 +35,33 @@ func CheckLatestVersion() { func TrimSuffix(tag string) string { return trimSuffix(tag) } + +func GetCtxKey() *string { + return &ctxKey +} + +func GenerateSSHKey(t *testing.T, path string) { + t.Helper() + + key, err := rsa.GenerateKey(rand.Reader, 256) + if err != nil { + t.Fatalf("error occur: %+v", err) + } + + if err := key.Validate(); err != nil { + t.Fatalf("error occur: %+v", err) + } + + f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0400) + if err != nil { + t.Fatalf("error occur: %+v", err) + } + + if _, err := f.Write(pem.EncodeToMemory(&pem.Block{ + Type: "RSA PRIVATE KEY", + Headers: nil, + Bytes: x509.MarshalPKCS1PrivateKey(key), + })); err != nil { + t.Fatalf("error occur: %+v", err) + } +} diff --git a/application/initialize.go b/application/initialize.go new file mode 100644 index 00000000..96f94c65 --- /dev/null +++ b/application/initialize.go @@ -0,0 +1,39 @@ +package application + +import ( + "context" + jobService "github.com/duck8823/duci/application/service/job" + "github.com/duck8823/duci/domain/model/job" + "github.com/duck8823/duci/domain/model/job/target/git" + "github.com/duck8823/duci/domain/model/job/target/github" + "github.com/pkg/errors" +) + +// Initialize singleton instances that are needed by application +func Initialize() error { + switch { + case len(Config.GitHub.SSHKeyPath) == 0: + if err := git.InitializeWithHTTP(printLog); err != nil { + return errors.WithStack(err) + } + default: + if err := git.InitializeWithSSH(Config.GitHub.SSHKeyPath, printLog); err != nil { + return errors.WithStack(err) + } + } + + if err := github.Initialize(Config.GitHub.APIToken.String()); err != nil { + return errors.WithStack(err) + } + + if err := jobService.Initialize(Config.Server.DatabasePath); err != nil { + return errors.WithStack(err) + } + return nil +} + +func printLog(_ context.Context, log job.Log) { + for line, err := log.ReadLine(); err == nil; line, err = log.ReadLine() { + println(line.Message) + } +} diff --git a/application/initialize_test.go b/application/initialize_test.go new file mode 100644 index 00000000..25fa8cfd --- /dev/null +++ b/application/initialize_test.go @@ -0,0 +1,202 @@ +package application_test + +import ( + "github.com/duck8823/duci/application" + "github.com/duck8823/duci/application/service/job" + "github.com/duck8823/duci/domain/model/job/target/git" + "github.com/duck8823/duci/domain/model/job/target/github" + "github.com/duck8823/duci/internal/container" + "github.com/labstack/gommon/random" + "os" + "path" + "testing" +) + +func TestInitialize(t *testing.T) { + t.Run("when singleton container is empty", func(t *testing.T) { + t.Run("without ssh key path", func(t *testing.T) { + // given + sshKeyPath := application.Config.GitHub.SSHKeyPath + databasePath := application.Config.Server.DatabasePath + application.Config.GitHub.SSHKeyPath = "" + application.Config.Server.DatabasePath = path.Join(os.TempDir(), random.String(16, random.Alphanumeric)) + defer func() { + application.Config.GitHub.SSHKeyPath = sshKeyPath + application.Config.Server.DatabasePath = databasePath + }() + + // and + container.Clear() + + // when + err := application.Initialize() + + // then + if err != nil { + t.Errorf("error must be nil, but got %+v", err) + } + + // and + git := new(git.Git) + if err := container.Get(git); err != nil { + t.Errorf("error must be nil, but got %+v", err) + } + + // and + github := new(github.GitHub) + if err := container.Get(github); err != nil { + t.Errorf("error must be nil, but got %+v", err) + } + + // and + jobService := new(job.Service) + if err := container.Get(jobService); err != nil { + t.Errorf("error must be nil, but got %+v", err) + } + }) + + t.Run("with correct ssh key path", func(t *testing.T) { + // given + dir := path.Join(os.TempDir(), random.String(16)) + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatalf("error occur: %+v", err) + } + keyPath := path.Join(dir, "id_rsa") + application.GenerateSSHKey(t, keyPath) + + // and + sshKeyPath := application.Config.GitHub.SSHKeyPath + databasePath := application.Config.Server.DatabasePath + application.Config.GitHub.SSHKeyPath = keyPath + application.Config.Server.DatabasePath = path.Join(os.TempDir(), random.String(16, random.Alphanumeric)) + defer func() { + application.Config.GitHub.SSHKeyPath = sshKeyPath + application.Config.Server.DatabasePath = databasePath + }() + + // and + container.Clear() + + // when + err := application.Initialize() + + // then + if err != nil { + t.Errorf("error must be nil, but got %+v", err) + } + + // and + git := new(git.Git) + if err := container.Get(git); err != nil { + t.Errorf("error must be nil, but got %+v", err) + } + + // and + github := new(github.GitHub) + if err := container.Get(github); err != nil { + t.Errorf("error must be nil, but got %+v", err) + } + + // and + jobService := new(job.Service) + if err := container.Get(jobService); err != nil { + t.Errorf("error must be nil, but got %+v", err) + } + }) + + t.Run("with invalid key path", func(t *testing.T) { + // given + sshKeyPath := application.Config.GitHub.SSHKeyPath + databasePath := application.Config.Server.DatabasePath + application.Config.GitHub.SSHKeyPath = "/path/to/invalid/key/path" + application.Config.Server.DatabasePath = path.Join(os.TempDir(), random.String(16, random.Alphanumeric)) + defer func() { + application.Config.GitHub.SSHKeyPath = sshKeyPath + application.Config.Server.DatabasePath = databasePath + }() + + // and + container.Clear() + + // when + err := application.Initialize() + + // then + if err == nil { + t.Error("error must not be nil") + } + }) + }) + + t.Run("when singleton container contains Git instance", func(t *testing.T) { + // given + sshKeyPath := application.Config.GitHub.SSHKeyPath + databasePath := application.Config.Server.DatabasePath + application.Config.GitHub.SSHKeyPath = "" + application.Config.Server.DatabasePath = path.Join(os.TempDir(), random.String(16, random.Alphanumeric)) + defer func() { + application.Config.GitHub.SSHKeyPath = sshKeyPath + application.Config.Server.DatabasePath = databasePath + }() + + // and + container.Override(new(git.Git)) + defer container.Clear() + + // when + err := application.Initialize() + + // then + if err == nil { + t.Error("error must not be nil") + } + }) + + t.Run("when singleton container contains GitHub instance", func(t *testing.T) { + // given + sshKeyPath := application.Config.GitHub.SSHKeyPath + databasePath := application.Config.Server.DatabasePath + application.Config.GitHub.SSHKeyPath = "" + application.Config.Server.DatabasePath = path.Join(os.TempDir(), random.String(16, random.Alphanumeric)) + defer func() { + application.Config.GitHub.SSHKeyPath = sshKeyPath + application.Config.Server.DatabasePath = databasePath + }() + + // and + container.Override(new(github.GitHub)) + defer container.Clear() + + // when + err := application.Initialize() + + // then + if err == nil { + t.Error("error must not be nil") + } + }) + + t.Run("when singleton container contains JobService instance", func(t *testing.T) { + // given + sshKeyPath := application.Config.GitHub.SSHKeyPath + databasePath := application.Config.Server.DatabasePath + application.Config.GitHub.SSHKeyPath = "" + application.Config.Server.DatabasePath = path.Join(os.TempDir(), random.String(16, random.Alphanumeric)) + defer func() { + application.Config.GitHub.SSHKeyPath = sshKeyPath + application.Config.Server.DatabasePath = databasePath + }() + + // and + container.Override(new(job.Service)) + defer container.Clear() + + // when + err := application.Initialize() + + // then + if err == nil { + t.Error("error must not be nil") + } + }) +} diff --git a/application/semaphore/semaphore.go b/application/semaphore/semaphore.go index a4a7eb45..f492ab7d 100644 --- a/application/semaphore/semaphore.go +++ b/application/semaphore/semaphore.go @@ -14,7 +14,7 @@ var ( // Make create semaphore with configuration func Make() error { if initialized { - return errors.New("semaphore already created.") + return errors.New("semaphore already created") } sem = make(chan struct{}, application.Config.Job.Concurrency) initialized = true diff --git a/application/service/docker/docker.go b/application/service/docker/docker.go deleted file mode 100644 index dfd9f253..00000000 --- a/application/service/docker/docker.go +++ /dev/null @@ -1,103 +0,0 @@ -package docker - -import ( - "context" - "github.com/duck8823/duci/infrastructure/docker" - "github.com/pkg/errors" - "io" -) - -// Log represents a log. -type Log = docker.Log - -// Tag describes a docker tag -type Tag string - -// Dockerfile represents a path to dockerfile -type Dockerfile string - -// RuntimeOptions represents a options -type RuntimeOptions = docker.RuntimeOptions - -// Command describes a docker CMD -type Command []string - -// ContainerID describes a container id of docker -type ContainerID string - -// ExitCode describes a exit code -type ExitCode int64 - -// Service is a interface describe docker service. -type Service interface { - Build(ctx context.Context, file io.Reader, tag Tag, dockerfile Dockerfile) (Log, error) - Run(ctx context.Context, opts RuntimeOptions, tag Tag, cmd Command) (ContainerID, Log, error) - Rm(ctx context.Context, containerID ContainerID) error - Rmi(ctx context.Context, tag Tag) error - ExitCode(ctx context.Context, containerID ContainerID) (ExitCode, error) - Status() error -} - -type serviceImpl struct { - moby docker.Client -} - -// New returns instance of docker service -func New() (Service, error) { - cli, err := docker.New() - if err != nil { - return nil, errors.WithStack(err) - } - return &serviceImpl{moby: cli}, nil -} - -// Build a docker image. -func (s *serviceImpl) Build(ctx context.Context, file io.Reader, tag Tag, dockerfile Dockerfile) (Log, error) { - log, err := s.moby.Build(ctx, file, string(tag), string(dockerfile)) - if err != nil { - return nil, errors.WithStack(err) - } - return log, nil -} - -// Run docker container with command. -func (s *serviceImpl) Run(ctx context.Context, opts RuntimeOptions, tag Tag, cmd Command) (ContainerID, Log, error) { - conID, log, err := s.moby.Run(ctx, opts, string(tag), cmd...) - if err != nil { - return ContainerID(conID), nil, errors.WithStack(err) - } - return ContainerID(conID), log, nil -} - -// Rm remove docker container. -func (s *serviceImpl) Rm(ctx context.Context, conID ContainerID) error { - if err := s.moby.Rm(ctx, string(conID)); err != nil { - return errors.WithStack(err) - } - return nil -} - -// Rmi remove docker image. -func (s *serviceImpl) Rmi(ctx context.Context, tag Tag) error { - if err := s.moby.Rmi(ctx, string(tag)); err != nil { - return errors.WithStack(err) - } - return nil -} - -// ExitCode returns exit code specific container id. -func (s *serviceImpl) ExitCode(ctx context.Context, conID ContainerID) (ExitCode, error) { - code, err := s.moby.ExitCode(ctx, string(conID)) - if err != nil { - return ExitCode(code), errors.WithStack(err) - } - return ExitCode(code), nil -} - -// Status returns error of docker daemon status. -func (s *serviceImpl) Status() error { - if _, err := s.moby.Info(context.Background()); err != nil { - return errors.Wrap(err, "Couldn't connect to Docker daemon.") - } - return nil -} diff --git a/application/service/docker/mock_docker/docker.go b/application/service/docker/mock_docker/docker.go deleted file mode 100644 index 837f4c3b..00000000 --- a/application/service/docker/mock_docker/docker.go +++ /dev/null @@ -1,112 +0,0 @@ -// Code generated by MockGen. DO NOT EDIT. -// Source: application/service/docker/docker.go - -// Package mock_docker is a generated GoMock package. -package mock_docker - -import ( - context "context" - docker "github.com/duck8823/duci/application/service/docker" - gomock "github.com/golang/mock/gomock" - io "io" - 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 -} - -// Build mocks base method -func (m *MockService) Build(ctx context.Context, file io.Reader, tag docker.Tag, dockerfile docker.Dockerfile) (docker.Log, error) { - ret := m.ctrl.Call(m, "Build", ctx, file, tag, dockerfile) - ret0, _ := ret[0].(docker.Log) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// Build indicates an expected call of Build -func (mr *MockServiceMockRecorder) Build(ctx, file, tag, dockerfile interface{}) *gomock.Call { - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Build", reflect.TypeOf((*MockService)(nil).Build), ctx, file, tag, dockerfile) -} - -// Run mocks base method -func (m *MockService) Run(ctx context.Context, opts docker.RuntimeOptions, tag docker.Tag, cmd docker.Command) (docker.ContainerID, docker.Log, error) { - ret := m.ctrl.Call(m, "Run", ctx, opts, tag, cmd) - ret0, _ := ret[0].(docker.ContainerID) - ret1, _ := ret[1].(docker.Log) - ret2, _ := ret[2].(error) - return ret0, ret1, ret2 -} - -// Run indicates an expected call of Run -func (mr *MockServiceMockRecorder) Run(ctx, opts, tag, cmd interface{}) *gomock.Call { - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Run", reflect.TypeOf((*MockService)(nil).Run), ctx, opts, tag, cmd) -} - -// Rm mocks base method -func (m *MockService) Rm(ctx context.Context, containerID docker.ContainerID) error { - ret := m.ctrl.Call(m, "Rm", ctx, containerID) - ret0, _ := ret[0].(error) - return ret0 -} - -// Rm indicates an expected call of Rm -func (mr *MockServiceMockRecorder) Rm(ctx, containerID interface{}) *gomock.Call { - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Rm", reflect.TypeOf((*MockService)(nil).Rm), ctx, containerID) -} - -// Rmi mocks base method -func (m *MockService) Rmi(ctx context.Context, tag docker.Tag) error { - ret := m.ctrl.Call(m, "Rmi", ctx, tag) - ret0, _ := ret[0].(error) - return ret0 -} - -// Rmi indicates an expected call of Rmi -func (mr *MockServiceMockRecorder) Rmi(ctx, tag interface{}) *gomock.Call { - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Rmi", reflect.TypeOf((*MockService)(nil).Rmi), ctx, tag) -} - -// ExitCode mocks base method -func (m *MockService) ExitCode(ctx context.Context, containerID docker.ContainerID) (docker.ExitCode, error) { - ret := m.ctrl.Call(m, "ExitCode", ctx, containerID) - ret0, _ := ret[0].(docker.ExitCode) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// ExitCode indicates an expected call of ExitCode -func (mr *MockServiceMockRecorder) ExitCode(ctx, containerID interface{}) *gomock.Call { - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExitCode", reflect.TypeOf((*MockService)(nil).ExitCode), ctx, containerID) -} - -// Status mocks base method -func (m *MockService) Status() error { - ret := m.ctrl.Call(m, "Status") - ret0, _ := ret[0].(error) - return ret0 -} - -// Status indicates an expected call of Status -func (mr *MockServiceMockRecorder) Status() *gomock.Call { - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Status", reflect.TypeOf((*MockService)(nil).Status)) -} diff --git a/application/service/executor/builder.go b/application/service/executor/builder.go new file mode 100644 index 00000000..ebbf8423 --- /dev/null +++ b/application/service/executor/builder.go @@ -0,0 +1,69 @@ +package executor + +import ( + "context" + "github.com/duck8823/duci/domain/model/docker" + "github.com/duck8823/duci/domain/model/runner" + "github.com/pkg/errors" +) + +// Builder is an executor builder +type Builder struct { + docker docker.Docker + logFunc runner.LogFunc + startFunc func(context.Context) + endFunc func(context.Context, error) +} + +// DefaultExecutorBuilder create new Builder of docker runner +func DefaultExecutorBuilder() (*Builder, error) { + docker, err := docker.New() + if err != nil { + return nil, errors.WithStack(err) + } + return &Builder{ + docker: docker, + logFunc: runner.NothingToDo, + startFunc: nothingToDoStart, + endFunc: nothingToDoEnd, + }, nil +} + +// LogFunc set a LogFunc +func (b *Builder) LogFunc(f runner.LogFunc) *Builder { + b.logFunc = f + return b +} + +// StartFunc set a startFunc +func (b *Builder) StartFunc(f func(context.Context)) *Builder { + b.startFunc = f + return b +} + +// EndFunc set a endFunc +func (b *Builder) EndFunc(f func(context.Context, error)) *Builder { + b.endFunc = f + return b +} + +// Build returns a executor +func (b *Builder) Build() Executor { + r := runner.DefaultDockerRunnerBuilder(). + LogFunc(b.logFunc). + Build() + + return &jobExecutor{ + DockerRunner: r, + StartFunc: b.startFunc, + EndFunc: b.endFunc, + } +} + +var nothingToDoStart = func(context.Context) { + // nothing to do +} + +var nothingToDoEnd = func(context.Context, error) { + // nothing to do +} diff --git a/application/service/executor/builder_test.go b/application/service/executor/builder_test.go new file mode 100644 index 00000000..4d2ad1db --- /dev/null +++ b/application/service/executor/builder_test.go @@ -0,0 +1,128 @@ +package executor_test + +import ( + "context" + "github.com/duck8823/duci/application/service/executor" + "github.com/duck8823/duci/domain/model/docker" + "github.com/duck8823/duci/domain/model/job" + "github.com/duck8823/duci/domain/model/runner" + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "reflect" + "testing" +) + +func TestDefaultExecutorBuilder(t *testing.T) { + // given + want := &executor.Builder{} + defer want.SetStartFunc(executor.NothingToDoStart)() + defer want.SetLogFunc(runner.NothingToDo)() + defer want.SetEndFunc(executor.NothingToDoEnd)() + + // when + got, err := executor.DefaultExecutorBuilder() + + // then + if err != nil { + t.Errorf("error must be nil, but got %+v", err) + } + + // and + opts := cmp.Options{ + cmp.AllowUnexported(executor.Builder{}), + cmp.Transformer("startFuncToPointer", func(f func(context.Context)) uintptr { + return reflect.ValueOf(f).Pointer() + }), + cmp.Transformer("logFuncToPointer", func(f runner.LogFunc) uintptr { + return reflect.ValueOf(f).Pointer() + }), + cmp.Transformer("endFuncToPointer", func(f func(context.Context, error)) uintptr { + return reflect.ValueOf(f).Pointer() + }), + cmpopts.IgnoreInterfaces(struct{ docker.Docker }{}), + } + if !cmp.Equal(got, want, opts) { + t.Errorf("must be equal. but: %+v", cmp.Diff(got, want, opts)) + } +} + +func TestBuilder_StartFunc(t *testing.T) { + // given + startFunc := func(context.Context) {} + + // and + want := &executor.Builder{} + defer want.SetStartFunc(startFunc)() + + // and + sut := &executor.Builder{} + + // when + got := sut.StartFunc(startFunc) + + // then + opts := cmp.Options{ + cmp.AllowUnexported(executor.Builder{}), + cmp.Transformer("startFuncToPointer", func(f func(context.Context)) uintptr { + return reflect.ValueOf(f).Pointer() + }), + cmpopts.IgnoreInterfaces(struct{ docker.Docker }{}), + } + if !cmp.Equal(got, want, opts) { + t.Errorf("must be equal. but: %+v", cmp.Diff(got, want, opts)) + } +} + +func TestBuilder_LogFunc(t *testing.T) { + // given + logFunc := func(context.Context, job.Log) {} + + // and + want := &executor.Builder{} + defer want.SetLogFunc(logFunc)() + + // and + sut := &executor.Builder{} + + // when + got := sut.LogFunc(logFunc) + + // then + opts := cmp.Options{ + cmp.AllowUnexported(executor.Builder{}), + cmp.Transformer("logFuncToPointer", func(f runner.LogFunc) uintptr { + return reflect.ValueOf(f).Pointer() + }), + cmpopts.IgnoreInterfaces(struct{ docker.Docker }{}), + } + if !cmp.Equal(got, want, opts) { + t.Errorf("must be equal. but: %+v", cmp.Diff(got, want, opts)) + } +} + +func TestBuilder_EndFunc(t *testing.T) { + // given + endFunc := func(context.Context, error) {} + + // and + want := &executor.Builder{} + defer want.SetEndFunc(endFunc)() + + // and + sut := &executor.Builder{} + + // when + got := sut.EndFunc(endFunc) + + // then + opts := cmp.Options{ + cmp.AllowUnexported(executor.Builder{}), + cmp.Transformer("endFuncToPointer", func(f func(context.Context, error)) uintptr { + return reflect.ValueOf(f).Pointer() + }), + cmpopts.IgnoreInterfaces(struct{ docker.Docker }{}), + } + if !cmp.Equal(got, want, opts) { + t.Errorf("must be equal. but: %+v", cmp.Diff(got, want, opts)) + } +} diff --git a/application/service/executor/executor.go b/application/service/executor/executor.go new file mode 100644 index 00000000..1354d6c2 --- /dev/null +++ b/application/service/executor/executor.go @@ -0,0 +1,54 @@ +package executor + +import ( + "context" + "github.com/duck8823/duci/application" + "github.com/duck8823/duci/application/semaphore" + "github.com/duck8823/duci/domain/model/docker" + "github.com/duck8823/duci/domain/model/job" + "github.com/duck8823/duci/domain/model/runner" + "github.com/labstack/gommon/random" + "github.com/pkg/errors" +) + +// Executor is job executor +type Executor interface { + Execute(ctx context.Context, target job.Target, cmd ...string) error +} + +type jobExecutor struct { + runner.DockerRunner + StartFunc func(context.Context) + EndFunc func(context.Context, error) +} + +// Execute job +func (r *jobExecutor) Execute(ctx context.Context, target job.Target, cmd ...string) error { + r.StartFunc(ctx) + + workDir, cleanup, err := target.Prepare() + if err != nil { + return errors.WithStack(err) + } + defer cleanup() + + errs := make(chan error, 1) + + timeout, cancel := context.WithTimeout(ctx, application.Config.Timeout()) + defer cancel() + + go func() { + semaphore.Acquire() + errs <- r.DockerRunner.Run(timeout, workDir, docker.Tag(random.String(16, random.Lowercase)), cmd) + semaphore.Release() + }() + + select { + case <-timeout.Done(): + r.EndFunc(ctx, timeout.Err()) + return timeout.Err() + case err := <-errs: + r.EndFunc(ctx, err) + return err + } +} diff --git a/application/service/executor/executor_test.go b/application/service/executor/executor_test.go new file mode 100644 index 00000000..63d4de7f --- /dev/null +++ b/application/service/executor/executor_test.go @@ -0,0 +1,234 @@ +package executor_test + +import ( + "context" + "github.com/duck8823/duci/application" + "github.com/duck8823/duci/application/service/executor" + "github.com/duck8823/duci/domain/model/job" + "github.com/duck8823/duci/domain/model/runner/mock_runner" + "github.com/golang/mock/gomock" + "github.com/labstack/gommon/random" + "github.com/pkg/errors" + "os" + "path" + "testing" + "time" +) + +func TestJobExecutor_Execute(t *testing.T) { + t.Run("with no error", func(t *testing.T) { + // given + ctx := context.Background() + target := &executor.StubTarget{ + Dir: job.WorkDir(path.Join(os.TempDir(), random.String(16))), + Cleanup: func() {}, + Err: nil, + } + + // and + var calledStartFunc, calledEndFunc bool + + // and + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + runner := mock_runner.NewMockDockerRunner(ctrl) + runner.EXPECT(). + Run(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Times(1). + Return(nil) + + // and + sut := &executor.JobExecutor{} + defer sut.SetDockerRunner(runner)() + defer sut.SetStartFunc(func(context.Context) { + calledStartFunc = true + })() + defer sut.SetEndFunc(func(context.Context, error) { + calledEndFunc = true + })() + + // when + err := sut.Execute(ctx, target) + + // then + if err != nil { + t.Errorf("must be nil, but got %+v", err) + } + + // and + if !calledStartFunc { + t.Errorf("must be called startFunc") + } + + // and + if !calledEndFunc { + t.Errorf("must be called endFunc") + } + }) + + t.Run("with error", func(t *testing.T) { + // given + ctx := context.Background() + target := &executor.StubTarget{ + Dir: job.WorkDir(path.Join(os.TempDir(), random.String(16))), + Cleanup: func() {}, + Err: nil, + } + + // and + wantErr := errors.New("test error") + + // and + var calledStartFunc, calledEndFunc bool + + // and + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + runner := mock_runner.NewMockDockerRunner(ctrl) + runner.EXPECT(). + Run(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Times(1). + Return(wantErr) + + // and + sut := &executor.JobExecutor{} + defer sut.SetDockerRunner(runner)() + defer sut.SetStartFunc(func(context.Context) { + calledStartFunc = true + })() + defer sut.SetEndFunc(func(context.Context, error) { + calledEndFunc = true + })() + + // when + err := sut.Execute(ctx, target) + + // then + if err != wantErr { + t.Errorf("must be equal. want %+v, but got %+v", wantErr, err) + } + + // and + if !calledStartFunc { + t.Errorf("must be called startFunc") + } + + // and + if !calledEndFunc { + t.Errorf("must be called endFunc") + } + }) + + t.Run("with timeout", func(t *testing.T) { + // given + timeout := application.Config.Timeout() + application.Config.Job.Timeout = 1 + defer func() { + application.Config.Job.Timeout = timeout.Nanoseconds() * 1000 * 1000 + }() + + // and + ctx := context.Background() + target := &executor.StubTarget{ + Dir: job.WorkDir(path.Join(os.TempDir(), random.String(16))), + Cleanup: func() {}, + Err: nil, + } + + // and + var calledStartFunc, calledEndFunc bool + + // and + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + runner := mock_runner.NewMockDockerRunner(ctrl) + runner.EXPECT(). + Run(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Times(1). + Do(func(_, _, _, _ interface{}) { + time.Sleep(5 * time.Second) + }). + Return(nil) + + // and + sut := &executor.JobExecutor{} + defer sut.SetDockerRunner(runner)() + defer sut.SetStartFunc(func(context.Context) { + calledStartFunc = true + })() + defer sut.SetEndFunc(func(context.Context, error) { + calledEndFunc = true + })() + + // when + err := sut.Execute(ctx, target) + + // then + if err != context.DeadlineExceeded { + t.Errorf("must be equal. want %+v, but got %+v", context.DeadlineExceeded, err) + } + + // and + if !calledStartFunc { + t.Errorf("must be called startFunc") + } + + // and + if !calledEndFunc { + t.Errorf("must be called endFunc") + } + }) + + t.Run("when prepare returns error", func(t *testing.T) { + // given + ctx := context.Background() + target := &executor.StubTarget{ + Dir: job.WorkDir(path.Join(os.TempDir(), random.String(16))), + Cleanup: func() {}, + Err: errors.New("test error"), + } + + // and + var calledStartFunc, calledEndFunc bool + + // and + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + runner := mock_runner.NewMockDockerRunner(ctrl) + runner.EXPECT(). + Run(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Times(0) + + // and + sut := &executor.JobExecutor{} + defer sut.SetDockerRunner(runner)() + defer sut.SetStartFunc(func(context.Context) { + calledStartFunc = true + })() + defer sut.SetEndFunc(func(context.Context, error) { + calledEndFunc = true + })() + + // when + err := sut.Execute(ctx, target) + + // then + if err == nil { + t.Error("must not be nil") + } + + // and + if !calledStartFunc { + t.Errorf("must be called startFunc") + } + + // and + if calledEndFunc { + t.Errorf("must not be called endFunc") + } + }) +} diff --git a/application/service/executor/export_test.go b/application/service/executor/export_test.go new file mode 100644 index 00000000..f2336ac4 --- /dev/null +++ b/application/service/executor/export_test.go @@ -0,0 +1,79 @@ +package executor + +import ( + "context" + "github.com/duck8823/duci/domain/model/docker" + "github.com/duck8823/duci/domain/model/job" + "github.com/duck8823/duci/domain/model/runner" +) + +func (b *Builder) SetDocker(docker docker.Docker) (reset func()) { + tmp := b.docker + b.docker = docker + return func() { + b.docker = tmp + } +} + +func (b *Builder) SetStartFunc(startFunc func(context.Context)) (reset func()) { + tmp := b.startFunc + b.startFunc = startFunc + return func() { + b.startFunc = tmp + } +} + +func (b *Builder) SetLogFunc(logFunc func(context.Context, job.Log)) (reset func()) { + tmp := b.logFunc + b.logFunc = logFunc + return func() { + b.logFunc = tmp + } +} + +func (b *Builder) SetEndFunc(endFunc func(context.Context, error)) (reset func()) { + tmp := b.endFunc + b.endFunc = endFunc + return func() { + b.endFunc = tmp + } +} + +var NothingToDoStart = nothingToDoStart +var NothingToDoEnd = nothingToDoEnd + +type JobExecutor = jobExecutor + +func (r *JobExecutor) SetStartFunc(startFunc func(context.Context)) (reset func()) { + tmp := r.StartFunc + r.StartFunc = startFunc + return func() { + r.StartFunc = tmp + } +} + +func (r *JobExecutor) SetEndFunc(endFunc func(context.Context, error)) (reset func()) { + tmp := r.EndFunc + r.EndFunc = endFunc + return func() { + r.EndFunc = tmp + } +} + +func (r *JobExecutor) SetDockerRunner(runner runner.DockerRunner) (reset func()) { + tmp := r.DockerRunner + r.DockerRunner = runner + return func() { + r.DockerRunner = tmp + } +} + +type StubTarget struct { + Dir job.WorkDir + Cleanup job.Cleanup + Err error +} + +func (t *StubTarget) Prepare() (dir job.WorkDir, cleanup job.Cleanup, err error) { + return t.Dir, t.Cleanup, t.Err +} diff --git a/application/service/executor/mock_executor/executor.go b/application/service/executor/mock_executor/executor.go new file mode 100644 index 00000000..0550d8de --- /dev/null +++ b/application/service/executor/mock_executor/executor.go @@ -0,0 +1,52 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: application/service/executor/executor.go + +// Package mock_executor is a generated GoMock package. +package mock_executor + +import ( + context "context" + job "github.com/duck8823/duci/domain/model/job" + gomock "github.com/golang/mock/gomock" + reflect "reflect" +) + +// MockExecutor is a mock of Executor interface +type MockExecutor struct { + ctrl *gomock.Controller + recorder *MockExecutorMockRecorder +} + +// MockExecutorMockRecorder is the mock recorder for MockExecutor +type MockExecutorMockRecorder struct { + mock *MockExecutor +} + +// NewMockExecutor creates a new mock instance +func NewMockExecutor(ctrl *gomock.Controller) *MockExecutor { + mock := &MockExecutor{ctrl: ctrl} + mock.recorder = &MockExecutorMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use +func (m *MockExecutor) EXPECT() *MockExecutorMockRecorder { + return m.recorder +} + +// Execute mocks base method +func (m *MockExecutor) Execute(ctx context.Context, target job.Target, cmd ...string) error { + varargs := []interface{}{ctx, target} + for _, a := range cmd { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Execute", varargs...) + ret0, _ := ret[0].(error) + return ret0 +} + +// Execute indicates an expected call of Execute +func (mr *MockExecutorMockRecorder) Execute(ctx, target interface{}, cmd ...interface{}) *gomock.Call { + varargs := append([]interface{}{ctx, target}, cmd...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Execute", reflect.TypeOf((*MockExecutor)(nil).Execute), varargs...) +} diff --git a/application/service/git/export_test.go b/application/service/git/export_test.go deleted file mode 100644 index cbfa9297..00000000 --- a/application/service/git/export_test.go +++ /dev/null @@ -1,32 +0,0 @@ -package git - -import ( - "gopkg.in/src-d/go-git.v4" - "gopkg.in/src-d/go-git.v4/plumbing" -) - -func SetPlainCloneFunc(f func(path string, isBare bool, o *git.CloneOptions) (*git.Repository, error)) (reset func()) { - tmp := plainClone - plainClone = f - return func() { - plainClone = tmp - } -} - -type MockTargetSource struct { - URL string - Ref string - SHA plumbing.Hash -} - -func (t *MockTargetSource) GetURL() string { - return t.URL -} - -func (t *MockTargetSource) GetRef() string { - return t.Ref -} - -func (t *MockTargetSource) GetSHA() plumbing.Hash { - return t.SHA -} diff --git a/application/service/git/git.go b/application/service/git/git.go deleted file mode 100644 index 9249fa7f..00000000 --- a/application/service/git/git.go +++ /dev/null @@ -1,96 +0,0 @@ -package git - -import ( - "github.com/duck8823/duci/application" - "github.com/duck8823/duci/application/context" - "github.com/pkg/errors" - "gopkg.in/src-d/go-git.v4" - "gopkg.in/src-d/go-git.v4/plumbing" - "gopkg.in/src-d/go-git.v4/plumbing/transport" - "gopkg.in/src-d/go-git.v4/plumbing/transport/ssh" -) - -var plainClone = git.PlainClone - -// TargetSource is a interface returns clone URL, Ref and SHA for target -type TargetSource interface { - GetURL() string - GetRef() string - GetSHA() plumbing.Hash -} - -// Service describes a git service. -type Service interface { - Clone(ctx context.Context, dir string, src TargetSource) error -} - -type sshGitService struct { - auth transport.AuthMethod -} - -type httpGitService struct{} - -// New returns the Service. -func New() (Service, error) { - if application.Config.GitHub.SSHKeyPath == "" { - return &httpGitService{}, nil - } - auth, err := ssh.NewPublicKeysFromFile("git", application.Config.GitHub.SSHKeyPath, "") - if err != nil { - return nil, err - } - return &sshGitService{auth: auth}, nil -} - -// Clone a repository into the path with target source. -func (s *sshGitService) Clone(ctx context.Context, dir string, src TargetSource) error { - gitRepository, err := plainClone(dir, false, &git.CloneOptions{ - URL: src.GetURL(), - Auth: s.auth, - Progress: &ProgressLogger{ctx.UUID()}, - ReferenceName: plumbing.ReferenceName(src.GetRef()), - Depth: 1, - }) - if err != nil { - return errors.WithStack(err) - } - - if err := checkout(gitRepository, src.GetSHA()); err != nil { - return errors.WithStack(err) - } - return nil -} - -// Clone a repository into the path with target source. -func (s *httpGitService) Clone(ctx context.Context, dir string, src TargetSource) error { - gitRepository, err := plainClone(dir, false, &git.CloneOptions{ - URL: src.GetURL(), - Progress: &ProgressLogger{ctx.UUID()}, - ReferenceName: plumbing.ReferenceName(src.GetRef()), - Depth: 1, - }) - if err != nil { - return errors.WithStack(err) - } - - if err := checkout(gitRepository, src.GetSHA()); err != nil { - return errors.WithStack(err) - } - return nil -} - -func checkout(repo *git.Repository, sha plumbing.Hash) error { - wt, err := repo.Worktree() - if err != nil { - return errors.WithStack(err) - } - - if err := wt.Checkout(&git.CheckoutOptions{ - Hash: sha, - Branch: plumbing.ReferenceName(sha.String()), - Create: true, - }); err != nil { - return errors.WithStack(err) - } - return nil -} diff --git a/application/service/git/git_test.go b/application/service/git/git_test.go deleted file mode 100644 index a108e676..00000000 --- a/application/service/git/git_test.go +++ /dev/null @@ -1,307 +0,0 @@ -package git_test - -import ( - "crypto/rand" - "crypto/rsa" - "crypto/x509" - "encoding/pem" - "fmt" - "github.com/duck8823/duci/application" - "github.com/duck8823/duci/application/context" - "github.com/duck8823/duci/application/service/git" - "github.com/google/uuid" - "github.com/labstack/gommon/random" - "github.com/pkg/errors" - go_git "gopkg.in/src-d/go-git.v4" - "gopkg.in/src-d/go-git.v4/plumbing" - "gopkg.in/src-d/go-git.v4/plumbing/object" - "net/url" - "os" - "path/filepath" - "testing" -) - -func TestNew(t *testing.T) { - t.Run("when without ssh key path", func(t *testing.T) { - // expect - if _, err := git.New(); err != nil { - t.Error("error must not occur") - } - }) - - t.Run("when missing ssh key path", func(t *testing.T) { - // given - application.Config.GitHub.SSHKeyPath = "/path/to/wrong" - - // expect - if _, err := git.New(); err == nil { - t.Error("error must occur") - } - }) -} - -func TestSshGitService_Clone(t *testing.T) { - // setup - path, remove := createTemporaryKey(t) - defer remove() - application.Config.GitHub.SSHKeyPath = path - - t.Run("when failure git clone", func(t *testing.T) { - // given - reset := git.SetPlainCloneFunc(func(_ string, _ bool, _ *go_git.CloneOptions) (*go_git.Repository, error) { - return nil, errors.New("test") - }) - defer reset() - - // and - sut, err := git.New() - if err != nil { - t.Fatalf("error occurred. %+v", err) - } - - // expect - if err := sut.Clone( - context.New("test/task", uuid.New(), &url.URL{}), - "", - &git.MockTargetSource{}, - ); err == nil { - t.Error("error must not nil.") - } - }) - - t.Run("when success git clone", func(t *testing.T) { - // setup - dirStr := fmt.Sprintf("duci_test_%s", random.String(16, random.Alphanumeric)) - tempDir := filepath.Join(os.TempDir(), dirStr) - if err := os.MkdirAll(tempDir, 0700); err != nil { - t.Fatalf("%+v", err) - } - - // and - var hash plumbing.Hash - reset := git.SetPlainCloneFunc(func(_ string, _ bool, _ *go_git.CloneOptions) (*go_git.Repository, error) { - // git init - repo, err := go_git.PlainInit(tempDir, false) - if err != nil { - t.Fatalf("error occur: %+v", err) - } - w, err := repo.Worktree() - if err != nil { - t.Fatalf("error occur: %+v", err) - } - // commit - hash, err = w.Commit("init. commit", &go_git.CommitOptions{ - Author: &object.Signature{}, - }) - if err != nil { - t.Fatalf("error occur: %+v", err) - } - return repo, nil - }) - defer reset() - - // and - sut, err := git.New() - if err != nil { - t.Fatalf("error occurred. %+v", err) - } - - // expect - if err := sut.Clone( - context.New("test/task", uuid.New(), &url.URL{}), - "", - &git.MockTargetSource{ - Ref: "HEAD", - SHA: hash, - }, - ); err != nil { - t.Errorf("error must not occur. but got %+v", err) - } - }) - - t.Run("when failure git checkout", func(t *testing.T) { - // setup - dirStr := fmt.Sprintf("duci_test_%s", random.String(16, random.Alphanumeric)) - tempDir := filepath.Join(os.TempDir(), dirStr) - if err := os.MkdirAll(tempDir, 0700); err != nil { - t.Fatalf("%+v", err) - } - - // and - reset := git.SetPlainCloneFunc(func(_ string, _ bool, _ *go_git.CloneOptions) (*go_git.Repository, error) { - // git init - repo, err := go_git.PlainInit(tempDir, false) - if err != nil { - t.Fatalf("error occur: %+v", err) - } - return repo, nil - }) - defer reset() - - // and - sut, err := git.New() - if err != nil { - t.Fatalf("error occurred. %+v", err) - } - - // expect - if err := sut.Clone( - context.New("test/task", uuid.New(), &url.URL{}), - "", - &git.MockTargetSource{ - Ref: "HEAD", - }, - ); err == nil { - t.Error("error must occur. but got nil") - } - }) -} - -func TestHttpGitService_Clone(t *testing.T) { - // setup - application.Config.GitHub.SSHKeyPath = "" - - t.Run("when failure git clone", func(t *testing.T) { - // given - reset := git.SetPlainCloneFunc(func(_ string, _ bool, _ *go_git.CloneOptions) (*go_git.Repository, error) { - return nil, errors.New("test") - }) - defer reset() - - // and - sut, err := git.New() - if err != nil { - t.Fatalf("error occurred. %+v", err) - } - - // expect - if err := sut.Clone( - context.New("test/task", uuid.New(), &url.URL{}), - "", - &git.MockTargetSource{}, - ); err == nil { - t.Error("error must not nil.") - } - }) - - t.Run("when success git clone", func(t *testing.T) { - // setup - dirStr := fmt.Sprintf("duci_test_%s", random.String(16, random.Alphanumeric)) - tempDir := filepath.Join(os.TempDir(), dirStr) - if err := os.MkdirAll(tempDir, 0700); err != nil { - t.Fatalf("%+v", err) - } - - // and - var hash plumbing.Hash - reset := git.SetPlainCloneFunc(func(_ string, _ bool, _ *go_git.CloneOptions) (*go_git.Repository, error) { - // git init - repo, err := go_git.PlainInit(tempDir, false) - if err != nil { - t.Fatalf("error occur: %+v", err) - } - w, err := repo.Worktree() - if err != nil { - t.Fatalf("error occur: %+v", err) - } - // commit - hash, err = w.Commit("init. commit", &go_git.CommitOptions{ - Author: &object.Signature{}, - }) - if err != nil { - t.Fatalf("error occur: %+v", err) - } - return repo, nil - }) - defer reset() - - // and - sut, err := git.New() - if err != nil { - t.Fatalf("error occurred. %+v", err) - } - - // expect - if err := sut.Clone( - context.New("test/task", uuid.New(), &url.URL{}), - "", - &git.MockTargetSource{ - Ref: "HEAD", - SHA: hash, - }, - ); err != nil { - t.Errorf("error must not occur. but got %+v", err) - } - }) - - t.Run("when failure git checkout", func(t *testing.T) { - // setup - dirStr := fmt.Sprintf("duci_test_%s", random.String(16, random.Alphanumeric)) - tempDir := filepath.Join(os.TempDir(), dirStr) - if err := os.MkdirAll(tempDir, 0700); err != nil { - t.Fatalf("%+v", err) - } - - // and - reset := git.SetPlainCloneFunc(func(_ string, _ bool, _ *go_git.CloneOptions) (*go_git.Repository, error) { - // git init - repo, err := go_git.PlainInit(tempDir, false) - if err != nil { - t.Fatalf("error occur: %+v", err) - } - return repo, nil - }) - defer reset() - - // and - sut, err := git.New() - if err != nil { - t.Fatalf("error occurred. %+v", err) - } - - // expect - if err := sut.Clone( - context.New("test/task", uuid.New(), &url.URL{}), - "", - &git.MockTargetSource{ - Ref: "HEAD", - }, - ); err == nil { - t.Error("error must occur. but got nil") - } - }) -} - -func createTemporaryKey(t *testing.T) (path string, reset func()) { - t.Helper() - - privateKey, err := rsa.GenerateKey(rand.Reader, 256) - if err != nil { - t.Fatalf("error occur: %+v", err) - } - privateKeyDer := x509.MarshalPKCS1PrivateKey(privateKey) - privateKeyBlock := pem.Block{ - Type: "RSA PRIVATE KEY", - Headers: nil, - Bytes: privateKeyDer, - } - privateKeyPem := string(pem.EncodeToMemory(&privateKeyBlock)) - - tempDir := filepath.Join(os.TempDir(), random.String(16, random.Alphanumeric)) - if err := os.MkdirAll(tempDir, 0700); err != nil { - t.Fatalf("error occur: %+v", err) - } - keyPath := filepath.Join(tempDir, "id_rsa") - file, err := os.OpenFile(keyPath, os.O_WRONLY|os.O_CREATE, 0600) - if err != nil { - t.Fatalf("error occur: %+v", err) - } - - if _, err := file.WriteString(privateKeyPem); err != nil { - t.Fatalf("error occur: %+v", err) - } - - return keyPath, func() { - os.RemoveAll(tempDir) - } -} diff --git a/application/service/git/mock_git/git.go b/application/service/git/mock_git/git.go deleted file mode 100644 index d34a8bc8..00000000 --- a/application/service/git/mock_git/git.go +++ /dev/null @@ -1,47 +0,0 @@ -// Code generated by MockGen. DO NOT EDIT. -// Source: application/service/git/git.go - -// Package mock_git is a generated GoMock package. -package mock_git - -import ( - context "github.com/duck8823/duci/application/context" - git "github.com/duck8823/duci/application/service/git" - gomock "github.com/golang/mock/gomock" - 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 -} - -// Clone mocks base method -func (m *MockService) Clone(ctx context.Context, dir string, src git.TargetSource) error { - ret := m.ctrl.Call(m, "Clone", ctx, dir, src) - ret0, _ := ret[0].(error) - return ret0 -} - -// Clone indicates an expected call of Clone -func (mr *MockServiceMockRecorder) Clone(ctx, dir, src interface{}) *gomock.Call { - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Clone", reflect.TypeOf((*MockService)(nil).Clone), ctx, dir, src) -} diff --git a/application/service/git/progress_logger.go b/application/service/git/progress_logger.go deleted file mode 100644 index 79b6ca04..00000000 --- a/application/service/git/progress_logger.go +++ /dev/null @@ -1,24 +0,0 @@ -package git - -import ( - "github.com/duck8823/duci/infrastructure/logger" - "github.com/google/uuid" - "regexp" -) - -// Regexp to remove CR or later (inline progress) -var rep = regexp.MustCompile("\r.*$") - -// ProgressLogger is a writer for git progress -type ProgressLogger struct { - uuid uuid.UUID -} - -// Write a log without CR or later. -func (l *ProgressLogger) Write(p []byte) (n int, err error) { - log := rep.ReplaceAllString(string(p), "") - if len(log) > 0 { - logger.Info(l.uuid, log) - } - return 0, nil -} diff --git a/application/service/git/progress_logger_test.go b/application/service/git/progress_logger_test.go deleted file mode 100644 index 03b37fd2..00000000 --- a/application/service/git/progress_logger_test.go +++ /dev/null @@ -1,56 +0,0 @@ -package git_test - -import ( - "github.com/duck8823/duci/application/service/git" - "github.com/duck8823/duci/infrastructure/logger" - "io" - "io/ioutil" - "os" - "regexp" - "strings" - "testing" -) - -var regex = regexp.MustCompile(`\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}.\d{3}`) - -func TestProgressLogger_Write(t *testing.T) { - // setup - reader, writer, err := os.Pipe() - if err != nil { - t.Fatalf("error occurred. %+v", err) - } - defer reader.Close() - - logger.Writer = writer - - // given - progress := &git.ProgressLogger{} - - // when - progress.Write([]byte("hoge\rfuga")) - writer.Close() - - actual := readLogTrimmedTime(t, reader) - expected := "[00000000-0000-0000-0000-000000000000] \033[1m[INFO]\033[0m hoge" - - // then - if actual != expected { - t.Errorf("must remove CR flag or later. wont: %+v, but got: %+v", expected, actual) - } -} - -func readLogTrimmedTime(t *testing.T, reader io.Reader) string { - t.Helper() - - bytes, err := ioutil.ReadAll(reader) - if err != nil { - t.Fatalf("error occurred. %+v", err) - } - - log := string(bytes) - if !regex.MatchString(log) { - t.Fatalf("invalid format. %+v", log) - } - - return strings.TrimRight(regex.ReplaceAllString(log, ""), "\n") -} diff --git a/application/service/github/alias.go b/application/service/github/alias.go deleted file mode 100644 index acf8f9a9..00000000 --- a/application/service/github/alias.go +++ /dev/null @@ -1,16 +0,0 @@ -package github - -import "github.com/google/go-github/github" - -// Repository is a interface to get information of git repository. -type Repository interface { - GetFullName() string - GetSSHURL() string - GetCloneURL() string -} - -// PullRequest is a type alias of github.PullRequest -type PullRequest = github.PullRequest - -// Status is a type alias of github.RepoStatus -type Status = github.RepoStatus diff --git a/application/service/github/export_test.go b/application/service/github/export_test.go deleted file mode 100644 index cb9d885e..00000000 --- a/application/service/github/export_test.go +++ /dev/null @@ -1,19 +0,0 @@ -package github - -type MockRepo struct { - FullName string - SSHURL string - CloneURL string -} - -func (r *MockRepo) GetFullName() string { - return r.FullName -} - -func (r *MockRepo) GetSSHURL() string { - return r.SSHURL -} - -func (r *MockRepo) GetCloneURL() string { - return r.CloneURL -} diff --git a/application/service/github/github.go b/application/service/github/github.go deleted file mode 100644 index deccd917..00000000 --- a/application/service/github/github.go +++ /dev/null @@ -1,109 +0,0 @@ -package github - -import ( - ctx "context" - "github.com/duck8823/duci/application" - "github.com/duck8823/duci/application/context" - "github.com/duck8823/duci/infrastructure/logger" - "github.com/google/go-github/github" - "github.com/pkg/errors" - "golang.org/x/oauth2" - "path" -) - -// State represents state of commit status -type State = string - -const ( - // PENDING represents pending state. - PENDING State = "pending" - // SUCCESS represents success state. - SUCCESS State = "success" - // ERROR represents error state. - ERROR State = "error" - // FAILURE represents failure state. - FAILURE State = "failure" -) - -// Service describes a github service. -type Service interface { - GetPullRequest(ctx context.Context, repository Repository, num int) (*PullRequest, error) - CreateCommitStatus(ctx context.Context, src *TargetSource, state State, description string) error -} - -type serviceImpl struct { - cli *github.Client -} - -// New returns a github service. -func New() (Service, error) { - ts := oauth2.StaticTokenSource( - &oauth2.Token{AccessToken: string(application.Config.GitHub.APIToken)}, - ) - tc := oauth2.NewClient(ctx.Background(), ts) - - return &serviceImpl{github.NewClient(tc)}, nil -} - -// GetPullRequest returns a pull request with specific repository and number. -func (s *serviceImpl) GetPullRequest(ctx context.Context, repository Repository, num int) (*PullRequest, error) { - name := &RepositoryName{repository.GetFullName()} - owner, err := name.Owner() - if err != nil { - return nil, errors.WithStack(err) - } - repo, err := name.Repo() - if err != nil { - return nil, errors.WithStack(err) - } - pr, resp, err := s.cli.PullRequests.Get( - ctx, - owner, - repo, - num, - ) - if err != nil { - logger.Errorf(ctx.UUID(), "Failed to get pull request no. %v on %s: %+v", num, repository.GetFullName(), resp) - return nil, errors.WithStack(err) - } - return pr, nil -} - -// CreateCommitStatus create commit status to github. -func (s *serviceImpl) CreateCommitStatus(ctx context.Context, src *TargetSource, state State, description string) error { - name := &RepositoryName{src.Repo.GetFullName()} - owner, err := name.Owner() - if err != nil { - return errors.WithStack(err) - } - repo, err := name.Repo() - if err != nil { - return errors.WithStack(err) - } - - taskName := ctx.TaskName() - if len(description) >= 50 { - description = string([]rune(description)[:46]) + "..." - } - targetURL := *ctx.URL() - targetURL.Path = path.Join(targetURL.Path, "logs", ctx.UUID().String()) - targetURLStr := targetURL.String() - status := &Status{ - Context: &taskName, - Description: &description, - State: &state, - TargetURL: &targetURLStr, - } - - if _, _, err := s.cli.Repositories.CreateStatus( - ctx, - owner, - repo, - src.SHA.String(), - status, - ); err != nil { - logger.Errorf(ctx.UUID(), "Failed to create commit status: %+v", err) - return errors.WithStack(err) - } - return nil -} diff --git a/application/service/github/github_test.go b/application/service/github/github_test.go deleted file mode 100644 index a8719fb9..00000000 --- a/application/service/github/github_test.go +++ /dev/null @@ -1,208 +0,0 @@ -package github_test - -import ( - "fmt" - "github.com/duck8823/duci/application/context" - "github.com/duck8823/duci/application/service/github" - "github.com/google/uuid" - "gopkg.in/h2non/gock.v1" - "gopkg.in/src-d/go-git.v4/plumbing" - "io/ioutil" - "net/url" - "testing" -) - -func TestService_GetPullRequest(t *testing.T) { - // setup - s, err := github.New() - if err != nil { - t.Fatalf("error occurred. %+v", err) - } - - t.Run("when github server returns status ok", func(t *testing.T) { - // given - repo := &github.MockRepo{ - FullName: "duck8823/duci", - SSHURL: "git@github.com:duck8823/duci.git", - } - num := 5 - var id int64 = 19 - - // and - gock.New("https://api.github.com"). - Get(fmt.Sprintf("/repos/%s/pulls/%d", repo.FullName, num)). - Reply(200). - JSON(&github.PullRequest{ - ID: &id, - }) - defer gock.Clean() - - // when - pr, err := s.GetPullRequest(context.New("test/task", uuid.New(), &url.URL{}), repo, num) - - // then - if err != nil { - t.Fatalf("error occurred. %+v", err) - } - - if pr.GetID() != id { - t.Errorf("id must be equal %+v, but got %+v. \npr=%+v", id, pr.GetID(), pr) - } - }) - - t.Run("when github server returns status not found", func(t *testing.T) { - // given - repo := &github.MockRepo{ - FullName: "duck8823/duci", - SSHURL: "git@github.com:duck8823/duci.git", - } - num := 5 - - // and - gock.New("https://api.github.com"). - Get(fmt.Sprintf("/repos/%s/pulls/%d", repo.FullName, num)). - Reply(404) - defer gock.Clean() - - // when - pr, err := s.GetPullRequest(context.New("test/task", uuid.New(), &url.URL{}), repo, num) - - // then - if err == nil { - t.Error("error must occur") - } - - if pr != nil { - t.Errorf("pr must nil, but got %+v", pr) - } - }) - - t.Run("with invalid repository", func(t *testing.T) { - // given - repo := &github.MockRepo{ - FullName: "", - } - num := 5 - - // expect - if _, err := s.GetPullRequest(context.New("test/task", uuid.New(), &url.URL{}), repo, num); err == nil { - t.Error("error must occurred. but got nil") - } - }) -} - -func TestService_CreateCommitStatus(t *testing.T) { - // setup - s, err := github.New() - if err != nil { - t.Fatalf("error occurred. %+v", err) - } - - t.Run("when github server returns status ok", func(t *testing.T) { - // given - repo := &github.MockRepo{ - FullName: "duck8823/duci", - } - - // and - gock.New("https://api.github.com"). - Post(fmt.Sprintf("/repos/%s/statuses/%s", repo.FullName, "0000000000000000000000000000000000000000")). - Reply(200) - defer gock.Clean() - - // expect - if err := s.CreateCommitStatus( - context.New("test/task", uuid.New(), &url.URL{}), - &github.TargetSource{Repo: repo, SHA: plumbing.Hash{}}, - github.SUCCESS, - "", - ); err != nil { - t.Errorf("error must not occurred: but got %+v", err) - } - }) - - t.Run("when github server returns status not found", func(t *testing.T) { - // given - repo := &github.MockRepo{ - FullName: "duck8823/duci", - } - - // and - gock.New("https://api.github.com"). - Post(fmt.Sprintf("/repos/%s/statuses/%s", repo.FullName, "0000000000000000000000000000000000000000")). - Reply(404) - defer gock.Clean() - - // expect - if err := s.CreateCommitStatus( - context.New("test/task", uuid.New(), &url.URL{}), - &github.TargetSource{Repo: repo, SHA: plumbing.Hash{}}, - github.SUCCESS, - "", - ); err == nil { - t.Error("errot must occred. but got nil") - } - }) - - t.Run("with invalid repository", func(t *testing.T) { - // given - repo := &github.MockRepo{ - FullName: "", - } - - // expect - if err := s.CreateCommitStatus( - context.New("test/task", uuid.New(), &url.URL{}), - &github.TargetSource{Repo: repo, SHA: plumbing.Hash{}}, - github.SUCCESS, - "", - ); err == nil { - t.Error("error must occurred. but got nil") - } - }) - - t.Run("with long description", func(t *testing.T) { - // given - repo := &github.MockRepo{ - FullName: "duck8823/duci", - } - - // and - taskName := "test/task" - description := "123456789012345678901234567890123456789012345678901234567890" - malformedDescription := "1234567890123456789012345678901234567890123456..." - state := github.SUCCESS - requestID := uuid.New() - logUrl := fmt.Sprintf("http://host:8080/logs/%s", requestID.String()) - - gock.New("https://api.github.com"). - Post(fmt.Sprintf("/repos/%s/statuses/%s", repo.FullName, "0000000000000000000000000000000000000000")). - MatchType("json"). - JSON(&github.Status{ - Context: &taskName, - Description: &malformedDescription, - State: &state, - TargetURL: &logUrl, - }). - Reply(404) - defer gock.Clean() - - // expect - if err := s.CreateCommitStatus( - context.New(taskName, requestID, &url.URL{Scheme: "http", Host: "host:8080"}), - &github.TargetSource{Repo: repo, SHA: plumbing.Hash{}}, - state, - description, - ); err == nil { - t.Error("error must occurred. but got nil") - } - - if !gock.IsDone() { - t.Error("request missing...") - for _, req := range gock.GetUnmatchedRequests() { - bytes, _ := ioutil.ReadAll(req.Body) - t.Logf("%+v", string(bytes)) - } - } - }) -} diff --git a/application/service/github/mock_github/github.go b/application/service/github/mock_github/github.go deleted file mode 100644 index a469778e..00000000 --- a/application/service/github/mock_github/github.go +++ /dev/null @@ -1,60 +0,0 @@ -// Code generated by MockGen. DO NOT EDIT. -// Source: application/service/github/github.go - -// Package mock_github is a generated GoMock package. -package mock_github - -import ( - context "github.com/duck8823/duci/application/context" - github "github.com/duck8823/duci/application/service/github" - gomock "github.com/golang/mock/gomock" - 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 -} - -// GetPullRequest mocks base method -func (m *MockService) GetPullRequest(ctx context.Context, repository github.Repository, num int) (*github.PullRequest, error) { - ret := m.ctrl.Call(m, "GetPullRequest", ctx, repository, num) - ret0, _ := ret[0].(*github.PullRequest) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetPullRequest indicates an expected call of GetPullRequest -func (mr *MockServiceMockRecorder) GetPullRequest(ctx, repository, num interface{}) *gomock.Call { - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPullRequest", reflect.TypeOf((*MockService)(nil).GetPullRequest), ctx, repository, num) -} - -// CreateCommitStatus mocks base method -func (m *MockService) CreateCommitStatus(ctx context.Context, src *github.TargetSource, state github.State, description string) error { - ret := m.ctrl.Call(m, "CreateCommitStatus", ctx, src, state, description) - ret0, _ := ret[0].(error) - return ret0 -} - -// CreateCommitStatus indicates an expected call of CreateCommitStatus -func (mr *MockServiceMockRecorder) CreateCommitStatus(ctx, src, state, description interface{}) *gomock.Call { - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateCommitStatus", reflect.TypeOf((*MockService)(nil).CreateCommitStatus), ctx, src, state, description) -} diff --git a/application/service/github/repository_name.go b/application/service/github/repository_name.go deleted file mode 100644 index aa803f72..00000000 --- a/application/service/github/repository_name.go +++ /dev/null @@ -1,29 +0,0 @@ -package github - -import ( - "fmt" - "strings" -) - -// RepositoryName is a github repository name. -type RepositoryName struct { - FullName string -} - -// Owner get a repository owner. -func (r *RepositoryName) Owner() (string, error) { - ss := strings.Split(r.FullName, "/") - if len(ss) != 2 { - return "", fmt.Errorf("Invalid repository name: %s ", r.FullName) - } - return ss[0], nil -} - -// Repo get a repository name without owner. -func (r *RepositoryName) Repo() (string, error) { - ss := strings.Split(r.FullName, "/") - if len(ss) != 2 { - return "", fmt.Errorf("Invalid repository name: %s ", r.FullName) - } - return ss[1], nil -} diff --git a/application/service/github/repository_name_test.go b/application/service/github/repository_name_test.go deleted file mode 100644 index afa29875..00000000 --- a/application/service/github/repository_name_test.go +++ /dev/null @@ -1,74 +0,0 @@ -package github_test - -import ( - "github.com/duck8823/duci/application/service/github" - "testing" -) - -func TestRepositoryName_Owner(t *testing.T) { - t.Run("with correct name", func(t *testing.T) { - // given - name := &github.RepositoryName{FullName: "hoge/fuga"} - - // and - expected := "hoge" - - // when - owner, err := name.Owner() - - // then - if err != nil { - t.Fatalf("must not error. %+v", err) - } - if owner != expected { - t.Errorf("owner must be equal %+v, but got %+v", expected, owner) - } - }) - - t.Run("with invalid name", func(t *testing.T) { - // given - name := &github.RepositoryName{FullName: "hoge"} - - // when - _, err := name.Owner() - - // then - if err == nil { - t.Fatalf("must error.") - } - }) -} - -func TestRepositoryName_Repo(t *testing.T) { - t.Run("with correct name", func(t *testing.T) { - // given - name := &github.RepositoryName{FullName: "hoge/fuga"} - - // and - expected := "fuga" - - // when - owner, err := name.Repo() - - // then - if err != nil { - t.Fatalf("must not error. %+v", err) - } - if owner != expected { - t.Errorf("owner must be equal %+v, but got %+v", expected, owner) - } - }) - - t.Run("with invalid name", func(t *testing.T) { - // given - name := &github.RepositoryName{FullName: "hoge"} - - // when - _, err := name.Repo() - - // then - if err == nil { - t.Fatalf("must error.") - } - }) -} diff --git a/application/service/github/target_source_test.go b/application/service/github/target_source_test.go deleted file mode 100644 index b8179cf4..00000000 --- a/application/service/github/target_source_test.go +++ /dev/null @@ -1,76 +0,0 @@ -package github_test - -import ( - "github.com/duck8823/duci/application" - "github.com/duck8823/duci/application/service/github" - "gopkg.in/src-d/go-git.v4/plumbing" - "testing" -) - -func TestTargetSource_GetURL(t *testing.T) { - t.Run("without ssh key path", func(t *testing.T) { - // given - application.Config.GitHub.SSHKeyPath = "" - - // and - expected := "clone_url" - - sut := github.TargetSource{ - Repo: &github.MockRepo{SSHURL: "ssh_url", CloneURL: expected}, - } - - // expect - if sut.GetURL() != expected { - t.Errorf("url must equal. wont %#v, but got %#v", expected, sut.GetURL()) - } - }) - - t.Run("without ssh key path", func(t *testing.T) { - // given - application.Config.GitHub.SSHKeyPath = "path/to/ssh_key" - - // and - expected := "ssh_url" - - sut := github.TargetSource{ - Repo: &github.MockRepo{SSHURL: expected, CloneURL: "clone_url"}, - } - - // expect - if sut.GetURL() != expected { - t.Errorf("url must equal. wont %#v, but got %#v", expected, sut.GetURL()) - } - }) -} - -func TestTargetSource_GetRef(t *testing.T) { - // given - expected := "ref" - - // and - sut := github.TargetSource{Ref: expected} - - // when - actual := sut.GetRef() - - // expect - if actual != expected { - t.Errorf("must equal. wont %#v, but got %#v", expected, actual) - } -} - -func TestTargetSource_GetSHA(t *testing.T) { - // given - expected := plumbing.NewHash("hello world.") - - // and - sut := github.TargetSource{SHA: expected} - - // when - actual := sut.GetSHA() - - // expect - if actual != expected { - t.Errorf("must equal. wont %#v, but got %#v", expected, actual) - } -} diff --git a/application/service/job/export_test.go b/application/service/job/export_test.go new file mode 100644 index 00000000..bf741f13 --- /dev/null +++ b/application/service/job/export_test.go @@ -0,0 +1,33 @@ +package job + +import "github.com/duck8823/duci/domain/model/job" + +type StubService struct { + ID string +} + +func (s *StubService) FindBy(_ job.ID) (*job.Job, error) { + return nil, nil +} + +func (s *StubService) Start(_ job.ID) error { + return nil +} + +func (s *StubService) Append(_ job.ID, _ job.LogLine) error { + return nil +} + +func (s *StubService) Finish(_ job.ID) error { + return nil +} + +type ServiceImpl = serviceImpl + +func (s *ServiceImpl) SetRepo(repo job.Repository) (reset func()) { + tmp := s.repo + s.repo = repo + return func() { + s.repo = tmp + } +} diff --git a/application/service/job/mock_job/service.go b/application/service/job/mock_job/service.go new file mode 100644 index 00000000..52b5c4e6 --- /dev/null +++ b/application/service/job/mock_job/service.go @@ -0,0 +1,83 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: application/service/job/service.go + +// Package mock_job_service is a generated GoMock package. +package mock_job_service + +import ( + job "github.com/duck8823/duci/domain/model/job" + gomock "github.com/golang/mock/gomock" + 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 +} + +// FindBy mocks base method +func (m *MockService) FindBy(id job.ID) (*job.Job, error) { + ret := m.ctrl.Call(m, "FindBy", id) + ret0, _ := ret[0].(*job.Job) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// FindBy indicates an expected call of FindBy +func (mr *MockServiceMockRecorder) FindBy(id interface{}) *gomock.Call { + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindBy", reflect.TypeOf((*MockService)(nil).FindBy), id) +} + +// Start mocks base method +func (m *MockService) Start(id job.ID) error { + ret := m.ctrl.Call(m, "Start", id) + ret0, _ := ret[0].(error) + return ret0 +} + +// Start indicates an expected call of Start +func (mr *MockServiceMockRecorder) Start(id interface{}) *gomock.Call { + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Start", reflect.TypeOf((*MockService)(nil).Start), id) +} + +// Append mocks base method +func (m *MockService) Append(id job.ID, line job.LogLine) error { + ret := m.ctrl.Call(m, "Append", id, line) + ret0, _ := ret[0].(error) + return ret0 +} + +// Append indicates an expected call of Append +func (mr *MockServiceMockRecorder) Append(id, line interface{}) *gomock.Call { + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Append", reflect.TypeOf((*MockService)(nil).Append), id, line) +} + +// Finish mocks base method +func (m *MockService) Finish(id job.ID) error { + ret := m.ctrl.Call(m, "Finish", id) + ret0, _ := ret[0].(error) + return ret0 +} + +// Finish indicates an expected call of Finish +func (mr *MockServiceMockRecorder) Finish(id interface{}) *gomock.Call { + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Finish", reflect.TypeOf((*MockService)(nil).Finish), id) +} diff --git a/application/service/job/service.go b/application/service/job/service.go new file mode 100644 index 00000000..51ed22a4 --- /dev/null +++ b/application/service/job/service.go @@ -0,0 +1,11 @@ +package job + +import "github.com/duck8823/duci/domain/model/job" + +// Service represents job service +type Service interface { + FindBy(id job.ID) (*job.Job, error) + Start(id job.ID) error + Append(id job.ID, line job.LogLine) error + Finish(id job.ID) error +} diff --git a/application/service/job/service_impl.go b/application/service/job/service_impl.go new file mode 100644 index 00000000..c428652d --- /dev/null +++ b/application/service/job/service_impl.go @@ -0,0 +1,95 @@ +package job + +import ( + "github.com/duck8823/duci/domain/model/job" + jobDataSource "github.com/duck8823/duci/infrastructure/job" + "github.com/duck8823/duci/internal/container" + "github.com/pkg/errors" +) + +type serviceImpl struct { + repo job.Repository +} + +// Initialize implementation of job service +func Initialize(path string) error { + dataSource, err := jobDataSource.NewDataSource(path) + if err != nil { + return errors.WithStack(err) + } + + service := new(Service) + *service = &serviceImpl{repo: dataSource} + if err := container.Submit(service); err != nil { + return errors.WithStack(err) + } + return nil +} + +// GetInstance returns job service +func GetInstance() (Service, error) { + ins := new(Service) + if err := container.Get(ins); err != nil { + return nil, errors.WithStack(err) + } + return *ins, nil +} + +// FindBy returns job is found by ID +func (s *serviceImpl) FindBy(id job.ID) (*job.Job, error) { + job, err := s.repo.FindBy(id) + if err != nil { + return nil, errors.WithStack(err) + } + return job, nil +} + +// Start store empty job +func (s *serviceImpl) Start(id job.ID) error { + job := job.Job{ID: id, Finished: false} + if err := s.repo.Save(job); err != nil { + return errors.WithStack(err) + } + return nil +} + +// Append log to job +func (s *serviceImpl) Append(id job.ID, line job.LogLine) error { + job, err := s.findOrInitialize(id) + if err != nil { + return errors.WithStack(err) + } + job.AppendLog(line) + + if err := s.repo.Save(*job); err != nil { + return errors.WithStack(err) + } + + return nil +} + +func (s *serviceImpl) findOrInitialize(id job.ID) (*job.Job, error) { + j, err := s.repo.FindBy(id) + if err == job.ErrNotFound { + return &job.Job{ID: id, Finished: false}, nil + } else if err != nil { + return nil, errors.WithStack(err) + } + + return j, nil +} + +// Finish store finished job +func (s *serviceImpl) Finish(id job.ID) error { + job, err := s.repo.FindBy(id) + if err != nil { + return errors.WithStack(err) + } + job.Finish() + + if err := s.repo.Save(*job); err != nil { + return errors.WithStack(err) + } + + return nil +} diff --git a/application/service/job/service_test.go b/application/service/job/service_test.go new file mode 100644 index 00000000..0a588199 --- /dev/null +++ b/application/service/job/service_test.go @@ -0,0 +1,447 @@ +package job_test + +import ( + jobService "github.com/duck8823/duci/application/service/job" + "github.com/duck8823/duci/domain/model/job" + "github.com/duck8823/duci/domain/model/job/mock_job" + "github.com/duck8823/duci/internal/container" + "github.com/golang/mock/gomock" + "github.com/google/go-cmp/cmp" + "github.com/google/uuid" + "github.com/labstack/gommon/random" + "github.com/pkg/errors" + "os" + "path" + "testing" + "time" +) + +func TestInitialize(t *testing.T) { + t.Run("with temporary directory", func(t *testing.T) { + // given + tmpDir := path.Join(os.TempDir(), random.String(16, random.Alphanumeric)) + defer func() { + _ = os.RemoveAll(tmpDir) + }() + + // when + err := jobService.Initialize(tmpDir) + + // then + if err != nil { + t.Errorf("error must be nil, but got %+v", err) + } + }) + + t.Run("with invalid directory", func(t *testing.T) { + // given + tmpDir := path.Join("/path/to/invalid/dir") + defer func() { + _ = os.RemoveAll(tmpDir) + }() + + // when + err := jobService.Initialize(tmpDir) + + // then + if err == nil { + t.Error("error must not be nil") + } + }) +} + +func TestGetInstance(t *testing.T) { + t.Run("when instance is nil", func(t *testing.T) { + // given + container.Clear() + + // when + got, err := jobService.GetInstance() + + // then + if err == nil { + t.Error("error must not be nil") + } + + // and + if got != nil { + t.Errorf("must be nil, but got %+v", err) + } + }) + + t.Run("when instance is not nil", func(t *testing.T) { + // given + want := &jobService.StubService{ + ID: random.String(16, random.Alphanumeric), + } + + // and + container.Override(want) + defer container.Clear() + + // when + got, err := jobService.GetInstance() + + // then + if err != nil { + t.Errorf("error must be nil, but got %+v", err) + } + + // and + if !cmp.Equal(got, want) { + t.Errorf("must be equal, but %+v", cmp.Diff(got, want)) + } + }) +} + +func TestServiceImpl_FindBy(t *testing.T) { + t.Run("when repo returns job", func(t *testing.T) { + // given + id := job.ID(uuid.New()) + + // and + want := &job.Job{ + ID: id, + Finished: true, + Stream: []job.LogLine{ + { + Timestamp: time.Now(), + Message: "hello world", + }, + }, + } + + // and + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + repo := mock_job.NewMockRepository(ctrl) + repo.EXPECT(). + FindBy(gomock.Eq(id)). + Times(1). + Return(want, nil) + + // and + sut := &jobService.ServiceImpl{} + defer sut.SetRepo(repo)() + + // when + got, err := sut.FindBy(id) + + // then + if err != nil { + t.Errorf("error must be nil, but got %+v", err) + } + + // and + if !cmp.Equal(got, want) { + t.Errorf("must be equal but %+v", cmp.Diff(got, want)) + } + }) + + t.Run("when repo returns error", func(t *testing.T) { + // given + id := job.ID(uuid.New()) + + // and + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + repo := mock_job.NewMockRepository(ctrl) + repo.EXPECT(). + FindBy(gomock.Eq(id)). + Times(1). + Return(nil, errors.New("test error")) + + // and + sut := &jobService.ServiceImpl{} + defer sut.SetRepo(repo)() + + // when + got, err := sut.FindBy(id) + + // then + if err == nil { + t.Error("error must not be nil") + } + + // and + if got != nil { + t.Errorf("must be nil, but got %+v", got) + } + }) +} + +func TestServiceImpl_Start(t *testing.T) { + t.Run("when repo returns nil", func(t *testing.T) { + // given + id := job.ID(uuid.New()) + + // and + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + repo := mock_job.NewMockRepository(ctrl) + repo.EXPECT(). + Save(gomock.Eq(job.Job{ID: id, Finished: false})). + Times(1). + Return(nil) + + // and + sut := &jobService.ServiceImpl{} + defer sut.SetRepo(repo)() + + // when + err := sut.Start(id) + + // then + if err != nil { + t.Errorf("error must be nil, but got %+v", err) + } + }) + + t.Run("when repo returns error", func(t *testing.T) { + // given + id := job.ID(uuid.New()) + + // and + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + repo := mock_job.NewMockRepository(ctrl) + repo.EXPECT(). + Save(gomock.Eq(job.Job{ID: id, Finished: false})). + Times(1). + Return(errors.New("test error")) + + // and + sut := &jobService.ServiceImpl{} + defer sut.SetRepo(repo)() + + // when + err := sut.Start(id) + + // then + if err == nil { + t.Error("error must not be nil") + } + }) +} + +func TestServiceImpl_Append(t *testing.T) { + t.Run("when failure find job", func(t *testing.T) { + // given + id := job.ID(uuid.New()) + line := job.LogLine{Timestamp: time.Now(), Message: "Hello Test"} + + // and + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + repo := mock_job.NewMockRepository(ctrl) + repo.EXPECT(). + FindBy(gomock.Eq(id)). + Times(1). + Return(nil, errors.New("test error")) + + // and + sut := &jobService.ServiceImpl{} + defer sut.SetRepo(repo)() + + // when + err := sut.Append(id, line) + + // then + if err == nil { + t.Error("error must not be nil") + } + }) + + t.Run("when failure save job", func(t *testing.T) { + // given + id := job.ID(uuid.New()) + line := job.LogLine{Timestamp: time.Now(), Message: "Hello Test"} + + // and + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + repo := mock_job.NewMockRepository(ctrl) + repo.EXPECT(). + FindBy(gomock.Eq(id)). + Times(1). + Return(&job.Job{ID: id, Finished: false}, nil) + repo.EXPECT(). + Save(gomock.Eq(job.Job{ID: id, Finished: false, Stream: []job.LogLine{line}})). + Times(1). + Return(errors.New("test error")) + + // and + sut := &jobService.ServiceImpl{} + defer sut.SetRepo(repo)() + + // when + err := sut.Append(id, line) + + // then + if err == nil { + t.Error("error must not be nil") + } + }) + + t.Run("when job not found", func(t *testing.T) { + // given + id := job.ID(uuid.New()) + line := job.LogLine{Timestamp: time.Now(), Message: "Hello Test"} + + // and + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + repo := mock_job.NewMockRepository(ctrl) + repo.EXPECT(). + FindBy(gomock.Eq(id)). + Times(1). + Return(nil, job.ErrNotFound) + repo.EXPECT(). + Save(gomock.Eq(job.Job{ID: id, Finished: false, Stream: []job.LogLine{line}})). + Times(1). + Return(errors.New("test error")) + + // and + sut := &jobService.ServiceImpl{} + defer sut.SetRepo(repo)() + + // when + err := sut.Append(id, line) + + // then + if err == nil { + t.Error("error must not be nil") + } + }) + + t.Run("without any error", func(t *testing.T) { + // given + id := job.ID(uuid.New()) + line := job.LogLine{Timestamp: time.Now(), Message: "Hello Test"} + + // and + stored := job.LogLine{Timestamp: time.Now(), Message: "Stored line"} + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + repo := mock_job.NewMockRepository(ctrl) + repo.EXPECT(). + FindBy(gomock.Eq(id)). + Times(1). + Return(&job.Job{ID: id, Finished: false, Stream: []job.LogLine{stored}}, nil) + repo.EXPECT(). + Save(gomock.Eq(job.Job{ID: id, Finished: false, Stream: []job.LogLine{stored, line}})). + Times(1). + Return(nil) + + // and + sut := &jobService.ServiceImpl{} + defer sut.SetRepo(repo)() + + // when + err := sut.Append(id, line) + + // then + if err != nil { + t.Errorf("error must be nil, but got %+v", err) + } + }) +} + +func TestServiceImpl_Finish(t *testing.T) { + t.Run("when find job, returns error", func(t *testing.T) { + // given + id := job.ID(uuid.New()) + + // and + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + repo := mock_job.NewMockRepository(ctrl) + repo.EXPECT(). + FindBy(gomock.Eq(id)). + Times(1). + Return(nil, errors.New("test error")) + + // and + sut := &jobService.ServiceImpl{} + defer sut.SetRepo(repo)() + + // when + err := sut.Finish(id) + + // then + if err == nil { + t.Error("error must not be nil") + } + }) + + t.Run("when save, returns error", func(t *testing.T) { + // given + id := job.ID(uuid.New()) + + // and + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + repo := mock_job.NewMockRepository(ctrl) + repo.EXPECT(). + FindBy(gomock.Eq(id)). + Times(1). + Return(&job.Job{ID: id, Finished: false}, nil) + repo.EXPECT(). + Save(gomock.Eq(job.Job{ID: id, Finished: true})). + Times(1). + Return(errors.New("test error")) + + // and + sut := &jobService.ServiceImpl{} + defer sut.SetRepo(repo)() + + // when + err := sut.Finish(id) + + // then + if err == nil { + t.Error("error must not be nil") + } + }) + + t.Run("without any error", func(t *testing.T) { + // given + id := job.ID(uuid.New()) + + // and + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + repo := mock_job.NewMockRepository(ctrl) + repo.EXPECT(). + FindBy(gomock.Eq(id)). + Times(1). + Return(&job.Job{ID: id, Finished: false}, nil) + repo.EXPECT(). + Save(gomock.Eq(job.Job{ID: id, Finished: true})). + Return(nil) + + // and + sut := &jobService.ServiceImpl{} + defer sut.SetRepo(repo)() + + // when + err := sut.Finish(id) + + // then + if err != nil { + t.Errorf("error must be nil, but got %+v", err) + } + }) +} diff --git a/application/service/logstore/export_test.go b/application/service/logstore/export_test.go deleted file mode 100644 index 73d07c15..00000000 --- a/application/service/logstore/export_test.go +++ /dev/null @@ -1,13 +0,0 @@ -package logstore - -import "github.com/duck8823/duci/infrastructure/store" - -type StoreServiceImpl = storeServiceImpl - -func (s *storeServiceImpl) SetDB(db store.Store) (reset func()) { - tmp := s.db - s.db = db - return func() { - s.db = tmp - } -} diff --git a/application/service/logstore/mock_logstore/store.go b/application/service/logstore/mock_logstore/store.go deleted file mode 100644 index 75f89edd..00000000 --- a/application/service/logstore/mock_logstore/store.go +++ /dev/null @@ -1,96 +0,0 @@ -// Code generated by MockGen. DO NOT EDIT. -// Source: application/service/logstore/store.go - -// Package mock_logstore is a generated GoMock package. -package mock_logstore - -import ( - model "github.com/duck8823/duci/data/model" - gomock "github.com/golang/mock/gomock" - uuid "github.com/google/uuid" - 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 -} - -// Get mocks base method -func (m *MockService) Get(uuid uuid.UUID) (*model.Job, error) { - ret := m.ctrl.Call(m, "Get", uuid) - ret0, _ := ret[0].(*model.Job) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// Get indicates an expected call of Get -func (mr *MockServiceMockRecorder) Get(uuid interface{}) *gomock.Call { - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockService)(nil).Get), uuid) -} - -// Append mocks base method -func (m *MockService) Append(uuid uuid.UUID, message model.Message) error { - ret := m.ctrl.Call(m, "Append", uuid, message) - ret0, _ := ret[0].(error) - return ret0 -} - -// Append indicates an expected call of Append -func (mr *MockServiceMockRecorder) Append(uuid, message interface{}) *gomock.Call { - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Append", reflect.TypeOf((*MockService)(nil).Append), uuid, message) -} - -// Start mocks base method -func (m *MockService) Start(uuid uuid.UUID) error { - ret := m.ctrl.Call(m, "Start", uuid) - ret0, _ := ret[0].(error) - return ret0 -} - -// Start indicates an expected call of Start -func (mr *MockServiceMockRecorder) Start(uuid interface{}) *gomock.Call { - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Start", reflect.TypeOf((*MockService)(nil).Start), uuid) -} - -// Finish mocks base method -func (m *MockService) Finish(uuid uuid.UUID) error { - ret := m.ctrl.Call(m, "Finish", uuid) - ret0, _ := ret[0].(error) - return ret0 -} - -// Finish indicates an expected call of Finish -func (mr *MockServiceMockRecorder) Finish(uuid interface{}) *gomock.Call { - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Finish", reflect.TypeOf((*MockService)(nil).Finish), uuid) -} - -// Close mocks base method -func (m *MockService) Close() error { - ret := m.ctrl.Call(m, "Close") - ret0, _ := ret[0].(error) - return ret0 -} - -// Close indicates an expected call of Close -func (mr *MockServiceMockRecorder) Close() *gomock.Call { - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Close", reflect.TypeOf((*MockService)(nil).Close)) -} diff --git a/application/service/logstore/store.go b/application/service/logstore/store.go deleted file mode 100644 index 59aeb5e3..00000000 --- a/application/service/logstore/store.go +++ /dev/null @@ -1,127 +0,0 @@ -package logstore - -import ( - "bytes" - "encoding/json" - "github.com/duck8823/duci/application" - "github.com/duck8823/duci/data/model" - "github.com/duck8823/duci/infrastructure/store" - "github.com/google/uuid" - "github.com/pkg/errors" - "github.com/syndtr/goleveldb/leveldb" -) - -// Level describes a log level. -type Level = string - -// Service is a interface describe store for log. -type Service interface { - Get(uuid uuid.UUID) (*model.Job, error) - Append(uuid uuid.UUID, message model.Message) error - Start(uuid uuid.UUID) error - Finish(uuid uuid.UUID) error - Close() error -} - -type storeServiceImpl struct { - db store.Store -} - -// New returns a implementation of Service interface. -func New() (Service, error) { - database, err := leveldb.OpenFile(application.Config.Server.DatabasePath, nil) - if err != nil { - return nil, errors.WithStack(err) - } - return &storeServiceImpl{database}, nil -} - -// Append a message to store. -func (s *storeServiceImpl) Append(uuid uuid.UUID, message model.Message) error { - job, err := s.findOrInitialize(uuid) - if err != nil { - return errors.WithStack(err) - } - - job.Stream = append(job.Stream, message) - - data, err := json.Marshal(job) - if err != nil { - return errors.WithStack(err) - } - if err := s.db.Put([]byte(uuid.String()), data, nil); err != nil { - return errors.WithStack(err) - } - return nil -} - -func (s *storeServiceImpl) findOrInitialize(uuid uuid.UUID) (*model.Job, error) { - job := &model.Job{} - - data, err := s.db.Get([]byte(uuid.String()), nil) - if err == store.NotFoundError { - return job, nil - } - if err != nil { - return nil, errors.WithStack(err) - } - if err := json.NewDecoder(bytes.NewReader(data)).Decode(job); err != nil { - return nil, errors.WithStack(err) - } - return job, nil -} - -// Get a job from store. -func (s *storeServiceImpl) Get(uuid uuid.UUID) (*model.Job, error) { - data, err := s.db.Get([]byte(uuid.String()), nil) - if err != nil { - return nil, errors.WithStack(err) - } - - job := &model.Job{} - if err := json.NewDecoder(bytes.NewReader(data)).Decode(job); err != nil { - return nil, errors.WithStack(err) - } - return job, nil -} - -// Start stores initialized job to store. -func (s *storeServiceImpl) Start(uuid uuid.UUID) error { - started, _ := json.Marshal(&model.Job{Finished: false}) - if err := s.db.Put([]byte(uuid.String()), started, nil); err != nil { - return errors.WithStack(err) - } - return nil -} - -// Finish stores with finished flag. -func (s *storeServiceImpl) Finish(uuid uuid.UUID) error { - data, err := s.db.Get([]byte(uuid.String()), nil) - if err != nil { - return errors.WithStack(err) - } - - job := &model.Job{} - if err := json.NewDecoder(bytes.NewReader(data)).Decode(job); err != nil { - return errors.WithStack(err) - } - - job.Finished = true - - finished, err := json.Marshal(job) - if err != nil { - return errors.WithStack(err) - } - if err := s.db.Put([]byte(uuid.String()), finished, nil); err != nil { - return errors.WithStack(err) - } - return nil -} - -// Close a data store. -func (s *storeServiceImpl) Close() error { - if err := s.db.Close(); err != nil { - return errors.WithStack(err) - } - return nil -} diff --git a/application/service/logstore/store_test.go b/application/service/logstore/store_test.go deleted file mode 100644 index b944d3b1..00000000 --- a/application/service/logstore/store_test.go +++ /dev/null @@ -1,566 +0,0 @@ -package logstore_test - -import ( - "bytes" - "encoding/json" - "errors" - "github.com/duck8823/duci/application/service/logstore" - "github.com/duck8823/duci/data/model" - "github.com/duck8823/duci/infrastructure/store" - "github.com/duck8823/duci/infrastructure/store/mock_store" - "github.com/golang/mock/gomock" - "github.com/google/go-cmp/cmp" - "github.com/google/uuid" - "testing" - "time" -) - -func TestNewStoreService(t *testing.T) { - // when - actual, err := logstore.New() - - // then - if _, ok := actual.(*logstore.StoreServiceImpl); !ok { - t.Error("must be a Service, but not.") - } - - if err != nil { - t.Errorf("error must not occur, but got %+v", err) - } -} - -func TestStoreServiceImpl_Append(t *testing.T) { - // setup - ctrl := gomock.NewController(t) - mockStore := mock_store.NewMockStore(ctrl) - - service := &logstore.StoreServiceImpl{} - reset := service.SetDB(mockStore) - defer reset() - - t.Run("when store returns correct data", func(t *testing.T) { - // given - jst, err := time.LoadLocation("Asia/Tokyo") - if err != nil { - t.Fatalf("error occurred: %+v", err) - } - - date1 := time.Date(2020, time.April, 1, 12, 3, 00, 00, jst) - date2 := time.Date(1987, time.March, 27, 19, 19, 00, 00, jst) - job := &model.Job{ - Finished: false, - Stream: []model.Message{{Time: date1, Text: "Hello World."}}, - } - storedData, err := json.Marshal(job) - if err != nil { - t.Fatalf("error occurred: %+v", err) - } - - // and - id, err := uuid.NewRandom() - if err != nil { - t.Fatalf("error occurred: %+v", err) - } - storedID := []byte(id.String()) - - // and - expected := &model.Job{ - Finished: false, - Stream: []model.Message{ - {Time: date1, Text: "Hello World."}, - {Time: date2, Text: "Hello Testing."}, - }, - } - expectedData, err := json.Marshal(expected) - if err != nil { - t.Fatalf("error occurred: %+v", err) - } - - // and - mockStore.EXPECT(). - Get(gomock.Eq(storedID), gomock.Nil()). - Times(1). - Return(storedData, nil) - mockStore.EXPECT(). - Put(gomock.Eq(storedID), gomock.Eq(expectedData), gomock.Nil()). - Times(1). - Return(nil) - mockStore.EXPECT(). - Put(gomock.Eq(storedID), gomock.Not(expectedData), gomock.Nil()). - Do(func(_ interface{}, data []byte, _ interface{}) { - t.Logf("wont: %s", string(expectedData)) - t.Logf("got: %s", string(data)) - }). - Return(errors.New("must not call this")) - - // expect - if err := service.Append(id, model.Message{Time: date2, Text: "Hello Testing."}); err != nil { - t.Errorf("error must not occur, but got %+v", err) - } - }) - - t.Run("when value not found", func(t *testing.T) { - // given - id, err := uuid.NewRandom() - if err != nil { - t.Fatalf("error occurred: %+v", err) - } - storedID := []byte(id.String()) - - // and - expected := &model.Job{ - Finished: false, - Stream: []model.Message{ - {Time: time.Now(), Text: "Hello Testing."}, - }, - } - expectedData, err := json.Marshal(expected) - if err != nil { - t.Fatalf("error occurred: %+v", err) - } - - // and - mockStore.EXPECT(). - Get(gomock.Eq(storedID), gomock.Nil()). - Times(1). - Return(nil, store.NotFoundError) - mockStore.EXPECT(). - Put(gomock.Eq(storedID), gomock.Eq(expectedData), gomock.Nil()). - Times(1). - Return(nil) - mockStore.EXPECT(). - Put(gomock.Eq(storedID), gomock.Not(expectedData), gomock.Nil()). - Do(func(_ interface{}, data []byte, _ interface{}) { - t.Logf("wont: %s", string(expectedData)) - t.Logf("got: %s", string(data)) - }). - Return(errors.New("must not call this")) - - // expect - if err := service.Append(id, expected.Stream[0]); err != nil { - t.Errorf("error must not occur, but got %+v", err) - } - }) - - t.Run("when store.Get returns error", func(t *testing.T) { - // given - id, err := uuid.NewRandom() - if err != nil { - t.Fatalf("error occurred: %+v", err) - } - storedID := []byte(id.String()) - - // and - mockStore.EXPECT(). - Get(gomock.Eq(storedID), gomock.Nil()). - Times(1). - Return(nil, errors.New("hello testing")) - - // expect - if err := service.Append(id, model.Message{Text: "Hello Testing."}); err == nil { - t.Error("error must occur, but got nil") - } - }) - - t.Run("when store.Get returns invalid data", func(t *testing.T) { - // given - storedData := []byte("invalid data") - - // and - id, err := uuid.NewRandom() - if err != nil { - t.Fatalf("error occurred: %+v", err) - } - storedID := []byte(id.String()) - - // and - mockStore.EXPECT(). - Get(gomock.Eq(storedID), gomock.Nil()). - Times(1). - Return(storedData, nil) - mockStore.EXPECT(). - Put(gomock.Eq(storedID), gomock.Any(), gomock.Nil()). - Times(1). - Do(func(_, _, _ interface{}) { - t.Fatalf("must not call this.") - }) - - // expect - if err := service.Append(id, model.Message{Text: "Hello Testing."}); err == nil { - t.Error("error must occur, but got nil") - } - }) - - t.Run("when store.Put returns invalid data", func(t *testing.T) { - // given - job := &model.Job{ - Finished: false, - Stream: []model.Message{{Time: time.Now(), Text: "Hello World."}}, - } - storedData, err := json.Marshal(job) - if err != nil { - t.Fatalf("error occurred: %+v", err) - } - - // and - id, err := uuid.NewRandom() - if err != nil { - t.Fatalf("error occurred: %+v", err) - } - storedID := []byte(id.String()) - - // and - mockStore.EXPECT(). - Get(gomock.Eq(storedID), gomock.Nil()). - Times(1). - Return(storedData, nil) - mockStore.EXPECT(). - Put(gomock.Eq(storedID), gomock.Any(), gomock.Nil()). - Times(1). - Return(errors.New("hello error")) - - // expect - if err := service.Append(id, model.Message{Text: "Hello Testing."}); err == nil { - t.Error("error must occur, but got nil") - } - }) -} - -func TestStoreServiceImpl_Get(t *testing.T) { - // setup - ctrl := gomock.NewController(t) - mockStore := mock_store.NewMockStore(ctrl) - - service := &logstore.StoreServiceImpl{} - reset := service.SetDB(mockStore) - defer reset() - - t.Run("with error", func(t *testing.T) { - // setup - id, err := uuid.NewRandom() - if err != nil { - t.Fatalf("error occurred: %+v", err) - } - storedID := []byte(id.String()) - - // given - mockStore.EXPECT(). - Get(gomock.Eq(storedID), gomock.Nil()). - Times(1). - Return(nil, errors.New("hello testing")) - - // when - actual, err := service.Get(id) - - // then - if actual != nil { - t.Errorf("job must be nil, but got %+v", actual) - } - - if err == nil { - t.Error("error must occur, but got nil") - } - }) - - t.Run("with invalid data", func(t *testing.T) { - // given - storedData := []byte("invalid data") - - // and - id, err := uuid.NewRandom() - if err != nil { - t.Fatalf("error occurred: %+v", err) - } - storedID := []byte(id.String()) - - // and - mockStore.EXPECT(). - Get(gomock.Eq(storedID), gomock.Nil()). - Times(1). - Return(storedData, nil) - - // when - actual, err := service.Get(id) - - // then - if err == nil { - t.Error("error must occur, but got nil") - } - - if actual != nil { - t.Errorf("job must be nil, but got %+v", err) - } - }) - - t.Run("with stored data", func(t *testing.T) { - // given - expected := &model.Job{ - Finished: false, - Stream: []model.Message{{Time: time.Now(), Text: "Hello World."}}, - } - storedData, err := json.Marshal(expected) - if err != nil { - t.Fatalf("error occurred: %+v", err) - } - - // and - id, err := uuid.NewRandom() - if err != nil { - t.Fatalf("error occurred: %+v", err) - } - storedID := []byte(id.String()) - - // and - mockStore.EXPECT(). - Get(gomock.Eq(storedID), gomock.Nil()). - Times(1). - Return(storedData, nil) - - // when - actual, err := service.Get(id) - - // then - if err != nil { - t.Errorf("error must not occur, but got %+v", err) - } - - if !cmp.Equal(actual.Stream[0].Time, expected.Stream[0].Time) { - t.Errorf("wont %+v, but got %+v", expected, actual) - } - }) -} - -func TestStoreServiceImpl_Start(t *testing.T) { - // setup - ctrl := gomock.NewController(t) - mockStore := mock_store.NewMockStore(ctrl) - - service := &logstore.StoreServiceImpl{} - reset := service.SetDB(mockStore) - defer reset() - - t.Run("when put success", func(t *testing.T) { - // given - id, err := uuid.NewRandom() - if err != nil { - t.Fatalf("error occurred: %+v", err) - } - storedID := []byte(id.String()) - - // and - expected, err := json.Marshal(&model.Job{Finished: false}) - if err != nil { - t.Fatalf("error occurred: %+v", err) - } - - // and - mockStore.EXPECT(). - Put(gomock.Eq(storedID), gomock.Eq(expected), gomock.Nil()). - Times(1). - Return(nil) - - // when - err = service.Start(id) - - // then - if err != nil { - t.Errorf("error must not occur, but got %+v", err) - } - }) - - t.Run("when put fail", func(t *testing.T) { - // given - id, err := uuid.NewRandom() - if err != nil { - t.Fatalf("error occurred: %+v", err) - } - - // and - mockStore.EXPECT(). - Put(gomock.Any(), gomock.Any(), gomock.Any()). - Times(1). - Return(errors.New("test error")) - - // when - err = service.Start(id) - - // then - if err == nil { - t.Error("error must occur, but got nil") - } - }) -} - -func TestStoreServiceImpl_Finish(t *testing.T) { - // setup - ctrl := gomock.NewController(t) - mockStore := mock_store.NewMockStore(ctrl) - - service := &logstore.StoreServiceImpl{} - reset := service.SetDB(mockStore) - defer reset() - - t.Run("with error", func(t *testing.T) { - // setup - id, err := uuid.NewRandom() - if err != nil { - t.Fatalf("error occurred: %+v", err) - } - storedID := []byte(id.String()) - - // given - mockStore.EXPECT(). - Get(gomock.Eq(storedID), gomock.Nil()). - Times(1). - Return(nil, errors.New("hello testing")) - - // expect - if err := service.Finish(id); err == nil { - t.Error("error must occur, but got nil") - } - }) - - t.Run("with invalid data", func(t *testing.T) { - // given - storedData := []byte("invalid data") - - // and - id, err := uuid.NewRandom() - if err != nil { - t.Fatalf("error occurred: %+v", err) - } - storedID := []byte(id.String()) - - // and - mockStore.EXPECT(). - Get(gomock.Eq(storedID), gomock.Nil()). - Times(1). - Return(storedData, nil) - - // expect - if err := service.Finish(id); err == nil { - t.Error("error must occur, but got nil") - } - }) - - t.Run("with stored data", func(t *testing.T) { - // given - given := &model.Job{ - Finished: false, - Stream: []model.Message{{Time: time.Now(), Text: "Hello World."}}, - } - storedData, err := json.Marshal(given) - if err != nil { - t.Fatalf("error occurred: %+v", err) - } - - // and - expected := &model.Job{ - Finished: true, - Stream: given.Stream, - } - expectedData, err := json.Marshal(expected) - if err != nil { - t.Fatalf("error occurred: %+v", err) - } - - // and - id, err := uuid.NewRandom() - if err != nil { - t.Fatalf("error occurred: %+v", err) - } - storedID := []byte(id.String()) - - // and - mockStore.EXPECT(). - Get(gomock.Eq(storedID), gomock.Nil()). - Times(1). - Return(storedData, nil) - mockStore.EXPECT(). - Put(gomock.Eq(storedID), gomock.Eq(expectedData), gomock.Nil()). - Times(1) - mockStore.EXPECT(). - Put(gomock.Eq(storedID), gomock.Not(expectedData), gomock.Nil()). - Do(func(_, arg, _ interface{}) { - actual := &model.Job{} - json.NewDecoder(bytes.NewReader(arg.([]byte))).Decode(actual) - t.Fatalf("invalid argument: wont %+v, but got %+v", expected, actual) - }) - - // when - err = service.Finish(id) - - // and - if err != nil { - t.Errorf("error must not occur, but got %+v", err) - } - }) - - t.Run("when failed put", func(t *testing.T) { - // given - given := &model.Job{ - Finished: false, - Stream: []model.Message{{Time: time.Now(), Text: "Hello World."}}, - } - storedData, err := json.Marshal(given) - if err != nil { - t.Fatalf("error occurred: %+v", err) - } - - // and - id, err := uuid.NewRandom() - if err != nil { - t.Fatalf("error occurred: %+v", err) - } - storedID := []byte(id.String()) - - // and - mockStore.EXPECT(). - Get(gomock.Eq(storedID), gomock.Nil()). - Times(1). - Return(storedData, nil) - mockStore.EXPECT(). - Put(gomock.Eq(storedID), gomock.Any(), gomock.Nil()). - Times(1). - Return(errors.New("hello testing")) - - // expect - if err := service.Finish(id); err == nil { - t.Error("error must occur, but got nil") - } - }) -} - -func TestStoreServiceImpl_Close(t *testing.T) { - // setup - ctrl := gomock.NewController(t) - mockStore := mock_store.NewMockStore(ctrl) - - service := &logstore.StoreServiceImpl{} - reset := service.SetDB(mockStore) - defer reset() - - t.Run("with error", func(t *testing.T) { - // given - mockStore.EXPECT(). - Close(). - Return(errors.New("hello testing")) - - // expect - if err := service.Close(); err == nil { - t.Errorf("error must not occur, but got %+v", err) - } - }) - - t.Run("without error", func(t *testing.T) { - // given - mockStore.EXPECT(). - Close(). - Return(nil) - - // expect - if err := service.Close(); err != nil { - t.Error("error must occur, but got nil") - } - }) -} diff --git a/application/service/runner/export_test.go b/application/service/runner/export_test.go deleted file mode 100644 index 84d08da7..00000000 --- a/application/service/runner/export_test.go +++ /dev/null @@ -1,39 +0,0 @@ -package runner - -import ( - "github.com/duck8823/duci/infrastructure/docker" - "io" - "time" -) - -type MockRepo struct { - FullName string - SSHURL string - CloneURL string -} - -func (r *MockRepo) GetFullName() string { - return r.FullName -} - -func (r *MockRepo) GetSSHURL() string { - return r.SSHURL -} - -func (r *MockRepo) GetCloneURL() string { - return r.CloneURL -} - -type MockBuildLog struct { -} - -func (l *MockBuildLog) ReadLine() (*docker.LogLine, error) { - return &docker.LogLine{Timestamp: time.Now(), Message: []byte("{\"stream\":\"Hello World,\"}")}, io.EOF -} - -type MockJobLog struct { -} - -func (l *MockJobLog) ReadLine() (*docker.LogLine, error) { - return &docker.LogLine{Timestamp: time.Now(), Message: []byte("Hello World,")}, io.EOF -} diff --git a/application/service/runner/mock_runner/runner.go b/application/service/runner/mock_runner/runner.go deleted file mode 100644 index 4366493c..00000000 --- a/application/service/runner/mock_runner/runner.go +++ /dev/null @@ -1,52 +0,0 @@ -// Code generated by MockGen. DO NOT EDIT. -// Source: application/service/runner/runner.go - -// Package mock_runner is a generated GoMock package. -package mock_runner - -import ( - context "github.com/duck8823/duci/application/context" - github "github.com/duck8823/duci/application/service/github" - gomock "github.com/golang/mock/gomock" - reflect "reflect" -) - -// MockRunner is a mock of Runner interface -type MockRunner struct { - ctrl *gomock.Controller - recorder *MockRunnerMockRecorder -} - -// MockRunnerMockRecorder is the mock recorder for MockRunner -type MockRunnerMockRecorder struct { - mock *MockRunner -} - -// NewMockRunner creates a new mock instance -func NewMockRunner(ctrl *gomock.Controller) *MockRunner { - mock := &MockRunner{ctrl: ctrl} - mock.recorder = &MockRunnerMockRecorder{mock} - return mock -} - -// EXPECT returns an object that allows the caller to indicate expected use -func (m *MockRunner) EXPECT() *MockRunnerMockRecorder { - return m.recorder -} - -// Run mocks base method -func (m *MockRunner) Run(ctx context.Context, src *github.TargetSource, command ...string) error { - varargs := []interface{}{ctx, src} - for _, a := range command { - varargs = append(varargs, a) - } - ret := m.ctrl.Call(m, "Run", varargs...) - ret0, _ := ret[0].(error) - return ret0 -} - -// Run indicates an expected call of Run -func (mr *MockRunnerMockRecorder) Run(ctx, src interface{}, command ...interface{}) *gomock.Call { - varargs := append([]interface{}{ctx, src}, command...) - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Run", reflect.TypeOf((*MockRunner)(nil).Run), varargs...) -} diff --git a/application/service/runner/runner.go b/application/service/runner/runner.go deleted file mode 100644 index aa6d8052..00000000 --- a/application/service/runner/runner.go +++ /dev/null @@ -1,218 +0,0 @@ -package runner - -import ( - "bytes" - "github.com/duck8823/duci/application" - "github.com/duck8823/duci/application/context" - "github.com/duck8823/duci/application/semaphore" - "github.com/duck8823/duci/application/service/docker" - "github.com/duck8823/duci/application/service/git" - "github.com/duck8823/duci/application/service/github" - "github.com/duck8823/duci/application/service/logstore" - "github.com/duck8823/duci/data/model" - "github.com/duck8823/duci/infrastructure/archive/tar" - "github.com/duck8823/duci/infrastructure/logger" - "github.com/labstack/gommon/random" - "github.com/pkg/errors" - "gopkg.in/yaml.v2" - "io" - "io/ioutil" - "os" - "path/filepath" -) - -// ErrFailure is a error describes task failure. -var ErrFailure = errors.New("Task Failure") - -// Runner is a interface describes task runner. -type Runner interface { - Run(ctx context.Context, src *github.TargetSource, command ...string) error -} - -// DockerRunner represents a runner implement for docker. -type DockerRunner struct { - Git git.Service - GitHub github.Service - Docker docker.Service - LogStore logstore.Service - BaseWorkDir string -} - -// Run task in docker container. -func (r *DockerRunner) Run(ctx context.Context, src *github.TargetSource, command ...string) error { - if err := r.LogStore.Start(ctx.UUID()); err != nil { - r.GitHub.CreateCommitStatus(ctx, src, github.ERROR, err.Error()) - return errors.WithStack(err) - } - - errs := make(chan error, 1) - - timeout, cancel := context.WithTimeout(ctx, application.Config.Timeout()) - defer cancel() - - go func() { - semaphore.Acquire() - errs <- r.run(timeout, src, command...) - semaphore.Release() - }() - - select { - case <-timeout.Done(): - r.timeout(timeout, src) - return timeout.Err() - case err := <-errs: - r.finish(ctx, src, err) - return err - } -} - -func (r *DockerRunner) run(ctx context.Context, src *github.TargetSource, command ...string) error { - workDir := filepath.Join(r.BaseWorkDir, random.String(36, random.Alphanumeric)) - - if err := r.Git.Clone(ctx, workDir, src); err != nil { - return errors.WithStack(err) - } - - r.GitHub.CreateCommitStatus(ctx, src, github.PENDING, "started job") - - if err := r.dockerBuild(ctx, workDir, src.Repo); err != nil { - return errors.WithStack(err) - } - - conID, err := r.dockerRun(ctx, workDir, src.Repo, command) - if err != nil { - return errors.WithStack(err) - } - - code, err := r.Docker.ExitCode(ctx, conID) - if err != nil { - return errors.WithStack(err) - } - if err := r.Docker.Rm(ctx, conID); err != nil { - return errors.WithStack(err) - } - if code != 0 { - return ErrFailure - } - - return err -} - -func (r *DockerRunner) dockerBuild(ctx context.Context, dir string, repo github.Repository) error { - tarball, err := createTarball(dir) - if err != nil { - return errors.WithStack(err) - } - defer tarball.Close() - - tag := docker.Tag(repo.GetFullName()) - buildLog, err := r.Docker.Build(ctx, tarball, tag, dockerfilePath(dir)) - if err != nil { - return errors.WithStack(err) - } - if err := r.logAppend(ctx, buildLog); err != nil { - return errors.WithStack(err) - } - return nil -} - -func createTarball(workDir string) (*os.File, error) { - tarFilePath := filepath.Join(workDir, "duci.tar") - writeFile, err := os.OpenFile(tarFilePath, os.O_RDWR|os.O_CREATE, 0600) - if err != nil { - return nil, errors.WithStack(err) - } - defer writeFile.Close() - - if err := tar.Create(workDir, writeFile); err != nil { - return nil, errors.WithStack(err) - } - - readFile, _ := os.Open(tarFilePath) - return readFile, nil -} - -func dockerfilePath(workDir string) docker.Dockerfile { - dockerfile := "./Dockerfile" - if exists(filepath.Join(workDir, ".duci/Dockerfile")) { - dockerfile = ".duci/Dockerfile" - } - return docker.Dockerfile(dockerfile) -} - -func (r *DockerRunner) dockerRun(ctx context.Context, dir string, repo github.Repository, cmd docker.Command) (docker.ContainerID, error) { - opts, err := runtimeOpts(dir) - if err != nil { - return "", errors.WithStack(err) - } - - tag := docker.Tag(repo.GetFullName()) - conID, runLog, err := r.Docker.Run(ctx, opts, tag, cmd) - if err != nil { - return conID, errors.WithStack(err) - } - if err := r.logAppend(ctx, runLog); err != nil { - return conID, errors.WithStack(err) - } - return conID, nil -} - -func runtimeOpts(workDir string) (docker.RuntimeOptions, error) { - var opts docker.RuntimeOptions - - if !exists(filepath.Join(workDir, ".duci/config.yml")) { - return opts, nil - } - content, err := ioutil.ReadFile(filepath.Join(workDir, ".duci/config.yml")) - if err != nil { - return opts, errors.WithStack(err) - } - content = []byte(os.ExpandEnv(string(content))) - if err := yaml.NewDecoder(bytes.NewReader(content)).Decode(&opts); err != nil { - return opts, errors.WithStack(err) - } - return opts, nil -} - -func (r *DockerRunner) logAppend(ctx context.Context, log docker.Log) error { - for { - line, err := log.ReadLine() - if err != nil && err != io.EOF { - logger.Debugf(ctx.UUID(), "skip read line with error: %s", err.Error()) - continue - } - logger.Info(ctx.UUID(), string(line.Message)) - if err := r.LogStore.Append(ctx.UUID(), model.Message{Time: line.Timestamp, Text: string(line.Message)}); err != nil { - return errors.WithStack(err) - } - if err == io.EOF { - return nil - } - } -} - -func (r *DockerRunner) timeout(ctx context.Context, src *github.TargetSource) { - if ctx.Err() != nil { - logger.Errorf(ctx.UUID(), "%+v", ctx.Err()) - r.GitHub.CreateCommitStatus(ctx, src, github.ERROR, ctx.Err().Error()) - } - r.LogStore.Finish(ctx.UUID()) -} - -func (r *DockerRunner) finish(ctx context.Context, src *github.TargetSource, err error) { - if err == ErrFailure { - logger.Error(ctx.UUID(), err.Error()) - r.GitHub.CreateCommitStatus(ctx, src, github.FAILURE, "failure job") - } else if err != nil { - logger.Errorf(ctx.UUID(), "%+v", err) - r.GitHub.CreateCommitStatus(ctx, src, github.ERROR, err.Error()) - } else { - r.GitHub.CreateCommitStatus(ctx, src, github.SUCCESS, "success") - } - r.LogStore.Finish(ctx.UUID()) -} - -func exists(name string) bool { - _, err := os.Stat(name) - return !os.IsNotExist(err) -} diff --git a/application/service/runner/runner_test.go b/application/service/runner/runner_test.go deleted file mode 100644 index f30fc656..00000000 --- a/application/service/runner/runner_test.go +++ /dev/null @@ -1,847 +0,0 @@ -package runner_test - -import ( - "github.com/duck8823/duci/application" - "github.com/duck8823/duci/application/context" - "github.com/duck8823/duci/application/service/docker" - "github.com/duck8823/duci/application/service/docker/mock_docker" - "github.com/duck8823/duci/application/service/git/mock_git" - "github.com/duck8823/duci/application/service/github" - "github.com/duck8823/duci/application/service/github/mock_github" - "github.com/duck8823/duci/application/service/logstore/mock_logstore" - "github.com/duck8823/duci/application/service/runner" - "github.com/golang/mock/gomock" - "github.com/google/uuid" - "github.com/pkg/errors" - "gopkg.in/src-d/go-git.v4/plumbing" - "net/url" - "os" - "path/filepath" - "testing" - "time" -) - -func TestRunnerImpl_Run_Normal(t *testing.T) { - // setup - ctrl := gomock.NewController(t) - - t.Run("with correct return values", func(t *testing.T) { - t.Run("when Dockerfile in project root", func(t *testing.T) { - // given - mockGitHub := mock_github.NewMockService(ctrl) - mockGitHub.EXPECT().CreateCommitStatus(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). - Times(2). - Return(nil) - - // and - mockGit := mock_git.NewMockService(ctrl) - mockGit.EXPECT().Clone(gomock.Any(), gomock.Any(), gomock.Any()). - Times(1). - DoAndReturn(func(_ interface{}, dir string, _ interface{}) error { - if err := os.MkdirAll(dir, 0700); err != nil { - return err - } - - dockerfile, err := os.OpenFile(filepath.Join(dir, "Dockerfile"), os.O_RDWR|os.O_CREATE, 0600) - if err != nil { - return err - } - defer dockerfile.Close() - - dockerfile.WriteString("FROM alpine\nENTRYPOINT [\"echo\"]") - - return nil - }) - - // and - mockDocker := mock_docker.NewMockService(ctrl) - mockDocker.EXPECT(). - Build(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Eq(docker.Dockerfile("./Dockerfile"))). - Times(1). - Return(&runner.MockBuildLog{}, nil) - mockDocker.EXPECT(). - Build(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Not(docker.Dockerfile("./Dockerfile"))). - Return(nil, errors.New("must not call this")) - mockDocker.EXPECT(). - Run(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). - Times(1). - Return(docker.ContainerID(""), &runner.MockJobLog{}, nil) - mockDocker.EXPECT(). - ExitCode(gomock.Any(), gomock.Any()). - AnyTimes(). - Return(docker.ExitCode(0), nil) - mockDocker.EXPECT(). - Rm(gomock.Any(), gomock.Any()). - AnyTimes(). - Return(nil) - - // and - mockLogStore := mock_logstore.NewMockService(ctrl) - mockLogStore.EXPECT(). - Append(gomock.Any(), gomock.Any()). - AnyTimes(). - Return(nil) - mockLogStore.EXPECT(). - Start(gomock.Any()). - AnyTimes(). - Return(nil) - mockLogStore.EXPECT(). - Finish(gomock.Any()). - AnyTimes(). - Return(nil) - - r := &runner.DockerRunner{ - BaseWorkDir: filepath.Join(os.TempDir(), "test-runner"), - Git: mockGit, - GitHub: mockGitHub, - Docker: mockDocker, - LogStore: mockLogStore, - } - - // and - repo := &runner.MockRepo{FullName: "duck8823/duci", SSHURL: "git@github.com:duck8823/duci.git"} - - // when - err := r.Run( - context.New("test/task", uuid.New(), &url.URL{}), - &github.TargetSource{Repo: repo, Ref: "master", SHA: plumbing.ZeroHash}, - "Hello World.", - ) - - // then - if err != nil { - t.Errorf("must not error. but: %+v", err) - } - }) - - t.Run("when Dockerfile in sub directory", func(t *testing.T) { - // given - mockGitHub := mock_github.NewMockService(ctrl) - mockGitHub.EXPECT().CreateCommitStatus(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). - Times(2). - Return(nil) - - // and - mockGit := mock_git.NewMockService(ctrl) - mockGit.EXPECT().Clone(gomock.Any(), gomock.Any(), gomock.Any()). - Times(1). - DoAndReturn(func(_ interface{}, dir string, _ interface{}) error { - if err := os.MkdirAll(filepath.Join(dir, ".duci"), 0700); err != nil { - return err - } - - dockerfile, err := os.OpenFile(filepath.Join(dir, ".duci/Dockerfile"), os.O_RDWR|os.O_CREATE, 0600) - if err != nil { - return err - } - defer dockerfile.Close() - - dockerfile.WriteString("FROM alpine\nENTRYPOINT [\"echo\"]") - - return nil - }) - - // and - mockDocker := mock_docker.NewMockService(ctrl) - mockDocker.EXPECT(). - Build(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Eq(docker.Dockerfile(".duci/Dockerfile"))). - Return(&runner.MockBuildLog{}, nil) - mockDocker.EXPECT(). - Build(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Not(docker.Dockerfile(".duci/Dockerfile"))). - Return(nil, errors.New("must not call this")) - mockDocker.EXPECT(). - Run(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). - Times(1). - Return(docker.ContainerID(""), &runner.MockJobLog{}, nil) - mockDocker.EXPECT(). - ExitCode(gomock.Any(), gomock.Any()). - AnyTimes(). - Return(docker.ExitCode(0), nil) - mockDocker.EXPECT(). - Rm(gomock.Any(), gomock.Any()). - AnyTimes(). - Return(nil) - - // and - mockLogStore := mock_logstore.NewMockService(ctrl) - mockLogStore.EXPECT(). - Append(gomock.Any(), gomock.Any()). - AnyTimes(). - Return(nil) - mockLogStore.EXPECT(). - Start(gomock.Any()). - AnyTimes(). - Return(nil) - mockLogStore.EXPECT(). - Finish(gomock.Any()). - AnyTimes(). - Return(nil) - - r := &runner.DockerRunner{ - BaseWorkDir: filepath.Join(os.TempDir(), "test-runner"), - Git: mockGit, - GitHub: mockGitHub, - Docker: mockDocker, - LogStore: mockLogStore, - } - - // and - repo := &runner.MockRepo{FullName: "duck8823/duci", SSHURL: "git@github.com:duck8823/duci.git"} - - // when - err := r.Run( - context.New("test/task", uuid.New(), &url.URL{}), - &github.TargetSource{Repo: repo, Ref: "master", SHA: plumbing.ZeroHash}, - "Hello World.", - ) - - // then - if err != nil { - t.Errorf("must not error. but: %+v", err) - } - }) - }) - - t.Run("with config file", func(t *testing.T) { - // given - mockGitHub := mock_github.NewMockService(ctrl) - mockGitHub.EXPECT().CreateCommitStatus(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). - Times(2). - Return(nil) - - // and - mockGit := mock_git.NewMockService(ctrl) - mockGit.EXPECT().Clone(gomock.Any(), gomock.Any(), gomock.Any()). - Times(1). - DoAndReturn(func(_ interface{}, dir string, _ interface{}) error { - if err := os.MkdirAll(filepath.Join(dir, ".duci"), 0700); err != nil { - return err - } - - dockerfile, err := os.OpenFile(filepath.Join(dir, ".duci/config.yml"), os.O_RDWR|os.O_CREATE, 0600) - if err != nil { - return err - } - defer dockerfile.Close() - - dockerfile.WriteString("---\nvolumes:\n - /hello:/hello") - - return nil - }) - - // and - mockDocker := mock_docker.NewMockService(ctrl) - mockDocker.EXPECT(). - Build(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). - Return(&runner.MockBuildLog{}, nil) - mockDocker.EXPECT(). - Run(gomock.Any(), gomock.Eq(docker.RuntimeOptions{Volumes: []string{"/hello:/hello"}}), gomock.Any(), gomock.Any()). - Times(1). - Return(docker.ContainerID(""), &runner.MockJobLog{}, nil) - mockDocker.EXPECT(). - Run(gomock.Any(), gomock.Not(docker.RuntimeOptions{Volumes: []string{"/hello:/hello"}}), gomock.Any(), gomock.Any()). - Return(docker.ContainerID(""), nil, errors.New("must not call this")) - mockDocker.EXPECT(). - ExitCode(gomock.Any(), gomock.Any()). - AnyTimes(). - Return(docker.ExitCode(0), nil) - mockDocker.EXPECT(). - Rm(gomock.Any(), gomock.Any()). - AnyTimes(). - Return(nil) - - // and - mockLogStore := mock_logstore.NewMockService(ctrl) - mockLogStore.EXPECT(). - Append(gomock.Any(), gomock.Any()). - AnyTimes(). - Return(nil) - mockLogStore.EXPECT(). - Start(gomock.Any()). - AnyTimes(). - Return(nil) - mockLogStore.EXPECT(). - Finish(gomock.Any()). - AnyTimes(). - Return(nil) - - r := &runner.DockerRunner{ - BaseWorkDir: filepath.Join(os.TempDir(), "test-runner"), - Git: mockGit, - GitHub: mockGitHub, - Docker: mockDocker, - LogStore: mockLogStore, - } - - // and - repo := &runner.MockRepo{FullName: "duck8823/duci", SSHURL: "git@github.com:duck8823/duci.git"} - - // when - err := r.Run( - context.New("test/task", uuid.New(), &url.URL{}), - &github.TargetSource{Repo: repo, Ref: "master", SHA: plumbing.ZeroHash}, - "Hello World.", - ) - - // then - if err != nil { - t.Errorf("must not error. but: %+v", err) - } - }) -} - -func TestRunnerImpl_Run_NonNormal(t *testing.T) { - // setup - ctrl := gomock.NewController(t) - - t.Run("when failed to git clone", func(t *testing.T) { - // given - mockGitHub := mock_github.NewMockService(ctrl) - mockGitHub.EXPECT().CreateCommitStatus(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). - Times(2). - Return(nil) - - // and - mockGit := mock_git.NewMockService(ctrl) - mockGit.EXPECT().Clone(gomock.Any(), gomock.Any(), gomock.Any()). - Times(1). - Return(errors.New("error")) - - // and - mockDocker := mock_docker.NewMockService(ctrl) - mockDocker.EXPECT(). - Build(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). - Times(0) - mockDocker.EXPECT(). - Run(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). - Times(0) - mockDocker.EXPECT(). - ExitCode(gomock.Any(), gomock.Any()). - AnyTimes(). - Return(docker.ExitCode(0), nil) - mockDocker.EXPECT(). - Rm(gomock.Any(), gomock.Any()). - AnyTimes(). - Return(nil) - - // and - mockLogStore := mock_logstore.NewMockService(ctrl) - mockLogStore.EXPECT(). - Append(gomock.Any(), gomock.Any()). - AnyTimes(). - Return(nil) - mockLogStore.EXPECT(). - Start(gomock.Any()). - AnyTimes(). - Return(nil) - mockLogStore.EXPECT(). - Finish(gomock.Any()). - AnyTimes(). - Return(nil) - - r := &runner.DockerRunner{ - BaseWorkDir: filepath.Join(os.TempDir(), "test-runner"), - Git: mockGit, - GitHub: mockGitHub, - Docker: mockDocker, - LogStore: mockLogStore, - } - - // and - repo := &runner.MockRepo{FullName: "duck8823/duci", SSHURL: "git@github.com:duck8823/duci.git"} - - // when - err := r.Run( - context.New("test/task", uuid.New(), &url.URL{}), - &github.TargetSource{Repo: repo, Ref: "master", SHA: plumbing.ZeroHash}, - "Hello World.", - ) - - // then - if err == nil { - t.Error("must occur error") - } - }) - - t.Run("when failed store#$tart", func(t *testing.T) { - // given - mockGitHub := mock_github.NewMockService(ctrl) - mockGitHub.EXPECT().CreateCommitStatus(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). - Times(2). - Return(nil) - - // and - mockLogStore := mock_logstore.NewMockService(ctrl) - mockLogStore.EXPECT(). - Start(gomock.Any()). - AnyTimes(). - Return(errors.New("test error")) - - r := &runner.DockerRunner{ - BaseWorkDir: filepath.Join(os.TempDir(), "test-runner"), - GitHub: mockGitHub, - LogStore: mockLogStore, - } - - // and - repo := &runner.MockRepo{FullName: "duck8823/duci", SSHURL: "git@github.com:duck8823/duci.git"} - - // when - err := r.Run( - context.New("test/task", uuid.New(), &url.URL{}), - &github.TargetSource{Repo: repo, Ref: "master", SHA: plumbing.ZeroHash}, - "Hello World.", - ) - - // then - if err == nil { - t.Error("must occur error") - } - }) - - t.Run("when workdir not exists", func(t *testing.T) { - // given - mockGitHub := mock_github.NewMockService(ctrl) - mockGitHub.EXPECT().CreateCommitStatus(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). - Times(2). - Return(nil) - - // and - mockGit := mock_git.NewMockService(ctrl) - mockGit.EXPECT().Clone(gomock.Any(), gomock.Any(), gomock.Any()). - Times(1). - Return(nil) - - // and - mockDocker := mock_docker.NewMockService(ctrl) - mockDocker.EXPECT(). - Build(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). - Times(0) - mockDocker.EXPECT(). - Run(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). - Times(0) - mockDocker.EXPECT(). - ExitCode(gomock.Any(), gomock.Any()). - AnyTimes(). - Return(docker.ExitCode(0), nil) - mockDocker.EXPECT(). - Rm(gomock.Any(), gomock.Any()). - AnyTimes(). - Return(nil) - - // and - mockLogStore := mock_logstore.NewMockService(ctrl) - mockLogStore.EXPECT(). - Append(gomock.Any(), gomock.Any()). - AnyTimes(). - Return(nil) - mockLogStore.EXPECT(). - Start(gomock.Any()). - AnyTimes(). - Return(nil) - mockLogStore.EXPECT(). - Finish(gomock.Any()). - AnyTimes(). - Return(nil) - - r := &runner.DockerRunner{ - BaseWorkDir: "/path/to/not/exists/dir", - Git: mockGit, - GitHub: mockGitHub, - Docker: mockDocker, - LogStore: mockLogStore, - } - - // and - repo := &runner.MockRepo{FullName: "duck8823/duci", SSHURL: "git@github.com:duck8823/duci.git"} - - // when - err := r.Run( - context.New("test/task", uuid.New(), &url.URL{}), - &github.TargetSource{Repo: repo, Ref: "master", SHA: plumbing.ZeroHash}, - "Hello World.", - ) - - // then - if err == nil { - t.Error("must occur error") - } - }) - - t.Run("when docker build failure", func(t *testing.T) { - // given - mockGitHub := mock_github.NewMockService(ctrl) - mockGitHub.EXPECT().CreateCommitStatus(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). - Times(2). - Return(nil) - - // and - mockGit := mock_git.NewMockService(ctrl) - mockGit.EXPECT().Clone(gomock.Any(), gomock.Any(), gomock.Any()). - Times(1). - Return(nil) - - // and - mockDocker := mock_docker.NewMockService(ctrl) - mockDocker.EXPECT(). - Build(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). - Times(1). - Return(nil, errors.New("test")) - mockDocker.EXPECT(). - Run(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). - Times(0) - mockDocker.EXPECT(). - ExitCode(gomock.Any(), gomock.Any()). - AnyTimes(). - Return(docker.ExitCode(0), nil) - mockDocker.EXPECT(). - Rm(gomock.Any(), gomock.Any()). - AnyTimes(). - Return(nil) - - // and - mockLogStore := mock_logstore.NewMockService(ctrl) - mockLogStore.EXPECT(). - Append(gomock.Any(), gomock.Any()). - AnyTimes(). - Return(nil) - mockLogStore.EXPECT(). - Start(gomock.Any()). - AnyTimes(). - Return(nil) - mockLogStore.EXPECT(). - Finish(gomock.Any()). - AnyTimes(). - Return(nil) - - r := &runner.DockerRunner{ - BaseWorkDir: filepath.Join(os.TempDir(), "test-runner"), - Git: mockGit, - GitHub: mockGitHub, - Docker: mockDocker, - LogStore: mockLogStore, - } - - // and - repo := &runner.MockRepo{FullName: "duck8823/duci", SSHURL: "git@github.com:duck8823/duci.git"} - - // when - err := r.Run( - context.New("test/task", uuid.New(), &url.URL{}), - &github.TargetSource{Repo: repo, Ref: "master", SHA: plumbing.ZeroHash}, - "Hello World.", - ) - - // then - if err == nil { - t.Error("must occur error") - } - }) - - t.Run("when docker run error", func(t *testing.T) { - // given - mockGitHub := mock_github.NewMockService(ctrl) - mockGitHub.EXPECT().CreateCommitStatus(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). - Times(2). - Return(nil) - - // and - mockGit := mock_git.NewMockService(ctrl) - mockGit.EXPECT().Clone(gomock.Any(), gomock.Any(), gomock.Any()). - Times(1). - Return(nil) - - // and - mockDocker := mock_docker.NewMockService(ctrl) - mockDocker.EXPECT(). - Build(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). - Times(1). - Return(&runner.MockBuildLog{}, nil) - mockDocker.EXPECT(). - Run(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). - Times(1). - Return(docker.ContainerID(""), nil, errors.New("test")) - mockDocker.EXPECT(). - ExitCode(gomock.Any(), gomock.Any()). - AnyTimes(). - Return(docker.ExitCode(0), nil) - mockDocker.EXPECT(). - Rm(gomock.Any(), gomock.Any()). - AnyTimes(). - Return(nil) - - // and - mockLogStore := mock_logstore.NewMockService(ctrl) - mockLogStore.EXPECT(). - Append(gomock.Any(), gomock.Any()). - AnyTimes(). - Return(nil) - mockLogStore.EXPECT(). - Start(gomock.Any()). - AnyTimes(). - Return(nil) - mockLogStore.EXPECT(). - Finish(gomock.Any()). - AnyTimes(). - Return(nil) - - r := &runner.DockerRunner{ - BaseWorkDir: filepath.Join(os.TempDir(), "test-runner"), - Git: mockGit, - GitHub: mockGitHub, - Docker: mockDocker, - LogStore: mockLogStore, - } - - // and - repo := &runner.MockRepo{FullName: "duck8823/duci", SSHURL: "git@github.com:duck8823/duci.git"} - - // when - err := r.Run( - context.New("test/task", uuid.New(), &url.URL{}), - &github.TargetSource{Repo: repo, Ref: "master", SHA: plumbing.ZeroHash}, - "Hello World.", - ) - - // then - if err == nil { - t.Error("must occur error") - } - }) - - t.Run("when fail to remove container", func(t *testing.T) { - // given - expected := errors.New("test") - - // and - mockGitHub := mock_github.NewMockService(ctrl) - mockGitHub.EXPECT().CreateCommitStatus(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). - Times(2). - Return(nil) - - // and - mockGit := mock_git.NewMockService(ctrl) - mockGit.EXPECT().Clone(gomock.Any(), gomock.Any(), gomock.Any()). - Times(1). - DoAndReturn(cloneSuccess) - - // and - mockDocker := mock_docker.NewMockService(ctrl) - mockDocker.EXPECT(). - Build(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). - Return(&runner.MockBuildLog{}, nil) - mockDocker.EXPECT(). - Run(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). - Times(1). - Return(docker.ContainerID(""), &runner.MockJobLog{}, nil) - mockDocker.EXPECT(). - ExitCode(gomock.Any(), gomock.Any()). - AnyTimes(). - Return(docker.ExitCode(0), nil) - mockDocker.EXPECT(). - Rm(gomock.Any(), gomock.Any()). - AnyTimes(). - Return(expected) - - // and - mockLogStore := mock_logstore.NewMockService(ctrl) - mockLogStore.EXPECT(). - Append(gomock.Any(), gomock.Any()). - AnyTimes(). - Return(nil) - mockLogStore.EXPECT(). - Start(gomock.Any()). - AnyTimes(). - Return(nil) - mockLogStore.EXPECT(). - Finish(gomock.Any()). - AnyTimes(). - Return(nil) - - r := &runner.DockerRunner{ - BaseWorkDir: filepath.Join(os.TempDir(), "test-runner"), - Git: mockGit, - GitHub: mockGitHub, - Docker: mockDocker, - LogStore: mockLogStore, - } - - // and - repo := &runner.MockRepo{FullName: "duck8823/duci", SSHURL: "git@github.com:duck8823/duci.git"} - - // when - err := r.Run( - context.New("test/task", uuid.New(), &url.URL{}), - &github.TargetSource{Repo: repo, Ref: "master", SHA: plumbing.ZeroHash}, - "Hello World.", - ) - - // then - if err.Error() != expected.Error() { - t.Errorf("err must be %+v, but got %+v", expected, err) - } - }) - - t.Run("when docker run failure ( with exit code 1 )", func(t *testing.T) { - // given - mockGitHub := mock_github.NewMockService(ctrl) - mockGitHub.EXPECT().CreateCommitStatus(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). - Times(2). - Return(nil) - - // and - mockGit := mock_git.NewMockService(ctrl) - mockGit.EXPECT().Clone(gomock.Any(), gomock.Any(), gomock.Any()). - Times(1). - DoAndReturn(cloneSuccess) - - // and - mockDocker := mock_docker.NewMockService(ctrl) - mockDocker.EXPECT(). - Build(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). - Times(1). - Return(&runner.MockBuildLog{}, nil) - mockDocker.EXPECT(). - Run(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). - Times(1). - Return(docker.ContainerID(""), &runner.MockJobLog{}, nil) - mockDocker.EXPECT(). - ExitCode(gomock.Any(), gomock.Any()). - AnyTimes(). - Return(docker.ExitCode(1), nil) - mockDocker.EXPECT(). - Rm(gomock.Any(), gomock.Any()). - AnyTimes(). - Return(nil) - - // and - mockLogStore := mock_logstore.NewMockService(ctrl) - mockLogStore.EXPECT(). - Append(gomock.Any(), gomock.Any()). - AnyTimes(). - Return(nil) - mockLogStore.EXPECT(). - Start(gomock.Any()). - AnyTimes(). - Return(nil) - mockLogStore.EXPECT(). - Finish(gomock.Any()). - AnyTimes(). - Return(nil) - - r := &runner.DockerRunner{ - BaseWorkDir: filepath.Join(os.TempDir(), "test-runner"), - Git: mockGit, - GitHub: mockGitHub, - Docker: mockDocker, - LogStore: mockLogStore, - } - - // and - repo := &runner.MockRepo{FullName: "duck8823/duci", SSHURL: "git@github.com:duck8823/duci.git"} - - // when - err := r.Run( - context.New("test/task", uuid.New(), &url.URL{}), - &github.TargetSource{Repo: repo, Ref: "master", SHA: plumbing.ZeroHash}, - "Hello World.", - ) - - // then - if err != runner.ErrFailure { - t.Errorf("error must be %s, but got %s", runner.ErrFailure, err) - } - }) - - t.Run("when runner timeout", func(t *testing.T) { - // given - mockGitHub := mock_github.NewMockService(ctrl) - mockGitHub.EXPECT().CreateCommitStatus(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). - Times(2). - Return(nil) - - // and - mockGit := mock_git.NewMockService(ctrl) - mockGit.EXPECT().Clone(gomock.Any(), gomock.Any(), gomock.Any()). - Times(1). - DoAndReturn(cloneSuccess) - - // and - application.Config.Job.Timeout = 1 - - mockDocker := mock_docker.NewMockService(ctrl) - mockDocker.EXPECT(). - Build(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). - Times(1). - Return(&runner.MockBuildLog{}, nil) - mockDocker.EXPECT(). - Run(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). - Times(1). - DoAndReturn(func(_, _, _, _ interface{}) (docker.ContainerID, docker.Log, error) { - time.Sleep(10 * time.Second) - return docker.ContainerID("container_id"), &runner.MockJobLog{}, nil - }) - mockDocker.EXPECT(). - ExitCode(gomock.Any(), gomock.Any()). - AnyTimes(). - Return(docker.ExitCode(0), nil) - mockDocker.EXPECT(). - Rm(gomock.Any(), gomock.Any()). - AnyTimes(). - Return(nil) - - // and - mockLogStore := mock_logstore.NewMockService(ctrl) - mockLogStore.EXPECT(). - Append(gomock.Any(), gomock.Any()). - AnyTimes(). - Return(nil) - mockLogStore.EXPECT(). - Start(gomock.Any()). - AnyTimes(). - Return(nil) - mockLogStore.EXPECT(). - Finish(gomock.Any()). - AnyTimes(). - Return(nil) - - r := &runner.DockerRunner{ - BaseWorkDir: filepath.Join(os.TempDir(), "test-runner"), - Git: mockGit, - GitHub: mockGitHub, - Docker: mockDocker, - LogStore: mockLogStore, - } - - // and - repo := &runner.MockRepo{FullName: "duck8823/duci", SSHURL: "git@github.com:duck8823/duci.git"} - - // when - err := r.Run( - context.New("test/task", uuid.New(), &url.URL{}), - &github.TargetSource{Repo: repo, Ref: "master", SHA: plumbing.ZeroHash}, - "Hello World.", - ) - - // then - if err.Error() != "context deadline exceeded" { - t.Errorf("error must be runner.ErrFailure, but got %+v", err) - } - }) -} - -func cloneSuccess(_ interface{}, dir string, _ interface{}) error { - if err := os.MkdirAll(dir, 0700); err != nil { - return err - } - - dockerfile, err := os.OpenFile(filepath.Join(dir, "Dockerfile"), os.O_RDWR|os.O_CREATE, 0600) - if err != nil { - return err - } - defer dockerfile.Close() - - dockerfile.WriteString("FROM alpine\nENTRYPOINT [\"echo\"]") - - return nil -} diff --git a/data/model/log.go b/data/model/log.go deleted file mode 100644 index f1ad5e68..00000000 --- a/data/model/log.go +++ /dev/null @@ -1,15 +0,0 @@ -package model - -import "time" - -// Job represents one of execution task. -type Job struct { - Finished bool `json:"finished"` - Stream []Message `json:"stream"` -} - -// Message is a log of job. -type Message struct { - Time time.Time `json:"time"` - Text string `json:"message"` -} diff --git a/domain/model/docker/docker.go b/domain/model/docker/docker.go new file mode 100644 index 00000000..730e4ca9 --- /dev/null +++ b/domain/model/docker/docker.go @@ -0,0 +1,17 @@ +package docker + +import ( + "context" + "github.com/duck8823/duci/domain/model/job" + "io" +) + +// Docker is a interface describe docker service. +type Docker interface { + Build(ctx context.Context, file io.Reader, tag Tag, dockerfile Dockerfile) (job.Log, error) + Run(ctx context.Context, opts RuntimeOptions, tag Tag, cmd Command) (ContainerID, job.Log, error) + RemoveContainer(ctx context.Context, containerID ContainerID) error + RemoveImage(ctx context.Context, tag Tag) error + ExitCode(ctx context.Context, containerID ContainerID) (ExitCode, error) + Status() error +} diff --git a/domain/model/docker/docker_impl.go b/domain/model/docker/docker_impl.go new file mode 100644 index 00000000..ecab7da7 --- /dev/null +++ b/domain/model/docker/docker_impl.go @@ -0,0 +1,104 @@ +package docker + +import ( + "context" + "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/container" + moby "github.com/docker/docker/client" + "github.com/duck8823/duci/domain/model/job" + "github.com/pkg/errors" + "io" +) + +type dockerImpl struct { + moby Moby +} + +// New returns instance of docker dockerImpl +func New() (Docker, error) { + cli, err := moby.NewClientWithOpts(moby.FromEnv) + if err != nil { + return nil, errors.WithStack(err) + } + return &dockerImpl{moby: cli}, nil +} + +// Build a docker image. +func (c *dockerImpl) Build(ctx context.Context, file io.Reader, tag Tag, dockerfile Dockerfile) (job.Log, error) { + opts := types.ImageBuildOptions{ + Tags: []string{tag.String()}, + Dockerfile: dockerfile.String(), + Remove: true, + } + resp, err := c.moby.ImageBuild(ctx, file, opts) + if err != nil { + return nil, errors.WithStack(err) + } + + return NewBuildLog(resp.Body), nil +} + +// Run docker container with command. +func (c *dockerImpl) Run(ctx context.Context, opts RuntimeOptions, tag Tag, cmd Command) (ContainerID, job.Log, error) { + con, err := c.moby.ContainerCreate(ctx, &container.Config{ + Image: tag.String(), + Env: opts.Environments.Array(), + Volumes: opts.Volumes.Map(), + Cmd: cmd.Slice(), + }, &container.HostConfig{ + Binds: opts.Volumes, + }, nil, "") + if err != nil { + return "", nil, errors.WithStack(err) + } + + if err := c.moby.ContainerStart(ctx, con.ID, types.ContainerStartOptions{}); err != nil { + return ContainerID(con.ID), nil, errors.WithStack(err) + } + + logs, err := c.moby.ContainerLogs(ctx, con.ID, types.ContainerLogsOptions{ + ShowStdout: true, + ShowStderr: true, + Follow: true, + }) + if err != nil { + return ContainerID(con.ID), nil, errors.WithStack(err) + } + + return ContainerID(con.ID), NewRunLog(logs), nil +} + +// RemoveContainer remove docker container. +func (c *dockerImpl) RemoveContainer(ctx context.Context, conID ContainerID) error { + if err := c.moby.ContainerRemove(ctx, conID.String(), types.ContainerRemoveOptions{}); err != nil { + return errors.WithStack(err) + } + return nil +} + +// RemoveImage remove docker image. +func (c *dockerImpl) RemoveImage(ctx context.Context, tag Tag) error { + if _, err := c.moby.ImageRemove(ctx, tag.String(), types.ImageRemoveOptions{}); err != nil { + return errors.WithStack(err) + } + return nil +} + +// ExitCode returns exit code specific container id. +func (c *dockerImpl) ExitCode(ctx context.Context, conID ContainerID) (ExitCode, error) { + body, err := c.moby.ContainerWait(ctx, conID.String(), container.WaitConditionNotRunning) + select { + case b := <-body: + return ExitCode(b.StatusCode), nil + case e := <-err: + return -1, errors.WithStack(e) + } +} + +// Status returns error of docker daemon status. +func (c *dockerImpl) Status() error { + if _, err := c.moby.Info(context.Background()); err != nil { + return errors.Wrap(err, "Couldn't connect to Docker daemon.") + } + return nil +} diff --git a/domain/model/docker/docker_test.go b/domain/model/docker/docker_test.go new file mode 100644 index 00000000..ca17a68b --- /dev/null +++ b/domain/model/docker/docker_test.go @@ -0,0 +1,631 @@ +package docker_test + +import ( + "bytes" + "context" + "errors" + "fmt" + "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/container" + "github.com/duck8823/duci/domain/model/docker" + "github.com/duck8823/duci/domain/model/docker/mock_docker" + . "github.com/golang/mock/gomock" + "github.com/google/go-cmp/cmp" + "github.com/labstack/gommon/random" + "gopkg.in/src-d/go-git.v4/utils/ioutil" + "os" + "strings" + "testing" +) + +func TestNew(t *testing.T) { + t.Run("without environment variable", func(t *testing.T) { + // given + DOCKER_CERT_PATH := os.Getenv("DOCKER_CERT_PATH") + _ = os.Setenv("DOCKER_CERT_PATH", "") + defer func() { + _ = os.Setenv("DOCKER_CERT_PATH", DOCKER_CERT_PATH) + }() + + DOCKER_HOST := os.Getenv("DOCKER_HOST") + _ = os.Setenv("DOCKER_HOST", "") + defer func() { + _ = os.Setenv("DOCKER_HOST", DOCKER_HOST) + }() + + DOCKER_API_VERSION := os.Getenv("DOCKER_API_VERSION") + _ = os.Setenv("DOCKER_API_VERSION", "") + defer func() { + _ = os.Setenv("DOCKER_API_VERSION", DOCKER_API_VERSION) + }() + + // when + get, err := docker.New() + + // then + if err != nil { + t.Errorf("error must be nil, but got %+v", err) + } + + // and + _, ok := get.(docker.Docker) + if !ok { + t.Error("instance must be docker.Docker") + } + }) + + t.Run("with wrong environment variable DOCKER_HOST", func(t *testing.T) { + // given + DOCKER_HOST := os.Getenv("DOCKER_HOST") + _ = os.Setenv("DOCKER_HOST", "wrong host name") + defer func() { + _ = os.Setenv("DOCKER_HOST", DOCKER_HOST) + }() + + // when + got, err := docker.New() + + // then + if err == nil { + t.Error("error must not be nil") + } + + if got != nil { + t.Errorf("instance must be nil, but got %+v", err) + } + }) +} + +func TestClient_Build(t *testing.T) { + t.Run("with collect ImageBuildResponse", func(t *testing.T) { + // given + ctrl := NewController(t) + defer ctrl.Finish() + + // and + ctx := context.Background() + buildContext := strings.NewReader("hello world") + tag := "test_tag" + dockerfile := "test_dockerfile" + + // and + want := "want value" + + // and + mockMoby := mock_docker.NewMockMoby(ctrl) + mockMoby.EXPECT(). + ImageBuild(Eq(ctx), Eq(buildContext), Eq(types.ImageBuildOptions{ + Tags: []string{tag}, + Dockerfile: dockerfile, + Remove: true, + })). + Times(1). + Return(types.ImageBuildResponse{ + Body: ioutil.NewReadCloser(strings.NewReader(fmt.Sprintf("{\"stream\":\"%s\"}", want)), nil), + }, nil) + + // and + sut := &docker.Client{} + defer sut.SetMoby(mockMoby)() + + // when + got, err := sut.Build(ctx, buildContext, docker.Tag(tag), docker.Dockerfile(dockerfile)) + + // then + if err != nil { + t.Errorf("error must be nil, but got %+v", err) + } + + // and + line, _ := got.ReadLine() + if line.Message != want { + t.Errorf("want: %s, but got: %s", want, line.Message) + } + }) + + t.Run("with error", func(t *testing.T) { + // given + ctrl := NewController(t) + defer ctrl.Finish() + + // and + ctx := context.Background() + buildContext := strings.NewReader("hello world") + tag := "test_tag" + dockerfile := "test_dockerfile" + + // and + empty := types.ImageBuildResponse{} + wantError := errors.New("test_error") + + // and + mockMoby := mock_docker.NewMockMoby(ctrl) + mockMoby.EXPECT(). + ImageBuild(Eq(ctx), Eq(buildContext), Eq(types.ImageBuildOptions{ + Tags: []string{tag}, + Dockerfile: dockerfile, + Remove: true, + })). + Times(1). + Return(empty, wantError) + + // and + sut := &docker.Client{} + defer sut.SetMoby(mockMoby)() + + // when + got, err := sut.Build(ctx, buildContext, docker.Tag(tag), docker.Dockerfile(dockerfile)) + + // then + if err.Error() != wantError.Error() { + t.Errorf("error want: %+v, but got: %+v", wantError, err) + } + + // and + if got != nil { + t.Errorf("log moust be nil, but got %+v", err) + } + }) +} + +func TestClient_Run(t *testing.T) { + t.Run("nominal scenario", func(t *testing.T) { + // given + ctrl := NewController(t) + defer ctrl.Finish() + + // and + ctx := context.Background() + opts := docker.RuntimeOptions{} + tag := docker.Tag("test_tag") + cmd := make([]string, 0) + + // and + wantID := docker.ContainerID(random.String(16, random.Alphanumeric)) + want := "hello test" + + // and + mockMoby := mock_docker.NewMockMoby(ctrl) + mockMoby.EXPECT(). + ContainerCreate(Eq(ctx), Any(), Any(), Nil(), Eq("")). + Times(1). + Return(container.ContainerCreateCreatedBody{ + ID: wantID.String(), + }, nil) + + mockMoby.EXPECT(). + ContainerStart(Eq(ctx), Eq(wantID.String()), Eq(types.ContainerStartOptions{})). + Times(1). + Return(nil) + + mockMoby.EXPECT(). + ContainerLogs(Eq(ctx), Eq(wantID.String()), Eq(types.ContainerLogsOptions{ + ShowStdout: true, + ShowStderr: true, + Follow: true, + })). + Times(1). + Return( + ioutil.NewReadCloser(bytes.NewReader(append([]byte{1, 0, 0, 0, 1, 1, 1, 1}, []byte(want)...)), + nil, + ), + nil, + ) + + // and + sut := &docker.Client{} + defer sut.SetMoby(mockMoby)() + + // when + gotID, got, err := sut.Run(ctx, opts, docker.Tag(tag), cmd) + + // then + if err != nil { + t.Errorf("error must be nil, but got %+v", err) + } + + // and + if gotID != wantID { + t.Errorf("id want: %s, but got: %s", wantID, gotID) + } + + // and + line, _ := got.ReadLine() + if line.Message != want { + t.Errorf("want: %s, but got: %s", want, line.Message) + } + }) + + t.Run("non-nominal scenarios", func(t *testing.T) { + // where + for _, tt := range []struct { + name string + f func(*testing.T, docker.RunArgs) (moby docker.Moby, finish func()) + emptyID bool + }{ + { + name: "when failed create container", + f: mockMobyFailedCreateContainer, + emptyID: true, + }, + { + name: "when failed start container", + f: mockMobyFailedContainerStart, + emptyID: false, + }, + { + name: "when failed container logs", + f: mockMobyFailedContainerLogs, + emptyID: false, + }, + } { + t.Run(tt.name, func(t *testing.T) { + // given + ctx := context.Background() + opts := docker.RuntimeOptions{} + tag := docker.Tag("test_tag") + cmd := make([]string, 0) + + // and + mockMoby, finish := tt.f(t, docker.RunArgs{Ctx: ctx, Opts: opts, Tag: tag, Cmd: cmd}) + defer finish() + + // and + sut := &docker.Client{} + defer sut.SetMoby(mockMoby)() + + // when + gotID, got, err := sut.Run(ctx, opts, tag, cmd) + + // then + if err == nil { + t.Error("error must not be nil") + } + + // and + if tt.emptyID && len(gotID.String()) != 0 { + t.Errorf("id must be empty, but got: %s", gotID) + } else if !tt.emptyID && len(gotID.String()) == 0 { + t.Error("id must not be empty") + } + + // and + if got != nil { + t.Errorf("log must be nil, but got: %+v", got) + } + }) + } + }) +} + +func mockMobyFailedCreateContainer( + t *testing.T, + args docker.RunArgs, +) (moby docker.Moby, finish func()) { + t.Helper() + + ctrl := NewController(t) + + mockMoby := mock_docker.NewMockMoby(ctrl) + mockMoby.EXPECT(). + ContainerCreate(Eq(args.Ctx), Any(), Any(), Nil(), Eq("")). + Times(1). + Return(container.ContainerCreateCreatedBody{ + ID: random.String(16, random.Alphanumeric), + }, errors.New("test error")) + + mockMoby.EXPECT(). + ContainerStart(Any(), Any(), Any()). + Times(0) + + mockMoby.EXPECT(). + ContainerLogs(Any(), Any(), Any()). + Times(0) + + return mockMoby, func() { + ctrl.Finish() + } +} + +func mockMobyFailedContainerStart( + t *testing.T, + args docker.RunArgs, +) (moby docker.Moby, finish func()) { + t.Helper() + + conID := random.String(16, random.Alphanumeric) + + ctrl := NewController(t) + + mockMoby := mock_docker.NewMockMoby(ctrl) + mockMoby.EXPECT(). + ContainerCreate(Eq(args.Ctx), Any(), Any(), Nil(), Eq("")). + Times(1). + Return(container.ContainerCreateCreatedBody{ + ID: conID, + }, nil) + + mockMoby.EXPECT(). + ContainerStart(Eq(args.Ctx), Eq(conID), Eq(types.ContainerStartOptions{})). + Times(1). + Return(errors.New("test error")) + + mockMoby.EXPECT(). + ContainerLogs(Any(), Any(), Any()). + Times(0) + + return mockMoby, func() { + ctrl.Finish() + } +} + +func mockMobyFailedContainerLogs( + t *testing.T, + args docker.RunArgs, +) (moby docker.Moby, finish func()) { + t.Helper() + + conID := random.String(16, random.Alphanumeric) + + ctrl := NewController(t) + + mockMoby := mock_docker.NewMockMoby(ctrl) + mockMoby.EXPECT(). + ContainerCreate(Eq(args.Ctx), Any(), Any(), Nil(), Eq("")). + Times(1). + Return(container.ContainerCreateCreatedBody{ + ID: conID, + }, nil) + + mockMoby.EXPECT(). + ContainerStart(Eq(args.Ctx), Eq(conID), Eq(types.ContainerStartOptions{})). + Times(1). + Return(nil) + + mockMoby.EXPECT(). + ContainerLogs(Eq(args.Ctx), Eq(conID), Eq(types.ContainerLogsOptions{ + ShowStdout: true, + ShowStderr: true, + Follow: true, + })). + Times(1). + Return(nil, errors.New("test error")) + + return mockMoby, func() { + ctrl.Finish() + } +} + +func TestClient_RemoveContainer(t *testing.T) { + t.Run("without error", func(t *testing.T) { + // given + ctx := context.Background() + conID := docker.ContainerID(random.String(16, random.Alphanumeric)) + + // and + ctrl := NewController(t) + defer ctrl.Finish() + + mockMoby := mock_docker.NewMockMoby(ctrl) + mockMoby.EXPECT(). + ContainerRemove(Eq(ctx), Eq(conID.String()), Eq(types.ContainerRemoveOptions{})). + Times(1). + Return(nil) + + // and + sut := &docker.Client{} + defer sut.SetMoby(mockMoby)() + + // expect + if err := sut.RemoveContainer(ctx, conID); err != nil { + t.Errorf("error must be nil, but got %+v", err) + } + }) + + t.Run("with error", func(t *testing.T) { + // given + ctx := context.Background() + conID := docker.ContainerID(random.String(16, random.Alphanumeric)) + + // and + ctrl := NewController(t) + defer ctrl.Finish() + + mockMoby := mock_docker.NewMockMoby(ctrl) + mockMoby.EXPECT(). + ContainerRemove(Eq(ctx), Eq(conID.String()), Eq(types.ContainerRemoveOptions{})). + Times(1). + Return(errors.New("test error")) + + // and + sut := &docker.Client{} + defer sut.SetMoby(mockMoby)() + + // expect + if err := sut.RemoveContainer(ctx, conID); err == nil { + t.Error("error must not be nil") + } + }) +} + +func TestClient_RemoveImage(t *testing.T) { + t.Run("without error", func(t *testing.T) { + // given + ctx := context.Background() + tag := docker.Tag(random.String(16, random.Alphanumeric)) + + // and + ctrl := NewController(t) + defer ctrl.Finish() + + mockMoby := mock_docker.NewMockMoby(ctrl) + mockMoby.EXPECT(). + ImageRemove(Eq(ctx), Eq(tag.String()), Eq(types.ImageRemoveOptions{})). + Times(1). + Return(nil, nil) + + sut := &docker.Client{} + defer sut.SetMoby(mockMoby)() + + // expect + if err := sut.RemoveImage(ctx, tag); err != nil { + t.Errorf("error must be nil, but got %+v", err) + } + }) + + t.Run("without error", func(t *testing.T) { + // given + ctx := context.Background() + tag := docker.Tag(random.String(16, random.Alphanumeric)) + + // and + ctrl := NewController(t) + defer ctrl.Finish() + + mockMoby := mock_docker.NewMockMoby(ctrl) + mockMoby.EXPECT(). + ImageRemove(Eq(ctx), Eq(tag.String()), Eq(types.ImageRemoveOptions{})). + Times(1). + Return(nil, errors.New("test error")) + + sut := &docker.Client{} + defer sut.SetMoby(mockMoby)() + + // expect + if err := sut.RemoveImage(ctx, tag); err == nil { + t.Error("error must not be nil") + } + }) +} + +func TestClient_ExitCode(t *testing.T) { + t.Run("with exit code", func(t *testing.T) { + // given + ctx := context.Background() + conID := docker.ContainerID(random.String(16, random.Alphanumeric)) + + // and + want := docker.ExitCode(19) + + // and + body := make(chan container.ContainerWaitOKBody, 1) + e := make(chan error, 1) + + // and + ctrl := NewController(t) + defer ctrl.Finish() + + mockMoby := mock_docker.NewMockMoby(ctrl) + mockMoby.EXPECT(). + ContainerWait(Eq(ctx), Eq(conID.String()), Eq(container.WaitConditionNotRunning)). + Times(1). + Return(body, e) + + // and + sut := &docker.Client{} + defer sut.SetMoby(mockMoby)() + + // and + body <- container.ContainerWaitOKBody{StatusCode: int64(want)} + + // when + got, err := sut.ExitCode(ctx, conID) + + // then + if err != nil { + t.Errorf("error must be nil, but got %+v", err) + } + + // and + if !cmp.Equal(got, want) { + t.Errorf("must be equal but: %+v", cmp.Diff(got, want)) + } + }) + + t.Run("with error", func(t *testing.T) { + // given + ctx := context.Background() + conID := docker.ContainerID(random.String(16, random.Alphanumeric)) + + // and + want := docker.ExitCode(-1) + + // and + body := make(chan container.ContainerWaitOKBody, 1) + e := make(chan error, 1) + + // and + ctrl := NewController(t) + defer ctrl.Finish() + + mockMoby := mock_docker.NewMockMoby(ctrl) + mockMoby.EXPECT(). + ContainerWait(Eq(ctx), Eq(conID.String()), Eq(container.WaitConditionNotRunning)). + Times(1). + Return(body, e) + + // and + sut := &docker.Client{} + defer sut.SetMoby(mockMoby)() + + // and + e <- errors.New("test error") + + // when + got, err := sut.ExitCode(ctx, conID) + + // then + if err == nil { + t.Error("error must not be nil") + } + + // and + if !cmp.Equal(got, want) { + t.Errorf("must be equal but: %+v", cmp.Diff(got, want)) + } + }) +} + +func TestClient_Status(t *testing.T) { + t.Run("without error", func(t *testing.T) { + // given + ctrl := NewController(t) + defer ctrl.Finish() + + mockMoby := mock_docker.NewMockMoby(ctrl) + mockMoby.EXPECT(). + Info(Any()). + Times(1). + Return(types.Info{}, nil) + + // and + sut := &docker.Client{} + defer sut.SetMoby(mockMoby)() + + // want + if err := sut.Status(); err != nil { + t.Errorf("error must be nil, but got %+v", err) + } + }) + + t.Run("with error", func(t *testing.T) { + // given + ctrl := NewController(t) + defer ctrl.Finish() + + mockMoby := mock_docker.NewMockMoby(ctrl) + mockMoby.EXPECT(). + Info(Any()). + Times(1). + Return(types.Info{}, errors.New("test error")) + + // and + sut := &docker.Client{} + defer sut.SetMoby(mockMoby)() + + // want + if err := sut.Status(); err == nil { + t.Error("error must not be nil") + } + }) +} diff --git a/domain/model/docker/export_test.go b/domain/model/docker/export_test.go new file mode 100644 index 00000000..43158380 --- /dev/null +++ b/domain/model/docker/export_test.go @@ -0,0 +1,31 @@ +package docker + +import ( + "context" + "time" +) + +type Client = dockerImpl + +func (c *Client) SetMoby(moby Moby) (reset func()) { + tmp := c.moby + c.moby = moby + return func() { + c.moby = tmp + } +} + +type RunArgs struct { + Ctx context.Context + Opts RuntimeOptions + Tag Tag + Cmd Command +} + +func SetNowFunc(f func() time.Time) (reset func()) { + tmp := now + now = f + return func() { + now = tmp + } +} diff --git a/domain/model/docker/log.go b/domain/model/docker/log.go new file mode 100644 index 00000000..2ba004ea --- /dev/null +++ b/domain/model/docker/log.go @@ -0,0 +1,94 @@ +package docker + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "github.com/duck8823/duci/domain/model/job" + "github.com/duck8823/duci/internal/logger" + "github.com/pkg/errors" + "io" + "time" +) + +var now = time.Now + +type buildLogger struct { + reader *bufio.Reader +} + +// NewBuildLog return a instance of Log. +func NewBuildLog(r io.Reader) job.Log { + return &buildLogger{bufio.NewReader(r)} +} + +// ReadLine returns LogLine. +func (l *buildLogger) ReadLine() (*job.LogLine, error) { + for { + line, _, err := l.reader.ReadLine() + if err != nil { + return nil, err + } + + msg := extractMessage(line) + if len(msg) == 0 { + continue + } + + return &job.LogLine{Timestamp: now(), Message: msg}, nil + } +} + +type runLogger struct { + reader *bufio.Reader +} + +// NewRunLog returns a instance of Log +func NewRunLog(r io.Reader) job.Log { + return &runLogger{bufio.NewReader(r)} +} + +// ReadLine returns LogLine. +func (l *runLogger) ReadLine() (*job.LogLine, error) { + for { + line, _, err := l.reader.ReadLine() + if err != nil { + return nil, err + } + + msg, err := trimPrefix(line) + if err != nil { + return nil, errors.WithStack(err) + } else if len(msg) == 0 { + continue + } + + // prevent to CR + progress := bytes.Split(msg, []byte{'\r'}) + return &job.LogLine{Timestamp: now(), Message: string(progress[0])}, nil + } +} + +func extractMessage(line []byte) string { + s := &struct { + Stream string `json:"stream"` + }{} + if err := json.NewDecoder(bytes.NewReader(line)).Decode(s); err != nil { + logger.Error(err) + } + return s.Stream +} + +func trimPrefix(line []byte) ([]byte, error) { + if len(line) < 8 { + return []byte{}, nil + } + + // detect prefix + // see https://godoc.org/github.com/docker/docker/client#Client.ContainerLogs + if !((line[0] == 1 || line[0] == 2) && (line[1] == 0 && line[2] == 0 && line[3] == 0)) { + return nil, fmt.Errorf("invalid prefix: %+v", line[:7]) + } + return line[8:], nil +} diff --git a/domain/model/docker/log_test.go b/domain/model/docker/log_test.go new file mode 100644 index 00000000..3fce6db5 --- /dev/null +++ b/domain/model/docker/log_test.go @@ -0,0 +1,131 @@ +package docker_test + +import ( + "fmt" + "github.com/duck8823/duci/domain/model/docker" + "github.com/duck8823/duci/domain/model/job" + "github.com/google/go-cmp/cmp" + "io" + "strings" + "testing" + "time" +) + +func TestNewBuildLog(t *testing.T) { + // when + got := docker.NewBuildLog(strings.NewReader("hello world")) + + // then + if _, ok := got.(job.Log); !ok { + t.Errorf("type assertion error.") + } +} + +func TestBuildLogger_ReadLine(t *testing.T) { + // given + now := time.Now() + defer docker.SetNowFunc(func() time.Time { + return now + })() + + // and + want := &job.LogLine{ + Timestamp: now, + Message: "hello test", + } + + // and + sut := docker.NewBuildLog(strings.NewReader(fmt.Sprintf("{\"stream\":\"%s\"}\n\nhello world\n", want.Message))) + + // when + got, err := sut.ReadLine() + + // then + if err != nil { + t.Errorf("error must be nil, but got %+v", err) + } + + // and + if !cmp.Equal(got, want) { + t.Errorf("must be equal, but: %+v", cmp.Diff(got, want)) + } + + // when + got, err = sut.ReadLine() + + // then + if err != io.EOF { + t.Errorf("error must be io.EOF, but got %+v", err) + } + + // and + if got != nil { + t.Errorf("must be nil, but got %+v", got.Message) + } +} + +func TestNewRunLog(t *testing.T) { + // when + got := docker.NewRunLog(strings.NewReader("hello world")) + + // then + if _, ok := got.(job.Log); !ok { + t.Errorf("type assertion error.") + } +} + +func TestRunLogger_ReadLine(t *testing.T) { + // given + now := time.Now() + defer docker.SetNowFunc(func() time.Time { + return now + })() + + // and + want := &job.LogLine{ + Timestamp: now, + Message: "hello test", + } + + // and + sut := docker.NewRunLog(strings.NewReader(fmt.Sprintf("%shello test\rskipped line\n\n1234567890", string([]byte{1, 0, 0, 0, 9, 9, 9, 9})))) + + // when + got, err := sut.ReadLine() + + // then + if err != nil { + t.Errorf("error must be nil, but got %+v", err) + } + + // and + if !cmp.Equal(got, want) { + t.Errorf("must be equal, but: %+v", cmp.Diff(got, want)) + } + + // when + got, err = sut.ReadLine() + + // then + if err == nil || err == io.EOF { + t.Errorf("error must not be nil (invalid prefix), but got %+v", err) + } + + // and + if got != nil { + t.Errorf("must be nil, but got %+v", got.Message) + } + + // when + got, err = sut.ReadLine() + + // then + if err != io.EOF { + t.Errorf("error must be io.EOF, but got %+v", err) + } + + // and + if got != nil { + t.Errorf("must be nil, but got %+v", got.Message) + } +} diff --git a/domain/model/docker/mock_docker/docker.go b/domain/model/docker/mock_docker/docker.go new file mode 100644 index 00000000..5a77b39d --- /dev/null +++ b/domain/model/docker/mock_docker/docker.go @@ -0,0 +1,113 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: domain/model/docker/docker.go + +// Package mock_docker is a generated GoMock package. +package mock_docker + +import ( + context "context" + docker "github.com/duck8823/duci/domain/model/docker" + job "github.com/duck8823/duci/domain/model/job" + gomock "github.com/golang/mock/gomock" + io "io" + reflect "reflect" +) + +// MockDocker is a mock of Docker interface +type MockDocker struct { + ctrl *gomock.Controller + recorder *MockDockerMockRecorder +} + +// MockDockerMockRecorder is the mock recorder for MockDocker +type MockDockerMockRecorder struct { + mock *MockDocker +} + +// NewMockDocker creates a new mock instance +func NewMockDocker(ctrl *gomock.Controller) *MockDocker { + mock := &MockDocker{ctrl: ctrl} + mock.recorder = &MockDockerMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use +func (m *MockDocker) EXPECT() *MockDockerMockRecorder { + return m.recorder +} + +// Build mocks base method +func (m *MockDocker) Build(ctx context.Context, file io.Reader, tag docker.Tag, dockerfile docker.Dockerfile) (job.Log, error) { + ret := m.ctrl.Call(m, "Build", ctx, file, tag, dockerfile) + ret0, _ := ret[0].(job.Log) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Build indicates an expected call of Build +func (mr *MockDockerMockRecorder) Build(ctx, file, tag, dockerfile interface{}) *gomock.Call { + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Build", reflect.TypeOf((*MockDocker)(nil).Build), ctx, file, tag, dockerfile) +} + +// Run mocks base method +func (m *MockDocker) Run(ctx context.Context, opts docker.RuntimeOptions, tag docker.Tag, cmd docker.Command) (docker.ContainerID, job.Log, error) { + ret := m.ctrl.Call(m, "Run", ctx, opts, tag, cmd) + ret0, _ := ret[0].(docker.ContainerID) + ret1, _ := ret[1].(job.Log) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// Run indicates an expected call of Run +func (mr *MockDockerMockRecorder) Run(ctx, opts, tag, cmd interface{}) *gomock.Call { + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Run", reflect.TypeOf((*MockDocker)(nil).Run), ctx, opts, tag, cmd) +} + +// RemoveContainer mocks base method +func (m *MockDocker) RemoveContainer(ctx context.Context, containerID docker.ContainerID) error { + ret := m.ctrl.Call(m, "RemoveContainer", ctx, containerID) + ret0, _ := ret[0].(error) + return ret0 +} + +// RemoveContainer indicates an expected call of RemoveContainer +func (mr *MockDockerMockRecorder) RemoveContainer(ctx, containerID interface{}) *gomock.Call { + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RemoveContainer", reflect.TypeOf((*MockDocker)(nil).RemoveContainer), ctx, containerID) +} + +// RemoveImage mocks base method +func (m *MockDocker) RemoveImage(ctx context.Context, tag docker.Tag) error { + ret := m.ctrl.Call(m, "RemoveImage", ctx, tag) + ret0, _ := ret[0].(error) + return ret0 +} + +// RemoveImage indicates an expected call of RemoveImage +func (mr *MockDockerMockRecorder) RemoveImage(ctx, tag interface{}) *gomock.Call { + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RemoveImage", reflect.TypeOf((*MockDocker)(nil).RemoveImage), ctx, tag) +} + +// ExitCode mocks base method +func (m *MockDocker) ExitCode(ctx context.Context, containerID docker.ContainerID) (docker.ExitCode, error) { + ret := m.ctrl.Call(m, "ExitCode", ctx, containerID) + ret0, _ := ret[0].(docker.ExitCode) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ExitCode indicates an expected call of ExitCode +func (mr *MockDockerMockRecorder) ExitCode(ctx, containerID interface{}) *gomock.Call { + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExitCode", reflect.TypeOf((*MockDocker)(nil).ExitCode), ctx, containerID) +} + +// Status mocks base method +func (m *MockDocker) Status() error { + ret := m.ctrl.Call(m, "Status") + ret0, _ := ret[0].(error) + return ret0 +} + +// Status indicates an expected call of Status +func (mr *MockDockerMockRecorder) Status() *gomock.Call { + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Status", reflect.TypeOf((*MockDocker)(nil).Status)) +} diff --git a/infrastructure/docker/mock_docker/third_party.go b/domain/model/docker/mock_docker/third_pirty.go similarity index 99% rename from infrastructure/docker/mock_docker/third_party.go rename to domain/model/docker/mock_docker/third_pirty.go index 2837d0c5..b7f03e5f 100644 --- a/infrastructure/docker/mock_docker/third_party.go +++ b/domain/model/docker/mock_docker/third_pirty.go @@ -1,5 +1,5 @@ // Code generated by MockGen. DO NOT EDIT. -// Source: infrastructure/docker/third_pirty.go +// Source: domain/model/docker/third_pirty.go // Package mock_docker is a generated GoMock package. package mock_docker diff --git a/domain/model/docker/options.go b/domain/model/docker/options.go new file mode 100644 index 00000000..94ec8585 --- /dev/null +++ b/domain/model/docker/options.go @@ -0,0 +1,37 @@ +package docker + +import ( + "fmt" + "strings" +) + +// RuntimeOptions is a docker options. +type RuntimeOptions struct { + Environments Environments + Volumes Volumes +} + +// Environments represents a docker `-e` option. +type Environments map[string]interface{} + +// Array returns string array of environments +func (e Environments) Array() []string { + var a []string + for key, val := range e { + a = append(a, fmt.Sprintf("%s=%v", key, val)) + } + return a +} + +// Volumes represents a docker `-v` option. +type Volumes []string + +// Map returns map of volumes. +func (v Volumes) Map() map[string]struct{} { + m := make(map[string]struct{}) + for _, volume := range v { + key := strings.Split(volume, ":")[0] + m[key] = struct{}{} + } + return m +} diff --git a/domain/model/docker/options_test.go b/domain/model/docker/options_test.go new file mode 100644 index 00000000..de1a1c14 --- /dev/null +++ b/domain/model/docker/options_test.go @@ -0,0 +1,71 @@ +package docker_test + +import ( + "github.com/duck8823/duci/domain/model/docker" + "github.com/google/go-cmp/cmp" + "sort" + "testing" +) + +func TestEnvironments_ToArray(t *testing.T) { + var empty []string + for _, tt := range []struct { + in docker.Environments + want []string + }{ + { + in: docker.Environments{}, + want: empty, + }, + { + in: docker.Environments{ + "int": 19, + "string": "hello", + }, + want: []string{ + "int=19", + "string=hello", + }, + }, + } { + // when + got := tt.in.Array() + want := tt.want + sort.Strings(got) + sort.Strings(want) + + // then + if !cmp.Equal(got, want) { + t.Errorf("must be equal. but %+v", cmp.Diff(got, want)) + } + } +} + +func TestVolumes_Volumes(t *testing.T) { + for _, tt := range []struct { + in docker.Volumes + want map[string]struct{} + }{ + { + in: docker.Volumes{}, + want: make(map[string]struct{}), + }, + { + in: docker.Volumes{ + "/hoge/fuga:/hoge/hoge", + }, + want: map[string]struct{}{ + "/hoge/fuga": {}, + }, + }, + } { + // when + got := tt.in.Map() + want := tt.want + + // then + if !cmp.Equal(got, want) { + t.Errorf("must be equal. but %+v", cmp.Diff(got, want)) + } + } +} diff --git a/infrastructure/docker/third_pirty.go b/domain/model/docker/third_pirty.go similarity index 100% rename from infrastructure/docker/third_pirty.go rename to domain/model/docker/third_pirty.go diff --git a/domain/model/docker/types.go b/domain/model/docker/types.go new file mode 100644 index 00000000..e3bacb42 --- /dev/null +++ b/domain/model/docker/types.go @@ -0,0 +1,41 @@ +package docker + +// Tag describes a docker tag +type Tag string + +// ToString return string value +func (t Tag) String() string { + return string(t) +} + +// Command describes a docker CMD +type Command []string + +// Slice returns slice values +func (c Command) Slice() []string { + return []string(c) +} + +// Dockerfile represents a path to dockerfile +type Dockerfile string + +// ToString returns string value +func (d Dockerfile) String() string { + return string(d) +} + +// ContainerID describes a container id of docker +type ContainerID string + +// ToString returns string value +func (c ContainerID) String() string { + return string(c) +} + +// ExitCode describes a exit code +type ExitCode int64 + +// IsFailure returns whether failure code or not +func (c ExitCode) IsFailure() bool { + return c != 0 +} diff --git a/domain/model/docker/types_test.go b/domain/model/docker/types_test.go new file mode 100644 index 00000000..7fe983f3 --- /dev/null +++ b/domain/model/docker/types_test.go @@ -0,0 +1,105 @@ +package docker_test + +import ( + "fmt" + "github.com/duck8823/duci/domain/model/docker" + "github.com/google/go-cmp/cmp" + "github.com/labstack/gommon/random" + "testing" +) + +func TestTag_String(t *testing.T) { + // given + want := "hello" + + // and + sut := docker.Tag(want) + + // when + got := sut.String() + + // then + if got != want { + t.Errorf("must equal: want %s, got %s", want, got) + } +} + +func TestCommand_Slice(t *testing.T) { + // given + want := []string{"test", "./..."} + + // and + sut := docker.Command(want) + + // when + got := sut.Slice() + + // then + if !cmp.Equal(got, want) { + t.Errorf("must equal: want %+v, got %+v", want, got) + } +} + +func TestDockerfile_String(t *testing.T) { + // given + want := "duck8823/duci" + + // and + sut := docker.Dockerfile(want) + + // when + got := sut.String() + + // then + if got != want { + t.Errorf("must equal: want %s, got %s", want, got) + } +} + +func TestContainerID_String(t *testing.T) { + // given + want := random.String(16, random.Alphanumeric) + + // and + sut := docker.ContainerID(want) + + // when + got := sut.String() + + // then + if got != want { + t.Errorf("must equal: want %s, got %s", want, got) + } +} + +func TestExitCode_IsFailure(t *testing.T) { + // where + for _, tt := range []struct { + code int64 + want bool + }{ + { + code: 0, + want: false, + }, + { + code: -1, + want: true, + }, + { + code: 1, + want: true, + }, + } { + t.Run(fmt.Sprintf("when code is %+v", tt.code), func(t *testing.T) { + // given + sut := docker.ExitCode(tt.code) + + // expect + if sut.IsFailure() != tt.want { + t.Errorf("must be %+v, but got %+v", tt.want, sut.IsFailure()) + } + }) + + } +} diff --git a/domain/model/job/job.go b/domain/model/job/job.go new file mode 100644 index 00000000..147f59c6 --- /dev/null +++ b/domain/model/job/job.go @@ -0,0 +1,41 @@ +package job + +import ( + "encoding/json" + "github.com/google/uuid" + "github.com/pkg/errors" +) + +// Job represents a task +type Job struct { + ID ID + Finished bool `json:"finished"` + Stream []LogLine `json:"stream"` +} + +// AppendLog append log line to stream +func (j *Job) AppendLog(line LogLine) { + j.Stream = append(j.Stream, line) +} + +// Finish set true to Finished +func (j *Job) Finish() { + j.Finished = true +} + +// ToBytes returns marshal byte slice +func (j *Job) ToBytes() ([]byte, error) { + data, err := json.Marshal(j) + if err != nil { + return nil, errors.WithStack(err) + } + return data, nil +} + +// ID is the identifier of job +type ID uuid.UUID + +// ToSlice returns slice value +func (i ID) ToSlice() []byte { + return []byte(uuid.UUID(i).String()) +} diff --git a/domain/model/job/job_test.go b/domain/model/job/job_test.go new file mode 100644 index 00000000..cfc14048 --- /dev/null +++ b/domain/model/job/job_test.go @@ -0,0 +1,84 @@ +package job_test + +import ( + "github.com/duck8823/duci/domain/model/job" + "github.com/google/go-cmp/cmp" + "github.com/google/uuid" + "testing" +) + +func TestJob_AppendLog(t *testing.T) { + // given + want := []job.LogLine{{ + Message: "Hello World", + }} + + // and + sut := job.Job{} + + // when + sut.AppendLog(want[0]) + + // then + got := sut.Stream + if !cmp.Equal(got, want) { + t.Errorf("must be equal, but %+v", cmp.Diff(got, want)) + } +} + +func TestJob_Finish(t *testing.T) { + // given + sut := job.Job{} + + // when + sut.Finish() + + // then + if !sut.Finished { + t.Errorf("must be true, but false") + } +} + +func TestJob_ToBytes(t *testing.T) { + t.Run("when success marshal", func(t *testing.T) { + // given + want := []byte("{\"ID\":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],\"finished\":false,\"stream\":[]}") + + // and + sut := job.Job{ + ID: job.ID(uuid.Nil), + Finished: false, + Stream: []job.LogLine{}, + } + + // when + got, err := sut.ToBytes() + + // then + if err != nil { + t.Errorf("error must be nil, but got %+v", err) + } + + // and + if !cmp.Equal(got, want) { + t.Errorf("must be equal, but %+v", cmp.Diff(got, want)) + } + }) +} + +func TestID_ToSlice(t *testing.T) { + // given + want := []byte(uuid.New().String()) + + // and + id, _ := uuid.ParseBytes(want) + sut := job.ID(id) + + // when + got := sut.ToSlice() + + // then + if !cmp.Equal(got, want) { + t.Errorf("must be equal, but %+v", cmp.Diff(string(got), string(want))) + } +} diff --git a/domain/model/job/log.go b/domain/model/job/log.go new file mode 100644 index 00000000..495650af --- /dev/null +++ b/domain/model/job/log.go @@ -0,0 +1,14 @@ +package job + +import "time" + +// Log is a interface represents docker log. +type Log interface { + ReadLine() (*LogLine, error) +} + +// LogLine stores timestamp and message. +type LogLine struct { + Timestamp time.Time `json:"time"` + Message string `json:"message"` +} diff --git a/domain/model/job/mock_job/log.go b/domain/model/job/mock_job/log.go new file mode 100644 index 00000000..17deca5b --- /dev/null +++ b/domain/model/job/mock_job/log.go @@ -0,0 +1,47 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: domain/model/job/log.go + +// Package mock_job is a generated GoMock package. +package mock_job + +import ( + job "github.com/duck8823/duci/domain/model/job" + gomock "github.com/golang/mock/gomock" + reflect "reflect" +) + +// MockLog is a mock of Log interface +type MockLog struct { + ctrl *gomock.Controller + recorder *MockLogMockRecorder +} + +// MockLogMockRecorder is the mock recorder for MockLog +type MockLogMockRecorder struct { + mock *MockLog +} + +// NewMockLog creates a new mock instance +func NewMockLog(ctrl *gomock.Controller) *MockLog { + mock := &MockLog{ctrl: ctrl} + mock.recorder = &MockLogMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use +func (m *MockLog) EXPECT() *MockLogMockRecorder { + return m.recorder +} + +// ReadLine mocks base method +func (m *MockLog) ReadLine() (*job.LogLine, error) { + ret := m.ctrl.Call(m, "ReadLine") + ret0, _ := ret[0].(*job.LogLine) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ReadLine indicates an expected call of ReadLine +func (mr *MockLogMockRecorder) ReadLine() *gomock.Call { + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReadLine", reflect.TypeOf((*MockLog)(nil).ReadLine)) +} diff --git a/domain/model/job/mock_job/repository.go b/domain/model/job/mock_job/repository.go new file mode 100644 index 00000000..a6e50244 --- /dev/null +++ b/domain/model/job/mock_job/repository.go @@ -0,0 +1,59 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: domain/model/job/repository.go + +// Package mock_job is a generated GoMock package. +package mock_job + +import ( + job "github.com/duck8823/duci/domain/model/job" + gomock "github.com/golang/mock/gomock" + reflect "reflect" +) + +// MockRepository is a mock of Repository interface +type MockRepository struct { + ctrl *gomock.Controller + recorder *MockRepositoryMockRecorder +} + +// MockRepositoryMockRecorder is the mock recorder for MockRepository +type MockRepositoryMockRecorder struct { + mock *MockRepository +} + +// NewMockRepository creates a new mock instance +func NewMockRepository(ctrl *gomock.Controller) *MockRepository { + mock := &MockRepository{ctrl: ctrl} + mock.recorder = &MockRepositoryMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use +func (m *MockRepository) EXPECT() *MockRepositoryMockRecorder { + return m.recorder +} + +// FindBy mocks base method +func (m *MockRepository) FindBy(arg0 job.ID) (*job.Job, error) { + ret := m.ctrl.Call(m, "FindBy", arg0) + ret0, _ := ret[0].(*job.Job) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// FindBy indicates an expected call of FindBy +func (mr *MockRepositoryMockRecorder) FindBy(arg0 interface{}) *gomock.Call { + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindBy", reflect.TypeOf((*MockRepository)(nil).FindBy), arg0) +} + +// Save mocks base method +func (m *MockRepository) Save(arg0 job.Job) error { + ret := m.ctrl.Call(m, "Save", arg0) + ret0, _ := ret[0].(error) + return ret0 +} + +// Save indicates an expected call of Save +func (mr *MockRepositoryMockRecorder) Save(arg0 interface{}) *gomock.Call { + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Save", reflect.TypeOf((*MockRepository)(nil).Save), arg0) +} diff --git a/domain/model/job/repository.go b/domain/model/job/repository.go new file mode 100644 index 00000000..e059017a --- /dev/null +++ b/domain/model/job/repository.go @@ -0,0 +1,12 @@ +package job + +import "errors" + +// ErrNotFound represents a job not found error +var ErrNotFound = errors.New("job not found") + +// Repository is Job Repository +type Repository interface { + FindBy(ID) (*Job, error) + Save(Job) error +} diff --git a/domain/model/job/target.go b/domain/model/job/target.go new file mode 100644 index 00000000..f4026d3c --- /dev/null +++ b/domain/model/job/target.go @@ -0,0 +1,17 @@ +package job + +// Target represents build target +type Target interface { + Prepare() (WorkDir, Cleanup, error) +} + +// WorkDir is a working directory for build job +type WorkDir string + +// String returns string value +func (w WorkDir) String() string { + return string(w) +} + +// Cleanup function for workdir +type Cleanup func() diff --git a/domain/model/job/target/export_test.go b/domain/model/job/target/export_test.go new file mode 100644 index 00000000..ada36ee8 --- /dev/null +++ b/domain/model/job/target/export_test.go @@ -0,0 +1,18 @@ +package target + +type MockRepository struct { + FullName string + URL string +} + +func (r *MockRepository) GetFullName() string { + return r.FullName +} + +func (r *MockRepository) GetSSHURL() string { + return r.URL +} + +func (r *MockRepository) GetCloneURL() string { + return r.URL +} diff --git a/domain/model/job/target/git/export_test.go b/domain/model/job/target/git/export_test.go new file mode 100644 index 00000000..af2fa41f --- /dev/null +++ b/domain/model/job/target/git/export_test.go @@ -0,0 +1,46 @@ +package git + +import ( + "bufio" + "context" + "gopkg.in/src-d/go-git.v4" + "time" +) + +type HTTPGitClient = httpGitClient + +type SSHGitClient = sshGitClient + +type CloneLogger = cloneLogger + +func (l *CloneLogger) SetReader(r *bufio.Reader) (reset func()) { + tmp := l.reader + l.reader = r + return func() { + l.reader = tmp + } +} + +func SetPlainCloneFunc(f func(path string, isBare bool, o *git.CloneOptions) (*git.Repository, error)) (reset func()) { + tmp := plainClone + plainClone = f + return func() { + plainClone = tmp + } +} + +func SetNowFunc(f func() time.Time) (reset func()) { + tmp := now + now = f + return func() { + now = tmp + } +} + +func (l *ProgressLogger) SetContext(ctx context.Context) (reset func()) { + tmp := l.ctx + l.ctx = ctx + return func() { + l.ctx = tmp + } +} diff --git a/domain/model/job/target/git/git.go b/domain/model/job/target/git/git.go new file mode 100644 index 00000000..3dbdccde --- /dev/null +++ b/domain/model/job/target/git/git.go @@ -0,0 +1,49 @@ +package git + +import ( + "context" + "github.com/duck8823/duci/internal/container" + "github.com/pkg/errors" + "gopkg.in/src-d/go-git.v4" + "gopkg.in/src-d/go-git.v4/plumbing" +) + +var plainClone = git.PlainClone + +// TargetSource is a interface returns clone URLs, Ref and SHA for target +type TargetSource interface { + GetSSHURL() string + GetCloneURL() string + GetRef() string + GetSHA() plumbing.Hash +} + +// Git describes a git service. +type Git interface { + Clone(ctx context.Context, dir string, src TargetSource) error +} + +// GetInstance returns a git client +func GetInstance() (Git, error) { + git := new(Git) + if err := container.Get(git); err != nil { + return nil, errors.WithStack(err) + } + return *git, nil +} + +func checkout(repo *git.Repository, sha plumbing.Hash) error { + wt, err := repo.Worktree() + if err != nil { + return errors.WithStack(err) + } + + if err := wt.Checkout(&git.CheckoutOptions{ + Hash: sha, + Branch: plumbing.ReferenceName(sha.String()), + Create: true, + }); err != nil { + return errors.WithStack(err) + } + return nil +} diff --git a/domain/model/job/target/git/git_test.go b/domain/model/job/target/git/git_test.go new file mode 100644 index 00000000..f8cdc19c --- /dev/null +++ b/domain/model/job/target/git/git_test.go @@ -0,0 +1,50 @@ +package git_test + +import ( + "github.com/duck8823/duci/domain/model/job/target/git" + "github.com/duck8823/duci/internal/container" + "github.com/google/go-cmp/cmp" + "testing" +) + +func TestGetInstance(t *testing.T) { + t.Run("when instance is nil", func(t *testing.T) { + // given + container.Clear() + + // when + got, err := git.GetInstance() + + // then + if err == nil { + t.Error("error must not be nil") + } + + // and + if got != nil { + t.Errorf("must be nil, but got %+v", err) + } + }) + + t.Run("when instance is not nil", func(t *testing.T) { + // given + want := &git.HTTPGitClient{} + + // and + container.Override(want) + defer container.Clear() + + // when + got, err := git.GetInstance() + + // then + if err != nil { + t.Errorf("error must be nil, but got %+v", err) + } + + // and + if !cmp.Equal(got, want) { + t.Errorf("must be equal, but %+v", cmp.Diff(got, want)) + } + }) +} diff --git a/domain/model/job/target/git/http_client.go b/domain/model/job/target/git/http_client.go new file mode 100644 index 00000000..c85edc3f --- /dev/null +++ b/domain/model/job/target/git/http_client.go @@ -0,0 +1,41 @@ +package git + +import ( + "context" + "github.com/duck8823/duci/domain/model/runner" + "github.com/duck8823/duci/internal/container" + "github.com/pkg/errors" + "gopkg.in/src-d/go-git.v4" + "gopkg.in/src-d/go-git.v4/plumbing" +) + +type httpGitClient struct { + runner.LogFunc +} + +// InitializeWithHTTP initialize git client with http protocol +func InitializeWithHTTP(logFunc runner.LogFunc) error { + git := new(Git) + *git = &httpGitClient{LogFunc: logFunc} + if err := container.Submit(git); err != nil { + return errors.WithStack(err) + } + return nil +} + +// Clone a repository into the path with target source. +func (s *httpGitClient) Clone(ctx context.Context, dir string, src TargetSource) error { + gitRepository, err := plainClone(dir, false, &git.CloneOptions{ + URL: src.GetCloneURL(), + Progress: &ProgressLogger{ctx: ctx, LogFunc: s.LogFunc}, + ReferenceName: plumbing.ReferenceName(src.GetRef()), + }) + if err != nil { + return errors.WithStack(err) + } + + if err := checkout(gitRepository, src.GetSHA()); err != nil { + return errors.WithStack(err) + } + return nil +} diff --git a/domain/model/job/target/git/http_client_test.go b/domain/model/job/target/git/http_client_test.go new file mode 100644 index 00000000..cba13194 --- /dev/null +++ b/domain/model/job/target/git/http_client_test.go @@ -0,0 +1,192 @@ +package git_test + +import ( + "context" + "errors" + "github.com/duck8823/duci/domain/model/job" + "github.com/duck8823/duci/domain/model/job/target/git" + "github.com/duck8823/duci/domain/model/job/target/git/mock_git" + "github.com/duck8823/duci/domain/model/runner" + "github.com/duck8823/duci/internal/container" + "github.com/golang/mock/gomock" + "github.com/labstack/gommon/random" + go_git "gopkg.in/src-d/go-git.v4" + "gopkg.in/src-d/go-git.v4/plumbing" + "gopkg.in/src-d/go-git.v4/plumbing/object" + "os" + "path/filepath" + "testing" +) + +func TestInitializeWithHTTP(t *testing.T) { + t.Run("when instance is nil", func(t *testing.T) { + // given + container.Clear() + + // when + err := git.InitializeWithHTTP(func(_ context.Context, _ job.Log) {}) + + // then + if err != nil { + t.Errorf("error must be nil, but got %+v", err) + } + }) + + t.Run("when instance is not nil", func(t *testing.T) { + // given + container.Override(&git.HTTPGitClient{}) + defer container.Clear() + + // when + err := git.InitializeWithHTTP(func(_ context.Context, _ job.Log) {}) + + // then + if err == nil { + t.Error("error must not be nil") + } + }) +} + +func TestHttpGitClient_Clone(t *testing.T) { + t.Run("when failure git clone", func(t *testing.T) { + // given + reset := git.SetPlainCloneFunc(func(_ string, _ bool, _ *go_git.CloneOptions) (*go_git.Repository, error) { + return nil, errors.New("test") + }) + defer reset() + + // and + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + targetSrc := mock_git.NewMockTargetSource(ctrl) + targetSrc.EXPECT(). + GetCloneURL(). + Times(1). + Times(1).Return("http://github.com/duck8823/duci.git") + targetSrc.EXPECT(). + GetRef(). + Times(1). + Return("HEAD") + + // and + sut := &git.HTTPGitClient{LogFunc: runner.NothingToDo} + + // expect + if err := sut.Clone( + context.Background(), + "/path/to/dummy", + targetSrc, + ); err == nil { + t.Error("error must not nil.") + } + }) + + t.Run("when success git clone and checkout", func(t *testing.T) { + // given + var hash plumbing.Hash + defer git.SetPlainCloneFunc(func(tmpDir string, _ bool, _ *go_git.CloneOptions) (*go_git.Repository, error) { + // git init + repo, err := go_git.PlainInit(tmpDir, false) + if err != nil { + t.Fatalf("error occur: %+v", err) + } + + w, err := repo.Worktree() + if err != nil { + t.Fatalf("error occur: %+v", err) + } + + // initial commit ( for success checkout ) + hash, err = w.Commit("init. commit", &go_git.CommitOptions{Author: &object.Signature{}}) + if err != nil { + t.Fatalf("error occur: %+v", err) + } + + return repo, nil + })() + + tmpDir, reset := createTmpDir(t) + defer reset() + + // and + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + targetSrc := mock_git.NewMockTargetSource(ctrl) + targetSrc.EXPECT(). + GetCloneURL(). + Times(1). + Times(1).Return("http://github.com/duck8823/duci.git") + targetSrc.EXPECT(). + GetRef(). + Times(1). + Return("HEAD") + targetSrc.EXPECT(). + GetSHA(). + Times(1). + Return(hash) + + // and + sut := &git.HTTPGitClient{LogFunc: runner.NothingToDo} + + // expect + if err := sut.Clone(context.Background(), tmpDir, targetSrc); err != nil { + t.Errorf("error must be nil, but got %+v", err) + } + }) + + t.Run("when success git clone but failure checkout", func(t *testing.T) { + // given + defer git.SetPlainCloneFunc(func(tmpDir string, _ bool, _ *go_git.CloneOptions) (*go_git.Repository, error) { + // git init + repo, err := go_git.PlainInit(tmpDir, false) + if err != nil { + t.Fatalf("error occur: %+v", err) + } + + return repo, nil + })() + + tmpDir, reset := createTmpDir(t) + defer reset() + + // and + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + targetSrc := mock_git.NewMockTargetSource(ctrl) + targetSrc.EXPECT(). + GetCloneURL(). + Times(1). + Return("http://github.com/duck8823/duci.git") + targetSrc.EXPECT(). + GetRef(). + Times(1). + Return("HEAD") + targetSrc.EXPECT(). + GetSHA(). + Times(1). + Return(plumbing.ZeroHash) + + // and + sut := &git.HTTPGitClient{LogFunc: runner.NothingToDo} + + // expect + if err := sut.Clone(context.Background(), tmpDir, targetSrc); err == nil { + t.Error("error must not be nil") + } + }) +} + +func createTmpDir(t *testing.T) (tmpDir string, reset func()) { + t.Helper() + + dir := filepath.Join(os.TempDir(), random.String(16, random.Alphanumeric)) + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatalf("error occur: %+v", err) + } + return dir, func() { + _ = os.RemoveAll(dir) + } +} diff --git a/domain/model/job/target/git/log.go b/domain/model/job/target/git/log.go new file mode 100644 index 00000000..383efdcb --- /dev/null +++ b/domain/model/job/target/git/log.go @@ -0,0 +1,51 @@ +package git + +import ( + "bufio" + "bytes" + "context" + "github.com/duck8823/duci/domain/model/job" + "github.com/duck8823/duci/domain/model/runner" + "regexp" + "time" +) + +var now = time.Now + +type cloneLogger struct { + reader *bufio.Reader +} + +// ReadLine returns LogLine. +func (l *cloneLogger) ReadLine() (*job.LogLine, error) { + for { + line, _, err := l.reader.ReadLine() + if err != nil { + return nil, err + } + + if len(line) == 0 || rep.Match(line) { + continue + } + + return &job.LogLine{Timestamp: now(), Message: string(line)}, nil + } +} + +// Regexp to remove CR or later (inline progress) +var rep = regexp.MustCompile("\r.*$") + +// ProgressLogger is a writer for git progress +type ProgressLogger struct { + ctx context.Context + runner.LogFunc +} + +// Write a log without CR or later. +func (l *ProgressLogger) Write(p []byte) (n int, err error) { + log := &cloneLogger{ + reader: bufio.NewReader(bytes.NewReader(p)), + } + l.LogFunc(l.ctx, log) + return 0, nil +} diff --git a/domain/model/job/target/git/log_test.go b/domain/model/job/target/git/log_test.go new file mode 100644 index 00000000..6ab795db --- /dev/null +++ b/domain/model/job/target/git/log_test.go @@ -0,0 +1,86 @@ +package git_test + +import ( + "bufio" + "context" + "fmt" + "github.com/duck8823/duci/domain/model/job" + "github.com/duck8823/duci/domain/model/job/target/git" + "github.com/google/go-cmp/cmp" + "io" + "strings" + "testing" + "time" +) + +func TestCloneLogger_ReadLine(t *testing.T) { + // given + now := time.Now() + defer git.SetNowFunc(func() time.Time { + return now + })() + + // and + want := &job.LogLine{ + Timestamp: now, + Message: "Hello World", + } + + // and + sut := &git.CloneLogger{} + defer sut.SetReader(bufio.NewReader(strings.NewReader(fmt.Sprintf("%s\n\n", want.Message))))() + + // when + got, err := sut.ReadLine() + + // then + if err != nil { + t.Errorf("err must be nil, but got %+v", err) + } + + // and + if !cmp.Equal(got, want) { + t.Errorf("must be equal, but %+v", cmp.Diff(got, want)) + } + + // when + got, err = sut.ReadLine() + + // then + if err != io.EOF { + t.Errorf("must be equal io.EOF, but got %+v", err) + } + + // and + if got != nil { + t.Errorf("must be nil, but got %+v", got) + } +} + +func TestProgressLogger_Write(t *testing.T) { + // given + var got string + sut := &git.ProgressLogger{ + LogFunc: func(_ context.Context, log job.Log) { + line, _ := log.ReadLine() + got = line.Message + }, + } + sut.SetContext(context.Background()) + + // and + want := "hello world" + + // when + _, err := sut.Write([]byte(want)) + + // then + if err != nil { + t.Errorf("error must be nil, but got %+v", err) + } + + // and + if got != want { + t.Errorf("must be equal, but not\n%+v", cmp.Diff(got, want)) + } +} diff --git a/domain/model/job/target/git/mock_git/git.go b/domain/model/job/target/git/mock_git/git.go new file mode 100644 index 00000000..a2a27d47 --- /dev/null +++ b/domain/model/job/target/git/mock_git/git.go @@ -0,0 +1,119 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: domain/model/job/target/git/git.go + +// Package mock_git is a generated GoMock package. +package mock_git + +import ( + context "context" + git "github.com/duck8823/duci/domain/model/job/target/git" + gomock "github.com/golang/mock/gomock" + plumbing "gopkg.in/src-d/go-git.v4/plumbing" + reflect "reflect" +) + +// MockTargetSource is a mock of TargetSource interface +type MockTargetSource struct { + ctrl *gomock.Controller + recorder *MockTargetSourceMockRecorder +} + +// MockTargetSourceMockRecorder is the mock recorder for MockTargetSource +type MockTargetSourceMockRecorder struct { + mock *MockTargetSource +} + +// NewMockTargetSource creates a new mock instance +func NewMockTargetSource(ctrl *gomock.Controller) *MockTargetSource { + mock := &MockTargetSource{ctrl: ctrl} + mock.recorder = &MockTargetSourceMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use +func (m *MockTargetSource) EXPECT() *MockTargetSourceMockRecorder { + return m.recorder +} + +// GetSSHURL mocks base method +func (m *MockTargetSource) GetSSHURL() string { + ret := m.ctrl.Call(m, "GetSSHURL") + ret0, _ := ret[0].(string) + return ret0 +} + +// GetSSHURL indicates an expected call of GetSSHURL +func (mr *MockTargetSourceMockRecorder) GetSSHURL() *gomock.Call { + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSSHURL", reflect.TypeOf((*MockTargetSource)(nil).GetSSHURL)) +} + +// GetCloneURL mocks base method +func (m *MockTargetSource) GetCloneURL() string { + ret := m.ctrl.Call(m, "GetCloneURL") + ret0, _ := ret[0].(string) + return ret0 +} + +// GetCloneURL indicates an expected call of GetCloneURL +func (mr *MockTargetSourceMockRecorder) GetCloneURL() *gomock.Call { + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetCloneURL", reflect.TypeOf((*MockTargetSource)(nil).GetCloneURL)) +} + +// GetRef mocks base method +func (m *MockTargetSource) GetRef() string { + ret := m.ctrl.Call(m, "GetRef") + ret0, _ := ret[0].(string) + return ret0 +} + +// GetRef indicates an expected call of GetRef +func (mr *MockTargetSourceMockRecorder) GetRef() *gomock.Call { + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetRef", reflect.TypeOf((*MockTargetSource)(nil).GetRef)) +} + +// GetSHA mocks base method +func (m *MockTargetSource) GetSHA() plumbing.Hash { + ret := m.ctrl.Call(m, "GetSHA") + ret0, _ := ret[0].(plumbing.Hash) + return ret0 +} + +// GetSHA indicates an expected call of GetSHA +func (mr *MockTargetSourceMockRecorder) GetSHA() *gomock.Call { + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSHA", reflect.TypeOf((*MockTargetSource)(nil).GetSHA)) +} + +// MockGit is a mock of Git interface +type MockGit struct { + ctrl *gomock.Controller + recorder *MockGitMockRecorder +} + +// MockGitMockRecorder is the mock recorder for MockGit +type MockGitMockRecorder struct { + mock *MockGit +} + +// NewMockGit creates a new mock instance +func NewMockGit(ctrl *gomock.Controller) *MockGit { + mock := &MockGit{ctrl: ctrl} + mock.recorder = &MockGitMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use +func (m *MockGit) EXPECT() *MockGitMockRecorder { + return m.recorder +} + +// Clone mocks base method +func (m *MockGit) Clone(ctx context.Context, dir string, src git.TargetSource) error { + ret := m.ctrl.Call(m, "Clone", ctx, dir, src) + ret0, _ := ret[0].(error) + return ret0 +} + +// Clone indicates an expected call of Clone +func (mr *MockGitMockRecorder) Clone(ctx, dir, src interface{}) *gomock.Call { + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Clone", reflect.TypeOf((*MockGit)(nil).Clone), ctx, dir, src) +} diff --git a/domain/model/job/target/git/ssh_client.go b/domain/model/job/target/git/ssh_client.go new file mode 100644 index 00000000..37be8fbc --- /dev/null +++ b/domain/model/job/target/git/ssh_client.go @@ -0,0 +1,50 @@ +package git + +import ( + "context" + "github.com/duck8823/duci/domain/model/runner" + "github.com/duck8823/duci/internal/container" + "github.com/pkg/errors" + "gopkg.in/src-d/go-git.v4" + "gopkg.in/src-d/go-git.v4/plumbing" + "gopkg.in/src-d/go-git.v4/plumbing/transport" + "gopkg.in/src-d/go-git.v4/plumbing/transport/ssh" +) + +type sshGitClient struct { + auth transport.AuthMethod + runner.LogFunc +} + +// InitializeWithSSH returns git client with ssh protocol +func InitializeWithSSH(path string, logFunc runner.LogFunc) error { + auth, err := ssh.NewPublicKeysFromFile("git", path, "") + if err != nil { + return errors.WithStack(err) + } + + git := new(Git) + *git = &sshGitClient{auth: auth, LogFunc: logFunc} + if err := container.Submit(git); err != nil { + return errors.WithStack(err) + } + return nil +} + +// Clone a repository into the path with target source. +func (s *sshGitClient) Clone(ctx context.Context, dir string, src TargetSource) error { + gitRepository, err := plainClone(dir, false, &git.CloneOptions{ + URL: src.GetSSHURL(), + Auth: s.auth, + Progress: &ProgressLogger{ctx: ctx, LogFunc: s.LogFunc}, + ReferenceName: plumbing.ReferenceName(src.GetRef()), + }) + if err != nil { + return errors.WithStack(err) + } + + if err := checkout(gitRepository, src.GetSHA()); err != nil { + return errors.WithStack(err) + } + return nil +} diff --git a/domain/model/job/target/git/ssh_client_test.go b/domain/model/job/target/git/ssh_client_test.go new file mode 100644 index 00000000..55d95114 --- /dev/null +++ b/domain/model/job/target/git/ssh_client_test.go @@ -0,0 +1,240 @@ +package git_test + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "errors" + "github.com/duck8823/duci/domain/model/job" + "github.com/duck8823/duci/domain/model/job/target/git" + "github.com/duck8823/duci/domain/model/job/target/git/mock_git" + "github.com/duck8823/duci/domain/model/runner" + "github.com/duck8823/duci/internal/container" + "github.com/golang/mock/gomock" + "github.com/labstack/gommon/random" + go_git "gopkg.in/src-d/go-git.v4" + "gopkg.in/src-d/go-git.v4/plumbing" + "gopkg.in/src-d/go-git.v4/plumbing/object" + "os" + "path/filepath" + "testing" +) + +func TestInitializeWithSSH(t *testing.T) { + t.Run("when instance is nil", func(t *testing.T) { + t.Run("with correct key path", func(t *testing.T) { + // given + container.Clear() + + // and + path, reset := createTemporaryKey(t) + defer reset() + + // when + err := git.InitializeWithSSH(path, func(_ context.Context, _ job.Log) {}) + + // then + if err != nil { + t.Errorf("error must be nil, but got %+v", err) + } + }) + + t.Run("with wrong key path", func(t *testing.T) { + // given + container.Clear() + + // when + err := git.InitializeWithSSH("/path/to/nothing", func(_ context.Context, _ job.Log) {}) + + // then + if err == nil { + t.Error("error must not be nil") + } + }) + }) + + t.Run("when instance is not nil", func(t *testing.T) { + // given + container.Override(new(git.Git)) + defer container.Clear() + + // and + path, reset := createTemporaryKey(t) + defer reset() + + // when + err := git.InitializeWithSSH(path, func(_ context.Context, _ job.Log) {}) + + // then + if err == nil { + t.Error("error must not be nil") + } + }) +} + +func TestSshGitClient_Clone(t *testing.T) { + t.Run("when failure git clone", func(t *testing.T) { + // given + reset := git.SetPlainCloneFunc(func(_ string, _ bool, _ *go_git.CloneOptions) (*go_git.Repository, error) { + return nil, errors.New("test") + }) + defer reset() + + // and + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + targetSrc := mock_git.NewMockTargetSource(ctrl) + targetSrc.EXPECT(). + GetSSHURL(). + Times(1). + Return("git@github.com:duck8823/duci.git") + targetSrc.EXPECT(). + GetRef(). + Times(1). + Return("HEAD") + + // and + sut := &git.SSHGitClient{LogFunc: runner.NothingToDo} + + // expect + if err := sut.Clone( + context.Background(), + "/path/to/dummy", + targetSrc, + ); err == nil { + t.Error("error must not nil.") + } + }) + + t.Run("when success git clone and checkout", func(t *testing.T) { + // given + var hash plumbing.Hash + defer git.SetPlainCloneFunc(func(tmpDir string, _ bool, _ *go_git.CloneOptions) (*go_git.Repository, error) { + // git init + repo, err := go_git.PlainInit(tmpDir, false) + if err != nil { + t.Fatalf("error occur: %+v", err) + } + + w, err := repo.Worktree() + if err != nil { + t.Fatalf("error occur: %+v", err) + } + + // initial commit ( for success checkout ) + hash, err = w.Commit("init. commit", &go_git.CommitOptions{Author: &object.Signature{}}) + if err != nil { + t.Fatalf("error occur: %+v", err) + } + + return repo, nil + })() + + tmpDir, reset := createTmpDir(t) + defer reset() + + // and + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + targetSrc := mock_git.NewMockTargetSource(ctrl) + targetSrc.EXPECT(). + GetSSHURL(). + Times(1) + targetSrc.EXPECT(). + GetRef(). + Times(1). + Return("HEAD") + targetSrc.EXPECT(). + GetSHA(). + Times(1). + Return(hash) + + // and + sut := &git.SSHGitClient{LogFunc: runner.NothingToDo} + + // expect + if err := sut.Clone(context.Background(), tmpDir, targetSrc); err != nil { + t.Errorf("error must be nil, but got %+v", err) + } + }) + + t.Run("when success git clone but failure checkout", func(t *testing.T) { + // given + defer git.SetPlainCloneFunc(func(tmpDir string, _ bool, _ *go_git.CloneOptions) (*go_git.Repository, error) { + // git init + repo, err := go_git.PlainInit(tmpDir, false) + if err != nil { + t.Fatalf("error occur: %+v", err) + } + + return repo, nil + })() + + tmpDir, reset := createTmpDir(t) + defer reset() + + // and + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + targetSrc := mock_git.NewMockTargetSource(ctrl) + targetSrc.EXPECT(). + GetSSHURL(). + Times(1). + Return("git@github.com:duck8823/duci.git") + targetSrc.EXPECT(). + GetRef(). + Times(1). + Return("HEAD") + targetSrc.EXPECT(). + GetSHA(). + Times(1). + Return(plumbing.ZeroHash) + + // and + sut := &git.SSHGitClient{LogFunc: runner.NothingToDo} + + // expect + if err := sut.Clone(context.Background(), tmpDir, targetSrc); err == nil { + t.Error("error must not be nil") + } + }) +} + +func createTemporaryKey(t *testing.T) (path string, reset func()) { + t.Helper() + + privateKey, err := rsa.GenerateKey(rand.Reader, 256) + if err != nil { + t.Fatalf("error occur: %+v", err) + } + privateKeyDer := x509.MarshalPKCS1PrivateKey(privateKey) + privateKeyBlock := pem.Block{ + Type: "RSA PRIVATE KEY", + Headers: nil, + Bytes: privateKeyDer, + } + privateKeyPem := string(pem.EncodeToMemory(&privateKeyBlock)) + + tempDir := filepath.Join(os.TempDir(), random.String(16, random.Alphanumeric)) + if err := os.MkdirAll(tempDir, 0700); err != nil { + t.Fatalf("error occur: %+v", err) + } + keyPath := filepath.Join(tempDir, "id_rsa") + file, err := os.OpenFile(keyPath, os.O_WRONLY|os.O_CREATE, 0600) + if err != nil { + t.Fatalf("error occur: %+v", err) + } + + if _, err := file.WriteString(privateKeyPem); err != nil { + t.Fatalf("error occur: %+v", err) + } + + return keyPath, func() { + _ = os.RemoveAll(tempDir) + } +} diff --git a/domain/model/job/target/github.go b/domain/model/job/target/github.go new file mode 100644 index 00000000..675a4530 --- /dev/null +++ b/domain/model/job/target/github.go @@ -0,0 +1,42 @@ +package target + +import ( + "context" + "github.com/duck8823/duci/domain/model/job" + "github.com/duck8823/duci/domain/model/job/target/git" + "github.com/duck8823/duci/domain/model/job/target/github" + "github.com/labstack/gommon/random" + "github.com/pkg/errors" + "gopkg.in/src-d/go-git.v4/plumbing" + "os" + "path" +) + +// GitHub is target with github repository +type GitHub struct { + Repo github.Repository + Point github.TargetPoint +} + +// Prepare working directory +func (g *GitHub) Prepare() (job.WorkDir, job.Cleanup, error) { + tmpDir := path.Join(os.TempDir(), random.String(16, random.Alphanumeric, random.Numeric)) + if err := os.MkdirAll(tmpDir, 0700); err != nil { + return "", cleanupFunc(tmpDir), errors.WithStack(err) + } + + git, err := git.GetInstance() + if err != nil { + return "", cleanupFunc(tmpDir), errors.WithStack(err) + } + + if err := git.Clone(context.Background(), tmpDir, &github.TargetSource{ + Repository: g.Repo, + Ref: g.Point.GetRef(), + SHA: plumbing.NewHash(g.Point.GetHead()), + }); err != nil { + return "", cleanupFunc(tmpDir), errors.WithStack(err) + } + + return job.WorkDir(tmpDir), cleanupFunc(tmpDir), nil +} diff --git a/domain/model/job/target/github/export_test.go b/domain/model/job/target/github/export_test.go new file mode 100644 index 00000000..0154af9d --- /dev/null +++ b/domain/model/job/target/github/export_test.go @@ -0,0 +1,34 @@ +package github + +import ( + "context" + "github.com/google/go-github/github" +) + +type StubClient struct { +} + +func (*StubClient) GetPullRequest(ctx context.Context, repo Repository, num int) (*github.PullRequest, error) { + return nil, nil +} + +func (*StubClient) CreateCommitStatus(ctx context.Context, status CommitStatus) error { + return nil +} + +type MockRepository struct { + FullName string + URL string +} + +func (r *MockRepository) GetFullName() string { + return r.FullName +} + +func (r *MockRepository) GetSSHURL() string { + return r.URL +} + +func (r *MockRepository) GetCloneURL() string { + return r.URL +} diff --git a/domain/model/job/target/github/github.go b/domain/model/job/target/github/github.go new file mode 100644 index 00000000..ac4affce --- /dev/null +++ b/domain/model/job/target/github/github.go @@ -0,0 +1,86 @@ +package github + +import ( + "context" + "github.com/duck8823/duci/internal/container" + go_github "github.com/google/go-github/github" + "github.com/pkg/errors" + "golang.org/x/oauth2" +) + +// GitHub describes a github client. +type GitHub interface { + GetPullRequest(ctx context.Context, repo Repository, num int) (*go_github.PullRequest, error) + CreateCommitStatus(ctx context.Context, status CommitStatus) error +} + +type client struct { + cli *go_github.Client +} + +// Initialize create a github client. +func Initialize(token string) error { + ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token}) + tc := oauth2.NewClient(context.Background(), ts) + + github := new(GitHub) + *github = &client{go_github.NewClient(tc)} + if err := container.Submit(github); err != nil { + return errors.WithStack(err) + } + return nil +} + +// GetInstance returns a github client +func GetInstance() (GitHub, error) { + github := new(GitHub) + if err := container.Get(github); err != nil { + return nil, errors.WithStack(err) + } + return *github, nil +} + +// GetPullRequest returns a pull request with specific repository and number. +func (c *client) GetPullRequest(ctx context.Context, repo Repository, num int) (*go_github.PullRequest, error) { + ownerName, repoName, err := RepositoryName(repo.GetFullName()).Split() + if err != nil { + return nil, errors.WithStack(err) + } + + pr, _, err := c.cli.PullRequests.Get( + ctx, + ownerName, + repoName, + num, + ) + if err != nil { + return nil, errors.WithStack(err) + } + return pr, nil +} + +// CreateCommitStatus create commit status to github. +func (c *client) CreateCommitStatus(ctx context.Context, status CommitStatus) error { + repoStatus := &go_github.RepoStatus{ + Context: go_github.String(status.Context), + Description: go_github.String(status.Description.TrimmedString()), + State: go_github.String(status.State.String()), + TargetURL: go_github.String(status.TargetURL.String()), + } + + ownerName, repoName, err := RepositoryName(status.TargetSource.GetFullName()).Split() + if err != nil { + return errors.WithStack(err) + } + + if _, _, err := c.cli.Repositories.CreateStatus( + ctx, + ownerName, + repoName, + status.TargetSource.GetSHA().String(), + repoStatus, + ); err != nil { + return errors.WithStack(err) + } + return nil +} diff --git a/domain/model/job/target/github/github_test.go b/domain/model/job/target/github/github_test.go new file mode 100644 index 00000000..492e003e --- /dev/null +++ b/domain/model/job/target/github/github_test.go @@ -0,0 +1,266 @@ +package github_test + +import ( + "context" + "fmt" + "github.com/duck8823/duci/domain/model/job/target/github" + "github.com/duck8823/duci/internal/container" + "github.com/google/go-cmp/cmp" + go_github "github.com/google/go-github/github" + "github.com/labstack/gommon/random" + "gopkg.in/h2non/gock.v1" + "gopkg.in/src-d/go-git.v4/plumbing" + "net/url" + "testing" +) + +func TestInitialize(t *testing.T) { + t.Run("when instance is nil", func(t *testing.T) { + // given + container.Clear() + + // when + err := github.Initialize("github_api_token") + + // then + if err != nil { + t.Errorf("error must be nil, but got %+v", err) + } + }) + + t.Run("when instance is not nil", func(t *testing.T) { + // given + container.Override(&github.StubClient{}) + defer container.Clear() + + // when + err := github.Initialize("github_api_token") + + // then + if err == nil { + t.Error("error must not be nil") + } + }) +} + +func TestGetInstance(t *testing.T) { + t.Run("when instance is nil", func(t *testing.T) { + // given + container.Clear() + + // when + got, err := github.GetInstance() + + // then + if err == nil { + t.Error("error must not be nil") + } + + // and + if got != nil { + t.Errorf("must be nil, but got %+v", got) + } + }) + + t.Run("when instance is not nil", func(t *testing.T) { + // given + want := &github.StubClient{} + + // and + container.Override(want) + defer container.Clear() + + // when + got, err := github.GetInstance() + + // then + if err != nil { + t.Errorf("error must be nil, but got %+v", err) + } + + // and + if !cmp.Equal(got, want) { + t.Errorf("must be equal, but %+v", cmp.Diff(got, want)) + } + + }) +} + +func TestClient_GetPullRequest(t *testing.T) { + // given + _ = github.Initialize("github_api_token") + sut, err := github.GetInstance() + if err != nil { + t.Fatalf("error occurred. %+v", err) + } + + t.Run("when github server returns status ok", func(t *testing.T) { + // given + repo := &github.MockRepository{ + FullName: "duck8823/duci", + } + num := 19 + + // and + want := "hello world" + + // and + gock.New("https://api.github.com"). + Get(fmt.Sprintf("/repos/%s/pulls/%d", repo.FullName, num)). + Reply(200). + JSON(&go_github.PullRequest{ + Title: go_github.String(want), + }) + defer gock.Clean() + + // when + got, err := sut.GetPullRequest(context.Background(), repo, num) + + // then + if err != nil { + t.Errorf("error must be nil, but got %+v", err) + } + + // and + if got.GetTitle() != want { + t.Errorf("must be equal, but diff %+v", cmp.Diff(got.GetTitle(), want)) + } + }) + + t.Run("when github server returns status not found", func(t *testing.T) { + // given + repo := &github.MockRepository{ + FullName: "duck8823/duci", + } + num := 19 + + // and + gock.New("https://api.github.com"). + Get(fmt.Sprintf("/repos/%s/pulls/%d", repo.FullName, num)). + Reply(404) + defer gock.Clean() + + // when + pr, err := sut.GetPullRequest(context.Background(), repo, num) + + // then + if err == nil { + t.Error("error must not be nil") + } + + // and + if pr != nil { + t.Errorf("must be nil, but got %+v", pr) + } + }) + + t.Run("with invalid repository", func(t *testing.T) { + // given + repo := &github.MockRepository{ + FullName: "", + } + + // expect + if _, err := sut.GetPullRequest(context.Background(), repo, 19); err == nil { + t.Error("error must not be nil") + } + }) +} + +func TestClient_CreateCommitStatus(t *testing.T) { + // given + _ = github.Initialize("github_api_token") + sut, err := github.GetInstance() + if err != nil { + t.Fatalf("error occurred. %+v", err) + } + + t.Run("when github server returns status ok", func(t *testing.T) { + // given + status := github.CommitStatus{ + TargetSource: &github.TargetSource{ + Repository: &github.MockRepository{ + FullName: "duck8823/duci", + }, + SHA: plumbing.ComputeHash(plumbing.AnyObject, []byte(random.String(16, random.Alphanumeric))), + }, + State: github.SUCCESS, + Description: "hello world", + Context: "duci test", + TargetURL: &url.URL{ + Scheme: "http", + Host: "example.com", + }, + } + + // and + gock.New("https://api.github.com"). + Post(fmt.Sprintf("/repos/%s/statuses/%s", status.TargetSource.Repository.GetFullName(), status.TargetSource.SHA)). + Reply(200) + defer gock.Clean() + + // expect + if err := sut.CreateCommitStatus( + context.Background(), + status, + ); err != nil { + t.Errorf("error must be nil: but got %+v", err) + } + }) + + t.Run("when github server returns status not found", func(t *testing.T) { + // given + status := github.CommitStatus{ + TargetSource: &github.TargetSource{ + Repository: &github.MockRepository{ + FullName: "duck8823/duci", + }, + SHA: plumbing.ComputeHash(plumbing.AnyObject, []byte(random.String(16, random.Alphanumeric))), + }, + State: github.SUCCESS, + Description: "hello world", + Context: "duci test", + TargetURL: &url.URL{ + Scheme: "http", + Host: "example.com", + }, + } + + // and + gock.New("https://api.github.com"). + Post(fmt.Sprintf("/repos/%s/statuses/%s", status.TargetSource.Repository.GetFullName(), status.TargetSource.SHA)). + Reply(404) + defer gock.Clean() + + // expect + if err := sut.CreateCommitStatus( + context.Background(), + status, + ); err == nil { + t.Error("error must not be nil") + } + }) + + t.Run("with invalid repository", func(t *testing.T) { + // given + status := github.CommitStatus{ + TargetSource: &github.TargetSource{ + Repository: &github.MockRepository{ + FullName: "", + }, + }, + TargetURL: &url.URL{ + Scheme: "http", + Host: "example.com", + }, + } + + // expect + if err := sut.CreateCommitStatus( + context.Background(), + status, + ); err == nil { + t.Error("error must not be nil") + } + }) +} diff --git a/domain/model/job/target/github/mock_github/github.go b/domain/model/job/target/github/mock_github/github.go new file mode 100644 index 00000000..ea5b4cd3 --- /dev/null +++ b/domain/model/job/target/github/mock_github/github.go @@ -0,0 +1,61 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: domain/model/job/target/github/github.go + +// Package mock_github is a generated GoMock package. +package mock_github + +import ( + context "context" + github "github.com/duck8823/duci/domain/model/job/target/github" + gomock "github.com/golang/mock/gomock" + github0 "github.com/google/go-github/github" + reflect "reflect" +) + +// MockGitHub is a mock of GitHub interface +type MockGitHub struct { + ctrl *gomock.Controller + recorder *MockGitHubMockRecorder +} + +// MockGitHubMockRecorder is the mock recorder for MockGitHub +type MockGitHubMockRecorder struct { + mock *MockGitHub +} + +// NewMockGitHub creates a new mock instance +func NewMockGitHub(ctrl *gomock.Controller) *MockGitHub { + mock := &MockGitHub{ctrl: ctrl} + mock.recorder = &MockGitHubMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use +func (m *MockGitHub) EXPECT() *MockGitHubMockRecorder { + return m.recorder +} + +// GetPullRequest mocks base method +func (m *MockGitHub) GetPullRequest(ctx context.Context, repo github.Repository, num int) (*github0.PullRequest, error) { + ret := m.ctrl.Call(m, "GetPullRequest", ctx, repo, num) + ret0, _ := ret[0].(*github0.PullRequest) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetPullRequest indicates an expected call of GetPullRequest +func (mr *MockGitHubMockRecorder) GetPullRequest(ctx, repo, num interface{}) *gomock.Call { + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPullRequest", reflect.TypeOf((*MockGitHub)(nil).GetPullRequest), ctx, repo, num) +} + +// CreateCommitStatus mocks base method +func (m *MockGitHub) CreateCommitStatus(ctx context.Context, status github.CommitStatus) error { + ret := m.ctrl.Call(m, "CreateCommitStatus", ctx, status) + ret0, _ := ret[0].(error) + return ret0 +} + +// CreateCommitStatus indicates an expected call of CreateCommitStatus +func (mr *MockGitHubMockRecorder) CreateCommitStatus(ctx, status interface{}) *gomock.Call { + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateCommitStatus", reflect.TypeOf((*MockGitHub)(nil).CreateCommitStatus), ctx, status) +} diff --git a/domain/model/job/target/github/repository.go b/domain/model/job/target/github/repository.go new file mode 100644 index 00000000..cb193bdd --- /dev/null +++ b/domain/model/job/target/github/repository.go @@ -0,0 +1,43 @@ +package github + +import ( + "fmt" + "strings" +) + +// Repository is a github repository +type Repository interface { + GetFullName() string + GetSSHURL() string + GetCloneURL() string +} + +// RepositoryName is a github repository name. +type RepositoryName string + +// Owner get a repository owner. +func (r RepositoryName) Owner() (string, error) { + ss := strings.Split(string(r), "/") + if len(ss) != 2 { + return "", fmt.Errorf("Invalid repository name: %s ", r) + } + return ss[0], nil +} + +// Repo get a repository name without owner. +func (r RepositoryName) Repo() (string, error) { + ss := strings.Split(string(r), "/") + if len(ss) != 2 { + return "", fmt.Errorf("Invalid repository name: %s ", r) + } + return ss[1], nil +} + +// Split repository name to owner and repo +func (r RepositoryName) Split() (owner string, repo string, err error) { + ss := strings.Split(string(r), "/") + if len(ss) != 2 { + return "", "", fmt.Errorf("Invalid repository name: %s ", r) + } + return ss[0], ss[1], nil +} diff --git a/domain/model/job/target/github/repository_test.go b/domain/model/job/target/github/repository_test.go new file mode 100644 index 00000000..28b9c283 --- /dev/null +++ b/domain/model/job/target/github/repository_test.go @@ -0,0 +1,124 @@ +package github_test + +import ( + "fmt" + "github.com/duck8823/duci/domain/model/job/target/github" + "github.com/google/go-cmp/cmp" + "testing" +) + +func TestRepositoryName_Owner(t *testing.T) { + t.Run("with correct name", func(t *testing.T) { + // given + want := "duck8823" + + // and + sut := github.RepositoryName(fmt.Sprintf("%s/duci", want)) + + // when + got, err := sut.Owner() + + // then + if err != nil { + t.Errorf("error must be nil, but got %+v", err) + } + + // and + if got != want { + t.Errorf("must be equal, but %+v", cmp.Diff(got, want)) + } + }) + + t.Run("with invalid name", func(t *testing.T) { + // where + for _, tt := range []struct { + name string + }{ + { + name: "", + }, + { + name: "duck8823", + }, + { + name: "duck8823/duci/domain", + }, + } { + t.Run(fmt.Sprintf("when name is %s", tt.name), func(t *testing.T) { + // given + sut := github.RepositoryName(tt.name) + + // when + got, err := sut.Owner() + + // then + if err == nil { + t.Error("error must not be nil") + } + + // and + if got != "" { + t.Errorf("must be empty, but got %+v", got) + } + }) + } + }) +} + +func TestRepositoryName_Repo(t *testing.T) { + t.Run("with correct name", func(t *testing.T) { + // given + want := "duci" + + // and + sut := github.RepositoryName(fmt.Sprintf("duck8823/%s", want)) + + // when + got, err := sut.Repo() + + // then + if err != nil { + t.Errorf("error must be nil, but got %+v", err) + } + + // and + if got != want { + t.Errorf("must be equal, but %+v", cmp.Diff(got, want)) + } + }) + + t.Run("with invalid name", func(t *testing.T) { + // where + for _, tt := range []struct { + name string + }{ + { + name: "", + }, + { + name: "duci", + }, + { + name: "duck8823/duci/domain", + }, + } { + t.Run(fmt.Sprintf("when name is %s", tt.name), func(t *testing.T) { + // given + sut := github.RepositoryName(tt.name) + + // when + got, err := sut.Repo() + + // then + if err == nil { + t.Error("error must not be nil") + } + + // and + if got != "" { + t.Errorf("must be empty, but got %+v", got) + } + }) + } + }) +} diff --git a/domain/model/job/target/github/status.go b/domain/model/job/target/github/status.go new file mode 100644 index 00000000..b1603c55 --- /dev/null +++ b/domain/model/job/target/github/status.go @@ -0,0 +1,42 @@ +package github + +import "net/url" + +// State represents state of commit status +type State string + +// String returns string value +func (s State) String() string { + return string(s) +} + +// Description explain a commit status +type Description string + +// TrimmedString returns length-fixed description +func (d Description) TrimmedString() string { + if len(d) > 50 { + return string([]rune(d)[:47]) + "..." + } + return string(d) +} + +const ( + // PENDING represents pending state. + PENDING State = "pending" + // SUCCESS represents success state. + SUCCESS State = "success" + // ERROR represents error state. + ERROR State = "error" + // FAILURE represents failure state. + FAILURE State = "failure" +) + +// CommitStatus represents a commit status +type CommitStatus struct { + TargetSource *TargetSource + State State + Description Description + Context string + TargetURL *url.URL +} diff --git a/domain/model/job/target/github/status_test.go b/domain/model/job/target/github/status_test.go new file mode 100644 index 00000000..9c499671 --- /dev/null +++ b/domain/model/job/target/github/status_test.go @@ -0,0 +1,43 @@ +package github_test + +import ( + "github.com/duck8823/duci/domain/model/job/target/github" + "github.com/google/go-cmp/cmp" + "testing" +) + +func TestDescription_TrimmedString(t *testing.T) { + // where + for _, tt := range []struct { + in string + want string + }{ + { + in: "hello world", + want: "hello world", + }, + { + in: "123456789012345678901234567890123456789012345678901234567890", + want: "12345678901234567890123456789012345678901234567...", + }, + { + in: "12345678901234567890123456789012345678901234567890", + want: "12345678901234567890123456789012345678901234567890", + }, + { + in: "123456789012345678901234567890123456789012345678901", + want: "12345678901234567890123456789012345678901234567...", + }, + } { + // given + sut := github.Description(tt.in) + + // when + got := sut.TrimmedString() + + // then + if got != tt.want { + t.Errorf("must be equal, but %+v", cmp.Diff(got, tt.want)) + } + } +} diff --git a/domain/model/job/target/github/target_point.go b/domain/model/job/target/github/target_point.go new file mode 100644 index 00000000..0720e97c --- /dev/null +++ b/domain/model/job/target/github/target_point.go @@ -0,0 +1,23 @@ +package github + +// TargetPoint represents a target point for clone. +type TargetPoint interface { + GetRef() string + GetHead() string +} + +// SimpleTargetPoint is a simple implementation for TargetPoint +type SimpleTargetPoint struct { + Ref string + SHA string +} + +// GetRef returns a Ref +func (s *SimpleTargetPoint) GetRef() string { + return s.Ref +} + +// GetHead returns a SHA +func (s *SimpleTargetPoint) GetHead() string { + return s.SHA +} diff --git a/domain/model/job/target/github/target_point_test.go b/domain/model/job/target/github/target_point_test.go new file mode 100644 index 00000000..12a1a366 --- /dev/null +++ b/domain/model/job/target/github/target_point_test.go @@ -0,0 +1,43 @@ +package github_test + +import ( + "github.com/duck8823/duci/domain/model/job/target/github" + "github.com/google/go-cmp/cmp" + "testing" +) + +func TestSimpleTargetPoint_GetRef(t *testing.T) { + // given + want := "ref" + + // and + sut := &github.SimpleTargetPoint{ + Ref: want, + } + + // when + got := sut.GetRef() + + // then + if got != want { + t.Errorf("must be equal, but %+v", cmp.Diff(got, want)) + } +} + +func TestSimpleTargetPoint_GetHead(t *testing.T) { + // given + want := "sha" + + // and + sut := &github.SimpleTargetPoint{ + SHA: want, + } + + // when + got := sut.GetHead() + + // then + if got != want { + t.Errorf("must be equal, but %+v", cmp.Diff(got, want)) + } +} diff --git a/application/service/github/target_source.go b/domain/model/job/target/github/target_source.go similarity index 53% rename from application/service/github/target_source.go rename to domain/model/job/target/github/target_source.go index c25e999a..6181d951 100644 --- a/application/service/github/target_source.go +++ b/domain/model/job/target/github/target_source.go @@ -1,23 +1,14 @@ package github import ( - "github.com/duck8823/duci/application" "gopkg.in/src-d/go-git.v4/plumbing" ) // TargetSource stores Repo, Ref and SHA for target type TargetSource struct { - Repo Repository - Ref string - SHA plumbing.Hash -} - -// GetURL returns a clone URL -func (s *TargetSource) GetURL() string { - if application.Config.GitHub.SSHKeyPath != "" { - return s.Repo.GetSSHURL() - } - return s.Repo.GetCloneURL() + Repository + Ref string + SHA plumbing.Hash } // GetRef returns a ref diff --git a/domain/model/job/target/github/target_source_test.go b/domain/model/job/target/github/target_source_test.go new file mode 100644 index 00000000..c806ad4f --- /dev/null +++ b/domain/model/job/target/github/target_source_test.go @@ -0,0 +1,46 @@ +package github_test + +import ( + "github.com/duck8823/duci/domain/model/job/target/github" + "github.com/google/go-cmp/cmp" + "github.com/labstack/gommon/random" + "gopkg.in/src-d/go-git.v4/plumbing" + "testing" +) + +func TestTargetSource_GetRef(t *testing.T) { + // given + want := "ref" + + // and + sut := &github.TargetSource{ + Ref: want, + } + + // when + got := sut.GetRef() + + // then + if got != want { + t.Errorf("must be euqal, but %+v", cmp.Diff(got, want)) + } + +} + +func TestTargetSource_GetSHA(t *testing.T) { + // given + want := plumbing.ComputeHash(plumbing.AnyObject, []byte(random.String(16, random.Alphanumeric))) + + // and + sut := &github.TargetSource{ + SHA: want, + } + + // when + got := sut.GetSHA() + + // then + if got != want { + t.Errorf("must be euqal, but %+v", cmp.Diff(got, want)) + } +} diff --git a/domain/model/job/target/github_test.go b/domain/model/job/target/github_test.go new file mode 100644 index 00000000..808b0b31 --- /dev/null +++ b/domain/model/job/target/github_test.go @@ -0,0 +1,139 @@ +package target_test + +import ( + "errors" + "github.com/duck8823/duci/domain/model/job/target" + "github.com/duck8823/duci/domain/model/job/target/git/mock_git" + "github.com/duck8823/duci/domain/model/job/target/github" + "github.com/duck8823/duci/internal/container" + "github.com/golang/mock/gomock" + "github.com/labstack/gommon/random" + "testing" +) + +func TestGithubPush_Prepare(t *testing.T) { + t.Run("when success git clone", func(t *testing.T) { + // given + repo := &target.MockRepository{ + FullName: "duck8823/duci", + URL: "http://example.com", + } + point := &github.SimpleTargetPoint{ + Ref: "test", + SHA: random.String(16, random.Alphanumeric), + } + + // and + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + // and + mockGit := mock_git.NewMockGit(ctrl) + mockGit.EXPECT(). + Clone(gomock.Any(), gomock.Any(), gomock.Any()). + Times(1). + Return(nil) + container.Override(mockGit) + defer container.Clear() + + // and + sut := &target.GitHub{ + Repo: repo, + Point: point, + } + + // when + got, cleanup, err := sut.Prepare() + defer cleanup() + + // then + if err != nil { + t.Errorf("error must be nil, but got %+v", err) + } + + // and + if len(got) == 0 { + t.Error("must not be empty") + } + }) + + t.Run("when failure git clone", func(t *testing.T) { + // given + repo := &target.MockRepository{ + FullName: "duck8823/duci", + URL: "http://example.com", + } + point := &github.SimpleTargetPoint{ + Ref: "test", + SHA: random.String(16, random.Alphanumeric), + } + + // and + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + // and + mockGit := mock_git.NewMockGit(ctrl) + mockGit.EXPECT(). + Clone(gomock.Any(), gomock.Any(), gomock.Any()). + Times(1). + Return(errors.New("test error")) + container.Override(mockGit) + defer container.Clear() + + // and + sut := &target.GitHub{ + Repo: repo, + Point: point, + } + + // when + got, cleanup, err := sut.Prepare() + defer cleanup() + + // then + if err == nil { + t.Error("error must not be nil") + } + + // and + if len(got) != 0 { + t.Errorf("must be empty, but got %+v", got) + } + }) + + t.Run("when git have not be initialized", func(t *testing.T) { + // given + repo := &target.MockRepository{ + FullName: "duck8823/duci", + URL: "http://example.com", + } + point := &github.SimpleTargetPoint{ + Ref: "test", + SHA: random.String(16, random.Alphanumeric), + } + + // and + container.Clear() + + // and + sut := &target.GitHub{ + Repo: repo, + Point: point, + } + + // when + got, cleanup, err := sut.Prepare() + defer cleanup() + + // then + if err == nil { + t.Error("error must not be nil") + } + + // and + if len(got) != 0 { + t.Errorf("must be empty, but got %+v", got) + } + }) +} diff --git a/domain/model/job/target/local.go b/domain/model/job/target/local.go new file mode 100644 index 00000000..550dc681 --- /dev/null +++ b/domain/model/job/target/local.go @@ -0,0 +1,85 @@ +package target + +import ( + "github.com/duck8823/duci/domain/model/job" + "github.com/duck8823/duci/internal/logger" + "github.com/labstack/gommon/random" + "github.com/pkg/errors" + "io" + "io/ioutil" + "os" + "path" +) + +// Local is target with Local directory +type Local struct { + Path string +} + +// Prepare working directory +func (l *Local) Prepare() (job.WorkDir, job.Cleanup, error) { + tmpDir := path.Join(os.TempDir(), random.String(16, random.Alphanumeric, random.Numeric)) + if err := os.MkdirAll(tmpDir, 0700); err != nil { + return "", nil, errors.WithStack(err) + } + + if err := copyDir(tmpDir, l.Path); err != nil { + return "", nil, errors.WithStack(err) + } + + return job.WorkDir(tmpDir), cleanupFunc(tmpDir), nil +} + +func copyDir(dstDir string, srcDir string) error { + entries, err := ioutil.ReadDir(srcDir) + if err != nil { + return errors.WithStack(err) + } + + for _, entry := range entries { + dstPath := path.Join(dstDir, entry.Name()) + srcPath := path.Join(srcDir, entry.Name()) + + if entry.IsDir() { + if err := os.MkdirAll(dstPath, 0700); err != nil { + return errors.WithStack(err) + } + + if err := copyDir(dstPath, srcPath); err != nil { + return errors.WithStack(err) + } + } else if err := copyFile(dstPath, srcPath); err != nil { + return errors.WithStack(err) + } + } + + return nil +} + +func copyFile(dstFile string, srcFile string) error { + dst, err := os.OpenFile(dstFile, os.O_WRONLY|os.O_CREATE, 0600) + if err != nil { + return errors.WithStack(err) + } + defer dst.Close() + + src, err := os.Open(srcFile) + if err != nil { + return errors.WithStack(err) + } + defer src.Close() + + if _, err := io.Copy(dst, src); err != nil { + return errors.WithStack(err) + } + + return nil +} + +func cleanupFunc(path string) job.Cleanup { + return func() { + if err := os.RemoveAll(path); err != nil { + logger.Error(err) + } + } +} diff --git a/domain/model/job/target/local_test.go b/domain/model/job/target/local_test.go new file mode 100644 index 00000000..63e7fa36 --- /dev/null +++ b/domain/model/job/target/local_test.go @@ -0,0 +1,25 @@ +package target_test + +import ( + "github.com/duck8823/duci/domain/model/job/target" + "testing" +) + +func TestLocal_Prepare(t *testing.T) { + // given + sut := &target.Local{Path: "."} + + // when + dir, cleanup, err := sut.Prepare() + defer cleanup() + + // then + if err != nil { + t.Errorf("error must be nil, but got %+v", err) + } + + // and + if len(dir) == 0 { + t.Errorf("must not be empty") + } +} diff --git a/domain/model/job/target_test.go b/domain/model/job/target_test.go new file mode 100644 index 00000000..b9f1b8d3 --- /dev/null +++ b/domain/model/job/target_test.go @@ -0,0 +1,23 @@ +package job_test + +import ( + "github.com/duck8823/duci/domain/model/job" + "github.com/google/go-cmp/cmp" + "testing" +) + +func TestWorkDir_String(t *testing.T) { + // given + want := "/path/to/dir" + + // and + sut := job.WorkDir(want) + + // when + got := sut.String() + + // then + if !cmp.Equal(got, want) { + t.Errorf("must be equal, but %+v", cmp.Diff(got, want)) + } +} diff --git a/domain/model/runner/builder.go b/domain/model/runner/builder.go new file mode 100644 index 00000000..9e11d6ba --- /dev/null +++ b/domain/model/runner/builder.go @@ -0,0 +1,31 @@ +package runner + +import ( + "github.com/duck8823/duci/domain/model/docker" +) + +// Builder represents a builder of docker runner +type Builder struct { + docker docker.Docker + logFunc LogFunc +} + +// DefaultDockerRunnerBuilder create new builder of docker runner +func DefaultDockerRunnerBuilder() *Builder { + cli, _ := docker.New() + return &Builder{docker: cli, logFunc: NothingToDo} +} + +// LogFunc append a LogFunc +func (b *Builder) LogFunc(f LogFunc) *Builder { + b.logFunc = f + return b +} + +// Build returns a docker runner +func (b *Builder) Build() DockerRunner { + return &dockerRunnerImpl{ + docker: b.docker, + logFunc: b.logFunc, + } +} diff --git a/domain/model/runner/builder_test.go b/domain/model/runner/builder_test.go new file mode 100644 index 00000000..69714305 --- /dev/null +++ b/domain/model/runner/builder_test.go @@ -0,0 +1,96 @@ +package runner_test + +import ( + "context" + "github.com/duck8823/duci/domain/model/docker" + "github.com/duck8823/duci/domain/model/job" + "github.com/duck8823/duci/domain/model/runner" + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "reflect" + "testing" +) + +func TestDefaultDockerRunnerBuilder(t *testing.T) { + // given + opts := []cmp.Option{ + cmp.AllowUnexported(runner.Builder{}), + cmp.Transformer("LogFunc", func(f runner.LogFunc) uintptr { + return reflect.ValueOf(f).Pointer() + }), + cmpopts.IgnoreInterfaces(struct{ docker.Docker }{}), + } + + // and + want := &runner.Builder{} + defer want.SetLogFunc(runner.NothingToDo)() + + // when + got := runner.DefaultDockerRunnerBuilder() + + // then + if !cmp.Equal(want, got, opts...) { + t.Errorf("must be equal, but: %+v", cmp.Diff(want, got, opts...)) + } +} + +func TestBuilder_LogFunc(t *testing.T) { + // given + opts := []cmp.Option{ + cmp.AllowUnexported(runner.Builder{}), + cmp.Transformer("LogFunc", func(f runner.LogFunc) uintptr { + return reflect.ValueOf(f).Pointer() + }), + cmpopts.IgnoreInterfaces(struct{ docker.Docker }{}), + } + + // and + var wantFunc runner.LogFunc = func(context.Context, job.Log) {} + + // and + want := &runner.Builder{} + defer want.SetLogFunc(wantFunc)() + + // and + sut := &runner.Builder{} + + // when + got := sut.LogFunc(wantFunc) + + // then + if !cmp.Equal(want, got, opts...) { + t.Errorf("must be equal, but: %+v", cmp.Diff(want, got, opts...)) + } + + // and + gotFunc := sut.GetLogFunc() + if !cmp.Equal(wantFunc, gotFunc, opts...) { + t.Errorf("must be equal, but: %+v", cmp.Diff(wantFunc, gotFunc, opts...)) + } + +} + +func TestBuilder_Build(t *testing.T) { + // given + opts := []cmp.Option{ + cmp.AllowUnexported(runner.DockerRunnerImpl{}), + cmp.Transformer("LogFunc", func(f runner.LogFunc) uintptr { + return reflect.ValueOf(f).Pointer() + }), + } + + // and + want := &runner.DockerRunnerImpl{} + defer want.SetLogFunc(func(context.Context, job.Log) {})() + + // and + sut := &runner.Builder{} + + // when + got := sut.LogFunc(want.GetLogFunc()).Build() + + // then + if !cmp.Equal(want, got, opts...) { + t.Errorf("must be equal, but: %+v", cmp.Diff(want, got, opts...)) + } +} diff --git a/domain/model/runner/errors.go b/domain/model/runner/errors.go new file mode 100644 index 00000000..807f6512 --- /dev/null +++ b/domain/model/runner/errors.go @@ -0,0 +1,6 @@ +package runner + +import "github.com/pkg/errors" + +// ErrFailure is a error describes task failure. +var ErrFailure = errors.New("Task Failure") diff --git a/domain/model/runner/export_test.go b/domain/model/runner/export_test.go new file mode 100644 index 00000000..915769b0 --- /dev/null +++ b/domain/model/runner/export_test.go @@ -0,0 +1,51 @@ +package runner + +import "github.com/duck8823/duci/domain/model/docker" + +func (b *Builder) SetDocker(docker docker.Docker) (reset func()) { + tmp := b.docker + b.docker = docker + return func() { + b.docker = tmp + } +} + +func (b *Builder) GetLogFunc() LogFunc { + return b.logFunc +} + +func (b *Builder) SetLogFunc(logFunc LogFunc) (reset func()) { + tmp := b.logFunc + b.logFunc = logFunc + return func() { + b.logFunc = tmp + } +} + +type DockerRunnerImpl = dockerRunnerImpl + +func (r *DockerRunnerImpl) SetDocker(docker docker.Docker) (reset func()) { + tmp := r.docker + r.docker = docker + return func() { + r.docker = tmp + } +} + +func (r *DockerRunnerImpl) GetLogFunc() LogFunc { + return r.logFunc +} + +func (r *DockerRunnerImpl) SetLogFunc(logFunc LogFunc) (reset func()) { + tmp := r.logFunc + r.logFunc = logFunc + return func() { + r.logFunc = tmp + } +} + +var CreateTarball = createTarball + +var DockerfilePath = dockerfilePath + +var ExportedRuntimeOptions = runtimeOptions diff --git a/domain/model/runner/function.go b/domain/model/runner/function.go new file mode 100644 index 00000000..ac259a09 --- /dev/null +++ b/domain/model/runner/function.go @@ -0,0 +1,12 @@ +package runner + +import ( + "context" + "github.com/duck8823/duci/domain/model/job" +) + +// LogFunc is function of Log +type LogFunc func(context.Context, job.Log) + +// NothingToDo is function nothing to do +var NothingToDo = func(_ context.Context, _ job.Log) {} diff --git a/domain/model/runner/function_test.go b/domain/model/runner/function_test.go new file mode 100644 index 00000000..813b8245 --- /dev/null +++ b/domain/model/runner/function_test.go @@ -0,0 +1,29 @@ +package runner_test + +import ( + "context" + "github.com/duck8823/duci/domain/model/job/mock_job" + "github.com/duck8823/duci/domain/model/runner" + "github.com/golang/mock/gomock" + "testing" +) + +func TestNothingToDo(t *testing.T) { + // given + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + log := mock_job.NewMockLog(ctrl) + log.EXPECT(). + ReadLine(). + Times(0). + Do(func() { + t.Error("must not call this.") + }) + + // and + sut := runner.NothingToDo + + // expect + sut(context.Background(), log) +} diff --git a/domain/model/runner/helper.go b/domain/model/runner/helper.go new file mode 100644 index 00000000..f518fd0b --- /dev/null +++ b/domain/model/runner/helper.go @@ -0,0 +1,63 @@ +package runner + +import ( + "bytes" + "github.com/duck8823/duci/domain/model/docker" + "github.com/duck8823/duci/domain/model/job" + "github.com/duck8823/duci/infrastructure/archive/tar" + "github.com/pkg/errors" + "gopkg.in/yaml.v2" + "io/ioutil" + "os" + "path/filepath" +) + +// createTarball creates a tar archive +func createTarball(workDir job.WorkDir) (*os.File, error) { + tarFilePath := filepath.Join(workDir.String(), "duci.tar") + writeFile, err := os.OpenFile(tarFilePath, os.O_RDWR|os.O_CREATE, 0600) + if err != nil { + return nil, errors.WithStack(err) + } + defer writeFile.Close() + + if err := tar.Create(workDir.String(), writeFile); err != nil { + return nil, errors.WithStack(err) + } + + readFile, _ := os.Open(tarFilePath) + return readFile, nil +} + +// dockerfilePath returns a path to dockerfile for duci using +func dockerfilePath(workDir job.WorkDir) docker.Dockerfile { + dockerfile := "./Dockerfile" + if exists(filepath.Join(workDir.String(), ".duci/Dockerfile")) { + dockerfile = ".duci/Dockerfile" + } + return docker.Dockerfile(dockerfile) +} + +// exists indicates whether the file exists +func exists(name string) bool { + _, err := os.Stat(name) + return !os.IsNotExist(err) +} + +// runtimeOptions parses a config.yml and returns a docker runtime options +func runtimeOptions(workDir job.WorkDir) (docker.RuntimeOptions, error) { + var opts docker.RuntimeOptions + + if !exists(filepath.Join(workDir.String(), ".duci/config.yml")) { + return opts, nil + } + content, err := ioutil.ReadFile(filepath.Join(workDir.String(), ".duci/config.yml")) + if err != nil { + return opts, errors.WithStack(err) + } + content = []byte(os.ExpandEnv(string(content))) + if err := yaml.NewDecoder(bytes.NewReader(content)).Decode(&opts); err != nil { + return opts, errors.WithStack(err) + } + return opts, nil +} diff --git a/domain/model/runner/helper_test.go b/domain/model/runner/helper_test.go new file mode 100644 index 00000000..64fe243b --- /dev/null +++ b/domain/model/runner/helper_test.go @@ -0,0 +1,266 @@ +package runner_test + +import ( + "github.com/duck8823/duci/domain/model/docker" + "github.com/duck8823/duci/domain/model/job" + "github.com/duck8823/duci/domain/model/runner" + "github.com/google/go-cmp/cmp" + "github.com/labstack/gommon/random" + "os" + "path" + "testing" +) + +func TestCreateTarball(t *testing.T) { + t.Run("with correct directory", func(t *testing.T) { + // given + tmpDir := path.Join(os.TempDir(), random.String(16, random.Alphanumeric)) + + if err := os.MkdirAll(tmpDir, 0700); err != nil { + t.Fatalf("error occur: %+v", err) + } + defer os.RemoveAll(tmpDir) + + if _, err := os.Create(path.Join(tmpDir, "a")); err != nil { + t.Fatalf("error occur: %+v", err) + } + + // and + want := path.Join(tmpDir, "duci.tar") + + // when + got, err := runner.CreateTarball(job.WorkDir(tmpDir)) + + // then + if err != nil { + t.Fatalf("error must be nil, but got %+v", err) + } + defer got.Close() + + // and + if got.Name() != want { + t.Errorf("file name: want %s, but got %s", want, got.Name()) + } + }) + + t.Run("with invalid directory", func(t *testing.T) { + // given + tmpDir := path.Join(os.TempDir(), random.String(16, random.Alphanumeric)) + + // when + got, err := runner.CreateTarball(job.WorkDir(tmpDir)) + + // then + if err == nil { + t.Error("error must not be nil") + } + + // and + if got != nil { + t.Errorf("must be nil, but got %+v", got) + + got.Close() + } + }) +} + +func TestDockerfilePath(t *testing.T) { + // where + for _, tt := range []struct { + name string + given func(t *testing.T) (workDir job.WorkDir, cleanup func()) + want docker.Dockerfile + }{ + { + name: "when .duci directory not found", + given: func(t *testing.T) (workDir job.WorkDir, cleanup func()) { + t.Helper() + + tmpDir := path.Join(os.TempDir(), random.String(16, random.Alphanumeric)) + if err := os.MkdirAll(tmpDir, 0700); err != nil { + t.Fatalf("error occur: %+v", err) + } + + return job.WorkDir(tmpDir), func() { + _ = os.RemoveAll(tmpDir) + } + }, + want: "./Dockerfile", + }, + { + name: "when .duci directory found but .duci/Dockerfile not found", + given: func(t *testing.T) (workDir job.WorkDir, cleanup func()) { + t.Helper() + + tmpDir := path.Join(os.TempDir(), random.String(16, random.Alphanumeric)) + if err := os.MkdirAll(path.Join(tmpDir, ".duci"), 0700); err != nil { + t.Fatalf("error occur: %+v", err) + } + + return job.WorkDir(tmpDir), func() { + _ = os.RemoveAll(tmpDir) + } + }, + want: "./Dockerfile", + }, + { + name: "when .duci/Dockerfile found", + given: func(t *testing.T) (workDir job.WorkDir, cleanup func()) { + t.Helper() + + tmpDir := path.Join(os.TempDir(), random.String(16, random.Alphanumeric)) + if err := os.MkdirAll(path.Join(tmpDir, ".duci"), 0700); err != nil { + t.Fatalf("error occur: %+v", err) + } + if _, err := os.Create(path.Join(tmpDir, ".duci", "Dockerfile")); err != nil { + t.Fatalf("error occur: %+v", err) + } + + return job.WorkDir(tmpDir), func() { + _ = os.RemoveAll(tmpDir) + } + }, + want: ".duci/Dockerfile", + }, + } { + t.Run(tt.name, func(t *testing.T) { + // given + in, cleanup := tt.given(t) + + // when + got := runner.DockerfilePath(in) + + // then + if got != tt.want { + t.Errorf("must be equal, but %+v", cmp.Diff(got, tt.want)) + } + + // cleanup + cleanup() + }) + } +} + +func TestRuntimeOptions(t *testing.T) { + // where + for _, tt := range []struct { + name string + given func(t *testing.T) (workDir job.WorkDir, cleanup func()) + want docker.RuntimeOptions + wantErr bool + }{ + { + name: "when .duci/config.yml not found", + given: func(t *testing.T) (workDir job.WorkDir, cleanup func()) { + t.Helper() + + tmpDir := path.Join(os.TempDir(), random.String(16, random.Alphanumeric)) + if err := os.MkdirAll(tmpDir, 0700); err != nil { + t.Fatalf("error occur: %+v", err) + } + + return job.WorkDir(tmpDir), func() { + _ = os.RemoveAll(tmpDir) + } + }, + want: docker.RuntimeOptions{}, + wantErr: false, + }, + { + name: "when .duci/config.yml found", + given: func(t *testing.T) (workDir job.WorkDir, cleanup func()) { + t.Helper() + + tmpDir := path.Join(os.TempDir(), random.String(16, random.Alphanumeric)) + if err := os.MkdirAll(path.Join(tmpDir, ".duci"), 0700); err != nil { + t.Fatalf("error occur: %+v", err) + } + + file, err := os.OpenFile(path.Join(tmpDir, ".duci", "config.yml"), os.O_RDWR|os.O_CREATE, 0400) + if err != nil { + t.Fatalf("%+v", err) + } + defer file.Close() + + _, _ = file.WriteString(`--- +volumes: + - hoge:fuga +`) + + return job.WorkDir(tmpDir), func() { + _ = os.RemoveAll(tmpDir) + } + }, + want: docker.RuntimeOptions{ + Volumes: docker.Volumes{"hoge:fuga"}, + }, + wantErr: false, + }, + { + name: "when .duci/config.yml is directory", + given: func(t *testing.T) (workDir job.WorkDir, cleanup func()) { + t.Helper() + + tmpDir := path.Join(os.TempDir(), random.String(16, random.Alphanumeric)) + if err := os.MkdirAll(path.Join(tmpDir, ".duci", "config.yml"), 0700); err != nil { + t.Fatalf("error occur: %+v", err) + } + + return job.WorkDir(tmpDir), func() { + _ = os.RemoveAll(tmpDir) + } + }, + want: docker.RuntimeOptions{}, + wantErr: true, + }, + { + name: "when .duci/config.yml is invalid format", + given: func(t *testing.T) (workDir job.WorkDir, cleanup func()) { + t.Helper() + + tmpDir := path.Join(os.TempDir(), random.String(16, random.Alphanumeric)) + if err := os.MkdirAll(path.Join(tmpDir, ".duci"), 0700); err != nil { + t.Fatalf("error occur: %+v", err) + } + + file, err := os.OpenFile(path.Join(tmpDir, ".duci", "config.yml"), os.O_RDWR|os.O_CREATE, 0400) + if err != nil { + t.Fatalf("%+v", err) + } + defer file.Close() + + _, _ = file.WriteString("invalid format") + + return job.WorkDir(tmpDir), func() { + _ = os.RemoveAll(tmpDir) + } + }, + want: docker.RuntimeOptions{}, + wantErr: true, + }, + } { + t.Run(tt.name, func(t *testing.T) { + // given + in, cleanup := tt.given(t) + + // when + got, err := runner.ExportedRuntimeOptions(in) + + // then + if tt.wantErr && err == nil { + t.Error("error must not be nil") + } + if !tt.wantErr && err != nil { + t.Errorf("error must be nil, but got %+v", err) + } + + // and + if !cmp.Equal(got, tt.want) { + t.Errorf("must be equal, but %+v", cmp.Diff(got, tt.want)) + } + + // cleanup + cleanup() + }) + } +} diff --git a/domain/model/runner/mock_runner/runner.go b/domain/model/runner/mock_runner/runner.go new file mode 100644 index 00000000..8d60120f --- /dev/null +++ b/domain/model/runner/mock_runner/runner.go @@ -0,0 +1,50 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: domain/model/runner/runner.go + +// Package mock_runner is a generated GoMock package. +package mock_runner + +import ( + context "context" + docker "github.com/duck8823/duci/domain/model/docker" + job "github.com/duck8823/duci/domain/model/job" + gomock "github.com/golang/mock/gomock" + reflect "reflect" +) + +// MockDockerRunner is a mock of DockerRunner interface +type MockDockerRunner struct { + ctrl *gomock.Controller + recorder *MockDockerRunnerMockRecorder +} + +// MockDockerRunnerMockRecorder is the mock recorder for MockDockerRunner +type MockDockerRunnerMockRecorder struct { + mock *MockDockerRunner +} + +// NewMockDockerRunner creates a new mock instance +func NewMockDockerRunner(ctrl *gomock.Controller) *MockDockerRunner { + mock := &MockDockerRunner{ctrl: ctrl} + mock.recorder = &MockDockerRunnerMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use +func (m *MockDockerRunner) EXPECT() *MockDockerRunnerMockRecorder { + return m.recorder +} + +// Run mocks base method +func (m *MockDockerRunner) Run(ctx context.Context, dir job.WorkDir, tag docker.Tag, cmd docker.Command) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Run", ctx, dir, tag, cmd) + ret0, _ := ret[0].(error) + return ret0 +} + +// Run indicates an expected call of Run +func (mr *MockDockerRunnerMockRecorder) Run(ctx, dir, tag, cmd interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Run", reflect.TypeOf((*MockDockerRunner)(nil).Run), ctx, dir, tag, cmd) +} diff --git a/domain/model/runner/runner.go b/domain/model/runner/runner.go new file mode 100644 index 00000000..32069f23 --- /dev/null +++ b/domain/model/runner/runner.go @@ -0,0 +1,76 @@ +package runner + +import ( + "context" + "github.com/duck8823/duci/domain/model/docker" + "github.com/duck8823/duci/domain/model/job" + "github.com/pkg/errors" +) + +// DockerRunner is a interface describes task runner. +type DockerRunner interface { + Run(ctx context.Context, dir job.WorkDir, tag docker.Tag, cmd docker.Command) error +} + +// dockerRunnerImpl is a implement of DockerRunner +type dockerRunnerImpl struct { + docker docker.Docker + logFunc LogFunc +} + +// Run task in docker container +func (r *dockerRunnerImpl) Run(ctx context.Context, dir job.WorkDir, tag docker.Tag, cmd docker.Command) error { + if err := r.dockerBuild(ctx, dir, tag); err != nil { + return errors.WithStack(err) + } + + // TODO: wait building container + conID, err := r.dockerRun(ctx, dir, tag, cmd) + if err != nil { + return errors.WithStack(err) + } + + code, err := r.docker.ExitCode(ctx, conID) + if err != nil { + return errors.WithStack(err) + } + if err := r.docker.RemoveContainer(ctx, conID); err != nil { + return errors.WithStack(err) + } + if code.IsFailure() { + return ErrFailure + } + + return nil +} + +// dockerBuild build a docker image +func (r *dockerRunnerImpl) dockerBuild(ctx context.Context, dir job.WorkDir, tag docker.Tag) error { + tarball, err := createTarball(dir) + if err != nil { + return errors.WithStack(err) + } + defer tarball.Close() + + buildLog, err := r.docker.Build(ctx, tarball, docker.Tag(tag), dockerfilePath(dir)) + if err != nil { + return errors.WithStack(err) + } + r.logFunc(ctx, buildLog) + return nil +} + +// dockerRun run docker container +func (r *dockerRunnerImpl) dockerRun(ctx context.Context, dir job.WorkDir, tag docker.Tag, cmd docker.Command) (docker.ContainerID, error) { + opts, err := runtimeOptions(dir) + if err != nil { + return "", errors.WithStack(err) + } + + conID, runLog, err := r.docker.Run(ctx, opts, tag, cmd) + if err != nil { + return conID, errors.WithStack(err) + } + r.logFunc(ctx, runLog) + return conID, nil +} diff --git a/domain/model/runner/runner_test.go b/domain/model/runner/runner_test.go new file mode 100644 index 00000000..9df7beb9 --- /dev/null +++ b/domain/model/runner/runner_test.go @@ -0,0 +1,392 @@ +package runner_test + +import ( + "context" + "fmt" + "github.com/duck8823/duci/domain/model/docker" + "github.com/duck8823/duci/domain/model/docker/mock_docker" + "github.com/duck8823/duci/domain/model/job" + "github.com/duck8823/duci/domain/model/job/mock_job" + "github.com/duck8823/duci/domain/model/runner" + "github.com/golang/mock/gomock" + "github.com/labstack/gommon/random" + "github.com/pkg/errors" + "io" + "os" + "path" + "testing" + "time" +) + +func TestDockerRunnerImpl_Run(t *testing.T) { + t.Run("with no error", func(t *testing.T) { + // given + dir, cleanup := tmpDir(t) + defer cleanup() + + tag := docker.Tag(fmt.Sprintf("duci/test:%s", random.String(8))) + cmd := docker.Command{"echo", "test"} + + // and + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + // and + log := stubLog(t, ctrl) + conID := docker.ContainerID(random.String(16, random.Alphanumeric)) + + mockDocker := mock_docker.NewMockDocker(ctrl) + mockDocker.EXPECT(). + Build(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Times(1). + Return(log, nil) + mockDocker.EXPECT(). + Run(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Times(1). + Return(conID, log, nil) + mockDocker.EXPECT(). + ExitCode(gomock.Any(), gomock.Eq(conID)). + Times(1). + Return(docker.ExitCode(0), nil) + mockDocker.EXPECT(). + RemoveContainer(gomock.Any(), gomock.Eq(conID)). + Times(1). + Return(nil) + + // and + sut := runner.DockerRunnerImpl{} + defer sut.SetDocker(mockDocker)() + defer sut.SetLogFunc(runner.NothingToDo)() + + // when + err := sut.Run(context.Background(), dir, tag, cmd) + + // then + if err != nil { + t.Errorf("error must be nil, but got %+v", err) + } + }) + + t.Run("when failure create tarball", func(t *testing.T) { + // given + dir, cleanup := tmpDir(t) + defer cleanup() + + tag := docker.Tag(fmt.Sprintf("duci/test:%s", random.String(8))) + cmd := docker.Command{"echo", "test"} + + // and + if err := os.MkdirAll(path.Join(dir.String(), "duci.tar"), 0700); err != nil { + t.Fatalf("error occur: %+v", err) + } + + // and + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + // and + mockDocker := mock_docker.NewMockDocker(ctrl) + mockDocker.EXPECT(). + Build(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Times(0) + + // and + sut := runner.DockerRunnerImpl{} + defer sut.SetDocker(mockDocker)() + defer sut.SetLogFunc(runner.NothingToDo)() + + // when + err := sut.Run(context.Background(), dir, tag, cmd) + + // then + if err == nil { + t.Errorf("error must not be nil") + } + }) + + t.Run("when failure load runtime options", func(t *testing.T) { + // given + dir, cleanup := tmpDir(t) + defer cleanup() + + tag := docker.Tag(fmt.Sprintf("duci/test:%s", random.String(8))) + cmd := docker.Command{"echo", "test"} + + // and + if err := os.MkdirAll(path.Join(dir.String(), ".duci", "config.yml"), 0700); err != nil { + t.Fatalf("error occur: %+v", err) + } + + // and + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + // and + log := stubLog(t, ctrl) + + mockDocker := mock_docker.NewMockDocker(ctrl) + mockDocker.EXPECT(). + Build(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Times(1). + Return(log, nil) + mockDocker.EXPECT(). + Run(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Times(0) + + // and + sut := runner.DockerRunnerImpl{} + defer sut.SetDocker(mockDocker)() + defer sut.SetLogFunc(runner.NothingToDo)() + + // expect + if err := sut.Run(context.Background(), dir, tag, cmd); err == nil { + t.Errorf("error must not be nil") + } + }) + + t.Run("when failure docker build", func(t *testing.T) { + // given + dir, cleanup := tmpDir(t) + defer cleanup() + + tag := docker.Tag(fmt.Sprintf("duci/test:%s", random.String(8))) + cmd := docker.Command{"echo", "test"} + + // and + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + // and + mockDocker := mock_docker.NewMockDocker(ctrl) + mockDocker.EXPECT(). + Build(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Times(1). + Return(nil, errors.New("error test")) + mockDocker.EXPECT(). + Run(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Times(0) + + // and + sut := runner.DockerRunnerImpl{} + defer sut.SetDocker(mockDocker)() + defer sut.SetLogFunc(runner.NothingToDo)() + + // when + err := sut.Run(context.Background(), dir, tag, cmd) + + // then + if err == nil { + t.Errorf("error must not be nil") + } + }) + + t.Run("when failure docker run", func(t *testing.T) { + // given + dir, cleanup := tmpDir(t) + defer cleanup() + + tag := docker.Tag(fmt.Sprintf("duci/test:%s", random.String(8))) + cmd := docker.Command{"echo", "test"} + + // and + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + // and + log := stubLog(t, ctrl) + conID := docker.ContainerID(random.String(16, random.Alphanumeric)) + + mockDocker := mock_docker.NewMockDocker(ctrl) + mockDocker.EXPECT(). + Build(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Times(1). + Return(log, nil) + mockDocker.EXPECT(). + Run(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Times(1). + Return(conID, nil, errors.New("test error")) + mockDocker.EXPECT(). + ExitCode(gomock.Any(), gomock.Eq(conID)). + Times(0) + + // and + sut := runner.DockerRunnerImpl{} + defer sut.SetDocker(mockDocker)() + defer sut.SetLogFunc(runner.NothingToDo)() + + // when + err := sut.Run(context.Background(), dir, tag, cmd) + + // then + if err == nil { + t.Error("error must not be nil") + } + }) + + t.Run("when failure to get exit code", func(t *testing.T) { + // given + dir, cleanup := tmpDir(t) + defer cleanup() + + tag := docker.Tag(fmt.Sprintf("duci/test:%s", random.String(8))) + cmd := docker.Command{"echo", "test"} + + // and + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + // and + log := stubLog(t, ctrl) + conID := docker.ContainerID(random.String(16, random.Alphanumeric)) + + mockDocker := mock_docker.NewMockDocker(ctrl) + mockDocker.EXPECT(). + Build(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Times(1). + Return(log, nil) + mockDocker.EXPECT(). + Run(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Times(1). + Return(conID, log, nil) + mockDocker.EXPECT(). + ExitCode(gomock.Any(), gomock.Eq(conID)). + Times(1). + Return(docker.ExitCode(0), errors.New("test error")) + mockDocker.EXPECT(). + RemoveContainer(gomock.Any(), gomock.Eq(conID)). + Times(0). + Return(nil) + + // and + sut := runner.DockerRunnerImpl{} + defer sut.SetDocker(mockDocker)() + defer sut.SetLogFunc(runner.NothingToDo)() + + // when + err := sut.Run(context.Background(), dir, tag, cmd) + + // then + if err == nil { + t.Error("error must not be nil") + } + }) + + t.Run("when exit code is not zero", func(t *testing.T) { + // given + dir, cleanup := tmpDir(t) + defer cleanup() + + tag := docker.Tag(fmt.Sprintf("duci/test:%s", random.String(8))) + cmd := docker.Command{"echo", "test"} + + // and + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + // and + log := stubLog(t, ctrl) + conID := docker.ContainerID(random.String(16, random.Alphanumeric)) + + mockDocker := mock_docker.NewMockDocker(ctrl) + mockDocker.EXPECT(). + Build(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Times(1). + Return(log, nil) + mockDocker.EXPECT(). + Run(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Times(1). + Return(conID, log, nil) + mockDocker.EXPECT(). + ExitCode(gomock.Any(), gomock.Eq(conID)). + Times(1). + Return(docker.ExitCode(-1), nil) + mockDocker.EXPECT(). + RemoveContainer(gomock.Any(), gomock.Eq(conID)). + Times(1). + Return(nil) + + // and + sut := runner.DockerRunnerImpl{} + defer sut.SetDocker(mockDocker)() + defer sut.SetLogFunc(runner.NothingToDo)() + + // when + err := sut.Run(context.Background(), dir, tag, cmd) + + // then + if err != runner.ErrFailure { + t.Errorf("error must be ErrFailure, but got %+v", err) + } + }) + + t.Run("when failure docker remove container", func(t *testing.T) { + // given + dir, cleanup := tmpDir(t) + defer cleanup() + + tag := docker.Tag(fmt.Sprintf("duci/test:%s", random.String(8))) + cmd := docker.Command{"echo", "test"} + + // and + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + // and + log := stubLog(t, ctrl) + conID := docker.ContainerID(random.String(16, random.Alphanumeric)) + + mockDocker := mock_docker.NewMockDocker(ctrl) + mockDocker.EXPECT(). + Build(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Times(1). + Return(log, nil) + mockDocker.EXPECT(). + Run(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Times(1). + Return(conID, log, nil) + mockDocker.EXPECT(). + ExitCode(gomock.Any(), gomock.Eq(conID)). + Times(1). + Return(docker.ExitCode(0), nil) + mockDocker.EXPECT(). + RemoveContainer(gomock.Any(), gomock.Eq(conID)). + Times(1). + Return(errors.New("test error")) + + // and + sut := runner.DockerRunnerImpl{} + defer sut.SetDocker(mockDocker)() + defer sut.SetLogFunc(runner.NothingToDo)() + + // when + err := sut.Run(context.Background(), dir, tag, cmd) + + // then + if err == nil { + t.Error("error must not be nil") + } + }) +} + +func tmpDir(t *testing.T) (workDir job.WorkDir, clean func()) { + t.Helper() + + dir := job.WorkDir(path.Join(os.TempDir(), random.String(16))) + if err := os.MkdirAll(dir.String(), 0700); err != nil { + t.Fatalf("error occur: %+v", err) + } + return dir, func() { + _ = os.RemoveAll(dir.String()) + } +} + +func stubLog(t *testing.T, ctrl *gomock.Controller) *mock_job.MockLog { + t.Helper() + + log := mock_job.NewMockLog(ctrl) + log.EXPECT(). + ReadLine(). + AnyTimes(). + Return(&job.LogLine{Timestamp: time.Now(), Message: "Hello Test"}, io.EOF) + return log +} diff --git a/go.mod b/go.mod index 7d9fd897..39e2bcc3 100644 --- a/go.mod +++ b/go.mod @@ -12,7 +12,7 @@ require ( github.com/go-chi/chi v3.3.3+incompatible github.com/gogo/protobuf v1.1.1 // indirect github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b // indirect - github.com/golang/mock v1.1.1 + github.com/golang/mock v1.2.0 github.com/golang/protobuf v1.1.0 // indirect github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db // indirect github.com/google/go-cmp v0.2.0 diff --git a/infrastructure/docker/docker.go b/infrastructure/docker/docker.go deleted file mode 100644 index b14270a5..00000000 --- a/infrastructure/docker/docker.go +++ /dev/null @@ -1,148 +0,0 @@ -package docker - -import ( - "bufio" - "context" - "fmt" - "github.com/docker/docker/api/types" - "github.com/docker/docker/api/types/container" - moby "github.com/docker/docker/client" - "github.com/pkg/errors" - "io" - "strings" -) - -// RuntimeOptions is a docker options. -type RuntimeOptions struct { - Environments Environments - Volumes Volumes -} - -// Environments represents a docker `-e` option. -type Environments map[string]interface{} - -// ToArray returns string array of environments -func (e Environments) ToArray() []string { - var a []string - for key, val := range e { - a = append(a, fmt.Sprintf("%s=%v", key, val)) - } - return a -} - -// Volumes represents a docker `-v` option. -type Volumes []string - -// ToMap returns map of volumes. -func (v Volumes) ToMap() map[string]struct{} { - m := make(map[string]struct{}) - for _, volume := range v { - key := strings.Split(volume, ":")[0] - m[key] = struct{}{} - } - return m -} - -// Client is a interface of docker client -type Client interface { - Build(ctx context.Context, file io.Reader, tag string, dockerfile string) (Log, error) - Run(ctx context.Context, opts RuntimeOptions, tag string, cmd ...string) (string, Log, error) - Rm(ctx context.Context, containerID string) error - Rmi(ctx context.Context, tag string) error - ExitCode(ctx context.Context, containerID string) (int64, error) - Info(ctx context.Context) (types.Info, error) -} - -type clientImpl struct { - moby Moby -} - -// New returns docker client. -func New() (*clientImpl, error) { - cli, err := moby.NewClientWithOpts(moby.FromEnv) - if err != nil { - return nil, errors.WithStack(err) - } - return &clientImpl{moby: cli}, nil -} - -// Build docker image. -func (c *clientImpl) Build(ctx context.Context, file io.Reader, tag string, dockerfile string) (Log, error) { - opts := types.ImageBuildOptions{ - Tags: []string{tag}, - Dockerfile: dockerfile, - Remove: true, - } - resp, err := c.moby.ImageBuild(ctx, file, opts) - if err != nil { - return nil, errors.WithStack(err) - } - - return &buildLogger{bufio.NewReader(resp.Body)}, nil -} - -// Run id a function create, start container. -func (c *clientImpl) Run(ctx context.Context, opts RuntimeOptions, tag string, cmd ...string) (string, Log, error) { - con, err := c.moby.ContainerCreate(ctx, &container.Config{ - Image: tag, - Env: opts.Environments.ToArray(), - Volumes: opts.Volumes.ToMap(), - Cmd: cmd, - }, &container.HostConfig{ - Binds: opts.Volumes, - }, nil, "") - if err != nil { - return "", nil, errors.WithStack(err) - } - - if err := c.moby.ContainerStart(ctx, con.ID, types.ContainerStartOptions{}); err != nil { - return con.ID, nil, errors.WithStack(err) - } - - log, err := c.moby.ContainerLogs(ctx, con.ID, types.ContainerLogsOptions{ - ShowStdout: true, - ShowStderr: true, - Follow: true, - }) - if err != nil { - return con.ID, nil, errors.WithStack(err) - } - - return con.ID, &runLogger{bufio.NewReader(log)}, nil -} - -// Rm remove docker container. -func (c *clientImpl) Rm(ctx context.Context, containerID string) error { - if err := c.moby.ContainerRemove(ctx, containerID, types.ContainerRemoveOptions{}); err != nil { - return errors.WithStack(err) - } - return nil -} - -// Rmi remove docker image. -func (c *clientImpl) Rmi(ctx context.Context, tag string) error { - if _, err := c.moby.ImageRemove(ctx, tag, types.ImageRemoveOptions{}); err != nil { - return errors.WithStack(err) - } - return nil -} - -// ExitCode wait container until exit and returns exit code. -func (c *clientImpl) ExitCode(ctx context.Context, containerID string) (int64, error) { - body, err := c.moby.ContainerWait(ctx, containerID, container.WaitConditionNotRunning) - select { - case b := <-body: - return b.StatusCode, nil - case e := <-err: - return -1, errors.WithStack(e) - } -} - -// Status returns error when failure get docker status. -func (c *clientImpl) Info(ctx context.Context) (types.Info, error) { - info, err := c.moby.Info(ctx) - if err != nil { - return types.Info{}, errors.WithStack(err) - } - return info, nil -} diff --git a/infrastructure/docker/docker_test.go b/infrastructure/docker/docker_test.go deleted file mode 100644 index 0cbafd0c..00000000 --- a/infrastructure/docker/docker_test.go +++ /dev/null @@ -1,595 +0,0 @@ -package docker_test - -import ( - "bytes" - "fmt" - "github.com/docker/docker/api/types" - "github.com/docker/docker/api/types/container" - "github.com/duck8823/duci/application/context" - "github.com/duck8823/duci/infrastructure/docker" - "github.com/duck8823/duci/infrastructure/docker/mock_docker" - "github.com/golang/mock/gomock" - "github.com/google/go-cmp/cmp" - "github.com/google/uuid" - "github.com/labstack/gommon/random" - "github.com/pkg/errors" - "io/ioutil" - "net/url" - "os" - "reflect" - "sort" - "strings" - "testing" -) - -func TestNew(t *testing.T) { - t.Run("with wrong docker environment", func(t *testing.T) { - // given - dockerHost := os.Getenv("DOCKER_HOST") - os.Setenv("DOCKER_HOST", "hoge") - defer os.Setenv("DOCKER_HOST", dockerHost) - - // expect - if _, err := docker.New(); err == nil { - t.Errorf("error must occur") - } - }) -} - -func TestClientImpl_Build(t *testing.T) { - // setup - sut, err := docker.New() - if err != nil { - t.Fatalf("error occurred: %+v", err) - } - - t.Run("when success image build", func(t *testing.T) { - // given - expected := "hello world" - sr := strings.NewReader(fmt.Sprintf("{\"stream\":\"%s\"}", expected)) - r := ioutil.NopCloser(sr) - - // and - ctrl := gomock.NewController(t) - mockMoby := mock_docker.NewMockMoby(ctrl) - - mockMoby.EXPECT(). - ImageBuild(gomock.Any(), gomock.Any(), gomock.Any()). - Return(types.ImageBuildResponse{Body: r}, nil) - - reset := sut.SetMoby(mockMoby) - defer reset() - - // when - log, err := sut.Build(context.New("test/task", uuid.New(), &url.URL{}), nil, "", "") - - // then - if err != nil { - t.Errorf("error must not occur, but got %+v", err) - } - - // and - line, err := log.ReadLine() - if err != nil { - t.Errorf("error must not occur, but got %+v", err) - } - - // and - if string(line.Message) != expected { - t.Errorf("must be equal. wont %#v, but got %#v", expected, string(line.Message)) - } - }) - - t.Run("when failure image build", func(t *testing.T) { - // given - ctrl := gomock.NewController(t) - mockMoby := mock_docker.NewMockMoby(ctrl) - - mockMoby.EXPECT(). - ImageBuild(gomock.Any(), gomock.Any(), gomock.Any()). - Return(types.ImageBuildResponse{}, errors.New("test error")) - - reset := sut.SetMoby(mockMoby) - defer reset() - - // expect - if _, err := sut.Build( - context.New("test/task", uuid.New(), &url.URL{}), - nil, - "", - "", - ); err == nil { - t.Errorf("error must occur, but got %+v", err) - } - }) -} - -func TestClientImpl_Run(t *testing.T) { - // setup - sut, err := docker.New() - if err != nil { - t.Fatalf("error occurred: %+v", err) - } - - t.Run("when failure create container", func(t *testing.T) { - // given - id := random.String(64, random.Alphanumeric, random.Symbols) - - // and - ctrl := gomock.NewController(t) - mockMoby := mock_docker.NewMockMoby(ctrl) - - mockMoby.EXPECT(). - ContainerCreate(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). - AnyTimes(). - Return(container.ContainerCreateCreatedBody{ID: id}, errors.New("test error")) - - reset := sut.SetMoby(mockMoby) - defer reset() - - // when - actual, _, err := sut.Run(context.New("test/task", uuid.New(), &url.URL{}), docker.RuntimeOptions{}, "hello-world") - - // then - if actual != "" { - t.Errorf("id must be empty string, but got %+v", actual) - } - - if err == nil { - t.Error("error must occur, but got nil") - } - }) - - t.Run("when success create container", func(t *testing.T) { - t.Run("when failure start container", func(t *testing.T) { - // given - id := random.String(64, random.Alphanumeric, random.Symbols) - - // and - ctrl := gomock.NewController(t) - mockMoby := mock_docker.NewMockMoby(ctrl) - - mockMoby.EXPECT(). - ContainerCreate(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). - AnyTimes(). - Return(container.ContainerCreateCreatedBody{ID: id}, nil) - - mockMoby.EXPECT(). - ContainerStart(gomock.Any(), gomock.Any(), gomock.Any()). - Return(errors.New("test error")) - - reset := sut.SetMoby(mockMoby) - defer reset() - - // when - actual, _, err := sut.Run(context.New("test/task", uuid.New(), &url.URL{}), docker.RuntimeOptions{}, "hello-world") - - // then - if actual != id { - t.Errorf("id must be equal %+v, but got %+v", id, actual) - } - - if err == nil { - t.Error("error must occur, but got nil") - } - }) - - t.Run("when success start container", func(t *testing.T) { - t.Run("when failure get log", func(t *testing.T) { - // given - id := random.String(64, random.Alphanumeric, random.Symbols) - - // and - ctrl := gomock.NewController(t) - mockMoby := mock_docker.NewMockMoby(ctrl) - - mockMoby.EXPECT(). - ContainerCreate(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). - AnyTimes(). - Return(container.ContainerCreateCreatedBody{ID: id}, nil) - - mockMoby.EXPECT(). - ContainerStart(gomock.Any(), gomock.Any(), gomock.Any()). - AnyTimes(). - Return(nil) - - mockMoby.EXPECT(). - ContainerLogs(gomock.Any(), gomock.Any(), gomock.Any()). - Return(nil, errors.New("test error")) - - reset := sut.SetMoby(mockMoby) - defer reset() - - // when - actual, _, err := sut.Run(context.New("test/task", uuid.New(), &url.URL{}), docker.RuntimeOptions{}, "hello-world") - - // then - if actual != id { - t.Errorf("id must be equal %+v, but got %+v", id, actual) - } - - if err == nil { - t.Error("error must occur, but got nil") - } - }) - - t.Run("when success get log", func(t *testing.T) { - t.Run("with valid log", func(t *testing.T) { - // given - id := random.String(64, random.Alphanumeric, random.Symbols) - - prefix := []byte{1, 0, 0, 0, 1, 1, 1, 1} - msg := "hello test" - log := ioutil.NopCloser(bytes.NewReader(append(prefix, []byte(msg)...))) - - // and - ctrl := gomock.NewController(t) - mockMoby := mock_docker.NewMockMoby(ctrl) - - mockMoby.EXPECT(). - ContainerCreate(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). - AnyTimes(). - Return(container.ContainerCreateCreatedBody{ID: id}, nil) - - mockMoby.EXPECT(). - ContainerStart(gomock.Any(), gomock.Any(), gomock.Any()). - AnyTimes(). - Return(nil) - - mockMoby.EXPECT(). - ContainerLogs(gomock.Any(), gomock.Any(), gomock.Any()). - Return(log, nil) - - reset := sut.SetMoby(mockMoby) - defer reset() - - // when - actualID, actualLog, err := sut.Run(context.New("test/task", uuid.New(), &url.URL{}), docker.RuntimeOptions{}, "hello-world") - - // then - if actualID != id { - t.Errorf("id must be equal %+v, but got %+v", id, actualID) - } - - if actualLog == nil { - t.Errorf("log must not nil") - } else { - line, _ := actualLog.ReadLine() - if string(line.Message) != msg { - t.Errorf("message must equal. wont %+v, but got %+v", msg, string(line.Message)) - } - } - - if err != nil { - t.Error("error must occur, but got nil") - } - }) - }) - }) - }) -} - -func TestClientImpl_Rm(t *testing.T) { - // setup - sut, err := docker.New() - if err != nil { - t.Fatalf("error occurred: %+v", err) - } - - t.Run("when success removing container", func(t *testing.T) { - // given - conID := random.String(16, random.Alphanumeric, random.Symbols) - - ctrl := gomock.NewController(t) - mockMoby := mock_docker.NewMockMoby(ctrl) - - mockMoby.EXPECT(). - ContainerRemove(gomock.Any(), gomock.Eq(conID), gomock.Any()). - Return(nil) - - reset := sut.SetMoby(mockMoby) - defer reset() - - // expect - if err := sut.Rm(context.New("test/task", uuid.New(), &url.URL{}), conID); err != nil { - t.Errorf("error must not occur, but got %+v", err) - } - }) - - t.Run("when failure removing container", func(t *testing.T) { - // given - conID := random.String(16, random.Alphanumeric, random.Symbols) - - ctrl := gomock.NewController(t) - mockMoby := mock_docker.NewMockMoby(ctrl) - - mockMoby.EXPECT(). - ContainerRemove(gomock.Any(), gomock.Eq(conID), gomock.Any()). - Return(errors.New("test error")) - - reset := sut.SetMoby(mockMoby) - defer reset() - - // expect - if err := sut.Rm(context.New("test/task", uuid.New(), &url.URL{}), conID); err == nil { - t.Error("error must occur, but got nil") - } - }) -} - -func TestClientImpl_Rmi(t *testing.T) { - // setup - sut, err := docker.New() - if err != nil { - t.Fatalf("error occurred: %+v", err) - } - - t.Run("when success removing image", func(t *testing.T) { - // given - imageID := random.String(16, random.Alphanumeric, random.Symbols) - - ctrl := gomock.NewController(t) - mockMoby := mock_docker.NewMockMoby(ctrl) - - mockMoby.EXPECT(). - ImageRemove(gomock.Any(), gomock.Eq(imageID), gomock.Any()). - Return(nil, nil) - - reset := sut.SetMoby(mockMoby) - defer reset() - - // expect - if err := sut.Rmi(context.New("test/task", uuid.New(), &url.URL{}), imageID); err != nil { - t.Errorf("error must not occur, but got %+v", err) - } - }) - - t.Run("when failure removing image", func(t *testing.T) { - // given - imageID := random.String(16, random.Alphanumeric, random.Symbols) - - ctrl := gomock.NewController(t) - mockMoby := mock_docker.NewMockMoby(ctrl) - - mockMoby.EXPECT(). - ImageRemove(gomock.Any(), gomock.Eq(imageID), gomock.Any()). - Return(nil, errors.New("test error")) - - reset := sut.SetMoby(mockMoby) - defer reset() - - // expect - if err := sut.Rmi(context.New("test/task", uuid.New(), &url.URL{}), imageID); err == nil { - t.Error("error must occur, but got nil") - } - }) -} - -func TestClientImpl_ExitCode2(t *testing.T) { - // setup - sut, err := docker.New() - if err != nil { - t.Fatalf("error occurred: %+v", err) - } - - t.Run("when success removing image", func(t *testing.T) { - // given - imageID := random.String(16, random.Alphanumeric, random.Symbols) - - ctrl := gomock.NewController(t) - mockMoby := mock_docker.NewMockMoby(ctrl) - - mockMoby.EXPECT(). - ImageRemove(gomock.Any(), gomock.Eq(imageID), gomock.Any()). - Return(nil, nil) - - reset := sut.SetMoby(mockMoby) - defer reset() - - // expect - if err := sut.Rmi(context.New("test/task", uuid.New(), &url.URL{}), imageID); err != nil { - t.Errorf("error must not occur, but got %+v", err) - } - }) - - t.Run("when failure removing image", func(t *testing.T) { - // given - imageID := random.String(16, random.Alphanumeric, random.Symbols) - - ctrl := gomock.NewController(t) - mockMoby := mock_docker.NewMockMoby(ctrl) - - mockMoby.EXPECT(). - ImageRemove(gomock.Any(), gomock.Eq(imageID), gomock.Any()). - Return(nil, errors.New("test error")) - - reset := sut.SetMoby(mockMoby) - defer reset() - - // expect - if err := sut.Rmi(context.New("test/task", uuid.New(), &url.URL{}), imageID); err == nil { - t.Error("error must occur, but got nil") - } - }) -} - -func TestClientImpl_ExitCode(t *testing.T) { - // setup - sut, err := docker.New() - if err != nil { - t.Fatalf("error occurred: %+v", err) - } - - t.Run("with exit code 0", func(t *testing.T) { - // given - exitCode := int64(19) - - body := make(chan container.ContainerWaitOKBody, 1) - err := make(chan error, 1) - - // and - body <- container.ContainerWaitOKBody{StatusCode: exitCode} - - // and - conID := random.String(16, random.Alphanumeric, random.Symbols) - - ctrl := gomock.NewController(t) - mockMoby := mock_docker.NewMockMoby(ctrl) - - mockMoby.EXPECT(). - ContainerWait(gomock.Any(), gomock.Eq(conID), gomock.Any()). - Return(body, err) - - reset := sut.SetMoby(mockMoby) - defer reset() - - // when - if code, _ := sut.ExitCode(context.New("test/task", uuid.New(), &url.URL{}), conID); code != exitCode { - t.Errorf("code must equal %+v, but got %+v", exitCode, code) - } - }) - - t.Run("with error", func(t *testing.T) { - // given - body := make(chan container.ContainerWaitOKBody, 1) - err := make(chan error, 1) - - // and - err <- errors.New("test error") - - // and - conID := random.String(16, random.Alphanumeric, random.Symbols) - - ctrl := gomock.NewController(t) - mockMoby := mock_docker.NewMockMoby(ctrl) - - mockMoby.EXPECT(). - ContainerWait(gomock.Any(), gomock.Eq(conID), gomock.Any()). - Return(body, err) - - reset := sut.SetMoby(mockMoby) - defer reset() - - // when - if _, actualErr := sut.ExitCode(context.New("test/task", uuid.New(), &url.URL{}), conID); actualErr == nil { - t.Error("error must occur but got nil") - } - }) -} - -func TestClientImpl_Info(t *testing.T) { - // setup - sut, err := docker.New() - if err != nil { - t.Fatalf("error occurred: %+v", err) - } - - t.Run("without error", func(t *testing.T) { - // given - expected := types.Info{ID: uuid.New().String()} - - // and - ctrl := gomock.NewController(t) - mockMoby := mock_docker.NewMockMoby(ctrl) - - mockMoby.EXPECT(). - Info(gomock.Any()). - Return(expected, nil) - - reset := sut.SetMoby(mockMoby) - defer reset() - - // when - actual, err := sut.Info(context.New("test", uuid.New(), nil)) - - // then - if !cmp.Equal(actual, expected) { - t.Errorf("must be equal. %+v", cmp.Diff(actual, expected)) - } - - if err != nil { - t.Errorf("error must not occur, but got %+v", err) - } - }) - - t.Run("with error", func(t *testing.T) { - // given - ctrl := gomock.NewController(t) - mockMoby := mock_docker.NewMockMoby(ctrl) - - mockMoby.EXPECT(). - Info(gomock.Any()). - Return(types.Info{}, errors.New("test")) - - reset := sut.SetMoby(mockMoby) - defer reset() - - // expect - if _, err := sut.Info(context.New("test", uuid.New(), nil)); err == nil { - t.Error("error must occur, but got nil") - } - }) -} - -func TestEnvironments_ToArray(t *testing.T) { - var empty []string - for _, testcase := range []struct { - in docker.Environments - expected []string - }{ - { - in: docker.Environments{}, - expected: empty, - }, - { - in: docker.Environments{ - "int": 19, - "string": "hello", - }, - expected: []string{ - "int=19", - "string=hello", - }, - }, - } { - // when - actual := testcase.in.ToArray() - expected := testcase.expected - sort.Strings(actual) - sort.Strings(expected) - - // then - if !reflect.DeepEqual(actual, expected) { - t.Errorf("must be equal. actual=%+v, wont=%+v", actual, expected) - } - } -} - -func TestVolumes_Volumes(t *testing.T) { - for _, testcase := range []struct { - in docker.Volumes - expected map[string]struct{} - }{ - { - in: docker.Volumes{}, - expected: make(map[string]struct{}), - }, - { - in: docker.Volumes{ - "/hoge/fuga:/hoge/hoge", - }, - expected: map[string]struct{}{ - "/hoge/fuga": {}, - }, - }, - } { - // when - actual := testcase.in.ToMap() - expected := testcase.expected - - // then - if !reflect.DeepEqual(actual, expected) { - t.Errorf("must be equal. actual=%+v, wont=%+v", actual, expected) - } - } -} diff --git a/infrastructure/docker/export_test.go b/infrastructure/docker/export_test.go deleted file mode 100644 index 26473089..00000000 --- a/infrastructure/docker/export_test.go +++ /dev/null @@ -1,42 +0,0 @@ -package docker - -import ( - "bufio" - "time" -) - -func SetNowFunc(f func() time.Time) (reset func()) { - tmp := now - now = f - return func() { - now = tmp - } -} - -type BuildLogger = buildLogger - -func (l *buildLogger) SetReader(r *bufio.Reader) (reset func()) { - tmp := l.reader - l.reader = r - return func() { - l.reader = tmp - } -} - -type RunLogger = runLogger - -func (l *runLogger) SetReader(r *bufio.Reader) (reset func()) { - tmp := l.reader - l.reader = r - return func() { - l.reader = tmp - } -} - -func (c *clientImpl) SetMoby(m Moby) (reset func()) { - tmp := c.moby - c.moby = m - return func() { - c.moby = tmp - } -} diff --git a/infrastructure/docker/log.go b/infrastructure/docker/log.go deleted file mode 100644 index 399098c8..00000000 --- a/infrastructure/docker/log.go +++ /dev/null @@ -1,92 +0,0 @@ -package docker - -import ( - "bufio" - "bytes" - "encoding/json" - "fmt" - "github.com/pkg/errors" - "io" - "time" -) - -var now = time.Now - -// Log is a interface represents docker log. -type Log interface { - ReadLine() (*LogLine, error) -} - -// LogLine stores timestamp and a line. -type LogLine struct { - Timestamp time.Time - Message []byte -} - -type buildLogger struct { - reader *bufio.Reader -} - -// ReadLine returns LogLine. -func (l *buildLogger) ReadLine() (*LogLine, error) { - for { - line, _, readErr := l.reader.ReadLine() - msg := extractMessage(line) - if readErr == io.EOF { - return &LogLine{Timestamp: now(), Message: msg}, readErr - } - if readErr != nil { - return nil, errors.WithStack(readErr) - } - - if len(msg) == 0 { - continue - } - - return &LogLine{Timestamp: now(), Message: msg}, readErr - } -} - -type runLogger struct { - reader *bufio.Reader -} - -// ReadLine returns LogLine. -func (l *runLogger) ReadLine() (*LogLine, error) { - for { - line, _, readErr := l.reader.ReadLine() - if readErr != nil && readErr != io.EOF { - return nil, errors.WithStack(readErr) - } - - messages, err := trimPrefix(line) - if err != nil { - return nil, errors.WithStack(err) - } - - // prevent to CR - progress := bytes.Split(messages, []byte{'\r'}) - return &LogLine{Timestamp: now(), Message: progress[0]}, readErr - } -} - -func extractMessage(line []byte) []byte { - s := &struct { - Stream string `json:"stream"` - }{} - json.NewDecoder(bytes.NewReader(line)).Decode(s) - return []byte(s.Stream) -} - -func trimPrefix(line []byte) ([]byte, error) { - if len(line) < 8 { - return []byte{}, nil - } - - // detect logstore prefix - // see https://godoc.org/github.com/docker/docker/client#Client.ContainerLogs - if !((line[0] == 1 || line[0] == 2) && (line[1] == 0 && line[2] == 0 && line[3] == 0)) { - return nil, fmt.Errorf("invalid logstore prefix: %+v", line[:7]) - } - return line[8:], nil -} diff --git a/infrastructure/docker/log_test.go b/infrastructure/docker/log_test.go deleted file mode 100644 index be5a239f..00000000 --- a/infrastructure/docker/log_test.go +++ /dev/null @@ -1,132 +0,0 @@ -package docker_test - -import ( - "bufio" - "bytes" - "github.com/duck8823/duci/infrastructure/docker" - "reflect" - "strings" - "testing" - "time" -) - -func TestBuildLogger_ReadLine(t *testing.T) { - // given - jst, err := time.LoadLocation("Asia/Tokyo") - if err != nil { - t.Fatalf("error occurred: %+v", err) - } - date := time.Date(2020, time.December, 4, 4, 32, 12, 3, jst) - - resetNowFunc := docker.SetNowFunc(func() time.Time { - return date - }) - defer resetNowFunc() - - // and - reader := bufio.NewReader(strings.NewReader("{\"stream\":\"Hello World.\"}")) - logger := &docker.BuildLogger{} - reset := logger.SetReader(reader) - defer reset() - - // and - expected := &docker.LogLine{Timestamp: date, Message: []byte("Hello World.")} - - // when - actual, err := logger.ReadLine() - - // then - if err != nil { - t.Errorf("error must not occur, but got %+v", err) - } - - // and - if !reflect.DeepEqual(expected, actual) { - t.Errorf("must be equal: wont %+v, but got %+v", expected, actual) - } -} - -func TestRunLogger_ReadLine(t *testing.T) { - // setup - jst, err := time.LoadLocation("Asia/Tokyo") - if err != nil { - t.Fatalf("error occurred: %+v", err) - } - date := time.Date(2020, time.December, 4, 4, 32, 12, 3, jst) - - resetNowFunc := docker.SetNowFunc(func() time.Time { - return date - }) - defer resetNowFunc() - - t.Run("with correct format", func(t *testing.T) { - // given - prefix := []byte{1, 0, 0, 0, 9, 9, 9, 9} - reader := bufio.NewReader(bytes.NewReader(append(prefix, 'H', 'e', 'l', 'l', 'o'))) - logger := &docker.RunLogger{} - reset := logger.SetReader(reader) - defer reset() - - // and - expected := &docker.LogLine{Timestamp: date, Message: []byte("Hello")} - - // when - actual, err := logger.ReadLine() - - // then - if err != nil { - t.Errorf("error must not occur, but got %+v", err) - } - - // and - if !reflect.DeepEqual(expected, actual) { - t.Errorf("must be equal: wont %+v, but got %+v", expected, actual) - } - }) - - t.Run("with invalid format", func(t *testing.T) { - // given - prefix := []byte{0, 0, 0, 0, 9, 9, 9, 9} - reader := bufio.NewReader(bytes.NewReader(append(prefix, 'H', 'e', 'l', 'l', 'o'))) - logger := &docker.RunLogger{} - reset := logger.SetReader(reader) - defer reset() - - // when - actual, err := logger.ReadLine() - - // then - if err == nil { - t.Error("error must occur, but got nil") - } - - // and - if actual != nil { - t.Errorf("must be equal: wont nil, but got %+v", actual) - } - }) - - t.Run("when too short", func(t *testing.T) { - // given - reader := bufio.NewReader(bytes.NewReader([]byte{'H', 'e', 'l', 'l', 'o'})) - logger := &docker.RunLogger{} - reset := logger.SetReader(reader) - defer reset() - - // and - expected := &docker.LogLine{Timestamp: date, Message: []byte{}} - - // when - actual, err := logger.ReadLine() - - // then - if err != nil { - t.Errorf("error must not occur, but got %+v", err) - } - - // and - if !reflect.DeepEqual(expected, actual) { - t.Errorf("must be equal: wont %+v, but got %+v", expected, actual) - } - }) -} diff --git a/infrastructure/docker/mock_docker/docker.go b/infrastructure/docker/mock_docker/docker.go deleted file mode 100644 index 2747d6d3..00000000 --- a/infrastructure/docker/mock_docker/docker.go +++ /dev/null @@ -1,119 +0,0 @@ -// Code generated by MockGen. DO NOT EDIT. -// Source: infrastructure/docker/docker.go - -// Package mock_docker is a generated GoMock package. -package mock_docker - -import ( - context "context" - types "github.com/docker/docker/api/types" - docker "github.com/duck8823/duci/infrastructure/docker" - gomock "github.com/golang/mock/gomock" - io "io" - reflect "reflect" -) - -// MockClient is a mock of Client interface -type MockClient struct { - ctrl *gomock.Controller - recorder *MockClientMockRecorder -} - -// MockClientMockRecorder is the mock recorder for MockClient -type MockClientMockRecorder struct { - mock *MockClient -} - -// NewMockClient creates a new mock instance -func NewMockClient(ctrl *gomock.Controller) *MockClient { - mock := &MockClient{ctrl: ctrl} - mock.recorder = &MockClientMockRecorder{mock} - return mock -} - -// EXPECT returns an object that allows the caller to indicate expected use -func (m *MockClient) EXPECT() *MockClientMockRecorder { - return m.recorder -} - -// Build mocks base method -func (m *MockClient) Build(ctx context.Context, file io.Reader, tag, dockerfile string) (docker.Log, error) { - ret := m.ctrl.Call(m, "Build", ctx, file, tag, dockerfile) - ret0, _ := ret[0].(docker.Log) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// Build indicates an expected call of Build -func (mr *MockClientMockRecorder) Build(ctx, file, tag, dockerfile interface{}) *gomock.Call { - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Build", reflect.TypeOf((*MockClient)(nil).Build), ctx, file, tag, dockerfile) -} - -// Run mocks base method -func (m *MockClient) Run(ctx context.Context, opts docker.RuntimeOptions, tag string, cmd ...string) (string, docker.Log, error) { - varargs := []interface{}{ctx, opts, tag} - for _, a := range cmd { - varargs = append(varargs, a) - } - ret := m.ctrl.Call(m, "Run", varargs...) - ret0, _ := ret[0].(string) - ret1, _ := ret[1].(docker.Log) - ret2, _ := ret[2].(error) - return ret0, ret1, ret2 -} - -// Run indicates an expected call of Run -func (mr *MockClientMockRecorder) Run(ctx, opts, tag interface{}, cmd ...interface{}) *gomock.Call { - varargs := append([]interface{}{ctx, opts, tag}, cmd...) - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Run", reflect.TypeOf((*MockClient)(nil).Run), varargs...) -} - -// Rm mocks base method -func (m *MockClient) Rm(ctx context.Context, containerID string) error { - ret := m.ctrl.Call(m, "Rm", ctx, containerID) - ret0, _ := ret[0].(error) - return ret0 -} - -// Rm indicates an expected call of Rm -func (mr *MockClientMockRecorder) Rm(ctx, containerID interface{}) *gomock.Call { - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Rm", reflect.TypeOf((*MockClient)(nil).Rm), ctx, containerID) -} - -// Rmi mocks base method -func (m *MockClient) Rmi(ctx context.Context, tag string) error { - ret := m.ctrl.Call(m, "Rmi", ctx, tag) - ret0, _ := ret[0].(error) - return ret0 -} - -// Rmi indicates an expected call of Rmi -func (mr *MockClientMockRecorder) Rmi(ctx, tag interface{}) *gomock.Call { - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Rmi", reflect.TypeOf((*MockClient)(nil).Rmi), ctx, tag) -} - -// ExitCode mocks base method -func (m *MockClient) ExitCode(ctx context.Context, containerID string) (int64, error) { - ret := m.ctrl.Call(m, "ExitCode", ctx, containerID) - ret0, _ := ret[0].(int64) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// ExitCode indicates an expected call of ExitCode -func (mr *MockClientMockRecorder) ExitCode(ctx, containerID interface{}) *gomock.Call { - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExitCode", reflect.TypeOf((*MockClient)(nil).ExitCode), ctx, containerID) -} - -// Info mocks base method -func (m *MockClient) Info(ctx context.Context) (types.Info, error) { - ret := m.ctrl.Call(m, "Info", ctx) - ret0, _ := ret[0].(types.Info) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// Info indicates an expected call of Info -func (mr *MockClientMockRecorder) Info(ctx interface{}) *gomock.Call { - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Info", reflect.TypeOf((*MockClient)(nil).Info), ctx) -} diff --git a/infrastructure/docker/testdata/correct_archive.tar b/infrastructure/docker/testdata/correct_archive.tar deleted file mode 100644 index 81c54d18..00000000 Binary files a/infrastructure/docker/testdata/correct_archive.tar and /dev/null differ diff --git a/infrastructure/docker/testdata/correct_archive_subdir.tar b/infrastructure/docker/testdata/correct_archive_subdir.tar deleted file mode 100644 index 8f2344fa..00000000 Binary files a/infrastructure/docker/testdata/correct_archive_subdir.tar and /dev/null differ diff --git a/infrastructure/docker/testdata/data b/infrastructure/docker/testdata/data deleted file mode 100644 index bdd51cc2..00000000 --- a/infrastructure/docker/testdata/data +++ /dev/null @@ -1 +0,0 @@ -hello-world \ No newline at end of file diff --git a/infrastructure/docker/testdata/invalid_archive.tar b/infrastructure/docker/testdata/invalid_archive.tar deleted file mode 100644 index 8c7df4eb..00000000 Binary files a/infrastructure/docker/testdata/invalid_archive.tar and /dev/null differ diff --git a/infrastructure/job/data_source.go b/infrastructure/job/data_source.go new file mode 100644 index 00000000..254a023b --- /dev/null +++ b/infrastructure/job/data_source.go @@ -0,0 +1,51 @@ +package job + +import ( + "bytes" + "encoding/json" + "github.com/duck8823/duci/domain/model/job" + "github.com/pkg/errors" + "github.com/syndtr/goleveldb/leveldb" +) + +type dataSource struct { + db LevelDB +} + +// NewDataSource returns job data source +func NewDataSource(path string) (job.Repository, error) { + db, err := leveldb.OpenFile(path, nil) + if err != nil { + return nil, errors.WithStack(err) + } + return &dataSource{db}, nil +} + +// FindBy returns job found by ID +func (d *dataSource) FindBy(id job.ID) (*job.Job, error) { + data, err := d.db.Get(id.ToSlice(), nil) + if err == leveldb.ErrNotFound { + return nil, job.ErrNotFound + } else if err != nil { + return nil, errors.WithStack(err) + } + + job := &job.Job{} + if err := json.NewDecoder(bytes.NewReader(data)).Decode(job); err != nil { + return nil, errors.WithStack(err) + } + job.ID = id + return job, nil +} + +// Save store job to data source +func (d *dataSource) Save(job job.Job) error { + data, err := job.ToBytes() + if err != nil { + return errors.WithStack(err) + } + if err := d.db.Put(job.ID.ToSlice(), data, nil); err != nil { + return errors.WithStack(err) + } + return nil +} diff --git a/infrastructure/job/data_source_test.go b/infrastructure/job/data_source_test.go new file mode 100644 index 00000000..02b71805 --- /dev/null +++ b/infrastructure/job/data_source_test.go @@ -0,0 +1,283 @@ +package job_test + +import ( + "encoding/json" + "github.com/duck8823/duci/domain/model/job" + . "github.com/duck8823/duci/infrastructure/job" + "github.com/duck8823/duci/infrastructure/job/mock_job" + "github.com/golang/mock/gomock" + "github.com/google/go-cmp/cmp" + "github.com/google/uuid" + "github.com/labstack/gommon/random" + "github.com/pkg/errors" + "github.com/syndtr/goleveldb/leveldb" + "os" + "path" + "testing" + "time" +) + +func TestNewDataSource(t *testing.T) { + t.Run("with temporary path", func(t *testing.T) { + // given + tmpDir := path.Join(os.TempDir(), random.String(16, random.Alphanumeric)) + defer func() { + _ = os.RemoveAll(tmpDir) + }() + + // when + got, err := NewDataSource(tmpDir) + + // then + if err != nil { + t.Errorf("error must be nil, but got %+v", err) + } + + // and + if got == nil { + t.Error("must not be nil") + } + }) + + t.Run("with wrong path", func(t *testing.T) { + // given + tmpDir := path.Join(os.TempDir(), random.String(16, random.Alphanumeric)) + defer func() { + _ = os.RemoveAll(tmpDir) + }() + + // and + db, err := leveldb.OpenFile(tmpDir, nil) + if err != nil { + t.Fatalf("error occurred: %+v", err) + } + defer db.Close() + + // when + got, err := NewDataSource(tmpDir) + + // then + if err == nil { + t.Error("error must not be nil") + } + + // and + if got != nil { + t.Errorf("must be nil, but got %+v", got) + } + }) +} + +func TestDataSource_FindBy(t *testing.T) { + t.Run("when returns data", func(t *testing.T) { + // given + id := job.ID(uuid.New()) + + // and + want := &job.Job{ + ID: id, + Finished: false, + Stream: []job.LogLine{{Timestamp: time.Now(), Message: "Hello Test"}}, + } + data, err := json.Marshal(want) + if err != nil { + t.Fatalf("error occurred: %+v", err) + } + + // and + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + db := mock_job.NewMockLevelDB(ctrl) + db.EXPECT(). + Get(gomock.Eq([]byte(uuid.UUID(id).String())), gomock.Nil()). + Times(1). + Return(data, nil) + + // and + sut := &DataSource{} + defer sut.SetDB(db)() + + // when + got, err := sut.FindBy(id) + + // then + if err != nil { + t.Errorf("error must be nil, but got %+v", err) + } + + // and + if !cmp.Equal(got, want) { + t.Errorf("must be equal, but %+v", cmp.Diff(got, want)) + } + }) + + t.Run("when returns error", func(t *testing.T) { + // given + id := job.ID(uuid.New()) + + // and + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + db := mock_job.NewMockLevelDB(ctrl) + db.EXPECT(). + Get(gomock.Eq([]byte(uuid.UUID(id).String())), gomock.Nil()). + Times(1). + Return(nil, errors.New("test error")) + + // and + sut := &DataSource{} + defer sut.SetDB(db)() + + // when + got, err := sut.FindBy(id) + + // then + if err == nil { + t.Error("error must not be nil") + } + + // and + if got != nil { + t.Errorf("must be nil, but got %+v", got) + } + }) + + t.Run("when leveldb.ErrNotFound", func(t *testing.T) { + // given + id := job.ID(uuid.New()) + + // and + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + db := mock_job.NewMockLevelDB(ctrl) + db.EXPECT(). + Get(gomock.Eq([]byte(uuid.UUID(id).String())), gomock.Nil()). + Times(1). + Return(nil, leveldb.ErrNotFound) + + // and + sut := &DataSource{} + defer sut.SetDB(db)() + + // when + got, err := sut.FindBy(id) + + // then + if err == nil { + t.Error("error must not be nil") + } + + // and + if got != nil { + t.Errorf("must be nil, but got %+v", got) + } + }) + + t.Run("when stored data is invalid format", func(t *testing.T) { + // given + id := job.ID(uuid.New()) + + // and + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + db := mock_job.NewMockLevelDB(ctrl) + db.EXPECT(). + Get(gomock.Eq([]byte(uuid.UUID(id).String())), gomock.Nil()). + Times(1). + Return([]byte("invalid format"), nil) + + // and + sut := &DataSource{} + defer sut.SetDB(db)() + + // when + got, err := sut.FindBy(id) + + // then + if err == nil { + t.Error("error must not be nil") + } + + // and + if got != nil { + t.Errorf("must be nil, but got %+v", got) + } + }) +} + +func TestDataSource_Save(t *testing.T) { + t.Run("when returns no error", func(t *testing.T) { + // given + id := job.ID(uuid.New()) + + // and + j := &job.Job{ + ID: id, + Finished: false, + Stream: []job.LogLine{{Timestamp: time.Now(), Message: "Hello Test"}}, + } + data, err := json.Marshal(j) + if err != nil { + t.Fatalf("error occurred: %+v", err) + } + + // and + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + db := mock_job.NewMockLevelDB(ctrl) + db.EXPECT(). + Put(gomock.Eq([]byte(uuid.UUID(id).String())), gomock.Eq(data), gomock.Nil()). + Times(1). + Return(nil) + + // and + sut := &DataSource{} + defer sut.SetDB(db)() + + // expect + if err := sut.Save(*j); err != nil { + t.Errorf("error must be nil, but got %+v", err) + } + }) + + t.Run("when returns error", func(t *testing.T) { + // given + id := job.ID(uuid.New()) + + // and + j := &job.Job{ + ID: id, + Finished: false, + Stream: []job.LogLine{{Timestamp: time.Now(), Message: "Hello Test"}}, + } + data, err := json.Marshal(j) + if err != nil { + t.Fatalf("error occurred: %+v", err) + } + + // and + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + db := mock_job.NewMockLevelDB(ctrl) + db.EXPECT(). + Put(gomock.Eq([]byte(uuid.UUID(id).String())), gomock.Eq(data), gomock.Nil()). + Times(1). + Return(errors.New("test error")) + + // and + sut := &DataSource{} + defer sut.SetDB(db)() + + // expect + if err := sut.Save(*j); err == nil { + t.Error("error must not be nil") + } + }) + +} diff --git a/infrastructure/job/export_test.go b/infrastructure/job/export_test.go new file mode 100644 index 00000000..bd59dd10 --- /dev/null +++ b/infrastructure/job/export_test.go @@ -0,0 +1,11 @@ +package job + +type DataSource = dataSource + +func (d *DataSource) SetDB(db LevelDB) (cancel func()) { + tmp := d.db + d.db = db + return func() { + d.db = tmp + } +} diff --git a/infrastructure/job/mock_job/third_pirty.go b/infrastructure/job/mock_job/third_pirty.go new file mode 100644 index 00000000..64f6e437 --- /dev/null +++ b/infrastructure/job/mock_job/third_pirty.go @@ -0,0 +1,59 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: infrastructure/job/third_pirty.go + +// Package mock_job is a generated GoMock package. +package mock_job + +import ( + gomock "github.com/golang/mock/gomock" + opt "github.com/syndtr/goleveldb/leveldb/opt" + reflect "reflect" +) + +// MockLevelDB is a mock of LevelDB interface +type MockLevelDB struct { + ctrl *gomock.Controller + recorder *MockLevelDBMockRecorder +} + +// MockLevelDBMockRecorder is the mock recorder for MockLevelDB +type MockLevelDBMockRecorder struct { + mock *MockLevelDB +} + +// NewMockLevelDB creates a new mock instance +func NewMockLevelDB(ctrl *gomock.Controller) *MockLevelDB { + mock := &MockLevelDB{ctrl: ctrl} + mock.recorder = &MockLevelDBMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use +func (m *MockLevelDB) EXPECT() *MockLevelDBMockRecorder { + return m.recorder +} + +// Get mocks base method +func (m *MockLevelDB) Get(key []byte, ro *opt.ReadOptions) ([]byte, error) { + ret := m.ctrl.Call(m, "Get", key, ro) + ret0, _ := ret[0].([]byte) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Get indicates an expected call of Get +func (mr *MockLevelDBMockRecorder) Get(key, ro interface{}) *gomock.Call { + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockLevelDB)(nil).Get), key, ro) +} + +// Put mocks base method +func (m *MockLevelDB) Put(key, value []byte, wo *opt.WriteOptions) error { + ret := m.ctrl.Call(m, "Put", key, value, wo) + ret0, _ := ret[0].(error) + return ret0 +} + +// Put indicates an expected call of Put +func (mr *MockLevelDBMockRecorder) Put(key, value, wo interface{}) *gomock.Call { + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Put", reflect.TypeOf((*MockLevelDB)(nil).Put), key, value, wo) +} diff --git a/infrastructure/job/third_pirty.go b/infrastructure/job/third_pirty.go new file mode 100644 index 00000000..9f5a3410 --- /dev/null +++ b/infrastructure/job/third_pirty.go @@ -0,0 +1,11 @@ +package job + +import ( + "github.com/syndtr/goleveldb/leveldb/opt" +) + +// LevelDB is a interface represents key-value store. +type LevelDB interface { + Get(key []byte, ro *opt.ReadOptions) (value []byte, err error) + Put(key, value []byte, wo *opt.WriteOptions) error +} diff --git a/infrastructure/logger/export_test.go b/infrastructure/logger/export_test.go deleted file mode 100644 index 13c4f7c2..00000000 --- a/infrastructure/logger/export_test.go +++ /dev/null @@ -1,11 +0,0 @@ -package logger - -import "time" - -func SetNowFunc(f func() time.Time) (reset func()) { - tmp := now - now = f - return func() { - now = tmp - } -} diff --git a/infrastructure/logger/logger.go b/infrastructure/logger/logger.go deleted file mode 100644 index c351f523..00000000 --- a/infrastructure/logger/logger.go +++ /dev/null @@ -1,58 +0,0 @@ -package logger - -import ( - "fmt" - "github.com/google/uuid" - "io" - "os" - "time" -) - -var ( - timeFormat = "2006-01-02 15:04:05.000" - // Writer is a log writer. default is os.Stdout. - Writer io.Writer = os.Stdout - now = time.Now -) - -// Debug logs with the Debug severity. -func Debug(uuid uuid.UUID, message string) { - if len(message) < 1 || message[len(message)-1] != '\n' { - message += "\n" - } - Writer.Write([]byte(fmt.Sprintf("[%s] %s \033[36;1m[DEBUG]\033[0m %s", uuid, now().Format(timeFormat), message))) -} - -// Debugf logs with the Debug severity. -func Debugf(uuid uuid.UUID, format string, args ...interface{}) { - message := fmt.Sprintf(format, args...) - Debug(uuid, message) -} - -// Info logs with the Info severity. -func Info(uuid uuid.UUID, message string) { - if len(message) < 1 || message[len(message)-1] != '\n' { - message += "\n" - } - Writer.Write([]byte(fmt.Sprintf("[%s] %s \033[1m[INFO]\033[0m %s", uuid, now().Format(timeFormat), message))) -} - -// Infof logs with the Info severity. -func Infof(uuid uuid.UUID, format string, args ...interface{}) { - message := fmt.Sprintf(format, args...) - Info(uuid, message) -} - -// Error logs with the Error severity. -func Error(uuid uuid.UUID, message string) { - if len(message) < 1 || message[len(message)-1] != '\n' { - message += "\n" - } - Writer.Write([]byte(fmt.Sprintf("[%s] %s \033[41;1m[ERROR]\033[0m %s", uuid, now().Format(timeFormat), message))) -} - -// Errorf logs with the Error severity. -func Errorf(uuid uuid.UUID, format string, args ...interface{}) { - message := fmt.Sprintf(format, args...) - Error(uuid, message) -} diff --git a/infrastructure/logger/logger_test.go b/infrastructure/logger/logger_test.go deleted file mode 100644 index 06b2e0ea..00000000 --- a/infrastructure/logger/logger_test.go +++ /dev/null @@ -1,193 +0,0 @@ -package logger_test - -import ( - "github.com/duck8823/duci/infrastructure/logger" - "github.com/google/uuid" - "io" - "io/ioutil" - "os" - "strings" - "testing" - "time" -) - -var ( - reader io.ReadCloser - writer io.WriteCloser -) - -func TestDebug(t *testing.T) { - // setup - initLogger(t) - - // and - jst, err := time.LoadLocation("Asia/Tokyo") - if err != nil { - t.Fatalf("error occurred: %+v", err) - } - reset := logger.SetNowFunc(func() time.Time { - return time.Date(1987, time.March, 27, 19, 19, 00, 00, jst) - }) - defer reset() - - // when - logger.Debug(uuid.UUID{}, "Hello World.") - - actual := readLog(t) - expected := "[00000000-0000-0000-0000-000000000000] 1987-03-27 19:19:00.000 \033[36;1m[DEBUG]\033[0m Hello World." - - // then - if actual != expected { - t.Errorf("wrong logstore. wont: \"%+v\", got: \"%+v\"", expected, actual) - } -} - -func TestDebugf(t *testing.T) { - // setup - initLogger(t) - - // and - jst, err := time.LoadLocation("Asia/Tokyo") - if err != nil { - t.Fatalf("error occurred: %+v", err) - } - logger.SetNowFunc(func() time.Time { - return time.Date(1987, time.March, 27, 19, 19, 00, 00, jst) - }) - defer logger.SetNowFunc(time.Now) - - // when - logger.Debugf(uuid.UUID{}, "Hello %s.", "World") - - actual := readLog(t) - expected := "[00000000-0000-0000-0000-000000000000] 1987-03-27 19:19:00.000 \033[36;1m[DEBUG]\033[0m Hello World." - - // then - if actual != expected { - t.Errorf("wrong logstore. wont: \"%+v\", got: \"%+v\"", expected, actual) - } -} - -func TestInfo(t *testing.T) { - // setup - initLogger(t) - - // and - jst, err := time.LoadLocation("Asia/Tokyo") - if err != nil { - t.Fatalf("error occurred: %+v", err) - } - logger.SetNowFunc(func() time.Time { - return time.Date(1987, time.March, 27, 19, 19, 00, 00, jst) - }) - defer logger.SetNowFunc(time.Now) - - // when - logger.Info(uuid.UUID{}, "Hello World.") - - actual := readLog(t) - expected := "[00000000-0000-0000-0000-000000000000] 1987-03-27 19:19:00.000 \033[1m[INFO]\033[0m Hello World." - - // then - if actual != expected { - t.Errorf("wrong logstore. wont: \"%+v\", got: \"%+v\"", expected, actual) - } -} - -func TestInfof(t *testing.T) { - // setup - initLogger(t) - - // and - jst, err := time.LoadLocation("Asia/Tokyo") - if err != nil { - t.Fatalf("error occurred: %+v", err) - } - logger.SetNowFunc(func() time.Time { - return time.Date(1987, time.March, 27, 19, 19, 00, 00, jst) - }) - defer logger.SetNowFunc(time.Now) - - // when - logger.Infof(uuid.UUID{}, "Hello %s.", "World") - - actual := readLog(t) - expected := "[00000000-0000-0000-0000-000000000000] 1987-03-27 19:19:00.000 \033[1m[INFO]\033[0m Hello World." - - // then - if actual != expected { - t.Errorf("wrong logstore. wont: \"%+v\", got: \"%+v\"", expected, actual) - } -} - -func TestError(t *testing.T) { - // setup - initLogger(t) - - // and - jst, err := time.LoadLocation("Asia/Tokyo") - if err != nil { - t.Fatalf("error occurred: %+v", err) - } - logger.SetNowFunc(func() time.Time { - return time.Date(1987, time.March, 27, 19, 19, 00, 00, jst) - }) - defer logger.SetNowFunc(time.Now) - - // when - logger.Error(uuid.UUID{}, "Hello World.") - - actual := readLog(t) - expected := "[00000000-0000-0000-0000-000000000000] 1987-03-27 19:19:00.000 \033[41;1m[ERROR]\033[0m Hello World." - - // then - if actual != expected { - t.Errorf("wrong logstore. wont: \"%+v\", got: \"%+v\"", expected, actual) - } -} - -func TestErrorf(t *testing.T) { - // setup - initLogger(t) - - // and - jst, err := time.LoadLocation("Asia/Tokyo") - if err != nil { - t.Fatalf("error occurred: %+v", err) - } - logger.SetNowFunc(func() time.Time { - return time.Date(1987, time.March, 27, 19, 19, 00, 00, jst) - }) - defer logger.SetNowFunc(time.Now) - - // when - logger.Errorf(uuid.UUID{}, "Hello %s.", "World") - - actual := readLog(t) - expected := "[00000000-0000-0000-0000-000000000000] 1987-03-27 19:19:00.000 \033[41;1m[ERROR]\033[0m Hello World." - - // then - if actual != expected { - t.Errorf("wrong logstore. wont: \"%+v\", got: \"%+v\"", expected, actual) - } -} - -func initLogger(t *testing.T) { - t.Helper() - - reader, writer, _ = os.Pipe() - - logger.Writer = writer -} - -func readLog(t *testing.T) string { - t.Helper() - - writer.Close() - log, err := ioutil.ReadAll(reader) - if err != nil { - t.Error() - } - - return strings.TrimRight(string(log), "\n") -} diff --git a/infrastructure/store/mock_store/store.go b/infrastructure/store/mock_store/store.go deleted file mode 100644 index 900174ad..00000000 --- a/infrastructure/store/mock_store/store.go +++ /dev/null @@ -1,84 +0,0 @@ -// Code generated by MockGen. DO NOT EDIT. -// Source: infrastructure/store/store.go - -// Package mock_store is a generated GoMock package. -package mock_store - -import ( - store "github.com/duck8823/duci/infrastructure/store" - gomock "github.com/golang/mock/gomock" - reflect "reflect" -) - -// MockStore is a mock of Store interface -type MockStore struct { - ctrl *gomock.Controller - recorder *MockStoreMockRecorder -} - -// MockStoreMockRecorder is the mock recorder for MockStore -type MockStoreMockRecorder struct { - mock *MockStore -} - -// NewMockStore creates a new mock instance -func NewMockStore(ctrl *gomock.Controller) *MockStore { - mock := &MockStore{ctrl: ctrl} - mock.recorder = &MockStoreMockRecorder{mock} - return mock -} - -// EXPECT returns an object that allows the caller to indicate expected use -func (m *MockStore) EXPECT() *MockStoreMockRecorder { - return m.recorder -} - -// Get mocks base method -func (m *MockStore) Get(key []byte, ro *store.ReadOptions) ([]byte, error) { - ret := m.ctrl.Call(m, "Get", key, ro) - ret0, _ := ret[0].([]byte) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// Get indicates an expected call of Get -func (mr *MockStoreMockRecorder) Get(key, ro interface{}) *gomock.Call { - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockStore)(nil).Get), key, ro) -} - -// Has mocks base method -func (m *MockStore) Has(key []byte, ro *store.ReadOptions) (bool, error) { - ret := m.ctrl.Call(m, "Has", key, ro) - ret0, _ := ret[0].(bool) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// Has indicates an expected call of Has -func (mr *MockStoreMockRecorder) Has(key, ro interface{}) *gomock.Call { - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Has", reflect.TypeOf((*MockStore)(nil).Has), key, ro) -} - -// Put mocks base method -func (m *MockStore) Put(key, value []byte, wo *store.WriteOptions) error { - ret := m.ctrl.Call(m, "Put", key, value, wo) - ret0, _ := ret[0].(error) - return ret0 -} - -// Put indicates an expected call of Put -func (mr *MockStoreMockRecorder) Put(key, value, wo interface{}) *gomock.Call { - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Put", reflect.TypeOf((*MockStore)(nil).Put), key, value, wo) -} - -// Close mocks base method -func (m *MockStore) Close() error { - ret := m.ctrl.Call(m, "Close") - ret0, _ := ret[0].(error) - return ret0 -} - -// Close indicates an expected call of Close -func (mr *MockStoreMockRecorder) Close() *gomock.Call { - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Close", reflect.TypeOf((*MockStore)(nil).Close)) -} diff --git a/infrastructure/store/store.go b/infrastructure/store/store.go deleted file mode 100644 index c14e9d0b..00000000 --- a/infrastructure/store/store.go +++ /dev/null @@ -1,25 +0,0 @@ -package store - -import ( - leveldb_errors "github.com/syndtr/goleveldb/leveldb/errors" - "github.com/syndtr/goleveldb/leveldb/opt" -) - -var ( - // NotFoundError is a leveldb/errors.ErrNotFound - NotFoundError = leveldb_errors.ErrNotFound -) - -// ReadOptions is a type alias of leveldb/opt.ReadOptions -type ReadOptions = opt.ReadOptions - -// WriteOptions is a type alias of leveldb/opt.WriteOptions -type WriteOptions = opt.WriteOptions - -// Store is a interface represents key-value store. -type Store interface { - Get(key []byte, ro *ReadOptions) (value []byte, err error) - Has(key []byte, ro *ReadOptions) (ret bool, err error) - Put(key, value []byte, wo *WriteOptions) error - Close() error -} diff --git a/internal/container/container.go b/internal/container/container.go new file mode 100644 index 00000000..c53e7f23 --- /dev/null +++ b/internal/container/container.go @@ -0,0 +1,83 @@ +package container + +import ( + "fmt" + "reflect" +) + +type singletonContainer struct { + values map[string]interface{} +} + +var instance *singletonContainer + +func init() { + instance = &singletonContainer{ + make(map[string]interface{}), + } +} + +// Submit an instance to container +func Submit(val interface{}) error { + var key string + if reflect.TypeOf(val).Kind() == reflect.Ptr { + key = reflect.Indirect(reflect.ValueOf(val)).Type().String() + } else { + key = reflect.ValueOf(val).Type().String() + } + + if instance.values[key] != nil { + return fmt.Errorf("already submitted such type of %s", key) + } + + instance.values[key] = val + return nil +} + +// Get an instance from container +func Get(ptr interface{}) error { + val := reflect.ValueOf(ptr) + key := reflect.Indirect(val).Type().String() + component := instance.values[key] + if component == nil { + if reflect.Indirect(val).Type().Kind() != reflect.Interface { + return fmt.Errorf("component not found. such type of %s", key) + } + for _, component := range instance.values { + value := reflect.ValueOf(component) + elm := reflect.ValueOf(ptr).Elem() + if value.Type().Implements(elm.Type()) { + elm.Set(value) + return nil + } + } + return fmt.Errorf("component not found. such type of %s", key) + } + + elm := reflect.ValueOf(ptr).Elem() + if reflect.TypeOf(component).Kind() == reflect.Ptr { + elm.Set(reflect.Indirect(reflect.ValueOf(component))) + } else { + elm.Set(reflect.ValueOf(component)) + } + return nil +} + +// Override an instance to container +// TODO: should not use in production code +func Override(val interface{}) { + var key string + if reflect.TypeOf(val).Kind() == reflect.Ptr { + key = reflect.Indirect(reflect.ValueOf(val)).Type().String() + } else { + key = reflect.ValueOf(val).Type().String() + } + + instance.values[key] = val +} + +// Clear instances in container +// TODO: should not use in production code +func Clear() { + instance.values = make(map[string]interface{}) +} diff --git a/internal/container/container_test.go b/internal/container/container_test.go new file mode 100644 index 00000000..e1c53209 --- /dev/null +++ b/internal/container/container_test.go @@ -0,0 +1,193 @@ +package container_test + +import ( + "fmt" + "github.com/duck8823/duci/internal/container" + "github.com/google/go-cmp/cmp" + "reflect" + "testing" +) + +type testInterface interface { + Hoge() +} + +type testImpl struct { + Name string +} + +func (*testImpl) Hoge() {} + +type testInterfaceNothing interface { + Fuga() +} + +type hoge string + +func TestSubmit(t *testing.T) { + // given + ins := &container.SingletonContainer{} + defer ins.SetValues(map[string]interface{}{})() + defer container.SetInstance(ins)() + + // and + want := map[string]interface{}{ + "string": "test", + } + + // when + err := container.Submit("test") + + // then + if err != nil { + t.Errorf("must be nil, but got %+v", err) + } + + // and + if !cmp.Equal(ins.GetValues(), want) { + t.Errorf("must be equal, but %+v", cmp.Diff(ins.GetValues(), want)) + } + + // when + err = container.Submit("twice") + + // then + if err == nil { + t.Error("must not be nil") + } + + // and + if !cmp.Equal(ins.GetValues(), want) { + t.Errorf("must be equal, but %+v", cmp.Diff(ins.GetValues(), want)) + } + + // when + str := "ptr tri" + err = container.Submit(&str) + + // then + if err == nil { + t.Error("must not be nil") + } + + // and + if !cmp.Equal(ins.GetValues(), want) { + t.Errorf("must be equal, but %+v", cmp.Diff(ins.GetValues(), want)) + } + +} + +func TestGet(t *testing.T) { + // given + ins := &container.SingletonContainer{} + defer ins.SetValues(map[string]interface{}{ + "string": "value", + "int": 1234, + "float64": 12.34, + "container_test.testImpl": &testImpl{Name: "hoge"}, + })() + defer container.SetInstance(ins)() + + // where + for _, tt := range []struct { + in interface{} + want interface{} + err bool + }{ + { + in: new(string), + want: "value", + }, + { + in: new(int), + want: 1234, + }, + { + in: new(float64), + want: 12.34, + }, + { + in: new(hoge), + want: hoge(""), + err: true, + }, + { + in: new(testImpl), + want: testImpl{Name: "hoge"}, + }, + { + in: new(testInterface), + want: &testImpl{Name: "hoge"}, + }, + { + in: new(testInterfaceNothing), + err: true, + }, + } { + t.Run(fmt.Sprintf("type=%s", reflect.TypeOf(tt.in).String()), func(t *testing.T) { + // when + err := container.Get(tt.in) + got := Value(tt.in) + + // then + if tt.err && err == nil { + t.Error("must not be nil") + } + if !tt.err && err != nil { + t.Errorf("must be nil, but got %+v", err) + } + + // and + if !cmp.Equal(got, tt.want) { + t.Errorf("must be equal, but %+v", cmp.Diff(got, tt.want)) + } + }) + } +} + +func TestOverride(t *testing.T) { + // given + ins := &container.SingletonContainer{} + defer ins.SetValues(map[string]interface{}{ + "string": "hoge", + })() + defer container.SetInstance(ins)() + + // and + want := map[string]interface{}{ + "string": "test", + } + + // when + container.Override("test") + + // then + if !cmp.Equal(ins.GetValues(), want) { + t.Errorf("must be equal, but %+v", cmp.Diff(ins.GetValues(), want)) + } + +} + +func TestClear(t *testing.T) { + // given + want := make(map[string]interface{}) + + // and + ins := &container.SingletonContainer{} + defer ins.SetValues(map[string]interface{}{ + "string": "hoge", + })() + defer container.SetInstance(ins)() + + // when + container.Clear() + + // then + if !cmp.Equal(ins.GetValues(), want) { + t.Errorf("must be equal, but %+v", cmp.Diff(ins.GetValues(), want)) + } +} + +func Value(v interface{}) interface{} { + return reflect.Indirect(reflect.ValueOf(v)).Interface() +} diff --git a/internal/container/export_test.go b/internal/container/export_test.go new file mode 100644 index 00000000..d173e1e1 --- /dev/null +++ b/internal/container/export_test.go @@ -0,0 +1,23 @@ +package container + +type SingletonContainer = singletonContainer + +func (s *SingletonContainer) SetValues(values map[string]interface{}) (reset func()) { + tmp := s.values + s.values = values + return func() { + s.values = tmp + } +} + +func (s *SingletonContainer) GetValues() map[string]interface{} { + return s.values +} + +func SetInstance(ins *singletonContainer) (reset func()) { + tmp := instance + instance = ins + return func() { + instance = tmp + } +} diff --git a/internal/logger/logger.go b/internal/logger/logger.go new file mode 100644 index 00000000..c5e8c4b8 --- /dev/null +++ b/internal/logger/logger.go @@ -0,0 +1,11 @@ +package logger + +import ( + "fmt" + "os" +) + +// Error print stack to stderr +func Error(err error) { + _, _ = os.Stderr.WriteString(fmt.Sprintf("%+v", err)) +} diff --git a/main.go b/main.go index d71b1ebe..af0d184e 100644 --- a/main.go +++ b/main.go @@ -1,7 +1,7 @@ package main import ( - "github.com/duck8823/duci/application/cmd" + "github.com/duck8823/duci/presentation/cmd" "os" ) diff --git a/application/cmd/config.go b/presentation/cmd/config.go similarity index 73% rename from application/cmd/config.go rename to presentation/cmd/config.go index b86ca8a1..19529dca 100644 --- a/application/cmd/config.go +++ b/presentation/cmd/config.go @@ -2,9 +2,8 @@ package cmd import ( "encoding/json" + "fmt" "github.com/duck8823/duci/application" - "github.com/duck8823/duci/infrastructure/logger" - "github.com/google/uuid" "github.com/spf13/cobra" "os" ) @@ -17,7 +16,7 @@ func displayConfig(cmd *cobra.Command, _ []string) { enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") if err := enc.Encode(application.Config); err != nil { - logger.Errorf(uuid.New(), "Failed to display config.\n%+v", err) + println(fmt.Sprintf("Failed to display config.\n%+v", err)) os.Exit(1) } } diff --git a/presentation/cmd/health.go b/presentation/cmd/health.go new file mode 100644 index 00000000..ff5277e7 --- /dev/null +++ b/presentation/cmd/health.go @@ -0,0 +1,31 @@ +package cmd + +import ( + "fmt" + "github.com/duck8823/duci/domain/model/docker" + "github.com/spf13/cobra" + "os" +) + +var healthCmd = createCmd("health", "Health check", healthCheck) + +func healthCheck(cmd *cobra.Command, _ []string) { + readConfiguration(cmd) + + cli, err := docker.New() + if err != nil { + msg := fmt.Sprintf("Failed to set configuration.\n%+v", err) + if _, err := fmt.Fprint(os.Stderr, msg); err != nil { + println(err) + } + os.Exit(1) + } + + if err := cli.Status(); err != nil { + println(fmt.Sprintf("Unhealth\n%s", err)) + os.Exit(1) + } else { + println("ok") + os.Exit(0) + } +} diff --git a/application/cmd/root.go b/presentation/cmd/root.go similarity index 87% rename from application/cmd/root.go rename to presentation/cmd/root.go index 28225dd9..daf30e7e 100644 --- a/application/cmd/root.go +++ b/presentation/cmd/root.go @@ -3,8 +3,6 @@ package cmd import ( "fmt" "github.com/duck8823/duci/application" - "github.com/duck8823/duci/infrastructure/logger" - "github.com/google/uuid" "github.com/spf13/cobra" "os" ) @@ -15,6 +13,7 @@ func init() { rootCmd.AddCommand(serverCmd, configCmd, healthCmd, versionCmd) } +// Execute command func Execute(args []string) { rootCmd.SetArgs(args) if err := rootCmd.Execute(); err != nil { @@ -42,7 +41,7 @@ func readConfiguration(cmd *cobra.Command) { } if err := application.Config.Set(configFilePath); err != nil { - logger.Errorf(uuid.New(), "Failed to set configuration.\n%+v", err) + println(fmt.Sprintf("Failed to set configuration.\n%+v", err)) os.Exit(1) } } diff --git a/application/cmd/server.go b/presentation/cmd/server.go similarity index 69% rename from application/cmd/server.go rename to presentation/cmd/server.go index 8421196f..4edb1428 100644 --- a/application/cmd/server.go +++ b/presentation/cmd/server.go @@ -1,11 +1,10 @@ package cmd import ( + "fmt" "github.com/duck8823/duci/application" "github.com/duck8823/duci/application/semaphore" - "github.com/duck8823/duci/infrastructure/logger" "github.com/duck8823/duci/presentation/router" - "github.com/google/uuid" "github.com/spf13/cobra" "net/http" "os" @@ -27,22 +26,28 @@ var ( func runServer(cmd *cobra.Command, _ []string) { readConfiguration(cmd) + if err := application.Initialize(); err != nil { + println(fmt.Sprintf("Failed to initialize a semaphore.\n%+v", err)) + os.Exit(1) + return + } + if err := semaphore.Make(); err != nil { - logger.Errorf(uuid.New(), "Failed to initialize a semaphore.\n%+v", err) + println(fmt.Sprintf("Failed to initialize a semaphore.\n%+v", err)) os.Exit(1) return } rtr, err := router.New() if err != nil { - logger.Errorf(uuid.New(), "Failed to initialize controllers.\n%+v", err) + println(fmt.Sprintf("Failed to initialize controllers.\n%+v", err)) os.Exit(1) return } println(logo) if err := http.ListenAndServe(application.Config.Addr(), rtr); err != nil { - logger.Errorf(uuid.New(), "Failed to run server.\n%+v", err) + println(fmt.Sprintf("Failed to run server.\n%+v", err)) os.Exit(1) return } diff --git a/application/cmd/version.go b/presentation/cmd/version.go similarity index 100% rename from application/cmd/version.go rename to presentation/cmd/version.go diff --git a/presentation/controller/health.go b/presentation/controller/health.go deleted file mode 100644 index 1d0c5945..00000000 --- a/presentation/controller/health.go +++ /dev/null @@ -1,20 +0,0 @@ -package controller - -import ( - "github.com/duck8823/duci/application/service/docker" - "net/http" -) - -// HealthController is a handler of health check. -type HealthController struct { - Docker docker.Service -} - -// ServeHTTP responses a server status -func (c *HealthController) ServeHTTP(w http.ResponseWriter, r *http.Request) { - if err := c.Docker.Status(); err != nil { - w.WriteHeader(http.StatusInternalServerError) - return - } - w.WriteHeader(http.StatusOK) -} diff --git a/presentation/controller/health/export_test.go b/presentation/controller/health/export_test.go new file mode 100644 index 00000000..c1044e30 --- /dev/null +++ b/presentation/controller/health/export_test.go @@ -0,0 +1,13 @@ +package health + +import "github.com/duck8823/duci/domain/model/docker" + +type Handler = handler + +func (c *Handler) SetDocker(docker docker.Docker) (reset func()) { + tmp := c.docker + c.docker = docker + return func() { + c.docker = tmp + } +} diff --git a/presentation/controller/health/handler.go b/presentation/controller/health/handler.go new file mode 100644 index 00000000..24963fc4 --- /dev/null +++ b/presentation/controller/health/handler.go @@ -0,0 +1,29 @@ +package health + +import ( + "github.com/duck8823/duci/domain/model/docker" + "github.com/pkg/errors" + "net/http" +) + +type handler struct { + docker docker.Docker +} + +// NewHandler returns implement of health check handler +func NewHandler() (http.Handler, error) { + docker, err := docker.New() + if err != nil { + return nil, errors.WithStack(err) + } + return &handler{docker: docker}, nil +} + +// ServeHTTP responses a server status +func (c *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if err := c.docker.Status(); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusOK) +} diff --git a/presentation/controller/health/handler_test.go b/presentation/controller/health/handler_test.go new file mode 100644 index 00000000..162c0afa --- /dev/null +++ b/presentation/controller/health/handler_test.go @@ -0,0 +1,125 @@ +package health_test + +import ( + "github.com/duck8823/duci/domain/model/docker" + "github.com/duck8823/duci/domain/model/docker/mock_docker" + "github.com/duck8823/duci/presentation/controller/health" + "github.com/golang/mock/gomock" + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/pkg/errors" + "net/http" + "net/http/httptest" + "os" + "testing" +) + +func TestNewHandler(t *testing.T) { + t.Run("with default", func(t *testing.T) { + // given + defaultDocker, err := docker.New() + if err != nil { + t.Fatalf("error occur: %+v", err) + } + + // and + want := &health.Handler{} + defer want.SetDocker(defaultDocker)() + + // when + got, err := health.NewHandler() + + // then + if err != nil { + t.Errorf("error must be nil, but got %+v", err) + } + + // and + opts := cmp.Options{ + cmp.AllowUnexported(health.Handler{}), + cmpopts.IgnoreInterfaces(struct{ docker.Moby }{}), + } + if !cmp.Equal(got, want, opts) { + t.Errorf("must be equal, but: %+v", cmp.Diff(got, want, opts)) + } + }) + + t.Run("with invalid environment variable", func(t *testing.T) { + // given + DOCKER_HOST := os.Getenv("DOCKER_HOST") + if err := os.Setenv("DOCKER_HOST", "invalid host"); err != nil { + t.Fatalf("error occur: %+v", err) + } + defer os.Setenv("DOCKER_HOST", DOCKER_HOST) + + // when + got, err := health.NewHandler() + + // then + if err == nil { + t.Error("error must not be nil") + } + + // and + if got != nil { + t.Errorf("must be nil, but got %+v", got) + } + }) + +} + +func TestHandler_ServeHTTP(t *testing.T) { + t.Run("when docker status returns no error", func(t *testing.T) { + // given + rec := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/", nil) + + // and + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + docker := mock_docker.NewMockDocker(ctrl) + docker.EXPECT(). + Status(). + Return(nil) + + // and + sut := &health.Handler{} + defer sut.SetDocker(docker)() + + // when + sut.ServeHTTP(rec, req) + + // then + if rec.Code != http.StatusOK { + t.Errorf("must be %d, but got %d", http.StatusOK, rec.Code) + } + }) + + t.Run("when docker status returns error", func(t *testing.T) { + // given + rec := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/", nil) + + // and + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + docker := mock_docker.NewMockDocker(ctrl) + docker.EXPECT(). + Status(). + Return(errors.New("test error")) + + // and + sut := &health.Handler{} + defer sut.SetDocker(docker)() + + // when + sut.ServeHTTP(rec, req) + + // then + if rec.Code != http.StatusInternalServerError { + t.Errorf("response code must be %d, but got %d", http.StatusInternalServerError, rec.Code) + } + }) +} diff --git a/presentation/controller/health_test.go b/presentation/controller/health_test.go deleted file mode 100644 index 7f87c34c..00000000 --- a/presentation/controller/health_test.go +++ /dev/null @@ -1,71 +0,0 @@ -package controller_test - -import ( - "github.com/duck8823/duci/application/service/docker/mock_docker" - "github.com/duck8823/duci/presentation/controller" - "github.com/golang/mock/gomock" - "github.com/pkg/errors" - "net/http/httptest" - "testing" -) - -func TestHealthCheckController_ServeHTTP(t *testing.T) { - t.Run("without error", func(t *testing.T) { - // given - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - mockService := mock_docker.NewMockService(ctrl) - mockService.EXPECT(). - Status(). - Return(nil) - - handler := &controller.HealthController{Docker: mockService} - - // and - s := httptest.NewServer(handler) - defer s.Close() - - // and - req := httptest.NewRequest("GET", "/health", nil) - rec := httptest.NewRecorder() - - // when - handler.ServeHTTP(rec, req) - - // then - if rec.Code != 200 { - t.Errorf("status code must be 200, but got %+v", rec.Code) - } - }) - - t.Run("with error", func(t *testing.T) { - // given - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - mockService := mock_docker.NewMockService(ctrl) - mockService.EXPECT(). - Status(). - Return(errors.New("test")) - - handler := &controller.HealthController{Docker: mockService} - - // and - s := httptest.NewServer(handler) - defer s.Close() - - // and - req := httptest.NewRequest("GET", "/health", nil) - rec := httptest.NewRecorder() - - // when - handler.ServeHTTP(rec, req) - - // then - if rec.Code != 500 { - t.Errorf("status code must be 500, but got %+v", rec.Code) - } - }) - -} diff --git a/presentation/controller/job/export_test.go b/presentation/controller/job/export_test.go new file mode 100644 index 00000000..34531bcd --- /dev/null +++ b/presentation/controller/job/export_test.go @@ -0,0 +1,13 @@ +package job + +import "github.com/duck8823/duci/application/service/job" + +type Handler = handler + +func (h *Handler) SetService(service job.Service) (reset func()) { + tmp := h.service + h.service = service + return func() { + h.service = tmp + } +} diff --git a/presentation/controller/job/handler.go b/presentation/controller/job/handler.go new file mode 100644 index 00000000..8ac9c5c7 --- /dev/null +++ b/presentation/controller/job/handler.go @@ -0,0 +1,69 @@ +package job + +import ( + "encoding/json" + "fmt" + jobService "github.com/duck8823/duci/application/service/job" + "github.com/duck8823/duci/domain/model/job" + "github.com/duck8823/duci/internal/logger" + "github.com/go-chi/chi" + "github.com/google/uuid" + "github.com/pkg/errors" + "net/http" +) + +type handler struct { + service jobService.Service +} + +// NewHandler returns implement of job +func NewHandler() (http.Handler, error) { + service, err := jobService.GetInstance() + if err != nil { + return nil, errors.WithStack(err) + } + return &handler{service: service}, nil +} + +// ServeHTTP responses log stream +func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + + id, err := uuid.Parse(chi.URLParam(r, "uuid")) + if err != nil { + http.Error(w, fmt.Sprintf("Error occurred: %s", err.Error()), http.StatusInternalServerError) + return + } + + if err := h.logs(w, job.ID(id)); err != nil { + http.Error(w, fmt.Sprintf(" Error occurred: %s", err.Error()), http.StatusInternalServerError) + return + } +} + +func (h *handler) logs(w http.ResponseWriter, id job.ID) error { + f, ok := w.(http.Flusher) + if !ok { + return errors.New("Streaming unsupported") + } + + // TODO: add timeout + var read int + for { + job, err := h.service.FindBy(id) + if err != nil { + return errors.WithStack(err) + } + for _, msg := range job.Stream[read:] { + if err := json.NewEncoder(w).Encode(msg); err != nil { + logger.Error(err) + } + f.Flush() + read++ + } + if job.Finished { + break + } + } + return nil +} diff --git a/presentation/controller/job/handler_test.go b/presentation/controller/job/handler_test.go new file mode 100644 index 00000000..2edf8c27 --- /dev/null +++ b/presentation/controller/job/handler_test.go @@ -0,0 +1,167 @@ +package job_test + +import ( + "context" + jobService "github.com/duck8823/duci/application/service/job" + "github.com/duck8823/duci/application/service/job/mock_job" + "github.com/duck8823/duci/domain/model/job" + "github.com/duck8823/duci/internal/container" + jobController "github.com/duck8823/duci/presentation/controller/job" + "github.com/go-chi/chi" + "github.com/golang/mock/gomock" + "github.com/google/go-cmp/cmp" + "github.com/google/uuid" + "github.com/pkg/errors" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestNewHandler(t *testing.T) { + t.Run("when there is service in container", func(t *testing.T) { + // given + service := new(jobService.Service) + + container.Override(service) + defer container.Clear() + + // and + want := &jobController.Handler{} + defer want.SetService(*service)() + + // when + got, err := jobController.NewHandler() + + // then + if err != nil { + t.Errorf("error must be nil, but got %+v", err) + } + + // and + opts := cmp.Options{ + cmp.AllowUnexported(jobController.Handler{}), + } + if !cmp.Equal(got, want, opts) { + t.Errorf("must be equal, but %+v", cmp.Diff(got, want, opts)) + } + }) + + t.Run("when there are no service in container", func(t *testing.T) { + // given + container.Clear() + + // when + got, err := jobController.NewHandler() + + // then + if err == nil { + t.Error("error must not be nil") + } + + // and + if got != nil { + t.Errorf("must be nil, but got %+v", got) + } + + }) +} + +func TestHandler_ServeHTTP(t *testing.T) { + t.Run("without error", func(t *testing.T) { + // given + rec := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/", nil) + + // and + id := job.ID(uuid.New()) + + routeCtx := chi.NewRouteContext() + routeCtx.URLParams.Add("uuid", uuid.UUID(id).String()) + ctx := context.WithValue(context.Background(), chi.RouteCtxKey, routeCtx) + + // and + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + service := mock_job_service.NewMockService(ctrl) + service.EXPECT(). + FindBy(gomock.Eq(id)). + Times(1). + Return(&job.Job{ + ID: id, + Finished: true, + Stream: []job.LogLine{ + {Timestamp: time.Now(), Message: "Hello Test"}, + }, + }, nil) + + // and + sut := &jobController.Handler{} + defer sut.SetService(service)() + + // when + sut.ServeHTTP(rec, req.WithContext(ctx)) + + // then + if rec.Code != http.StatusOK { + t.Errorf("must be %d, but got %d", http.StatusOK, rec.Code) + } + }) + + t.Run("with invalid path param", func(t *testing.T) { + // given + rec := httptest.NewRecorder() + + routeCtx := chi.NewRouteContext() + routeCtx.URLParams.Add("uuid", "") + ctx := context.WithValue(context.Background(), chi.RouteCtxKey, routeCtx) + req := httptest.NewRequest("GET", "/", nil).WithContext(ctx) + + // and + sut := &jobController.Handler{} + + // when + sut.ServeHTTP(rec, req) + + // then + if rec.Code != http.StatusInternalServerError { + t.Errorf("must be %d, but got %d", http.StatusInternalServerError, rec.Code) + } + }) + + t.Run("when service returns error", func(t *testing.T) { + // given + rec := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/", nil) + + // and + id := job.ID(uuid.New()) + + routeCtx := chi.NewRouteContext() + routeCtx.URLParams.Add("uuid", uuid.UUID(id).String()) + ctx := context.WithValue(context.Background(), chi.RouteCtxKey, routeCtx) + + // and + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + service := mock_job_service.NewMockService(ctrl) + service.EXPECT(). + FindBy(gomock.Eq(id)). + Times(1). + Return(nil, errors.New("test error")) + + // and + sut := &jobController.Handler{} + defer sut.SetService(service)() + + // when + sut.ServeHTTP(rec, req.WithContext(ctx)) + + // then + if rec.Code != http.StatusInternalServerError { + t.Errorf("response code must be %d, but got %d", http.StatusInternalServerError, rec.Code) + } + }) +} diff --git a/presentation/controller/logs.go b/presentation/controller/logs.go deleted file mode 100644 index ff15cc5d..00000000 --- a/presentation/controller/logs.go +++ /dev/null @@ -1,59 +0,0 @@ -package controller - -import ( - "encoding/json" - "fmt" - "github.com/duck8823/duci/application/service/logstore" - "github.com/duck8823/duci/data/model" - "github.com/go-chi/chi" - "github.com/google/uuid" - "github.com/pkg/errors" - "net/http" -) - -// LogController is a handler of stored log. -type LogController struct { - LogStore logstore.Service -} - -// ServeHTTP responses a log of specific uuid. -func (c *LogController) ServeHTTP(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Access-Control-Allow-Origin", "*") - flusher, ok := w.(http.Flusher) - if !ok { - http.Error(w, "Streaming unsupported!", http.StatusInternalServerError) - return - } - - id, err := uuid.Parse(chi.URLParam(r, "uuid")) - if err != nil { - http.Error(w, fmt.Sprintf("Error occurred: %s", err.Error()), http.StatusInternalServerError) - return - } - - if err := c.logs(w, flusher, id); err != nil { - http.Error(w, fmt.Sprintf("Sorry, Error occurred: %s", err.Error()), http.StatusInternalServerError) - return - } -} - -func (c *LogController) logs(w http.ResponseWriter, f http.Flusher, id uuid.UUID) error { - var read int - var job *model.Job - var err error - for { - job, err = c.LogStore.Get(id) - if err != nil { - return errors.WithStack(err) - } - for _, msg := range job.Stream[read:] { - json.NewEncoder(w).Encode(msg) - f.Flush() - read++ - } - if job.Finished { - break - } - } - return nil -} diff --git a/presentation/controller/logs_test.go b/presentation/controller/logs_test.go deleted file mode 100644 index c96ae0ef..00000000 --- a/presentation/controller/logs_test.go +++ /dev/null @@ -1,119 +0,0 @@ -package controller_test - -import ( - ctx "context" - "github.com/duck8823/duci/application/service/logstore/mock_logstore" - "github.com/duck8823/duci/data/model" - "github.com/duck8823/duci/presentation/controller" - "github.com/go-chi/chi" - "github.com/golang/mock/gomock" - "github.com/google/uuid" - "github.com/pkg/errors" - "net/http/httptest" - "testing" - "time" -) - -func TestLogsController_ServeHTTP(t *testing.T) { - t.Run("with valid uuid", func(t *testing.T) { - // setup - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - mockService := mock_logstore.NewMockService(ctrl) - handler := &controller.LogController{LogStore: mockService} - - // given - id, err := uuid.NewRandom() - if err != nil { - t.Fatalf("error occurred: %+v", err) - } - - t.Run("when service returns error", func(t *testing.T) { - // and - mockService.EXPECT(). - Get(gomock.Eq(id)). - Return(nil, errors.New("hello error")) - - // and - s := httptest.NewServer(handler) - defer s.Close() - - // and - chiCtx := chi.NewRouteContext() - chiCtx.URLParams.Add("uuid", id.String()) - - req := httptest.NewRequest("GET", "/", nil). - WithContext(ctx.WithValue(ctx.Background(), chi.RouteCtxKey, chiCtx)) - rec := httptest.NewRecorder() - - // when - handler.ServeHTTP(rec, req) - - // then - if rec.Code != 500 { - t.Errorf("status must equal %+v, but got %+v", 500, rec.Code) - } - }) - - t.Run("when service returns correct job", func(t *testing.T) { - // and - job := &model.Job{ - Finished: true, - Stream: []model.Message{{ - Time: time.Now(), - Text: "Hello World", - }}, - } - - mockService.EXPECT(). - Get(gomock.Eq(id)). - Return(job, nil) - - // and - s := httptest.NewServer(handler) - defer s.Close() - - // and - chiCtx := chi.NewRouteContext() - chiCtx.URLParams.Add("uuid", id.String()) - - req := httptest.NewRequest("GET", "/", nil). - WithContext(ctx.WithValue(ctx.Background(), chi.RouteCtxKey, chiCtx)) - rec := httptest.NewRecorder() - - // when - handler.ServeHTTP(rec, req) - - // then - if rec.Code != 200 { - t.Errorf("status must equal %+v, but got %+v", 200, rec.Code) - } - }) - }) - - t.Run("with invalid uuid", func(t *testing.T) { - // setup - handler := &controller.LogController{} - - // given - s := httptest.NewServer(handler) - defer s.Close() - - // and - chiCtx := chi.NewRouteContext() - chiCtx.URLParams.Add("uuid", "invalid_uuid") - - req := httptest.NewRequest("GET", "/", nil). - WithContext(ctx.WithValue(ctx.Background(), chi.RouteCtxKey, chiCtx)) - rec := httptest.NewRecorder() - - // when - handler.ServeHTTP(rec, req) - - // then - if rec.Code != 500 { - t.Errorf("status must equal %+v, but got %+v", 500, rec.Code) - } - }) -} diff --git a/presentation/controller/webhook/export_test.go b/presentation/controller/webhook/export_test.go new file mode 100644 index 00000000..486236a2 --- /dev/null +++ b/presentation/controller/webhook/export_test.go @@ -0,0 +1,50 @@ +package webhook + +import ( + "github.com/duck8823/duci/application/service/executor" + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "net/url" + "reflect" +) + +type Handler = handler + +func (h *Handler) SetExecutor(executor executor.Executor) (reset func()) { + tmp := h.executor + h.executor = executor + return func() { + h.executor = tmp + } +} + +func URLMust(url *url.URL, err error) *url.URL { + if err != nil { + panic(err) + } + return url +} + +func CmpOptsAllowFields(typ interface{}, names ...string) cmp.Option { + return cmpopts.IgnoreFields(typ, func() []string { + var ignoreFields []string + + t := reflect.TypeOf(typ) + for i := 0; i < t.NumField(); i++ { + name := t.Field(i).Name + if !contains(names, name) { + ignoreFields = append(ignoreFields, name) + } + } + return ignoreFields + }()...) +} + +func contains(s []string, e string) bool { + for _, v := range s { + if v == e { + return true + } + } + return false +} diff --git a/presentation/controller/webhook/handler.go b/presentation/controller/webhook/handler.go new file mode 100644 index 00000000..99e63d62 --- /dev/null +++ b/presentation/controller/webhook/handler.go @@ -0,0 +1,157 @@ +package webhook + +import ( + "context" + "encoding/json" + "fmt" + "github.com/duck8823/duci/application" + "github.com/duck8823/duci/application/duci" + "github.com/duck8823/duci/application/service/executor" + "github.com/duck8823/duci/domain/model/job/target" + "github.com/duck8823/duci/domain/model/job/target/github" + "github.com/duck8823/duci/internal/logger" + go_github "github.com/google/go-github/github" + "github.com/pkg/errors" + "gopkg.in/src-d/go-git.v4/plumbing" + "net/http" +) + +// ErrSkipBuild represents error of skip build +var ErrSkipBuild = errors.New("Skip build") + +type handler struct { + executor executor.Executor +} + +// NewHandler returns a implement of webhook handler +func NewHandler() (http.Handler, error) { + executor, err := duci.New() + if err != nil { + return nil, errors.WithStack(err) + } + + return &handler{executor: executor}, nil +} + +// ServeHTTP receives github event +func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + event := r.Header.Get("X-GitHub-Event") + switch event { + case "push": + h.PushEvent(w, r) + case "issue_comment": + h.IssueCommentEvent(w, r) + default: + msg := fmt.Sprintf("payload event type must be push or issue_comment. but %s", event) + http.Error(w, msg, http.StatusBadRequest) + return + } +} + +// PushEvent receives github push event +func (h *handler) PushEvent(w http.ResponseWriter, r *http.Request) { + event := &go_github.PushEvent{} + if err := json.NewDecoder(r.Body).Decode(event); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + reqID, err := reqID(r) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + targetURL := targetURL(r) + targetURL.Path = fmt.Sprintf("/logs/%s", reqID.ToSlice()) + ctx := application.ContextWithJob(context.Background(), &application.BuildJob{ + ID: reqID, + TargetSource: &github.TargetSource{ + Repository: event.GetRepo(), + Ref: event.GetRef(), + SHA: plumbing.NewHash(event.GetHeadCommit().GetID()), + }, + TaskName: fmt.Sprintf("%s/push", application.Name), + TargetURL: targetURL, + }) + + tgt := &target.GitHub{ + Repo: event.GetRepo(), + Point: event, + } + + go func() { + if err := h.executor.Execute(ctx, tgt); err != nil { + logger.Error(err) + } + }() + + w.WriteHeader(http.StatusOK) +} + +// IssueCommentEvent receives github issue comment event +func (h *handler) IssueCommentEvent(w http.ResponseWriter, r *http.Request) { + event := &go_github.IssueCommentEvent{} + if err := json.NewDecoder(r.Body).Decode(event); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + if !isValidAction(event.Action) { + w.WriteHeader(http.StatusOK) + if _, err := w.Write([]byte("{\"message\":\"skip build\"}")); err != nil { + logger.Error(err) + } + return + } + + reqID, err := reqID(r) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + pnt, err := targetPoint(event) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + phrase, err := extractBuildPhrase(event.GetComment().GetBody()) + if err == ErrSkipBuild { + w.WriteHeader(http.StatusOK) + if _, err := w.Write([]byte("{\"message\":\"skip build\"}")); err != nil { + logger.Error(err) + } + return + } else if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + targetURL := targetURL(r) + targetURL.Path = fmt.Sprintf("/logs/%s", reqID.ToSlice()) + ctx := application.ContextWithJob(context.Background(), &application.BuildJob{ + ID: reqID, + TargetSource: &github.TargetSource{ + Repository: event.GetRepo(), + Ref: pnt.GetRef(), + SHA: plumbing.NewHash(pnt.GetHead()), + }, + TaskName: fmt.Sprintf("%s/pr/%s", application.Name, phrase.Command().Slice()[0]), + TargetURL: targetURL, + }) + + tgt := &target.GitHub{ + Repo: event.GetRepo(), + Point: pnt, + } + + go func() { + if err := h.executor.Execute(ctx, tgt, phrase.Command()...); err != nil { + logger.Error(err) + } + }() + + w.WriteHeader(http.StatusOK) +} diff --git a/presentation/controller/webhook/handler_test.go b/presentation/controller/webhook/handler_test.go new file mode 100644 index 00000000..12b48260 --- /dev/null +++ b/presentation/controller/webhook/handler_test.go @@ -0,0 +1,724 @@ +package webhook_test + +import ( + "context" + "github.com/docker/docker/pkg/ioutils" + "github.com/duck8823/duci/application" + "github.com/duck8823/duci/application/service/executor/mock_executor" + jobService "github.com/duck8823/duci/application/service/job" + "github.com/duck8823/duci/domain/model/job" + "github.com/duck8823/duci/domain/model/job/target/github" + "github.com/duck8823/duci/domain/model/job/target/github/mock_github" + "github.com/duck8823/duci/internal/container" + "github.com/duck8823/duci/presentation/controller/webhook" + "github.com/golang/mock/gomock" + "github.com/google/go-cmp/cmp" + go_github "github.com/google/go-github/github" + "github.com/google/uuid" + "github.com/pkg/errors" + "gopkg.in/src-d/go-git.v4/plumbing" + "net/http" + "net/http/httptest" + "net/url" + "os" + "reflect" + "strings" + "testing" + "time" +) + +func TestNewHandler(t *testing.T) { + t.Run("when there are job service and github in container", func(t *testing.T) { + // given + container.Override(new(jobService.Service)) + container.Override(new(github.GitHub)) + defer container.Clear() + + // when + _, err := webhook.NewHandler() + + // then + if err != nil { + t.Errorf("error must be nil, but got %+v", err) + } + }) + + t.Run("when there are not enough instance in container", func(t *testing.T) { + // where + for _, tt := range []struct { + name string + given func() + }{ + { + name: "without job service", + given: func() { + container.Override(new(github.GitHub)) + }, + }, + { + name: "without github", + given: func() { + container.Override(new(jobService.Service)) + }, + }, + } { + t.Run(tt.name, func(t *testing.T) { + // given + container.Clear() + tt.given() + + // when + _, err := webhook.NewHandler() + + // then + if err == nil { + t.Error("error must not be nil") + } + + // cleanup + container.Clear() + }) + } + }) +} + +func TestHandler_ServeHTTP(t *testing.T) { + t.Run("when push event", func(t *testing.T) { + // given + rec := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/", nil) + + // and + req.Header.Set("X-GitHub-Event", "push") + req.Header.Set("X-GitHub-Delivery", "72d3162e-cc78-11e3-81ab-4c9367dc0958") + + // and + f, err := os.Open("testdata/push.correct.json") + if err != nil { + t.Fatalf("error occur: %+v", err) + } + req.Body = f + + // and + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + executor := mock_executor.NewMockExecutor(ctrl) + executor.EXPECT(). + Execute(gomock.Any(), gomock.Any()). + Times(1). + Return(nil) + + // and + sut := &webhook.Handler{} + reset := sut.SetExecutor(executor) + defer func() { + time.Sleep(10 * time.Millisecond) // for goroutine + reset() + }() + + // when + sut.ServeHTTP(rec, req) + + // then + if rec.Code != http.StatusOK { + t.Errorf("response code must be %d, but got %d", http.StatusOK, rec.Code) + } + }) + + t.Run("when pull request comment event", func(t *testing.T) { + // given + rec := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/", nil) + + // and + req.Header.Set("X-GitHub-Event", "issue_comment") + req.Header.Set("X-GitHub-Delivery", "72d3162e-cc78-11e3-81ab-4c9367dc0958") + + // and + f, err := os.Open("testdata/issue_comment.correct.json") + if err != nil { + t.Fatalf("error occur: %+v", err) + } + req.Body = f + + // and + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + gh := mock_github.NewMockGitHub(ctrl) + gh.EXPECT(). + GetPullRequest(gomock.Any(), gomock.Any(), gomock.Eq(2)). + Times(1). + Return(&go_github.PullRequest{ + Head: &go_github.PullRequestBranch{ + Ref: go_github.String("refs/test/dummy"), + SHA: go_github.String("aa218f56b14c9653891f9e74264a383fa43fefbd"), + }, + }, nil) + container.Override(gh) + defer container.Clear() + + executor := mock_executor.NewMockExecutor(ctrl) + executor.EXPECT(). + Execute(gomock.Any(), gomock.Any(), gomock.Any()). + Times(1). + Return(nil) + + // and + sut := &webhook.Handler{} + reset := sut.SetExecutor(executor) + defer func() { + time.Sleep(10 * time.Millisecond) // for goroutine + reset() + }() + + // when + sut.ServeHTTP(rec, req) + + // then + if rec.Code != http.StatusOK { + t.Errorf("response code must be %d, but got %d", http.StatusOK, rec.Code) + } + }) + + t.Run("when other event", func(t *testing.T) { + // given + rec := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/", nil) + + // and + sut := &webhook.Handler{} + + // when + sut.ServeHTTP(rec, req) + + // then + if rec.Code != http.StatusBadRequest { + t.Errorf("response code must be %d, but got %d", http.StatusBadRequest, rec.Code) + } + }) +} + +func TestHandler_PushEvent(t *testing.T) { + t.Run("with no error", func(t *testing.T) { + // given + rec := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/", nil) + + // and + req.Header = http.Header{ + "X-Github-Delivery": []string{"72d3162e-cc78-11e3-81ab-4c9367dc0958"}, + } + + // and + f, err := os.Open("testdata/push.correct.json") + if err != nil { + t.Fatalf("error occur: %+v", err) + } + req.Body = f + + // and + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + executor := mock_executor.NewMockExecutor(ctrl) + executor.EXPECT(). + Execute(gomock.Any(), gomock.Any()). + Times(1). + Do(func(ctx context.Context, target job.Target) { + got, err := application.BuildJobFromContext(ctx) + if err != nil { + t.Errorf("must not be nil, but got %+v", err) + } + + want := &application.BuildJob{ + ID: job.ID(uuid.Must(uuid.Parse("72d3162e-cc78-11e3-81ab-4c9367dc0958"))), + TargetSource: &github.TargetSource{ + Repository: &go_github.PushEventRepository{ + ID: go_github.Int64(135493233), + FullName: go_github.String("Codertocat/Hello-World"), + SSHURL: go_github.String("git@github.com:Codertocat/Hello-World.git"), + CloneURL: go_github.String("https://github.com/Codertocat/Hello-World.git"), + }, + Ref: "refs/tags/simple-tag", + SHA: plumbing.ZeroHash, + }, + TaskName: "duci/push", + TargetURL: webhook.URLMust(url.Parse("http://example.com/logs/72d3162e-cc78-11e3-81ab-4c9367dc0958")), + } + + opt := webhook.CmpOptsAllowFields(go_github.PushEventRepository{}, "ID", "FullName", "SSHURL", "CloneURL") + if !cmp.Equal(got, want, opt) { + t.Errorf("must be equal but: %+v", cmp.Diff(got, want, opt)) + } + + typ := reflect.TypeOf(target).String() + if typ != "*target.GitHub" { + t.Errorf("type must be *target.GitHub, but got %s", typ) + } + }). + Return(nil) + + // and + sut := &webhook.Handler{} + reset := sut.SetExecutor(executor) + defer func() { + time.Sleep(10 * time.Millisecond) // for goroutine + reset() + }() + + // when + sut.PushEvent(rec, req) + + // then + if rec.Code != http.StatusOK { + t.Errorf("response code must be %d, but got %d", http.StatusOK, rec.Code) + } + }) + + t.Run("when url param is invalid format uuid", func(t *testing.T) { + // given + rec := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/", nil) + + // and + req.Header = http.Header{ + "X-Github-Delivery": []string{"invalid format"}, + } + + // and + f, err := os.Open("testdata/push.correct.json") + if err != nil { + t.Fatalf("error occur: %+v", err) + } + req.Body = f + + // and + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + executor := mock_executor.NewMockExecutor(ctrl) + executor.EXPECT(). + Execute(gomock.Any(), gomock.Any()). + Times(0) + + // and + sut := &webhook.Handler{} + defer sut.SetExecutor(executor)() + + // when + sut.PushEvent(rec, req) + + // then + if rec.Code != http.StatusBadRequest { + t.Errorf("response code must be %d, but got %d", http.StatusBadRequest, rec.Code) + } + }) + + t.Run("with invalid payload", func(t *testing.T) { + // given + rec := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/", nil) + + // and + req.Header = http.Header{ + "X-Github-Delivery": []string{"72d3162e-cc78-11e3-81ab-4c9367dc0958"}, + } + + // and + req.Body = ioutils.NewReadCloserWrapper(strings.NewReader("invalid payload"), func() error { + return nil + }) + + // and + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + executor := mock_executor.NewMockExecutor(ctrl) + executor.EXPECT(). + Execute(gomock.Any(), gomock.Any()). + Times(0) + + // and + sut := &webhook.Handler{} + defer sut.SetExecutor(executor)() + + // when + sut.PushEvent(rec, req) + + // then + if rec.Code != http.StatusInternalServerError { + t.Errorf("response code must be %d, but got %d", http.StatusInternalServerError, rec.Code) + } + }) +} + +func TestHandler_IssueCommentEvent_Normal(t *testing.T) { + t.Run("with no error", func(t *testing.T) { + // given + rec := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/", nil) + + // and + req.Header = http.Header{ + "X-Github-Delivery": []string{"72d3162e-cc78-11e3-81ab-4c9367dc0958"}, + } + + // and + f, err := os.Open("testdata/issue_comment.correct.json") + if err != nil { + t.Fatalf("error occur: %+v", err) + } + req.Body = f + + // and + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + gh := mock_github.NewMockGitHub(ctrl) + gh.EXPECT(). + GetPullRequest(gomock.Any(), gomock.Any(), gomock.Eq(2)). + Times(1). + Return(&go_github.PullRequest{ + Head: &go_github.PullRequestBranch{ + Ref: go_github.String("dummy"), + SHA: go_github.String("aa218f56b14c9653891f9e74264a383fa43fefbd"), + }, + }, nil) + container.Override(gh) + defer container.Clear() + + executor := mock_executor.NewMockExecutor(ctrl) + executor.EXPECT(). + Execute(gomock.Any(), gomock.Any(), gomock.Any()). + Times(1). + Do(func(ctx context.Context, target job.Target, cmd ...string) { + got, err := application.BuildJobFromContext(ctx) + if err != nil { + t.Errorf("must not be nil, but got %+v", err) + } + + want := &application.BuildJob{ + ID: job.ID(uuid.Must(uuid.Parse("72d3162e-cc78-11e3-81ab-4c9367dc0958"))), + TargetSource: &github.TargetSource{ + Repository: &go_github.Repository{ + ID: go_github.Int64(135493233), + FullName: go_github.String("Codertocat/Hello-World"), + SSHURL: go_github.String("git@github.com:Codertocat/Hello-World.git"), + CloneURL: go_github.String("https://github.com/Codertocat/Hello-World.git"), + }, + Ref: "refs/heads/dummy", + SHA: plumbing.NewHash("aa218f56b14c9653891f9e74264a383fa43fefbd"), + }, + TaskName: "duci/pr/build", + TargetURL: webhook.URLMust(url.Parse("http://example.com/logs/72d3162e-cc78-11e3-81ab-4c9367dc0958")), + } + + opt := webhook.CmpOptsAllowFields(go_github.Repository{}, "ID", "FullName", "SSHURL", "CloneURL") + if !cmp.Equal(got, want, opt) { + t.Errorf("must be equal but: %+v", cmp.Diff(got, want, opt)) + } + + typ := reflect.TypeOf(target).String() + if typ != "*target.GitHub" { + t.Errorf("type must be *target.GitHub, but got %s", typ) + } + }). + Return(nil) + + // and + sut := &webhook.Handler{} + reset := sut.SetExecutor(executor) + defer func() { + time.Sleep(10 * time.Millisecond) // for goroutine + reset() + }() + + // when + sut.IssueCommentEvent(rec, req) + + // then + if rec.Code != http.StatusOK { + t.Errorf("response code must be %d, but got %d", http.StatusOK, rec.Code) + } + }) + + t.Run("when no match comment", func(t *testing.T) { + // given + rec := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/", nil) + + // and + req.Header = http.Header{ + "X-Github-Delivery": []string{"72d3162e-cc78-11e3-81ab-4c9367dc0958"}, + } + + // and + f, err := os.Open("testdata/issue_comment.skip_comment.json") + if err != nil { + t.Fatalf("error occur: %+v", err) + } + req.Body = f + + // and + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + gh := mock_github.NewMockGitHub(ctrl) + gh.EXPECT(). + GetPullRequest(gomock.Any(), gomock.Any(), gomock.Eq(2)). + Times(1). + Return(&go_github.PullRequest{ + Head: &go_github.PullRequestBranch{ + Ref: go_github.String("refs/test/dummy"), + SHA: go_github.String("aa218f56b14c9653891f9e74264a383fa43fefbd"), + }, + }, nil) + container.Override(gh) + defer container.Clear() + + executor := mock_executor.NewMockExecutor(ctrl) + executor.EXPECT(). + Execute(gomock.Any(), gomock.Any()). + Times(0) + + // and + sut := &webhook.Handler{} + defer sut.SetExecutor(executor)() + + // when + sut.IssueCommentEvent(rec, req) + + // then + if rec.Code != http.StatusOK { + t.Errorf("response code must be %d, but got %d", http.StatusOK, rec.Code) + } + + // and + got := rec.Body.String() + if got != `{"message":"skip build"}` { + t.Errorf("must be equal. want %s, but got %s", `{"message":"skip build"}`, got) + } + }) + + t.Run("when action is deleted", func(t *testing.T) { + // given + rec := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/", nil) + + // and + req.Header = http.Header{ + "X-Github-Delivery": []string{"72d3162e-cc78-11e3-81ab-4c9367dc0958"}, + } + + // and + f, err := os.Open("testdata/issue_comment.deleted.json") + if err != nil { + t.Fatalf("error occur: %+v", err) + } + req.Body = f + + // and + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + executor := mock_executor.NewMockExecutor(ctrl) + executor.EXPECT(). + Execute(gomock.Any(), gomock.Any(), gomock.Any()). + Times(0) + + // and + sut := &webhook.Handler{} + reset := sut.SetExecutor(executor) + defer func() { + time.Sleep(10 * time.Millisecond) // for goroutine + reset() + }() + + // when + sut.IssueCommentEvent(rec, req) + + // then + if rec.Code != http.StatusOK { + t.Errorf("response code must be %d, but got %d", http.StatusOK, rec.Code) + } + + // and + got := rec.Body.String() + if got != `{"message":"skip build"}` { + t.Errorf("must be equal. want %s, but got %s", `{"message":"skip build"}`, got) + } + }) +} + +func TestHandler_IssueCommentEvent_UnNormal(t *testing.T) { + t.Run("with invalid payload body", func(t *testing.T) { + // given + rec := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/", nil) + + // and + req.Header = http.Header{ + "X-Github-Delivery": []string{"72d3162e-cc78-11e3-81ab-4c9367dc0958"}, + } + + // and + req.Body = ioutils.NewReadCloserWrapper(strings.NewReader("invalid payload"), func() error { + return nil + }) + + // and + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + executor := mock_executor.NewMockExecutor(ctrl) + executor.EXPECT(). + Execute(gomock.Any(), gomock.Any(), gomock.Any()). + Times(0) + + // and + sut := &webhook.Handler{} + reset := sut.SetExecutor(executor) + defer func() { + time.Sleep(10 * time.Millisecond) // for goroutine + reset() + }() + + // when + sut.IssueCommentEvent(rec, req) + + // then + if rec.Code != http.StatusInternalServerError { + t.Errorf("response code must be %d, but got %d", http.StatusInternalServerError, rec.Code) + } + }) + + t.Run("when url param is invalid format uuid", func(t *testing.T) { + // given + rec := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/", nil) + + // and + req.Header = http.Header{ + "X-Github-Delivery": []string{"invalid format"}, + } + + // and + f, err := os.Open("testdata/issue_comment.correct.json") + if err != nil { + t.Fatalf("error occur: %+v", err) + } + req.Body = f + + // and + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + executor := mock_executor.NewMockExecutor(ctrl) + executor.EXPECT(). + Execute(gomock.Any(), gomock.Any()). + Times(0) + + // and + sut := &webhook.Handler{} + defer sut.SetExecutor(executor)() + + // when + sut.IssueCommentEvent(rec, req) + + // then + if rec.Code != http.StatusBadRequest { + t.Errorf("response code must be %d, but got %d", http.StatusBadRequest, rec.Code) + } + }) + + t.Run("when fail to get pull request", func(t *testing.T) { + // given + rec := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/", nil) + + // and + req.Header = http.Header{ + "X-Github-Delivery": []string{"72d3162e-cc78-11e3-81ab-4c9367dc0958"}, + } + + // and + f, err := os.Open("testdata/issue_comment.skip_comment.json") + if err != nil { + t.Fatalf("error occur: %+v", err) + } + req.Body = f + + // and + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + gh := mock_github.NewMockGitHub(ctrl) + gh.EXPECT(). + GetPullRequest(gomock.Any(), gomock.Any(), gomock.Eq(2)). + Times(1). + Return(nil, errors.New("test error")) + container.Override(gh) + defer container.Clear() + + executor := mock_executor.NewMockExecutor(ctrl) + executor.EXPECT(). + Execute(gomock.Any(), gomock.Any()). + Times(0) + + // and + sut := &webhook.Handler{} + defer sut.SetExecutor(executor)() + + // when + sut.IssueCommentEvent(rec, req) + + // then + if rec.Code != http.StatusBadRequest { + t.Errorf("response code must be %d, but got %d", http.StatusBadRequest, rec.Code) + } + }) + + t.Run("when fail to get github instance", func(t *testing.T) { + // given + rec := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/", nil) + + // and + req.Header = http.Header{ + "X-Github-Delivery": []string{"72d3162e-cc78-11e3-81ab-4c9367dc0958"}, + } + + // and + f, err := os.Open("testdata/issue_comment.skip_comment.json") + if err != nil { + t.Fatalf("error occur: %+v", err) + } + req.Body = f + + // and + container.Clear() + + // and + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + executor := mock_executor.NewMockExecutor(ctrl) + executor.EXPECT(). + Execute(gomock.Any(), gomock.Any()). + Times(0) + + // and + sut := &webhook.Handler{} + defer sut.SetExecutor(executor)() + + // when + sut.IssueCommentEvent(rec, req) + + // then + if rec.Code != http.StatusBadRequest { + t.Errorf("response code must be %d, but got %d", http.StatusBadRequest, rec.Code) + } + }) +} diff --git a/presentation/controller/webhook/helper.go b/presentation/controller/webhook/helper.go new file mode 100644 index 00000000..027682d1 --- /dev/null +++ b/presentation/controller/webhook/helper.go @@ -0,0 +1,59 @@ +package webhook + +import ( + "context" + "fmt" + "github.com/duck8823/duci/domain/model/job" + "github.com/duck8823/duci/domain/model/job/target/github" + go_github "github.com/google/go-github/github" + "github.com/google/uuid" + "github.com/pkg/errors" + "net/http" + "net/url" +) + +func reqID(r *http.Request) (job.ID, error) { + deliveryID := go_github.DeliveryID(r) + requestID, err := uuid.Parse(deliveryID) + if err != nil { + msg := fmt.Sprintf("Error: invalid request header `X-GitHub-Delivery`: %+v", deliveryID) + return job.ID{}, errors.Wrap(err, msg) + } + return job.ID(requestID), nil +} + +func targetURL(r *http.Request) *url.URL { + runtimeURL := &url.URL{ + Scheme: "http", + Host: r.Host, + Path: r.URL.Path, + } + if r.URL.Scheme != "" { + runtimeURL.Scheme = r.URL.Scheme + } + return runtimeURL +} + +func targetPoint(event *go_github.IssueCommentEvent) (github.TargetPoint, error) { + gh, err := github.GetInstance() + if err != nil { + return nil, errors.WithStack(err) + } + + pr, err := gh.GetPullRequest(context.Background(), event.GetRepo(), event.GetIssue().GetNumber()) + if err != nil { + return nil, errors.WithStack(err) + } + + return &github.SimpleTargetPoint{ + Ref: fmt.Sprintf("refs/heads/%s", pr.GetHead().GetRef()), + SHA: pr.GetHead().GetSHA(), + }, nil +} + +func isValidAction(action *string) bool { + if action == nil { + return false + } + return *action == "created" || *action == "edited" +} diff --git a/presentation/controller/webhook/phrase.go b/presentation/controller/webhook/phrase.go new file mode 100644 index 00000000..db91d246 --- /dev/null +++ b/presentation/controller/webhook/phrase.go @@ -0,0 +1,22 @@ +package webhook + +import ( + "github.com/duck8823/duci/domain/model/docker" + "regexp" + "strings" +) + +type phrase string + +// Command returns command of docker +func (p phrase) Command() docker.Command { + return strings.Split(string(p), " ") +} + +func extractBuildPhrase(comment string) (phrase, error) { + if !regexp.MustCompile(`^ci\s+[^\\s]+`).Match([]byte(comment)) { + return "", ErrSkipBuild + } + phrase := phrase(regexp.MustCompile(`^ci\s+`).ReplaceAllString(comment, "")) + return phrase, nil +} diff --git a/presentation/controller/webhook/testdata/issue_comment.correct.json b/presentation/controller/webhook/testdata/issue_comment.correct.json new file mode 100644 index 00000000..d281fa45 --- /dev/null +++ b/presentation/controller/webhook/testdata/issue_comment.correct.json @@ -0,0 +1,202 @@ +{ + "action": "created", + "issue": { + "url": "https://api.github.com/repos/Codertocat/Hello-World/issues/2", + "repository_url": "https://api.github.com/repos/Codertocat/Hello-World", + "labels_url": "https://api.github.com/repos/Codertocat/Hello-World/issues/2/labels{/name}", + "comments_url": "https://api.github.com/repos/Codertocat/Hello-World/issues/2/comments", + "events_url": "https://api.github.com/repos/Codertocat/Hello-World/issues/2/events", + "html_url": "https://github.com/Codertocat/Hello-World/issues/2", + "id": 327883527, + "node_id": "MDU6SXNzdWUzMjc4ODM1Mjc=", + "number": 2, + "title": "Spelling error in the README file", + "user": { + "login": "Codertocat", + "id": 21031067, + "node_id": "MDQ6VXNlcjIxMDMxMDY3", + "avatar_url": "https://avatars1.githubusercontent.com/u/21031067?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/Codertocat", + "html_url": "https://github.com/Codertocat", + "followers_url": "https://api.github.com/users/Codertocat/followers", + "following_url": "https://api.github.com/users/Codertocat/following{/other_user}", + "gists_url": "https://api.github.com/users/Codertocat/gists{/gist_id}", + "starred_url": "https://api.github.com/users/Codertocat/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/Codertocat/subscriptions", + "organizations_url": "https://api.github.com/users/Codertocat/orgs", + "repos_url": "https://api.github.com/users/Codertocat/repos", + "events_url": "https://api.github.com/users/Codertocat/events{/privacy}", + "received_events_url": "https://api.github.com/users/Codertocat/received_events", + "type": "User", + "site_admin": false + }, + "labels": [ + { + "id": 949737505, + "node_id": "MDU6TGFiZWw5NDk3Mzc1MDU=", + "url": "https://api.github.com/repos/Codertocat/Hello-World/labels/bug", + "name": "bug", + "color": "d73a4a", + "default": true + } + ], + "state": "open", + "locked": false, + "assignee": null, + "assignees": [ + + ], + "milestone": null, + "comments": 0, + "created_at": "2018-05-30T20:18:32Z", + "updated_at": "2018-05-30T20:18:32Z", + "closed_at": null, + "author_association": "OWNER", + "body": "It looks like you accidently spelled 'commit' with two 't's." + }, + "comment": { + "url": "https://api.github.com/repos/Codertocat/Hello-World/issues/comments/393304133", + "html_url": "https://github.com/Codertocat/Hello-World/issues/2#issuecomment-393304133", + "issue_url": "https://api.github.com/repos/Codertocat/Hello-World/issues/2", + "id": 393304133, + "node_id": "MDEyOklzc3VlQ29tbWVudDM5MzMwNDEzMw==", + "user": { + "login": "Codertocat", + "id": 21031067, + "node_id": "MDQ6VXNlcjIxMDMxMDY3", + "avatar_url": "https://avatars1.githubusercontent.com/u/21031067?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/Codertocat", + "html_url": "https://github.com/Codertocat", + "followers_url": "https://api.github.com/users/Codertocat/followers", + "following_url": "https://api.github.com/users/Codertocat/following{/other_user}", + "gists_url": "https://api.github.com/users/Codertocat/gists{/gist_id}", + "starred_url": "https://api.github.com/users/Codertocat/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/Codertocat/subscriptions", + "organizations_url": "https://api.github.com/users/Codertocat/orgs", + "repos_url": "https://api.github.com/users/Codertocat/repos", + "events_url": "https://api.github.com/users/Codertocat/events{/privacy}", + "received_events_url": "https://api.github.com/users/Codertocat/received_events", + "type": "User", + "site_admin": false + }, + "created_at": "2018-05-30T20:18:32Z", + "updated_at": "2018-05-30T20:18:32Z", + "author_association": "OWNER", + "body": "ci build" + }, + "repository": { + "id": 135493233, + "node_id": "MDEwOlJlcG9zaXRvcnkxMzU0OTMyMzM=", + "name": "Hello-World", + "full_name": "Codertocat/Hello-World", + "owner": { + "login": "Codertocat", + "id": 21031067, + "node_id": "MDQ6VXNlcjIxMDMxMDY3", + "avatar_url": "https://avatars1.githubusercontent.com/u/21031067?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/Codertocat", + "html_url": "https://github.com/Codertocat", + "followers_url": "https://api.github.com/users/Codertocat/followers", + "following_url": "https://api.github.com/users/Codertocat/following{/other_user}", + "gists_url": "https://api.github.com/users/Codertocat/gists{/gist_id}", + "starred_url": "https://api.github.com/users/Codertocat/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/Codertocat/subscriptions", + "organizations_url": "https://api.github.com/users/Codertocat/orgs", + "repos_url": "https://api.github.com/users/Codertocat/repos", + "events_url": "https://api.github.com/users/Codertocat/events{/privacy}", + "received_events_url": "https://api.github.com/users/Codertocat/received_events", + "type": "User", + "site_admin": false + }, + "private": false, + "html_url": "https://github.com/Codertocat/Hello-World", + "description": null, + "fork": false, + "url": "https://api.github.com/repos/Codertocat/Hello-World", + "forks_url": "https://api.github.com/repos/Codertocat/Hello-World/forks", + "keys_url": "https://api.github.com/repos/Codertocat/Hello-World/keys{/key_id}", + "collaborators_url": "https://api.github.com/repos/Codertocat/Hello-World/collaborators{/collaborator}", + "teams_url": "https://api.github.com/repos/Codertocat/Hello-World/teams", + "hooks_url": "https://api.github.com/repos/Codertocat/Hello-World/hooks", + "issue_events_url": "https://api.github.com/repos/Codertocat/Hello-World/issues/events{/number}", + "events_url": "https://api.github.com/repos/Codertocat/Hello-World/events", + "assignees_url": "https://api.github.com/repos/Codertocat/Hello-World/assignees{/user}", + "branches_url": "https://api.github.com/repos/Codertocat/Hello-World/branches{/branch}", + "tags_url": "https://api.github.com/repos/Codertocat/Hello-World/tags", + "blobs_url": "https://api.github.com/repos/Codertocat/Hello-World/git/blobs{/sha}", + "git_tags_url": "https://api.github.com/repos/Codertocat/Hello-World/git/tags{/sha}", + "git_refs_url": "https://api.github.com/repos/Codertocat/Hello-World/git/refs{/sha}", + "trees_url": "https://api.github.com/repos/Codertocat/Hello-World/git/trees{/sha}", + "statuses_url": "https://api.github.com/repos/Codertocat/Hello-World/statuses/{sha}", + "languages_url": "https://api.github.com/repos/Codertocat/Hello-World/languages", + "stargazers_url": "https://api.github.com/repos/Codertocat/Hello-World/stargazers", + "contributors_url": "https://api.github.com/repos/Codertocat/Hello-World/contributors", + "subscribers_url": "https://api.github.com/repos/Codertocat/Hello-World/subscribers", + "subscription_url": "https://api.github.com/repos/Codertocat/Hello-World/subscription", + "commits_url": "https://api.github.com/repos/Codertocat/Hello-World/commits{/sha}", + "git_commits_url": "https://api.github.com/repos/Codertocat/Hello-World/git/commits{/sha}", + "comments_url": "https://api.github.com/repos/Codertocat/Hello-World/comments{/number}", + "issue_comment_url": "https://api.github.com/repos/Codertocat/Hello-World/issues/comments{/number}", + "contents_url": "https://api.github.com/repos/Codertocat/Hello-World/contents/{+path}", + "compare_url": "https://api.github.com/repos/Codertocat/Hello-World/compare/{base}...{head}", + "merges_url": "https://api.github.com/repos/Codertocat/Hello-World/merges", + "archive_url": "https://api.github.com/repos/Codertocat/Hello-World/{archive_format}{/ref}", + "downloads_url": "https://api.github.com/repos/Codertocat/Hello-World/downloads", + "issues_url": "https://api.github.com/repos/Codertocat/Hello-World/issues{/number}", + "pulls_url": "https://api.github.com/repos/Codertocat/Hello-World/pulls{/number}", + "milestones_url": "https://api.github.com/repos/Codertocat/Hello-World/milestones{/number}", + "notifications_url": "https://api.github.com/repos/Codertocat/Hello-World/notifications{?since,all,participating}", + "labels_url": "https://api.github.com/repos/Codertocat/Hello-World/labels{/name}", + "releases_url": "https://api.github.com/repos/Codertocat/Hello-World/releases{/id}", + "deployments_url": "https://api.github.com/repos/Codertocat/Hello-World/deployments", + "created_at": "2018-05-30T20:18:04Z", + "updated_at": "2018-05-30T20:18:10Z", + "pushed_at": "2018-05-30T20:18:30Z", + "git_url": "git://github.com/Codertocat/Hello-World.git", + "ssh_url": "git@github.com:Codertocat/Hello-World.git", + "clone_url": "https://github.com/Codertocat/Hello-World.git", + "svn_url": "https://github.com/Codertocat/Hello-World", + "homepage": null, + "size": 0, + "stargazers_count": 0, + "watchers_count": 0, + "language": null, + "has_issues": true, + "has_projects": true, + "has_downloads": true, + "has_wiki": true, + "has_pages": true, + "forks_count": 0, + "mirror_url": null, + "archived": false, + "open_issues_count": 2, + "license": null, + "forks": 0, + "open_issues": 2, + "watchers": 0, + "default_branch": "master" + }, + "sender": { + "login": "Codertocat", + "id": 21031067, + "node_id": "MDQ6VXNlcjIxMDMxMDY3", + "avatar_url": "https://avatars1.githubusercontent.com/u/21031067?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/Codertocat", + "html_url": "https://github.com/Codertocat", + "followers_url": "https://api.github.com/users/Codertocat/followers", + "following_url": "https://api.github.com/users/Codertocat/following{/other_user}", + "gists_url": "https://api.github.com/users/Codertocat/gists{/gist_id}", + "starred_url": "https://api.github.com/users/Codertocat/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/Codertocat/subscriptions", + "organizations_url": "https://api.github.com/users/Codertocat/orgs", + "repos_url": "https://api.github.com/users/Codertocat/repos", + "events_url": "https://api.github.com/users/Codertocat/events{/privacy}", + "received_events_url": "https://api.github.com/users/Codertocat/received_events", + "type": "User", + "site_admin": false + } +} \ No newline at end of file diff --git a/presentation/controller/webhook/testdata/issue_comment.deleted.json b/presentation/controller/webhook/testdata/issue_comment.deleted.json new file mode 100644 index 00000000..078c7544 --- /dev/null +++ b/presentation/controller/webhook/testdata/issue_comment.deleted.json @@ -0,0 +1,202 @@ +{ + "action": "deleted", + "issue": { + "url": "https://api.github.com/repos/Codertocat/Hello-World/issues/2", + "repository_url": "https://api.github.com/repos/Codertocat/Hello-World", + "labels_url": "https://api.github.com/repos/Codertocat/Hello-World/issues/2/labels{/name}", + "comments_url": "https://api.github.com/repos/Codertocat/Hello-World/issues/2/comments", + "events_url": "https://api.github.com/repos/Codertocat/Hello-World/issues/2/events", + "html_url": "https://github.com/Codertocat/Hello-World/issues/2", + "id": 327883527, + "node_id": "MDU6SXNzdWUzMjc4ODM1Mjc=", + "number": 2, + "title": "Spelling error in the README file", + "user": { + "login": "Codertocat", + "id": 21031067, + "node_id": "MDQ6VXNlcjIxMDMxMDY3", + "avatar_url": "https://avatars1.githubusercontent.com/u/21031067?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/Codertocat", + "html_url": "https://github.com/Codertocat", + "followers_url": "https://api.github.com/users/Codertocat/followers", + "following_url": "https://api.github.com/users/Codertocat/following{/other_user}", + "gists_url": "https://api.github.com/users/Codertocat/gists{/gist_id}", + "starred_url": "https://api.github.com/users/Codertocat/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/Codertocat/subscriptions", + "organizations_url": "https://api.github.com/users/Codertocat/orgs", + "repos_url": "https://api.github.com/users/Codertocat/repos", + "events_url": "https://api.github.com/users/Codertocat/events{/privacy}", + "received_events_url": "https://api.github.com/users/Codertocat/received_events", + "type": "User", + "site_admin": false + }, + "labels": [ + { + "id": 949737505, + "node_id": "MDU6TGFiZWw5NDk3Mzc1MDU=", + "url": "https://api.github.com/repos/Codertocat/Hello-World/labels/bug", + "name": "bug", + "color": "d73a4a", + "default": true + } + ], + "state": "open", + "locked": false, + "assignee": null, + "assignees": [ + + ], + "milestone": null, + "comments": 0, + "created_at": "2018-05-30T20:18:32Z", + "updated_at": "2018-05-30T20:18:32Z", + "closed_at": null, + "author_association": "OWNER", + "body": "It looks like you accidently spelled 'commit' with two 't's." + }, + "comment": { + "url": "https://api.github.com/repos/Codertocat/Hello-World/issues/comments/393304133", + "html_url": "https://github.com/Codertocat/Hello-World/issues/2#issuecomment-393304133", + "issue_url": "https://api.github.com/repos/Codertocat/Hello-World/issues/2", + "id": 393304133, + "node_id": "MDEyOklzc3VlQ29tbWVudDM5MzMwNDEzMw==", + "user": { + "login": "Codertocat", + "id": 21031067, + "node_id": "MDQ6VXNlcjIxMDMxMDY3", + "avatar_url": "https://avatars1.githubusercontent.com/u/21031067?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/Codertocat", + "html_url": "https://github.com/Codertocat", + "followers_url": "https://api.github.com/users/Codertocat/followers", + "following_url": "https://api.github.com/users/Codertocat/following{/other_user}", + "gists_url": "https://api.github.com/users/Codertocat/gists{/gist_id}", + "starred_url": "https://api.github.com/users/Codertocat/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/Codertocat/subscriptions", + "organizations_url": "https://api.github.com/users/Codertocat/orgs", + "repos_url": "https://api.github.com/users/Codertocat/repos", + "events_url": "https://api.github.com/users/Codertocat/events{/privacy}", + "received_events_url": "https://api.github.com/users/Codertocat/received_events", + "type": "User", + "site_admin": false + }, + "created_at": "2018-05-30T20:18:32Z", + "updated_at": "2018-05-30T20:18:32Z", + "author_association": "OWNER", + "body": "You are totally right! I'll get this fixed right away." + }, + "repository": { + "id": 135493233, + "node_id": "MDEwOlJlcG9zaXRvcnkxMzU0OTMyMzM=", + "name": "Hello-World", + "full_name": "Codertocat/Hello-World", + "owner": { + "login": "Codertocat", + "id": 21031067, + "node_id": "MDQ6VXNlcjIxMDMxMDY3", + "avatar_url": "https://avatars1.githubusercontent.com/u/21031067?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/Codertocat", + "html_url": "https://github.com/Codertocat", + "followers_url": "https://api.github.com/users/Codertocat/followers", + "following_url": "https://api.github.com/users/Codertocat/following{/other_user}", + "gists_url": "https://api.github.com/users/Codertocat/gists{/gist_id}", + "starred_url": "https://api.github.com/users/Codertocat/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/Codertocat/subscriptions", + "organizations_url": "https://api.github.com/users/Codertocat/orgs", + "repos_url": "https://api.github.com/users/Codertocat/repos", + "events_url": "https://api.github.com/users/Codertocat/events{/privacy}", + "received_events_url": "https://api.github.com/users/Codertocat/received_events", + "type": "User", + "site_admin": false + }, + "private": false, + "html_url": "https://github.com/Codertocat/Hello-World", + "description": null, + "fork": false, + "url": "https://api.github.com/repos/Codertocat/Hello-World", + "forks_url": "https://api.github.com/repos/Codertocat/Hello-World/forks", + "keys_url": "https://api.github.com/repos/Codertocat/Hello-World/keys{/key_id}", + "collaborators_url": "https://api.github.com/repos/Codertocat/Hello-World/collaborators{/collaborator}", + "teams_url": "https://api.github.com/repos/Codertocat/Hello-World/teams", + "hooks_url": "https://api.github.com/repos/Codertocat/Hello-World/hooks", + "issue_events_url": "https://api.github.com/repos/Codertocat/Hello-World/issues/events{/number}", + "events_url": "https://api.github.com/repos/Codertocat/Hello-World/events", + "assignees_url": "https://api.github.com/repos/Codertocat/Hello-World/assignees{/user}", + "branches_url": "https://api.github.com/repos/Codertocat/Hello-World/branches{/branch}", + "tags_url": "https://api.github.com/repos/Codertocat/Hello-World/tags", + "blobs_url": "https://api.github.com/repos/Codertocat/Hello-World/git/blobs{/sha}", + "git_tags_url": "https://api.github.com/repos/Codertocat/Hello-World/git/tags{/sha}", + "git_refs_url": "https://api.github.com/repos/Codertocat/Hello-World/git/refs{/sha}", + "trees_url": "https://api.github.com/repos/Codertocat/Hello-World/git/trees{/sha}", + "statuses_url": "https://api.github.com/repos/Codertocat/Hello-World/statuses/{sha}", + "languages_url": "https://api.github.com/repos/Codertocat/Hello-World/languages", + "stargazers_url": "https://api.github.com/repos/Codertocat/Hello-World/stargazers", + "contributors_url": "https://api.github.com/repos/Codertocat/Hello-World/contributors", + "subscribers_url": "https://api.github.com/repos/Codertocat/Hello-World/subscribers", + "subscription_url": "https://api.github.com/repos/Codertocat/Hello-World/subscription", + "commits_url": "https://api.github.com/repos/Codertocat/Hello-World/commits{/sha}", + "git_commits_url": "https://api.github.com/repos/Codertocat/Hello-World/git/commits{/sha}", + "comments_url": "https://api.github.com/repos/Codertocat/Hello-World/comments{/number}", + "issue_comment_url": "https://api.github.com/repos/Codertocat/Hello-World/issues/comments{/number}", + "contents_url": "https://api.github.com/repos/Codertocat/Hello-World/contents/{+path}", + "compare_url": "https://api.github.com/repos/Codertocat/Hello-World/compare/{base}...{head}", + "merges_url": "https://api.github.com/repos/Codertocat/Hello-World/merges", + "archive_url": "https://api.github.com/repos/Codertocat/Hello-World/{archive_format}{/ref}", + "downloads_url": "https://api.github.com/repos/Codertocat/Hello-World/downloads", + "issues_url": "https://api.github.com/repos/Codertocat/Hello-World/issues{/number}", + "pulls_url": "https://api.github.com/repos/Codertocat/Hello-World/pulls{/number}", + "milestones_url": "https://api.github.com/repos/Codertocat/Hello-World/milestones{/number}", + "notifications_url": "https://api.github.com/repos/Codertocat/Hello-World/notifications{?since,all,participating}", + "labels_url": "https://api.github.com/repos/Codertocat/Hello-World/labels{/name}", + "releases_url": "https://api.github.com/repos/Codertocat/Hello-World/releases{/id}", + "deployments_url": "https://api.github.com/repos/Codertocat/Hello-World/deployments", + "created_at": "2018-05-30T20:18:04Z", + "updated_at": "2018-05-30T20:18:10Z", + "pushed_at": "2018-05-30T20:18:30Z", + "git_url": "git://github.com/Codertocat/Hello-World.git", + "ssh_url": "git@github.com:Codertocat/Hello-World.git", + "clone_url": "https://github.com/Codertocat/Hello-World.git", + "svn_url": "https://github.com/Codertocat/Hello-World", + "homepage": null, + "size": 0, + "stargazers_count": 0, + "watchers_count": 0, + "language": null, + "has_issues": true, + "has_projects": true, + "has_downloads": true, + "has_wiki": true, + "has_pages": true, + "forks_count": 0, + "mirror_url": null, + "archived": false, + "open_issues_count": 2, + "license": null, + "forks": 0, + "open_issues": 2, + "watchers": 0, + "default_branch": "master" + }, + "sender": { + "login": "Codertocat", + "id": 21031067, + "node_id": "MDQ6VXNlcjIxMDMxMDY3", + "avatar_url": "https://avatars1.githubusercontent.com/u/21031067?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/Codertocat", + "html_url": "https://github.com/Codertocat", + "followers_url": "https://api.github.com/users/Codertocat/followers", + "following_url": "https://api.github.com/users/Codertocat/following{/other_user}", + "gists_url": "https://api.github.com/users/Codertocat/gists{/gist_id}", + "starred_url": "https://api.github.com/users/Codertocat/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/Codertocat/subscriptions", + "organizations_url": "https://api.github.com/users/Codertocat/orgs", + "repos_url": "https://api.github.com/users/Codertocat/repos", + "events_url": "https://api.github.com/users/Codertocat/events{/privacy}", + "received_events_url": "https://api.github.com/users/Codertocat/received_events", + "type": "User", + "site_admin": false + } +} \ No newline at end of file diff --git a/presentation/controller/webhook/testdata/issue_comment.skip_comment.json b/presentation/controller/webhook/testdata/issue_comment.skip_comment.json new file mode 100644 index 00000000..e486dede --- /dev/null +++ b/presentation/controller/webhook/testdata/issue_comment.skip_comment.json @@ -0,0 +1,202 @@ +{ + "action": "created", + "issue": { + "url": "https://api.github.com/repos/Codertocat/Hello-World/issues/2", + "repository_url": "https://api.github.com/repos/Codertocat/Hello-World", + "labels_url": "https://api.github.com/repos/Codertocat/Hello-World/issues/2/labels{/name}", + "comments_url": "https://api.github.com/repos/Codertocat/Hello-World/issues/2/comments", + "events_url": "https://api.github.com/repos/Codertocat/Hello-World/issues/2/events", + "html_url": "https://github.com/Codertocat/Hello-World/issues/2", + "id": 327883527, + "node_id": "MDU6SXNzdWUzMjc4ODM1Mjc=", + "number": 2, + "title": "Spelling error in the README file", + "user": { + "login": "Codertocat", + "id": 21031067, + "node_id": "MDQ6VXNlcjIxMDMxMDY3", + "avatar_url": "https://avatars1.githubusercontent.com/u/21031067?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/Codertocat", + "html_url": "https://github.com/Codertocat", + "followers_url": "https://api.github.com/users/Codertocat/followers", + "following_url": "https://api.github.com/users/Codertocat/following{/other_user}", + "gists_url": "https://api.github.com/users/Codertocat/gists{/gist_id}", + "starred_url": "https://api.github.com/users/Codertocat/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/Codertocat/subscriptions", + "organizations_url": "https://api.github.com/users/Codertocat/orgs", + "repos_url": "https://api.github.com/users/Codertocat/repos", + "events_url": "https://api.github.com/users/Codertocat/events{/privacy}", + "received_events_url": "https://api.github.com/users/Codertocat/received_events", + "type": "User", + "site_admin": false + }, + "labels": [ + { + "id": 949737505, + "node_id": "MDU6TGFiZWw5NDk3Mzc1MDU=", + "url": "https://api.github.com/repos/Codertocat/Hello-World/labels/bug", + "name": "bug", + "color": "d73a4a", + "default": true + } + ], + "state": "open", + "locked": false, + "assignee": null, + "assignees": [ + + ], + "milestone": null, + "comments": 0, + "created_at": "2018-05-30T20:18:32Z", + "updated_at": "2018-05-30T20:18:32Z", + "closed_at": null, + "author_association": "OWNER", + "body": "It looks like you accidently spelled 'commit' with two 't's." + }, + "comment": { + "url": "https://api.github.com/repos/Codertocat/Hello-World/issues/comments/393304133", + "html_url": "https://github.com/Codertocat/Hello-World/issues/2#issuecomment-393304133", + "issue_url": "https://api.github.com/repos/Codertocat/Hello-World/issues/2", + "id": 393304133, + "node_id": "MDEyOklzc3VlQ29tbWVudDM5MzMwNDEzMw==", + "user": { + "login": "Codertocat", + "id": 21031067, + "node_id": "MDQ6VXNlcjIxMDMxMDY3", + "avatar_url": "https://avatars1.githubusercontent.com/u/21031067?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/Codertocat", + "html_url": "https://github.com/Codertocat", + "followers_url": "https://api.github.com/users/Codertocat/followers", + "following_url": "https://api.github.com/users/Codertocat/following{/other_user}", + "gists_url": "https://api.github.com/users/Codertocat/gists{/gist_id}", + "starred_url": "https://api.github.com/users/Codertocat/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/Codertocat/subscriptions", + "organizations_url": "https://api.github.com/users/Codertocat/orgs", + "repos_url": "https://api.github.com/users/Codertocat/repos", + "events_url": "https://api.github.com/users/Codertocat/events{/privacy}", + "received_events_url": "https://api.github.com/users/Codertocat/received_events", + "type": "User", + "site_admin": false + }, + "created_at": "2018-05-30T20:18:32Z", + "updated_at": "2018-05-30T20:18:32Z", + "author_association": "OWNER", + "body": "You are totally right! I'll get this fixed right away." + }, + "repository": { + "id": 135493233, + "node_id": "MDEwOlJlcG9zaXRvcnkxMzU0OTMyMzM=", + "name": "Hello-World", + "full_name": "Codertocat/Hello-World", + "owner": { + "login": "Codertocat", + "id": 21031067, + "node_id": "MDQ6VXNlcjIxMDMxMDY3", + "avatar_url": "https://avatars1.githubusercontent.com/u/21031067?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/Codertocat", + "html_url": "https://github.com/Codertocat", + "followers_url": "https://api.github.com/users/Codertocat/followers", + "following_url": "https://api.github.com/users/Codertocat/following{/other_user}", + "gists_url": "https://api.github.com/users/Codertocat/gists{/gist_id}", + "starred_url": "https://api.github.com/users/Codertocat/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/Codertocat/subscriptions", + "organizations_url": "https://api.github.com/users/Codertocat/orgs", + "repos_url": "https://api.github.com/users/Codertocat/repos", + "events_url": "https://api.github.com/users/Codertocat/events{/privacy}", + "received_events_url": "https://api.github.com/users/Codertocat/received_events", + "type": "User", + "site_admin": false + }, + "private": false, + "html_url": "https://github.com/Codertocat/Hello-World", + "description": null, + "fork": false, + "url": "https://api.github.com/repos/Codertocat/Hello-World", + "forks_url": "https://api.github.com/repos/Codertocat/Hello-World/forks", + "keys_url": "https://api.github.com/repos/Codertocat/Hello-World/keys{/key_id}", + "collaborators_url": "https://api.github.com/repos/Codertocat/Hello-World/collaborators{/collaborator}", + "teams_url": "https://api.github.com/repos/Codertocat/Hello-World/teams", + "hooks_url": "https://api.github.com/repos/Codertocat/Hello-World/hooks", + "issue_events_url": "https://api.github.com/repos/Codertocat/Hello-World/issues/events{/number}", + "events_url": "https://api.github.com/repos/Codertocat/Hello-World/events", + "assignees_url": "https://api.github.com/repos/Codertocat/Hello-World/assignees{/user}", + "branches_url": "https://api.github.com/repos/Codertocat/Hello-World/branches{/branch}", + "tags_url": "https://api.github.com/repos/Codertocat/Hello-World/tags", + "blobs_url": "https://api.github.com/repos/Codertocat/Hello-World/git/blobs{/sha}", + "git_tags_url": "https://api.github.com/repos/Codertocat/Hello-World/git/tags{/sha}", + "git_refs_url": "https://api.github.com/repos/Codertocat/Hello-World/git/refs{/sha}", + "trees_url": "https://api.github.com/repos/Codertocat/Hello-World/git/trees{/sha}", + "statuses_url": "https://api.github.com/repos/Codertocat/Hello-World/statuses/{sha}", + "languages_url": "https://api.github.com/repos/Codertocat/Hello-World/languages", + "stargazers_url": "https://api.github.com/repos/Codertocat/Hello-World/stargazers", + "contributors_url": "https://api.github.com/repos/Codertocat/Hello-World/contributors", + "subscribers_url": "https://api.github.com/repos/Codertocat/Hello-World/subscribers", + "subscription_url": "https://api.github.com/repos/Codertocat/Hello-World/subscription", + "commits_url": "https://api.github.com/repos/Codertocat/Hello-World/commits{/sha}", + "git_commits_url": "https://api.github.com/repos/Codertocat/Hello-World/git/commits{/sha}", + "comments_url": "https://api.github.com/repos/Codertocat/Hello-World/comments{/number}", + "issue_comment_url": "https://api.github.com/repos/Codertocat/Hello-World/issues/comments{/number}", + "contents_url": "https://api.github.com/repos/Codertocat/Hello-World/contents/{+path}", + "compare_url": "https://api.github.com/repos/Codertocat/Hello-World/compare/{base}...{head}", + "merges_url": "https://api.github.com/repos/Codertocat/Hello-World/merges", + "archive_url": "https://api.github.com/repos/Codertocat/Hello-World/{archive_format}{/ref}", + "downloads_url": "https://api.github.com/repos/Codertocat/Hello-World/downloads", + "issues_url": "https://api.github.com/repos/Codertocat/Hello-World/issues{/number}", + "pulls_url": "https://api.github.com/repos/Codertocat/Hello-World/pulls{/number}", + "milestones_url": "https://api.github.com/repos/Codertocat/Hello-World/milestones{/number}", + "notifications_url": "https://api.github.com/repos/Codertocat/Hello-World/notifications{?since,all,participating}", + "labels_url": "https://api.github.com/repos/Codertocat/Hello-World/labels{/name}", + "releases_url": "https://api.github.com/repos/Codertocat/Hello-World/releases{/id}", + "deployments_url": "https://api.github.com/repos/Codertocat/Hello-World/deployments", + "created_at": "2018-05-30T20:18:04Z", + "updated_at": "2018-05-30T20:18:10Z", + "pushed_at": "2018-05-30T20:18:30Z", + "git_url": "git://github.com/Codertocat/Hello-World.git", + "ssh_url": "git@github.com:Codertocat/Hello-World.git", + "clone_url": "https://github.com/Codertocat/Hello-World.git", + "svn_url": "https://github.com/Codertocat/Hello-World", + "homepage": null, + "size": 0, + "stargazers_count": 0, + "watchers_count": 0, + "language": null, + "has_issues": true, + "has_projects": true, + "has_downloads": true, + "has_wiki": true, + "has_pages": true, + "forks_count": 0, + "mirror_url": null, + "archived": false, + "open_issues_count": 2, + "license": null, + "forks": 0, + "open_issues": 2, + "watchers": 0, + "default_branch": "master" + }, + "sender": { + "login": "Codertocat", + "id": 21031067, + "node_id": "MDQ6VXNlcjIxMDMxMDY3", + "avatar_url": "https://avatars1.githubusercontent.com/u/21031067?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/Codertocat", + "html_url": "https://github.com/Codertocat", + "followers_url": "https://api.github.com/users/Codertocat/followers", + "following_url": "https://api.github.com/users/Codertocat/following{/other_user}", + "gists_url": "https://api.github.com/users/Codertocat/gists{/gist_id}", + "starred_url": "https://api.github.com/users/Codertocat/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/Codertocat/subscriptions", + "organizations_url": "https://api.github.com/users/Codertocat/orgs", + "repos_url": "https://api.github.com/users/Codertocat/repos", + "events_url": "https://api.github.com/users/Codertocat/events{/privacy}", + "received_events_url": "https://api.github.com/users/Codertocat/received_events", + "type": "User", + "site_admin": false + } +} \ No newline at end of file diff --git a/presentation/controller/webhook/testdata/push.correct.json b/presentation/controller/webhook/testdata/push.correct.json new file mode 100644 index 00000000..0d987d18 --- /dev/null +++ b/presentation/controller/webhook/testdata/push.correct.json @@ -0,0 +1,135 @@ +{ + "ref": "refs/tags/simple-tag", + "before": "a10867b14bb761a232cd80139fbd4c0d33264240", + "after": "0000000000000000000000000000000000000000", + "created": false, + "deleted": true, + "forced": false, + "base_ref": null, + "compare": "https://github.com/Codertocat/Hello-World/compare/a10867b14bb7...000000000000", + "commits": [ + + ], + "head_commit": null, + "repository": { + "id": 135493233, + "node_id": "MDEwOlJlcG9zaXRvcnkxMzU0OTMyMzM=", + "name": "Hello-World", + "full_name": "Codertocat/Hello-World", + "owner": { + "name": "Codertocat", + "email": "21031067+Codertocat@users.noreply.github.com", + "login": "Codertocat", + "id": 21031067, + "node_id": "MDQ6VXNlcjIxMDMxMDY3", + "avatar_url": "https://avatars1.githubusercontent.com/u/21031067?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/Codertocat", + "html_url": "https://github.com/Codertocat", + "followers_url": "https://api.github.com/users/Codertocat/followers", + "following_url": "https://api.github.com/users/Codertocat/following{/other_user}", + "gists_url": "https://api.github.com/users/Codertocat/gists{/gist_id}", + "starred_url": "https://api.github.com/users/Codertocat/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/Codertocat/subscriptions", + "organizations_url": "https://api.github.com/users/Codertocat/orgs", + "repos_url": "https://api.github.com/users/Codertocat/repos", + "events_url": "https://api.github.com/users/Codertocat/events{/privacy}", + "received_events_url": "https://api.github.com/users/Codertocat/received_events", + "type": "User", + "site_admin": false + }, + "private": false, + "html_url": "https://github.com/Codertocat/Hello-World", + "description": null, + "fork": false, + "url": "https://github.com/Codertocat/Hello-World", + "forks_url": "https://api.github.com/repos/Codertocat/Hello-World/forks", + "keys_url": "https://api.github.com/repos/Codertocat/Hello-World/keys{/key_id}", + "collaborators_url": "https://api.github.com/repos/Codertocat/Hello-World/collaborators{/collaborator}", + "teams_url": "https://api.github.com/repos/Codertocat/Hello-World/teams", + "hooks_url": "https://api.github.com/repos/Codertocat/Hello-World/hooks", + "issue_events_url": "https://api.github.com/repos/Codertocat/Hello-World/issues/events{/number}", + "events_url": "https://api.github.com/repos/Codertocat/Hello-World/events", + "assignees_url": "https://api.github.com/repos/Codertocat/Hello-World/assignees{/user}", + "branches_url": "https://api.github.com/repos/Codertocat/Hello-World/branches{/branch}", + "tags_url": "https://api.github.com/repos/Codertocat/Hello-World/tags", + "blobs_url": "https://api.github.com/repos/Codertocat/Hello-World/git/blobs{/sha}", + "git_tags_url": "https://api.github.com/repos/Codertocat/Hello-World/git/tags{/sha}", + "git_refs_url": "https://api.github.com/repos/Codertocat/Hello-World/git/refs{/sha}", + "trees_url": "https://api.github.com/repos/Codertocat/Hello-World/git/trees{/sha}", + "statuses_url": "https://api.github.com/repos/Codertocat/Hello-World/statuses/{sha}", + "languages_url": "https://api.github.com/repos/Codertocat/Hello-World/languages", + "stargazers_url": "https://api.github.com/repos/Codertocat/Hello-World/stargazers", + "contributors_url": "https://api.github.com/repos/Codertocat/Hello-World/contributors", + "subscribers_url": "https://api.github.com/repos/Codertocat/Hello-World/subscribers", + "subscription_url": "https://api.github.com/repos/Codertocat/Hello-World/subscription", + "commits_url": "https://api.github.com/repos/Codertocat/Hello-World/commits{/sha}", + "git_commits_url": "https://api.github.com/repos/Codertocat/Hello-World/git/commits{/sha}", + "comments_url": "https://api.github.com/repos/Codertocat/Hello-World/comments{/number}", + "issue_comment_url": "https://api.github.com/repos/Codertocat/Hello-World/issues/comments{/number}", + "contents_url": "https://api.github.com/repos/Codertocat/Hello-World/contents/{+path}", + "compare_url": "https://api.github.com/repos/Codertocat/Hello-World/compare/{base}...{head}", + "merges_url": "https://api.github.com/repos/Codertocat/Hello-World/merges", + "archive_url": "https://api.github.com/repos/Codertocat/Hello-World/{archive_format}{/ref}", + "downloads_url": "https://api.github.com/repos/Codertocat/Hello-World/downloads", + "issues_url": "https://api.github.com/repos/Codertocat/Hello-World/issues{/number}", + "pulls_url": "https://api.github.com/repos/Codertocat/Hello-World/pulls{/number}", + "milestones_url": "https://api.github.com/repos/Codertocat/Hello-World/milestones{/number}", + "notifications_url": "https://api.github.com/repos/Codertocat/Hello-World/notifications{?since,all,participating}", + "labels_url": "https://api.github.com/repos/Codertocat/Hello-World/labels{/name}", + "releases_url": "https://api.github.com/repos/Codertocat/Hello-World/releases{/id}", + "deployments_url": "https://api.github.com/repos/Codertocat/Hello-World/deployments", + "created_at": 1527711484, + "updated_at": "2018-05-30T20:18:35Z", + "pushed_at": 1527711528, + "git_url": "git://github.com/Codertocat/Hello-World.git", + "ssh_url": "git@github.com:Codertocat/Hello-World.git", + "clone_url": "https://github.com/Codertocat/Hello-World.git", + "svn_url": "https://github.com/Codertocat/Hello-World", + "homepage": null, + "size": 0, + "stargazers_count": 0, + "watchers_count": 0, + "language": null, + "has_issues": true, + "has_projects": true, + "has_downloads": true, + "has_wiki": true, + "has_pages": true, + "forks_count": 0, + "mirror_url": null, + "archived": false, + "open_issues_count": 2, + "license": null, + "forks": 0, + "open_issues": 2, + "watchers": 0, + "default_branch": "master", + "stargazers": 0, + "master_branch": "master" + }, + "pusher": { + "name": "Codertocat", + "email": "21031067+Codertocat@users.noreply.github.com" + }, + "sender": { + "login": "Codertocat", + "id": 21031067, + "node_id": "MDQ6VXNlcjIxMDMxMDY3", + "avatar_url": "https://avatars1.githubusercontent.com/u/21031067?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/Codertocat", + "html_url": "https://github.com/Codertocat", + "followers_url": "https://api.github.com/users/Codertocat/followers", + "following_url": "https://api.github.com/users/Codertocat/following{/other_user}", + "gists_url": "https://api.github.com/users/Codertocat/gists{/gist_id}", + "starred_url": "https://api.github.com/users/Codertocat/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/Codertocat/subscriptions", + "organizations_url": "https://api.github.com/users/Codertocat/orgs", + "repos_url": "https://api.github.com/users/Codertocat/repos", + "events_url": "https://api.github.com/users/Codertocat/events{/privacy}", + "received_events_url": "https://api.github.com/users/Codertocat/received_events", + "type": "User", + "site_admin": false + } +} \ No newline at end of file diff --git a/presentation/controller/webhooks.go b/presentation/controller/webhooks.go deleted file mode 100644 index 72824a8a..00000000 --- a/presentation/controller/webhooks.go +++ /dev/null @@ -1,176 +0,0 @@ -package controller - -import ( - "encoding/json" - "fmt" - "github.com/duck8823/duci/application" - "github.com/duck8823/duci/application/context" - "github.com/duck8823/duci/application/service/github" - "github.com/duck8823/duci/application/service/runner" - "github.com/duck8823/duci/infrastructure/logger" - go_github "github.com/google/go-github/github" - "github.com/google/uuid" - "github.com/pkg/errors" - "gopkg.in/src-d/go-git.v4/plumbing" - "net/http" - "net/url" - "regexp" - "strings" -) - -// ErrSkipBuild is a error of build skip. -var ErrSkipBuild = errors.New("build skip") - -// WebhooksController is a handler of webhook. -type WebhooksController struct { - Runner runner.Runner - GitHub github.Service -} - -// Command represents docker command. -type Command []string - -// ServeHTTP receive webhook. -func (c *WebhooksController) ServeHTTP(w http.ResponseWriter, r *http.Request) { - requestID, err := requestID(r) - if err != nil { - logger.Error(requestID, err.Error()) - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - - // Trigger build - githubEvent := r.Header.Get("X-GitHub-Event") - switch githubEvent { - case "issue_comment": - c.runWithIssueCommentEvent(requestID, w, r) - case "push": - c.runWithPushEvent(requestID, w, r) - default: - message := fmt.Sprintf("payload event type must be issue_comment or push. but %s", githubEvent) - logger.Error(requestID, message) - http.Error(w, message, http.StatusInternalServerError) - return - } - - // Response - w.WriteHeader(http.StatusOK) -} - -func (c *WebhooksController) runWithIssueCommentEvent(requestID uuid.UUID, w http.ResponseWriter, r *http.Request) { - ctx, src, command, err := c.parseIssueComment(requestID, r) - if err == ErrSkipBuild { - logger.Info(requestID, "skip build") - w.WriteHeader(http.StatusOK) - w.Write([]byte(err.Error())) - return - } else if err != nil { - logger.Errorf(requestID, "%+v", err) - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - go c.run(ctx, src, command...) -} - -func (c *WebhooksController) parseIssueComment(requestID uuid.UUID, r *http.Request) (context.Context, *github.TargetSource, Command, error) { - event := &go_github.IssueCommentEvent{} - if err := json.NewDecoder(r.Body).Decode(event); err != nil { - return nil, nil, nil, errors.WithStack(err) - } - - cmd, err := command(event) - if err != nil { - return nil, nil, nil, errors.Cause(err) - } - ctx := context.New(fmt.Sprintf("%s/pr/%s", application.Name, cmd[0]), requestID, runtimeURL(r)) - - src, err := c.targetSource(ctx, *event) - if err != nil { - return nil, nil, nil, errors.WithStack(err) - } - - return ctx, src, cmd, err -} - -func (c *WebhooksController) targetSource(ctx context.Context, event go_github.IssueCommentEvent) (*github.TargetSource, error) { - pr, err := c.GitHub.GetPullRequest(ctx, event.GetRepo(), event.GetIssue().GetNumber()) - if err != nil { - return nil, errors.WithStack(err) - } - - src := &github.TargetSource{ - Repo: event.GetRepo(), - Ref: fmt.Sprintf("refs/heads/%s", pr.GetHead().GetRef()), - SHA: plumbing.NewHash(pr.GetHead().GetSHA()), - } - return src, nil -} - -func (c *WebhooksController) runWithPushEvent(requestID uuid.UUID, w http.ResponseWriter, r *http.Request) { - event := &go_github.PushEvent{} - if err := json.NewDecoder(r.Body).Decode(event); err != nil { - logger.Errorf(requestID, "%+v", err) - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - sha := event.GetHeadCommit().GetID() - if len(sha) == 0 { - logger.Info(requestID, "skip build: could not get head commit") - w.WriteHeader(http.StatusOK) - w.Write([]byte("skip build")) - return - } - - ctx := context.New(fmt.Sprintf("%s/push", application.Name), requestID, runtimeURL(r)) - go c.run(ctx, &github.TargetSource{Repo: event.GetRepo(), Ref: event.GetRef(), SHA: plumbing.NewHash(sha)}) -} - -func (c *WebhooksController) run(ctx context.Context, src *github.TargetSource, cmd ...string) { - if err := c.Runner.Run(ctx, src); err != nil { - logger.Errorf(ctx.UUID(), "error occur: %+v", err) - } -} - -func requestID(r *http.Request) (uuid.UUID, error) { - deliveryID := go_github.DeliveryID(r) - requestID, err := uuid.Parse(deliveryID) - if err != nil { - msg := fmt.Sprintf("Error: invalid request header `X-GitHub-Delivery`: %+v", deliveryID) - return uuid.New(), errors.Wrap(err, msg) - } - return requestID, nil -} - -func runtimeURL(r *http.Request) *url.URL { - runtimeURL := &url.URL{ - Scheme: "http", - Host: r.Host, - Path: r.URL.Path, - } - if r.URL.Scheme != "" { - runtimeURL.Scheme = r.URL.Scheme - } - return runtimeURL -} - -func command(event *go_github.IssueCommentEvent) (Command, error) { - if !isValidAction(event.Action) { - return Command{}, ErrSkipBuild - } - - if !regexp.MustCompile("^ci\\s+[^\\s]+").Match([]byte(event.Comment.GetBody())) { - return Command{}, ErrSkipBuild - } - phrase := regexp.MustCompile("^ci\\s+").ReplaceAllString(event.Comment.GetBody(), "") - command := strings.Split(phrase, " ") - return command, nil -} - -func isValidAction(action *string) bool { - if action == nil { - return false - } - return *action == "created" || *action == "edited" -} diff --git a/presentation/controller/webhooks_test.go b/presentation/controller/webhooks_test.go deleted file mode 100644 index d9174c12..00000000 --- a/presentation/controller/webhooks_test.go +++ /dev/null @@ -1,390 +0,0 @@ -package controller_test - -import ( - "bytes" - "encoding/json" - "github.com/duck8823/duci/application/service/github/mock_github" - "github.com/duck8823/duci/application/service/runner/mock_runner" - "github.com/duck8823/duci/presentation/controller" - "github.com/golang/mock/gomock" - "github.com/google/go-github/github" - "github.com/google/uuid" - "github.com/pkg/errors" - "io" - "net/http/httptest" - "strings" - "testing" -) - -func TestWebhooksController_ServeHTTP(t *testing.T) { - // setup - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - t.Run("with correct payload", func(t *testing.T) { - // given - requestID, _ := uuid.NewRandom() - - t.Run("when issue_comment", func(t *testing.T) { - // given - event := "issue_comment" - - t.Run("when github service returns no error", func(t *testing.T) { - // given - runner := mock_runner.NewMockRunner(ctrl) - runner.EXPECT().Run(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes() - - githubService := mock_github.NewMockService(ctrl) - githubService.EXPECT().GetPullRequest(gomock.Any(), gomock.Any(), gomock.Any()). - AnyTimes(). - Return(&github.PullRequest{ - Head: &github.PullRequestBranch{ - SHA: new(string), - }, - }, nil) - githubService.EXPECT().CreateCommitStatus(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). - AnyTimes(). - Return(nil) - - // and - handler := &controller.WebhooksController{Runner: runner, GitHub: githubService} - - s := httptest.NewServer(handler) - defer s.Close() - - t.Run("with valid action", func(t *testing.T) { - actions := []string{"created", "edited"} - for _, action := range actions { - // and - payload := createIssueCommentPayload(t, action, "ci test") - - req := httptest.NewRequest("POST", "/", payload) - req.Header.Set("X-GitHub-Delivery", requestID.String()) - req.Header.Set("X-GitHub-Event", event) - rec := httptest.NewRecorder() - - // when - handler.ServeHTTP(rec, req) - - // then - if rec.Code != 200 { - t.Errorf("status must equal %+v, but got %+v", 200, rec.Code) - } - } - }) - - t.Run("with invalid action", func(t *testing.T) { - actions := []string{"deleted", "foo", ""} - for _, action := range actions { - t.Run(action, func(t *testing.T) { - // given - body := createIssueCommentPayload(t, action, "ci test") - - // and - req := httptest.NewRequest("POST", "/", body) - req.Header.Set("X-GitHub-Delivery", requestID.String()) - req.Header.Set("X-GitHub-Event", event) - rec := httptest.NewRecorder() - - // when - handler.ServeHTTP(rec, req) - - // then - if rec.Code != 200 { - t.Errorf("status must equal %+v, but got %+v", 200, rec.Code) - } - - if rec.Body.String() != "build skip" { - t.Errorf("body must equal %+v, but got %+v", "build skip", rec.Body.String()) - } - }) - } - }) - }) - - t.Run("when github service return error", func(t *testing.T) { - // given - runner := mock_runner.NewMockRunner(ctrl) - runner.EXPECT().Run(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(0) - - githubService := mock_github.NewMockService(ctrl) - githubService.EXPECT().GetPullRequest(gomock.Any(), gomock.Any(), gomock.Any()). - AnyTimes(). - Return(nil, errors.New("error occur")) - githubService.EXPECT().CreateCommitStatus(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). - AnyTimes(). - Return(nil) - - // and - handler := &controller.WebhooksController{Runner: runner, GitHub: githubService} - - s := httptest.NewServer(handler) - defer s.Close() - - // and - payload := createIssueCommentPayload(t, "created", "ci test") - - req := httptest.NewRequest("POST", "/", payload) - req.Header.Set("X-GitHub-Delivery", requestID.String()) - req.Header.Set("X-GitHub-Event", event) - rec := httptest.NewRecorder() - - // when - handler.ServeHTTP(rec, req) - - // then - if rec.Code != 500 { - t.Errorf("status must equal %+v, but got %+v", 500, rec.Code) - } - }) - }) - - t.Run("when push", func(t *testing.T) { - // setup - githubService := mock_github.NewMockService(ctrl) - githubService.EXPECT().GetPullRequest(gomock.Any(), gomock.Any(), gomock.Any()). - AnyTimes(). - Return(&github.PullRequest{ - Head: &github.PullRequestBranch{ - SHA: new(string), - }, - }, nil) - githubService.EXPECT().CreateCommitStatus(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). - AnyTimes(). - Return(nil) - - t.Run("with head_commit.id", func(t *testing.T) { - // given - runner := mock_runner.NewMockRunner(ctrl) - runner.EXPECT().Run(gomock.Any(), gomock.Any()).Times(1) - - // and - handler := &controller.WebhooksController{Runner: runner, GitHub: githubService} - - s := httptest.NewServer(handler) - defer s.Close() - - // and - payload := createPushPayload(t, "test/repo", "master", "sha") - - req := httptest.NewRequest("POST", "/", payload) - req.Header.Set("X-GitHub-Delivery", requestID.String()) - req.Header.Set("X-GitHub-Event", "push") - rec := httptest.NewRecorder() - - // when - handler.ServeHTTP(rec, req) - - // then - if rec.Code != 200 { - t.Errorf("status must equal %+v, but got %+v", 200, rec.Code) - } - }) - - t.Run("without head_commit.id", func(t *testing.T) { - // given - runner := mock_runner.NewMockRunner(ctrl) - runner.EXPECT().Run(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(0) - - // and - handler := &controller.WebhooksController{Runner: runner, GitHub: githubService} - - s := httptest.NewServer(handler) - defer s.Close() - - // and - payload := createPushPayload(t, "test/repo", "master", "") - - req := httptest.NewRequest("POST", "/", payload) - req.Header.Set("X-GitHub-Delivery", requestID.String()) - req.Header.Set("X-GitHub-Event", "push") - rec := httptest.NewRecorder() - - // when - handler.ServeHTTP(rec, req) - - // then - if rec.Code != 200 { - t.Errorf("status must equal %+v, but got %+v", 200, rec.Code) - } - }) - }) - }) - - t.Run("with invalid payload", func(t *testing.T) { - // setup - runner := mock_runner.NewMockRunner(ctrl) - runner.EXPECT().Run(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(0) - - // and - githubService := mock_github.NewMockService(ctrl) - githubService.EXPECT().GetPullRequest(gomock.Any(), gomock.Any(), gomock.Any()). - AnyTimes(). - Return(&github.PullRequest{ - Head: &github.PullRequestBranch{ - SHA: new(string), - }, - }, nil) - githubService.EXPECT().CreateCommitStatus(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). - AnyTimes(). - Return(nil) - - // and - handler := &controller.WebhooksController{Runner: runner, GitHub: githubService} - - s := httptest.NewServer(handler) - defer s.Close() - - t.Run("with invalid `X-GitHub-Event` header", func(t *testing.T) { - // given - body := createIssueCommentPayload(t, "created", "ci test") - - // and - requestID, _ := uuid.NewRandom() - - req := httptest.NewRequest("POST", "/", body) - req.Header.Set("X-GitHub-Delivery", requestID.String()) - req.Header.Set("X-GitHub-Event", "hogefuga") - rec := httptest.NewRecorder() - - // when - handler.ServeHTTP(rec, req) - - // then - if rec.Code != 500 { - t.Errorf("status must equal %+v, but got %+v", 500, rec.Code) - } - }) - - t.Run("with invalid `X-GitHub-Delivery` header", func(t *testing.T) { - // given - body := createIssueCommentPayload(t, "created", "ci test") - - // and - req := httptest.NewRequest("POST", "/", body) - req.Header.Set("X-GitHub-Delivery", "hogefuga") - req.Header.Set("X-GitHub-Event", "push") - rec := httptest.NewRecorder() - - // when - handler.ServeHTTP(rec, req) - - // then - if rec.Code != 400 { - t.Errorf("status must equal %+v, but got %+v", 400, rec.Code) - } - }) - - t.Run("with issue_comment", func(t *testing.T) { - // given - event := "issue_comment" - requestID, _ := uuid.NewRandom() - - t.Run("without comment started ci", func(t *testing.T) { - // given - body := createIssueCommentPayload(t, "created", "test") - - // and - req := httptest.NewRequest("POST", "/", body) - req.Header.Set("X-GitHub-Delivery", requestID.String()) - req.Header.Set("X-GitHub-Event", event) - rec := httptest.NewRecorder() - - // when - handler.ServeHTTP(rec, req) - - // then - if rec.Code != 200 { - t.Errorf("status must equal %+v, but got %+v", 200, rec.Code) - } - - if rec.Body.String() != "build skip" { - t.Errorf("body must equal %+v, but got %+v", "build skip", rec.Body.String()) - } - }) - - t.Run("with invalid body", func(t *testing.T) { - // given - body := strings.NewReader("Invalid JSON format.") - - // and - req := httptest.NewRequest("POST", "/", body) - req.Header.Set("X-GitHub-Delivery", requestID.String()) - req.Header.Set("X-GitHub-Event", event) - rec := httptest.NewRecorder() - - // when - handler.ServeHTTP(rec, req) - - // then - if rec.Code != 500 { - t.Errorf("status must equal %+v, but got %+v", 500, rec.Code) - } - }) - }) - - t.Run("with push", func(t *testing.T) { - // given - event := "push" - requestID, _ := uuid.NewRandom() - - t.Run("with invalid body", func(t *testing.T) { - // given - body := strings.NewReader("Invalid JSON format.") - - // and - req := httptest.NewRequest("POST", "/", body) - req.Header.Set("X-GitHub-Delivery", requestID.String()) - req.Header.Set("X-GitHub-Event", event) - rec := httptest.NewRecorder() - - // when - handler.ServeHTTP(rec, req) - - // then - if rec.Code != 500 { - t.Errorf("status must equal %+v, but got %+v", 500, rec.Code) - } - }) - }) - }) -} - -func createIssueCommentPayload(t *testing.T, action, comment string) io.Reader { - t.Helper() - - event := &github.IssueCommentEvent{ - Repo: &github.Repository{}, - Action: &action, - Issue: &github.Issue{ - Number: new(int), - }, - Comment: &github.IssueComment{ - Body: &comment, - }, - } - payload, err := json.Marshal(event) - if err != nil { - t.Fatalf("error occurred. %+v", err) - } - return bytes.NewReader(payload) -} - -func createPushPayload(t *testing.T, repoName, ref string, sha string) io.Reader { - t.Helper() - - event := github.PushEvent{ - Repo: &github.PushEventRepository{ - FullName: &repoName, - }, - Ref: &ref, - HeadCommit: &github.PushEventCommit{ - ID: &sha, - }, - } - payload, err := json.Marshal(event) - if err != nil { - t.Fatalf("error occurred: %+v", err) - } - return bytes.NewReader(payload) -} diff --git a/presentation/router/router.go b/presentation/router/router.go index 858b467e..b2f4cbea 100644 --- a/presentation/router/router.go +++ b/presentation/router/router.go @@ -1,13 +1,9 @@ package router import ( - "github.com/duck8823/duci/application" - "github.com/duck8823/duci/application/service/docker" - "github.com/duck8823/duci/application/service/git" - "github.com/duck8823/duci/application/service/github" - "github.com/duck8823/duci/application/service/logstore" - "github.com/duck8823/duci/application/service/runner" - "github.com/duck8823/duci/presentation/controller" + "github.com/duck8823/duci/presentation/controller/health" + "github.com/duck8823/duci/presentation/controller/job" + "github.com/duck8823/duci/presentation/controller/webhook" "github.com/go-chi/chi" "github.com/pkg/errors" "net/http" @@ -15,59 +11,25 @@ import ( // New returns handler of application. func New() (http.Handler, error) { - dockerService, logstoreService, githubService, err := createCommonServices() + webhookHandler, err := webhook.NewHandler() if err != nil { return nil, errors.WithStack(err) } - dockerRunner, err := createRunner(logstoreService, githubService, dockerService) + jobHandler, err := job.NewHandler() if err != nil { return nil, errors.WithStack(err) } - webhooksCtrl := &controller.WebhooksController{Runner: dockerRunner, GitHub: githubService} - logCtrl := &controller.LogController{LogStore: logstoreService} - healthCtrl := &controller.HealthController{Docker: dockerService} - - rtr := chi.NewRouter() - rtr.Post("/", webhooksCtrl.ServeHTTP) - rtr.Get("/logs/{uuid}", logCtrl.ServeHTTP) - rtr.Get("/health", healthCtrl.ServeHTTP) - - return rtr, nil -} - -func createCommonServices() (docker.Service, logstore.Service, github.Service, error) { - dockerService, err := docker.New() - if err != nil { - return nil, nil, nil, errors.WithStack(err) - } - - logstoreService, err := logstore.New() - if err != nil { - return nil, nil, nil, errors.WithStack(err) - } - githubService, err := github.New() - if err != nil { - return nil, nil, nil, errors.WithStack(err) - } - - return dockerService, logstoreService, githubService, nil -} - -func createRunner(logstoreService logstore.Service, githubService github.Service, dockerService docker.Service) (runner.Runner, error) { - gitClient, err := git.New() + healthHandler, err := health.NewHandler() if err != nil { return nil, errors.WithStack(err) } - dockerRunner := &runner.DockerRunner{ - BaseWorkDir: application.Config.Server.WorkDir, - Git: gitClient, - GitHub: githubService, - Docker: dockerService, - LogStore: logstoreService, - } + rtr := chi.NewRouter() + rtr.Post("/", webhookHandler.ServeHTTP) + rtr.Get("/logs/{uuid}", jobHandler.ServeHTTP) + rtr.Get("/health", healthHandler.ServeHTTP) - return dockerRunner, nil + return rtr, nil } diff --git a/presentation/router/router_test.go b/presentation/router/router_test.go new file mode 100644 index 00000000..a68aa767 --- /dev/null +++ b/presentation/router/router_test.go @@ -0,0 +1,64 @@ +package router_test + +import ( + "github.com/duck8823/duci/application/service/job" + "github.com/duck8823/duci/domain/model/job/target/github" + "github.com/duck8823/duci/internal/container" + "github.com/duck8823/duci/presentation/router" + "os" + "testing" +) + +func TestNew(t *testing.T) { + t.Run("with no error", func(t *testing.T) { + // given + container.Override(new(job.Service)) + container.Override(new(github.GitHub)) + defer container.Clear() + + // when + _, err := router.New() + + // then + if err != nil { + t.Errorf("error must be nil, but got %+v", err) + } + }) + + t.Run("when component not enough", func(t *testing.T) { + // given + container.Clear() + + // when + _, err := router.New() + + // then + if err == nil { + t.Error("error must not be nil") + } + }) + + t.Run("with invalid environment variable for docker client", func(t *testing.T) { + // given + container.Override(new(job.Service)) + container.Override(new(github.GitHub)) + defer container.Clear() + + // and + DOCKER_HOST := os.Getenv("DOCKER_HOST") + if err := os.Setenv("DOCKER_HOST", "invalid_host"); err != nil { + t.Fatalf("error occur: %+v", err) + } + defer func() { + _ = os.Setenv("DOCKER_HOST", DOCKER_HOST) + }() + + // when + _, err := router.New() + + // then + if err == nil { + t.Error("error must not be nil") + } + }) +}