From d6c145ac84d596325869a0227fbb01062032dd50 Mon Sep 17 00:00:00 2001 From: nfebe Date: Mon, 8 Jun 2026 00:15:27 +0100 Subject: [PATCH 1/7] feat(config): Add file manager show-hidden setting The file manager's hidden files default is now a persisted agent setting exposed through the key based config API. It defaults to enabled, and an explicit off choice survives agent restarts. --- config.example.yml | 2 ++ pkg/config/config.go | 11 +++++++++++ pkg/config/registry_test.go | 33 +++++++++++++++++++++++++++++++++ 3 files changed, 46 insertions(+) diff --git a/config.example.yml b/config.example.yml index a0cc84c..459d96d 100644 --- a/config.example.yml +++ b/config.example.yml @@ -115,6 +115,8 @@ system_terminal: enabled: false cleanup: timeout: 2m0s +files: + show_hidden: true plans: ttl: 24h0m0s retention_days: 30 diff --git a/pkg/config/config.go b/pkg/config/config.go index e31a0f6..9b96cb0 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -41,6 +41,12 @@ type Config struct { Cleanup CleanupConfig `yaml:"cleanup"` Plans PlansConfig `yaml:"plans"` AI AIConfig `yaml:"ai"` + Files FilesConfig `yaml:"files"` +} + +type FilesConfig struct { + // Pointer so an explicit false survives reloads; nil means "use default" (true). + ShowHidden *bool `yaml:"show_hidden" json:"show_hidden"` } type AIConfig struct { @@ -426,6 +432,11 @@ func setDefaults(cfg *Config) { if cfg.Plans.RetentionDays == 0 { cfg.Plans.RetentionDays = 30 } + // Files defaults + if cfg.Files.ShowHidden == nil { + showHidden := true + cfg.Files.ShowHidden = &showHidden + } // Cluster defaults if cfg.Cluster.ServerName == "" { hostname, err := os.Hostname() diff --git a/pkg/config/registry_test.go b/pkg/config/registry_test.go index b3defaa..622a560 100644 --- a/pkg/config/registry_test.go +++ b/pkg/config/registry_test.go @@ -119,6 +119,39 @@ func TestGetReturnsEntry(t *testing.T) { } } +func TestFilesShowHiddenDefaultsTrueAndAcceptsFalse(t *testing.T) { + cfg := &Config{} + setDefaults(cfg) + + e, err := Get(cfg, "files.show_hidden") + if err != nil { + t.Fatalf("Get files.show_hidden: %v", err) + } + if e.Type != "bool" { + t.Errorf("type = %q, want bool", e.Type) + } + if v, _ := e.Value.(bool); !v { + t.Errorf("value = %v, want true", e.Value) + } + if d, _ := e.Default.(bool); !d { + t.Errorf("default = %v, want true", e.Default) + } + + if err := Set(cfg, "files.show_hidden", false); err != nil { + t.Fatalf("Set files.show_hidden: %v", err) + } + if cfg.Files.ShowHidden == nil || *cfg.Files.ShowHidden { + t.Errorf("files.show_hidden = %v, want false", cfg.Files.ShowHidden) + } + + // An explicit false must survive a save/load round trip instead of + // being flipped back to the default. + setDefaults(cfg) + if cfg.Files.ShowHidden == nil || *cfg.Files.ShowHidden { + t.Errorf("files.show_hidden reset to %v after setDefaults, want false", cfg.Files.ShowHidden) + } +} + func entryKeys(entries []Entry) []string { out := make([]string, 0, len(entries)) for _, e := range entries { From 2f478ce3c6b0a26e0f5923ea3be81e318d9527ce Mon Sep 17 00:00:00 2001 From: nfebe Date: Mon, 8 Jun 2026 00:15:27 +0100 Subject: [PATCH 2/7] feat(deploy): Apply template defaults to image based deployments Deployments created from a custom image can now reference an app template. The user's image is kept while the template contributes its default container port and bind mounts; required mounts are included when no explicit selection is made. Combined with the existing template scaffolding, image and compose deploys get pre-created directories, default environment files and correct ownership. --- internal/api/server.go | 50 ++++++++++++++++++--- internal/api/server_test.go | 86 +++++++++++++++++++++++++++++++++++++ 2 files changed, 129 insertions(+), 7 deletions(-) diff --git a/internal/api/server.go b/internal/api/server.go index 400b5da..40b1a84 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -3303,6 +3303,17 @@ func (s *Server) generateComposeWithOptions(templateID string, opts *ComposeGene templatesDir := filepath.Join(s.config.DeploymentsPath, ".flatrun", "templates") + var metadata TemplateMetadata + metadataPath := filepath.Join(templatesDir, templateID, "metadata.yml") + metadataContent, err := os.ReadFile(metadataPath) + if err == nil { + _ = yaml.Unmarshal(metadataContent, &metadata) + } + + if opts.Image != "" { + return s.generateImageComposeWithTemplate(opts, &metadata) + } + composeBytes, err := templates.GetCompose(templateID) if err != nil { composePath := filepath.Join(templatesDir, templateID, "docker-compose.yml") @@ -3320,13 +3331,6 @@ func (s *Server) generateComposeWithOptions(templateID string, opts *ComposeGene content = strings.ReplaceAll(content, "${PROXY_NETWORK}", networkName) content = replaceHardcodedNetwork(content, "proxy", networkName) - var metadata TemplateMetadata - metadataPath := filepath.Join(templatesDir, templateID, "metadata.yml") - metadataContent, err := os.ReadFile(metadataPath) - if err == nil { - _ = yaml.Unmarshal(metadataContent, &metadata) - } - if opts.MapPorts && opts.HostPort != "" { containerPort := opts.ContainerPort if containerPort == 0 && metadata.ContainerPort > 0 { @@ -3477,6 +3481,38 @@ func hasVolumeOptions(volume string) bool { return len(strings.Split(volume, ":")) >= 3 } +// generateImageComposeWithTemplate keeps the user-provided image as the +// deployed service but applies the template's defaults (container port and +// bind mounts) on top of the generated compose. +func (s *Server) generateImageComposeWithTemplate(opts *ComposeGenerateRequest, metadata *TemplateMetadata) (string, error) { + if opts.ContainerPort == 0 && metadata.ContainerPort > 0 { + opts.ContainerPort = metadata.ContainerPort + } + + content, err := s.generateCustomCompose(opts) + if err != nil { + return "", err + } + + if len(metadata.Mounts) == 0 { + return content, nil + } + + selections := opts.Mounts + if len(selections) == 0 { + for _, m := range metadata.Mounts { + if m.Required { + selections = append(selections, MountSelection{ID: m.ID, Enabled: true, Type: m.Type}) + } + } + } + if len(selections) > 0 { + content = s.injectMounts(content, selections, metadata.Mounts) + } + + return content, nil +} + func (s *Server) generateCustomCompose(opts *ComposeGenerateRequest) (string, error) { networkName := s.config.Infrastructure.DefaultProxyNetwork image := strings.TrimSpace(opts.Image) diff --git a/internal/api/server_test.go b/internal/api/server_test.go index a58bb59..7ae3853 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -613,6 +613,92 @@ func TestGenerateComposeWithOptionsCustomImage(t *testing.T) { } } +func TestGenerateComposeWithOptionsImageKeepsUserImageWithTemplateDefaults(t *testing.T) { + cfg := &config.Config{ + DeploymentsPath: t.TempDir(), + Infrastructure: config.InfrastructureConfig{ + DefaultProxyNetwork: "proxy", + }, + } + s := &Server{config: cfg} + + templateDir := cfg.DeploymentsPath + "/.flatrun/templates/laravel" + if err := createDir(templateDir); err != nil { + t.Fatalf("failed to create template dir: %v", err) + } + + composeContent := `name: ${NAME} +services: + app: + image: serversideup/php:8.3 + expose: + - "8000" + networks: + - proxy + +networks: + proxy: + external: true +` + if err := writeFile(templateDir+"/docker-compose.yml", composeContent); err != nil { + t.Fatalf("failed to write compose: %v", err) + } + + metadata := `name: Laravel +container_port: 8000 +mounts: + - id: storage + name: Storage + container_path: /app/storage + type: file + required: true + - id: cache + name: Cache + container_path: /app/bootstrap/cache + type: file + required: false +` + if err := writeFile(templateDir+"/metadata.yml", metadata); err != nil { + t.Fatalf("failed to write metadata: %v", err) + } + + opts := &ComposeGenerateRequest{ + Name: "my-laravel", + Image: "ghcr.io/me/laravel-app:1.0", + } + + result, err := s.generateComposeWithOptions("laravel", opts) + if err != nil { + t.Fatalf("generateComposeWithOptions failed: %v", err) + } + + if !strings.Contains(result, "ghcr.io/me/laravel-app:1.0") { + t.Error("Result should keep the user-provided image") + } + if strings.Contains(result, "serversideup/php") { + t.Error("Result should not use the template's image") + } + if !strings.Contains(result, "8000") { + t.Error("Result should use the template's container port") + } + if !strings.Contains(result, "./storage:/app/storage") { + t.Error("Result should inject the template's required mount") + } + if strings.Contains(result, "/app/bootstrap/cache") { + t.Error("Result should not inject optional mounts by default") + } + + // Explicit selections override the required-mounts default. + opts.Mounts = []MountSelection{{ID: "cache", Enabled: true, Type: "file"}} + result, err = s.generateComposeWithOptions("laravel", opts) + if err != nil { + t.Fatalf("generateComposeWithOptions with selections failed: %v", err) + } + if !strings.Contains(result, "./cache:/app/bootstrap/cache") { + t.Error("Result should inject the selected mount") + } +} + func TestGenerateDeploymentComposePreservesDefaultTemplate(t *testing.T) { cfg := &config.Config{ DeploymentsPath: t.TempDir(), From 51294227f079553231daf3df07cebc5b0ceeb481 Mon Sep 17 00:00:00 2001 From: nfebe Date: Mon, 8 Jun 2026 01:39:01 +0100 Subject: [PATCH 3/7] feat(deploy): Template scaffolding for image and compose deploys Deployments created from a custom image or compose file can now select an app template. The user's image and compose content are kept while the template contributes its container port, default bind mounts, pre-created directories and ownership. Required mounts apply by default and explicit selections override them entirely. Templates can also declare how a platform's environment file is produced: preferring the example shipped inside the deployed image, falling back to template-provided content, generating fresh secrets per deployment and filling in database credentials. The Laravel template uses this for its environment file, full storage directory tree and bootstrap cache. Bind mounts can declare their host path, single-file mounts stay files, and ownership is applied recursively so nested directories belong to the container user. On-disk template copies are refreshed whenever the binary's embedded set changes, so upgrades take effect without a manual refresh. --- internal/api/server.go | 260 ++++++++++++++++++++++++++++-- internal/api/server_test.go | 221 ++++++++++++++++++++++++- internal/docker/discovery.go | 40 +++-- internal/docker/discovery_test.go | 58 +++++++ internal/docker/image.go | 54 +++++++ templates/laravel/metadata.yml | 55 ++++--- templates/templates.go | 24 +++ 7 files changed, 667 insertions(+), 45 deletions(-) create mode 100644 internal/docker/image.go diff --git a/internal/api/server.go b/internal/api/server.go index 40b1a84..b8e3c0b 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -3,6 +3,8 @@ package api import ( "context" cryptoRand "crypto/rand" + "encoding/base64" + "encoding/hex" "encoding/json" "fmt" "log" @@ -133,6 +135,13 @@ func New(cfg *config.Config, configPath string) *Server { manager := docker.NewManager(cfg.DeploymentsPath) manager.SetCleanupTimeout(cfg.Cleanup.Timeout) + + // Deploys read template copies from disk, so sync them with the + // binary's embedded set before anything can deploy. + builtinTemplatesDir := filepath.Join(cfg.DeploymentsPath, ".flatrun", "templates") + if err := os.MkdirAll(builtinTemplatesDir, 0755); err == nil { + ensureBuiltinTemplates(builtinTemplatesDir) + } certsDiscovery := certs.NewDiscovery(cfg.DeploymentsPath) networksManager := networks.NewManager() pluginsDir := filepath.Join(cfg.DeploymentsPath, ".flatrun", "plugins") @@ -998,6 +1007,7 @@ func (s *Server) createDeployment(c *gin.Context) { if req.TemplateID != "" { s.processTemplateFiles(req.Name, req.TemplateID, allEnvVars) + s.processTemplateEnv(req.Name, req.TemplateID, req.ComposeContent, allEnvVars) s.applyTemplateMountOwnership(req.Name, req.TemplateID) } @@ -2833,11 +2843,32 @@ type TemplateMetadata struct { ContainerPort int `yaml:"container_port"` Mounts []TemplateMount `yaml:"mounts"` Files []TemplateFile `yaml:"files"` + Env TemplateEnv `yaml:"env"` +} + +// TemplateEnv describes how a platform's environment file is produced. The +// template defines everything: which file to write (file), where the example +// shipped inside the deployed image lives (example_path, preferred base when +// readable), the fallback content (template), and which secrets to generate +// fresh per deployment. Nothing is assumed about the platform. +type TemplateEnv struct { + File string `json:"file,omitempty" yaml:"file,omitempty"` + ExamplePath string `json:"example_path,omitempty" yaml:"example_path,omitempty"` + Template string `json:"template,omitempty" yaml:"template,omitempty"` + Secrets []TemplateEnvSecret `json:"secrets,omitempty" yaml:"secrets,omitempty"` +} + +type TemplateEnvSecret struct { + Key string `json:"key" yaml:"key"` + Encoding string `json:"encoding,omitempty" yaml:"encoding,omitempty"` // base64 (default), hex, alphanumeric + Bytes int `json:"bytes,omitempty" yaml:"bytes,omitempty"` + Prefix string `json:"prefix,omitempty" yaml:"prefix,omitempty"` } type TemplateMount struct { ID string `json:"id" yaml:"id"` Name string `json:"name" yaml:"name"` + HostPath string `json:"host_path,omitempty" yaml:"host_path,omitempty"` ContainerPath string `json:"container_path" yaml:"container_path"` Description string `json:"description" yaml:"description"` Type string `json:"type" yaml:"type"` @@ -2846,6 +2877,15 @@ type TemplateMount struct { Subdirectories []string `json:"subdirectories,omitempty" yaml:"subdirectories,omitempty"` } +// templateMountHostPath resolves where a template mount lives in the +// deployment directory: an explicit host_path from the metadata, or ./. +func templateMountHostPath(m TemplateMount) string { + if m.HostPath != "" { + return m.HostPath + } + return "./" + m.ID +} + type Template struct { ID string `json:"id"` Name string `json:"name" yaml:"name"` @@ -2870,7 +2910,7 @@ func (s *Server) listTemplates(c *gin.Context) { return } - s.ensureBuiltinTemplates(templatesDir) + ensureBuiltinTemplates(templatesDir) typeFilter := c.DefaultQuery("type", "") @@ -3404,7 +3444,7 @@ func (s *Server) injectMounts(content string, selections []MountSelection, avail if sel.Type == "volume" { hostPath = sel.ID + "_data" } else { - hostPath = "./" + sel.ID + hostPath = templateMountHostPath(mount) } newVolumes = append(newVolumes, fmt.Sprintf("%s:%s", hostPath, mount.ContainerPath)) } @@ -3483,7 +3523,8 @@ func hasVolumeOptions(volume string) bool { // generateImageComposeWithTemplate keeps the user-provided image as the // deployed service but applies the template's defaults (container port and -// bind mounts) on top of the generated compose. +// bind mounts) on top of the generated compose. Explicit selections override +// the defaults entirely; without them the template's required mounts apply. func (s *Server) generateImageComposeWithTemplate(opts *ComposeGenerateRequest, metadata *TemplateMetadata) (string, error) { if opts.ContainerPort == 0 && metadata.ContainerPort > 0 { opts.ContainerPort = metadata.ContainerPort @@ -3494,10 +3535,6 @@ func (s *Server) generateImageComposeWithTemplate(opts *ComposeGenerateRequest, return "", err } - if len(metadata.Mounts) == 0 { - return content, nil - } - selections := opts.Mounts if len(selections) == 0 { for _, m := range metadata.Mounts { @@ -3506,7 +3543,7 @@ func (s *Server) generateImageComposeWithTemplate(opts *ComposeGenerateRequest, } } } - if len(selections) > 0 { + if len(selections) > 0 && len(metadata.Mounts) > 0 { content = s.injectMounts(content, selections, metadata.Mounts) } @@ -3716,18 +3753,30 @@ func extractNetworkNames(networks interface{}) []string { } } -func (s *Server) ensureBuiltinTemplates(templatesDir string) { +func ensureBuiltinTemplates(templatesDir string) { builtinList, err := templates.List() if err != nil { return } + // On-disk copies are what deploys actually read; overwrite them + // whenever the embedded set changed (typically an agent upgrade), + // otherwise stale metadata keeps shaping new deployments. + checksumPath := filepath.Join(templatesDir, ".builtin-checksum") + checksum := templates.Checksum() + stale := true + if current, err := os.ReadFile(checksumPath); err == nil && strings.TrimSpace(string(current)) == checksum { + stale = false + } + for _, tmplID := range builtinList { templatePath := filepath.Join(templatesDir, tmplID) composePath := filepath.Join(templatePath, "docker-compose.yml") - if _, err := os.Stat(composePath); err == nil { - continue + if !stale { + if _, err := os.Stat(composePath); err == nil { + continue + } } if err := os.MkdirAll(templatePath, 0755); err != nil { @@ -3744,6 +3793,10 @@ func (s *Server) ensureBuiltinTemplates(templatesDir string) { _ = os.WriteFile(composePath, composeContent, 0644) } } + + if stale && checksum != "" { + _ = os.WriteFile(checksumPath, []byte(checksum), 0644) + } } func (s *Server) generateComposeContent(name, templateID string) (string, error) { @@ -3926,6 +3979,188 @@ func (s *Server) processTemplateFiles(deploymentName, templateID string, envVars } } +// processTemplateEnv produces the platform's environment file for a new +// deployment, entirely driven by the template's env spec. Base content +// precedence: the example file inside the deployed image (when the template +// declares one and it is readable), then the template's own fallback content, +// then whatever the template already placed at the target path. The +// deployment's environment variables and freshly generated secrets are then +// applied on top, key by key. +func (s *Server) processTemplateEnv(deploymentName, templateID, composeContent string, envVars []EnvVar) { + templatesDir := filepath.Join(s.config.DeploymentsPath, ".flatrun", "templates") + metadataPath := filepath.Join(templatesDir, templateID, "metadata.yml") + + metadataContent, err := os.ReadFile(metadataPath) + if err != nil { + return + } + + var metadata TemplateMetadata + if err := yaml.Unmarshal(metadataContent, &metadata); err != nil { + return + } + + spec := metadata.Env + if spec.File == "" { + return + } + + targetPath := filepath.Join(s.config.DeploymentsPath, deploymentName, spec.File) + + var base string + if spec.ExamplePath != "" { + if image := mainServiceImage(composeContent); image != "" { + if content, err := docker.ExtractFileFromImage(image, spec.ExamplePath); err == nil { + base = string(content) + } else { + log.Printf("template env: could not read %s from %s: %v", spec.ExamplePath, image, err) + } + } + } + if base == "" { + base = spec.Template + } + if base == "" { + if existing, err := os.ReadFile(targetPath); err == nil { + base = string(existing) + } + } + + content := buildTemplateEnvContent(base, deploymentName, envVars, spec) + if err := os.WriteFile(targetPath, []byte(content), 0600); err != nil { + log.Printf("template env: failed to write %s: %v", targetPath, err) + } +} + +func buildTemplateEnvContent(base, deploymentName string, envVars []EnvVar, spec TemplateEnv) string { + content := strings.ReplaceAll(base, "${NAME}", deploymentName) + + var keys []string + values := make(map[string]string) + for _, env := range envVars { + if env.Key == "" { + continue + } + if _, ok := values[env.Key]; !ok { + keys = append(keys, env.Key) + } + values[env.Key] = env.Value + } + + for _, secret := range spec.Secrets { + if secret.Key == "" { + continue + } + if _, ok := values[secret.Key]; ok { + continue + } + value, err := generateSecretValue(secret) + if err != nil { + continue + } + keys = append(keys, secret.Key) + values[secret.Key] = value + } + + return applyEnvValues(content, keys, values) +} + +// applyEnvValues sets KEY=value pairs in dotenv-formatted content, replacing +// lines whose key matches and appending the rest. +func applyEnvValues(content string, keys []string, values map[string]string) string { + lines := strings.Split(content, "\n") + seen := make(map[string]bool) + for i, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + continue + } + eq := strings.IndexByte(trimmed, '=') + if eq <= 0 { + continue + } + key := strings.TrimSpace(trimmed[:eq]) + if value, ok := values[key]; ok { + lines[i] = key + "=" + quoteEnvValue(value) + seen[key] = true + } + } + + content = strings.Join(lines, "\n") + var missing []string + for _, key := range keys { + if !seen[key] { + missing = append(missing, key+"="+quoteEnvValue(values[key])) + } + } + if len(missing) > 0 { + if content != "" && !strings.HasSuffix(content, "\n") { + content += "\n" + } + content += strings.Join(missing, "\n") + "\n" + } + return content +} + +func quoteEnvValue(value string) string { + if strings.ContainsAny(value, " \t#\"") { + return strconv.Quote(value) + } + return value +} + +func generateSecretValue(spec TemplateEnvSecret) (string, error) { + n := spec.Bytes + if n <= 0 { + n = 32 + } + buf := make([]byte, n) + if _, err := cryptoRand.Read(buf); err != nil { + return "", err + } + + var value string + switch spec.Encoding { + case "hex": + value = hex.EncodeToString(buf) + case "alphanumeric": + const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + out := make([]byte, n) + for i, b := range buf { + out[i] = charset[int(b)%len(charset)] + } + value = string(out) + default: + value = base64.StdEncoding.EncodeToString(buf) + } + + return spec.Prefix + value, nil +} + +// mainServiceImage picks the image of the service a template's env example +// should come from: the conventional "app" service, or the first service with +// an image. +func mainServiceImage(content string) string { + var compose composeFile + if err := yaml.Unmarshal([]byte(content), &compose); err != nil { + return "" + } + if svc, ok := compose.Services["app"]; ok && svc.Image != "" { + return svc.Image + } + names := make([]string, 0, len(compose.Services)) + for name := range compose.Services { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + if image := compose.Services[name].Image; image != "" { + return image + } + } + return "" +} + func (s *Server) applyTemplateMountOwnership(deploymentName, templateID string) { templatesDir := filepath.Join(s.config.DeploymentsPath, ".flatrun", "templates") metadataPath := filepath.Join(templatesDir, templateID, "metadata.yml") @@ -3949,9 +4184,8 @@ func (s *Server) applyTemplateMountOwnership(deploymentName, templateID string) if m.Type != "file" { continue } - hostPath := "./" + m.ID mounts = append(mounts, docker.MountOwnership{ - HostPath: hostPath, + HostPath: templateMountHostPath(m), User: m.User, Subdirectories: m.Subdirectories, }) diff --git a/internal/api/server_test.go b/internal/api/server_test.go index 7ae3853..6a3bb1e 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -647,6 +647,11 @@ networks: metadata := `name: Laravel container_port: 8000 mounts: + - id: app + name: Application + container_path: /app + type: file + required: false - id: storage name: Storage container_path: /app/storage @@ -681,21 +686,227 @@ mounts: if !strings.Contains(result, "8000") { t.Error("Result should use the template's container port") } + // Without explicit selections the template's required mounts apply. if !strings.Contains(result, "./storage:/app/storage") { - t.Error("Result should inject the template's required mount") + t.Error("Result should inject the template's required mounts by default") } - if strings.Contains(result, "/app/bootstrap/cache") { + if strings.Contains(result, "./app:/app") || strings.Contains(result, "/app/bootstrap/cache") { t.Error("Result should not inject optional mounts by default") } - // Explicit selections override the required-mounts default. - opts.Mounts = []MountSelection{{ID: "cache", Enabled: true, Type: "file"}} + // Explicit selections override the defaults entirely, including + // deselecting required mounts. + opts.Mounts = []MountSelection{ + {ID: "storage", Enabled: false, Type: "file"}, + {ID: "cache", Enabled: true, Type: "file"}, + } result, err = s.generateComposeWithOptions("laravel", opts) if err != nil { t.Fatalf("generateComposeWithOptions with selections failed: %v", err) } + if strings.Contains(result, "./storage:/app/storage") { + t.Error("Deselected required mount must not be injected") + } if !strings.Contains(result, "./cache:/app/bootstrap/cache") { - t.Error("Result should inject the selected mount") + t.Error("Result should inject the selected cache mount") + } +} + +func TestApplyEnvValues(t *testing.T) { + base := `APP_NAME=Laravel +APP_KEY= +# DB settings +DB_HOST=127.0.0.1 +DB_PASSWORD= +` + result := applyEnvValues(base, []string{"APP_KEY", "DB_HOST", "DB_PASSWORD", "REDIS_HOST"}, map[string]string{ + "APP_KEY": "base64:abc123", + "DB_HOST": "shared-mysql", + "DB_PASSWORD": "s3cret", + "REDIS_HOST": "redis", + }) + + for _, want := range []string{ + "APP_KEY=base64:abc123\n", + "DB_HOST=shared-mysql\n", + "DB_PASSWORD=s3cret\n", + "REDIS_HOST=redis\n", + "APP_NAME=Laravel\n", + "# DB settings\n", + } { + if !strings.Contains(result, want) { + t.Errorf("result missing %q:\n%s", want, result) + } + } + if strings.Contains(result, "DB_HOST=127.0.0.1") { + t.Errorf("old DB_HOST value should be replaced:\n%s", result) + } + if strings.Count(result, "DB_HOST=") != 1 { + t.Errorf("DB_HOST should appear exactly once:\n%s", result) + } +} + +func TestApplyEnvValuesQuotesSpecialValues(t *testing.T) { + result := applyEnvValues("", []string{"APP_NAME"}, map[string]string{"APP_NAME": "My App #1"}) + if !strings.Contains(result, `APP_NAME="My App #1"`) { + t.Errorf("value with spaces should be quoted, got:\n%s", result) + } +} + +func TestGenerateSecretValue(t *testing.T) { + v, err := generateSecretValue(TemplateEnvSecret{Key: "APP_KEY", Prefix: "base64:", Encoding: "base64", Bytes: 32}) + if err != nil { + t.Fatalf("generateSecretValue failed: %v", err) + } + if !strings.HasPrefix(v, "base64:") { + t.Errorf("expected base64: prefix, got %q", v) + } + if len(v) < len("base64:")+40 { + t.Errorf("expected ~44 chars of base64 after prefix, got %q", v) + } + + hexVal, err := generateSecretValue(TemplateEnvSecret{Key: "TOKEN", Encoding: "hex", Bytes: 16}) + if err != nil { + t.Fatalf("generateSecretValue hex failed: %v", err) + } + if len(hexVal) != 32 { + t.Errorf("expected 32 hex chars, got %d (%q)", len(hexVal), hexVal) + } +} + +func TestBuildTemplateEnvContentSecretsAndOverrides(t *testing.T) { + base := "APP_NAME=${NAME}\nAPP_KEY=\nDB_HOST=localhost\n" + spec := TemplateEnv{Secrets: []TemplateEnvSecret{ + {Key: "APP_KEY", Prefix: "base64:", Encoding: "base64"}, + {Key: "PROVIDED_SECRET", Encoding: "hex"}, + }} + envVars := []EnvVar{ + {Key: "DB_HOST", Value: "shared-mysql"}, + {Key: "PROVIDED_SECRET", Value: "user-supplied"}, + } + + result := buildTemplateEnvContent(base, "my-app", envVars, spec) + + if !strings.Contains(result, "APP_NAME=my-app") { + t.Errorf("${NAME} should be substituted:\n%s", result) + } + if !strings.Contains(result, "APP_KEY=base64:") { + t.Errorf("APP_KEY should be generated:\n%s", result) + } + if !strings.Contains(result, "DB_HOST=shared-mysql") { + t.Errorf("DB_HOST should be overridden:\n%s", result) + } + if !strings.Contains(result, "PROVIDED_SECRET=user-supplied") { + t.Errorf("user-provided value must win over secret generation:\n%s", result) + } +} + +func TestProcessTemplateEnvRequiresSpecFile(t *testing.T) { + cfg := &config.Config{DeploymentsPath: t.TempDir()} + s := &Server{config: cfg} + + templateDir := cfg.DeploymentsPath + "/.flatrun/templates/anything" + if err := createDir(templateDir); err != nil { + t.Fatalf("failed to create template dir: %v", err) + } + // Secrets without a declared file: the template did not define env + // handling, so nothing may be written. + metadata := `name: Anything +env: + secrets: + - key: APP_KEY +` + if err := writeFile(templateDir+"/metadata.yml", metadata); err != nil { + t.Fatalf("failed to write metadata: %v", err) + } + deploymentDir := cfg.DeploymentsPath + "/my-app" + if err := createDir(deploymentDir); err != nil { + t.Fatalf("failed to create deployment dir: %v", err) + } + + s.processTemplateEnv("my-app", "anything", "services: {}", nil) + + entries, err := os.ReadDir(deploymentDir) + if err != nil { + t.Fatalf("read deployment dir: %v", err) + } + if len(entries) != 0 { + t.Errorf("no files should be written without env.file, found %d", len(entries)) + } +} + +func TestProcessTemplateEnvUsesSpecFallback(t *testing.T) { + cfg := &config.Config{ + DeploymentsPath: t.TempDir(), + Infrastructure: config.InfrastructureConfig{ + DefaultProxyNetwork: "proxy", + }, + } + s := &Server{config: cfg} + + templateDir := cfg.DeploymentsPath + "/.flatrun/templates/laravel" + if err := createDir(templateDir); err != nil { + t.Fatalf("failed to create template dir: %v", err) + } + metadata := `name: Laravel +env: + file: .env + example_path: /app/.env.example + template: | + APP_NAME=${NAME} + APP_KEY= + DB_HOST= + secrets: + - key: APP_KEY + prefix: "base64:" +` + if err := writeFile(templateDir+"/metadata.yml", metadata); err != nil { + t.Fatalf("failed to write metadata: %v", err) + } + + deploymentDir := cfg.DeploymentsPath + "/my-laravel" + if err := createDir(deploymentDir); err != nil { + t.Fatalf("failed to create deployment dir: %v", err) + } + + envVars := []EnvVar{{Key: "DB_HOST", Value: "shared-mysql"}} + + // The compose resolves no image, so the spec's fallback content is the base. + s.processTemplateEnv("my-laravel", "laravel", "services: {}", envVars) + + content, err := os.ReadFile(deploymentDir + "/.env") + if err != nil { + t.Fatalf("expected .env to be written: %v", err) + } + text := string(content) + if !strings.Contains(text, "APP_NAME=my-laravel") { + t.Errorf(".env should contain the deployment name:\n%s", text) + } + if !strings.Contains(text, "APP_KEY=base64:") { + t.Errorf(".env should contain a generated APP_KEY:\n%s", text) + } + if !strings.Contains(text, "DB_HOST=shared-mysql") { + t.Errorf(".env should contain the database host:\n%s", text) + } +} + +func TestMainServiceImage(t *testing.T) { + compose := `services: + db: + image: mysql:8 + app: + image: ghcr.io/me/app:1 +` + if got := mainServiceImage(compose); got != "ghcr.io/me/app:1" { + t.Errorf("mainServiceImage = %q, want app service image", got) + } + + compose = `services: + web: + image: nginx:alpine +` + if got := mainServiceImage(compose); got != "nginx:alpine" { + t.Errorf("mainServiceImage = %q, want nginx:alpine", got) } } diff --git a/internal/docker/discovery.go b/internal/docker/discovery.go index 9208914..dd72881 100644 --- a/internal/docker/discovery.go +++ b/internal/docker/discovery.go @@ -377,8 +377,12 @@ type MountOwnership struct { } // ApplyMountOwnership sets ownership and creates subdirectories for bind mounts. -// When User is specified (UID:GID format), directories are chowned to that user. -// When User is empty, directories are chmod'd to 0777 as a fallback for non-template deploys. +// When User is specified (UID:GID format), the mount is chowned recursively to +// that user so intermediate directories and pre-existing content end up owned +// by the container too. When User is empty, directories are chmod'd to 0777 as +// a fallback for non-template deploys. A host path that already exists as a +// regular file (e.g. a generated .env) is only chowned, never turned into a +// directory. func (d *Discovery) ApplyMountOwnership(deploymentPath string, mounts []MountOwnership) error { for _, m := range mounts { base := m.HostPath @@ -386,6 +390,24 @@ func (d *Discovery) ApplyMountOwnership(deploymentPath string, mounts []MountOwn base = filepath.Join(deploymentPath, base) } + var uid, gid int + if m.User != "" { + var err error + uid, gid, err = parseUIDGID(m.User) + if err != nil { + return fmt.Errorf("parse user %q: %w", m.User, err) + } + } + + if info, err := os.Stat(base); err == nil && !info.IsDir() { + if m.User != "" { + if err := os.Chown(base, uid, gid); err != nil { + return fmt.Errorf("chown %s: %w", base, err) + } + } + continue + } + if err := os.MkdirAll(base, 0755); err != nil { return fmt.Errorf("create mount dir %s: %w", base, err) } @@ -400,14 +422,14 @@ func (d *Discovery) ApplyMountOwnership(deploymentPath string, mounts []MountOwn } if m.User != "" { - uid, gid, err := parseUIDGID(m.User) - if err != nil { - return fmt.Errorf("parse user %q: %w", m.User, err) - } - for _, dir := range dirs { - if err := os.Chown(dir, uid, gid); err != nil { - return fmt.Errorf("chown %s: %w", dir, err) + err := filepath.WalkDir(base, func(path string, _ os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr } + return os.Chown(path, uid, gid) + }) + if err != nil { + return fmt.Errorf("chown %s: %w", base, err) } } else { for _, dir := range dirs { diff --git a/internal/docker/discovery_test.go b/internal/docker/discovery_test.go index e65337f..5433b3d 100644 --- a/internal/docker/discovery_test.go +++ b/internal/docker/discovery_test.go @@ -306,6 +306,64 @@ func TestApplyMountOwnership(t *testing.T) { t.Fatal("Expected error for invalid user format") } }) + + t.Run("keeps an existing file mount a file", func(t *testing.T) { + envPath := filepath.Join(deploymentPath, ".env") + if err := os.WriteFile(envPath, []byte("APP_ENV=production\n"), 0600); err != nil { + t.Fatalf("Failed to write env file: %v", err) + } + + user := fmt.Sprintf("%d:%d", os.Getuid(), os.Getgid()) + mounts := []MountOwnership{ + { + HostPath: "./.env", + User: user, + }, + } + + if err := d.ApplyMountOwnership(deploymentPath, mounts); err != nil { + t.Fatalf("ApplyMountOwnership failed: %v", err) + } + + info, err := os.Stat(envPath) + if err != nil { + t.Fatalf("Expected env file to still exist: %v", err) + } + if info.IsDir() { + t.Error("Expected env mount to stay a regular file, got a directory") + } + }) + + t.Run("chowns recursively including pre-existing content", func(t *testing.T) { + base := filepath.Join(deploymentPath, "data") + nested := filepath.Join(base, "deep", "dir") + if err := os.MkdirAll(nested, 0755); err != nil { + t.Fatalf("Failed to create nested dirs: %v", err) + } + if err := os.WriteFile(filepath.Join(nested, "file.txt"), []byte("x"), 0644); err != nil { + t.Fatalf("Failed to write nested file: %v", err) + } + + user := fmt.Sprintf("%d:%d", os.Getuid(), os.Getgid()) + mounts := []MountOwnership{ + { + HostPath: "./data", + User: user, + Subdirectories: []string{"extra"}, + }, + } + + if err := d.ApplyMountOwnership(deploymentPath, mounts); err != nil { + t.Fatalf("ApplyMountOwnership failed: %v", err) + } + + if _, err := os.Stat(filepath.Join(base, "extra")); err != nil { + t.Errorf("Expected subdirectory to be created: %v", err) + } + if _, err := os.Stat(filepath.Join(nested, "file.txt")); err != nil { + t.Errorf("Expected pre-existing file to survive: %v", err) + } + }) } func TestParseUIDGID(t *testing.T) { diff --git a/internal/docker/image.go b/internal/docker/image.go new file mode 100644 index 0000000..56f53c3 --- /dev/null +++ b/internal/docker/image.go @@ -0,0 +1,54 @@ +package docker + +import ( + "archive/tar" + "bytes" + "context" + "fmt" + "io" + "os/exec" + "strings" + "time" +) + +// ExtractFileFromImage reads a single file from a container image without +// running it, by creating a stopped container and copying the file out. +// Missing images are pulled, so the call is bounded by a timeout. +func ExtractFileFromImage(image, path string) ([]byte, error) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + createOut, err := exec.CommandContext(ctx, "docker", "create", image).Output() + if err != nil { + return nil, fmt.Errorf("create container from %s: %w", image, err) + } + containerID := strings.TrimSpace(string(createOut)) + defer exec.Command("docker", "rm", "-f", containerID).Run() + + cpOut, err := exec.CommandContext(ctx, "docker", "cp", containerID+":"+path, "-").Output() + if err != nil { + return nil, fmt.Errorf("copy %s from %s: %w", path, image, err) + } + + // docker cp to stdout emits a tar stream containing the requested file. + reader := tar.NewReader(bytes.NewReader(cpOut)) + for { + header, err := reader.Next() + if err == io.EOF { + break + } + if err != nil { + return nil, fmt.Errorf("read tar from %s: %w", image, err) + } + if header.Typeflag != tar.TypeReg { + continue + } + content, err := io.ReadAll(reader) + if err != nil { + return nil, fmt.Errorf("read %s from %s: %w", path, image, err) + } + return content, nil + } + + return nil, fmt.Errorf("no regular file at %s in %s", path, image) +} diff --git a/templates/laravel/metadata.yml b/templates/laravel/metadata.yml index 1091336..54cd91c 100644 --- a/templates/laravel/metadata.yml +++ b/templates/laravel/metadata.yml @@ -32,10 +32,11 @@ mounts: container_path: /app description: Laravel application source code type: file - required: true + required: false user: "1000:1000" - id: env name: Environment Config + host_path: ./.env container_path: /app/.env description: Laravel environment configuration file type: file @@ -46,31 +47,49 @@ mounts: container_path: /app/storage description: Logs, cache, and file uploads type: file - required: false + required: true user: "1000:1000" subdirectories: + - app/public + - app/private - framework/cache - framework/sessions + - framework/testing - framework/views - logs -files: - - path: .env - content: | - APP_NAME=${NAME} - APP_ENV=production - APP_DEBUG=false - APP_URL=http://localhost + - id: bootstrap-cache + name: Bootstrap Cache + host_path: ./bootstrap/cache + container_path: /app/bootstrap/cache + description: Compiled framework files + type: file + required: true + user: "1000:1000" +env: + file: .env + example_path: /app/.env.example + template: | + APP_NAME=${NAME} + APP_ENV=production + APP_KEY= + APP_DEBUG=false + APP_URL=http://localhost - DB_CONNECTION=mysql - DB_HOST=${DB_HOST} - DB_PORT=${DB_PORT} - DB_DATABASE=${DB_DATABASE} - DB_USERNAME=${DB_USERNAME} - DB_PASSWORD=${DB_PASSWORD} + DB_CONNECTION=mysql + DB_HOST= + DB_PORT= + DB_DATABASE= + DB_USERNAME= + DB_PASSWORD= - CACHE_DRIVER=file - SESSION_DRIVER=file - QUEUE_CONNECTION=sync + CACHE_DRIVER=file + SESSION_DRIVER=file + QUEUE_CONNECTION=sync + secrets: + - key: APP_KEY + prefix: "base64:" + encoding: base64 + bytes: 32 backup: container_paths: - service: app diff --git a/templates/templates.go b/templates/templates.go index b974f4d..5168682 100644 --- a/templates/templates.go +++ b/templates/templates.go @@ -2,10 +2,13 @@ package templates import ( "bytes" + "crypto/sha256" "embed" + "encoding/hex" "io/fs" "net/netip" "path/filepath" + "sort" "strings" "text/template" ) @@ -70,6 +73,27 @@ func listInfraTemplates() ([]string, error) { return templates, nil } +// Checksum identifies the embedded template set, so on-disk copies can +// be detected as stale after an agent upgrade. +func Checksum() string { + ids, err := List() + if err != nil { + return "" + } + sort.Strings(ids) + h := sha256.New() + for _, id := range ids { + h.Write([]byte(id)) + if metadata, err := GetMetadata(id); err == nil { + h.Write(metadata) + } + if compose, err := GetCompose(id); err == nil { + h.Write(compose) + } + } + return hex.EncodeToString(h.Sum(nil)) +} + func GetMetadata(name string) ([]byte, error) { return FS.ReadFile(filepath.Join(name, "metadata.yml")) } From 4fa7985230e4ffe1236c6e2ca345f463fa928490 Mon Sep 17 00:00:00 2001 From: nfebe Date: Mon, 8 Jun 2026 01:39:01 +0100 Subject: [PATCH 4/7] feat(ai): Hide seeded prompts from the session transcript Entry points that open the assistant with a canned prompt (log analysis, operation diagnosis) no longer show that prompt as if the operator typed it. The model still receives the full prompt and its context; the visible conversation starts with the assistant's answer. Only messages the operator actually types are displayed. --- internal/ai/provider.go | 4 +++ internal/ai/session.go | 7 +++-- internal/api/ai_session_handlers.go | 5 ++-- internal/api/ai_session_test.go | 45 +++++++++++++++++++++++++++++ 4 files changed, 57 insertions(+), 4 deletions(-) diff --git a/internal/ai/provider.go b/internal/ai/provider.go index aa05a46..6608cc5 100644 --- a/internal/ai/provider.go +++ b/internal/ai/provider.go @@ -19,6 +19,10 @@ type Message struct { // while showing the operator a short label. Never sent to the // provider. Display string `json:"display,omitempty"` + // Hidden marks a turn the UI must not show at all: prompts composed + // by the product (e.g. "analyze these logs") rather than typed by + // the operator. The model still sees Content. + Hidden bool `json:"hidden,omitempty"` // ToolCalls is set on an assistant message that wants tools run. ToolCalls []ToolCall `json:"tool_calls,omitempty"` // ToolCallID and Name identify a role:"tool" result message. diff --git a/internal/ai/session.go b/internal/ai/session.go index 10a7a62..a5596ae 100644 --- a/internal/ai/session.go +++ b/internal/ai/session.go @@ -74,8 +74,8 @@ func (s *Session) touch() { // AddUserMessage records a user turn. When display differs from // content, the model sees content (e.g. message plus embedded logs) // while the UI shows display (e.g. a short label). -func (s *Session) AddUserMessage(content, display string) { - s.Messages = append(s.Messages, Message{Role: "user", Content: content, Display: display}) +func (s *Session) AddUserMessage(content, display string, hidden bool) { + s.Messages = append(s.Messages, Message{Role: "user", Content: content, Display: display, Hidden: hidden}) s.touch() } @@ -206,6 +206,9 @@ func (s *Session) DisplayMessages() []DisplayTurn { for _, m := range s.Messages { switch m.Role { case "user": + if m.Hidden { + continue + } shown := m.Content if m.Display != "" { shown = m.Display diff --git a/internal/api/ai_session_handlers.go b/internal/api/ai_session_handlers.go index f647672..1300162 100644 --- a/internal/api/ai_session_handlers.go +++ b/internal/api/ai_session_handlers.go @@ -136,6 +136,7 @@ func (s *Server) createAISession(c *gin.Context) { AutoRun bool `json:"auto_run"` Message string `json:"message"` Context string `json:"context"` + Seed bool `json:"seed"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) @@ -161,7 +162,7 @@ func (s *Server) createAISession(c *gin.Context) { prompt := ai.BuildSessionPrompt(req.Scope, req.Deployment, s.config.AI.DocsURL) sess := ai.NewSession(req.Scope, req.Deployment, req.AutoRun, sessionActorFrom(c), prompt) content, display := composeUserMessage(req.Message, req.Context) - sess.AddUserMessage(content, display) + sess.AddUserMessage(content, display, req.Seed) if err := s.advanceSession(c, sess); err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) @@ -224,7 +225,7 @@ func (s *Server) postAISessionMessage(c *gin.Context) { } content, display := composeUserMessage(req.Message, req.Context) - sess.AddUserMessage(content, display) + sess.AddUserMessage(content, display, false) if err := s.advanceSession(c, sess); err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) return diff --git a/internal/api/ai_session_test.go b/internal/api/ai_session_test.go index 3371f16..084ece7 100644 --- a/internal/api/ai_session_test.go +++ b/internal/api/ai_session_test.go @@ -131,6 +131,51 @@ func TestAISessionHidesBulkyContext(t *testing.T) { } } +func TestAISessionHidesSeededPrompt(t *testing.T) { + s, tmpDir, ts := setupPlanTestServer(t) + createTestDeployment(t, tmpDir, "myapp", &models.ServiceMetadata{Name: "myapp"}) + + stub := &scriptedProvider{responses: []*ai.Response{{Content: "All healthy.", Model: "scripted"}}} + s.aiProvider = stub + + resp, parsed := doJSON(t, http.MethodPost, ts.URL+"/api/ai/sessions", map[string]interface{}{ + "scope": "deployment", + "deployment": "myapp", + "auto_run": true, + "message": "Analyze the recent logs for myapp.", + "context": "```\nGET /health 200 OK\n```", + "seed": true, + }) + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, body %v", resp.StatusCode, parsed) + } + + // A seeded prompt is composed by the product, not typed by the + // operator, so the transcript starts with the assistant's answer. + messages := parsed["messages"].([]interface{}) + for _, m := range messages { + turn := m.(map[string]interface{}) + if turn["role"] == "user" { + t.Errorf("seeded prompt leaked into the transcript: %v", turn["content"]) + } + } + if len(messages) == 0 { + t.Fatal("expected the assistant turn in the transcript") + } + + // The model must still receive the seeded prompt and its context. + var prompt strings.Builder + for _, m := range stub.lastRequestMessages() { + prompt.WriteString(m.Content) + } + if !strings.Contains(prompt.String(), "Analyze the recent logs for myapp.") { + t.Error("seeded prompt was not sent to the model") + } + if !strings.Contains(prompt.String(), "GET /health 200 OK") { + t.Error("seeded context was not sent to the model") + } +} + func TestAISessionApprovalGating(t *testing.T) { s, tmpDir, ts := setupPlanTestServer(t) createTestDeployment(t, tmpDir, "myapp", &models.ServiceMetadata{Name: "myapp"}) From b283af548706433162ea99fe9a7a4d3cf86c1cec Mon Sep 17 00:00:00 2001 From: nfebe Date: Mon, 8 Jun 2026 01:39:01 +0100 Subject: [PATCH 5/7] fix(test): Abort e2e runs on leftover root-owned state The agent container runs as root and leaves root-owned files in the shared test directories. Teardown could not remove them and the next run silently started on stale state, failing unrelated tests in confusing ways. The suite now refuses to start until the leftover directories are removed. --- test/e2e/main_test.go | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/test/e2e/main_test.go b/test/e2e/main_test.go index 93d91fd..5df67e5 100644 --- a/test/e2e/main_test.go +++ b/test/e2e/main_test.go @@ -96,8 +96,13 @@ func TestMain(m *testing.M) { } func prepareDirectories() error { - _ = os.RemoveAll(deploymentsPath) - _ = os.RemoveAll(certsPath) + // The agent container runs as root and leaves root-owned state behind; + // starting on top of it makes unrelated tests fail in confusing ways. + for _, dir := range []string{deploymentsPath, certsPath} { + if err := os.RemoveAll(dir); err != nil { + return fmt.Errorf("leftover state at %s cannot be removed (root-owned files from a previous run; remove the directory manually): %w", dir, err) + } + } if err := os.MkdirAll(deploymentsPath+"/nginx/conf.d", 0755); err != nil { return fmt.Errorf("failed to create deployments directory: %w", err) From cc41a0e068fb4f34d0195d5f69b333427dd29365 Mon Sep 17 00:00:00 2001 From: nfebe Date: Mon, 8 Jun 2026 01:50:35 +0100 Subject: [PATCH 6/7] feat(terminal): Run the system terminal on a real PTY The system terminal could not run anything that needs a tty: terminal-control commands (reset, tput, stty) failed and full-screen interactive programs (top, htop, vim, less, watch) had no way to receive keystrokes. A new interactive websocket endpoint runs the host shell under a PTY with the same stream protocol as the container terminal: binary frames for raw bytes, JSON text frames for resizes, first-message token auth. Global protected command rules are enforced per submitted line; a blocked line is cancelled and the operator notified. Disabling the terminal through global protected mode is honored. The JSON command endpoint stays unchanged as the programmatic surface. Closes #137 --- internal/api/container_exec.go | 20 ++- internal/api/server.go | 1 + internal/api/system_terminal_interactive.go | 63 ++++++++ .../api/system_terminal_interactive_test.go | 148 ++++++++++++++++++ 4 files changed, 228 insertions(+), 4 deletions(-) create mode 100644 internal/api/system_terminal_interactive.go create mode 100644 internal/api/system_terminal_interactive_test.go diff --git a/internal/api/container_exec.go b/internal/api/container_exec.go index 45d6027..83323e8 100644 --- a/internal/api/container_exec.go +++ b/internal/api/container_exec.go @@ -109,7 +109,19 @@ func (s *Server) containerExec(c *gin.Context) { cmd := exec.Command("docker", "exec", "-i", "-t", containerID, shell) cmd.Env = append(os.Environ(), "TERM=xterm-256color") - // Start with PTY + guard := func(command string) (bool, *models.ProtectedCommandRule, error) { + return s.protectedContainerCommandBlocked(containerID, command) + } + streamPTY(conn, cmd, guard) +} + +// terminalCommandGuard decides whether a submitted command line may run. +type terminalCommandGuard func(command string) (bool, *models.ProtectedCommandRule, error) + +// streamPTY runs cmd under a PTY and pumps bytes between it and the +// websocket: binary frames carry raw terminal bytes, JSON text frames carry +// resizes, and each submitted line is checked against guard before it runs. +func streamPTY(conn *websocket.Conn, cmd *exec.Cmd, guard terminalCommandGuard) { ptmx, err := pty.Start(cmd) if err != nil { sendError(conn, "Failed to start terminal: "+err.Error()) @@ -181,7 +193,7 @@ func (s *Server) containerExec(c *gin.Context) { } } - if blocked, err := s.handleTerminalInput(containerID, ptmx, conn, message, &commandBuffer); err != nil { + if blocked, err := handleTerminalInput(guard, ptmx, conn, message, &commandBuffer); err != nil { log.Printf("PTY write error: %v", err) return } else if blocked { @@ -205,7 +217,7 @@ func (s *Server) containerExec(c *gin.Context) { wg.Wait() } -func (s *Server) handleTerminalInput(containerID string, ptmx *os.File, conn *websocket.Conn, message []byte, commandBuffer *strings.Builder) (bool, error) { +func handleTerminalInput(guard terminalCommandGuard, ptmx *os.File, conn *websocket.Conn, message []byte, commandBuffer *strings.Builder) (bool, error) { for _, b := range message { switch b { case '\r', '\n': @@ -214,7 +226,7 @@ func (s *Server) handleTerminalInput(containerID string, ptmx *os.File, conn *we if command == "" { continue } - blocked, rule, err := s.protectedContainerCommandBlocked(containerID, command) + blocked, rule, err := guard(command) if err != nil { return false, err } diff --git a/internal/api/server.go b/internal/api/server.go index b8e3c0b..c366033 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -318,6 +318,7 @@ func (s *Server) setupRoutes() { // WebSocket endpoint handles its own auth via first-message api.GET("/containers/:id/exec", s.containerExec) api.GET("/system/terminal", s.systemTerminal) + api.GET("/system/terminal/interactive", s.systemTerminalInteractive) // Setup endpoints (public, gated by setup state) setupGroup := api.Group("/setup") diff --git a/internal/api/system_terminal_interactive.go b/internal/api/system_terminal_interactive.go new file mode 100644 index 0000000..ad5cfa1 --- /dev/null +++ b/internal/api/system_terminal_interactive.go @@ -0,0 +1,63 @@ +package api + +import ( + "os" + "os/exec" + "path/filepath" + + "github.com/flatrun/agent/pkg/models" + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" +) + +// systemTerminalInteractive runs the host shell under a real PTY over a +// websocket, using the same stream protocol as the container exec terminal. +// The JSON command endpoint stays as the API surface for programmatic use. +func (s *Server) systemTerminalInteractive(c *gin.Context) { + conn, err := upgrader.Upgrade(c.Writer, c.Request, nil) + if err != nil { + return + } + defer conn.Close() + + if !s.authenticateSystemTerminal(c, conn) { + return + } + if s.config != nil && s.config.SystemTerminal.ProtectedMode.Enabled && s.config.SystemTerminal.ProtectedMode.DisableTerminal { + sendTerminalError(conn, "protected_mode", "System terminal access is disabled by global protected mode settings") + return + } + if s.authMiddleware != nil && s.authMiddleware.IsAuthEnabled() { + if err := conn.WriteMessage(websocket.TextMessage, []byte(`{"type":"auth_success"}`)); err != nil { + return + } + } + + cmd := exec.Command(hostShell(), "-l") + cmd.Env = append(os.Environ(), "TERM=xterm-256color") + if s.config != nil && s.config.DeploymentsPath != "" { + if abs, err := filepath.Abs(s.config.DeploymentsPath); err == nil { + cmd.Dir = abs + } + } + + guard := func(command string) (bool, *models.ProtectedCommandRule, error) { + if s.config == nil { + return false, nil, nil + } + return protectedCommandBlocked(&s.config.SystemTerminal.ProtectedMode, command) + } + streamPTY(conn, cmd, guard) +} + +func hostShell() string { + if shell := os.Getenv("SHELL"); shell != "" { + return shell + } + for _, shell := range []string{"/bin/bash", "/bin/sh"} { + if _, err := os.Stat(shell); err == nil { + return shell + } + } + return "sh" +} diff --git a/internal/api/system_terminal_interactive_test.go b/internal/api/system_terminal_interactive_test.go new file mode 100644 index 0000000..4cf3669 --- /dev/null +++ b/internal/api/system_terminal_interactive_test.go @@ -0,0 +1,148 @@ +package api + +import ( + "encoding/json" + "os/exec" + "strings" + "testing" + "time" + + "github.com/creack/pty" + "github.com/flatrun/agent/pkg/config" + "github.com/flatrun/agent/pkg/models" + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" +) + +func dialSystemTerminalInteractive(t *testing.T, cfg *config.Config) *websocket.Conn { + t.Helper() + gin.SetMode(gin.TestMode) + + server := &Server{config: cfg} + router := gin.New() + router.GET("/system/terminal/interactive", server.systemTerminalInteractive) + + httpServer := newSkippableHTTPServer(t, router) + t.Cleanup(httpServer.Close) + + wsURL := "ws" + strings.TrimPrefix(httpServer.URL, "http") + "/system/terminal/interactive" + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + t.Fatalf("failed to dial websocket: %v", err) + } + t.Cleanup(func() { conn.Close() }) + return conn +} + +func requirePTY(t *testing.T) { + t.Helper() + cmd := exec.Command("sh", "-c", "true") + ptmx, err := pty.Start(cmd) + if err != nil { + t.Skipf("PTY not available in this environment: %v", err) + } + ptmx.Close() + _ = cmd.Wait() +} + +// readUntil collects frames until the predicate matches or the deadline hits. +func readUntil(t *testing.T, conn *websocket.Conn, want func(string) bool) string { + t.Helper() + var all strings.Builder + deadline := time.Now().Add(10 * time.Second) + for time.Now().Before(deadline) { + _ = conn.SetReadDeadline(time.Now().Add(2 * time.Second)) + _, message, err := conn.ReadMessage() + if err != nil { + continue + } + all.Write(message) + if want(all.String()) { + return all.String() + } + } + return all.String() +} + +func TestSystemTerminalInteractiveDisabledByProtectedMode(t *testing.T) { + cfg := &config.Config{ + SystemTerminal: config.SystemTerminalConfig{ + ProtectedMode: models.ProtectedModeConfig{ + Enabled: true, + DisableTerminal: true, + }, + }, + } + conn := dialSystemTerminalInteractive(t, cfg) + + _ = conn.SetReadDeadline(time.Now().Add(5 * time.Second)) + _, message, err := conn.ReadMessage() + if err != nil { + t.Fatalf("failed to read websocket response: %v", err) + } + + var payload struct { + Type string `json:"type"` + Code string `json:"code"` + } + if err := json.Unmarshal(message, &payload); err != nil { + t.Fatalf("expected JSON error payload, got %q", message) + } + if payload.Type != "error" || payload.Code != "protected_mode" { + t.Fatalf("expected protected_mode error, got %q", message) + } +} + +func TestSystemTerminalInteractiveRunsShellUnderPTY(t *testing.T) { + requirePTY(t) + + conn := dialSystemTerminalInteractive(t, &config.Config{DeploymentsPath: t.TempDir()}) + + // tty only succeeds with a real terminal attached; piped execution + // prints "not a tty" instead of a device path. + if err := conn.WriteMessage(websocket.TextMessage, []byte("tty && echo flatrun-pty-ok\r")); err != nil { + t.Fatalf("failed to send command: %v", err) + } + + output := readUntil(t, conn, func(s string) bool { return strings.Contains(s, "flatrun-pty-ok") }) + if !strings.Contains(output, "flatrun-pty-ok") { + t.Fatalf("shell did not run under a tty, output:\n%s", output) + } + if strings.Contains(output, "not a tty") { + t.Fatalf("shell reports no tty, output:\n%s", output) + } +} + +func TestSystemTerminalInteractiveBlocksProtectedCommands(t *testing.T) { + requirePTY(t) + + cfg := &config.Config{ + DeploymentsPath: t.TempDir(), + SystemTerminal: config.SystemTerminalConfig{ + ProtectedMode: models.ProtectedModeConfig{ + Enabled: true, + BlockedCommandRules: []models.ProtectedCommandRule{ + {ID: "no-secrets", Name: "No secret reads", Match: "contains", Pattern: "cat /etc/shadow"}, + }, + }, + }, + } + conn := dialSystemTerminalInteractive(t, cfg) + + if err := conn.WriteMessage(websocket.TextMessage, []byte("cat /etc/shadow\r")); err != nil { + t.Fatalf("failed to send command: %v", err) + } + output := readUntil(t, conn, func(s string) bool { return strings.Contains(s, "Command blocked") }) + if !strings.Contains(output, "Command blocked") { + t.Fatalf("expected the command to be blocked, output:\n%s", output) + } + + // A harmless command must still run after the block. + if err := conn.WriteMessage(websocket.TextMessage, []byte("echo flatrun-still-alive\r")); err != nil { + t.Fatalf("failed to send follow-up command: %v", err) + } + output = readUntil(t, conn, func(s string) bool { return strings.Contains(s, "flatrun-still-alive") }) + if !strings.Contains(output, "flatrun-still-alive") { + t.Fatalf("terminal did not survive the blocked command, output:\n%s", output) + } +} From cf53ef9aa96184ffb695246a7c082bd75d9739bd Mon Sep 17 00:00:00 2001 From: nfebe Date: Mon, 8 Jun 2026 02:01:46 +0100 Subject: [PATCH 7/7] chore: Bump version to 0.3.0 --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 0ea3a94..0d91a54 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.2.0 +0.3.0