Skip to content

Commit

Permalink
fix: Refresh and destroy on resources with an out of band deletion (#154
Browse files Browse the repository at this point in the history
)

DeploymentSettings, Team and Webhook api client now returns nil on 404
responses so the provider treats it accordingly and recognize them as
deleted resources

Fix #146

<img width="1169" alt="image"
src="https://github.com/pulumi/pulumi-pulumiservice/assets/5647310/9c2b49bf-ceb9-43ee-996c-3766bccc9c4b">

<img width="1162" alt="image"
src="https://github.com/pulumi/pulumi-pulumiservice/assets/5647310/1ad9eb82-b5d1-4072-aec2-7a9c0d3180e5">
  • Loading branch information
glena authored Aug 4, 2023
2 parents dd5e1dd + 9a74b77 commit 7450e23
Show file tree
Hide file tree
Showing 13 changed files with 442 additions and 11 deletions.
65 changes: 65 additions & 0 deletions provider/pkg/internal/pulumiapi/deployment_setting_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package pulumiapi

import (
"net/http"
"path"
"testing"

"github.com/pulumi/pulumi/sdk/v3/go/common/apitype"
"github.com/stretchr/testify/assert"
)

func TestDeploymentSettings(t *testing.T) {

orgName := "an-organization"
projectName := "a-project"
stackName := "a-stack"

t.Run("Happy Path", func(t *testing.T) {
dsValue := DeploymentSettings{
OperationContext: &OperationContext{},
GitHub: &GitHubConfiguration{},
SourceContext: &apitype.SourceContext{},
ExecutorContext: &apitype.ExecutorContext{},
}

c, cleanup := startTestServer(t, testServerConfig{
ExpectedReqMethod: http.MethodGet,
ExpectedReqPath: "/" + path.Join("api", "preview", orgName, projectName, stackName, "deployment", "settings"),
ResponseCode: 200,
ResponseBody: dsValue,
})
defer cleanup()

ds, err := c.GetDeploymentSettings(ctx, StackName{
OrgName: orgName,
ProjectName: projectName,
StackName: stackName,
})

assert.Nil(t, err)
assert.Equal(t, *ds, dsValue)
})

t.Run("404", func(t *testing.T) {
c, cleanup := startTestServer(t, testServerConfig{
ExpectedReqMethod: http.MethodGet,
ExpectedReqPath: "/" + path.Join("api", "preview", orgName, projectName, stackName, "deployment", "settings"),
ResponseCode: 404,
ResponseBody: errorResponse{
StatusCode: 404,
Message: "not found",
},
})
defer cleanup()

ds, err := c.GetDeploymentSettings(ctx, StackName{
OrgName: orgName,
ProjectName: projectName,
StackName: stackName,
})

assert.Nil(t, ds, "deployment settings should be nil since error was returned")
assert.Nil(t, err, "err should be nil since error was returned")
})
}
14 changes: 10 additions & 4 deletions provider/pkg/internal/pulumiapi/deployment_settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,19 @@ package pulumiapi

import (
"context"
"errors"
"fmt"
"net/http"
"path"

"github.com/pulumi/pulumi/sdk/v3/go/common/apitype"
)

type DeploymentSettingsClient interface {
CreateDeploymentSettings(ctx context.Context, stack StackName, ds DeploymentSettings) error
GetDeploymentSettings(ctx context.Context, stack StackName) (*DeploymentSettings, error)
DeleteDeploymentSettings(ctx context.Context, stack StackName) error
}

type DeploymentSettings struct {
OperationContext *OperationContext `json:"operationContext,omitempty"`
GitHub *GitHubConfiguration `json:"gitHub,omitempty"`
Expand Down Expand Up @@ -81,9 +86,10 @@ func (c *Client) GetDeploymentSettings(ctx context.Context, stack StackName) (*D
var ds DeploymentSettings
_, err := c.do(ctx, http.MethodGet, apiPath, nil, &ds)
if err != nil {
var errResp *errorResponse
if errors.As(err, &errResp) && errResp.StatusCode == http.StatusNotFound {
return nil, fmt.Errorf("deployment settings for stack (%s) not found", stack.String())
statusCode := GetErrorStatusCode(err)
if statusCode == http.StatusNotFound {
// Important: we return nil here to hint it was not found
return nil, nil
}
return nil, fmt.Errorf("failed to get deployment settings for stack (%s): %w", stack.String(), err)
}
Expand Down
15 changes: 13 additions & 2 deletions provider/pkg/internal/pulumiapi/error.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
Expand All @@ -13,7 +13,10 @@
// limitations under the License.
package pulumiapi

import "fmt"
import (
"errors"
"fmt"
)

// errorResponse is returned from pulumi service api when there's been an error
type errorResponse struct {
Expand All @@ -24,3 +27,11 @@ type errorResponse struct {
func (err *errorResponse) Error() string {
return fmt.Sprintf("%d API error: %s", err.StatusCode, err.Message)
}

func GetErrorStatusCode(err error) int {
var errResp *errorResponse
if errors.As(err, &errResp) {
return errResp.StatusCode
}
return 0
}
21 changes: 19 additions & 2 deletions provider/pkg/internal/pulumiapi/teams.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,18 @@ import (
"path"
)

type TeamClient interface {
ListTeams(ctx context.Context, orgName string) ([]Team, error)
GetTeam(ctx context.Context, orgName string, teamName string) (*Team, error)
CreateTeam(ctx context.Context, orgName, teamName, teamType, displayName, description string, teamID int64) (*Team, error)
UpdateTeam(ctx context.Context, orgName, teamName, displayName, description string) error
DeleteTeam(ctx context.Context, orgName, teamName string) error
AddMemberToTeam(ctx context.Context, orgName, teamName, userName string) error
DeleteMemberFromTeam(ctx context.Context, orgName, teamName, userName string) error
AddStackPermission(ctx context.Context, stack StackName, teamName string, permission int) error
RemoveStackPermission(ctx context.Context, stack StackName, teamName string) error
}

type Teams struct {
Teams []Team
}
Expand Down Expand Up @@ -107,6 +119,11 @@ func (c *Client) GetTeam(ctx context.Context, orgName string, teamName string) (
var team Team
_, err := c.do(ctx, http.MethodGet, apiPath, nil, &team)
if err != nil {
statusCode := GetErrorStatusCode(err)
if statusCode == http.StatusNotFound {
// Important: we return nil here to hint it was not found
return nil, nil
}
return nil, fmt.Errorf("failed to get team: %w", err)
}

Expand Down Expand Up @@ -227,8 +244,8 @@ func (c *Client) updateTeamMembership(ctx context.Context, orgName, teamName, us
func (c *Client) AddMemberToTeam(ctx context.Context, orgName, teamName, userName string) error {
err := c.updateTeamMembership(ctx, orgName, teamName, userName, "add")
if err != nil {
var errResp *errorResponse
if errors.As(err, &errResp) && errResp.StatusCode == http.StatusConflict {
statusCode := GetErrorStatusCode(err)
if statusCode == http.StatusConflict {
// ignore 409 since that means the team member is already added
return nil
}
Expand Down
16 changes: 16 additions & 0 deletions provider/pkg/internal/pulumiapi/teams_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,22 @@ func TestGetTeam(t *testing.T) {
assert.Nil(t, team, "team should be nil since error was returned")
assert.EqualError(t, err, "failed to get team: 401 API error: unauthorized")
})

t.Run("404", func(t *testing.T) {
c, cleanup := startTestServer(t, testServerConfig{
ExpectedReqMethod: http.MethodGet,
ExpectedReqPath: "/api/orgs/an-organization/teams/a-team",
ResponseCode: 404,
ResponseBody: errorResponse{
StatusCode: 404,
Message: "not found",
},
})
defer cleanup()
team, err := c.GetTeam(ctx, orgName, teamName)
assert.Nil(t, team, "team should be nil since 404 was returned")
assert.Nil(t, err, "err should be nil since 404 was returned")
})
}

func TestCreateTeam(t *testing.T) {
Expand Down
13 changes: 13 additions & 0 deletions provider/pkg/internal/pulumiapi/webhooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,14 @@ import (
"path"
)

type WebhookClient interface {
CreateWebhook(ctx context.Context, req WebhookRequest) (*Webhook, error)
ListWebhooks(ctx context.Context, orgName string, projectName, stackName *string) ([]Webhook, error)
GetWebhook(ctx context.Context, orgName string, projectName, stackName *string, webhookName string) (*Webhook, error)
UpdateWebhook(ctx context.Context, req UpdateWebhookRequest) error
DeleteWebhook(ctx context.Context, orgName string, projectName, stackName *string, name string) error
}

type Webhook struct {
Active bool
DisplayName string
Expand Down Expand Up @@ -121,6 +129,11 @@ func (c *Client) GetWebhook(ctx context.Context,
var webhook Webhook
_, err := c.do(ctx, http.MethodGet, apiPath, nil, &webhook)
if err != nil {
statusCode := GetErrorStatusCode(err)
if statusCode == http.StatusNotFound {
// Important: we return nil here to hint it was not found
return nil, nil
}
return nil, fmt.Errorf("failed to get webhook: %w", err)
}
return &webhook, nil
Expand Down
16 changes: 16 additions & 0 deletions provider/pkg/internal/pulumiapi/webhooks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,22 @@ func TestGetWebhook(t *testing.T) {
assert.Nil(t, actualWebhook, "webhooks should be nil since error was returned")
assert.EqualError(t, err, "failed to get webhook: 401 API error: unauthorized")
})

t.Run("404", func(t *testing.T) {
c, cleanup := startTestServer(t, testServerConfig{
ExpectedReqMethod: http.MethodGet,
ExpectedReqPath: "/api/orgs/an-organization/hooks/a-webhook",
ResponseCode: 404,
ResponseBody: errorResponse{
StatusCode: 404,
Message: "not found",
},
})
defer cleanup()
actualWebhook, err := c.GetWebhook(ctx, orgName, nil, nil, webhookName)
assert.Nil(t, actualWebhook, "webhook should be nil since error was returned")
assert.Nil(t, err, "err should be nil since error was returned")
})
}

func TestUpdateWebhook(t *testing.T) {
Expand Down
83 changes: 83 additions & 0 deletions provider/pkg/provider/deployment_setting_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package provider

import (
"context"
"testing"

"github.com/pulumi/pulumi-pulumiservice/provider/pkg/internal/pulumiapi"
"github.com/pulumi/pulumi/sdk/v3/go/common/apitype"
pulumirpc "github.com/pulumi/pulumi/sdk/v3/proto/go"
"github.com/stretchr/testify/assert"
)

type getDeploymentSettingsFunc func() (*pulumiapi.DeploymentSettings, error)

type DeploymentSettingsClientMock struct {
getDeploymentSettingsFunc getDeploymentSettingsFunc
}

func (c *DeploymentSettingsClientMock) CreateDeploymentSettings(ctx context.Context, stack pulumiapi.StackName, ds pulumiapi.DeploymentSettings) error {
return nil
}
func (c *DeploymentSettingsClientMock) GetDeploymentSettings(ctx context.Context, stack pulumiapi.StackName) (*pulumiapi.DeploymentSettings, error) {
return c.getDeploymentSettingsFunc()
}
func (c *DeploymentSettingsClientMock) DeleteDeploymentSettings(ctx context.Context, stack pulumiapi.StackName) error {
return nil
}

func buildDeploymentSettingsClientMock(getDeploymentSettingsFunc getDeploymentSettingsFunc) *DeploymentSettingsClientMock {
return &DeploymentSettingsClientMock{
getDeploymentSettingsFunc,
}
}

func TestDeploymentSettings(t *testing.T) {
t.Run("Read when the resource is not found", func(t *testing.T) {
mockedClient := buildDeploymentSettingsClientMock(
func() (*pulumiapi.DeploymentSettings, error) { return nil, nil },
)

provider := PulumiServiceDeploymentSettingsResource{
client: mockedClient,
}

req := pulumirpc.ReadRequest{
Id: "abc/def/123",
Urn: "urn:123",
}

resp, err := provider.Read(&req)

assert.NoError(t, err)
assert.Equal(t, resp.Id, "")
assert.Nil(t, resp.Properties)
})

t.Run("Read when the resource is found", func(t *testing.T) {
mockedClient := buildDeploymentSettingsClientMock(
func() (*pulumiapi.DeploymentSettings, error) {
return &pulumiapi.DeploymentSettings{
OperationContext: &pulumiapi.OperationContext{},
GitHub: &pulumiapi.GitHubConfiguration{},
SourceContext: &apitype.SourceContext{},
ExecutorContext: &apitype.ExecutorContext{},
}, nil
},
)

provider := PulumiServiceDeploymentSettingsResource{
client: mockedClient,
}

req := pulumirpc.ReadRequest{
Id: "abc/def/123",
Urn: "urn:123",
}

resp, err := provider.Read(&req)

assert.NoError(t, err)
assert.Equal(t, resp.Id, "abc/def/123")
})
}
7 changes: 6 additions & 1 deletion provider/pkg/provider/deployment_settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ func (ds *PulumiServiceDeploymentSettingsInput) ToPropertyMap() resource.Propert
}

type PulumiServiceDeploymentSettingsResource struct {
client *pulumiapi.Client
client pulumiapi.DeploymentSettingsClient
}

func (ds *PulumiServiceDeploymentSettingsResource) ToPulumiServiceDeploymentSettingsInput(inputMap resource.PropertyMap) PulumiServiceDeploymentSettingsInput {
Expand Down Expand Up @@ -532,6 +532,11 @@ func (ds *PulumiServiceDeploymentSettingsResource) Read(req *pulumirpc.ReadReque
return nil, err
}

if settings == nil {
// Empty response causes the resource to be deleted from the state.
return &pulumirpc.ReadResponse{Id: "", Properties: nil}, nil
}

dsInput := PulumiServiceDeploymentSettingsInput{
Stack: stack,
DeploymentSettings: *settings,
Expand Down
2 changes: 1 addition & 1 deletion provider/pkg/provider/team.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import (

type PulumiServiceTeamResource struct {
config PulumiServiceConfig
client *pulumiapi.Client
client pulumiapi.TeamClient
}

type PulumiServiceTeamStackPermission struct {
Expand Down
Loading

0 comments on commit 7450e23

Please sign in to comment.