Kaynağa Gözat

Merge pull request #1287 from porter-dev/belanger/por-87-bind-integration

[POR-87] Support creating domains via PowerDNS server
abelanger5 4 yıl önce
ebeveyn
işleme
30eee83faa

+ 1 - 1
api/server/handlers/release/create_subdomain.go

@@ -67,7 +67,7 @@ func (c *CreateSubdomainHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 
 	_record := domain.DNSRecord(*record)
 
-	err = _record.CreateDomain(c.Config().IngressAgent.Clientset)
+	err = _record.CreateDomain(c.Config().PowerDNSClient)
 
 	if err != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))

+ 4 - 4
api/server/shared/config/config.go

@@ -8,6 +8,7 @@ import (
 	"github.com/porter-dev/porter/internal/analytics"
 	"github.com/porter-dev/porter/internal/auth/token"
 	"github.com/porter-dev/porter/internal/helm/urlcache"
+	"github.com/porter-dev/porter/internal/integrations/powerdns"
 	"github.com/porter-dev/porter/internal/kubernetes"
 	"github.com/porter-dev/porter/internal/logger"
 	"github.com/porter-dev/porter/internal/notifier"
@@ -76,15 +77,14 @@ type Config struct {
 	// jobs
 	ProvisionerAgent *kubernetes.Agent
 
-	// IngressAgent is the kubernetes client responsible for creating new ingress
-	// resources
-	IngressAgent *kubernetes.Agent
-
 	// DB is the gorm DB instance
 	DB *gorm.DB
 
 	// AnalyticsClient if Segment analytics reporting is enabled on the API instance
 	AnalyticsClient analytics.AnalyticsSegmentClient
+
+	// PowerDNSClient is a client for PowerDNS, if the Porter instance supports vanity URLs
+	PowerDNSClient *powerdns.Client
 }
 
 type ConfigLoader interface {

+ 4 - 0
api/server/shared/config/env/envconfs.go

@@ -55,6 +55,10 @@ type ServerConf struct {
 	ProvisionerImagePullSecret string `env:"PROV_IMAGE_PULL_SECRET"`
 	SegmentClientKey           string `env:"SEGMENT_CLIENT_KEY"`
 
+	// PowerDNS client API key and the host of the PowerDNS API server
+	PowerDNSAPIServerURL string `env:"POWER_DNS_API_SERVER_URL"`
+	PowerDNSAPIKey       string `env:"POWER_DNS_API_KEY"`
+
 	// Email for an admin user. On a self-hosted instance of Porter, the
 	// admin user is the only user that can log in and register. After the admin
 	// user has logged in, registration is turned off.

+ 4 - 25
api/server/shared/config/loader/loader.go

@@ -15,6 +15,7 @@ import (
 	"github.com/porter-dev/porter/internal/auth/sessionstore"
 	"github.com/porter-dev/porter/internal/auth/token"
 	"github.com/porter-dev/porter/internal/helm/urlcache"
+	"github.com/porter-dev/porter/internal/integrations/powerdns"
 	"github.com/porter-dev/porter/internal/kubernetes"
 	"github.com/porter-dev/porter/internal/kubernetes/local"
 	"github.com/porter-dev/porter/internal/notifier"
@@ -190,16 +191,12 @@ func (e *EnvConfigLoader) LoadConfig() (res *config.Config, err error) {
 		res.Metadata.Provisioning = true
 	}
 
-	ingressAgent, err := getIngressAgent(sc)
+	res.AnalyticsClient = analytics.InitializeAnalyticsSegmentClient(sc.SegmentClientKey, res.Logger)
 
-	if err != nil {
-		return nil, err
+	if sc.PowerDNSAPIKey != "" && sc.PowerDNSAPIServerURL != "" {
+		res.PowerDNSClient = powerdns.NewClient(sc.PowerDNSAPIServerURL, sc.PowerDNSAPIKey, sc.AppRootDomain)
 	}
 
-	res.IngressAgent = ingressAgent
-
-	res.AnalyticsClient = analytics.InitializeAnalyticsSegmentClient(sc.SegmentClientKey, res.Logger)
-
 	return res, nil
 }
 
@@ -220,21 +217,3 @@ func getProvisionerAgent(sc *env.ServerConf) (*kubernetes.Agent, error) {
 
 	return agent, nil
 }
-
-func getIngressAgent(sc *env.ServerConf) (*kubernetes.Agent, error) {
-	if sc.IngressCluster == "kubeconfig" && sc.SelfKubeconfig != "" {
-		agent, err := local.GetSelfAgentFromFileConfig(sc.SelfKubeconfig)
-
-		if err != nil {
-			return nil, fmt.Errorf("could not get in-cluster agent: %v", err)
-		}
-
-		return agent, nil
-	} else if sc.ProvisionerCluster == "kubeconfig" {
-		return nil, fmt.Errorf(`"kubeconfig" cluster option requires path to kubeconfig`)
-	}
-
-	agent, _ := kubernetes.GetAgentInClusterConfig()
-
-	return agent, nil
-}

+ 153 - 0
internal/integrations/powerdns/powerdns.go

@@ -0,0 +1,153 @@
+package powerdns
+
+import (
+	"encoding/json"
+	"fmt"
+	"io/ioutil"
+	"net/http"
+	"net/url"
+	"strings"
+	"time"
+)
+
+// Client contains an API client for a PowerDNS server
+type Client struct {
+	apiKey    string
+	serverURL string
+	runDomain string
+
+	httpClient *http.Client
+}
+
+// NewClient creates a new bind API client
+func NewClient(serverURL, apiKey, runDomain string) *Client {
+	httpClient := &http.Client{
+		Timeout: time.Minute,
+	}
+
+	return &Client{apiKey, serverURL, runDomain, httpClient}
+}
+
+// RecordData represents the data required to create or delete an A/CNAME record
+// for the nameserver
+type RecordData struct {
+	RRSets []RR `json:"rrsets"`
+}
+
+type RR struct {
+	Name       string   `json:"name"`
+	Type       string   `json:"type"`
+	ChangeType string   `json:"changetype"`
+	TTL        uint     `json:"ttl"`
+	Records    []Record `json:"records"`
+}
+
+type Record struct {
+	Content  string `json:"content"`
+	Disabled bool   `json:"disabled"`
+	Name     string `json:"name"`
+	Type     string `json:"type"`
+	Priority uint   `json:"priority"`
+}
+
+// CreateCNAMERecord creates a new CNAME record for the nameserver
+func (c *Client) CreateCNAMERecord(value, hostname string) error {
+	valueC := canonicalize(value)
+	hostnameC := canonicalize(hostname)
+
+	return c.sendRequest("PATCH", &RecordData{
+		RRSets: []RR{{
+			Name:       hostnameC,
+			Type:       "CNAME",
+			ChangeType: "REPLACE",
+			TTL:        300,
+			Records: []Record{{
+				Content:  valueC,
+				Disabled: false,
+				Name:     hostnameC,
+				Type:     "CNAME",
+				Priority: 0,
+			}},
+		}},
+	})
+}
+
+// CreateARecord creates a new A record for the nameserver
+func (c *Client) CreateARecord(value, hostname string) error {
+	hostnameC := canonicalize(hostname)
+
+	return c.sendRequest("PATCH", &RecordData{
+		RRSets: []RR{{
+			Name:       hostnameC,
+			Type:       "A",
+			ChangeType: "REPLACE",
+			TTL:        300,
+			Records: []Record{{
+				Content:  value,
+				Disabled: false,
+				Name:     hostnameC,
+				Type:     "A",
+				Priority: 0,
+			}},
+		}},
+	})
+}
+
+func canonicalize(value string) string {
+	// if the string ends in a period, return
+	if value[len(value)-1:] == "." {
+		return value
+	}
+
+	return fmt.Sprintf("%s.", value)
+}
+
+func (c *Client) sendRequest(method string, data *RecordData) error {
+	reqURL, err := url.Parse(c.serverURL)
+
+	if err != nil {
+		return nil
+	}
+
+	reqURL.Path = fmt.Sprintf("/api/v1/servers/localhost/zones/%s", c.runDomain)
+
+	strData, err := json.Marshal(data)
+
+	if err != nil {
+		return err
+	}
+
+	req, err := http.NewRequest(
+		method,
+		reqURL.String(),
+		strings.NewReader(string(strData)),
+	)
+
+	if err != nil {
+		return err
+	}
+
+	req.Header.Set("Content-Type", "application/json; charset=utf-8")
+	req.Header.Set("Accept", "application/json; charset=utf-8")
+	req.Header.Set("X-Api-Key", c.apiKey)
+
+	res, err := c.httpClient.Do(req)
+
+	if err != nil {
+		return err
+	}
+
+	defer res.Body.Close()
+
+	if res.StatusCode < http.StatusOK || res.StatusCode >= http.StatusBadRequest {
+		resBytes, err := ioutil.ReadAll(res.Body)
+
+		if err != nil {
+			return fmt.Errorf("request failed with status code %d, but could not read body (%s)\n", res.StatusCode, err.Error())
+		}
+
+		return fmt.Errorf("request failed with status code %d: %s\n", res.StatusCode, string(resBytes))
+	}
+
+	return nil
+}

+ 6 - 140
internal/kubernetes/domain/domain.go

@@ -6,14 +6,13 @@ import (
 	"net"
 	"strings"
 
+	"github.com/porter-dev/porter/internal/integrations/powerdns"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/repository"
 	v1 "k8s.io/api/core/v1"
-	"k8s.io/api/extensions/v1beta1"
 	"k8s.io/client-go/kubernetes"
 
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
-	"k8s.io/apimachinery/pkg/util/intstr"
 )
 
 // GetNGINXIngressServiceIP retrieves the external address of the nginx-ingress service
@@ -80,147 +79,14 @@ func (c *CreateDNSRecordConfig) NewDNSRecordForEndpoint() *models.DNSRecord {
 	}
 }
 
-func (e *DNSRecord) CreateDomain(clientset kubernetes.Interface) error {
-	// determine if IP address or domain
-	err := e.createIngress(clientset)
-
-	if err != nil {
-		return err
-	}
-
-	return e.createServiceWithEndpoint(clientset)
-}
-
-func (e *DNSRecord) createIngress(clientset kubernetes.Interface) error {
-	_, err := clientset.ExtensionsV1beta1().Ingresses("default").Create(
-		context.TODO(),
-		&v1beta1.Ingress{
-			ObjectMeta: metav1.ObjectMeta{
-				Annotations: map[string]string{
-					"kubernetes.io/ingress.class":                  "nginx",
-					"nginx.ingress.kubernetes.io/ssl-redirect":     "true",
-					"nginx.ingress.kubernetes.io/backend-protocol": "HTTPS",
-					"nginx.ingress.kubernetes.io/upstream-vhost":   e.Hostname,
-				},
-				Name:      e.SubdomainPrefix,
-				Namespace: "default",
-			},
-			Spec: v1beta1.IngressSpec{
-				TLS: []v1beta1.IngressTLS{
-					{
-						Hosts:      []string{fmt.Sprintf("%s.%s", e.SubdomainPrefix, e.RootDomain)},
-						SecretName: "wildcard-cert-tls",
-					},
-				},
-				Rules: []v1beta1.IngressRule{
-					{
-						Host: fmt.Sprintf("%s.%s", e.SubdomainPrefix, e.RootDomain),
-						IngressRuleValue: v1beta1.IngressRuleValue{
-							HTTP: &v1beta1.HTTPIngressRuleValue{
-								Paths: []v1beta1.HTTPIngressPath{
-									{
-										Backend: v1beta1.IngressBackend{
-											ServiceName: e.SubdomainPrefix,
-											ServicePort: intstr.IntOrString{
-												Type:   intstr.Int,
-												IntVal: 443,
-											},
-										},
-									},
-								},
-							},
-						},
-					},
-				},
-			},
-		},
-		metav1.CreateOptions{},
-	)
-
-	return err
-}
-
-func (e *DNSRecord) createServiceWithEndpoint(clientset kubernetes.Interface) error {
-	// determine if endpoint needs to be created or external name is ok
+// CreateDomain creates a new record for the vanity domain
+func (e *DNSRecord) CreateDomain(powerDNSClient *powerdns.Client) error {
 	isIPv4 := net.ParseIP(e.Endpoint) != nil
-
-	svcSpec := v1.ServiceSpec{
-		Ports: []v1.ServicePort{
-			{
-				Port: 80,
-				TargetPort: intstr.IntOrString{
-					Type:   intstr.Int,
-					IntVal: 80,
-				},
-				Name: "http",
-			},
-			{
-				Port: 443,
-				TargetPort: intstr.IntOrString{
-					Type:   intstr.Int,
-					IntVal: 443,
-				},
-				Name: "https",
-			},
-		},
-	}
-
-	// case service spec on ipv4
-	if isIPv4 {
-		svcSpec.ClusterIP = "None"
-	} else {
-		svcSpec.Type = "ExternalName"
-		svcSpec.ExternalName = e.Endpoint
-	}
-
-	// create service
-	_, err := clientset.CoreV1().Services("default").Create(
-		context.TODO(),
-		&v1.Service{
-			ObjectMeta: metav1.ObjectMeta{
-				Name:      e.SubdomainPrefix,
-				Namespace: "default",
-			},
-			Spec: svcSpec,
-		},
-		metav1.CreateOptions{},
-	)
-
-	if err != nil {
-		return err
-	}
+	domain := fmt.Sprintf("%s.%s", e.SubdomainPrefix, e.RootDomain)
 
 	if isIPv4 {
-		_, err = clientset.CoreV1().Endpoints("default").Create(
-			context.TODO(),
-			&v1.Endpoints{
-				ObjectMeta: metav1.ObjectMeta{
-					Name:      e.SubdomainPrefix,
-					Namespace: "default",
-				},
-				Subsets: []v1.EndpointSubset{
-					{
-						Addresses: []v1.EndpointAddress{
-							{
-								IP: e.Endpoint,
-							},
-						},
-						Ports: []v1.EndpointPort{
-							{
-								Name: "http",
-								Port: 80,
-							},
-							{
-								Name: "https",
-								Port: 443,
-							},
-						},
-					},
-				},
-			},
-			metav1.CreateOptions{},
-		)
+		return powerDNSClient.CreateARecord(e.Endpoint, domain)
 	}
 
-	return err
+	return powerDNSClient.CreateCNAMERecord(e.Endpoint, domain)
 }