diff --git a/helper_test.go b/helper_test.go index 83a8a2483..6e2d74035 100644 --- a/helper_test.go +++ b/helper_test.go @@ -384,6 +384,27 @@ func createAppliedRun(t *testing.T, client *Client, w *Workspace) (*Run, func()) } } +func createPlanExport(t *testing.T, client *Client, plan *Plan) (*PlanExport, func()) { + var runCleanup func() + var run *Run + + if plan == nil { + run, runCleanup = createPlannedRun(t, client, nil) + plan = run.Plan + } + + ctx := context.Background() + pe, err := client.PlanExports.Create(ctx, PlanExportCreateOptions{ + Plan: plan, + DataType: PlanExportType(PlanExportSentinelMockBundleV0), + }) + if err != nil { + t.Fatal(err) + } + + return pe, runCleanup +} + func createSSHKey(t *testing.T, client *Client, org *Organization) (*SSHKey, func()) { var orgCleanup func() diff --git a/plan.go b/plan.go index 31aef138d..328380f45 100644 --- a/plan.go +++ b/plan.go @@ -55,6 +55,9 @@ type Plan struct { ResourceDestructions int `jsonapi:"attr,resource-destructions"` Status PlanStatus `jsonapi:"attr,status"` StatusTimestamps *PlanStatusTimestamps `jsonapi:"attr,status-timestamps"` + + // Relations + Exports []*PlanExport `jsonapi:"relation,exports"` } // PlanStatusTimestamps holds the timestamps for individual plan statuses. diff --git a/plan_export.go b/plan_export.go new file mode 100644 index 000000000..bfbb54df3 --- /dev/null +++ b/plan_export.go @@ -0,0 +1,175 @@ +package tfe + +import ( + "bytes" + "context" + "errors" + "fmt" + "net/url" + "time" +) + +// Compile-time proof of interface implementation. +var _ PlanExports = (*planExports)(nil) + +// PlanExports describes all the plan export related methods that the Terraform Enterprise +// API supports. +// +// TFE API docs: https://www.terraform.io/docs/enterprise/api/plan-exports.html +type PlanExports interface { + // Export a plan by its ID with the given options. + Create(ctx context.Context, options PlanExportCreateOptions) (*PlanExport, error) + + // Read a plan export by its ID. + Read(ctx context.Context, planExportID string) (*PlanExport, error) + + // Download the data of an plan export. + Download(ctx context.Context, planExportID string) ([]byte, error) + + // Delete a plan export by its ID. + Delete(ctx context.Context, planExportID string) error +} + +// planExports implements PlanExports. +type planExports struct { + client *Client +} + +// PlanExportDataType represents the type of data exported from a plan. +type PlanExportDataType string + +//List all available plan export data types +const ( + PlanExportSentinelMockBundleV0 PlanExportDataType = "sentinel-mock-bundle-v0" +) + +// PlanExportStatus represents a plan export state. +type PlanExportStatus string + +//List all available plan export statuses. +const ( + PlanExportCanceled PlanExportStatus = "canceled" + PlanExportErrored PlanExportStatus = "errored" + PlanExportExpired PlanExportStatus = "expired" + PlanExportFinished PlanExportStatus = "finished" + PlanExportPending PlanExportStatus = "pending" + PlanExportQueued PlanExportStatus = "queued" +) + +// PlanExportStatusTimestamps holds the timestamps for individual plan export statuses. +type PlanExportStatusTimestamps struct { + CanceledAt time.Time `json:"canceled-at"` + ErroredAt time.Time `json:"errored-at"` + ExpiredAt time.Time `json:"expired-at"` + FinishedAt time.Time `json:"finished-at"` + QueuedAt time.Time `json:"queued-at"` +} + +// PlanExport represents an export of Terraform Enterprise plan data. +type PlanExport struct { + ID string `jsonapi:"primary,plan-exports"` + DataType PlanExportDataType `jsonapi:"attr,data-type"` + Status PlanExportStatus `jsonapi:"attr,status"` + StatusTimestamps *PlanExportStatusTimestamps `jsonapi:"attr,status-timestamps"` +} + +// PlanExportCreateOptions represents the options for exporting data from a plan. +type PlanExportCreateOptions struct { + // For internal use only! + ID string `jsonapi:"primary,plan-exports"` + + // The plan to export. + Plan *Plan `jsonapi:"relation,plan"` + + // The name of the policy set. + DataType *PlanExportDataType `jsonapi:"attr,data-type"` +} + +func (o PlanExportCreateOptions) valid() error { + if o.Plan == nil { + return errors.New("plan is required") + } + if o.DataType == nil { + return errors.New("data type is required") + } + return nil +} + +func (s *planExports) Create(ctx context.Context, options PlanExportCreateOptions) (*PlanExport, error) { + if err := options.valid(); err != nil { + return nil, err + } + + // Make sure we don't send a user provided ID. + options.ID = "" + + req, err := s.client.newRequest("POST", "plan-exports", &options) + if err != nil { + return nil, err + } + + pe := &PlanExport{} + err = s.client.do(ctx, req, pe) + if err != nil { + return nil, err + } + + return pe, err +} + +// Read a plan export by its ID. +func (s *planExports) Read(ctx context.Context, planExportID string) (*PlanExport, error) { + if !validStringID(&planExportID) { + return nil, errors.New("invalid value for plan export ID") + } + + u := fmt.Sprintf("plan-exports/%s", url.QueryEscape(planExportID)) + req, err := s.client.newRequest("GET", u, nil) + if err != nil { + return nil, err + } + + pe := &PlanExport{} + err = s.client.do(ctx, req, pe) + if err != nil { + return nil, err + } + + return pe, nil +} + +// Download a plan export's data. Data is exported in a .tar.gz format. +func (s *planExports) Download(ctx context.Context, planExportID string) ([]byte, error) { + if !validStringID(&planExportID) { + return nil, errors.New("invalid value for plan export ID") + } + + u := fmt.Sprintf("plan-exports/%s/download", url.QueryEscape(planExportID)) + req, err := s.client.newRequest("GET", u, nil) + if err != nil { + return nil, err + } + + var buf bytes.Buffer + err = s.client.do(ctx, req, &buf) + if err != nil { + return nil, err + } + + return buf.Bytes(), nil +} + +// Delete a plan export by ID. +func (s *planExports) Delete(ctx context.Context, planExportID string) error { + if !validStringID(&planExportID) { + return errors.New("invalid value for plan export ID") + } + + u := fmt.Sprintf("plan-exports/%s", url.QueryEscape(planExportID)) + req, err := s.client.newRequest("DELETE", u, nil) + if err != nil { + return err + } + + return s.client.do(ctx, req, nil) +} diff --git a/plan_export_test.go b/plan_export_test.go new file mode 100644 index 000000000..1e962c5a8 --- /dev/null +++ b/plan_export_test.go @@ -0,0 +1,106 @@ +package tfe + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestPlanExportsCreate(t *testing.T) { + client := testClient(t) + ctx := context.Background() + + rTest, rTestCleanup := createPlannedRun(t, client, nil) + defer rTestCleanup() + + pTest, err := client.Plans.Read(ctx, rTest.Plan.ID) + assert.NoError(t, err) + + t.Run("with valid options", func(t *testing.T) { + options := PlanExportCreateOptions{ + Plan: pTest, + DataType: PlanExportType(PlanExportSentinelMockBundleV0), + } + + pe, err := client.PlanExports.Create(ctx, options) + assert.NoError(t, err) + assert.NotEmpty(t, pe.ID) + assert.Equal(t, PlanExportSentinelMockBundleV0, pe.DataType) + }) + + t.Run("without a plan", func(t *testing.T) { + options := PlanExportCreateOptions{ + Plan: nil, + DataType: PlanExportType(PlanExportSentinelMockBundleV0), + } + + pe, err := client.PlanExports.Create(ctx, options) + assert.Nil(t, pe) + assert.EqualError(t, err, "plan is required") + }) + + t.Run("without a data type", func(t *testing.T) { + options := PlanExportCreateOptions{ + Plan: pTest, + DataType: nil, + } + + pe, err := client.PlanExports.Create(ctx, options) + assert.Nil(t, pe) + assert.EqualError(t, err, "data type is required") + }) +} + +func TestPlanExportsRead(t *testing.T) { + client := testClient(t) + ctx := context.Background() + + peTest, peTestCleanup := createPlanExport(t, client, nil) + defer peTestCleanup() + + t.Run("without a valid ID", func(t *testing.T) { + _, err := client.PlanExports.Read(ctx, badIdentifier) + assert.EqualError(t, err, "invalid value for plan export ID") + }) + + t.Run("with a valid ID", func(t *testing.T) { + pe, err := client.PlanExports.Read(ctx, peTest.ID) + assert.NoError(t, err) + assert.Equal(t, peTest.ID, pe.ID) + assert.Equal(t, peTest.DataType, pe.DataType) + }) +} + +func TestPlanExportsDownload(t *testing.T) { + client := testClient(t) + ctx := context.Background() + + t.Run("without a valid ID", func(t *testing.T) { + _, err := client.PlanExports.Download(ctx, badIdentifier) + assert.EqualError(t, err, "invalid value for plan export ID") + }) +} + +func TestPlanExportsDelete(t *testing.T) { + client := testClient(t) + ctx := context.Background() + + peTest, peTestCleanup := createPlanExport(t, client, nil) + defer peTestCleanup() + + t.Run("when the export does not exist", func(t *testing.T) { + err := client.Policies.Delete(ctx, "pe-doesntexist") + assert.Equal(t, err, ErrResourceNotFound) + }) + + t.Run("without a valid ID", func(t *testing.T) { + err := client.PlanExports.Delete(ctx, badIdentifier) + assert.EqualError(t, err, "invalid value for plan export ID") + }) + + t.Run("with a valid ID", func(t *testing.T) { + err := client.PlanExports.Delete(ctx, peTest.ID) + assert.NoError(t, err) + }) +} diff --git a/tfe.go b/tfe.go index c9d173b6a..32eac6128 100644 --- a/tfe.go +++ b/tfe.go @@ -113,6 +113,7 @@ type Client struct { Organizations Organizations OrganizationTokens OrganizationTokens Plans Plans + PlanExports PlanExports Policies Policies PolicyChecks PolicyChecks PolicySets PolicySets @@ -203,6 +204,7 @@ func NewClient(cfg *Config) (*Client, error) { client.Organizations = &organizations{client: client} client.OrganizationTokens = &organizationTokens{client: client} client.Plans = &plans{client: client} + client.PlanExports = &planExports{client: client} client.Policies = &policies{client: client} client.PolicyChecks = &policyChecks{client: client} client.PolicySets = &policySets{client: client} diff --git a/type_helpers.go b/type_helpers.go index 87f497dd2..9296e4e6f 100644 --- a/type_helpers.go +++ b/type_helpers.go @@ -40,6 +40,11 @@ func NotificationDestination(v NotificationDestinationType) *NotificationDestina return &v } +// PlanExportType returns a pointer to the given plan export data type. +func PlanExportType(v PlanExportDataType) *PlanExportDataType { + return &v +} + // ServiceProvider returns a pointer to the given service provider type. func ServiceProvider(v ServiceProviderType) *ServiceProviderType { return &v