From 8ecab2c270d18a1de11d15ff4d288c8de475eaed Mon Sep 17 00:00:00 2001 From: chenchen Date: Wed, 3 Jun 2026 21:43:34 +0800 Subject: [PATCH] feat(controller): env assembly from resource bindings for template agents buildAgentEnvFromBindings resolves selected resource bindings into a flat env map for a template agent's .env at start: non-secret values from Metadata, secret values resolved from Key Vault (getJSONSecret) only here, lazily, never persisted. AM-independent foundation for the deploy-agent flow. Co-Authored-By: Claude Opus 4.8 --- heicode/controller/agent_template_env.go | 108 +++++++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 heicode/controller/agent_template_env.go diff --git a/heicode/controller/agent_template_env.go b/heicode/controller/agent_template_env.go new file mode 100644 index 0000000..b06140e --- /dev/null +++ b/heicode/controller/agent_template_env.go @@ -0,0 +1,108 @@ +package controller + +import ( + "fmt" + "strings" + + "github.com/heicode/manager/common" + "github.com/heicode/manager/model" +) + +// envMapEntry describes how one environment variable is sourced from a resource +// binding when starting a template agent. +// +// Source == "metadata" -> value taken from the binding's non-secret Metadata JSON +// Source == "secret" -> value resolved from Key Vault (binding.SecretRef) at +// start time only; never persisted in plaintext. +// +// EnvMap on a ResourceBinding is a JSON object: {"ENV_NAME": {"source": "...", +// "key": ""}}. +type envMapEntry struct { + Source string `json:"source"` + Key string `json:"key"` +} + +// buildAgentEnvFromBindings resolves the selected resource bindings into a flat +// environment map for a template agent's .env at start time. +// +// Non-secret values come from each binding's Metadata; secret values are read +// from Key Vault here, at start time, and returned for injection into the agent +// — they are NOT stored back on the Manager side. Callers must not log the +// returned map. +func buildAgentEnvFromBindings(userID int, bindingIDs []int) (map[string]string, error) { + env := map[string]string{} + if len(bindingIDs) == 0 { + return env, nil + } + if model.DB == nil { + return nil, fmt.Errorf("database not initialised") + } + + var store secretStoreClient + storeReady := false + + for _, id := range bindingIDs { + var binding model.ResourceBinding + if err := model.DB.Where("id = ? AND user_id = ?", id, userID).First(&binding).Error; err != nil { + return nil, fmt.Errorf("resource binding %d not found for user", id) + } + + entries := map[string]envMapEntry{} + if strings.TrimSpace(binding.EnvMap) != "" { + if err := common.UnmarshalJsonStr(binding.EnvMap, &entries); err != nil { + return nil, fmt.Errorf("binding %d env_map is not valid JSON: %w", id, err) + } + } + if len(entries) == 0 { + continue + } + + metadata := map[string]any{} + if strings.TrimSpace(binding.Metadata) != "" { + _ = common.UnmarshalJsonStr(binding.Metadata, &metadata) + } + + // The secret JSON is fetched lazily, only if some entry needs it. + var secret map[string]any + secretLoaded := false + + for envName, entry := range entries { + name := strings.TrimSpace(envName) + if name == "" { + continue + } + switch entry.Source { + case "metadata": + if value, ok := metadata[entry.Key]; ok { + env[name] = fmt.Sprintf("%v", value) + } + case "secret": + if !secretLoaded { + if strings.TrimSpace(binding.SecretRef) == "" { + return nil, fmt.Errorf("binding %d env_map needs a secret but the binding has no secret_ref", id) + } + if !storeReady { + client, err := newSecretStoreClientFromEnv() + if err != nil { + return nil, err + } + store = client + storeReady = true + } + resolved, err := store.getJSONSecret(binding.SecretRef) + if err != nil { + return nil, fmt.Errorf("binding %d secret read failed: %w", id, err) + } + secret = resolved + secretLoaded = true + } + if value, ok := secret[entry.Key]; ok { + env[name] = fmt.Sprintf("%v", value) + } + default: + return nil, fmt.Errorf("binding %d env_map %q: unknown source %q (want metadata|secret)", id, name, entry.Source) + } + } + } + return env, nil +}