package main import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "flag" "fmt" "io" "net/http" "os" "path/filepath" "strings" ) func cmdServe(args []string) { fs := flag.NewFlagSet("serve", flag.ExitOnError) port := fs.Int("port", 9090, "listen port") fs.Parse(args) s := openStoreOrDie() // No defer close — server runs forever d := NewDeployer(s, registryRoot()) srv := &Server{store: s, deployer: d} mux := http.NewServeMux() mux.HandleFunc("POST /webhook/push", srv.handleWebhook) mux.HandleFunc("GET /api/targets", srv.handleListTargets) mux.HandleFunc("GET /api/targets/{app}", srv.handleGetTarget) mux.HandleFunc("POST /api/deploy/{app}", srv.handleDeploy) mux.HandleFunc("GET /api/status/{app}", srv.handleStatus) mux.HandleFunc("GET /api/health", srv.handleHealth) mux.HandleFunc("GET /api/logs", srv.handleLogs) mux.HandleFunc("GET /api/logs/{app}", srv.handleLogs) addr := fmt.Sprintf(":%d", *port) fmt.Printf("deploy_server listening on %s\n", addr) if err := http.ListenAndServe(addr, mux); err != nil { fmt.Fprintf(os.Stderr, "server error: %v\n", err) os.Exit(1) } } type Server struct { store *Store deployer *Deployer } // giteaPushPayload es el subset del payload de Gitea que nos interesa. type giteaPushPayload struct { Ref string `json:"ref"` Repository struct { Name string `json:"name"` FullName string `json:"full_name"` } `json:"repository"` Commits []struct { Modified []string `json:"modified"` Added []string `json:"added"` Removed []string `json:"removed"` } `json:"commits"` } func (s *Server) handleWebhook(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(r.Body) if err != nil { http.Error(w, "read body", http.StatusBadRequest) return } // Validar signature si hay secret configurado secret := os.Getenv("DEPLOY_WEBHOOK_SECRET") if secret != "" { sig := r.Header.Get("X-Gitea-Signature") if !verifySignature(body, sig, secret) { http.Error(w, "invalid signature", http.StatusUnauthorized) return } } var payload giteaPushPayload if err := json.Unmarshal(body, &payload); err != nil { http.Error(w, "parse payload", http.StatusBadRequest) return } // Detectar qué apps fueron afectadas por los cambios apps := s.detectAffectedApps(payload) if len(apps) == 0 { // Intentar match por nombre de repo targets, _ := s.store.GetTargets(payload.Repository.Name) if len(targets) > 0 { apps = append(apps, payload.Repository.Name) } } if len(apps) == 0 { json.NewEncoder(w).Encode(map[string]string{"status": "no matching targets"}) return } // Deploy cada app afectada results := make(map[string]string) for _, app := range apps { targets, _ := s.store.GetTargets(app) for _, t := range targets { if err := s.deployer.Deploy(t, "webhook"); err != nil { results[app+"@"+t.Host] = "failed: " + err.Error() } else { results[app+"@"+t.Host] = "deployed" } } } json.NewEncoder(w).Encode(results) } func (s *Server) detectAffectedApps(payload giteaPushPayload) []string { seen := make(map[string]bool) var apps []string for _, commit := range payload.Commits { allFiles := append(append(commit.Modified, commit.Added...), commit.Removed...) for _, f := range allFiles { // Detectar apps/ paths if strings.HasPrefix(f, "apps/") { parts := strings.SplitN(f, "/", 3) if len(parts) >= 2 && !seen[parts[1]] { seen[parts[1]] = true apps = append(apps, parts[1]) } } } } return apps } func (s *Server) handleListTargets(w http.ResponseWriter, r *http.Request) { targets, err := s.store.ListTargets() if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } json.NewEncoder(w).Encode(targets) } func (s *Server) handleGetTarget(w http.ResponseWriter, r *http.Request) { app := r.PathValue("app") targets, err := s.store.GetTargets(app) if err != nil || len(targets) == 0 { http.Error(w, "not found", http.StatusNotFound) return } json.NewEncoder(w).Encode(targets) } func (s *Server) handleDeploy(w http.ResponseWriter, r *http.Request) { app := r.PathValue("app") targets, err := s.store.GetTargets(app) if err != nil || len(targets) == 0 { http.Error(w, "no targets for "+app, http.StatusNotFound) return } results := make(map[string]string) for _, t := range targets { if err := s.deployer.Deploy(t, "api"); err != nil { results[t.Host] = "failed: " + err.Error() } else { results[t.Host] = "deployed" } } json.NewEncoder(w).Encode(results) } func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { app := r.PathValue("app") targets, _ := s.store.GetTargets(app) type statusResult struct { Host string `json:"host"` Status string `json:"status"` } var results []statusResult for _, t := range targets { out, _ := s.deployer.Status(t) results = append(results, statusResult{Host: t.Host, Status: out}) } json.NewEncoder(w).Encode(results) } func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) fmt.Fprintln(w, `{"status":"ok"}`) } func (s *Server) handleLogs(w http.ResponseWriter, r *http.Request) { app := r.PathValue("app") logs, err := s.store.RecentLogs(app, 20) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } json.NewEncoder(w).Encode(logs) } func verifySignature(body []byte, signature, secret string) bool { mac := hmac.New(sha256.New, []byte(secret)) mac.Write(body) expected := hex.EncodeToString(mac.Sum(nil)) return hmac.Equal([]byte(expected), []byte(signature)) } // registryRootFromExe intenta derivar la raíz del registry desde el ejecutable. func registryRootFromExe() string { exe, err := os.Executable() if err != nil { return "." } // apps/deploy_server/deploy_server → raíz es ../../ return filepath.Dir(filepath.Dir(filepath.Dir(exe))) }