diff --git a/pkg/cli/build.go b/pkg/cli/build.go new file mode 100644 index 0000000..2770c99 --- /dev/null +++ b/pkg/cli/build.go @@ -0,0 +1,221 @@ +package cli + +import ( + "context" + "fmt" + "os" + "strconv" + "time" + + "github.com/spf13/cobra" + + "github.com/ethpandaops/panda/pkg/serverapi" +) + +const ( + buildPollInterval = 15 * time.Second +) + +var ( + buildRef string + buildRepo string + buildDockerTag string + buildWait bool +) + +var buildCmd = &cobra.Command{ + GroupID: groupWorkflow, + Use: "build ", + Short: "Trigger a Docker image build for an Ethereum client", + Long: `Trigger a Docker image build via GitHub Actions for an Ethereum client +or tool. The build is dispatched through the proxy which holds the +GitHub token. + +Client names map directly to workflow files in the +eth-client-docker-image-builder repository: + geth -> build-push-geth.yml + lighthouse -> build-push-lighthouse.yml + nimbus-eth2 -> build-push-nimbus-eth2.yml + +By default the command triggers the build and returns immediately. +Use --wait to block until the build completes. + +Examples: + panda build geth # trigger and return + panda build geth --ref master + panda build lighthouse --ref unstable + panda build geth --wait # block until completion + panda build geth --repo ethereum/go-ethereum --ref my-branch + panda build geth --ref my-branch --tag my-custom-tag`, + Args: cobra.ExactArgs(1), + RunE: runBuild, +} + +var buildStatusCmd = &cobra.Command{ + Use: "status ", + Short: "Check the status of a GitHub Actions build run", + Args: cobra.ExactArgs(1), + RunE: runBuildStatus, +} + +func init() { + rootCmd.AddCommand(buildCmd) + buildCmd.AddCommand(buildStatusCmd) + buildCmd.Flags().StringVar(&buildRef, "ref", "", "branch, tag, or SHA to build from (uses workflow default if omitted)") + buildCmd.Flags().StringVar(&buildRepo, "repo", "", "source repository override (e.g. user/go-ethereum)") + buildCmd.Flags().StringVar(&buildDockerTag, "tag", "", "override target docker tag") + buildCmd.Flags().BoolVar(&buildWait, "wait", false, "block until the build completes instead of returning immediately") +} + +func runBuild(_ *cobra.Command, args []string) error { + client := args[0] + ctx := context.Background() + + resp, err := triggerBuild(ctx, serverapi.BuildTriggerRequest{ + Client: client, + Repository: buildRepo, + Ref: buildRef, + DockerTag: buildDockerTag, + }) + if err != nil { + return fmt.Errorf("triggering build: %w", err) + } + + if !buildWait { + return printBuildTriggered(resp) + } + + // --wait: block until completion. + if resp.RunID == 0 { + // Trigger succeeded but we couldn't find the run ID. + // Fall back to non-wait output. + fmt.Fprintf(os.Stderr, "Build triggered but run ID not available, cannot wait for completion\n") + return printBuildTriggered(resp) + } + + fmt.Fprintf(os.Stderr, "Build triggered for %s (run %d), waiting for completion...\n", resp.Client, resp.RunID) + + if resp.RunURL != "" { + fmt.Fprintf(os.Stderr, " url: %s\n", resp.RunURL) + } + + result, err := pollBuildStatus(ctx, resp.RunID) + if err != nil { + return fmt.Errorf("polling build status: %w", err) + } + + if isJSON() { + return printJSON(result) + } + + switch result.Conclusion { + case "success": + fmt.Fprintf(os.Stderr, "Build completed successfully\n") + case "failure": + fmt.Fprintf(os.Stderr, "Build failed\n") + fmt.Fprintf(os.Stderr, " url: %s\n", result.HTMLURL) + + return fmt.Errorf("build failed (run %d)", result.RunID) + case "cancelled": + fmt.Fprintf(os.Stderr, "Build was cancelled\n") + + return fmt.Errorf("build cancelled (run %d)", result.RunID) + default: + fmt.Fprintf(os.Stderr, "Build finished with conclusion: %s\n", result.Conclusion) + } + + return nil +} + +func runBuildStatus(_ *cobra.Command, args []string) error { + runID, err := strconv.ParseInt(args[0], 10, 64) + if err != nil { + return fmt.Errorf("invalid run ID: %w", err) + } + + ctx := context.Background() + + status, err := getBuildStatus(ctx, runID) + if err != nil { + return fmt.Errorf("checking build status: %w", err) + } + + if isJSON() { + return printJSON(status) + } + + fmt.Printf("run=%d status=%s conclusion=%s url=%s\n", + status.RunID, status.Status, status.Conclusion, status.HTMLURL) + + return nil +} + +func printBuildTriggered(resp *serverapi.BuildTriggerResponse) error { + if isJSON() { + return printJSON(resp) + } + + fmt.Printf("Build triggered for %s\n", resp.Client) + fmt.Printf(" workflow: %s\n", resp.Workflow) + + if resp.RunURL != "" { + fmt.Printf(" url: %s\n", resp.RunURL) + } else { + fmt.Printf(" url: %s\n", resp.WorkflowURL) + } + + if buildRef != "" { + fmt.Printf(" ref: %s\n", buildRef) + } + + if buildRepo != "" { + fmt.Printf(" repo: %s\n", buildRepo) + } + + if buildDockerTag != "" { + fmt.Printf(" tag: %s\n", buildDockerTag) + } + + if resp.RunID > 0 { + fmt.Println() + fmt.Println("") + fmt.Println("To monitor this build in the background, use the Monitor tool with the following parameters:") + fmt.Printf(" description: \"%s build (run %d)\"\n", resp.Client, resp.RunID) + fmt.Println(" timeout_ms: 1800000") + fmt.Println(" persistent: false") + fmt.Println(" command:") + fmt.Println(" while true; do") + fmt.Printf(" line=$(panda build status %d 2>&1) || { echo \"status_check_failed\"; sleep 15; continue; }\n", resp.RunID) + fmt.Println(" echo \"$line\"") + fmt.Println(" echo \"$line\" | grep -q 'status=completed' && exit 0") + fmt.Println(" sleep 15") + fmt.Println(" done") + fmt.Println("") + } + + return nil +} + +func pollBuildStatus(ctx context.Context, runID int64) (*serverapi.BuildStatusResponse, error) { + ticker := time.NewTicker(buildPollInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-ticker.C: + status, err := getBuildStatus(ctx, runID) + if err != nil { + fmt.Fprintf(os.Stderr, " (status check failed: %v, retrying...)\n", err) + continue + } + + if status.Status == "completed" { + return status, nil + } + + fmt.Fprintf(os.Stderr, " status: %s\n", status.Status) + } + } +} diff --git a/pkg/cli/dora.go b/pkg/cli/dora.go index 137cec3..e904f57 100644 --- a/pkg/cli/dora.go +++ b/pkg/cli/dora.go @@ -90,7 +90,7 @@ var doraOverviewCmd = &cobra.Command{ // Format participation rate as a percentage. participationStr := fmt.Sprintf("%v", data["participation_rate"]) if rate, ok := data["participation_rate"].(float64); ok { - participationStr = fmt.Sprintf("%.2f%%", rate*100) + participationStr = fmt.Sprintf("%.2f%%", rate) } pairs := [][2]string{ diff --git a/pkg/cli/server_helpers.go b/pkg/cli/server_helpers.go index d8e1a41..32fe5e7 100644 --- a/pkg/cli/server_helpers.go +++ b/pkg/cli/server_helpers.go @@ -373,6 +373,24 @@ func readClickHouseTable(ctx context.Context, tableName string) (*clickhousemodu return &payload, nil } +func triggerBuild(ctx context.Context, req serverapi.BuildTriggerRequest) (*serverapi.BuildTriggerResponse, error) { + var response serverapi.BuildTriggerResponse + if err := serverPostJSON(ctx, "/api/v1/build/trigger", req, &response); err != nil { + return nil, err + } + + return &response, nil +} + +func getBuildStatus(ctx context.Context, runID int64) (*serverapi.BuildStatusResponse, error) { + var response serverapi.BuildStatusResponse + if err := serverPostJSON(ctx, "/api/v1/build/status", serverapi.BuildStatusRequest{RunID: runID}, &response); err != nil { + return nil, err + } + + return &response, nil +} + func decodeAPIError(status int, data []byte) error { var message string diff --git a/pkg/proxy/handlers/github.go b/pkg/proxy/handlers/github.go new file mode 100644 index 0000000..c796c48 --- /dev/null +++ b/pkg/proxy/handlers/github.go @@ -0,0 +1,420 @@ +package handlers + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "sync" + "time" + + "github.com/sirupsen/logrus" +) + +const ( + githubAPIBase = "https://api.github.com" + + // defaultAllowedRepository is the only repository allowed by default. + defaultAllowedRepository = "ethpandaops/eth-client-docker-image-builder" + + // runFindTimeout is how long to poll for the triggered run after dispatch. + runFindTimeout = 20 * time.Second + // runFindInterval is how often to poll. + runFindInterval = 2 * time.Second + + // workflowCooldown is the minimum time between triggers for the same workflow. + workflowCooldown = 2 * time.Minute + // globalTriggerLimit is the max number of triggers allowed within globalTriggerWindow. + globalTriggerLimit = 10 + // globalTriggerWindow is the time window for the global trigger limit. + globalTriggerWindow = 10 * time.Minute +) + +// GitHubConfig holds GitHub API proxy configuration. +type GitHubConfig struct { + Token string +} + +// GitHubTriggerRequest is the request body for triggering a workflow. +type GitHubTriggerRequest struct { + // Repository is the target GitHub repository (e.g. "ethpandaops/eth-client-docker-image-builder"). + Repository string `json:"repository"` + // Workflow is the workflow filename (e.g. "build-push-geth.yml"). + Workflow string `json:"workflow"` + // Ref is the git ref to run the workflow on (typically "master"). + Ref string `json:"ref"` + // Inputs are the workflow_dispatch inputs. + Inputs map[string]string `json:"inputs,omitempty"` +} + +// GitHubTriggerResponse is the response from a successful workflow trigger. +type GitHubTriggerResponse struct { + WorkflowURL string `json:"workflow_url"` + RunID int64 `json:"run_id,omitempty"` + RunURL string `json:"run_url,omitempty"` +} + +// GitHubRunStatusRequest is the request for checking a run's status. +type GitHubRunStatusRequest struct { + Repository string `json:"repository"` + RunID int64 `json:"run_id"` +} + +// GitHubRunStatusResponse is the response from a run status check. +type GitHubRunStatusResponse struct { + RunID int64 `json:"run_id"` + Status string `json:"status"` + Conclusion string `json:"conclusion"` + HTMLURL string `json:"html_url"` +} + +// gitHubWorkflowRun is a subset of the GitHub Actions run object. +type gitHubWorkflowRun struct { + ID int64 `json:"id"` + Status string `json:"status"` + Conclusion string `json:"conclusion"` + HTMLURL string `json:"html_url"` + CreatedAt string `json:"created_at"` +} + +// GitHubHandler handles GitHub API requests. +type GitHubHandler struct { + log logrus.FieldLogger + token string + httpClient *http.Client + + mu sync.Mutex + lastTrigger map[string]time.Time // workflow -> last trigger time + globalTriggerLog []time.Time // timestamps of recent triggers +} + +// NewGitHubHandler creates a new GitHub handler. +func NewGitHubHandler(log logrus.FieldLogger, cfg GitHubConfig) *GitHubHandler { + return &GitHubHandler{ + log: log.WithField("handler", "github"), + token: cfg.Token, + httpClient: &http.Client{}, + lastTrigger: make(map[string]time.Time), + } +} + +// checkTriggerAllowed returns an error message if the trigger should be rejected. +func (h *GitHubHandler) checkTriggerAllowed(workflow string) string { + h.mu.Lock() + defer h.mu.Unlock() + + now := time.Now() + + // Per-workflow cooldown. + if last, ok := h.lastTrigger[workflow]; ok { + remaining := workflowCooldown - now.Sub(last) + if remaining > 0 { + return fmt.Sprintf( + "workflow %s was triggered %s ago, wait %s (cooldown: %s)", + workflow, now.Sub(last).Round(time.Second), remaining.Round(time.Second), workflowCooldown, + ) + } + } + + // Global rate limit: count triggers within the window. + cutoff := now.Add(-globalTriggerWindow) + recent := h.globalTriggerLog[:0] + + for _, t := range h.globalTriggerLog { + if t.After(cutoff) { + recent = append(recent, t) + } + } + + h.globalTriggerLog = recent + + if len(h.globalTriggerLog) >= globalTriggerLimit { + return fmt.Sprintf( + "global trigger limit reached: %d triggers in the last %s (max %d)", + len(h.globalTriggerLog), globalTriggerWindow, globalTriggerLimit, + ) + } + + return "" +} + +// recordTrigger records a successful trigger for rate limiting. +func (h *GitHubHandler) recordTrigger(workflow string) { + h.mu.Lock() + defer h.mu.Unlock() + + now := time.Now() + h.lastTrigger[workflow] = now + h.globalTriggerLog = append(h.globalTriggerLog, now) +} + +// ServeHTTP routes GitHub API requests. +func (h *GitHubHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/github") + + switch { + case path == "/actions/trigger" && r.Method == http.MethodPost: + h.handleTriggerWorkflow(w, r) + case path == "/actions/run-status" && r.Method == http.MethodPost: + h.handleRunStatus(w, r) + default: + http.Error(w, fmt.Sprintf("unknown github endpoint: %s %s", r.Method, path), http.StatusNotFound) + } +} + +func (h *GitHubHandler) handleTriggerWorkflow(w http.ResponseWriter, r *http.Request) { + var req GitHubTriggerRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body: %v", err) + return + } + + if req.Repository == "" { + writeError(w, http.StatusBadRequest, "repository is required") + return + } + + if req.Workflow == "" { + writeError(w, http.StatusBadRequest, "workflow is required") + return + } + + if req.Ref == "" { + req.Ref = "master" + } + + if req.Repository != defaultAllowedRepository { + writeError(w, http.StatusForbidden, "repository %q is not allowed", req.Repository) + return + } + + // Rate limit check. + if reason := h.checkTriggerAllowed(req.Workflow); reason != "" { + h.log.WithFields(logrus.Fields{ + "workflow": req.Workflow, + "reason": reason, + }).Warn("Build trigger rate limited") + + writeError(w, http.StatusTooManyRequests, "%s", reason) + + return + } + + // Record time before dispatch so we can find the run. + dispatchTime := time.Now().UTC() + + // Build GitHub API request. + body := map[string]any{ + "ref": req.Ref, + "inputs": req.Inputs, + } + + jsonBody, err := json.Marshal(body) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to marshal request: %v", err) + return + } + + dispatchURL := fmt.Sprintf("%s/repos/%s/actions/workflows/%s/dispatches", githubAPIBase, req.Repository, req.Workflow) + + ghReq, err := http.NewRequestWithContext(r.Context(), http.MethodPost, dispatchURL, bytes.NewReader(jsonBody)) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to create request: %v", err) + return + } + + ghReq.Header.Set("Accept", "application/vnd.github.v3+json") + ghReq.Header.Set("Authorization", "Bearer "+h.token) + ghReq.Header.Set("Content-Type", "application/json") + + resp, err := h.httpClient.Do(ghReq) + if err != nil { + writeError(w, http.StatusBadGateway, "github request failed: %v", err) + return + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusNoContent { + respBody, _ := io.ReadAll(resp.Body) + + h.log.WithFields(logrus.Fields{ + "status": resp.StatusCode, + "repository": req.Repository, + "workflow": req.Workflow, + "response": string(respBody), + }).Error("GitHub workflow dispatch failed") + + writeError(w, http.StatusBadGateway, "github returned status %d: %s", resp.StatusCode, string(respBody)) + + return + } + + // Record successful trigger for rate limiting. + h.recordTrigger(req.Workflow) + + h.log.WithFields(logrus.Fields{ + "repository": req.Repository, + "workflow": req.Workflow, + "ref": req.Ref, + "inputs": req.Inputs, + }).Info("Triggered GitHub workflow") + + workflowURL := fmt.Sprintf("https://github.com/%s/actions/workflows/%s", req.Repository, req.Workflow) + + triggerResp := GitHubTriggerResponse{ + WorkflowURL: workflowURL, + } + + // Try to find the workflow run that was just triggered. + if run := h.findTriggeredRun(r.Context(), req.Repository, req.Workflow, dispatchTime); run != nil { + triggerResp.RunID = run.ID + triggerResp.RunURL = run.HTMLURL + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(triggerResp) +} + +func (h *GitHubHandler) handleRunStatus(w http.ResponseWriter, r *http.Request) { + var req GitHubRunStatusRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body: %v", err) + return + } + + if req.Repository == "" || req.RunID == 0 { + writeError(w, http.StatusBadRequest, "repository and run_id are required") + return + } + + if req.Repository != defaultAllowedRepository { + writeError(w, http.StatusForbidden, "repository %q is not allowed", req.Repository) + return + } + + run, err := h.getRun(r.Context(), req.Repository, req.RunID) + if err != nil { + writeError(w, http.StatusBadGateway, "failed to get run status: %v", err) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(GitHubRunStatusResponse{ + RunID: run.ID, + Status: run.Status, + Conclusion: run.Conclusion, + HTMLURL: run.HTMLURL, + }) +} + +// findTriggeredRun polls GitHub to find the workflow run created after dispatchTime. +// It lists recent runs and compares timestamps client-side rather than relying on +// the GitHub API's created filter syntax. +func (h *GitHubHandler) findTriggeredRun(ctx context.Context, repo, workflow string, after time.Time) *gitHubWorkflowRun { + deadline := time.After(runFindTimeout) + listURL := fmt.Sprintf( + "%s/repos/%s/actions/workflows/%s/runs?event=workflow_dispatch&per_page=5", + githubAPIBase, repo, workflow, + ) + + for { + select { + case <-ctx.Done(): + return nil + case <-deadline: + h.log.Warn("Timed out finding triggered workflow run") + return nil + default: + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, listURL, nil) + if err != nil { + return nil + } + + req.Header.Set("Accept", "application/vnd.github.v3+json") + req.Header.Set("Authorization", "Bearer "+h.token) + + resp, err := h.httpClient.Do(req) + if err != nil { + return nil + } + + var result struct { + WorkflowRuns []gitHubWorkflowRun `json:"workflow_runs"` + } + + err = json.NewDecoder(resp.Body).Decode(&result) + _ = resp.Body.Close() + + if err != nil { + return nil + } + + // Find the most recent run created after our dispatch time. + for i := range result.WorkflowRuns { + run := &result.WorkflowRuns[i] + + createdAt, err := time.Parse(time.RFC3339, run.CreatedAt) + if err != nil { + continue + } + + if createdAt.After(after) || createdAt.Equal(after) { + h.log.WithFields(logrus.Fields{ + "run_id": run.ID, + "status": run.Status, + "url": run.HTMLURL, + }).Info("Found triggered workflow run") + + return run + } + } + + time.Sleep(runFindInterval) + } +} + +// getRun fetches a specific workflow run by ID. +func (h *GitHubHandler) getRun(ctx context.Context, repo string, runID int64) (*gitHubWorkflowRun, error) { + url := fmt.Sprintf("%s/repos/%s/actions/runs/%d", githubAPIBase, repo, runID) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("creating request: %w", err) + } + + req.Header.Set("Accept", "application/vnd.github.v3+json") + req.Header.Set("Authorization", "Bearer "+h.token) + + resp, err := h.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("github request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("github returned %d: %s", resp.StatusCode, string(body)) + } + + var run gitHubWorkflowRun + if err := json.NewDecoder(resp.Body).Decode(&run); err != nil { + return nil, fmt.Errorf("decoding response: %w", err) + } + + return &run, nil +} + +func writeError(w http.ResponseWriter, status int, format string, args ...any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(map[string]string{ + "error": fmt.Sprintf(format, args...), + }) +} diff --git a/pkg/proxy/server.go b/pkg/proxy/server.go index acce23b..f915f69 100644 --- a/pkg/proxy/server.go +++ b/pkg/proxy/server.go @@ -59,6 +59,7 @@ type server struct { lokiHandler *handlers.LokiHandler ethNodeHandler *handlers.EthNodeHandler embeddingService *EmbeddingService + githubHandler *handlers.GitHubHandler mu sync.RWMutex started bool @@ -174,6 +175,13 @@ func newServer(log logrus.FieldLogger, cfg ServerConfig, hostURL, port string) ( ) } + // Create GitHub handler if configured. + if cfg.GitHub != nil && cfg.GitHub.Token != "" { + s.githubHandler = handlers.NewGitHubHandler(log, handlers.GitHubConfig{ + Token: cfg.GitHub.Token, + }) + } + if s.url == "" { s.url = fmt.Sprintf("http://localhost:%s", port) } @@ -245,6 +253,10 @@ func (s *server) registerRoutes() { s.handleSubtreeRoute("/beacon", s.metricsMiddleware(chain(s.ethNodeHandler))) s.handleSubtreeRoute("/execution", s.metricsMiddleware(chain(s.ethNodeHandler))) } + + if s.githubHandler != nil { + s.handleSubtreeRoute("/github", s.metricsMiddleware(chain(s.githubHandler))) + } } func (s *server) handleSubtreeRoute(pattern string, handler http.Handler) { diff --git a/pkg/proxy/server_config.go b/pkg/proxy/server_config.go index 51fbe75..85d6fc5 100644 --- a/pkg/proxy/server_config.go +++ b/pkg/proxy/server_config.go @@ -46,6 +46,15 @@ type ServerConfig struct { // Embedding holds optional embedding API configuration. Embedding *EmbeddingConfig `yaml:"embedding,omitempty"` + + // GitHub holds optional GitHub API configuration for triggering workflows. + GitHub *GitHubAPIConfig `yaml:"github,omitempty"` +} + +// GitHubAPIConfig holds GitHub API configuration for the proxy. +type GitHubAPIConfig struct { + // Token is a GitHub personal access token or app token with actions:write permission. + Token string `yaml:"token"` } // HTTPServerConfig holds HTTP server configuration. diff --git a/pkg/server/api.go b/pkg/server/api.go index 5a3bd81..6c385db 100644 --- a/pkg/server/api.go +++ b/pkg/server/api.go @@ -1,6 +1,7 @@ package server import ( + "bytes" "context" "encoding/json" "fmt" @@ -32,6 +33,8 @@ func (s *service) mountAPIRoutes(r chi.Router) { r.Delete("/sessions/{sessionID}", s.handleAPIDestroySession) r.Get("/resources", s.handleAPIListResources) r.Get("/resources/read", s.handleAPIReadResource) + r.Post("/build/trigger", s.handleAPIBuildTrigger) + r.Post("/build/status", s.handleAPIBuildStatus) r.HandleFunc("/operations/{operationID}", s.handleAPIOperation) // Public file serving (no auth — same as MinIO anonymous download). @@ -522,6 +525,147 @@ func (s *service) handleStorageServeFile(w http.ResponseWriter, r *http.Request) s.storageService.ServeFile(w, r, filePath) } +func (s *service) handleAPIBuildTrigger(w http.ResponseWriter, r *http.Request) { + if s.proxyService == nil { + writeAPIError(w, http.StatusServiceUnavailable, "proxy service is unavailable") + return + } + + var req serverapi.BuildTriggerRequest + if err := decodeJSON(r, &req); err != nil { + writeAPIError(w, http.StatusBadRequest, err.Error()) + return + } + + if strings.TrimSpace(req.Client) == "" { + writeAPIError(w, http.StatusBadRequest, "client is required") + return + } + + // Build the proxy request: map client name to workflow filename. + workflow := fmt.Sprintf("build-push-%s.yml", req.Client) + + inputs := make(map[string]string) + if req.Repository != "" { + inputs["repository"] = req.Repository + } + if req.Ref != "" { + inputs["ref"] = req.Ref + } + if req.DockerTag != "" { + inputs["docker_tag"] = req.DockerTag + } + + proxyReq := map[string]any{ + "repository": "ethpandaops/eth-client-docker-image-builder", + "workflow": workflow, + "ref": "master", + "inputs": inputs, + } + + body, err := json.Marshal(proxyReq) + if err != nil { + writeAPIError(w, http.StatusInternalServerError, "failed to marshal proxy request") + return + } + + data, status, _, err := s.proxyRequest( + r.Context(), + http.MethodPost, + "/github/actions/trigger", + bytes.NewReader(body), + http.Header{"Content-Type": []string{"application/json"}}, + ) + if err != nil { + writeAPIError(w, http.StatusBadGateway, fmt.Sprintf("proxy request failed: %v", err)) + return + } + + if status < 200 || status >= 300 { + // Forward the proxy error. + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _, _ = w.Write(data) + + return + } + + var proxyResp struct { + WorkflowURL string `json:"workflow_url"` + RunID int64 `json:"run_id"` + RunURL string `json:"run_url"` + } + if err := json.Unmarshal(data, &proxyResp); err != nil { + writeAPIError(w, http.StatusInternalServerError, "failed to decode proxy response") + return + } + + writeJSON(w, http.StatusOK, serverapi.BuildTriggerResponse{ + WorkflowURL: proxyResp.WorkflowURL, + Client: req.Client, + Workflow: workflow, + RunID: proxyResp.RunID, + RunURL: proxyResp.RunURL, + }) +} + +func (s *service) handleAPIBuildStatus(w http.ResponseWriter, r *http.Request) { + if s.proxyService == nil { + writeAPIError(w, http.StatusServiceUnavailable, "proxy service is unavailable") + return + } + + var req serverapi.BuildStatusRequest + if err := decodeJSON(r, &req); err != nil { + writeAPIError(w, http.StatusBadRequest, err.Error()) + return + } + + if req.RunID == 0 { + writeAPIError(w, http.StatusBadRequest, "run_id is required") + return + } + + proxyReq := map[string]any{ + "repository": "ethpandaops/eth-client-docker-image-builder", + "run_id": req.RunID, + } + + body, err := json.Marshal(proxyReq) + if err != nil { + writeAPIError(w, http.StatusInternalServerError, "failed to marshal proxy request") + return + } + + data, status, _, err := s.proxyRequest( + r.Context(), + http.MethodPost, + "/github/actions/run-status", + bytes.NewReader(body), + http.Header{"Content-Type": []string{"application/json"}}, + ) + if err != nil { + writeAPIError(w, http.StatusBadGateway, fmt.Sprintf("proxy request failed: %v", err)) + return + } + + if status < 200 || status >= 300 { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _, _ = w.Write(data) + + return + } + + var proxyResp serverapi.BuildStatusResponse + if err := json.Unmarshal(data, &proxyResp); err != nil { + writeAPIError(w, http.StatusInternalServerError, "failed to decode proxy response") + return + } + + writeJSON(w, http.StatusOK, proxyResp) +} + func (s *service) proxyRequest( ctx context.Context, method string, diff --git a/pkg/serverapi/types.go b/pkg/serverapi/types.go index 6a878a7..8950518 100644 --- a/pkg/serverapi/types.go +++ b/pkg/serverapi/types.go @@ -176,3 +176,42 @@ type CreateSessionResponse struct { SessionID string `json:"session_id"` TTLRemaining string `json:"ttl_remaining,omitempty"` } + +// BuildTriggerRequest is the request for POST /api/v1/build/trigger. +type BuildTriggerRequest struct { + // Client is the client name (e.g. "geth", "lighthouse", "prysm"). + Client string `json:"client"` + // Repository is the source repository override (optional). + Repository string `json:"repository,omitempty"` + // Ref is the branch, tag, or SHA to build from (optional). + Ref string `json:"ref,omitempty"` + // DockerTag is the target docker tag override (optional). + DockerTag string `json:"docker_tag,omitempty"` +} + +// BuildTriggerResponse is the response from POST /api/v1/build/trigger. +type BuildTriggerResponse struct { + // WorkflowURL is the URL to the GitHub Actions workflow page. + WorkflowURL string `json:"workflow_url"` + // Client is the client that was built. + Client string `json:"client"` + // Workflow is the workflow filename that was triggered. + Workflow string `json:"workflow"` + // RunID is the GitHub Actions run ID (0 if not yet available). + RunID int64 `json:"run_id,omitempty"` + // RunURL is the direct URL to the specific workflow run. + RunURL string `json:"run_url,omitempty"` +} + +// BuildStatusRequest is the request for GET /api/v1/build/status. +type BuildStatusRequest struct { + RunID int64 `json:"run_id"` +} + +// BuildStatusResponse is the response from GET /api/v1/build/status. +type BuildStatusResponse struct { + RunID int64 `json:"run_id"` + Status string `json:"status"` + Conclusion string `json:"conclusion"` + HTMLURL string `json:"html_url"` +}