package plugin import ( "log/slog" "github.com/alexei/tinyforge/internal/crypto" ) // ResolveSharedSecrets returns the applicable shared secrets (global, then // app-scoped overlaying global) as a decrypted KEY->VALUE map. Decrypt // failures log + skip the one entry (mirroring BuildWorkloadEnv). Best-effort: // a store error logs and returns an empty map so a shared-secret outage never // fails a deploy. // // The store orders the rows global-first (then app), so iterating in order and // writing into the map makes a later app-scoped entry with the same Name // overwrite the global default — the intended global < app precedence. // // NOTE: the compose plugin intentionally does NOT call this — compose env is // YAML-defined and shared-secret support for compose is an explicit // out-of-scope follow-up. func ResolveSharedSecrets(deps Deps, appID, sourceName string) map[string]string { merged := map[string]string{} rows, err := deps.Store.ListApplicableSharedSecrets(appID) if err != nil { slog.Warn(sourceName+": list shared secrets", "app", appID, "error", err) return merged } for _, sec := range rows { value := sec.Value if sec.Encrypted { decrypted, err := crypto.Decrypt(deps.EncKey, sec.Value) if err != nil { slog.Warn(sourceName+": decrypt shared secret", "app", appID, "name", sec.Name, "error", err) continue } value = decrypted } merged[sec.Name] = value } return merged } // BuildWorkloadEnv flattens the applicable shared secrets plus workload_env // rows into the KEY=VALUE list Docker expects. Shared by the source plugins // (static, dockerfile) so they all handle decrypt failures the same way. // // Shared secrets are the low-precedence base layer; workload_env rows overlay // them so a workload's own config always wins on a key conflict. A workload // with no applicable shared secrets starts from an empty base, so the output // is identical to the workload_env-only behavior that predated shared secrets. // // Encrypted rows are decrypted lazily so plaintext never lives in the store // output. A decrypt failure logs and skips the entry rather than failing the // whole deploy: bricking a sync/build because one rotated key missed an env // entry would be worse than running with the variable unset and surfacing the // warning. // // appID is the workload's app_id (plugin.Workload.GroupID), used to resolve // app-scoped shared secrets. sourceName is the slog prefix the caller wants on // the warning lines (e.g. "static source" / "dockerfile source") so existing // log scrapers keep matching the per-source message text. func BuildWorkloadEnv(deps Deps, workloadID, appID, sourceName string) []string { // Base layer: shared secrets (global, then app overlaying global). merged := ResolveSharedSecrets(deps, appID, sourceName) rows, err := deps.Store.ListWorkloadEnv(workloadID) if err != nil { slog.Warn(sourceName+": list workload env", "workload", workloadID, "error", err) // Still return whatever shared secrets resolved; a workload_env // outage shouldn't drop the shared defaults a deploy already has. return flattenEnvMap(merged) } for _, e := range rows { value := e.Value if e.Encrypted { decrypted, err := crypto.Decrypt(deps.EncKey, e.Value) if err != nil { slog.Warn(sourceName+": decrypt env value", "workload", workloadID, "key", e.Key, "error", err) continue } value = decrypted } merged[e.Key] = value // workload_env overrides shared secrets } return flattenEnvMap(merged) } // flattenEnvMap turns a KEY->VALUE map into the KEY=VALUE slice Docker // expects. Order is unspecified (map iteration) — Docker treats env as a // set, and callers that need determinism sort downstream. func flattenEnvMap(m map[string]string) []string { out := make([]string, 0, len(m)) for k, v := range m { out = append(out, k+"="+v) } return out }