Przeglądaj źródła

[POR-1743] Env group revision sync (#3594)

ianedwards 2 lat temu
rodzic
commit
f9eea6179c

+ 2 - 2
api/server/handlers/environment_groups/list.go

@@ -39,8 +39,8 @@ type ListEnvironmentGroupsResponse struct {
 type EnvironmentGroupListItem struct {
 	Name               string            `json:"name"`
 	LatestVersion      int               `json:"latest_version"`
-	Variables          map[string]string `json:"variables"`
-	SecretVariables    map[string]string `json:"secret_variables"`
+	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"`
 }

+ 5 - 4
api/server/handlers/porter_app/get_app_env.go

@@ -124,10 +124,11 @@ func (c *GetAppEnvHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	}
 
 	envFromProtoInp := porter_app.AppEnvironmentFromProtoInput{
-		ProjectID: project.ID,
-		ClusterID: int(cluster.ID),
-		App:       appProto,
-		K8SAgent:  agent,
+		ProjectID:                  project.ID,
+		ClusterID:                  int(cluster.ID),
+		App:                        appProto,
+		K8SAgent:                   agent,
+		DeploymentTargetRepository: c.Repo().DeploymentTarget(),
 	}
 
 	envGroups, err := porter_app.AppEnvironmentFromProto(ctx, envFromProtoInp, porter_app.WithEnvGroupFilter(request.EnvGroups), porter_app.WithSecrets())

+ 2 - 0
api/server/handlers/porter_app/validate.go

@@ -40,6 +40,7 @@ func NewValidatePorterAppHandler(
 type Deletions struct {
 	ServiceNames     []string `json:"service_names"`
 	EnvVariableNames []string `json:"env_variable_names"`
+	EnvGroupNames    []string `json:"env_group_names"`
 }
 
 // ValidatePorterAppRequest is the request object for the /apps/validate endpoint
@@ -128,6 +129,7 @@ func (c *ValidatePorterAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 		Deletions: &porterv1.Deletions{
 			ServiceNames:     request.Deletions.ServiceNames,
 			EnvVariableNames: request.Deletions.EnvVariableNames,
+			EnvGroupNames:    request.Deletions.EnvGroupNames,
 		},
 	})
 	ccpResp, err := c.Config().ClusterControlPlaneClient.ValidatePorterApp(ctx, validateReq)

+ 19 - 0
dashboard/package-lock.json

@@ -136,6 +136,7 @@
         "style-loader": "^2.0.0",
         "terser-webpack-plugin": "^4.2.3",
         "ts-loader": "^8.0.4",
+        "type-fest": "^4.3.1",
         "typescript": "^4.1.2",
         "webpack": "^4.44.2",
         "webpack-bundle-analyzer": "^4.4.2",
@@ -13297,6 +13298,18 @@
       "integrity": "sha512-JVa5ijo+j/sOoHGjw0sxw734b1LhBkQ3bvUGNdxnVXDCX81Yx7TFgnZygxrIIWn23hbfTaMYLwRmAxFyDuFmIw==",
       "dev": true
     },
+    "node_modules/type-fest": {
+      "version": "4.3.1",
+      "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.3.1.tgz",
+      "integrity": "sha512-pphNW/msgOUSkJbH58x8sqpq8uQj6b0ZKGxEsLKMUnGorRcDjrUaLS+39+/ub41JNTwrrMyJcUB8+YZs3mbwqw==",
+      "dev": true,
+      "engines": {
+        "node": ">=16"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
     "node_modules/type-is": {
       "version": "1.6.18",
       "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
@@ -25726,6 +25739,12 @@
       "integrity": "sha512-JVa5ijo+j/sOoHGjw0sxw734b1LhBkQ3bvUGNdxnVXDCX81Yx7TFgnZygxrIIWn23hbfTaMYLwRmAxFyDuFmIw==",
       "dev": true
     },
+    "type-fest": {
+      "version": "4.3.1",
+      "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.3.1.tgz",
+      "integrity": "sha512-pphNW/msgOUSkJbH58x8sqpq8uQj6b0ZKGxEsLKMUnGorRcDjrUaLS+39+/ub41JNTwrrMyJcUB8+YZs3mbwqw==",
+      "dev": true
+    },
     "type-is": {
       "version": "1.6.18",
       "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",

+ 1 - 0
dashboard/package.json

@@ -141,6 +141,7 @@
     "style-loader": "^2.0.0",
     "terser-webpack-plugin": "^4.2.3",
     "ts-loader": "^8.0.4",
+    "type-fest": "^4.3.1",
     "typescript": "^4.1.2",
     "webpack": "^4.44.2",
     "webpack-bundle-analyzer": "^4.4.2",

+ 1 - 0
dashboard/src/lib/hooks/useAppValidation.ts

@@ -117,6 +117,7 @@ export const useAppValidation = ({
           commit_sha,
           deletions: {
             service_names: data.deletions.serviceNames.map((s) => s.name),
+            env_group_names: data.deletions.envGroupNames.map((eg) => eg.name),
             env_variable_names: [],
           },
         },

+ 6 - 1
dashboard/src/lib/porter-apps/index.ts

@@ -62,6 +62,11 @@ export const deletionValidator = z.object({
       name: z.string(),
     })
     .array(),
+  envGroupNames: z
+    .object({
+      name: z.string(),
+    })
+    .array(),
 });
 
 // clientAppValidator is the representation of a Porter app on the client, and is used to validate inputs for app setting fields
@@ -314,7 +319,7 @@ export function clientAppFromProto({
       key,
       value,
       hidden: true,
-      locked: false,
+      locked: true,
       deleted: false,
     })),
   ];

+ 1 - 1
dashboard/src/lib/revisions/types.ts

@@ -20,7 +20,7 @@ export const appRevisionValidator = z.object({
     name: z.string(),
     latest_version: z.number(),
     variables: z.record(z.string(), z.string()).optional(),
-    secrets: z.record(z.string(), z.string()).optional(),
+    secret_variables: z.record(z.string(), z.string()).optional(),
     created_at: z.string(),
   }),
 });

+ 4 - 2
dashboard/src/main/home/app-dashboard/app-view/AppDataContainer.tsx

@@ -110,11 +110,12 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
         proto: latestProto,
         overrides: servicesFromYaml,
         variables: latestRevision.env.variables,
-        secrets: latestRevision.env.secrets,
+        secrets: latestRevision.env.secret_variables,
       }),
       source: latestSource,
       deletions: {
         serviceNames: [],
+        envGroupNames: [],
       },
     },
   });
@@ -258,10 +259,11 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
         proto: latestProto,
         overrides: servicesFromYaml,
         variables: latestRevision.env.variables,
-        secrets: latestRevision.env.secrets,
+        secrets: latestRevision.env.secret_variables,
       }),
       source: latestSource,
       deletions: {
+        envGroupNames: [],
         serviceNames: [],
       },
     });

+ 41 - 1
dashboard/src/main/home/app-dashboard/app-view/tabs/Environment.tsx

@@ -7,12 +7,46 @@ import Error from "components/porter/Error";
 import { useFormContext } from "react-hook-form";
 import { PorterAppFormData } from "lib/porter-apps";
 import { useLatestRevision } from "../LatestRevisionContext";
+import { useQuery } from "@tanstack/react-query";
+import api from "shared/api";
+import { z } from "zod";
+import { populatedEnvGroup } from "../../validate-apply/app-settings/types";
+import EnvGroups from "../../validate-apply/app-settings/EnvGroups";
 
 const Environment: React.FC = () => {
-  const { latestRevision } = useLatestRevision();
+  const {
+    latestRevision,
+    latestProto,
+    clusterId,
+    projectId,
+  } = useLatestRevision();
   const {
     formState: { isSubmitting, errors },
+    watch,
   } = useFormContext<PorterAppFormData>();
+  const envGroupNames = watch("app.envGroups").map((eg) => eg.name);
+
+  const { data: baseEnvGroups = [] } = useQuery(
+    ["getAllEnvGroups", projectId, clusterId],
+    async () => {
+      const res = await api.getAllEnvGroups(
+        "<token>",
+        {},
+        {
+          id: projectId,
+          cluster_id: clusterId,
+        }
+      );
+
+      const { environment_groups } = await z
+        .object({
+          environment_groups: z.array(populatedEnvGroup).default([]),
+        })
+        .parseAsync(res.data);
+
+      return environment_groups;
+    }
+  );
 
   const buttonStatus = useMemo(() => {
     if (isSubmitting) {
@@ -32,6 +66,12 @@ const Environment: React.FC = () => {
       <Spacer y={0.5} />
       <Text color="helper">Shared among all services.</Text>
       <EnvVariables />
+      <EnvGroups
+        appName={latestProto.name}
+        revisionId={latestRevision.id}
+        baseEnvGroups={baseEnvGroups}
+        existingEnvGroupNames={envGroupNames}
+      />
       <Spacer y={0.5} />
       <Button
         type="submit"

+ 40 - 5
dashboard/src/main/home/app-dashboard/create-app/CreateApp.tsx

@@ -44,6 +44,11 @@ import { useAppValidation } from "lib/hooks/useAppValidation";
 import { useQuery } from "@tanstack/react-query";
 import { z } from "zod";
 import PorterYamlModal from "./PorterYamlModal";
+import EnvGroups from "../validate-apply/app-settings/EnvGroups";
+import {
+  PopulatedEnvGroup,
+  populatedEnvGroup,
+} from "../validate-apply/app-settings/types";
 
 type CreateAppProps = {} & RouteComponentProps;
 
@@ -103,6 +108,31 @@ const CreateApp: React.FC<CreateAppProps> = ({ history }) => {
     }
   );
 
+  const { data: baseEnvGroups = [] } = useQuery(
+    ["getAllEnvGroups", currentProject?.id, currentCluster?.id],
+    async () => {
+      if (!currentProject?.id || !currentCluster?.id) {
+        return [];
+      }
+      const res = await api.getAllEnvGroups<PopulatedEnvGroup[]>(
+        "<token>",
+        {},
+        {
+          id: currentProject.id,
+          cluster_id: currentCluster.id,
+        }
+      );
+
+      const { environment_groups } = await z
+        .object({
+          environment_groups: z.array(populatedEnvGroup).default([]),
+        })
+        .parseAsync(res.data);
+
+      return environment_groups;
+    }
+  );
+
   const porterAppFormMethods = useForm<PorterAppFormData>({
     resolver: zodResolver(porterAppFormValidator),
     reValidateMode: "onSubmit",
@@ -127,6 +157,7 @@ const CreateApp: React.FC<CreateAppProps> = ({ history }) => {
       },
       deletions: {
         serviceNames: [],
+        envGroupNames: [],
       },
     },
   });
@@ -248,11 +279,13 @@ const CreateApp: React.FC<CreateAppProps> = ({ history }) => {
           .parseAsync(envGroupResponse.data);
 
         const envGroups = [
-          ...app.envGroups.filter(group => group.name !== addedEnvGroup.env_group_name),
+          ...app.envGroups.filter(
+            (group) => group.name !== addedEnvGroup.env_group_name
+          ),
           {
             name: addedEnvGroup.env_group_name,
-            version: addedEnvGroup.env_group_version
-          }
+            version: addedEnvGroup.env_group_version,
+          },
         ];
         const appWithSeededEnv = new PorterApp({
           ...app,
@@ -552,8 +585,9 @@ const CreateApp: React.FC<CreateAppProps> = ({ 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.`}
                           </Text>
                         </AppearingDiv>
@@ -572,6 +606,7 @@ const CreateApp: React.FC<CreateAppProps> = ({ history }) => {
                       Specify environment variables shared among all services.
                     </Text>
                     <EnvVariables />
+                    <EnvGroups baseEnvGroups={baseEnvGroups} />
                   </>,
                   source.type === "github" && (
                     <>

+ 47 - 40
dashboard/src/main/home/app-dashboard/expanded-app/env-vars/EnvVariablesTab.tsx

@@ -8,7 +8,11 @@ import Error from "components/porter/Error";
 import sliders from "assets/sliders.svg";
 import EnvGroupModal from "./EnvGroupModal";
 import ExpandableEnvGroup from "./ExpandableEnvGroup";
-import { PopulatedEnvGroup, PartialEnvGroup, NewPopulatedEnvGroup } from "../../../../../components/porter-form/types";
+import {
+  PopulatedEnvGroup,
+  PartialEnvGroup,
+  NewPopulatedEnvGroup,
+} from "../../../../../components/porter-form/types";
 import _, { isObject, differenceBy, omit } from "lodash";
 import api from "../../../../../shared/api";
 import { Context } from "../../../../../shared/Context";
@@ -44,10 +48,12 @@ export const EnvVariablesTab: React.FC<EnvVariablesTabProps> = ({
   const [hovered, setHovered] = useState(false);
 
   const [showEnvModal, setShowEnvModal] = useState(false);
-  const [envGroups, setEnvGroups] = useState<any>([])
+  const [envGroups, setEnvGroups] = useState<any>([]);
   const { currentCluster, currentProject } = useContext(Context);
 
-  const [values, setValues] = React.useState<string>(yaml.dump(appData.chart.config));
+  const [values, setValues] = React.useState<string>(
+    yaml.dump(appData.chart.config)
+  );
   const initialMount = useRef(true);
 
   useEffect(() => {
@@ -81,24 +87,24 @@ export const EnvVariablesTab: React.FC<EnvVariablesTabProps> = ({
 
     try {
       const populatedEnvGroups = await Promise.all(populateEnvGroupsPromises);
-      setEnvGroups(populatedEnvGroups)
-      const filteredEnvGroups = populatedEnvGroups?.filter(envGroup =>
-        envGroup.linked_applications && envGroup.linked_applications.includes(appData.chart.name)
+      setEnvGroups(populatedEnvGroups);
+      const filteredEnvGroups = populatedEnvGroups?.filter(
+        (envGroup) =>
+          envGroup.linked_applications &&
+          envGroup.linked_applications.includes(appData.chart.name)
       );
-      setSyncedEnvGroups(filteredEnvGroups)
-
+      setSyncedEnvGroups(filteredEnvGroups);
     } catch (error) {
       return;
     }
-  }
+  };
 
   const deleteEnvGroup = (envGroup: NewPopulatedEnvGroup) => {
-
     setDeletedEnvGroups([...deletedEnvGroups, envGroup]);
-    setSyncedEnvGroups(syncedEnvGroups?.filter(
-      (env) => env.name !== envGroup.name
-    ))
-  }
+    setSyncedEnvGroups(
+      syncedEnvGroups?.filter((env) => env.name !== envGroup.name)
+    );
+  };
   const maxEnvGroupsReached = syncedEnvGroups.length >= 4;
 
   return (
@@ -113,7 +119,7 @@ export const EnvVariablesTab: React.FC<EnvVariablesTabProps> = ({
           if (status !== "") {
             clearStatus();
           }
-          setEnvVars(x)
+          setEnvVars(x);
         }}
         fileUpload={true}
         syncedEnvGroups={syncedEnvGroups}
@@ -122,33 +128,38 @@ export const EnvVariablesTab: React.FC<EnvVariablesTabProps> = ({
       <>
         <TooltipWrapper
           onMouseOver={() => setHovered(true)}
-          onMouseOut={() => setHovered(false)}>
+          onMouseOut={() => setHovered(false)}
+        >
           <LoadButton
             disabled={maxEnvGroupsReached}
             onClick={() => !maxEnvGroupsReached && setShowEnvModal(true)}
           >
             <img src={sliders} /> Load from Env Group
           </LoadButton>
-          <TooltipText visible={maxEnvGroupsReached && hovered}>Max 4 Env Groups allowed</TooltipText>
+          <TooltipText visible={maxEnvGroupsReached && hovered}>
+            Max 4 Env Groups allowed
+          </TooltipText>
         </TooltipWrapper>
 
-        {showEnvModal && <EnvGroupModal
-          setValues={(x: any) => {
-            if (status !== "") {
-              clearStatus();
-            }
-            setEnvVars(x);
-          }}
-          values={envVars}
-          closeModal={() => setShowEnvModal(false)}
-          syncedEnvGroups={syncedEnvGroups}
-          setSyncedEnvGroups={setSyncedEnvGroups}
-          namespace={appData.chart.namespace}
-        />}
+        {showEnvModal && (
+          <EnvGroupModal
+            setValues={(x: any) => {
+              if (status !== "") {
+                clearStatus();
+              }
+              setEnvVars(x);
+            }}
+            values={envVars}
+            closeModal={() => setShowEnvModal(false)}
+            syncedEnvGroups={syncedEnvGroups}
+            setSyncedEnvGroups={setSyncedEnvGroups}
+            namespace={appData.chart.namespace}
+          />
+        )}
         {!!syncedEnvGroups?.length && (
           <>
             <Spacer y={0.5} />
-            <Text size={16}>Synced environment groups</Text >
+            <Text size={16}>Synced environment groups</Text>
             {syncedEnvGroups?.map((envGroup: any) => {
               return (
                 <ExpandableEnvGroup
@@ -164,11 +175,10 @@ export const EnvVariablesTab: React.FC<EnvVariablesTabProps> = ({
         )}
       </>
 
-
       <Spacer y={0.5} />
       <Button
         onClick={() => {
-          updatePorterApp()
+          updatePorterApp();
         }}
         status={status}
         loadingText={"Updating..."}
@@ -180,7 +190,6 @@ export const EnvVariablesTab: React.FC<EnvVariablesTabProps> = ({
   );
 };
 
-
 const AddRowButton = styled.div`
   display: flex;
   align-items: center;
@@ -206,7 +215,7 @@ const AddRowButton = styled.div`
   }
 `;
 
-const LoadButton = styled(AddRowButton) <{ disabled?: boolean }>`
+const LoadButton = styled(AddRowButton)<{ disabled?: boolean }>`
   background: ${(props) => (props.disabled ? "#aaaaaa55" : "none")};
   border: 1px solid ${(props) => (props.disabled ? "#aaaaaa55" : "#ffffff55")};
   cursor: ${(props) => (props.disabled ? "not-allowed" : "pointer")};
@@ -228,7 +237,6 @@ const LoadButton = styled(AddRowButton) <{ disabled?: boolean }>`
   }
 `;
 
-
 type InputProps = {
   disabled?: boolean;
   width: string;
@@ -306,8 +314,8 @@ const TooltipWrapper = styled.div`
   display: inline-block;
 `;
 
-const TooltipText = styled.span`
-  visibility: ${(props) => (props.visible ? 'visible' : 'hidden')};
+const TooltipText = styled.span<{ visible: boolean }>`
+  visibility: ${(props) => (props.visible ? "visible" : "hidden")};
   width: 240px;
   color: #fff;
   text-align: center;
@@ -318,8 +326,7 @@ const TooltipText = styled.span`
   bottom: 100%;
   left: 50%;
   margin-left: -120px;
-  opacity: ${(props) => (props.visible ? '1' : '0')};
+  opacity: ${(props) => (props.visible ? "1" : "0")};
   transition: opacity 0.3s;
   font-size: 12px;
 `;
-

+ 208 - 0
dashboard/src/main/home/app-dashboard/validate-apply/app-settings/EnvGroupModal.tsx

@@ -0,0 +1,208 @@
+import { PorterAppFormData } from "lib/porter-apps";
+import React, {
+  Dispatch,
+  SetStateAction,
+  useCallback,
+  useMemo,
+  useState,
+} from "react";
+import { UseFieldArrayAppend, useFormContext } from "react-hook-form";
+
+import sliders from "assets/sliders.svg";
+
+import { PopulatedEnvGroup } from "./types";
+import Text from "components/porter/Text";
+import Spacer from "components/porter/Spacer";
+import Modal from "components/porter/Modal";
+import styled, { css } from "styled-components";
+import Button from "components/porter/Button";
+import { IterableElement } from "type-fest";
+
+type Props = {
+  baseEnvGroups: PopulatedEnvGroup[];
+  setOpen: Dispatch<SetStateAction<boolean>>;
+  append: (inp: IterableElement<PorterAppFormData["app"]["envGroups"]>) => void;
+};
+
+const EnvGroupModal: React.FC<Props> = ({ append, setOpen, baseEnvGroups }) => {
+  const [
+    selectedEnvGroup,
+    setSelectedEnvGroup,
+  ] = useState<PopulatedEnvGroup | null>(null);
+
+  const { watch } = useFormContext<PorterAppFormData>();
+  const envGroups = watch("app.envGroups");
+
+  const onSubmit = useCallback(() => {
+    if (selectedEnvGroup) {
+      append({
+        name: selectedEnvGroup.name,
+        version: selectedEnvGroup.latest_version,
+      });
+      setOpen(false);
+    }
+  }, [selectedEnvGroup]);
+
+  const remainingEnvGroupOptions = useMemo(() => {
+    return baseEnvGroups.filter((eg) => {
+      return !envGroups.some((eg2) => eg2.name === eg.name);
+    });
+  }, [envGroups, baseEnvGroups]);
+
+  return (
+    <Modal closeModal={() => setOpen(false)}>
+      <Text size={16}>Load env group</Text>
+      <Spacer height="15px" />
+      <ColumnContainer>
+        <ScrollableContainer>
+          {baseEnvGroups?.length != envGroups?.length ? (
+            <>
+              <Text color="helper">
+                Select an Env Group to load into your application.
+              </Text>
+              <Spacer y={0.5} />
+              <GroupModalSections>
+                <SidebarSection $expanded={!selectedEnvGroup}>
+                  <EnvGroupList>
+                    {remainingEnvGroupOptions.map((eg, i) => (
+                      <EnvGroupRow
+                        key={eg.name}
+                        isSelected={
+                          Boolean(selectedEnvGroup) &&
+                          selectedEnvGroup?.name === eg.name
+                        }
+                        lastItem={i === remainingEnvGroupOptions?.length - 1}
+                        onClick={() => setSelectedEnvGroup(eg)}
+                      >
+                        <img src={sliders} />
+                        {eg.name}
+                      </EnvGroupRow>
+                    ))}
+                  </EnvGroupList>
+                </SidebarSection>
+                {selectedEnvGroup && (
+                  <>
+                    <SidebarSection>
+                      <GroupEnvPreview>
+                        {Object.entries(selectedEnvGroup?.variables || {}).map(
+                          ([key, value]) => (
+                            <div key={key}>
+                              <span className="key">{key} = </span>
+                              <span className="value">{value}</span>
+                            </div>
+                          )
+                        )}
+                        {Object.entries(
+                          selectedEnvGroup?.secret_variables || {}
+                        ).map(([key, value]) => (
+                          <div key={key}>
+                            <span className="key">{key} = </span>
+                            <span className="value">{value}</span>
+                          </div>
+                        ))}
+                      </GroupEnvPreview>
+                    </SidebarSection>
+                  </>
+                )}
+              </GroupModalSections>
+              <Spacer y={1} />
+
+              <Spacer y={1} />
+            </>
+          ) : (
+            <Text>No selectable Env Groups</Text>
+          )}
+        </ScrollableContainer>
+      </ColumnContainer>
+      <SubmitButtonContainer>
+        <Button onClick={onSubmit} disabled={!selectedEnvGroup}>
+          Load Env Group
+        </Button>
+      </SubmitButtonContainer>
+    </Modal>
+  );
+};
+
+export default EnvGroupModal;
+
+const EnvGroupRow = styled.div<{ lastItem?: boolean; isSelected: boolean }>`
+  display: flex;
+  width: 100%;
+  font-size: 13px;
+  border-bottom: 1px solid
+    ${(props) => (props.lastItem ? "#00000000" : "#606166")};
+  color: #ffffff;
+  user-select: none;
+  align-items: center;
+  padding: 10px 0px;
+  cursor: pointer;
+  background: ${(props) => (props.isSelected ? "#ffffff11" : "")};
+  :hover {
+    background: #ffffff11;
+  }
+
+  > img,
+  i {
+    width: 16px;
+    height: 18px;
+    margin-left: 12px;
+    margin-right: 12px;
+    font-size: 20px;
+  }
+`;
+const EnvGroupList = styled.div`
+  width: 100%;
+  border-radius: 3px;
+  background: #ffffff11;
+  border: 1px solid #ffffff44;
+  overflow-y: auto;
+`;
+
+const SidebarSection = styled.section<{ $expanded?: boolean }>`
+  height: 100%;
+  overflow-y: auto;
+  ${(props) =>
+    props.$expanded &&
+    css`
+      grid-column: span 2;
+    `}
+`;
+
+const GroupEnvPreview = styled.pre`
+  font-family: monospace;
+  margin: 0 0 10px 0;
+  white-space: pre-line;
+  word-break: break-word;
+  user-select: text;
+  .key {
+    color: white;
+  }
+  .value {
+    color: #3a48ca;
+  }
+`;
+const GroupModalSections = styled.div`
+  margin-top: 20px;
+  width: 100%;
+  height: 100%;
+  display: grid;
+  gap: 10px;
+  grid-template-columns: 1fr 1fr;
+  max-height: 365px;
+`;
+const ColumnContainer = styled.div`
+  display: flex;
+  flex-direction: column;
+  align-items: stretch;
+`;
+
+const ScrollableContainer = styled.div`
+  flex: 1;
+  overflow-y: auto;
+  max-height: 300px;
+`;
+
+const SubmitButtonContainer = styled.div`
+  margin-top: 10px;
+  text-align: right;
+`;

+ 252 - 0
dashboard/src/main/home/app-dashboard/validate-apply/app-settings/EnvGroups.tsx

@@ -0,0 +1,252 @@
+import React, { useContext, useMemo, useState } from "react";
+import styled from "styled-components";
+import { useFieldArray, useFormContext } from "react-hook-form";
+
+import sliders from "assets/sliders.svg";
+
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+import { PorterAppFormData } from "lib/porter-apps";
+import ExpandableEnvGroup from "./ExpandableEnvGroup";
+import { PopulatedEnvGroup, populatedEnvGroup } from "./types";
+import { useQuery } from "@tanstack/react-query";
+import { Context } from "shared/Context";
+import api from "shared/api";
+import { z } from "zod";
+import { valueExists } from "shared/util";
+import EnvGroupModal from "./EnvGroupModal";
+import { IterableElement } from "type-fest";
+
+type Props = {
+  appName?: string;
+  revisionId?: string;
+  baseEnvGroups?: PopulatedEnvGroup[];
+  existingEnvGroupNames?: string[];
+};
+
+const EnvGroups: React.FC<Props> = ({
+  appName,
+  revisionId,
+  baseEnvGroups = [],
+  existingEnvGroupNames = [],
+}) => {
+  const { currentCluster, currentProject } = useContext(Context);
+
+  const [showEnvModal, setShowEnvModal] = useState(false);
+  const [hovered, setHovered] = useState(false);
+
+  const { control } = useFormContext<PorterAppFormData>();
+  const { append, remove, fields: envGroups } = useFieldArray({
+    control,
+    name: "app.envGroups",
+  });
+  const {
+    append: appendDeletion,
+    remove: removeDeletion,
+    fields: deletedEnvGroups,
+  } = useFieldArray({
+    control,
+    name: "deletions.envGroupNames",
+  });
+
+  const maxEnvGroupsReached = envGroups.length >= 3;
+
+  const { data: attachedEnvGroups = [] } = useQuery(
+    ["getAttachedEnvGroups", appName, revisionId],
+    async () => {
+      if (!appName || !revisionId || !currentCluster?.id || !currentProject?.id)
+        return [];
+
+      const res = await api.getAttachedEnvGroups(
+        "<token>",
+        {},
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+          app_name: appName,
+          revision_id: revisionId,
+        }
+      );
+
+      const { env_groups } = await z
+        .object({
+          env_groups: z.array(populatedEnvGroup),
+        })
+        .parseAsync(res.data);
+
+      return env_groups;
+    },
+    {
+      enabled:
+        !!appName && !!revisionId && !!currentCluster && !!currentProject,
+    }
+  );
+
+  const populatedEnvWithFallback = useMemo(() => {
+    return envGroups
+      .map((envGroup, index) => {
+        const attachedEnvGroup = attachedEnvGroups.find(
+          (attachedEnvGroup) => attachedEnvGroup.name === envGroup.name
+        );
+
+        if (attachedEnvGroup) {
+          return {
+            id: envGroup.id,
+            envGroup: attachedEnvGroup,
+            index,
+          };
+        }
+
+        const baseEnvGroup = baseEnvGroups.find(
+          (baseEnvGroup) => baseEnvGroup.name === envGroup.name
+        );
+
+        if (baseEnvGroup) {
+          return {
+            id: envGroup.id,
+            envGroup: baseEnvGroup,
+            index,
+          };
+        }
+
+        return undefined;
+      })
+      .filter(valueExists);
+  }, [envGroups, attachedEnvGroups, baseEnvGroups]);
+
+  const onAdd = (
+    inp: IterableElement<PorterAppFormData["app"]["envGroups"]>
+  ) => {
+    const previouslyDeleted = deletedEnvGroups.findIndex(
+      (s) => s.name === inp.name
+    );
+
+    if (previouslyDeleted !== -1) {
+      removeDeletion(previouslyDeleted);
+    }
+
+    append(inp);
+  };
+
+  const onRemove = (index: number) => {
+    const name = populatedEnvWithFallback[index].envGroup.name;
+    remove(index);
+
+    if (existingEnvGroupNames.includes(name)) {
+      appendDeletion({ name });
+    }
+  };
+
+  return (
+    <div>
+      <TooltipWrapper
+        onMouseOver={() => setHovered(true)}
+        onMouseOut={() => setHovered(false)}
+      >
+        <LoadButton
+          disabled={maxEnvGroupsReached}
+          onClick={() => !maxEnvGroupsReached && setShowEnvModal(true)}
+        >
+          <img src={sliders} /> Load from Env Group
+        </LoadButton>
+        <TooltipText visible={maxEnvGroupsReached && hovered}>
+          Max 4 Env Groups allowed
+        </TooltipText>
+      </TooltipWrapper>
+      {envGroups.length > 0 && (
+        <>
+          <Spacer y={0.5} />
+          <Text size={16}>Synced environment groups</Text>
+          {populatedEnvWithFallback.map(({ envGroup, id, index }) => {
+            return (
+              <ExpandableEnvGroup
+                key={id}
+                index={index}
+                envGroup={envGroup}
+                remove={onRemove}
+              />
+            );
+          })}
+        </>
+      )}
+      {showEnvModal ? (
+        <EnvGroupModal
+          setOpen={setShowEnvModal}
+          baseEnvGroups={baseEnvGroups}
+          append={onAdd}
+        />
+      ) : null}
+    </div>
+  );
+};
+
+export default EnvGroups;
+
+const AddRowButton = styled.div`
+  display: flex;
+  align-items: center;
+  width: 270px;
+  font-size: 13px;
+  color: #aaaabb;
+  height: 32px;
+  border-radius: 3px;
+  cursor: pointer;
+  background: #ffffff11;
+  :hover {
+    background: #ffffff22;
+  }
+
+  > i {
+    color: #ffffff44;
+    font-size: 16px;
+    margin-left: 8px;
+    margin-right: 10px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+  }
+`;
+
+const LoadButton = styled(AddRowButton)<{ disabled?: boolean }>`
+  background: ${(props) => (props.disabled ? "#aaaaaa55" : "none")};
+  border: 1px solid ${(props) => (props.disabled ? "#aaaaaa55" : "#ffffff55")};
+  cursor: ${(props) => (props.disabled ? "not-allowed" : "pointer")};
+
+  > i {
+    color: ${(props) => (props.disabled ? "#aaaaaa44" : "#ffffff44")};
+    font-size: 16px;
+    margin-left: 8px;
+    margin-right: 10px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+  }
+  > img {
+    width: 14px;
+    margin-left: 10px;
+    margin-right: 12px;
+    opacity: ${(props) => (props.disabled ? "0.5" : "1")};
+  }
+`;
+
+const TooltipWrapper = styled.div`
+  position: relative;
+  display: inline-block;
+`;
+
+const TooltipText = styled.span<{ visible: boolean }>`
+  visibility: ${(props) => (props.visible ? "visible" : "hidden")};
+  width: 240px;
+  color: #fff;
+  text-align: center;
+  padding: 5px 0;
+  border-radius: 6px;
+  position: absolute;
+  z-index: 1;
+  bottom: 100%;
+  left: 50%;
+  margin-left: -120px;
+  opacity: ${(props) => (props.visible ? "1" : "0")};
+  transition: opacity 0.3s;
+  font-size: 12px;
+`;

+ 6 - 169
dashboard/src/main/home/app-dashboard/validate-apply/app-settings/EnvVariables.tsx

@@ -1,59 +1,15 @@
-import React, { useCallback, useContext, useEffect, useState } from "react";
+import React from "react";
 import { Controller, useFormContext } from "react-hook-form";
 
 import { PorterAppFormData } from "lib/porter-apps";
 import EnvGroupArrayV2 from "main/home/cluster-dashboard/env-groups/EnvGroupArrayV2";
 import { KeyValueType } from "main/home/cluster-dashboard/env-groups/EnvGroupArrayV2";
-import styled from "styled-components";
-import Spacer from "components/porter/Spacer";
-import EnvGroupModal from "../../expanded-app/env-vars/EnvGroupModal";
-import ExpandableEnvGroup from "../../expanded-app/env-vars/ExpandableEnvGroup";
-import { NewPopulatedEnvGroup } from "components/porter-form/types";
-import sliders from "assets/sliders.svg";
-import Text from "components/porter/Text";
-import api from "shared/api";
-import { Context } from "shared/Context";
-import { z } from "zod";
-import { EnvGroup } from "@porter-dev/api-contracts";
-
 
 const EnvVariables: React.FC = () => {
   const { control } = useFormContext<PorterAppFormData>();
-  const [hovered, setHovered] = useState(false);
-  const [syncedEnvGroups, setSyncedEnvGroups] = useState<NewPopulatedEnvGroup[]>([]);
-  const [showEnvModal, setShowEnvModal] = useState(false);
-
-  const [deletedEnvGroups, setDeletedEnvGroups] = useState<NewPopulatedEnvGroup[]>([])
-
-  const maxEnvGroupsReached = syncedEnvGroups.length >= 4;
-
-  const convertSynced = (envGroups: NewPopulatedEnvGroup[]): {}[] => {
-    return envGroups?.map(group => (
-      new EnvGroup(
-        {
-          name: group.name,
-          version: BigInt(group.latest_version),
-        },
-      )
-    )
-    )
-  }
 
-  const removeSynced = (envGroups: [], envGroup: NewPopulatedEnvGroup): {}[] => {
-    return envGroups?.filter(group => (
-      group?.name !== envGroup.name
-    )
-    )
-  }
-  const deleteEnvGroup = (envGroup: NewPopulatedEnvGroup) => {
-
-    setDeletedEnvGroups([...deletedEnvGroups, envGroup]);
-    setSyncedEnvGroups(syncedEnvGroups?.filter(
-      (env) => env.name !== envGroup.name
-    ))
-  }
   return (
-    <><Controller
+    <Controller
       name={`app.env`}
       control={control}
       render={({ field: { value, onChange } }) => (
@@ -64,131 +20,12 @@ const EnvVariables: React.FC = () => {
               onChange(x);
             }}
             fileUpload={true}
-            syncedEnvGroups={syncedEnvGroups} />
+            syncedEnvGroups={[]}
+          />
         </>
-      )} />
-      <Controller
-        name={`app.envGroups`}
-        control={control}
-        render={({ field: { value, onChange } }) => (
-          <>
-            <TooltipWrapper
-              onMouseOver={() => setHovered(true)}
-              onMouseOut={() => setHovered(false)}>
-              <LoadButton
-                disabled={maxEnvGroupsReached}
-                onClick={() => !maxEnvGroupsReached && setShowEnvModal(true)}
-              >
-                <img src={sliders} /> Load from Env Group
-              </LoadButton>
-              <TooltipText visible={maxEnvGroupsReached && hovered}>Max 4 Env Groups allowed</TooltipText>
-            </TooltipWrapper>
-
-            {showEnvModal && <EnvGroupModal
-              setValues={(x: KeyValueType[]) => {
-                onChange(x);
-              }}
-              values={value}
-              closeModal={() => setShowEnvModal(false)}
-              syncedEnvGroups={syncedEnvGroups}
-              setSyncedEnvGroups={(x: NewPopulatedEnvGroup[]) => {
-                setSyncedEnvGroups(x);
-                onChange(convertSynced(x));
-              }}
-              namespace={"default"}
-              newApp={true} />}
-            {!!syncedEnvGroups?.length && (
-              <>
-                <Spacer y={0.5} />
-                <Text size={16}>Synced environment groups</Text>
-                {syncedEnvGroups?.map((envGroup: any) => {
-                  return (
-                    <ExpandableEnvGroup
-                      key={envGroup?.name}
-                      envGroup={envGroup}
-                      onDelete={() => {
-                        deleteEnvGroup(envGroup);
-                        onChange(removeSynced(value, envGroup));
-                      }} />
-                  );
-                })}
-              </>
-            )}
-          </>)} />
-
-    </>
-
+      )}
+    />
   );
 };
 
-
 export default EnvVariables;
-
-const AddRowButton = styled.div`
-  display: flex;
-  align-items: center;
-  width: 270px;
-  font-size: 13px;
-  color: #aaaabb;
-  height: 32px;
-  border-radius: 3px;
-  cursor: pointer;
-  background: #ffffff11;
-  :hover {
-    background: #ffffff22;
-  }
-
-  > i {
-    color: #ffffff44;
-    font-size: 16px;
-    margin-left: 8px;
-    margin-right: 10px;
-    display: flex;
-    align-items: center;
-    justify-content: center;
-  }
-`;
-const LoadButton = styled(AddRowButton) <{ disabled?: boolean }>`
-  background: ${(props) => (props.disabled ? "#aaaaaa55" : "none")};
-  border: 1px solid ${(props) => (props.disabled ? "#aaaaaa55" : "#ffffff55")};
-  cursor: ${(props) => (props.disabled ? "not-allowed" : "pointer")};
-
-  > i {
-    color: ${(props) => (props.disabled ? "#aaaaaa44" : "#ffffff44")};
-    font-size: 16px;
-    margin-left: 8px;
-    margin-right: 10px;
-    display: flex;
-    align-items: center;
-    justify-content: center;
-  }
-  > img {
-    width: 14px;
-    margin-left: 10px;
-    margin-right: 12px;
-    opacity: ${(props) => (props.disabled ? "0.5" : "1")};
-  }
-`;
-
-const TooltipWrapper = styled.div`
-  position: relative;
-  display: inline-block;
-`;
-
-const TooltipText = styled.span`
-  visibility: ${(props) => (props.visible ? 'visible' : 'hidden')};
-  width: 240px;
-  color: #fff;
-  text-align: center;
-  padding: 5px 0;
-  border-radius: 6px;
-  position: absolute;
-  z-index: 1;
-  bottom: 100%;
-  left: 50%;
-  margin-left: -120px;
-  opacity: ${(props) => (props.visible ? '1' : '0')};
-  transition: opacity 0.3s;
-  font-size: 12px;
-`;
-

+ 242 - 0
dashboard/src/main/home/app-dashboard/validate-apply/app-settings/ExpandableEnvGroup.tsx

@@ -0,0 +1,242 @@
+import React, { useState } from "react";
+import styled, { keyframes } from "styled-components";
+import { PopulatedEnvGroup } from "./types";
+import Spacer from "components/porter/Spacer";
+
+type Props = {
+  index: number;
+  remove: (index: number) => void;
+  envGroup: PopulatedEnvGroup;
+};
+
+const ExpandableEnvGroup: React.FC<Props> = ({ index, remove, envGroup }) => {
+  const [isExpanded, setIsExpanded] = useState(false);
+
+  return (
+    <StyledCard>
+      <Flex>
+        <ContentContainer>
+          <EventInformation>
+            <EventName>{envGroup.name}</EventName>
+          </EventInformation>
+        </ContentContainer>
+        <ActionContainer>
+          <ActionButton type="button" onClick={() => remove(index)}>
+            <span className="material-icons">delete</span>
+          </ActionButton>
+          <ActionButton
+            type="button"
+            onClick={() => setIsExpanded((prev) => !prev)}
+          >
+            <i className="material-icons">
+              {isExpanded ? "arrow_drop_up" : "arrow_drop_down"}
+            </i>
+          </ActionButton>
+        </ActionContainer>
+      </Flex>
+      {isExpanded ? (
+        <>
+          {Object.entries(envGroup.variables ?? {}).map(([key, value], i) => (
+            <InputWrapper key={i}>
+              <KeyInput
+                placeholder="ex: key"
+                width="270px"
+                value={key}
+                disabled
+              />
+              <Spacer x={0.5} inline />
+              <MultiLineInput
+                placeholder="ex: value"
+                width="270px"
+                value={value}
+                disabled
+                rows={value.split("\n").length}
+                spellCheck={false}
+              />
+            </InputWrapper>
+          ))}
+          {Object.entries(envGroup.secret_variables ?? {}).map(
+            ([key, value], i) => (
+              <InputWrapper key={i}>
+                <KeyInput
+                  placeholder="ex: key"
+                  width="270px"
+                  value={key}
+                  disabled
+                />
+                <Spacer x={0.5} inline />
+                <KeyInput
+                  placeholder="ex: value"
+                  width="270px"
+                  value={value}
+                  disabled
+                  type="password"
+                />
+              </InputWrapper>
+            )
+          )}
+        </>
+      ) : null}
+    </StyledCard>
+  );
+};
+
+export default ExpandableEnvGroup;
+
+const fadeIn = keyframes`
+  from {
+    opacity: 0;
+  }
+  to {
+    opacity: 1;
+  }
+`;
+
+const StyledCard = styled.div`
+  border: 1px solid #ffffff44;
+  background: #ffffff11;
+  margin-bottom: 5px;
+  border-radius: 8px;
+  margin-top: 15px;
+  padding: 10px 14px;
+  overflow: hidden;
+  font-size: 13px;
+  animation: ${fadeIn} 0.5s;
+`;
+
+const Flex = styled.div`
+  display: flex;
+  height: 25px;
+  align-items: center;
+  justify-content: space-between;
+`;
+
+const ContentContainer = styled.div`
+  display: flex;
+  height: 40px;
+  width: 100%;
+  align-items: center;
+`;
+
+const EventInformation = styled.div`
+  display: flex;
+  flex-direction: column;
+  justify-content: space-around;
+  height: 100%;
+`;
+
+const EventName = styled.div`
+  font-family: "Work Sans", sans-serif;
+  font-weight: 500;
+  color: #ffffff;
+`;
+
+const ActionContainer = styled.div`
+  display: flex;
+  align-items: center;
+  white-space: nowrap;
+  height: 100%;
+`;
+
+const ActionButton = styled.button`
+  position: relative;
+  border: none;
+  background: none;
+  color: white;
+  padding: 5px;
+  width: 30px;
+  height: 30px;
+  margin-left: 5px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  border-radius: 50%;
+  cursor: pointer;
+  color: #aaaabb;
+  border: 1px solid #ffffff00;
+
+  :hover {
+    background: #ffffff11;
+    border: 1px solid #ffffff44;
+  }
+
+  > span {
+    font-size: 20px;
+  }
+`;
+
+const NoVariablesTextWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #ffffff99;
+`;
+
+const InputWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  margin-top: 5px;
+`;
+
+type InputProps = {
+  disabled?: boolean;
+  width: string;
+  borderColor?: string;
+};
+
+const KeyInput = styled.input<InputProps>`
+  outline: none;
+  border: none;
+  margin-bottom: 5px;
+  font-size: 13px;
+  background: #ffffff11;
+  border: 1px solid
+    ${(props) => (props.borderColor ? props.borderColor : "#ffffff55")};
+  border-radius: 3px;
+  width: ${(props) => (props.width ? props.width : "270px")};
+  color: ${(props) => (props.disabled ? "#ffffff44" : "white")};
+  padding: 5px 10px;
+  height: 35px;
+`;
+
+export const MultiLineInput = styled.textarea<InputProps>`
+  outline: none;
+  border: none;
+  margin-bottom: 5px;
+  font-size: 13px;
+  background: #ffffff11;
+  border: 1px solid
+    ${(props) => (props.borderColor ? props.borderColor : "#ffffff55")};
+  border-radius: 3px;
+  min-width: ${(props) => (props.width ? props.width : "270px")};
+  max-width: ${(props) => (props.width ? props.width : "270px")};
+  color: ${(props) => (props.disabled ? "#ffffff44" : "white")};
+  padding: 8px 10px 5px 10px;
+  min-height: 35px;
+  max-height: 100px;
+  white-space: nowrap;
+
+  ::-webkit-scrollbar {
+    width: 8px;
+    :horizontal {
+      height: 8px;
+    }
+  }
+
+  ::-webkit-scrollbar-corner {
+    width: 10px;
+    background: #ffffff11;
+    color: white;
+  }
+
+  ::-webkit-scrollbar-track {
+    width: 10px;
+    -webkit-box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.3);
+    box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.3);
+  }
+
+  ::-webkit-scrollbar-thumb {
+    background-color: darkgrey;
+    outline: 1px solid slategrey;
+  }
+`;

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

@@ -0,0 +1,11 @@
+import { z } from "zod";
+
+export const populatedEnvGroup = z.object({
+  name: z.string(),
+  latest_version: z.coerce.bigint(),
+  variables: z.record(z.string()).optional().default({}),
+  secret_variables: z.record(z.string()).optional().default({}),
+  linked_applications: z.array(z.string()).optional(),
+  created_at: z.string(),
+});
+export type PopulatedEnvGroup = z.infer<typeof populatedEnvGroup>;

+ 3 - 2
dashboard/src/main/home/app-dashboard/validate-apply/revisions-list/RevisionTableContents.tsx

@@ -190,11 +190,12 @@ const RevisionTableContents: React.FC<RevisionTableContentsProps> = ({
                           proto: revision.app_proto,
                           overrides: servicesFromYaml,
                           variables: revision.env.variables,
-                          secrets: revision.env.secrets,
+                          secrets: revision.env.secret_variables,
                         }),
                         source: latestSource,
                         deletions: {
                           serviceNames: [],
+                          envGroupNames: [],
                         },
                       });
                       setPreviewRevision(
@@ -234,7 +235,7 @@ const RevisionTableContents: React.FC<RevisionTableContentsProps> = ({
                             app: revision.app_proto,
                             revision: revision.revision_number,
                             variables: revision.env.variables ?? {},
-                            secrets: revision.env.secrets ?? {},
+                            secrets: revision.env.secret_variables ?? {},
                           });
                         }}
                       >

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

@@ -867,6 +867,7 @@ const validatePorterApp = baseApi<
     deletions: {
       service_names: string[];
       env_variable_names: string[];
+      env_group_names: string[];
     };
   },
   {
@@ -932,6 +933,18 @@ const applyApp = baseApi<
   return `/api/projects/${pathParams.project_id}/clusters/${pathParams.cluster_id}/apps/apply`;
 });
 
+const getAttachedEnvGroups = baseApi<
+  {},
+  {
+    project_id: number;
+    cluster_id: number;
+    app_name: string;
+    revision_id: string;
+  }
+>("GET", (pathParams) => {
+  return `/api/projects/${pathParams.project_id}/clusters/${pathParams.cluster_id}/apps/${pathParams.app_name}/revisions/${pathParams.revision_id}/env`;
+});
+
 const getLatestRevision = baseApi<
   {
     deployment_target_id: string;
@@ -3085,6 +3098,7 @@ export default {
   createApp,
   updateAppEnvironmentGroup,
   applyApp,
+  getAttachedEnvGroups,
   getLatestRevision,
   listAppRevisions,
   getLatestAppRevisions,

+ 1 - 1
internal/kubernetes/environment_groups/list.go

@@ -33,7 +33,7 @@ type EnvironmentGroup struct {
 	// Variables are non-secret values for the EnvironmentGroup. This usually will be a configmap
 	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][]byte `json:"secrets,omitempty"`
+	SecretVariables map[string][]byte `json:"secret_variables,omitempty"`
 	// CreatedAt is only used for display purposes and is in UTC Unix time
 	CreatedAtUTC time.Time `json:"created_at"`
 }