Skip to content

Commit

Permalink
Merge pull request #50 from kelvintaywl/feat-project-resource
Browse files Browse the repository at this point in the history
Feat: support project
  • Loading branch information
kelvintaywl authored Aug 29, 2023
2 parents 5271e6b + 31d4443 commit 5bc40db
Show file tree
Hide file tree
Showing 10 changed files with 372 additions and 17 deletions.
1 change: 1 addition & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ The CircleCI provider supports the creation of specific CircleCI resources.

Currently, the following resources are supported:

- [Project](https://circleci.com/docs/create-project/)
- [Project Webhook](https://circleci.com/docs/webhooks/)
- [Project Scheduled Pipeline](https://circleci.com/docs/scheduled-pipelines/)
- [Project Environment Variables](https://circleci.com/docs/set-environment-variable/#set-an-environment-variable-in-a-project)
Expand Down
59 changes: 59 additions & 0 deletions docs/resources/project.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
---
page_title: "circleci_project Resource - terraform-provider-circleci"
subcategory: ""
description: |-
Manages a project
---

# circleci_project (Resource)

Manages a project

## Assumption

- The underlying repository on GitHub / Bitbucket has a .circleci/config.yml file in its default branch.

## Important

CircleCI projects **cannot be deleted**.
When you run `terraform destroy`, it will not destroy the project on CircleCI.

## Example Usage

```terraform
# set up a new CircleCI project
# ASSUMPTION: the GitHub project has a .circleci/config.yml on its default branch
resource "circleci_project" "my_project" {
slug = "github/acme/foobar"
}
# add a project env var to this project
resource "circleci_env_var" "my_env_var" {
project_slug = circleci_project.my_project.slug
name = "FOOBAR"
value = "0Cme2FmlXk"
}
output "vcs_url" {
description = "VCS url"
value = circleci_project.my_project.vcs_url
}
```

<!-- schema generated by tfplugindocs -->
## Schema

### Required

- `slug` (String) Project slug in the form `vcs-slug/org-name/repo-name`. The / characters may be URL-escaped.

### Read-Only

- `id` (String) Read-only unique identifier
- `name` (String) Name of the project
- `organization_id` (String) The id of the organization the project belongs to
- `organization_name` (String) The name of the organization the project belongs to
- `organization_slug` (String) The slug of the organization the project belongs to
- `vcs_default_branch` (String) Default branch of this project
- `vcs_provider` (String) VCS provider (either GitHub, Bitbucket or CircleCI)
- `vcs_url` (String) URL to the repository hosting the project's code
17 changes: 17 additions & 0 deletions examples/resources/project/resource.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# set up a new CircleCI project
# ASSUMPTION: the GitHub project has a .circleci/config.yml on its default branch
resource "circleci_project" "my_project" {
slug = "github/acme/foobar"
}

# add a project env var to this project
resource "circleci_env_var" "my_env_var" {
project_slug = circleci_project.my_project.slug
name = "FOOBAR"
value = "0Cme2FmlXk"
}

output "vcs_url" {
description = "VCS url"
value = circleci_project.my_project.vcs_url
}
208 changes: 208 additions & 0 deletions internal/provider/project_resource.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
package provider

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

"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
"github.com/hashicorp/terraform-plugin-framework/types"

"github.com/hashicorp/terraform-plugin-log/tflog"

"github.com/kelvintaywl/circleci-go-sdk/client/project"
)

// Ensure provider defined types fully satisfy framework interfaces
var _ resource.Resource = &ProjectResource{}

func NewProjectResource() resource.Resource {
return &ProjectResource{}
}

type ProjectResource struct {
client *CircleciAPIClient
}

type ProjectResourceModel struct {
Id types.String `tfsdk:"id"`
Slug types.String `tfsdk:"slug"`
Name types.String `tfsdk:"name"`
OrganizationName types.String `tfsdk:"organization_name"`
OrganizationSlug types.String `tfsdk:"organization_slug"`
OrganizationId types.String `tfsdk:"organization_id"`
VcsProvider types.String `tfsdk:"vcs_provider"`
VcsDefaultBranch types.String `tfsdk:"vcs_default_branch"`
VcsURL types.String `tfsdk:"vcs_url"`
}

func (r *ProjectResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_project"
}

func (r *ProjectResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{
MarkdownDescription: "Manages a project",
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
MarkdownDescription: "Read-only unique identifier",
Computed: true,
// unchanged even during updates
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
"slug": schema.StringAttribute{
MarkdownDescription: "Project slug in the form `vcs-slug/org-name/repo-name`. The / characters may be URL-escaped.",
Required: true,
},
"name": schema.StringAttribute{
MarkdownDescription: "Name of the project",
Computed: true,
},
"organization_name": schema.StringAttribute{
MarkdownDescription: "The name of the organization the project belongs to",
Computed: true,
},
"organization_slug": schema.StringAttribute{
MarkdownDescription: "The slug of the organization the project belongs to",
Computed: true,
},
"organization_id": schema.StringAttribute{
MarkdownDescription: "The id of the organization the project belongs to",
Computed: true,
},
"vcs_url": schema.StringAttribute{
MarkdownDescription: "URL to the repository hosting the project's code",
Computed: true,
},
"vcs_provider": schema.StringAttribute{
MarkdownDescription: "VCS provider (either GitHub, Bitbucket or CircleCI)",
Computed: true,
},
"vcs_default_branch": schema.StringAttribute{
MarkdownDescription: "Default branch of this project",
Computed: true,
},
},
}
}

// Configure adds the provider configured client to the data source.
func (r *ProjectResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
// Prevent panic if the provider has not been configured.
if req.ProviderData == nil {
return
}

client, ok := req.ProviderData.(*CircleciAPIClient)

if !ok {
resp.Diagnostics.AddError(
"Unexpected Resource Configure Type",
fmt.Sprintf("Expected *CircleciAPIClient, got: %T. Please report this issue to the provider developers.", req.ProviderData),
)

return
}

r.client = client
}

// Read refreshes the Terraform state with the latest data.
func (r *ProjectResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
// Get current state
var state ProjectResourceModel
diags := req.State.Get(ctx, &state)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}

projectSlug := state.Slug.ValueString()
param := project.NewGetProjectParamsWithContext(ctx).WithDefaults()
param = param.WithProjectSlug(projectSlug)

res, err := r.client.Client.Project.GetProject(param, r.client.Auth)
if err != nil {
resp.Diagnostics.AddError(fmt.Sprintf("Encountered error reading Project(%s)", projectSlug), fmt.Sprintf("%s", err))
return
}

pj := res.GetPayload()
state.Id = types.StringValue(pj.ID.String())
state.Name = types.StringValue(pj.Name)
state.OrganizationName = types.StringValue(pj.OrganizationName)
state.OrganizationSlug = types.StringValue(pj.OrganizationSlug)
state.OrganizationId = types.StringValue(pj.OrganizationID.String())
state.VcsProvider = types.StringValue(pj.VcsInfo.Provider)
state.VcsDefaultBranch = types.StringValue(pj.VcsInfo.DefaultBranch)
state.VcsURL = types.StringValue(pj.VcsInfo.VcsURL)

diags = resp.State.Set(ctx, &state)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
}

// Create creates the resource and sets the initial Terraform state.
func (r *ProjectResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
// Retrieve values from plan
var plan ProjectResourceModel
diags := req.Plan.Get(ctx, &plan)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}

projectSlug := plan.Slug.ValueString()
url := fmt.Sprintf("https://%s/api/v1.1/project/%s/follow", r.client.Hostname, projectSlug)
request, err := http.NewRequest(http.MethodPost, url, nil)
if err != nil {
resp.Diagnostics.AddError("Encountered error setting up API call", fmt.Sprintf("%s", err))
}
res, err := r.client.V1Client.Do(request)
if err != nil || res.StatusCode != 200 {
resp.Diagnostics.AddError(fmt.Sprintf("Encountered error following project (%s)", projectSlug), fmt.Sprintf("%s", err))
}

// read
readParam := project.NewGetProjectParamsWithContext(ctx).WithDefaults()
readParam = readParam.WithProjectSlug(projectSlug)

readRes, err := r.client.Client.Project.GetProject(readParam, r.client.Auth)
if err != nil {
resp.Diagnostics.AddError(fmt.Sprintf("Encountered error reading Project(%s)", projectSlug), fmt.Sprintf("%s", err))
return
}

pj := readRes.GetPayload()
plan.Id = types.StringValue(pj.ID.String())
plan.Name = types.StringValue(pj.Name)
plan.OrganizationName = types.StringValue(pj.OrganizationName)
plan.OrganizationSlug = types.StringValue(pj.OrganizationSlug)
plan.OrganizationId = types.StringValue(pj.OrganizationID.String())
plan.VcsProvider = types.StringValue(pj.VcsInfo.Provider)
plan.VcsDefaultBranch = types.StringValue(pj.VcsInfo.DefaultBranch)
plan.VcsURL = types.StringValue(pj.VcsInfo.VcsURL)

diags = resp.State.Set(ctx, &plan)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
}

func (r *ProjectResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
// not implemented; not possible to update project
tflog.Warn(ctx, "Project cannot be updated via this provider.")
}

func (r *ProjectResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
// not implemented; not possible to delete project
tflog.Warn(ctx, "Project cannot be deleted via this provider.")
}
35 changes: 35 additions & 0 deletions internal/provider/project_resource_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package provider

import (
"fmt"
"testing"

"github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource"
)

func TestAccProjectResource(t *testing.T) {
resource.Test(t, resource.TestCase{
ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
Steps: []resource.TestStep{
// Create and Read testing
{
Config: providerConfig + fmt.Sprintf(`
resource "circleci_project" "p1" {
slug = "%s"
}
`, projectSlug),
Check: resource.ComposeAggregateTestCheckFunc(
resource.TestCheckResourceAttr("circleci_project.p1", "slug", projectSlug),

resource.TestCheckResourceAttr("circleci_project.p1", "organization_name", "kelvintaywl-tf"),
resource.TestCheckResourceAttr("circleci_project.p1", "organization_slug", "gh/kelvintaywl-tf"),
resource.TestCheckResourceAttrSet("circleci_project.p1", "organization_id"),

resource.TestCheckResourceAttr("circleci_project.p1", "vcs_url", "https://github.com/kelvintaywl-tf/tf-provider-acceptance-test-dummy"),
resource.TestCheckResourceAttr("circleci_project.p1", "vcs_default_branch", "main"),
resource.TestCheckResourceAttr("circleci_project.p1", "vcs_provider", "GitHub"),
),
},
},
})
}
19 changes: 19 additions & 0 deletions internal/provider/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package provider
import (
"context"
"fmt"
"net/http"
"os"

"github.com/go-openapi/strfmt"
Expand Down Expand Up @@ -46,9 +47,20 @@ type CircleciProvider struct {
type CircleciAPIClient struct {
Client *api.Circleci
RunnerClient *rapi.Circleci
V1Client *http.Client
Hostname string
Auth runtime.ClientAuthInfoWriter
}

type httpClientTransport struct {
APIToken string
}

func (t *httpClientTransport) RoundTrip(req *http.Request) (*http.Response, error) {
req.Header.Add("Circle-Token", t.APIToken)
return http.DefaultTransport.RoundTrip(req)
}

// CircleciProviderModel describes the provider data model.
type CircleciProviderModel struct {
ApiToken types.String `tfsdk:"api_token"`
Expand Down Expand Up @@ -126,9 +138,15 @@ func (p *CircleciProvider) Configure(ctx context.Context, req provider.Configure
rcfg := rapi.DefaultTransportConfig().WithHost(rhostname)
rclient := rapi.NewHTTPClientWithConfig(strfmt.Default, rcfg)

httpClient := &http.Client{Transport: &httpClientTransport{
APIToken: apiToken,
}}

apiClient := &CircleciAPIClient{
Client: client,
RunnerClient: rclient,
V1Client: httpClient,
Hostname: hostname,
Auth: auth,
}

Expand All @@ -146,6 +164,7 @@ func (p *CircleciProvider) Resources(ctx context.Context) []func() resource.Reso
NewContextEnvVarResource,
NewRunnerResourceClassResource,
NewRunnerTokenResource,
NewProjectResource,
}
}

Expand Down
2 changes: 1 addition & 1 deletion sandbox/.terraform.lock.hcl

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

Loading

0 comments on commit 5bc40db

Please sign in to comment.