Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

SUP-863 Allow pipeline to be removed from a cluster #279

Merged
merged 9 commits into from
Jun 15, 2023
1 change: 1 addition & 0 deletions .graphqlrc.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
schema: schema.graphql
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

All notable changes to this project will be documented in this file.

## Unreleased

* Allow pipeline to be removed from a cluster [[PR #279](https://github.com/buildkite/terraform-provider-buildkite/pull/279)]

## [v0.18.1](https://github.com/buildkite/terraform-provider-buildkite/compare/v0.18.0...v0.18.1)

### Fixes
Expand Down
630 changes: 630 additions & 0 deletions buildkite/generated.go

Large diffs are not rendered by default.

59 changes: 59 additions & 0 deletions buildkite/graphql/pipeline.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,62 @@ query getPipeline($slug: ID!) {
webhookURL
}
}

# @genqlient(for: "PipelineUpdateInput.clusterId", pointer: true)
# @genqlient(for: "PipelineUpdateInput.visibility", omitempty: true)
# @genqlient(for: "PipelineUpdateInput.buildRetentionPeriod", omitempty: true)
# @genqlient(for: "PipelineUpdateInput.buildRetentionNumber", omitempty: true)
# @genqlient(for: "PipelineUpdateInput.archived", omitempty: true)
# @genqlient(for: "PipelineUpdateInput.nextBuildNumber", omitempty: true)
mutation updatePipeline(
$input: PipelineUpdateInput!
) {
pipelineUpdate(input: $input) {
pipeline {
id
allowRebuilds
cancelIntermediateBuilds
cancelIntermediateBuildsBranchFilter
cluster {
id
}
defaultBranch
defaultTimeoutInMinutes
maximumTimeoutInMinutes
description
name
repository {
url
}
skipIntermediateBuilds
skipIntermediateBuildsBranchFilter
slug
steps {
yaml
}
tags {
label
}
teams (first: 50) {
edges {
node {
accessLevel
id
team {
description
id
isDefaultTeam
defaultMemberRole
name
membersCanCreatePipelines
privacy
slug
uuid
}
}
}
}
webhookURL
}
}
}
167 changes: 102 additions & 65 deletions buildkite/resource_pipeline.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,46 +15,43 @@ const defaultSteps = `steps:
command: buildkite-agent pipeline upload`

// PipelineNode represents a pipeline as returned from the GraphQL API
type Cluster struct {
ID graphql.String
}
type Repository struct {
URL graphql.String
}
type Steps struct {
YAML graphql.String
}
mcncl marked this conversation as resolved.
Show resolved Hide resolved
type PipelineNode struct {
AllowRebuilds graphql.Boolean
CancelIntermediateBuilds graphql.Boolean
CancelIntermediateBuildsBranchFilter graphql.String
Cluster struct {
ID graphql.String
}
DefaultBranch graphql.String
DefaultTimeoutInMinutes graphql.Int
MaximumTimeoutInMinutes graphql.Int
Description graphql.String
ID graphql.String
Name graphql.String
Repository struct {
URL graphql.String
}
SkipIntermediateBuilds graphql.Boolean
SkipIntermediateBuildsBranchFilter graphql.String
Slug graphql.String
Steps struct {
YAML graphql.String
}
Tags []PipelineTag
Teams struct {
Cluster Cluster
DefaultBranch graphql.String
DefaultTimeoutInMinutes graphql.Int
MaximumTimeoutInMinutes graphql.Int
Description graphql.String
ID graphql.String
Name graphql.String
Repository Repository
SkipIntermediateBuilds graphql.Boolean
SkipIntermediateBuildsBranchFilter graphql.String
Slug graphql.String
Steps Steps
Tags []PipelineTag
Teams struct {
Edges []struct {
Node TeamPipelineNode
}
} `graphql:"teams(first: 50)"`
WebhookURL graphql.String `graphql:"webhookURL"`
}

// PipelineAccessLevels represents a pipeline access levels as returned from the GraphQL API
type PipelineAccessLevels graphql.String

type PipelineTag struct {
Label graphql.String
}
type PipelineTagInput struct {
Label graphql.String `json:"label"`
}

// TeamPipelineNode represents a team pipeline as returned from the GraphQL API
type TeamPipelineNode struct {
Expand Down Expand Up @@ -101,7 +98,6 @@ func resourcePipeline() *schema.Resource {
Type: schema.TypeString,
},
"cluster_id": {
Computed: true,
Optional: true,
Type: schema.TypeString,
},
Expand Down Expand Up @@ -431,58 +427,71 @@ func ReadPipeline(ctx context.Context, d *schema.ResourceData, m interface{}) di
func UpdatePipeline(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics {
client := m.(*Client)
var err error
var mutation struct {
PipelineUpdate struct {
Pipeline PipelineNode
} `graphql:"pipelineUpdate(input: {allowRebuilds: $allow_rebuilds, cancelIntermediateBuilds: $cancel_intermediate_builds, cancelIntermediateBuildsBranchFilter: $cancel_intermediate_builds_branch_filter, defaultBranch: $default_branch, defaultTimeoutInMinutes: $default_timeout_in_minutes, maximumTimeoutInMinutes: $maximum_timeout_in_minutes, description: $desc, id: $id, name: $name, repository: {url: $repository_url}, skipIntermediateBuilds: $skip_intermediate_builds, skipIntermediateBuildsBranchFilter: $skip_intermediate_builds_branch_filter, steps: {yaml: $steps}, tags: $tags})"`
}
vars := map[string]interface{}{
"allow_rebuilds": graphql.Boolean(d.Get("allow_rebuilds").(bool)),
"cancel_intermediate_builds": graphql.Boolean(d.Get("cancel_intermediate_builds").(bool)),
"cancel_intermediate_builds_branch_filter": graphql.String(d.Get("cancel_intermediate_builds_branch_filter").(string)),
"default_branch": graphql.String(d.Get("default_branch").(string)),
"default_timeout_in_minutes": graphql.Int(d.Get("default_timeout_in_minutes").(int)),
"maximum_timeout_in_minutes": graphql.Int(d.Get("maximum_timeout_in_minutes").(int)),
"desc": graphql.String(d.Get("description").(string)),
"id": graphql.ID(d.Id()),
"name": graphql.String(d.Get("name").(string)),
"repository_url": graphql.String(d.Get("repository").(string)),
"skip_intermediate_builds": graphql.Boolean(d.Get("skip_intermediate_builds").(bool)),
"skip_intermediate_builds_branch_filter": graphql.String(d.Get("skip_intermediate_builds_branch_filter").(string)),
"steps": graphql.String(d.Get("steps").(string)),
"tags": getTagsFromSchema(d),
}

log.Printf("Updating pipeline %s ...", vars["name"])

// If the cluster_id key is present in the mutation, GraphQL expects a valid ID.
// Check if cluster_id exists in the configuration before adding to mutation.
if clusterID, ok := d.GetOk("cluster_id"); ok {
var mutationWithClusterID struct {
PipelineUpdate struct {
Pipeline PipelineNode
} `graphql:"pipelineUpdate(input: {allowRebuilds: $allow_rebuilds, cancelIntermediateBuilds: $cancel_intermediate_builds, cancelIntermediateBuildsBranchFilter: $cancel_intermediate_builds_branch_filter, clusterId: $cluster_id, defaultBranch: $default_branch, defaultTimeoutInMinutes: $default_timeout_in_minutes, maximumTimeoutInMinutes: $maximum_timeout_in_minutes, description: $desc, id: $id, name: $name, repository: {url: $repository_url}, skipIntermediateBuilds: $skip_intermediate_builds, skipIntermediateBuildsBranchFilter: $skip_intermediate_builds_branch_filter, steps: {yaml: $steps}, tags: $tags})"`
input := PipelineUpdateInput{
AllowRebuilds: d.Get("allow_rebuilds").(bool),
CancelIntermediateBuilds: d.Get("cancel_intermediate_builds").(bool),
CancelIntermediateBuildsBranchFilter: d.Get("cancel_intermediate_builds_branch_filter").(string),
DefaultBranch: d.Get("default_branch").(string),
DefaultTimeoutInMinutes: d.Get("default_timeout_in_minutes").(int),
MaximumTimeoutInMinutes: d.Get("maximum_timeout_in_minutes").(int),
Description: d.Get("description").(string),
Id: d.Id(),
Name: d.Get("name").(string),
Repository: PipelineRepositoryInput{Url: d.Get("repository").(string)},
SkipIntermediateBuilds: d.Get("skip_intermediate_builds").(bool),
SkipIntermediateBuildsBranchFilter: d.Get("skip_intermediate_builds_branch_filter").(string),
Steps: PipelineStepsInput{Yaml: d.Get("steps").(string)},
Tags: getTagsFromSchema(d),
}

// If cluster_id exists in the schema it must be a non-empty string
// Otherwise, if its not present it will be set to nil by default
if clusterID, clusterIdPresent := d.GetOk("cluster_id"); clusterIdPresent {
if value, isString := clusterID.(string); isString && value != "" {
input.ClusterId = &value
}
vars["cluster_id"] = graphql.ID(clusterID.(string))
err = client.graphql.Mutate(context.Background(), &mutationWithClusterID, vars)
mutation.PipelineUpdate.Pipeline = mutationWithClusterID.PipelineUpdate.Pipeline
} else {
err = client.graphql.Mutate(context.Background(), &mutation, vars)
}

log.Printf("Updating pipeline %s ...", input.Name)

response, err := updatePipeline(client.genqlient, input)

if err != nil {
log.Printf("Unable to update pipeline %s", d.Get("name"))
return diag.FromErr(err)
}

// While transitioning from shurcool to genqlient, we'll map the response here to utilise existing functionality
pipeline := PipelineNode{
AllowRebuilds: graphql.Boolean(response.PipelineUpdate.Pipeline.AllowRebuilds),
CancelIntermediateBuilds: graphql.Boolean(response.PipelineUpdate.Pipeline.CancelIntermediateBuilds),
CancelIntermediateBuildsBranchFilter: graphql.String(response.PipelineUpdate.Pipeline.CancelIntermediateBuildsBranchFilter),
Cluster: Cluster{ID: graphql.String(response.PipelineUpdate.Pipeline.Cluster.Id)},
DefaultBranch: graphql.String(response.PipelineUpdate.Pipeline.DefaultBranch),
DefaultTimeoutInMinutes: graphql.Int(response.PipelineUpdate.Pipeline.DefaultTimeoutInMinutes),
MaximumTimeoutInMinutes: graphql.Int(response.PipelineUpdate.Pipeline.MaximumTimeoutInMinutes),
Description: graphql.String(response.PipelineUpdate.Pipeline.Description),
ID: graphql.String(response.PipelineUpdate.Pipeline.Id),
Name: graphql.String(response.PipelineUpdate.Pipeline.Name),
Repository: Repository{URL: graphql.String(response.PipelineUpdate.Pipeline.Repository.Url)},
SkipIntermediateBuilds: graphql.Boolean(response.PipelineUpdate.Pipeline.SkipIntermediateBuilds),
SkipIntermediateBuildsBranchFilter: graphql.String(response.PipelineUpdate.Pipeline.SkipIntermediateBuildsBranchFilter),
Slug: graphql.String(response.PipelineUpdate.Pipeline.Slug),
Steps: Steps{YAML: graphql.String(response.PipelineUpdate.Pipeline.Steps.Yaml)},
Tags: mapTagsFromGenqlient(response.PipelineUpdate.Pipeline.Tags),
Teams: mapTeamPipelinesFromGenqlient(response.PipelineUpdate.Pipeline.Teams.Edges),
WebhookURL: graphql.String(response.PipelineUpdate.Pipeline.WebhookURL),
}

teamPipelines := getTeamPipelinesFromSchema(d)
err = reconcileTeamPipelines(teamPipelines, &mutation.PipelineUpdate.Pipeline, client)
err = reconcileTeamPipelines(teamPipelines, &pipeline, client)
if err != nil {
log.Print("Unable to reconcile team pipelines")
return diag.FromErr(err)
}

updatePipelineResource(d, &mutation.PipelineUpdate.Pipeline)
updatePipelineResource(d, &pipeline)

pipelineExtraInfo, err := updatePipelineExtraInfo(d, client)
if err != nil {
Expand Down Expand Up @@ -584,17 +593,45 @@ func updatePipelineExtraInfo(d *schema.ResourceData, client *Client) (PipelineEx
return pipelineExtraInfo, nil
}

func mapTagsFromGenqlient(tags []updatePipelinePipelineUpdatePipelineUpdatePayloadPipelineTagsPipelineTag) []PipelineTag {
newTags := make([]PipelineTag, len(tags))

for i, v := range tags {
newTags[i] = PipelineTag{Label: graphql.String(v.Label)}
}
return newTags
}

func getTagsFromSchema(d *schema.ResourceData) []PipelineTagInput {
tagSet := d.Get("tags").(*schema.Set)
tags := make([]PipelineTagInput, tagSet.Len())
for i, v := range tagSet.List() {
tags[i] = PipelineTagInput{
Label: graphql.String(v.(string)),
Label: v.(string),
}
}
return tags
}

func mapTeamPipelinesFromGenqlient(tags []updatePipelinePipelineUpdatePipelineUpdatePayloadPipelineTeamsTeamPipelineConnectionEdgesTeamPipelineEdge) struct {
Edges []struct{ Node TeamPipelineNode }
} {
teamPipelineNodes := make([]struct{ Node TeamPipelineNode }, len(tags))
for i, v := range tags {
teamPipelineNodes[i] = struct{ Node TeamPipelineNode }{
Node: TeamPipelineNode{
AccessLevel: v.Node.AccessLevel,
ID: graphql.String(v.Node.Id),
Team: TeamNode{
Slug: graphql.String(v.Node.Team.Slug),
}},
}
}
return struct {
Edges []struct{ Node TeamPipelineNode }
}{Edges: teamPipelineNodes}
}

func getTeamPipelinesFromSchema(d *schema.ResourceData) []TeamPipelineNode {
teamsInput := d.Get("team").(*schema.Set).List()
teamPipelineNodes := make([]TeamPipelineNode, len(teamsInput))
Expand Down
46 changes: 42 additions & 4 deletions buildkite/resource_pipeline_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ func TestAccPipeline_add_remove(t *testing.T) {
}

// Confirm that we can create a new pipeline with a cluster, and then delete it without error
func TestAccPipeline_add_remove_withcluster(t *testing.T) {
func TestAccPipeline_add_delete_withcluster(t *testing.T) {
var resourcePipeline PipelineNode

resource.Test(t, resource.TestCase{
Expand Down Expand Up @@ -73,6 +73,45 @@ func TestAccPipeline_add_remove_withcluster(t *testing.T) {
})
}

// Confirm that we can create a new pipeline with a cluster, and then remove it from the cluster
func TestAccPipeline_add_remove_withcluster(t *testing.T) {
var resourcePipeline PipelineNode

resource.Test(t, resource.TestCase{
PreCheck: func() { testAccPreCheck(t) },
Providers: testAccProviders,
CheckDestroy: testAccCheckPipelineResourceDestroy,
Steps: []resource.TestStep{
{
Config: testAccPipelineConfigBasicWithCluster("foo"),
Check: resource.ComposeAggregateTestCheckFunc(
// Confirm the pipeline exists in the buildkite API
testAccCheckPipelineExists("buildkite_pipeline.foobar", &resourcePipeline),
// Confirm the pipeline has the correct values in Buildkite's system
testAccCheckPipelineRemoteValues(&resourcePipeline, "Test Pipeline foo"),
// Confirm the pipeline has the correct values in terraform state
resource.TestCheckResourceAttr("buildkite_pipeline.foobar", "name", "Test Pipeline foo"),
resource.TestCheckResourceAttr("buildkite_pipeline.foobar", "cluster_id", "Q2x1c3Rlci0tLTRlN2JmM2FjLWUzMjMtNGY1OS05MGY2LTQ5OTljZmI2MGQyYg=="),
resource.TestCheckResourceAttr("buildkite_pipeline.foobar", "allow_rebuilds", "true"),
),
},
{
Config: testAccPipelineConfigBasicWithTeam("foo"),
Check: resource.ComposeAggregateTestCheckFunc(
// Confirm the pipeline exists in the buildkite API
testAccCheckPipelineExists("buildkite_pipeline.foobar", &resourcePipeline),
// Confirm the pipeline has the correct values in Buildkite's system
testAccCheckPipelineRemoteValues(&resourcePipeline, "Test Pipeline foo"),
// Confirm the pipeline has the correct values in terraform state
resource.TestCheckResourceAttr("buildkite_pipeline.foobar", "name", "Test Pipeline foo"),
resource.TestCheckResourceAttr("buildkite_pipeline.foobar", "cluster_id", ""),
resource.TestCheckResourceAttr("buildkite_pipeline.foobar", "allow_rebuilds", "true"),
),
},
},
})
}

func TestAccPipeline_add_remove_complex(t *testing.T) {
var resourcePipeline PipelineNode
steps := `"steps:\n- command: buildkite-agent pipeline upload\n"`
Expand Down Expand Up @@ -540,9 +579,8 @@ func testAccPipelineConfigBasicWithCluster(name string) string {
resource "buildkite_pipeline" "foobar" {
name = "Test Pipeline %s"
repository = "https://github.com/buildkite/terraform-provider-buildkite.git"
steps = ""
cluster_id = "Q2x1c3Rlci0tLTRlN2JmM2FjLWUzMjMtNGY1OS05MGY2LTQ5OTljZmI2MGQyYg=="
allow_rebuilds = true
cluster_id = "Q2x1c3Rlci0tLTRlN2JmM2FjLWUzMjMtNGY1OS05MGY2LTQ5OTljZmI2MGQyYg=="
allow_rebuilds = true
team {
slug = "everyone"
Expand Down
3 changes: 0 additions & 3 deletions buildkite/resource_team.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,6 @@ import (
"github.com/shurcooL/graphql"
)

type TeamPrivacy graphql.String
type TeamMemberRole graphql.String

type TeamNode struct {
Description graphql.String
ID graphql.String
Expand Down
4 changes: 4 additions & 0 deletions genqlient.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,7 @@ generated: buildkite/generated.go

# We pass context.Background() everywhere so just leave it off
context_type: "-"

bindings:
YAML:
type: string
Loading