diff --git a/docs/stackit_dns.md b/docs/stackit_dns.md index 7f26e9946..fe55ee5b2 100644 --- a/docs/stackit_dns.md +++ b/docs/stackit_dns.md @@ -30,6 +30,7 @@ stackit dns [flags] ### SEE ALSO * [stackit](./stackit.md) - Manage STACKIT resources using the command line +* [stackit dns flush](./stackit_dns_flush.md) - Flushes the cache for a domain on the STACKIT DNS resolvers * [stackit dns record-set](./stackit_dns_record-set.md) - Provides functionality for DNS record set * [stackit dns zone](./stackit_dns_zone.md) - Provides functionality for DNS zones diff --git a/docs/stackit_dns_flush.md b/docs/stackit_dns_flush.md new file mode 100644 index 000000000..e3118b9c9 --- /dev/null +++ b/docs/stackit_dns_flush.md @@ -0,0 +1,44 @@ +## stackit dns flush + +Flushes the cache for a domain on the STACKIT DNS resolvers + +### Synopsis + +Flushes the cache for a domain on the STACKIT DNS resolvers. + +``` +stackit dns flush DOMAIN [flags] +``` + +### Examples + +``` + Flush the cache for domain "www.example.com" + $ stackit dns flush www.example.com + + Flush the cache for the A records of domain "www.example.com" + $ stackit dns flush www.example.com --type A +``` + +### Options + +``` + -h, --help Help for "stackit dns flush" + --type string Record type to flush. If not set, the cache is flushed for all record types of the domain (one of: [A, AAAA, SOA, CNAME, NS, MX, TXT, SRV, PTR, DNAME, CAA, DNSKEY, DS, LOC, NAPTR, SSHFP, TLSA, URI, CERT, SVCB, CSYNC, HINFO, HTTPS]) +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, (one of: [json, pretty, none, yaml]) + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, (one of: [debug, info, warning, error]) (default "info") +``` + +### SEE ALSO + +* [stackit dns](./stackit_dns.md) - Provides functionality for DNS + diff --git a/go.mod b/go.mod index 4468b6f42..a5de1c9bd 100644 --- a/go.mod +++ b/go.mod @@ -21,7 +21,7 @@ require ( github.com/stackitcloud/stackit-sdk-go/services/authorization v0.16.0 github.com/stackitcloud/stackit-sdk-go/services/automation v0.2.1 github.com/stackitcloud/stackit-sdk-go/services/cdn v1.22.0 - github.com/stackitcloud/stackit-sdk-go/services/dns v0.25.0 + github.com/stackitcloud/stackit-sdk-go/services/dns v0.26.0 github.com/stackitcloud/stackit-sdk-go/services/edge v0.14.0 github.com/stackitcloud/stackit-sdk-go/services/git v0.16.0 github.com/stackitcloud/stackit-sdk-go/services/iaas v1.15.1 diff --git a/go.sum b/go.sum index ee432c779..42ea5b4f0 100644 --- a/go.sum +++ b/go.sum @@ -620,8 +620,8 @@ github.com/stackitcloud/stackit-sdk-go/services/automation v0.2.1 h1:VxYQ4fCkuZA github.com/stackitcloud/stackit-sdk-go/services/automation v0.2.1/go.mod h1:pDhpbmeDlthML6h4jyI7ZRGrG2C4Tx3aIGru5Ts6ISo= github.com/stackitcloud/stackit-sdk-go/services/cdn v1.22.0 h1:NyG5PvXB9+ldlKqbTfpp4YMb/Afg8LRDG5BIa+mBf+g= github.com/stackitcloud/stackit-sdk-go/services/cdn v1.22.0/go.mod h1:sr7UP8YdI18bH+2mfpy9qjsDqcPQU/+YduqH1Zw2JgY= -github.com/stackitcloud/stackit-sdk-go/services/dns v0.25.0 h1:Yn4etadrdEEPU8YcbvF7a/UExSqri+jUC62w62ic4N8= -github.com/stackitcloud/stackit-sdk-go/services/dns v0.25.0/go.mod h1:D0iv6XBYkaEwTjbBeom0VTZnwQMfwb48FC8R2jRg80c= +github.com/stackitcloud/stackit-sdk-go/services/dns v0.26.0 h1:WFOUn8EnbMvQC8tuEyB65sVLzA6mXsNTEWOVnQhOoYY= +github.com/stackitcloud/stackit-sdk-go/services/dns v0.26.0/go.mod h1:D0iv6XBYkaEwTjbBeom0VTZnwQMfwb48FC8R2jRg80c= github.com/stackitcloud/stackit-sdk-go/services/edge v0.14.0 h1:TTwlI34FUb0AisiApxKs5brGNP3Ma9cTvdmbxzNxs40= github.com/stackitcloud/stackit-sdk-go/services/edge v0.14.0/go.mod h1:FBBnDsQns6SjJYQrox3YSY7ydOa4T8FR3sXNwFRTsPc= github.com/stackitcloud/stackit-sdk-go/services/git v0.16.0 h1:M75DCyPbTzTx4mPdIkBvOVE3/6Eda0rp8xLm40d02qc= diff --git a/internal/cmd/dns/dns.go b/internal/cmd/dns/dns.go index 216c02dac..35fea7805 100644 --- a/internal/cmd/dns/dns.go +++ b/internal/cmd/dns/dns.go @@ -1,6 +1,7 @@ package dns import ( + "github.com/stackitcloud/stackit-cli/internal/cmd/dns/flush" recordset "github.com/stackitcloud/stackit-cli/internal/cmd/dns/record-set" "github.com/stackitcloud/stackit-cli/internal/cmd/dns/zone" "github.com/stackitcloud/stackit-cli/internal/pkg/args" @@ -25,4 +26,5 @@ func NewCmd(params *types.CmdParams) *cobra.Command { func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { cmd.AddCommand(zone.NewCmd(params)) cmd.AddCommand(recordset.NewCmd(params)) + cmd.AddCommand(flush.NewCmd(params)) } diff --git a/internal/cmd/dns/flush/flush.go b/internal/cmd/dns/flush/flush.go new file mode 100644 index 000000000..afe3ce70d --- /dev/null +++ b/internal/cmd/dns/flush/flush.go @@ -0,0 +1,122 @@ +package flush + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/dns/client" + + "github.com/spf13/cobra" + dns "github.com/stackitcloud/stackit-sdk-go/services/dns/v1api" +) + +const ( + domainArg = "DOMAIN" +) + +var typeFlag = flags.StringEnumFlag( + "type", + dns.AllowedFlushDomainPayloadTypeEnumValues, + "Record type to flush. If not set, the cache is flushed for all record types of the domain", +) + +type inputModel struct { + *globalflags.GlobalFlagModel + Domain string + Type *dns.FlushDomainPayloadType +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("flush %s", domainArg), + Short: "Flushes the cache for a domain on the STACKIT DNS resolvers", + Long: "Flushes the cache for a domain on the STACKIT DNS resolvers.", + Args: args.SingleArg(domainArg, nil), + Example: examples.Build( + examples.NewExample( + `Flush the cache for domain "www.example.com"`, + "$ stackit dns flush www.example.com"), + examples.NewExample( + `Flush the cache for the A records of domain "www.example.com"`, + "$ stackit dns flush www.example.com --type A"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + prompt := fmt.Sprintf("Are you sure you want to flush the cache for domain %q?", model.Domain) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("flush DNS cache: %w", err) + } + + return outputResult(params.Printer, model, resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + typeFlag.Register(cmd.Flags()) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + domain := inputArgs[0] + globalFlags := globalflags.Parse(p, cmd) + + model := inputModel{ + GlobalFlagModel: globalFlags, + Domain: domain, + Type: typeFlag.Ptr(), + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *dns.APIClient) dns.ApiFlushDomainRequest { + req := apiClient.DefaultAPI.FlushDomain(ctx) + req = req.FlushDomainPayload(dns.FlushDomainPayload{ + Domain: model.Domain, + Type: model.Type, + }) + return req +} + +func outputResult(p *print.Printer, model *inputModel, resp *dns.FlushDomainResponse) error { + return p.OutputResult(model.OutputFormat, resp, func() error { + if resp == nil { + return fmt.Errorf("flush DNS cache response is empty") + } + if model.Type != nil { + p.Outputf("Triggered flush of the %s record cache for domain %q\n", *model.Type, model.Domain) + } else { + p.Outputf("Triggered flush of the cache for domain %q\n", model.Domain) + } + return nil + }) +} diff --git a/internal/cmd/dns/flush/flush_test.go b/internal/cmd/dns/flush/flush_test.go new file mode 100644 index 000000000..be50bb6cd --- /dev/null +++ b/internal/cmd/dns/flush/flush_test.go @@ -0,0 +1,243 @@ +package flush + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + dns "github.com/stackitcloud/stackit-sdk-go/services/dns/v1api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/testparams" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +type testCtxKey struct{} + +const ( + testDomain = "www.example.com" + testType = dns.FLUSHDOMAINPAYLOADTYPE_AAAA +) + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &dns.APIClient{DefaultAPI: &dns.DefaultAPIService{}} + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testDomain, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + typeFlag.Name(): string(testType), + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + Verbosity: globalflags.VerbosityDefault, + }, + Domain: testDomain, + Type: utils.Ptr(testType), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *dns.ApiFlushDomainRequest)) dns.ApiFlushDomainRequest { + request := testClient.DefaultAPI.FlushDomain(testCtx) + request = request.FlushDomainPayload(dns.FlushDomainPayload{ + Domain: testDomain, + Type: utils.Ptr(testType), + }) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "without type", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, typeFlag.Name()) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Type = nil + }), + }, + { + description: "no values", + argValues: []string{}, + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "no arg values", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "no flag values", + argValues: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Type = nil + }), + }, + { + description: "domain empty", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "type invalid", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[typeFlag.Name()] = "INVALID" + }), + isValid: false, + }, + { + description: "type lowercase", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[typeFlag.Name()] = "aaaa" + }), + isValid: false, + }, + { + description: "type empty", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[typeFlag.Name()] = "" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest dns.ApiFlushDomainRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + { + description: "without type", + model: fixtureInputModel(func(model *inputModel) { + model.Type = nil + }), + expectedRequest: fixtureRequest(func(request *dns.ApiFlushDomainRequest) { + *request = request.FlushDomainPayload(dns.FlushDomainPayload{ + Domain: testDomain, + }) + }), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest, dns.DefaultAPIService{}), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + model *inputModel + resp *dns.FlushDomainResponse + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "empty", + args: args{ + model: fixtureInputModel(), + }, + wantErr: true, + }, + { + name: "base", + args: args{ + model: fixtureInputModel(), + resp: &dns.FlushDomainResponse{}, + }, + wantErr: false, + }, + { + name: "without type", + args: args{ + model: fixtureInputModel(func(model *inputModel) { + model.Type = nil + }), + resp: &dns.FlushDomainResponse{}, + }, + wantErr: false, + }, + } + params := testparams.NewTestParams() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(params.Printer, tt.args.model, tt.args.resp); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +}