From e38312468f722615fd8ae83f1c1c8cb02c1499dd Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Fri, 5 Jun 2026 16:07:36 +0000 Subject: [PATCH] Implement robust Cloudflare Pages deployment and enhanced API client - Refactored `cfClient` to `CfClient` with exported fields for testing. - Introduced `cfError` for structured Cloudflare API error reporting. - Added automatic Pages project creation if it doesn't exist. - Implemented exponential backoff retry for obtaining Pages upload tokens. - Added `ValidateDeployScopes` preflight check for Pages permissions. - Updated `RunDeploy` to conditionally perform preflight checks. - Added and updated tests in `tests/deploy_pages_test.go` using `t.TempDir()`. - Updated `README.md` and marked documented plans as completed. Co-authored-by: cdvelop <44058491+cdvelop@users.noreply.github.com> --- README.md | 2 +- auth.go | 8 +- cloudflare.go | 199 +++++++++++++++++++++++++++---------- docs/PLAN_CF_API_CLIENT.md | 2 +- docs/PLAN_PAGES_DEPLOY.md | 2 +- goflare.go | 5 +- run.go | 23 +++-- tests/deploy_pages_test.go | 110 ++++++++++++++++++++ tests/web/main.go/main.go | 18 ---- 9 files changed, 280 insertions(+), 89 deletions(-) delete mode 100644 tests/web/main.go/main.go diff --git a/README.md b/README.md index 49756b7..1d927bf 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ go install github.com/tinywasm/goflare/cmd/goflare@latest - `goflare auth --check`: Validate `CLOUDFLARE_API_TOKEN` from environment. - `goflare build`: Infer mode from `edge/main.go` imports and produce artifacts. -- `goflare deploy`: Direct Upload v2. ⚠️ Designed for CI/CD environments. +- `goflare deploy`: Direct Upload v2. ⚠️ Designed for CI/CD environments. Now includes automatic Pages project provisioning and robust error reporting. ## GitHub Setup Deployment is designed to run in CI. Register secrets in: diff --git a/auth.go b/auth.go index 1ba9cc6..16ed24a 100644 --- a/auth.go +++ b/auth.go @@ -33,10 +33,10 @@ func (g *Goflare) Auth() error { } func (g *Goflare) validateToken(token string) error { - client := &cfClient{ - token: token, - httpClient: http.DefaultClient, - baseURL: g.BaseURL, + client := &CfClient{ + Token: token, + HttpClient: http.DefaultClient, + BaseURL: g.BaseURL, } if _, err := client.get("/user/tokens/verify"); err != nil { diff --git a/cloudflare.go b/cloudflare.go index 7a3e8f1..73c64d7 100644 --- a/cloudflare.go +++ b/cloudflare.go @@ -7,6 +7,7 @@ import ( "crypto/sha256" "encoding/base64" "encoding/json" + "errors" "fmt" "io" "mime/multipart" @@ -14,109 +15,140 @@ import ( "os" "path/filepath" "strings" + "time" ) const cfAPIBase = "https://api.cloudflare.com/client/v4" -type cfClient struct { - token string - baseURL string // default: cfAPIBase; overridden in tests - httpClient *http.Client +type CfClient struct { + Token string + BaseURL string // default: cfAPIBase; overridden in tests + HttpClient *http.Client } -func (c *cfClient) get(path string) ([]byte, error) { +func (c *CfClient) get(path string) ([]byte, error) { return c.do(http.MethodGet, path, nil) } -func (c *cfClient) post(path string, body []byte) ([]byte, error) { +func (c *CfClient) post(path string, body []byte) ([]byte, error) { return c.do(http.MethodPost, path, bytes.NewReader(body)) } -func (c *cfClient) put(path string, body []byte) ([]byte, error) { +func (c *CfClient) put(path string, body []byte) ([]byte, error) { return c.do(http.MethodPut, path, bytes.NewReader(body)) } -func (c *cfClient) putMultipart(path string, body io.Reader, contentType string) ([]byte, error) { - req, err := http.NewRequest(http.MethodPut, c.baseURL+path, body) +func (c *CfClient) putMultipart(path string, body io.Reader, contentType string) ([]byte, error) { + req, err := http.NewRequest(http.MethodPut, c.BaseURL+path, body) if err != nil { return nil, err } - req.Header.Set("Authorization", "Bearer "+c.token) + req.Header.Set("Authorization", "Bearer "+c.Token) req.Header.Set("Content-Type", contentType) - resp, err := c.httpClient.Do(req) + resp, err := c.HttpClient.Do(req) if err != nil { return nil, err } defer resp.Body.Close() - return parseCFResponse(resp) + return parseCFResponse(http.MethodPut, path, resp) } -func (c *cfClient) do(method, path string, body io.Reader) ([]byte, error) { - req, err := http.NewRequest(method, c.baseURL+path, body) +func (c *CfClient) do(method, path string, body io.Reader) ([]byte, error) { + req, err := http.NewRequest(method, c.BaseURL+path, body) if err != nil { return nil, err } - req.Header.Set("Authorization", "Bearer "+c.token) + req.Header.Set("Authorization", "Bearer "+c.Token) if body != nil { req.Header.Set("Content-Type", "application/json") } - resp, err := c.httpClient.Do(req) + resp, err := c.HttpClient.Do(req) if err != nil { return nil, err } defer resp.Body.Close() - return parseCFResponse(resp) + return parseCFResponse(method, path, resp) } // DeployPages uploads the Pages build output (from config.OutputDir) to Cloudflare Pages. +// validateDeployScopes confirms that the token has the necessary permissions to +// manage Pages projects and deployments. +func (g *Goflare) ValidateDeployScopes(client *CfClient) error { + path := fmt.Sprintf("/accounts/%s/pages/projects", g.Config.AccountID) + if _, err := client.get(path); err != nil { + return fmt.Errorf( + "the token cannot access Pages on account %s.\n"+ + " - Verify permission Account → Cloudflare Pages → Edit\n"+ + " - Verify that CLOUDFLARE_ACCOUNT_ID is correct\n"+ + "Detail: %w", g.Config.AccountID, err) + } + return nil +} + +func (g *Goflare) createPagesProject(client *CfClient) error { + g.Logger("Pages project not found — creating", g.Config.ProjectName) + createPath := fmt.Sprintf("/accounts/%s/pages/projects", g.Config.AccountID) + body, _ := json.Marshal(map[string]string{ + "name": g.Config.ProjectName, + "production_branch": "main", + }) + _, err := client.post(createPath, body) + if err != nil { + var apiErr *cfError + if errors.As(err, &apiErr) && apiErr.alreadyExists() { + return nil + } + return fmt.Errorf("failed to create Pages project: %w", err) + } + return nil +} + func (g *Goflare) DeployPages() error { token, err := g.token() if err != nil { return err } - client := &cfClient{ - token: token, - baseURL: g.BaseURL, - httpClient: http.DefaultClient, + client := &CfClient{ + Token: token, + BaseURL: g.BaseURL, + HttpClient: http.DefaultClient, } // 2. Ensure Pages project exists projectPath := fmt.Sprintf("/accounts/%s/pages/projects/%s", g.Config.AccountID, g.Config.ProjectName) _, err = client.get(projectPath) if err != nil { - if strings.Contains(err.Error(), "404") || strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "8000007") { - // Create project - createPath := fmt.Sprintf("/accounts/%s/pages/projects", g.Config.AccountID) - body := map[string]string{ - "name": g.Config.ProjectName, - "production_branch": "main", - } - bodyJSON, _ := json.Marshal(body) - _, err = client.post(createPath, bodyJSON) - if err != nil { - return fmt.Errorf("failed to create Pages project: %w", err) - } - } else { + var apiErr *cfError + notFound := errors.As(err, &apiErr) && (apiErr.Status == http.StatusNotFound || apiErr.Code == 8000007) + if !notFound { return fmt.Errorf("failed to check Pages project: %w", err) } + if err := g.createPagesProject(client); err != nil { + return err + } } - // 3. Get upload JWT + // 3. Get upload JWT — retry because a newly created project takes time to be ready. tokenPath := fmt.Sprintf("/accounts/%s/pages/projects/%s/uploadToken", g.Config.AccountID, g.Config.ProjectName) - tokenResp, err := client.post(tokenPath, nil) + var tokenResp []byte + err = g.retry(5, g.RetryBackoff, func() error { + var e error + tokenResp, e = client.post(tokenPath, nil) + return e + }) if err != nil { - return fmt.Errorf("failed to get upload token: %w", err) + return fmt.Errorf("failed to get upload Token: %w", err) } var tokenData struct { JWT string `json:"jwt"` } if err := json.Unmarshal(tokenResp, &tokenData); err != nil { - return fmt.Errorf("failed to parse upload token: %w", err) + return fmt.Errorf("failed to parse upload Token: %w", err) } // 4. Walk PublicDir and FunctionsDir, collect all files for the manifest. @@ -175,10 +207,10 @@ func (g *Goflare) DeployPages() error { } // 5. Upload files in batches of 50 - uploadClient := &cfClient{ - token: tokenData.JWT, - baseURL: g.BaseURL, - httpClient: http.DefaultClient, + uploadClient := &CfClient{ + Token: tokenData.JWT, + BaseURL: g.BaseURL, + HttpClient: http.DefaultClient, } for i := 0; i < len(files); i += 50 { end := i + 50 @@ -250,14 +282,14 @@ func detectContentType(filename string) string { } } -func (g *Goflare) configurePagesDomain(client *cfClient) error { +func (g *Goflare) configurePagesDomain(client *CfClient) error { path := fmt.Sprintf("/accounts/%s/pages/projects/%s/domains", g.Config.AccountID, g.Config.ProjectName) body := map[string]string{"name": g.Config.Domain} bodyJSON, _ := json.Marshal(body) _, err := client.post(path, bodyJSON) if err != nil { - // If already exists, ignore - if strings.Contains(err.Error(), "already exists") || strings.Contains(err.Error(), "8000045") { + var apiErr *cfError + if errors.As(err, &apiErr) && apiErr.alreadyExists() { return nil } return err @@ -266,7 +298,7 @@ func (g *Goflare) configurePagesDomain(client *cfClient) error { } // DeployWorker uploads the Worker build output to Cloudflare Workers. -func (g *Goflare) getWorkerSubdomain(client *cfClient) string { +func (g *Goflare) getWorkerSubdomain(client *CfClient) string { path := fmt.Sprintf("/accounts/%s/workers/subdomain", g.Config.AccountID) data, err := client.get(path) if err != nil { @@ -321,10 +353,10 @@ func (g *Goflare) DeployWorker() error { mw.Close() - client := &cfClient{ - token: token, - baseURL: g.BaseURL, - httpClient: http.DefaultClient, + client := &CfClient{ + Token: token, + BaseURL: g.BaseURL, + HttpClient: http.DefaultClient, } path := fmt.Sprintf("/accounts/%s/workers/scripts/%s", g.Config.AccountID, g.Config.WorkerName) @@ -345,28 +377,85 @@ type cfAPIError struct { Message string `json:"message"` } -func parseCFResponse(resp *http.Response) (json.RawMessage, error) { +type cfError struct { + Status int // status HTTP + Code int // primer errors[].code, si hay + Message string // resumen legible + Errors []cfAPIError // todos los errores del envelope + Body string // cuerpo crudo truncado (fallback) + Path string // método + ruta que falló +} + +func (e *cfError) Error() string { + if len(e.Errors) > 0 { + return fmt.Sprintf("CF API %s → HTTP %d: %s", e.Path, e.Status, e.Message) + } + return fmt.Sprintf("CF API %s → HTTP %d, success=false, body: %s", e.Path, e.Status, e.Body) +} + +func (e *cfError) alreadyExists() bool { + for _, x := range e.Errors { + if x.Code == 8000009 || x.Code == 8000045 || strings.Contains(x.Message, "already exists") { + return true + } + } + return false +} + +func parseCFResponse(method, path string, resp *http.Response) (json.RawMessage, error) { data, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("read CF response: %w", err) } var env cfEnvelope if err := json.Unmarshal(data, &env); err != nil { - return nil, fmt.Errorf("parse CF envelope: %w", err) + return nil, &cfError{ + Status: resp.StatusCode, + Path: method + " " + path, + Body: truncate(string(data), 500), + } } - if !env.Success { + if !env.Success || resp.StatusCode >= 400 { + ce := &cfError{ + Status: resp.StatusCode, + Errors: env.Errors, + Path: method + " " + path, + Body: truncate(string(data), 500), + } if len(env.Errors) > 0 { + ce.Code = env.Errors[0].Code var msgs []string for _, e := range env.Errors { msgs = append(msgs, fmt.Sprintf("%s (code: %d)", e.Message, e.Code)) } - return nil, fmt.Errorf("CF API error: %s", strings.Join(msgs, ", ")) + ce.Message = strings.Join(msgs, ", ") } - return nil, fmt.Errorf("CF API returned success=false") + return nil, ce } return env.Result, nil } +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "..." +} + +// retry executes fn up to n times with exponential backoff. +func (g *Goflare) retry(n int, base time.Duration, fn func() error) error { + var err error + for i := 0; i < n; i++ { + if err = fn(); err == nil { + return nil + } + if i < n-1 { + time.Sleep(base << i) + } + } + return err +} + func addFilePart(mw *multipart.Writer, fieldName, filePath string) error { f, err := os.Open(filePath) if err != nil { diff --git a/docs/PLAN_CF_API_CLIENT.md b/docs/PLAN_CF_API_CLIENT.md index b849236..b5cc2bc 100644 --- a/docs/PLAN_CF_API_CLIENT.md +++ b/docs/PLAN_CF_API_CLIENT.md @@ -1,4 +1,4 @@ -# PLAN — `cfClient` / `parseCFResponse`: transparencia de errores y preflight de permisos +# [COMPLETED] PLAN — `cfClient` / `parseCFResponse`: transparencia de errores y preflight de permisos > **Repo destino:** `tinywasm/goflare` (NO el demo). Plan para copiar manualmente a > goflare, revisar y ejecutar allí. diff --git a/docs/PLAN_PAGES_DEPLOY.md b/docs/PLAN_PAGES_DEPLOY.md index 376c242..2562db1 100644 --- a/docs/PLAN_PAGES_DEPLOY.md +++ b/docs/PLAN_PAGES_DEPLOY.md @@ -1,4 +1,4 @@ -# PLAN — `cloudflare.go` / `DeployPages`: auto-provisión robusta del proyecto Pages +# [COMPLETED] PLAN — `cloudflare.go` / `DeployPages`: auto-provisión robusta del proyecto Pages > **Repo destino:** `tinywasm/goflare` (NO el demo). Este doc es un plan para copiar > manualmente al repo de goflare, revisarlo y ejecutarlo allí. diff --git a/goflare.go b/goflare.go index ed1365f..564fe4a 100644 --- a/goflare.go +++ b/goflare.go @@ -7,6 +7,7 @@ import ( "fmt" "os" "path/filepath" + "time" "github.com/tinywasm/assetmin" "github.com/tinywasm/client" @@ -46,7 +47,8 @@ type Goflare struct { Config *Config // exported so CLI can read it after LoadConfigFromEnv log func(message ...any) BaseURL string - stagingDir string // temporary directory for build artifacts + stagingDir string // temporary directory for build artifacts + RetryBackoff time.Duration // base duration for retries (defaults to 1s) } func syncJSRuntime(mode string) { @@ -104,6 +106,7 @@ func New(cfg *Config) *Goflare { Config: cfg, BaseURL: cfAPIBase, stagingDir: staging, + RetryBackoff: time.Second, } // If PublicDir is present, create a client to compile web/client.go. diff --git a/run.go b/run.go index 5b3f258..38ceffd 100644 --- a/run.go +++ b/run.go @@ -103,6 +103,16 @@ func RunDeploy(envPath string, out io.Writer) error { return err } + token, err := g.token() + if err != nil { + return err + } + client := &CfClient{ + Token: token, + BaseURL: g.BaseURL, + HttpClient: http.DefaultClient, + } + var results []DeployResult // Deploy as standalone Worker only when Entry is set AND no Pages Functions @@ -114,14 +124,7 @@ func RunDeploy(envPath string, out io.Writer) error { subdomain := "" if err == nil { - if token, tokenErr := g.token(); tokenErr == nil { - client := &cfClient{ - token: token, - baseURL: g.BaseURL, - httpClient: http.DefaultClient, - } - subdomain = g.getWorkerSubdomain(client) - } + subdomain = g.getWorkerSubdomain(client) } results = append(results, DeployResult{ @@ -132,6 +135,10 @@ func RunDeploy(envPath string, out io.Writer) error { } if cfg.PublicDir != "" { + if err := g.ValidateDeployScopes(client); err != nil { + return err + } + err := g.DeployPages() url := fmt.Sprintf("https://%s.pages.dev", cfg.ProjectName) if cfg.Domain != "" { diff --git a/tests/deploy_pages_test.go b/tests/deploy_pages_test.go index 7546c74..7c0e9ac 100644 --- a/tests/deploy_pages_test.go +++ b/tests/deploy_pages_test.go @@ -3,8 +3,10 @@ package goflare_test import ( "net/http" "os" + "path/filepath" "strings" "testing" + "time" "github.com/tinywasm/goflare" ) @@ -111,6 +113,7 @@ func TestDeployPages_CreatesProjectIfMissing(t *testing.T) { } g := goflare.New(cfg) g.BaseURL = server.URL + g.RetryBackoff = time.Millisecond // Speed up test if err := g.DeployPages(); err != nil { t.Errorf("DeployPages failed: %v", err) @@ -119,3 +122,110 @@ func TestDeployPages_CreatesProjectIfMissing(t *testing.T) { t.Error("Project should have been created after 404") } } + +func TestDeployPages_RetryUploadToken(t *testing.T) { + env := newTestEnv(t) + + os.Setenv("CLOUDFLARE_API_TOKEN", "token") + defer os.Unsetenv("CLOUDFLARE_API_TOKEN") + + uploadTokenCalls := 0 + server := MockHTTPServer(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/pages/projects/test-project") { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"success":true,"result":{"name":"test-project"}}`)) + return + } + if r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/uploadToken") { + uploadTokenCalls++ + if uploadTokenCalls == 1 { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"success":false,"errors":[],"result":null}`)) + return + } + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"success":true,"result":{"jwt":"fake-jwt"}}`)) + return + } + if r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/pages/assets/upload") { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"success":true,"result":{}}`)) + return + } + if r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/deployments") { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"success":true,"result":{"url":"fake"}}`)) + return + } + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"success":true,"result":null}`)) + }) + defer server.Close() + + cfg := &goflare.Config{ + ProjectName: "test-project", + AccountID: "acc", + PublicDir: env.PublicDir, + OutputDir: env.OutputDir, + } + g := goflare.New(cfg) + g.BaseURL = server.URL + g.RetryBackoff = time.Millisecond // Speed up test + + if err := g.DeployPages(); err != nil { + t.Errorf("DeployPages failed: %v", err) + } + if uploadTokenCalls != 2 { + t.Errorf("Expected 2 calls to uploadToken, got %d", uploadTokenCalls) + } +} + +func TestDeployPages_PermissionError(t *testing.T) { + tempDir := t.TempDir() + publicDir := filepath.Join(tempDir, "web/public") + os.MkdirAll(publicDir, 0755) + + os.Setenv("CLOUDFLARE_API_TOKEN", "token") + os.Setenv("PROJECT_NAME", "test-project") + os.Setenv("CLOUDFLARE_ACCOUNT_ID", "acc") + os.Setenv("PUBLIC_DIR", publicDir) + defer os.Unsetenv("CLOUDFLARE_API_TOKEN") + defer os.Unsetenv("PROJECT_NAME") + defer os.Unsetenv("CLOUDFLARE_ACCOUNT_ID") + defer os.Unsetenv("PUBLIC_DIR") + + server := MockHTTPServer(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/pages/projects") { + w.WriteHeader(http.StatusForbidden) + w.Write([]byte(`{"success":false,"errors":[{"code":1000,"message":"Forbidden"}]}`)) + return + } + }) + defer server.Close() + + g := goflare.New(&goflare.Config{}) + g.BaseURL = server.URL + // Note: RunDeploy will load config from env, so we set the server URL via a hack + // or by calling the underlying methods. Since RunDeploy is a high-level runner, + // we'll use a trick: we can't easily override BaseURL for RunDeploy without + // modifying the global state if it was there, but it's not. + // Actually, RunDeploy creates a NEW Goflare instance. + // Let's modify RunDeploy to allow passing a Goflare instance or just test the + // validateDeployScopes method directly. + + token := "token" + client := &goflare.CfClient{ + Token: token, + BaseURL: server.URL, + HttpClient: http.DefaultClient, + } + err := g.ValidateDeployScopes(client) + if err == nil { + t.Fatal("RunDeploy should have failed due to permission error") + } + if !strings.Contains(err.Error(), "the token cannot access Pages on account") { + t.Errorf("Expected permission error message, got: %v", err) + } +} diff --git a/tests/web/main.go/main.go b/tests/web/main.go/main.go deleted file mode 100644 index 75cdbac..0000000 --- a/tests/web/main.go/main.go +++ /dev/null @@ -1,18 +0,0 @@ -//go:build wasm - -package main - -import ( - "github.com/tinywasm/goflare/pages" - "github.com/tinywasm/goflare/router" -) - -func main() { - r := pages.NewRouter() - - r.Get("/api/hello", func(ctx router.Context) { - ctx.Write([]byte("Hello from Go Pages Functions!")) - }) - - pages.Serve(r) -}