From 423c17419a4228b9fa38cc63da68d3a96435fd30 Mon Sep 17 00:00:00 2001 From: Chris Lesiw Date: Sun, 13 Oct 2024 15:40:24 -0400 Subject: [PATCH] Add cmdio/ctr --- .github/workflows/main.yml | 1 + .ops/go.mod | 2 +- .ops/go.sum | 4 +- ctr/cmd.go | 58 ++++++++++++++++ ctr/runner.go | 133 +++++++++++++++++++++++++++++++++++++ ctr/runner_test.go | 55 +++++++++++++++ ctr/testdata/Dockerfile | 3 + io.go | 6 +- runner.go | 12 ++-- runner_suite_test.go | 40 +++++++++++ sub/runner.go | 35 ++++++++++ sys/runner.go | 6 +- 12 files changed, 341 insertions(+), 14 deletions(-) create mode 100644 ctr/cmd.go create mode 100644 ctr/runner.go create mode 100644 ctr/runner_test.go create mode 100644 ctr/testdata/Dockerfile create mode 100644 sub/runner.go diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index acb6be1..52b68b5 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -6,4 +6,5 @@ jobs: steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 + - uses: docker/setup-buildx-action@v3 - run: '"$(wget -O- lesiw.io/op | sh)"' diff --git a/.ops/go.mod b/.ops/go.mod index d5dfbc3..cbf6c06 100644 --- a/.ops/go.mod +++ b/.ops/go.mod @@ -10,7 +10,7 @@ require ( require ( golang.org/x/sync v0.8.0 // indirect lesiw.io/clerk v0.2.0 // indirect - lesiw.io/cmdio v0.1.0 // indirect + lesiw.io/cmdio v0.2.0 // indirect lesiw.io/defers v0.8.0 // indirect lesiw.io/flag v0.7.0 // indirect lesiw.io/prefix v0.1.0 // indirect diff --git a/.ops/go.sum b/.ops/go.sum index 6c158a6..1daa86f 100644 --- a/.ops/go.sum +++ b/.ops/go.sum @@ -8,8 +8,8 @@ labs.lesiw.io/ops v0.0.0-20241006223243-7fb72522a5c8 h1:bqAHF+cB9UhxpPBH+4SekcPL labs.lesiw.io/ops v0.0.0-20241006223243-7fb72522a5c8/go.mod h1:KUSKG3HltPDtfcGR73aIdjfxEPPQrXfLU/zkmkj6olo= lesiw.io/clerk v0.2.0 h1:Rcq19dHx9TRk4Rkpm9Sw/KGySUxrNmPhrcmU2EQhBYo= lesiw.io/clerk v0.2.0/go.mod h1:WMyvgTe+3Eob36b6KX86MOaxaeNMvv/HZTSsf2Dedhg= -lesiw.io/cmdio v0.1.0 h1:LmeNsDTCJP9kQpTMKRd49Ti2ykXw5iNoCEPzF55Cq6E= -lesiw.io/cmdio v0.1.0/go.mod h1:NysrmGcOlIAtjNMxtvAKXV+a5c+qJGadLtLLOUbFt5Y= +lesiw.io/cmdio v0.2.0 h1:okDogf3TK6uG/IutCK0EvcEdu63FaqYv0zvV3oVo2hA= +lesiw.io/cmdio v0.2.0/go.mod h1:NysrmGcOlIAtjNMxtvAKXV+a5c+qJGadLtLLOUbFt5Y= lesiw.io/defers v0.8.0 h1:Z9PIdf9DRr4DNxTz3SGAjIIk/RbSLDb3svNRvHNtgE0= lesiw.io/defers v0.8.0/go.mod h1:AP09yGFHxL5vmTVJxkPL33N1hWI4OzHwTEOzilbDZU4= lesiw.io/flag v0.7.0 h1:+8rTdoplDMBhOSKok5eKP6ZuLLPTodkDABRY7jfX5JU= diff --git a/ctr/cmd.go b/ctr/cmd.go new file mode 100644 index 0000000..a4ca753 --- /dev/null +++ b/ctr/cmd.go @@ -0,0 +1,58 @@ +package ctr + +import ( + "context" + "fmt" + "io" + + "lesiw.io/cmdio" +) + +type cmd struct { + io.ReadWriter + *cdr + ctx context.Context + env map[string]string + arg []string +} + +func newCmd( + cdr *cdr, ctx context.Context, env map[string]string, args ...string, +) io.ReadWriter { + c := &cmd{ + ctx: ctx, + env: env, + cdr: cdr, + arg: args, + } + c.setCmd(false) + return c +} + +func (c *cmd) Attach() error { + c.setCmd(true) + if a, ok := c.ReadWriter.(cmdio.Attacher); ok { + return a.Attach() + } + return nil +} + +func (c *cmd) String() string { + if s, ok := c.ReadWriter.(fmt.Stringer); ok { + return s.String() + } + return fmt.Sprintf("<%T>", c) +} + +func (c *cmd) setCmd(attach bool) { + cmd := []string{"container", "exec"} + if attach { + cmd = append(cmd, "-ti") + } + for k, v := range c.env { + cmd = append(cmd, "-e", k+"="+v) + } + cmd = append(cmd, c.ctrid) + cmd = append(cmd, c.arg...) + c.ReadWriter = c.rnr.Commander.Command(c.ctx, nil, cmd...) +} diff --git a/ctr/runner.go b/ctr/runner.go new file mode 100644 index 0000000..9d7e193 --- /dev/null +++ b/ctr/runner.go @@ -0,0 +1,133 @@ +package ctr + +import ( + "context" + "crypto/sha1" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "time" + + "lesiw.io/cmdio" + "lesiw.io/cmdio/sub" + "lesiw.io/cmdio/sys" +) + +var clis = [...][]string{ + {"docker"}, + {"podman"}, + {"nerdctl"}, + {"lima", "nerdctl"}, +} + +type cdr struct { + rnr *cmdio.Runner + ctrid string +} + +func (c *cdr) Command( + ctx context.Context, env map[string]string, args ...string, +) io.ReadWriter { + return newCmd(c, ctx, env, args...) +} + +func (c *cdr) Close() error { + return c.rnr.Run("container", "rm", "-f", c.ctrid) +} + +// New instantiates a [cmdio.Runner] that runs commands in a container. +func New(name string) (*cmdio.Runner, error) { + return WithRunner(sys.Runner(), name) +} + +// WithRunner instantiates a [cmdio.Runner] that runs commands in a container +// using the given runner. +func WithRunner(rnr *cmdio.Runner, name string) (*cmdio.Runner, error) { + var ctrcli []string + for _, cli := range clis { + if _, err := rnr.Get("which", cli[0]); err == nil { + ctrcli = cli + break + } + } + if len(ctrcli) == 0 { + return nil, fmt.Errorf("failed to find container CLI") + } + rnr = sub.WithRunner(rnr, ctrcli...) + + if len(name) > 0 && (name[0] == '/' || name[0] == '.') { + var err error + if name, err = buildContainer(rnr, name); err != nil { + return nil, fmt.Errorf("failed to build container: %w", err) + } + } + r, err := rnr.Get("container", "run", "--rm", "-d", "-i", name, "cat") + if err != nil { + return nil, fmt.Errorf("failed to start container: %w", err) + } + + return cmdio.NewRunner( + context.Background(), + make(map[string]string), + &cdr{rnr: rnr, ctrid: r.Out}, + ), nil +} + +func buildContainer( + rnr *cmdio.Runner, rpath string, +) (image string, err error) { + var path string + if path, err = filepath.Abs(rpath); err != nil { + err = fmt.Errorf("bad Containerfile path '%s': %w", rpath, err) + return + } + imagehash := sha1.New() + imagehash.Write([]byte(path)) + image = fmt.Sprintf("%x", imagehash.Sum(nil)) + insp, insperr := rnr.Get( + "image", "inspect", + "--format", "{{.Created}}", + image, + ) + mtime, err := getMtime(path) + if err != nil { + err = fmt.Errorf("bad Containerfile '%s': %w", path, err) + return + } + if insperr == nil { + var ctime time.Time + ctime, err = time.Parse(time.RFC3339, insp.Out) + if err != nil { + err = fmt.Errorf( + "failed to parse container created timestamp '%s': %s", + insp.Out, err) + return + } + if ctime.Unix() > mtime { + return // Container is newer than Containerfile. + } + } + _, err = rnr.Get( + "image", "build", + "--file", path, + "--no-cache", + "--tag", image, + filepath.Dir(path), + ) + if err != nil { + err = fmt.Errorf("failed to build '%s': %w", path, err) + } + return +} + +func getMtime(path string) (mtime int64, err error) { + var info fs.FileInfo + info, err = os.Lstat(path) + if err != nil { + return + } + mtime = info.ModTime().Unix() + return +} diff --git a/ctr/runner_test.go b/ctr/runner_test.go new file mode 100644 index 0000000..fbbb854 --- /dev/null +++ b/ctr/runner_test.go @@ -0,0 +1,55 @@ +package ctr + +import ( + "fmt" + "testing" +) + +func TestAlpine(t *testing.T) { + rnr, err := New("alpine") + if err != nil { + t.Fatal(err) + } + defer rnr.Close() + + r, err := rnr.Get("which", "apk") + if err != nil { + t.Fatal(err) + } + + if got, want := r.Out, "/sbin/apk"; got != want { + t.Errorf("[which apk] = %q, want %q", got, want) + } +} + +func TestString(t *testing.T) { + rnr, err := New("alpine") + if err != nil { + t.Fatal(err) + } + defer rnr.Close() + + cmd := rnr.Command("echo", "hello world") + str := fmt.Sprintf("docker container exec %s echo 'hello world'", + rnr.Commander.(*cdr).ctrid) + if got, want := fmt.Sprintf("%v", cmd), str; got != want { + t.Errorf("Sprintf(cmd) = %q, want = %q", got, want) + } +} + +func TestBuild(t *testing.T) { + rnr, err := New("./testdata/Dockerfile") + if err != nil { + t.Fatal(err) + } + defer rnr.Close() + + cat, err := rnr.Get("cat", "/tmp/test") + if err != nil { + t.Fatal(err) + } + + if got, want := cat.Out, "hello world"; got != want { + t.Errorf("[cat /tmp/test] = %q, want %q", got, want) + } +} diff --git a/ctr/testdata/Dockerfile b/ctr/testdata/Dockerfile new file mode 100644 index 0000000..5805486 --- /dev/null +++ b/ctr/testdata/Dockerfile @@ -0,0 +1,3 @@ +FROM alpine + +RUN echo "hello world" > /tmp/test diff --git a/io.go b/io.go index aaa0e14..047dbd5 100644 --- a/io.go +++ b/io.go @@ -5,8 +5,10 @@ // optionally implement [Logger] to capture standard error and [Coder] to // represent exit codes. // -// Commands are instantiated by a [Runner]. One such runner implementation is -// [lesiw.io/cmdio/sys], which runs commands on the local system. +// Commands are instantiated by a [Runner]. This package contains several +// Runner implementations: [lesiw.io/cmdio/sys], which runs commands on the +// local system; [lesiw.io/cmdio/ctr], which runs commands in containers; and +// [lesiw.io/cmdio/sub], which runs commands as subcommands. // // While most of this package is written to support traditional Go error // handling, Must-type functions, such as [Runner.MustRun] and [MustPipe], are diff --git a/runner.go b/runner.go index d2b6a61..0cafe01 100644 --- a/runner.go +++ b/runner.go @@ -12,7 +12,7 @@ import ( type Runner struct { ctx context.Context env map[string]string - cdr Commander + Commander } // NewRunner instantiates a new [Runner]. @@ -34,7 +34,7 @@ func (rnr *Runner) WithEnv(env map[string]string) *Runner { for k, v := range env { env2[k] = v } - return &Runner{rnr.ctx, env2, rnr.cdr} + return &Runner{rnr.ctx, env2, rnr.Commander} } // WithContext creates a new runner with the provided [context.Context]. @@ -44,7 +44,7 @@ func (rnr *Runner) WithContext(ctx context.Context) *Runner { for k, v := range rnr.env { env[k] = v } - return &Runner{ctx, env, rnr.cdr} + return &Runner{ctx, env, rnr.Commander} } // Command instantiates a command as an [io.ReadWriter]. @@ -56,7 +56,7 @@ func (rnr *Runner) Command(args ...string) io.ReadWriter { if ctx == nil { ctx = context.Background() } - return rnr.cdr.Command(ctx, rnr.env, args...) + return rnr.Commander.Command(ctx, rnr.env, args...) } // Run attaches a command to the controlling terminal and executes it. @@ -94,7 +94,7 @@ func (rnr *Runner) MustGet(args ...string) Result { // Close closes the underlying [Commander] if it is an [io.Closer]. func (rnr *Runner) Close() error { - if closer, ok := rnr.cdr.(io.Closer); ok { + if closer, ok := rnr.Commander.(io.Closer); ok { return closer.Close() } return nil @@ -105,7 +105,7 @@ func (rnr *Runner) Close() error { // By default, it parses the output of an env command. [Commander] // implementations may customize this behavior by implementing [Enver]. func (rnr *Runner) Env(name string) (value string) { - if enver, ok := rnr.cdr.(Enver); ok { + if enver, ok := rnr.Commander.(Enver); ok { return enver.Env(name) } scanner := bufio.NewScanner(rnr.Command("env")) diff --git a/runner_suite_test.go b/runner_suite_test.go index 1fdfceb..db3260b 100644 --- a/runner_suite_test.go +++ b/runner_suite_test.go @@ -1,16 +1,20 @@ package cmdio_test import ( + "context" + "errors" "io" "reflect" "testing" "lesiw.io/cmdio" + "lesiw.io/cmdio/ctr" "lesiw.io/cmdio/sys" ) var runners = map[string]*cmdio.Runner{ "sys": sys.Runner(), + "ctr": mustv(ctr.New("alpine")), } func TestRunners(t *testing.T) { @@ -27,6 +31,9 @@ func TestRunners(t *testing.T) { }) }) } + if err := rnr.Close(); err != nil { + t.Fatal(err) + } } } @@ -51,3 +58,36 @@ func (rnrtests) TestPipeToFailedCommand(t *testing.T, rnr *cmdio.Runner) { t.Error("want err, got ") } } + +func (rnrtests) TestEnv(t *testing.T, rnr *cmdio.Runner) { + rnr = rnr.WithEnv(map[string]string{ + "TEST_ENV": "testenv", + }) + if got, want := rnr.Env("TEST_ENV"), "testenv"; got != want { + t.Errorf("Env(TEST_ENV) = %q, want %q", got, want) + } +} + +func (rnrtests) TestContext(t *testing.T, rnr *cmdio.Runner) { + ctx, cancel := context.WithCancel(context.Background()) + rnr = rnr.WithContext(ctx) + ch := make(chan struct{}) + go func() { + _, err := rnr.Get("sleep", "5") + if err == nil { + t.Error("Get(sleep 5) err = , want context.Canceled") + } else if !errors.Is(err, context.Canceled) { + t.Errorf("Get(sleep 5) err = %q, want context.Canceled", err) + } + ch <- struct{}{} + }() + cancel() + <-ch +} + +func mustv[T any](v T, err error) T { + if err != nil { + panic(err) + } + return v +} diff --git a/sub/runner.go b/sub/runner.go new file mode 100644 index 0000000..652e9ca --- /dev/null +++ b/sub/runner.go @@ -0,0 +1,35 @@ +package sub + +import ( + "context" + "io" + + "lesiw.io/cmdio" + "lesiw.io/cmdio/sys" +) + +type cdr struct { + rnr *cmdio.Runner + cmd []string +} + +func (c *cdr) Command( + ctx context.Context, env map[string]string, args ...string, +) io.ReadWriter { + return c.rnr.Commander.Command(ctx, env, append(c.cmd, args...)...) +} + +// New instantiates a [cmdio.WithRunner] that runs subcommands. +func New(cmd ...string) *cmdio.Runner { + return WithRunner(sys.Runner(), cmd...) +} + +// WithRunner instantiates a [cmdio.WithRunner] that runs subcommands using the +// given runner. +func WithRunner(rnr *cmdio.Runner, cmd ...string) *cmdio.Runner { + return cmdio.NewRunner( + context.Background(), + make(map[string]string), + &cdr{rnr, cmd}, + ) +} diff --git a/sys/runner.go b/sys/runner.go index 536b288..3355c50 100644 --- a/sys/runner.go +++ b/sys/runner.go @@ -7,9 +7,9 @@ import ( "lesiw.io/cmdio" ) -type cmdr struct{} +type cdr struct{} -func (cmdr) Command( +func (cdr) Command( ctx context.Context, env map[string]string, args ...string, ) io.ReadWriter { return newCmd(ctx, env, args...) @@ -20,6 +20,6 @@ func Runner() *cmdio.Runner { return cmdio.NewRunner( context.Background(), make(map[string]string), - new(cmdr), + new(cdr), ) }