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") }