diff --git a/provider/pkg/internal/pulumiapi/deployment_setting_test.go b/provider/pkg/internal/pulumiapi/deployment_setting_test.go new file mode 100644 index 00000000..914ca3a5 --- /dev/null +++ b/provider/pkg/internal/pulumiapi/deployment_setting_test.go @@ -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") + }) +} diff --git a/provider/pkg/internal/pulumiapi/deployment_settings.go b/provider/pkg/internal/pulumiapi/deployment_settings.go index f097cb7f..cd97d17c 100644 --- a/provider/pkg/internal/pulumiapi/deployment_settings.go +++ b/provider/pkg/internal/pulumiapi/deployment_settings.go @@ -2,7 +2,6 @@ package pulumiapi import ( "context" - "errors" "fmt" "net/http" "path" @@ -10,6 +9,12 @@ import ( "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"` @@ -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) } diff --git a/provider/pkg/internal/pulumiapi/error.go b/provider/pkg/internal/pulumiapi/error.go index 9bb051fc..15c32ef9 100644 --- a/provider/pkg/internal/pulumiapi/error.go +++ b/provider/pkg/internal/pulumiapi/error.go @@ -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, @@ -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 { @@ -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 +} diff --git a/provider/pkg/internal/pulumiapi/teams.go b/provider/pkg/internal/pulumiapi/teams.go index 8ab22ec1..8ecfe6ed 100644 --- a/provider/pkg/internal/pulumiapi/teams.go +++ b/provider/pkg/internal/pulumiapi/teams.go @@ -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 } @@ -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) } @@ -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 } diff --git a/provider/pkg/internal/pulumiapi/teams_test.go b/provider/pkg/internal/pulumiapi/teams_test.go index 0a937f35..81bc39c9 100644 --- a/provider/pkg/internal/pulumiapi/teams_test.go +++ b/provider/pkg/internal/pulumiapi/teams_test.go @@ -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) { diff --git a/provider/pkg/internal/pulumiapi/webhooks.go b/provider/pkg/internal/pulumiapi/webhooks.go index bbd6b45b..c4b09493 100644 --- a/provider/pkg/internal/pulumiapi/webhooks.go +++ b/provider/pkg/internal/pulumiapi/webhooks.go @@ -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 @@ -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 diff --git a/provider/pkg/internal/pulumiapi/webhooks_test.go b/provider/pkg/internal/pulumiapi/webhooks_test.go index bc3674c9..e342bada 100644 --- a/provider/pkg/internal/pulumiapi/webhooks_test.go +++ b/provider/pkg/internal/pulumiapi/webhooks_test.go @@ -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) { diff --git a/provider/pkg/provider/deployment_setting_test.go b/provider/pkg/provider/deployment_setting_test.go new file mode 100644 index 00000000..64df8c59 --- /dev/null +++ b/provider/pkg/provider/deployment_setting_test.go @@ -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") + }) +} diff --git a/provider/pkg/provider/deployment_settings.go b/provider/pkg/provider/deployment_settings.go index 9cf1852a..d1a4be62 100644 --- a/provider/pkg/provider/deployment_settings.go +++ b/provider/pkg/provider/deployment_settings.go @@ -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 { @@ -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, diff --git a/provider/pkg/provider/team.go b/provider/pkg/provider/team.go index c701905e..02d9d57e 100644 --- a/provider/pkg/provider/team.go +++ b/provider/pkg/provider/team.go @@ -19,7 +19,7 @@ import ( type PulumiServiceTeamResource struct { config PulumiServiceConfig - client *pulumiapi.Client + client pulumiapi.TeamClient } type PulumiServiceTeamStackPermission struct { diff --git a/provider/pkg/provider/team_test.go b/provider/pkg/provider/team_test.go new file mode 100644 index 00000000..d03d0e49 --- /dev/null +++ b/provider/pkg/provider/team_test.go @@ -0,0 +1,103 @@ +package provider + +import ( + "context" + "testing" + + "github.com/pulumi/pulumi-pulumiservice/provider/pkg/internal/pulumiapi" + pulumirpc "github.com/pulumi/pulumi/sdk/v3/proto/go" + "github.com/stretchr/testify/assert" +) + +type getTeamFunc func() (*pulumiapi.Team, error) + +type TeamClientMock struct { + getTeamFunc getTeamFunc +} + +func (c *TeamClientMock) GetTeam(ctx context.Context, orgName string, teamName string) (*pulumiapi.Team, error) { + return c.getTeamFunc() +} + +func (c *TeamClientMock) ListTeams(ctx context.Context, orgName string) ([]pulumiapi.Team, error) { + return nil, nil +} +func (c *TeamClientMock) CreateTeam(ctx context.Context, orgName, teamName, teamType, displayName, description string, teamID int64) (*pulumiapi.Team, error) { + return nil, nil +} +func (c *TeamClientMock) UpdateTeam(ctx context.Context, orgName, teamName, displayName, description string) error { + return nil +} +func (c *TeamClientMock) DeleteTeam(ctx context.Context, orgName, teamName string) error { return nil } +func (c *TeamClientMock) AddMemberToTeam(ctx context.Context, orgName, teamName, userName string) error { + return nil +} +func (c *TeamClientMock) DeleteMemberFromTeam(ctx context.Context, orgName, teamName, userName string) error { + return nil +} +func (c *TeamClientMock) AddStackPermission(ctx context.Context, stack pulumiapi.StackName, teamName string, permission int) error { + return nil +} +func (c *TeamClientMock) RemoveStackPermission(ctx context.Context, stack pulumiapi.StackName, teamName string) error { + return nil +} + +func buildTeamClientMock(getTeamFunc getTeamFunc) *TeamClientMock { + return &TeamClientMock{ + getTeamFunc, + } +} + +func TestTeam(t *testing.T) { + t.Run("Read when the resource is not found", func(t *testing.T) { + mockedClient := buildTeamClientMock( + func() (*pulumiapi.Team, error) { return nil, nil }, + ) + + provider := PulumiServiceTeamResource{ + client: mockedClient, + } + + req := pulumirpc.ReadRequest{ + Id: "abc/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 := buildTeamClientMock( + func() (*pulumiapi.Team, error) { + return &pulumiapi.Team{ + Type: "pulumi", + Name: "test", + DisplayName: "test team", + Description: "test team description", + Members: []pulumiapi.TeamMember{ + {Name: "member1"}, + {Name: "member2"}, + }, + }, nil + }, + ) + + provider := PulumiServiceTeamResource{ + client: mockedClient, + } + + req := pulumirpc.ReadRequest{ + Id: "abc/123", + Urn: "urn:123", + } + + resp, err := provider.Read(&req) + + assert.NoError(t, err) + assert.Equal(t, resp.Id, "abc/123") + }) +} diff --git a/provider/pkg/provider/webhook.go b/provider/pkg/provider/webhook.go index dd55f353..484a39ce 100644 --- a/provider/pkg/provider/webhook.go +++ b/provider/pkg/provider/webhook.go @@ -15,7 +15,7 @@ import ( type PulumiServiceWebhookResource struct { config PulumiServiceConfig - client *pulumiapi.Client + client pulumiapi.WebhookClient } type PulumiServiceWebhookInput struct { @@ -376,6 +376,10 @@ func (wh *PulumiServiceWebhookResource) Read(req *pulumirpc.ReadRequest) (*pulum return nil, err } + if webhook == nil { + return &pulumirpc.ReadResponse{}, nil + } + hookID, err := splitWebhookID(req.Id) if err != nil { return nil, err diff --git a/provider/pkg/provider/webhook_test.go b/provider/pkg/provider/webhook_test.go new file mode 100644 index 00000000..2923724e --- /dev/null +++ b/provider/pkg/provider/webhook_test.go @@ -0,0 +1,92 @@ +package provider + +import ( + "context" + "testing" + + "github.com/pulumi/pulumi-pulumiservice/provider/pkg/internal/pulumiapi" + pulumirpc "github.com/pulumi/pulumi/sdk/v3/proto/go" + "github.com/stretchr/testify/assert" +) + +type getWebhookFunc func() (*pulumiapi.Webhook, error) + +type WebhookClientMock struct { + getWebhookFunc getWebhookFunc +} + +func (c *WebhookClientMock) GetWebhook(ctx context.Context, orgName string, projectName, stackName *string, webhookName string) (*pulumiapi.Webhook, error) { + return c.getWebhookFunc() +} + +func (c *WebhookClientMock) CreateWebhook(ctx context.Context, req pulumiapi.WebhookRequest) (*pulumiapi.Webhook, error) { + return nil, nil +} + +func (c *WebhookClientMock) ListWebhooks(ctx context.Context, orgName string, projectName, stackName *string) ([]pulumiapi.Webhook, error) { + return nil, nil +} + +func (c *WebhookClientMock) UpdateWebhook(ctx context.Context, req pulumiapi.UpdateWebhookRequest) error { + return nil +} + +func (c *WebhookClientMock) DeleteWebhook(ctx context.Context, orgName string, projectName, stackName *string, name string) error { + return nil +} + +func buildWebhookClientMock(getWebhookFunc getWebhookFunc) *WebhookClientMock { + return &WebhookClientMock{ + getWebhookFunc, + } +} + +func TestWebhook(t *testing.T) { + t.Run("Read when the resource is not found", func(t *testing.T) { + mockedClient := buildWebhookClientMock( + func() (*pulumiapi.Webhook, error) { return nil, nil }, + ) + + provider := PulumiServiceWebhookResource{ + client: mockedClient, + } + + req := pulumirpc.ReadRequest{ + Id: "abc/def/ghi/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 := buildWebhookClientMock( + func() (*pulumiapi.Webhook, error) { + return &pulumiapi.Webhook{ + Active: true, + DisplayName: "test webhook", + PayloadUrl: "https://example.com/webhook", + Name: "test-webhook", + }, nil + }, + ) + + provider := PulumiServiceWebhookResource{ + client: mockedClient, + } + + req := pulumirpc.ReadRequest{ + Id: "abc/def/ghi/123", + Urn: "urn:123", + } + + resp, err := provider.Read(&req) + + assert.NoError(t, err) + assert.Equal(t, resp.Id, "abc/def/ghi/123") + }) +}