Files
patel-lyzr 6b1a13ecdc feat: implement environment management features
- Added environment creation and editing pages with forms for name and description.
- Integrated environment listing with options to edit and delete environments.
- Updated agent detail page to manage environments followed by agents.
- Enhanced API to support environment operations including listing, creating, updating, and deleting environments.
- Refactored related components and state management for improved clarity and functionality.
2026-05-13 22:15:08 +05:30

701 lines
21 KiB
Go

package api
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"strings"
"time"
"github.com/lyzrai/flow/pkg/engine"
"github.com/lyzrai/flow/pkg/github"
"github.com/lyzrai/flow/pkg/models"
"github.com/lyzrai/flow/pkg/orchestrator"
"github.com/lyzrai/flow/pkg/storage"
)
// PublicCredential is the scrubbed shape of a stored credential. We
// return non-secret fields (region, accountId, role ARN, kv keys) and
// boolean flags for any sealed values, never the sealed bytes themselves.
type PublicCredential struct {
ID string `json:"id"`
Name string `json:"name"`
Type storage.CredentialType `json:"type"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
// AWS — all non-secret.
AwsRegion string `json:"awsRegion,omitempty"`
AwsAccountID string `json:"awsAccountId,omitempty"`
AwsCrossAccountRoleArn string `json:"awsCrossAccountRoleArn,omitempty"`
// GCP — projectId / location are non-secret. HasServiceAccount tells
// the UI whether the SA JSON has been uploaded.
GcpProjectID string `json:"gcpProjectId,omitempty"`
GcpLocation string `json:"gcpLocation,omitempty"`
HasServiceAccount bool `json:"hasServiceAccount,omitempty"`
// Generic kv — keys are surfaced; values never are.
KvKeys []string `json:"kvKeys,omitempty"`
}
// Agent is the wire shape of an agent record. PAT and webhook secret are
// scrubbed; HasPAT and webhook fields surface only the safe parts.
type Agent struct {
ID string `json:"id"`
Name string `json:"name"`
RepoURL string `json:"repoUrl"`
Ref string `json:"ref,omitempty"`
HasPAT bool `json:"hasPat"`
WebhookID int64 `json:"webhookId,omitempty"`
WebhookURL string `json:"webhookUrl,omitempty"`
WebhookInstalled bool `json:"webhookInstalled"`
WebhookInstalledAt *time.Time `json:"webhookInstalledAt,omitempty"`
AuthStatus storage.AuthStatus `json:"authStatus,omitempty"`
AuthCheckedAt *time.Time `json:"authCheckedAt,omitempty"`
Environments []string `json:"environments,omitempty"`
Credentials []PublicCredential `json:"credentials,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func publicCredential(c storage.Credential) PublicCredential {
out := PublicCredential{
ID: c.ID,
Name: c.Name,
Type: c.Type,
CreatedAt: c.CreatedAt,
UpdatedAt: c.UpdatedAt,
}
switch c.Type {
case storage.CredentialAWS:
out.AwsRegion = c.AwsRegion
out.AwsAccountID = c.AwsAccountID
out.AwsCrossAccountRoleArn = c.AwsCrossAccountRoleArn
case storage.CredentialGCP:
out.GcpProjectID = c.GcpProjectID
out.GcpLocation = c.GcpLocation
out.HasServiceAccount = c.GcpServiceAccountSealed != ""
case storage.CredentialKV:
out.KvKeys = make([]string, 0, len(c.KvSealed))
for k := range c.KvSealed {
out.KvKeys = append(out.KvKeys, k)
}
}
return out
}
func (s *Server) publicAgent(a *storage.Agent) Agent {
creds := make([]PublicCredential, 0, len(a.Credentials))
for _, c := range a.Credentials {
creds = append(creds, publicCredential(c))
}
return Agent{
ID: a.ID,
Name: a.Name,
RepoURL: a.RepoURL,
Ref: a.Ref,
HasPAT: a.PAT != "",
WebhookID: a.WebhookID,
WebhookURL: s.webhookURLFor(a.ID),
WebhookInstalled: a.WebhookID != 0,
WebhookInstalledAt: a.WebhookInstalledAt,
AuthStatus: a.AuthStatus,
AuthCheckedAt: a.AuthCheckedAt,
Environments: a.Environments,
Credentials: creds,
CreatedAt: a.CreatedAt,
UpdatedAt: a.UpdatedAt,
}
}
func (s *Server) webhookURLFor(agentID string) string {
if s.publicURL == "" {
return ""
}
return s.publicURL + "/webhooks/github/" + agentID
}
// --- CRUD -----------------------------------------------------------------
func (s *Server) handleListAgents(w http.ResponseWriter, r *http.Request) {
agents, err := s.agents.List(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
out := make([]Agent, 0, len(agents))
for _, a := range agents {
out = append(out, s.publicAgent(a))
}
writeJSON(w, http.StatusOK, out)
}
func (s *Server) handleCreateAgent(w http.ResponseWriter, r *http.Request) {
var body struct {
RepoURL string `json:"repoUrl"`
PAT string `json:"pat"`
Ref string `json:"ref,omitempty"`
Name string `json:"name,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
repo := strings.TrimSpace(body.RepoURL)
if repo == "" {
writeError(w, http.StatusBadRequest, errors.New("repoUrl is required"))
return
}
if _, err := url.Parse(repo); err != nil {
writeError(w, http.StatusBadRequest, fmt.Errorf("invalid repoUrl: %w", err))
return
}
now := time.Now().UTC()
id := newID()
name := strings.TrimSpace(body.Name)
if name == "" {
name = deriveAgentName(repo)
}
ref := strings.TrimSpace(body.Ref)
if ref == "" {
ref = "main"
}
a := &storage.Agent{
ID: id,
Name: name,
RepoURL: repo,
Ref: ref,
PAT: strings.TrimSpace(body.PAT),
AuthStatus: storage.AuthUntested,
CreatedAt: now,
UpdatedAt: now,
}
if err := s.agents.Create(r.Context(), a); err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusCreated, s.publicAgent(a))
}
func (s *Server) handleGetAgent(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
a, err := s.agents.Get(r.Context(), id)
if err != nil {
writeStorageErr(w, err, "agent not found")
return
}
writeJSON(w, http.StatusOK, s.publicAgent(a))
}
func (s *Server) handleDeleteAgent(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
// Best-effort uninstall webhook before deleting, so we don't leak
// dangling hooks pointing at a dead agent ID.
if a, err := s.agents.Get(r.Context(), id); err == nil && a.WebhookID != 0 {
if repo, perr := github.ParseRepo(a.RepoURL); perr == nil {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
_ = github.NewClient(a.PAT).UninstallWebhook(ctx, repo, a.WebhookID)
cancel()
}
}
if err := s.agents.Delete(r.Context(), id); err != nil {
writeStorageErr(w, err, "agent not found")
return
}
w.WriteHeader(http.StatusNoContent)
}
// --- auth probe ----------------------------------------------------------
func (s *Server) handleTestAgentAuth(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
a, err := s.agents.Get(r.Context(), id)
if err != nil {
writeStorageErr(w, err, "agent not found")
return
}
repo, err := github.ParseRepo(a.RepoURL)
if err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
authErr := github.NewClient(a.PAT).TestAuth(ctx, repo)
now := time.Now().UTC()
a.AuthCheckedAt = &now
if authErr == nil {
a.AuthStatus = storage.AuthOK
} else {
a.AuthStatus = storage.AuthFailed
}
a.UpdatedAt = now
if uerr := s.agents.Update(r.Context(), a); uerr != nil {
writeError(w, http.StatusInternalServerError, uerr)
return
}
resp := map[string]any{
"authStatus": a.AuthStatus,
"authCheckedAt": a.AuthCheckedAt,
}
if authErr != nil {
resp["error"] = authErr.Error()
}
writeJSON(w, http.StatusOK, resp)
}
// --- webhook install / uninstall -----------------------------------------
func (s *Server) handleInstallWebhook(w http.ResponseWriter, r *http.Request) {
if s.publicURL == "" {
writeError(w, http.StatusServiceUnavailable,
errors.New("FLOW_PUBLIC_URL is not configured"))
return
}
id := r.PathValue("id")
a, err := s.agents.Get(r.Context(), id)
if err != nil {
writeStorageErr(w, err, "agent not found")
return
}
if a.PAT == "" {
writeError(w, http.StatusBadRequest, errors.New("agent has no PAT — re-create with one"))
return
}
repo, err := github.ParseRepo(a.RepoURL)
if err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
// Fresh secret per install so rotating is just "uninstall + install".
secret, err := github.GenerateSecret()
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
callback := s.webhookURLFor(id)
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
defer cancel()
hookID, err := github.NewClient(a.PAT).InstallWebhook(ctx, repo, callback, secret)
if err != nil {
writeError(w, http.StatusBadGateway, err)
return
}
now := time.Now().UTC()
a.WebhookID = hookID
a.WebhookSecret = secret
a.WebhookInstalledAt = &now
a.UpdatedAt = now
if err := s.agents.Update(r.Context(), a); err != nil {
// Try to roll back the hook so we don't leak it.
_ = github.NewClient(a.PAT).UninstallWebhook(ctx, repo, hookID)
writeError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, s.publicAgent(a))
}
func (s *Server) handleUninstallWebhook(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
a, err := s.agents.Get(r.Context(), id)
if err != nil {
writeStorageErr(w, err, "agent not found")
return
}
if a.WebhookID == 0 {
writeError(w, http.StatusBadRequest, errors.New("no webhook installed"))
return
}
repo, err := github.ParseRepo(a.RepoURL)
if err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
if err := github.NewClient(a.PAT).UninstallWebhook(ctx, repo, a.WebhookID); err != nil {
writeError(w, http.StatusBadGateway, err)
return
}
a.WebhookID = 0
a.WebhookSecret = ""
a.WebhookInstalledAt = nil
a.UpdatedAt = time.Now().UTC()
if err := s.agents.Update(r.Context(), a); err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, s.publicAgent(a))
}
// --- environment subscriptions -------------------------------------------
func (s *Server) handleAgentFollowEnv(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
envName := r.PathValue("envName")
a, err := s.agents.Get(r.Context(), id)
if err != nil {
writeStorageErr(w, err, "agent not found")
return
}
if s.environments == nil {
writeError(w, http.StatusServiceUnavailable, errors.New("environments store not configured"))
return
}
if _, err := s.environments.GetByName(r.Context(), envName); err != nil {
writeStorageErr(w, err, "environment not found")
return
}
for _, e := range a.Environments {
if strings.EqualFold(e, envName) {
writeJSON(w, http.StatusOK, s.publicAgent(a)) // already following
return
}
}
a.Environments = append(a.Environments, envName)
a.UpdatedAt = time.Now().UTC()
if err := s.agents.Update(r.Context(), a); err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, s.publicAgent(a))
}
func (s *Server) handleAgentUnfollowEnv(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
envName := r.PathValue("envName")
a, err := s.agents.Get(r.Context(), id)
if err != nil {
writeStorageErr(w, err, "agent not found")
return
}
out := a.Environments[:0]
for _, e := range a.Environments {
if !strings.EqualFold(e, envName) {
out = append(out, e)
}
}
a.Environments = out
a.UpdatedAt = time.Now().UTC()
if err := s.agents.Update(r.Context(), a); err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// --- manual trigger ------------------------------------------------------
// handleTriggerAgent dispatches runs across the agent's followed
// environments. Trigger data describes who/what triggered the run.
func (s *Server) handleTriggerAgent(w http.ResponseWriter, r *http.Request) {
if s.orch == nil {
writeError(w, http.StatusServiceUnavailable, errors.New("orchestrator not configured"))
return
}
id := r.PathValue("id")
a, err := s.agents.Get(r.Context(), id)
if err != nil {
writeStorageErr(w, err, "agent not found")
return
}
if len(a.Environments) == 0 {
writeError(w, http.StatusBadRequest, errors.New("agent follows no environments"))
return
}
triggerData := []map[string]any{{
"source": "manual",
"agentId": a.ID,
"agentName": a.Name,
"repoUrl": a.RepoURL,
"ref": a.Ref,
}}
execIDs, failures, err := s.dispatchAgent(r.Context(), a, triggerData)
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
status := http.StatusAccepted
if len(execIDs) == 0 && len(failures) > 0 {
// Nothing dispatched — surface as 502 so the UI shows the error
// card instead of silently navigating nowhere.
status = http.StatusBadGateway
}
writeJSON(w, status, map[string]any{
"executionIds": execIDs,
"failures": failures,
})
}
// dispatchAgent fans out across the agent's followed environments. For
// each env it iterates the env's pipelines, applies the per-pipeline
// branch filter (for push triggers), stamps the env name + agent info
// into the trigger payload, and submits the run. Returns the execution
// IDs and a per-(env,pipeline) failure list so callers can surface skips.
func (s *Server) dispatchAgent(ctx context.Context, a *storage.Agent, trigger any) ([]string, []DispatchFailure, error) {
// pushedBranch is set only for github_push triggers; it gates which
// pipelines run (a pipeline's Trigger.fromBranch must match, or be a
// wildcard). Manual triggers run every pipeline in every followed env.
pushedBranch := ""
isPush := false
source := ""
if items, ok := trigger.([]map[string]any); ok && len(items) > 0 {
if src, _ := items[0]["source"].(string); src != "" {
source = src
if src == "github_push" {
isPush = true
if b, _ := items[0]["branch"].(string); b != "" {
pushedBranch = b
} else if b, _ := items[0]["ref"].(string); b != "" {
pushedBranch = b
}
}
}
}
var out []string
var failures []DispatchFailure
for _, envName := range a.Environments {
env, err := s.environments.GetByName(ctx, envName)
if err != nil {
slog.WarnContext(ctx, "agent_dispatch_env_missing",
slog.String("agent_id", a.ID), slog.String("env", envName), slog.Any("error", err))
failures = append(failures, DispatchFailure{
Environment: envName, Reason: "environment not found", Error: err.Error(),
})
continue
}
for _, pid := range env.PipelineIDs {
p, err := s.pipelines.Get(ctx, pid)
if err != nil {
failures = append(failures, DispatchFailure{
Environment: envName, PipelineID: pid, Reason: "pipeline not found", Error: err.Error(),
})
continue
}
wf, err := engine.ParseWorkflow(p.Definition)
if err != nil {
failures = append(failures, DispatchFailure{
Environment: envName, PipelineID: pid, Reason: "parse failed", Error: err.Error(),
})
continue
}
if isPush {
tb := pipelineTriggerBranch(wf)
if tb != "" && tb != "*" && tb != pushedBranch {
failures = append(failures, DispatchFailure{
Environment: envName, PipelineID: pid, Reason: "branch filtered",
Error: fmt.Sprintf("trigger fromBranch=%q ≠ pushed=%q", tb, pushedBranch),
})
continue
}
}
// Per-(env, pipeline) trigger payload: clone the base items and
// stamp the env name so downstream nodes (Deploy / Approval) can
// inherit env defaults.
perRunItems := stampTriggerEnv(trigger, envName)
triggerJSON, _ := json.Marshal(perRunItems)
triggerItems := make([]models.Item, 0, len(perRunItems))
for _, m := range perRunItems {
triggerItems = append(triggerItems, models.Item(m))
}
runCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
execID, err := s.orch.RunAsync(runCtx, &orchestrator.RunRequest{
RequestMeta: orchestrator.RequestMeta{WorkflowID: pid},
Workflow: wf,
TriggerData: triggerItems,
})
cancel()
if err != nil {
failures = append(failures, DispatchFailure{
Environment: envName, PipelineID: pid, Reason: "orchestrator submit", Error: err.Error(),
})
continue
}
startLogArchiver(context.Background(), s.logs, s.events, execID)
s.runsBus.Publish(RunCreatedEvent{
Type: "run_created",
ExecutionID: execID,
PipelineID: pid,
PipelineName: p.Name,
AgentID: a.ID,
Environment: envName,
Source: source,
StartedAt: time.Now().UTC(),
})
if s.runs != nil {
_ = s.runs.Insert(ctx, &storage.Run{
ID: execID,
PipelineID: pid,
PipelineName: p.Name,
Status: "running",
StartedAt: time.Now().UTC(),
TriggerData: triggerJSON,
})
}
out = append(out, execID)
}
}
return out, failures, nil
}
// stampTriggerEnv returns a copy of the trigger items (each a map) with
// `environment` set to envName. The base items are not mutated so the
// same trigger payload can be reused across environments.
func stampTriggerEnv(trigger any, envName string) []map[string]any {
items, ok := trigger.([]map[string]any)
if !ok || len(items) == 0 {
return []map[string]any{{"environment": envName}}
}
out := make([]map[string]any, 0, len(items))
for _, m := range items {
cp := make(map[string]any, len(m)+1)
for k, v := range m {
cp[k] = v
}
cp["environment"] = envName
out = append(out, cp)
}
return out
}
// DispatchFailure describes why a single (environment, pipeline) pair was
// skipped at dispatch time. Surfaced to API callers so trigger failures
// don't appear as silent no-ops.
type DispatchFailure struct {
Environment string `json:"environment,omitempty"`
PipelineID string `json:"pipelineId,omitempty"`
Reason string `json:"reason"`
Error string `json:"error,omitempty"`
}
// --- webhook receiver ----------------------------------------------------
func (s *Server) handleGitHubWebhook(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
a, err := s.agents.Get(r.Context(), id)
if err != nil {
writeStorageErr(w, err, "agent not found")
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
if err := github.VerifySignature(r.Header.Get("X-Hub-Signature-256"), a.WebhookSecret, body); err != nil {
slog.WarnContext(r.Context(), "github_webhook_signature_invalid",
slog.String("agent_id", id),
slog.Any("error", err),
)
writeError(w, http.StatusUnauthorized, errors.New("signature invalid"))
return
}
event := r.Header.Get("X-GitHub-Event")
switch event {
case "ping":
writeJSON(w, http.StatusOK, map[string]string{"message": "pong"})
return
case "push":
// fall through
default:
// Acknowledge unknown events; nothing to dispatch.
writeJSON(w, http.StatusOK, map[string]string{"message": "ignored"})
return
}
push, err := github.ParsePushEvent(body)
if err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
// Note: branch filtering happens per-pipeline in dispatchAgent — each
// Trigger node's `fromBranch` decides whether that pipeline runs for
// this push. The agent-level `Ref` is now only the default clone branch
// (used by Build when nothing upstream specifies one), not a webhook
// gate, so chains across branches (Promote main→dev fires the dev
// pipeline) work correctly.
if s.orch == nil {
writeError(w, http.StatusServiceUnavailable, errors.New("orchestrator not configured"))
return
}
// Normalize the ref to the bare branch name (`main`, not `refs/heads/main`)
// so downstream nodes — particularly Build's git clone --branch — don't
// have to know about Git's internal ref namespace. `fullRef` is kept for
// nodes that want the original.
branch := github.BranchFromRef(push.Ref)
trigger := []map[string]any{{
"source": "github_push",
"agentId": a.ID,
"agentName": a.Name,
"repoUrl": a.RepoURL,
"ref": branch,
"branch": branch,
"fullRef": push.Ref,
"commit": push.After,
"pusher": push.Pusher.Name,
}}
execIDs, failures, err := s.dispatchAgent(r.Context(), a, trigger)
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusAccepted, map[string]any{
"executionIds": execIDs,
"failures": failures,
})
}
// --- helpers -------------------------------------------------------------
// deriveAgentName extracts an "org/repo" name from a git URL. Falls back to
// the URL itself if parsing fails.
func deriveAgentName(raw string) string {
if r, err := github.ParseRepo(raw); err == nil {
return r.String()
}
return strings.TrimSpace(raw)
}
// pipelineTriggerBranch returns the `fromBranch` parameter on the workflow's
// Trigger node, or "" if the workflow has no Trigger node or no fromBranch
// configured. Used to filter webhook dispatch so only pipelines matching the
// pushed branch run. If multiple Trigger nodes exist (rare — schema allows it
// but the orchestrator only feeds one), the first wins.
func pipelineTriggerBranch(wf *models.WorkflowDefinition) string {
if wf == nil {
return ""
}
for _, n := range wf.Nodes {
if n.Type != "flow-nodes-base.trigger" {
continue
}
if v, ok := n.Parameters["fromBranch"].(string); ok {
return strings.TrimSpace(v)
}
return ""
}
return ""
}