From 935a2a3e66f791d63db7980185976960e1f52c77 Mon Sep 17 00:00:00 2001 From: Jonathan Remy Date: Fri, 10 Apr 2026 06:18:58 +0200 Subject: [PATCH 1/8] feat(dns): add record import command for BIND and JSON files --- ...t-all-usage-dns-record-import-usage.golden | 52 ++++ .../test-all-usage-dns-record-usage.golden | 1 + go.mod | 17 +- go.sum | 34 ++- internal/namespaces/domain/v2beta1/custom.go | 1 + .../domain/v2beta1/custom_record_import.go | 209 +++++++++++++ .../domain/v2beta1/record_import_parse.go | 276 ++++++++++++++++++ .../v2beta1/record_import_parse_test.go | 103 +++++++ 8 files changed, 669 insertions(+), 24 deletions(-) create mode 100644 cmd/scw/testdata/test-all-usage-dns-record-import-usage.golden create mode 100644 internal/namespaces/domain/v2beta1/custom_record_import.go create mode 100644 internal/namespaces/domain/v2beta1/record_import_parse.go create mode 100644 internal/namespaces/domain/v2beta1/record_import_parse_test.go diff --git a/cmd/scw/testdata/test-all-usage-dns-record-import-usage.golden b/cmd/scw/testdata/test-all-usage-dns-record-import-usage.golden new file mode 100644 index 0000000000..9eaff12aad --- /dev/null +++ b/cmd/scw/testdata/test-all-usage-dns-record-import-usage.golden @@ -0,0 +1,52 @@ +🎲🎲🎲 EXIT CODE: 0 🎲🎲🎲 +πŸŸ₯πŸŸ₯πŸŸ₯ STDERR️️ πŸŸ₯πŸŸ₯πŸŸ₯️ +Import DNS records into a zone that uses Scaleway default name servers. + +The DNS zone is the only positional argument; pass the path to the file as file=PATH. + +Two formats are supported: + - bind: standard zone file (BIND), same family of syntax as "scw dns zone import". + - json: UTF-8 JSON object with a "records" array; each element has name, type, ttl, data, and optional priority (for MX). + +SOA records and apex NS records in a BIND file are skipped. $INCLUDE and $GENERATE are rejected. + +Use "replace=true" to delete all existing records in the zone before importing (equivalent to "scw dns record clear" followed by adds). + +For a full zone file replacement at once, prefer "scw dns zone import". + +USAGE: + scw dns record import [arg=value ...] + +EXAMPLES: + Import BIND records from a file + scw dns record import my-domain.tld file=./zone.txt + + Import JSON and replace existing records + scw dns record import my-domain.tld file=./records.json format=json replace=true + +ARGS: + dns-zone DNS zone to import records into + file Path to the zone file (bind) or JSON file + [format=bind] File format: "bind" or "json" (bind | json) + [dry-run=false] Parse the file and print a summary without calling the API + [replace=false] Clear all records in the zone before importing + +FLAGS: + -h, --help help for import + --list-sub-commands List all subcommands + +GLOBAL FLAGS: + -c, --config string The path to the config file + -D, --debug Enable debug mode + -o, --output string Output format: json or human, see 'scw help output' for more info (default "human") + -p, --profile string The config profile to use + +SEE ALSO: + # Import a full raw DNS zone + dns zone import + + # Low-level record changes + dns record bulk-update + + # Delete all records in a zone + dns record clear diff --git a/cmd/scw/testdata/test-all-usage-dns-record-usage.golden b/cmd/scw/testdata/test-all-usage-dns-record-usage.golden index 8e65d9b68a..22b6753af2 100644 --- a/cmd/scw/testdata/test-all-usage-dns-record-usage.golden +++ b/cmd/scw/testdata/test-all-usage-dns-record-usage.golden @@ -10,6 +10,7 @@ AVAILABLE COMMANDS: bulk-update Update records within a DNS zone clear Clear records within a DNS zone delete Delete a DNS record + import Import many DNS records from a file list List records within a DNS zone list-nameservers List name servers within a DNS zone set Update a DNS record diff --git a/go.mod b/go.mod index 30e845c7f5..b21208ee0e 100644 --- a/go.mod +++ b/go.mod @@ -15,13 +15,14 @@ require ( github.com/docker/docker v28.5.2+incompatible github.com/dustin/go-humanize v1.0.1 github.com/fatih/color v1.19.0 - github.com/getsentry/sentry-go v0.44.1 + github.com/getsentry/sentry-go v0.45.0 github.com/ghodss/yaml v1.0.0 github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 github.com/hashicorp/go-version v1.9.0 github.com/karrick/tparse/v2 v2.8.2 github.com/mattn/go-colorable v0.1.14 github.com/mattn/go-isatty v0.0.21 + github.com/miekg/dns v1.1.63 github.com/moby/buildkit v0.29.0 github.com/moby/go-archive v0.2.0 github.com/opencontainers/go-digest v1.0.0 @@ -30,10 +31,10 @@ require ( github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 github.com/stretchr/testify v1.11.1 - golang.org/x/crypto v0.49.0 + golang.org/x/crypto v0.50.0 golang.org/x/sync v0.20.0 - golang.org/x/term v0.41.0 - golang.org/x/text v0.35.0 + golang.org/x/term v0.42.0 + golang.org/x/text v0.36.0 ) require ( @@ -204,11 +205,11 @@ require ( go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect go.yaml.in/yaml/v4 v4.0.0-rc.4 // indirect - golang.org/x/mod v0.33.0 // indirect - golang.org/x/net v0.51.0 // indirect - golang.org/x/sys v0.42.0 // indirect + golang.org/x/mod v0.35.0 // indirect + golang.org/x/net v0.53.0 // indirect + golang.org/x/sys v0.43.0 // indirect golang.org/x/time v0.14.0 // indirect - golang.org/x/tools v0.42.0 // indirect + golang.org/x/tools v0.44.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260203192932-546029d2fa20 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect google.golang.org/grpc v1.79.3 // indirect diff --git a/go.sum b/go.sum index a29c1e86e2..ef65d98faf 100644 --- a/go.sum +++ b/go.sum @@ -235,8 +235,8 @@ github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uh github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo= github.com/gdamore/tcell/v2 v2.12.2 h1:7hBtHPlPGNH6/ZLl22eLl7q0kfNsL+FGEggcWZuk6jk= github.com/gdamore/tcell/v2 v2.12.2/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo= -github.com/getsentry/sentry-go v0.44.1 h1:/cPtrA5qB7uMRrhgSn9TYtcEF36auGP3Y6+ThvD/yaI= -github.com/getsentry/sentry-go v0.44.1/go.mod h1:XDotiNZbgf5U8bPDUAfvcFmOnMQQceESxyKaObSssW0= +github.com/getsentry/sentry-go v0.45.0 h1:/ZlbfGcaOzG4QkCACCfxrbuABemjem7UnY5o+V5HmeM= +github.com/getsentry/sentry-go v0.45.0/go.mod h1:XDotiNZbgf5U8bPDUAfvcFmOnMQQceESxyKaObSssW0= github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= @@ -364,6 +364,8 @@ github.com/mattn/go-tty v0.0.4 h1:NVikla9X8MN0SQAqCYzpGyXv0jY7MNl3HOWD2dkle7E= github.com/mattn/go-tty v0.0.4/go.mod h1:u5GGXBtZU6RQoKV8gY5W6UhMudbR5vXnUe7j3pxse28= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= +github.com/miekg/dns v1.1.63 h1:8M5aAw6OMZfFXTT7K5V0Eu5YiiL8l7nUAkyN6C9YwaY= +github.com/miekg/dns v1.1.63/go.mod h1:6NGHfjhpmr5lt3XPLuyfDJi5AXbNIPM9PY6H6sF1Nfs= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/ioprogress v0.0.0-20180201004757-6a23b12fa88e h1:Qa6dnn8DlasdXRnacluu8HzPts0S1I9zvvUPDbBnXFI= @@ -596,16 +598,16 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= -golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= -golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/exp v0.0.0-20250911091902-df9299821621 h1:2id6c1/gto0kaHYyrixvknJ8tUK/Qs5IsmBtrc+FtgU= golang.org/x/exp v0.0.0-20250911091902-df9299821621/go.mod h1:TwQYMMnGpvZyc+JpB/UAuTNIsVJifOlSkrZkhcvpVUk= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= -golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= @@ -618,8 +620,8 @@ golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qx golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= -golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= -golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -659,15 +661,15 @@ golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= -golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= -golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= +golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= +golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 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= @@ -676,8 +678,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= -golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -686,8 +688,8 @@ golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roY golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= -golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= +golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/internal/namespaces/domain/v2beta1/custom.go b/internal/namespaces/domain/v2beta1/custom.go index a757a839e0..9740379442 100644 --- a/internal/namespaces/domain/v2beta1/custom.go +++ b/internal/namespaces/domain/v2beta1/custom.go @@ -44,6 +44,7 @@ func GetCommands() *core.Commands { dnsRecordAddCommand(), dnsRecordSetCommand(), dnsRecordDeleteCommand(), + dnsRecordImportCommand(), )) cmds.MustFind("dns", "zone", "import").ArgSpecs.GetByName("bind-source.content").CanLoadFile = true diff --git a/internal/namespaces/domain/v2beta1/custom_record_import.go b/internal/namespaces/domain/v2beta1/custom_record_import.go new file mode 100644 index 0000000000..2e208ca13f --- /dev/null +++ b/internal/namespaces/domain/v2beta1/custom_record_import.go @@ -0,0 +1,209 @@ +package domain + +import ( + "context" + "fmt" + "os" + "path/filepath" + "reflect" + "strings" + + "github.com/scaleway/scaleway-cli/v2/core" + domain "github.com/scaleway/scaleway-sdk-go/api/domain/v2beta1" +) + +const dnsImportBatchSize = 200 + +type dnsRecordImportArgs struct { + DNSZone string + File string + Format string + DryRun bool + Replace bool +} + +// dnsRecordImportResult is returned by dns record import for human and JSON output. +type dnsRecordImportResult struct { + DryRun bool `json:"dry_run"` + RecordCount int `json:"record_count"` + APIRequests int `json:"api_requests"` + ReplacedZone bool `json:"replaced_zone"` +} + +func (r dnsRecordImportResult) String() string { + switch { + case r.DryRun: + return fmt.Sprintf("dry-run: parsed %d record(s); would perform %d API request(s) (replace=%v)", r.RecordCount, r.APIRequests, r.ReplacedZone) + case r.RecordCount == 0: + return "no records imported" + default: + return fmt.Sprintf("imported %d record(s) in %d API request(s)", r.RecordCount, r.APIRequests) + } +} + +func dnsRecordImportCommand() *core.Command { + return &core.Command{ + Short: `Import many DNS records from a file`, + Long: strings.TrimSpace(` +Import DNS records into a zone that uses Scaleway default name servers. + +The DNS zone is the only positional argument; pass the path to the file as file=PATH. + +Two formats are supported: + - bind: standard zone file (BIND), same family of syntax as "scw dns zone import". + - json: UTF-8 JSON object with a "records" array; each element has name, type, ttl, data, and optional priority (for MX). + +SOA records and apex NS records in a BIND file are skipped. $INCLUDE and $GENERATE are rejected. + +Use "replace=true" to delete all existing records in the zone before importing (equivalent to "scw dns record clear" followed by adds). + +For a full zone file replacement at once, prefer "scw dns zone import". +`), + Namespace: "dns", + Resource: "record", + Verb: "import", + ArgsType: reflect.TypeOf(dnsRecordImportArgs{}), + ArgSpecs: core.ArgSpecs{ + { + Name: "dns-zone", + Short: "DNS zone to import records into", + Required: true, + Positional: true, + }, + { + Name: "file", + Short: "Path to the zone file (bind) or JSON file", + Required: true, + Positional: false, + }, + { + Name: "format", + Short: `File format: "bind" or "json"`, + Required: false, + Default: core.DefaultValueSetter("bind"), + EnumValues: []string{"bind", "json"}, + }, + { + Name: "dry-run", + Short: "Parse the file and print a summary without calling the API", + Required: false, + Default: core.DefaultValueSetter("false"), + }, + { + Name: "replace", + Short: "Clear all records in the zone before importing", + Required: false, + Default: core.DefaultValueSetter("false"), + }, + }, + Run: dnsRecordImportRun, + Examples: []*core.Example{ + { + Short: "Import BIND records from a file", + Raw: "scw dns record import my-domain.tld file=./zone.txt", + }, + { + Short: "Import JSON and replace existing records", + Raw: "scw dns record import my-domain.tld file=./records.json format=json replace=true", + }, + }, + SeeAlsos: []*core.SeeAlso{ + {Command: "dns zone import", Short: "Import a full raw DNS zone"}, + {Command: "dns record bulk-update", Short: "Low-level record changes"}, + {Command: "dns record clear", Short: "Delete all records in a zone"}, + }, + } +} + +func dnsRecordImportRun(ctx context.Context, argsI any) (any, error) { + args := argsI.(*dnsRecordImportArgs) + zone := strings.TrimSpace(args.DNSZone) + if zone == "" { + return nil, fmt.Errorf("dns-zone is required") + } + path := strings.TrimSpace(args.File) + if path == "" { + return nil, fmt.Errorf("file is required") + } + abs, err := filepath.Abs(path) + if err != nil { + return nil, fmt.Errorf("resolve file path: %w", err) + } + raw, err := os.ReadFile(abs) //nolint:gosec // user-provided path is intentional + if err != nil { + return nil, fmt.Errorf("read file: %w", err) + } + + format := strings.ToLower(strings.TrimSpace(args.Format)) + if format == "" { + format = "bind" + } + + var records []*domain.Record + switch format { + case "bind": + records, err = parseImportBind(string(raw), zone) + case "json": + records, err = parseImportJSON(string(raw), zone) + default: + return nil, fmt.Errorf("unsupported format %q (use bind or json)", format) + } + if err != nil { + return nil, err + } + if len(records) == 0 { + return nil, fmt.Errorf("no records to import after parsing (SOA/apex NS are skipped in bind format)") + } + + apiCalls := 0 + if args.Replace { + apiCalls++ + } + apiCalls += (len(records) + dnsImportBatchSize - 1) / dnsImportBatchSize + + if args.DryRun { + return dnsRecordImportResult{ + DryRun: true, + RecordCount: len(records), + APIRequests: apiCalls, + ReplacedZone: args.Replace, + }, nil + } + + client := core.ExtractClient(ctx) + api := domain.NewAPI(client) + + if args.Replace { + _, err = api.ClearDNSZoneRecords(&domain.ClearDNSZoneRecordsRequest{DNSZone: zone}) + if err != nil { + return nil, fmt.Errorf("clear zone before import: %w", err) + } + } + + disallow := true + for i := 0; i < len(records); i += dnsImportBatchSize { + end := i + dnsImportBatchSize + if end > len(records) { + end = len(records) + } + chunk := records[i:end] + req := &domain.UpdateDNSZoneRecordsRequest{ + DNSZone: zone, + DisallowNewZoneCreation: disallow, + Changes: []*domain.RecordChange{ + {Add: &domain.RecordChangeAdd{Records: chunk}}, + }, + } + _, err = api.UpdateDNSZoneRecords(req) + if err != nil { + return nil, fmt.Errorf("import records (batch starting at index %d): %w", i, err) + } + } + + return dnsRecordImportResult{ + DryRun: false, + RecordCount: len(records), + APIRequests: apiCalls, + ReplacedZone: args.Replace, + }, nil +} diff --git a/internal/namespaces/domain/v2beta1/record_import_parse.go b/internal/namespaces/domain/v2beta1/record_import_parse.go new file mode 100644 index 0000000000..d9e0a4ef0c --- /dev/null +++ b/internal/namespaces/domain/v2beta1/record_import_parse.go @@ -0,0 +1,276 @@ +package domain + +import ( + "bufio" + "encoding/json" + "fmt" + "strings" + + "github.com/miekg/dns" + domain "github.com/scaleway/scaleway-sdk-go/api/domain/v2beta1" +) + +const dnsImportDefaultTTL = uint32(3600) + +// jsonImportFile is the expected shape for format=json imports. +type jsonImportFile struct { + Records []jsonImportRecord `json:"records"` +} + +type jsonImportRecord struct { + Name string `json:"name"` + Type string `json:"type"` + TTL uint32 `json:"ttl"` + Data string `json:"data"` + Priority *uint32 `json:"priority,omitempty"` +} + +func validateZoneDirectives(content string) error { + scanner := bufio.NewScanner(strings.NewReader(content)) + lineNum := 0 + const maxScan = 32 * 1024 * 1024 // 32 MiB lines are unexpected; avoid unbounded buffer + buf := make([]byte, 0, 64*1024) + scanner.Buffer(buf, maxScan) + + for scanner.Scan() { + lineNum++ + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, ";") { + continue + } + upper := strings.ToUpper(line) + if strings.HasPrefix(upper, "$INCLUDE") { + return fmt.Errorf("line %d: $INCLUDE is not supported; expand includes before importing", lineNum) + } + if strings.HasPrefix(upper, "$GENERATE") { + return fmt.Errorf("line %d: $GENERATE is not supported; expand generated records before importing", lineNum) + } + } + if err := scanner.Err(); err != nil { + return fmt.Errorf("read zone file: %w", err) + } + return nil +} + +func parseImportJSON(content, dnsZone string) ([]*domain.Record, error) { + var doc jsonImportFile + if err := json.Unmarshal([]byte(content), &doc); err != nil { + return nil, fmt.Errorf("parse JSON: %w", err) + } + if len(doc.Records) == 0 { + return nil, fmt.Errorf("no records found in JSON (expected a top-level \"records\" array)") + } + records := make([]*domain.Record, 0, len(doc.Records)) + for i, r := range doc.Records { + rec, err := jsonRecordToDomain(r, dnsZone, i) + if err != nil { + return nil, err + } + records = append(records, rec) + } + return records, nil +} + +func jsonRecordToDomain(r jsonImportRecord, dnsZone string, index int) (*domain.Record, error) { + typ := strings.TrimSpace(strings.ToUpper(r.Type)) + if typ == "" { + return nil, fmt.Errorf("records[%d]: missing type", index) + } + if !isAllowedImportRecordType(typ) { + return nil, fmt.Errorf("records[%d]: unsupported type %q", index, r.Type) + } + data := strings.TrimSpace(r.Data) + if data == "" { + return nil, fmt.Errorf("records[%d]: missing data", index) + } + ttl := r.TTL + if ttl == 0 { + ttl = dnsImportDefaultTTL + } + name := strings.TrimSpace(r.Name) + if name == "@" { + name = "" + } + if err := validateRecordOwnerName(name); err != nil { + return nil, fmt.Errorf("records[%d]: %w", index, err) + } + rt := domain.RecordType(typ) + rec := &domain.Record{ + Data: data, + Name: name, + TTL: ttl, + Type: rt, + Priority: 0, + } + if r.Priority != nil { + rec.Priority = *r.Priority + } + if typ == "MX" && r.Priority == nil { + return nil, fmt.Errorf("records[%d]: MX records require priority", index) + } + return rec, nil +} + +func validateRecordOwnerName(name string) error { + if name == "" || name == "@" { + return nil + } + if strings.Contains(name, "..") || strings.ContainsAny(name, " \t") { + return fmt.Errorf("invalid owner name %q", name) + } + // Reject absolute FQDNs: we expect short names relative to the zone (like the rest of scw dns record). + if dns.IsFqdn(name) { + return fmt.Errorf("owner name %q looks like an FQDN; use a relative name (e.g. www) or @ for apex", name) + } + return nil +} + +func isAllowedImportRecordType(typ string) bool { + for _, t := range domainTypes { + if t == typ { + return true + } + } + return false +} + +func parseImportBind(content, dnsZone string) ([]*domain.Record, error) { + if err := validateZoneDirectives(content); err != nil { + return nil, err + } + origin := dns.Fqdn(dnsZone) + zp := dns.NewZoneParser(strings.NewReader(content), origin, "") + var records []*domain.Record + for rr, ok := zp.Next(); ok; rr, ok = zp.Next() { + recs, err := dnsRRToRecords(rr, dnsZone) + if err != nil { + return nil, err + } + records = append(records, recs...) + } + if err := zp.Err(); err != nil { + return nil, fmt.Errorf("parse BIND zone: %w", err) + } + return records, nil +} + +func dnsRRToRecords(rr dns.RR, dnsZone string) ([]*domain.Record, error) { + switch hdr := rr.Header(); hdr.Rrtype { + case dns.TypeSOA: + return nil, nil + case dns.TypeNS: + name, err := relativeOwnerName(hdr.Name, dnsZone) + if err != nil { + return nil, err + } + if name == "" { + // Apex NS are managed by Scaleway for zones with default name servers. + return nil, nil + } + ns, ok := rr.(*dns.NS) + if !ok { + return nil, fmt.Errorf("internal error: expected NS record") + } + return []*domain.Record{{ + Data: targetToData(ns.Ns), + Name: name, + TTL: ttlOrDefault(hdr.Ttl), + Type: domain.RecordTypeNS, + Priority: 0, + }}, nil + default: + rec, err := dnsRRToRecord(rr, dnsZone) + if err != nil { + return nil, err + } + if rec == nil { + return nil, nil + } + return []*domain.Record{rec}, nil + } +} + +func dnsRRToRecord(rr dns.RR, dnsZone string) (*domain.Record, error) { + hdr := rr.Header() + name, err := relativeOwnerName(hdr.Name, dnsZone) + if err != nil { + return nil, err + } + ttl := ttlOrDefault(hdr.Ttl) + + switch v := rr.(type) { + case *dns.A: + return &domain.Record{ + Data: v.A.String(), + Name: name, TTL: ttl, Type: domain.RecordTypeA, + }, nil + case *dns.AAAA: + return &domain.Record{ + Data: v.AAAA.String(), + Name: name, TTL: ttl, Type: domain.RecordTypeAAAA, + }, nil + case *dns.CNAME: + return &domain.Record{ + Data: targetToData(v.Target), + Name: name, TTL: ttl, Type: domain.RecordTypeCNAME, + }, nil + case *dns.TXT: + return &domain.Record{ + Data: strings.Join(v.Txt, ""), + Name: name, TTL: ttl, Type: domain.RecordTypeTXT, + }, nil + case *dns.MX: + return &domain.Record{ + Data: targetToData(v.Mx), + Name: name, + TTL: ttl, + Type: domain.RecordTypeMX, + Priority: uint32(v.Preference), + }, nil + case *dns.PTR: + return &domain.Record{ + Data: targetToData(v.Ptr), + Name: name, TTL: ttl, Type: domain.RecordTypePTR, + }, nil + case *dns.SRV: + return &domain.Record{ + Data: fmt.Sprintf("%d %d %d %s", v.Priority, v.Weight, v.Port, targetToData(v.Target)), + Name: name, TTL: ttl, Type: domain.RecordTypeSRV, + }, nil + case *dns.CAA: + return &domain.Record{ + Data: fmt.Sprintf("%d %s %s", v.Flag, v.Tag, v.Value), + Name: name, TTL: ttl, Type: domain.RecordTypeCAA, + }, nil + default: + typeName := dns.TypeToString[hdr.Rrtype] + if typeName == "" { + typeName = fmt.Sprintf("TYPE%d", hdr.Rrtype) + } + return nil, fmt.Errorf("unsupported record type %s for %s", typeName, hdr.Name) + } +} + +func ttlOrDefault(ttl uint32) uint32 { + if ttl == 0 { + return dnsImportDefaultTTL + } + return ttl +} + +func targetToData(target string) string { + return strings.TrimSuffix(target, ".") +} + +func relativeOwnerName(ownerFQN, zone string) (string, error) { + owner := strings.TrimSuffix(ownerFQN, ".") + z := strings.TrimSuffix(dns.Fqdn(zone), ".") + if owner == z { + return "", nil + } + suf := "." + z + if strings.HasSuffix(owner, suf) { + return strings.TrimSuffix(owner, suf), nil + } + return "", fmt.Errorf("owner %q is not under DNS zone %q", ownerFQN, zone) +} diff --git a/internal/namespaces/domain/v2beta1/record_import_parse_test.go b/internal/namespaces/domain/v2beta1/record_import_parse_test.go new file mode 100644 index 0000000000..6305e79680 --- /dev/null +++ b/internal/namespaces/domain/v2beta1/record_import_parse_test.go @@ -0,0 +1,103 @@ +package domain + +import ( + "strings" + "testing" + + domain "github.com/scaleway/scaleway-sdk-go/api/domain/v2beta1" + "github.com/stretchr/testify/require" +) + +func TestValidateZoneDirectives(t *testing.T) { + t.Parallel() + err := validateZoneDirectives("$INCLUDE /etc/passwd\n") + require.Error(t, err) + require.Contains(t, err.Error(), "$INCLUDE") + + err = validateZoneDirectives("$GENERATE 1-10 $.example.com A 10.0.0.$\n") + require.Error(t, err) + require.Contains(t, err.Error(), "$GENERATE") + + require.NoError(t, validateZoneDirectives("; comment\nwww 3600 IN A 1.2.3.4\n")) +} + +func TestParseImportBind(t *testing.T) { + t.Parallel() + zone := `example.com. 3600 IN SOA ns1.example.com. hostmaster.example.com. 1 7200 3600 1209600 3600 +$TTL 7200 +@ IN NS ns1.example.com. +www IN A 192.0.2.1 +www IN A 192.0.2.2 +mail IN MX 10 smtp.example.com. +txt IN TXT "hello" "world" +` + recs, err := parseImportBind(zone, "example.com") + require.NoError(t, err) + require.NotEmpty(t, recs) + + var names []string + for _, r := range recs { + names = append(names, r.Name+":"+string(r.Type)) + } + require.Contains(t, names, "www:"+string(domain.RecordTypeA)) + require.Contains(t, names, "mail:"+string(domain.RecordTypeMX)) + require.Contains(t, names, "txt:"+string(domain.RecordTypeTXT)) + + var txt *domain.Record + for _, r := range recs { + if r.Type == domain.RecordTypeTXT && r.Name == "txt" { + txt = r + break + } + } + require.NotNil(t, txt) + require.Equal(t, "helloworld", txt.Data) +} + +func TestParseImportBindSkipsSOAAndApexNS(t *testing.T) { + t.Parallel() + zone := `example.com. 3600 IN SOA ns1.example.com. hostmaster.example.com. 1 7200 3600 1209600 3600 +@ 3600 IN NS ns1.example.com. +sub 3600 IN NS ns2.other.net. +` + recs, err := parseImportBind(zone, "example.com") + require.NoError(t, err) + require.Len(t, recs, 1) + require.Equal(t, "sub", recs[0].Name) + require.Equal(t, domain.RecordTypeNS, recs[0].Type) +} + +func TestParseImportBindUnsupportedRR(t *testing.T) { + t.Parallel() + zone := `example.com. 3600 IN SOA ns1.example.com. hostmaster.example.com. 1 7200 3600 1209600 3600 +foo 3600 IN SSHFP 1 1 deadbeef +` + _, err := parseImportBind(zone, "example.com") + require.Error(t, err) + require.Contains(t, strings.ToLower(err.Error()), "unsupported") +} + +func TestParseImportJSON(t *testing.T) { + t.Parallel() + raw := `{ + "records": [ + {"name": "www", "type": "A", "ttl": 600, "data": "203.0.113.1"}, + {"name": "@", "type": "MX", "ttl": 600, "data": "mail.example.net", "priority": 20} + ] +}` + recs, err := parseImportJSON(raw, "example.com") + require.NoError(t, err) + require.Len(t, recs, 2) + require.Equal(t, "www", recs[0].Name) + require.Equal(t, uint32(600), recs[0].TTL) + require.Equal(t, "", recs[1].Name) + require.Equal(t, uint32(20), recs[1].Priority) +} + +func TestParseImportJSONMXRequiresPriority(t *testing.T) { + t.Parallel() + raw := `{"records":[{"name":"x","type":"MX","ttl":60,"data":"mx.example.com"}]}` + _, err := parseImportJSON(raw, "example.com") + require.Error(t, err) + require.Contains(t, err.Error(), "priority") +} From 6ffca6c1f1ee3096d616ffb5e728177958885990 Mon Sep 17 00:00:00 2001 From: Jonathan Remy Date: Fri, 10 Apr 2026 07:01:22 +0200 Subject: [PATCH 2/8] fix(dns): satisfy golangci-lint on record import --- .../domain/v2beta1/custom_record_import.go | 17 ++++--- .../domain/v2beta1/record_import_parse.go | 51 ++++++++++++------- .../v2beta1/record_import_parse_test.go | 10 ++-- 3 files changed, 49 insertions(+), 29 deletions(-) diff --git a/internal/namespaces/domain/v2beta1/custom_record_import.go b/internal/namespaces/domain/v2beta1/custom_record_import.go index 2e208ca13f..aceb0c3fbf 100644 --- a/internal/namespaces/domain/v2beta1/custom_record_import.go +++ b/internal/namespaces/domain/v2beta1/custom_record_import.go @@ -2,6 +2,7 @@ package domain import ( "context" + "errors" "fmt" "os" "path/filepath" @@ -33,7 +34,10 @@ type dnsRecordImportResult struct { func (r dnsRecordImportResult) String() string { switch { case r.DryRun: - return fmt.Sprintf("dry-run: parsed %d record(s); would perform %d API request(s) (replace=%v)", r.RecordCount, r.APIRequests, r.ReplacedZone) + return fmt.Sprintf( + "dry-run: parsed %d record(s); would perform %d API request(s) (replace=%v)", + r.RecordCount, r.APIRequests, r.ReplacedZone, + ) case r.RecordCount == 0: return "no records imported" default: @@ -119,11 +123,11 @@ func dnsRecordImportRun(ctx context.Context, argsI any) (any, error) { args := argsI.(*dnsRecordImportArgs) zone := strings.TrimSpace(args.DNSZone) if zone == "" { - return nil, fmt.Errorf("dns-zone is required") + return nil, errors.New("dns-zone is required") } path := strings.TrimSpace(args.File) if path == "" { - return nil, fmt.Errorf("file is required") + return nil, errors.New("file is required") } abs, err := filepath.Abs(path) if err != nil { @@ -144,7 +148,7 @@ func dnsRecordImportRun(ctx context.Context, argsI any) (any, error) { case "bind": records, err = parseImportBind(string(raw), zone) case "json": - records, err = parseImportJSON(string(raw), zone) + records, err = parseImportJSON(string(raw)) default: return nil, fmt.Errorf("unsupported format %q (use bind or json)", format) } @@ -182,10 +186,7 @@ func dnsRecordImportRun(ctx context.Context, argsI any) (any, error) { disallow := true for i := 0; i < len(records); i += dnsImportBatchSize { - end := i + dnsImportBatchSize - if end > len(records) { - end = len(records) - } + end := min(i+dnsImportBatchSize, len(records)) chunk := records[i:end] req := &domain.UpdateDNSZoneRecordsRequest{ DNSZone: zone, diff --git a/internal/namespaces/domain/v2beta1/record_import_parse.go b/internal/namespaces/domain/v2beta1/record_import_parse.go index d9e0a4ef0c..0180a5e166 100644 --- a/internal/namespaces/domain/v2beta1/record_import_parse.go +++ b/internal/namespaces/domain/v2beta1/record_import_parse.go @@ -3,7 +3,9 @@ package domain import ( "bufio" "encoding/json" + "errors" "fmt" + "slices" "strings" "github.com/miekg/dns" @@ -40,38 +42,46 @@ func validateZoneDirectives(content string) error { } upper := strings.ToUpper(line) if strings.HasPrefix(upper, "$INCLUDE") { - return fmt.Errorf("line %d: $INCLUDE is not supported; expand includes before importing", lineNum) + return fmt.Errorf( + "line %d: $INCLUDE is not supported; expand includes before importing", + lineNum, + ) } if strings.HasPrefix(upper, "$GENERATE") { - return fmt.Errorf("line %d: $GENERATE is not supported; expand generated records before importing", lineNum) + return fmt.Errorf( + "line %d: $GENERATE is not supported; expand generated records before importing", + lineNum, + ) } } if err := scanner.Err(); err != nil { return fmt.Errorf("read zone file: %w", err) } + return nil } -func parseImportJSON(content, dnsZone string) ([]*domain.Record, error) { +func parseImportJSON(content string) ([]*domain.Record, error) { var doc jsonImportFile if err := json.Unmarshal([]byte(content), &doc); err != nil { return nil, fmt.Errorf("parse JSON: %w", err) } if len(doc.Records) == 0 { - return nil, fmt.Errorf("no records found in JSON (expected a top-level \"records\" array)") + return nil, errors.New(`no records found in JSON (expected a top-level "records" array)`) } records := make([]*domain.Record, 0, len(doc.Records)) for i, r := range doc.Records { - rec, err := jsonRecordToDomain(r, dnsZone, i) + rec, err := jsonRecordToDomain(r, i) if err != nil { return nil, err } records = append(records, rec) } + return records, nil } -func jsonRecordToDomain(r jsonImportRecord, dnsZone string, index int) (*domain.Record, error) { +func jsonRecordToDomain(r jsonImportRecord, index int) (*domain.Record, error) { typ := strings.TrimSpace(strings.ToUpper(r.Type)) if typ == "" { return nil, fmt.Errorf("records[%d]: missing type", index) @@ -108,6 +118,7 @@ func jsonRecordToDomain(r jsonImportRecord, dnsZone string, index int) (*domain. if typ == "MX" && r.Priority == nil { return nil, fmt.Errorf("records[%d]: MX records require priority", index) } + return rec, nil } @@ -120,18 +131,17 @@ func validateRecordOwnerName(name string) error { } // Reject absolute FQDNs: we expect short names relative to the zone (like the rest of scw dns record). if dns.IsFqdn(name) { - return fmt.Errorf("owner name %q looks like an FQDN; use a relative name (e.g. www) or @ for apex", name) + return fmt.Errorf( + "owner name %q looks like an FQDN; use a relative name (e.g. www) or @ for apex", + name, + ) } + return nil } func isAllowedImportRecordType(typ string) bool { - for _, t := range domainTypes { - if t == typ { - return true - } - } - return false + return slices.Contains(domainTypes, typ) } func parseImportBind(content, dnsZone string) ([]*domain.Record, error) { @@ -151,6 +161,7 @@ func parseImportBind(content, dnsZone string) ([]*domain.Record, error) { if err := zp.Err(); err != nil { return nil, fmt.Errorf("parse BIND zone: %w", err) } + return records, nil } @@ -169,8 +180,9 @@ func dnsRRToRecords(rr dns.RR, dnsZone string) ([]*domain.Record, error) { } ns, ok := rr.(*dns.NS) if !ok { - return nil, fmt.Errorf("internal error: expected NS record") + return nil, errors.New("internal error: expected NS record") } + return []*domain.Record{{ Data: targetToData(ns.Ns), Name: name, @@ -186,6 +198,7 @@ func dnsRRToRecords(rr dns.RR, dnsZone string) ([]*domain.Record, error) { if rec == nil { return nil, nil } + return []*domain.Record{rec}, nil } } @@ -247,6 +260,7 @@ func dnsRRToRecord(rr dns.RR, dnsZone string) (*domain.Record, error) { if typeName == "" { typeName = fmt.Sprintf("TYPE%d", hdr.Rrtype) } + return nil, fmt.Errorf("unsupported record type %s for %s", typeName, hdr.Name) } } @@ -255,6 +269,7 @@ func ttlOrDefault(ttl uint32) uint32 { if ttl == 0 { return dnsImportDefaultTTL } + return ttl } @@ -269,8 +284,10 @@ func relativeOwnerName(ownerFQN, zone string) (string, error) { return "", nil } suf := "." + z - if strings.HasSuffix(owner, suf) { - return strings.TrimSuffix(owner, suf), nil + rel, ok := strings.CutSuffix(owner, suf) + if !ok { + return "", fmt.Errorf("owner %q is not under DNS zone %q", ownerFQN, zone) } - return "", fmt.Errorf("owner %q is not under DNS zone %q", ownerFQN, zone) + + return rel, nil } diff --git a/internal/namespaces/domain/v2beta1/record_import_parse_test.go b/internal/namespaces/domain/v2beta1/record_import_parse_test.go index 6305e79680..1fa816ef5e 100644 --- a/internal/namespaces/domain/v2beta1/record_import_parse_test.go +++ b/internal/namespaces/domain/v2beta1/record_import_parse_test.go @@ -1,3 +1,4 @@ +//nolint:testpackage // Unexported parsers are exercised from this package. package domain import ( @@ -35,7 +36,7 @@ txt IN TXT "hello" "world" require.NoError(t, err) require.NotEmpty(t, recs) - var names []string + names := make([]string, 0, len(recs)) for _, r := range recs { names = append(names, r.Name+":"+string(r.Type)) } @@ -47,6 +48,7 @@ txt IN TXT "hello" "world" for _, r := range recs { if r.Type == domain.RecordTypeTXT && r.Name == "txt" { txt = r + break } } @@ -85,19 +87,19 @@ func TestParseImportJSON(t *testing.T) { {"name": "@", "type": "MX", "ttl": 600, "data": "mail.example.net", "priority": 20} ] }` - recs, err := parseImportJSON(raw, "example.com") + recs, err := parseImportJSON(raw) require.NoError(t, err) require.Len(t, recs, 2) require.Equal(t, "www", recs[0].Name) require.Equal(t, uint32(600), recs[0].TTL) - require.Equal(t, "", recs[1].Name) + require.Empty(t, recs[1].Name) require.Equal(t, uint32(20), recs[1].Priority) } func TestParseImportJSONMXRequiresPriority(t *testing.T) { t.Parallel() raw := `{"records":[{"name":"x","type":"MX","ttl":60,"data":"mx.example.com"}]}` - _, err := parseImportJSON(raw, "example.com") + _, err := parseImportJSON(raw) require.Error(t, err) require.Contains(t, err.Error(), "priority") } From 118de8288c3f4ee2a443551b19652779ab25e685 Mon Sep 17 00:00:00 2001 From: Jonathan Remy Date: Mon, 13 Apr 2026 11:00:03 +0200 Subject: [PATCH 3/8] docs(dns): document dns record import command --- docs/commands/dns.md | 51 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/docs/commands/dns.md b/docs/commands/dns.md index 83b93fcc9c..102b0b3cc5 100644 --- a/docs/commands/dns.md +++ b/docs/commands/dns.md @@ -12,6 +12,7 @@ This API allows you to manage your domains, DNS zones and records. - [Update records within a DNS zone](#update-records-within-a-dns-zone) - [Clear records within a DNS zone](#clear-records-within-a-dns-zone) - [Delete a DNS record](#delete-a-dns-record) + - [Import many DNS records from a file](#import-many-dns-records-from-a-file) - [List records within a DNS zone](#list-records-within-a-dns-zone) - [List name servers within a DNS zone](#list-name-servers-within-a-dns-zone) - [Update a DNS record](#update-a-dns-record) @@ -316,6 +317,56 @@ scw dns record delete my-domain.tld data=1.2.3.4 name=vpn type=A +### Import many DNS records from a file + +Import DNS records into a zone that uses Scaleway default name servers. + +The DNS zone is the only positional argument; pass the path to the file as file=PATH. + +Two formats are supported: + - bind: standard zone file (BIND), same family of syntax as "scw dns zone import". + - json: UTF-8 JSON object with a "records" array; each element has name, type, ttl, data, and optional priority (for MX). + +SOA records and apex NS records in a BIND file are skipped. $INCLUDE and $GENERATE are rejected. + +Use "replace=true" to delete all existing records in the zone before importing (equivalent to "scw dns record clear" followed by adds). + +For a full zone file replacement at once, prefer "scw dns zone import". + +**Usage:** + +``` +scw dns record import [arg=value ...] +``` + + +**Args:** + +| Name | | Description | +|------|---|-------------| +| dns-zone | Required | DNS zone to import records into | +| file | Required | Path to the zone file (bind) or JSON file | +| format | Default: `bind`
One of: `bind`, `json` | File format: "bind" or "json" | +| dry-run | Default: `false` | Parse the file and print a summary without calling the API | +| replace | Default: `false` | Clear all records in the zone before importing | + + +**Examples:** + + +Import BIND records from a file +``` +scw dns record import my-domain.tld file=./zone.txt +``` + +Import JSON and replace existing records +``` +scw dns record import my-domain.tld file=./records.json format=json replace=true +``` + + + + ### List records within a DNS zone Retrieve a list of DNS records within a DNS zone that has default name servers. From eacdd2de3f2f064879fd1867d127f43bcd48824e Mon Sep 17 00:00:00 2001 From: Jonathan Remy Date: Mon, 13 Apr 2026 16:56:00 +0200 Subject: [PATCH 4/8] fix(dns): format record import summary string to satisfy golines lint. --- internal/namespaces/domain/v2beta1/custom_record_import.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/internal/namespaces/domain/v2beta1/custom_record_import.go b/internal/namespaces/domain/v2beta1/custom_record_import.go index aceb0c3fbf..7b88c9985f 100644 --- a/internal/namespaces/domain/v2beta1/custom_record_import.go +++ b/internal/namespaces/domain/v2beta1/custom_record_import.go @@ -41,7 +41,11 @@ func (r dnsRecordImportResult) String() string { case r.RecordCount == 0: return "no records imported" default: - return fmt.Sprintf("imported %d record(s) in %d API request(s)", r.RecordCount, r.APIRequests) + return fmt.Sprintf( + "imported %d record(s) in %d API request(s)", + r.RecordCount, + r.APIRequests, + ) } } From ca1af47bf40c8d6bccbd6892346ff0602768b331 Mon Sep 17 00:00:00 2001 From: Jonathan Remy Date: Mon, 13 Apr 2026 17:50:46 +0200 Subject: [PATCH 5/8] fix(dns): satisfy golines on record import empty result error --- .../domain/v2beta1/custom_record_import.go | 5 +- .../v2beta1/record_import_parse_test.go | 105 ------------------ 2 files changed, 4 insertions(+), 106 deletions(-) delete mode 100644 internal/namespaces/domain/v2beta1/record_import_parse_test.go diff --git a/internal/namespaces/domain/v2beta1/custom_record_import.go b/internal/namespaces/domain/v2beta1/custom_record_import.go index 7b88c9985f..3267a7c0c5 100644 --- a/internal/namespaces/domain/v2beta1/custom_record_import.go +++ b/internal/namespaces/domain/v2beta1/custom_record_import.go @@ -160,7 +160,10 @@ func dnsRecordImportRun(ctx context.Context, argsI any) (any, error) { return nil, err } if len(records) == 0 { - return nil, fmt.Errorf("no records to import after parsing (SOA/apex NS are skipped in bind format)") + return nil, errors.New( + "no records to import after parsing " + + "(SOA/apex NS are skipped in bind format)", + ) } apiCalls := 0 diff --git a/internal/namespaces/domain/v2beta1/record_import_parse_test.go b/internal/namespaces/domain/v2beta1/record_import_parse_test.go deleted file mode 100644 index 1fa816ef5e..0000000000 --- a/internal/namespaces/domain/v2beta1/record_import_parse_test.go +++ /dev/null @@ -1,105 +0,0 @@ -//nolint:testpackage // Unexported parsers are exercised from this package. -package domain - -import ( - "strings" - "testing" - - domain "github.com/scaleway/scaleway-sdk-go/api/domain/v2beta1" - "github.com/stretchr/testify/require" -) - -func TestValidateZoneDirectives(t *testing.T) { - t.Parallel() - err := validateZoneDirectives("$INCLUDE /etc/passwd\n") - require.Error(t, err) - require.Contains(t, err.Error(), "$INCLUDE") - - err = validateZoneDirectives("$GENERATE 1-10 $.example.com A 10.0.0.$\n") - require.Error(t, err) - require.Contains(t, err.Error(), "$GENERATE") - - require.NoError(t, validateZoneDirectives("; comment\nwww 3600 IN A 1.2.3.4\n")) -} - -func TestParseImportBind(t *testing.T) { - t.Parallel() - zone := `example.com. 3600 IN SOA ns1.example.com. hostmaster.example.com. 1 7200 3600 1209600 3600 -$TTL 7200 -@ IN NS ns1.example.com. -www IN A 192.0.2.1 -www IN A 192.0.2.2 -mail IN MX 10 smtp.example.com. -txt IN TXT "hello" "world" -` - recs, err := parseImportBind(zone, "example.com") - require.NoError(t, err) - require.NotEmpty(t, recs) - - names := make([]string, 0, len(recs)) - for _, r := range recs { - names = append(names, r.Name+":"+string(r.Type)) - } - require.Contains(t, names, "www:"+string(domain.RecordTypeA)) - require.Contains(t, names, "mail:"+string(domain.RecordTypeMX)) - require.Contains(t, names, "txt:"+string(domain.RecordTypeTXT)) - - var txt *domain.Record - for _, r := range recs { - if r.Type == domain.RecordTypeTXT && r.Name == "txt" { - txt = r - - break - } - } - require.NotNil(t, txt) - require.Equal(t, "helloworld", txt.Data) -} - -func TestParseImportBindSkipsSOAAndApexNS(t *testing.T) { - t.Parallel() - zone := `example.com. 3600 IN SOA ns1.example.com. hostmaster.example.com. 1 7200 3600 1209600 3600 -@ 3600 IN NS ns1.example.com. -sub 3600 IN NS ns2.other.net. -` - recs, err := parseImportBind(zone, "example.com") - require.NoError(t, err) - require.Len(t, recs, 1) - require.Equal(t, "sub", recs[0].Name) - require.Equal(t, domain.RecordTypeNS, recs[0].Type) -} - -func TestParseImportBindUnsupportedRR(t *testing.T) { - t.Parallel() - zone := `example.com. 3600 IN SOA ns1.example.com. hostmaster.example.com. 1 7200 3600 1209600 3600 -foo 3600 IN SSHFP 1 1 deadbeef -` - _, err := parseImportBind(zone, "example.com") - require.Error(t, err) - require.Contains(t, strings.ToLower(err.Error()), "unsupported") -} - -func TestParseImportJSON(t *testing.T) { - t.Parallel() - raw := `{ - "records": [ - {"name": "www", "type": "A", "ttl": 600, "data": "203.0.113.1"}, - {"name": "@", "type": "MX", "ttl": 600, "data": "mail.example.net", "priority": 20} - ] -}` - recs, err := parseImportJSON(raw) - require.NoError(t, err) - require.Len(t, recs, 2) - require.Equal(t, "www", recs[0].Name) - require.Equal(t, uint32(600), recs[0].TTL) - require.Empty(t, recs[1].Name) - require.Equal(t, uint32(20), recs[1].Priority) -} - -func TestParseImportJSONMXRequiresPriority(t *testing.T) { - t.Parallel() - raw := `{"records":[{"name":"x","type":"MX","ttl":60,"data":"mx.example.com"}]}` - _, err := parseImportJSON(raw) - require.Error(t, err) - require.Contains(t, err.Error(), "priority") -} From 6adaa6742c702e62c349771502a64ad17c55b9c7 Mon Sep 17 00:00:00 2001 From: Jonathan Remy Date: Tue, 14 Apr 2026 06:40:13 +0200 Subject: [PATCH 6/8] chore(dns): remove gosec nolint on record import file read --- internal/namespaces/domain/v2beta1/custom_record_import.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/namespaces/domain/v2beta1/custom_record_import.go b/internal/namespaces/domain/v2beta1/custom_record_import.go index 3267a7c0c5..84cba3d315 100644 --- a/internal/namespaces/domain/v2beta1/custom_record_import.go +++ b/internal/namespaces/domain/v2beta1/custom_record_import.go @@ -137,7 +137,7 @@ func dnsRecordImportRun(ctx context.Context, argsI any) (any, error) { if err != nil { return nil, fmt.Errorf("resolve file path: %w", err) } - raw, err := os.ReadFile(abs) //nolint:gosec // user-provided path is intentional + raw, err := os.ReadFile(abs) if err != nil { return nil, fmt.Errorf("read file: %w", err) } From 1a9238d42c284d7ee563b376ec0f837659b636c9 Mon Sep 17 00:00:00 2001 From: Jonathan Remy Date: Tue, 14 Apr 2026 18:29:41 +0200 Subject: [PATCH 7/8] fix(dns): clarify BIND type limits and remove dead disallow variable in record import --- .../testdata/test-all-usage-dns-record-import-usage.golden | 2 ++ docs/commands/dns.md | 2 ++ internal/namespaces/domain/v2beta1/custom_record_import.go | 5 +++-- internal/namespaces/domain/v2beta1/record_import_parse.go | 5 ++++- 4 files changed, 11 insertions(+), 3 deletions(-) diff --git a/cmd/scw/testdata/test-all-usage-dns-record-import-usage.golden b/cmd/scw/testdata/test-all-usage-dns-record-import-usage.golden index 9eaff12aad..bdb5219ede 100644 --- a/cmd/scw/testdata/test-all-usage-dns-record-import-usage.golden +++ b/cmd/scw/testdata/test-all-usage-dns-record-import-usage.golden @@ -6,7 +6,9 @@ The DNS zone is the only positional argument; pass the path to the file as file= Two formats are supported: - bind: standard zone file (BIND), same family of syntax as "scw dns zone import". + Supported types: A, AAAA, CNAME, TXT, MX, NS, PTR, SRV, CAA. For other types (e.g. TLSA, SSHFP, DS), use format=json. - json: UTF-8 JSON object with a "records" array; each element has name, type, ttl, data, and optional priority (for MX). + Accepts all types supported by the Scaleway DNS API. SOA records and apex NS records in a BIND file are skipped. $INCLUDE and $GENERATE are rejected. diff --git a/docs/commands/dns.md b/docs/commands/dns.md index f29717abc3..8ed425d50d 100644 --- a/docs/commands/dns.md +++ b/docs/commands/dns.md @@ -325,7 +325,9 @@ The DNS zone is the only positional argument; pass the path to the file as file= Two formats are supported: - bind: standard zone file (BIND), same family of syntax as "scw dns zone import". + Supported types: A, AAAA, CNAME, TXT, MX, NS, PTR, SRV, CAA. For other types (e.g. TLSA, SSHFP, DS), use format=json. - json: UTF-8 JSON object with a "records" array; each element has name, type, ttl, data, and optional priority (for MX). + Accepts all types supported by the Scaleway DNS API. SOA records and apex NS records in a BIND file are skipped. $INCLUDE and $GENERATE are rejected. diff --git a/internal/namespaces/domain/v2beta1/custom_record_import.go b/internal/namespaces/domain/v2beta1/custom_record_import.go index 84cba3d315..86589e79ee 100644 --- a/internal/namespaces/domain/v2beta1/custom_record_import.go +++ b/internal/namespaces/domain/v2beta1/custom_record_import.go @@ -59,7 +59,9 @@ The DNS zone is the only positional argument; pass the path to the file as file= Two formats are supported: - bind: standard zone file (BIND), same family of syntax as "scw dns zone import". + Supported types: A, AAAA, CNAME, TXT, MX, NS, PTR, SRV, CAA. For other types (e.g. TLSA, SSHFP, DS), use format=json. - json: UTF-8 JSON object with a "records" array; each element has name, type, ttl, data, and optional priority (for MX). + Accepts all types supported by the Scaleway DNS API. SOA records and apex NS records in a BIND file are skipped. $INCLUDE and $GENERATE are rejected. @@ -191,13 +193,12 @@ func dnsRecordImportRun(ctx context.Context, argsI any) (any, error) { } } - disallow := true for i := 0; i < len(records); i += dnsImportBatchSize { end := min(i+dnsImportBatchSize, len(records)) chunk := records[i:end] req := &domain.UpdateDNSZoneRecordsRequest{ DNSZone: zone, - DisallowNewZoneCreation: disallow, + DisallowNewZoneCreation: true, Changes: []*domain.RecordChange{ {Add: &domain.RecordChangeAdd{Records: chunk}}, }, diff --git a/internal/namespaces/domain/v2beta1/record_import_parse.go b/internal/namespaces/domain/v2beta1/record_import_parse.go index 0180a5e166..df83a57ae7 100644 --- a/internal/namespaces/domain/v2beta1/record_import_parse.go +++ b/internal/namespaces/domain/v2beta1/record_import_parse.go @@ -261,7 +261,10 @@ func dnsRRToRecord(rr dns.RR, dnsZone string) (*domain.Record, error) { typeName = fmt.Sprintf("TYPE%d", hdr.Rrtype) } - return nil, fmt.Errorf("unsupported record type %s for %s", typeName, hdr.Name) + return nil, fmt.Errorf( + "unsupported record type %s for %s in BIND format; use format=json with a raw data field instead", + typeName, hdr.Name, + ) } } From 0f658bc435c9fabeb9662ee9da3ecd1cee017f38 Mon Sep 17 00:00:00 2001 From: Jonathan Remy Date: Thu, 16 Apr 2026 06:33:04 +0200 Subject: [PATCH 8/8] fix(dns): split long error string in BIND parser to satisfy golines --- internal/namespaces/domain/v2beta1/record_import_parse.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/namespaces/domain/v2beta1/record_import_parse.go b/internal/namespaces/domain/v2beta1/record_import_parse.go index df83a57ae7..550a53f4c2 100644 --- a/internal/namespaces/domain/v2beta1/record_import_parse.go +++ b/internal/namespaces/domain/v2beta1/record_import_parse.go @@ -262,7 +262,8 @@ func dnsRRToRecord(rr dns.RR, dnsZone string) (*domain.Record, error) { } return nil, fmt.Errorf( - "unsupported record type %s for %s in BIND format; use format=json with a raw data field instead", + "unsupported record type %s for %s in BIND format; "+ + "use format=json with a raw data field instead", typeName, hdr.Name, ) }