This is an automated email from the ASF dual-hosted git repository.
JingsongLi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/terraform-provider-paimon.git
The following commit(s) were added to refs/heads/main by this push:
new 9ec965c feat(client): name the missing resource in REST error
diagnostics (#23)
9ec965c is described below
commit 9ec965cf1ef3f5faa60acf995188989aebdda689
Author: Jiajia Li <[email protected]>
AuthorDate: Fri Sep 18 11:04:52 2026 +0800
feat(client): name the missing resource in REST error diagnostics (#23)
---
internal/client/client.go | 148 ++++++
internal/client/client_test.go | 396 +++++++++++++++
internal/provider/data_source_database.go | 4 +-
internal/provider/data_source_table.go | 13 +-
internal/provider/production_readiness_test.go | 14 +-
internal/provider/provider_management_test.go | 666 +++++++++++++++++++++++++
internal/provider/resource_database.go | 4 +-
internal/provider/resource_permission.go | 4 +-
internal/provider/resource_policy.go | 41 +-
internal/provider/resource_table.go | 13 +-
internal/provider/write_diagnostics.go | 45 ++
11 files changed, 1299 insertions(+), 49 deletions(-)
diff --git a/internal/client/client.go b/internal/client/client.go
index 6442c0a..6b11010 100644
--- a/internal/client/client.go
+++ b/internal/client/client.go
@@ -78,6 +78,8 @@ type APIError struct {
ResourceType *string `json:"resourceType"`
ResourceName *string `json:"resourceName"`
RequestID string `json:"-"`
+ // LoadingConfig: the catalog config request failed, not the operation.
+ LoadingConfig bool `json:"-"`
}
func (e *APIError) Error() string {
@@ -88,10 +90,142 @@ func (e *APIError) Error() string {
if e.RequestID != "" {
detail += " (request ID " + e.RequestID + ")"
}
+ if subject := e.resourceSubject(); subject != "" {
+ if e.StatusCode == http.StatusNotFound {
+ detail += ": " + subject + " not found"
+ } else {
+ detail += " for " + subject
+ }
+ }
return detail
}
+// resourceSubject renders e.g. `USER "analyst"`; never the free-text message.
+func (e *APIError) resourceSubject() string {
+ kind := "resource"
+ if e.ResourceType != nil {
+ kind = *e.ResourceType
+ }
+ switch {
+ case e.ResourceName != nil:
+ return kind + " " + strconv.Quote(*e.ResourceName)
+ case e.ResourceType != nil:
+ return kind
+ default:
+ return ""
+ }
+}
+
+// resourceTypes is the allowlist of kinds worth naming: Paimon's
+// ErrorResponse kinds plus the principal kinds access-controlled servers use.
+var resourceTypes = map[string]struct{}{
+ "catalog": {}, "database": {}, "table": {}, "view": {}, "function": {},
"column": {},
+ "partition": {}, "snapshot": {}, "branch": {}, "tag": {}, "dialect":
{}, "definition": {},
+ "policy": {}, "permission": {}, "user": {}, "role": {}, "principal": {},
+}
+
+func safeResourceType(value *string) *string {
+ if value == nil {
+ return nil
+ }
+ if _, ok := resourceTypes[strings.ToLower(*value)]; !ok {
+ return nil
+ }
+
+ return value
+}
+
+// safeResourceName keeps a resourceName only if the client sent it as an
+// identifier (or two joined by a dot), so a server cannot echo arbitrary text.
+func safeResourceName(sent map[string]struct{}, value *string) *string {
+ if value == nil {
+ return nil
+ }
+ longest := 0
+ for identifier := range sent {
+ longest = max(longest, len(identifier))
+ }
+ name := *value
+ if len(name) > 2*longest+1 {
+ return nil
+ }
+ if _, ok := sent[name]; ok {
+ return value
+ }
+ for head := range sent {
+ if len(name) > len(head)+1 && name[len(head)] == '.' &&
name[:len(head)] == head {
+ if _, ok := sent[name[len(head)+1:]]; ok {
+ return value
+ }
+ }
+ }
+
+ return nil
+}
+
+// identityKeys name catalog objects and principals, which configuration
+// pins; column names are left out because they can come from the server via
+// state or data sources. dataKeys are subtrees that are never entered.
+var (
+ identityKeys = map[string]struct{}{
+ "warehouse": {}, "name": {}, "database": {}, "table": {},
"object": {}, "function": {},
+ "view": {}, "principal": {},
+ }
+ dataKeys = map[string]struct{}{
+ "schema": {}, "changes": {}, "columns": {}, "rowFilter": {},
"columnMask": {},
+ "options": {}, "updates": {}, "removals": {}, "comment": {},
"description": {},
+ }
+)
+
+// requestIdentifiers collects what the request named: path segments after the
+// server-provided prefix, plus query and body values under identityKeys.
+func requestIdentifiers(segments []string, query url.Values, body []byte)
map[string]struct{} {
+ sent := make(map[string]struct{})
+ if len(segments) > 2 {
+ for _, segment := range segments[2:] {
+ sent[segment] = struct{}{}
+ }
+ }
+ for key, values := range query {
+ if _, ok := identityKeys[key]; !ok {
+ continue
+ }
+ for _, value := range values {
+ sent[value] = struct{}{}
+ }
+ }
+ if len(body) > 0 {
+ var decoded any
+ if err := json.Unmarshal(body, &decoded); err == nil {
+ collectIdentifiers(decoded, "", sent)
+ }
+ }
+ delete(sent, "")
+
+ return sent
+}
+
+func collectIdentifiers(value any, key string, into map[string]struct{}) {
+ if _, data := dataKeys[key]; data {
+ return
+ }
+ switch typed := value.(type) {
+ case string:
+ if _, ok := identityKeys[key]; ok {
+ into[typed] = struct{}{}
+ }
+ case []any:
+ for _, element := range typed {
+ collectIdentifiers(element, key, into)
+ }
+ case map[string]any:
+ for childKey, element := range typed {
+ collectIdentifiers(element, childKey, into)
+ }
+ }
+}
+
var requestIDPattern =
regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`)
// Only expose conventional UUID correlation IDs, never arbitrary response text
@@ -124,6 +258,13 @@ func IsNotFound(err error) bool {
return errors.As(err, &apiErr) && apiErr.StatusCode ==
http.StatusNotFound
}
+// IsConfigurationFailure reports whether err came from loading the config.
+func IsConfigurationFailure(err error) bool {
+ var apiErr *APIError
+
+ return errors.As(err, &apiErr) && apiErr.LoadingConfig
+}
+
// IsMutationOutcomeUncertain reports whether a failed mutation may have been
// accepted remotely despite the client receiving an error.
func IsMutationOutcomeUncertain(err error) bool {
@@ -299,6 +440,11 @@ func (c *Client) ensureConfigured(ctx context.Context)
error {
var response ConfigResponse
if err := c.doRaw(ctx, http.MethodGet, []string{"v1", "config"}, query,
nil, &response, c.headers); err != nil {
+ var apiErr *APIError
+ if errors.As(err, &apiErr) {
+ apiErr.LoadingConfig = true
+ }
+
return fmt.Errorf("load Paimon REST catalog config: %w", err)
}
@@ -404,6 +550,8 @@ func (c *Client) doRaw(ctx context.Context, method string,
segments []string, qu
if err := json.NewDecoder(limited).Decode(apiErr); err != nil
&& err != io.EOF {
apiErr.Message = http.StatusText(response.StatusCode)
}
+ apiErr.ResourceType = safeResourceType(apiErr.ResourceType)
+ apiErr.ResourceName =
safeResourceName(requestIdentifiers(segments, query, requestBody),
apiErr.ResourceName)
return apiErr
}
diff --git a/internal/client/client_test.go b/internal/client/client_test.go
index 81b9e22..cda0223 100644
--- a/internal/client/client_test.go
+++ b/internal/client/client_test.go
@@ -121,6 +121,402 @@ func TestClientDoesNotExposeRemoteErrorMessage(t
*testing.T) {
assert.NotContains(t, err.Error(), "secret-token")
}
+// errorServer answers every non-config request with the given error.
+func errorServer(t *testing.T, status int, body map[string]any)
*httptest.Server {
+ t.Helper()
+ server := httptest.NewServer(http.HandlerFunc(func(w
http.ResponseWriter, r *http.Request) {
+ if r.URL.Path == "/v1/config" {
+ writeJSON(t, w, ConfigResponse{Defaults:
map[string]string{"prefix": "catalog"}})
+
+ return
+ }
+ w.WriteHeader(status)
+ writeJSON(t, w, body)
+ }))
+ t.Cleanup(server.Close)
+
+ return server
+}
+
+func TestClientNamesMissingResourceFromStructuredErrorFields(t *testing.T) {
+ server := errorServer(t, http.StatusNotFound, map[string]any{
+ "resourceType": "USER",
+ "resourceName": "user:analyst",
+ "message": "User user:analyst does not exist.",
+ "code": 404,
+ })
+ defer server.Close()
+
+ api, err := New(Config{URI: server.URL, Token: "secret-token"})
+ require.NoError(t, err)
+ err = api.GrantPermission(context.Background(), PermissionAssignment{
+ Resource: PermissionResource{Type: ResourceTypeTable,
Database: "analytics", Table: "events"},
+ Access: PermissionAccessSelect,
+ Principal: "user:analyst",
+ })
+ require.Error(t, err)
+ assert.EqualError(t, err, `Paimon REST API returned HTTP 404 with code
404: USER "user:analyst" not found`)
+ assert.NotContains(t, err.Error(), "does not exist")
+}
+
+func TestClientNamesResourceNeutrallyOnOtherStatuses(t *testing.T) {
+ server := errorServer(t, http.StatusConflict, map[string]any{
+ "resourceType": "TABLE",
+ "resourceName": "analytics.events",
+ "code": 409,
+ })
+ defer server.Close()
+
+ api, err := New(Config{URI: server.URL})
+ require.NoError(t, err)
+ _, err = api.GetTable(context.Background(), "analytics", "events")
+ require.Error(t, err)
+ assert.EqualError(t, err, `Paimon REST API returned HTTP 409 with code
409 for TABLE "analytics.events"`)
+}
+
+func TestClientNamesOnlyIdentifiersItSentItself(t *testing.T) {
+ for name, resourceName := range map[string]string{
+ "bare token": "secret-token",
+ "whole header": "Bearer secret-token",
+ "token behind a prefix": "principal:secret-token",
+ "token split by a mark": "Bearer\u034f secret-token",
+ "short custom header value": "abc",
+ "unrelated identifier": "other",
+ "identifier plus extra text": "analytics extra",
+ "substring of an identifier": "analytic",
+ } {
+ t.Run(name, func(t *testing.T) {
+ server := errorServer(t, http.StatusNotFound,
map[string]any{
+ "resourceType": "DATABASE",
+ "resourceName": resourceName,
+ "code": 404,
+ })
+ defer server.Close()
+
+ api, err := New(Config{URI: server.URL, Token:
"secret-token", Headers: map[string]string{"X-Custom-Secret": "abc"}})
+ require.NoError(t, err)
+ _, err = api.GetDatabase(context.Background(),
"analytics")
+ require.Error(t, err)
+ assert.EqualError(t, err, "Paimon REST API returned
HTTP 404 with code 404: DATABASE not found")
+ })
+ }
+}
+
+func TestClientNamesIdentifiersThatOverlapProtocolHeaders(t *testing.T) {
+ for _, database := range []string{"app", "terraform"} {
+ t.Run(database, func(t *testing.T) {
+ server := errorServer(t, http.StatusNotFound,
map[string]any{
+ "resourceType": "DATABASE",
+ "resourceName": database,
+ "code": 404,
+ })
+ defer server.Close()
+
+ api, err := New(Config{URI: server.URL})
+ require.NoError(t, err)
+ _, err = api.GetDatabase(context.Background(), database)
+ require.Error(t, err)
+ assert.EqualError(t, err, `Paimon REST API returned
HTTP 404 with code 404: DATABASE "`+database+`" not found`)
+ })
+ }
+}
+
+func TestClientDoesNotTreatTheServerPrefixAsAnIdentifier(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w
http.ResponseWriter, r *http.Request) {
+ if r.URL.Path == "/v1/config" {
+ writeJSON(t, w, ConfigResponse{Overrides:
map[string]string{"prefix": "Bearer secret-token"}})
+
+ return
+ }
+ w.WriteHeader(http.StatusNotFound)
+ writeJSON(t, w, map[string]any{"resourceType": "CATALOG",
"resourceName": "Bearer secret-token", "code": 404})
+ }))
+ defer server.Close()
+
+ api, err := New(Config{URI: server.URL, Token: "secret-token"})
+ require.NoError(t, err)
+ _, err = api.GetDatabase(context.Background(), "analytics")
+ require.Error(t, err)
+ assert.EqualError(t, err, "Paimon REST API returned HTTP 404 with code
404: CATALOG not found")
+}
+
+func TestClientDoesNotTreatDataValuesAsIdentifiers(t *testing.T) {
+ for name, tc := range map[string]struct {
+ reflected string
+ call func(*Client) error
+ }{
+ "database option value": {"opt-secret", func(api *Client) error
{
+ return api.CreateDatabase(context.Background(),
"analytics", map[string]string{"s3.secret-key": "opt-secret"})
+ }},
+ "database option under an identifier-looking key":
{"secret-option-value", func(api *Client) error {
+ return api.CreateDatabase(context.Background(),
"analytics", map[string]string{"name": "secret-option-value"})
+ }},
+ "database option update": {"updated-secret", func(api *Client)
error {
+ return api.AlterDatabase(context.Background(),
"analytics", nil, map[string]string{"table": "updated-secret"})
+ }},
+ "table option value": {"tbl-secret", func(api *Client) error {
+ return api.CreateTable(context.Background(),
"analytics", "events", Schema{Options: map[string]string{"principal":
"tbl-secret"}})
+ }},
+ "table option change value": {"changed-secret", func(api
*Client) error {
+ return api.AlterTable(context.Background(),
"analytics", "events", []SchemaChange{{"action": "setOption", "key": "name",
"value": "changed-secret"}})
+ }},
+ } {
+ t.Run(name, func(t *testing.T) {
+ server := errorServer(t, http.StatusNotFound,
map[string]any{
+ "resourceType": "DATABASE",
+ "resourceName": tc.reflected,
+ "code": 404,
+ })
+ defer server.Close()
+
+ api, err := New(Config{URI: server.URL})
+ require.NoError(t, err)
+ err = tc.call(api)
+ require.Error(t, err)
+ assert.EqualError(t, err, "Paimon REST API returned
HTTP 404 with code 404: DATABASE not found")
+ })
+ }
+}
+
+func TestClientNamesIdentifiersFromTableBodies(t *testing.T) {
+ for name, tc := range map[string]struct {
+ reflected string
+ call func(*Client) error
+ }{
+ "table object": {"events", func(api *Client) error {
+ return api.CreateTable(context.Background(),
"analytics", "events", Schema{})
+ }},
+ "qualified table object": {"analytics.events", func(api
*Client) error {
+ return api.CreateTable(context.Background(),
"analytics", "events", Schema{})
+ }},
+ } {
+ t.Run(name, func(t *testing.T) {
+ server := errorServer(t, http.StatusNotFound,
map[string]any{
+ "resourceType": "TABLE",
+ "resourceName": tc.reflected,
+ "code": 404,
+ })
+
+ api, err := New(Config{URI: server.URL})
+ require.NoError(t, err)
+ err = tc.call(api)
+ require.Error(t, err)
+ assert.EqualError(t, err, `Paimon REST API returned
HTTP 404 with code 404: TABLE "`+tc.reflected+`" not found`)
+ })
+ }
+}
+
+func TestClientDoesNotPrintColumnNamesInAnyShape(t *testing.T) {
+ // Column names can come from the server, so none of them are printed.
+ for name, call := range map[string]func(*Client) error{
+ "schema field": func(api *Client) error {
+ return api.CreateTable(context.Background(),
"analytics", "events", Schema{Fields: []Field{{Name: "secret-token"}}})
+ },
+ "field nested in a map value type": func(api *Client) error {
+ return api.CreateTable(context.Background(),
"analytics", "events", Schema{Fields: []Field{{Name: "attrs", Type:
DataType("MAP<STRING, ROW<`secret-token` STRING>>")}}})
+ },
+ "dropped field": func(api *Client) error {
+ return api.AlterTable(context.Background(),
"analytics", "events", []SchemaChange{{"action": "dropColumn", "fieldNames":
[]string{"secret-token"}}})
+ },
+ "renamed field": func(api *Client) error {
+ return api.AlterTable(context.Background(),
"analytics", "events", []SchemaChange{{"action": "renameColumn", "fieldNames":
[]string{"email"}, "newName": "secret-token"}})
+ },
+ "moved field": func(api *Client) error {
+ return api.AlterTable(context.Background(),
"analytics", "events", []SchemaChange{{"action": "updateColumnPosition",
"move": map[string]any{"fieldName": "secret-token", "referenceFieldName":
"secret-token", "type": "AFTER"}}})
+ },
+ "masked column": func(api *Client) error {
+ return api.CreatePolicy(context.Background(),
"analytics", "events", PolicyRequest{Principal: "user:analyst", ColumnMask:
&ColumnMask{OnColumn: "secret-token", Transform: `{"type":"null"}`}})
+ },
+ "dropped policy column": func(api *Client) error {
+ return api.DropPolicy(context.Background(),
"analytics", "events", PolicyTypeColumnMasking, "user:analyst", "secret-token")
+ },
+ } {
+ t.Run(name, func(t *testing.T) {
+ server := errorServer(t, http.StatusNotFound,
map[string]any{
+ "resourceType": "COLUMN",
+ "resourceName": "secret-token",
+ "code": 404,
+ })
+
+ api, err := New(Config{URI: server.URL, Token:
"secret-token"})
+ require.NoError(t, err)
+ err = call(api)
+ require.Error(t, err)
+ assert.EqualError(t, err, "Paimon REST API returned
HTTP 404 with code 404: COLUMN not found")
+ })
+ }
+}
+
+func TestClientDoesNotPrintColumnListsThatMayComeFromTheServer(t *testing.T) {
+ // Column and key lists can come back from state unchanged.
+ for name, call := range map[string]func(*Client) error{
+ "permission column": func(api *Client) error {
+ return api.GrantPermission(context.Background(),
PermissionAssignment{
+ Resource: PermissionResource{Type:
ResourceTypeTable, Database: "analytics", Table: "events"},
+ Access: PermissionAccessSelect,
+ Principal: "user:analyst",
+ Columns: &PermissionColumns{ColumnNames:
[]string{"secret-token"}},
+ })
+ },
+ "excluded permission column": func(api *Client) error {
+ return api.GrantPermission(context.Background(),
PermissionAssignment{
+ Resource: PermissionResource{Type:
ResourceTypeTable, Database: "analytics", Table: "events"},
+ Access: PermissionAccessSelect,
+ Principal: "user:analyst",
+ Columns:
&PermissionColumns{ExcludedColumnNames: []string{"secret-token"}},
+ })
+ },
+ "partition key": func(api *Client) error {
+ return api.CreateTable(context.Background(),
"analytics", "events", Schema{PartitionKeys: []string{"secret-token"}})
+ },
+ "primary key": func(api *Client) error {
+ return api.CreateTable(context.Background(),
"analytics", "events", Schema{PrimaryKeys: []string{"secret-token"}})
+ },
+ } {
+ t.Run(name, func(t *testing.T) {
+ server := errorServer(t, http.StatusNotFound,
map[string]any{
+ "resourceType": "COLUMN",
+ "resourceName": "secret-token",
+ "code": 404,
+ })
+
+ api, err := New(Config{URI: server.URL, Token:
"secret-token"})
+ require.NoError(t, err)
+ err = call(api)
+ require.Error(t, err)
+ assert.EqualError(t, err, "Paimon REST API returned
HTTP 404 with code 404: COLUMN not found")
+ })
+ }
+}
+
+func TestClientRejectsQualifiedNamesWithAnUnsentComponent(t *testing.T) {
+ for name, resourceName := range map[string]string{
+ "unsent suffix": "analytics.abc",
+ "unsent prefix": "abc.analytics",
+ "header in both": "abc.abc",
+ "three parts": "analytics.events.abc",
+ } {
+ t.Run(name, func(t *testing.T) {
+ server := errorServer(t, http.StatusNotFound,
map[string]any{
+ "resourceType": "TABLE",
+ "resourceName": resourceName,
+ "code": 404,
+ })
+
+ api, err := New(Config{URI: server.URL, Headers:
map[string]string{"X-Custom-Secret": "abc"}})
+ require.NoError(t, err)
+ _, err = api.GetTable(context.Background(),
"analytics", "events")
+ require.Error(t, err)
+ assert.EqualError(t, err, "Paimon REST API returned
HTTP 404 with code 404: TABLE not found")
+ })
+ }
+}
+
+func TestClientRejectsOversizedResourceNamesBeforeScanning(t *testing.T) {
+ huge := strings.Repeat("analytics.", 25000)
+ server := errorServer(t, http.StatusNotFound, map[string]any{
+ "resourceType": "TABLE",
+ "resourceName": huge,
+ "code": 404,
+ })
+ defer server.Close()
+
+ api, err := New(Config{URI: server.URL})
+ require.NoError(t, err)
+ err = api.GrantPermission(context.Background(), PermissionAssignment{
+ Resource: PermissionResource{Type: ResourceTypeTable, Database:
"analytics", Table: "events"},
+ Columns: &PermissionColumns{ColumnNames: []string{"a", "b",
"c", "d", "e", "f", "g", "h", "i", "j"}},
+ })
+ require.Error(t, err)
+ assert.EqualError(t, err, "Paimon REST API returned HTTP 404 with code
404: TABLE not found")
+}
+
+func TestClientNamesQualifiedTargetsSentInTheBody(t *testing.T) {
+ for _, tc := range []struct{ database, table, qualified string }{
+ {"analytics", "events", "analytics.events"},
+ {"analytics", "events.v2", "analytics.events.v2"},
+ {"analytics.v2", "events", "analytics.v2.events"},
+ } {
+ t.Run(tc.qualified, func(t *testing.T) {
+ server := errorServer(t, http.StatusNotFound,
map[string]any{
+ "resourceType": "TABLE",
+ "resourceName": tc.qualified,
+ "code": 404,
+ })
+ defer server.Close()
+
+ api, err := New(Config{URI: server.URL})
+ require.NoError(t, err)
+ err = api.GrantPermission(context.Background(),
PermissionAssignment{
+ Resource: PermissionResource{Type:
ResourceTypeTable, Database: tc.database, Table: tc.table},
+ Access: PermissionAccessSelect,
+ Principal: "user:analyst",
+ })
+ require.Error(t, err)
+ assert.EqualError(t, err, `Paimon REST API returned
HTTP 404 with code 404: TABLE "`+tc.qualified+`" not found`)
+ })
+ }
+}
+
+func TestClientMarksConfigFailuresSeparatelyFromOperations(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w
http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusNotFound)
+ writeJSON(t, w, map[string]any{"resourceType": "CATALOG",
"resourceName": "analytics", "code": 404})
+ }))
+ defer server.Close()
+
+ api, err := New(Config{URI: server.URL, Warehouse: "analytics"})
+ require.NoError(t, err)
+ err = api.GrantPermission(context.Background(),
PermissionAssignment{Principal: "user:analyst"})
+ require.Error(t, err)
+ assert.True(t, IsNotFound(err))
+ assert.True(t, IsConfigurationFailure(err))
+ assert.ErrorContains(t, err, `CATALOG "analytics" not found`)
+
+ operationServer := errorServer(t, http.StatusNotFound,
map[string]any{"code": 404})
+ api, err = New(Config{URI: operationServer.URL})
+ require.NoError(t, err)
+ err = api.GrantPermission(context.Background(),
PermissionAssignment{Principal: "user:analyst"})
+ require.Error(t, err)
+ assert.True(t, IsNotFound(err))
+ assert.False(t, IsConfigurationFailure(err))
+}
+
+func TestClientDropsResourceTypeOutsideTheContract(t *testing.T) {
+ server := errorServer(t, http.StatusNotFound, map[string]any{
+ "resourceType": "Bearer secret-token",
+ "resourceName": "analytics",
+ "code": 404,
+ })
+ defer server.Close()
+
+ api, err := New(Config{URI: server.URL, Token: "secret-token"})
+ require.NoError(t, err)
+ _, err = api.GetDatabase(context.Background(), "analytics")
+ require.Error(t, err)
+ assert.EqualError(t, err, `Paimon REST API returned HTTP 404 with code
404: resource "analytics" not found`)
+}
+
+func TestClientOmitsResourceIdentityWithoutUsableFields(t *testing.T) {
+ for name, body := range map[string]map[string]any{
+ "null fields": {"resourceType": nil, "resourceName": nil,
"code": 404},
+ "empty fields": {"resourceType": "", "resourceName": "",
"code": 404},
+ "unknown type": {"resourceType": "Bearer secret-token",
"resourceName": "secret-token", "code": 404},
+ "no such fields": {"code": 404},
+ } {
+ t.Run(name, func(t *testing.T) {
+ server := errorServer(t, http.StatusNotFound, body)
+ defer server.Close()
+
+ api, err := New(Config{URI: server.URL, Token:
"secret-token"})
+ require.NoError(t, err)
+ _, err = api.GetDatabase(context.Background(),
"analytics")
+ require.Error(t, err)
+ assert.EqualError(t, err, "Paimon REST API returned
HTTP 404 with code 404")
+ })
+ }
+}
+
func TestClientRetriesRetryableReads(t *testing.T) {
var configCalls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w
http.ResponseWriter, r *http.Request) {
diff --git a/internal/provider/data_source_database.go
b/internal/provider/data_source_database.go
index 1b2b2d0..7a81c6d 100644
--- a/internal/provider/data_source_database.go
+++ b/internal/provider/data_source_database.go
@@ -91,9 +91,9 @@ func (d *databaseDataSource) Read(ctx context.Context, req
datasource.ReadReques
return
}
- data.ID = types.StringValue(database.Name)
+ // Identity comes from the caller, never from the response.
+ data.ID = types.StringValue(data.Name.ValueString())
data.ServerID = types.StringValue(database.ID)
- data.Name = types.StringValue(database.Name)
data.Options = stringMapValue(ctx, database.Options, &resp.Diagnostics)
data.Location = types.StringValue(database.Location)
data.Owner = types.StringValue(database.Owner)
diff --git a/internal/provider/data_source_table.go
b/internal/provider/data_source_table.go
index c584758..fdf66dd 100644
--- a/internal/provider/data_source_table.go
+++ b/internal/provider/data_source_table.go
@@ -83,18 +83,9 @@ func (d *tableDataSource) Read(ctx context.Context, req
datasource.ReadRequest,
return
}
- database := table.Database
- if database == "" {
- database = data.Database.ValueString()
- }
- name := table.Name
- if name == "" {
- name = data.Name.ValueString()
- }
- data.ID = types.StringValue(tableID(database, name))
+ // Identity comes from the caller, never from the response.
+ data.ID = types.StringValue(tableID(data.Database.ValueString(),
data.Name.ValueString()))
data.ServerID = types.StringValue(table.ID)
- data.Database = types.StringValue(database)
- data.Name = types.StringValue(name)
data.Fields = fieldsValueFromRemote(ctx, table.Schema.Fields,
&resp.Diagnostics)
data.PartitionKeys = stringListValue(ctx, table.Schema.PartitionKeys,
&resp.Diagnostics)
data.PrimaryKeys = stringListValue(ctx, table.Schema.PrimaryKeys,
&resp.Diagnostics)
diff --git a/internal/provider/production_readiness_test.go
b/internal/provider/production_readiness_test.go
index 9081ff3..16d5ec3 100644
--- a/internal/provider/production_readiness_test.go
+++ b/internal/provider/production_readiness_test.go
@@ -57,7 +57,7 @@ func TestDatabaseFailedOptionRemovalRetainsManagement(t
*testing.T) {
var sr resource.SchemaResponse
res.Schema(ctx, resource.SchemaRequest{}, &sr)
var ds diag.Diagnostics
- model := databaseResourceModel{Options:
types.MapValueMust(types.StringType, map[string]attr.Value{"owner":
types.StringValue("old")})}
+ model := databaseResourceModel{Name: types.StringValue("analytics"),
Options: types.MapValueMust(types.StringType, map[string]attr.Value{"owner":
types.StringValue("old")})}
setDatabaseResourceModel(ctx, &model, remote, &ds)
require.False(t, ds.HasError(), ds)
state := tfsdk.State{Schema: sr.Schema}
@@ -98,7 +98,7 @@ func TestTableFailedOptionRemovalRetainsManagement(t
*testing.T) {
var sr resource.SchemaResponse
res.Schema(ctx, resource.SchemaRequest{}, &sr)
var ds diag.Diagnostics
- model := tableResourceModel{Fields:
types.ListNull(types.ObjectType{AttrTypes: tableFieldAttrTypes()}), Options:
types.MapValueMust(types.StringType, map[string]attr.Value{"retention":
types.StringValue("old")})}
+ model := tableResourceModel{Database: types.StringValue("analytics"),
Name: types.StringValue("events"), Fields:
types.ListNull(types.ObjectType{AttrTypes: tableFieldAttrTypes()}), Options:
types.MapValueMust(types.StringType, map[string]attr.Value{"retention":
types.StringValue("old")})}
setTableResourceModel(ctx, &model, remote, &ds)
require.False(t, ds.HasError(), ds)
state := tfsdk.State{Schema: sr.Schema}
@@ -134,7 +134,7 @@ func TestImportedImmutableOptionCanBecomeManaged(t
*testing.T) {
ctx := context.Background()
remote := &client.Table{Database: "analytics", Name: "events", Schema:
client.Schema{Fields: []client.Field{{ID: 0, Name: "id", Type: "BIGINT"}},
Options: map[string]string{"merge-engine": "partial-update"}}}
var ds diag.Diagnostics
- imported := tableResourceModel{Fields:
types.ListNull(types.ObjectType{AttrTypes: tableFieldAttrTypes()}), Options:
types.MapNull(types.StringType)}
+ imported := tableResourceModel{Database:
types.StringValue("analytics"), Name: types.StringValue("events"), Fields:
types.ListNull(types.ObjectType{AttrTypes: tableFieldAttrTypes()}), Options:
types.MapNull(types.StringType)}
setTableResourceModel(ctx, &imported, remote, &ds)
require.False(t, ds.HasError(), ds)
require.True(t, imported.Options.IsNull())
@@ -261,7 +261,7 @@ func
TestTableUpdateRejectsResolvedNewFieldIDBeforeMutation(t *testing.T) {
ctx := context.Background()
var diags diag.Diagnostics
remote := &client.Table{Database: "analytics", Name: "events", Schema:
client.Schema{Fields: []client.Field{{ID: 0, Name: "id", Type: "BIGINT"}}}}
- model := tableResourceModel{Fields:
types.ListNull(types.ObjectType{AttrTypes: tableFieldAttrTypes()}), Options:
types.MapNull(types.StringType)}
+ model := tableResourceModel{Database: types.StringValue("analytics"),
Name: types.StringValue("events"), Fields:
types.ListNull(types.ObjectType{AttrTypes: tableFieldAttrTypes()}), Options:
types.MapNull(types.StringType)}
setTableResourceModel(ctx, &model, remote, &diags)
require.False(t, diags.HasError(), diags)
res := &tableResource{} // No client: validation must finish before any
REST call.
@@ -297,7 +297,7 @@ func TestTableReplacementRequiresOptIn(t *testing.T) {
var sr resource.SchemaResponse
res.Schema(ctx, resource.SchemaRequest{}, &sr)
var diags diag.Diagnostics
- before := tableResourceModel{Fields:
types.ListNull(types.ObjectType{AttrTypes: tableFieldAttrTypes()}), Options:
types.MapNull(types.StringType)}
+ before := tableResourceModel{Database:
types.StringValue("analytics"), Name: types.StringValue("events"), Fields:
types.ListNull(types.ObjectType{AttrTypes: tableFieldAttrTypes()}), Options:
types.MapNull(types.StringType)}
remote := &client.Table{Database: "analytics",
Name: "events", Schema: client.Schema{Fields: []client.Field{{ID: 0, Name:
"id", Type: "BIGINT NOT NULL"}}}}
setTableResourceModel(ctx, &before, remote,
&diags)
require.False(t, diags.HasError(), diags)
@@ -360,7 +360,7 @@ func
TestDatabaseSuccessfulButStaleRemovalRetainsManagement(t *testing.T) {
var sr resource.SchemaResponse
res.Schema(ctx, resource.SchemaRequest{}, &sr)
var ds diag.Diagnostics
- model := databaseResourceModel{Options:
types.MapValueMust(types.StringType, map[string]attr.Value{"owner":
types.StringValue("old")})}
+ model := databaseResourceModel{Name: types.StringValue("analytics"),
Options: types.MapValueMust(types.StringType, map[string]attr.Value{"owner":
types.StringValue("old")})}
setDatabaseResourceModel(ctx, &model, remote, &ds)
require.False(t, ds.HasError(), ds)
state := tfsdk.State{Schema: sr.Schema}
@@ -414,7 +414,7 @@ func TestTableSuccessfulButStaleRemovalRetainsManagement(t
*testing.T) {
var sr resource.SchemaResponse
res.Schema(ctx, resource.SchemaRequest{}, &sr)
var ds diag.Diagnostics
- model := tableResourceModel{Fields:
types.ListNull(types.ObjectType{AttrTypes: tableFieldAttrTypes()}), Options:
types.MapValueMust(types.StringType, map[string]attr.Value{"retention":
types.StringValue("old")})}
+ model := tableResourceModel{Database: types.StringValue("analytics"),
Name: types.StringValue("events"), Fields:
types.ListNull(types.ObjectType{AttrTypes: tableFieldAttrTypes()}), Options:
types.MapValueMust(types.StringType, map[string]attr.Value{"retention":
types.StringValue("old")})}
setTableResourceModel(ctx, &model, remote, &ds)
require.False(t, ds.HasError(), ds)
state := tfsdk.State{Schema: sr.Schema}
diff --git a/internal/provider/provider_management_test.go
b/internal/provider/provider_management_test.go
index a5fa835..e1262d6 100644
--- a/internal/provider/provider_management_test.go
+++ b/internal/provider/provider_management_test.go
@@ -30,6 +30,7 @@ import (
"github.com/apache/terraform-provider-paimon/internal/client"
"github.com/hashicorp/terraform-plugin-framework/attr"
+ "github.com/hashicorp/terraform-plugin-framework/datasource"
"github.com/hashicorp/terraform-plugin-framework/diag"
"github.com/hashicorp/terraform-plugin-framework/resource"
resourceschema
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
@@ -636,6 +637,671 @@ func
TestPermissionCreateRetainsStateWhenReconciliationFails(t *testing.T) {
assert.GreaterOrEqual(t, listCalls, 3)
}
+// unknownPrincipalServer refuses every write with an unknown-principal 404.
+func unknownPrincipalServer(t *testing.T) *httptest.Server {
+ t.Helper()
+
+ return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter,
request *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ switch {
+ case request.Method == http.MethodGet && request.URL.Path ==
"/v1/config":
+ require.NoError(t,
json.NewEncoder(w).Encode(client.ConfigResponse{Defaults:
map[string]string{"prefix": "catalog"}}))
+ case request.Method == http.MethodPost:
+ w.WriteHeader(http.StatusNotFound)
+ require.NoError(t,
json.NewEncoder(w).Encode(map[string]any{
+ "resourceType": "USER",
+ "resourceName": "role:analyst",
+ "message": "User role:analyst does not
exist.",
+ "code": 404,
+ }))
+ case request.Method == http.MethodGet && request.URL.Path ==
"/v1/catalog/permissions":
+ require.NoError(t,
json.NewEncoder(w).Encode(client.ListPermissionsResponse{}))
+ case request.Method == http.MethodGet && request.URL.Path ==
"/v1/catalog/databases/analytics/tables/events/policies":
+ require.NoError(t,
json.NewEncoder(w).Encode(client.ListPoliciesResponse{}))
+ default:
+ http.NotFound(w, request)
+ }
+ }))
+}
+
+func TestPermissionCreateExplainsUnknownPrincipal(t *testing.T) {
+ ctx := context.Background()
+ server := unknownPrincipalServer(t)
+ defer server.Close()
+
+ api, err := client.New(client.Config{URI: server.URL, RecoveryTimeout:
250 * time.Millisecond})
+ require.NoError(t, err)
+ managed := &permissionResource{client: api}
+ var schemaResponse resource.SchemaResponse
+ managed.Schema(ctx, resource.SchemaRequest{}, &schemaResponse)
+ planModel := permissionResourceModel{
+ ID: types.StringUnknown(),
+ ResourceType:
types.StringValue(client.ResourceTypeTable),
+ Database: types.StringValue("analytics"),
+ Table: types.StringValue("events"),
+ Function: types.StringNull(),
+ View: types.StringNull(),
+ Access:
types.StringValue(client.PermissionAccessSelect),
+ Principal: types.StringValue("role:analyst"),
+ ColumnNames: types.SetNull(types.StringType),
+ ExcludedColumnNames: types.SetNull(types.StringType),
+ ExpireTime: types.StringNull(),
+ }
+ plan := tfsdk.Plan{Schema: schemaResponse.Schema}
+ require.False(t, plan.Set(ctx, &planModel).HasError())
+ response := resource.CreateResponse{State: tfsdk.State{Schema:
schemaResponse.Schema}}
+ managed.Create(ctx, resource.CreateRequest{Plan: plan}, &response)
+
+ require.True(t, response.Diagnostics.HasError())
+ detail := response.Diagnostics.Errors()[0].Detail()
+ assert.Contains(t, detail, `USER "role:analyst" not found`)
+ assert.Contains(t, detail, "could not find the principal or the
catalog, database, table, function, view, or columns the permission targets")
+ assert.NotContains(t, detail, "does not exist")
+}
+
+func TestRowFilterCreateExplainsUnknownPrincipal(t *testing.T) {
+ ctx := context.Background()
+ server := unknownPrincipalServer(t)
+ defer server.Close()
+
+ api, err := client.New(client.Config{URI: server.URL, RecoveryTimeout:
250 * time.Millisecond})
+ require.NoError(t, err)
+ managed := &rowFilterResource{client: api}
+ var schemaResponse resource.SchemaResponse
+ managed.Schema(ctx, resource.SchemaRequest{}, &schemaResponse)
+ planModel := rowFilterResourceModel{
+ ID: types.StringUnknown(),
+ Database: types.StringValue("analytics"),
+ Table: types.StringValue("events"),
+ Principal: types.StringValue("role:analyst"),
+ Predicate: types.StringValue(`{"field":"tenant_id"}`),
+ }
+ plan := tfsdk.Plan{Schema: schemaResponse.Schema}
+ require.False(t, plan.Set(ctx, &planModel).HasError())
+ response := resource.CreateResponse{State: tfsdk.State{Schema:
schemaResponse.Schema}}
+ managed.Create(ctx, resource.CreateRequest{Plan: plan}, &response)
+
+ require.True(t, response.Diagnostics.HasError())
+ detail := response.Diagnostics.Errors()[0].Detail()
+ assert.Contains(t, detail, `USER "role:analyst" not found`)
+ assert.Contains(t, detail, "could not find the principal or the table
the row filter targets")
+}
+
+func TestColumnMaskCreateExplainsUnknownPrincipal(t *testing.T) {
+ ctx := context.Background()
+ server := unknownPrincipalServer(t)
+ defer server.Close()
+
+ api, err := client.New(client.Config{URI: server.URL, RecoveryTimeout:
250 * time.Millisecond})
+ require.NoError(t, err)
+ managed := &columnMaskResource{client: api}
+ var schemaResponse resource.SchemaResponse
+ managed.Schema(ctx, resource.SchemaRequest{}, &schemaResponse)
+ planModel := columnMaskResourceModel{
+ AllowNonAtomicUpdate: types.BoolValue(true),
+ ID: types.StringUnknown(),
+ Database: types.StringValue("analytics"),
+ Table: types.StringValue("events"),
+ Principal: types.StringValue("role:analyst"),
+ Column: types.StringValue("email"),
+ Transform: types.StringValue(`{"type":"null"}`),
+ }
+ plan := tfsdk.Plan{Schema: schemaResponse.Schema}
+ require.False(t, plan.Set(ctx, &planModel).HasError())
+ response := resource.CreateResponse{State: tfsdk.State{Schema:
schemaResponse.Schema}}
+ managed.Create(ctx, resource.CreateRequest{Plan: plan}, &response)
+
+ require.True(t, response.Diagnostics.HasError())
+ detail := response.Diagnostics.Errors()[0].Detail()
+ assert.Contains(t, detail, `USER "role:analyst" not found`)
+ assert.Contains(t, detail, "could not find the principal, the table, or
the column the column mask targets")
+}
+
+func TestRowFilterUpdateExplainsUnknownPrincipalWhenRestoreSucceeds(t
*testing.T) {
+ ctx := context.Background()
+ var mu sync.Mutex
+ remote := []client.DataPolicy{}
+ server := httptest.NewServer(http.HandlerFunc(func(w
http.ResponseWriter, request *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ mu.Lock()
+ defer mu.Unlock()
+ switch {
+ case request.Method == http.MethodGet && request.URL.Path ==
"/v1/config":
+ require.NoError(t,
json.NewEncoder(w).Encode(client.ConfigResponse{Defaults:
map[string]string{"prefix": "catalog"}}))
+ case request.Method == http.MethodPost && request.URL.Path ==
"/v1/catalog/databases/analytics/tables/events/policies/drop":
+ remote = nil
+ w.WriteHeader(http.StatusOK)
+ case request.Method == http.MethodPost && request.URL.Path ==
"/v1/catalog/databases/analytics/tables/events/policies":
+ var body client.PolicyRequest
+ require.NoError(t,
json.NewDecoder(request.Body).Decode(&body))
+ if body.RowFilter.Predicate == `{"version":"new"}` {
+ w.WriteHeader(http.StatusNotFound)
+ require.NoError(t,
json.NewEncoder(w).Encode(map[string]any{
+ "resourceType": "USER", "resourceName":
"role:analyst", "code": 404,
+ }))
+
+ return
+ }
+ remote = []client.DataPolicy{{
+ Resource: client.PermissionResource{Type:
client.ResourceTypeTable, Database: "analytics", Table: "events"},
+ RowFilter: body.RowFilter,
+ Principal: body.Principal,
+ }}
+ w.WriteHeader(http.StatusOK)
+ case request.Method == http.MethodGet && request.URL.Path ==
"/v1/catalog/databases/analytics/tables/events/policies":
+ require.NoError(t,
json.NewEncoder(w).Encode(client.ListPoliciesResponse{Policies: remote}))
+ default:
+ http.NotFound(w, request)
+ }
+ }))
+ defer server.Close()
+
+ detail := updateRowFilterPredicate(ctx, t, server.URL)
+ assert.Contains(t, detail, "previous row filter was restored")
+ assert.NotContains(t, detail, "verification of the restored policy also
failed")
+ assert.Contains(t, detail, `USER "role:analyst" not found`)
+ assert.Contains(t, detail, "could not find the principal or the table
the row filter targets")
+}
+
+func TestRowFilterUpdateExplainsUnknownPrincipalWhenRestoreFails(t *testing.T)
{
+ ctx := context.Background()
+ server := unknownPrincipalServer(t)
+ defer server.Close()
+
+ detail := updateRowFilterPredicate(ctx, t, server.URL)
+ assert.Contains(t, detail, "restoring the previous row filter also
failed")
+ assert.Contains(t, detail, `USER "role:analyst" not found`)
+ assert.Contains(t, detail, "could not find the principal or the table
the row filter targets")
+}
+
+func TestRowFilterUpdateExplainsUnknownPrincipalWhenOnlyRestoreIs404(t
*testing.T) {
+ ctx := context.Background()
+ server := httptest.NewServer(http.HandlerFunc(func(w
http.ResponseWriter, request *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ switch {
+ case request.Method == http.MethodGet && request.URL.Path ==
"/v1/config":
+ require.NoError(t,
json.NewEncoder(w).Encode(client.ConfigResponse{Defaults:
map[string]string{"prefix": "catalog"}}))
+ case request.Method == http.MethodPost && request.URL.Path ==
"/v1/catalog/databases/analytics/tables/events/policies/drop":
+ w.WriteHeader(http.StatusOK)
+ case request.Method == http.MethodPost && request.URL.Path ==
"/v1/catalog/databases/analytics/tables/events/policies":
+ var body client.PolicyRequest
+ require.NoError(t,
json.NewDecoder(request.Body).Decode(&body))
+ if body.RowFilter.Predicate == `{"version":"new"}` {
+ w.WriteHeader(http.StatusBadRequest)
+ require.NoError(t,
json.NewEncoder(w).Encode(map[string]any{"code": 400}))
+
+ return
+ }
+ w.WriteHeader(http.StatusNotFound)
+ require.NoError(t,
json.NewEncoder(w).Encode(map[string]any{
+ "resourceType": "USER", "resourceName":
"role:analyst", "code": 404,
+ }))
+ case request.Method == http.MethodGet && request.URL.Path ==
"/v1/catalog/databases/analytics/tables/events/policies":
+ require.NoError(t,
json.NewEncoder(w).Encode(client.ListPoliciesResponse{}))
+ default:
+ http.NotFound(w, request)
+ }
+ }))
+ defer server.Close()
+
+ detail := updateRowFilterPredicate(ctx, t, server.URL)
+ assert.Contains(t, detail, "restoring the previous row filter also
failed")
+ assert.Contains(t, detail, `USER "role:analyst" not found`)
+ assert.Contains(t, detail, "could not find the principal or the table
the row filter targets")
+}
+
+func TestRowFilterUpdateExplainsUnknownPrincipalAfterConfigOutage(t
*testing.T) {
+ // Config fails during the replacement and recovers before the restore.
+ ctx := context.Background()
+ var mu sync.Mutex
+ configCalls := 0
+ server := httptest.NewServer(http.HandlerFunc(func(w
http.ResponseWriter, request *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ mu.Lock()
+ defer mu.Unlock()
+ switch {
+ case request.Method == http.MethodGet && request.URL.Path ==
"/v1/config":
+ configCalls++
+ if configCalls <= 2 {
+ w.WriteHeader(http.StatusNotFound)
+ require.NoError(t,
json.NewEncoder(w).Encode(map[string]any{"code": 404}))
+
+ return
+ }
+ require.NoError(t,
json.NewEncoder(w).Encode(client.ConfigResponse{Defaults:
map[string]string{"prefix": "catalog"}}))
+ case request.Method == http.MethodPost && request.URL.Path ==
"/v1/catalog/databases/analytics/tables/events/policies":
+ w.WriteHeader(http.StatusNotFound)
+ require.NoError(t,
json.NewEncoder(w).Encode(map[string]any{
+ "resourceType": "USER", "resourceName":
"role:analyst", "code": 404,
+ }))
+ case request.Method == http.MethodGet && request.URL.Path ==
"/v1/catalog/databases/analytics/tables/events/policies":
+ require.NoError(t,
json.NewEncoder(w).Encode(client.ListPoliciesResponse{}))
+ default:
+ http.NotFound(w, request)
+ }
+ }))
+ defer server.Close()
+
+ detail := updateRowFilterPredicate(ctx, t, server.URL)
+ assert.Contains(t, detail, "restoring the previous row filter also
failed")
+ assert.Contains(t, detail, `USER "role:analyst" not found`)
+ assert.Contains(t, detail, "could not find the principal or the table
the row filter targets")
+}
+
+func TestColumnMaskUpdateExplainsUnknownPrincipal(t *testing.T) {
+ ctx := context.Background()
+ server := unknownPrincipalServer(t)
+ defer server.Close()
+
+ api, err := client.New(client.Config{URI: server.URL, RecoveryTimeout:
250 * time.Millisecond})
+ require.NoError(t, err)
+ managed := &columnMaskResource{client: api}
+ var schemaResponse resource.SchemaResponse
+ managed.Schema(ctx, resource.SchemaRequest{}, &schemaResponse)
+ stateModel := columnMaskResourceModel{
+ AllowNonAtomicUpdate: types.BoolValue(true),
+ ID:
types.StringValue("column=email&database=analytics&principal=role%3Aanalyst&table=events"),
+ Database: types.StringValue("analytics"),
+ Table: types.StringValue("events"),
+ Principal: types.StringValue("role:analyst"),
+ Column: types.StringValue("email"),
+ Transform: types.StringValue(`{"type":"null"}`),
+ }
+ planModel := stateModel
+ planModel.Transform = types.StringValue(`{"type":"hash"}`)
+ state := tfsdk.State{Schema: schemaResponse.Schema}
+ plan := tfsdk.Plan{Schema: schemaResponse.Schema}
+ require.False(t, state.Set(ctx, &stateModel).HasError())
+ require.False(t, plan.Set(ctx, &planModel).HasError())
+ response := resource.UpdateResponse{State: tfsdk.State{Schema:
schemaResponse.Schema}}
+ managed.Update(ctx, resource.UpdateRequest{State: state, Plan: plan},
&response)
+
+ require.True(t, response.Diagnostics.HasError())
+ detail := response.Diagnostics.Errors()[0].Detail()
+ assert.Contains(t, detail, `USER "role:analyst" not found`)
+ assert.Contains(t, detail, "could not find the principal, the table, or
the column the column mask targets")
+}
+
+// updateRowFilterPredicate runs a row-filter Update and returns the error
detail.
+func updateRowFilterPredicate(ctx context.Context, t *testing.T, uri string)
string {
+ t.Helper()
+ api, err := client.New(client.Config{URI: uri, RecoveryTimeout: 250 *
time.Millisecond})
+ require.NoError(t, err)
+ managed := &rowFilterResource{client: api}
+ var schemaResponse resource.SchemaResponse
+ managed.Schema(ctx, resource.SchemaRequest{}, &schemaResponse)
+ stateModel := rowFilterResourceModel{
+ AllowNonAtomicUpdate: types.BoolValue(true),
+ ID:
types.StringValue("database=analytics&principal=role%3Aanalyst&table=events"),
+ Database: types.StringValue("analytics"),
+ Table: types.StringValue("events"),
+ Principal: types.StringValue("role:analyst"),
+ Predicate: types.StringValue(`{"version":"old"}`),
+ }
+ planModel := stateModel
+ planModel.Predicate = types.StringValue(`{"version":"new"}`)
+ state := tfsdk.State{Schema: schemaResponse.Schema}
+ plan := tfsdk.Plan{Schema: schemaResponse.Schema}
+ require.False(t, state.Set(ctx, &stateModel).HasError())
+ require.False(t, plan.Set(ctx, &planModel).HasError())
+ response := resource.UpdateResponse{State: tfsdk.State{Schema:
schemaResponse.Schema}}
+ managed.Update(ctx, resource.UpdateRequest{State: state, Plan: plan},
&response)
+ require.True(t, response.Diagnostics.HasError())
+
+ return response.Diagnostics.Errors()[0].Detail()
+}
+
+func TestRowFilterCreateDoesNotBlameThePrincipalWhenConfigFails(t *testing.T) {
+ ctx := context.Background()
+ posts := 0
+ server := httptest.NewServer(http.HandlerFunc(func(w
http.ResponseWriter, request *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ if request.Method == http.MethodPost {
+ posts++
+ }
+ w.WriteHeader(http.StatusNotFound)
+ require.NoError(t,
json.NewEncoder(w).Encode(map[string]any{"resourceType": "CATALOG",
"resourceName": "analytics", "code": 404}))
+ }))
+ defer server.Close()
+
+ api, err := client.New(client.Config{URI: server.URL, Warehouse:
"analytics", RecoveryTimeout: 250 * time.Millisecond})
+ require.NoError(t, err)
+ managed := &rowFilterResource{client: api}
+ var schemaResponse resource.SchemaResponse
+ managed.Schema(ctx, resource.SchemaRequest{}, &schemaResponse)
+ planModel := rowFilterResourceModel{
+ ID: types.StringUnknown(),
+ Database: types.StringValue("analytics"),
+ Table: types.StringValue("events"),
+ Principal: types.StringValue("role:analyst"),
+ Predicate: types.StringValue(`{"field":"tenant_id"}`),
+ }
+ plan := tfsdk.Plan{Schema: schemaResponse.Schema}
+ require.False(t, plan.Set(ctx, &planModel).HasError())
+ response := resource.CreateResponse{State: tfsdk.State{Schema:
schemaResponse.Schema}}
+ managed.Create(ctx, resource.CreateRequest{Plan: plan}, &response)
+
+ require.True(t, response.Diagnostics.HasError())
+ detail := response.Diagnostics.Errors()[0].Detail()
+ assert.Contains(t, detail, `CATALOG "analytics" not found`)
+ assert.NotContains(t, detail, "could not find the principal")
+ assert.Equal(t, 0, posts)
+}
+
+func TestRowFilterCreateHintMentionsUnsupportedManagementAPI(t *testing.T) {
+ ctx := context.Background()
+ server := httptest.NewServer(http.HandlerFunc(func(w
http.ResponseWriter, request *http.Request) {
+ if request.Method == http.MethodGet && request.URL.Path ==
"/v1/config" {
+ w.Header().Set("Content-Type", "application/json")
+ require.NoError(t,
json.NewEncoder(w).Encode(client.ConfigResponse{Defaults:
map[string]string{"prefix": "catalog"}}))
+
+ return
+ }
+ http.NotFound(w, request)
+ }))
+ defer server.Close()
+
+ api, err := client.New(client.Config{URI: server.URL, RecoveryTimeout:
250 * time.Millisecond})
+ require.NoError(t, err)
+ managed := &rowFilterResource{client: api}
+ var schemaResponse resource.SchemaResponse
+ managed.Schema(ctx, resource.SchemaRequest{}, &schemaResponse)
+ planModel := rowFilterResourceModel{
+ ID: types.StringUnknown(),
+ Database: types.StringValue("analytics"),
+ Table: types.StringValue("events"),
+ Principal: types.StringValue("role:analyst"),
+ Predicate: types.StringValue(`{"field":"tenant_id"}`),
+ }
+ plan := tfsdk.Plan{Schema: schemaResponse.Schema}
+ require.False(t, plan.Set(ctx, &planModel).HasError())
+ response := resource.CreateResponse{State: tfsdk.State{Schema:
schemaResponse.Schema}}
+ managed.Create(ctx, resource.CreateRequest{Plan: plan}, &response)
+
+ require.True(t, response.Diagnostics.HasError())
+ detail := response.Diagnostics.Errors()[0].Detail()
+ assert.Contains(t, detail, "usually means it could not find the
principal or the table the row filter targets")
+ assert.Contains(t, detail, "does not serve this management API")
+}
+
+func TestDatabaseReadKeepsTheRequestedName(t *testing.T) {
+ ctx := context.Background()
+ server := httptest.NewServer(http.HandlerFunc(func(w
http.ResponseWriter, request *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ switch {
+ case request.Method == http.MethodGet && request.URL.Path ==
"/v1/config":
+ require.NoError(t,
json.NewEncoder(w).Encode(client.ConfigResponse{Defaults:
map[string]string{"prefix": "catalog"}}))
+ case request.Method == http.MethodGet && request.URL.Path ==
"/v1/catalog/databases/analytics":
+ require.NoError(t,
json.NewEncoder(w).Encode(client.Database{ID: "db-1", Name: "secret-token",
Location: "oss://bucket/analytics"}))
+ default:
+ http.NotFound(w, request)
+ }
+ }))
+ defer server.Close()
+
+ api, err := client.New(client.Config{URI: server.URL})
+ require.NoError(t, err)
+ managed := &databaseResource{client: api}
+ var schemaResponse resource.SchemaResponse
+ managed.Schema(ctx, resource.SchemaRequest{}, &schemaResponse)
+ stateModel := databaseResourceModel{
+ ID: types.StringValue("analytics"),
+ ServerID: types.StringValue("db-1"),
+ Name: types.StringValue("analytics"),
+ Options: types.MapNull(types.StringType),
+ ServerOptions: types.MapNull(types.StringType),
+ Location: types.StringValue("oss://bucket/analytics"),
+ Owner: types.StringNull(),
+ CreatedAt: types.Int64Null(),
+ CreatedBy: types.StringNull(),
+ UpdatedAt: types.Int64Null(),
+ UpdatedBy: types.StringNull(),
+ }
+ state := tfsdk.State{Schema: schemaResponse.Schema}
+ require.False(t, state.Set(ctx, &stateModel).HasError())
+ response := resource.ReadResponse{State: state}
+ managed.Read(ctx, resource.ReadRequest{State: state}, &response)
+
+ require.False(t, response.Diagnostics.HasError(),
response.Diagnostics.Errors())
+ var refreshed databaseResourceModel
+ require.False(t, response.State.Get(ctx, &refreshed).HasError())
+ assert.Equal(t, "analytics", refreshed.Name.ValueString())
+ assert.Equal(t, "analytics", refreshed.ID.ValueString())
+}
+
+func TestRowFilterUpdateExplainsUnknownPrincipalWhenComparisonFails(t
*testing.T) {
+ // Comparing the leftover policy needs the schema, which stops being
served.
+ ctx := context.Background()
+ var mu sync.Mutex
+ tableCalls := 0
+ server := httptest.NewServer(http.HandlerFunc(func(w
http.ResponseWriter, request *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ mu.Lock()
+ defer mu.Unlock()
+ switch {
+ case request.Method == http.MethodGet && request.URL.Path ==
"/v1/config":
+ require.NoError(t,
json.NewEncoder(w).Encode(client.ConfigResponse{Defaults:
map[string]string{"prefix": "catalog"}}))
+ case request.Method == http.MethodPost && request.URL.Path ==
"/v1/catalog/databases/analytics/tables/events/policies/drop":
+ w.WriteHeader(http.StatusOK)
+ case request.Method == http.MethodPost && request.URL.Path ==
"/v1/catalog/databases/analytics/tables/events/policies":
+ w.WriteHeader(http.StatusNotFound)
+ require.NoError(t,
json.NewEncoder(w).Encode(map[string]any{
+ "resourceType": "USER", "resourceName":
"role:analyst", "code": 404,
+ }))
+ case request.Method == http.MethodGet && request.URL.Path ==
"/v1/catalog/databases/analytics/tables/events/policies":
+ require.NoError(t,
json.NewEncoder(w).Encode(client.ListPoliciesResponse{Policies:
[]client.DataPolicy{{
+ Resource: client.PermissionResource{Type:
client.ResourceTypeTable, Database: "analytics", Table: "events"},
+ RowFilter: &client.RowFilter{Predicate:
`{"version":"other"}`},
+ Principal: "role:analyst",
+ }}}))
+ case request.Method == http.MethodGet && request.URL.Path ==
"/v1/catalog/databases/analytics/tables/events":
+ tableCalls++
+ if tableCalls > 1 {
+ w.WriteHeader(http.StatusInternalServerError)
+ require.NoError(t,
json.NewEncoder(w).Encode(map[string]any{"code": 500}))
+
+ return
+ }
+ require.NoError(t,
json.NewEncoder(w).Encode(client.Table{Database: "analytics", Name: "events",
Schema: client.Schema{Fields: []client.Field{{ID: 0, Name: "region", Type:
client.DataType("STRING")}}}}))
+ default:
+ http.NotFound(w, request)
+ }
+ }))
+ defer server.Close()
+
+ api, err := client.New(client.Config{URI: server.URL, RecoveryTimeout:
250 * time.Millisecond})
+ require.NoError(t, err)
+ managed := &rowFilterResource{client: api}
+ var schemaResponse resource.SchemaResponse
+ managed.Schema(ctx, resource.SchemaRequest{}, &schemaResponse)
+ stateModel := rowFilterResourceModel{
+ AllowNonAtomicUpdate: types.BoolValue(true),
+ ID:
types.StringValue("database=analytics&principal=role%3Aanalyst&table=events"),
+ Database: types.StringValue("analytics"),
+ Table: types.StringValue("events"),
+ Principal: types.StringValue("role:analyst"),
+ Predicate:
types.StringValue(`{"kind":"LEAF","transform":{"name":"FIELD_REF","fieldRef":{"name":"region"}}}`),
+ }
+ planModel := stateModel
+ planModel.Predicate = types.StringValue(`{"version":"new"}`)
+ state := tfsdk.State{Schema: schemaResponse.Schema}
+ plan := tfsdk.Plan{Schema: schemaResponse.Schema}
+ require.False(t, state.Set(ctx, &stateModel).HasError())
+ require.False(t, plan.Set(ctx, &planModel).HasError())
+ response := resource.UpdateResponse{State: tfsdk.State{Schema:
schemaResponse.Schema}}
+ managed.Update(ctx, resource.UpdateRequest{State: state, Plan: plan},
&response)
+
+ require.True(t, response.Diagnostics.HasError())
+ detail := response.Diagnostics.Errors()[0].Detail()
+ assert.Contains(t, detail, "comparing the remaining policy failed")
+ assert.Contains(t, detail, `USER "role:analyst" not found`)
+ assert.Contains(t, detail, "could not find the principal or the table
the row filter targets")
+}
+
+func TestTableReadKeepsTheRequestedIdentity(t *testing.T) {
+ ctx := context.Background()
+ legit := &client.Table{ID: "tbl-1", Database: "analytics", Name:
"events", Schema: client.Schema{Fields: []client.Field{{ID: 0, Name: "id",
Type: client.DataType("BIGINT")}}}}
+ poisoned := *legit
+ poisoned.Database, poisoned.Name = "secret-token", "secret-token"
+ server := httptest.NewServer(http.HandlerFunc(func(w
http.ResponseWriter, request *http.Request) {
+ switch {
+ case request.Method == http.MethodGet && request.URL.Path ==
"/v1/config":
+ writeProviderJSON(t, w, client.ConfigResponse{Defaults:
map[string]string{"prefix": "catalog"}})
+ case request.Method == http.MethodGet && request.URL.Path ==
"/v1/catalog/databases/analytics/tables/events":
+ writeProviderJSON(t, w, poisoned)
+ default:
+ http.NotFound(w, request)
+ }
+ }))
+ defer server.Close()
+
+ api, err := client.New(client.Config{URI: server.URL})
+ require.NoError(t, err)
+ managed := &tableResource{client: api}
+ var schemaResponse resource.SchemaResponse
+ managed.Schema(ctx, resource.SchemaRequest{}, &schemaResponse)
+ var ds diag.Diagnostics
+ model := tableResourceModel{
+ Database: types.StringValue("analytics"),
+ Name: types.StringValue("events"),
+ Fields: types.ListNull(types.ObjectType{AttrTypes:
tableFieldAttrTypes()}),
+ Options: types.MapValueMust(types.StringType,
map[string]attr.Value{}),
+ }
+ setTableResourceModel(ctx, &model, legit, &ds)
+ require.False(t, ds.HasError(), ds)
+ state := tfsdk.State{Schema: schemaResponse.Schema}
+ require.False(t, state.Set(ctx, &model).HasError())
+ read := resource.ReadResponse{State: state}
+ managed.Read(ctx, resource.ReadRequest{State: state}, &read)
+
+ require.False(t, read.Diagnostics.HasError(), read.Diagnostics)
+ var after tableResourceModel
+ require.False(t, read.State.Get(ctx, &after).HasError())
+ assert.Equal(t, "analytics", after.Database.ValueString())
+ assert.Equal(t, "events", after.Name.ValueString())
+ assert.Equal(t, tableID("analytics", "events"), after.ID.ValueString())
+}
+
+func TestDatabaseDataSourceKeepsTheConfiguredName(t *testing.T) {
+ ctx := context.Background()
+ server := httptest.NewServer(http.HandlerFunc(func(w
http.ResponseWriter, request *http.Request) {
+ switch {
+ case request.Method == http.MethodGet && request.URL.Path ==
"/v1/config":
+ writeProviderJSON(t, w, client.ConfigResponse{Defaults:
map[string]string{"prefix": "catalog"}})
+ case request.Method == http.MethodGet && request.URL.Path ==
"/v1/catalog/databases/analytics":
+ writeProviderJSON(t, w, client.Database{ID: "db-1",
Name: "secret-token"})
+ default:
+ http.NotFound(w, request)
+ }
+ }))
+ defer server.Close()
+
+ api, err := client.New(client.Config{URI: server.URL})
+ require.NoError(t, err)
+ source := &databaseDataSource{client: api}
+ var schemaResponse datasource.SchemaResponse
+ source.Schema(ctx, datasource.SchemaRequest{}, &schemaResponse)
+ config := tfsdk.State{Schema: schemaResponse.Schema}
+ require.False(t, config.Set(ctx, &databaseDataSourceModel{
+ ID: types.StringUnknown(), ServerID: types.StringUnknown(),
Name: types.StringValue("analytics"),
+ Options: types.MapUnknown(types.StringType), Location:
types.StringUnknown(), Owner: types.StringUnknown(),
+ CreatedAt: types.Int64Unknown(), CreatedBy:
types.StringUnknown(), UpdatedAt: types.Int64Unknown(), UpdatedBy:
types.StringUnknown(),
+ }).HasError())
+ response := datasource.ReadResponse{State: tfsdk.State{Schema:
schemaResponse.Schema}}
+ source.Read(ctx, datasource.ReadRequest{Config: tfsdk.Config{Raw:
config.Raw, Schema: schemaResponse.Schema}}, &response)
+
+ require.False(t, response.Diagnostics.HasError(), response.Diagnostics)
+ var data databaseDataSourceModel
+ require.False(t, response.State.Get(ctx, &data).HasError())
+ assert.Equal(t, "analytics", data.Name.ValueString())
+ assert.Equal(t, "analytics", data.ID.ValueString())
+}
+
+func TestTableDataSourceKeepsTheConfiguredIdentity(t *testing.T) {
+ ctx := context.Background()
+ server := httptest.NewServer(http.HandlerFunc(func(w
http.ResponseWriter, request *http.Request) {
+ switch {
+ case request.Method == http.MethodGet && request.URL.Path ==
"/v1/config":
+ writeProviderJSON(t, w, client.ConfigResponse{Defaults:
map[string]string{"prefix": "catalog"}})
+ case request.Method == http.MethodGet && request.URL.Path ==
"/v1/catalog/databases/analytics/tables/events":
+ writeProviderJSON(t, w, client.Table{ID: "tbl-1",
Database: "secret-token", Name: "secret-token", Schema: client.Schema{Fields:
[]client.Field{{ID: 0, Name: "id", Type: client.DataType("BIGINT")}}}})
+ default:
+ http.NotFound(w, request)
+ }
+ }))
+ defer server.Close()
+
+ api, err := client.New(client.Config{URI: server.URL})
+ require.NoError(t, err)
+ source := &tableDataSource{client: api}
+ var schemaResponse datasource.SchemaResponse
+ source.Schema(ctx, datasource.SchemaRequest{}, &schemaResponse)
+ config := tfsdk.State{Schema: schemaResponse.Schema}
+ require.False(t, config.Set(ctx, &tableDataSourceModel{
+ ID: types.StringUnknown(), ServerID: types.StringUnknown(),
Database: types.StringValue("analytics"), Name: types.StringValue("events"),
+ Fields: types.ListUnknown(types.ObjectType{AttrTypes:
tableFieldAttrTypes()}), PartitionKeys: types.ListUnknown(types.StringType),
PrimaryKeys: types.ListUnknown(types.StringType),
+ Options: types.MapUnknown(types.StringType), Comment:
types.StringUnknown(), SchemaID: types.Int64Unknown(), Path:
types.StringUnknown(), IsExternal: types.BoolUnknown(),
+ Owner: types.StringUnknown(), CreatedAt: types.Int64Unknown(),
CreatedBy: types.StringUnknown(), UpdatedAt: types.Int64Unknown(), UpdatedBy:
types.StringUnknown(),
+ }).HasError())
+ response := datasource.ReadResponse{State: tfsdk.State{Schema:
schemaResponse.Schema}}
+ source.Read(ctx, datasource.ReadRequest{Config: tfsdk.Config{Raw:
config.Raw, Schema: schemaResponse.Schema}}, &response)
+
+ require.False(t, response.Diagnostics.HasError(), response.Diagnostics)
+ var data tableDataSourceModel
+ require.False(t, response.State.Get(ctx, &data).HasError())
+ assert.Equal(t, "analytics", data.Database.ValueString())
+ assert.Equal(t, "events", data.Name.ValueString())
+ assert.Equal(t, tableID("analytics", "events"), data.ID.ValueString())
+}
+
+func TestRowFilterCreateDoesNotBlameThePrincipalWhenOnlyTheLookupIs404(t
*testing.T) {
+ // Only the lookup answers 404; the write itself failed with 500.
+ ctx := context.Background()
+ server := httptest.NewServer(http.HandlerFunc(func(w
http.ResponseWriter, request *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ switch {
+ case request.Method == http.MethodGet && request.URL.Path ==
"/v1/config":
+ require.NoError(t,
json.NewEncoder(w).Encode(client.ConfigResponse{Defaults:
map[string]string{"prefix": "catalog"}}))
+ case request.Method == http.MethodPost && request.URL.Path ==
"/v1/catalog/databases/analytics/tables/events/policies":
+ w.WriteHeader(http.StatusInternalServerError)
+ require.NoError(t,
json.NewEncoder(w).Encode(map[string]any{"code": 500}))
+ case request.Method == http.MethodGet && request.URL.Path ==
"/v1/catalog/databases/analytics/tables/events/policies":
+ w.WriteHeader(http.StatusNotFound)
+ require.NoError(t,
json.NewEncoder(w).Encode(map[string]any{"resourceType": "TABLE",
"resourceName": "events", "code": 404}))
+ default:
+ http.NotFound(w, request)
+ }
+ }))
+ defer server.Close()
+
+ api, err := client.New(client.Config{URI: server.URL, RecoveryTimeout:
250 * time.Millisecond})
+ require.NoError(t, err)
+ managed := &rowFilterResource{client: api}
+ var schemaResponse resource.SchemaResponse
+ managed.Schema(ctx, resource.SchemaRequest{}, &schemaResponse)
+ planModel := rowFilterResourceModel{
+ ID: types.StringUnknown(),
+ Database: types.StringValue("analytics"),
+ Table: types.StringValue("events"),
+ Principal: types.StringValue("role:analyst"),
+ Predicate: types.StringValue(`{"field":"tenant_id"}`),
+ }
+ plan := tfsdk.Plan{Schema: schemaResponse.Schema}
+ require.False(t, plan.Set(ctx, &planModel).HasError())
+ response := resource.CreateResponse{State: tfsdk.State{Schema:
schemaResponse.Schema}}
+ managed.Create(ctx, resource.CreateRequest{Plan: plan}, &response)
+
+ require.True(t, response.Diagnostics.HasError())
+ detail := response.Diagnostics.Errors()[0].Detail()
+ assert.Contains(t, detail, "HTTP 500")
+ assert.NotContains(t, detail, "HTTP 404 to this write")
+}
+
+func TestExplainNotFoundLeavesOtherFailuresAlone(t *testing.T) {
+ detail := explainNotFound("boom", &client.APIError{StatusCode:
http.StatusInternalServerError}, "the principal")
+ assert.Equal(t, "boom", detail)
+}
+
func TestPermissionCreateReconcilesLostResponse(t *testing.T) {
ctx := context.Background()
var mu sync.Mutex
diff --git a/internal/provider/resource_database.go
b/internal/provider/resource_database.go
index 676b4c3..2ef9082 100644
--- a/internal/provider/resource_database.go
+++ b/internal/provider/resource_database.go
@@ -270,9 +270,9 @@ func databaseMatchesOptions(database *client.Database, name
string, options map[
}
func setDatabaseResourceModel(ctx context.Context, model
*databaseResourceModel, database *client.Database, diags *diag.Diagnostics) {
- model.ID = types.StringValue(database.Name)
+ // Identity comes from the caller, never from the response.
+ model.ID = types.StringValue(model.Name.ValueString())
model.ServerID = types.StringValue(database.ID)
- model.Name = types.StringValue(database.Name)
model.Options = syncManagedOptions(ctx, model.Options,
database.Options, diags)
model.ServerOptions = stringMapValue(ctx, database.Options, diags)
model.Location = types.StringValue(database.Location)
diff --git a/internal/provider/resource_permission.go
b/internal/provider/resource_permission.go
index eff1f79..55c7e3d 100644
--- a/internal/provider/resource_permission.go
+++ b/internal/provider/resource_permission.go
@@ -218,7 +218,7 @@ func (r *permissionResource) Create(ctx context.Context,
req resource.CreateRequ
if err := r.client.GrantPermission(ctx, assignment); err != nil {
observed, recovered, recoveryErr := r.reconcileFailedGrant(ctx,
plan, assignment, err)
if !recovered {
- resp.Diagnostics.AddError("Unable to grant Paimon
permission", recoveryErr.Error())
+ resp.Diagnostics.AddError("Unable to grant Paimon
permission", explainNotFound(recoveryErr.Error(), err, permissionReferences))
return
}
@@ -281,7 +281,7 @@ func (r *permissionResource) Update(ctx context.Context,
req resource.UpdateRequ
if err := r.client.GrantPermission(ctx, assignment); err != nil {
observed, recovered, recoveryErr := r.reconcileFailedGrant(ctx,
plan, assignment, err)
if !recovered {
- resp.Diagnostics.AddError("Unable to update Paimon
permission", recoveryErr.Error())
+ resp.Diagnostics.AddError("Unable to update Paimon
permission", explainNotFound(recoveryErr.Error(), err, permissionReferences))
return
}
diff --git a/internal/provider/resource_policy.go
b/internal/provider/resource_policy.go
index d82b54c..cf21f3b 100644
--- a/internal/provider/resource_policy.go
+++ b/internal/provider/resource_policy.go
@@ -20,6 +20,7 @@ package provider
import (
"context"
"encoding/json"
+ "errors"
"fmt"
"net/url"
@@ -202,7 +203,7 @@ func (r *rowFilterResource) Create(ctx context.Context, req
resource.CreateReque
}
result := createPolicyWithReconciliation(ctx, r.client,
rowFilterSpec(plan), false)
if !result.accepted {
- resp.Diagnostics.AddError("Unable to create Paimon row filter",
result.err.Error())
+ resp.Diagnostics.AddError("Unable to create Paimon row filter",
explainNotFound(result.err.Error(), result.cause, rowFilterReferences))
return
}
@@ -281,7 +282,7 @@ func (r *rowFilterResource) Update(ctx context.Context, req
resource.UpdateReque
resp.Diagnostics.AddWarning("Recovered Paimon row-filter
replacement", result.warning)
}
if result.err != nil {
- resp.Diagnostics.AddError("Unable to replace Paimon row
filter", result.err.Error())
+ resp.Diagnostics.AddError("Unable to replace Paimon row
filter", explainNotFound(result.err.Error(), result.cause, rowFilterReferences))
}
}
@@ -392,7 +393,7 @@ func (r *columnMaskResource) Create(ctx context.Context,
req resource.CreateRequ
}
result := createPolicyWithReconciliation(ctx, r.client,
columnMaskSpec(plan), false)
if !result.accepted {
- resp.Diagnostics.AddError("Unable to create Paimon column
mask", result.err.Error())
+ resp.Diagnostics.AddError("Unable to create Paimon column
mask", explainNotFound(result.err.Error(), result.cause, columnMaskReferences))
return
}
@@ -471,7 +472,7 @@ func (r *columnMaskResource) Update(ctx context.Context,
req resource.UpdateRequ
resp.Diagnostics.AddWarning("Recovered Paimon column-mask
replacement", result.warning)
}
if result.err != nil {
- resp.Diagnostics.AddError("Unable to replace Paimon column
mask", result.err.Error())
+ resp.Diagnostics.AddError("Unable to replace Paimon column
mask", explainNotFound(result.err.Error(), result.cause, columnMaskReferences))
}
}
@@ -623,12 +624,14 @@ type policyCreateResult struct {
observed *client.DataPolicy
warning string
err error
+ // cause is the raw REST error behind err.
+ cause error
}
func createPolicyWithReconciliation(ctx context.Context, api *client.Client,
spec policySpec, alreadyManaged bool) policyCreateResult {
createErr := api.CreatePolicy(ctx, spec.database, spec.table,
spec.request)
if createErr != nil && !alreadyManaged &&
!client.IsMutationOutcomeUncertain(createErr) {
- return policyCreateResult{err: fmt.Errorf("creating the %s was
rejected: %w. Import an existing policy explicitly; it has not been adopted
into state", spec.label, createErr)}
+ return policyCreateResult{cause: createErr, err:
fmt.Errorf("creating the %s was rejected: %w. Import an existing policy
explicitly; it has not been adopted into state", spec.label, createErr)}
}
recoveryCtx, cancel := mutationRecoveryContext(ctx, api)
defer cancel()
@@ -658,13 +661,13 @@ func createPolicyWithReconciliation(ctx context.Context,
api *client.Client, spe
return result
}
if reconcileErr != nil {
- return policyCreateResult{err: fmt.Errorf("creating the %s
failed (%s), and bounded reconciliation could not establish the remote state:
%w", spec.label, createErr, reconcileErr)}
+ return policyCreateResult{cause: createErr, err:
fmt.Errorf("creating the %s failed (%s), and bounded reconciliation could not
establish the remote state: %w", spec.label, createErr, reconcileErr)}
}
if !found {
- return policyCreateResult{err: fmt.Errorf("creating the %s
failed, and bounded reconciliation confirmed that the policy is absent: %w",
spec.label, createErr)}
+ return policyCreateResult{cause: createErr, err:
fmt.Errorf("creating the %s failed, and bounded reconciliation confirmed that
the policy is absent: %w", spec.label, createErr)}
}
if !converged {
- return policyCreateResult{observed: &observed, err:
fmt.Errorf("creating the %s failed (%s), and the same identity exists with
different policy content", spec.label, createErr)}
+ return policyCreateResult{cause: createErr, observed:
&observed, err: fmt.Errorf("creating the %s failed (%s), and the same identity
exists with different policy content", spec.label, createErr)}
}
return policyCreateResult{
@@ -679,6 +682,7 @@ type policyReplacementResult struct {
observed *client.DataPolicy
warning string
err error
+ cause error
}
type policyLookupObservation struct {
@@ -732,13 +736,13 @@ func replacePolicyWithReconciliation(ctx context.Context,
api *client.Client, pr
if created.observed != nil {
matches, err := previous.matchesWithSchema(ctx, api,
*created.observed)
if err != nil {
- return policyReplacementResult{observed:
created.observed, err: err}
+ return policyReplacementResult{cause: created.cause,
observed: created.observed, err: fmt.Errorf("creating the replacement failed
(%s), and comparing the remaining policy failed: %w", created.err, err)}
}
if matches {
- return policyReplacementResult{observed:
created.observed, err: fmt.Errorf("creating the replacement failed, but
reconciliation confirmed that the previous %s remains attached: %w",
previous.label, created.err)}
+ return policyReplacementResult{cause: created.cause,
observed: created.observed, err: fmt.Errorf("creating the replacement failed,
but reconciliation confirmed that the previous %s remains attached: %w",
previous.label, created.err)}
}
- return policyReplacementResult{observed: created.observed, err:
fmt.Errorf("creating the replacement failed, and reconciliation found
unexpected policy content for the same identity: %w", created.err)}
+ return policyReplacementResult{cause: created.cause, observed:
created.observed, err: fmt.Errorf("creating the replacement failed, and
reconciliation found unexpected policy content for the same identity: %w",
created.err)}
}
recoveryCtx, cancel := mutationRecoveryContext(ctx, api)
@@ -750,14 +754,14 @@ func replacePolicyWithReconciliation(ctx context.Context,
api *client.Client, pr
detail += fmt.Sprintf(", but verification of the
restored policy also failed (%s)", restored.err)
}
- return policyReplacementResult{observed: restored.observed,
err: fmt.Errorf("%s", detail)}
+ return policyReplacementResult{cause: created.cause, observed:
restored.observed, err: errors.New(detail)}
}
restoredDesired := false
if restored.observed != nil {
var err error
restoredDesired, err = desired.matchesWithSchema(recoveryCtx,
api, *restored.observed)
if err != nil {
- return policyReplacementResult{observed:
restored.observed, err: err}
+ return policyReplacementResult{cause:
notFoundCause(created.cause, restored.cause), observed: restored.observed, err:
fmt.Errorf("creating the replacement failed (%s), restoring the previous %s
failed (%s), and comparing the remaining policy failed: %w", created.err,
previous.label, restored.err, err)}
}
}
if restoredDesired {
@@ -768,7 +772,16 @@ func replacePolicyWithReconciliation(ctx context.Context,
api *client.Client, pr
}
}
- return policyReplacementResult{err: fmt.Errorf("creating the
replacement failed (%s), and restoring the previous %s also failed (%s). The
principal may currently have no %s", created.err, previous.label, restored.err,
previous.label)}
+ return policyReplacementResult{cause: notFoundCause(created.cause,
restored.cause), err: fmt.Errorf("creating the replacement failed (%s), and
restoring the previous %s also failed (%s). The principal may currently have no
%s", created.err, previous.label, restored.err, previous.label)}
+}
+
+// notFoundCause picks the write that was refused with HTTP 404, if either was.
+func notFoundCause(first, second error) error {
+ if !explainableNotFound(first) && explainableNotFound(second) {
+ return second
+ }
+
+ return first
}
func validateSerializedPolicy(name, value string, diags *diag.Diagnostics) {
diff --git a/internal/provider/resource_table.go
b/internal/provider/resource_table.go
index 6a3129a..f02928a 100644
--- a/internal/provider/resource_table.go
+++ b/internal/provider/resource_table.go
@@ -467,18 +467,9 @@ func tableMatchesPlannedSchema(table *client.Table,
expected client.Schema, comp
}
func setTableResourceModel(ctx context.Context, model *tableResourceModel,
table *client.Table, diags *diag.Diagnostics) {
- database := table.Database
- if database == "" {
- database = model.Database.ValueString()
- }
- name := table.Name
- if name == "" {
- name = model.Name.ValueString()
- }
- model.ID = types.StringValue(tableID(database, name))
+ // Identity comes from the caller, never from the response.
+ model.ID = types.StringValue(tableID(model.Database.ValueString(),
model.Name.ValueString()))
model.ServerID = types.StringValue(table.ID)
- model.Database = types.StringValue(database)
- model.Name = types.StringValue(name)
model.Fields = resourceFieldsValueFromRemote(ctx, model.Fields,
table.Schema.Fields, diags)
model.PartitionKeys = stringListValue(ctx, table.Schema.PartitionKeys,
diags)
model.PrimaryKeys = stringListValue(ctx, table.Schema.PrimaryKeys,
diags)
diff --git a/internal/provider/write_diagnostics.go
b/internal/provider/write_diagnostics.go
new file mode 100644
index 0000000..f257bb0
--- /dev/null
+++ b/internal/provider/write_diagnostics.go
@@ -0,0 +1,45 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package provider
+
+import (
+ "github.com/apache/terraform-provider-paimon/internal/client"
+)
+
+const (
+ permissionReferences = "the principal or the catalog, database, table,
function, view, or columns the permission targets"
+ rowFilterReferences = "the principal or the table the row filter
targets"
+ columnMaskReferences = "the principal, the table, or the column the
column mask targets"
+)
+
+// explainNotFound adds a hint when a write was refused with HTTP 404; a 404
+// from loading the config is not about the write and gets none.
+func explainNotFound(detail string, cause error, referenced string) string {
+ if !explainableNotFound(cause) {
+ return detail
+ }
+
+ return detail + "\n\nThe REST Catalog answered HTTP 404 to this write.
That usually means it could not find " + referenced +
+ ": check that each of them exists on the catalog exactly as
written, and that the principal is already known to the catalog's identity
provider." +
+ " A catalog that does not serve this management API answers the
same way."
+}
+
+// explainableNotFound: a 404 from the operation, not from loading the config.
+func explainableNotFound(err error) bool {
+ return client.IsNotFound(err) && !client.IsConfigurationFailure(err)
+}