|
| 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