jusrhee 2 лет назад
Родитель
Сommit
f91cb09334

+ 30 - 7
api/server/handlers/environment_groups/list.go

@@ -1,6 +1,7 @@
 package environment_groups
 
 import (
+	"encoding/base64"
 	"net/http"
 	"strings"
 	"time"
@@ -46,14 +47,22 @@ type ListEnvironmentGroupsResponse struct {
 	EnvironmentGroups []EnvironmentGroupListItem `json:"environment_groups,omitempty"`
 }
 
+// EnvironmentGroupFile represents a file in an environment group
+type EnvironmentGroupFile struct {
+	Name     string `json:"name"`
+	Contents string `json:"contents"`
+}
+
+// EnvironmentGroupListItem represents an environment group in the list response
 type EnvironmentGroupListItem struct {
-	Name               string            `json:"name"`
-	Type               string            `json:"type"`
-	LatestVersion      int               `json:"latest_version"`
-	Variables          map[string]string `json:"variables,omitempty"`
-	SecretVariables    map[string]string `json:"secret_variables,omitempty"`
-	CreatedAtUTC       time.Time         `json:"created_at"`
-	LinkedApplications []string          `json:"linked_applications,omitempty"`
+	Name               string                 `json:"name"`
+	Type               string                 `json:"type"`
+	LatestVersion      int                    `json:"latest_version"`
+	Variables          map[string]string      `json:"variables,omitempty"`
+	SecretVariables    map[string]string      `json:"secret_variables,omitempty"`
+	CreatedAtUTC       time.Time              `json:"created_at"`
+	LinkedApplications []string               `json:"linked_applications,omitempty"`
+	Files              []EnvironmentGroupFile `json:"files,omitempty"`
 }
 
 func (c *ListEnvironmentGroupsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -93,12 +102,26 @@ func (c *ListEnvironmentGroupsHandler) ServeHTTP(w http.ResponseWriter, r *http.
 
 		var envGroups []EnvironmentGroupListItem
 		for _, envGroup := range listEnvGroupResp.Msg.EnvGroups {
+			var files []EnvironmentGroupFile
+			for _, file := range envGroup.Files {
+				decoded, err := base64.StdEncoding.DecodeString(file.B64Contents)
+				if err != nil {
+					err = telemetry.Error(ctx, span, err, "unable to decode base64 contents")
+					c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+					return
+				}
+				files = append(files, EnvironmentGroupFile{
+					Name:     file.Name,
+					Contents: string(decoded),
+				})
+			}
 			envGroups = append(envGroups, EnvironmentGroupListItem{
 				Name:               envGroup.Name,
 				Type:               translateProtoTypeToEnvGroupType[envGroup.Type],
 				LatestVersion:      int(envGroup.Version),
 				Variables:          envGroup.Variables,
 				SecretVariables:    envGroup.SecretVariables,
+				Files:              files,
 				CreatedAtUTC:       envGroup.CreatedAt.AsTime(),
 				LinkedApplications: envGroup.LinkedApplications,
 			})

+ 15 - 0
api/server/handlers/environment_groups/create.go → api/server/handlers/environment_groups/update.go

@@ -1,6 +1,7 @@
 package environment_groups
 
 import (
+	"encoding/base64"
 	"net/http"
 	"time"
 
@@ -57,6 +58,9 @@ type UpdateEnvironmentGroupRequest struct {
 	// SecretVariables are sensitive values. All values must be a string due to a kubernetes limitation.
 	SecretVariables map[string]string `json:"secret_variables"`
 
+	// Files is a list of files associated with the env group
+	Files []EnvironmentGroupFile `json:"files"`
+
 	// IsEnvOverride is a flag to determine if provided variables should override or merge with existing variables
 	IsEnvOverride bool `json:"is_env_override"`
 
@@ -110,6 +114,16 @@ func (c *UpdateEnvironmentGroupHandler) ServeHTTP(w http.ResponseWriter, r *http
 		}
 
 	default:
+		var files []*porterv1.EnvGroupFile
+		for _, file := range request.Files {
+			contents := file.Contents
+			encoded := base64.StdEncoding.EncodeToString([]byte(contents))
+			files = append(files, &porterv1.EnvGroupFile{
+				Name:        file.Name,
+				B64Contents: encoded,
+			})
+		}
+
 		_, err := c.Config().ClusterControlPlaneClient.CreateOrUpdateEnvGroup(ctx, connect.NewRequest(&porterv1.CreateOrUpdateEnvGroupRequest{
 			ProjectId:            int64(cluster.ProjectID),
 			ClusterId:            int64(cluster.ID),
@@ -118,6 +132,7 @@ func (c *UpdateEnvironmentGroupHandler) ServeHTTP(w http.ResponseWriter, r *http
 			EnvVars: &porterv1.EnvGroupVariables{
 				Normal: request.Variables,
 				Secret: request.SecretVariables,
+				Files:  files,
 			},
 			EnvVariableDeletions: &porterv1.EnvVariableDeletions{
 				Variables: request.Deletions.Variables,

+ 12 - 4
dashboard/src/components/porter/Button.tsx

@@ -2,6 +2,7 @@ import React, { useEffect, useState } from "react";
 import styled, { keyframes } from "styled-components";
 
 import loading from "assets/loading.gif";
+
 import Tooltip from "./Tooltip";
 
 type Props = {
@@ -22,6 +23,7 @@ type Props = {
   type?: React.ButtonHTMLAttributes<HTMLButtonElement>["type"];
   disabledTooltipMessage?: string;
   disabledTooltipPosition?: "top" | "right" | "bottom" | "left";
+  highlight?: boolean;
 };
 
 const Button: React.FC<Props> = ({
@@ -42,6 +44,7 @@ const Button: React.FC<Props> = ({
   type = "button",
   disabledTooltipMessage,
   disabledTooltipPosition = "right",
+  highlight,
 }) => {
   const renderStatus = () => {
     switch (status) {
@@ -78,7 +81,10 @@ const Button: React.FC<Props> = ({
   };
 
   return disabled && disabledTooltipMessage ? (
-    <Tooltip content={disabledTooltipMessage} position={disabledTooltipPosition}>
+    <Tooltip
+      content={disabledTooltipMessage}
+      position={disabledTooltipPosition}
+    >
       <Wrapper>
         <StyledButton
           disabled={disabled}
@@ -94,6 +100,7 @@ const Button: React.FC<Props> = ({
           rounded={rounded || alt}
           alt={alt}
           type={type}
+          highlight={highlight}
         >
           <Text>{children}</Text>
         </StyledButton>
@@ -116,6 +123,7 @@ const Button: React.FC<Props> = ({
         rounded={rounded || alt}
         alt={alt}
         type={type}
+        highlight={highlight}
       >
         <Text>{children}</Text>
       </StyledButton>
@@ -183,6 +191,7 @@ const StyledButton = styled.button<{
   withBorder?: boolean;
   rounded?: boolean;
   alt?: boolean;
+  highlight?: boolean;
 }>`
   height: ${(props) => props.height || "35px"};
   width: ${(props) => props.width || "auto"};
@@ -198,15 +207,14 @@ const StyledButton = styled.button<{
     if (props.alt || props.color === "fg") {
       return props.theme.fg;
     }
-    return props.disabled
-      ? "#aaaabb"
-      : props.color || props.theme.button;
+    return props.disabled ? "#aaaabb" : props.color || props.theme.button;
   }};
   display: flex;
   align-items: center;
   justify-content: center;
   border-radius: ${(props) => (props.rounded ? "50px" : "5px")};
   border: ${(props) => (props.withBorder ? "1px solid #494b4f" : "none")};
+  filter: ${(props) => (props.highlight ? "brightness(120%)" : "")};
 
   :hover {
     filter: ${(props) => (props.disabled ? "" : "brightness(120%)")};

+ 4 - 5
dashboard/src/components/porter/Clickable.tsx

@@ -1,5 +1,6 @@
 import React from "react";
 import styled from "styled-components";
+
 import Container from "./Container";
 
 type Props = {
@@ -12,11 +13,7 @@ type Props = {
   onClick?: () => void;
 };
 
-const Clickable: React.FC<Props> = ({
-  children,
-  style,
-  onClick,
-}) => {
+const Clickable: React.FC<Props> = ({ children, style, onClick }) => {
   return (
     <StyledClickable onClick={onClick} style={style}>
       {children}
@@ -37,4 +34,6 @@ const StyledClickable = styled.div`
   :hover {
     border: 1px solid #7a7b80;
   }
+  display: flex;
+  align-items: center;
 `;

+ 326 - 0
dashboard/src/components/porter/FileArray.tsx

@@ -0,0 +1,326 @@
+import React, { useEffect, useRef, useState } from "react";
+import AceEditor from "react-ace";
+import styled from "styled-components";
+
+import Button from "components/porter/Button";
+import Clickable from "components/porter/Clickable";
+import Container from "components/porter/Container";
+import Image from "components/porter/Image";
+import Input from "components/porter/Input";
+import Modal from "components/porter/Modal";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+
+import fileIcon from "assets/file.svg";
+
+type File = {
+  name: string;
+  contents: string;
+};
+
+type PropsType = {
+  files: File[];
+  setFiles: (x: File[]) => void;
+  disabled?: boolean;
+};
+
+const FileArray = ({
+  files,
+  setFiles,
+  disabled,
+}: PropsType): React.ReactElement => {
+  const [showDropdown, setShowDropdown] = useState(false);
+  const [expandedFile, setExpandedFile] = useState<File | null>();
+  const [expandedFileName, setExpandedFileName] = useState<string>("");
+  const [expandedFileContent, setExpandedFileContent] = useState<string>("");
+
+  useEffect(() => {
+    if (expandedFile) {
+      setExpandedFileName(expandedFile.name);
+      setExpandedFileContent(expandedFile.contents);
+    }
+  }, [expandedFile]);
+
+  const fileInputRef = useRef(null);
+
+  // Function to simulate click on file input
+  const handleButtonClick = (): void => {
+    fileInputRef?.current?.click();
+    setShowDropdown(false);
+  };
+
+  // Function to handle file selection
+  const handleFileChange = (
+    event: React.ChangeEvent<HTMLInputElement>
+  ): void => {
+    const file = event?.target?.files?.[0];
+    event.target.value = "";
+    if (file) {
+      // Handle the file, e.g., store in state, display details, upload, etc.
+      const reader = new FileReader();
+
+      // Read the file as text
+      reader.readAsText(file);
+
+      reader.onload = () => {
+        // reader.result contains the contents of the file as a text string
+        const fileContent = reader.result;
+        const _files = [...files];
+        _files.push({ name: file.name, contents: fileContent as string });
+        setFiles(_files);
+      };
+
+      reader.onerror = () => {
+        console.error("Error reading file:", reader.error);
+      };
+    }
+  };
+
+  return (
+    <Relative>
+      {!!files?.length &&
+        files.map((file: File, i: number) => {
+          return (
+            <>
+              <Container row>
+                <Clickable
+                  style={{ padding: "10px 15px" }}
+                  key={i}
+                  onClick={() => {
+                    setExpandedFile(file);
+                  }}
+                >
+                  <Image src={fileIcon} size={16} />
+                  <Spacer x={0.5} inline />
+                  {file.name}
+                </Clickable>
+                {!disabled && (
+                  <DeleteButton
+                    onClick={() => {
+                      const _files = [...files];
+                      _files.splice(i, 1);
+                      setFiles(_files);
+                    }}
+                  >
+                    <i className="material-icons">cancel</i>
+                  </DeleteButton>
+                )}
+              </Container>
+              {i === files.length - 1 ? (
+                <Spacer y={1} />
+              ) : (
+                <Spacer height="10px" />
+              )}
+            </>
+          );
+        })}
+      {!disabled && (
+        <Button
+          alt
+          onClick={() => {
+            setShowDropdown(true);
+          }}
+        >
+          <I className="material-icons">add</I> Add file{" "}
+          <Arrow className="material-icons">arrow_drop_down</Arrow>
+        </Button>
+      )}
+      <input
+        type="file"
+        style={{ display: "none" }}
+        ref={fileInputRef}
+        onChange={handleFileChange}
+      />
+      {showDropdown && (
+        <>
+          <CloseOverlay
+            onClick={() => {
+              setShowDropdown(false);
+            }}
+          />
+          <Dropdown>
+            <DropdownButton
+              onClick={() => {
+                setExpandedFile({
+                  name: "",
+                  contents: "",
+                });
+                setShowDropdown(false);
+              }}
+            >
+              <I className="material-icons">add</I>Create new file
+            </DropdownButton>
+            <DropdownButton onClick={handleButtonClick}>
+              <I className="material-icons">upload</I>Upload file
+            </DropdownButton>
+          </Dropdown>
+        </>
+      )}
+      {expandedFile && (
+        <Modal
+          closeModal={() => {
+            setExpandedFile(null);
+          }}
+        >
+          <Input
+            placeholder="Name your file . . ."
+            autoFocus
+            value={expandedFileName}
+            setValue={setExpandedFileName}
+          />
+          <Spacer height="15px" />
+          <Text color="helper">
+            View and edit the contents of the file below.
+          </Text>
+          <Spacer y={1} />
+          <Holder>
+            <AceEditor
+              value={expandedFileContent}
+              theme="porter"
+              onChange={setExpandedFileContent}
+              name="codeEditor"
+              readOnly={disabled}
+              height="calc(100vh - 400px)"
+              width="100%"
+              style={{ borderRadius: "10px" }}
+              showPrintMargin={false}
+              showGutter={true}
+              highlightActiveLine={true}
+              fontSize={14}
+            />
+          </Holder>
+          <Spacer y={1} />
+          <Button
+            onClick={() => {
+              const _files = [...files];
+              let found = false;
+              _files.forEach((f) => {
+                if (f.name === expandedFile.name) {
+                  f.name = expandedFileName;
+                  f.contents = expandedFileContent;
+                  found = true;
+                }
+              });
+
+              if (!found) {
+                _files.push({
+                  name: expandedFileName,
+                  contents: expandedFileContent,
+                });
+              }
+              setFiles(_files);
+              setExpandedFile(null);
+            }}
+          >
+            Save changes
+          </Button>
+        </Modal>
+      )}
+    </Relative>
+  );
+};
+
+export default FileArray;
+
+const Holder = styled.div`
+  .ace_scrollbar {
+    display: none;
+  }
+  .ace_editor,
+  .ace_editor * {
+    color: #aaaabb;
+    font-family: "Monaco", "Menlo", "Ubuntu Mono", "Droid Sans Mono", "Consolas",
+      monospace !important;
+    font-size: 12px !important;
+    font-weight: 400 !important;
+    letter-spacing: 0 !important;
+  }
+`;
+
+const DeleteButton = styled.div`
+  width: 15px;
+  height: 15px;
+  display: flex;
+  align-items: center;
+  margin-left: 8px;
+  margin-top: 0px;
+  justify-content: center;
+
+  > i {
+    font-size: 17px;
+    color: #ffffff44;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    cursor: pointer;
+    :hover {
+      color: #ffffff88;
+    }
+  }
+`;
+
+const CloseOverlay = styled.div`
+  position: fixed;
+  top: 0;
+  left: 0;
+  z-index: 998;
+  width: 100vw;
+  height: 100vh;
+`;
+
+const Relative = styled.div`
+  position: relative;
+`;
+
+const Arrow = styled.i`
+  font-size: 18px;
+  margin-right: -5px;
+  margin-left: 7px;
+`;
+
+const I = styled.i`
+  font-size: 16px;
+  margin-right: 7px;
+`;
+
+const DropdownButton = styled.div`
+  cursor: pointer;
+  padding: 13px;
+  padding-right: 15px;
+  position: relative;
+  height: 40px;
+  font-size: 13px;
+  color: #ffffff88;
+  display: flex;
+  align-items: center;
+  user-select: none;
+  :hover {
+    color: #fff;
+    > i {
+      color: #fff;
+    }
+    > img {
+      opacity: 100%;
+    }
+  }
+  > i {
+    color: #ffffff88;
+  }
+`;
+
+const Dropdown = styled.div<{
+  width?: string;
+  maxHeight?: string;
+}>`
+  position: absolute;
+  left: 0;
+  top: calc(100% + 5px);
+  background: #121212;
+  width: ${(props) => props.width || ""};
+  max-height: ${(props) => props.maxHeight || "300px"};
+  border-radius: 5px;
+  z-index: 999;
+  border: 1px solid #494b4f;
+  overflow-y: auto;
+  margin-bottom: 20px;
+`;

+ 15 - 11
dashboard/src/lib/env-groups/types.ts

@@ -8,17 +8,21 @@ export const envGroupFormValidator = z.object({
     .regex(/^[a-z0-9-]+$/, {
       message: 'Lowercase letters, numbers, and " - " only.',
     }),
-  envVariables: z
-    .array(
-      z.object({
-        key: z.string().min(1, { message: "Key cannot be empty" }),
-        value: z.string().min(1, { message: "Value cannot be empty" }),
-        deleted: z.boolean(),
-        hidden: z.boolean(),
-        locked: z.boolean(),
-      })
-    )
-    .min(1, { message: "At least one environment variable is required" }),
+  envVariables: z.array(
+    z.object({
+      key: z.string().min(1, { message: "Key cannot be empty" }),
+      value: z.string().min(1, { message: "Value cannot be empty" }),
+      deleted: z.boolean(),
+      hidden: z.boolean(),
+      locked: z.boolean(),
+    })
+  ),
+  envFiles: z.array(
+    z.object({
+      name: z.string().min(1, { message: "File name cannot be empty" }),
+      contents: z.string().min(1, { message: "File cannot be empty" }),
+    })
+  ),
 });
 
 export type EnvGroupFormData = z.infer<typeof envGroupFormValidator>;

+ 8 - 0
dashboard/src/main/home/app-dashboard/validate-apply/app-settings/EnvGroupRow.tsx

@@ -4,6 +4,7 @@ import styled from "styled-components";
 
 import Container from "components/porter/Container";
 import Expandable from "components/porter/Expandable";
+import FileArray from "components/porter/FileArray";
 import Image from "components/porter/Image";
 import Spacer from "components/porter/Spacer";
 import Text from "components/porter/Text";
@@ -23,6 +24,7 @@ type Props = {
     type: string;
     variables: Record<string, string>;
     secret_variables: Record<string, string>;
+    files: Array<{ name: string; contents: string }>;
   };
   canDelete?: boolean;
   noLink?: boolean;
@@ -129,6 +131,12 @@ const EnvGroupRow: React.FC<Props> = ({
       }
     >
       <EnvGroupArray values={variables} disabled={true} />
+      {envGroup.files?.length > 0 && (
+        <>
+          <Spacer y={0.5} />
+          <FileArray files={envGroup.files} setFiles={() => {}} disabled />
+        </>
+      )}
     </Expandable>
   );
 };

+ 56 - 49
dashboard/src/main/home/app-dashboard/validate-apply/app-settings/EnvGroups.tsx

@@ -1,21 +1,21 @@
-import React, { useMemo, useState, useContext } from "react";
+import React, { useContext, useMemo, useState } from "react";
 import { useFieldArray, useFormContext } from "react-hook-form";
+import { useHistory } from "react-router";
 import styled from "styled-components";
 import { type IterableElement } from "type-fest";
-import { useHistory } from "react-router";
 
-import { Context } from "shared/Context";
+import Button from "components/porter/Button";
 import Icon from "components/porter/Icon";
 import Spacer from "components/porter/Spacer";
 import Text from "components/porter/Text";
-import { type PopulatedEnvGroup } from "./types";
 import { type PorterAppFormData } from "lib/porter-apps";
 
+import { Context } from "shared/Context";
 import { valueExists } from "shared/util";
 
 import EnvGroupModal from "./EnvGroupModal";
-import Button from "components/porter/Button";
 import EnvGroupRow from "./EnvGroupRow";
+import { type PopulatedEnvGroup } from "./types";
 
 type Props = {
   baseEnvGroups?: PopulatedEnvGroup[];
@@ -95,59 +95,66 @@ const EnvGroups: React.FC<Props> = ({
   };
 
   const onRemove = (name: string): void => {
-    const index = populatedEnvWithFallback.findIndex(eg => eg.envGroup.name === name);
+    const index = populatedEnvWithFallback.findIndex(
+      (eg) => eg.envGroup.name === name
+    );
     if (index !== -1) {
       remove(index);
     }
-  
+
     const existingEnvGroupNames = envGroups.map((eg) => eg.name);
     if (existingEnvGroupNames.includes(name)) {
       appendDeletion({ name });
     }
   };
 
-  return !currentProject?.sandbox_enabled && (
-    <>
-      <Text size={16}>Synced environment groups</Text>
-      <Spacer y={0.5} />
-      <Text color="helper">
-        This application will be automatically redeployed when a synced env group is updated.
-      </Text>
-      <Spacer y={1} />
-      {populatedEnvWithFallback.length > 0 && (
-        <>
-          {populatedEnvWithFallback.map(({ envGroup, id, index }) => {
-            return (
-              <>
-                <EnvGroupRow
-                  key={id}
-                  envGroup={envGroup}
-                  onRemove={onRemove}
-                />
-                {index !== populatedEnvWithFallback.length - 1 && <Spacer y={.5} />}
-              </>
-            );
-          })}
-          <Spacer y={1} />
-        </>
-      )}
-      <Button
-        alt
-        onClick={() => {
-          setShowEnvModal(true);
-        }}
-      >
-        <I className="material-icons">add</I>
-        Sync an env group
-      </Button>
-      {showEnvModal ? (
-        <EnvGroupModal
-          setOpen={setShowEnvModal}
-          baseEnvGroups={baseEnvGroups}
-          append={onAdd}
-        />
-      ) : null}
-    </>
+  return (
+    !currentProject?.sandbox_enabled && (
+      <>
+        <Text size={16}>Synced environment groups</Text>
+        <Spacer y={0.5} />
+        <Text color="helper">
+          This application will be automatically redeployed when a synced env
+          group is updated.
+        </Text>
+        <Spacer y={1} />
+        {populatedEnvWithFallback.length > 0 && (
+          <>
+            {populatedEnvWithFallback.map(({ envGroup, id, index }) => {
+              return (
+                <>
+                  <EnvGroupRow
+                    key={id}
+                    envGroup={envGroup}
+                    onRemove={onRemove}
+                  />
+                  {index !== populatedEnvWithFallback.length - 1 && (
+                    <Spacer y={0.5} />
+                  )}
+                </>
+              );
+            })}
+            <Spacer y={1} />
+          </>
+        )}
+        <Button
+          alt
+          onClick={() => {
+            setShowEnvModal(true);
+          }}
+        >
+          <I className="material-icons">add</I>
+          Sync an env group
+        </Button>
+        {showEnvModal ? (
+          <EnvGroupModal
+            setOpen={setShowEnvModal}
+            baseEnvGroups={baseEnvGroups}
+            append={onAdd}
+          />
+        ) : null}
+      </>
+    )
   );
 };
 

+ 3 - 4
dashboard/src/main/home/app-dashboard/validate-apply/app-settings/EnvVarRow.tsx

@@ -4,7 +4,6 @@ import styled from "styled-components";
 
 import Image from "components/porter/Image";
 import Spacer from "components/porter/Spacer";
-import Text from "components/porter/Text";
 import Tooltip from "components/porter/Tooltip";
 import { type KeyValueType } from "main/home/cluster-dashboard/env-groups/EnvGroupArray";
 import { type PorterAppFormData } from "lib/porter-apps";
@@ -111,7 +110,7 @@ const EnvVarRow: React.FC<Props> = ({
               }}
               disabled={entry.locked}
             >
-              <i className="material-icons">lock_open</i>
+              <i className="material-icons">lock</i>
             </HideButton>
           )}
         />
@@ -126,7 +125,7 @@ const EnvVarRow: React.FC<Props> = ({
               }}
               disabled={entry.locked}
             >
-              <i className="material-icons">lock</i>
+              <i className="material-icons">lock_open</i>
             </HideButton>
           )}
         />
@@ -164,7 +163,7 @@ const InputWrapper = styled.div`
 
 type InputProps = {
   disabled?: boolean;
-  width: string;
+  width?: string;
   override?: boolean;
 };
 

+ 12 - 0
dashboard/src/main/home/app-dashboard/validate-apply/app-settings/types.ts

@@ -6,6 +6,18 @@ export const populatedEnvGroup = z.object({
   latest_version: z.coerce.bigint(),
   variables: z.record(z.string()).optional().default({}),
   secret_variables: z.record(z.string()).optional().default({}),
+  files: z
+    .array(
+      z
+        .object({
+          name: z.string(),
+          contents: z.string(),
+        })
+        .optional()
+        .default({ name: "", contents: "" })
+    )
+    .optional()
+    .default([]),
   linked_applications: z.array(z.string()).optional(),
   created_at: z.string(),
 });

+ 21 - 2
dashboard/src/main/home/env-dashboard/CreateEnvGroup.tsx

@@ -9,6 +9,7 @@ import Back from "components/porter/Back";
 import Button from "components/porter/Button";
 import { ControlledInput } from "components/porter/ControlledInput";
 import Error from "components/porter/Error";
+import FileArray from "components/porter/FileArray";
 import Spacer from "components/porter/Spacer";
 import Text from "components/porter/Text";
 import VerticalSteps from "components/porter/VerticalSteps";
@@ -42,6 +43,7 @@ const CreateEnvGroup: React.FC<RouteComponentProps> = ({ history }) => {
           deleted: false,
         },
       ],
+      envFiles: [],
     },
   });
 
@@ -58,15 +60,16 @@ const CreateEnvGroup: React.FC<RouteComponentProps> = ({ history }) => {
   const [step, setStep] = React.useState(0);
   const name = watch("name");
   const envVariables = watch("envVariables");
+  const envFiles = watch("envFiles");
 
   useEffect(() => {
     const validate = async (): Promise<void> => {
       const isNameValid = await trigger("name");
       const isEnvVariablesValid = await trigger("envVariables");
       if (isNameValid && isEnvVariablesValid) {
-        setStep(2);
+        setStep(3);
       } else if (isNameValid) {
-        setStep(1);
+        setStep(2);
       } else {
         setStep(0);
       }
@@ -116,6 +119,7 @@ const CreateEnvGroup: React.FC<RouteComponentProps> = ({ history }) => {
           name: data.name,
           variables: apiEnvVariables,
           secret_variables: secretEnvVariables,
+          files: envFiles,
           is_env_override: true,
         },
         {
@@ -197,6 +201,21 @@ const CreateEnvGroup: React.FC<RouteComponentProps> = ({ history }) => {
                       secretOption={true}
                     />
                   </>,
+                  <>
+                    <Text size={16}>Environment files</Text>
+                    <Spacer y={0.5} />
+                    <Text color="helper">
+                      Files containing sensitive data that will be injected into
+                      your app&apos;s root directory.
+                    </Text>
+                    <Spacer y={1} />
+                    <FileArray
+                      files={envFiles}
+                      setFiles={(x) => {
+                        setValue("envFiles", x);
+                      }}
+                    />
+                  </>,
                   <Button
                     key={2}
                     type="submit"

+ 35 - 23
dashboard/src/main/home/env-dashboard/EnvGroupArray.tsx

@@ -1,14 +1,14 @@
-import EnvEditorModal from "main/home/modals/EnvEditorModal";
-import Modal from "main/home/modals/Modal";
 import React, { useEffect, useState } from "react";
 import styled from "styled-components";
 
-import upload from "assets/upload.svg";
-import { dotenv_parse } from "shared/string_utils";
-
 import Button from "components/porter/Button";
 import Image from "components/porter/Image";
 import Spacer from "components/porter/Spacer";
+import EnvEditorModal from "main/home/modals/EnvEditorModal";
+import Modal from "main/home/modals/Modal";
+
+import { dotenv_parse } from "shared/string_utils";
+import upload from "assets/upload.svg";
 
 export type KeyValueType = {
   key: string;
@@ -31,12 +31,12 @@ type PropsType = {
 const EnvGroupArray = ({
   label,
   values,
-  setValues=()=>{},
+  setValues = () => {},
   disabled,
   fileUpload,
   secretOption,
   setButtonDisabled,
-}: PropsType): React.ReactNode => {
+}: PropsType): React.ReactElement => {
   const [showEditorModal, setShowEditorModal] = useState(false);
   const blankValues = (): void => {
     const isAnyEnvVariableBlank = values.some(
@@ -48,15 +48,15 @@ const EnvGroupArray = ({
   };
   const blankValue = (key: string): boolean => {
     if (key === "" && setButtonDisabled) {
-      return true
+      return true;
     }
-    return false
+    return false;
   };
 
   const incorrectRegex = (key: string) => {
     const pattern = /^[a-zA-Z0-9._-]+$/;
     if (setButtonDisabled) {
-      setButtonDisabled(!pattern.test(key))
+      setButtonDisabled(!pattern.test(key));
       blankValues();
     }
     if (key) {
@@ -145,7 +145,11 @@ const EnvGroupArray = ({
                   />
                 ) : (
                   <MultiLineInputer
-                    placeholder={blankValue(entry.value) ? "value cannot be blank" : "ex: value"}
+                    placeholder={
+                      blankValue(entry.value)
+                        ? "value cannot be blank"
+                        : "ex: value"
+                    }
                     width="270px"
                     value={entry.value}
                     onChange={(e: any) => {
@@ -193,7 +197,7 @@ const EnvGroupArray = ({
         })}
       {!disabled && (
         <>
-          {values.length > 0 && <Spacer y={.5} />}
+          {values.length > 0 && <Spacer y={0.5} />}
           <InputWrapper>
             <Button
               alt
@@ -213,7 +217,7 @@ const EnvGroupArray = ({
             >
               <I className="material-icons">add</I> Add row
             </Button>
-            <Spacer inline x={.5} />
+            <Spacer inline x={0.5} />
             {fileUpload && (
               <Button
                 alt
@@ -231,13 +235,19 @@ const EnvGroupArray = ({
       )}
       {showEditorModal && (
         <Modal
-          onRequestClose={() => { setShowEditorModal(false); }}
+          onRequestClose={() => {
+            setShowEditorModal(false);
+          }}
           width="60%"
           height="650px"
         >
           <EnvEditorModal
-            closeModal={() => { setShowEditorModal(false); }}
-            setEnvVariables={(envFile: string) => { readFile(envFile); }}
+            closeModal={() => {
+              setShowEditorModal(false);
+            }}
+            setEnvVariables={(envFile: string) => {
+              readFile(envFile);
+            }}
           />
         </Modal>
       )}
@@ -279,10 +289,10 @@ const HideButton = styled(DeleteButton)`
   > i {
     font-size: 19px;
     cursor: ${(props: { disabled: boolean }) =>
-    props.disabled ? "default" : "pointer"};
+      props.disabled ? "default" : "pointer"};
     :hover {
       color: ${(props: { disabled: boolean }) =>
-    props.disabled ? "#ffffff44" : "#ffffff88"};
+        props.disabled ? "#ffffff44" : "#ffffff88"};
     }
   }
 `;
@@ -296,15 +306,16 @@ const InputWrapper = styled.div`
 const Input = styled.input<{ flex?: boolean; override?: boolean }>`
   outline: none;
   display: ${(props) => (props.flex ? "flex" : "block")};
-  ${(props) => (props.flex && 'flex: 1;')}
+  ${(props) => props.flex && "flex: 1;"}
   border: none;
   margin-bottom: 5px;
   font-size: 13px;
   background: ${(props) => props.theme.fg};
-  border: ${(props) => (props.override ? '2px solid #f4cb42' : ' 1px solid #494b4f')};
+  border: ${(props) =>
+    props.override ? "2px solid #f4cb42" : " 1px solid #494b4f"};
   border-radius: 5px;
-  width: ${(props) => props.width ? props.width : "270px"};
-  color: ${(props) => props.disabled ? "#ffffff44" : "#fefefe"};
+  width: ${(props) => (props.width ? props.width : "270px")};
+  color: ${(props) => (props.disabled ? "#ffffff44" : "#fefefe")};
   padding: 5px 10px;
   height: 35px;
 `;
@@ -322,7 +333,8 @@ export const MultiLineInputer = styled.textarea<InputProps>`
   margin-bottom: 5px;
   font-size: 13px;
   background: ${(props) => props.theme.fg};
-  border: ${(props) => (props.override ? '2px solid #f4cb42' : ' 1px solid #494b4f')};
+  border: ${(props) =>
+    props.override ? "2px solid #f4cb42" : " 1px solid #494b4f"};
   border-radius: 5px;
   color: ${(props) => (props.disabled ? "#ffffff44" : "#fefefe")};
   padding: 8px 10px 5px 10px;

+ 8 - 2
dashboard/src/main/home/env-dashboard/ExpandedEnv.tsx

@@ -14,8 +14,8 @@ import TabSelector from "components/TabSelector";
 
 import api from "shared/api";
 import { Context } from "shared/Context";
-import doppler from "assets/doppler.png";
 import database from "assets/database.svg";
+import doppler from "assets/doppler.png";
 import key from "assets/key.svg";
 import notFound from "assets/not-found.png";
 import time from "assets/time.png";
@@ -109,7 +109,13 @@ const ExpandedEnv: React.FC = () => {
 
           <Container row>
             <Image
-              src={envGroup.type === "doppler" ? doppler : envGroup.type === "datastore" ? database : key}
+              src={
+                envGroup.type === "doppler"
+                  ? doppler
+                  : envGroup.type === "datastore"
+                  ? database
+                  : key
+              }
               size={28}
             />
             <Spacer inline x={1} />

+ 95 - 59
dashboard/src/main/home/env-dashboard/tabs/EnvVarsTab.tsx

@@ -1,44 +1,52 @@
-import React, { useEffect, useState, useMemo, useContext } from "react";
-import { FormProvider, useForm } from "react-hook-form";
+import React, { useContext, useEffect, useMemo, useState } from "react";
 import { zodResolver } from "@hookform/resolvers/zod";
 import axios from "axios";
+import { FormProvider, useForm } from "react-hook-form";
 import styled from "styled-components";
 
+import Button from "components/porter/Button";
+import Error from "components/porter/Error";
+import FileArray from "components/porter/FileArray";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+import {
+  envGroupFormValidator,
+  type EnvGroupFormData,
+} from "lib/env-groups/types";
+
 import api from "shared/api";
 import { Context } from "shared/Context";
-import { type EnvGroupFormData, envGroupFormValidator } from "lib/env-groups/types";
 
-import Spacer from "components/porter/Spacer";
-import Text from "components/porter/Text";
 import EnvGroupArray from "../EnvGroupArray";
-import Button from "components/porter/Button";
-import Error from "components/porter/Error";
 
 type Props = {
   envGroup: {
     name: string;
     variables: Record<string, string>;
     secret_variables?: Record<string, string>;
+    files?: EnvGroupFormData["envFiles"];
     type?: string;
   };
   fetchEnvGroup: () => void;
-}
+};
 
 const EnvVarsTab: React.FC<Props> = ({ envGroup, fetchEnvGroup }) => {
   const { currentProject, currentCluster } = useContext(Context);
-  const [buttonStatus, setButtonStatus] = useState<string | React.ReactNode>("");
+  const [buttonStatus, setButtonStatus] = useState<string | React.ReactNode>(
+    ""
+  );
   const [wasCreated, setWasCreated] = useState(false);
 
   useEffect(() => {
-    const created = new URLSearchParams(window.location.search).get("created")
+    const created = new URLSearchParams(window.location.search).get("created");
     setWasCreated(created === "true");
-  }, [])
+  }, []);
 
   const envGroupFormMethods = useForm<EnvGroupFormData>({
     resolver: zodResolver(envGroupFormValidator),
     reValidateMode: "onSubmit",
   });
-  const { 
+  const {
     formState: { isValidating, isSubmitting },
     watch,
     trigger,
@@ -49,6 +57,7 @@ const EnvVarsTab: React.FC<Props> = ({ envGroup, fetchEnvGroup }) => {
   const [submitErrorMessage, setSubmitErrorMessage] = useState<string>("");
   const [isValid, setIsValid] = useState<boolean>(false);
   const envVariables = watch("envVariables");
+  const envFiles = watch("envFiles", []);
 
   useEffect(() => {
     if (buttonStatus === "success") {
@@ -66,27 +75,37 @@ const EnvVarsTab: React.FC<Props> = ({ envGroup, fetchEnvGroup }) => {
   }, [envVariables]);
 
   useEffect(() => {
-    const normalVariables = Object.entries(
-      envGroup.variables || {}
-    ).map(([key, value]) => ({
-      key,
-      value,
-      hidden: (value ).includes("PORTERSECRET"),
-      locked: (value ).includes("PORTERSECRET"),
-      deleted: false,
-    }));
-    const secretVariables = Object.entries(
-      envGroup.secret_variables || {}
-    ).map(([key, value]) => ({
-      key,
-      value,
-      hidden: true,
-      locked: true,
-      deleted: false,
-    }));
+    const normalVariables = Object.entries(envGroup.variables || {}).map(
+      ([key, value]) => ({
+        key,
+        value,
+        hidden: value.includes("PORTERSECRET"),
+        locked: value.includes("PORTERSECRET"),
+        deleted: false,
+      })
+    );
+    const secretVariables = Object.entries(envGroup.secret_variables || {}).map(
+      ([key, value]) => ({
+        key,
+        value,
+        hidden: true,
+        locked: true,
+        deleted: false,
+      })
+    );
     const variables = [...normalVariables, ...secretVariables];
-    setValue("envVariables", variables as Array<{ key: string; value: string; hidden: boolean; locked: boolean; deleted: boolean }>);
+    setValue(
+      "envVariables",
+      variables as Array<{
+        key: string;
+        value: string;
+        hidden: boolean;
+        locked: boolean;
+        deleted: boolean;
+      }>
+    );
     setValue("name", envGroup.name);
+    setValue("envFiles", envGroup.files || []);
   }, [envGroup]);
 
   const onSubmit = handleSubmit(async (data) => {
@@ -97,35 +116,33 @@ const EnvVarsTab: React.FC<Props> = ({ envGroup, fetchEnvGroup }) => {
     const envVariables = data.envVariables;
     try {
       // Old env var create logic
-      const filtered = envVariables.filter((
-        envVar: KeyValueType, 
-        index: number,
-        self: KeyValueType[]
-      ) => {
-        // remove any collisions that are marked as deleted and are duplicates
-        const numCollisions = self.reduce((n, _envVar: KeyValueType) => {
-          return n + (_envVar.key === envVar.key ? 1 : 0);
-        }, 0);
-
-        if (numCollisions === 1) {
-          return true;
-        } else {
-          return (
-            index ===
-            self.findIndex(
-              (_envVar: KeyValueType) =>
-                _envVar.key === envVar.key && !_envVar.deleted
-            )
-          );
+      const filtered = envVariables.filter(
+        (envVar: KeyValueType, index: number, self: KeyValueType[]) => {
+          // remove any collisions that are marked as deleted and are duplicates
+          const numCollisions = self.reduce((n, _envVar: KeyValueType) => {
+            return n + (_envVar.key === envVar.key ? 1 : 0);
+          }, 0);
+
+          if (numCollisions === 1) {
+            return true;
+          } else {
+            return (
+              index ===
+              self.findIndex(
+                (_envVar: KeyValueType) =>
+                  _envVar.key === envVar.key && !_envVar.deleted
+              )
+            );
+          }
         }
-      })
-        
+      );
+
       filtered
         .filter((envVar) => !envVar.deleted && envVar.hidden)
         .forEach((envVar) => {
           secretEnvVariables[envVar.key] = envVar.value;
         });
-      
+
       filtered
         .filter((envVar) => !envVar.deleted && !envVar.hidden)
         .forEach((envVar) => {
@@ -139,6 +156,7 @@ const EnvVarsTab: React.FC<Props> = ({ envGroup, fetchEnvGroup }) => {
             name: envGroup.name,
             variables: apiEnvVariables,
             secret_variables: secretEnvVariables,
+            files: data.envFiles,
             is_env_override: true,
           },
           {
@@ -146,8 +164,8 @@ const EnvVarsTab: React.FC<Props> = ({ envGroup, fetchEnvGroup }) => {
             cluster_id: currentCluster?.id ?? -1,
           }
         );
-      };
-     
+      }
+
       fetchEnvGroup();
       setButtonStatus("success");
     } catch (err) {
@@ -176,11 +194,13 @@ const EnvVarsTab: React.FC<Props> = ({ envGroup, fetchEnvGroup }) => {
       <Spacer y={0.5} />
       {envGroup.type === "doppler" ? (
         <Text color="helper">
-          Doppler environment variables can only be updated from the Doppler dashboard.
+          Doppler environment variables can only be updated from the Doppler
+          dashboard.
         </Text>
       ) : (
         <Text color="helper">
-          Set secret values and environment-specific configuration for your applications.
+          Set secret values and environment-specific configuration for your
+          applications.
         </Text>
       )}
       <Spacer height="15px" />
@@ -198,6 +218,20 @@ const EnvVarsTab: React.FC<Props> = ({ envGroup, fetchEnvGroup }) => {
             secretOption={true}
             disabled={envGroup.type === "doppler"}
           />
+          <Spacer y={1} />
+          <Text size={16}>Environment files</Text>
+          <Spacer y={0.5} />
+          <Text color="helper">
+            Files containing sensitive data that will be injected into your
+            app&apos;s root directory.
+          </Text>
+          <Spacer y={1} />
+          <FileArray
+            files={envFiles}
+            setFiles={(x) => {
+              setValue("envFiles", x);
+            }}
+          />
           {envGroup.type !== "doppler" && envGroup.type !== "datastore" && (
             <>
               <Spacer y={1} />
@@ -209,7 +243,9 @@ const EnvVarsTab: React.FC<Props> = ({ envGroup, fetchEnvGroup }) => {
                       <i className="material-icons">done</i>
                       Successfully created
                     </StatusWrapper>
-                  ) : buttonStatus
+                  ) : (
+                    buttonStatus
+                  )
                 }
                 loadingText="Updating env group . . ."
                 disabled={!isValid}

+ 2 - 2
dashboard/src/main/home/env-dashboard/tabs/SettingsTab.tsx

@@ -60,8 +60,8 @@ const SettingsTab: React.FC<Props> = ({ envGroup }) => {
     }
 
     try {
-      await deleteEnvGroup();
       setCurrentOverlay(null);
+      await deleteEnvGroup();
       history.push(envGroupPath(currentProject, ""));
     } catch (error) {
       setIsDeleting(false);
@@ -93,7 +93,7 @@ const SettingsTab: React.FC<Props> = ({ envGroup }) => {
           </Container>
           <Spacer y={0.5} />
           <Text color="helper">
-            Please wait while we delete this datastore.
+            Please wait while we delete this env group.
           </Text>
         </>
       )}

+ 68 - 45
dashboard/src/shared/api.tsx

@@ -12,8 +12,8 @@ import {
 
 import { type PolicyDocType } from "./auth/types";
 import { baseApi } from "./baseApi";
-import { type AppEventWebhook } from "./types";
 import {
+  type AppEventWebhook,
   type BuildConfig,
   type CreateUpdatePorterAppOptions,
   type FullActionConfigType,
@@ -386,8 +386,9 @@ const getFeedEvents = baseApi<
   }
 >("GET", (pathParams) => {
   const { project_id, cluster_id, stack_name, page } = pathParams;
-  return `/api/projects/${project_id}/clusters/${cluster_id}/applications/${stack_name}/events?page=${page || 1
-    }`;
+  return `/api/projects/${project_id}/clusters/${cluster_id}/applications/${stack_name}/events?page=${
+    page || 1
+  }`;
 });
 
 const createEnvironment = baseApi<
@@ -875,9 +876,11 @@ const detectBuildpack = baseApi<
     branch: string;
   }
 >("GET", (pathParams) => {
-  return `/api/projects/${pathParams.project_id}/gitrepos/${pathParams.git_repo_id
-    }/repos/${pathParams.kind}/${pathParams.owner}/${pathParams.name
-    }/${encodeURIComponent(pathParams.branch)}/buildpack/detect`;
+  return `/api/projects/${pathParams.project_id}/gitrepos/${
+    pathParams.git_repo_id
+  }/repos/${pathParams.kind}/${pathParams.owner}/${
+    pathParams.name
+  }/${encodeURIComponent(pathParams.branch)}/buildpack/detect`;
 });
 
 const detectGitlabBuildpack = baseApi<
@@ -908,9 +911,11 @@ const getBranchContents = baseApi<
     branch: string;
   }
 >("GET", (pathParams) => {
-  return `/api/projects/${pathParams.project_id}/gitrepos/${pathParams.git_repo_id
-    }/repos/${pathParams.kind}/${pathParams.owner}/${pathParams.name
-    }/${encodeURIComponent(pathParams.branch)}/contents`;
+  return `/api/projects/${pathParams.project_id}/gitrepos/${
+    pathParams.git_repo_id
+  }/repos/${pathParams.kind}/${pathParams.owner}/${
+    pathParams.name
+  }/${encodeURIComponent(pathParams.branch)}/contents`;
 });
 
 const getProcfileContents = baseApi<
@@ -926,9 +931,11 @@ const getProcfileContents = baseApi<
     branch: string;
   }
 >("GET", (pathParams) => {
-  return `/api/projects/${pathParams.project_id}/gitrepos/${pathParams.git_repo_id
-    }/repos/${pathParams.kind}/${pathParams.owner}/${pathParams.name
-    }/${encodeURIComponent(pathParams.branch)}/procfile`;
+  return `/api/projects/${pathParams.project_id}/gitrepos/${
+    pathParams.git_repo_id
+  }/repos/${pathParams.kind}/${pathParams.owner}/${
+    pathParams.name
+  }/${encodeURIComponent(pathParams.branch)}/procfile`;
 });
 
 const getPorterYamlContents = baseApi<
@@ -944,9 +951,11 @@ const getPorterYamlContents = baseApi<
     branch: string;
   }
 >("GET", (pathParams) => {
-  return `/api/projects/${pathParams.project_id}/gitrepos/${pathParams.git_repo_id
-    }/repos/${pathParams.kind}/${pathParams.owner}/${pathParams.name
-    }/${encodeURIComponent(pathParams.branch)}/porteryaml`;
+  return `/api/projects/${pathParams.project_id}/gitrepos/${
+    pathParams.git_repo_id
+  }/repos/${pathParams.kind}/${pathParams.owner}/${
+    pathParams.name
+  }/${encodeURIComponent(pathParams.branch)}/porteryaml`;
 });
 
 const parsePorterYaml = baseApi<
@@ -1006,30 +1015,32 @@ const getBranchHead = baseApi<
     branch: string;
   }
 >("GET", (pathParams) => {
-  return `/api/projects/${pathParams.project_id}/gitrepos/${pathParams.git_repo_id
-    }/repos/${pathParams.kind}/${pathParams.owner}/${pathParams.name
-    }/${encodeURIComponent(pathParams.branch)}/head`;
+  return `/api/projects/${pathParams.project_id}/gitrepos/${
+    pathParams.git_repo_id
+  }/repos/${pathParams.kind}/${pathParams.owner}/${
+    pathParams.name
+  }/${encodeURIComponent(pathParams.branch)}/head`;
 });
 
 const createApp = baseApi<
   | {
-    name: string;
-    deployment_target_id: string;
-    type: "github";
-    git_repo_id: number;
-    git_branch: string;
-    git_repo_name: string;
-    porter_yaml_path: string;
-  }
+      name: string;
+      deployment_target_id: string;
+      type: "github";
+      git_repo_id: number;
+      git_branch: string;
+      git_repo_name: string;
+      porter_yaml_path: string;
+    }
   | {
-    name: string;
-    deployment_target_id: string;
-    type: "docker-registry";
-    image: {
-      repository: string;
-      tag: string;
-    };
-  },
+      name: string;
+      deployment_target_id: string;
+      type: "docker-registry";
+      image: {
+        repository: string;
+        tag: string;
+      };
+    },
   {
     project_id: number;
     cluster_id: number;
@@ -2255,9 +2266,11 @@ const getEnvGroup = baseApi<
     version?: number;
   }
 >("GET", (pathParams) => {
-  return `/api/projects/${pathParams.id}/clusters/${pathParams.cluster_id
-    }/namespaces/${pathParams.namespace}/envgroup?name=${pathParams.name}${pathParams.version ? "&version=" + pathParams.version : ""
-    }`;
+  return `/api/projects/${pathParams.id}/clusters/${
+    pathParams.cluster_id
+  }/namespaces/${pathParams.namespace}/envgroup?name=${pathParams.name}${
+    pathParams.version ? "&version=" + pathParams.version : ""
+  }`;
 });
 
 const getConfigMap = baseApi<
@@ -2293,6 +2306,10 @@ const createEnvironmentGroups = baseApi<
     name: string;
     variables?: Record<string, string>;
     secret_variables?: Record<string, string>;
+    files?: Array<{
+      name: string;
+      contents: string;
+    }>;
     type?: string;
     auth_token?: string;
     is_env_override?: boolean;
@@ -3526,7 +3543,7 @@ const deletePaymentMethod = baseApi<
     `/api/projects/${project_id}/billing/payment_method/${payment_method_id}`
 );
 
-const getGithubStatus = baseApi<{}, {}>("GET", ({ }) => `/api/status/github`);
+const getGithubStatus = baseApi<{}, {}>("GET", ({}) => `/api/status/github`);
 
 const createSecretAndOpenGitHubPullRequest = baseApi<
   {
@@ -3586,7 +3603,9 @@ const createCloudSqlSecret = baseApi<
 const appEventWebhooks = baseApi<
   {},
   {
-    projectId: number; deploymentTargetId: string; appName: string
+    projectId: number;
+    deploymentTargetId: string;
+    appName: string;
   }
 >("GET", (pathParams) => {
   return `/api/projects/${pathParams.projectId}/targets/${pathParams.deploymentTargetId}/apps/${pathParams.appName}/app-event-webhooks`;
@@ -3597,7 +3616,9 @@ const updateAppEventWebhooks = baseApi<
     app_event_webhooks: AppEventWebhook[];
   },
   {
-    projectId: number; deploymentTargetId: string; appName: string
+    projectId: number;
+    deploymentTargetId: string;
+    appName: string;
   }
 >("POST", (pathParams) => {
   return `/api/projects/${pathParams.projectId}/targets/${pathParams.deploymentTargetId}/apps/${pathParams.appName}/update-app-event-webhooks`;
@@ -3606,10 +3627,12 @@ const updateAppEventWebhooks = baseApi<
 const systemStatusHistory = baseApi<
   {},
   {
-    projectId: number; clusterId: number;
-  }>("GET", (pathParams) => {
-    return `/api/projects/${pathParams.projectId}/clusters/${pathParams.clusterId}/system-status-history`;
-  });
+    projectId: number;
+    clusterId: number;
+  }
+>("GET", (pathParams) => {
+  return `/api/projects/${pathParams.projectId}/clusters/${pathParams.clusterId}/system-status-history`;
+});
 
 // Bundle export to allow default api import (api.<method> is more readable)
 export default {
@@ -3924,5 +3947,5 @@ export default {
   updateAppEventWebhooks,
 
   // system status
-  systemStatusHistory
+  systemStatusHistory,
 };

+ 1 - 1
go.mod

@@ -85,7 +85,7 @@ require (
 	github.com/matryer/is v1.4.0
 	github.com/nats-io/nats.go v1.24.0
 	github.com/open-policy-agent/opa v0.44.0
-	github.com/porter-dev/api-contracts v0.2.150
+	github.com/porter-dev/api-contracts v0.2.155
 	github.com/riandyrn/otelchi v0.5.1
 	github.com/santhosh-tekuri/jsonschema/v5 v5.0.1
 	github.com/stefanmcshane/helm v0.0.0-20221213002717-88a4a2c6e77d

+ 2 - 2
go.sum

@@ -1552,8 +1552,8 @@ github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/polyfloyd/go-errorlint v0.0.0-20210722154253-910bb7978349/go.mod h1:wi9BfjxjF/bwiZ701TzmfKu6UKC357IOAtNr0Td0Lvw=
-github.com/porter-dev/api-contracts v0.2.150 h1:4BMuDuRboUg5aeuQOTy+/MWK+zFmKQ6Vdgek3/1nKOk=
-github.com/porter-dev/api-contracts v0.2.150/go.mod h1:VV5BzXd02ZdbWIPLVP+PX3GKawJSGQnxorVT2sUZALU=
+github.com/porter-dev/api-contracts v0.2.155 h1:sRCFsoq1Gvtm/rnvNknSs7CrSvGLZD7Mp7sgy20kB4I=
+github.com/porter-dev/api-contracts v0.2.155/go.mod h1:VV5BzXd02ZdbWIPLVP+PX3GKawJSGQnxorVT2sUZALU=
 github.com/porter-dev/switchboard v0.0.3 h1:dBuYkiVLa5Ce7059d6qTe9a1C2XEORFEanhbtV92R+M=
 github.com/porter-dev/switchboard v0.0.3/go.mod h1:xSPzqSFMQ6OSbp42fhCi4AbGbQbsm6nRvOkrblFeXU4=
 github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=

+ 16 - 2
internal/kubernetes/environment_groups/delete.go

@@ -37,14 +37,14 @@ func DeleteEnvironmentGroup(ctx context.Context, a *kubernetes.Agent, name strin
 		}
 	}
 
-	allConfigMapsInAllNamespaces, err := a.Clientset.CoreV1().ConfigMaps(metav1.NamespaceAll).List(ctx,
+	allSecretsInAllNamespaces, err := a.Clientset.CoreV1().Secrets(metav1.NamespaceAll).List(ctx,
 		metav1.ListOptions{LabelSelector: fmt.Sprintf("%s=%s", LabelKey_EnvironmentGroupName, name)},
 	)
 	if err != nil {
 		return telemetry.Error(ctx, span, err, "unable to list environment group variables")
 	}
 
-	for _, val := range allConfigMapsInAllNamespaces.Items {
+	for _, val := range allSecretsInAllNamespaces.Items {
 		labelName := fmt.Sprintf("%s.%s", val.Labels[LabelKey_EnvironmentGroupName], val.Labels[LabelKey_EnvironmentGroupVersion])
 
 		err := a.Clientset.CoreV1().ConfigMaps(val.Namespace).Delete(ctx,
@@ -66,7 +66,21 @@ func DeleteEnvironmentGroup(ctx context.Context, a *kubernetes.Agent, name strin
 				return telemetry.Error(ctx, span, err, "unable to delete environment group secret variables")
 			}
 		}
+
+		err = a.Clientset.CoreV1().Secrets(val.Namespace).Delete(ctx,
+			envGroupFileSecretName(val.Labels[LabelKey_EnvironmentGroupName], val.Labels[LabelKey_EnvironmentGroupVersion]),
+			metav1.DeleteOptions{},
+		)
+		if err != nil {
+			if !k8serror.IsNotFound(err) {
+				return telemetry.Error(ctx, span, err, "unable to delete environment group files")
+			}
+		}
 	}
 
 	return nil
 }
+
+func envGroupFileSecretName(envGroupName, version string) string {
+	return fmt.Sprintf("%s-files.%s", envGroupName, version)
+}

+ 49 - 10
internal/kubernetes/environment_groups/list.go

@@ -2,6 +2,7 @@ package environment_groups
 
 import (
 	"context"
+	"encoding/base64"
 	"fmt"
 	"strconv"
 	"strings"
@@ -25,6 +26,8 @@ const (
 	LabelKey_DefaultAppEnvironment = "porter.run/default-app-environment"
 	// LabelKey_DefaultAddonEnvironment is the label key signifying the resource is the default addon environment
 	LabelKey_DefaultAddonEnvironment = "porter.run/default-addon-environment"
+	// LabelKey_FileSecret is the label key for a secret which contains files belonging to an env group
+	LabelKey_FileSecret = "porter.run/file-secret"
 
 	// Namespace_EnvironmentGroups is the base namespace for storing all environment groups.
 	// The configmaps and secrets here should be considered the source's of truth for a given version
@@ -34,6 +37,14 @@ const (
 	LabelKey_AppName = "porter.run/app-name"
 )
 
+// EnvGroupFile is a struct that contains information about a file associated with the env group
+type EnvGroupFile struct {
+	// Name is the name of the file
+	Name string `json:"name"`
+	// B64Contents is the base64 encoded contents of the file
+	B64Contents string `json:"contents"`
+}
+
 // EnvironmentGroup represents a ConfigMap in the porter-env-group namespace
 type EnvironmentGroup struct {
 	// Type is the type of environment group
@@ -46,6 +57,8 @@ type EnvironmentGroup struct {
 	Variables map[string]string `json:"variables,omitempty"`
 	// SecretVariables are secret values for the EnvironmentGroup. This usually will be a Secret on the kubernetes cluster
 	SecretVariables map[string]string `json:"secret_variables,omitempty"`
+	// Files is a list of files associated with the env group
+	Files []EnvGroupFile `json:"files,omitempty"`
 	// CreatedAt is only used for display purposes and is in UTC Unix time
 	CreatedAtUTC time.Time `json:"created_at,omitempty"`
 	// DefaultAppEnvironment is a boolean value that determines whether or not this environment group is the default environment group for an app
@@ -216,17 +229,43 @@ func listEnvironmentGroups(ctx context.Context, a *kubernetes.Agent, listOpts ..
 			}
 		}
 
-		if _, ok := envGroupSet[secret.Name]; !ok {
-			envGroupSet[secret.Name] = EnvironmentGroup{}
+		versionedName := fmt.Sprintf("%s.%d", name, version)
+		if _, ok := envGroupSet[versionedName]; !ok {
+			envGroupSet[versionedName] = EnvironmentGroup{}
 		}
-		envGroupSet[secret.Name] = EnvironmentGroup{
-			Type:                  secret.Labels[LabelKey_EnvironmentGroupType],
-			Name:                  name,
-			Version:               version,
-			SecretVariables:       stringSecret,
-			Variables:             envGroupSet[secret.Name].Variables,
-			CreatedAtUTC:          secret.CreationTimestamp.Time.UTC(),
-			DefaultAppEnvironment: secret.Labels[LabelKey_DefaultAppEnvironment] == "true",
+
+		isFileSecret, ok := secret.Labels[LabelKey_FileSecret]
+		// we handle file secrets differently - they are stored in the Files field of the EnvGroup rather than SecretVariables
+		if ok && isFileSecret == "true" {
+			var files []EnvGroupFile
+			for k, v := range secret.Data {
+				encodedContents := base64.StdEncoding.EncodeToString(v)
+				files = append(files, EnvGroupFile{
+					Name:        k,
+					B64Contents: encodedContents,
+				})
+			}
+			envGroupSet[versionedName] = EnvironmentGroup{
+				Type:                  secret.Labels[LabelKey_EnvironmentGroupType],
+				Name:                  name,
+				Version:               version,
+				SecretVariables:       envGroupSet[versionedName].SecretVariables,
+				Variables:             envGroupSet[versionedName].Variables,
+				Files:                 files,
+				CreatedAtUTC:          secret.CreationTimestamp.Time.UTC(),
+				DefaultAppEnvironment: secret.Labels[LabelKey_DefaultAppEnvironment] == "true",
+			}
+		} else {
+			envGroupSet[versionedName] = EnvironmentGroup{
+				Type:                  secret.Labels[LabelKey_EnvironmentGroupType],
+				Name:                  name,
+				Version:               version,
+				SecretVariables:       stringSecret,
+				Variables:             envGroupSet[versionedName].Variables,
+				Files:                 envGroupSet[versionedName].Files,
+				CreatedAtUTC:          secret.CreationTimestamp.Time.UTC(),
+				DefaultAppEnvironment: secret.Labels[LabelKey_DefaultAppEnvironment] == "true",
+			}
 		}
 	}