Skip to content

Commit

Permalink
feat: add azure devops servicehook webhook resource
Browse files Browse the repository at this point in the history
  • Loading branch information
iswym committed Oct 8, 2021
1 parent b00f784 commit 4b8fe82
Show file tree
Hide file tree
Showing 20 changed files with 3,393 additions and 32 deletions.
7 changes: 7 additions & 0 deletions azuredevops/internal/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"github.com/microsoft/azure-devops-go-api/azuredevops/release"
"github.com/microsoft/azure-devops-go-api/azuredevops/security"
"github.com/microsoft/azure-devops-go-api/azuredevops/serviceendpoint"
"github.com/microsoft/azure-devops-go-api/azuredevops/servicehooks"
"github.com/microsoft/azure-devops-go-api/azuredevops/taskagent"
"github.com/microsoft/azure-devops-go-api/azuredevops/workitemtracking"
"github.com/microsoft/terraform-provider-azuredevops/version"
Expand All @@ -38,6 +39,7 @@ type AggregatedClient struct {
BuildClient build.Client
GitReposClient git.Client
GraphClient graph.Client
ServiceHooksClient servicehooks.Client
OperationsClient operations.Client
PolicyClient policy.Client
ReleaseClient release.Client
Expand Down Expand Up @@ -94,6 +96,10 @@ func GetAzdoClient(azdoPAT string, organizationURL string, tfVersion string) (*A
return nil, err
}

// client for these APIs (includes CRUD for AzDO service hooks a.k.a.):
// https://docs.microsoft.com/en-us/rest/api/azure/devops/hooks/?view=azure-devops-rest-5.1
serviceHooksClient := servicehooks.NewClient(ctx, connection)

// client for these APIs (includes CRUD for AzDO variable groups):
taskagentClient, err := taskagent.NewClient(ctx, connection)
if err != nil {
Expand Down Expand Up @@ -162,6 +168,7 @@ func GetAzdoClient(azdoPAT string, organizationURL string, tfVersion string) (*A
PolicyClient: policyClient,
ReleaseClient: releaseClient,
ServiceEndpointClient: serviceEndpointClient,
ServiceHooksClient: serviceHooksClient,
TaskAgentClient: taskagentClient,
MemberEntitleManagementClient: memberentitlementmanagementClient,
FeatureManagementClient: featuremanagementClient,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,251 @@
package servicehooks

import (
"bufio"
"fmt"
"net/http"
"strings"

"github.com/hashicorp/terraform-plugin-sdk/helper/schema"
"github.com/hashicorp/terraform-plugin-sdk/helper/validation"
"github.com/microsoft/azure-devops-go-api/azuredevops/servicehooks"
"github.com/microsoft/terraform-provider-azuredevops/azuredevops/internal/client"
"github.com/microsoft/terraform-provider-azuredevops/azuredevops/internal/utils"
"github.com/microsoft/terraform-provider-azuredevops/azuredevops/internal/utils/converter"
"github.com/microsoft/terraform-provider-azuredevops/azuredevops/internal/utils/tfhelper"
)

func ResourceServiceHookWebhook() *schema.Resource {
return &schema.Resource{
Create: resourceWebhookCreate,
Read: createResourceWebhookRead(false),
Update: resourceWebhookUpdate,
Delete: resourceWebhookDelete,
Importer: tfhelper.ImportProjectQualifiedResourceUUID(),
Schema: map[string]*schema.Schema{
"project_id": {
Type: schema.TypeString,
Required: true,
ForceNew: true,
ValidateFunc: validation.IsUUID,
},
"url": {
Type: schema.TypeString,
Optional: true,
ValidateFunc: validation.IsURLWithHTTPS,
},
"event_type": {
Type: schema.TypeString,
Required: true,
ValidateFunc: validation.StringIsNotWhiteSpace,
},
"basic_auth": {
Type: schema.TypeList,
Optional: true,
MinItems: 1,
MaxItems: 1,
Elem: &schema.Resource{
Schema: map[string]*schema.Schema{
"username": {
Type: schema.TypeString,
Optional: true,
Default: nil,
ValidateFunc: validation.StringIsNotWhiteSpace,
},
"password": {
Type: schema.TypeString,
Optional: true,
ValidateFunc: validation.StringIsNotWhiteSpace,
Sensitive: true,
},
},
},
},
"updated_at": {
Type: schema.TypeString,
Computed: true,
ForceNew: true,
},
"http_headers": {
Type: schema.TypeMap,
Optional: true,
Elem: &schema.Schema{
Type: schema.TypeString,
},
},
"filters": {
Type: schema.TypeMap,
Optional: true,
Elem: &schema.Schema{
Type: schema.TypeString,
},
},
},
}
}

func resourceWebhookCreate(d *schema.ResourceData, m interface{}) error {
clients := m.(*client.AggregatedClient)

subscriptionData := getSubscription(d)

subscription, err := clients.ServiceHooksClient.CreateSubscription(clients.Ctx, servicehooks.CreateSubscriptionArgs{
Subscription: &subscriptionData,
})

if err != nil {
return err
}

d.SetId(subscription.Id.String())

return createResourceWebhookRead(true)(d, m)
}

func createResourceWebhookRead(afterCreateOrUpdate bool) func(d *schema.ResourceData, m interface{}) error {
return func(d *schema.ResourceData, m interface{}) error {
clients := m.(*client.AggregatedClient)

subscriptionId := d.Id()

subscription, err := clients.ServiceHooksClient.GetSubscription(clients.Ctx, servicehooks.GetSubscriptionArgs{
SubscriptionId: converter.UUID(subscriptionId),
})

if err != nil {
if utils.ResponseWasNotFound(err) {
d.SetId("")
return nil
}
return err
}

d.Set("project_id", (*subscription.PublisherInputs)["projectId"])
d.Set("url", (*subscription.ConsumerInputs)["url"])
d.Set("event_type", *subscription.EventType)

oldUpdatedAt := d.Get("updated_at")
newUpdatedAt := subscription.ModifiedDate.String()
d.Set("updated_at", newUpdatedAt)

if basicAuthList, ok := d.GetOk("basic_auth"); ok {
basicAuth := basicAuthList.([]interface{})[0].(map[string]interface{})

if username, ok := (*subscription.ConsumerInputs)["basicAuthUsername"]; ok {
basicAuth["username"] = username
}

if password, ok := basicAuth["password"]; ok && !afterCreateOrUpdate && oldUpdatedAt != newUpdatedAt {
// note: condition above means someone modified webhook subscription externally and since we can't
// know whether they've changed password (API returns mask ********) we'll force a password change
basicAuth["password"] = password.(string) + " - this suffix will cause a diff and therefore change"
}

d.Set("basic_auth", basicAuthList)
}

// http headers are returned as string -> we need to parse them
httpHeadersString := (*subscription.ConsumerInputs)["httpHeaders"]
reader := bufio.NewReader(strings.NewReader("GET / HTTP/1.1\r\n" + (*subscription.ConsumerInputs)["httpHeaders"] + "\r\n\n"))
req, err := http.ReadRequest(reader)
if err != nil {
return fmt.Errorf("could not parse subscription http headers: %s", httpHeadersString)
}

httpHeaders := map[string]string{}
for header, values := range req.Header {
httpHeaders[header] = strings.Join(values, ", ")
}
d.Set("http_headers", httpHeaders)

filters := map[string]string{}
for key, value := range *subscription.PublisherInputs {
if key == "projectId" || key == "tfsSubscriptionId" {
continue
}
filters[key] = value
}
d.Set("filters", filters)

return nil
}
}

func resourceWebhookUpdate(d *schema.ResourceData, m interface{}) error {
clients := m.(*client.AggregatedClient)

subscriptionData := getSubscription(d)

if _, err := clients.ServiceHooksClient.ReplaceSubscription(clients.Ctx, servicehooks.ReplaceSubscriptionArgs{
SubscriptionId: converter.UUID(d.Id()),
Subscription: &subscriptionData,
}); err != nil {
return err
}

return createResourceWebhookRead(true)(d, m)
}

func resourceWebhookDelete(d *schema.ResourceData, m interface{}) error {
clients := m.(*client.AggregatedClient)

err := clients.ServiceHooksClient.DeleteSubscription(clients.Ctx, servicehooks.DeleteSubscriptionArgs{
SubscriptionId: converter.UUID(d.Id()),
})

if err != nil {
return err
}

d.SetId("")
return nil
}

func getSubscription(d *schema.ResourceData) servicehooks.Subscription {
publisherId := "tfs"
eventType := d.Get("event_type").(string)
url := d.Get("url").(string)
consumerId := "webHooks"
consumerActionId := "httpRequest"
httpHeaders := d.Get("http_headers").(map[string]interface{})
filters := d.Get("filters").(map[string]interface{})

consumerInputs := map[string]string{
"url": url,
}
if basicAuthList, ok := d.GetOk("basic_auth"); ok {
basicAuth := basicAuthList.([]interface{})[0].(map[string]interface{})
if username, ok := basicAuth["username"]; ok && username != "" {
consumerInputs["basicAuthUsername"] = username.(string)
}
if password, ok := basicAuth["password"]; ok && password != "" {
consumerInputs["basicAuthPassword"] = password.(string)
}
}

httpHeadersSlice := []string{}
for header, value := range httpHeaders {
httpHeadersSlice = append(httpHeadersSlice, fmt.Sprintf("%s: %s", header, value.(string)))
}
httpHeadersStr := strings.Join(httpHeadersSlice, "\n")
if httpHeadersStr != "" {
consumerInputs["httpHeaders"] = httpHeadersStr
}

publisherInputs := map[string]string{}
for key, value := range filters {
publisherInputs[key] = value.(string)
}
publisherInputs["projectId"] = d.Get("project_id").(string)

subscriptionData := servicehooks.Subscription{
PublisherId: &publisherId,
EventType: &eventType,
ConsumerId: &consumerId,
ConsumerActionId: &consumerActionId,
PublisherInputs: &publisherInputs,
ConsumerInputs: &consumerInputs,
}

return subscriptionData
}
2 changes: 2 additions & 0 deletions azuredevops/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"github.com/microsoft/terraform-provider-azuredevops/azuredevops/internal/service/policy/branch"
"github.com/microsoft/terraform-provider-azuredevops/azuredevops/internal/service/policy/repository"
"github.com/microsoft/terraform-provider-azuredevops/azuredevops/internal/service/serviceendpoint"
"github.com/microsoft/terraform-provider-azuredevops/azuredevops/internal/service/servicehooks"
"github.com/microsoft/terraform-provider-azuredevops/azuredevops/internal/service/taskagent"
"github.com/microsoft/terraform-provider-azuredevops/azuredevops/internal/service/workitemtracking"
)
Expand Down Expand Up @@ -57,6 +58,7 @@ func Provider() *schema.Provider {
"azuredevops_serviceendpoint_npm": serviceendpoint.ResourceServiceEndpointNpm(),
"azuredevops_serviceendpoint_generic": serviceendpoint.ResourceServiceEndpointGeneric(),
"azuredevops_serviceendpoint_generic_git": serviceendpoint.ResourceServiceEndpointGenericGit(),
"azuredevops_servicehooks_webhook": servicehooks.ResourceServiceHookWebhook(),
"azuredevops_git_repository": git.ResourceGitRepository(),
"azuredevops_git_repository_file": git.ResourceGitRepositoryFile(),
"azuredevops_user_entitlement": memberentitlementmanagement.ResourceUserEntitlement(),
Expand Down
1 change: 1 addition & 0 deletions azuredevops/provider_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ func TestProvider_HasChildResources(t *testing.T) {
"azuredevops_serviceendpoint_npm",
"azuredevops_serviceendpoint_generic",
"azuredevops_serviceendpoint_generic_git",
"azuredevops_servicehooks_webhook",
"azuredevops_variable_group",
"azuredevops_repository_policy_author_email_pattern",
"azuredevops_repository_policy_case_enforcement",
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ require (
github.com/hashicorp/go-uuid v1.0.1
github.com/hashicorp/terraform-exec v0.14.0 // indirect
github.com/hashicorp/terraform-plugin-sdk v1.17.2
github.com/microsoft/azure-devops-go-api/azuredevops v1.0.0-b3
github.com/microsoft/azure-devops-go-api/azuredevops v1.0.0-b5
github.com/stretchr/testify v1.7.0
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b
gopkg.in/yaml.v2 v2.3.0
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -280,8 +280,8 @@ github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hd
github.com/mattn/go-isatty v0.0.10 h1:qxFzApOv4WsAL965uUPIsXzAKCZxN2p9UqdhFS4ZW10=
github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84=
github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
github.com/microsoft/azure-devops-go-api/azuredevops v1.0.0-b3 h1:5iyKm9Mzp0NbKLVHP6PZbigCAzvOYq/pAaMyc8KpNLs=
github.com/microsoft/azure-devops-go-api/azuredevops v1.0.0-b3/go.mod h1:PoGiBqKSQK1vIfQ+yVaFcGjDySHvym6FM1cNYnwzbrY=
github.com/microsoft/azure-devops-go-api/azuredevops v1.0.0-b5 h1:YH424zrwLTlyHSH/GzLMJeu5zhYVZSx5RQxGKm1h96s=
github.com/microsoft/azure-devops-go-api/azuredevops v1.0.0-b5/go.mod h1:PoGiBqKSQK1vIfQ+yVaFcGjDySHvym6FM1cNYnwzbrY=
github.com/mitchellh/cli v1.1.2 h1:PvH+lL2B7IQ101xQL63Of8yFS2y+aDlsFcsqNc+u/Kw=
github.com/mitchellh/cli v1.1.2/go.mod h1:6iaV0fGdElS6dPBx0EApTxHrcWvmJphyh2n8YBLPPZ4=
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ=
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading

0 comments on commit 4b8fe82

Please sign in to comment.