Skip to content

Commit

Permalink
Add cmdio/ctr
Browse files Browse the repository at this point in the history
  • Loading branch information
lesiw committed Oct 13, 2024
1 parent 8d14686 commit 423c174
Show file tree
Hide file tree
Showing 12 changed files with 341 additions and 14 deletions.
1 change: 1 addition & 0 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)"'
2 changes: 1 addition & 1 deletion .ops/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions .ops/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
58 changes: 58 additions & 0 deletions ctr/cmd.go
Original file line number Diff line number Diff line change
@@ -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...)
}
133 changes: 133 additions & 0 deletions ctr/runner.go
Original file line number Diff line number Diff line change
@@ -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
}
55 changes: 55 additions & 0 deletions ctr/runner_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
3 changes: 3 additions & 0 deletions ctr/testdata/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
FROM alpine

RUN echo "hello world" > /tmp/test
6 changes: 4 additions & 2 deletions io.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 6 additions & 6 deletions runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import (
type Runner struct {
ctx context.Context
env map[string]string
cdr Commander
Commander
}

// NewRunner instantiates a new [Runner].
Expand All @@ -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].
Expand All @@ -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].
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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"))
Expand Down
Loading

0 comments on commit 423c174

Please sign in to comment.