Merge pull request #15393 from scaleway/scaleway_dns

scaleway: dns support
This commit is contained in:
Kubernetes Prow Robot 2023-06-19 04:30:23 -07:00 committed by GitHub
commit e299cc2c4b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 5033 additions and 8 deletions

View File

@ -0,0 +1,124 @@
/*
Copyright 2023 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package mockdns
import (
"fmt"
"github.com/google/uuid"
domain "github.com/scaleway/scaleway-sdk-go/api/domain/v2beta1"
"github.com/scaleway/scaleway-sdk-go/scw"
)
type FakeDomainAPI struct {
DNSZones []*domain.DNSZone
Records map[string]*domain.Record
}
func (f *FakeDomainAPI) ListDNSZones(req *domain.ListDNSZonesRequest, opts ...scw.RequestOption) (*domain.ListDNSZonesResponse, error) {
if f.DNSZones == nil {
return nil, fmt.Errorf("error response")
}
return &domain.ListDNSZonesResponse{
TotalCount: uint32(len(f.DNSZones)),
DNSZones: f.DNSZones,
}, nil
}
func (f *FakeDomainAPI) CreateDNSZone(req *domain.CreateDNSZoneRequest, opts ...scw.RequestOption) (*domain.DNSZone, error) {
if f.DNSZones == nil {
return nil, fmt.Errorf("error response")
}
newZone := &domain.DNSZone{
Domain: req.Domain,
Subdomain: req.Subdomain,
}
f.DNSZones = append(f.DNSZones, newZone)
return newZone, nil
}
func (f *FakeDomainAPI) DeleteDNSZone(req *domain.DeleteDNSZoneRequest, opts ...scw.RequestOption) (*domain.DeleteDNSZoneResponse, error) {
if f.DNSZones == nil {
return nil, fmt.Errorf("error response")
}
var newZoneList []*domain.DNSZone
for _, zone := range f.DNSZones {
if req.DNSZone == fmt.Sprintf("%s.%s", zone.Subdomain, zone.Domain) {
continue
}
newZoneList = append(newZoneList, zone)
}
f.DNSZones = newZoneList
return &domain.DeleteDNSZoneResponse{}, nil
}
func (f *FakeDomainAPI) ListDNSZoneRecords(req *domain.ListDNSZoneRecordsRequest, opts ...scw.RequestOption) (*domain.ListDNSZoneRecordsResponse, error) {
var recordsList []*domain.Record
for _, record := range f.Records {
recordsList = append(recordsList, record)
}
records := &domain.ListDNSZoneRecordsResponse{
TotalCount: uint32(len(f.Records)),
Records: recordsList,
}
return records, nil
}
func (f *FakeDomainAPI) UpdateDNSZoneRecords(req *domain.UpdateDNSZoneRecordsRequest, opts ...scw.RequestOption) (*domain.UpdateDNSZoneRecordsResponse, error) {
for _, change := range req.Changes {
if change.Add != nil {
for _, toAdd := range change.Add.Records {
toAdd.ID = uuid.New().String()
f.Records[toAdd.Name] = toAdd
}
} else if change.Set != nil {
if len(change.Set.Records) != 1 {
fmt.Printf("only 1 record change will be applied from %d changes requested", len(change.Set.Records))
}
for _, toUpsert := range change.Set.Records {
if _, ok := f.Records[toUpsert.Name]; !ok {
return nil, fmt.Errorf("could not find record %s to upsert", toUpsert.Name)
}
toUpsert.ID = *change.Set.ID
f.Records[toUpsert.Name] = toUpsert
}
} else if change.Delete != nil {
found := false
for name, record := range f.Records {
if record.ID == *change.Delete.ID {
delete(f.Records, name)
found = true
break
}
}
if found == false {
return nil, fmt.Errorf("could not find record %s to delete", *change.Delete.ID)
}
} else {
return nil, fmt.Errorf("mock DNS not implemented for this method")
}
}
var recordsList []*domain.Record
for _, record := range f.Records {
recordsList = append(recordsList, record)
}
return &domain.UpdateDNSZoneRecordsResponse{Records: recordsList}, nil
}

View File

@ -38,6 +38,7 @@ import (
_ "k8s.io/kops/dnsprovider/pkg/dnsprovider/providers/do"
_ "k8s.io/kops/dnsprovider/pkg/dnsprovider/providers/google/clouddns"
_ "k8s.io/kops/dnsprovider/pkg/dnsprovider/providers/openstack/designate"
_ "k8s.io/kops/dnsprovider/pkg/dnsprovider/providers/scaleway"
"k8s.io/kops/pkg/wellknownports"
"k8s.io/kops/protokube/pkg/gossip"
gossipdns "k8s.io/kops/protokube/pkg/gossip/dns"
@ -67,7 +68,7 @@ func main() {
flags.BoolVar(&watchIngress, "watch-ingress", true, "Configure hostnames found in ingress resources")
flags.StringSliceVar(&gossipSeeds, "gossip-seed", gossipSeeds, "If set, will enable gossip zones and seed using the provided addresses")
flags.StringSliceVarP(&zones, "zone", "z", []string{}, "Configure permitted zones and their mappings")
flags.StringVar(&dnsProviderID, "dns", "aws-route53", "DNS provider we should use (aws-route53, google-clouddns, digitalocean, gossip, openstack-designate)")
flags.StringVar(&dnsProviderID, "dns", "aws-route53", "DNS provider we should use (aws-route53, google-clouddns, digitalocean, gossip, openstack-designate, scaleway)")
flag.StringVar(&gossipProtocol, "gossip-protocol", "mesh", "mesh/memberlist")
flags.StringVar(&gossipListen, "gossip-listen", fmt.Sprintf("0.0.0.0:%d", wellknownports.DNSControllerGossipWeaveMesh), "The address on which to listen if gossip is enabled")
flags.StringVar(&gossipSecret, "gossip-secret", gossipSecret, "Secret to use to secure gossip")

View File

@ -0,0 +1,458 @@
/*
Copyright 2023 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package dns
import (
"context"
"fmt"
"io"
"os"
"strings"
domain "github.com/scaleway/scaleway-sdk-go/api/domain/v2beta1"
"github.com/scaleway/scaleway-sdk-go/scw"
"golang.org/x/oauth2"
"k8s.io/klog/v2"
kopsv "k8s.io/kops"
"k8s.io/kops/dns-controller/pkg/dns"
"k8s.io/kops/dnsprovider/pkg/dnsprovider"
"k8s.io/kops/dnsprovider/pkg/dnsprovider/rrstype"
)
var _ dnsprovider.Interface = Interface{}
const (
ProviderName = "scaleway"
)
func init() {
dnsprovider.RegisterDNSProvider(ProviderName, func(config io.Reader) (dnsprovider.Interface, error) {
client, err := newClient()
if err != nil {
return nil, err
}
return NewProvider(domain.NewAPI(client)), nil
})
}
// TokenSource implements oauth2.TokenSource
type TokenSource struct {
AccessToken string
}
// Token returns oauth2.Token
func (t *TokenSource) Token() (*oauth2.Token, error) {
token := &oauth2.Token{
AccessToken: t.AccessToken,
}
return token, nil
}
func newClient() (*scw.Client, error) {
if accessKey := os.Getenv("SCW_ACCESS_KEY"); accessKey == "" {
return nil, fmt.Errorf("SCW_ACCESS_KEY is required")
}
if secretKey := os.Getenv("SCW_SECRET_KEY"); secretKey == "" {
return nil, fmt.Errorf("SCW_SECRET_KEY is required")
}
scwClient, err := scw.NewClient(
scw.WithUserAgent("kubernetes-kops/"+kopsv.Version),
scw.WithEnv(),
)
if err != nil {
return nil, err
}
return scwClient, nil
}
// Interface implements dnsprovider.Interface
type Interface struct {
domainAPI DomainAPI
}
// NewProvider returns an implementation of dnsprovider.Interface
func NewProvider(api DomainAPI) dnsprovider.Interface {
return &Interface{domainAPI: api}
}
// Zones returns an implementation of dnsprovider.Zones
func (d Interface) Zones() (dnsprovider.Zones, bool) {
return &zones{
domainAPI: d.domainAPI,
}, true
}
// zones is an implementation of dnsprovider.Zones
type zones struct {
domainAPI DomainAPI
}
// List returns a list of all dns zones
func (z *zones) List() ([]dnsprovider.Zone, error) {
dnsZones, err := z.domainAPI.ListDNSZones(&domain.ListDNSZonesRequest{}, scw.WithAllPages())
if err != nil {
return nil, fmt.Errorf("failed to list DNS zones: %w", err)
}
zonesList := []dnsprovider.Zone(nil)
for _, dnsZone := range dnsZones.DNSZones {
newZone := &zone{
name: dnsZone.Domain,
domainAPI: z.domainAPI,
}
zonesList = append(zonesList, newZone)
}
return zonesList, nil
}
// Add adds a new DNS zone. The name of the new zone should be of the form "name.domain", otherwise we can't infer the
// domain name from anywhere else in this function
func (z *zones) Add(newZone dnsprovider.Zone) (dnsprovider.Zone, error) {
newZoneNameSplit := strings.SplitN(newZone.Name(), ".", 2)
if len(newZoneNameSplit) < 2 {
return nil, fmt.Errorf("new zone name should contain at least 1 '.', got %q", newZone.Name())
}
newZoneName := newZoneNameSplit[0]
domainName := newZoneNameSplit[1]
klog.V(8).Infof("Adding new DNS zone %s to domain %s", newZoneName, domainName)
_, err := z.domainAPI.CreateDNSZone(&domain.CreateDNSZoneRequest{
Subdomain: newZoneName,
Domain: domainName,
})
if err != nil {
return nil, err
}
klog.V(4).Infof("Added new DNS zone %s to domain %s", newZoneName, domainName)
return &zone{
name: newZoneName,
domainAPI: z.domainAPI,
}, nil
}
// Remove deletes a zone
func (z *zones) Remove(zone dnsprovider.Zone) error {
_, err := z.domainAPI.DeleteDNSZone(&domain.DeleteDNSZoneRequest{
DNSZone: zone.Name(),
})
if err != nil {
return err
}
return nil
}
// New returns a new implementation of dnsprovider.Zone
func (z *zones) New(name string) (dnsprovider.Zone, error) {
return &zone{
name: name,
domainAPI: z.domainAPI,
}, nil
}
// zone implements dnsprovider.Zone
type zone struct {
name string
domainAPI DomainAPI
}
// Name returns the Name of a dns zone
func (z *zone) Name() string {
return z.name
}
// ID returns the ID of a dns zone, here we use the name as an identifier
func (z *zone) ID() string {
return z.name
}
// ResourceRecordSets returns an implementation of dnsprovider.ResourceRecordSets
func (z *zone) ResourceRecordSets() (dnsprovider.ResourceRecordSets, bool) {
return &resourceRecordSets{zone: z, domainAPI: z.domainAPI}, true
}
// resourceRecordSets implements dnsprovider.ResourceRecordSet
type resourceRecordSets struct {
zone *zone
domainAPI DomainAPI
}
// List returns a list of dnsprovider.ResourceRecordSet
func (r *resourceRecordSets) List() ([]dnsprovider.ResourceRecordSet, error) {
records, err := listRecords(r.domainAPI, r.zone.Name())
if err != nil {
return nil, err
}
var rrsets []dnsprovider.ResourceRecordSet
rrsetsWithoutDups := make(map[string]*resourceRecordSet)
for _, record := range records {
// The scaleway API returns the record without the zone
// but the consumers of this interface expect the zone to be included
recordName := dns.EnsureDotSuffix(record.Name) + r.Zone().Name()
recordKey := recordName + "_" + record.Type.String()
if rrset, ok := rrsetsWithoutDups[recordKey]; !ok {
rrsetsWithoutDups[recordKey] = &resourceRecordSet{
name: recordName,
data: []string{record.Data},
ttl: int(record.TTL),
recordType: rrstype.RrsType(record.Type),
}
} else {
rrset.data = append(rrset.data, record.Data)
}
}
for _, rrset := range rrsetsWithoutDups {
rrsets = append(rrsets, rrset)
}
return rrsets, nil
}
// Get returns a list of dnsprovider.ResourceRecordSet that matches the name parameter. The name should contain the domain name.
func (r *resourceRecordSets) Get(name string) ([]dnsprovider.ResourceRecordSet, error) {
rrsetList, err := r.List()
if err != nil {
return nil, err
}
var recordSets []dnsprovider.ResourceRecordSet
for _, rrset := range rrsetList {
if rrset.Name() == name {
recordSets = append(recordSets, rrset)
}
}
return recordSets, nil
}
// New returns an implementation of dnsprovider.ResourceRecordSet. The name should contain the domain name.
func (r *resourceRecordSets) New(name string, rrdatas []string, ttl int64, rrstype rrstype.RrsType) dnsprovider.ResourceRecordSet {
if len(rrdatas) == 0 {
return nil
}
return &resourceRecordSet{
name: name,
data: rrdatas,
ttl: int(ttl),
recordType: rrstype,
}
}
// StartChangeset returns an implementation of dnsprovider.ResourceRecordChangeset
func (r *resourceRecordSets) StartChangeset() dnsprovider.ResourceRecordChangeset {
return &resourceRecordChangeset{
domainAPI: r.domainAPI,
zone: r.zone,
rrsets: r,
additions: []dnsprovider.ResourceRecordSet{},
removals: []dnsprovider.ResourceRecordSet{},
upserts: []dnsprovider.ResourceRecordSet{},
}
}
// Zone returns the associated implementation of dnsprovider.Zone
func (r *resourceRecordSets) Zone() dnsprovider.Zone {
return r.zone
}
// recordRecordSet implements dnsprovider.ResourceRecordSet which represents
// a single record associated with a zone
type resourceRecordSet struct {
name string
data []string
ttl int
recordType rrstype.RrsType
}
// Name returns the name of a resource record set
func (r *resourceRecordSet) Name() string {
return r.name
}
// Rrdatas returns a list of data associated with a resource record set
func (r *resourceRecordSet) Rrdatas() []string {
return r.data
}
// Ttl returns the time-to-live of a record
func (r *resourceRecordSet) Ttl() int64 {
return int64(r.ttl)
}
// Type returns the type of record a resource record set is
func (r *resourceRecordSet) Type() rrstype.RrsType {
return r.recordType
}
// resourceRecordChangeset implements dnsprovider.ResourceRecordChangeset
type resourceRecordChangeset struct {
domainAPI DomainAPI
zone *zone
rrsets dnsprovider.ResourceRecordSets
additions []dnsprovider.ResourceRecordSet
removals []dnsprovider.ResourceRecordSet
upserts []dnsprovider.ResourceRecordSet
}
// Add adds a new resource record set to the list of additions to apply
func (r *resourceRecordChangeset) Add(rrset dnsprovider.ResourceRecordSet) dnsprovider.ResourceRecordChangeset {
r.additions = append(r.additions, rrset)
return r
}
// Remove adds a new resource record set to the list of removals to apply
func (r *resourceRecordChangeset) Remove(rrset dnsprovider.ResourceRecordSet) dnsprovider.ResourceRecordChangeset {
r.removals = append(r.removals, rrset)
return r
}
// Upsert adds a new resource record set to the list of upserts to apply
func (r *resourceRecordChangeset) Upsert(rrset dnsprovider.ResourceRecordSet) dnsprovider.ResourceRecordChangeset {
r.upserts = append(r.upserts, rrset)
return r
}
// Apply adds new records stored in r.additions, updates records stored in r.upserts and deletes records stored in r.removals
func (r *resourceRecordChangeset) Apply(ctx context.Context) error {
// Empty changesets should be a relatively quick no-op
if r.IsEmpty() {
klog.V(4).Info("record change set is empty")
return nil
}
updateRecordsRequest := []*domain.RecordChange(nil)
klog.V(8).Infof("applying changes in record change set : [ %d additions | %d upserts | %d removals ]",
len(r.additions), len(r.upserts), len(r.removals))
records, err := listRecords(r.domainAPI, r.zone.Name())
if err != nil {
return err
}
if len(r.additions) > 0 {
recordsToAdd := []*domain.Record(nil)
for _, rrset := range r.additions {
recordName := strings.TrimSuffix(rrset.Name(), ".")
recordName = strings.TrimSuffix(recordName, "."+r.zone.Name())
for _, rrdata := range rrset.Rrdatas() {
recordsToAdd = append(recordsToAdd, &domain.Record{
Name: recordName,
Data: rrdata,
TTL: uint32(rrset.Ttl()),
Type: domain.RecordType(rrset.Type()),
})
}
klog.V(8).Infof("adding new DNS record %q to zone %q", recordName, r.zone.name)
updateRecordsRequest = append(updateRecordsRequest, &domain.RecordChange{
Add: &domain.RecordChangeAdd{
Records: recordsToAdd,
},
})
}
}
if len(r.upserts) > 0 {
for _, rrset := range r.upserts {
for _, rrdata := range rrset.Rrdatas() {
for _, record := range records {
recordNameWithZone := fmt.Sprintf("%s.%s.", record.Name, r.zone.Name())
if recordNameWithZone == dns.EnsureDotSuffix(rrset.Name()) && rrset.Type() == rrstype.RrsType(record.Type) {
klog.V(8).Infof("changing DNS record %q of zone %q", record.Name, r.zone.Name())
updateRecordsRequest = append(updateRecordsRequest, &domain.RecordChange{
Set: &domain.RecordChangeSet{
ID: &record.ID,
Records: []*domain.Record{
{
Name: record.Name,
Data: rrdata,
TTL: uint32(rrset.Ttl()),
Type: domain.RecordType(rrset.Type()),
},
},
},
})
}
}
}
}
}
if len(r.removals) > 0 {
for _, rrset := range r.removals {
for _, record := range records {
recordNameWithZone := fmt.Sprintf("%s.%s.", record.Name, r.zone.Name())
if recordNameWithZone == dns.EnsureDotSuffix(rrset.Name()) && record.Data == rrset.Rrdatas()[0] &&
rrset.Type() == rrstype.RrsType(record.Type) {
klog.V(8).Infof("removing DNS record %q of zone %q", record.Name, r.zone.name)
updateRecordsRequest = append(updateRecordsRequest, &domain.RecordChange{
Delete: &domain.RecordChangeDelete{
ID: &record.ID,
},
})
}
}
}
}
_, err = r.domainAPI.UpdateDNSZoneRecords(&domain.UpdateDNSZoneRecordsRequest{
DNSZone: r.zone.Name(),
Changes: updateRecordsRequest,
}, scw.WithContext(ctx))
if err != nil {
return fmt.Errorf("failed to apply resource record set: %w", err)
}
klog.V(2).Info("record change sets successfully applied")
return nil
}
// IsEmpty returns true if a changeset is empty, false otherwise
func (r *resourceRecordChangeset) IsEmpty() bool {
if len(r.additions) == 0 && len(r.removals) == 0 && len(r.upserts) == 0 {
return true
}
return false
}
// ResourceRecordSets returns the associated resourceRecordSets of a changeset
func (r *resourceRecordChangeset) ResourceRecordSets() dnsprovider.ResourceRecordSets {
return r.rrsets
}
// listRecords returns a list of scaleway records given a zone name (the name of the record doesn't end with the zone name)
func listRecords(api DomainAPI, zoneName string) ([]*domain.Record, error) {
records, err := api.ListDNSZoneRecords(&domain.ListDNSZoneRecordsRequest{
DNSZone: zoneName,
}, scw.WithAllPages())
if err != nil {
return nil, fmt.Errorf("failed to list records: %w", err)
}
return records.Records, err
}

View File

@ -0,0 +1,301 @@
/*
Copyright 2023 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package dns
import (
"context"
"fmt"
"strings"
"testing"
"github.com/google/uuid"
domain "github.com/scaleway/scaleway-sdk-go/api/domain/v2beta1"
"k8s.io/kops/cloudmock/scaleway/mockdns"
"k8s.io/kops/dnsprovider/pkg/dnsprovider"
"k8s.io/kops/dnsprovider/pkg/dnsprovider/rrstype"
)
func setUpFakeZones() *mockdns.FakeDomainAPI {
return &mockdns.FakeDomainAPI{
DNSZones: []*domain.DNSZone{
{
Domain: "example.com",
Subdomain: "zone",
},
{
Domain: "example.com",
Subdomain: "kops",
},
{
Domain: "domain.fr",
Subdomain: "zone",
},
},
}
}
func setUpFakeZonesNil() *mockdns.FakeDomainAPI {
return &mockdns.FakeDomainAPI{}
}
func getDNSProviderZones(api DomainAPI) dnsprovider.Zones {
dnsProvider := NewProvider(api)
zs, _ := dnsProvider.Zones()
return zs
}
func TestZonesListValid(t *testing.T) {
domainAPI := setUpFakeZones()
z := &zones{domainAPI: domainAPI}
zoneList, err := z.List()
if err != nil {
t.Errorf("error listing zones: %v", err)
}
if len(zoneList) != 3 {
t.Errorf("expected at least 1 zone, got 0")
}
for i, zone := range zoneList {
if zone.Name() != domainAPI.DNSZones[i].Domain {
t.Errorf("expected %s as zone name, got: %s", domainAPI.DNSZones[i].Domain, zone.Name())
}
}
}
func TestZonesListShouldFail(t *testing.T) {
domainAPI := setUpFakeZonesNil()
z := &zones{domainAPI: domainAPI}
zoneList, err := z.List()
if err == nil {
t.Errorf("expected non-nil err")
}
if zoneList != nil {
t.Errorf("expected nil zone, got %v", zoneList)
}
}
func TestAddValid(t *testing.T) {
domainAPI := setUpFakeZones()
zs := getDNSProviderZones(domainAPI)
inZone := &zone{
name: "dns.example.com",
domainAPI: domainAPI,
}
outZone, err := zs.Add(inZone)
if err != nil {
t.Errorf("unexpected err: %v", err)
}
if outZone == nil {
t.Errorf("zone is nil, exiting test early")
}
if outZone.Name() != "dns" {
t.Errorf("unexpected zone name: %s", outZone.Name())
}
}
func TestAddShouldFail(t *testing.T) {
domainAPI := setUpFakeZonesNil()
zs := getDNSProviderZones(domainAPI)
inZone := &zone{
name: "dns.example.com",
domainAPI: domainAPI,
}
outZone, err := zs.Add(inZone)
if outZone != nil {
t.Errorf("expected zone to be nil, got :%v", outZone)
}
if err == nil {
t.Errorf("expected non-nil err: %v", err)
}
}
func TestRemoveValid(t *testing.T) {
domainAPI := setUpFakeZones()
zs := getDNSProviderZones(domainAPI)
inZone := &zone{
name: "kops.example.com",
domainAPI: domainAPI,
}
err := zs.Remove(inZone)
if err != nil {
t.Errorf("unexpected err: %v", err)
}
}
func TestRemoveShouldFail(t *testing.T) {
domainAPI := setUpFakeZonesNil()
zs := getDNSProviderZones(domainAPI)
inZone := &zone{
name: "kops.example.com",
domainAPI: domainAPI,
}
err := zs.Remove(inZone)
if err == nil {
t.Errorf("expected non-nil err: %v", err)
}
}
func TestNewZone(t *testing.T) {
domainAPI := setUpFakeZones()
zs := getDNSProviderZones(domainAPI)
zone, err := zs.New("kops-dns-test")
if err != nil {
t.Errorf("error creating zone: %v", err)
return
}
if zone == nil {
t.Errorf("zone is nil, exiting test early")
}
if zone.Name() != "kops-dns-test" {
t.Errorf("unexpected zone name: %v", zone.Name())
}
}
func setUpFakeRecords() *mockdns.FakeDomainAPI {
return &mockdns.FakeDomainAPI{
Records: map[string]*domain.Record{
"test": {
Data: "1.2.3.4",
Name: "test",
TTL: 3600,
Type: "A",
ID: uuid.New().String(),
},
"to-remove": {
Data: "5.6.7.8",
Name: "to-remove",
TTL: 3600,
Type: "A",
ID: uuid.New().String(),
},
"to-upsert": {
Data: "127.0.0.1",
Name: "to-upsert",
TTL: 3600,
Type: "A",
ID: uuid.New().String(),
},
},
}
}
func TestNewResourceRecordSet(t *testing.T) {
domainAPI := setUpFakeRecords()
zoneName := "kops.example.com"
zone := zone{
domainAPI: domainAPI,
name: zoneName,
}
rrset, _ := zone.ResourceRecordSets()
rrsets, err := rrset.List()
if err != nil {
t.Errorf("error listing resource record sets: %v", err)
}
if len(rrsets) != 3 {
t.Errorf("unexpected number of records: %d", len(rrsets))
}
for _, record := range rrsets {
recordNameShort := strings.TrimSuffix(record.Name(), "."+zoneName)
expectedRecord, ok := domainAPI.Records[recordNameShort]
if !ok {
t.Errorf("could not find record %s in mock records list", record.Name())
}
expectedName := fmt.Sprintf("%s.%s", expectedRecord.Name, zoneName)
if record.Name() != expectedName {
t.Errorf("expected %q as record name, got %q", expectedName, record.Name())
}
if record.Ttl() != int64(expectedRecord.TTL) {
t.Errorf("expected %d as record TTL, got %d", expectedRecord.TTL, record.Ttl())
}
if record.Type() != rrstype.RrsType(expectedRecord.Type) {
t.Errorf("expected %q as record type, got %q", expectedRecord.Type, record.Type())
}
if len(record.Rrdatas()) < 1 {
t.Errorf("expected at least 1 rrdata for record %s, got 0", record.Name())
} else if record.Rrdatas()[0] != expectedRecord.Data {
t.Errorf("expected %q as record data, got %q", expectedRecord.Data, record.Rrdatas())
}
}
}
func TestResourceRecordChangeset(t *testing.T) {
ctx := context.Background()
domainAPI := setUpFakeRecords()
zoneName := "kops.example.com"
zone := zone{
domainAPI: domainAPI,
name: zoneName,
}
rrsets, _ := zone.ResourceRecordSets()
changeset := rrsets.StartChangeset()
if !changeset.IsEmpty() {
t.Error("expected empty changeset")
}
rrset := rrsets.New(fmt.Sprintf("%s.%s", "to-add", zoneName), []string{"127.0.0.1"}, 3600, rrstype.A)
changeset.Add(rrset)
rrset = rrsets.New(fmt.Sprintf("%s.%s", "to-remove", zoneName), []string{"5.6.7.8"}, 3600, rrstype.A)
changeset.Remove(rrset)
rrset = rrsets.New(fmt.Sprintf("%s.%s", "to-upsert", zoneName), []string{"127.0.0.1"}, 3601, rrstype.A)
changeset.Upsert(rrset)
err := changeset.Apply(ctx)
if err != nil {
t.Errorf("error applying changeset: %v", err)
}
_, err = rrsets.Get(fmt.Sprintf("%s.%s", "test", zoneName))
if err != nil {
t.Errorf("unexpected error getting resource record set: %v", err)
}
_, err = rrsets.Get(fmt.Sprintf("%s.%s", "to-add", zoneName))
if err != nil {
t.Errorf("unexpected error getting resource record set: %v", err)
}
recordsRemove, _ := rrsets.Get(fmt.Sprintf("%s.%s", "to-remove", zoneName))
if recordsRemove != nil {
t.Errorf("record set 'to-remove' should have been deleted")
}
recordsUpsert, err := rrsets.Get(fmt.Sprintf("%s.%s", "to-upsert", zoneName))
if err != nil {
t.Errorf("unexpected error getting resource record set: %v", err)
}
if recordsUpsert[0].Ttl() != 3601 {
t.Errorf("unexpected record TTL: %d, expected 3601", recordsUpsert[0].Ttl())
}
}

View File

@ -0,0 +1,34 @@
/*
Copyright 2023 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package dns
import (
domain "github.com/scaleway/scaleway-sdk-go/api/domain/v2beta1"
"github.com/scaleway/scaleway-sdk-go/scw"
"k8s.io/kops/cloudmock/scaleway/mockdns"
)
type DomainAPI interface {
ListDNSZones(req *domain.ListDNSZonesRequest, opts ...scw.RequestOption) (*domain.ListDNSZonesResponse, error)
CreateDNSZone(req *domain.CreateDNSZoneRequest, opts ...scw.RequestOption) (*domain.DNSZone, error)
DeleteDNSZone(req *domain.DeleteDNSZoneRequest, opts ...scw.RequestOption) (*domain.DeleteDNSZoneResponse, error)
ListDNSZoneRecords(req *domain.ListDNSZoneRecordsRequest, opts ...scw.RequestOption) (*domain.ListDNSZoneRecordsResponse, error)
UpdateDNSZoneRecords(req *domain.UpdateDNSZoneRecordsRequest, opts ...scw.RequestOption) (*domain.UpdateDNSZoneRecordsResponse, error)
}
var _ DomainAPI = &domain.API{}
var _ DomainAPI = &mockdns.FakeDomainAPI{}

View File

@ -10,15 +10,14 @@
### Coming soon
* Scaleway DNS (to create clusters with a custom domain name)
* [Terraform](https://github.com/scaleway/terraform-provider-scaleway) support
* Private network
### Next features to implement
* `kops rolling-update`
* BareMetal servers
* [Terraform](https://github.com/scaleway/terraform-provider-scaleway) support
* [Autoscaler](https://github.com/kubernetes/autoscaler/tree/master/cluster-autoscaler/cloudprovider/scaleway) support
* BareMetal servers
## Requirements
@ -30,7 +29,7 @@
### Optional
* [SSH key](https://www.scaleway.com/en/docs/configure-new-ssh-key/) : creating a cluster can be done without an SSH key, but it is required to update it. `id_rsa` and `id_ed25519` keys are supported
* [Domain name](https://www.scaleway.com/en/docs/network/domains-and-dns/quickstart/) : if you want to host your cluster on your own domain, you will have to register it with Scaleway.
## Environment Variables
@ -65,6 +64,8 @@ Note that for now you can only create a kops cluster in a single availability zo
kops create cluster --cloud=scaleway --name=mycluster.k8s.local --zones=fr-par-1 --yes
# This creates a cluster with no DNS in zone nl-ams-2
kops create cluster --cloud=scaleway --name=my.cluster --zones=nl-ams-2 --yes
# This creates a cluster with the Scaleway DNS (on a domain name that you own and have registered with Scaleway) in zone pl-waw-1
kops create cluster --cloud=scaleway --name=mycluster.mydomain.com --zones=pl-waw-1 --yes
```
### Editing your cluster

View File

@ -17,6 +17,9 @@ limitations under the License.
package scaleway
import (
"strings"
domain "github.com/scaleway/scaleway-sdk-go/api/domain/v2beta1"
"k8s.io/kops/pkg/resources"
"k8s.io/kops/upup/pkg/fi"
"k8s.io/kops/upup/pkg/fi/cloudup/scaleway"
@ -27,6 +30,7 @@ import (
)
const (
resourceTypeDNSRecord = "dns-record"
resourceTypeLoadBalancer = "load-balancer"
resourceTypeServer = "server"
resourceTypeSSHKey = "ssh-key"
@ -45,6 +49,9 @@ func ListResources(cloud scaleway.ScwCloud, clusterInfo resources.ClusterInfo) (
listSSHKeys,
listVolumes,
}
if !strings.HasSuffix(clusterName, ".k8s.local") && !clusterInfo.UsesNoneDNS {
listFunctions = append(listFunctions, listDNSRecords)
}
for _, fn := range listFunctions {
rt, err := fn(cloud, clusterName)
@ -59,6 +66,30 @@ func ListResources(cloud scaleway.ScwCloud, clusterInfo resources.ClusterInfo) (
return resourceTrackers, nil
}
func listDNSRecords(cloud fi.Cloud, clusterName string) ([]*resources.Resource, error) {
c := cloud.(scaleway.ScwCloud)
records, err := c.GetClusterDNSRecords(clusterName)
if err != nil {
return nil, err
}
resourceTrackers := []*resources.Resource(nil)
for _, record := range records {
resourceTracker := &resources.Resource{
Name: record.Name,
ID: record.ID,
Type: resourceTypeDNSRecord,
Deleter: func(cloud fi.Cloud, tracker *resources.Resource) error {
return deleteDNSRecord(cloud, tracker, clusterName)
},
Obj: record,
}
resourceTrackers = append(resourceTrackers, resourceTracker)
}
return resourceTrackers, nil
}
func listLoadBalancers(cloud fi.Cloud, clusterName string) ([]*resources.Resource, error) {
c := cloud.(scaleway.ScwCloud)
lbs, err := c.GetClusterLoadBalancers(clusterName)
@ -158,6 +189,13 @@ func listVolumes(cloud fi.Cloud, clusterName string) ([]*resources.Resource, err
return resourceTrackers, nil
}
func deleteDNSRecord(cloud fi.Cloud, tracker *resources.Resource, domainName string) error {
c := cloud.(scaleway.ScwCloud)
record := tracker.Obj.(*domain.Record)
return c.DeleteDNSRecord(record, domainName)
}
func deleteLoadBalancer(cloud fi.Cloud, tracker *resources.Resource) error {
c := cloud.(scaleway.ScwCloud)
loadBalancer := tracker.Obj.(*lb.LB)

View File

@ -34,7 +34,7 @@ spec:
version: 9.99.0
- id: k8s-1.12
manifest: dns-controller.addons.k8s.io/k8s-1.12.yaml
manifestHash: 304cbbd52a3dbff42b8302023297ce34cae0da69ac0884e8cff0d9f1b5c74028
manifestHash: 4814c76684eae04bb46a11f7f2a9485aedc41200205b9216020dc7e8cdc73ded
name: dns-controller.addons.k8s.io
selector:
k8s-addon: dns-controller.addons.k8s.io

View File

@ -51,6 +51,9 @@ spec:
env:
- name: KUBERNETES_SERVICE_HOST
value: 127.0.0.1
envFrom:
- secretRef:
name: scaleway-secret
image: registry.k8s.io/kops/dns-controller:1.27.0-beta.1
name: dns-controller
resources:

View File

@ -72,6 +72,11 @@ spec:
secretKeyRef:
name: digitalocean
key: access-token
{{- end }}
{{- if eq GetCloudProvider "scaleway" }}
envFrom:
- secretRef:
name: scaleway-secret
{{- end }}
resources:
requests:

View File

@ -21,6 +21,7 @@ import (
"os"
"strings"
domain "github.com/scaleway/scaleway-sdk-go/api/domain/v2beta1"
iam "github.com/scaleway/scaleway-sdk-go/api/iam/v1alpha1"
"github.com/scaleway/scaleway-sdk-go/api/instance/v1"
"github.com/scaleway/scaleway-sdk-go/api/lb/v1"
@ -29,6 +30,7 @@ import (
"k8s.io/klog/v2"
kopsv "k8s.io/kops"
"k8s.io/kops/dnsprovider/pkg/dnsprovider"
dns "k8s.io/kops/dnsprovider/pkg/dnsprovider/providers/scaleway"
"k8s.io/kops/pkg/apis/kops"
"k8s.io/kops/pkg/cloudinstances"
"k8s.io/kops/upup/pkg/fi"
@ -54,6 +56,7 @@ type ScwCloud interface {
Region() string
Zone() string
DomainService() *domain.API
IamService() *iam.API
InstanceService() *instance.API
LBService() *lb.ZonedAPI
@ -67,11 +70,13 @@ type ScwCloud interface {
GetApiIngressStatus(cluster *kops.Cluster) ([]fi.ApiIngressStatus, error)
GetCloudGroups(cluster *kops.Cluster, instancegroups []*kops.InstanceGroup, warnUnmatched bool, nodes []v1.Node) (map[string]*cloudinstances.CloudInstanceGroup, error)
GetClusterDNSRecords(clusterName string) ([]*domain.Record, error)
GetClusterLoadBalancers(clusterName string) ([]*lb.LB, error)
GetClusterServers(clusterName string, instanceGroupName *string) ([]*instance.Server, error)
GetClusterSSHKeys(clusterName string) ([]*iam.SSHKey, error)
GetClusterVolumes(clusterName string) ([]*instance.Volume, error)
DeleteDNSRecord(record *domain.Record, clusterName string) error
DeleteLoadBalancer(loadBalancer *lb.LB) error
DeleteServer(server *instance.Server) error
DeleteSSHKey(sshkey *iam.SSHKey) error
@ -86,8 +91,10 @@ type scwCloudImplementation struct {
client *scw.Client
region scw.Region
zone scw.Zone
dns dnsprovider.Interface
tags map[string]string
domainAPI *domain.API
iamAPI *iam.API
instanceAPI *instance.API
lbAPI *lb.ZonedAPI
@ -130,7 +137,9 @@ func NewScwCloud(tags map[string]string) (ScwCloud, error) {
client: scwClient,
region: region,
zone: zone,
dns: dns.NewProvider(domain.NewAPI(scwClient)),
tags: tags,
domainAPI: domain.NewAPI(scwClient),
iamAPI: iam.NewAPI(scwClient),
instanceAPI: instance.NewAPI(scwClient),
lbAPI: lb.NewZonedAPI(scwClient),
@ -147,8 +156,11 @@ func (s *scwCloudImplementation) ClusterName(tags []string) string {
}
func (s *scwCloudImplementation) DNS() (dnsprovider.Interface, error) {
klog.V(8).Infof("Scaleway DNS is not implemented yet")
return nil, fmt.Errorf("DNS is not implemented yet for Scaleway")
provider, err := dnsprovider.GetDnsProvider(dns.ProviderName, nil)
if err != nil {
return nil, fmt.Errorf("error building DNS provider: %w", err)
}
return provider, nil
}
func (s *scwCloudImplementation) ProviderID() kops.CloudProviderID {
@ -163,6 +175,10 @@ func (s *scwCloudImplementation) Zone() string {
return string(s.zone)
}
func (s *scwCloudImplementation) DomainService() *domain.API {
return s.domainAPI
}
func (s *scwCloudImplementation) IamService() *iam.API {
return s.iamAPI
}
@ -373,6 +389,27 @@ func buildCloudGroup(ig *kops.InstanceGroup, sg []*instance.Server, nodeMap map[
return cloudInstanceGroup, nil
}
func (s *scwCloudImplementation) GetClusterDNSRecords(clusterName string) ([]*domain.Record, error) {
names := strings.SplitN(clusterName, ".", 2)
clusterNameShort := names[0]
domainName := names[1]
records, err := s.domainAPI.ListDNSZoneRecords(&domain.ListDNSZoneRecordsRequest{
DNSZone: domainName,
}, scw.WithAllPages())
if err != nil {
return nil, fmt.Errorf("listing cluster DNS records: %w", err)
}
clusterDNSRecords := []*domain.Record(nil)
for _, record := range records.Records {
if strings.HasSuffix(record.Name, clusterNameShort) {
clusterDNSRecords = append(clusterDNSRecords, record)
}
}
return clusterDNSRecords, nil
}
func (s *scwCloudImplementation) GetClusterLoadBalancers(clusterName string) ([]*lb.LB, error) {
loadBalancerName := "api." + clusterName
lbs, err := s.lbAPI.ListLBs(&lb.ZonedAPIListLBsRequest{
@ -430,6 +467,29 @@ func (s *scwCloudImplementation) GetClusterVolumes(clusterName string) ([]*insta
return volumes.Volumes, nil
}
func (s *scwCloudImplementation) DeleteDNSRecord(record *domain.Record, clusterName string) error {
domainName := strings.SplitN(clusterName, ".", 2)[1]
recordDeleteRequest := &domain.UpdateDNSZoneRecordsRequest{
DNSZone: domainName,
Changes: []*domain.RecordChange{
{
Delete: &domain.RecordChangeDelete{
ID: scw.StringPtr(record.ID),
},
},
},
}
_, err := s.domainAPI.UpdateDNSZoneRecords(recordDeleteRequest)
if err != nil {
if is404Error(err) {
klog.V(8).Infof("DNS record %q (%s) was already deleted", record.Name, record.ID)
return nil
}
return fmt.Errorf("failed to delete record %s: %w", record.Name, err)
}
return nil
}
func (s *scwCloudImplementation) DeleteLoadBalancer(loadBalancer *lb.LB) error {
ipsToRelease := loadBalancer.IP

View File

@ -613,6 +613,8 @@ func (tf *TemplateFunctions) DNSControllerArgv() ([]string, error) {
argv = append(argv, "--dns=digitalocean")
case kops.CloudProviderOpenstack:
argv = append(argv, "--dns=openstack-designate")
case kops.CloudProviderScaleway:
argv = append(argv, "--dns=scaleway")
default:
return nil, fmt.Errorf("unhandled cloudprovider %q", cluster.Spec.GetCloudProvider())

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,81 @@
package domain
import (
"fmt"
"time"
"github.com/scaleway/scaleway-sdk-go/internal/async"
"github.com/scaleway/scaleway-sdk-go/internal/errors"
"github.com/scaleway/scaleway-sdk-go/scw"
)
const (
defaultRetryInterval = 15 * time.Second
defaultTimeout = 5 * time.Minute
)
const (
// ErrCodeNoSuchDNSZone for service response error code
//
// The specified dns zone does not exist.
ErrCodeNoSuchDNSZone = "NoSuchDNSZone"
)
// WaitForDNSZoneRequest is used by WaitForDNSZone method.
type WaitForDNSZoneRequest struct {
DNSZone string
Timeout *time.Duration
RetryInterval *time.Duration
}
func (s *API) WaitForDNSZone(
req *WaitForDNSZoneRequest,
opts ...scw.RequestOption,
) (*DNSZone, error) {
timeout := defaultTimeout
if req.Timeout != nil {
timeout = *req.Timeout
}
retryInterval := defaultRetryInterval
if req.RetryInterval != nil {
retryInterval = *req.RetryInterval
}
terminalStatus := map[DNSZoneStatus]struct{}{
DNSZoneStatusActive: {},
DNSZoneStatusLocked: {},
DNSZoneStatusError: {},
}
dns, err := async.WaitSync(&async.WaitSyncConfig{
Get: func() (interface{}, bool, error) {
// listing dns zones and take the first one
DNSZones, err := s.ListDNSZones(&ListDNSZonesRequest{
DNSZone: req.DNSZone,
}, opts...)
if err != nil {
return nil, false, err
}
if len(DNSZones.DNSZones) == 0 {
return nil, true, fmt.Errorf(ErrCodeNoSuchDNSZone)
}
Dns := DNSZones.DNSZones[0]
_, isTerminal := terminalStatus[Dns.Status]
return Dns, isTerminal, nil
},
Timeout: timeout,
IntervalStrategy: async.LinearIntervalStrategy(retryInterval),
})
if err != nil {
return nil, errors.Wrap(err, "waiting for DNS failed")
}
return dns.(*DNSZone), nil
}

1
vendor/modules.txt generated vendored
View File

@ -767,6 +767,7 @@ github.com/russross/blackfriday/v2
github.com/sahilm/fuzzy
# github.com/scaleway/scaleway-sdk-go v1.0.0-beta.17
## explicit; go 1.17
github.com/scaleway/scaleway-sdk-go/api/domain/v2beta1
github.com/scaleway/scaleway-sdk-go/api/iam/v1alpha1
github.com/scaleway/scaleway-sdk-go/api/instance/v1
github.com/scaleway/scaleway-sdk-go/api/lb/v1