Unverified Commit 8382e2a0 authored by Nick Jüttner's avatar Nick Jüttner Committed by GitHub
Browse files

Merge pull request #626 from prydie/ap/oci-support

Oracle Cloud Infrastructure (OCI) DNS provider
parents 795b567f 3c9a944f
Showing with 1334 additions and 8 deletions
+1334 -8
......@@ -149,7 +149,8 @@
[[projects]]
name = "github.com/davecgh/go-spew"
packages = ["spew"]
revision = "5215b55f46b2b919f50a1df0eaa5886afe4e3b3d"
revision = "346938d642f2ec3594ed81d874461961cd0faa76"
version = "v1.1.0"
[[projects]]
name = "github.com/dgrijalva/jwt-go"
......@@ -334,6 +335,15 @@
revision = "cdd946344b54bdf7dbeac406c2f1fe93150f08ea"
version = "v0.6.0"
[[projects]]
name = "github.com/oracle/oci-go-sdk"
packages = [
"common",
"dns"
]
revision = "a2ded717dc4bb4916c0416ec79f81718b576dbc4"
version = "v1.8.0"
[[projects]]
name = "github.com/pkg/errors"
packages = ["."]
......@@ -342,7 +352,8 @@
[[projects]]
name = "github.com/pmezard/go-difflib"
packages = ["difflib"]
revision = "d8ed2627bdf02c080bf22230dbb337003b7aba2d"
revision = "792786c7400a136282c1664665ae0a8db921c6c2"
version = "v1.0.0"
[[projects]]
name = "github.com/prometheus/client_golang"
......@@ -391,7 +402,8 @@
[[projects]]
name = "github.com/stretchr/objx"
packages = ["."]
revision = "cbeaeb16a013161a98496fad62933b1d21786672"
revision = "facf9a85c22f48d2f52f2380e4efce1768749a89"
version = "v0.1"
[[projects]]
name = "github.com/stretchr/testify"
......@@ -401,8 +413,8 @@
"require",
"suite"
]
revision = "69483b4bd14f5845b5a1e55bca19e954e827f1d0"
version = "v1.1.4"
revision = "12b6f73e6084dad08a7c6e575284b177ecafbc71"
version = "v1.2.1"
[[projects]]
branch = "master"
......@@ -667,6 +679,6 @@
[solve-meta]
analyzer-name = "dep"
analyzer-version = 1
inputs-digest = "84dd4d46f1682b174b47c24afd13104daa26c16da187d07513cb9a58bb3f4820"
inputs-digest = "66dc2d3612a3cea92d6533aef837db593aa9b49b2eeffe724d7211ceba87294b"
solver-name = "gps-cdcl"
solver-version = 1
......@@ -54,7 +54,7 @@ ignored = ["github.com/kubernetes/repo-infra/kazel"]
[[constraint]]
name = "github.com/stretchr/testify"
version = "~1.1.4"
version = "~1.2.1"
[[constraint]]
name = "k8s.io/client-go"
......@@ -67,3 +67,7 @@ ignored = ["github.com/kubernetes/repo-infra/kazel"]
[[constraint]]
name = "github.com/nesv/go-dynect"
version = "0.6.0"
[[constraint]]
name = "github.com/oracle/oci-go-sdk"
version = "1.8.0"
......@@ -36,6 +36,7 @@ ExternalDNS' current release is `v0.5`. This version allows you to keep selected
* [OpenStack Designate](https://docs.openstack.org/designate/latest/)
* [PowerDNS](https://www.powerdns.com/)
* [CoreDNS](https://coredns.io/)
* [Oracle Cloud Infrastructure DNS](https://docs.cloud.oracle.com/iaas/Content/DNS/Concepts/dnszonemanagement.htm)
From this release, ExternalDNS can become aware of the records it is managing (enabled via `--registry=txt`), therefore ExternalDNS can safely manage non-empty hosted zones. We strongly encourage you to use `v0.5` (or greater) with `--registry=txt` enabled and `--txt-owner-id` set to a unique value that doesn't change for the lifetime of your cluster. You might also want to run ExternalDNS in a dry run mode (`--dry-run` flag) to see the changes to be submitted to your DNS Provider API.
......@@ -57,6 +58,7 @@ The following tutorials are provided:
* Google Container Engine
* [Using Google's Default Ingress Controller](docs/tutorials/gke.md)
* [Using the Nginx Ingress Controller](docs/tutorials/nginx-ingress.md)
* [Oracle Cloud Infrastructure (OCI) DNS](docs/tutorials/oracle.md)
## Running Locally
......
# Setting up ExternalDNS for Oracle Cloud Infrastructure (OCI)
This tutorial describes how to setup ExternalDNS for usage within a Kubernetes cluster using OCI DNS.
Make sure to use the latest version of ExternalDNS for this tutorial.
## Creating an OCI DNS Zone
Create a DNS zone which will contain the managed DNS records. Let's use `example.com` as an reference here.
For more information about OCI DNS see the documentation [here][1].
## Deploy ExternalDNS
Connect your `kubectl` client to the cluster you want to test ExternalDNS with.
We first need to create a config file containing the information needed to connect with the OCI API.
Create a new file (oci.yaml) and modify the contents to match the example below. Be sure to adjust the values to match your own credentials:
```yaml
auth:
region: us-phoenix-1
tenancy: ocid1.tenancy.oc1...
user: ocid1.user.oc1...
-----BEGIN RSA PRIVATE KEY-----
-----END RSA PRIVATE KEY-----
fingerprint: af:81:71:8e...
compartment: ocid1.compartment.oc1...
```
Create a secret using the config file above:
```shell
$ kubectl create secret generic external-dns-config --from-file=oci.yaml
```
### Manifest (for clusters with RBAC enabled)
Apply the following manifest to deploy ExternalDNS.
```yaml
apiVersion: v1
kind: ServiceAccount
metadata:
name: external-dns
---
apiVersion: rbac.authorization.k8s.io/v1beta1
kind: ClusterRole
metadata:
name: external-dns
rules:
- apiGroups: [""]
resources: ["services"]
verbs: ["get","watch","list"]
- apiGroups: [""]
resources: ["pods"]
verbs: ["get","watch","list"]
- apiGroups: ["extensions"]
resources: ["ingresses"]
verbs: ["get","watch","list"]
- apiGroups: [""]
resources: ["nodes"]
verbs: ["list"]
---
apiVersion: rbac.authorization.k8s.io/v1beta1
kind: ClusterRoleBinding
metadata:
name: external-dns-viewer
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: external-dns
subjects:
- kind: ServiceAccount
name: external-dns
namespace: default
---
apiVersion: extensions/v1beta1
kind: Deployment
metadata:
name: external-dns
spec:
strategy:
type: Recreate
template:
metadata:
labels:
app: external-dns
spec:
serviceAccountName: external-dns
containers:
- name: external-dns
image: registry.opensource.zalan.do/teapot/external-dns:latest
args:
- --source=service
- --source=ingress
- --provider=oci
- --policy=upsert-only # prevent ExternalDNSfrom deleting any records, omit to enable full synchronization
- --txt-owner-id=my-identifier
volumeMounts:
- name: config
mountPath: /etc/kubernetes/
volumes:
- name: config
secret:
secretName: external-dns-config
```
## Verify ExternalDNS works (Service example)
Create the following sample application to test that ExternalDNS works.
> For services ExternalDNS will look for the annotation `external-dns.alpha.kubernetes.io/hostname` on the service and use the corresponding value.
```yaml
apiVersion: v1
kind: Service
metadata:
name: nginx
annotations:
external-dns.alpha.kubernetes.io/hostname: example.com
spec:
type: LoadBalancer
ports:
- port: 80
name: http
targetPort: 80
selector:
app: nginx
---
apiVersion: extensions/v1beta1
kind: Deployment
metadata:
name: nginx
spec:
template:
metadata:
labels:
app: nginx
spec:
containers:
- image: nginx
name: nginx
ports:
- containerPort: 80
name: http
```
Apply the manifest above and wait roughly two minutes and check that a corresponding DNS record for your service was created.
```
$ kubectl apply -f nginx.yaml
```
[1]: https://docs.cloud.oracle.com/iaas/Content/DNS/Concepts/dnszonemanagement.htm
......@@ -170,6 +170,12 @@ func main() {
},
},
)
case "oci":
var config *provider.OCIConfig
config, err = provider.LoadOCIConfig(cfg.OCIConfigFile)
if err == nil {
p, err = provider.NewOCIProvider(*config, domainFilter, zoneIDFilter, cfg.DryRun)
}
default:
log.Fatalf("unknown dns provider: %s", cfg.Provider)
}
......
......@@ -67,6 +67,7 @@ type Config struct {
DynUsername string
DynPassword string
DynMinTTLSeconds int
OCIConfigFile string
InMemoryZones []string
PDNSServer string
PDNSAPIKey string
......@@ -114,6 +115,7 @@ var defaultConfig = &Config{
InfobloxWapiPassword: "",
InfobloxWapiVersion: "2.3.1",
InfobloxSSLVerify: true,
OCIConfigFile: "/etc/kubernetes/oci.yaml",
InMemoryZones: []string{},
PDNSServer: "http://localhost:8081",
PDNSAPIKey: "",
......@@ -185,7 +187,7 @@ func (cfg *Config) ParseFlags(args []string) error {
app.Flag("connector-source-server", "The server to connect for connector source, valid only when using connector source").Default(defaultConfig.ConnectorSourceServer).StringVar(&cfg.ConnectorSourceServer)
// Flags related to providers
app.Flag("provider", "The DNS provider where the DNS records will be created (required, options: aws, aws-sd, google, azure, cloudflare, digitalocean, dnsimple, infoblox, dyn, designate, coredns, skydns, inmemory, pdns)").Required().PlaceHolder("provider").EnumVar(&cfg.Provider, "aws", "aws-sd", "google", "azure", "cloudflare", "digitalocean", "dnsimple", "infoblox", "dyn", "designate", "coredns", "skydns", "inmemory", "pdns")
app.Flag("provider", "The DNS provider where the DNS records will be created (required, options: aws, aws-sd, google, azure, cloudflare, digitalocean, dnsimple, infoblox, dyn, designate, coredns, skydns, inmemory, pdns, oci)").Required().PlaceHolder("provider").EnumVar(&cfg.Provider, "aws", "aws-sd", "google", "azure", "cloudflare", "digitalocean", "dnsimple", "infoblox", "dyn", "designate", "coredns", "skydns", "inmemory", "pdns", "oci")
app.Flag("domain-filter", "Limit possible target zones by a domain suffix; specify multiple times for multiple domains (optional)").Default("").StringsVar(&cfg.DomainFilter)
app.Flag("zone-id-filter", "Filter target zones by hosted zone id; specify multiple times for multiple zones (optional)").Default("").StringsVar(&cfg.ZoneIDFilter)
app.Flag("google-project", "When using the Google provider, current project is auto-detected, when running on GCP. Specify other project with this. Must be specified when running outside GCP.").Default(defaultConfig.GoogleProject).StringVar(&cfg.GoogleProject)
......@@ -206,6 +208,7 @@ func (cfg *Config) ParseFlags(args []string) error {
app.Flag("dyn-username", "When using the Dyn provider, specify the Username").Default("").StringVar(&cfg.DynUsername)
app.Flag("dyn-password", "When using the Dyn provider, specify the pasword").Default("").StringVar(&cfg.DynPassword)
app.Flag("dyn-min-ttl", "Minimal TTL (in seconds) for records. This value will be used if the provided TTL for a service/ingress is lower than this.").IntVar(&cfg.DynMinTTLSeconds)
app.Flag("oci-config-file", "When using the OCI provider, specify the OCI configuration file (required when --provider=oci").Default(defaultConfig.OCIConfigFile).StringVar(&cfg.OCIConfigFile)
app.Flag("inmemory-zone", "Provide a list of pre-configured zones for the inmemory provider; specify multiple times for multiple zones (optional)").Default("").StringsVar(&cfg.InMemoryZones)
app.Flag("pdns-server", "When using the PowerDNS/PDNS provider, specify the URL to the pdns server (required when --provider=pdns)").Default(defaultConfig.PDNSServer).StringVar(&cfg.PDNSServer)
......
......@@ -52,6 +52,7 @@ var (
InfobloxWapiPassword: "",
InfobloxWapiVersion: "2.3.1",
InfobloxSSLVerify: true,
OCIConfigFile: "/etc/kubernetes/oci.yaml",
InMemoryZones: []string{""},
PDNSServer: "http://localhost:8081",
PDNSAPIKey: "",
......@@ -93,6 +94,7 @@ var (
InfobloxWapiPassword: "infoblox",
InfobloxWapiVersion: "2.6.1",
InfobloxSSLVerify: false,
OCIConfigFile: "oci.yaml",
InMemoryZones: []string{"example.org", "company.com"},
PDNSServer: "http://ns.example.com:8081",
PDNSAPIKey: "some-secret-key",
......@@ -157,6 +159,7 @@ func TestParseFlags(t *testing.T) {
"--pdns-server=http://ns.example.com:8081",
"--pdns-api-key=some-secret-key",
"--pdns-tls-enabled",
"--oci-config-file=oci.yaml",
"--tls-ca=/path/to/ca.crt",
"--tls-client-cert=/path/to/cert.pem",
"--tls-client-cert-key=/path/to/key.pem",
......@@ -206,6 +209,7 @@ func TestParseFlags(t *testing.T) {
"EXTERNAL_DNS_INFOBLOX_WAPI_PASSWORD": "infoblox",
"EXTERNAL_DNS_INFOBLOX_WAPI_VERSION": "2.6.1",
"EXTERNAL_DNS_INFOBLOX_SSL_VERIFY": "0",
"EXTERNAL_DNS_OCI_CONFIG_FILE": "oci.yaml",
"EXTERNAL_DNS_INMEMORY_ZONE": "example.org\ncompany.com",
"EXTERNAL_DNS_DOMAIN_FILTER": "example.org\ncompany.com",
"EXTERNAL_DNS_PDNS_SERVER": "http://ns.example.com:8081",
......
/*
Copyright 2018 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 provider
import (
"context"
"io/ioutil"
"strings"
"github.com/oracle/oci-go-sdk/common"
"github.com/oracle/oci-go-sdk/dns"
"github.com/pkg/errors"
log "github.com/sirupsen/logrus"
yaml "gopkg.in/yaml.v2"
"github.com/kubernetes-incubator/external-dns/endpoint"
"github.com/kubernetes-incubator/external-dns/plan"
)
const ociRecordTTL = 300
// OCIAuthConfig holds connection parameters for the OCI API.
type OCIAuthConfig struct {
Region string `yaml:"region"`
TenancyID string `yaml:"tenancy"`
UserID string `yaml:"user"`
PrivateKey string `yaml:"key"`
Fingerprint string `yaml:"fingerprint"`
Passphrase string `yaml:"passphrase"`
}
// OCIConfig holds the configuration for the OCI Provider.
type OCIConfig struct {
Auth OCIAuthConfig `yaml:"auth"`
CompartmentID string `yaml:"compartment"`
}
// OCIProvider is an implementation of Provider for Oracle Cloud Infrastructure
// (OCI) DNS.
type OCIProvider struct {
client ociDNSClient
cfg OCIConfig
domainFilter DomainFilter
zoneIDFilter ZoneIDFilter
dryRun bool
}
// ociDNSClient is the subset of the OCI DNS API required by the OCI Provider.
type ociDNSClient interface {
ListZones(ctx context.Context, request dns.ListZonesRequest) (response dns.ListZonesResponse, err error)
GetZoneRecords(ctx context.Context, request dns.GetZoneRecordsRequest) (response dns.GetZoneRecordsResponse, err error)
PatchZoneRecords(ctx context.Context, request dns.PatchZoneRecordsRequest) (response dns.PatchZoneRecordsResponse, err error)
}
// LoadOCIConfig reads and parses the OCI ExternalDNS config file at the given
// path.
func LoadOCIConfig(path string) (*OCIConfig, error) {
contents, err := ioutil.ReadFile(path)
if err != nil {
return nil, errors.Wrapf(err, "reading OCI config file %q", path)
}
cfg := OCIConfig{}
if err := yaml.Unmarshal(contents, &cfg); err != nil {
return nil, errors.Wrapf(err, "parsing OCI config file %q", path)
}
return &cfg, nil
}
// NewOCIProvider initialises a new OCI DNS based Provider.
func NewOCIProvider(cfg OCIConfig, domainFilter DomainFilter, zoneIDFilter ZoneIDFilter, dryRun bool) (*OCIProvider, error) {
var client ociDNSClient
client, err := dns.NewDnsClientWithConfigurationProvider(common.NewRawConfigurationProvider(
cfg.Auth.TenancyID,
cfg.Auth.UserID,
cfg.Auth.Region,
cfg.Auth.Fingerprint,
cfg.Auth.PrivateKey,
&cfg.Auth.Passphrase,
))
if err != nil {
return nil, errors.Wrap(err, "initialising OCI DNS API client")
}
return &OCIProvider{
client: client,
cfg: cfg,
domainFilter: domainFilter,
zoneIDFilter: zoneIDFilter,
dryRun: dryRun,
}, nil
}
func (p *OCIProvider) zones(ctx context.Context) (map[string]*dns.ZoneSummary, error) {
zones := make(map[string]*dns.ZoneSummary)
log.Debugf("Matching zones against domain filters: %v", p.domainFilter.filters)
var page *string
for {
resp, err := p.client.ListZones(ctx, dns.ListZonesRequest{
CompartmentId: &p.cfg.CompartmentID,
ZoneType: dns.ListZonesZoneTypePrimary,
Page: page,
})
if err != nil {
return nil, errors.Wrapf(err, "listing zones in %q", p.cfg.CompartmentID)
}
for _, zone := range resp.Items {
if p.domainFilter.Match(*zone.Name) && p.zoneIDFilter.Match(*zone.Id) {
zones[*zone.Name] = &zone
log.Debugf("Matched %q (%q)", *zone.Name, *zone.Id)
} else {
log.Debugf("Filtered %q (%q)", *zone.Name, *zone.Id)
}
}
if page = resp.OpcNextPage; resp.OpcNextPage == nil {
break
}
}
if len(zones) == 0 {
if p.domainFilter.IsConfigured() {
log.Warnf("No zones in compartment %q match domain filters %v", p.cfg.CompartmentID, p.domainFilter.filters)
} else {
log.Warnf("No zones found in compartment %q", p.cfg.CompartmentID)
}
}
return zones, nil
}
func (p *OCIProvider) newFilteredRecordOperations(endpoints []*endpoint.Endpoint, opType dns.RecordOperationOperationEnum) []dns.RecordOperation {
ops := []dns.RecordOperation{}
for _, endpoint := range endpoints {
if p.domainFilter.Match(endpoint.DNSName) {
ops = append(ops, newRecordOperation(endpoint, opType))
}
}
return ops
}
// Records returns the list of records in a given hosted zone.
func (p *OCIProvider) Records() ([]*endpoint.Endpoint, error) {
ctx := context.Background()
zones, err := p.zones(ctx)
if err != nil {
return nil, errors.Wrap(err, "getting zones")
}
endpoints := []*endpoint.Endpoint{}
for _, zone := range zones {
var page *string
for {
resp, err := p.client.GetZoneRecords(ctx, dns.GetZoneRecordsRequest{
ZoneNameOrId: zone.Id,
Page: page,
CompartmentId: &p.cfg.CompartmentID,
})
if err != nil {
return nil, errors.Wrapf(err, "getting records for zone %q", *zone.Id)
}
for _, record := range resp.Items {
if !supportedRecordType(*record.Rtype) {
continue
}
endpoints = append(endpoints,
endpoint.NewEndpointWithTTL(
*record.Domain,
*record.Rtype,
endpoint.TTL(*record.Ttl),
*record.Rdata,
),
)
}
if page = resp.OpcNextPage; resp.OpcNextPage == nil {
break
}
}
}
return endpoints, nil
}
// ApplyChanges applies a given set of changes to a given zone.
func (p *OCIProvider) ApplyChanges(changes *plan.Changes) error {
log.Debugf("Processing chages: %+v", changes)
ops := []dns.RecordOperation{}
ops = append(ops, p.newFilteredRecordOperations(changes.Create, dns.RecordOperationOperationAdd)...)
ops = append(ops, p.newFilteredRecordOperations(changes.UpdateNew, dns.RecordOperationOperationAdd)...)
ops = append(ops, p.newFilteredRecordOperations(changes.UpdateOld, dns.RecordOperationOperationRemove)...)
ops = append(ops, p.newFilteredRecordOperations(changes.Delete, dns.RecordOperationOperationRemove)...)
if len(ops) == 0 {
log.Info("All records are already up to date")
return nil
}
ctx := context.Background()
zones, err := p.zones(ctx)
if err != nil {
return errors.Wrap(err, "fetching zones")
}
// Separate into per-zone change sets to be passed to OCI API.
opsByZone := operationsByZone(zones, ops)
for zoneID, ops := range opsByZone {
log.Infof("Change zone: %q", zoneID)
for _, op := range ops {
log.Info(op)
}
}
if p.dryRun {
return nil
}
for zoneID, ops := range opsByZone {
if _, err := p.client.PatchZoneRecords(ctx, dns.PatchZoneRecordsRequest{
CompartmentId: &p.cfg.CompartmentID,
ZoneNameOrId: &zoneID,
PatchZoneRecordsDetails: dns.PatchZoneRecordsDetails{Items: ops},
}); err != nil {
return err
}
}
return nil
}
// newRecordOperation returns a RecordOperation based on a given endpoint.
func newRecordOperation(ep *endpoint.Endpoint, opType dns.RecordOperationOperationEnum) dns.RecordOperation {
targets := make([]string, len(ep.Targets))
copy(targets, []string(ep.Targets))
if ep.RecordType == endpoint.RecordTypeCNAME {
targets[0] = ensureTrailingDot(targets[0])
}
rdata := strings.Join(targets, " ")
ttl := ociRecordTTL
if ep.RecordTTL.IsConfigured() {
ttl = int(ep.RecordTTL)
}
return dns.RecordOperation{
Domain: &ep.DNSName,
Rdata: &rdata,
Ttl: &ttl,
Rtype: &ep.RecordType,
Operation: opType,
}
}
// operationsByZone segments a slice of RecordOperations by their zone.
func operationsByZone(zones map[string]*dns.ZoneSummary, ops []dns.RecordOperation) map[string][]dns.RecordOperation {
changes := make(map[string][]dns.RecordOperation)
zoneNameIDMapper := zoneIDName{}
for _, z := range zones {
zoneNameIDMapper.Add(*z.Id, *z.Name)
changes[*z.Id] = []dns.RecordOperation{}
}
for _, op := range ops {
if zoneID, _ := zoneNameIDMapper.FindZone(*op.Domain); zoneID != "" {
changes[zoneID] = append(changes[zoneID], op)
} else {
log.Warnf("No matching zone for record operation %s", op)
}
}
// Remove zones that don't have have any changes.
for zone, ops := range changes {
if len(ops) == 0 {
delete(changes, zone)
}
}
return changes
}
This diff is collapsed.
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment