ci: improve docker image and KANDEV_DATA_DIR env var (#370)

This commit is contained in:
Carlos Florêncio
2026-03-05 20:31:17 +00:00
committed by GitHub
parent 4336f2e4f8
commit c81fb77f72
25 changed files with 158 additions and 59 deletions
+1
View File
@@ -129,6 +129,7 @@ jobs:
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha
type=raw,value=latest,enable=${{ github.ref_type == 'tag' && startsWith(github.ref_name, 'v') }}
- uses: docker/setup-buildx-action@v3
+13 -7
View File
@@ -60,9 +60,13 @@ RUN apt-get update && \
apt-get install -y --no-install-recommends \
git \
ca-certificates \
gosu \
tini && \
rm -rf /var/lib/apt/lists/*
# Create kandev user (uid 1000) — not tied to base image's built-in node user
RUN groupadd -r kandev && useradd -r -g kandev -u 1000 -m kandev
# Create app directory structure matching what the CLI expects:
# /app/apps/backend/bin/kandev
# /app/apps/web/.next/standalone/web/server.js
@@ -92,20 +96,22 @@ VOLUME ["/data"]
# Environment defaults for containerized operation
ENV KANDEV_NO_BROWSER=1 \
KANDEV_DATABASE_PATH=/data/kandev.db \
KANDEV_WORKTREE_BASEPATH=/data/worktrees \
KANDEV_DATA_DIR=/data \
KANDEV_DOCKER_ENABLED=false \
HOSTNAME=0.0.0.0 \
NODE_ENV=production
# Run as non-root
RUN chown -R node:node /app /data
USER node
# Copy entrypoint script
COPY docker-entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
# Set build-time ownership of /app and /data
RUN chown -R kandev:kandev /app /data
WORKDIR /app
EXPOSE 8080 3000
# tini as PID 1 for signal handling; CLI manages backend + web processes
ENTRYPOINT ["tini", "--"]
# tini as PID 1 for signal handling; entrypoint handles privilege drop
ENTRYPOINT ["tini", "--", "docker-entrypoint.sh"]
CMD ["kandev", "start", "--backend-port", "8080", "--web-port", "3000", "--verbose"]
+1
View File
@@ -95,6 +95,7 @@ func provideLifecycleManager(
profileResolver,
mcpService,
lifecycle.ExecutorFallbackWarn,
cfg.ResolvedDataDir(),
log,
)
+2 -1
View File
@@ -79,6 +79,7 @@ func provideGateway(
notificationRepo notificationstore.Repository,
taskRepo *sqliterepo.Repository,
githubSvc *github.Service,
dataDir string,
) (*gateways.Gateway, *notificationservice.Service, *notificationcontroller.Controller, error) {
gateway, err := gateways.Provide(log)
if err != nil {
@@ -89,7 +90,7 @@ func provideGateway(
scriptSvc := &scriptServiceAdapter{taskSvc: taskSvc}
if lifecycleMgr != nil {
gateway.SetLifecycleManager(lifecycleMgr, userSvc, scriptSvc)
gateway.SetLSPHandler(lifecycleMgr, userSvc, lspinstaller.NewRegistry(log))
gateway.SetLSPHandler(lifecycleMgr, userSvc, lspinstaller.NewRegistry(dataDir, log))
gateway.SetVscodeProxy(lifecycleMgr)
}
+2 -1
View File
@@ -285,7 +285,7 @@ func startAgentInfrastructure(
// ============================================
repoCloner := repoclone.NewCloner(repoclone.Config{
BasePath: cfg.RepoClone.BasePath,
}, repoclone.DetectGitProtocol(), log)
}, repoclone.DetectGitProtocol(), cfg.ResolvedDataDir(), log)
log.Info("Repository cloner configured",
zap.String("base_path", cfg.RepoClone.BasePath))
@@ -341,6 +341,7 @@ func startGatewayAndServe(
ctx, log, eventBus, services.Task, services.User,
orchestratorSvc, lifecycleMgr, agentRegistry,
repos.Notification, repos.Task, services.GitHub,
cfg.ResolvedDataDir(),
)
if err != nil {
log.Error("Failed to initialize WebSocket gateway", zap.Error(err))
+1 -7
View File
@@ -2,8 +2,6 @@ package main
import (
"fmt"
"os"
"path/filepath"
analyticsrepository "github.com/kandev/kandev/internal/analytics/repository"
"github.com/kandev/kandev/internal/common/config"
@@ -89,11 +87,7 @@ func provideRepositories(cfg *config.Config, log *logger.Logger) (*db.Pool, *Rep
cleanups = append(cleanups, cleanup)
// Initialize master key and secrets store
kandevDir := os.Getenv("KANDEV_DATA_DIR")
if kandevDir == "" {
kandevDir = filepath.Join(os.Getenv("HOME"), ".kandev")
}
masterKeyProvider, err := secrets.NewMasterKeyProvider(kandevDir)
masterKeyProvider, err := secrets.NewMasterKeyProvider(cfg.ResolvedDataDir())
if err != nil {
return nil, nil, nil, fmt.Errorf("master key: %w", err)
}
@@ -184,7 +184,7 @@ func newTestManager() *lifecycle.Manager {
credsMgr := &MockCredentialsManager{}
profileResolver := &MockProfileResolver{}
// Pass nil for runtime - tests don't need them
return lifecycle.NewManager(reg, eventBus, nil, credsMgr, profileResolver, nil, lifecycle.ExecutorFallbackWarn, log)
return lifecycle.NewManager(reg, eventBus, nil, credsMgr, profileResolver, nil, lifecycle.ExecutorFallbackWarn, "", log)
}
func TestWsShellStatus_NoInstanceFound(t *testing.T) {
@@ -269,7 +269,7 @@ func TestDockerExecutor_Close_AfterFailedInit(t *testing.T) {
func TestDockerClientProvider_NilRegistry(t *testing.T) {
log := newTestDockerLogger()
mgr := NewManager(nil, nil, nil, nil, nil, nil, ExecutorFallbackWarn, log)
mgr := NewManager(nil, nil, nil, nil, nil, nil, ExecutorFallbackWarn, "", log)
provider := mgr.DockerClientProvider()
if provider == nil {
@@ -284,7 +284,7 @@ func TestDockerClientProvider_NoDockerExecutor(t *testing.T) {
log := newTestDockerLogger()
registry := NewExecutorRegistry(log)
registry.Register(&MockExecutor{name: executor.NameStandalone})
mgr := NewManager(nil, nil, registry, nil, nil, nil, ExecutorFallbackWarn, log)
mgr := NewManager(nil, nil, registry, nil, nil, nil, ExecutorFallbackWarn, "", log)
provider := mgr.DockerClientProvider()
if client := provider(); client != nil {
@@ -298,7 +298,7 @@ func TestDockerClientProvider_WithDockerExecutor(t *testing.T) {
dockerExec := NewDockerExecutor(config.DockerConfig{}, log)
dockerExec.newClientFunc = failingClientFactory("docker unavailable")
registry.Register(dockerExec)
mgr := NewManager(nil, nil, registry, nil, nil, nil, ExecutorFallbackWarn, log)
mgr := NewManager(nil, nil, registry, nil, nil, nil, ExecutorFallbackWarn, "", log)
provider := mgr.DockerClientProvider()
if client := provider(); client != nil {
@@ -311,7 +311,7 @@ func TestDockerClientProvider_WithWorkingDocker(t *testing.T) {
registry := NewExecutorRegistry(log)
dockerExec := NewDockerExecutor(config.DockerConfig{}, log)
registry.Register(dockerExec)
mgr := NewManager(nil, nil, registry, nil, nil, nil, ExecutorFallbackWarn, log)
mgr := NewManager(nil, nil, registry, nil, nil, nil, ExecutorFallbackWarn, "", log)
provider := mgr.DockerClientProvider()
client := provider()
@@ -86,6 +86,7 @@ func NewManager(
profileResolver ProfileResolver,
mcpProvider McpConfigProvider,
fallbackPolicy ExecutorFallbackPolicy,
dataDir string,
log *logger.Logger,
) *Manager {
componentLogger := log.WithFields(zap.String("component", "lifecycle-manager"))
@@ -106,7 +107,7 @@ func NewManager(
executionStore := NewExecutionStore()
// Initialize session history manager for fork_session pattern (context injection)
historyManager, err := NewSessionHistoryManager("", log)
historyManager, err := NewSessionHistoryManager("", dataDir, log)
if err != nil {
log.Warn("failed to create session history manager, context injection disabled", zap.Error(err))
}
@@ -66,7 +66,7 @@ func createTestManagerWithTracking() (*Manager, *MockEventBusWithTracking) {
eventBus := &MockEventBusWithTracking{}
credsMgr := &MockCredentialsManager{}
profileResolver := &MockProfileResolver{}
mgr := NewManager(reg, eventBus, nil, credsMgr, profileResolver, nil, ExecutorFallbackWarn, log)
mgr := NewManager(reg, eventBus, nil, credsMgr, profileResolver, nil, ExecutorFallbackWarn, "", log)
return mgr, eventBus
}
@@ -208,7 +208,7 @@ func newTestManager() *Manager {
credsMgr := &MockCredentialsManager{}
profileResolver := &MockProfileResolver{}
// Pass nil for runtime - tests don't need them
return NewManager(reg, eventBus, nil, credsMgr, profileResolver, nil, ExecutorFallbackWarn, log)
return NewManager(reg, eventBus, nil, credsMgr, profileResolver, nil, ExecutorFallbackWarn, "", log)
}
func TestNewManager(t *testing.T) {
@@ -49,14 +49,10 @@ type HistoryEntry struct {
// NewSessionHistoryManager creates a new SessionHistoryManager.
// The baseDir is the directory where session history files will be stored.
// If baseDir is empty, defaults to ~/.kandev/sessions/
func NewSessionHistoryManager(baseDir string, log *logger.Logger) (*SessionHistoryManager, error) {
// If baseDir is empty, it defaults to dataDir+"/sessions".
func NewSessionHistoryManager(baseDir string, dataDir string, log *logger.Logger) (*SessionHistoryManager, error) {
if baseDir == "" {
homeDir, err := os.UserHomeDir()
if err != nil {
return nil, fmt.Errorf("failed to get home directory: %w", err)
}
baseDir = filepath.Join(homeDir, ".kandev", "sessions")
baseDir = filepath.Join(dataDir, "sessions")
}
// Ensure directory exists
+27 -3
View File
@@ -15,6 +15,7 @@ import (
// Config holds all configuration sections for Kandev.
type Config struct {
DataDir string `mapstructure:"dataDir"`
Server ServerConfig `mapstructure:"server"`
Database DatabaseConfig `mapstructure:"database"`
NATS NATSConfig `mapstructure:"nats"`
@@ -28,6 +29,25 @@ type Config struct {
RepoClone RepoCloneConfig `mapstructure:"repoClone"`
}
// ResolvedDataDir returns the base data directory for Kandev.
// Config.DataDir is populated by Viper from the config file or KANDEV_DATA_DIR env var.
// If empty, falls back to ~/.kandev.
func (c *Config) ResolvedDataDir() string {
if c.DataDir != "" {
if strings.HasPrefix(c.DataDir, "~/") {
if home, err := os.UserHomeDir(); err == nil {
return filepath.Join(home, c.DataDir[2:])
}
}
return c.DataDir
}
home, err := os.UserHomeDir()
if err != nil {
return ".kandev"
}
return filepath.Join(home, ".kandev")
}
// ServerConfig holds HTTP server configuration.
type ServerConfig struct {
Host string `mapstructure:"host"`
@@ -176,9 +196,12 @@ func setDefaults(v *viper.Viper) {
v.SetDefault("server.readTimeout", 30)
v.SetDefault("server.writeTimeout", 30)
// DataDir default — empty means resolve from KANDEV_DATA_DIR env or ~/.kandev
v.SetDefault("dataDir", "")
// Database defaults
v.SetDefault("database.driver", "sqlite")
v.SetDefault("database.path", "./kandev.db")
v.SetDefault("database.path", "")
v.SetDefault("database.host", "localhost")
v.SetDefault("database.port", 5432)
v.SetDefault("database.user", "kandev")
@@ -227,12 +250,12 @@ func setDefaults(v *viper.Viper) {
// Worktree defaults
v.SetDefault("worktree.enabled", true)
v.SetDefault("worktree.basePath", "~/.kandev/worktrees")
v.SetDefault("worktree.basePath", "")
v.SetDefault("worktree.defaultBranch", "main")
v.SetDefault("worktree.cleanupOnRemove", true)
// RepoClone defaults
v.SetDefault("repoClone.basePath", "~/.kandev/repos")
v.SetDefault("repoClone.basePath", "")
}
// DefaultDockerHost returns the platform-appropriate Docker socket path.
@@ -285,6 +308,7 @@ func LoadWithPath(configPath string) (*Config, error) {
_ = v.BindEnv("agent.standaloneHost", "KANDEV_AGENT_STANDALONE_HOST")
_ = v.BindEnv("agent.mcpServerPort", "KANDEV_AGENT_MCP_SERVER_PORT")
_ = v.BindEnv("agent.mcpServerUrl", "KANDEV_AGENT_MCP_SERVER_URL")
_ = v.BindEnv("dataDir", "KANDEV_DATA_DIR")
_ = v.BindEnv("logging.level", "KANDEV_LOG_LEVEL")
_ = v.BindEnv("events.namespace", "KANDEV_EVENTS_NAMESPACE")
@@ -0,0 +1,47 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func TestResolvedDataDir_ExplicitConfig(t *testing.T) {
cfg := &Config{DataDir: "/custom/data"}
if got := cfg.ResolvedDataDir(); got != "/custom/data" {
t.Errorf("ResolvedDataDir() = %q, want %q", got, "/custom/data")
}
}
func TestResolvedDataDir_FallbackToHome(t *testing.T) {
cfg := &Config{}
home, err := os.UserHomeDir()
if err != nil {
t.Skip("cannot determine home directory")
}
want := filepath.Join(home, ".kandev")
if got := cfg.ResolvedDataDir(); got != want {
t.Errorf("ResolvedDataDir() = %q, want %q", got, want)
}
}
func TestResolvedDataDir_ConfigTakesPrecedence(t *testing.T) {
t.Setenv("KANDEV_DATA_DIR", "/env/data")
// Even with env set, explicit Config.DataDir wins
cfg := &Config{DataDir: "/explicit/data"}
if got := cfg.ResolvedDataDir(); got != "/explicit/data" {
t.Errorf("ResolvedDataDir() = %q, want %q", got, "/explicit/data")
}
}
func TestResolvedDataDir_TildeExpansion(t *testing.T) {
home, err := os.UserHomeDir()
if err != nil {
t.Skip("cannot determine home directory")
}
cfg := &Config{DataDir: "~/kandev"}
want := filepath.Join(home, "kandev")
if got := cfg.ResolvedDataDir(); got != want {
t.Errorf("ResolvedDataDir() = %q, want %q", got, want)
}
}
@@ -68,13 +68,21 @@ type Registry struct {
}
// NewRegistry creates a new installer registry.
func NewRegistry(log *logger.Logger) *Registry {
home, err := os.UserHomeDir()
if err != nil {
home = "."
// If dataDir is non-empty, LSP binaries are stored under dataDir+"/lsp-servers".
// Otherwise falls back to ~/.kandev/lsp-servers.
func NewRegistry(dataDir string, log *logger.Logger) *Registry {
var binDir string
if dataDir != "" {
binDir = filepath.Join(dataDir, "lsp-servers")
} else {
home, err := os.UserHomeDir()
if err != nil {
home = "."
}
binDir = filepath.Join(home, DefaultBinDir)
}
return &Registry{
binDir: filepath.Join(home, DefaultBinDir),
binDir: binDir,
logger: log.WithFields(zap.String("component", "lsp-installer")),
}
}
@@ -106,7 +106,7 @@ func TestBinaryName(t *testing.T) {
}
func TestStrategyFor(t *testing.T) {
r := NewRegistry(testLogger())
r := NewRegistry("", testLogger())
// Supported languages should return a strategy
for _, lang := range []string{"typescript", "go", "rust", "python"} {
@@ -133,7 +133,7 @@ func TestStrategyFor(t *testing.T) {
func TestBinaryPath_InPATH(t *testing.T) {
// "ls" should always be in PATH
r := NewRegistry(testLogger())
r := NewRegistry("", testLogger())
// Override the languages map temporarily — we can't do that directly,
// so we test that BinaryPath returns an error for a language whose binary
@@ -217,7 +217,7 @@ func TestBinaryPath_NotFound(t *testing.T) {
}
func TestBinaryPath_UnsupportedLanguage(t *testing.T) {
r := NewRegistry(testLogger())
r := NewRegistry("", testLogger())
_, err := r.BinaryPath("java")
if err == nil {
t.Error("BinaryPath(\"java\") should return error for unsupported language")
@@ -2,6 +2,7 @@ package persistence
import (
"fmt"
"path/filepath"
"github.com/jmoiron/sqlx"
"go.uber.org/zap"
@@ -34,7 +35,7 @@ func Provide(cfg *config.Config, log *logger.Logger) (*db.Pool, func() error, er
func provideSQLite(cfg *config.Config, log *logger.Logger) (*db.Pool, func() error, error) {
dbPath := cfg.Database.Path
if dbPath == "" {
dbPath = "./kandev.db"
dbPath = filepath.Join(cfg.ResolvedDataDir(), "kandev.db")
}
// Writer: single connection, owns WAL/journal_mode setup.
+5 -4
View File
@@ -33,10 +33,11 @@ type Cloner struct {
repoMus sync.Map
}
// NewCloner creates a new Cloner with the given config and git protocol.
func NewCloner(cfg Config, protocol string, log *logger.Logger) *Cloner {
if cfg.BasePath == "" {
cfg.BasePath = "~/.kandev/repos"
// NewCloner creates a new Cloner with the given config, git protocol, and data directory.
// If cfg.BasePath is empty, it defaults to dataDir+"/repos".
func NewCloner(cfg Config, protocol string, dataDir string, log *logger.Logger) *Cloner {
if cfg.BasePath == "" && dataDir != "" {
cfg.BasePath = filepath.Join(dataDir, "repos")
}
return &Cloner{config: cfg, protocol: protocol, logger: log}
}
@@ -462,7 +462,7 @@ func getExecutionStore(t *testing.T, mgr *lifecycle.Manager) *lifecycle.Executio
func newLifecycleManager(t *testing.T, log *logger.Logger) *lifecycle.Manager {
t.Helper()
reg := registry.NewRegistry(log)
return lifecycle.NewManager(reg, nil, nil, nil, nil, nil, lifecycle.ExecutorFallbackWarn, log)
return lifecycle.NewManager(reg, nil, nil, nil, nil, nil, lifecycle.ExecutorFallbackWarn, "", log)
}
func newAgentctlClient(t *testing.T, serverURL string, log *logger.Logger) *agentctlclient.Client {
+7 -3
View File
@@ -33,12 +33,16 @@ func (c *Config) Validate() error {
if c.BranchPrefix == "" {
c.BranchPrefix = DefaultBranchPrefix
}
if c.BasePath == "" {
c.BasePath = "~/.kandev/worktrees"
}
return nil
}
// SetDataDirFallback sets the BasePath from the data directory if not already configured.
func (c *Config) SetDataDirFallback(dataDir string) {
if c.BasePath == "" && dataDir != "" {
c.BasePath = filepath.Join(dataDir, "worktrees")
}
}
// ExpandedBasePath returns the base path with ~ expanded to the user's home directory.
func (c *Config) ExpandedBasePath() (string, error) {
path := c.BasePath
+4 -2
View File
@@ -13,11 +13,13 @@ func Provide(writer, reader *sqlx.DB, cfg *config.Config, log *logger.Logger) (*
if err != nil {
return nil, nil, err
}
manager, err := NewManager(Config{
wtCfg := Config{
Enabled: cfg.Worktree.Enabled,
BasePath: cfg.Worktree.BasePath,
BranchPrefix: "kandev/",
}, store, log)
}
wtCfg.SetDataDirFallback(cfg.ResolvedDataDir())
manager, err := NewManager(wtCfg, store, log)
if err != nil {
return nil, nil, err
}
+9
View File
@@ -0,0 +1,9 @@
#!/bin/sh
set -e
if [ "$(id -u)" = '0' ]; then
chown -R kandev:kandev /data
exec gosu kandev "$@"
fi
exec "$@"
+4 -2
View File
@@ -10,15 +10,17 @@ SQLite requires no external services and works out of the box.
| Variable | Default | Description |
|----------|---------|-------------|
| `KANDEV_DATA_DIR` | `~/.kandev` | Base directory for all data (DB, worktrees, sessions, etc.) |
| `KANDEV_DATABASE_DRIVER` | `sqlite` | Database driver |
| `KANDEV_DATABASE_PATH` | `./kandev.db` | Path to the SQLite database file |
| `KANDEV_DATABASE_PATH` | `$KANDEV_DATA_DIR/kandev.db` | Path to the SQLite database file (override) |
### Config file (`config.yaml`)
```yaml
dataDir: "" # empty = resolve from KANDEV_DATA_DIR or ~/.kandev
database:
driver: sqlite
path: ./kandev.db
path: "" # empty = $dataDir/kandev.db
```
No additional setup is needed - the database file is created automatically on first run.
+2 -2
View File
@@ -63,12 +63,12 @@ docker run -p 8080:8080 -p 3000:3000 \
| Variable | Default | Description |
|----------|---------|-------------|
| `KANDEV_DATA_DIR` | `/data` | Base directory for all data (DB, worktrees, sessions, etc.) |
| `KANDEV_DATABASE_DRIVER` | `sqlite` | Database driver (`sqlite` or `postgres`) |
| `KANDEV_DATABASE_PATH` | `/data/kandev.db` | SQLite database file path |
| `KANDEV_DATABASE_PATH` | `$KANDEV_DATA_DIR/kandev.db` | SQLite database file path (override) |
| `KANDEV_LOG_LEVEL` | `info` | Log level: `debug`, `info`, `warn`, `error` |
| `KANDEV_LOGGING_FORMAT` | `text` | Log format: `text` or `json` |
| `KANDEV_DOCKER_ENABLED` | `false` | Enable Docker runtime for agents (see below) |
| `KANDEV_WORKTREE_BASEPATH` | `/data/worktrees` | Git worktree storage directory |
### PostgreSQL
+2 -2
View File
@@ -81,12 +81,12 @@ Kandev reads configuration via `KANDEV_`-prefixed environment variables (Viper).
| Env Var | Default | Description |
|---------|---------|-------------|
| `KANDEV_SERVER_PORT` | `8080` | Backend API port |
| `KANDEV_DATA_DIR` | `/data` | Base directory for all data (DB, worktrees, sessions, etc.) |
| `KANDEV_DATABASE_DRIVER` | `sqlite` | Database driver (`sqlite` or `postgres`) |
| `KANDEV_DATABASE_PATH` | `/data/kandev.db` | SQLite database file path |
| `KANDEV_DATABASE_PATH` | `$KANDEV_DATA_DIR/kandev.db` | SQLite database file path (override) |
| `KANDEV_DOCKER_ENABLED` | `false` | Enable Docker runtime for agents (requires DinD) |
| `KANDEV_LOG_LEVEL` | `info` | Log level: `debug`, `info`, `warn`, `error` |
| `KANDEV_LOGGING_FORMAT` | auto | Log format: `json` (auto-detected in K8s) or `text` |
| `KANDEV_WORKTREE_BASEPATH` | `/data/worktrees` | Git worktree storage directory |
### PostgreSQL Settings (when `KANDEV_DATABASE_DRIVER=postgres`)