Browse Source

Merge pull request #409 from porter-dev/beta.3.domain-generation

Beta.3.domain generation
abelanger5 5 năm trước cách đây
mục cha
commit
7b6a5e52cc

+ 1 - 0
cmd/app/main.go

@@ -60,6 +60,7 @@ func main() {
 		&models.GitActionConfig{},
 		&models.Invite{},
 		&models.AuthCode{},
+		&models.DNSRecord{},
 		&ints.KubeIntegration{},
 		&ints.BasicIntegration{},
 		&ints.OIDCIntegration{},

+ 1 - 0
cmd/migrate/main.go

@@ -45,6 +45,7 @@ func main() {
 		&models.GitActionConfig{},
 		&models.Invite{},
 		&models.AuthCode{},
+		&models.DNSRecord{},
 		&ints.KubeIntegration{},
 		&ints.BasicIntegration{},
 		&ints.OIDCIntegration{},

+ 34 - 3
dashboard/src/main/home/launch/expanded-template/LaunchTemplate.tsx

@@ -175,14 +175,14 @@ class LaunchTemplate extends Component<PropsType, StateType> {
       });
   };
 
-  onSubmit = (rawValues: any) => {
+  onSubmit = async (rawValues: any) => {
     let { currentCluster, currentProject } = this.context;
     let name =
       this.state.templateName || randomWords({ exactly: 3, join: "-" });
     this.setState({ saveValuesStatus: "loading" });
 
     // Convert dotted keys to nested objects
-    let values = {};
+    let values: any = {};
     for (let key in rawValues) {
       _.set(values, key, rawValues[key]);
     }
@@ -215,7 +215,7 @@ class LaunchTemplate extends Component<PropsType, StateType> {
         provider = "digitalocean";
         break;
       default:
-        provider = null;
+        provider = "";
     }
 
     // don't overwrite for templates that already have a source (i.e. non-Docker templates)
@@ -225,6 +225,37 @@ class LaunchTemplate extends Component<PropsType, StateType> {
     }
 
     _.set(values, "ingress.provider", provider);
+    var url: string;
+
+    // check if template is docker and create external domain if necessary
+    if (this.props.currentTemplate.name == "web") {
+      if (values?.ingress?.enabled && values?.ingress?.hosts?.length == 0) {
+        url = await new Promise((resolve, reject) => {
+          api
+            .createSubdomain(
+              "<token>",
+              {
+                release_name: name,
+              },
+              {
+                id: currentProject.id,
+                cluster_id: currentCluster.id,
+              }
+            )
+            .then((res) => {
+              resolve(res.data?.external_url);
+            })
+            .catch((err) => {
+              this.setState({ saveValuesStatus: "error" });
+            });
+        });
+
+        values.ingress.hosts = [url];
+        values.ingress.custom_domain = true;
+      }
+    }
+
+    console.log("VALUES ARE", values);
 
     api
       .deployTemplate(

+ 15 - 0
dashboard/src/shared/api.tsx

@@ -143,6 +143,20 @@ const createProject = baseApi<{ name: string }, {}>("POST", (pathParams) => {
   return `/api/projects`;
 });
 
+const createSubdomain = baseApi<
+  {
+    release_name: string;
+  },
+  {
+    id: number;
+    cluster_id: number;
+  }
+>("POST", (pathParams) => {
+  let { cluster_id, id } = pathParams;
+
+  return `/api/projects/${id}/k8s/subdomain?cluster_id=${cluster_id}`;
+});
+
 const deleteCluster = baseApi<
   {},
   {
@@ -620,6 +634,7 @@ export default {
   deleteCluster,
   deleteInvite,
   deleteProject,
+  createSubdomain,
   deployTemplate,
   destroyEKS,
   destroyGKE,

+ 1 - 0
internal/config/config.go

@@ -29,6 +29,7 @@ type ServerConf struct {
 	TimeoutIdle          time.Duration `env:"SERVER_TIMEOUT_IDLE,default=15s"`
 	IsLocal              bool          `env:"IS_LOCAL,default=false"`
 	IsTesting            bool          `env:"IS_TESTING,default=false"`
+	AppRootDomain        string        `env:"APP_ROOT_DOMAIN,default=porter.run"`
 
 	DefaultHelmRepoURL string `env:"HELM_REPO_URL,default=https://porter-dev.github.io/chart-repo/"`
 

+ 8 - 0
internal/forms/domain.go

@@ -0,0 +1,8 @@
+package forms
+
+// CreateDomainForm represents the accepted values for creating a DNS record
+type CreateDomainForm struct {
+	*K8sForm
+
+	ReleaseName string `json:"release_name" form:"required"`
+}

+ 224 - 0
internal/kubernetes/domain/domain.go

@@ -0,0 +1,224 @@
+package domain
+
+import (
+	"context"
+	"fmt"
+	"math/rand"
+	"net"
+	"strings"
+
+	"github.com/porter-dev/porter/internal/models"
+	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
+func GetNGINXIngressServiceIP(clientset kubernetes.Interface) (string, bool, error) {
+	svcList, err := clientset.CoreV1().Services("").List(context.TODO(), metav1.ListOptions{
+		LabelSelector: "app.kubernetes.io/managed-by=Helm",
+	})
+
+	if err != nil {
+		return "", false, err
+	}
+
+	var nginxSvc *v1.Service
+	exists := false
+
+	for _, svc := range svcList.Items {
+		// check that helm chart annotation is correct exists
+		if chartAnn, found := svc.ObjectMeta.Labels["helm.sh/chart"]; found {
+			if (strings.Contains(chartAnn, "ingress-nginx") || strings.Contains(chartAnn, "nginx-ingress")) && svc.Spec.Type == v1.ServiceTypeLoadBalancer {
+				nginxSvc = &svc
+				exists = true
+			}
+		}
+	}
+
+	if !exists {
+		return "", false, nil
+	}
+
+	if ipArr := nginxSvc.Status.LoadBalancer.Ingress; len(ipArr) > 0 {
+		return ipArr[0].IP, true, nil
+	}
+
+	return "", false, nil
+}
+
+// DNSRecord wraps the gorm DNSRecord model
+type DNSRecord models.DNSRecord
+
+type CreateDNSRecordConfig struct {
+	ReleaseName string
+	RootDomain  string
+	Endpoint    string
+}
+
+// NewDNSRecordForEndpoint generates a random subdomain and returns a DNSRecord
+// model
+func (c *CreateDNSRecordConfig) NewDNSRecordForEndpoint() *models.DNSRecord {
+	const allowed = "123456789abcdefghijklmnopqrstuvwxyz"
+	suffix := make([]byte, 8)
+	for i := range suffix {
+		suffix[i] = allowed[rand.Intn(len(allowed))]
+	}
+
+	subdomain := fmt.Sprintf("%s-%s", c.ReleaseName, string(suffix))
+
+	return &models.DNSRecord{
+		SubdomainPrefix: subdomain,
+		RootDomain:      c.RootDomain,
+		Endpoint:        c.Endpoint,
+		Hostname:        fmt.Sprintf("%s.%s", subdomain, c.RootDomain),
+	}
+}
+
+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
+	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
+	}
+
+	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 err
+}

+ 40 - 0
internal/models/dns_record.go

@@ -0,0 +1,40 @@
+package models
+
+import (
+	"fmt"
+
+	"gorm.io/gorm"
+)
+
+// DNSRecord type that extends gorm.Model
+type DNSRecord struct {
+	gorm.Model
+
+	SubdomainPrefix string `json:"subdomain_prefix" gorm:"unique"`
+	RootDomain      string `json:"root_domain"`
+
+	Endpoint string `json:"endpoint"`
+	Hostname string `json:"hostname"`
+
+	ClusterID uint `json:"cluster_id"`
+}
+
+// DNSRecordExternal represents the DNSRecord type that is sent over REST
+type DNSRecordExternal struct {
+	ExternalURL string `json:"external_url"`
+
+	Endpoint string `json:"endpoint"`
+	Hostname string `json:"hostname"`
+
+	ClusterID uint `json:"cluster_id"`
+}
+
+// Externalize generates an external Project to be shared over REST
+func (p *DNSRecord) Externalize() *DNSRecordExternal {
+	return &DNSRecordExternal{
+		ExternalURL: fmt.Sprintf("%s.%s", p.SubdomainPrefix, p.RootDomain),
+		Endpoint:    p.Endpoint,
+		Hostname:    p.Hostname,
+		ClusterID:   p.ClusterID,
+	}
+}

+ 11 - 0
internal/repository/dns_record.go

@@ -0,0 +1,11 @@
+package repository
+
+import (
+	"github.com/porter-dev/porter/internal/models"
+)
+
+// DNSRecordRepository represents the set of queries on the
+// DNSRecord model
+type DNSRecordRepository interface {
+	CreateDNSRecord(record *models.DNSRecord) (*models.DNSRecord, error)
+}

+ 27 - 0
internal/repository/gorm/dns_record.go

@@ -0,0 +1,27 @@
+package gorm
+
+import (
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"gorm.io/gorm"
+)
+
+// DNSRecordRepository uses gorm.DB for querying the database
+type DNSRecordRepository struct {
+	db *gorm.DB
+}
+
+// NewDNSRecordRepository returns a DNSRecordRepository which uses
+// gorm.DB for querying the database
+func NewDNSRecordRepository(db *gorm.DB) repository.DNSRecordRepository {
+	return &DNSRecordRepository{db}
+}
+
+// CreateDNSRecord creates a new helm repo
+func (repo *DNSRecordRepository) CreateDNSRecord(record *models.DNSRecord) (*models.DNSRecord, error) {
+	if err := repo.db.Create(record).Error; err != nil {
+		return nil, err
+	}
+
+	return record, nil
+}

+ 1 - 0
internal/repository/gorm/repository.go

@@ -21,6 +21,7 @@ func NewRepository(db *gorm.DB, key *[32]byte) *repository.Repository {
 		GitActionConfig:  NewGitActionConfigRepository(db),
 		Invite:           NewInviteRepository(db),
 		AuthCode:         NewAuthCodeRepository(db),
+		DNSRecord:        NewDNSRecordRepository(db),
 		KubeIntegration:  NewKubeIntegrationRepository(db, key),
 		BasicIntegration: NewBasicIntegrationRepository(db, key),
 		OIDCIntegration:  NewOIDCIntegrationRepository(db, key),

+ 36 - 0
internal/repository/memory/dns_record.go

@@ -0,0 +1,36 @@
+package test
+
+import (
+	"errors"
+
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+)
+
+// DNSRecordRepository implements repository.DNSRecordRepository
+type DNSRecordRepository struct {
+	canQuery   bool
+	dnsRecords []*models.DNSRecord
+}
+
+// NewDNSRecordRepository will return errors if canQuery is false
+func NewDNSRecordRepository(canQuery bool) repository.DNSRecordRepository {
+	return &DNSRecordRepository{
+		canQuery,
+		[]*models.DNSRecord{},
+	}
+}
+
+// CreateDNSRecord creates a new repoistry
+func (repo *DNSRecordRepository) CreateDNSRecord(
+	record *models.DNSRecord,
+) (*models.DNSRecord, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot write database")
+	}
+
+	repo.dnsRecords = append(repo.dnsRecords, record)
+	record.ID = uint(len(repo.dnsRecords))
+
+	return record, nil
+}

+ 1 - 0
internal/repository/memory/repository.go

@@ -17,6 +17,7 @@ func NewRepository(canQuery bool) *repository.Repository {
 		GitRepo:          NewGitRepoRepository(canQuery),
 		Invite:           NewInviteRepository(canQuery),
 		AuthCode:         NewAuthCodeRepository(canQuery),
+		DNSRecord:        NewDNSRecordRepository(canQuery),
 		KubeIntegration:  NewKubeIntegrationRepository(canQuery),
 		BasicIntegration: NewBasicIntegrationRepository(canQuery),
 		OIDCIntegration:  NewOIDCIntegrationRepository(canQuery),

+ 1 - 0
internal/repository/repository.go

@@ -14,6 +14,7 @@ type Repository struct {
 	GitActionConfig  GitActionConfigRepository
 	Invite           InviteRepository
 	AuthCode         AuthCodeRepository
+	DNSRecord        DNSRecordRepository
 	KubeIntegration  KubeIntegrationRepository
 	BasicIntegration BasicIntegrationRepository
 	OIDCIntegration  OIDCIntegrationRepository

+ 99 - 0
server/api/dns_record_handler.go

@@ -0,0 +1,99 @@
+package api
+
+import (
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"net/url"
+
+	"github.com/porter-dev/porter/internal/forms"
+	"github.com/porter-dev/porter/internal/kubernetes"
+	"github.com/porter-dev/porter/internal/kubernetes/domain"
+)
+
+// HandleCreateProjectCluster creates a new cluster
+func (app *App) HandleCreateDNSRecord(w http.ResponseWriter, r *http.Request) {
+	vals, err := url.ParseQuery(r.URL.RawQuery)
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
+		return
+	}
+
+	// get the filter options
+	form := &forms.CreateDomainForm{
+		K8sForm: &forms.K8sForm{
+			OutOfClusterConfig: &kubernetes.OutOfClusterConfig{
+				Repo:              app.Repo,
+				DigitalOceanOAuth: app.DOConf,
+			},
+		},
+	}
+
+	form.PopulateK8sOptionsFromQueryParams(vals, app.Repo.Cluster)
+
+	if err := json.NewDecoder(r.Body).Decode(form); err != nil {
+		app.handleErrorFormDecoding(err, ErrUserDecode, w)
+		return
+	}
+
+	// validate the form
+	if err := app.validator.Struct(form); err != nil {
+		app.handleErrorFormValidation(err, ErrK8sValidate, w)
+		return
+	}
+
+	// create a new agent
+	var agent *kubernetes.Agent
+
+	if app.ServerConf.IsTesting {
+		agent = app.TestAgents.K8sAgent
+	} else {
+		agent, err = kubernetes.GetAgentOutOfClusterConfig(form.OutOfClusterConfig)
+	}
+
+	endpoint, found, err := domain.GetNGINXIngressServiceIP(agent.Clientset)
+
+	if !found {
+		app.handleErrorInternal(fmt.Errorf("target cluster does not have nginx ingress"), w)
+		return
+	}
+
+	createDomain := domain.CreateDNSRecordConfig{
+		ReleaseName: form.ReleaseName,
+		RootDomain:  app.ServerConf.AppRootDomain,
+		Endpoint:    endpoint,
+	}
+
+	record := createDomain.NewDNSRecordForEndpoint()
+
+	record, err = app.Repo.DNSRecord.CreateDNSRecord(record)
+
+	if err != nil {
+		app.handleErrorDataWrite(err, w)
+		return
+	}
+
+	// launch provisioning destruction pod
+	inClusterAgent, err := kubernetes.GetAgentInClusterConfig()
+
+	if err != nil {
+		app.handleErrorDataRead(err, w)
+		return
+	}
+
+	_record := domain.DNSRecord(*record)
+
+	err = _record.CreateDomain(inClusterAgent.Clientset)
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+	}
+
+	w.WriteHeader(http.StatusCreated)
+
+	if err := json.NewEncoder(w).Encode(record.Externalize()); err != nil {
+		app.handleErrorFormDecoding(err, ErrK8sDecode, w)
+		return
+	}
+}

+ 15 - 0
server/router/router.go

@@ -1133,6 +1133,21 @@ func New(a *api.App) *chi.Mux {
 				mw.ReadAccess,
 			),
 		)
+
+		// /api/projects/{project_id}/subdomain routes
+		r.Method(
+			"POST",
+			"/projects/{project_id}/k8s/subdomain",
+			auth.DoesUserHaveProjectAccess(
+				auth.DoesUserHaveClusterAccess(
+					requestlog.NewHandler(a.HandleCreateDNSRecord, l),
+					mw.URLParam,
+					mw.QueryParam,
+				),
+				mw.URLParam,
+				mw.ReadAccess,
+			),
+		)
 	})
 
 	staticFilePath := a.ServerConf.StaticFilePath