package modules import ( "bytes" "errors" "fmt" "os" "os/exec" "strconv" "text/template" "time" "gopkg.in/yaml.v3" ) // FunctionDef is a single callable function within a module. type FunctionDef struct { Exec string `yaml:"exec"` // Go text/template that renders to a shell command Params map[string]string `yaml:"params"` // default parameter values } // ModuleDef maps function names to their definitions. type ModuleDef map[string]FunctionDef // Registry holds all loaded modules. type Registry struct { Modules map[string]ModuleDef `yaml:"modules"` } // LoadRegistry reads a modules.yaml file. // A missing file is not an error — returns an empty Registry so callers don't need to // special-case systems without a modules.yaml. func LoadRegistry(path string) (*Registry, error) { data, err := os.ReadFile(path) if err != nil { if errors.Is(err, os.ErrNotExist) { return &Registry{}, nil } return nil, fmt.Errorf("read modules %q: %w", path, err) } var r Registry if err := yaml.Unmarshal(data, &r); err != nil { return nil, fmt.Errorf("parse modules %q: %w", path, err) } return &r, nil } // Resolve looks up module/function, merges params (defaults overridden by overrides), // and renders the exec template. Returns the shell command string ready for sh -c. func (r *Registry) Resolve(module, function string, overrides map[string]string) (string, error) { if r == nil || r.Modules == nil { return "", fmt.Errorf("no modules loaded") } mod, ok := r.Modules[module] if !ok { return "", fmt.Errorf("unknown module %q", module) } fn, ok := mod[function] if !ok { return "", fmt.Errorf("module %q has no function %q", module, function) } // Merge: start with defaults, apply overrides on top. params := make(map[string]string, len(fn.Params)+len(overrides)) for k, v := range fn.Params { params[k] = v } for k, v := range overrides { params[k] = v } tmpl, err := template.New("exec").Funcs(funcMap()).Parse(fn.Exec) if err != nil { return "", fmt.Errorf("module %q function %q: parse template: %w", module, function, err) } var buf bytes.Buffer if err := tmpl.Execute(&buf, params); err != nil { return "", fmt.Errorf("module %q function %q: render template: %w", module, function, err) } return buf.String(), nil } // Execute resolves the module/function and runs it via sh -c. func (r *Registry) Execute(module, function string, overrides map[string]string) error { shell, err := r.Resolve(module, function, overrides) if err != nil { return err } c := exec.Command("sh", "-c", shell) c.Stdout = os.Stdout c.Stderr = os.Stderr return c.Run() } // funcMap returns the template helper functions available in exec templates. func funcMap() template.FuncMap { return template.FuncMap{ // env returns the value of an environment variable. // Use this to keep secrets (tokens, passwords) out of modules.yaml. "env": os.Getenv, // envDefault returns the value of an environment variable, or // fallback if the variable is empty/unset. "envDefault": func(key, fallback string) string { if v := os.Getenv(key); v != "" { return v } return fallback }, // expiry converts a duration string like "+1h" to a Unix epoch timestamp string. // Pass "" or "0" to get "0" (no expiry). Supports Go duration syntax (e.g. "30m", "2h"). // Note: time.ParseDuration does not support days (d) or weeks (w). "expiry": func(s string) string { if s == "" || s == "0" { return "0" } d, err := time.ParseDuration(s) if err != nil { return "0" } return strconv.FormatInt(time.Now().Add(d).Unix(), 10) }, } }