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>
186 lines
6.8 KiB
Go
186 lines
6.8 KiB
Go
package controller
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/heicode/manager/common"
|
|
)
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// AM template-agent adapter (the ONLY place that knows AM's template HTTP API).
|
|
//
|
|
// Template-agent model: HM asks AM to start a template agent with the selected
|
|
// resources injected as env; AM returns a unique subdomain + access token; the
|
|
// desktop client then talks to that subdomain directly over SSE.
|
|
//
|
|
// When AM finalises its real API, change ONLY:
|
|
// - the default paths in the *Path() helpers (or set the env overrides), and
|
|
// - the request payload / response field names in the three am* functions.
|
|
// No other HM code needs to change.
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
// agentTemplate is one deployable template offered by AM.
|
|
type agentTemplate struct {
|
|
TemplateID string `json:"template_id"`
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
RequiredResourceTypes []string `json:"required_resource_types"`
|
|
EnvSchema []string `json:"env_schema"`
|
|
}
|
|
|
|
// amStartResult is what AM returns after starting a template agent.
|
|
type amStartResult struct {
|
|
RuntimeID string
|
|
Subdomain string
|
|
AccessToken string
|
|
Status string
|
|
}
|
|
|
|
func agentTemplatesPath() string {
|
|
return common.GetEnvOrDefaultString("AGENT_RUNTIME_TEMPLATES_PATH", "/api/agent/templates")
|
|
}
|
|
|
|
func agentTemplateStartPath(templateID string) string {
|
|
p := common.GetEnvOrDefaultString("AGENT_RUNTIME_TEMPLATE_START_PATH", "/api/agent/templates/{template_id}/start")
|
|
return strings.ReplaceAll(p, "{template_id}", url.PathEscape(templateID))
|
|
}
|
|
|
|
func agentTemplateAgentPath(agentRuntimeID string) string {
|
|
p := common.GetEnvOrDefaultString("AGENT_RUNTIME_AGENT_PATH", "/api/agent/agents/{agent_id}")
|
|
return strings.ReplaceAll(p, "{agent_id}", url.PathEscape(agentRuntimeID))
|
|
}
|
|
|
|
func agentTemplateAgentStopPath(agentRuntimeID string) string {
|
|
p := common.GetEnvOrDefaultString("AGENT_RUNTIME_AGENT_STOP_PATH", "/api/agent/agents/{agent_id}/stop")
|
|
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). 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)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
reader = bytes.NewReader(raw)
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, method, endpoint, reader)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
if cfg.Token != "" {
|
|
req.Header.Set("Authorization", "Bearer "+cfg.Token)
|
|
}
|
|
client := &http.Client{Timeout: timeout}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
|
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
|
return nil, fmt.Errorf("AM %s %s returned HTTP %d: %s", method, path, resp.StatusCode, strings.TrimSpace(string(raw)))
|
|
}
|
|
var envelope map[string]any
|
|
if len(raw) > 0 {
|
|
if err := common.Unmarshal(raw, &envelope); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if msg := agentRuntimeEnvelopeError(envelope); msg != "" {
|
|
return nil, errors.New(msg)
|
|
}
|
|
return extractAgentRuntimeData(envelope), nil
|
|
}
|
|
|
|
// 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, 0)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
raw, err := common.Marshal(data["templates"])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var templates []agentTemplate
|
|
if err := common.Unmarshal(raw, &templates); err != nil {
|
|
return nil, err
|
|
}
|
|
return templates, nil
|
|
}
|
|
|
|
// amStartTemplateAgent asks AM to start a template agent with the given env.
|
|
// Proposed request: {manager_deployment_id, env, callback_url}.
|
|
// Proposed response data: {runtime_id, subdomain, access_token, status}.
|
|
func amStartTemplateAgent(ctx context.Context, templateID, managerDeploymentID string, env map[string]string, callbackURL string) (amStartResult, error) {
|
|
payload := map[string]any{
|
|
"manager_deployment_id": managerDeploymentID,
|
|
"env": env,
|
|
"callback_url": callbackURL,
|
|
}
|
|
data, err := amTemplateDo(ctx, http.MethodPost, agentTemplateStartPath(templateID), payload, agentTemplateStartTimeout())
|
|
if err != nil {
|
|
return amStartResult{}, err
|
|
}
|
|
return amStartResult{
|
|
RuntimeID: stringFromMap(data, "runtime_id", "agent_id", "id", "deployment_id"),
|
|
Subdomain: stringFromMap(data, "subdomain", "address", "url"),
|
|
AccessToken: stringFromMap(data, "access_token", "token"),
|
|
Status: stringFromMap(data, "status", "runtime_status"),
|
|
}, nil
|
|
}
|
|
|
|
// amGetAgentStatus pulls the live status of a running template agent from AM,
|
|
// 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, 0)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return stringFromMap(data, "status", "runtime_status", "state"), nil
|
|
}
|
|
|
|
func amStopTemplateAgent(ctx context.Context, runtimeID string) error {
|
|
_, 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, 0)
|
|
return err
|
|
}
|