restructured files, and updated for build outside
This commit is contained in:
@@ -0,0 +1,301 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// AppConfig holds the per-app deployment configuration.
|
||||
// Saved as app.yaml in each stack directory after first deployment.
|
||||
type AppConfig struct {
|
||||
Deployed bool `yaml:"deployed" json:"deployed"`
|
||||
DeployedAt string `yaml:"deployed_at" json:"deployed_at"`
|
||||
Env map[string]string `yaml:"env" json:"env"`
|
||||
LockedFields []string `yaml:"locked_fields" json:"locked_fields"`
|
||||
}
|
||||
|
||||
// DeployRequest contains the user-provided values from the deploy form.
|
||||
type DeployRequest struct {
|
||||
StackName string `json:"stack_name"`
|
||||
Values map[string]string `json:"values"` // env_var -> user-provided value
|
||||
}
|
||||
|
||||
// DeployStack handles first-time deployment of an app:
|
||||
// 1. Load metadata (.felhom.yml) to know what fields exist
|
||||
// 2. Auto-generate secrets for secret/password fields without user values
|
||||
// 3. Auto-fill domain from controller config
|
||||
// 4. Merge with user-provided values
|
||||
// 5. Save app.yaml
|
||||
// 6. Run docker compose up -d with env vars
|
||||
func (m *Manager) DeployStack(req DeployRequest) error {
|
||||
stack, ok := m.GetStack(req.StackName)
|
||||
if !ok {
|
||||
return fmt.Errorf("stack %q not found", req.StackName)
|
||||
}
|
||||
|
||||
stackDir := filepath.Dir(stack.ComposePath)
|
||||
meta := LoadMetadata(stackDir)
|
||||
|
||||
// Check if already deployed
|
||||
existing := LoadAppConfig(stackDir)
|
||||
if existing != nil && existing.Deployed {
|
||||
return fmt.Errorf("stack %q is already deployed; use update instead", req.StackName)
|
||||
}
|
||||
|
||||
// Build the full env map
|
||||
env := make(map[string]string)
|
||||
var lockedFields []string
|
||||
|
||||
for _, field := range meta.DeployFields {
|
||||
var value string
|
||||
|
||||
switch field.Type {
|
||||
case "domain":
|
||||
// Auto-fill from controller config
|
||||
value = m.cfg.Customer.Domain
|
||||
|
||||
case "secret":
|
||||
// Always auto-generate, user never sees these
|
||||
generated, err := generateValue(field.Generate)
|
||||
if err != nil {
|
||||
return fmt.Errorf("generating %s: %w", field.EnvVar, err)
|
||||
}
|
||||
value = generated
|
||||
|
||||
case "password":
|
||||
// Use user value if provided, otherwise generate
|
||||
if userVal, ok := req.Values[field.EnvVar]; ok && userVal != "" {
|
||||
value = userVal
|
||||
} else if field.Generate != "" {
|
||||
generated, err := generateValue(field.Generate)
|
||||
if err != nil {
|
||||
return fmt.Errorf("generating %s: %w", field.EnvVar, err)
|
||||
}
|
||||
value = generated
|
||||
}
|
||||
|
||||
default:
|
||||
// text, path, select, boolean — use user value or default
|
||||
if userVal, ok := req.Values[field.EnvVar]; ok {
|
||||
value = userVal
|
||||
} else if field.Default != "" {
|
||||
value = field.Default
|
||||
}
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if field.Required && value == "" {
|
||||
return fmt.Errorf("required field %q (%s) is empty", field.Label, field.EnvVar)
|
||||
}
|
||||
|
||||
// Validate path fields exist
|
||||
if field.Type == "path" && value != "" {
|
||||
if _, err := os.Stat(value); os.IsNotExist(err) {
|
||||
return fmt.Errorf("path %q does not exist for field %q", value, field.Label)
|
||||
}
|
||||
}
|
||||
|
||||
if value != "" {
|
||||
env[field.EnvVar] = value
|
||||
}
|
||||
|
||||
if field.LockedAfterDeploy {
|
||||
lockedFields = append(lockedFields, field.EnvVar)
|
||||
}
|
||||
}
|
||||
|
||||
// Save app.yaml
|
||||
appCfg := &AppConfig{
|
||||
Deployed: true,
|
||||
DeployedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
Env: env,
|
||||
LockedFields: lockedFields,
|
||||
}
|
||||
|
||||
if err := SaveAppConfig(stackDir, appCfg); err != nil {
|
||||
return fmt.Errorf("saving app config: %w", err)
|
||||
}
|
||||
|
||||
m.logger.Printf("[INFO] Deploying stack %s with %d env vars", req.StackName, len(env))
|
||||
|
||||
// Run docker compose up -d
|
||||
_, err := m.composeExecWithEnv(stackDir, env, "up", "-d")
|
||||
if err != nil {
|
||||
// Deployment failed — keep app.yaml for debugging but mark as not deployed
|
||||
appCfg.Deployed = false
|
||||
_ = SaveAppConfig(stackDir, appCfg)
|
||||
return fmt.Errorf("docker compose up failed: %w", err)
|
||||
}
|
||||
|
||||
m.logger.Printf("[INFO] Stack %s deployed successfully", req.StackName)
|
||||
return m.RefreshStatus()
|
||||
}
|
||||
|
||||
// UpdateStackConfig updates non-locked fields for a deployed stack.
|
||||
func (m *Manager) UpdateStackConfig(name string, values map[string]string) error {
|
||||
stack, ok := m.GetStack(name)
|
||||
if !ok {
|
||||
return fmt.Errorf("stack %q not found", name)
|
||||
}
|
||||
|
||||
stackDir := filepath.Dir(stack.ComposePath)
|
||||
appCfg := LoadAppConfig(stackDir)
|
||||
if appCfg == nil || !appCfg.Deployed {
|
||||
return fmt.Errorf("stack %q is not deployed yet", name)
|
||||
}
|
||||
|
||||
// Apply changes, respecting locked fields
|
||||
lockedSet := make(map[string]bool)
|
||||
for _, f := range appCfg.LockedFields {
|
||||
lockedSet[f] = true
|
||||
}
|
||||
|
||||
for key, val := range values {
|
||||
if lockedSet[key] {
|
||||
return fmt.Errorf("field %q is locked and cannot be changed after deployment", key)
|
||||
}
|
||||
appCfg.Env[key] = val
|
||||
}
|
||||
|
||||
if err := SaveAppConfig(stackDir, appCfg); err != nil {
|
||||
return fmt.Errorf("saving updated config: %w", err)
|
||||
}
|
||||
|
||||
// Restart with new env
|
||||
_, err := m.composeExecWithEnv(stackDir, appCfg.Env, "up", "-d")
|
||||
if err != nil {
|
||||
return fmt.Errorf("restarting with new config: %w", err)
|
||||
}
|
||||
|
||||
m.logger.Printf("[INFO] Stack %s config updated and restarted", name)
|
||||
return m.RefreshStatus()
|
||||
}
|
||||
|
||||
// composeExecWithEnv runs a compose command with custom env vars injected.
|
||||
func (m *Manager) composeExecWithEnv(dir string, env map[string]string, args ...string) (string, error) {
|
||||
// Build env slice: start with os env, then add our vars
|
||||
cmdEnv := os.Environ()
|
||||
for k, v := range env {
|
||||
cmdEnv = append(cmdEnv, fmt.Sprintf("%s=%s", k, v))
|
||||
}
|
||||
// Always inject DOMAIN from controller config
|
||||
cmdEnv = append(cmdEnv, fmt.Sprintf("DOMAIN=%s", m.cfg.Customer.Domain))
|
||||
|
||||
return m.composeExecCustomEnv(dir, cmdEnv, args...)
|
||||
}
|
||||
|
||||
// GetDeployFields returns the deployment fields for a stack (for the deploy form).
|
||||
func (m *Manager) GetDeployFields(name string) (*Metadata, *AppConfig, error) {
|
||||
stack, ok := m.GetStack(name)
|
||||
if !ok {
|
||||
return nil, nil, fmt.Errorf("stack %q not found", name)
|
||||
}
|
||||
|
||||
stackDir := filepath.Dir(stack.ComposePath)
|
||||
meta := LoadMetadata(stackDir)
|
||||
appCfg := LoadAppConfig(stackDir)
|
||||
|
||||
return &meta, appCfg, nil
|
||||
}
|
||||
|
||||
// --- App config persistence ---
|
||||
|
||||
// LoadAppConfig reads app.yaml from a stack directory.
|
||||
// Returns nil if the file doesn't exist.
|
||||
func LoadAppConfig(stackDir string) *AppConfig {
|
||||
path := filepath.Join(stackDir, "app.yaml")
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
cfg := &AppConfig{}
|
||||
if err := yaml.Unmarshal(data, cfg); err != nil {
|
||||
return nil
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
// SaveAppConfig writes app.yaml to a stack directory.
|
||||
func SaveAppConfig(stackDir string, cfg *AppConfig) error {
|
||||
data, err := yaml.Marshal(cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshaling app config: %w", err)
|
||||
}
|
||||
|
||||
path := filepath.Join(stackDir, "app.yaml")
|
||||
|
||||
header := "# Auto-generated by felhom-controller — do not edit locked fields manually\n"
|
||||
content := header + string(data)
|
||||
|
||||
if err := os.WriteFile(path, []byte(content), 0600); err != nil {
|
||||
return fmt.Errorf("writing %s: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- Secret generation ---
|
||||
|
||||
const alphanumChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
|
||||
// generateValue creates a random value based on the generator spec.
|
||||
// Formats: "password:N", "hex:N", "static:VALUE"
|
||||
func generateValue(spec string) (string, error) {
|
||||
if spec == "" {
|
||||
return "", fmt.Errorf("empty generator spec")
|
||||
}
|
||||
|
||||
parts := strings.SplitN(spec, ":", 2)
|
||||
if len(parts) != 2 {
|
||||
return "", fmt.Errorf("invalid generator spec: %q (expected type:param)", spec)
|
||||
}
|
||||
|
||||
genType := parts[0]
|
||||
param := parts[1]
|
||||
|
||||
switch genType {
|
||||
case "password":
|
||||
length := 0
|
||||
if _, err := fmt.Sscanf(param, "%d", &length); err != nil || length <= 0 {
|
||||
return "", fmt.Errorf("invalid password length: %q", param)
|
||||
}
|
||||
return randomAlphanumeric(length)
|
||||
|
||||
case "hex":
|
||||
byteLen := 0
|
||||
if _, err := fmt.Sscanf(param, "%d", &byteLen); err != nil || byteLen <= 0 {
|
||||
return "", fmt.Errorf("invalid hex length: %q", param)
|
||||
}
|
||||
b := make([]byte, byteLen)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", fmt.Errorf("reading random bytes: %w", err)
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
|
||||
case "static":
|
||||
return param, nil
|
||||
|
||||
default:
|
||||
return "", fmt.Errorf("unknown generator type: %q", genType)
|
||||
}
|
||||
}
|
||||
|
||||
func randomAlphanumeric(length int) (string, error) {
|
||||
result := make([]byte, length)
|
||||
for i := range result {
|
||||
n, err := rand.Int(rand.Reader, big.NewInt(int64(len(alphanumChars))))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
result[i] = alphanumChars[n.Int64()]
|
||||
}
|
||||
return string(result), nil
|
||||
}
|
||||
@@ -0,0 +1,452 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
||||
)
|
||||
|
||||
// ContainerState represents the current state of a container.
|
||||
type ContainerState string
|
||||
|
||||
const (
|
||||
StateRunning ContainerState = "running"
|
||||
StateStopped ContainerState = "stopped"
|
||||
StateRestarting ContainerState = "restarting"
|
||||
StateExited ContainerState = "exited"
|
||||
StatePaused ContainerState = "paused"
|
||||
StateUnknown ContainerState = "unknown"
|
||||
StateNotDeployed ContainerState = "not_deployed"
|
||||
)
|
||||
|
||||
// ContainerInfo holds status info about a single container within a stack.
|
||||
type ContainerInfo struct {
|
||||
Name string `json:"name"`
|
||||
Image string `json:"image"`
|
||||
State ContainerState `json:"state"`
|
||||
Status string `json:"status"` // e.g. "Up 3 hours"
|
||||
}
|
||||
|
||||
// Stack represents a docker compose stack on disk.
|
||||
type Stack struct {
|
||||
Name string `json:"name"`
|
||||
Meta Metadata `json:"meta"`
|
||||
ComposePath string `json:"compose_path"`
|
||||
State ContainerState `json:"state"`
|
||||
Deployed bool `json:"deployed"` // Has app.yaml with deployed=true
|
||||
Protected bool `json:"protected"`
|
||||
Containers []ContainerInfo `json:"containers"`
|
||||
AppConfig *AppConfig `json:"app_config,omitempty"`
|
||||
LastUpdated time.Time `json:"last_updated"`
|
||||
}
|
||||
|
||||
// Manager handles all docker compose stack operations.
|
||||
type Manager struct {
|
||||
cfg *config.Config
|
||||
logger *log.Logger
|
||||
composeCmd string
|
||||
stacks map[string]*Stack
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewManager creates a new stack manager.
|
||||
func NewManager(cfg *config.Config, logger *log.Logger) (*Manager, error) {
|
||||
composeCmd := cfg.Stacks.ComposeCommand
|
||||
if composeCmd == "" {
|
||||
composeCmd = detectComposeCommand()
|
||||
}
|
||||
if composeCmd == "" {
|
||||
return nil, fmt.Errorf("docker compose not found (tried 'docker compose' and 'docker-compose')")
|
||||
}
|
||||
|
||||
logger.Printf("[INFO] Using compose command: %s", composeCmd)
|
||||
|
||||
if err := os.MkdirAll(cfg.Paths.StacksDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("creating stacks directory %s: %w", cfg.Paths.StacksDir, err)
|
||||
}
|
||||
|
||||
return &Manager{
|
||||
cfg: cfg,
|
||||
logger: logger,
|
||||
composeCmd: composeCmd,
|
||||
stacks: make(map[string]*Stack),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// toTitleCase capitalizes the first letter of each word.
|
||||
func toTitleCase(s string) string {
|
||||
words := strings.Fields(s)
|
||||
for i, w := range words {
|
||||
if len(w) > 0 {
|
||||
words[i] = strings.ToUpper(w[:1]) + w[1:]
|
||||
}
|
||||
}
|
||||
return strings.Join(words, " ")
|
||||
}
|
||||
|
||||
func detectComposeCommand() string {
|
||||
if err := exec.Command("docker", "compose", "version").Run(); err == nil {
|
||||
return "docker compose"
|
||||
}
|
||||
if _, err := exec.LookPath("docker-compose"); err == nil {
|
||||
return "docker-compose"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ScanStacks discovers all compose stacks in the stacks directory.
|
||||
func (m *Manager) ScanStacks() error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
entries, err := os.ReadDir(m.cfg.Paths.StacksDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading stacks directory: %w", err)
|
||||
}
|
||||
|
||||
found := make(map[string]bool)
|
||||
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
name := entry.Name()
|
||||
stackDir := filepath.Join(m.cfg.Paths.StacksDir, name)
|
||||
composePath := filepath.Join(stackDir, "docker-compose.yml")
|
||||
|
||||
if _, err := os.Stat(composePath); os.IsNotExist(err) {
|
||||
composePath = filepath.Join(stackDir, "docker-compose.yaml")
|
||||
if _, err := os.Stat(composePath); os.IsNotExist(err) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
found[name] = true
|
||||
|
||||
meta := LoadMetadata(stackDir)
|
||||
appCfg := LoadAppConfig(stackDir)
|
||||
deployed := appCfg != nil && appCfg.Deployed
|
||||
|
||||
if existing, ok := m.stacks[name]; ok {
|
||||
existing.ComposePath = composePath
|
||||
existing.Meta = meta
|
||||
existing.Protected = m.cfg.IsProtectedStack(name)
|
||||
existing.Deployed = deployed
|
||||
existing.AppConfig = appCfg
|
||||
} else {
|
||||
m.stacks[name] = &Stack{
|
||||
Name: name,
|
||||
Meta: meta,
|
||||
ComposePath: composePath,
|
||||
State: StateNotDeployed,
|
||||
Deployed: deployed,
|
||||
Protected: m.cfg.IsProtectedStack(name),
|
||||
AppConfig: appCfg,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove stacks no longer on disk
|
||||
for name := range m.stacks {
|
||||
if !found[name] {
|
||||
delete(m.stacks, name)
|
||||
}
|
||||
}
|
||||
|
||||
m.logger.Printf("[INFO] Scanned stacks: %d found", len(m.stacks))
|
||||
return m.refreshStatusLocked()
|
||||
}
|
||||
|
||||
// RefreshStatus updates container status for all known stacks.
|
||||
func (m *Manager) RefreshStatus() error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.refreshStatusLocked()
|
||||
}
|
||||
|
||||
func (m *Manager) refreshStatusLocked() error {
|
||||
output, err := m.execCommand("docker", "ps", "-a",
|
||||
"--format", "{{.Names}}\t{{.Image}}\t{{.State}}\t{{.Status}}\t{{.Label \"com.docker.compose.project\"}}",
|
||||
"--no-trunc")
|
||||
if err != nil {
|
||||
return fmt.Errorf("docker ps: %w", err)
|
||||
}
|
||||
|
||||
projectContainers := make(map[string][]ContainerInfo)
|
||||
|
||||
for _, line := range strings.Split(strings.TrimSpace(output), "\n") {
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
parts := strings.SplitN(line, "\t", 5)
|
||||
if len(parts) < 5 || parts[4] == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
ci := ContainerInfo{
|
||||
Name: parts[0],
|
||||
Image: parts[1],
|
||||
State: parseContainerState(parts[2]),
|
||||
Status: parts[3],
|
||||
}
|
||||
projectContainers[parts[4]] = append(projectContainers[parts[4]], ci)
|
||||
}
|
||||
|
||||
for name, stack := range m.stacks {
|
||||
containers, exists := projectContainers[name]
|
||||
if !exists {
|
||||
stack.Containers = nil
|
||||
if stack.Deployed {
|
||||
stack.State = StateStopped
|
||||
} else {
|
||||
stack.State = StateNotDeployed
|
||||
}
|
||||
} else {
|
||||
stack.Containers = containers
|
||||
stack.State = aggregateState(containers)
|
||||
}
|
||||
stack.LastUpdated = time.Now()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseContainerState(s string) ContainerState {
|
||||
switch strings.ToLower(strings.TrimSpace(s)) {
|
||||
case "running":
|
||||
return StateRunning
|
||||
case "exited":
|
||||
return StateExited
|
||||
case "restarting":
|
||||
return StateRestarting
|
||||
case "paused":
|
||||
return StatePaused
|
||||
case "created", "dead", "removing":
|
||||
return StateStopped
|
||||
default:
|
||||
return StateUnknown
|
||||
}
|
||||
}
|
||||
|
||||
func aggregateState(containers []ContainerInfo) ContainerState {
|
||||
if len(containers) == 0 {
|
||||
return StateNotDeployed
|
||||
}
|
||||
for _, c := range containers {
|
||||
if c.State == StateRunning {
|
||||
return StateRunning
|
||||
}
|
||||
}
|
||||
for _, c := range containers {
|
||||
if c.State == StateRestarting {
|
||||
return StateRestarting
|
||||
}
|
||||
}
|
||||
return StateStopped
|
||||
}
|
||||
|
||||
// --- Stack accessors ---
|
||||
|
||||
func (m *Manager) GetStacks() []Stack {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
result := make([]Stack, 0, len(m.stacks))
|
||||
for _, s := range m.stacks {
|
||||
result = append(result, *s)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (m *Manager) GetStack(name string) (*Stack, bool) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
s, ok := m.stacks[name]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
copy := *s
|
||||
return ©, true
|
||||
}
|
||||
|
||||
// --- Stack operations ---
|
||||
// StartStack, StopStack, etc. now load app.yaml env for deployed stacks.
|
||||
|
||||
func (m *Manager) StartStack(name string) error {
|
||||
stack, ok := m.GetStack(name)
|
||||
if !ok {
|
||||
return fmt.Errorf("stack %q not found", name)
|
||||
}
|
||||
|
||||
m.logger.Printf("[INFO] Starting stack: %s", name)
|
||||
|
||||
dir := filepath.Dir(stack.ComposePath)
|
||||
env := m.stackEnv(dir)
|
||||
|
||||
if _, err := m.composeExecCustomEnv(dir, env, "up", "-d"); err != nil {
|
||||
return fmt.Errorf("starting stack %s: %w", name, err)
|
||||
}
|
||||
|
||||
m.logger.Printf("[INFO] Stack %s started", name)
|
||||
return m.RefreshStatus()
|
||||
}
|
||||
|
||||
func (m *Manager) StopStack(name string) error {
|
||||
if m.cfg.IsProtectedStack(name) {
|
||||
return fmt.Errorf("stack %q is protected and cannot be stopped", name)
|
||||
}
|
||||
|
||||
stack, ok := m.GetStack(name)
|
||||
if !ok {
|
||||
return fmt.Errorf("stack %q not found", name)
|
||||
}
|
||||
|
||||
m.logger.Printf("[INFO] Stopping stack: %s", name)
|
||||
dir := filepath.Dir(stack.ComposePath)
|
||||
|
||||
if _, err := m.composeExec(dir, "down"); err != nil {
|
||||
return fmt.Errorf("stopping stack %s: %w", name, err)
|
||||
}
|
||||
|
||||
m.logger.Printf("[INFO] Stack %s stopped", name)
|
||||
return m.RefreshStatus()
|
||||
}
|
||||
|
||||
func (m *Manager) RestartStack(name string) error {
|
||||
stack, ok := m.GetStack(name)
|
||||
if !ok {
|
||||
return fmt.Errorf("stack %q not found", name)
|
||||
}
|
||||
|
||||
m.logger.Printf("[INFO] Restarting stack: %s", name)
|
||||
dir := filepath.Dir(stack.ComposePath)
|
||||
|
||||
if _, err := m.composeExec(dir, "restart"); err != nil {
|
||||
return fmt.Errorf("restarting stack %s: %w", name, err)
|
||||
}
|
||||
|
||||
m.logger.Printf("[INFO] Stack %s restarted", name)
|
||||
return m.RefreshStatus()
|
||||
}
|
||||
|
||||
func (m *Manager) UpdateStack(name string) error {
|
||||
stack, ok := m.GetStack(name)
|
||||
if !ok {
|
||||
return fmt.Errorf("stack %q not found", name)
|
||||
}
|
||||
|
||||
m.logger.Printf("[INFO] Updating stack: %s", name)
|
||||
dir := filepath.Dir(stack.ComposePath)
|
||||
env := m.stackEnv(dir)
|
||||
|
||||
if _, err := m.composeExecCustomEnv(dir, env, "pull"); err != nil {
|
||||
return fmt.Errorf("pulling images for %s: %w", name, err)
|
||||
}
|
||||
|
||||
if _, err := m.composeExecCustomEnv(dir, env, "up", "-d", "--remove-orphans"); err != nil {
|
||||
return fmt.Errorf("recreating %s: %w", name, err)
|
||||
}
|
||||
|
||||
m.logger.Printf("[INFO] Stack %s updated", name)
|
||||
return m.RefreshStatus()
|
||||
}
|
||||
|
||||
func (m *Manager) GetLogs(name string, lines int) (string, error) {
|
||||
stack, ok := m.GetStack(name)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("stack %q not found", name)
|
||||
}
|
||||
|
||||
if lines <= 0 {
|
||||
lines = 100
|
||||
}
|
||||
if lines > 1000 {
|
||||
lines = 1000
|
||||
}
|
||||
|
||||
dir := filepath.Dir(stack.ComposePath)
|
||||
output, err := m.composeExec(dir, "logs", "--tail", fmt.Sprintf("%d", lines), "--no-color")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("getting logs for %s: %w", name, err)
|
||||
}
|
||||
return output, nil
|
||||
}
|
||||
|
||||
// --- Env and compose helpers ---
|
||||
|
||||
// stackEnv builds the full OS env slice for a stack, merging app.yaml values.
|
||||
func (m *Manager) stackEnv(stackDir string) []string {
|
||||
env := os.Environ()
|
||||
|
||||
// Always inject DOMAIN
|
||||
env = append(env, fmt.Sprintf("DOMAIN=%s", m.cfg.Customer.Domain))
|
||||
|
||||
// Load app.yaml if it exists — merge its env vars
|
||||
appCfg := LoadAppConfig(stackDir)
|
||||
if appCfg != nil {
|
||||
for k, v := range appCfg.Env {
|
||||
env = append(env, fmt.Sprintf("%s=%s", k, v))
|
||||
}
|
||||
}
|
||||
|
||||
return env
|
||||
}
|
||||
|
||||
func (m *Manager) composeExec(dir string, args ...string) (string, error) {
|
||||
return m.composeExecCustomEnv(dir, nil, args...)
|
||||
}
|
||||
|
||||
func (m *Manager) composeExecCustomEnv(dir string, env []string, args ...string) (string, error) {
|
||||
var cmd *exec.Cmd
|
||||
|
||||
if m.composeCmd == "docker compose" {
|
||||
fullArgs := append([]string{"compose"}, args...)
|
||||
cmd = exec.Command("docker", fullArgs...)
|
||||
} else {
|
||||
cmd = exec.Command("docker-compose", args...)
|
||||
}
|
||||
|
||||
cmd.Dir = dir
|
||||
|
||||
if env != nil {
|
||||
cmd.Env = env
|
||||
} else {
|
||||
cmd.Env = m.stackEnv(dir)
|
||||
}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
m.logger.Printf("[DEBUG] Running: %s %s (in %s)", m.composeCmd, strings.Join(args, " "), dir)
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return stdout.String(), fmt.Errorf("%w\nstderr: %s", err, stderr.String())
|
||||
}
|
||||
|
||||
return stdout.String(), nil
|
||||
}
|
||||
|
||||
func (m *Manager) execCommand(name string, args ...string) (string, error) {
|
||||
cmd := exec.Command(name, args...)
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return "", fmt.Errorf("exec %s %s: %w\nstderr: %s", name, strings.Join(args, " "), err, stderr.String())
|
||||
}
|
||||
|
||||
return stdout.String(), nil
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Metadata holds app information parsed from .felhom.yml.
|
||||
type Metadata struct {
|
||||
DisplayName string `yaml:"display_name" json:"display_name"`
|
||||
Description string `yaml:"description" json:"description"`
|
||||
Category string `yaml:"category" json:"category"`
|
||||
Subdomain string `yaml:"subdomain" json:"subdomain"`
|
||||
Slug string `yaml:"slug" json:"slug"`
|
||||
Resources ResourceHints `yaml:"resources" json:"resources"`
|
||||
DeployFields []DeployField `yaml:"deploy_fields" json:"deploy_fields"`
|
||||
}
|
||||
|
||||
// ResourceHints describe what the app needs.
|
||||
type ResourceHints struct {
|
||||
RAM string `yaml:"ram" json:"ram"`
|
||||
PiCompatible bool `yaml:"pi_compatible" json:"pi_compatible"`
|
||||
NeedsHDD bool `yaml:"needs_hdd" json:"needs_hdd"`
|
||||
}
|
||||
|
||||
// DeployField defines one configuration field shown during first deployment.
|
||||
type DeployField struct {
|
||||
EnvVar string `yaml:"env_var" json:"env_var"`
|
||||
Label string `yaml:"label" json:"label"`
|
||||
Type string `yaml:"type" json:"type"` // domain, secret, password, path, text, select, boolean
|
||||
Generate string `yaml:"generate" json:"generate"` // e.g., "password:24", "hex:32", "static:admin"
|
||||
Default string `yaml:"default" json:"default"`
|
||||
Required bool `yaml:"required" json:"required"`
|
||||
Placeholder string `yaml:"placeholder" json:"placeholder"`
|
||||
Description string `yaml:"description" json:"description"`
|
||||
LockedAfterDeploy bool `yaml:"locked_after_deploy" json:"locked_after_deploy"`
|
||||
Options []SelectOption `yaml:"options" json:"options,omitempty"`
|
||||
}
|
||||
|
||||
// SelectOption is a choice for "select" type fields.
|
||||
type SelectOption struct {
|
||||
Value string `yaml:"value" json:"value"`
|
||||
Label string `yaml:"label" json:"label"`
|
||||
}
|
||||
|
||||
// LoadMetadata reads .felhom.yml from a stack directory.
|
||||
// Returns default metadata if the file doesn't exist.
|
||||
func LoadMetadata(stackDir string) Metadata {
|
||||
meta := Metadata{}
|
||||
|
||||
path := filepath.Join(stackDir, ".felhom.yml")
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
// No metadata file — build defaults from directory name
|
||||
dirName := filepath.Base(stackDir)
|
||||
meta.DisplayName = toTitleCase(strings.ReplaceAll(dirName, "-", " "))
|
||||
meta.Slug = dirName
|
||||
meta.Category = "tools"
|
||||
return meta
|
||||
}
|
||||
|
||||
if err := yaml.Unmarshal(data, &meta); err != nil {
|
||||
// Parse error — still return defaults
|
||||
dirName := filepath.Base(stackDir)
|
||||
meta.DisplayName = toTitleCase(strings.ReplaceAll(dirName, "-", " "))
|
||||
meta.Slug = dirName
|
||||
return meta
|
||||
}
|
||||
|
||||
// Fill in defaults for missing fields
|
||||
dirName := filepath.Base(stackDir)
|
||||
if meta.Slug == "" {
|
||||
meta.Slug = dirName
|
||||
}
|
||||
if meta.DisplayName == "" {
|
||||
meta.DisplayName = toTitleCase(strings.ReplaceAll(dirName, "-", " "))
|
||||
}
|
||||
if meta.Category == "" {
|
||||
meta.Category = "tools"
|
||||
}
|
||||
|
||||
// DOMAIN field is always auto-filled — mark it implicitly required
|
||||
for i := range meta.DeployFields {
|
||||
if meta.DeployFields[i].Type == "domain" {
|
||||
meta.DeployFields[i].Required = true
|
||||
meta.DeployFields[i].LockedAfterDeploy = true
|
||||
}
|
||||
// secret fields are always locked after deploy
|
||||
if meta.DeployFields[i].Type == "secret" {
|
||||
meta.DeployFields[i].LockedAfterDeploy = true
|
||||
}
|
||||
}
|
||||
|
||||
return meta
|
||||
}
|
||||
|
||||
// HasDeployFields returns true if the app has any user-facing deploy fields
|
||||
// (i.e., fields beyond auto-filled domain and auto-generated secrets).
|
||||
func (m *Metadata) HasDeployFields() bool {
|
||||
for _, f := range m.DeployFields {
|
||||
if f.Type != "domain" && f.Type != "secret" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// UserFacingFields returns only fields the user needs to interact with.
|
||||
// Excludes auto-filled (domain) and fully hidden (secret) fields.
|
||||
func (m *Metadata) UserFacingFields() []DeployField {
|
||||
var fields []DeployField
|
||||
for _, f := range m.DeployFields {
|
||||
if f.Type != "domain" && f.Type != "secret" {
|
||||
fields = append(fields, f)
|
||||
}
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
// AutoGeneratedFields returns fields that are generated without user input.
|
||||
func (m *Metadata) AutoGeneratedFields() []DeployField {
|
||||
var fields []DeployField
|
||||
for _, f := range m.DeployFields {
|
||||
if f.Type == "secret" || f.Type == "domain" {
|
||||
fields = append(fields, f)
|
||||
}
|
||||
}
|
||||
return fields
|
||||
}
|
||||
Reference in New Issue
Block a user