Skip to content

Commit 85012b3

Browse files
intel352claude
andcommitted
feat: add App Platform deployment drivers
Implements DeployDriver, BlueGreenDriver, and CanaryDriver for DigitalOcean App Platform. Blue/green creates a temporary green app clone and promotes via image update. CanaryDriver returns a clear unsupported error for RoutePercent, directing users to DO Load Balancer + Droplets for canary traffic splitting. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent a83714e commit 85012b3

2 files changed

Lines changed: 669 additions & 0 deletions

File tree

internal/drivers/deploy.go

Lines changed: 304 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,304 @@
1+
package drivers
2+
3+
import (
4+
"context"
5+
"fmt"
6+
7+
"github.com/digitalocean/godo"
8+
)
9+
10+
// ─── AppDeployDriver ──────────────────────────────────────────────────────────
11+
12+
// AppDeployDriver implements module.DeployDriver for DigitalOcean App Platform.
13+
// It manages a single App Platform application identified by its app ID.
14+
type AppDeployDriver struct {
15+
client AppPlatformClient
16+
region string
17+
appID string
18+
appName string
19+
}
20+
21+
// NewAppDeployDriver creates a DeployDriver backed by a DO App Platform app.
22+
func NewAppDeployDriver(c AppPlatformClient, region, appID, appName string) *AppDeployDriver {
23+
return &AppDeployDriver{client: c, region: region, appID: appID, appName: appName}
24+
}
25+
26+
func (d *AppDeployDriver) Update(ctx context.Context, image string) error {
27+
app, _, err := d.client.Get(ctx, d.appID)
28+
if err != nil {
29+
return fmt.Errorf("app deploy: get %q: %w", d.appName, err)
30+
}
31+
spec := app.Spec
32+
for _, svc := range spec.Services {
33+
if svc.Image != nil {
34+
svc.Image.Repository = imageRepo(image)
35+
svc.Image.Tag = imageTag(image)
36+
}
37+
}
38+
if _, _, err := d.client.Update(ctx, d.appID, &godo.AppUpdateRequest{Spec: spec}); err != nil {
39+
return fmt.Errorf("app deploy: update %q: %w", d.appName, err)
40+
}
41+
return nil
42+
}
43+
44+
func (d *AppDeployDriver) HealthCheck(ctx context.Context, _ string) error {
45+
app, _, err := d.client.Get(ctx, d.appID)
46+
if err != nil {
47+
return fmt.Errorf("app deploy: health check %q: %w", d.appName, err)
48+
}
49+
if app.ActiveDeployment == nil || app.ActiveDeployment.Phase != godo.DeploymentPhase_Active {
50+
phase := ""
51+
if app.ActiveDeployment != nil {
52+
phase = string(app.ActiveDeployment.Phase)
53+
}
54+
return fmt.Errorf("app deploy: %q not active (phase: %q)", d.appName, phase)
55+
}
56+
return nil
57+
}
58+
59+
func (d *AppDeployDriver) CurrentImage(ctx context.Context) (string, error) {
60+
app, _, err := d.client.Get(ctx, d.appID)
61+
if err != nil {
62+
return "", fmt.Errorf("app deploy: current image %q: %w", d.appName, err)
63+
}
64+
if app.Spec == nil || len(app.Spec.Services) == 0 {
65+
return "", fmt.Errorf("app deploy: no services in %q", d.appName)
66+
}
67+
svc := app.Spec.Services[0]
68+
if svc.Image == nil {
69+
return "", fmt.Errorf("app deploy: service in %q has no image spec", d.appName)
70+
}
71+
return svc.Image.Repository + ":" + svc.Image.Tag, nil
72+
}
73+
74+
func (d *AppDeployDriver) ReplicaCount(ctx context.Context) (int, error) {
75+
app, _, err := d.client.Get(ctx, d.appID)
76+
if err != nil {
77+
return 0, fmt.Errorf("app deploy: replica count %q: %w", d.appName, err)
78+
}
79+
if app.Spec == nil || len(app.Spec.Services) == 0 {
80+
return 1, nil
81+
}
82+
return int(app.Spec.Services[0].InstanceCount), nil
83+
}
84+
85+
// ─── AppBlueGreenDriver ───────────────────────────────────────────────────────
86+
87+
// AppBlueGreenDriver implements module.BlueGreenDriver for DigitalOcean App Platform.
88+
//
89+
// Blue environment: the existing app identified by blueID.
90+
// Green environment: a new app created with the "-green" name suffix.
91+
//
92+
// SwitchTraffic is implemented by updating the blue app's spec with the green
93+
// image (making blue the new stable), then DestroyBlue removes the green clone.
94+
// The green app's live URL is returned from GreenEndpoint.
95+
type AppBlueGreenDriver struct {
96+
client AppPlatformClient
97+
region string
98+
blueID string
99+
blueName string
100+
greenID string // set during CreateGreen
101+
greenURL string // set during CreateGreen
102+
}
103+
104+
// NewAppBlueGreenDriver creates a BlueGreenDriver for DO App Platform.
105+
func NewAppBlueGreenDriver(c AppPlatformClient, region, blueID, blueName string) *AppBlueGreenDriver {
106+
return &AppBlueGreenDriver{client: c, region: region, blueID: blueID, blueName: blueName}
107+
}
108+
109+
// DeployDriver methods delegate to the blue (stable) app.
110+
111+
func (d *AppBlueGreenDriver) Update(ctx context.Context, image string) error {
112+
stable := NewAppDeployDriver(d.client, d.region, d.blueID, d.blueName)
113+
return stable.Update(ctx, image)
114+
}
115+
116+
func (d *AppBlueGreenDriver) HealthCheck(ctx context.Context, path string) error {
117+
target := d.greenID
118+
name := d.blueName + "-green"
119+
if target == "" {
120+
target = d.blueID
121+
name = d.blueName
122+
}
123+
drv := NewAppDeployDriver(d.client, d.region, target, name)
124+
return drv.HealthCheck(ctx, path)
125+
}
126+
127+
func (d *AppBlueGreenDriver) CurrentImage(ctx context.Context) (string, error) {
128+
stable := NewAppDeployDriver(d.client, d.region, d.blueID, d.blueName)
129+
return stable.CurrentImage(ctx)
130+
}
131+
132+
func (d *AppBlueGreenDriver) ReplicaCount(ctx context.Context) (int, error) {
133+
stable := NewAppDeployDriver(d.client, d.region, d.blueID, d.blueName)
134+
return stable.ReplicaCount(ctx)
135+
}
136+
137+
// CreateGreen creates a new App Platform app with the "-green" name suffix and
138+
// the given image, recording the green app ID and live URL for later use.
139+
func (d *AppBlueGreenDriver) CreateGreen(ctx context.Context, image string) error {
140+
blueApp, _, err := d.client.Get(ctx, d.blueID)
141+
if err != nil {
142+
return fmt.Errorf("app blue-green: get blue %q: %w", d.blueName, err)
143+
}
144+
145+
greenSpec := blueApp.Spec
146+
greenSpec.Name = d.blueName + "-green"
147+
for _, svc := range greenSpec.Services {
148+
if svc.Image != nil {
149+
svc.Image.Repository = imageRepo(image)
150+
svc.Image.Tag = imageTag(image)
151+
}
152+
}
153+
154+
greenApp, _, err := d.client.Create(ctx, &godo.AppCreateRequest{Spec: greenSpec})
155+
if err != nil {
156+
return fmt.Errorf("app blue-green: create green: %w", err)
157+
}
158+
d.greenID = greenApp.ID
159+
d.greenURL = greenApp.LiveURL
160+
return nil
161+
}
162+
163+
// SwitchTraffic updates the blue app spec to use the green image, effectively
164+
// promoting the green version as the stable app. DO App Platform does not
165+
// support weighted traffic splitting natively; this performs a full cutover.
166+
func (d *AppBlueGreenDriver) SwitchTraffic(ctx context.Context) error {
167+
if d.greenID == "" {
168+
return fmt.Errorf("app blue-green: CreateGreen must be called before SwitchTraffic")
169+
}
170+
greenImg, err := NewAppDeployDriver(d.client, d.region, d.greenID, d.blueName+"-green").CurrentImage(ctx)
171+
if err != nil {
172+
return fmt.Errorf("app blue-green: get green image: %w", err)
173+
}
174+
return NewAppDeployDriver(d.client, d.region, d.blueID, d.blueName).Update(ctx, greenImg)
175+
}
176+
177+
// DestroyBlue deletes the green clone (the temporary environment).
178+
func (d *AppBlueGreenDriver) DestroyBlue(ctx context.Context) error {
179+
if d.greenID == "" {
180+
return fmt.Errorf("app blue-green: no green app to destroy")
181+
}
182+
if _, err := d.client.Delete(ctx, d.greenID); err != nil {
183+
return fmt.Errorf("app blue-green: destroy green clone: %w", err)
184+
}
185+
return nil
186+
}
187+
188+
// GreenEndpoint returns the live URL of the green App Platform app.
189+
func (d *AppBlueGreenDriver) GreenEndpoint(_ context.Context) (string, error) {
190+
if d.greenURL == "" {
191+
return "", fmt.Errorf("app blue-green: green endpoint not available (CreateGreen not called)")
192+
}
193+
return d.greenURL, nil
194+
}
195+
196+
// ─── AppCanaryDriver ──────────────────────────────────────────────────────────
197+
198+
// AppCanaryDriver implements module.CanaryDriver for DigitalOcean App Platform.
199+
//
200+
// DigitalOcean App Platform does not support native traffic splitting between
201+
// app instances. RoutePercent returns a clear unsupported error directing users
202+
// to DigitalOcean Load Balancer + Droplets for canary deployments.
203+
//
204+
// CreateCanary, PromoteCanary, and DestroyCanary follow the standard
205+
// create/promote/delete pattern using two separate apps.
206+
type AppCanaryDriver struct {
207+
client AppPlatformClient
208+
region string
209+
stableID string
210+
stableName string
211+
canaryID string // set during CreateCanary
212+
}
213+
214+
// NewAppCanaryDriver creates a CanaryDriver for DO App Platform.
215+
func NewAppCanaryDriver(c AppPlatformClient, region, stableID, stableName string) *AppCanaryDriver {
216+
return &AppCanaryDriver{client: c, region: region, stableID: stableID, stableName: stableName}
217+
}
218+
219+
// DeployDriver methods delegate to the stable app.
220+
221+
func (d *AppCanaryDriver) Update(ctx context.Context, image string) error {
222+
return NewAppDeployDriver(d.client, d.region, d.stableID, d.stableName).Update(ctx, image)
223+
}
224+
225+
func (d *AppCanaryDriver) HealthCheck(ctx context.Context, path string) error {
226+
target, name := d.stableID, d.stableName
227+
if d.canaryID != "" {
228+
target = d.canaryID
229+
name = d.stableName + "-canary"
230+
}
231+
return NewAppDeployDriver(d.client, d.region, target, name).HealthCheck(ctx, path)
232+
}
233+
234+
func (d *AppCanaryDriver) CurrentImage(ctx context.Context) (string, error) {
235+
return NewAppDeployDriver(d.client, d.region, d.stableID, d.stableName).CurrentImage(ctx)
236+
}
237+
238+
func (d *AppCanaryDriver) ReplicaCount(ctx context.Context) (int, error) {
239+
return NewAppDeployDriver(d.client, d.region, d.stableID, d.stableName).ReplicaCount(ctx)
240+
}
241+
242+
// CreateCanary creates a new App Platform app with the "-canary" name suffix
243+
// and the given image.
244+
func (d *AppCanaryDriver) CreateCanary(ctx context.Context, image string) error {
245+
stableApp, _, err := d.client.Get(ctx, d.stableID)
246+
if err != nil {
247+
return fmt.Errorf("app canary: get stable %q: %w", d.stableName, err)
248+
}
249+
250+
canarySpec := stableApp.Spec
251+
canarySpec.Name = d.stableName + "-canary"
252+
for _, svc := range canarySpec.Services {
253+
if svc.Image != nil {
254+
svc.Image.Repository = imageRepo(image)
255+
svc.Image.Tag = imageTag(image)
256+
}
257+
}
258+
259+
canaryApp, _, err := d.client.Create(ctx, &godo.AppCreateRequest{Spec: canarySpec})
260+
if err != nil {
261+
return fmt.Errorf("app canary: create canary: %w", err)
262+
}
263+
d.canaryID = canaryApp.ID
264+
return nil
265+
}
266+
267+
// RoutePercent is not supported by DigitalOcean App Platform. Use
268+
// DigitalOcean Load Balancer with Droplets for canary traffic splitting.
269+
func (d *AppCanaryDriver) RoutePercent(_ context.Context, percent int) error {
270+
return fmt.Errorf("app canary: RoutePercent(%d) unsupported — DigitalOcean App Platform does not "+
271+
"support traffic splitting; use DigitalOcean Load Balancer + Droplets for canary deployments", percent)
272+
}
273+
274+
// CheckMetricGate always passes (no native metric integration).
275+
func (d *AppCanaryDriver) CheckMetricGate(_ context.Context, _ string) error {
276+
return nil
277+
}
278+
279+
// PromoteCanary updates the stable app with the canary image and deletes the canary.
280+
func (d *AppCanaryDriver) PromoteCanary(ctx context.Context) error {
281+
if d.canaryID == "" {
282+
return fmt.Errorf("app canary: CreateCanary must be called before PromoteCanary")
283+
}
284+
canaryImg, err := NewAppDeployDriver(d.client, d.region, d.canaryID, d.stableName+"-canary").CurrentImage(ctx)
285+
if err != nil {
286+
return fmt.Errorf("app canary: get canary image: %w", err)
287+
}
288+
if err := NewAppDeployDriver(d.client, d.region, d.stableID, d.stableName).Update(ctx, canaryImg); err != nil {
289+
return fmt.Errorf("app canary: promote to stable: %w", err)
290+
}
291+
return d.DestroyCanary(ctx)
292+
}
293+
294+
// DestroyCanary deletes the canary App Platform app.
295+
func (d *AppCanaryDriver) DestroyCanary(ctx context.Context) error {
296+
if d.canaryID == "" {
297+
return fmt.Errorf("app canary: no canary app to destroy")
298+
}
299+
if _, err := d.client.Delete(ctx, d.canaryID); err != nil {
300+
return fmt.Errorf("app canary: destroy canary: %w", err)
301+
}
302+
d.canaryID = ""
303+
return nil
304+
}

0 commit comments

Comments
 (0)