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>
207 lines
5.8 KiB
Go
207 lines
5.8 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 "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.
|
|
//
|
|
// 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, 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
|
|
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)
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
// 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) != "" {
|
|
env[f.Env] = str
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return env, nil
|
|
}
|