From 77ce7d223ae5504a025f7b95fdb11a76861f66fc Mon Sep 17 00:00:00 2001 From: Jon Phenow Date: Wed, 15 Apr 2026 12:09:19 -0500 Subject: [PATCH] fix(statics): scope FindBucket lookup to the app's organization FindBucket was calling the top-level addOns(type: tigris) query, which returns every tigris add-on the caller can see across every org and then filters client-side by org + metadata. For accounts with a lot of tigris add-ons this stalls `fly apps destroy` (and `fly apps move`). Use Organization.addOns(type:) via a new ListOrganizationAddOns query so we only transfer the relevant org's add-ons. Metadata-based app matching stays the same, since existing statics buckets are not linked by app_id. --- gql/generated.go | 201 +++++++++++++++++++++++ gql/genqclient.graphql | 24 +++ internal/command/deploy/statics/addon.go | 20 ++- internal/command/deploy/statics/move.go | 2 +- 4 files changed, 240 insertions(+), 7 deletions(-) diff --git a/gql/generated.go b/gql/generated.go index 890cb649d6..f9273cb4e6 100644 --- a/gql/generated.go +++ b/gql/generated.go @@ -2671,6 +2671,141 @@ type ListAddOnsResponse struct { // GetAddOns returns ListAddOnsResponse.AddOns, and is useful for accessing the field via an interface. func (v *ListAddOnsResponse) GetAddOns() ListAddOnsAddOnsAddOnConnection { return v.AddOns } +// ListOrganizationAddOnsOrganization includes the requested fields of the GraphQL type Organization. +type ListOrganizationAddOnsOrganization struct { + // List third party integrations associated with an organization + AddOns ListOrganizationAddOnsOrganizationAddOnsAddOnConnection `json:"addOns"` +} + +// GetAddOns returns ListOrganizationAddOnsOrganization.AddOns, and is useful for accessing the field via an interface. +func (v *ListOrganizationAddOnsOrganization) GetAddOns() ListOrganizationAddOnsOrganizationAddOnsAddOnConnection { + return v.AddOns +} + +// ListOrganizationAddOnsOrganizationAddOnsAddOnConnection includes the requested fields of the GraphQL type AddOnConnection. +// The GraphQL type's documentation follows. +// +// The connection type for AddOn. +type ListOrganizationAddOnsOrganizationAddOnsAddOnConnection struct { + // A list of nodes. + Nodes []ListOrganizationAddOnsOrganizationAddOnsAddOnConnectionNodesAddOn `json:"nodes"` +} + +// GetNodes returns ListOrganizationAddOnsOrganizationAddOnsAddOnConnection.Nodes, and is useful for accessing the field via an interface. +func (v *ListOrganizationAddOnsOrganizationAddOnsAddOnConnection) GetNodes() []ListOrganizationAddOnsOrganizationAddOnsAddOnConnectionNodesAddOn { + return v.Nodes +} + +// ListOrganizationAddOnsOrganizationAddOnsAddOnConnectionNodesAddOn includes the requested fields of the GraphQL type AddOn. +type ListOrganizationAddOnsOrganizationAddOnsAddOnConnectionNodesAddOn struct { + Id string `json:"id"` + // The service name according to the provider + Name string `json:"name"` + // The add-on plan + AddOnPlan ListOrganizationAddOnsOrganizationAddOnsAddOnConnectionNodesAddOnAddOnPlan `json:"addOnPlan"` + // Private flycast IP address of the add-on + PrivateIp string `json:"privateIp"` + // Region where the primary instance is deployed + PrimaryRegion string `json:"primaryRegion"` + // Regions where replica instances are deployed + ReadRegions []string `json:"readRegions"` + // Add-on options + Options interface{} `json:"options"` + // Add-on metadata + Metadata interface{} `json:"metadata"` + // Organization that owns this service + Organization ListOrganizationAddOnsOrganizationAddOnsAddOnConnectionNodesAddOnOrganization `json:"organization"` +} + +// GetId returns ListOrganizationAddOnsOrganizationAddOnsAddOnConnectionNodesAddOn.Id, and is useful for accessing the field via an interface. +func (v *ListOrganizationAddOnsOrganizationAddOnsAddOnConnectionNodesAddOn) GetId() string { + return v.Id +} + +// GetName returns ListOrganizationAddOnsOrganizationAddOnsAddOnConnectionNodesAddOn.Name, and is useful for accessing the field via an interface. +func (v *ListOrganizationAddOnsOrganizationAddOnsAddOnConnectionNodesAddOn) GetName() string { + return v.Name +} + +// GetAddOnPlan returns ListOrganizationAddOnsOrganizationAddOnsAddOnConnectionNodesAddOn.AddOnPlan, and is useful for accessing the field via an interface. +func (v *ListOrganizationAddOnsOrganizationAddOnsAddOnConnectionNodesAddOn) GetAddOnPlan() ListOrganizationAddOnsOrganizationAddOnsAddOnConnectionNodesAddOnAddOnPlan { + return v.AddOnPlan +} + +// GetPrivateIp returns ListOrganizationAddOnsOrganizationAddOnsAddOnConnectionNodesAddOn.PrivateIp, and is useful for accessing the field via an interface. +func (v *ListOrganizationAddOnsOrganizationAddOnsAddOnConnectionNodesAddOn) GetPrivateIp() string { + return v.PrivateIp +} + +// GetPrimaryRegion returns ListOrganizationAddOnsOrganizationAddOnsAddOnConnectionNodesAddOn.PrimaryRegion, and is useful for accessing the field via an interface. +func (v *ListOrganizationAddOnsOrganizationAddOnsAddOnConnectionNodesAddOn) GetPrimaryRegion() string { + return v.PrimaryRegion +} + +// GetReadRegions returns ListOrganizationAddOnsOrganizationAddOnsAddOnConnectionNodesAddOn.ReadRegions, and is useful for accessing the field via an interface. +func (v *ListOrganizationAddOnsOrganizationAddOnsAddOnConnectionNodesAddOn) GetReadRegions() []string { + return v.ReadRegions +} + +// GetOptions returns ListOrganizationAddOnsOrganizationAddOnsAddOnConnectionNodesAddOn.Options, and is useful for accessing the field via an interface. +func (v *ListOrganizationAddOnsOrganizationAddOnsAddOnConnectionNodesAddOn) GetOptions() interface{} { + return v.Options +} + +// GetMetadata returns ListOrganizationAddOnsOrganizationAddOnsAddOnConnectionNodesAddOn.Metadata, and is useful for accessing the field via an interface. +func (v *ListOrganizationAddOnsOrganizationAddOnsAddOnConnectionNodesAddOn) GetMetadata() interface{} { + return v.Metadata +} + +// GetOrganization returns ListOrganizationAddOnsOrganizationAddOnsAddOnConnectionNodesAddOn.Organization, and is useful for accessing the field via an interface. +func (v *ListOrganizationAddOnsOrganizationAddOnsAddOnConnectionNodesAddOn) GetOrganization() ListOrganizationAddOnsOrganizationAddOnsAddOnConnectionNodesAddOnOrganization { + return v.Organization +} + +// ListOrganizationAddOnsOrganizationAddOnsAddOnConnectionNodesAddOnAddOnPlan includes the requested fields of the GraphQL type AddOnPlan. +type ListOrganizationAddOnsOrganizationAddOnsAddOnConnectionNodesAddOnAddOnPlan struct { + DisplayName string `json:"displayName"` + Description string `json:"description"` +} + +// GetDisplayName returns ListOrganizationAddOnsOrganizationAddOnsAddOnConnectionNodesAddOnAddOnPlan.DisplayName, and is useful for accessing the field via an interface. +func (v *ListOrganizationAddOnsOrganizationAddOnsAddOnConnectionNodesAddOnAddOnPlan) GetDisplayName() string { + return v.DisplayName +} + +// GetDescription returns ListOrganizationAddOnsOrganizationAddOnsAddOnConnectionNodesAddOnAddOnPlan.Description, and is useful for accessing the field via an interface. +func (v *ListOrganizationAddOnsOrganizationAddOnsAddOnConnectionNodesAddOnAddOnPlan) GetDescription() string { + return v.Description +} + +// ListOrganizationAddOnsOrganizationAddOnsAddOnConnectionNodesAddOnOrganization includes the requested fields of the GraphQL type Organization. +type ListOrganizationAddOnsOrganizationAddOnsAddOnConnectionNodesAddOnOrganization struct { + Id string `json:"id"` + // Unique organization slug + Slug string `json:"slug"` +} + +// GetId returns ListOrganizationAddOnsOrganizationAddOnsAddOnConnectionNodesAddOnOrganization.Id, and is useful for accessing the field via an interface. +func (v *ListOrganizationAddOnsOrganizationAddOnsAddOnConnectionNodesAddOnOrganization) GetId() string { + return v.Id +} + +// GetSlug returns ListOrganizationAddOnsOrganizationAddOnsAddOnConnectionNodesAddOnOrganization.Slug, and is useful for accessing the field via an interface. +func (v *ListOrganizationAddOnsOrganizationAddOnsAddOnConnectionNodesAddOnOrganization) GetSlug() string { + return v.Slug +} + +// ListOrganizationAddOnsResponse is returned by ListOrganizationAddOns on success. +type ListOrganizationAddOnsResponse struct { + // Find an organization by ID + Organization ListOrganizationAddOnsOrganization `json:"organization"` +} + +// GetOrganization returns ListOrganizationAddOnsResponse.Organization, and is useful for accessing the field via an interface. +func (v *ListOrganizationAddOnsResponse) GetOrganization() ListOrganizationAddOnsOrganization { + return v.Organization +} + // LogOutLogOutLogOutPayload includes the requested fields of the GraphQL type LogOutPayload. // The GraphQL type's documentation follows. // @@ -3224,6 +3359,18 @@ type __ListAddOnsInput struct { // GetAddOnType returns __ListAddOnsInput.AddOnType, and is useful for accessing the field via an interface. func (v *__ListAddOnsInput) GetAddOnType() AddOnType { return v.AddOnType } +// __ListOrganizationAddOnsInput is used internally by genqlient +type __ListOrganizationAddOnsInput struct { + OrgSlug string `json:"orgSlug"` + AddOnType AddOnType `json:"addOnType"` +} + +// GetOrgSlug returns __ListOrganizationAddOnsInput.OrgSlug, and is useful for accessing the field via an interface. +func (v *__ListOrganizationAddOnsInput) GetOrgSlug() string { return v.OrgSlug } + +// GetAddOnType returns __ListOrganizationAddOnsInput.AddOnType, and is useful for accessing the field via an interface. +func (v *__ListOrganizationAddOnsInput) GetAddOnType() AddOnType { return v.AddOnType } + // __ResetAddOnPasswordInput is used internally by genqlient type __ResetAddOnPasswordInput struct { Name string `json:"name"` @@ -4272,6 +4419,60 @@ func ListAddOns( return data_, err_ } +// The query executed by ListOrganizationAddOns. +const ListOrganizationAddOns_Operation = ` +query ListOrganizationAddOns ($orgSlug: String!, $addOnType: AddOnType) { + organization(slug: $orgSlug) { + addOns(type: $addOnType) { + nodes { + id + name + addOnPlan { + displayName + description + } + privateIp + primaryRegion + readRegions + options + metadata + organization { + id + slug + } + } + } + } +} +` + +func ListOrganizationAddOns( + ctx_ context.Context, + client_ graphql.Client, + orgSlug string, + addOnType AddOnType, +) (data_ *ListOrganizationAddOnsResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "ListOrganizationAddOns", + Query: ListOrganizationAddOns_Operation, + Variables: &__ListOrganizationAddOnsInput{ + OrgSlug: orgSlug, + AddOnType: addOnType, + }, + } + + data_ = &ListOrganizationAddOnsResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + // The mutation executed by LogOut. const LogOut_Operation = ` mutation LogOut { diff --git a/gql/genqclient.graphql b/gql/genqclient.graphql index 53ecf60d67..4191eecb79 100644 --- a/gql/genqclient.graphql +++ b/gql/genqclient.graphql @@ -265,6 +265,30 @@ query ListAddOns($addOnType: AddOnType) { } } +query ListOrganizationAddOns($orgSlug: String!, $addOnType: AddOnType) { + organization(slug: $orgSlug) { + addOns(type: $addOnType) { + nodes { + id + name + addOnPlan { + displayName + description + } + privateIp + primaryRegion + readRegions + options + metadata + organization { + id + slug + } + } + } + } +} + mutation UpdateAddOn($addOnId: ID!, $planId: ID!, $readRegions: [String!]!, $options: JSON!, $metadata: JSON!) { updateAddOn(input: {addOnId: $addOnId, planId: $planId, readRegions: $readRegions, options: $options, metadata: $metadata}) { addOn { diff --git a/internal/command/deploy/statics/addon.go b/internal/command/deploy/statics/addon.go index 19f63e68e1..4d8efdc3c9 100644 --- a/internal/command/deploy/statics/addon.go +++ b/internal/command/deploy/statics/addon.go @@ -19,14 +19,25 @@ import ( "github.com/superfly/tokenizer" ) +// Bucket is a tigris statics add-on as returned by FindBucket. It is an alias +// for the generated type of the ListOrganizationAddOns query's node fields so +// callers don't have to deal with the unwieldy generated name directly. +type Bucket = gql.ListOrganizationAddOnsOrganizationAddOnsAddOnConnectionNodesAddOn + // FindBucket finds the tigris statics bucket for the given app and org. // Returns nil, nil if no bucket is found. -func FindBucket(ctx context.Context, app *fly.App, org *fly.Organization) (*gql.ListAddOnsAddOnsAddOnConnectionNodesAddOn, error) { +// +// The query is scoped to the app's organization so accounts with many tigris +// add-ons don't have to transfer (and filter client-side) every tigris add-on +// visible to the caller. Once new statics buckets are created with an app_id +// link (see ensureBucketCreated), this can be tightened further to an +// app-scoped query; until then we still match by the metadata pointer. +func FindBucket(ctx context.Context, app *fly.App, org *fly.Organization) (*Bucket, error) { client := flyutil.ClientFromContext(ctx) gqlClient := client.GenqClient() - response, err := gql.ListAddOns(ctx, gqlClient, "tigris") + response, err := gql.ListOrganizationAddOns(ctx, gqlClient, org.Slug, "tigris") if err != nil { return nil, err } @@ -34,13 +45,10 @@ func FindBucket(ctx context.Context, app *fly.App, org *fly.Organization) (*gql. // Using string comparison here because we might want to use BigInt app IDs in the future. internalAppIdStr := strconv.FormatUint(uint64(app.InternalNumericID), 10) - for _, extension := range response.AddOns.Nodes { + for _, extension := range response.Organization.AddOns.Nodes { if extension.Metadata == nil { continue } - if extension.Organization.Slug != org.Slug { - continue - } if extension.Metadata.(map[string]any)[staticsMetaKeyAppId] == internalAppIdStr { return &extension, nil } diff --git a/internal/command/deploy/statics/move.go b/internal/command/deploy/statics/move.go index f824ef7656..d95a97edc9 100644 --- a/internal/command/deploy/statics/move.go +++ b/internal/command/deploy/statics/move.go @@ -19,7 +19,7 @@ import ( // all the files from the old bucket to the new bucket - then deletes the old bucket. func MoveBucket( ctx context.Context, - prevBucket *gql.ListAddOnsAddOnsAddOnConnectionNodesAddOn, + prevBucket *Bucket, prevOrg *fly.Organization, app *fly.App, targetOrg *fly.Organization,