diff --git a/go.mod b/go.mod index 171c01e18..6c5ca8308 100644 --- a/go.mod +++ b/go.mod @@ -60,6 +60,7 @@ require ( cloud.google.com/go/iam v1.5.0 // indirect cloud.google.com/go/longrunning v0.6.6 // indirect dario.cat/mergo v1.0.1 // indirect + github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 // indirect github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c // indirect github.com/atombender/go-jsonschema v0.16.0 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect @@ -71,6 +72,7 @@ require ( github.com/charmbracelet/x/term v0.2.1 // indirect github.com/chavacava/garif v0.1.0 // indirect github.com/cncf/xds/go v0.0.0-20250326154945-ae57f3c0d45f // indirect + github.com/containerd/log v0.1.0 // indirect github.com/cyphar/filepath-securejoin v0.4.1 // indirect github.com/distribution/reference v0.6.0 // indirect github.com/docker/go-connections v0.5.0 // indirect @@ -92,10 +94,15 @@ require ( github.com/gookit/color v1.5.4 // indirect github.com/hashicorp/go-version v1.7.0 // indirect github.com/icholy/gomajor v0.13.1 // indirect + github.com/klauspost/compress v1.17.11 // indirect github.com/mgechev/dots v0.0.0-20210922191527-e955255bf517 // indirect github.com/mgechev/revive v1.7.0 // indirect github.com/mitchellh/go-wordwrap v1.0.1 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/patternmatcher v0.6.0 // indirect + github.com/moby/sys/sequential v0.6.0 // indirect + github.com/moby/sys/user v0.1.0 // indirect + github.com/moby/sys/userns v0.1.0 // indirect github.com/moby/term v0.5.0 // indirect github.com/morikuni/aec v1.0.0 // indirect github.com/olekukonko/tablewriter v0.0.5 // indirect @@ -104,6 +111,7 @@ require ( github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/sanity-io/litter v1.5.5 // indirect github.com/securego/gosec/v2 v2.22.1 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect github.com/spf13/afero v1.12.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect diff --git a/go.sum b/go.sum index 39229368f..bf4f7721f 100644 --- a/go.sum +++ b/go.sum @@ -16,6 +16,8 @@ cloud.google.com/go/secretmanager v1.14.6 h1:/ooktIMSORaWk9gm3vf8+Mg+zSrUplJFKBz cloud.google.com/go/secretmanager v1.14.6/go.mod h1:0OWeM3qpJ2n71MGgNfKsgjC/9LfVTcUqXFUlGxo5PzY= dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8= github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c h1:pxW6RcqyfI9/kWtOwnv/G+AzdKuy2ZrqINhenH4HyNs= @@ -210,6 +212,8 @@ github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4 github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= +github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= @@ -243,6 +247,14 @@ github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQ github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= +github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= +github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= +github.com/moby/sys/user v0.1.0 h1:WmZ93f5Ux6het5iituh9x2zAG7NFY9Aqi49jjE1PaQg= +github.com/moby/sys/user v0.1.0/go.mod h1:fKJhFOnsCN6xZ5gSfbM6zaHGgDJMrqt9/reuj4T7MmU= +github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= +github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= diff --git a/internal/command/command.go b/internal/command/command.go index 96022f5c4..d9ba520c8 100644 --- a/internal/command/command.go +++ b/internal/command/command.go @@ -63,15 +63,15 @@ func (c *base) Running() bool { } func (c *base) Start(context.Context) error { - return errors.New("not implemented") + return errors.New("start: not implemented") } func (c *base) Signal(os.Signal) error { - return errors.New("not implemented") + return errors.New("signal: not implemented") } func (c *base) Wait(context.Context) error { - return errors.New("not implemented") + return errors.New("wait: not implemented") } func (c *base) Env() []string { diff --git a/internal/command/command_test.go b/internal/command/command_test.go index cb766e00c..f10aecfb2 100644 --- a/internal/command/command_test.go +++ b/internal/command/command_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "io" + "os/exec" "testing" "github.com/stretchr/testify/assert" @@ -39,6 +40,10 @@ func testExecuteCommandWithSession( ) { t.Helper() + if _, err := exec.LookPath(cfg.ProgramName); err != nil { + t.Skipf("command %q not found", cfg.ProgramName) + } + stdout := bytes.NewBuffer(nil) stderr := bytes.NewBuffer(nil) diff --git a/internal/command/factory.go b/internal/command/factory.go index a642a33f4..5f128b131 100644 --- a/internal/command/factory.go +++ b/internal/command/factory.go @@ -79,6 +79,12 @@ func WithRuntime(r Runtime) FactoryOption { } } +func WithUseSystemEnv(val bool) FactoryOption { + return func(f *commandFactory) { + f.useSystemEnv = val + } +} + func NewFactory(opts ...FactoryOption) Factory { f := &commandFactory{} for _, opt := range opts { @@ -91,11 +97,12 @@ func NewFactory(opts ...FactoryOption) Factory { } type commandFactory struct { - debug bool - docker *dockerexec.Docker - logger *zap.Logger - project *project.Project - runtime Runtime + debug bool + docker *dockerexec.Docker + logger *zap.Logger + project *project.Project + runtime Runtime + useSystemEnv bool } // Build creates a new command based on the provided [ProgramConfig] and [CommandOptions]. @@ -214,19 +221,11 @@ func (f *commandFactory) Build(cfg *ProgramConfig, opts CommandOptions) (Command } func (f *commandFactory) buildBase(cfg *ProgramConfig, opts CommandOptions) *base { - runtime := f.runtime - - if isNil(runtime) && f.docker != nil { - runtime = &dockerRuntime{Docker: f.docker} - } else if isNil(runtime) { - runtime = &hostRuntime{useSystem: true} - } - return &base{ cfg: cfg, logger: f.getLogger("Base"), project: f.project, - runtime: runtime, + runtime: f.getRuntime(), session: opts.Session, stdin: opts.Stdin, stdout: opts.Stdout, @@ -306,6 +305,18 @@ func (f *commandFactory) getLogger(name string) *zap.Logger { return f.logger.Named(name).With(zap.String("instanceID", id)) } +func (f *commandFactory) getRuntime() Runtime { + runtime := f.runtime + + if isNil(runtime) && f.docker != nil { + runtime = &dockerRuntime{Docker: f.docker} + } else if isNil(runtime) { + runtime = &hostRuntime{useSystem: f.useSystemEnv} + } + + return runtime +} + func isNil(val any) bool { if val == nil { return true diff --git a/internal/config/autoconfig/autoconfig.go b/internal/config/autoconfig/autoconfig.go index cb1f5e0eb..8fe637ad0 100644 --- a/internal/config/autoconfig/autoconfig.go +++ b/internal/config/autoconfig/autoconfig.go @@ -120,12 +120,18 @@ func getClientFactory(cfg *config.Config, logger *zap.Logger) (ClientFactory, er }, nil } -func getCommandFactory(docker *dockerexec.Docker, logger *zap.Logger, proj *project.Project) command.Factory { - return command.NewFactory( +func getCommandFactory(cfg *config.Config, docker *dockerexec.Docker, logger *zap.Logger, proj *project.Project) command.Factory { + opts := []command.FactoryOption{ command.WithDocker(docker), command.WithLogger(logger), command.WithProject(proj), - ) + } + + if env := cfg.Project.Env; env != nil { + opts = append(opts, command.WithUseSystemEnv(env.UseSystemEnv)) + } + + return command.NewFactory(opts...) } func getConfigLoader() (*config.Loader, error) { diff --git a/internal/dockerexec/docker.go b/internal/dockerexec/docker.go index c863efeb8..ee45bffc9 100644 --- a/internal/dockerexec/docker.go +++ b/internal/dockerexec/docker.go @@ -1,15 +1,19 @@ package dockerexec import ( + "bufio" "context" "encoding/hex" + "encoding/json" "io" "math/rand" "time" + "github.com/docker/docker/api/types" "github.com/docker/docker/api/types/filters" "github.com/docker/docker/api/types/image" "github.com/docker/docker/client" + "github.com/docker/docker/pkg/archive" "github.com/pkg/errors" "go.uber.org/zap" ) @@ -77,6 +81,14 @@ func (d *Docker) CommandContext(ctx context.Context, program string, args ...str } } +func (d *Docker) RemoveImage(ctx context.Context) error { + _, err := d.client.ImageRemove(ctx, d.image, image.RemoveOptions{Force: true, PruneChildren: true}) + if err != nil { + return errors.WithStack(err) + } + return nil +} + func (d *Docker) containerUniqueName() string { var hash [4]byte _, _ = d.rnd.Read(hash[:]) @@ -90,8 +102,62 @@ func (d *Docker) buildOrPullImage(ctx context.Context) error { return d.pullImage(ctx) } -func (d *Docker) buildImage(context.Context) error { - return errors.New("not implemented") +func (d *Docker) buildImage(ctx context.Context) error { + tar, err := archive.TarWithOptions(d.buildContext, &archive.TarOptions{}) + if err != nil { + return errors.WithMessage(err, "failed to create tar archive") + } + + resp, err := d.client.ImageBuild( + ctx, + tar, + types.ImageBuildOptions{ + Dockerfile: d.dockerfile, + Tags: []string{d.image}, + Remove: true, + ForceRemove: true, + NoCache: true, + }, + ) + if err != nil { + return errors.WithMessage(err, "failed to build image") + } + defer resp.Body.Close() + + return errors.WithStack( + logClientMessages(resp.Body, d.logger), + ) +} + +func logClientMessages(r io.Reader, logger *zap.Logger) error { + type errorLine struct { + Error string `json:"error"` + ErrorDetail struct { + Message string `json:"message"` + } `json:"errorDetail"` + } + + var lastLine string + + scanner := bufio.NewScanner(r) + + for scanner.Scan() { + lastLine = scanner.Text() + logger.Debug("docker build", zap.String("log", lastLine)) + } + + if err := scanner.Err(); err != nil { + return errors.WithMessage(err, "docker build") + } + + errLine := errorLine{} + if err := json.Unmarshal([]byte(lastLine), &errLine); err != nil { + return errors.WithMessage(err, "docker build") + } + if errLine.Error != "" { + return errors.Errorf("docker build: %s", errLine.Error) + } + return nil } func (d *Docker) pullImage(ctx context.Context) error { diff --git a/internal/dockerexec/docker_test.go b/internal/dockerexec/docker_test.go index 7db2a3158..625bd2cc5 100644 --- a/internal/dockerexec/docker_test.go +++ b/internal/dockerexec/docker_test.go @@ -5,11 +5,17 @@ package dockerexec import ( "bytes" "context" + "os" + "path/filepath" "testing" + "time" "github.com/stretchr/testify/require" + "go.uber.org/zap/zaptest" ) +const testAlpineImage = "alpine:3.19" + func TestDockerCommandContext(t *testing.T) { t.Parallel() @@ -17,7 +23,7 @@ func TestDockerCommandContext(t *testing.T) { docker, err := New( &Options{ - Image: "alpine:3.19", + Image: testAlpineImage, }, ) require.NoError(t, err) @@ -74,3 +80,76 @@ func TestDockerCommandContext(t *testing.T) { require.Equal(t, "hello\r\n", stdout.String()) }) } + +func TestDockerCommandContextWithCustomImage(t *testing.T) { + t.Parallel() + + logger := zaptest.NewLogger(t) + buildContext := t.TempDir() + + err := os.WriteFile( + filepath.Join(buildContext, "Dockerfile"), + []byte(`FROM `+testAlpineImage+` +COPY hello.txt /hello.txt +`), + 0o644, + ) + require.NoError(t, err) + err = os.WriteFile( + filepath.Join(buildContext, "hello.txt"), + []byte("hello from file"), + 0o644, + ) + require.NoError(t, err) + + docker, err := New( + &Options{ + Image: "runme-runner-test:latest", + BuildContext: buildContext, + Logger: logger, + }, + ) + require.NoError(t, err) + + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + testRemoveImage(t, ctx, docker) + }) + + workingDir := t.TempDir() + + t.Run("Basic", func(t *testing.T) { + t.Parallel() + + stdout := bytes.NewBuffer(nil) + + cmd := docker.CommandContext(context.Background(), "echo", "hello") + cmd.Dir = workingDir + cmd.Stdout = stdout + + require.NoError(t, cmd.Start()) + require.NoError(t, cmd.Wait()) + require.Equal(t, "hello\n", stdout.String()) + }) + + t.Run("BuildContextFile", func(t *testing.T) { + t.Parallel() + + stdout := bytes.NewBuffer(nil) + + cmd := docker.CommandContext(context.Background(), "cat", "/hello.txt") + cmd.Dir = workingDir + cmd.Stdout = stdout + + require.NoError(t, cmd.Start()) + require.NoError(t, cmd.Wait()) + require.Equal(t, "hello from file", stdout.String()) + }) +} + +func testRemoveImage(t *testing.T, ctx context.Context, docker *Docker) { + t.Helper() + err := docker.RemoveImage(ctx) + require.NoError(t, err) +}