diff --git a/api/server/handlers/porter_app/validate.go b/api/server/handlers/porter_app/validate.go index 459d014e6c..222935348a 100644 --- a/api/server/handlers/porter_app/validate.go +++ b/api/server/handlers/porter_app/validate.go @@ -112,14 +112,28 @@ func (c *ValidatePorterAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ } } + existingApps, err := c.Repo().PorterApp().ListPorterAppsByProjectID(project.ID) + if err != nil { + err := telemetry.Error(ctx, span, err, "error listing porter apps by project id") + c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError)) + return + } + if appProto.Name == "" { err := telemetry.Error(ctx, span, nil, "app proto name is empty") c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest)) return } + telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "app-name", Value: appProto.Name}) + for _, existingApp := range existingApps { + if existingApp.Name == appProto.Name { + err := telemetry.Error(ctx, span, nil, "app with the provided name already exists in the project") + c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest)) + return + } + } telemetry.WithAttributes(span, - telemetry.AttributeKV{Key: "app-name", Value: appProto.Name}, telemetry.AttributeKV{Key: "deployment-target-id", Value: request.DeploymentTargetId}, telemetry.AttributeKV{Key: "commit-sha", Value: request.CommitSHA}, ) diff --git a/dashboard/src/lib/porter-apps/index.ts b/dashboard/src/lib/porter-apps/index.ts index 76280d7c2d..7ca7a7c8c8 100644 --- a/dashboard/src/lib/porter-apps/index.ts +++ b/dashboard/src/lib/porter-apps/index.ts @@ -59,7 +59,13 @@ export const deletionValidator = z.object({ export const clientAppValidator = z.object({ name: z.object({ readOnly: z.boolean(), - value: z.string(), + value: z + .string() + .min(1, { message: "Name must be at least 1 character" }) + .max(30, { message: "Name must be 30 characters or less" }) + .regex(/^[a-z0-9-]{1,61}$/, { + message: 'Lowercase letters, numbers, and "-" only.', + }), }), envGroups: z .object({ name: z.string(), version: z.bigint() }) diff --git a/dashboard/src/main/home/app-dashboard/create-app/CreateApp.tsx b/dashboard/src/main/home/app-dashboard/create-app/CreateApp.tsx index c202661219..df383fe8f2 100644 --- a/dashboard/src/main/home/app-dashboard/create-app/CreateApp.tsx +++ b/dashboard/src/main/home/app-dashboard/create-app/CreateApp.tsx @@ -80,7 +80,7 @@ const CreateApp: React.FC = ({ history }) => { const { maxCPU, maxRAM } = useClusterResourceLimits({ projectId: currentProject?.id, clusterId: currentCluster?.id, - }) + }); const { data: porterApps = [] } = useQuery( ["getPorterApps", currentProject?.id, currentCluster?.id], @@ -513,7 +513,7 @@ const CreateApp: React.FC = ({ history }) => { placeholder="ex: academic-sophon" type="text" width="300px" - error={errors.app?.name?.message} + error={errors.app?.name?.value?.message} disabled={name.readOnly} disabledTooltip={ "You may only edit this field in your porter.yaml." @@ -585,10 +585,23 @@ const CreateApp: React.FC = ({ history }) => { setValue("source.image", { ...image, repository: uri })} + setImageUri={(uri: string) => + setValue("source.image", { + ...image, + repository: uri, + }) + } imageTag={image?.tag ?? ""} - setImageTag={(tag: string) => setValue("source.image", { ...image, tag })} - resetImageInfo={() => setValue("source.image", { ...image, repository: "", tag: "" })} + setImageTag={(tag: string) => + setValue("source.image", { ...image, tag }) + } + resetImageInfo={() => + setValue("source.image", { + ...image, + repository: "", + tag: "", + }) + } /> ) ) : null} @@ -614,8 +627,9 @@ const CreateApp: React.FC = ({ history }) => { } > {detectedServices.count > 0 - ? `Detected ${detectedServices.count} service${detectedServices.count > 1 ? "s" : "" - } from porter.yaml.` + ? `Detected ${detectedServices.count} service${ + detectedServices.count > 1 ? "s" : "" + } from porter.yaml.` : `Could not detect any services from porter.yaml. Make sure it exists in the root of your repo.`} diff --git a/internal/repository/gorm/porter_app.go b/internal/repository/gorm/porter_app.go index 47b3df55f5..e4c988d365 100644 --- a/internal/repository/gorm/porter_app.go +++ b/internal/repository/gorm/porter_app.go @@ -34,6 +34,17 @@ func (repo *PorterAppRepository) ListPorterAppByClusterID(clusterID uint) ([]*mo return apps, nil } +// ListPorterAppsByProjectID returns a list of PorterApps by project ID. +func (repo *PorterAppRepository) ListPorterAppsByProjectID(projectID uint) ([]*models.PorterApp, error) { + apps := []*models.PorterApp{} + + if err := repo.db.Where("project_id = ?", projectID).Find(&apps).Error; err != nil { + return nil, err + } + + return apps, nil +} + func (repo *PorterAppRepository) ReadPorterAppByName(clusterID uint, name string) (*models.PorterApp, error) { app := &models.PorterApp{} diff --git a/internal/repository/porter_app.go b/internal/repository/porter_app.go index 5e4a3a3313..c4221ca482 100644 --- a/internal/repository/porter_app.go +++ b/internal/repository/porter_app.go @@ -10,6 +10,7 @@ type PorterAppRepository interface { ReadPorterAppsByProjectIDAndName(projectID uint, name string) ([]*models.PorterApp, error) CreatePorterApp(app *models.PorterApp) (*models.PorterApp, error) ListPorterAppByClusterID(clusterID uint) ([]*models.PorterApp, error) + ListPorterAppsByProjectID(projectID uint) ([]*models.PorterApp, error) UpdatePorterApp(app *models.PorterApp) (*models.PorterApp, error) DeletePorterApp(app *models.PorterApp) (*models.PorterApp, error) } diff --git a/internal/repository/test/porter_app.go b/internal/repository/test/porter_app.go index cb9eef74da..a338f4d3d4 100644 --- a/internal/repository/test/porter_app.go +++ b/internal/repository/test/porter_app.go @@ -34,10 +34,16 @@ func (repo *PorterAppRepository) UpdatePorterApp(app *models.PorterApp) (*models return nil, errors.New("cannot write database") } +// ListPorterAppByClusterID is a test method that is not implemented func (repo *PorterAppRepository) ListPorterAppByClusterID(clusterID uint) ([]*models.PorterApp, error) { return nil, errors.New("cannot write database") } +// ListPorterAppsByProjectID is a test method that is not implemented +func (repo *PorterAppRepository) ListPorterAppsByProjectID(projectID uint) ([]*models.PorterApp, error) { + return nil, errors.New("cannot write database") +} + func (repo *PorterAppRepository) DeletePorterApp(app *models.PorterApp) (*models.PorterApp, error) { return nil, errors.New("cannot write database") }