Files
heicode-mananger/heicode/controller/agent_template_env.go
T
chenchenandClaude Opus 4.8 074a3cc7e7 feat(agent): align HM to AM's coding_a2a_agent contract
Adapt HM's template-agent integration to AM's actual CODING_A2A API (per their
doc), keeping it isolated in agent_template_runtime.go:

- start payload -> AM's POST /agents { name, template:"coding_a2a_agent",
  framework:"A2A", config:{user_id,...}, env } with the template .md folded into
  env.AGENT_INSTRUCTION_TEXT, template_key -> AGENT_ROLE_NAME, model gateway via
  OPENAI_BASE_URL + MODEL_NAME (OPENAI_API_KEY left to the client per A2A request).
- response parse -> access_info.domain/external_ip -> subdomain, namespace/name
  -> runtime_id; AM issues no access_token (client uses A2A api_key).
- env names aligned to AM: GIT_DEFAULT_BRANCH, POSTGRES_* (was PG_*),
  AZURE_BLOB_ACCOUNT_NAME/CONTAINER/ACCOUNT_KEY (was BLOB_*); source keys aligned
  to the resource-binding form (db_name/username/database_password/access_key).
  Only AM-supported types (git/mysql/postgres/azure-blob); vm/redis/mongo/bucket
  now rejected as unsupported until AM adds them.
- frontend: resources page splits DB into MySQL/PostgreSQL (correct provider),
  drops vm; deploy page hides unsupported resource types.
- docs: AM contract + client doc updated to the real env names, payload, and the
  A2A direct-connect (message/send · message/stream) + api_key auth.
- tests updated for the new env names + AM payload/response shape. All green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 15:30:33 +08:00

192 lines
6.1 KiB
Go

package controller
import (
"fmt"
"strings"
"github.com/heicode/manager/common"
"github.com/heicode/manager/model"
)
// 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.
//
// 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 "postgres"
case "mysql", "mariadb":
return "mysql"
case "github":
return "github"
case "gitea":
return "gitea"
case "gitlab":
return "gitlab"
case "blob", "azureblob", "azure-blob", "azure":
return "blob"
default:
return strings.ToLower(strings.TrimSpace(p))
}
}
// dbEnvSpec maps the resource-binding form fields (host/port/db_name/username +
// secret database_password) to the AM-expected {PREFIX}_* env names.
func dbEnvSpec(prefix string) []envField {
return []envField{
{prefix + "_HOST", envMeta, "host"},
{prefix + "_PORT", envMeta, "port"},
{prefix + "_DATABASE", envMeta, "db_name"},
{prefix + "_USER", envMeta, "username"},
{prefix + "_PASSWORD", envSecret, "database_password"},
}
}
// builtinEnvSpec returns the fixed env mapping for a binding. The env NAMES match
// what AM's coding_a2a_agent reads (see docs/integration/heicode-am-contract.md
// §2); the source KEYS match what the resource-binding form stores.
//
// Supported by AM today: git (github/gitea/gitlab) · database (mysql/postgres) ·
// azure blob. Other types (vm/redis/mongo/object-bucket) return nil → the deploy
// is rejected with an "unsupported" error until AM adds them.
func builtinEnvSpec(resourceType, provider string) []envField {
rt := strings.ToLower(strings.TrimSpace(resourceType))
p := normalizeProvider(provider)
switch rt {
case "git":
return []envField{
{"GIT_PROVIDER", envMeta, "provider"},
{"GIT_REPO_URL", envMeta, "repo_url"},
{"GIT_DEFAULT_BRANCH", envMeta, "default_branch"},
{"GIT_TOKEN", envSecret, "token"},
}
case "database", "db":
switch p {
case "mysql":
return dbEnvSpec("MYSQL")
case "postgres":
return dbEnvSpec("POSTGRES")
default:
return nil // AM supports only mysql / postgres
}
case "blob", "storage":
if p == "blob" || p == "azure" || p == "" {
return []envField{
{"AZURE_BLOB_ACCOUNT_NAME", envMeta, "account"},
{"AZURE_BLOB_CONTAINER", envMeta, "container"},
{"AZURE_BLOB_ACCOUNT_KEY", envSecret, "access_key"},
}
}
return nil // object-storage buckets not supported by AM yet
default:
return nil // vm / other not supported by AM yet
}
}
// buildAgentEnvFromBindings resolves the selected resource bindings into a flat
// 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 — 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 {
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
// Only active bindings — a revoked/disabled resource must not be resolved
// (its KV secret would otherwise still be read and injected).
if err := model.DB.Where("id = ? AND user_id = ? AND status = ?", id, userID, "active").First(&binding).Error; err != nil {
return nil, fmt.Errorf("resource binding %d not found or not active", id)
}
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{}
if strings.TrimSpace(binding.Metadata) != "" {
_ = common.UnmarshalJsonStr(binding.Metadata, &metadata)
}
// provider lives on the binding row, not in metadata — surface it so
// GIT_PROVIDER (and similar) resolve.
if _, ok := metadata["provider"]; !ok && strings.TrimSpace(binding.Provider) != "" {
metadata["provider"] = binding.Provider
}
// Resolve the secret JSON once per binding, only if one is bound.
var secret map[string]any
if strings.TrimSpace(binding.SecretRef) != "" {
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
}
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) == "" {
continue
}
// Two bindings of the same type produce the same env name; refuse
// rather than silently let the later one overwrite the earlier.
if prev, dup := env[f.Env]; dup && prev != str {
return nil, fmt.Errorf("环境变量 %s 冲突:同类型资源(如两个 git/数据库)只能挂一个", f.Env)
}
env[f.Env] = str
}
}
}
return env, nil
}