diff --git a/internal/drivers/app_platform.go b/internal/drivers/app_platform.go index 3f68b05..89f3ff9 100644 --- a/internal/drivers/app_platform.go +++ b/internal/drivers/app_platform.go @@ -21,6 +21,7 @@ type AppPlatformClient interface { Get(ctx context.Context, appID string) (*godo.App, *godo.Response, error) List(ctx context.Context, opts *godo.ListOptions) ([]*godo.App, *godo.Response, error) Update(ctx context.Context, appID string, req *godo.AppUpdateRequest) (*godo.App, *godo.Response, error) + CreateDeployment(ctx context.Context, appID string, req ...*godo.DeploymentCreateRequest) (*godo.Deployment, *godo.Response, error) Delete(ctx context.Context, appID string) (*godo.Response, error) } @@ -140,6 +141,10 @@ func (d *AppPlatformDriver) Update(ctx context.Context, ref interfaces.ResourceR if err != nil { return nil, fmt.Errorf("app platform update %q: %w", ref.Name, WrapGodoError(err)) } + // Trigger a new deployment — Update only changes the spec; DO does not auto-deploy. + if _, _, err := d.client.CreateDeployment(ctx, ref.ProviderID, &godo.DeploymentCreateRequest{ForceBuild: true}); err != nil { + return nil, fmt.Errorf("app platform create deployment %q: %w", ref.Name, WrapGodoError(err)) + } return appOutput(app), nil } diff --git a/internal/drivers/app_platform_test.go b/internal/drivers/app_platform_test.go index 23826fe..9ff0917 100644 --- a/internal/drivers/app_platform_test.go +++ b/internal/drivers/app_platform_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "net/http" "strings" "testing" @@ -12,14 +13,26 @@ import ( "github.com/digitalocean/godo" ) +// makeGodoErr builds a *godo.ErrorResponse with the given HTTP status code, +// matching what WrapGodoError inspects. +func makeGodoErr(statusCode int) error { + return &godo.ErrorResponse{ + Response: &http.Response{StatusCode: statusCode}, + Message: http.StatusText(statusCode), + } +} + // mockAppClient is a mock implementation of AppPlatformClient. type mockAppClient struct { - app *godo.App - err error - listApps []*godo.App // returned by List - listErr error // error returned by List - lastCreateReq *godo.AppCreateRequest - lastUpdateReq *godo.AppUpdateRequest + app *godo.App + err error + listApps []*godo.App // returned by List + listErr error // error returned by List + createDeploymentErr error // error returned by CreateDeployment + createDeploymentCalled bool + lastCreateDeployReq *godo.DeploymentCreateRequest + lastCreateReq *godo.AppCreateRequest + lastUpdateReq *godo.AppUpdateRequest } func (m *mockAppClient) Create(_ context.Context, req *godo.AppCreateRequest) (*godo.App, *godo.Response, error) { @@ -36,6 +49,13 @@ func (m *mockAppClient) Update(_ context.Context, _ string, req *godo.AppUpdateR m.lastUpdateReq = req return m.app, nil, m.err } +func (m *mockAppClient) CreateDeployment(_ context.Context, _ string, reqs ...*godo.DeploymentCreateRequest) (*godo.Deployment, *godo.Response, error) { + m.createDeploymentCalled = true + for _, r := range reqs { + m.lastCreateDeployReq = r + } + return &godo.Deployment{ID: "dep-1"}, nil, m.createDeploymentErr +} func (m *mockAppClient) Delete(_ context.Context, _ string) (*godo.Response, error) { return nil, m.err } @@ -167,6 +187,66 @@ func TestAppPlatformDriver_Update_Error(t *testing.T) { } } +func TestAppPlatformDriver_Update_TriggersCreateDeployment(t *testing.T) { + mock := &mockAppClient{app: testApp()} + d := drivers.NewAppPlatformDriverWithClient(mock, "nyc3") + + _, err := d.Update(context.Background(), interfaces.ResourceRef{ + Name: "my-app", ProviderID: "app-123", + }, interfaces.ResourceSpec{ + Name: "my-app", + Config: map[string]any{"image": "registry.digitalocean.com/myrepo/myapp:v2"}, + }) + if err != nil { + t.Fatalf("Update: %v", err) + } + if !mock.createDeploymentCalled { + t.Error("expected CreateDeployment to be called after Update") + } + if mock.lastCreateDeployReq == nil || !mock.lastCreateDeployReq.ForceBuild { + t.Error("expected CreateDeployment called with ForceBuild=true") + } +} + +func TestAppPlatformDriver_Update_CreateDeploymentFails(t *testing.T) { + mock := &mockAppClient{ + app: testApp(), + createDeploymentErr: fmt.Errorf("deployment quota exceeded"), + } + d := drivers.NewAppPlatformDriverWithClient(mock, "nyc3") + + _, err := d.Update(context.Background(), interfaces.ResourceRef{ + Name: "my-app", ProviderID: "app-123", + }, interfaces.ResourceSpec{ + Name: "my-app", + Config: map[string]any{"image": "registry.digitalocean.com/myrepo/myapp:v2"}, + }) + if err == nil { + t.Fatal("expected error when CreateDeployment fails, got nil") + } + if !strings.Contains(err.Error(), "deployment quota exceeded") { + t.Errorf("error should contain original message, got: %v", err) + } +} + +func TestAppPlatformDriver_Update_CreateDeploymentSentinelPropagates(t *testing.T) { + mock := &mockAppClient{ + app: testApp(), + createDeploymentErr: makeGodoErr(http.StatusTooManyRequests), + } + d := drivers.NewAppPlatformDriverWithClient(mock, "nyc3") + + _, err := d.Update(context.Background(), interfaces.ResourceRef{ + Name: "my-app", ProviderID: "app-123", + }, interfaces.ResourceSpec{ + Name: "my-app", + Config: map[string]any{"image": "registry.digitalocean.com/myrepo/myapp:v2"}, + }) + if !errors.Is(err, interfaces.ErrRateLimited) { + t.Errorf("expected ErrRateLimited sentinel, got: %v", err) + } +} + func TestAppPlatformDriver_Delete_Error(t *testing.T) { mock := &mockAppClient{err: fmt.Errorf("delete failed")} d := drivers.NewAppPlatformDriverWithClient(mock, "nyc3") diff --git a/internal/drivers/deploy_test.go b/internal/drivers/deploy_test.go index 230b13b..74bcccc 100644 --- a/internal/drivers/deploy_test.go +++ b/internal/drivers/deploy_test.go @@ -76,6 +76,13 @@ func (m *deployMockClient) List(_ context.Context, _ *godo.ListOptions) ([]*godo return apps, &godo.Response{}, nil } +func (m *deployMockClient) CreateDeployment(_ context.Context, _ string, _ ...*godo.DeploymentCreateRequest) (*godo.Deployment, *godo.Response, error) { + if m.err != nil { + return nil, nil, m.err + } + return &godo.Deployment{ID: "dep-deploy"}, nil, nil +} + func (m *deployMockClient) Delete(_ context.Context, appID string) (*godo.Response, error) { if m.err != nil { return nil, m.err