From adb235534bd85a15d60f34f1459deb00e81bf743 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. If the IaC endpoint can't be determined, the provider configuration fails. Provider data now includes an `IaCAPI` client, which is exposed to resources and data sources as `iacAPI`. ### 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 | 9 +- internal/acceptance_tests/setup_test.go | 2 +- internal/api/iac/api.go | 15 ++ internal/api/iac/client.go | 130 ++++++++++++++++ internal/api/iac/client_test.go | 190 ++++++++++++++++++++++++ internal/api/iac/error.go | 73 +++++++++ internal/datasources/datasource.go | 5 +- internal/provider/provider.go | 71 +++++++-- internal/provider/provider_test.go | 118 ++++++++++++--- internal/providerdata/providerdata.go | 9 +- internal/resources/resource.go | 5 +- 13 files changed, 597 insertions(+), 42 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..d39d231c 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, the provider configuration fails. 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..da46d0d0 100644 --- a/docs/index.md +++ b/docs/index.md @@ -34,9 +34,6 @@ provider "stacklet" { ### Optional -- `api_key` (String, Sensitive) The API key for Stacklet authentication. - -May also be provided via STACKLET_API_KEY environment variable, or from the stacklet-admin CLI configuration. -- `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. +- `api_key` (String, Sensitive) The API key for Stacklet authentication. May also be provided via STACKLET_API_KEY environment variable, or from the stacklet-admin CLI configuration. +- `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/acceptance_tests/setup_test.go b/internal/acceptance_tests/setup_test.go index d436e4a5..28ab7782 100644 --- a/internal/acceptance_tests/setup_test.go +++ b/internal/acceptance_tests/setup_test.go @@ -22,7 +22,7 @@ func ensureVars() { return } - mustSetenv("STACKLET_ENDPOINT", "https://fake") + mustSetenv("STACKLET_ENDPOINT", "https://api.fake") mustSetenv("STACKLET_API_KEY", "fake") } 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..f249e462 --- /dev/null +++ b/internal/api/iac/client.go @@ -0,0 +1,130 @@ +// 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 result into response. +func (c *client) Get(ctx context.Context, path string, query url.Values, response any) error { + return c.do(ctx, http.MethodGet, path, query, nil, response) +} + +// Post performs a POST request with payload as body, decoding the result into response. +func (c *client) Post(ctx context.Context, path string, payload any, response any) error { + return c.do(ctx, http.MethodPost, path, nil, payload, response) +} + +// Put performs a PUT request with payload as body, decoding the result into response. +func (c *client) Put(ctx context.Context, path string, payload any, response any) error { + return c.do(ctx, http.MethodPut, path, nil, payload, response) +} + +// Patch performs a PATCH request with payload as body, decoding the result into response. +func (c *client) Patch(ctx context.Context, path string, payload any, response any) error { + return c.do(ctx, http.MethodPatch, path, nil, payload, response) +} + +// Delete performs a DELETE request, decoding the result into response. +func (c *client) Delete(ctx context.Context, path string, response any) error { + return c.do(ctx, http.MethodDelete, path, nil, nil, response) +} + +func (c *client) do(ctx context.Context, method string, path string, query url.Values, payload any, response any) error { + 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 payload != nil { + content, err := json.Marshal(payload) + 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 payload != 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 response == nil || len(content) == 0 { + return nil + } + if err := json.Unmarshal(content, response); 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 { + switch part { + case ".", "..": + // dots aren't escaped by PathEscape, and dot segments would be + // removed when joining paths + escaped[i] = strings.ReplaceAll(part, ".", "%2E") + default: + 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..4cde74d5 --- /dev/null +++ b/internal/api/iac/client_test.go @@ -0,0 +1,190 @@ +// Copyright Stacklet, Inc. 2025, 2026 + +package iac + +import ( + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "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 response item + err := c.Get(t.Context(), resourcePath("projects", "a/b c"), url.Values{"page": {"2"}}, &response) + + assert.NoError(t, err) + assert.Equal(t, item{Name: "foo"}, response) + 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 TestClientDotSegments(t *testing.T) { + for _, name := range []string{".", ".."} { + t.Run(name, func(t *testing.T) { + c, req := newTestServer(t, http.StatusNotFound, `{"detail": "Not found"}`) + + err := c.Get(t.Context(), resourcePath("projects", name), nil, nil) + + assert.Equal(t, api.NotFound{Message: "Not found"}, err) + assert.Equal(t, "/base/projects/"+strings.ReplaceAll(name, ".", "%2E"), req.URI) + }) + } +} + +func TestClientWithBody(t *testing.T) { + tests := []struct { + method string + call func(c *client, response *item) error + }{ + { + method: http.MethodPost, + call: func(c *client, response *item) error { + return c.Post(t.Context(), "projects", item{Name: "foo"}, response) + }, + }, + { + method: http.MethodPut, + call: func(c *client, response *item) error { + return c.Put(t.Context(), "projects", item{Name: "foo"}, response) + }, + }, + { + method: http.MethodPatch, + call: func(c *client, response *item) error { + return c.Patch(t.Context(), "projects", item{Name: "foo"}, response) + }, + }, + } + for _, tc := range tests { + t.Run(tc.method, func(t *testing.T) { + c, req := newTestServer(t, http.StatusCreated, `{"name": "foo"}`) + + var response item + err := tc.call(c, &response) + + assert.NoError(t, err) + assert.Equal(t, item{Name: "foo"}, response) + 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 response item + err := c.Get(t.Context(), "projects/foo", nil, &response) + + assert.ErrorContains(t, err, "invalid API response") +} + +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..20a03299 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"` @@ -72,18 +77,20 @@ It allows managing resources like accounts, account groups, policy collections, Attributes: map[string]schema.Attribute{ "api_key": schema.StringAttribute{ Description: ` -The API key for Stacklet authentication. - -May also be provided via STACKLET_API_KEY environment variable, or from the stacklet-admin CLI configuration. +The API key for Stacklet authentication. May also be provided via STACKLET_API_KEY environment variable, or from the stacklet-admin CLI configuration. `, Optional: true, Sensitive: true, }, "endpoint": schema.StringAttribute{ Description: ` -The endpoint URL of the Stacklet GraphQL API. - - May also be provided via STACKLET_ENDPOINT environment variable, or from the stacklet-admin CLI configuration. +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 +127,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 +154,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 +171,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 +193,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 +229,10 @@ func getCredentials(config providerModel, env providerEnv) (credentials, diag.Di } } + if creds.IaCEndpoint == "" { + creds.IaCEndpoint = iacEndpointFromAPIEndpoint(creds.Endpoint) + } + if creds.Endpoint == "" { diags.AddAttributeError( tfpath.Root("endpoint"), @@ -211,6 +240,13 @@ func getCredentials(config providerModel, env providerEnv) (credentials, diag.Di "The provider cannot create the Stacklet API client as there is a missing or empty value for the Stacklet API endpoint. "+ "Set the endpoint value in the configuration, in the STACKLET_ENDPOINT environment variable, or login via the stacklet-admin CLI first.", ) + } else if creds.IaCEndpoint == "" { + diags.AddAttributeError( + tfpath.Root("iac_endpoint"), + "Missing Stacklet IaC API Endpoint", + "The provider cannot create the Stacklet IaC API client as the endpoint can't be derived from the Stacklet API endpoint. "+ + "Set the iac_endpoint value in the configuration, or the STACKLET_IAC_ENDPOINT environment variable.", + ) } if creds.APIKey == "" { diags.AddAttributeError( @@ -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..4e889c8b 100644 --- a/internal/provider/provider_test.go +++ b/internal/provider/provider_test.go @@ -51,15 +51,25 @@ func setupStackletAdminConfig(t *testing.T, endpoint string, apiKey string) { func TestGetCredentials(t *testing.T) { envFull := providerEnv{ - Endpoint: "https://env-endpoint.example.com", + Endpoint: "https://api.env.example.com", + IaCEndpoint: "https://sinistral.env.example.com", + APIKey: "env-api-key", + } + envNoIaCEndpoint := providerEnv{ + Endpoint: "https://api.env.example.com", APIKey: "env-api-key", } configFull := providerModel{ - Endpoint: types.StringValue("https://config-endpoint.example.com"), + Endpoint: types.StringValue("https://api.config.example.com"), + IaCEndpoint: types.StringValue("https://sinistral.config.example.com"), + APIKey: types.StringValue("config-api-key"), + } + configNoIaCEndpoint := providerModel{ + Endpoint: types.StringValue("https://api.config.example.com"), APIKey: types.StringValue("config-api-key"), } adminCLIFull := &credentials{ - Endpoint: "https://cli-endpoint.example.com", + Endpoint: "https://api.cli.example.com", APIKey: "cli-api-key", } @@ -75,24 +85,27 @@ func TestGetCredentials(t *testing.T) { name: "FromConfig", config: configFull, expected: credentials{ - Endpoint: "https://config-endpoint.example.com", - APIKey: "config-api-key", + Endpoint: "https://api.config.example.com", + IaCEndpoint: "https://sinistral.config.example.com", + APIKey: "config-api-key", }, }, { name: "FromEnviron", env: envFull, expected: credentials{ - Endpoint: "https://env-endpoint.example.com", - APIKey: "env-api-key", + Endpoint: "https://api.env.example.com", + IaCEndpoint: "https://sinistral.env.example.com", + APIKey: "env-api-key", }, }, { name: "FromAdminCLI", adminCLI: adminCLIFull, expected: credentials{ - Endpoint: "https://cli-endpoint.example.com", - APIKey: "cli-api-key", + Endpoint: "https://api.cli.example.com", + IaCEndpoint: "https://api.sinistral.cli.example.com/", + APIKey: "cli-api-key", }, }, { @@ -100,8 +113,9 @@ func TestGetCredentials(t *testing.T) { env: envFull, config: configFull, expected: credentials{ - Endpoint: "https://config-endpoint.example.com", - APIKey: "config-api-key", + Endpoint: "https://api.config.example.com", + IaCEndpoint: "https://sinistral.config.example.com", + APIKey: "config-api-key", }, }, { @@ -109,8 +123,9 @@ func TestGetCredentials(t *testing.T) { env: envFull, adminCLI: adminCLIFull, expected: credentials{ - Endpoint: "https://env-endpoint.example.com", - APIKey: "env-api-key", + Endpoint: "https://api.env.example.com", + IaCEndpoint: "https://sinistral.env.example.com", + APIKey: "env-api-key", }, }, { @@ -119,12 +134,58 @@ func TestGetCredentials(t *testing.T) { APIKey: "env-api-key", }, adminCLI: &credentials{ - Endpoint: "https://cli-endpoint.example.com", + Endpoint: "https://api.cli.example.com", + }, + expected: credentials{ + Endpoint: "https://api.cli.example.com", + IaCEndpoint: "https://api.sinistral.cli.example.com/", + APIKey: "env-api-key", + }, + }, + { + name: "IaCEndpointFromEnvironWithConfigEndpoint", + env: envFull, + config: configNoIaCEndpoint, + expected: credentials{ + Endpoint: "https://api.config.example.com", + IaCEndpoint: "https://sinistral.env.example.com", + APIKey: "config-api-key", + }, + }, + { + name: "IaCEndpointDerivedFromConfig", + config: configNoIaCEndpoint, + expected: credentials{ + Endpoint: "https://api.config.example.com", + IaCEndpoint: "https://api.sinistral.config.example.com/", + APIKey: "config-api-key", }, + }, + { + name: "IaCEndpointDerivedFromEnviron", + env: envNoIaCEndpoint, expected: credentials{ - Endpoint: "https://cli-endpoint.example.com", - APIKey: "env-api-key", + Endpoint: "https://api.env.example.com", + IaCEndpoint: "https://api.sinistral.env.example.com/", + APIKey: "env-api-key", + }, + }, + { + name: "UnknownIaCEndpoint", + config: providerModel{ + Endpoint: types.StringValue("https://api.config.example.com"), + IaCEndpoint: types.StringUnknown(), + APIKey: types.StringValue("config-api-key"), + }, + diagErrors: []string{"Unknown Stacklet IaC API Endpoint"}, + }, + { + name: "IaCEndpointNotDerivable", + config: providerModel{ + Endpoint: types.StringValue("https://stacklet.example.com"), + APIKey: types.StringValue("config-api-key"), }, + diagErrors: []string{"Missing Stacklet IaC API Endpoint"}, }, { name: "MissingEndpoint", @@ -136,7 +197,7 @@ func TestGetCredentials(t *testing.T) { { name: "MissingAPIKey", config: providerModel{ - Endpoint: types.StringValue("https://config-endpoint.example.com"), + Endpoint: types.StringValue("https://api.config.example.com"), }, diagErrors: []string{"Missing Stacklet API key"}, }, @@ -158,7 +219,7 @@ func TestGetCredentials(t *testing.T) { { name: "UnknownAPIKey", config: providerModel{ - Endpoint: types.StringValue("https://config-endpoint.example.com"), + Endpoint: types.StringValue("https://api.config.example.com"), APIKey: types.StringUnknown(), }, diagErrors: []string{"Unknown Stacklet API key"}, @@ -208,3 +269,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 } }