Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion dev-docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
4 changes: 3 additions & 1 deletion dev-docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
9 changes: 3 additions & 6 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 1 addition & 1 deletion internal/acceptance_tests/setup_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ func ensureVars() {
return
}

mustSetenv("STACKLET_ENDPOINT", "https://fake")
mustSetenv("STACKLET_ENDPOINT", "https://api.fake")
mustSetenv("STACKLET_API_KEY", "fake")
}

Expand Down
15 changes: 15 additions & 0 deletions internal/api/iac/api.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// Copyright Stacklet, Inc. 2025, 2026

package iac

// API provides access to the IaC REST API.
type API struct {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: iac.New returns an API wrapper with no exported operations, so resources and data sources receiving *iac.API cannot make any IaC request. Expose the request methods on API (or embed an exported client) before wiring this client into provider data.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At internal/api/iac/api.go, line 6:

<comment>`iac.New` returns an API wrapper with no exported operations, so resources and data sources receiving `*iac.API` cannot make any IaC request. Expose the request methods on `API` (or embed an exported client) before wiring this client into provider data.</comment>

<file context>
@@ -0,0 +1,15 @@
+package iac
+
+// API provides access to the IaC REST API.
+type API struct {
+	c *client
+}
</file context>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This branch just adds the barebone client, followup will start exposing resources through the API object (similar to the graphql one)

c *client
}

// New creates an API wrapper.
func New(config ClientConfig) *API {
return &API{
c: newClient(config),
}
}
130 changes: 130 additions & 0 deletions internal/api/iac/client.go
Original file line number Diff line number Diff line change
@@ -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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Custom agent: Flag Security Vulnerabilities

This accepts http:// IaC endpoints, so the shared transport sends the bearer API key without TLS. Reject non-HTTPS endpoint schemes before making the request.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At internal/api/iac/client.go, line 68:

<comment>This accepts `http://` IaC endpoints, so the shared transport sends the bearer API key without TLS. Reject non-HTTPS endpoint schemes before making the request.</comment>

<file context>
@@ -0,0 +1,123 @@
+}
+
+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))
</file context>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is not relevant at the client level, actual deployments always use https

if err != nil {
return api.NewAPIError(fmt.Errorf("invalid IaC API endpoint: %w", err))
}
reqURL = reqURL.JoinPath(path)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: JoinPath cleans . and .. after resourcePath escapes its segments, so identifiers equal to a dot segment are removed and the request can target the wrong API path. Build the URL without path normalization or encode dot segments in a way that survives joining.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At internal/api/iac/client.go, line 72:

<comment>`JoinPath` cleans `.` and `..` after `resourcePath` escapes its segments, so identifiers equal to a dot segment are removed and the request can target the wrong API path. Build the URL without path normalization or encode dot segments in a way that survives joining.</comment>

<file context>
@@ -0,0 +1,123 @@
+	if err != nil {
+		return api.NewAPIError(fmt.Errorf("invalid IaC API endpoint: %w", err))
+	}
+	reqURL = reqURL.JoinPath(path)
+	reqURL.RawQuery = query.Encode()
+
</file context>

reqURL.RawQuery = query.Encode()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: This assignment drops query parameters supplied as part of the configured IaC endpoint. Preserve the endpoint query and merge request parameters instead, otherwise valid endpoint configurations with tenant, routing, or gateway parameters send requests to the wrong API context.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At internal/api/iac/client.go, line 73:

<comment>This assignment drops query parameters supplied as part of the configured IaC endpoint. Preserve the endpoint query and merge request parameters instead, otherwise valid endpoint configurations with tenant, routing, or gateway parameters send requests to the wrong API context.</comment>

<file context>
@@ -0,0 +1,123 @@
+		return api.NewAPIError(fmt.Errorf("invalid IaC API endpoint: %w", err))
+	}
+	reqURL = reqURL.JoinPath(path)
+	reqURL.RawQuery = query.Encode()
+
+	var body io.Reader
</file context>
Suggested change
reqURL.RawQuery = query.Encode()
endpointQuery := reqURL.Query()
for key, values := range query {
for _, value := range values {
endpointQuery.Add(key, value)
}
}
reqURL.RawQuery = endpointQuery.Encode()

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The request URL is meant to be a plain URL, not containing parameters


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, "/")
}
190 changes: 190 additions & 0 deletions internal/api/iac/client_test.go
Original file line number Diff line number Diff line change
@@ -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"}`)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: These raw-string JSON fixtures escape quotes unnecessarily, so the tests send malformed JSON and fail instead of exercising the client. Remove the backslashes from all JSON raw strings, including the expected request body.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At internal/api/iac/client_test.go, line 50:

<comment>These raw-string JSON fixtures escape quotes unnecessarily, so the tests send malformed JSON and fail instead of exercising the client. Remove the backslashes from all JSON raw strings, including the expected request body.</comment>

<file context>
@@ -0,0 +1,176 @@
+}
+
+func TestClientGet(t *testing.T) {
+	c, req := newTestServer(t, http.StatusOK, `{"name": "foo"}`)
+
+	var response item
</file context>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is not true, the content is passed as-is


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)
})
}
}
Loading
Loading