From 61f3fe3afe83049f431a86e205fcc6638798a6c6 Mon Sep 17 00:00:00 2001 From: Alberto Donato Date: Fri, 23 Oct 2026 09:21:29 +0200 Subject: [PATCH] feat: add client for the IaC API ### what Add `internal/api/iac`, a JSON REST client for the IaC (Sinistral) API, built on the shared transports from `internal/api`. Non-successful responses are converted to `api.NotFound` (404) or `api.APIError`, reporting the FastAPI `detail` message (including validation errors) or the API gateway `message`. Add the `iac_endpoint` provider attribute (also `STACKLET_IAC_ENDPOINT` environment variable) for the IaC API endpoint. If not set, it's derived from the Stacklet API endpoint by replacing the `api.` hostname prefix with `api.sinistral.`. The IaC client uses the same API key as the platform one. Provider data now includes an `IaCAPI` client, which is exposed to resources and data sources as `iacAPI`. If the IaC endpoint can't be determined, the provider configuration still succeeds, and an error is reported when IaC resources or data sources are used. ### why Allow adding `stacklet_iac_*` resources and data sources. ### testing Unit tests against a local HTTP server for all request methods, path escaping and error responses, and for endpoint resolution precedence and derivation. `just lint` and `just test` pass. ### docs Regenerated provider docs, updated dev docs. --- dev-docs/architecture.md | 8 +- dev-docs/development.md | 4 +- docs/index.md | 3 + internal/api/iac/api.go | 15 +++ internal/api/iac/client.go | 129 ++++++++++++++++++ internal/api/iac/client_test.go | 186 ++++++++++++++++++++++++++ internal/api/iac/error.go | 73 ++++++++++ internal/datasources/datasource.go | 5 +- internal/provider/provider.go | 59 +++++++- internal/provider/provider_test.go | 73 ++++++++++ internal/providerdata/providerdata.go | 9 +- internal/resources/resource.go | 5 +- 12 files changed, 558 insertions(+), 11 deletions(-) create mode 100644 internal/api/iac/api.go create mode 100644 internal/api/iac/client.go create mode 100644 internal/api/iac/client_test.go create mode 100644 internal/api/iac/error.go diff --git a/dev-docs/architecture.md b/dev-docs/architecture.md index 1d8a69dc..5794e738 100644 --- a/dev-docs/architecture.md +++ b/dev-docs/architecture.md @@ -8,6 +8,7 @@ ├── internal/ │ ├── provider/ # Provider configuration and registration │ ├── api/ # Shared HTTP transports and errors for API clients +│ │ ├── iac/ # IaC (Sinistral) REST API client │ │ └── platform/ # Platform GraphQL API client and wrappers │ ├── resources/ # Terraform resource implementations │ ├── datasources/ # Terraform data source implementations @@ -37,6 +38,10 @@ - **Filtering**: Filter API in `internal/api/platform/filter.go` for constructing GraphQL filter queries - `filterElementInput` and `filterValueInput` types for building filter expressions - `newExactMatchFilter()` helper for creating exact-match filters with "equals" operator +- **REST Client**: The IaC client (`internal/api/iac/`) talks to the IaC (Sinistral) REST API, using the same API key as the platform one + - `Get()`, `Post()`, `Put()`, `Patch()`, `Delete()` methods for JSON requests + - `resourcePath()` builds request paths, escaping each component + - Error responses are converted to `api.NotFound` (404) or `api.APIError`, reporting FastAPI `detail` messages (including validation errors) ## Resource Architecture @@ -117,7 +122,8 @@ Models in `internal/models/` define Terraform schema structure with `tfsdk` stru - New code should use `diag.Diagnostics` directly and the `typehelpers` package functions which all return diagnostics **Provider Data** (`internal/providerdata/`): -- `providerData` struct holds API clients for resources/data sources, built by `New()` +- `providerData` struct holds API clients for resources/data sources, built by `New()`: `API` for the platform API and `IaCAPI` for the IaC API +- Exposed as `api` and `iacAPI` fields in the `apiResource` and `apiDataSource` base types - `GetForResource()` - Retrieves provider data in resources - `GetForDataSource()` - Retrieves provider data in data sources diff --git a/dev-docs/development.md b/dev-docs/development.md index 90f28ab0..b9393a43 100644 --- a/dev-docs/development.md +++ b/dev-docs/development.md @@ -53,7 +53,9 @@ Provider supports multiple authentication methods (in priority order): 2. Environment variables (`STACKLET_ENDPOINT`, `STACKLET_API_KEY`) 3. stacklet-admin CLI (`~/.stacklet/config.json`, `~/.stacklet/credentials`) -Each credential is resolved independently using the first non-empty value found. Unit tests in `internal/provider/provider_test.go` verify precedence and mixed sources. +Each credential is resolved independently using the first non-empty value found. + +The IaC API endpoint (`iac_endpoint`) is resolved from the provider block, then the `STACKLET_IAC_ENDPOINT` environment variable. If neither is set, it's derived from the API endpoint by replacing the `api.` hostname prefix with `api.sinistral.` (e.g. `https://api.example.stacklet.io/` becomes `https://api.sinistral.example.stacklet.io/`). If it can't be derived, IaC resources and data sources fail when used. The API key is shared by both APIs. Unit tests in `internal/provider/provider_test.go` verify precedence and mixed sources. ## Development Tools diff --git a/docs/index.md b/docs/index.md index ad75cba8..ee07004b 100644 --- a/docs/index.md +++ b/docs/index.md @@ -40,3 +40,6 @@ May also be provided via STACKLET_API_KEY environment variable, or from the stac - `endpoint` (String) The endpoint URL of the Stacklet GraphQL API. May also be provided via STACKLET_ENDPOINT environment variable, or from the stacklet-admin CLI configuration. +- `iac_endpoint` (String) The endpoint URL of the Stacklet IaC API. + +May also be provided via STACKLET_IAC_ENDPOINT environment variable. If not set, it's derived from the Stacklet API endpoint. diff --git a/internal/api/iac/api.go b/internal/api/iac/api.go new file mode 100644 index 00000000..091a4743 --- /dev/null +++ b/internal/api/iac/api.go @@ -0,0 +1,15 @@ +// Copyright Stacklet, Inc. 2025, 2026 + +package iac + +// API provides access to the IaC REST API. +type API struct { + c *client +} + +// New creates an API wrapper. +func New(config ClientConfig) *API { + return &API{ + c: newClient(config), + } +} diff --git a/internal/api/iac/client.go b/internal/api/iac/client.go new file mode 100644 index 00000000..00fe9dc0 --- /dev/null +++ b/internal/api/iac/client.go @@ -0,0 +1,129 @@ +// Copyright Stacklet, Inc. 2025, 2026 + +// package iac provides access to the IaC (Sinistral) REST API. +package iac + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "github.com/stacklet/terraform-provider-stacklet/internal/api" +) + +// ClientConfig is the configuration for the API client. +type ClientConfig struct { + Endpoint string + APIKey string + Version string +} + +// client is a JSON client for the REST API. +type client struct { + httpClient *http.Client + endpoint string +} + +// newClient returns a configured REST client. +func newClient(config ClientConfig) *client { + return &client{ + httpClient: &http.Client{ + Transport: api.NewTransport(config.APIKey, config.Version), + }, + endpoint: config.Endpoint, + } +} + +// Get performs a GET request, decoding the response in out. +func (c *client) Get(ctx context.Context, path string, query url.Values, out any) error { + return c.do(ctx, http.MethodGet, path, query, nil, out) +} + +// Post performs a POST request with in as body, decoding the response in out. +func (c *client) Post(ctx context.Context, path string, in any, out any) error { + return c.do(ctx, http.MethodPost, path, nil, in, out) +} + +// Put performs a PUT request with in as body, decoding the response in out. +func (c *client) Put(ctx context.Context, path string, in any, out any) error { + return c.do(ctx, http.MethodPut, path, nil, in, out) +} + +// Patch performs a PATCH request with in as body, decoding the response in out. +func (c *client) Patch(ctx context.Context, path string, in any, out any) error { + return c.do(ctx, http.MethodPatch, path, nil, in, out) +} + +// Delete performs a DELETE request, decoding the response in out. +func (c *client) Delete(ctx context.Context, path string, out any) error { + return c.do(ctx, http.MethodDelete, path, nil, nil, out) +} + +func (c *client) do(ctx context.Context, method string, path string, query url.Values, in any, out any) error { + if c.endpoint == "" { + return api.APIError{ + Kind: "Missing IaC API Endpoint", + Detail: "The IaC API endpoint could not be determined. Set the iac_endpoint value in the provider configuration, or the STACKLET_IAC_ENDPOINT environment variable.", + } + } + reqURL, err := url.Parse(c.endpoint) + if err != nil { + return api.NewAPIError(fmt.Errorf("invalid IaC API endpoint: %w", err)) + } + reqURL = reqURL.JoinPath(path) + reqURL.RawQuery = query.Encode() + + var body io.Reader + if in != nil { + content, err := json.Marshal(in) + if err != nil { + return api.NewAPIError(err) + } + body = bytes.NewReader(content) + } + + req, err := http.NewRequestWithContext(ctx, method, reqURL.String(), body) + if err != nil { + return api.NewAPIError(err) + } + req.Header.Set("Accept", "application/json") + if in != nil { + req.Header.Set("Content-Type", "application/json") + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return api.NewAPIError(err) + } + defer resp.Body.Close() + + content, err := io.ReadAll(resp.Body) + if err != nil { + return api.NewAPIError(err) + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return newResponseError(resp.StatusCode, content) + } + if out == nil || len(content) == 0 { + return nil + } + if err := json.Unmarshal(content, out); err != nil { + return api.NewAPIError(fmt.Errorf("invalid API response: %w", err)) + } + return nil +} + +// resourcePath returns an API path from its parts, escaping each of them. +func resourcePath(parts ...string) string { + escaped := make([]string, len(parts)) + for i, part := range parts { + escaped[i] = url.PathEscape(part) + } + return strings.Join(escaped, "/") +} diff --git a/internal/api/iac/client_test.go b/internal/api/iac/client_test.go new file mode 100644 index 00000000..bd85d695 --- /dev/null +++ b/internal/api/iac/client_test.go @@ -0,0 +1,186 @@ +// Copyright Stacklet, Inc. 2025, 2026 + +package iac + +import ( + "io" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/stacklet/terraform-provider-stacklet/internal/api" +) + +type item struct { + Name string `json:"name"` +} + +type capturedRequest struct { + Method string + URI string + Body string + ContentType string + Auth string +} + +func newTestServer(t *testing.T, status int, response string) (*client, *capturedRequest) { + captured := &capturedRequest{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + *captured = capturedRequest{ + Method: r.Method, + URI: r.URL.RequestURI(), + Body: string(body), + ContentType: r.Header.Get("Content-Type"), + Auth: r.Header.Get("Authorization"), + } + w.WriteHeader(status) + _, _ = w.Write([]byte(response)) + })) + t.Cleanup(server.Close) + + c := newClient(ClientConfig{Endpoint: server.URL + "/base/", APIKey: "secret", Version: "1.0.0"}) + return c, captured +} + +func TestClientGet(t *testing.T) { + c, req := newTestServer(t, http.StatusOK, `{"name": "foo"}`) + + var out item + err := c.Get(t.Context(), resourcePath("projects", "a/b c"), url.Values{"page": {"2"}}, &out) + + assert.NoError(t, err) + assert.Equal(t, item{Name: "foo"}, out) + assert.Equal(t, http.MethodGet, req.Method) + assert.Equal(t, "/base/projects/a%2Fb%20c?page=2", req.URI) + assert.Equal(t, "Bearer secret", req.Auth) + assert.Equal(t, "", req.ContentType) +} + +func TestClientWithBody(t *testing.T) { + tests := []struct { + method string + call func(c *client, out *item) error + }{ + { + method: http.MethodPost, + call: func(c *client, out *item) error { + return c.Post(t.Context(), "projects", item{Name: "foo"}, out) + }, + }, + { + method: http.MethodPut, + call: func(c *client, out *item) error { + return c.Put(t.Context(), "projects", item{Name: "foo"}, out) + }, + }, + { + method: http.MethodPatch, + call: func(c *client, out *item) error { + return c.Patch(t.Context(), "projects", item{Name: "foo"}, out) + }, + }, + } + for _, tc := range tests { + t.Run(tc.method, func(t *testing.T) { + c, req := newTestServer(t, http.StatusCreated, `{"name": "foo"}`) + + var out item + err := tc.call(c, &out) + + assert.NoError(t, err) + assert.Equal(t, item{Name: "foo"}, out) + assert.Equal(t, tc.method, req.Method) + assert.Equal(t, "/base/projects", req.URI) + assert.Equal(t, `{"name":"foo"}`, req.Body) + assert.Equal(t, "application/json", req.ContentType) + }) + } +} + +func TestClientDeleteNoContent(t *testing.T) { + c, req := newTestServer(t, http.StatusNoContent, "") + + err := c.Delete(t.Context(), "projects/foo", nil) + + assert.NoError(t, err) + assert.Equal(t, http.MethodDelete, req.Method) + assert.Equal(t, "/base/projects/foo", req.URI) +} + +func TestClientInvalidResponse(t *testing.T) { + c, _ := newTestServer(t, http.StatusOK, "not json") + + var out item + err := c.Get(t.Context(), "projects/foo", nil, &out) + + assert.ErrorContains(t, err, "invalid API response") +} + +func TestClientNoEndpoint(t *testing.T) { + c := newClient(ClientConfig{APIKey: "secret"}) + + err := c.Get(t.Context(), "projects", nil, nil) + + var apiErr api.APIError + assert.ErrorAs(t, err, &apiErr) + assert.Equal(t, "Missing IaC API Endpoint", apiErr.Summary()) +} + +func TestClientErrors(t *testing.T) { + tests := []struct { + name string + status int + response string + err error + }{ + { + name: "not found", + status: http.StatusNotFound, + response: `{"detail": "Project not found"}`, + err: api.NotFound{Message: "Project not found"}, + }, + { + name: "unauthorized", + status: http.StatusUnauthorized, + response: `{"detail": "Invalid Authorization token"}`, + err: api.APIError{Kind: "Authorization Error", Detail: "Invalid Authorization token"}, + }, + { + name: "gateway message", + status: http.StatusForbidden, + response: `{"message": "Forbidden"}`, + err: api.APIError{Kind: "Authorization Error", Detail: "Forbidden"}, + }, + { + name: "validation", + status: http.StatusUnprocessableEntity, + response: `{"detail": [{"loc": ["body", "name"], "msg": "Field required"}, {"loc": ["body", "groups", 0], "msg": "Invalid"}]}`, + err: api.APIError{Kind: "API Error", Detail: "body.name: Field required\nbody.groups.0: Invalid"}, + }, + { + name: "not json", + status: http.StatusInternalServerError, + response: "Internal server error", + err: api.APIError{Kind: "API Error", Detail: "Internal server error"}, + }, + { + name: "empty", + status: http.StatusBadGateway, + response: "", + err: api.APIError{Kind: "API Error", Detail: "Bad Gateway"}, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + c, _ := newTestServer(t, tc.status, tc.response) + + err := c.Get(t.Context(), "projects/foo", nil, nil) + + assert.Equal(t, tc.err, err) + }) + } +} diff --git a/internal/api/iac/error.go b/internal/api/iac/error.go new file mode 100644 index 00000000..d9353035 --- /dev/null +++ b/internal/api/iac/error.go @@ -0,0 +1,73 @@ +// Copyright Stacklet, Inc. 2025, 2026 + +package iac + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + + "github.com/stacklet/terraform-provider-stacklet/internal/api" +) + +// errorPayload is the body of an error response. +// +// The API returns a "detail" field which is either a string or a list of +// validation errors. Errors returned by the API gateway have a "message" field instead. +type errorPayload struct { + Detail json.RawMessage `json:"detail"` + Message string `json:"message"` +} + +type validationError struct { + Loc []any `json:"loc"` + Msg string `json:"msg"` +} + +// newResponseError returns an error for a non-successful API response. +func newResponseError(statusCode int, content []byte) error { + detail := errorDetail(content) + if detail == "" { + detail = http.StatusText(statusCode) + } + + switch statusCode { + case http.StatusNotFound: + return api.NotFound{Message: detail} + case http.StatusUnauthorized, http.StatusForbidden: + return api.APIError{Kind: "Authorization Error", Detail: detail} + default: + return api.APIError{Kind: "API Error", Detail: detail} + } +} + +func errorDetail(content []byte) string { + var payload errorPayload + if err := json.Unmarshal(content, &payload); err != nil { + return strings.TrimSpace(string(content)) + } + + var detail string + if err := json.Unmarshal(payload.Detail, &detail); err == nil { + return detail + } + + var validationErrors []validationError + if err := json.Unmarshal(payload.Detail, &validationErrors); err == nil && len(validationErrors) > 0 { + msgs := make([]string, len(validationErrors)) + for i, e := range validationErrors { + msgs[i] = e.Msg + if len(e.Loc) > 0 { + loc := make([]string, len(e.Loc)) + for j, l := range e.Loc { + loc[j] = fmt.Sprint(l) + } + msgs[i] = strings.Join(loc, ".") + ": " + e.Msg + } + } + return strings.Join(msgs, "\n") + } + + return payload.Message +} diff --git a/internal/datasources/datasource.go b/internal/datasources/datasource.go index 27dd4370..e9867df8 100644 --- a/internal/datasources/datasource.go +++ b/internal/datasources/datasource.go @@ -7,6 +7,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/stacklet/terraform-provider-stacklet/internal/api/iac" "github.com/stacklet/terraform-provider-stacklet/internal/api/platform" "github.com/stacklet/terraform-provider-stacklet/internal/errors" "github.com/stacklet/terraform-provider-stacklet/internal/providerdata" @@ -14,7 +15,8 @@ import ( // apiDataSource is a datasource based on the API. type apiDataSource struct { - api *platform.API + api *platform.API + iacAPI *iac.API } // Configure sets up API access for the datasource. @@ -23,5 +25,6 @@ func (d *apiDataSource) Configure(_ context.Context, req datasource.ConfigureReq errors.AddDiagError(&resp.Diagnostics, err) } else if pd != nil { d.api = pd.API + d.iacAPI = pd.IaCAPI } } diff --git a/internal/provider/provider.go b/internal/provider/provider.go index a83a28f0..f458dd32 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -5,8 +5,10 @@ package provider import ( "context" "encoding/json" + "net/url" "os" "path" + "strings" "github.com/caarlos0/env/v11" "github.com/hashicorp/terraform-plugin-framework/datasource" @@ -17,6 +19,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/stacklet/terraform-provider-stacklet/internal/api/iac" "github.com/stacklet/terraform-provider-stacklet/internal/api/platform" "github.com/stacklet/terraform-provider-stacklet/internal/datasources" "github.com/stacklet/terraform-provider-stacklet/internal/errors" @@ -28,13 +31,15 @@ var _ provider.Provider = &stackletProvider{} // providerModel holds the terraform configuration for the provider. type providerModel struct { - Endpoint types.String `tfsdk:"endpoint"` - APIKey types.String `tfsdk:"api_key"` + Endpoint types.String `tfsdk:"endpoint"` + IaCEndpoint types.String `tfsdk:"iac_endpoint"` + APIKey types.String `tfsdk:"api_key"` } // providerEnv holds environment variables supported by the provider. type providerEnv struct { Endpoint string `env:"STACKLET_ENDPOINT"` + IaCEndpoint string `env:"STACKLET_IAC_ENDPOINT"` APIKey string `env:"STACKLET_API_KEY"` PageSize int `env:"STACKLET_PAGE_SIZE" envDefault:"100"` UnreleasedFeatures bool `env:"STACKLET_UNRELEASED_FEATURES"` @@ -84,6 +89,14 @@ May also be provided via STACKLET_API_KEY environment variable, or from the stac The endpoint URL of the Stacklet GraphQL API. May also be provided via STACKLET_ENDPOINT environment variable, or from the stacklet-admin CLI configuration. +`, + Optional: true, + }, + "iac_endpoint": schema.StringAttribute{ + Description: ` +The endpoint URL of the Stacklet IaC API. + +May also be provided via STACKLET_IAC_ENDPOINT environment variable. If not set, it's derived from the Stacklet API endpoint. `, Optional: true, }, @@ -120,6 +133,11 @@ func (p *stackletProvider) Configure(ctx context.Context, req provider.Configure Version: p.version, PageSize: env.PageSize, }, + iac.ClientConfig{ + Endpoint: creds.IaCEndpoint, + APIKey: creds.APIKey, + Version: p.version, + }, ) resp.ResourceData = providerData resp.DataSourceData = providerData @@ -142,8 +160,9 @@ func envConfig() (providerEnv, error) { } type credentials struct { - Endpoint string - APIKey string + Endpoint string + IaCEndpoint string + APIKey string } func getCredentials(config providerModel, env providerEnv) (credentials, diag.Diagnostics) { @@ -158,6 +177,14 @@ func getCredentials(config providerModel, env providerEnv) (credentials, diag.Di "Either target apply the source of the value first, set the value statically in the configuration, or use the STACKLET_ENDPOINT environment variable.", ) } + if config.IaCEndpoint.IsUnknown() { + diags.AddAttributeError( + tfpath.Root("iac_endpoint"), + "Unknown Stacklet IaC API Endpoint", + "The provider cannot create the Stacklet IaC API client as there is an unknown configuration value for the Stacklet IaC API endpoint. "+ + "Either target apply the source of the value first, set the value statically in the configuration, or use the STACKLET_IAC_ENDPOINT environment variable.", + ) + } if config.APIKey.IsUnknown() { diags.AddAttributeError( tfpath.Root("api_key"), @@ -172,12 +199,16 @@ func getCredentials(config providerModel, env providerEnv) (credentials, diag.Di // lookup provider configuration (might return empty strings) creds.Endpoint = config.Endpoint.ValueString() + creds.IaCEndpoint = config.IaCEndpoint.ValueString() creds.APIKey = config.APIKey.ValueString() // lookup environment variables if creds.Endpoint == "" { creds.Endpoint = env.Endpoint } + if creds.IaCEndpoint == "" { + creds.IaCEndpoint = env.IaCEndpoint + } if creds.APIKey == "" { creds.APIKey = env.APIKey } @@ -204,6 +235,11 @@ func getCredentials(config providerModel, env providerEnv) (credentials, diag.Di } } + // if not found, the IaC client reports the missing endpoint when used + if creds.IaCEndpoint == "" { + creds.IaCEndpoint = iacEndpointFromAPIEndpoint(creds.Endpoint) + } + if creds.Endpoint == "" { diags.AddAttributeError( tfpath.Root("endpoint"), @@ -222,3 +258,18 @@ func getCredentials(config providerModel, env providerEnv) (credentials, diag.Di } return creds, diags } + +// iacEndpointFromAPIEndpoint returns the IaC API endpoint derived from the +// Stacklet API one, or an empty string if it can't be derived. +func iacEndpointFromAPIEndpoint(endpoint string) string { + u, err := url.Parse(endpoint) + if err != nil || !strings.HasPrefix(u.Host, "api.") { + return "" + } + iacURL := url.URL{ + Scheme: u.Scheme, + Host: "api.sinistral." + strings.TrimPrefix(u.Host, "api."), + Path: "/", + } + return iacURL.String() +} diff --git a/internal/provider/provider_test.go b/internal/provider/provider_test.go index c1d43c3b..6475cdf5 100644 --- a/internal/provider/provider_test.go +++ b/internal/provider/provider_test.go @@ -126,6 +126,58 @@ func TestGetCredentials(t *testing.T) { APIKey: "env-api-key", }, }, + { + name: "IaCEndpointFromConfig", + env: providerEnv{ + IaCEndpoint: "https://env-iac-endpoint.example.com", + }, + config: providerModel{ + Endpoint: types.StringValue("https://api.example.com"), + IaCEndpoint: types.StringValue("https://config-iac-endpoint.example.com"), + APIKey: types.StringValue("config-api-key"), + }, + expected: credentials{ + Endpoint: "https://api.example.com", + IaCEndpoint: "https://config-iac-endpoint.example.com", + APIKey: "config-api-key", + }, + }, + { + name: "IaCEndpointFromEnviron", + env: providerEnv{ + IaCEndpoint: "https://env-iac-endpoint.example.com", + }, + config: providerModel{ + Endpoint: types.StringValue("https://api.example.com"), + APIKey: types.StringValue("config-api-key"), + }, + expected: credentials{ + Endpoint: "https://api.example.com", + IaCEndpoint: "https://env-iac-endpoint.example.com", + APIKey: "config-api-key", + }, + }, + { + name: "IaCEndpointDerived", + adminCLI: &credentials{ + Endpoint: "https://api.example.com/", + APIKey: "cli-api-key", + }, + expected: credentials{ + Endpoint: "https://api.example.com/", + IaCEndpoint: "https://api.sinistral.example.com/", + APIKey: "cli-api-key", + }, + }, + { + name: "UnknownIaCEndpoint", + config: providerModel{ + Endpoint: types.StringValue("https://api.example.com"), + IaCEndpoint: types.StringUnknown(), + APIKey: types.StringValue("config-api-key"), + }, + diagErrors: []string{"Unknown Stacklet IaC API Endpoint"}, + }, { name: "MissingEndpoint", config: providerModel{ @@ -208,3 +260,24 @@ func TestGetCredentials_InvalidAdminCLIConfigJSON(t *testing.T) { require.True(t, diags.HasError()) assert.Contains(t, diags[0].Summary(), "Missing Stacklet API Endpoint") } + +func TestIaCEndpointFromAPIEndpoint(t *testing.T) { + tests := []struct { + endpoint string + expected string + }{ + {"https://api.example.com", "https://api.sinistral.example.com/"}, + {"https://api.example.com/", "https://api.sinistral.example.com/"}, + {"https://api.example.com/graphql", "https://api.sinistral.example.com/"}, + {"https://api.example.com:8443/", "https://api.sinistral.example.com:8443/"}, + {"https://example.com/", ""}, + {"https://myapi.example.com/", ""}, + {"", ""}, + {"://invalid", ""}, + } + for _, tc := range tests { + t.Run(tc.endpoint, func(t *testing.T) { + assert.Equal(t, tc.expected, iacEndpointFromAPIEndpoint(tc.endpoint)) + }) + } +} diff --git a/internal/providerdata/providerdata.go b/internal/providerdata/providerdata.go index f5f1ab98..ef4b4598 100644 --- a/internal/providerdata/providerdata.go +++ b/internal/providerdata/providerdata.go @@ -8,18 +8,21 @@ import ( "github.com/hashicorp/terraform-plugin-framework/datasource" "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/stacklet/terraform-provider-stacklet/internal/api/iac" "github.com/stacklet/terraform-provider-stacklet/internal/api/platform" ) // providerData holds shared data for available in requests. type providerData struct { - API *platform.API + API *platform.API + IaCAPI *iac.API } // New returns configured provider data. -func New(config platform.ClientConfig) *providerData { +func New(config platform.ClientConfig, iacConfig iac.ClientConfig) *providerData { return &providerData{ - API: platform.New(config), + API: platform.New(config), + IaCAPI: iac.New(iacConfig), } } diff --git a/internal/resources/resource.go b/internal/resources/resource.go index c02c4542..d4d2a724 100644 --- a/internal/resources/resource.go +++ b/internal/resources/resource.go @@ -7,6 +7,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/stacklet/terraform-provider-stacklet/internal/api/iac" "github.com/stacklet/terraform-provider-stacklet/internal/api/platform" "github.com/stacklet/terraform-provider-stacklet/internal/errors" "github.com/stacklet/terraform-provider-stacklet/internal/providerdata" @@ -14,7 +15,8 @@ import ( // apiResource is a resource based on the API. type apiResource struct { - api *platform.API + api *platform.API + iacAPI *iac.API } // Configure sets up API access for the resource. @@ -23,5 +25,6 @@ func (r *apiResource) Configure(_ context.Context, req resource.ConfigureRequest errors.AddDiagError(&resp.Diagnostics, err) } else if pd != nil { r.api = pd.API + r.iacAPI = pd.IaCAPI } }