refactor(agent): built-in per-type env mapping; fix start timeout, orphan, guards

Replace the user-facing env_map mistake with a built-in env convention keyed by
resource type + provider — users never see/edit env names; they only fill plain
resource fields. Supports git (gitea/github/gitlab), vm, database
(mysql/pg/redis/mongo, with alias normalisation), storage (azure blob / bucket).
Lenient: missing optional fields are skipped; only unsupported type or a KV read
failure errors.

Other issues found in review and fixed:
- start timeout: template-agent start now uses a longer timeout (default 60s,
  AGENT_RUNTIME_START_TIMEOUT_SECONDS) since AM provisions synchronously — 5s
  would time out. amTemplateDo takes a per-call timeout.
- orphan agent: if AM start succeeds but the Manager record fails to persist, the
  orphan is rolled back (best-effort amDeleteTemplateAgent).
- findUserTemplateAgent now guards template_id<>'' so the new endpoints can't
  touch a legacy task deployment.
- binding_ids defaults to [] (not null).
- removed ResourceBinding.EnvMap field entirely.

Tests rewritten for the built-in convention (blob metadata-only, db provider
prefixes incl pg/mg aliases, git provider-agnostic names, ownership, unsupported
type, empty); adapter round-trip + router tests still pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-03 22:48:29 +08:00
co-authored by Claude Opus 4.8
parent 65a71f6b35
commit 19fd4f3326
5 changed files with 247 additions and 102 deletions
+143 -45
View File
@@ -8,27 +8,138 @@ import (
"github.com/heicode/manager/model"
)
// envMapEntry describes how one environment variable is sourced from a resource
// binding when starting a template agent.
// envField maps one environment variable a template agent receives from a bound
// resource. Source is envMeta (non-secret, from the binding's Metadata JSON) or
// envSecret (resolved from Key Vault at start only). Key is the field name
// inside that JSON.
//
// 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.
// These env names are the HM<->AM contract: the agent template reads them.
// Users NEVER see or edit env names — they only fill plain resource fields
// (repo url, token, host, password, ...) in the web console.
type envField struct {
Env string
Source string
Key string
}
const (
envMeta = "metadata"
envSecret = "secret"
)
// normalizeProvider folds provider aliases to a canonical key.
func normalizeProvider(p string) string {
switch strings.ToLower(strings.TrimSpace(p)) {
case "postgres", "postgresql", "pg":
return "pg"
case "mysql", "mariadb":
return "mysql"
case "redis":
return "redis"
case "mongo", "mongodb", "mg":
return "mongo"
case "github":
return "github"
case "gitea":
return "gitea"
case "gitlab":
return "gitlab"
case "blob", "azureblob", "azure-blob":
return "blob"
case "bucket", "s3", "oss", "minio", "cos":
return "bucket"
default:
return strings.ToLower(strings.TrimSpace(p))
}
}
func dbEnvSpec(prefix string) []envField {
return []envField{
{prefix + "_HOST", envMeta, "host"},
{prefix + "_PORT", envMeta, "port"},
{prefix + "_DATABASE", envMeta, "database"},
{prefix + "_USER", envMeta, "user"},
{prefix + "_PASSWORD", envSecret, "password"},
}
}
// builtinEnvSpec returns the fixed env mapping for a binding, keyed by resource
// type and provider. Built into HM; not user-configurable.
//
// 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"`
// Supported: git (gitea/github/gitlab), vm, database (mysql/pg/redis/mongo),
// storage (azure blob / object-storage bucket).
func builtinEnvSpec(resourceType, provider string) []envField {
rt := strings.ToLower(strings.TrimSpace(resourceType))
p := normalizeProvider(provider)
switch rt {
case "git":
// Same env names across gitea/github/gitlab; provider passed through.
return []envField{
{"GIT_PROVIDER", envMeta, "provider"},
{"GIT_REPO_URL", envMeta, "repo_url"},
{"GIT_BRANCH", envMeta, "default_branch"},
{"GIT_API_BASE", envMeta, "api_base"},
{"GIT_TOKEN", envSecret, "token"},
}
case "vm":
return []envField{
{"VM_HOST", envMeta, "host"},
{"VM_PORT", envMeta, "port"},
{"VM_USER", envMeta, "user"},
{"VM_PASSWORD", envSecret, "password"},
{"VM_SSH_KEY", envSecret, "private_key"},
}
case "database", "db":
switch p {
case "mysql":
return dbEnvSpec("MYSQL")
case "pg":
return dbEnvSpec("PG")
case "mongo":
return dbEnvSpec("MONGO")
case "redis":
return []envField{
{"REDIS_HOST", envMeta, "host"},
{"REDIS_PORT", envMeta, "port"},
{"REDIS_DB", envMeta, "db"},
{"REDIS_PASSWORD", envSecret, "password"},
}
default:
return dbEnvSpec("DB")
}
case "blob", "storage":
if p == "bucket" {
return []envField{
{"BUCKET_ENDPOINT", envMeta, "endpoint"},
{"BUCKET_REGION", envMeta, "region"},
{"BUCKET_NAME", envMeta, "bucket"},
{"BUCKET_ACCESS_KEY_ID", envSecret, "access_key_id"},
{"BUCKET_SECRET_ACCESS_KEY", envSecret, "secret_access_key"},
}
}
// Default: Azure Blob.
return []envField{
{"BLOB_ACCOUNT", envMeta, "account"},
{"BLOB_CONTAINER", envMeta, "container"},
{"BLOB_KEY", envSecret, "key"},
}
default:
return nil
}
}
// buildAgentEnvFromBindings resolves the selected resource bindings into a flat
// environment map for a template agent's .env at start time.
// environment map for a template agent's .env at start time, using the built-in
// per-type env convention.
//
// 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.
// from Key Vault here, at start time, and returned for injection — they are NOT
// persisted on the Manager side. Callers must NOT log the returned map.
//
// Lenient by design: a missing optional field (e.g. a vm with a password but no
// ssh key, or a binding with no secret yet) is simply skipped, not an error.
// Only an unsupported resource type or a Key Vault read failure errors out.
func buildAgentEnvFromBindings(userID int, bindingIDs []int) (map[string]string, error) {
env := map[string]string{}
if len(bindingIDs) == 0 {
@@ -47,14 +158,9 @@ func buildAgentEnvFromBindings(userID int, bindingIDs []int) (map[string]string,
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
spec := builtinEnvSpec(binding.ResourceType, binding.Provider)
if len(spec) == 0 {
return nil, fmt.Errorf("binding %d: unsupported resource type %q (provider %q)", id, binding.ResourceType, binding.Provider)
}
metadata := map[string]any{}
@@ -62,25 +168,9 @@ func buildAgentEnvFromBindings(userID int, bindingIDs []int) (map[string]string,
_ = common.UnmarshalJsonStr(binding.Metadata, &metadata)
}
// The secret JSON is fetched lazily, only if some entry needs it.
// Resolve the secret JSON once per binding, only if one is bound.
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 strings.TrimSpace(binding.SecretRef) != "" {
if !storeReady {
client, err := newSecretStoreClientFromEnv()
if err != nil {
@@ -94,13 +184,21 @@ func buildAgentEnvFromBindings(userID int, bindingIDs []int) (map[string]string,
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)
for _, f := range spec {
src := metadata
if f.Source == envSecret {
src = secret
}
if src == nil {
continue
}
if value, ok := src[f.Key]; ok {
str := fmt.Sprintf("%v", value)
if strings.TrimSpace(str) != "" {
env[f.Env] = str
}
default:
return nil, fmt.Errorf("binding %d env_map %q: unknown source %q (want metadata|secret)", id, name, entry.Source)
}
}
}
+13 -1
View File
@@ -70,6 +70,9 @@ func HeicodeDeployAgent(c *gin.Context) {
agentError(c, "POLICY_REJECTED", "template_id is required")
return
}
if req.BindingIDs == nil {
req.BindingIDs = []int{}
}
if model.DB == nil {
agentError(c, "DEPLOYMENT_PERSIST_FAILED", "database not initialised")
return
@@ -111,6 +114,13 @@ func HeicodeDeployAgent(c *gin.Context) {
}
if err := model.DB.Create(&row).Error; err != nil {
common.SysLog("HeicodeDeployAgent persist: " + err.Error())
// We started an agent in AM but failed to record it: roll back the
// orphan so it does not leak/keep running with no Manager record.
if strings.TrimSpace(result.RuntimeID) != "" {
if delErr := amDeleteTemplateAgent(c.Request.Context(), result.RuntimeID); delErr != nil {
common.SysLog("HeicodeDeployAgent orphan cleanup failed: " + delErr.Error())
}
}
agentError(c, "DEPLOYMENT_PERSIST_FAILED", "failed to persist agent")
return
}
@@ -126,7 +136,9 @@ func findUserTemplateAgent(c *gin.Context) (model.AgentDeployment, bool) {
agentError(c, "POLICY_REJECTED", "deployment_id and authentication required")
return row, false
}
if err := model.DB.Where("deployment_id = ? AND user_id = ?", deploymentID, strconv.Itoa(userID)).First(&row).Error; err != nil {
// template_id <> '' guards against touching a legacy task deployment of the
// same user through the new template-agent endpoints.
if err := model.DB.Where("deployment_id = ? AND user_id = ? AND template_id <> ''", deploymentID, strconv.Itoa(userID)).First(&row).Error; err != nil {
agentError(c, "DEPLOYMENT_CONFLICT", "agent not found")
return row, false
}
+24 -8
View File
@@ -9,6 +9,7 @@ import (
"net/http"
"net/url"
"strings"
"time"
"github.com/heicode/manager/common"
)
@@ -62,14 +63,29 @@ func agentTemplateAgentStopPath(agentRuntimeID string) string {
return strings.ReplaceAll(p, "{agent_id}", url.PathEscape(agentRuntimeID))
}
// agentTemplateStartTimeout is the timeout for starting a template agent —
// longer than the default runtime timeout because AM provisions the agent
// (container + subdomain) synchronously before responding.
func agentTemplateStartTimeout() time.Duration {
sec := common.GetEnvOrDefault("AGENT_RUNTIME_START_TIMEOUT_SECONDS", 60)
if sec <= 0 {
sec = 60
}
return time.Duration(sec) * time.Second
}
// amTemplateDo performs an AM HTTP call and returns the decoded `data` object.
// Reuses the existing runtime config (base URL / service token / timeout).
func amTemplateDo(ctx context.Context, method, path string, body any) (map[string]any, error) {
// Reuses the existing runtime config (base URL / service token). A non-zero
// timeout overrides the default runtime timeout (used by the start call).
func amTemplateDo(ctx context.Context, method, path string, body any, timeout time.Duration) (map[string]any, error) {
cfg := agentRuntimeClientConfigForMode(agentRuntimeModeAgent)
endpoint, err := agentRuntimeURL(cfg.BaseURL, path)
if err != nil {
return nil, err
}
if timeout <= 0 {
timeout = cfg.Timeout
}
var reader io.Reader
if body != nil {
raw, err := common.Marshal(body)
@@ -86,7 +102,7 @@ func amTemplateDo(ctx context.Context, method, path string, body any) (map[strin
if cfg.Token != "" {
req.Header.Set("Authorization", "Bearer "+cfg.Token)
}
client := &http.Client{Timeout: cfg.Timeout}
client := &http.Client{Timeout: timeout}
resp, err := client.Do(req)
if err != nil {
return nil, err
@@ -111,7 +127,7 @@ func amTemplateDo(ctx context.Context, method, path string, body any) (map[strin
// amListTemplates lists AM's deployable templates.
// Proposed contract: data.templates = [...]. Swap this mapping if AM differs.
func amListTemplates(ctx context.Context) ([]agentTemplate, error) {
data, err := amTemplateDo(ctx, http.MethodGet, agentTemplatesPath(), nil)
data, err := amTemplateDo(ctx, http.MethodGet, agentTemplatesPath(), nil, 0)
if err != nil {
return nil, err
}
@@ -135,7 +151,7 @@ func amStartTemplateAgent(ctx context.Context, templateID, managerDeploymentID s
"env": env,
"callback_url": callbackURL,
}
data, err := amTemplateDo(ctx, http.MethodPost, agentTemplateStartPath(templateID), payload)
data, err := amTemplateDo(ctx, http.MethodPost, agentTemplateStartPath(templateID), payload, agentTemplateStartTimeout())
if err != nil {
return amStartResult{}, err
}
@@ -151,7 +167,7 @@ func amStartTemplateAgent(ctx context.Context, templateID, managerDeploymentID s
// so HM can tell whether the agent is still alive / has crashed.
// Proposed contract: GET /api/agent/agents/{agent_id} -> data.status.
func amGetAgentStatus(ctx context.Context, runtimeID string) (string, error) {
data, err := amTemplateDo(ctx, http.MethodGet, agentTemplateAgentPath(runtimeID), nil)
data, err := amTemplateDo(ctx, http.MethodGet, agentTemplateAgentPath(runtimeID), nil, 0)
if err != nil {
return "", err
}
@@ -159,11 +175,11 @@ func amGetAgentStatus(ctx context.Context, runtimeID string) (string, error) {
}
func amStopTemplateAgent(ctx context.Context, runtimeID string) error {
_, err := amTemplateDo(ctx, http.MethodPost, agentTemplateAgentStopPath(runtimeID), nil)
_, err := amTemplateDo(ctx, http.MethodPost, agentTemplateAgentStopPath(runtimeID), nil, 0)
return err
}
func amDeleteTemplateAgent(ctx context.Context, runtimeID string) error {
_, err := amTemplateDo(ctx, http.MethodDelete, agentTemplateAgentPath(runtimeID), nil)
_, err := amTemplateDo(ctx, http.MethodDelete, agentTemplateAgentPath(runtimeID), nil, 0)
return err
}
+56 -30
View File
@@ -11,59 +11,85 @@ import (
"github.com/stretchr/testify/require"
)
func TestBuildAgentEnvFromBindings_MetadataOnly(t *testing.T) {
func TestBuildAgentEnvFromBindings_BuiltinBlobMetadataOnly(t *testing.T) {
setupResourceControllerTestDB(t)
// No secret_ref -> secret fields (BLOB_KEY) are skipped, not an error.
b := model.ResourceBinding{
UserId: 7,
Name: "blob1",
ResourceType: "blob",
Provider: "blob",
Metadata: `{"account":"myacct","container":"uploads"}`,
EnvMap: `{"AZURE_BLOB_ACCOUNT":{"source":"metadata","key":"account"},"AZURE_BLOB_CONTAINER":{"source":"metadata","key":"container"}}`,
}
require.NoError(t, model.DB.Create(&b).Error)
env, err := buildAgentEnvFromBindings(7, []int{b.Id})
require.NoError(t, err)
require.Equal(t, "myacct", env["AZURE_BLOB_ACCOUNT"])
require.Equal(t, "uploads", env["AZURE_BLOB_CONTAINER"])
require.Equal(t, "myacct", env["BLOB_ACCOUNT"])
require.Equal(t, "uploads", env["BLOB_CONTAINER"])
_, hasKey := env["BLOB_KEY"]
require.False(t, hasKey) // no secret bound
}
func TestBuildAgentEnvFromBindings_DatabaseProviderPrefix(t *testing.T) {
setupResourceControllerTestDB(t)
cases := []struct {
provider string
wantHost string // env var that should carry the host
}{
{"mysql", "MYSQL_HOST"},
{"pg", "PG_HOST"},
{"postgresql", "PG_HOST"}, // alias normalises to pg
{"redis", "REDIS_HOST"},
{"mg", "MONGO_HOST"}, // alias normalises to mongo
}
for _, tc := range cases {
b := model.ResourceBinding{
UserId: 7,
Name: "db-" + tc.provider,
ResourceType: "database",
Provider: tc.provider,
Metadata: `{"host":"db.example","port":"5432","database":"app","user":"u"}`,
}
require.NoError(t, model.DB.Create(&b).Error)
env, err := buildAgentEnvFromBindings(7, []int{b.Id})
require.NoError(t, err, tc.provider)
require.Equal(t, "db.example", env[tc.wantHost], "provider %s -> %s", tc.provider, tc.wantHost)
}
}
func TestBuildAgentEnvFromBindings_GitNamesProviderAgnostic(t *testing.T) {
setupResourceControllerTestDB(t)
for _, provider := range []string{"github", "gitea", "gitlab"} {
b := model.ResourceBinding{
UserId: 7,
Name: "git-" + provider,
ResourceType: "git",
Provider: provider,
Metadata: `{"provider":"` + provider + `","repo_url":"https://x/owner/repo","default_branch":"main"}`,
}
require.NoError(t, model.DB.Create(&b).Error)
env, err := buildAgentEnvFromBindings(7, []int{b.Id})
require.NoError(t, err)
require.Equal(t, "https://x/owner/repo", env["GIT_REPO_URL"])
require.Equal(t, "main", env["GIT_BRANCH"])
require.Equal(t, provider, env["GIT_PROVIDER"])
}
}
func TestBuildAgentEnvFromBindings_OwnershipEnforced(t *testing.T) {
setupResourceControllerTestDB(t)
b := model.ResourceBinding{UserId: 7, Name: "x", ResourceType: "blob", Metadata: `{}`, EnvMap: `{}`}
b := model.ResourceBinding{UserId: 7, Name: "x", ResourceType: "blob", Provider: "blob", Metadata: `{}`}
require.NoError(t, model.DB.Create(&b).Error)
_, err := buildAgentEnvFromBindings(99, []int{b.Id}) // different user
require.Error(t, err)
}
func TestBuildAgentEnvFromBindings_SecretWithoutRefFailsBeforeKV(t *testing.T) {
func TestBuildAgentEnvFromBindings_UnsupportedType(t *testing.T) {
setupResourceControllerTestDB(t)
b := model.ResourceBinding{
UserId: 7,
Name: "vm1",
ResourceType: "vm",
Metadata: `{"host":"1.2.3.4"}`,
EnvMap: `{"VM_PASSWORD":{"source":"secret","key":"password"}}`,
// SecretRef intentionally empty -> must error before any Key Vault call
}
require.NoError(t, model.DB.Create(&b).Error)
_, err := buildAgentEnvFromBindings(7, []int{b.Id})
require.Error(t, err)
require.Contains(t, err.Error(), "secret_ref")
}
func TestBuildAgentEnvFromBindings_UnknownSource(t *testing.T) {
setupResourceControllerTestDB(t)
b := model.ResourceBinding{
UserId: 7,
Name: "x",
ResourceType: "blob",
Metadata: `{}`,
EnvMap: `{"FOO":{"source":"bogus","key":"x"}}`,
}
b := model.ResourceBinding{UserId: 7, Name: "x", ResourceType: "quantum", Provider: "weird", Metadata: `{}`}
require.NoError(t, model.DB.Create(&b).Error)
_, err := buildAgentEnvFromBindings(7, []int{b.Id})
-7
View File
@@ -15,13 +15,6 @@ type ResourceBinding struct {
ExternalId string `json:"external_id" gorm:"type:varchar(512)"`
SecretRef string `json:"secret_ref" gorm:"type:varchar(512)"`
Metadata string `json:"metadata" gorm:"type:text"`
// EnvMap declares the environment variables this binding exposes to a
// template agent at start time, as a JSON object: {"ENV_NAME": {"source":
// "metadata|secret", "key": "<metadata field or secret json key>"}}.
// Non-secret values come from Metadata; secret values are resolved from the
// Key Vault secret (SecretRef) only at agent-start and injected into the
// agent's .env — never stored here in plaintext.
EnvMap string `json:"env_map" gorm:"type:text"`
PermissionScope string `json:"permission_scope" gorm:"type:text"`
Constraints string `json:"constraints" gorm:"type:text"`
Status string `json:"status" gorm:"type:varchar(32);default:'active';index"`