diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..af57015 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +set -euo pipefail + +if ! command -v go >/dev/null 2>&1; then + echo "go not found; skipping build/validate." >&2 + exit 0 +fi + +# Build provider +go build -o terraform.coderforge.org/coderforge/coderforge || exit 1 + +# Terraform optional +if command -v terraform >/dev/null 2>&1; then + export TF_CLI_CONFIG_FILE="$(git rev-parse --show-toplevel)/.terraformrc" + (cd examples/resources/function && terraform init -input=false -upgrade && terraform validate) + (cd examples/resources/container_registry && terraform init -input=false -upgrade && terraform validate) +else + echo "terraform not found; skipping terraform validate." >&2 +fi \ No newline at end of file diff --git a/.terraformrc b/.terraformrc new file mode 100644 index 0000000..47575d1 --- /dev/null +++ b/.terraformrc @@ -0,0 +1,7 @@ +provider_installation { + dev_overrides { + "registry.terraform.io/coderforge/coderforge" = "/workspace" + } + direct {} +} + diff --git a/Makefile b/Makefile index 5ae5863..dcceb34 100644 --- a/Makefile +++ b/Makefile @@ -11,6 +11,19 @@ build-only: go mod download go build -o terraform-provider-coderforge_$(VERSION) +.PHONY: build-dev +build-dev: + go build -o registry.terraform.io/coderforge/coderforge + +.PHONY: validate-examples +validate-examples: build-dev + cd examples/resources/function && TF_CLI_CONFIG_FILE=../../..//.terraformrc terraform init -input=false -upgrade && TF_CLI_CONFIG_FILE=../../..//.terraformrc terraform validate + cd examples/resources/container_registry && TF_CLI_CONFIG_FILE=../../..//.terraformrc terraform init -input=false -upgrade && TF_CLI_CONFIG_FILE=../../..//.terraformrc terraform validate + +.PHONY: hooks-install +hooks-install: + git config core.hooksPath .githooks + .PHONY: doc-preview doc-preview: @echo "Preview your markdown documentation on this page: https://registry.terraform.io/tools/doc-preview" \ No newline at end of file diff --git a/examples/resources/container_registry/main.tf b/examples/resources/container_registry/main.tf new file mode 100644 index 0000000..cb6e33f --- /dev/null +++ b/examples/resources/container_registry/main.tf @@ -0,0 +1,26 @@ +terraform { + required_providers { + coderforge = { + source = "registry.terraform.io/coderforge/coderforge" + } + } +} + +provider "coderforge" { + stack_id = "stack-helloworld-dev" + cloud_space = "helloworld.dev.coderforge.org" + locations = ["gbr-1", "gbr-2", "ita-1"] +} + +resource "coderforge_container_registry" "example" { + name = "demo-container" + runtime = "docker" + image_uri = "docker.coderforge.org/demo:latest" + timeout = 120 + max_ram_size = "512MB" +} + +output "container_registry" { + value = coderforge_container_registry.example +} + diff --git a/examples/resources/function/main.tf b/examples/resources/function/main.tf index 587815e..8aa0a20 100644 --- a/examples/resources/function/main.tf +++ b/examples/resources/function/main.tf @@ -1,8 +1,7 @@ terraform { required_providers { coderforge = { - source = "coderforge/coderforge" - version = "0.1.2" + source = "registry.terraform.io/coderforge/coderforge" } } } diff --git a/internal/provider/client.go b/internal/provider/client.go index 0801fd6..6dfc49a 100644 --- a/internal/provider/client.go +++ b/internal/provider/client.go @@ -1,14 +1,17 @@ package provider import ( + "context" + "errors" "fmt" "io" - "log" "net/http" "time" ) -const HostURL string = "https://api.coderforge.org" +const defaultHostURL string = "https://api.coderforge.org" + +var ErrNotFound = errors.New("not_found") type Client struct { StackId string @@ -19,10 +22,14 @@ type Client struct { Locations []string } -func NewClient(token *string, cloudSpace *string, locations *[]string, stackId *string) (*Client, error) { +func NewClient(token *string, cloudSpace *string, locations *[]string, stackId *string, hostURL *string) (*Client, error) { + host := defaultHostURL + if hostURL != nil && *hostURL != "" { + host = *hostURL + } c := Client{ StackId: *stackId, - HostURL: HostURL, + HostURL: host, HTTPClient: &http.Client{Timeout: 10 * time.Second}, Token: *token, CloudSpace: *cloudSpace, @@ -31,29 +38,35 @@ func NewClient(token *string, cloudSpace *string, locations *[]string, stackId * return &c, nil } -func (c *Client) doRequest(req *http.Request) ([]byte, error) { - // req.Header.Set("Authorization", "Bearer "+c.Token) +func (c *Client) doRequest(ctx context.Context, req *http.Request) ([]byte, error) { + if ctx == nil { + ctx = context.Background() + } + req = req.WithContext(ctx) + if c.Token != "" { + req.Header.Set("Authorization", "Bearer "+c.Token) + } req.Header.Set("X-CoderForge.org-Context", "{\"userId\": \"u00001\"}") req.Header.Set("Content-Type", "application/json") res, err := c.HTTPClient.Do(req) if err != nil { return nil, err } - defer func(Body io.ReadCloser) { - err := Body.Close() - if err != nil { - log.Fatal(err) - } - }(res.Body) + defer func(Body io.ReadCloser) { + _ = Body.Close() + }(res.Body) body, err := io.ReadAll(res.Body) if err != nil { return nil, err } - if res.StatusCode != http.StatusOK { - return nil, fmt.Errorf("status: %d, body: %s", res.StatusCode, body) - } + if res.StatusCode < 200 || res.StatusCode >= 300 { + if res.StatusCode == http.StatusNotFound { + return nil, ErrNotFound + } + return nil, fmt.Errorf("status: %d, body: %s", res.StatusCode, body) + } return body, err } diff --git a/internal/provider/container_registry_resource.go b/internal/provider/container_registry_resource.go new file mode 100644 index 0000000..ad3141a --- /dev/null +++ b/internal/provider/container_registry_resource.go @@ -0,0 +1,244 @@ +package provider + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +var ( + _ resource.Resource = &containerRegistryResource{} + _ resource.ResourceWithConfigure = &containerRegistryResource{} +) + +func NewContainerRegistryResource() resource.Resource { + return &containerRegistryResource{} +} + +type containerRegistryResourceModel struct { + ID types.String `tfsdk:"id"` + Name types.String `tfsdk:"name"` + Runtime types.String `tfsdk:"runtime"` + ImageUri types.String `tfsdk:"image_uri"` + Timeout types.Int64 `tfsdk:"timeout"` + MaxRamSize types.String `tfsdk:"max_ram_size"` + LastUpdated types.String `tfsdk:"last_updated"` +} + +type containerRegistryResource struct { + client *Client +} + +func (r *containerRegistryResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_container_registry" +} + +func (r *containerRegistryResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + }, + "last_updated": schema.StringAttribute{ + Computed: true, + }, + "name": schema.StringAttribute{ + Computed: false, + Optional: true, + }, + "image_uri": schema.StringAttribute{ + Computed: false, + Optional: true, + }, + "runtime": schema.StringAttribute{ + Computed: false, + Required: true, + }, + "timeout": schema.Int64Attribute{ + Computed: false, + Optional: true, + }, + "max_ram_size": schema.StringAttribute{ + Computed: false, + Optional: true, + }, + }, + } +} + +// Create a new resource. +func (r *containerRegistryResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + // Retrieve values from plan + var plan containerRegistryResourceModel + diags := req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Generate API request body from plan + var resourceItem ResourceItem + resourceItem.Type = "container" + resourceItem.Name = plan.Name.ValueString() + code := Code{ + ImageUri: plan.ImageUri.ValueString(), + Runtime: plan.Runtime.ValueString(), + } + resourceItem.Code = code + resourceItem.Timeout = plan.Timeout.ValueInt64() + resourceItem.MaxRamSize = plan.MaxRamSize.ValueString() + resourceItemRes, err := r.client.CreateResource(ctx, resourceItem) + if err != nil { + resp.Diagnostics.AddError( + "Error creating resource", + "Could not create resource, unexpected error: "+err.Error(), + ) + return + } + + // Map response body to schema and populate Computed attribute values + plan.ID = types.StringValue(resourceItemRes.ID) + plan.Name = types.StringValue(resourceItemRes.Name) + plan.ImageUri = types.StringValue(resourceItemRes.Code.ImageUri) + plan.Runtime = types.StringValue(resourceItemRes.Code.Runtime) + plan.Timeout = types.Int64Value(resourceItemRes.Timeout) + plan.MaxRamSize = types.StringValue(resourceItemRes.MaxRamSize) + plan.LastUpdated = types.StringValue(time.Now().Format(time.RFC850)) + + diags = resp.State.Set(ctx, plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } +} + +// Read resource information. +func (r *containerRegistryResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state containerRegistryResourceModel + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + resourceItemRes, err := r.client.GetResource(ctx, state.ID.ValueString()) + if err != nil { + if errors.Is(err, ErrNotFound) { + resp.State.RemoveResource(ctx) + return + } + resp.Diagnostics.AddError( + "Error Reading Resource", + "Could not read resource ID "+state.ID.ValueString()+": "+err.Error(), + ) + return + } + if resourceItemRes == nil { + resp.State.RemoveResource(ctx) + return + } + state.ID = types.StringValue(resourceItemRes.ID) + state.Name = types.StringValue(resourceItemRes.Name) + state.ImageUri = types.StringValue(resourceItemRes.Code.ImageUri) + state.Runtime = types.StringValue(resourceItemRes.Code.Runtime) + state.Timeout = types.Int64Value(resourceItemRes.Timeout) + state.MaxRamSize = types.StringValue(resourceItemRes.MaxRamSize) + diags = resp.State.Set(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } +} + +// Update updates the resource and sets the updated Terraform state on success. +func (r *containerRegistryResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan containerRegistryResourceModel + diags := req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + var state containerRegistryResourceModel + diagsState := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diagsState...) + if resp.Diagnostics.HasError() { + return + } + var resourceItem ResourceItem + resourceItem.ID = state.ID.ValueString() + resourceItem.Type = "container" + resourceItem.Name = plan.Name.ValueString() + code := Code{ + ImageUri: plan.ImageUri.ValueString(), + Runtime: plan.Runtime.ValueString(), + } + resourceItem.Code = code + resourceItem.Timeout = plan.Timeout.ValueInt64() + resourceItem.MaxRamSize = plan.MaxRamSize.ValueString() + resourceItemRes, err := r.client.UpdateResource(ctx, resourceItem) + if err != nil { + resp.Diagnostics.AddError( + "Error updating resource", + "Could not update resource, unexpected error: "+err.Error(), + ) + return + } + plan.ID = types.StringValue(resourceItemRes.ID) + plan.Name = types.StringValue(resourceItemRes.Name) + plan.ImageUri = types.StringValue(resourceItemRes.Code.ImageUri) + plan.Runtime = types.StringValue(resourceItemRes.Code.Runtime) + plan.Timeout = types.Int64Value(resourceItemRes.Timeout) + plan.MaxRamSize = types.StringValue(resourceItemRes.MaxRamSize) + plan.LastUpdated = types.StringValue(time.Now().Format(time.RFC850)) + diags = resp.State.Set(ctx, plan) + resp.Diagnostics.Append(diags...) + resp.Diagnostics.Append(diagsState...) + if resp.Diagnostics.HasError() { + return + } +} + +// Delete deletes the resource and removes the Terraform state on success. +func (r *containerRegistryResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + // Retrieve values from plan + var plan containerRegistryResourceModel + diags := req.State.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + err := r.client.DeleteResource(ctx, plan.ID.ValueString()) + if err != nil { + resp.Diagnostics.AddError( + "Error deleting resource", + "Could not delete resource, unexpected error: "+err.Error(), + ) + } + return +} + +// Configure adds the provider configured client to the resource. +func (r *containerRegistryResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + // Add a nil check when handling ProviderData because Terraform + // sets that data after it calls the ConfigureProvider RPC. + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(*Client) + + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf("Expected *provider.Client, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + + return + } + + r.client = client +} + diff --git a/internal/provider/container_resource.go b/internal/provider/container_resource.go index 751936f..0529f6b 100644 --- a/internal/provider/container_resource.go +++ b/internal/provider/container_resource.go @@ -2,6 +2,7 @@ package provider import ( "context" + "errors" "fmt" "time" @@ -125,15 +126,22 @@ func (r *containerResource) Read(ctx context.Context, req resource.ReadRequest, return } - // Get refreshed order value from HashiCups resourceItemRes, err := r.client.GetResource(ctx, state.ID.ValueString()) if err != nil { + if errors.Is(err, ErrNotFound) { + resp.State.RemoveResource(ctx) + return + } resp.Diagnostics.AddError( "Error Reading Resource", "Could not read resource ID "+state.ID.ValueString()+": "+err.Error(), ) return } + if resourceItemRes == nil { + resp.State.RemoveResource(ctx) + return + } state.ID = types.StringValue(resourceItemRes.ID) state.Name = types.StringValue(resourceItemRes.Name) state.ImageUri = types.StringValue(resourceItemRes.Code.ImageUri) @@ -172,17 +180,17 @@ func (r *containerResource) Update(ctx context.Context, req resource.UpdateReque resourceItemRes, err := r.client.UpdateResource(ctx, resourceItem) if err != nil { resp.Diagnostics.AddError( - "Error creating order", - "Could not create order, unexpected error: "+err.Error(), + "Error updating resource", + "Could not update resource, unexpected error: "+err.Error(), ) return } plan.ID = types.StringValue(resourceItemRes.ID) - state.Name = types.StringValue(resourceItemRes.Name) - state.ImageUri = types.StringValue(resourceItemRes.Code.ImageUri) - state.Runtime = types.StringValue(resourceItemRes.Code.Runtime) - state.Timeout = types.Int64Value(resourceItemRes.Timeout) - state.MaxRamSize = types.StringValue(resourceItemRes.MaxRamSize) + plan.Name = types.StringValue(resourceItemRes.Name) + plan.ImageUri = types.StringValue(resourceItemRes.Code.ImageUri) + plan.Runtime = types.StringValue(resourceItemRes.Code.Runtime) + plan.Timeout = types.Int64Value(resourceItemRes.Timeout) + plan.MaxRamSize = types.StringValue(resourceItemRes.MaxRamSize) plan.LastUpdated = types.StringValue(time.Now().Format(time.RFC850)) diags = resp.State.Set(ctx, plan) resp.Diagnostics.Append(diags...) @@ -224,8 +232,8 @@ func (r *containerResource) Configure(_ context.Context, req resource.ConfigureR if !ok { resp.Diagnostics.AddError( - "Unexpected Data Source Configure Type", - fmt.Sprintf("Expected *hashicups.Client, got: %T. Please report this issue to the provider developers.", req.ProviderData), + "Unexpected Resource Configure Type", + fmt.Sprintf("Expected *provider.Client, got: %T. Please report this issue to the provider developers.", req.ProviderData), ) return diff --git a/internal/provider/function_resource.go b/internal/provider/function_resource.go index cddc5a5..a0d79ec 100644 --- a/internal/provider/function_resource.go +++ b/internal/provider/function_resource.go @@ -2,6 +2,7 @@ package provider import ( "context" + "errors" "fmt" "time" @@ -115,16 +116,14 @@ func (r *functionResource) Create(ctx context.Context, req resource.CreateReques return } - // Map response body to schema and populate Computed attribute values + // Map response body to schema and populate Computed attribute values plan.ID = types.StringValue(resourceItemRes.ID) plan.FunctionName = types.StringValue(resourceItemRes.Name) - if &resourceItemRes.Code != nil { - plan.Code = functionCodeModel{ - PackageType: types.StringValue(resourceItemRes.Code.PackageType), - ImageUri: types.StringValue(resourceItemRes.Code.ImageUri), - Runtime: types.StringValue(resourceItemRes.Code.Runtime), - } - } + plan.Code = functionCodeModel{ + PackageType: types.StringValue(resourceItemRes.Code.PackageType), + ImageUri: types.StringValue(resourceItemRes.Code.ImageUri), + Runtime: types.StringValue(resourceItemRes.Code.Runtime), + } plan.Timeout = types.Int64Value(resourceItemRes.Timeout) plan.MaxRamSize = types.StringValue(resourceItemRes.MaxRamSize) plan.LastUpdated = types.StringValue(time.Now().Format(time.RFC850)) @@ -145,15 +144,22 @@ func (r *functionResource) Read(ctx context.Context, req resource.ReadRequest, r return } - // Get refreshed order value from HashiCups - resourceItemRes, err := r.client.GetResource(ctx, state.ID.ValueString()) - if err != nil { - resp.Diagnostics.AddError( - "Error Reading Resource", - "Could not read resource ID "+state.ID.ValueString()+": "+err.Error(), - ) - return - } + resourceItemRes, err := r.client.GetResource(ctx, state.ID.ValueString()) + if err != nil { + if errors.Is(err, ErrNotFound) { + resp.State.RemoveResource(ctx) + return + } + resp.Diagnostics.AddError( + "Error Reading Resource", + "Could not read resource ID "+state.ID.ValueString()+": "+err.Error(), + ) + return + } + if resourceItemRes == nil { + resp.State.RemoveResource(ctx) + return + } state.ID = types.StringValue(resourceItemRes.ID) state.FunctionName = types.StringValue(resourceItemRes.Name) state.Code.PackageType = types.StringValue(resourceItemRes.Code.PackageType) @@ -182,31 +188,30 @@ func (r *functionResource) Update(ctx context.Context, req resource.UpdateReques var resourceItem ResourceItem resourceItem.Type = "function" resourceItem.Name = plan.FunctionName.ValueString() - code := Code{ - PackageType: plan.Code.PackageType.ValueString(), - ImageUri: plan.Code.ImageUri.ValueString(), - } + code := Code{ + PackageType: plan.Code.PackageType.ValueString(), + ImageUri: plan.Code.ImageUri.ValueString(), + Runtime: plan.Code.Runtime.ValueString(), + } resourceItem.Code = code resourceItem.Timeout = plan.Timeout.ValueInt64() resourceItem.MaxRamSize = plan.MaxRamSize.ValueString() resourceItem.ID = state.ID.ValueString() resourceItemRes, err := r.client.UpdateResource(ctx, resourceItem) - if err != nil { - resp.Diagnostics.AddError( - "Error creating order", - "Could not create order, unexpected error: "+err.Error(), - ) - return - } + if err != nil { + resp.Diagnostics.AddError( + "Error updating resource", + "Could not update resource, unexpected error: "+err.Error(), + ) + return + } plan.ID = types.StringValue(resourceItemRes.ID) plan.FunctionName = types.StringValue(resourceItemRes.Name) - if &resourceItemRes.Code != nil { - plan.Code = functionCodeModel{ - PackageType: types.StringValue(resourceItemRes.Code.PackageType), - ImageUri: types.StringValue(resourceItemRes.Code.ImageUri), - Runtime: types.StringValue(resourceItemRes.Code.Runtime), - } - } + plan.Code = functionCodeModel{ + PackageType: types.StringValue(resourceItemRes.Code.PackageType), + ImageUri: types.StringValue(resourceItemRes.Code.ImageUri), + Runtime: types.StringValue(resourceItemRes.Code.Runtime), + } plan.MaxRamSize = types.StringValue(resourceItemRes.MaxRamSize) plan.Timeout = types.Int64Value(resourceItemRes.Timeout) plan.LastUpdated = types.StringValue(time.Now().Format(time.RFC850)) @@ -250,8 +255,8 @@ func (r *functionResource) Configure(_ context.Context, req resource.ConfigureRe if !ok { resp.Diagnostics.AddError( - "Unexpected Data Source Configure Type", - fmt.Sprintf("Expected *hashicups.Client, got: %T. Please report this issue to the provider developers.", req.ProviderData), + "Unexpected Resource Configure Type", + fmt.Sprintf("Expected *provider.Client, got: %T. Please report this issue to the provider developers.", req.ProviderData), ) return diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 14cfd4e..061e706 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -32,6 +32,7 @@ type coderforgeProviderModel struct { CloudSpace types.String `tfsdk:"cloud_space"` Locations []types.String `tfsdk:"locations"` StackId types.String `tfsdk:"stack_id"` + HostURL types.String `tfsdk:"host_url"` } // coderforgeProvider is the provider implementation. @@ -66,6 +67,9 @@ func (p *coderforgeProvider) Schema(_ context.Context, _ provider.SchemaRequest, "stack_id": schema.StringAttribute{ Optional: true, }, + "host_url": schema.StringAttribute{ + Optional: true, + }, }, } } @@ -81,30 +85,30 @@ func (p *coderforgeProvider) Configure(ctx context.Context, req provider.Configu return } - var token string - - if !config.Token.IsNull() { - token = config.Token.ValueString() - } else { - token = os.Getenv("CODERFORGE_CLOUD_TOKEN") - } - - if token == "" { - resp.Diagnostics.AddAttributeError( - path.Root("token"), - "Missing CoderForge.org API API Password", - "The provider cannot create the CoderForge.org API API client as there is a missing or empty value for the CoderForge.org API token. "+ - "Set the token value in the configuration or use the CODERFORGE_PASSWORD environment variable. "+ - "If either is already set, ensure the value is not empty.", - ) - } - - if config.CloudSpace.IsNull() { + var token string + if !config.Token.IsNull() && !config.Token.IsUnknown() { + token = config.Token.ValueString() + } else { + token = os.Getenv("CODERFORGE_CLOUD_TOKEN") + if token == "" { + token = os.Getenv("CODERFORGE_TOKEN") + } + } + + if token == "" { + resp.Diagnostics.AddAttributeError( + path.Root("token"), + "Missing CoderForge.org API token", + "The provider cannot create the CoderForge.org API client because the API token is missing. "+ + "Set the token in the provider configuration or via the CODERFORGE_CLOUD_TOKEN (preferred) or CODERFORGE_TOKEN environment variable.", + ) + } + + if config.CloudSpace.IsNull() || config.CloudSpace.IsUnknown() { resp.Diagnostics.AddAttributeError( path.Root("cloud_space"), - "Missing CoderForge.org API API cloud_space", - "The provider cannot create the CoderForge.org API API client as there is a missing or empty value for the CoderForge.org API cloud_space. "+ - "Set the cloud_space inside the provider.", + "Missing CoderForge.org cloud_space", + "The provider cannot create the CoderForge.org API client because cloud_space is missing. Set cloud_space in the provider configuration.", ) } @@ -112,11 +116,24 @@ func (p *coderforgeProvider) Configure(ctx context.Context, req provider.Configu return } - var cloudSpace = config.CloudSpace.ValueString() - var stackId = config.StackId.ValueString() - - ctx = tflog.SetField(ctx, "coderforge_cloud_token", token) - ctx = tflog.MaskFieldValuesWithFieldKeys(ctx, "coderforge_password") + var cloudSpace = config.CloudSpace.ValueString() + var stackId = config.StackId.ValueString() + var hostURLStr string + if !config.HostURL.IsNull() && !config.HostURL.IsUnknown() { + hostURLStr = config.HostURL.ValueString() + } + if hostURLStr == "" { + // Allow environment variable override for local/dev testing + // Prefer CODERFORGE_API_URL, then CODERFORGE_HOST + if v := os.Getenv("CODERFORGE_API_URL"); v != "" { + hostURLStr = v + } else if v := os.Getenv("CODERFORGE_HOST"); v != "" { + hostURLStr = v + } + } + + // Never log tokens; mask any potential fields + ctx = tflog.MaskFieldValuesWithFieldKeys(ctx, "token", "coderforge_token", "coderforge_cloud_token") tflog.Debug(ctx, "Creating CoderForge.org client") @@ -125,8 +142,12 @@ func (p *coderforgeProvider) Configure(ctx context.Context, req provider.Configu locations = append(locations, location.ValueString()) } - // Create a new CoderForge.org client using the configuration values - client, err := NewClient(&token, &cloudSpace, &locations, &stackId) + // Create a new CoderForge.org client using the configuration values + var hostOverride *string + if hostURLStr != "" { + hostOverride = &hostURLStr + } + client, err := NewClient(&token, &cloudSpace, &locations, &stackId, hostOverride) if err != nil { resp.Diagnostics.AddError( "Unable to Create CoderForge.org API Client", @@ -153,6 +174,7 @@ func (p *coderforgeProvider) DataSources(_ context.Context) []func() datasource. // Resources defines the resources implemented in the provider. func (p *coderforgeProvider) Resources(_ context.Context) []func() resource.Resource { return []func() resource.Resource{ - NewFunctionResource, + NewFunctionResource, + NewContainerRegistryResource, } } diff --git a/internal/provider/resources.go b/internal/provider/resources.go index d4c64db..5bf38f8 100644 --- a/internal/provider/resources.go +++ b/internal/provider/resources.go @@ -3,6 +3,7 @@ package provider import ( "context" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -15,10 +16,13 @@ func (c *Client) GetResource(ctx context.Context, resourceID string) (*ResourceI return nil, err } - body, err := c.doRequest(req) - if err != nil { - return nil, err - } + body, err := c.doRequest(ctx, req) + if err != nil { + if errors.Is(err, ErrNotFound) { + return nil, ErrNotFound + } + return nil, err + } cloudData := CloudData{} err = json.Unmarshal(body, &cloudData) @@ -28,11 +32,11 @@ func (c *Client) GetResource(ctx context.Context, resourceID string) (*ResourceI resourceItems := &cloudData.ResourceItems - if len(*resourceItems) > 0 { - return &(*resourceItems)[0], nil - } + if len(*resourceItems) > 0 { + return &(*resourceItems)[0], nil + } - return nil, nil + return nil, ErrNotFound } func (c *Client) CreateResource(ctx context.Context, resourceItem ResourceItem) (*ResourceItem, error) { @@ -55,7 +59,7 @@ func (c *Client) CreateResource(ctx context.Context, resourceItem ResourceItem) return nil, err } - body, err := c.doRequest(req) + body, err := c.doRequest(ctx, req) if err != nil { return nil, err } @@ -92,7 +96,7 @@ func (c *Client) UpdateResource(ctx context.Context, resourceItem ResourceItem) if err != nil { return nil, err } - body, err := c.doRequest(req) + body, err := c.doRequest(ctx, req) if err != nil { return nil, err } @@ -122,7 +126,7 @@ func (c *Client) DeleteResource(ctx context.Context, resourceID string) error { if err != nil { return err } - body, err := c.doRequest(req) + body, err := c.doRequest(ctx, req) if err != nil { return err } diff --git a/main.go b/main.go index d8513f3..226e7c7 100644 --- a/main.go +++ b/main.go @@ -28,12 +28,7 @@ func main() { flag.Parse() opts := providerserver.ServeOpts{ - // NOTE: This is not a typical Terraform Registry provider address, - // such as registry.terraform.io/hashicorp/hashicups. This specific - // provider address is used in these tutorials in conjunction with a - // specific Terraform CLI configuration for manual development testing - // of this provider. - Address: "terraform.coderforge.org/coderforge/coderforge", + Address: "registry.terraform.io/coderforge/coderforge", Debug: debug, } diff --git a/registry.terraform.io/coderforge/coderforge b/registry.terraform.io/coderforge/coderforge new file mode 100755 index 0000000..54e9aad Binary files /dev/null and b/registry.terraform.io/coderforge/coderforge differ diff --git a/scripts/test_local_server.bat b/scripts/test_local_server.bat new file mode 100644 index 0000000..a8d49d2 --- /dev/null +++ b/scripts/test_local_server.bat @@ -0,0 +1,143 @@ +@echo off +setlocal enabledelayedexpansion + +REM Default options +set "HOST_URL=http://127.0.0.1:8080" +set "TOKEN=dev-token" + +if /I "%~1"=="-h" goto :usage +if /I "%~1"=="--help" goto :usage + +:parse +if "%~1"=="" goto :after_parse +if /I "%~1"=="--host" ( + if "%~2"=="" goto :usage + set "HOST_URL=%~2" + shift + shift + goto :parse +) +if /I "%~1"=="--token" ( + if "%~2"=="" goto :usage + set "TOKEN=%~2" + shift + shift + goto :parse +) +echo Unknown arg: %~1 +goto :usage + +:usage +echo Usage: scripts\test_local_server.bat [--host URL] [--token TOKEN] +echo. +echo Runs a local test against a running CoderForge API server: +echo - Builds the dev provider +echo - Uses .terraformrc dev override +echo - For each example: init, validate, plan, apply, output, destroy +echo. +echo Options: +echo --host Local API base URL ^(default: http://127.0.0.1:8080^) +echo --token API token to use ^(default: dev-token^) +exit /b 1 + +:after_parse + +REM Resolve repo root: prefer git, else script dir parent, else CWD +set "ROOT_DIR=" +where git >nul 2>nul +if not errorlevel 1 ( + for /f "delims=" %%G in ('git rev-parse --show-toplevel 2^>nul') do set "ROOT_DIR=%%G" +) +if "%ROOT_DIR%"=="" ( + set "SCRIPT_DIR=%~dp0" + if "%SCRIPT_DIR:~-1%"=="\" set "SCRIPT_DIR=%SCRIPT_DIR:~0,-1%" + for %%I in ("%SCRIPT_DIR%\..") do set "ROOT_DIR=%%~fI" +) +if not exist "%ROOT_DIR%\go.mod" ( + set "ROOT_DIR=%CD%" +) +if not exist "%ROOT_DIR%\go.mod" ( + echo ERROR: could not locate go.mod. Run this script from within the repository. + exit /b 3 +) + +REM Check dependencies +where go >nul 2>nul +if errorlevel 1 ( + echo ERROR: go is required on PATH + exit /b 2 +) +where terraform >nul 2>nul +if errorlevel 1 ( + echo ERROR: terraform is required on PATH + exit /b 2 +) + +REM Environment for Terraform CLI +set "CODERFORGE_API_URL=%HOST_URL%" +set "CODERFORGE_CLOUD_TOKEN=%TOKEN%" + +REM Build a per-run Terraform CLI config with correct override path (Windows-safe) +set "TF_CLI_CONFIG_FILE=%ROOT_DIR%\.terraformrc.dev" +echo provider_installation {>"%TF_CLI_CONFIG_FILE%" +echo dev_overrides {>>"%TF_CLI_CONFIG_FILE%" +echo "registry.terraform.io/coderforge/coderforge" = "%ROOT_DIR%">>"%TF_CLI_CONFIG_FILE%" +echo }>>"%TF_CLI_CONFIG_FILE%" +echo direct {}>>"%TF_CLI_CONFIG_FILE%" +echo }>>"%TF_CLI_CONFIG_FILE%" + +echo Building dev provider ... +pushd "%ROOT_DIR%" >nul +if not exist registry.terraform.io mkdir registry.terraform.io >nul 2>nul +if not exist registry.terraform.io\coderforge mkdir registry.terraform.io\coderforge >nul 2>nul +if not exist registry.terraform.io\coderforge\coderforge mkdir registry.terraform.io\coderforge\coderforge >nul 2>nul +go build -o registry.terraform.io\coderforge\coderforge\terraform-provider-coderforge.exe +if errorlevel 1 ( + popd >nul + exit /b 1 +) +popd >nul + +call :run_example examples\resources\function +if errorlevel 1 exit /b 1 +call :run_example examples\resources\container_registry +if errorlevel 1 exit /b 1 + +echo. +echo All local tests completed successfully. +exit /b 0 + +:run_example +set "EX_DIR=%~1" +echo. +echo === Testing example: %EX_DIR% === +pushd "%ROOT_DIR%\%EX_DIR%" >nul +terraform init -input=false -upgrade +if errorlevel 1 ( + popd >nul + exit /b 1 +) +terraform validate +if errorlevel 1 ( + popd >nul + exit /b 1 +) +terraform plan -out=tfplan +if errorlevel 1 ( + popd >nul + exit /b 1 +) +terraform apply -auto-approve tfplan +if errorlevel 1 ( + popd >nul + exit /b 1 +) +terraform output -json >nul 2>nul +terraform destroy -auto-approve +if errorlevel 1 ( + echo WARN: destroy failed; attempting re-run once + terraform destroy -auto-approve >nul 2>nul +) +popd >nul +exit /b 0 + diff --git a/scripts/test_local_server.sh b/scripts/test_local_server.sh new file mode 100755 index 0000000..dd77d55 --- /dev/null +++ b/scripts/test_local_server.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash + +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: scripts/test_local_server.sh [--host URL] [--token TOKEN] + +Runs a full local test against a running CoderForge API server: + - Builds the dev provider binary + - Uses .terraformrc dev override for local provider + - For each example (function, container_registry): + init -> validate -> plan -> apply -> output -> destroy + +Options: + --host Local API base URL (default: http://127.0.0.1:8080) + --token API token to use (default: dev-token) + +Environment overrides: + CODERFORGE_API_URL, CODERFORGE_HOST, CODERFORGE_CLOUD_TOKEN, CODERFORGE_TOKEN +EOF +} + +ROOT_DIR="$(git rev-parse --show-toplevel 2>/dev/null || true)" +if [[ -z "${ROOT_DIR}" ]]; then + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)" + ROOT_DIR="${SCRIPT_DIR%/scripts}" +fi + +HOST_URL="http://127.0.0.1:8080" +TOKEN="dev-token" + +while [[ $# -gt 0 ]]; do + case "$1" in + --host) + HOST_URL="$2"; shift 2 ;; + --token) + TOKEN="$2"; shift 2 ;; + -h|--help) + usage; exit 0 ;; + *) + echo "Unknown arg: $1" >&2; usage; exit 1 ;; + esac +done + +if ! command -v go >/dev/null 2>&1; then + echo "go is required on PATH" >&2 + exit 2 +fi + +if ! command -v terraform >/dev/null 2>&1; then + echo "terraform is required on PATH" >&2 + exit 2 +fi + +export TF_CLI_CONFIG_FILE="${ROOT_DIR}/.terraformrc" +export CODERFORGE_API_URL="${HOST_URL}" +export CODERFORGE_CLOUD_TOKEN="${TOKEN}" + +echo "Building dev provider ..." +( + cd "${ROOT_DIR}" + mkdir -p registry.terraform.io/coderforge >/dev/null 2>&1 || true + go build -o registry.terraform.io/coderforge/coderforge +) + +run_example() { + local example_dir="$1" + echo "\n=== Testing example: ${example_dir} ===" + ( + cd "${ROOT_DIR}/${example_dir}" + terraform init -input=false -upgrade + terraform validate + terraform plan -out=tfplan + terraform apply -auto-approve tfplan + terraform output -json || true + terraform destroy -auto-approve || { + echo "WARN: destroy failed; attempting re-run once" >&2 + terraform destroy -auto-approve || true + } + ) +} + +run_example examples/resources/function +run_example examples/resources/container_registry + +echo "\nAll local tests completed successfully." + diff --git a/terraform.coderforge.org/coderforge/coderforge b/terraform.coderforge.org/coderforge/coderforge new file mode 100755 index 0000000..dd276e9 Binary files /dev/null and b/terraform.coderforge.org/coderforge/coderforge differ