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>
192 lines
6.1 KiB
Go
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
|
|
}
|