From eae3a817e1395dac8faeea5ee5e95c6f5d55f14c Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Mon, 20 Apr 2026 15:14:25 -0400 Subject: [PATCH 1/2] feat(wfctl): add DigitalOcean App Platform deploy provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `digitalocean` (alias `do`) to the wfctl deploy provider registry. - `digitaloceanProvider.Deploy` upserts to the DO App Platform REST API: GET /v2/apps?name=X to detect existing; POST to create, PUT to update. Converts ServiceConfig (expose, scaling, secrets) into a minimal doAppSpec. - `digitaloceanProvider.HealthCheck` fetches live_url from GET /v2/apps/{id} and delegates to the shared pollHealthCheck helper. - Auth via DIGITALOCEAN_TOKEN env var; returns a clear error when unset. - Five tests covering: provider registration, missing token, create-new, update-existing, and health check — all using httptest.NewServer stubs. Co-Authored-By: Claude Sonnet 4.6 --- cmd/wfctl/deploy_providers.go | 314 ++++++++++++++++++++++++++++- cmd/wfctl/deploy_providers_test.go | 137 +++++++++++++ 2 files changed, 450 insertions(+), 1 deletion(-) diff --git a/cmd/wfctl/deploy_providers.go b/cmd/wfctl/deploy_providers.go index a66ee438..bae0016d 100644 --- a/cmd/wfctl/deploy_providers.go +++ b/cmd/wfctl/deploy_providers.go @@ -3,7 +3,9 @@ package main import ( "bytes" "context" + "encoding/json" "fmt" + "io" "net/http" "os" "os/exec" @@ -49,8 +51,10 @@ func newDeployProvider(provider string) (DeployProvider, error) { return &dockerProvider{}, nil case "aws-ecs": return &awsECSProvider{}, nil + case "digitalocean", "do": + return &digitaloceanProvider{}, nil default: - return nil, fmt.Errorf("unsupported deploy provider %q (supported: kubernetes, docker, aws-ecs)", provider) + return nil, fmt.Errorf("unsupported deploy provider %q (supported: kubernetes, docker, aws-ecs, digitalocean)", provider) } } @@ -477,3 +481,311 @@ func cmp(a, b string) string { } return b } + +// ── digitalocean provider ───────────────────────────────────────────────────── + +type digitaloceanProvider struct { + baseURL string // defaults to "https://api.digitalocean.com"; injectable for testing + appID string // populated after successful Deploy, used by HealthCheck +} + +// DO App Platform API request/response types (minimal subset). +type doAppSpec struct { + Name string `json:"name"` + Region string `json:"region,omitempty"` + Services []doAppService `json:"services"` +} + +type doAppService struct { + Name string `json:"name"` + Image *doAppImage `json:"image"` + HTTPPort int `json:"http_port,omitempty"` + InstanceCount int `json:"instance_count,omitempty"` + Envs []doAppEnv `json:"envs,omitempty"` +} + +type doAppImage struct { + RegistryType string `json:"registry_type"` + Registry string `json:"registry"` + Repository string `json:"repository"` + Tag string `json:"tag"` +} + +type doAppEnv struct { + Key string `json:"key"` + Value string `json:"value,omitempty"` + Type string `json:"type,omitempty"` +} + +type doApp struct { + ID string `json:"id"` + Spec doAppSpec `json:"spec"` + LiveURL string `json:"live_url,omitempty"` +} + +type doListAppsResponse struct { + Apps []doApp `json:"apps"` +} + +type doCreateAppRequest struct { + Spec doAppSpec `json:"spec"` +} + +type doAppResponse struct { + App doApp `json:"app"` +} + +func (p *digitaloceanProvider) doBase() string { + if p.baseURL != "" { + return p.baseURL + } + return "https://api.digitalocean.com" +} + +func (p *digitaloceanProvider) Deploy(ctx context.Context, cfg DeployConfig) error { + token := os.Getenv("DIGITALOCEAN_TOKEN") + if token == "" { + return fmt.Errorf("DIGITALOCEAN_TOKEN is required for DigitalOcean deployments") + } + + spec := p.buildAppSpec(cfg) + + existingID, err := p.findApp(ctx, token, cfg.AppName) + if err != nil { + return fmt.Errorf("find app: %w", err) + } + + var appID string + if existingID != "" { + appID, err = p.updateApp(ctx, token, existingID, spec) + if err != nil { + return fmt.Errorf("update app: %w", err) + } + fmt.Printf(" updated DO app %q (id: %s)\n", cfg.AppName, appID) + } else { + appID, err = p.createApp(ctx, token, spec) + if err != nil { + return fmt.Errorf("create app: %w", err) + } + fmt.Printf(" created DO app %q (id: %s)\n", cfg.AppName, appID) + } + p.appID = appID + return nil +} + +func (p *digitaloceanProvider) buildAppSpec(cfg DeployConfig) doAppSpec { + region := cmp(cfg.Env.Region, "nyc3") + registry, repository, tag := parseImageRef(cfg.ImageTag) + + var envs []doAppEnv + for k, v := range cfg.Secrets { + envs = append(envs, doAppEnv{Key: k, Value: v, Type: "SECRET"}) + } + + instanceCount := 1 + if len(cfg.Services) == 1 { + for _, svc := range cfg.Services { + if svc.Scaling != nil && svc.Scaling.Replicas > 0 { + instanceCount = svc.Scaling.Replicas + } + } + } + + httpPort := 8080 + if len(cfg.Services) == 1 { + for _, svc := range cfg.Services { + if len(svc.Expose) > 0 { + httpPort = svc.Expose[0].Port + } + } + } + + svc := doAppService{ + Name: cfg.AppName, + Image: &doAppImage{ + RegistryType: "DOCR", + Registry: registry, + Repository: repository, + Tag: tag, + }, + HTTPPort: httpPort, + InstanceCount: instanceCount, + Envs: envs, + } + + return doAppSpec{ + Name: cfg.AppName, + Region: region, + Services: []doAppService{svc}, + } +} + +// parseImageRef splits "registry.digitalocean.com/myreg/myapp:sha" into (registry, repo, tag). +func parseImageRef(imageTag string) (registry, repository, tag string) { + if i := strings.LastIndex(imageTag, ":"); i >= 0 { + tag = imageTag[i+1:] + imageTag = imageTag[:i] + } + if i := strings.Index(imageTag, "/"); i >= 0 { + registry = imageTag[:i] + repository = imageTag[i+1:] + } else { + repository = imageTag + } + return +} + +func (p *digitaloceanProvider) findApp(ctx context.Context, token, name string) (string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.doBase()+"/v2/apps?name="+name, nil) + if err != nil { + return "", err + } + req.Header.Set("Authorization", "Bearer "+token) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", fmt.Errorf("GET /v2/apps: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + if resp.StatusCode >= 400 { + return "", fmt.Errorf("GET /v2/apps: HTTP %d: %s", resp.StatusCode, body) + } + + var result doListAppsResponse + if err := json.Unmarshal(body, &result); err != nil { + return "", fmt.Errorf("decode apps list: %w", err) + } + for _, app := range result.Apps { + if app.Spec.Name == name { + return app.ID, nil + } + } + return "", nil +} + +func (p *digitaloceanProvider) createApp(ctx context.Context, token string, spec doAppSpec) (string, error) { + payload, err := json.Marshal(doCreateAppRequest{Spec: spec}) + if err != nil { + return "", err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.doBase()+"/v2/apps", bytes.NewReader(payload)) + if err != nil { + return "", err + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", fmt.Errorf("POST /v2/apps: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + if resp.StatusCode >= 400 { + return "", fmt.Errorf("POST /v2/apps: HTTP %d: %s", resp.StatusCode, body) + } + + var result doAppResponse + if err := json.Unmarshal(body, &result); err != nil { + return "", fmt.Errorf("decode create app response: %w", err) + } + return result.App.ID, nil +} + +func (p *digitaloceanProvider) updateApp(ctx context.Context, token, appID string, spec doAppSpec) (string, error) { + payload, err := json.Marshal(doCreateAppRequest{Spec: spec}) + if err != nil { + return "", err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPut, p.doBase()+"/v2/apps/"+appID, bytes.NewReader(payload)) + if err != nil { + return "", err + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", fmt.Errorf("PUT /v2/apps/%s: %w", appID, err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + if resp.StatusCode >= 400 { + return "", fmt.Errorf("PUT /v2/apps/%s: HTTP %d: %s", appID, resp.StatusCode, body) + } + + var result doAppResponse + if err := json.Unmarshal(body, &result); err != nil { + return "", fmt.Errorf("decode update app response: %w", err) + } + return result.App.ID, nil +} + +func (p *digitaloceanProvider) HealthCheck(ctx context.Context, cfg DeployConfig) error { + if cfg.Env.HealthCheck == nil { + return nil + } + + // If we have the app ID and a token, fetch the live URL from DO and prepend it. + if p.appID != "" { + if token := os.Getenv("DIGITALOCEAN_TOKEN"); token != "" { + liveURL, err := p.fetchLiveURL(ctx, token) + if err == nil && liveURL != "" { + hcPath := cfg.Env.HealthCheck.Path + fullURL := strings.TrimRight(liveURL, "/") + "/" + strings.TrimLeft(hcPath, "/") + hcCopy := *cfg.Env.HealthCheck + hcCopy.Path = fullURL + envCopy := *cfg.Env + envCopy.HealthCheck = &hcCopy + cfgCopy := cfg + cfgCopy.Env = &envCopy + return pollHealthCheck(ctx, cfgCopy) + } + } + } + + return pollHealthCheck(ctx, cfg) +} + +func (p *digitaloceanProvider) fetchLiveURL(ctx context.Context, token string) (string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.doBase()+"/v2/apps/"+p.appID, nil) + if err != nil { + return "", err + } + req.Header.Set("Authorization", "Bearer "+token) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + if resp.StatusCode >= 400 { + return "", fmt.Errorf("GET /v2/apps/%s: HTTP %d: %s", p.appID, resp.StatusCode, body) + } + + var result doAppResponse + if err := json.Unmarshal(body, &result); err != nil { + return "", err + } + return result.App.LiveURL, nil +} diff --git a/cmd/wfctl/deploy_providers_test.go b/cmd/wfctl/deploy_providers_test.go index f2cabec7..79cb4287 100644 --- a/cmd/wfctl/deploy_providers_test.go +++ b/cmd/wfctl/deploy_providers_test.go @@ -2,6 +2,10 @@ package main import ( "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" "os" "strings" "testing" @@ -379,3 +383,136 @@ func TestDockerProvider_GeneratesAndRemovesComposeFile(t *testing.T) { t.Error("unexpected leftover docker-compose.wfctl.yml") } } + +// ── DigitalOcean provider ───────────────────────────────────────────────────── + +func TestDigitalOceanProvider_NewProvider(t *testing.T) { + for _, name := range []string{"digitalocean", "do"} { + p, err := newDeployProvider(name) + if err != nil { + t.Fatalf("newDeployProvider(%q): unexpected error: %v", name, err) + } + if _, ok := p.(*digitaloceanProvider); !ok { + t.Fatalf("expected *digitaloceanProvider, got %T", p) + } + } +} + +func TestDigitalOceanProvider_MissingToken(t *testing.T) { + t.Setenv("DIGITALOCEAN_TOKEN", "") + p := &digitaloceanProvider{} + err := p.Deploy(context.Background(), DeployConfig{ + AppName: "myapp", + ImageTag: "registry.digitalocean.com/myreg/myapp:sha", + Env: &config.CIDeployEnvironment{Region: "nyc3"}, + }) + if err == nil { + t.Fatal("expected error when DIGITALOCEAN_TOKEN is unset") + } + if !strings.Contains(err.Error(), "DIGITALOCEAN_TOKEN") { + t.Errorf("expected DIGITALOCEAN_TOKEN in error, got: %v", err) + } +} + +func TestDigitalOceanProvider_Deploy_CreatesNewApp(t *testing.T) { + var postBody []byte + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/v2/apps"): + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(doListAppsResponse{Apps: []doApp{}}) + case r.Method == http.MethodPost && r.URL.Path == "/v2/apps": + postBody, _ = io.ReadAll(r.Body) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(doAppResponse{App: doApp{ID: "new-app-1"}}) + default: + http.Error(w, "unexpected: "+r.Method+" "+r.URL.Path, http.StatusInternalServerError) + } + })) + defer srv.Close() + + t.Setenv("DIGITALOCEAN_TOKEN", "test-token") + p := &digitaloceanProvider{baseURL: srv.URL} + cfg := DeployConfig{ + AppName: "myapp", + ImageTag: "registry.digitalocean.com/myreg/myapp:abc123", + Env: &config.CIDeployEnvironment{Region: "nyc3"}, + } + if err := p.Deploy(context.Background(), cfg); err != nil { + t.Fatalf("Deploy: %v", err) + } + if !strings.Contains(string(postBody), "myapp") { + t.Errorf("expected app name in POST body, got: %s", postBody) + } + if p.appID != "new-app-1" { + t.Errorf("expected appID 'new-app-1', got %q", p.appID) + } +} + +func TestDigitalOceanProvider_Deploy_UpdatesExistingApp(t *testing.T) { + var putPath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/v2/apps"): + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(doListAppsResponse{Apps: []doApp{ + {ID: "existing-1", Spec: doAppSpec{Name: "myapp"}}, + }}) + case r.Method == http.MethodPut: + putPath = r.URL.Path + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(doAppResponse{App: doApp{ID: "existing-1"}}) + default: + http.Error(w, "unexpected: "+r.Method+" "+r.URL.Path, http.StatusInternalServerError) + } + })) + defer srv.Close() + + t.Setenv("DIGITALOCEAN_TOKEN", "test-token") + p := &digitaloceanProvider{baseURL: srv.URL} + cfg := DeployConfig{ + AppName: "myapp", + ImageTag: "registry.digitalocean.com/myreg/myapp:newsha", + Env: &config.CIDeployEnvironment{Region: "nyc3"}, + } + if err := p.Deploy(context.Background(), cfg); err != nil { + t.Fatalf("Deploy: %v", err) + } + if putPath != "/v2/apps/existing-1" { + t.Errorf("expected PUT /v2/apps/existing-1, got %s", putPath) + } +} + +func TestDigitalOceanProvider_HealthCheck(t *testing.T) { + // Mock health check endpoint + hcSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer hcSrv.Close() + + // Mock DO API returning the live URL + doSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(doAppResponse{App: doApp{ + ID: "app-hc", + LiveURL: hcSrv.URL, + }}) + })) + defer doSrv.Close() + + t.Setenv("DIGITALOCEAN_TOKEN", "test-token") + p := &digitaloceanProvider{baseURL: doSrv.URL, appID: "app-hc"} + cfg := DeployConfig{ + AppName: "myapp", + Env: &config.CIDeployEnvironment{ + HealthCheck: &config.CIHealthCheck{ + Path: "/", + Timeout: "5s", + }, + }, + } + if err := p.HealthCheck(context.Background(), cfg); err != nil { + t.Fatalf("HealthCheck: %v", err) + } +} From 59d987165e8992a1f5943c78dbb159aded050939 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Mon, 20 Apr 2026 15:30:53 -0400 Subject: [PATCH 2/2] fix(deploy): add HTTP timeout + url.QueryEscape to DO provider (review fixes) - Add injectable *http.Client field to digitaloceanProvider with 2-min default - Replace all http.DefaultClient.Do calls with p.httpClient().Do - Use url.QueryEscape for app name in GET /v2/apps query param - Warn when fetchLiveURL fails in HealthCheck instead of silently falling back - Log warning when deploying multiple services via DO App Platform Co-Authored-By: Claude Sonnet 4.6 --- cmd/wfctl/deploy_providers.go | 31 +++++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/cmd/wfctl/deploy_providers.go b/cmd/wfctl/deploy_providers.go index bae0016d..8b81e19b 100644 --- a/cmd/wfctl/deploy_providers.go +++ b/cmd/wfctl/deploy_providers.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "net/http" + "net/url" "os" "os/exec" "strings" @@ -485,8 +486,16 @@ func cmp(a, b string) string { // ── digitalocean provider ───────────────────────────────────────────────────── type digitaloceanProvider struct { - baseURL string // defaults to "https://api.digitalocean.com"; injectable for testing - appID string // populated after successful Deploy, used by HealthCheck + baseURL string // defaults to "https://api.digitalocean.com"; injectable for testing + appID string // populated after successful Deploy, used by HealthCheck + client *http.Client // injectable for testing; nil uses a 2-minute default +} + +func (p *digitaloceanProvider) httpClient() *http.Client { + if p.client != nil { + return p.client + } + return &http.Client{Timeout: 2 * time.Minute} } // DO App Platform API request/response types (minimal subset). @@ -548,6 +557,10 @@ func (p *digitaloceanProvider) Deploy(ctx context.Context, cfg DeployConfig) err return fmt.Errorf("DIGITALOCEAN_TOKEN is required for DigitalOcean deployments") } + if len(cfg.Services) > 1 { + fmt.Printf(" warning: DO App Platform deploys all services under a single app spec; per-service resource tuning is best-effort\n") + } + spec := p.buildAppSpec(cfg) existingID, err := p.findApp(ctx, token, cfg.AppName) @@ -636,13 +649,13 @@ func parseImageRef(imageTag string) (registry, repository, tag string) { } func (p *digitaloceanProvider) findApp(ctx context.Context, token, name string) (string, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.doBase()+"/v2/apps?name="+name, nil) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.doBase()+"/v2/apps?name="+url.QueryEscape(name), nil) if err != nil { return "", err } req.Header.Set("Authorization", "Bearer "+token) - resp, err := http.DefaultClient.Do(req) + resp, err := p.httpClient().Do(req) if err != nil { return "", fmt.Errorf("GET /v2/apps: %w", err) } @@ -681,7 +694,7 @@ func (p *digitaloceanProvider) createApp(ctx context.Context, token string, spec req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Content-Type", "application/json") - resp, err := http.DefaultClient.Do(req) + resp, err := p.httpClient().Do(req) if err != nil { return "", fmt.Errorf("POST /v2/apps: %w", err) } @@ -715,7 +728,7 @@ func (p *digitaloceanProvider) updateApp(ctx context.Context, token, appID strin req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Content-Type", "application/json") - resp, err := http.DefaultClient.Do(req) + resp, err := p.httpClient().Do(req) if err != nil { return "", fmt.Errorf("PUT /v2/apps/%s: %w", appID, err) } @@ -745,7 +758,9 @@ func (p *digitaloceanProvider) HealthCheck(ctx context.Context, cfg DeployConfig if p.appID != "" { if token := os.Getenv("DIGITALOCEAN_TOKEN"); token != "" { liveURL, err := p.fetchLiveURL(ctx, token) - if err == nil && liveURL != "" { + if err != nil { + fmt.Printf(" warning: could not fetch live_url from DO API: %v — falling back to config health check path\n", err) + } else if liveURL != "" { hcPath := cfg.Env.HealthCheck.Path fullURL := strings.TrimRight(liveURL, "/") + "/" + strings.TrimLeft(hcPath, "/") hcCopy := *cfg.Env.HealthCheck @@ -769,7 +784,7 @@ func (p *digitaloceanProvider) fetchLiveURL(ctx context.Context, token string) ( } req.Header.Set("Authorization", "Bearer "+token) - resp, err := http.DefaultClient.Do(req) + resp, err := p.httpClient().Do(req) if err != nil { return "", err }