feat: Improve sandbox image fallback, add Podman-compatible workspace binds, and centralize home directory resolution.
This commit is contained in:
parent
0186122a87
commit
7cdaf2cfa1
7 changed files with 107 additions and 41 deletions
4
Makefile
4
Makefile
|
|
@ -96,8 +96,8 @@ build-all: generate
|
|||
GOOS=windows GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR)
|
||||
@echo "All builds complete"
|
||||
|
||||
build-docker-images:
|
||||
docker build -t picoclaw-sandbox:bookworm-slim .
|
||||
build-sandbox:
|
||||
./scripts/build-sandbox.sh
|
||||
|
||||
## install: Install picoclaw to system and copy builtin skills
|
||||
install: build
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import (
|
|||
"path/filepath"
|
||||
"runtime"
|
||||
|
||||
"github.com/sipeed/picoclaw/internal/infra"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/skills"
|
||||
)
|
||||
|
|
@ -190,8 +191,7 @@ func printHelp() {
|
|||
}
|
||||
|
||||
func getConfigPath() string {
|
||||
home, _ := os.UserHomeDir()
|
||||
return filepath.Join(home, ".picoclaw", "config.json")
|
||||
return filepath.Join(infra.ResolveHomeDir(), "config.json")
|
||||
}
|
||||
|
||||
func loadConfig() (*config.Config, error) {
|
||||
|
|
|
|||
22
internal/infra/homedir.go
Normal file
22
internal/infra/homedir.go
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
package infra
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ResolveHomeDir returns the effective home directory for PicoClaw.
|
||||
// It checks the PICOCLAW_HOME environment variable first,
|
||||
// falls back to ~/.picoclaw if not set or empty.
|
||||
func ResolveHomeDir() string {
|
||||
if envHome := strings.TrimSpace(os.Getenv("PICOCLAW_HOME")); envHome != "" {
|
||||
return envHome
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil || strings.TrimSpace(home) == "" {
|
||||
// Extreme fallback
|
||||
return filepath.Join(os.TempDir(), ".picoclaw")
|
||||
}
|
||||
return filepath.Join(home, ".picoclaw")
|
||||
}
|
||||
|
|
@ -24,6 +24,7 @@ import (
|
|||
"github.com/docker/docker/pkg/stdcopy"
|
||||
"github.com/docker/go-units"
|
||||
|
||||
"github.com/sipeed/picoclaw/internal/infra"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
|
|
@ -68,12 +69,16 @@ type ContainerSandbox struct {
|
|||
hash string
|
||||
}
|
||||
|
||||
const defaultSandboxRegistryFile = "containers.json"
|
||||
const (
|
||||
defaultSandboxRegistryFile = "containers.json"
|
||||
DefaultSandboxImage = "picoclaw-sandbox:bookworm-slim"
|
||||
FallbackSandboxImage = "debian:bookworm-slim"
|
||||
)
|
||||
|
||||
// NewContainerSandbox creates a container sandbox with normalized defaults and precomputed config hash.
|
||||
func NewContainerSandbox(cfg ContainerSandboxConfig) *ContainerSandbox {
|
||||
if strings.TrimSpace(cfg.Image) == "" {
|
||||
cfg.Image = "picoclaw-sandbox:bookworm-slim"
|
||||
cfg.Image = DefaultSandboxImage
|
||||
}
|
||||
if strings.TrimSpace(cfg.ContainerPrefix) == "" {
|
||||
cfg.ContainerPrefix = "picoclaw-sandbox-"
|
||||
|
|
@ -148,13 +153,31 @@ func (c *ContainerSandbox) Start(ctx context.Context) error {
|
|||
}
|
||||
|
||||
if _, err := c.cli.ImageInspect(ctx, c.cfg.Image); err != nil {
|
||||
rc, pullErr := c.cli.ImagePull(ctx, c.cfg.Image, image.PullOptions{})
|
||||
if pullErr != nil {
|
||||
c.startErr = fmt.Errorf("docker image unavailable (%s): %w", c.cfg.Image, pullErr)
|
||||
return c.startErr
|
||||
if c.cfg.Image == DefaultSandboxImage {
|
||||
// If default image is missing, try to pull fallback and tag it
|
||||
rc, pullErr := c.cli.ImagePull(ctx, FallbackSandboxImage, image.PullOptions{})
|
||||
if pullErr != nil {
|
||||
c.startErr = fmt.Errorf("docker fallback image unavailable (%s): %w", FallbackSandboxImage, pullErr)
|
||||
return c.startErr
|
||||
}
|
||||
defer rc.Close()
|
||||
_, _ = io.Copy(io.Discard, rc)
|
||||
|
||||
// Tag debian:bookworm-slim as picoclaw-sandbox:bookworm-slim
|
||||
if err := c.cli.ImageTag(ctx, FallbackSandboxImage, DefaultSandboxImage); err != nil {
|
||||
c.startErr = fmt.Errorf("failed to tag fallback image: %w", err)
|
||||
return c.startErr
|
||||
}
|
||||
} else {
|
||||
// For non-default images, just try to pull directly
|
||||
rc, pullErr := c.cli.ImagePull(ctx, c.cfg.Image, image.PullOptions{})
|
||||
if pullErr != nil {
|
||||
c.startErr = fmt.Errorf("docker image unavailable (%s): %w", c.cfg.Image, pullErr)
|
||||
return c.startErr
|
||||
}
|
||||
defer rc.Close()
|
||||
_, _ = io.Copy(io.Discard, rc)
|
||||
}
|
||||
defer rc.Close()
|
||||
_, _ = io.Copy(io.Discard, rc)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
|
@ -371,17 +394,29 @@ func (c *ContainerSandbox) createAndStart(ctx context.Context) error {
|
|||
func (c *ContainerSandbox) binds() []string {
|
||||
binds := make([]string, 0, 1+len(c.cfg.Binds))
|
||||
workspace := strings.TrimSpace(c.cfg.Workspace)
|
||||
|
||||
// Determine the effective host directory to mount
|
||||
var hostDir string
|
||||
if workspace != "" {
|
||||
abs, err := filepath.Abs(workspace)
|
||||
if err == nil {
|
||||
switch c.cfg.WorkspaceAccess {
|
||||
case "ro":
|
||||
binds = append(binds, fmt.Sprintf("%s:%s:ro", abs, c.cfg.Workdir))
|
||||
case "rw":
|
||||
binds = append(binds, fmt.Sprintf("%s:%s:rw", abs, c.cfg.Workdir))
|
||||
default:
|
||||
binds = append(binds, fmt.Sprintf("%s:%s", abs, c.cfg.Workdir))
|
||||
}
|
||||
if abs, err := filepath.Abs(workspace); err == nil {
|
||||
hostDir = abs
|
||||
}
|
||||
}
|
||||
|
||||
if hostDir != "" {
|
||||
if c.cfg.WorkspaceAccess == "none" {
|
||||
// Ensure the isolated directory exists on the host so Docker doesn't create it as root
|
||||
_ = os.MkdirAll(hostDir, 0755)
|
||||
}
|
||||
// Add :Z flag for SELinux (Podman) to label the content with a private unshared label.
|
||||
// This fixes errors like: "crun: getcwd: Operation not permitted: OCI permission denied"
|
||||
switch c.cfg.WorkspaceAccess {
|
||||
case "ro":
|
||||
binds = append(binds, fmt.Sprintf("%s:%s:ro,Z", hostDir, c.cfg.Workdir))
|
||||
case "rw", "none":
|
||||
binds = append(binds, fmt.Sprintf("%s:%s:rw,Z", hostDir, c.cfg.Workdir))
|
||||
default:
|
||||
// Default to no mount for unknown access types
|
||||
}
|
||||
}
|
||||
for _, bind := range c.cfg.Binds {
|
||||
|
|
@ -397,19 +432,7 @@ func (c *ContainerSandbox) registryPath() string {
|
|||
}
|
||||
|
||||
func (c *ContainerSandbox) sandboxStateDir() string {
|
||||
return filepath.Join(resolvePicoClawHomeDir(), "sandboxes")
|
||||
}
|
||||
|
||||
func resolvePicoClawHomeDir() string {
|
||||
if envHome := strings.TrimSpace(os.Getenv("PICOCLAW_HOME")); envHome != "" {
|
||||
if abs := resolveAbsPath(expandHomePath(envHome)); strings.TrimSpace(abs) != "" {
|
||||
return abs
|
||||
}
|
||||
}
|
||||
if home, err := os.UserHomeDir(); err == nil && strings.TrimSpace(home) != "" {
|
||||
return filepath.Join(home, ".picoclaw")
|
||||
}
|
||||
return filepath.Join(osTempDir(), ".picoclaw")
|
||||
return filepath.Join(infra.ResolveHomeDir(), "sandboxes")
|
||||
}
|
||||
|
||||
func (c *ContainerSandbox) stopAndRemoveContainer(ctx context.Context, containerName string) error {
|
||||
|
|
|
|||
|
|
@ -238,7 +238,7 @@ func TestContainerSandbox_Binds_WorkspaceAccessModes(t *testing.T) {
|
|||
Workdir: "/workspace",
|
||||
})
|
||||
roBinds := ro.binds()
|
||||
if len(roBinds) == 0 || !strings.HasSuffix(roBinds[0], ":/workspace:ro") {
|
||||
if len(roBinds) == 0 || !strings.HasSuffix(roBinds[0], ":/workspace:ro,Z") {
|
||||
t.Fatalf("unexpected ro bind: %#v", roBinds)
|
||||
}
|
||||
|
||||
|
|
@ -248,7 +248,7 @@ func TestContainerSandbox_Binds_WorkspaceAccessModes(t *testing.T) {
|
|||
Workdir: "/workspace",
|
||||
})
|
||||
rwBinds := rw.binds()
|
||||
if len(rwBinds) == 0 || !strings.HasSuffix(rwBinds[0], ":/workspace:rw") {
|
||||
if len(rwBinds) == 0 || !strings.HasSuffix(rwBinds[0], ":/workspace:rw,Z") {
|
||||
t.Fatalf("unexpected rw bind: %#v", rwBinds)
|
||||
}
|
||||
|
||||
|
|
@ -256,10 +256,11 @@ func TestContainerSandbox_Binds_WorkspaceAccessModes(t *testing.T) {
|
|||
Workspace: filepath.Join(root, "ws-none"),
|
||||
WorkspaceAccess: "none",
|
||||
Workdir: "/workspace",
|
||||
ContainerName: "test-none",
|
||||
})
|
||||
noneBinds := none.binds()
|
||||
if len(noneBinds) == 0 || !strings.HasSuffix(noneBinds[0], ":/workspace") {
|
||||
t.Fatalf("unexpected none bind: %#v", noneBinds)
|
||||
if len(noneBinds) == 0 || !strings.HasSuffix(noneBinds[0], ":/workspace:rw,Z") {
|
||||
t.Fatalf("expected none bind to isolated workspace, got: %#v", noneBinds)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import (
|
|||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/internal/infra"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/routing"
|
||||
)
|
||||
|
|
@ -275,8 +276,7 @@ func (m *scopedSandboxManager) pruneOnce(ctx context.Context) error {
|
|||
if m.pruneIdleHours <= 0 && m.pruneMaxAgeDays <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
regPath := filepath.Join(resolvePicoClawHomeDir(), "sandbox", defaultSandboxRegistryFile)
|
||||
regPath := filepath.Join(infra.ResolveHomeDir(), "sandbox", defaultSandboxRegistryFile)
|
||||
registryMu.Lock()
|
||||
data, err := loadRegistry(regPath)
|
||||
registryMu.Unlock()
|
||||
|
|
|
|||
20
scripts/build-sandbox.sh
Executable file
20
scripts/build-sandbox.sh
Executable file
|
|
@ -0,0 +1,20 @@
|
|||
#!/bin/bash
|
||||
# PicoClaw Sandbox Build Script
|
||||
|
||||
set -e
|
||||
|
||||
# Base directory
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
DOCKERFILE="${REPO_ROOT}/Dockerfile.sandbox"
|
||||
IMAGE_NAME="picoclaw-sandbox:bookworm-slim"
|
||||
|
||||
echo "Building PicoClaw sandbox image: ${IMAGE_NAME}..."
|
||||
|
||||
if [ ! -f "${DOCKERFILE}" ]; then
|
||||
echo "Error: Dockerfile.sandbox not found at ${DOCKERFILE}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
docker build -t "${IMAGE_NAME}" -f "${DOCKERFILE}" "${REPO_ROOT}"
|
||||
|
||||
echo "Successfully built ${IMAGE_NAME}"
|
||||
Loading…
Add table
Reference in a new issue