fix(database): resolve type=app trusted_source names to UUIDs before DO API call#25
fix(database): resolve type=app trusted_source names to UUIDs before DO API call#25
Conversation
…DO API call The DO database firewall API requires an app UUID for type=app rules. Passing an app name directly results in a 422 "invalid app uuid" error. Add DatabaseDriver.resolveAppName which, when the value is not already UUID-shaped, looks up the app by name via the Apps API. Wire AppPlatformClient into DatabaseDriver at construction time (NewDatabaseDriver wires c.Apps; NewDatabaseDriverWithClients supports injection for tests). Replace the plain trustedSourceCreateRulesFromConfig / trustedSourceFirewallRulesFromConfig calls in Create and Update with context-aware buildCreateFirewallRules and buildUpdateFirewallRules methods that call resolveAppName for type=app entries. Add five new unit tests: - AppNameResolved (Create + Update): name → UUID resolved via mock Apps list - AppUUIDPassThrough: UUID-shaped values skip the list call entirely - AppNameNotFound: returns error when app is not in the list - NoAppsClient: returns an actionable error when appsClient is nil Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Fixes DigitalOcean database firewall trusted_sources rules of type: app by ensuring app names are resolved to UUIDs before calling the DO Databases Firewall API (which requires UUIDs), preventing 422 invalid app uuid errors.
Changes:
- Extend
DatabaseDriverto optionally hold anAppPlatformClientand resolvetype=apptrusted source values from name → UUID when needed. - Wire the Apps client automatically in
NewDatabaseDriver, and addNewDatabaseDriverWithClientsfor tests. - Add unit tests covering name→UUID resolution, UUID pass-through, missing app handling, and nil Apps client behavior.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| internal/drivers/database.go | Adds app name→UUID resolution for trusted_sources (type=app) in both Create and Update paths via an optional Apps client. |
| internal/drivers/database_test.go | Adds unit tests validating type=app trusted source resolution and associated error cases. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if ruleType == "app" { | ||
| resolved, err := d.resolveAppName(ctx, ruleValue) | ||
| if err != nil { | ||
| return nil, err | ||
| } |
There was a problem hiding this comment.
buildCreateFirewallRules resolves each type=app rule by calling resolveAppName, which paginates Apps.List. If multiple trusted_sources include type=app, this can trigger multiple full app-list scans and extra API calls. Consider resolving all non-UUID app names in one list pass (build a name→UUID map) or caching within a single build* call to reduce latency and rate-limit risk.
| }) | ||
| if err == nil { | ||
| t.Fatal("expected error when app name not found, got nil") | ||
| } |
There was a problem hiding this comment.
This test only asserts that an error is returned when the app name isn’t found. To ensure callers can reliably classify the failure, please also assert that the error wraps drivers.ErrResourceNotFound (errors.Is) and/or that the message includes the missing app name.
| } | |
| } | |
| if !errors.Is(err, drivers.ErrResourceNotFound) { | |
| t.Fatalf("expected error to wrap drivers.ErrResourceNotFound, got %v", err) | |
| } |
| if err == nil { | ||
| t.Fatal("expected error when apps client is nil and value is not UUID, got nil") | ||
| } |
There was a problem hiding this comment.
This test only checks err != nil when appsClient is nil. Since the intent is to return an actionable error, consider asserting on the error text (e.g., mentions that the value isn’t UUID-shaped and no Apps client is configured) so the test will fail if the message regresses to a generic/cryptic error.
…tests Address Copilot review on PR #25: - Replace per-rule resolveAppName (one Apps.List scan per rule) with resolveAppNamesMap, which collects all non-UUID app names and resolves them in a single paginated Apps.List pass regardless of how many type=app rules are present, reducing API calls and rate-limit risk. - Remove now-unused resolveAppName helper. - TestDatabaseDriver_Create_AppNameNotFound_ReturnsError: assert errors.Is(err, ErrResourceNotFound) and that the missing name is included in the error message. - TestDatabaseDriver_Create_AppType_NoAppsClient_ReturnsError: assert error message contains "not a UUID" and the offending value so the message cannot silently regress to a cryptic form. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| opts := &godo.ListOptions{Page: 1, PerPage: 200} | ||
| for { | ||
| apps, resp, err := d.appsClient.List(ctx, opts) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("app list for trusted_source resolution: %w", WrapGodoError(err)) | ||
| } | ||
| for _, app := range apps { | ||
| if app.Spec == nil { | ||
| continue | ||
| } | ||
| if _, want := needed[app.Spec.Name]; want { | ||
| resolved[app.Spec.Name] = app.ID | ||
| } | ||
| } | ||
| if resp == nil || resp.Links == nil || resp.Links.IsLastPage() { | ||
| break | ||
| } | ||
| opts.Page++ | ||
| } |
There was a problem hiding this comment.
resolveAppNamesMap continues paging through the entire Apps list even after all needed names have been resolved. You can short-circuit the pagination loop once len(resolved) == len(needed) to avoid unnecessary API calls in accounts with many apps.
…ppNamesMap Address Copilot round-3 review on PR #25: - Docstring: "A single paginated Apps.List call is made" was misleading (one call per page). Reworded to "a single paginated listing pass (potentially multiple page requests)" — accurate without overpromising. - Early exit: once len(resolved) == len(needed), return immediately without fetching further pages. Avoids scanning the full Apps list in accounts with many apps when all names are found early. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| } | ||
| resolved := make(map[string]string, len(needed)) | ||
| opts := &godo.ListOptions{Page: 1, PerPage: 200} | ||
| for { | ||
| apps, resp, err := d.appsClient.List(ctx, opts) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("app list for trusted_source resolution: %w", WrapGodoError(err)) | ||
| } | ||
| for _, app := range apps { | ||
| if app.Spec == nil { | ||
| continue | ||
| } | ||
| if _, want := needed[app.Spec.Name]; want { | ||
| resolved[app.Spec.Name] = app.ID | ||
| if len(resolved) == len(needed) { | ||
| return resolved, nil // all names resolved — no more pages needed | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
resolveAppNamesMap keeps paging through Apps.List even after all needed app names have already been resolved, which can result in unnecessary API calls (and slower applies) in accounts with many apps. Consider returning early once len(resolved)==len(needed), and adjust the function comment accordingly (it currently implies a single pass without extra work once resolved).
| if !ok { | ||
| return nil, false, nil // key absent: leave existing rules unchanged | ||
| } | ||
| list, _ := raw.([]any) |
There was a problem hiding this comment.
buildUpdateFirewallRules does list, _ := raw.([]any). If trusted_sources is present but not a slice (e.g., mis-typed config), this silently treats it as an empty list and returns ok=true, which will clear all firewall rules on Update. It would be safer to validate the type and return an error when trusted_sources is present but not the expected []any.
| list, _ := raw.([]any) | |
| list, ok := raw.([]any) | |
| if !ok { | |
| return nil, false, fmt.Errorf("trusted_sources must be a list") | |
| } |
…host-flag Address Copilot round-4 findings on PR #25: Finding 1 (ghost-flag): early-exit `if len(resolved) == len(needed)` is confirmed present at line 275 in 91c3cf6. Copilot re-flagged based on a stale diff view; no code change needed. Finding 2 (real): buildUpdateFirewallRules did `list, _ := raw.([]any)`, silently treating a non-list trusted_sources value (e.g. a YAML scalar) as an empty slice — returning ok=true with zero rules and clearing all firewall rules. Same latent bug existed in buildCreateFirewallRules. Fix both: check key existence separately, then type-assert with the ok form and return an actionable error (mentioning "trusted_sources" and the actual Go type) when the value is not a []any. Add two tests: - TestDatabaseDriver_Create_TrustedSources_WrongType_ReturnsError - TestDatabaseDriver_Update_TrustedSources_WrongType_ReturnsError Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
|
||
| // buildUpdateFirewallRules converts "trusted_sources" config to | ||
| // ([]*godo.DatabaseFirewallRule, bool, error). The bool mirrors | ||
| // trustedSourceFirewallRulesFromConfig: false means key absent (leave unchanged); | ||
| // true means key present (apply the returned slice, even if empty). type=app | ||
| // values are resolved to UUIDs via a single Apps.List pass (see resolveAppNamesMap). |
There was a problem hiding this comment.
In buildUpdateFirewallRules, if the "trusted_sources" key is present but its value is not a []any (e.g. a mis-typed config), the type assertion falls back to nil and the function returns (empty rules, ok=true). That will clear all firewall rules unexpectedly. Consider validating the type (and returning an explicit error) so a malformed config can’t silently wipe rules.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // requested names have been resolved. Returns an error only if the Apps client | ||
| // is unavailable for a non-UUID name, or if the DO API call fails. |
There was a problem hiding this comment.
The doc comment for resolveAppNamesMap says it only errors when the Apps client is unavailable or the DO API call fails, but the function also returns ErrResourceNotFound when a requested app name isn't found. Please update the comment to reflect the not-found error path as well.
| // requested names have been resolved. Returns an error only if the Apps client | |
| // is unavailable for a non-UUID name, or if the DO API call fails. | |
| // requested names have been resolved. Returns an error if the Apps client is | |
| // unavailable for a non-UUID name, if the DO API call fails, or if a | |
| // requested app name cannot be found (ErrResourceNotFound). |
| if d.appsClient == nil { | ||
| // Surface the first offending name for a clear error message. | ||
| for name := range needed { | ||
| return nil, fmt.Errorf( | ||
| "trusted_source type=app value %q is not a UUID and no Apps client is configured for name lookup; pass the app UUID directly", | ||
| name, | ||
| ) | ||
| } |
There was a problem hiding this comment.
resolveAppNamesMap iterates over a map to pick an offending app name when appsClient is nil; map iteration order is non-deterministic, so the error message can vary if multiple names are present. Consider making this deterministic (e.g., keep a slice of needed names, sort, or include all unresolved names in the error) so failures are stable and easier to debug.
| if rules, ok, fwErr := d.buildUpdateFirewallRules(ctx, spec.Config); fwErr != nil { | ||
| return nil, fmt.Errorf("database update firewall %q: %w", ref.Name, fwErr) | ||
| } else if ok { |
There was a problem hiding this comment.
In Update, firewall rule parsing/resolution (buildUpdateFirewallRules) happens after the Resize call. Since buildUpdateFirewallRules can now fail for config errors (wrong type) or app name resolution failures, Update may partially apply changes (resize succeeds, then Update returns an error before firewall sync). Consider validating/resolving trusted_sources before making any mutating DO API calls to avoid partial updates on user input errors.
…e-validate firewall Address Copilot round-5 findings on PR #25: 1. resolveAppNamesMap docstring now lists all 3 error cases: nil Apps client, DO API failure, and ErrResourceNotFound for a missing name. 2. Nil-client error message was non-deterministic when multiple type=app names needed resolution (map iteration order). Sort names before picking the first to report so the message is stable across runs. 3. Update() called buildUpdateFirewallRules AFTER Resize, risking a partial-apply where the DB is resized but firewall rules are left stale on config/resolution errors. Move buildUpdateFirewallRules before Resize so all validation and name→UUID resolution happens upfront, before any DO API mutations. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (1)
internal/drivers/database.go:153
- In Update(), firewall rule parsing/name→UUID resolution happens after the Resize API call. If trusted_sources is malformed or Apps.List fails / app name isn’t found, Update will return an error but the resize may already have been applied, leaving a partial update. Consider building/validating the firewall rules (and doing any app lookups) before calling Resize so Update either fails fast with no side effects, or succeeds fully.
size := strFromConfig(spec.Config, "size", "db-s-1vcpu-1gb")
numNodes, _ := intFromConfig(spec.Config, "num_nodes", 1)
_, err = d.client.Resize(ctx, providerID, &godo.DatabaseResizeRequest{
SizeSlug: size,
NumNodes: numNodes,
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // can fail for config errors (wrong type) or app name→UUID resolution failures; | ||
| // validating upfront prevents a partial-apply where Resize succeeds but the | ||
| // subsequent firewall update fails, leaving the DB resized with stale rules. |
There was a problem hiding this comment.
The comment implies that resolving/validating firewall rules upfront "prevents" partial applies where Resize succeeds but firewall update fails. This only prevents partial applies caused by config/type errors or app name→UUID resolution failures; UpdateFirewallRules can still fail due to API/network errors after Resize. Please reword to accurately scope what this upfront validation guarantees (or doesn’t).
| // can fail for config errors (wrong type) or app name→UUID resolution failures; | |
| // validating upfront prevents a partial-apply where Resize succeeds but the | |
| // subsequent firewall update fails, leaving the DB resized with stale rules. | |
| // can fail for config errors (wrong type) or app name→UUID resolution failures, | |
| // so validating upfront avoids partial applies caused by those preflight issues. | |
| // A later UpdateFirewallRules call can still fail after Resize due to API/service | |
| // errors, so this reduces—but does not eliminate—the chance of partial applies. |
… prevents Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| sort.Strings(names) | ||
| return nil, fmt.Errorf( | ||
| "trusted_source type=app value %q is not a UUID and no Apps client is configured for name lookup; pass the app UUID directly", | ||
| names[0], |
There was a problem hiding this comment.
The nil-appsClient error only includes the first unresolved app name (names[0]) and uses "trusted_source" (singular) even though the config key is "trusted_sources". If multiple type=app rules are present, this can be misleading; consider including all unresolved names (or a count + list) and referencing the "trusted_sources" key for easier troubleshooting.
…key in error message - Include all non-UUID app names in the nil-client error (not just names[0]) - Fix "trusted_source" → "trusted_sources" to match the config key - Update test assertion to match new "not UUIDs" wording Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| client DatabaseClient | ||
| appsClient AppPlatformClient // optional; used to resolve type=app trusted_source names to UUIDs | ||
| region string |
There was a problem hiding this comment.
appsClient is typed as AppPlatformClient, which is a fairly large interface (Create/Get/Update/Deployments/Delete) even though DatabaseDriver only needs List. This increases coupling between drivers and makes it harder to supply minimal test doubles. Consider using a narrow local interface (e.g., just List) for appsClient and NewDatabaseDriverWithClients.
| for name := range needed { | ||
| if _, ok := resolved[name]; !ok { | ||
| return nil, fmt.Errorf("trusted_source app %q: %w", name, ErrResourceNotFound) | ||
| } | ||
| } | ||
| return resolved, nil |
There was a problem hiding this comment.
When one or more requested app names are missing, the returned error depends on Go map iteration order (for name := range needed). This can make the user-facing error message nondeterministic if multiple type=app entries are unresolved. Consider collecting missing names, sorting them, and returning a deterministic error that lists all missing apps (or at least selects the first in sorted order).
| for name := range needed { | |
| if _, ok := resolved[name]; !ok { | |
| return nil, fmt.Errorf("trusted_source app %q: %w", name, ErrResourceNotFound) | |
| } | |
| } | |
| return resolved, nil | |
| missing := make([]string, 0) | |
| for name := range needed { | |
| if _, ok := resolved[name]; !ok { | |
| missing = append(missing, name) | |
| } | |
| } | |
| if len(missing) == 0 { | |
| return resolved, nil | |
| } | |
| sort.Strings(missing) | |
| quoted := make([]string, len(missing)) | |
| for i, name := range missing { | |
| quoted[i] = fmt.Sprintf("%q", name) | |
| } | |
| return nil, fmt.Errorf( | |
| "trusted_source app(s) %s: %w", | |
| strings.Join(quoted, ", "), | |
| ErrResourceNotFound, | |
| ) |
…p error - Define appNameLister (List only) instead of coupling to full AppPlatformClient - Collect all unresolved names, sort them, report them all in one error rather than returning the first found via non-deterministic map iteration Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…y is present When trusted_sources is present but invalid (wrong type or unresolvable app name), return (nil, true, error) so callers can distinguish "key absent" (false) from "key present but invalid" (true+error). Update docstring to match the bool semantics precisely. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| func TestDatabaseDriver_Create_AppNameNotFound_ReturnsError(t *testing.T) { | ||
| // When the app name cannot be found in the Apps list, Create must return an | ||
| // error that wraps ErrResourceNotFound and names the missing app. | ||
| dbMock := &mockDatabaseClient{db: testDatabase()} | ||
| appMock := &mockAppClient{listApps: []*godo.App{}} // empty list — name not found | ||
| d := drivers.NewDatabaseDriverWithClients(dbMock, appMock, "nyc3") | ||
|
|
||
| _, err := d.Create(context.Background(), interfaces.ResourceSpec{ | ||
| Name: "my-db", | ||
| Config: map[string]any{ | ||
| "engine": "pg", | ||
| "trusted_sources": []any{ | ||
| map[string]any{"type": "app", "value": "nonexistent-app"}, | ||
| }, | ||
| }, | ||
| }) | ||
| if err == nil { | ||
| t.Fatal("expected error when app name not found, got nil") | ||
| } | ||
| if !errors.Is(err, drivers.ErrResourceNotFound) { | ||
| t.Errorf("expected error to wrap drivers.ErrResourceNotFound; got: %v", err) | ||
| } | ||
| if !strings.Contains(err.Error(), "nonexistent-app") { | ||
| t.Errorf("expected error to mention missing app name %q; got: %v", "nonexistent-app", err) | ||
| } | ||
| } | ||
|
|
||
| func TestDatabaseDriver_Create_AppType_NoAppsClient_ReturnsError(t *testing.T) { | ||
| // When no Apps client is configured and the rule value is not UUID-shaped, | ||
| // Create must return an actionable error (not a generic one) that tells the | ||
| // caller the value is not a UUID and no client is available for lookup. | ||
| dbMock := &mockDatabaseClient{db: testDatabase()} | ||
| // NewDatabaseDriverWithClient does NOT wire an apps client. | ||
| d := drivers.NewDatabaseDriverWithClient(dbMock, "nyc3") | ||
|
|
||
| _, err := d.Create(context.Background(), interfaces.ResourceSpec{ | ||
| Name: "my-db", | ||
| Config: map[string]any{ | ||
| "engine": "pg", | ||
| "trusted_sources": []any{ | ||
| map[string]any{"type": "app", "value": "bmw-staging"}, | ||
| }, | ||
| }, | ||
| }) | ||
| if err == nil { | ||
| t.Fatal("expected error when apps client is nil and value is not UUID, got nil") | ||
| } | ||
| if !strings.Contains(err.Error(), "not UUIDs") { | ||
| t.Errorf("expected error to mention 'not UUIDs'; got: %v", err) | ||
| } | ||
| if !strings.Contains(err.Error(), "bmw-staging") { | ||
| t.Errorf("expected error to mention the offending value %q; got: %v", "bmw-staging", err) | ||
| } | ||
| } | ||
|
|
||
| func TestDatabaseDriver_Create_TrustedSources_WrongType_ReturnsError(t *testing.T) { | ||
| // trusted_sources present but not a list (e.g. user wrote a scalar in YAML) | ||
| // must return an error rather than silently dropping the rule. | ||
| dbMock := &mockDatabaseClient{db: testDatabase()} | ||
| d := drivers.NewDatabaseDriverWithClient(dbMock, "nyc3") |
There was a problem hiding this comment.
PR description says "Five new unit tests in database_test.go", but this diff adds 7 new tests (including the two WrongType_ReturnsError cases). Please update the PR description to match the actual changes (or drop the extra tests if they’re out of scope).
|
@copilot review |
All prior review feedback has been incorporated. Here's the current state:
All 7 new unit tests pass. |
|
@copilot review |
The code is clean — all 7 new tests pass ( |
Summary
trusted_sourcesrules withtype: appwere passing the app name directly to the DO Databases Firewall API, which requires a UUID. This caused a 422invalid app uuiderror oncf5ad4a4.DatabaseDrivernow holds an optionalappNameLister(narrow interface —Listonly). When a trusted_source rule hastype: appand the value is not already UUID-shaped (isUUIDLike), it resolves the name to a UUID viaApps.List. UUID-shaped values pass through unchanged (no list call).Create(viabuildCreateFirewallRules) andUpdate(viabuildUpdateFirewallRules) both resolve names via a singleresolveAppNamesMappass.NewDatabaseDriver(*godo.Client, region)now wiresc.Appsautomatically.NewDatabaseDriverWithClientis unchanged. NewNewDatabaseDriverWithClientsinjects both clients for tests.Why adc2473b succeeded
The first successful deploy likely had a different first-apply code path (Create vs Update) or DO's API behavior around
type=appin the create-firewall-rules embedded inDatabaseCreateRequestdiffered from the standaloneUpdateFirewallRulesendpoint. Regardless, the fix makes both paths safe.Tests
Seven new unit tests in
database_test.go:TestDatabaseDriver_Create_WithTrustedSources_AppNameResolved— name resolved to UUID on CreateTestDatabaseDriver_Create_WithTrustedSources_AppUUIDPassThrough— UUID values skip list callTestDatabaseDriver_Update_WithTrustedSources_AppNameResolved— name resolved to UUID on UpdateTestDatabaseDriver_Create_AppNameNotFound_ReturnsError— clear error when app absent from listTestDatabaseDriver_Create_AppType_NoAppsClient_ReturnsError— actionable error when no apps client configuredTestDatabaseDriver_Create_TrustedSources_WrongType_ReturnsError— explicit error (not silent empty rules) whentrusted_sourcesis not a list on CreateTestDatabaseDriver_Update_TrustedSources_WrongType_ReturnsError— same guard on Update pathAll existing tests pass.
GOWORK=off go test ./internal/... ✓Scope addition note
The existing unit tests (
TestDatabaseDriver_Create_WithTrustedSources,TestDatabaseDriver_Update_WithTrustedSources) only testedip_addrandk8srule types. This PR adds explicittype=appcoverage and type-safety guards.Next steps
Tag v0.7.10, then BMW pin-bump PR.
🤖 Generated with Claude Code