Files
homelab/knecht/stack/stack.go
2026-04-04 14:34:22 +02:00

145 lines
3.6 KiB
Go

package stack
import (
"bufio"
"fmt"
"os"
"path/filepath"
"strings"
)
type Local struct {
Name string
ComposePath string
EnvExample string // path to .env.example, may not exist
}
// Discover finds all stacks in the services directory.
func Discover(servicesPath string) ([]Local, error) {
entries, err := os.ReadDir(servicesPath)
if err != nil {
return nil, fmt.Errorf("reading services dir %s: %w", servicesPath, err)
}
var stacks []Local
for _, e := range entries {
if !e.IsDir() {
continue
}
composePath := filepath.Join(servicesPath, e.Name(), "docker-compose.yml")
if _, err := os.Stat(composePath); err != nil {
continue // no compose file, skip
}
s := Local{
Name: e.Name(),
ComposePath: composePath,
EnvExample: filepath.Join(servicesPath, e.Name(), ".env.example"),
}
stacks = append(stacks, s)
}
return stacks, nil
}
// Get returns a single local stack by name.
func Get(servicesPath, name string) (*Local, error) {
composePath := filepath.Join(servicesPath, name, "docker-compose.yml")
if _, err := os.Stat(composePath); err != nil {
return nil, fmt.Errorf("no docker-compose.yml found for stack %q in %s", name, servicesPath)
}
return &Local{
Name: name,
ComposePath: composePath,
EnvExample: filepath.Join(servicesPath, name, ".env.example"),
}, nil
}
// ReadCompose returns the raw compose file content.
func (s *Local) ReadCompose() (string, error) {
b, err := os.ReadFile(s.ComposePath)
if err != nil {
return "", err
}
return string(b), nil
}
// EnvExampleKeys returns the variable names defined in .env.example.
// Returns empty slice if the file doesn't exist.
func (s *Local) EnvExampleKeys() ([]string, error) {
f, err := os.Open(s.EnvExample)
if os.IsNotExist(err) {
return nil, nil
}
if err != nil {
return nil, err
}
defer f.Close()
var keys []string
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
key, _, _ := strings.Cut(line, "=")
keys = append(keys, strings.TrimSpace(key))
}
return keys, scanner.Err()
}
// WriteEnvExample creates a .env.example file from a list of keys (values left empty).
// Existing file is overwritten.
func (s *Local) WriteEnvExample(keys []string) error {
var sb strings.Builder
sb.WriteString("# Generated by knecht from Portainer env vars\n")
for _, k := range keys {
sb.WriteString(k + "=\n")
}
return os.WriteFile(s.EnvExample, []byte(sb.String()), 0644)
}
// AppendEnvExample appends missing keys to an existing .env.example file.
func (s *Local) AppendEnvExample(keys []string) error {
f, err := os.OpenFile(s.EnvExample, os.O_APPEND|os.O_WRONLY, 0644)
if err != nil {
return err
}
defer f.Close()
f.WriteString("\n# Added by knecht\n")
for _, k := range keys {
f.WriteString(k + "=\n")
}
return nil
}
// ServicesPath resolves the services directory: uses cfg value if set,
// otherwise walks up from cwd to find the git root and appends /services.
func ServicesPath(cfgPath string) (string, error) {
if cfgPath != "" {
return cfgPath, nil
}
return findServicesDir()
}
func findServicesDir() (string, error) {
dir, err := os.Getwd()
if err != nil {
return "", err
}
for {
if _, err := os.Stat(filepath.Join(dir, ".git")); err == nil {
p := filepath.Join(dir, "services")
if _, err := os.Stat(p); err == nil {
return p, nil
}
return "", fmt.Errorf("found git root at %s but no services/ directory", dir)
}
parent := filepath.Dir(dir)
if parent == dir {
break
}
dir = parent
}
return "", fmt.Errorf("could not find git root with a services/ directory")
}