Procházet zdrojové kódy

Merge branch 'master' into nafees/preview-env-improvements

Mohammed Nafees před 3 roky
rodič
revize
95e00dc1d9

+ 1 - 0
api/server/handlers/stack/create.go

@@ -263,6 +263,7 @@ func getSourceConfigModels(sourceConfigs []*types.CreateStackSourceConfigRequest
 
 
 			res = append(res, models.StackSourceConfig{
 			res = append(res, models.StackSourceConfig{
 				UID:          uid,
 				UID:          uid,
+				DisplayName:  sourceConfig.DisplayName,
 				Name:         sourceConfig.Name,
 				Name:         sourceConfig.Name,
 				ImageRepoURI: sourceConfig.ImageRepoURI,
 				ImageRepoURI: sourceConfig.ImageRepoURI,
 				ImageTag:     sourceConfig.ImageTag,
 				ImageTag:     sourceConfig.ImageTag,

+ 64 - 0
api/server/handlers/stack/update_stack.go

@@ -0,0 +1,64 @@
+package stack
+
+import (
+	"fmt"
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type StackUpdateStack struct {
+	handlers.PorterHandlerReadWriter
+}
+
+func NewStackUpdateStackHandler(
+	config *config.Config,
+	reader shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *StackUpdateStack {
+	return &StackUpdateStack{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, reader, writer),
+	}
+}
+
+func (p *StackUpdateStack) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+	stack, _ := r.Context().Value(types.StackScope).(*models.Stack)
+
+	req := &types.UpdateStackRequest{}
+
+	if ok := p.DecodeAndValidate(w, r, req); !ok {
+		return
+	}
+
+	if len(stack.Revisions) == 0 {
+		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			fmt.Errorf("no stack revisions exist"), http.StatusBadRequest,
+		))
+		return
+	}
+
+	stack, err := p.Repo().Stack().ReadStackByID(proj.ID, stack.ID)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// Update stack name
+	stack.Name = req.Name
+
+	newStack, err := p.Repo().Stack().UpdateStack(stack)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	p.WriteResult(w, r, newStack)
+}

+ 57 - 1
api/server/router/v1/stack.go

@@ -9,7 +9,7 @@ import (
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/api/types"
 )
 )
 
 
-// swagger:parameters getStack deleteStack putStackSource rollbackStack listStackRevisions addApplication addEnvGroup
+// swagger:parameters getStack deleteStack putStackSource rollbackStack listStackRevisions addApplication addEnvGroup updateStack
 type stackPathParams struct {
 type stackPathParams struct {
 	// The project id
 	// The project id
 	// in: path
 	// in: path
@@ -820,5 +820,61 @@ func getV1StackRoutes(
 		Router:   r,
 		Router:   r,
 	})
 	})
 
 
+	// PATCH /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/stacks/{stack_id} -> stack.NewStackUpdateStackHandler
+	// swagger:operation PATCH /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/stacks/{stack_id} updateStack
+	//
+	// Updates a stack. Currently the only value available to update is the stack name.
+	//
+	// ---
+	// produces:
+	// - application/json
+	// summary: Update Stack
+	// tags:
+	// - Stacks
+	// parameters:
+	//   - name: project_id
+	//   - name: cluster_id
+	//   - name: namespace
+	//   - name: stack_id
+	//   - in: body
+	//     name: UpdateStack
+	//     description: The stack to update
+	//     schema:
+	//       $ref: '#/definitions/UpdateStackRequest'
+	// responses:
+	//   '200':
+	//     description: Successfully updated the stack
+	//   '403':
+	//     description: Forbidden
+	updateStackEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbUpdate,
+			Method: types.HTTPVerbPatch,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/{stack_id}",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+				types.NamespaceScope,
+				types.StackScope,
+			},
+		},
+	)
+
+	updateStackHandler := stack.NewStackUpdateStackHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: updateStackEndpoint,
+		Handler:  updateStackHandler,
+		Router:   r,
+	})
+
 	return routes, newPath
 	return routes, newPath
 }
 }

+ 12 - 1
api/types/stacks.go

@@ -63,6 +63,11 @@ type CreateStackAppResourceRequest struct {
 	SourceConfigName string `json:"source_config_name" form:"required"`
 	SourceConfigName string `json:"source_config_name" form:"required"`
 }
 }
 
 
+// swagger:model
+type UpdateStackRequest struct {
+	Name string `json:"name" form:"required"`
+}
+
 // swagger:model
 // swagger:model
 type Stack struct {
 type Stack struct {
 	// The time that the stack was initially created
 	// The time that the stack was initially created
@@ -207,9 +212,12 @@ type StackSourceConfig struct {
 	// The numerical revision id that this source config belongs to
 	// The numerical revision id that this source config belongs to
 	StackRevisionID uint `json:"stack_revision_id"`
 	StackRevisionID uint `json:"stack_revision_id"`
 
 
-	// The display name of the stack source
+	// Unique name for the source config
 	Name string `json:"name"`
 	Name string `json:"name"`
 
 
+	// Display name for the stack source
+	DisplayName string `json:"display_name"`
+
 	// The unique id of the stack source config
 	// The unique id of the stack source config
 	ID string `json:"id"`
 	ID string `json:"id"`
 
 
@@ -245,6 +253,9 @@ type CreateStackEnvGroupRequest struct {
 
 
 // swagger:model
 // swagger:model
 type CreateStackSourceConfigRequest struct {
 type CreateStackSourceConfigRequest struct {
+	// required: true
+	DisplayName string `json:"display_name" form:"required"`
+
 	// required: true
 	// required: true
 	Name string `json:"name" form:"required"`
 	Name string `json:"name" form:"required"`
 
 

+ 66 - 3
cli/cmd/deploy.go

@@ -14,6 +14,7 @@ import (
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/cli/cmd/config"
 	"github.com/porter-dev/porter/cli/cmd/config"
 	"github.com/porter-dev/porter/cli/cmd/deploy"
 	"github.com/porter-dev/porter/cli/cmd/deploy"
+	"github.com/porter-dev/porter/cli/cmd/docker"
 	"github.com/porter-dev/porter/cli/cmd/utils"
 	"github.com/porter-dev/porter/cli/cmd/utils"
 	templaterUtils "github.com/porter-dev/porter/internal/templater/utils"
 	templaterUtils "github.com/porter-dev/porter/internal/templater/utils"
 	"github.com/spf13/cobra"
 	"github.com/spf13/cobra"
@@ -148,10 +149,19 @@ for the application:
 
 
 var updatePushCmd = &cobra.Command{
 var updatePushCmd = &cobra.Command{
 	Use:   "push",
 	Use:   "push",
-	Short: "Pushes a new image for an application specified by the --app flag.",
+	Short: "Pushes an image to a Docker registry linked to your Porter project.",
+	Args:  cobra.MaximumNArgs(1),
 	Long: fmt.Sprintf(`
 	Long: fmt.Sprintf(`
 %s
 %s
 
 
+Pushes a local Docker image to a registry linked to your Porter project. This command
+requires the project ID to be set either by using the %s command
+or the --project flag. For example, to push a local nginx image:
+
+  %s
+
+%s
+
 Pushes a new image for an application specified by the --app flag. This command uses
 Pushes a new image for an application specified by the --app flag. This command uses
 the image repository saved in the application config by default. For example, if an
 the image repository saved in the application config by default. For example, if an
 application "nginx" was created from the image repo "gcr.io/snowflake-123456/nginx",
 application "nginx" was created from the image repo "gcr.io/snowflake-123456/nginx",
@@ -164,6 +174,9 @@ are using an image registry that was created outside of Porter, make sure that y
 linked it via "porter connect".
 linked it via "porter connect".
 `,
 `,
 		color.New(color.FgBlue, color.Bold).Sprintf("Help for \"porter update push\":"),
 		color.New(color.FgBlue, color.Bold).Sprintf("Help for \"porter update push\":"),
+		color.New(color.FgBlue).Sprintf("porter config set-project"),
+		color.New(color.FgGreen, color.Bold).Sprintf("porter update push gcr.io/snowflake-123456/nginx:1234567"),
+		color.New(color.Bold).Sprintf("LEGACY USAGE:"),
 		color.New(color.FgGreen, color.Bold).Sprintf("porter update push --app nginx --tag new-tag"),
 		color.New(color.FgGreen, color.Bold).Sprintf("porter update push --app nginx --tag new-tag"),
 	),
 	),
 	Run: func(cmd *cobra.Command, args []string) {
 	Run: func(cmd *cobra.Command, args []string) {
@@ -369,8 +382,6 @@ func init() {
 
 
 	updateBuildCmd.MarkPersistentFlagRequired("app")
 	updateBuildCmd.MarkPersistentFlagRequired("app")
 
 
-	updatePushCmd.MarkPersistentFlagRequired("app")
-
 	updateConfigCmd.MarkPersistentFlagRequired("app")
 	updateConfigCmd.MarkPersistentFlagRequired("app")
 
 
 	updateEnvGroupCmd.PersistentFlags().StringVar(
 	updateEnvGroupCmd.PersistentFlags().StringVar(
@@ -490,6 +501,58 @@ func updateBuild(_ *types.GetAuthenticatedUserResponse, client *api.Client, args
 }
 }
 
 
 func updatePush(_ *types.GetAuthenticatedUserResponse, client *api.Client, args []string) error {
 func updatePush(_ *types.GetAuthenticatedUserResponse, client *api.Client, args []string) error {
+	if app == "" {
+		if len(args) == 0 {
+			return fmt.Errorf("please provide the docker image name")
+		}
+
+		image := args[0]
+
+		registries, err := client.ListRegistries(context.Background(), cliConf.Project)
+
+		if err != nil {
+			return err
+		}
+
+		regs := *registries
+		regID := uint(0)
+
+		for _, reg := range regs {
+			if strings.Contains(image, reg.URL) {
+				regID = reg.ID
+				break
+			}
+		}
+
+		if regID == 0 {
+			return fmt.Errorf("could not find registry for image: %s", image)
+		}
+
+		err = client.CreateRepository(context.Background(), cliConf.Project, regID,
+			&types.CreateRegistryRepositoryRequest{
+				ImageRepoURI: strings.Split(image, ":")[0],
+			},
+		)
+
+		if err != nil {
+			return err
+		}
+
+		agent, err := docker.NewAgentWithAuthGetter(client, cliConf.Project)
+
+		if err != nil {
+			return err
+		}
+
+		err = agent.PushImage(image)
+
+		if err != nil {
+			return err
+		}
+
+		return nil
+	}
+
 	updateAgent, err := updateGetAgent(client)
 	updateAgent, err := updateGetAgent(client)
 
 
 	if err != nil {
 	if err != nil {

+ 28 - 0
cmd/migrate/main.go

@@ -5,6 +5,7 @@ import (
 
 
 	"github.com/porter-dev/porter/api/server/shared/config/envloader"
 	"github.com/porter-dev/porter/api/server/shared/config/envloader"
 	"github.com/porter-dev/porter/cmd/migrate/keyrotate"
 	"github.com/porter-dev/porter/cmd/migrate/keyrotate"
+	"github.com/porter-dev/porter/cmd/migrate/populate_source_config_display_name"
 
 
 	adapter "github.com/porter-dev/porter/internal/adapter"
 	adapter "github.com/porter-dev/porter/internal/adapter"
 	"github.com/porter-dev/porter/internal/repository/gorm"
 	"github.com/porter-dev/porter/internal/repository/gorm"
@@ -61,6 +62,14 @@ func main() {
 		}
 		}
 	}
 	}
 
 
+	if shouldPopulateSourceConfigDisplayName() {
+		err := populate_source_config_display_name.PopulateSourceConfigDisplayName(db, logger)
+
+		if err != nil {
+			logger.Fatal().Err(err).Msg("failed to populate source config display name")
+		}
+	}
+
 	if err := InstanceMigrate(db, envConf.DBConf); err != nil {
 	if err := InstanceMigrate(db, envConf.DBConf); err != nil {
 		logger.Fatal().Err(err).Msg("vault migration failed")
 		logger.Fatal().Err(err).Msg("vault migration failed")
 	}
 	}
@@ -83,3 +92,22 @@ func shouldKeyRotate() (bool, string, string) {
 
 
 	return c.OldEncryptionKey != "" && c.NewEncryptionKey != "", c.OldEncryptionKey, c.NewEncryptionKey
 	return c.OldEncryptionKey != "" && c.NewEncryptionKey != "", c.OldEncryptionKey, c.NewEncryptionKey
 }
 }
+
+type PopulateSourceConfigDisplayNameConf struct {
+	// we add a dummy field to avoid empty struct issue with envdecode
+	DummyField string `env:"ASDF,default=asdf"`
+
+	// if true, will populate the display name for all source configs
+	PopulateSourceConfigDisplayName bool `env:"POPULATE_SOURCE_CONFIG_DISPLAY_NAME"`
+}
+
+func shouldPopulateSourceConfigDisplayName() bool {
+	var c PopulateSourceConfigDisplayNameConf
+
+	if err := envdecode.StrictDecode(&c); err != nil {
+		log.Fatalf("Failed to decode migration conf: %s", err)
+		return false
+	}
+
+	return c.PopulateSourceConfigDisplayName
+}

+ 365 - 0
cmd/migrate/populate_source_config_display_name/helpers_test.go

@@ -0,0 +1,365 @@
+package populate_source_config_display_name_test
+
+import (
+	"fmt"
+	"os"
+	"testing"
+	"time"
+
+	"github.com/porter-dev/porter/api/server/shared/config/env"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/adapter"
+	"github.com/porter-dev/porter/internal/encryption"
+	"github.com/porter-dev/porter/internal/models"
+	ints "github.com/porter-dev/porter/internal/models/integrations"
+	"github.com/porter-dev/porter/internal/repository"
+	"github.com/porter-dev/porter/internal/repository/gorm"
+	_gorm "gorm.io/gorm"
+)
+
+type tester struct {
+	Key *[32]byte
+	DB  *_gorm.DB
+
+	repo         repository.Repository
+	dbFileName   string
+	key          *[32]byte
+	initUsers    []*models.User
+	initProjects []*models.Project
+	initClusters []*models.Cluster
+	initKIs      []*ints.KubeIntegration
+	initStacks   []*models.Stack
+}
+
+func setupTestEnv(tester *tester, t *testing.T) {
+	t.Helper()
+
+	db, err := adapter.New(&env.DBConf{
+		EncryptionKey: "__random_strong_encryption_key__",
+		SQLLite:       true,
+		SQLLitePath:   tester.dbFileName,
+	})
+
+	if err != nil {
+		t.Fatalf("%\n", err)
+	}
+
+	err = db.AutoMigrate(
+		&models.Project{},
+		&models.User{},
+		&models.Cluster{},
+		&models.Stack{},
+		&models.StackEnvGroup{},
+		&models.StackSourceConfig{},
+		&models.StackRevision{},
+		&models.StackResource{},
+		&ints.KubeIntegration{},
+		&ints.ClusterTokenCache{},
+	)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	var key [32]byte
+
+	for i, b := range []byte("__random_strong_encryption_key__") {
+		key[i] = b
+	}
+
+	tester.key = &key
+	tester.Key = &key
+	tester.DB = db
+
+	tester.repo = gorm.NewRepository(db, &key, nil)
+}
+
+func cleanup(tester *tester, t *testing.T) {
+	t.Helper()
+
+	// remove the created file file
+	os.Remove(tester.dbFileName)
+}
+
+func initUser(tester *tester, t *testing.T) {
+	t.Helper()
+
+	user := &models.User{
+		Email:    "example@example.com",
+		Password: "hello1234",
+	}
+
+	user, err := tester.repo.User().CreateUser(user)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	tester.initUsers = append(tester.initUsers, user)
+}
+
+func initCluster(tester *tester, t *testing.T) {
+	t.Helper()
+
+	if len(tester.initProjects) == 0 {
+		initProject(tester, t)
+	}
+
+	if len(tester.initKIs) == 0 {
+		initKubeIntegration(tester, t)
+	}
+
+	cluster := &models.Cluster{
+		ProjectID:                tester.initProjects[0].ID,
+		Name:                     "cluster-test",
+		Server:                   "https://localhost",
+		KubeIntegrationID:        tester.initKIs[0].ID,
+		CertificateAuthorityData: []byte("-----BEGIN"),
+		TokenCache: ints.ClusterTokenCache{
+			TokenCache: ints.TokenCache{
+				Token:  []byte("token-1"),
+				Expiry: time.Now().Add(-1 * time.Hour),
+			},
+		},
+	}
+
+	cluster, err := tester.repo.Cluster().CreateCluster(cluster)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	tester.initClusters = append(tester.initClusters, cluster)
+}
+
+func initProject(tester *tester, t *testing.T) {
+	t.Helper()
+
+	proj := &models.Project{
+		Name: "project-test",
+	}
+
+	proj, err := tester.repo.Project().CreateProject(proj)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	tester.initProjects = append(tester.initProjects, proj)
+}
+
+func initKubeIntegration(tester *tester, t *testing.T) {
+	t.Helper()
+
+	if len(tester.initProjects) == 0 {
+		initProject(tester, t)
+	}
+
+	if len(tester.initUsers) == 0 {
+		initUser(tester, t)
+	}
+
+	ki := &ints.KubeIntegration{
+		Mechanism:             ints.KubeLocal,
+		ProjectID:             tester.initProjects[0].ID,
+		UserID:                tester.initUsers[0].ID,
+		Kubeconfig:            []byte("current-context: testing\n"),
+		ClientCertificateData: []byte("clientcertdata"),
+		ClientKeyData:         []byte("clientkeydata"),
+		Token:                 []byte("token"),
+		Username:              []byte("username"),
+		Password:              []byte("password"),
+	}
+
+	ki, err := tester.repo.KubeIntegration().CreateKubeIntegration(ki)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	tester.initKIs = append(tester.initKIs, ki)
+}
+
+func initEmptyStack(tester *tester, t *testing.T, stackName string) {
+	t.Helper()
+
+	if len(tester.initProjects) == 0 {
+		initProject(tester, t)
+	}
+
+	if len(tester.initUsers) == 0 {
+		initUser(tester, t)
+	}
+
+	if len(tester.initClusters) == 0 {
+		initCluster(tester, t)
+	}
+
+	uid, _ := encryption.GenerateRandomBytes(16)
+
+	// write stack to the database with creating status
+	stack := &models.Stack{
+		ProjectID: tester.initProjects[0].ID,
+		ClusterID: tester.initClusters[0].ID,
+		Namespace: "test-namespace",
+		Name:      stackName,
+		UID:       uid,
+		Revisions: []models.StackRevision{
+			{
+				RevisionNumber: 1,
+				Status:         string(types.StackRevisionStatusDeploying),
+				SourceConfigs:  []models.StackSourceConfig{},
+			},
+		},
+	}
+
+	newStack, err := tester.repo.Stack().CreateStack(stack)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	tester.initStacks = append(tester.initStacks, newStack)
+}
+
+func initStack(tester *tester, t *testing.T, stackName string) {
+	t.Helper()
+
+	if len(tester.initProjects) == 0 {
+		initProject(tester, t)
+	}
+
+	if len(tester.initUsers) == 0 {
+		initUser(tester, t)
+	}
+
+	if len(tester.initClusters) == 0 {
+		initCluster(tester, t)
+	}
+
+	uid, _ := encryption.GenerateRandomBytes(16)
+
+	sourceConfigs := []models.StackSourceConfig{
+		{
+			Name:         "source-config-1",
+			ImageRepoURI: "some-repo",
+			ImageTag:     "some-tag",
+			UID:          uid,
+		},
+	}
+
+	// write stack to the database with creating status
+	stack := &models.Stack{
+		ProjectID: tester.initProjects[0].ID,
+		ClusterID: tester.initClusters[0].ID,
+		Namespace: "test-namespace",
+		Name:      stackName,
+		UID:       uid,
+		Revisions: []models.StackRevision{
+			{
+				RevisionNumber: 1,
+				Status:         string(types.StackRevisionStatusDeploying),
+				SourceConfigs:  sourceConfigs,
+			},
+		},
+	}
+
+	newStack, err := tester.repo.Stack().CreateStack(stack)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	tester.initStacks = append(tester.initStacks, newStack)
+}
+
+func createNewStackRevision(tester *tester, t *testing.T, stackName string) {
+	t.Helper()
+
+	if len(tester.initProjects) == 0 {
+		initProject(tester, t)
+	}
+	if len(tester.initUsers) == 0 {
+		initUser(tester, t)
+	}
+	if len(tester.initClusters) == 0 {
+		initCluster(tester, t)
+	}
+	if len(tester.initStacks) == 0 {
+		initStack(tester, t, stackName)
+	}
+
+	stack := tester.initStacks[0]
+
+	for _, s := range tester.initStacks {
+		if s.Name == stackName {
+			stack = s
+			break
+		}
+	}
+
+	prevRevision := findLatestRevisionByRevisionNumber(t, stack.Revisions)
+
+	oldSourceConfig := prevRevision.SourceConfigs[0]
+
+	newUid, _ := encryption.GenerateRandomBytes(16)
+	sourceConfigs := []models.StackSourceConfig{
+		{
+			Name:         oldSourceConfig.Name,
+			ImageRepoURI: "some-repo-" + fmt.Sprint(prevRevision.RevisionNumber+1),
+			ImageTag:     "some-tag-" + fmt.Sprint(prevRevision.RevisionNumber+1),
+			UID:          newUid,
+		},
+	}
+
+	newRevision := models.StackRevision{
+		RevisionNumber: prevRevision.RevisionNumber + 1,
+		Status:         string(types.StackRevisionStatusDeploying),
+		SourceConfigs:  sourceConfigs,
+		StackID:        stack.ID,
+	}
+
+	tester.repo.Stack().AppendNewRevision(&newRevision)
+}
+
+func findLatestRevisionByRevisionNumber(t *testing.T, revisions []models.StackRevision) *models.StackRevision {
+	t.Helper()
+
+	latestRevision := revisions[0]
+	for _, revision := range revisions {
+		if revision.RevisionNumber > latestRevision.RevisionNumber {
+			latestRevision = revision
+		}
+	}
+
+	return &latestRevision
+}
+
+func appendNewSourceConfig(t *testing.T, tester *tester, stack *models.Stack, sourceConfig models.StackSourceConfig) {
+	t.Helper()
+
+	prevRevision := findLatestRevisionByRevisionNumber(t, stack.Revisions)
+
+	previousSourceConfigs := []models.StackSourceConfig{}
+
+	for _, sourceConfig := range prevRevision.SourceConfigs {
+		newUid, _ := encryption.GenerateRandomBytes(16)
+
+		sc := models.StackSourceConfig{
+			Name:         sourceConfig.Name,
+			ImageRepoURI: sourceConfig.ImageRepoURI,
+			ImageTag:     sourceConfig.ImageTag,
+			UID:          newUid,
+		}
+		previousSourceConfigs = append(previousSourceConfigs, sc)
+	}
+
+	newRevision := models.StackRevision{
+		RevisionNumber: prevRevision.RevisionNumber + 1,
+		Status:         string(types.StackRevisionStatusDeploying),
+		SourceConfigs:  append(prevRevision.SourceConfigs, sourceConfig),
+		StackID:        stack.ID,
+	}
+
+	tester.repo.Stack().AppendNewRevision(&newRevision)
+}

+ 40 - 0
cmd/migrate/populate_source_config_display_name/populate.go

@@ -0,0 +1,40 @@
+package populate_source_config_display_name
+
+import (
+	"github.com/porter-dev/porter/internal/models"
+	lr "github.com/porter-dev/porter/pkg/logger"
+	_gorm "gorm.io/gorm"
+)
+
+func PopulateSourceConfigDisplayName(db *_gorm.DB, logger *lr.Logger) error {
+	logger.Info().Msg("Initiated source config display name population")
+	// get all source configs
+	sourceConfigs := make([]*models.StackSourceConfig, 0)
+
+	if err := db.Find(&sourceConfigs).Error; err != nil {
+		logger.Error().Msgf("failed to get source configs %v", err)
+		return err
+	}
+
+	if len(sourceConfigs) == 0 {
+		logger.Info().Msg("no source configs to populate")
+		return nil
+	}
+
+	updatedCount := 0
+	// copy name to display name if display name is empty
+	for _, sourceConfig := range sourceConfigs {
+		if sourceConfig.DisplayName == "" {
+			sourceConfig.DisplayName = sourceConfig.Name
+			updatedCount++
+		}
+	}
+	// update source configs
+	if err := db.Save(&sourceConfigs).Error; err != nil {
+		logger.Error().Msgf("failed to update source configs %v", err)
+		return err
+	}
+
+	logger.Info().Msgf("source config display name population completed, %d source configs updated", updatedCount)
+	return nil
+}

+ 76 - 0
cmd/migrate/populate_source_config_display_name/populate_test.go

@@ -0,0 +1,76 @@
+package populate_source_config_display_name_test
+
+import (
+	"testing"
+
+	"github.com/porter-dev/porter/cmd/migrate/populate_source_config_display_name"
+
+	"github.com/porter-dev/porter/internal/models"
+	lr "github.com/porter-dev/porter/pkg/logger"
+)
+
+func TestAllSourceConfigsArePopulated(t *testing.T) {
+	logger := lr.NewConsole(true)
+
+	tester := &tester{
+		dbFileName: "./porter_stable_source_config_id_population.db",
+	}
+
+	setupTestEnv(tester, t)
+
+	defer cleanup(tester, t)
+
+	stackName := "first-stack"
+
+	initStack(tester, t, stackName)
+
+	createNewStackRevision(tester, t, stackName)
+
+	createNewStackRevision(tester, t, stackName)
+
+	createNewStackRevision(tester, t, stackName)
+
+	err := populate_source_config_display_name.PopulateSourceConfigDisplayName(tester.DB, logger)
+
+	if err != nil {
+		t.Fatalf("%\n", err)
+		return
+	}
+
+	sourceConfigs := []*models.StackSourceConfig{}
+
+	if err := tester.DB.Find(&sourceConfigs).Error; err != nil {
+		t.Fatalf("failed to find source configs: %s", err)
+	}
+
+	if len(sourceConfigs) != 4 {
+		t.Fatalf("expected 4 source configs, got %d", len(sourceConfigs))
+	}
+
+	for _, sc := range sourceConfigs {
+		if sc.DisplayName == "" {
+			t.Fatalf("expected display name to be populated, got empty string")
+		}
+	}
+}
+
+func TestPopulateOnEmptyStack(t *testing.T) {
+	logger := lr.NewConsole(true)
+
+	tester := &tester{
+		dbFileName: "./porter_stable_source_config_id_population.db",
+	}
+
+	setupTestEnv(tester, t)
+
+	initEmptyStack(tester, t, "empty-stack")
+
+	defer cleanup(tester, t)
+
+	err := populate_source_config_display_name.PopulateSourceConfigDisplayName(tester.DB, logger)
+
+	if err != nil {
+		t.Fatalf("expected no error, got %s", err)
+		return
+	}
+}

+ 5 - 1
dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/ExpandedStack.tsx

@@ -218,7 +218,11 @@ const ExpandedStack = () => {
             component: (
             component: (
               <>
               <>
                 <Gap></Gap>
                 <Gap></Gap>
-                <Settings stackName={stack.name} onDelete={handleDelete} />
+                <Settings
+                  stack={stack}
+                  onDelete={handleDelete}
+                  onUpdate={refreshStack}
+                />
               </>
               </>
             ),
             ),
           },
           },

+ 114 - 73
dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/_SourceConfig.tsx

@@ -1,11 +1,9 @@
-import { Tooltip } from "@material-ui/core";
-import ImageSelector from "components/image-selector/ImageSelector";
 import SaveButton from "components/SaveButton";
 import SaveButton from "components/SaveButton";
-import React, { useContext, useMemo, useState } from "react";
+import React, { useContext, useReducer, useRef, useState } from "react";
 import api from "shared/api";
 import api from "shared/api";
 import { Context } from "shared/Context";
 import { Context } from "shared/Context";
 import styled from "styled-components";
 import styled from "styled-components";
-import { AppResource, FullStackRevision, SourceConfig, Stack } from "../types";
+import { FullStackRevision, SourceConfig } from "../types";
 import SourceEditorDocker from "./components/SourceEditorDocker";
 import SourceEditorDocker from "./components/SourceEditorDocker";
 
 
 const _SourceConfig = ({
 const _SourceConfig = ({
@@ -32,7 +30,12 @@ const _SourceConfig = ({
     const index = newSourceConfigArray.findIndex(
     const index = newSourceConfigArray.findIndex(
       (sc) => sc.id === sourceConfig.id
       (sc) => sc.id === sourceConfig.id
     );
     );
-    newSourceConfigArray[index] = sourceConfig;
+
+    newSourceConfigArray[index] = {
+      ...sourceConfig,
+      display_name: sourceConfig.display_name || sourceConfig.name,
+    };
+
     setSourceConfigArrayCopy(newSourceConfigArray);
     setSourceConfigArrayCopy(newSourceConfigArray);
   };
   };
 
 
@@ -64,39 +67,13 @@ const _SourceConfig = ({
   return (
   return (
     <SourceConfigStyles.Wrapper>
     <SourceConfigStyles.Wrapper>
       {revision.source_configs.map((sourceConfig) => {
       {revision.source_configs.map((sourceConfig) => {
-        const apps = getAppsFromSourceConfig(revision.resources, sourceConfig);
-
-        const appList = formatAppList(apps, 2);
         return (
         return (
-          <SourceConfigStyles.ItemContainer>
-            {appList.hiddenApps?.length ? (
-              <Tooltip
-                title={
-                  <>
-                    {appList.hiddenApps.map((appName) => (
-                      <SourceConfigStyles.TooltipItem>
-                        {appName}
-                      </SourceConfigStyles.TooltipItem>
-                    ))}
-                  </>
-                }
-                placement={"bottom-end"}
-              >
-                <SourceConfigStyles.ItemTitle>
-                  Used by {appList.value}
-                </SourceConfigStyles.ItemTitle>
-              </Tooltip>
-            ) : (
-              <SourceConfigStyles.ItemTitle>
-                Used by {appList.value}
-              </SourceConfigStyles.ItemTitle>
-            )}
-            <SourceEditorDocker
-              sourceConfig={sourceConfig}
-              onChange={handleChange}
-              readOnly={readOnly || buttonStatus === "loading"}
-            />
-          </SourceConfigStyles.ItemContainer>
+          <SourceConfigItem
+            sourceConfig={sourceConfig}
+            key={sourceConfig.id}
+            handleChange={handleChange}
+            disabled={readOnly || buttonStatus === "loading"}
+          />
         );
         );
       })}
       })}
       {readOnly ? null : (
       {readOnly ? null : (
@@ -117,41 +94,6 @@ const _SourceConfig = ({
 
 
 export default _SourceConfig;
 export default _SourceConfig;
 
 
-const getAppsFromSourceConfig = (
-  apps: AppResource[],
-  sourceConfig: SourceConfig
-) => {
-  return apps.filter((app) => {
-    return app.stack_source_config.id === sourceConfig.id;
-  });
-};
-
-const formatAppList = (apps: AppResource[], limit: number = 3) => {
-  if (apps.length <= limit) {
-    const formatter = new Intl.ListFormat("en", {
-      style: "long",
-      type: "conjunction",
-    });
-    return {
-      value: formatter.format(apps.map((app) => app.name)),
-      hiddenApps: [],
-    };
-  }
-
-  const hiddenApps = [...apps]
-    .splice(limit, apps.length)
-    .map((app) => app.name);
-
-  return {
-    value: apps
-      .map((app) => app.name)
-      .splice(0, limit)
-      .join(", ")
-      .concat(` and ${apps.length - limit} more`),
-    hiddenApps,
-  };
-};
-
 const SourceConfigStyles = {
 const SourceConfigStyles = {
   Wrapper: styled.div`
   Wrapper: styled.div`
     margin-top: 30px;
     margin-top: 30px;
@@ -164,8 +106,17 @@ const SourceConfigStyles = {
   `,
   `,
   ItemTitle: styled.div`
   ItemTitle: styled.div`
     font-size: 16px;
     font-size: 16px;
-    width: fit-content;
     font-weight: 500;
     font-weight: 500;
+
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    gap: 10px;
+    > span {
+      overflow-x: hidden;
+      text-overflow: ellipsis;
+      white-space: nowrap;
+    }
   `,
   `,
   TooltipItem: styled.div`
   TooltipItem: styled.div`
     font-size: 14px;
     font-size: 14px;
@@ -179,3 +130,93 @@ const SourceConfigStyles = {
     z-index: unset;
     z-index: unset;
   `,
   `,
 };
 };
+
+const SourceConfigItem = ({
+  sourceConfig,
+  handleChange,
+  disabled,
+}: {
+  sourceConfig: SourceConfig;
+  handleChange: (sourceConfig: SourceConfig) => void;
+  disabled: boolean;
+}) => {
+  const [editNameMode, toggleEditNameMode] = useReducer((prev) => !prev, false);
+  const prevName = useRef(sourceConfig.display_name || sourceConfig.name);
+  const [name, setName] = useState(
+    sourceConfig.display_name || sourceConfig.name
+  );
+
+  const handleNameChange = (newName: string) => {
+    setName(newName);
+    handleChange({ ...sourceConfig, display_name: newName });
+  };
+
+  const handleNameChangeCancel = () => {
+    setName(prevName.current);
+    handleChange({ ...sourceConfig, display_name: prevName.current });
+    toggleEditNameMode();
+  };
+
+  return (
+    <SourceConfigStyles.ItemContainer>
+      {editNameMode && !disabled ? (
+        <>
+          <SourceConfigStyles.ItemTitle>
+            <PlainTextInput
+              value={name}
+              onChange={(e) => handleNameChange(e.target.value)}
+              type="text"
+              disabled={disabled}
+            />
+            <EditButton onClick={handleNameChangeCancel}>
+              <i className="material-icons-outlined">close</i>
+            </EditButton>
+          </SourceConfigStyles.ItemTitle>
+        </>
+      ) : (
+        <SourceConfigStyles.ItemTitle>
+          <span>{name}</span>
+
+          <EditButton onClick={toggleEditNameMode}>
+            <i className="material-icons-outlined">edit</i>
+          </EditButton>
+        </SourceConfigStyles.ItemTitle>
+      )}
+
+      <SourceEditorDocker
+        sourceConfig={sourceConfig}
+        onChange={handleChange}
+        readOnly={disabled}
+      />
+    </SourceConfigStyles.ItemContainer>
+  );
+};
+
+const EditButton = styled.button`
+  outline: none;
+  cursor: pointer;
+  color: white;
+  border: 1px solid rgba(255, 255, 255, 0.333);
+  background: rgba(255, 255, 255, 0.067);
+  height: 35px;
+  width: 35px;
+  border-radius: 24px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  > i {
+    font-size: 20px;
+  }
+`;
+
+const PlainTextInput = styled.input`
+  outline: none;
+  border: 1px solid #ffffff55;
+  border-radius: 3px;
+  font-size: 13px;
+  background: #ffffff11;
+  width: 100%;
+  color: white;
+  padding: 5px 10px;
+  height: 35px;
+`;

+ 67 - 5
dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/components/Settings.tsx

@@ -1,16 +1,30 @@
 import Heading from "components/form-components/Heading";
 import Heading from "components/form-components/Heading";
-import React, { useContext } from "react";
+import Helper from "components/form-components/Helper";
+import InputRow from "components/form-components/InputRow";
+import React, { useContext, useState } from "react";
+import api from "shared/api";
 import { Context } from "shared/Context";
 import { Context } from "shared/Context";
 import styled from "styled-components";
 import styled from "styled-components";
+import { SubmitButton } from "../../launch/components/styles";
+import { Stack } from "../../types";
 
 
 const Settings = ({
 const Settings = ({
-  stackName,
+  stack,
   onDelete,
   onDelete,
+  onUpdate,
 }: {
 }: {
-  stackName: string;
+  stack: Stack;
   onDelete: () => void;
   onDelete: () => void;
+  onUpdate: () => Promise<void>;
 }) => {
 }) => {
-  const { setCurrentOverlay } = useContext(Context);
+  const {
+    currentCluster,
+    currentProject,
+    setCurrentOverlay,
+    setCurrentError,
+  } = useContext(Context);
+  const [stackName, setStackName] = useState(stack.name);
+  const [buttonStatus, setButtonStatus] = useState("");
 
 
   const handleDelete = () => {
   const handleDelete = () => {
     setCurrentOverlay({
     setCurrentOverlay({
@@ -22,10 +36,54 @@ const Settings = ({
       onNo: () => setCurrentOverlay(null),
       onNo: () => setCurrentOverlay(null),
     });
     });
   };
   };
+
+  const handleStackNameChange = async () => {
+    setButtonStatus("loading");
+    try {
+      await api.updateStack(
+        "<token>",
+        {
+          name: stackName,
+        },
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+          stack_id: stack.id,
+          namespace: stack.namespace,
+        }
+      );
+      await onUpdate();
+      setButtonStatus("successful");
+    } catch (err) {
+      setCurrentError(err);
+      setButtonStatus("Couldn't update the stack name. Try again later.");
+    }
+  };
+
   return (
   return (
     <Wrapper>
     <Wrapper>
       <StyledSettingsSection>
       <StyledSettingsSection>
-        <Heading>Settings</Heading>
+        <Heading>Update Stack name</Heading>
+
+        <InputRow
+          label="Stack name"
+          value={stackName}
+          setValue={setStackName as any}
+          type="text"
+          width="300px"
+        />
+        <SaveButton
+          text="Update"
+          onClick={handleStackNameChange}
+          disabled={stackName === stack.name}
+          makeFlush
+          clearPosition
+          statusPosition="right"
+          status={buttonStatus}
+        ></SaveButton>
+
+        <Heading>Additional Settings</Heading>
+
         <Button color="#b91133" onClick={handleDelete}>
         <Button color="#b91133" onClick={handleDelete}>
           Delete stack
           Delete stack
         </Button>
         </Button>
@@ -36,6 +94,10 @@ const Settings = ({
 
 
 export default Settings;
 export default Settings;
 
 
+const SaveButton = styled(SubmitButton)`
+  justify-content: flex-start;
+`;
+
 const Wrapper = styled.div`
 const Wrapper = styled.div`
   width: 100%;
   width: 100%;
   padding-bottom: 65px;
   padding-bottom: 65px;

+ 20 - 2
dashboard/src/main/home/cluster-dashboard/stacks/launch/SelectSource.tsx

@@ -8,10 +8,11 @@ import Helper from "components/form-components/Helper";
 import Heading from "components/form-components/Heading";
 import Heading from "components/form-components/Heading";
 import styled from "styled-components";
 import styled from "styled-components";
 import TitleSection from "components/TitleSection";
 import TitleSection from "components/TitleSection";
+import InputRow from "components/form-components/InputRow";
 
 
 const SelectSource = () => {
 const SelectSource = () => {
   const { addSourceConfig } = useContext(StacksLaunchContext);
   const { addSourceConfig } = useContext(StacksLaunchContext);
-
+  const [sourceName, setSourceName] = useState("");
   const [imageUrl, setImageUrl] = useState("");
   const [imageUrl, setImageUrl] = useState("");
   const [imageTag, setImageTag] = useState("");
   const [imageTag, setImageTag] = useState("");
   const { pushFiltered } = useRouting();
   const { pushFiltered } = useRouting();
@@ -22,6 +23,7 @@ const SelectSource = () => {
     }
     }
 
 
     const newSource: Omit<CreateStackBody["source_configs"][0], "name"> = {
     const newSource: Omit<CreateStackBody["source_configs"][0], "name"> = {
+      display_name: sourceName,
       image_repo_uri: imageUrl,
       image_repo_uri: imageUrl,
       image_tag: imageTag,
       image_tag: imageTag,
     };
     };
@@ -39,11 +41,23 @@ const SelectSource = () => {
         New Application Stack
         New Application Stack
       </TitleSection>
       </TitleSection>
       <Heading>Stack Source</Heading>
       <Heading>Stack Source</Heading>
+
+      <Br />
+      <InputRowWrapper>
+        <InputRow
+          label="Source Name"
+          value={sourceName}
+          setValue={(val) => setSourceName(val as string)}
+          type="text"
+          width="100%"
+          placeholder="Leave empty for auto-generated source config name"
+        />
+      </InputRowWrapper>
+
       <Helper>
       <Helper>
         Specify a source to deploy all stack applications from:
         Specify a source to deploy all stack applications from:
         <Required>*</Required>
         <Required>*</Required>
       </Helper>
       </Helper>
-      <Br />
       <ImageSelector
       <ImageSelector
         selectedImageUrl={imageUrl}
         selectedImageUrl={imageUrl}
         setSelectedImageUrl={setImageUrl}
         setSelectedImageUrl={setImageUrl}
@@ -86,3 +100,7 @@ const Polymer = styled.div`
     margin-right: 18px;
     margin-right: 18px;
   }
   }
 `;
 `;
+
+const InputRowWrapper = styled.div`
+  width: 60%;
+`;

+ 4 - 1
dashboard/src/main/home/cluster-dashboard/stacks/launch/Store.tsx

@@ -96,8 +96,11 @@ const StacksLaunchContextProvider: React.FC<{}> = ({ children }) => {
       source_configs: [
       source_configs: [
         ...prev.source_configs,
         ...prev.source_configs,
         {
         {
-          name: newSourceConfigName(prev.source_configs.length),
           ...sourceConfig,
           ...sourceConfig,
+          display_name:
+            sourceConfig.display_name ||
+            newSourceConfigName(prev.source_configs.length),
+          name: newSourceConfigName(prev.source_configs.length),
         },
         },
       ],
       ],
     }));
     }));

+ 2 - 0
dashboard/src/main/home/cluster-dashboard/stacks/types.ts

@@ -9,6 +9,7 @@ export type CreateStackBody = {
     values: unknown;
     values: unknown;
   }[];
   }[];
   source_configs: {
   source_configs: {
+    display_name: string;
     name: string;
     name: string;
     image_repo_uri: string;
     image_repo_uri: string;
     image_tag: string;
     image_tag: string;
@@ -80,6 +81,7 @@ export type StackRevision = {
 
 
 export type SourceConfig = {
 export type SourceConfig = {
   id: string;
   id: string;
+  display_name: string;
   name: string;
   name: string;
   created_at: string;
   created_at: string;
   updated_at: string;
   updated_at: string;

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

@@ -2022,6 +2022,22 @@ const createStack = baseApi<
     `/api/v1/projects/${project_id}/clusters/${cluster_id}/namespaces/${namespace}/stacks`
     `/api/v1/projects/${project_id}/clusters/${cluster_id}/namespaces/${namespace}/stacks`
 );
 );
 
 
+const updateStack = baseApi<
+  {
+    name: string;
+  },
+  {
+    project_id: number;
+    cluster_id: number;
+    namespace: string;
+    stack_id: string;
+  }
+>(
+  "PATCH",
+  ({ project_id, cluster_id, namespace, stack_id }) =>
+    `/api/v1/projects/${project_id}/clusters/${cluster_id}/namespaces/${namespace}/stacks/${stack_id}`
+);
+
 const listStacks = baseApi<
 const listStacks = baseApi<
   {},
   {},
   { project_id: number; cluster_id: number; namespace: string }
   { project_id: number; cluster_id: number; namespace: string }
@@ -2359,6 +2375,7 @@ export default {
   getStack,
   getStack,
   getStackRevision,
   getStackRevision,
   createStack,
   createStack,
+  updateStack,
   rollbackStack,
   rollbackStack,
   deleteStack,
   deleteStack,
   updateStackSourceConfig,
   updateStackSourceConfig,

+ 3 - 1
internal/kubernetes/prometheus/metrics.go

@@ -301,7 +301,7 @@ func getSelectionRegex(kind, name string) (string, error) {
 
 
 	switch strings.ToLower(kind) {
 	switch strings.ToLower(kind) {
 	case "deployment":
 	case "deployment":
-		suffix = "[a-z0-9]+-[a-z0-9]+"
+		suffix = "[a-z0-9]+"
 	case "statefulset":
 	case "statefulset":
 		suffix = "[0-9]+"
 		suffix = "[0-9]+"
 	case "job":
 	case "job":
@@ -310,6 +310,8 @@ func getSelectionRegex(kind, name string) (string, error) {
 		suffix = "[a-z0-9]+-[a-z0-9]+"
 		suffix = "[a-z0-9]+-[a-z0-9]+"
 	case "ingress":
 	case "ingress":
 		return name, nil
 		return name, nil
+	case "daemonset":
+		suffix = "[a-z0-9]+"
 	default:
 	default:
 		return "", fmt.Errorf("not a supported controller to query for metrics")
 		return "", fmt.Errorf("not a supported controller to query for metrics")
 	}
 	}

+ 3 - 0
internal/models/stack.go

@@ -164,6 +164,8 @@ type StackSourceConfig struct {
 
 
 	Name string
 	Name string
 
 
+	DisplayName string
+
 	UID string
 	UID string
 
 
 	ImageRepoURI string
 	ImageRepoURI string
@@ -183,6 +185,7 @@ func (s StackSourceConfig) ToStackSourceConfigType(stackID string, stackRevision
 		ID:              s.UID,
 		ID:              s.UID,
 		ImageRepoURI:    s.ImageRepoURI,
 		ImageRepoURI:    s.ImageRepoURI,
 		ImageTag:        s.ImageTag,
 		ImageTag:        s.ImageTag,
+		DisplayName:     s.DisplayName,
 	}
 	}
 }
 }
 
 

+ 8 - 0
internal/repository/gorm/stack.go

@@ -118,6 +118,14 @@ func (repo *StackRepository) DeleteStack(stack *models.Stack) (*models.Stack, er
 	return stack, nil
 	return stack, nil
 }
 }
 
 
+func (repo *StackRepository) UpdateStack(stack *models.Stack) (*models.Stack, error) {
+	if err := repo.db.Save(stack).Error; err != nil {
+		return nil, err
+	}
+
+	return stack, nil
+}
+
 func (repo *StackRepository) UpdateStackRevision(revision *models.StackRevision) (*models.StackRevision, error) {
 func (repo *StackRepository) UpdateStackRevision(revision *models.StackRevision) (*models.StackRevision, error) {
 	if err := repo.db.Save(revision).Error; err != nil {
 	if err := repo.db.Save(revision).Error; err != nil {
 		return nil, err
 		return nil, err

+ 1 - 0
internal/repository/stack.go

@@ -9,6 +9,7 @@ type StackRepository interface {
 	ReadStackByStringID(projectID uint, stackID string) (*models.Stack, error)
 	ReadStackByStringID(projectID uint, stackID string) (*models.Stack, error)
 	ListStacks(projectID uint, clusterID uint, namespace string) ([]*models.Stack, error)
 	ListStacks(projectID uint, clusterID uint, namespace string) ([]*models.Stack, error)
 	DeleteStack(stack *models.Stack) (*models.Stack, error)
 	DeleteStack(stack *models.Stack) (*models.Stack, error)
+	UpdateStack(stack *models.Stack) (*models.Stack, error)
 
 
 	UpdateStackRevision(revision *models.StackRevision) (*models.StackRevision, error)
 	UpdateStackRevision(revision *models.StackRevision) (*models.StackRevision, error)
 	ReadStackRevision(stackRevisionID uint) (*models.StackRevision, error)
 	ReadStackRevision(stackRevisionID uint) (*models.StackRevision, error)

+ 4 - 0
internal/repository/test/stack.go

@@ -35,6 +35,10 @@ func (repo *StackRepository) DeleteStack(stack *models.Stack) (*models.Stack, er
 	panic("unimplemented")
 	panic("unimplemented")
 }
 }
 
 
+func (repo *StackRepository) UpdateStack(stack *models.Stack) (*models.Stack, error) {
+	panic("unimplemented")
+}
+
 func (repo *StackRepository) UpdateStackRevision(revision *models.StackRevision) (*models.StackRevision, error) {
 func (repo *StackRepository) UpdateStackRevision(revision *models.StackRevision) (*models.StackRevision, error) {
 	panic("unimplemented")
 	panic("unimplemented")
 }
 }

+ 1 - 0
internal/stacks/helpers.go

@@ -22,6 +22,7 @@ func CloneSourceConfigs(sourceConfigs []models.StackSourceConfig) ([]models.Stac
 		res = append(res, models.StackSourceConfig{
 		res = append(res, models.StackSourceConfig{
 			UID:          uid,
 			UID:          uid,
 			Name:         sourceConfig.Name,
 			Name:         sourceConfig.Name,
+			DisplayName:  sourceConfig.DisplayName,
 			ImageRepoURI: sourceConfig.ImageRepoURI,
 			ImageRepoURI: sourceConfig.ImageRepoURI,
 			ImageTag:     sourceConfig.ImageTag,
 			ImageTag:     sourceConfig.ImageTag,
 		})
 		})