Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
*
!Dockerfile
!starcli
17 changes: 17 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,23 @@ on:
permissions: read-all

jobs:
container:
runs-on: ubuntu-22.04
timeout-minutes: 15
steps:
- uses: actions/checkout@v5
- uses: actions/setup-go@v6
with:
go-version: "1.27.1"
- name: Build the runtime image
run: |
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -o starcli .
docker build --platform linux/amd64 -t starcli:test .
- name: Test the runtime image
env:
STARCLI_TEST_IMAGE: starcli:test
run: go test -v ./e2e -run TestContainer -count=1

ci:
permissions:
contents: read
Expand Down
29 changes: 12 additions & 17 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,23 +1,18 @@
FROM ubuntu:20.04
FROM debian:bookworm-slim

ARG ROOT_PATH=/root
ARG BINARY=starcli

ENV BINPATH=${ROOT_PATH}/${BINARY}
# Build the static linux/amd64 binary first with `make build_linux`.
# Named time zones are embedded by main.go's time/tzdata import.
ENV TERM=xterm-256color
ENV TZ=Asia/Shanghai
ENV PORT=80

EXPOSE 80
WORKDIR ${ROOT_PATH}

COPY ${BINARY} ${ROOT_PATH}/
ENV HOME=/work

RUN set -eux; \
apt-get update \
&& apt-get install -y --no-install-recommends apt-transport-https ca-certificates tzdata \
&& apt-get clean && rm -rf /var/lib/apt/lists/* ; \
ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo ${TZ} > /etc/timezone ; \
chmod +x ${BINPATH}
&& apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/* \
&& mkdir /work && chown 65532:65532 /work

CMD ["sh", "-c", "${BINPATH}"]
COPY --chmod=755 starcli /usr/local/bin/starcli
USER 65532:65532
WORKDIR /work
EXPOSE 8080
ENTRYPOINT ["/usr/local/bin/starcli"]
31 changes: 26 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,19 +45,40 @@ make build

### Docker

The project includes a Dockerfile to build and run StarCLI in a container:
Build the static Linux binary before building the image. The image runs as UID
65532, includes system CA certificates, and uses the binary's embedded time-zone
data. Arguments go directly to StarCLI, which also receives stop signals.

```bash
# Build the Docker image
docker build -t starcli .
# Build and test the linux/amd64 image (also from an Apple Silicon host)
make build_linux
docker build --platform linux/amd64 -t starcli .
STARCLI_TEST_IMAGE=starcli go test ./e2e -run TestContainer -count=1

# Run in interactive mode
docker run -it starcli
docker run --rm --platform linux/amd64 -it starcli

# Run a specific script
docker run -v $(pwd):/scripts starcli sh -c "/root/starcli /scripts/your-script.star"
docker run --rm --platform linux/amd64 --read-only \
--cap-drop=ALL --security-opt=no-new-privileges \
--memory=256m --cpus=1 --pids-limit=64 \
--mount "type=bind,src=$(pwd),dst=/scripts,readonly" \
starcli --caps safe -I /scripts /scripts/your-script.star

# Serve a host-selected script; publish only on the host's loopback interface
docker run --rm --platform linux/amd64 --read-only \
--cap-drop=ALL --security-opt=no-new-privileges \
--memory=256m --cpus=1 --pids-limit=64 \
-p 127.0.0.1:8080:8080 starcli --caps safe \
--web-host 0.0.0.0 --web 8080 -c 'response.set_text("ready")'
```

Mounted scripts must be readable by UID 65532. Mount a separate writable
directory when a trusted script needs persistent data. These examples are for
host-selected scripts; the image is not an untrusted multi-tenant execution
service. The container no longer uses `/root`, port 80, or a shell command as
its default entrypoint. Override `--entrypoint` explicitly if you need a shell.

## Usage

```bash
Expand Down
131 changes: 131 additions & 0 deletions e2e/e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,26 @@
// the "build a binary -> run a .star -> compare stdout/exit" coverage the v0.1.0
// cost-price audit flagged as missing: it proves the wired modules actually
// *work* through the CLI, not merely that they load.
//
// Sections: local imports, golden scripts, domain modules, stdin, containers.
package e2e

import (
"bytes"
"context"
"crypto/x509"
"encoding/pem"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
)

// binPath is the freshly built starcli binary, set up once in TestMain.
Expand Down Expand Up @@ -210,3 +221,123 @@ func TestStdinConsumption(t *testing.T) {
})
}
}

// TestContainer exercises the repository Dockerfile, including its entrypoint,
// trust store, embedded time zones, non-root user, and PID 1 shutdown behavior.
// Build it for linux/amd64 and set STARCLI_TEST_IMAGE to opt in.
func TestContainer(t *testing.T) {
image := os.Getenv("STARCLI_TEST_IMAGE")
if image == "" {
t.Skip("set STARCLI_TEST_IMAGE to test a built container image")
}
docker := func(args ...string) (string, error) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
out, err := exec.CommandContext(ctx, "docker", args...).CombinedOutput()
return string(out), err
}
runArgs := []string{"run", "--rm", "--platform", "linux/amd64", "--read-only",
"--cap-drop=ALL", "--security-opt=no-new-privileges", "--memory=256m", "--cpus=1",
"--pids-limit=64", "--tmpfs", "/tmp:rw,noexec,nosuid,size=16m"}
run := func(options, args []string) (string, error) {
all := append(append([]string{}, runArgs...), options...)
all = append(all, image)
return docker(append(all, args...)...)
}
for _, tc := range []struct {
name string
args []string
want string
}{
{"arguments reach CLI", []string{"-c", `print(6 * 7)`}, "42\n"},
{"non-root user", []string{"--allow-cmd", "-c", `load("cmd", "run"); print(run("id -u").stdout.strip())`}, "65532\n"},
{"embedded timezone", []string{"-c", `load("time", "parse_time"); print(parse_time("2021-03-22T12:00:00Z").in_location("America/New_York").hour)`}, "8\n"},
} {
t.Run(tc.name, func(t *testing.T) {
out, err := run(nil, tc.args)
if err != nil || out != tc.want {
t.Fatalf("output=%q error=%v, want %q", out, err, tc.want)
}
})
}
t.Run("exit status", func(t *testing.T) {
out, err := run(nil, []string{"-c", `fail("container failure")`})
if err == nil || !strings.Contains(out, "container failure") {
t.Fatalf("output=%q error=%v, want script failure", out, err)
}
})
t.Run("HTTPS trust", func(t *testing.T) {
bundle, err := run([]string{"--entrypoint", "/bin/cat"}, []string{"/etc/ssl/certs/ca-certificates.crt"})
if err != nil || !x509.NewCertPool().AppendCertsFromPEM([]byte(bundle)) {
t.Fatalf("image must include a usable CA bundle: %v", err)
}
// The standard httptest certificate covers example.com. Resolve that
// name to the local Docker host, so this test needs no public service.
srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = io.WriteString(w, "trusted fixture")
}))
_ = srv.Listener.Close()
srv.Listener, err = net.Listen("tcp", "0.0.0.0:0")
if err != nil {
t.Fatal(err)
}
srv.StartTLS()
defer srv.Close()
port := srv.Listener.Addr().(*net.TCPAddr).Port
code := fmt.Sprintf(`load("http", "get"); print(get("https://example.com:%d").status_code)`, port)
options := []string{"--add-host", "example.com:host-gateway"}
out, err := run(options, []string{"-c", code})
if err == nil || !strings.Contains(out, "certificate signed by unknown authority") {
t.Fatalf("untrusted certificate: output=%q error=%v", out, err)
}
caFile := filepath.Join(t.TempDir(), "ca-certificates.crt")
bundle += string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: srv.Certificate().Raw}))
if err := os.WriteFile(caFile, []byte(bundle), 0o644); err != nil {
t.Fatal(err)
}
options = append(options, "--mount", "type=bind,src="+caFile+",dst=/etc/ssl/certs/ca-certificates.crt,readonly")
out, err = run(options, []string{"-c", code})
if err != nil || out != "200\n" {
t.Fatalf("trusted certificate: output=%q error=%v", out, err)
}
})
t.Run("HTTP and graceful stop", func(t *testing.T) {
args := []string{"run", "-d", "--platform", "linux/amd64", "--read-only", "--cap-drop=ALL",
"--security-opt=no-new-privileges", "--memory=256m", "--cpus=1", "--pids-limit=64",
"-p", "127.0.0.1::8080", image, "--caps", "safe", "--web-host", "0.0.0.0", "--web", "8080",
"-c", `response.set_text("container ready")`}
id, err := docker(args...)
if err != nil {
t.Fatalf("start: %v: %s", err, id)
}
id = strings.TrimSpace(id)
t.Cleanup(func() { _, _ = docker("rm", "-f", id) })
port, err := docker("port", id, "8080/tcp")
if err != nil {
t.Fatalf("published port: %v: %s", err, port)
}
client := &http.Client{Timeout: time.Second}
ready := false
for deadline := time.Now().Add(10 * time.Second); time.Now().Before(deadline); {
resp, err := client.Get("http://" + strings.TrimSpace(port))
if err == nil {
body, readErr := io.ReadAll(resp.Body)
_ = resp.Body.Close()
ready = readErr == nil && resp.StatusCode == http.StatusOK && string(body) == "container ready"
break
}
time.Sleep(100 * time.Millisecond)
}
if !ready {
logs, _ := docker("logs", id)
t.Fatalf("HTTP endpoint did not become ready: %s", logs)
}
if out, err := docker("stop", "--time", "5", id); err != nil {
t.Fatalf("stop: %v: %s", err, out)
}
if out, err := docker("inspect", "--format", "{{.State.ExitCode}}", id); err != nil || strings.TrimSpace(out) != "0" {
t.Fatalf("graceful exit: %v: %s", err, out)
}
})
}
Loading