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 <noreply@anthropic.com>
This commit is contained in:
2026-06-03 21:43:34 +08:00
co-authored by Claude Opus 4.8
parent efdc360499
commit 8ecab2c270
+108
View File
@@ -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": "<field in metadata or secret json>"}}.
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
}