diff --git a/go.mod b/go.mod index a5dceddaaf..041886cbba 100644 --- a/go.mod +++ b/go.mod @@ -74,7 +74,7 @@ require ( github.com/spf13/pflag v1.0.9 github.com/spf13/viper v1.20.1 github.com/stretchr/testify v1.11.1 - github.com/superfly/fly-go v0.1.70 + github.com/superfly/fly-go v0.1.71 github.com/superfly/graphql v0.2.6 github.com/superfly/lfsc-go v0.1.1 github.com/superfly/macaroon v0.3.0 diff --git a/go.sum b/go.sum index 527b52a557..0332936760 100644 --- a/go.sum +++ b/go.sum @@ -637,8 +637,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= -github.com/superfly/fly-go v0.1.70 h1:JHeVxPXE/Cf2ori9mLXGe1NKyA81Ov4arRl7JLQB2RE= -github.com/superfly/fly-go v0.1.70/go.mod h1:2gCFoNR3iUELADGTJtbBoviMa2jlh2vlPK3cKUajOp8= +github.com/superfly/fly-go v0.1.71 h1:F0rn8W7vq3/FFGGGCp4FVDTKGX9pxD0deVLHeMiRMRs= +github.com/superfly/fly-go v0.1.71/go.mod h1:2gCFoNR3iUELADGTJtbBoviMa2jlh2vlPK3cKUajOp8= github.com/superfly/graphql v0.2.6 h1:zppbodNerWecoXEdjkhrqaNaSjGqobhXNlViHFuZzb4= github.com/superfly/graphql v0.2.6/go.mod h1:CVfDl31srm8HnJ9udwLu6hFNUW/P6GUM2dKcG1YQ8jc= github.com/superfly/lfsc-go v0.1.1 h1:dGjLgt81D09cG+aR9lJZIdmonjZSR5zYCi7s54+ZU2Q= diff --git a/gql/schema.graphql b/gql/schema.graphql index 00ce24c367..bf11f0192a 100644 --- a/gql/schema.graphql +++ b/gql/schema.graphql @@ -84,6 +84,37 @@ enum AccessTokenType { ui } +""" +Autogenerated input type of AddAllowedReplaySourceOrgs +""" +input AddAllowedReplaySourceOrgsInput { + """ + Slugs of orgs to add to the allowed replay sources list + """ + allowedOrgSlugs: [String!]! + + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + Slug of the org to configure (the target org) + """ + organizationSlug: String! +} + +""" +Autogenerated return type of AddAllowedReplaySourceOrgs. +""" +type AddAllowedReplaySourceOrgsPayload { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + organization: Organization! +} + """ Autogenerated return type of AddCertificate. """ @@ -1256,6 +1287,11 @@ type AppCertificate implements Node { """ last: Int ): CertificateConnection! + + """ + If rate limited by the certificate provider, when the certificate can be retried + """ + rateLimitedUntil: ISO8601DateTime source: String validationErrors: [AppCertificateValidationError!]! } @@ -2099,6 +2135,11 @@ enum CertificateValidationErrorCodeEnum { """ NO_ALLOCATED_IPS + """ + Let's Encrypt rate limit exceeded. Too many certificate requests for this hostname + """ + RATE_LIMITED + """ Service exposing port 443 does not have a TLS handler configured """ @@ -4350,14 +4391,13 @@ type DummyWireGuardPeerPayload { } type EgressIPAddress implements Node { - createdAt: ISO8601DateTime! - """ ID of the object. """ id: ID! ip: String! region: String! + updatedAt: ISO8601DateTime! version: Int! } @@ -5961,6 +6001,12 @@ type MoveAppPayload { } type Mutations { + addAllowedReplaySourceOrgs( + """ + Parameters for AddAllowedReplaySourceOrgs + """ + input: AddAllowedReplaySourceOrgsInput! + ): AddAllowedReplaySourceOrgsPayload addCertificate( """ The application to attach the new hostname to @@ -6561,6 +6607,12 @@ type Mutations { """ input: ReleaseManagedServiceIPAddressInput! ): ReleaseManagedServiceIPAddressPayload + removeAllowedReplaySourceOrgs( + """ + Parameters for RemoveAllowedReplaySourceOrgs + """ + input: RemoveAllowedReplaySourceOrgsInput! + ): RemoveAllowedReplaySourceOrgsPayload removeMachine( """ Parameters for RemoveMachine @@ -6933,6 +6985,11 @@ type Organization implements Node { Check if the organization has agreed to the extension provider terms of service """ agreedToProviderTos(providerName: String!): Boolean! + + """ + Slugs of organizations allowed to send replays to this org + """ + allowedReplaySourceOrgSlugs: [String!]! apps( """ Returns the elements in the list that come after the specified cursor. @@ -8599,6 +8656,37 @@ type RemoteDockerBuilderAppRole implements AppRole { name: String! } +""" +Autogenerated input type of RemoveAllowedReplaySourceOrgs +""" +input RemoveAllowedReplaySourceOrgsInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + Slugs of orgs to remove from the allowed replay sources list + """ + orgSlugsToRemove: [String!]! + + """ + Slug of the org to configure (the target org) + """ + organizationSlug: String! +} + +""" +Autogenerated return type of RemoveAllowedReplaySourceOrgs. +""" +type RemoveAllowedReplaySourceOrgsPayload { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + organization: Organization! +} + """ Autogenerated input type of RemoveMachine """ diff --git a/internal/command/orgs/orgs.go b/internal/command/orgs/orgs.go index d71145e3e7..00e224e304 100644 --- a/internal/command/orgs/orgs.go +++ b/internal/command/orgs/orgs.go @@ -38,6 +38,7 @@ Organization admins can also invite or remove users from Organizations. newRemove(), newCreate(), newDelete(), + newReplaySources(), ) return orgs diff --git a/internal/command/orgs/replay_sources.go b/internal/command/orgs/replay_sources.go new file mode 100644 index 0000000000..a1aa7d3cf5 --- /dev/null +++ b/internal/command/orgs/replay_sources.go @@ -0,0 +1,23 @@ +package orgs + +import ( + "github.com/spf13/cobra" + "github.com/superfly/flyctl/internal/command" +) + +func newReplaySources() *cobra.Command { + const ( + long = `Commands for managing cross-organization replay permissions.` + short = "Manage allowed replay source organizations" + ) + + cmd := command.New("replay-sources", short, long, nil) + + cmd.AddCommand( + newReplaySourcesList(), + newReplaySourcesAdd(), + newReplaySourcesRemove(), + ) + + return cmd +} diff --git a/internal/command/orgs/replay_sources_add.go b/internal/command/orgs/replay_sources_add.go new file mode 100644 index 0000000000..fdc623447c --- /dev/null +++ b/internal/command/orgs/replay_sources_add.go @@ -0,0 +1,109 @@ +package orgs + +import ( + "context" + "fmt" + "strings" + + "github.com/spf13/cobra" + + fly "github.com/superfly/fly-go" + "github.com/superfly/flyctl/internal/command" + "github.com/superfly/flyctl/internal/flag" + "github.com/superfly/flyctl/internal/flyutil" + "github.com/superfly/flyctl/internal/prompt" + "github.com/superfly/flyctl/iostreams" +) + +func newReplaySourcesAdd() *cobra.Command { + const ( + long = `Add organizations to the list of allowed replay sources for this organization. + +If no slugs are provided, an interactive selector will be shown.` + short = "Add allowed replay source organizations" + usage = "add [...]" + ) + + cmd := command.New(usage, short, long, runReplaySourcesAdd, + command.RequireSession, + ) + + cmd.Args = cobra.ArbitraryArgs + + flag.Add(cmd, + flag.Org(), + ) + + return cmd +} + +func runReplaySourcesAdd(ctx context.Context) error { + client := flyutil.ClientFromContext(ctx) + io := iostreams.FromContext(ctx) + + org, err := OrgFromFlagOrSelect(ctx, fly.AdminOnly) + if err != nil { + return err + } + + args := flag.Args(ctx) + var sourceOrgSlugs []string + + if len(args) == 0 { + // Interactive mode: show multi-select of available orgs + userOrgs, err := client.GetOrganizations(ctx) + if err != nil { + return fmt.Errorf("failed to get organizations: %w", err) + } + + // Get currently allowed orgs to exclude them + currentAllowed, err := client.GetAllowedReplaySourceOrgSlugs(ctx, org.RawSlug) + if err != nil { + return fmt.Errorf("failed to get current replay sources: %w", err) + } + currentSet := make(map[string]bool) + for _, slug := range currentAllowed { + currentSet[slug] = true + } + + var options []string + var availableSlugs []string + for _, userOrg := range userOrgs { + if userOrg.RawSlug != org.RawSlug && !currentSet[userOrg.RawSlug] { + options = append(options, fmt.Sprintf("%s (%s)", userOrg.Name, userOrg.RawSlug)) + availableSlugs = append(availableSlugs, userOrg.RawSlug) + } + } + + if len(options) == 0 { + fmt.Fprintln(io.Out, "No organizations available to add") + return nil + } + + var selections []int + if err := prompt.MultiSelect(ctx, &selections, "Select organizations to add:", nil, options...); err != nil { + return err + } + + if len(selections) == 0 { + return nil + } + + for _, idx := range selections { + sourceOrgSlugs = append(sourceOrgSlugs, availableSlugs[idx]) + } + } else { + // Use positional arguments + sourceOrgSlugs = args + } + + _, err = client.AddAllowedReplaySourceOrgs(ctx, org.RawSlug, sourceOrgSlugs) + if err != nil { + return fmt.Errorf("failed to add allowed replay source orgs: %w", err) + } + + fmt.Fprintf(io.Out, "Added allowed replay source organizations for %s: %s\n", + org.RawSlug, strings.Join(sourceOrgSlugs, ", ")) + + return nil +} diff --git a/internal/command/orgs/replay_sources_list.go b/internal/command/orgs/replay_sources_list.go new file mode 100644 index 0000000000..46c9288758 --- /dev/null +++ b/internal/command/orgs/replay_sources_list.go @@ -0,0 +1,66 @@ +package orgs + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + + fly "github.com/superfly/fly-go" + "github.com/superfly/flyctl/internal/command" + "github.com/superfly/flyctl/internal/config" + "github.com/superfly/flyctl/internal/flag" + "github.com/superfly/flyctl/internal/flyutil" + "github.com/superfly/flyctl/internal/render" + "github.com/superfly/flyctl/iostreams" +) + +func newReplaySourcesList() *cobra.Command { + const ( + long = `List organizations allowed to replay requests to this organization.` + short = "List allowed replay source organizations" + usage = "list" + ) + + cmd := command.New(usage, short, long, runReplaySourcesList, + command.RequireSession, + ) + + cmd.Aliases = []string{"ls"} + flag.Add(cmd, flag.Org(), flag.JSONOutput()) + + return cmd +} + +func runReplaySourcesList(ctx context.Context) error { + client := flyutil.ClientFromContext(ctx) + + org, err := OrgFromFlagOrSelect(ctx, fly.AdminOnly) + if err != nil { + return err + } + + sourceOrgSlugs, err := client.GetAllowedReplaySourceOrgSlugs(ctx, org.RawSlug) + if err != nil { + return fmt.Errorf("failed to get allowed replay source orgs: %w", err) + } + + io := iostreams.FromContext(ctx) + cfg := config.FromContext(ctx) + + if cfg.JSONOutput { + return render.JSON(io.Out, sourceOrgSlugs) + } + + if len(sourceOrgSlugs) == 0 { + fmt.Fprintf(io.Out, "No replay source organizations configured for %s\n", org.RawSlug) + return nil + } + + fmt.Fprintf(io.Out, "Allowed replay source organizations for %s:\n", org.RawSlug) + for _, slug := range sourceOrgSlugs { + fmt.Fprintf(io.Out, " %s\n", slug) + } + + return nil +} diff --git a/internal/command/orgs/replay_sources_remove.go b/internal/command/orgs/replay_sources_remove.go new file mode 100644 index 0000000000..ab095d4779 --- /dev/null +++ b/internal/command/orgs/replay_sources_remove.go @@ -0,0 +1,132 @@ +package orgs + +import ( + "context" + "fmt" + "strings" + + "github.com/spf13/cobra" + + fly "github.com/superfly/fly-go" + "github.com/superfly/flyctl/internal/command" + "github.com/superfly/flyctl/internal/flag" + "github.com/superfly/flyctl/internal/flyutil" + "github.com/superfly/flyctl/internal/prompt" + "github.com/superfly/flyctl/iostreams" +) + +func newReplaySourcesRemove() *cobra.Command { + const ( + long = `Remove organizations from the list of allowed replay sources for this organization. + +If no slugs are provided, an interactive selector will be shown.` + short = "Remove allowed replay source organizations" + usage = "remove [...]" + ) + + cmd := command.New(usage, short, long, runReplaySourcesRemove, + command.RequireSession, + ) + + cmd.Args = cobra.ArbitraryArgs + + flag.Add(cmd, + flag.Org(), + flag.Yes(), + ) + + return cmd +} + +func runReplaySourcesRemove(ctx context.Context) error { + client := flyutil.ClientFromContext(ctx) + io := iostreams.FromContext(ctx) + + org, err := OrgFromFlagOrSelect(ctx, fly.AdminOnly) + if err != nil { + return err + } + + userOrgs, err := client.GetOrganizations(ctx) + if err != nil { + return fmt.Errorf("failed to get user organizations: %w", err) + } + + userOrgMap := make(map[string]string) + for _, userOrg := range userOrgs { + userOrgMap[userOrg.RawSlug] = userOrg.Name + } + + args := flag.Args(ctx) + var orgSlugsToRemove []string + + if len(args) == 0 { + // Interactive mode: show multi-select of currently allowed orgs + currentAllowed, err := client.GetAllowedReplaySourceOrgSlugs(ctx, org.RawSlug) + if err != nil { + return fmt.Errorf("failed to get current replay sources: %w", err) + } + + if len(currentAllowed) == 0 { + fmt.Fprintln(io.Out, "No replay source organizations configured") + return nil + } + + var options []string + for _, slug := range currentAllowed { + if name, isMember := userOrgMap[slug]; isMember { + options = append(options, fmt.Sprintf("%s (%s)", name, slug)) + } else { + options = append(options, fmt.Sprintf("%s (not a member - cannot re-add)", slug)) + } + } + + var selections []int + if err := prompt.MultiSelect(ctx, &selections, "Select organizations to remove:", nil, options...); err != nil { + return err + } + + if len(selections) == 0 { + return nil + } + + for _, idx := range selections { + orgSlugsToRemove = append(orgSlugsToRemove, currentAllowed[idx]) + } + } else { + // Use positional arguments + orgSlugsToRemove = args + } + + // Check which orgs user is NOT a member of + var nonMemberSlugs []string + for _, slug := range orgSlugsToRemove { + if _, isMember := userOrgMap[slug]; !isMember { + nonMemberSlugs = append(nonMemberSlugs, slug) + } + } + + // Warn about non-member orgs if not using --yes + if len(nonMemberSlugs) > 0 && !flag.GetYes(ctx) { + fmt.Fprintf(io.Out, "Warning: You are not a member of: %s\n", strings.Join(nonMemberSlugs, ", ")) + fmt.Fprintf(io.Out, "You will not be able to re-add these organizations.\n") + + confirmed, err := prompt.Confirm(ctx, "Continue with removal?") + if err != nil { + return err + } + if !confirmed { + return nil + } + } + + _, err = client.RemoveAllowedReplaySourceOrgs(ctx, org.RawSlug, orgSlugsToRemove) + if err != nil { + return fmt.Errorf("failed to remove allowed replay source orgs: %w", err) + } + + fmt.Fprintf(io.Out, "Removed allowed replay source organizations from %s: %s\n", + org.RawSlug, strings.Join(orgSlugsToRemove, ", ")) + + return nil +} diff --git a/internal/flyutil/client.go b/internal/flyutil/client.go index 670defeda7..20a19cd50d 100644 --- a/internal/flyutil/client.go +++ b/internal/flyutil/client.go @@ -74,6 +74,9 @@ type Client interface { GetOrganizationByApp(ctx context.Context, appName string) (*fly.Organization, error) GetOrganizationRemoteBuilderBySlug(ctx context.Context, slug string) (*fly.Organization, error) GetOrganizations(ctx context.Context, filters ...fly.OrganizationFilter) ([]fly.Organization, error) + GetAllowedReplaySourceOrgSlugs(ctx context.Context, slug string) ([]string, error) + AddAllowedReplaySourceOrgs(ctx context.Context, orgSlug string, sourceOrgSlugs []string) (*fly.Organization, error) + RemoveAllowedReplaySourceOrgs(ctx context.Context, orgSlug string, orgSlugsToRemove []string) (*fly.Organization, error) GetSnapshotsFromVolume(ctx context.Context, volID string) ([]fly.VolumeSnapshot, error) GetWireGuardPeer(ctx context.Context, slug, name string) (*fly.WireGuardPeer, error) GetWireGuardPeers(ctx context.Context, slug string) ([]*fly.WireGuardPeer, error) diff --git a/internal/inmem/client.go b/internal/inmem/client.go index d53b35d13d..a3f90b0b37 100644 --- a/internal/inmem/client.go +++ b/internal/inmem/client.go @@ -320,6 +320,18 @@ func (m *Client) GetOrganizations(ctx context.Context, filters ...fly.Organizati panic("TODO") } +func (m *Client) GetAllowedReplaySourceOrgSlugs(ctx context.Context, slug string) ([]string, error) { + panic("TODO") +} + +func (m *Client) AddAllowedReplaySourceOrgs(ctx context.Context, orgSlug string, sourceOrgSlugs []string) (*fly.Organization, error) { + panic("TODO") +} + +func (m *Client) RemoveAllowedReplaySourceOrgs(ctx context.Context, orgSlug string, orgSlugsToRemove []string) (*fly.Organization, error) { + panic("TODO") +} + func (m *Client) GetSnapshotsFromVolume(ctx context.Context, volID string) ([]fly.VolumeSnapshot, error) { panic("TODO") } diff --git a/internal/mock/client.go b/internal/mock/client.go index daf6f531ae..e2bf652693 100644 --- a/internal/mock/client.go +++ b/internal/mock/client.go @@ -76,6 +76,9 @@ type Client struct { GetOrganizationRemoteBuilderBySlugFunc func(ctx context.Context, slug string) (*fly.Organization, error) GetOrganizationByAppFunc func(ctx context.Context, appName string) (*fly.Organization, error) GetOrganizationsFunc func(ctx context.Context, filters ...fly.OrganizationFilter) ([]fly.Organization, error) + GetAllowedReplaySourceOrgSlugsFunc func(ctx context.Context, slug string) ([]string, error) + AddAllowedReplaySourceOrgsFunc func(ctx context.Context, orgSlug string, sourceOrgSlugs []string) (*fly.Organization, error) + RemoveAllowedReplaySourceOrgsFunc func(ctx context.Context, orgSlug string, orgSlugsToRemove []string) (*fly.Organization, error) GetSnapshotsFromVolumeFunc func(ctx context.Context, volID string) ([]fly.VolumeSnapshot, error) GetWireGuardPeerFunc func(ctx context.Context, slug, name string) (*fly.WireGuardPeer, error) GetWireGuardPeersFunc func(ctx context.Context, slug string) ([]*fly.WireGuardPeer, error) @@ -351,6 +354,18 @@ func (m *Client) GetOrganizations(ctx context.Context, filters ...fly.Organizati return m.GetOrganizationsFunc(ctx, filters...) } +func (m *Client) GetAllowedReplaySourceOrgSlugs(ctx context.Context, slug string) ([]string, error) { + return m.GetAllowedReplaySourceOrgSlugsFunc(ctx, slug) +} + +func (m *Client) AddAllowedReplaySourceOrgs(ctx context.Context, orgSlug string, sourceOrgSlugs []string) (*fly.Organization, error) { + return m.AddAllowedReplaySourceOrgsFunc(ctx, orgSlug, sourceOrgSlugs) +} + +func (m *Client) RemoveAllowedReplaySourceOrgs(ctx context.Context, orgSlug string, orgSlugsToRemove []string) (*fly.Organization, error) { + return m.RemoveAllowedReplaySourceOrgsFunc(ctx, orgSlug, orgSlugsToRemove) +} + func (m *Client) GetSnapshotsFromVolume(ctx context.Context, volID string) ([]fly.VolumeSnapshot, error) { return m.GetSnapshotsFromVolumeFunc(ctx, volID) }