+5
-5
@@ -1,9 +1,9 @@
|
||||
.git
|
||||
.gitignore
|
||||
*.zip
|
||||
data/
|
||||
stacks/
|
||||
.env
|
||||
/data/
|
||||
/stacks/
|
||||
/.env
|
||||
.DS_Store
|
||||
dist/
|
||||
bin/
|
||||
/dist/
|
||||
/bin/
|
||||
|
||||
+8
-8
@@ -1,9 +1,9 @@
|
||||
.env
|
||||
data/
|
||||
stacks/
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
bin/
|
||||
dist/
|
||||
/.env
|
||||
/data/
|
||||
/stacks/
|
||||
/*.db
|
||||
/*.db-shm
|
||||
/*.db-wal
|
||||
/bin/
|
||||
/dist/
|
||||
*.zip
|
||||
|
||||
+2
-1
@@ -7,7 +7,8 @@ ARG BUILD_DATE=unknown
|
||||
COPY go.mod ./
|
||||
RUN --mount=type=cache,target=/go/pkg/mod go mod download
|
||||
COPY . .
|
||||
RUN test -f ./cmd/dockwatch/main.go || (echo "ERROR: cmd/dockwatch/main.go missing from Docker build context; check .dockerignore" >&2; exit 1)
|
||||
RUN test -f ./cmd/dockwatch/main.go && test -f ./internal/stacks/stacks.go && test -f ./web/embed.go || \
|
||||
(echo "ERROR: required source files are missing from Docker build context; check .dockerignore" >&2; exit 1)
|
||||
# Keep the module graph in sync with the actual source tree. This is required for
|
||||
# Go 1.17+ module graph pruning when transitive dependencies must be recorded as
|
||||
# indirect requirements in go.mod. The project intentionally has no vendored deps.
|
||||
|
||||
@@ -410,3 +410,7 @@ CI can use `make verify` to fail when `go.mod`/`go.sum` are not committed in tid
|
||||
## v9.3.2 module-build fix
|
||||
|
||||
v9.3.2 fixes Docker/CI builds that stopped at `go: updates to go.mod needed; to update it: go mod tidy`. The builder now runs `go mod tidy` after the complete source tree has been copied and before `go build`, so indirect requirements required by Go module graph pruning are materialized in the build stage. The Makefile also includes `tidy` and `verify` targets for maintaining committed `go.mod`/`go.sum` files.
|
||||
|
||||
### Build-context note (v9.3.3)
|
||||
|
||||
Runtime directories in `.dockerignore` and `.gitignore` are root-anchored (`/stacks/`, `/data/`, `/bin/`, `/dist/`). This is intentional: unanchored patterns such as `stacks/` also match the source package `internal/stacks/` and can make Go try to resolve the project's own internal package as a remote module during `go mod tidy`.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,270 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseNumericUserSpec(t *testing.T) {
|
||||
tests := []struct {
|
||||
spec string
|
||||
uid, gid int
|
||||
wantUID bool
|
||||
wantGID bool
|
||||
}{
|
||||
{"", 0, 0, true, true},
|
||||
{"1000:1001", 1000, 1001, true, true},
|
||||
{"1000", 1000, 0, true, false},
|
||||
{"root:root", 0, 0, true, true},
|
||||
{"app:app", 0, 0, false, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
u, g := parseNumericUserSpec(tt.spec)
|
||||
if (u != nil) != tt.wantUID || (g != nil) != tt.wantGID {
|
||||
t.Fatalf("%q presence got uid=%v gid=%v", tt.spec, u, g)
|
||||
}
|
||||
if u != nil && *u != tt.uid {
|
||||
t.Fatalf("%q uid=%d want %d", tt.spec, *u, tt.uid)
|
||||
}
|
||||
if g != nil && *g != tt.gid {
|
||||
t.Fatalf("%q gid=%d want %d", tt.spec, *g, tt.gid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadHostAccountsAndAccessStatus(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(root, "etc"), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "etc", "passwd"), []byte("root:x:0:0:root:/root:/bin/sh\napp:x:1234:2345::/nonexistent:/usr/sbin/nologin\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "etc", "group"), []byte("root:x:0:\napp:x:2345:\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
users, groups, err := readHostAccounts(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if u := accountByUID(users, 1234); u == nil || u.Name != "app" || u.GID != 2345 {
|
||||
t.Fatalf("unexpected user lookup: %#v", u)
|
||||
}
|
||||
if g := groupByGID(groups, 2345); g == nil || g.Name != "app" {
|
||||
t.Fatalf("unexpected group lookup: %#v", g)
|
||||
}
|
||||
s, err := New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.ConfigureHostAccess(root, false)
|
||||
st := s.hostAccessStatus()
|
||||
if !st.Configured || !st.Available || st.ManagementEnabled {
|
||||
t.Fatalf("unexpected host access status: %#v", st)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateHostUserRequiresExplicitOptIn(t *testing.T) {
|
||||
s, err := New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.CreateHostUser(context.Background(), CreateHostUserInput{ContainerID: "demo", Username: "demo"}); err == nil {
|
||||
t.Fatal("expected host user creation to be disabled by default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostAccountNameValidation(t *testing.T) {
|
||||
for _, name := range []string{"dockwatch-app", "app_1", "_svc"} {
|
||||
if !validHostAccountName.MatchString(name) {
|
||||
t.Fatalf("valid account name rejected: %q", name)
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"Root", "../root", "app user", "-root", ""} {
|
||||
if validHostAccountName.MatchString(name) {
|
||||
t.Fatalf("invalid account name accepted: %q", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestContainerIdentityUsesPID1AndMapsHostAccount(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(root, "etc"), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(root, "srv", "data"), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "etc", "passwd"), []byte("root:x:0:0:root:/root:/bin/sh\napp:x:1000:1000::/nonexistent:/usr/sbin/nologin\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "etc", "group"), []byte("root:x:0:\napp:x:1000:\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bin := t.TempDir()
|
||||
docker := filepath.Join(bin, "docker")
|
||||
script := `#!/bin/sh
|
||||
set -eu
|
||||
if [ "$1" = "inspect" ]; then
|
||||
cat <<'JSON'
|
||||
[{"Id":"abc","Name":"/demo","Config":{"Image":"demo:latest","User":"","Env":["PUID=1000","PGID=1000"]},"State":{"Running":true},"HostConfig":{"Privileged":false,"CapAdd":[],"Devices":[]},"Mounts":[{"Type":"bind","Source":"/srv/data","Destination":"/data","RW":true}]}]
|
||||
JSON
|
||||
exit 0
|
||||
fi
|
||||
if [ "$1" = "exec" ] && [ "$3" = "cat" ]; then
|
||||
printf 'Name:\tdemo\nUid:\t1000\t1000\t1000\t1000\nGid:\t1000\t1000\t1000\t1000\n'
|
||||
exit 0
|
||||
fi
|
||||
exit 2
|
||||
`
|
||||
if err := os.WriteFile(docker, []byte(script), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
s, err := New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.ConfigureHostAccess(root, false)
|
||||
r, err := s.ContainerIdentity(context.Background(), "demo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r.EffectiveUID == nil || *r.EffectiveUID != 1000 || r.EffectiveGID == nil || *r.EffectiveGID != 1000 {
|
||||
t.Fatalf("unexpected identity: uid=%v gid=%v", r.EffectiveUID, r.EffectiveGID)
|
||||
}
|
||||
if r.RunsAsRoot == nil || *r.RunsAsRoot || r.RootAssessment != "non-root" {
|
||||
t.Fatalf("unexpected root assessment: %#v", r)
|
||||
}
|
||||
if r.HostUser == nil || r.HostUser.Name != "app" {
|
||||
t.Fatalf("expected host UID mapping, got %#v", r.HostUser)
|
||||
}
|
||||
if len(r.BindMounts) != 1 || r.BindMounts[0].Source != "/srv/data" {
|
||||
t.Fatalf("unexpected bind mounts: %#v", r.BindMounts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNumericEnvPairPrefersPUIDPGID(t *testing.T) {
|
||||
cfg := map[string]any{"Env": []any{"USER_ID=2000", "GROUP_ID=2001", "PUID=1000", "PGID=1001"}}
|
||||
u, g, src := numericEnvPair(cfg)
|
||||
if u == nil || g == nil || *u != 1000 || *g != 1001 || src != "PUID/PGID environment" {
|
||||
t.Fatalf("unexpected bind identity: uid=%v gid=%v source=%q", u, g, src)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaticWriteAccess(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
p := filepath.Join(root, "data")
|
||||
if err := os.WriteFile(p, []byte("x"), 0640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fi, err := os.Stat(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
u, g := fileOwnerIDs(fi)
|
||||
if u == nil || g == nil {
|
||||
t.Skip("platform does not expose Unix UID/GID")
|
||||
}
|
||||
ownerUID, ownerGID := *u, *g
|
||||
ok, _ := staticWriteAccess(fi, &ownerUID, &ownerGID, &ownerUID, &ownerGID, nil)
|
||||
if ok == nil || !*ok {
|
||||
t.Fatal("expected owner write access")
|
||||
}
|
||||
otherUID, otherGID := ownerUID+10000, ownerGID+10000
|
||||
ok, _ = staticWriteAccess(fi, &ownerUID, &ownerGID, &otherUID, &otherGID, nil)
|
||||
if ok == nil || *ok {
|
||||
t.Fatal("expected other identity to lack write access")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecureHostMappedPathRejectsSymlink(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(root, "srv"), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink("/tmp", filepath.Join(root, "srv", "link")); err != nil {
|
||||
t.Skipf("symlink unavailable: %v", err)
|
||||
}
|
||||
if _, err := secureHostMappedPath(root, "/srv/link"); err == nil {
|
||||
t.Fatal("expected symlinked host path to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePermissionMode(t *testing.T) {
|
||||
for in, want := range map[string]os.FileMode{"750": 0750, "0755": 0755, "000": 0} {
|
||||
got, err := parsePermissionMode(in)
|
||||
if err != nil || got != want {
|
||||
t.Fatalf("parsePermissionMode(%q)=%#o,%v want %#o", in, got, err, want)
|
||||
}
|
||||
}
|
||||
for _, in := range []string{"7777", "888", "75", "abc"} {
|
||||
if _, err := parsePermissionMode(in); err == nil {
|
||||
t.Fatalf("expected %q to be rejected", in)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBindPermissionPreviewUsesBindIdentityAndOptIn(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(root, "etc"), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(root, "srv", "data"), 0750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "etc", "passwd"), []byte("root:x:0:0:root:/root:/bin/sh\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "etc", "group"), []byte("root:x:0:\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bin := t.TempDir()
|
||||
docker := filepath.Join(bin, "docker")
|
||||
script := `#!/bin/sh
|
||||
set -eu
|
||||
if [ "$1" = "inspect" ]; then
|
||||
cat <<'JSON'
|
||||
[{"Id":"abc","Name":"/demo","Config":{"Image":"demo:latest","User":"","Env":["PUID=4242","PGID=4343"]},"State":{"Running":false},"HostConfig":{"Privileged":false,"CapAdd":[],"Devices":[],"UsernsMode":"host"},"Mounts":[{"Type":"bind","Source":"/srv/data","Destination":"/data","RW":true}]}]
|
||||
JSON
|
||||
exit 0
|
||||
fi
|
||||
if [ "$1" = "info" ]; then
|
||||
printf '["name=seccomp"]'
|
||||
exit 0
|
||||
fi
|
||||
exit 2
|
||||
`
|
||||
if err := os.WriteFile(docker, []byte(script), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
svc, err := New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc.ConfigureHostAccess(root, false)
|
||||
p, err := svc.BindPermissionPreview(context.Background(), BindPermissionPreviewInput{ContainerID: "demo", Destination: "/data"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.ExpectedUID == nil || *p.ExpectedUID != 4242 || p.ExpectedGID == nil || *p.ExpectedGID != 4343 {
|
||||
t.Fatalf("unexpected expected bind identity: %#v", p)
|
||||
}
|
||||
if p.CanRepair || p.BlockedReason == "" {
|
||||
t.Fatalf("repair should be blocked without explicit opt-in: %#v", p)
|
||||
}
|
||||
svc.ConfigureHostPermissionManagement(true)
|
||||
p, err = svc.BindPermissionPreview(context.Background(), BindPermissionPreviewInput{ContainerID: "demo", Destination: "/data"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !p.CanRepair {
|
||||
t.Fatalf("expected repair to be enabled after opt-in: %#v", p)
|
||||
}
|
||||
if p.Source != "/srv/data" || p.Destination != "/data" {
|
||||
t.Fatalf("unexpected mount: %#v", p)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,141 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPathRejectsTraversal(t *testing.T) {
|
||||
s, err := New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, name := range []string{"../x", "/tmp/x", "a/b", ""} {
|
||||
if _, err := s.path(name); err == nil {
|
||||
t.Fatalf("expected %q to be rejected", name)
|
||||
}
|
||||
}
|
||||
if _, err := s.path("my-stack_1.2"); err != nil {
|
||||
t.Fatalf("valid name rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveStagesEnvAndSecretsBeforeComposeValidation(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
bin := t.TempDir()
|
||||
docker := filepath.Join(bin, "docker")
|
||||
script := `#!/bin/sh
|
||||
set -eu
|
||||
[ -f .env ]
|
||||
[ -f secrets/api_key ]
|
||||
[ "$(cat secrets/api_key)" = "supersecret" ]
|
||||
exit 0
|
||||
`
|
||||
if err := os.WriteFile(docker, []byte(script), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
s, err := New(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
in := SaveInput{Compose: "services:\n app:\n image: nginx:alpine\n env_file: .env\nsecrets:\n api_key:\n file: ./secrets/api_key\n", Env: "A=B\n", Secrets: []SecretFile{{Name: "api_key", Content: "supersecret"}}}
|
||||
if err := s.Save(context.Background(), "demo", in); err != nil {
|
||||
t.Fatalf("save: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "demo", "compose.yaml")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if b, err := os.ReadFile(filepath.Join(root, "demo", "secrets", "api_key")); err != nil || string(b) != "supersecret" {
|
||||
t.Fatalf("secret not committed: %q %v", string(b), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveReportsDockerExecErrorWhenComposeIsSilent(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
bin := t.TempDir()
|
||||
docker := filepath.Join(bin, "docker")
|
||||
if err := os.WriteFile(docker, []byte("#!/bin/sh\nexit 7\n"), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
s, _ := New(root)
|
||||
err := s.Save(context.Background(), "demo", SaveInput{Compose: "services:\n app:\n image: nginx\n"})
|
||||
if err == nil || !strings.Contains(err.Error(), "exit status 7") {
|
||||
t.Fatalf("expected useful exit error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDescriptorDigestPrefersDescriptor(t *testing.T) {
|
||||
v := map[string]any{"Descriptor": map[string]any{"digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}
|
||||
got := descriptorDigest(v)
|
||||
if got != "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeletePreservesUnmanagedStackDataByDefault(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
s, err := New(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dir := filepath.Join(root, "demo")
|
||||
if err := os.MkdirAll(filepath.Join(dir, "data"), 0750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "compose.yaml"), []byte("services: {}\n"), 0640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "data", "important.db"), []byte("keep"), 0640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.Delete(context.Background(), "demo", false, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "compose.yaml")); !os.IsNotExist(err) {
|
||||
t.Fatalf("managed compose file should be removed, got %v", err)
|
||||
}
|
||||
if b, err := os.ReadFile(filepath.Join(dir, "data", "important.db")); err != nil || string(b) != "keep" {
|
||||
t.Fatalf("unmanaged bind-mount data was damaged: %q %v", b, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeDockerPositionalRejectsOptionLikeValues(t *testing.T) {
|
||||
for _, v := range []string{"--help", "-f", "bad\nvalue"} {
|
||||
if _, err := safeDockerPositional(v, "target"); err == nil {
|
||||
t.Fatalf("expected %q to be rejected", v)
|
||||
}
|
||||
}
|
||||
if got, err := safeDockerPositional("sha256:abc", "target"); err != nil || got != "sha256:abc" {
|
||||
t.Fatalf("valid target rejected: %q %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecRejectsOptionLikeServiceName(t *testing.T) {
|
||||
s, err := New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.Exec(context.Background(), "demo", ExecInput{Service: "--index", Command: "id"}); err == nil {
|
||||
t.Fatal("expected invalid service name to be rejected before invoking Docker")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStackPathRejectsSymlinkDirectory(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
outside := t.TempDir()
|
||||
if err := os.Symlink(outside, filepath.Join(root, "demo")); err != nil {
|
||||
t.Skipf("symlinks unavailable: %v", err)
|
||||
}
|
||||
s, err := New(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.path("demo"); err == nil {
|
||||
t.Fatal("expected symlink stack directory to be rejected")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user