Bläddra i källkod

Merge branch 'master' of https://github.com/porter-dev/porter into main

sunguroku 5 år sedan
förälder
incheckning
c92312575c

+ 2 - 1
.github/workflows/dev.yaml

@@ -1,4 +1,4 @@
-name: Deploy to production
+name: Deploy to dev
 on:
   push:
     branches:
@@ -31,6 +31,7 @@ jobs:
           FEEDBACK_ENDPOINT=${{secrets.FEEDBACK_ENDPOINT}}
           POSTHOG_API_KEY=${{secrets.POSTHOG_API_KEY}}
           POSTHOG_HOST=${{secrets.POSTHOG_HOST}}
+          APPLICATION_CHART_REPO_URL=${{secrets.APPLICATION_CHART_REPO_URL}}
           EOL
       - name: Build
         run: |

+ 1 - 1
.github/workflows/staging.yaml

@@ -1,4 +1,4 @@
-name: Build, Push to GCR.
+name: Deploy to staging
 on:
   push:
     branches:

+ 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{},

+ 1 - 1
dashboard/src/components/TabSelector.tsx

@@ -46,7 +46,7 @@ export default class TabSelector extends Component<PropsType, StateType> {
         <TabWrapper>
           {this.renderTabList()}
           <Tab lastItem={true} highlight={null}>
-            { this.props.noBuffer ? null : <Buffer /> }
+            {this.props.noBuffer ? null : <Buffer />}
           </Tab>
         </TabWrapper>
         {this.props.addendum}

+ 1 - 1
dashboard/src/components/repo-selector/ActionDetails.tsx

@@ -147,7 +147,7 @@ export default class ActionDetails extends Component<PropsType, StateType> {
             href="https://docs.getporter.dev/docs/auto-deploy-requirements#cicd-with-github-actions"
             target="_blank"
           >
-            Learn More
+            Learn more
           </Highlight>
         </SubtitleAlt>
         <Br />

+ 1 - 4
dashboard/src/components/repo-selector/RepoList.tsx

@@ -123,11 +123,10 @@ export default class ActionConfEditor extends Component<PropsType, StateType> {
         <LoadingWrapper>
           No connected Github repos found. You can
           <A
-            padRight={true}
             href={`/api/oauth/projects/${this.context.currentProject.id}/github?redirected=true`}
           >
             log in with GitHub
-          </A>{" "}
+          </A>
           .
         </LoadingWrapper>
       );
@@ -254,6 +253,4 @@ const A = styled.a`
   text-decoration: underline;
   margin-left: 5px;
   cursor: pointer;
-  padding-right: ${(props: { padRight?: boolean }) =>
-    props.padRight ? "5px" : ""};
 `;

+ 11 - 12
dashboard/src/main/home/cluster-dashboard/expanded-chart/metrics/MetricsSection.tsx

@@ -109,7 +109,7 @@ export default class MetricsSection extends Component<PropsType, StateType> {
         res.data.map((controller: any) => {
           let name = controller?.metadata?.name;
           controllerOptions.push({ value: controller, label: name });
-        })
+        });
 
         // iterate through the controllers to get the list of pods
         this.setState({
@@ -170,8 +170,6 @@ export default class MetricsSection extends Component<PropsType, StateType> {
       pods = [this.state.selectedPod];
     }
 
-
-
     api
       .getMetrics(
         "<token>",
@@ -291,7 +289,7 @@ export default class MetricsSection extends Component<PropsType, StateType> {
         let pods = [{ value: "All", label: "All (Summed)" }] as any[];
         res?.data?.forEach((pod: any) => {
           let name = pod?.metadata?.name;
-          pods.push({ value: name, label: name});
+          pods.push({ value: name, label: name });
         });
 
         this.setState({ pods, selectedPod: "All" });
@@ -358,15 +356,14 @@ export default class MetricsSection extends Component<PropsType, StateType> {
           <DropdownOverlay
             onClick={() => this.setState({ showMetricsSettings: false })}
           />
-          <DropdownAlt
-            dropdownWidth="330px"
-            dropdownMaxHeight="300px"
-          >
+          <DropdownAlt dropdownWidth="330px" dropdownMaxHeight="300px">
             <Label>Additional Settings</Label>
             <SelectRow
               label="Target Controller"
               value={this.state.selectedController}
-              setActiveValue={(x: any) => this.setState({ selectedController: x })}
+              setActiveValue={(x: any) =>
+                this.setState({ selectedController: x })
+              }
               options={this.state.controllerOptions}
               width="100%"
             />
@@ -381,7 +378,7 @@ export default class MetricsSection extends Component<PropsType, StateType> {
         </>
       );
     }
-  }
+  };
 
   render() {
     return (
@@ -390,7 +387,9 @@ export default class MetricsSection extends Component<PropsType, StateType> {
           <Flex>
             <MetricSelector
               onClick={() =>
-                this.setState({ dropdownExpanded: !this.state.dropdownExpanded })
+                this.setState({
+                  dropdownExpanded: !this.state.dropdownExpanded,
+                })
               }
             >
               <MetricsLabel>{this.state.selectedMetricLabel}</MetricsLabel>
@@ -398,7 +397,7 @@ export default class MetricsSection extends Component<PropsType, StateType> {
               {this.renderDropdown()}
             </MetricSelector>
             <Relative>
-              <IconWrapper 
+              <IconWrapper
                 onClick={() => this.setState({ showMetricsSettings: true })}
               >
                 <SettingsIcon src={settings} />

+ 45 - 6
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(
@@ -534,6 +565,11 @@ class LaunchTemplate extends Component<PropsType, StateType> {
           <Subtitle>
             Specify the container image you would like to connect to this
             template.
+            <Highlight
+              onClick={() => this.props.history.push("integrations/registry")}
+            >
+              Manage Docker registries
+            </Highlight>
             <Required>*</Required>
           </Subtitle>
           <DarkMatter antiHeight="-4px" />
@@ -581,6 +617,11 @@ class LaunchTemplate extends Component<PropsType, StateType> {
           </CloseButton>
           <Subtitle>
             Provide a repo folder to use as source.
+            <Highlight
+              onClick={() => this.props.history.push("integrations/repo")}
+            >
+              Manage Git repos
+            </Highlight>
             <Required>*</Required>
           </Subtitle>
           <DarkMatter antiHeight="-4px" />
@@ -990,11 +1031,9 @@ const StyledLaunchTemplate = styled.div`
 
 const Highlight = styled.div`
   color: #8590ff;
-  text-decoration: underline;
+  text-decoration: none;
   margin-left: 5px;
   cursor: pointer;
-  padding-right: ${(props: { padRight?: boolean }) =>
-    props.padRight ? "5px" : ""};
 `;
 
 const StyledSourceBox = styled.div`

+ 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"`
+}

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

@@ -0,0 +1,230 @@
+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
+				break
+			}
+		}
+	}
+
+	if !exists {
+		return "", false, nil
+	}
+
+	if ipArr := nginxSvc.Status.LoadBalancer.Ingress; len(ipArr) > 0 {
+		// first default to ip, then check hostname
+		if ipArr[0].IP != "" {
+			return ipArr[0].IP, true, nil
+		} else if ipArr[0].Hostname != "" {
+			return ipArr[0].Hostname, 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