diff --git a/cmd/cloudflare.go b/cmd/cloudflare.go new file mode 100644 index 0000000..545f6e2 --- /dev/null +++ b/cmd/cloudflare.go @@ -0,0 +1,76 @@ +package cmd + +import ( + "context" + "errors" + "fmt" + "io" + "os" + + "github.com/konstructio/dropkick/internal/cloudflare" + "github.com/konstructio/dropkick/internal/logger" + "github.com/spf13/cobra" +) + +type cloudflareOptions struct { + nuke bool + domain string + subdomain string + quiet bool +} + +func getCloudflareCommand() *cobra.Command { + var opts cloudflareOptions + + cloudflareCmd := &cobra.Command{ + Use: "cloudflare", + Short: "clean cloudflare dns resources", + RunE: func(cmd *cobra.Command, _ []string) error { + opts.quiet = cmd.Flags().Lookup("quiet").Value.String() == "true" + return runCloudflare(cmd.Context(), cmd.OutOrStderr(), opts, os.Getenv("CLOUDFLARE_API_TOKEN")) + }, + } + + cloudflareCmd.Flags().BoolVar(&opts.nuke, "nuke", false, "required to confirm deletion of resources") + cloudflareCmd.Flags().StringVar(&opts.domain, "domain", "", "the cloudflare apex domain to clean") + cloudflareCmd.Flags().StringVar(&opts.subdomain, "subdomain", "", "the subdomain to clean") + + cloudflareCmd.MarkFlagRequired("domain") + + return cloudflareCmd +} + +func runCloudflare(ctx context.Context, output io.Writer, opts cloudflareOptions, token string) error { + if token == "" { + return errors.New("CLOUDFLARE_API_TOKEN environment variable not found: get an api key here https://dash.cloudflare.com/profile/api-tokens") + } + + // Create a logger and make it quiet + var log *logger.Logger + if opts.quiet { + log = logger.New(io.Discard) + } else { + log = logger.New(output) + } + + client, err := cloudflare.New( + cloudflare.WithToken(token), + cloudflare.WithZoneName(opts.domain), + cloudflare.WithSubdomain(opts.subdomain), + cloudflare.WithNuke(opts.nuke), + cloudflare.WithLogger(log), + ) + if err != nil { + return fmt.Errorf("unable to create new cloudflare client: %w", err) + } + + if err := client.NukeDNSRecords(ctx); err != nil { + if opts.nuke { + return fmt.Errorf("unable to nuke resources: %w", err) + } + + return fmt.Errorf("unable to process resources: %w", err) + } + + return nil +} diff --git a/cmd/root.go b/cmd/root.go index d6d9688..6b208ed 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -22,6 +22,7 @@ func Execute() { // Add subcommands rootCmd.AddCommand(getCivoCommand()) rootCmd.AddCommand(getDigitalOceanCommand()) + rootCmd.AddCommand(getCloudflareCommand()) // Configure a global flag for "--quiet" rootCmd.PersistentFlags().BoolP("quiet", "q", false, "suppress output from processing while keeping deletion messages to stdout") diff --git a/go.mod b/go.mod index d9b3717..ac850c9 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.23 require ( github.com/aws/aws-sdk-go v1.55.5 + github.com/cloudflare/cloudflare-go v0.110.0 github.com/digitalocean/godo v1.119.0 github.com/fatih/color v1.17.0 github.com/sirupsen/logrus v1.9.3 @@ -11,6 +12,7 @@ require ( ) require ( + github.com/goccy/go-json v0.10.3 // indirect github.com/golang/protobuf v1.5.3 // indirect github.com/google/go-cmp v0.5.9 // indirect github.com/google/go-querystring v1.1.0 // indirect @@ -21,11 +23,11 @@ require ( github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/spf13/pflag v1.0.5 // indirect - github.com/stretchr/testify v1.9.0 // indirect - golang.org/x/net v0.26.0 // indirect + golang.org/x/net v0.31.0 // indirect golang.org/x/oauth2 v0.0.0-20220411215720-9780585627b5 // indirect - golang.org/x/sys v0.21.0 // indirect - golang.org/x/time v0.0.0-20220922220347-f3bd1da661af // indirect + golang.org/x/sys v0.27.0 // indirect + golang.org/x/text v0.20.0 // indirect + golang.org/x/time v0.8.0 // indirect google.golang.org/appengine v1.6.7 // indirect google.golang.org/protobuf v1.33.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect diff --git a/go.sum b/go.sum index 41b7bc6..3de0bbb 100644 --- a/go.sum +++ b/go.sum @@ -40,6 +40,8 @@ github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWR github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cloudflare/cloudflare-go v0.110.0 h1:aBKKUXwRWqErd4rITsnCLESOacxxset/BcpdXn23900= +github.com/cloudflare/cloudflare-go v0.110.0/go.mod h1:2ZZ+EkmThmd6pkZ56UKGXWpz2wsjeqoTg93P4+VSmMg= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -56,6 +58,8 @@ github.com/fatih/color v1.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLg github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= +github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= @@ -222,8 +226,8 @@ golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81R golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= -golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ= -golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= +golang.org/x/net v0.31.0 h1:68CPQngjLL0r2AlUKiSxtQFKvzRVbnzLwMUn5SzcLHo= +golang.org/x/net v0.31.0/go.mod h1:P4fl1q7dY2hnZFxEk4pPSkDHF+QqjitcnDjUQyMM+pM= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -269,8 +273,8 @@ golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= -golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= +golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -278,11 +282,13 @@ golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3 golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug= +golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20220922220347-f3bd1da661af h1:Yx9k8YCG3dvF87UAn2tu2HQLf2dt/eR1bXxpLMWeH+Y= -golang.org/x/time v0.0.0-20220922220347-f3bd1da661af/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg= +golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= diff --git a/internal/cloudflare/client.go b/internal/cloudflare/client.go new file mode 100644 index 0000000..835d668 --- /dev/null +++ b/internal/cloudflare/client.go @@ -0,0 +1,107 @@ +package cloudflare + +import ( + "errors" + "fmt" + "os" + + cloudflarego "github.com/cloudflare/cloudflare-go" + "github.com/konstructio/dropkick/internal/logger" +) + +// Cloudflare is a client for the Cloudflare API. +type Cloudflare struct { + client *cloudflarego.API // The underlying Cloudflare API client. + nuke bool // Whether to nuke resources. + token string // The API token. + subdomain string // The subdomain to delete records from. + zoneID string // The zone ID for the domain. + zoneName string // The domain to clean in cloudflare. + logger *logger.Logger // The logger instance. +} + +func (c *Cloudflare) getFullName() string { + if c.subdomain != "" { + return fmt.Sprintf("%s.%s", c.subdomain, c.zoneName) + } + return c.zoneName +} + +// Option is a function that configures a Cloudflare. +type Option func(*Cloudflare) error + +// WithLogger sets the logger for a Cloudflare. +func WithLogger(logger *logger.Logger) Option { + return func(c *Cloudflare) error { + c.logger = logger + return nil + } +} + +// WithZoneName sets the API token for a Cloudflare. +func WithZoneName(zoneName string) Option { + return func(c *Cloudflare) error { + c.zoneName = zoneName + return nil + } +} + +// WithSubdomain sets the subdomain to filter on for aCloudflare. +func WithSubdomain(subdomain string) Option { + return func(c *Cloudflare) error { + c.subdomain = subdomain + return nil + } +} + +// WithToken sets the API token for a Cloudflare. +func WithToken(token string) Option { + return func(c *Cloudflare) error { + c.token = token + return nil + } +} + +// WithNuke sets whether to nuke resources for a Cloudflare. +func WithNuke(nuke bool) Option { + return func(c *Cloudflare) error { + c.nuke = nuke + return nil + } +} + +// New creates a new Cloudflare with the given options. +// It returns an error if the token is not set, or if it fails to +// create the underlying Cloudflare API client. +func New(opts ...Option) (*Cloudflare, error) { + c := &Cloudflare{} + + for _, opt := range opts { + if err := opt(c); err != nil { + return nil, fmt.Errorf("unable to apply option: %w", err) + } + } + + if c.token == "" { + return nil, errors.New("required token not found for cloudflare client") + } + + cloudflareAPI, err := cloudflarego.NewWithAPIToken(os.Getenv("CLOUDFLARE_API_TOKEN")) + if err != nil { + return nil, fmt.Errorf("unable to authenticate Cloudflare client: %w", err) + } + + zoneID, err := cloudflareAPI.ZoneIDByName(c.zoneName) + if err != nil { + return nil, fmt.Errorf("unable to get zone ID by name %q: %w", c.zoneName, err) + } + c.zoneID = zoneID + + c.client = cloudflareAPI + + if c.logger == nil { + c.logger = logger.None + } + + return c, nil +} diff --git a/internal/cloudflare/dns.go b/internal/cloudflare/dns.go new file mode 100644 index 0000000..f718fa3 --- /dev/null +++ b/internal/cloudflare/dns.go @@ -0,0 +1,58 @@ +package cloudflare + +import ( + "context" + "fmt" + "strings" + + cloudflarego "github.com/cloudflare/cloudflare-go" + "github.com/konstructio/dropkick/internal/outputwriter" +) + +func (c *Cloudflare) NukeDNSRecords(ctx context.Context) error { + c.logger.Infof("listing DNS records for %q", c.getFullName()) + + records, _, err := c.client.ListDNSRecords(ctx, &cloudflarego.ResourceContainer{ + Identifier: c.zoneID, + }, cloudflarego.ListDNSRecordsParams{}) + if err != nil { + return fmt.Errorf("unable to list records for domain %q: %w", c.zoneName, err) + } + + c.logger.Infof("found %d records for %q", len(records), c.getFullName()) + + filteredRecords := filterRecords(records, c.getFullName()) + + c.logger.Infof("applied filter %q to %d records, got %d records suitable for deletion", c.getFullName(), len(records), len(filteredRecords)) + + for _, r := range filteredRecords { + if c.nuke { + c.logger.Infof("deleting %s record %q", r.Type, r.Name) + if err := c.client.DeleteDNSRecord(ctx, &cloudflarego.ResourceContainer{Identifier: c.zoneID}, r.ID); err != nil { + return fmt.Errorf("unable to delete record %q: %w", r.Name, err) + } + outputwriter.WriteStdoutf("deleted %s record %q", r.Type, r.Name) + } else { + c.logger.Warnf("refusing to delete %s record %q: nuke is not enabled", r.Type, r.Name) + } + } + + return nil +} + +// fetch all txt records with values like "heritage=external-dns,external-dns/owner=default,external-dns/resource=ingress/argo/argo-server" +// fetch all txt records with values like "heritage=external-dns,external-dns/owner=default,external-dns/resource" + +func filterRecords(records []cloudflarego.DNSRecord, suffix string) []cloudflarego.DNSRecord { + aRecord := "A" + txtRecord := "TXT" + + filteredRecords := make([]cloudflarego.DNSRecord, 0, len(records)) + for _, r := range records { + if strings.HasSuffix(r.Name, suffix) && (r.Type == txtRecord || r.Type == aRecord) { + filteredRecords = append(filteredRecords, r) + } + } + + return filteredRecords +} diff --git a/internal/cloudflare/dns_test.go b/internal/cloudflare/dns_test.go new file mode 100644 index 0000000..a9e2dce --- /dev/null +++ b/internal/cloudflare/dns_test.go @@ -0,0 +1,100 @@ +package cloudflare + +import ( + "reflect" + "testing" + + cloudflarego "github.com/cloudflare/cloudflare-go" +) + +func Test_filterRecords(t *testing.T) { + type args struct { + records []cloudflarego.DNSRecord + subdomain string + } + tests := []struct { + name string + args args + want []cloudflarego.DNSRecord + }{ + { + name: "successful case scenario", + args: args{ + records: []cloudflarego.DNSRecord{ + { + ID: "1", + Type: "CNAME", + }, + { + ID: "2", + Type: "TXT", + }, + }, + }, + want: []cloudflarego.DNSRecord{ + { + ID: "2", + Type: "TXT", + }, + }, + }, + { + name: "successful case scenario with subdomain", + args: args{ + records: []cloudflarego.DNSRecord{ + { + ID: "1", + Type: "CNAME", + Name: "metaphor-development.ci-k1-126b3ab2-civo-gh-cf.kubesecond.com", + }, + { + ID: "2", + Type: "A", + Name: "metaphor-development.ci-k1-2c6ab0c9-civo-gh-cf.kubesecond.com", + }, + }, + subdomain: "ci-k1-2c6ab0c9-civo-gh-cf", + }, + want: []cloudflarego.DNSRecord{ + { + ID: "2", + Type: "A", + Name: "metaphor-development.ci-k1-2c6ab0c9-civo-gh-cf.kubesecond.com", + }, + }, + }, + { + name: "successful case scenario with subdomain", + args: args{ + records: []cloudflarego.DNSRecord{ + { + ID: "1", + Type: "TXT", + Name: "metaphor-development.ci-k1-2c6ab0c9-civo-gh-cf.kubesecond.com", + }, + { + ID: "2", + Type: "A", + Name: "metaphor-development.ci-k1-foobar-civo-gh-cf.kubesecond.com", + }, + }, + subdomain: "ci-k1-2c6ab0c9-civo-gh-cf", + }, + want: []cloudflarego.DNSRecord{ + { + ID: "1", + Type: "TXT", + Name: "metaphor-development.ci-k1-2c6ab0c9-civo-gh-cf.kubesecond.com", + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := filterRecords(tt.args.records, tt.args.subdomain); !reflect.DeepEqual(got, tt.want) { + t.Errorf("filterRecords() = %v, want %v", got, tt.want) + } + }) + } +}