From ebd281bf72cf7243080ce5bed432356c53968e3c Mon Sep 17 00:00:00 2001
From: William Chu <william.chu@uptickhq.com>
Date: Fri, 11 Oct 2024 09:27:23 +1100
Subject: [PATCH 1/5] chore: add types to get_apps

---
 gitops/utils/apps.py | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/gitops/utils/apps.py b/gitops/utils/apps.py
index a2b18c5..2c481d5 100644
--- a/gitops/utils/apps.py
+++ b/gitops/utils/apps.py
@@ -1,5 +1,6 @@
 import sys
 from pathlib import Path
+from typing import Literal
 
 from colorama import Fore
 from tabulate import tabulate
@@ -63,7 +64,7 @@ def update_app(app_name: str, **kwargs: object) -> None:
 def get_apps(  # noqa: C901
     filter: set[str] | list[str] | str = "",
     exclude: set[str] | list[str] | str = "",
-    mode: str = "PROMPT",
+    mode: Literal["PROMPT", "PREVIEW", "SILENT"] | None = "PROMPT",
     autoexclude_inactive: bool = True,
     message: str | None = None,
     load_secrets: bool = True,

From 95e72902f4cea2e84c75eb99a3165f979471069b Mon Sep 17 00:00:00 2001
From: William Chu <william.chu@uptickhq.com>
Date: Fri, 11 Oct 2024 09:51:12 +1100
Subject: [PATCH 2/5] chore: expose app.secrets and allow secrets to be
 unencoded

---
 gitops/common/app.py | 26 +++++++++++++++++++++-----
 gitops/utils/apps.py |  8 ++++++--
 tests/test_app.py    | 15 +++++++++++++++
 3 files changed, 42 insertions(+), 7 deletions(-)

diff --git a/gitops/common/app.py b/gitops/common/app.py
index 898e00d..8e00094 100644
--- a/gitops/common/app.py
+++ b/gitops/common/app.py
@@ -24,8 +24,19 @@ def __init__(
         deployments: dict | None = None,
         secrets: dict | None = None,
         load_secrets: bool = True,
+        encode_secrets: bool = True,
+        # deprecated
         account_id: str = "",
     ) -> None:
+        """
+        :params name: The name of the app
+        :params path: The path to the app directory
+        :params deployments: The deployments dictionary (injecting for testing)
+        :params secrets: The secrets dictionary (injecting for testing)
+        :params load_secrets: Whether to load the secrets file
+        :params encode_secrets: Whether to base64 encode the secrets
+        """
+        self.encode_secrets = encode_secrets
         self.name = name
         self.path = path
         self.account_id = account_id
@@ -61,11 +72,11 @@ def set_value(self, path: str, value: Any) -> None:
             current_dict = current_dict.setdefault(key, {})
         current_dict[keys[-1]] = value
 
-    def _make_values(self, deployments: dict, secrets: dict) -> dict:
-        values = {
-            **deployments,
-            "secrets": {**{k: b64encode(v.encode()).decode() for k, v in secrets.items()}},
-        }
+    def _make_values(self, deployments: dict, secrets: dict[str, str]) -> dict:
+        def encode(value: str) -> str:
+            return b64encode(str(value).encode()).decode() if self.encode_secrets else value
+
+        values = {**deployments, "secrets": {**{k: encode(v) for k, v in secrets.items()}}}
 
         image = self._make_image(deployments)
         if image:
@@ -127,6 +138,11 @@ def tags(self) -> list[str]:
     def service_account_name(self) -> str:
         return self.values.get("serviceAccount", {}).get("name") or self.values.get("serviceAccountName") or "default"
 
+    @property
+    def secrets(self) -> dict[str, str]:
+        # TODO: This should be a first class property
+        return self.values.get("secrets", {})
+
 
 class Chart:
     """Represents a Helm chart
diff --git a/gitops/utils/apps.py b/gitops/utils/apps.py
index 2c481d5..51c1c4f 100644
--- a/gitops/utils/apps.py
+++ b/gitops/utils/apps.py
@@ -22,13 +22,16 @@ def is_valid_app_directory(directory: Path) -> bool:
     return all(file_paths)
 
 
-def get_app_details(app_name: str, load_secrets: bool = True, exit_if_not_found: bool = True) -> App:
+def get_app_details(
+    app_name: str, load_secrets: bool = True, encode_secrets: bool = True, exit_if_not_found: bool = True
+) -> App:
     account_id = get_account_id() if load_secrets else "UNKNOWN"
     try:
         app = App(
             app_name,
             path=str(get_apps_directory() / app_name),
             load_secrets=load_secrets,
+            encode_secrets=encode_secrets,
             account_id=account_id,
         )
     except FileNotFoundError as e:
@@ -68,6 +71,7 @@ def get_apps(  # noqa: C901
     autoexclude_inactive: bool = True,
     message: str | None = None,
     load_secrets: bool = True,
+    encode_secrets: bool = True,
 ) -> list[App]:
     """Return apps that contain ALL of the tags listed in `filter` and NONE of the tags listed in
     `exclude`. The incoming filter and exclude params may come in as a list or commastring.
@@ -102,7 +106,7 @@ def get_apps(  # noqa: C901
             continue
         elif not is_valid_app_directory(entry):
             continue
-        app = get_app_details(entry.name, load_secrets=load_secrets)
+        app = get_app_details(entry.name, load_secrets=load_secrets, encode_secrets=encode_secrets)
 
         pseudotags = [app.name, app.cluster]
         if app.image and app.image_prefix:
diff --git a/tests/test_app.py b/tests/test_app.py
index 525bff0..f23f46c 100644
--- a/tests/test_app.py
+++ b/tests/test_app.py
@@ -105,3 +105,18 @@ def test_local_repo_is_parsed_properly(self):
 
         assert chart.type == "local"
         assert chart.path == "."
+
+    def test_app_namespace_property(self):
+        path = create_test_yaml()
+        app = App("test", path, encode_secrets=True)
+
+        assert app.namespace
+
+    def test_app_encode_secrets_works(self):
+        path = create_test_yaml()
+        app = App("test", path, encode_secrets=True)
+
+        assert app.secrets["SNAPE"] != "KILLS_DUMBLEDORE"
+
+        app_decoded = App("test", path, encode_secrets=False)
+        assert app_decoded.secrets["SNAPE"] == "KILLS_DUMBLEDORE"

From 626466a5034c532995e41b0cf476f182bb3679f0 Mon Sep 17 00:00:00 2001
From: William Chu <william.chu@uptickhq.com>
Date: Fri, 11 Oct 2024 09:55:00 +1100
Subject: [PATCH 3/5] feat(gitops): expose App, get_app_details, get_apps as
 top level interfaces

---
 gitops/__init__.py | 4 ++++
 1 file changed, 4 insertions(+)

diff --git a/gitops/__init__.py b/gitops/__init__.py
index d51cb91..276f6c8 100644
--- a/gitops/__init__.py
+++ b/gitops/__init__.py
@@ -2,6 +2,8 @@
 import sys
 from pathlib import Path
 
+from gitops.utils.apps import App, get_app_details, get_apps
+
 from .utils.cli import success, warning
 
 __version__ = "0.11.5"
@@ -25,3 +27,5 @@
                 f" requirement {success(min_gitops_version)}.",
                 file=sys.stderr,
             )
+
+__all__ = ["App", "get_app_details", "get_apps", "__version__"]

From 743abaa231817de2903ec7a6e7c03be68a0ddfea Mon Sep 17 00:00:00 2001
From: William Chu <william.chu@uptickhq.com>
Date: Fri, 11 Oct 2024 10:27:05 +1100
Subject: [PATCH 4/5] fix(gitops_server): treat suspended servers as removed

---
 gitops_server/types.py                   | 24 +++++++++++++++---------
 gitops_server/workers/deployer/deploy.py |  3 +--
 tests/conftest.py                        |  3 +++
 tests/test_deploy.py                     | 16 +++++++++++++++-
 tests/utils.py                           | 10 ++++++++--
 5 files changed, 42 insertions(+), 14 deletions(-)
 create mode 100644 tests/conftest.py

diff --git a/gitops_server/types.py b/gitops_server/types.py
index e2865fa..dfe9073 100644
--- a/gitops_server/types.py
+++ b/gitops_server/types.py
@@ -19,16 +19,22 @@ class UpdateAppResult(RunOutput):
 
 
 class AppDefinitions:
-    def __init__(self, name, apps: dict | None = None):
+    def __init__(self, name, apps: dict[str, App] | None = None, path: str | None = None):
         self.name = name
         self.apps = apps or {}
 
-    def from_path(self, path: str):
-        path = os.path.join(path, "apps")
-        for entry in os.listdir(path):
-            entry_path = os.path.join(path, entry)
-            if entry[0] != "." and not os.path.isfile(entry_path):
-                app = App(entry, entry_path, account_id=settings.ACCOUNT_ID)
-                # We only care for apps pertaining to our current cluster.
-                if app.values["cluster"] == settings.CLUSTER_NAME:
+        if path:
+            path = os.path.join(path, "apps")
+
+            for entry in os.listdir(path):
+                entry_path = os.path.join(path, entry)
+                if entry[0] != "." and not os.path.isfile(entry_path):
+                    app = App(entry, entry_path, account_id=settings.ACCOUNT_ID)
                     self.apps[entry] = app
+
+        # Removing apps that are suspended or not part of this cluster
+        for app in list(self.apps.values()):
+            # We only care for apps pertaining to our current cluster.
+            if app.values["cluster"] != settings.CLUSTER_NAME or "suspended" in app.tags:
+                # and suspended apps are considered removed from the cluster.
+                self.apps.pop(app.name)
diff --git a/gitops_server/workers/deployer/deploy.py b/gitops_server/workers/deployer/deploy.py
index 6ad1e64..a71fc9d 100644
--- a/gitops_server/workers/deployer/deploy.py
+++ b/gitops_server/workers/deployer/deploy.py
@@ -67,8 +67,7 @@ async def post_result_summary(source: str, results: list[UpdateAppResult]):
 async def load_app_definitions(url: str, sha: str) -> AppDefinitions:
     logger.info(f'Loading app definitions at "{sha}".')
     async with temp_repo(url, ref=sha) as repo:
-        app_definitions = AppDefinitions(name=get_repo_name_from_url(url))
-        app_definitions.from_path(repo)
+        app_definitions = AppDefinitions(name=get_repo_name_from_url(url), path=repo)
         return app_definitions
 
 
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 0000000..db25ff0
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,3 @@
+import gitops_server.settings
+
+gitops_server.settings.CLUSTER_NAME = "test-cluster"
diff --git a/tests/test_deploy.py b/tests/test_deploy.py
index d877b89..9107d62 100644
--- a/tests/test_deploy.py
+++ b/tests/test_deploy.py
@@ -4,10 +4,11 @@
 import pytest
 
 from gitops.common.app import App
+from gitops_server.types import AppDefinitions
 from gitops_server.workers.deployer import Deployer
 
 from .sample_data import SAMPLE_GITHUB_PAYLOAD, SAMPLE_GITHUB_PAYLOAD_SKIP_MIGRATIONS
-from .utils import mock_load_app_definitions
+from .utils import create_test_yaml, mock_load_app_definitions
 
 # Patch gitops_server.git.run & check correct commands + order
 # Patch command that reads yaml from cluster repo +
@@ -120,3 +121,16 @@ async def test_deployer_skip_migrations_in_commit_message_should_run_helm_withou
         ]
         for where, check in check_in_run_mock:
             assert check in post_mock.call_args_list[where][0][0]
+
+
+class TestLoadAppDefinitions:
+    def test_load_app_definitions_ignores_suspended_apps(self):
+        app_definitions = AppDefinitions(
+            "mock-repo",
+            apps={
+                "in-cluster": App("in-cluster", path=create_test_yaml(tags=[])),
+                "suspended": App("suspended", path=create_test_yaml(tags=["suspended"])),
+                "not-in-cluster": App("not-in-cluster", path=create_test_yaml(tags=[], cluster="other-cluster")),
+            },
+        )
+        assert len(app_definitions.apps) == 1
diff --git a/tests/utils.py b/tests/utils.py
index 73558d4..532012d 100644
--- a/tests/utils.py
+++ b/tests/utils.py
@@ -1,3 +1,5 @@
+from typing import Any
+
 import yaml
 
 from gitops.common.app import App
@@ -20,14 +22,14 @@ async def mock_load_app_definitions(url, sha):
     return app_definitions
 
 
-def create_test_yaml(fg=4, bg=2):
+def create_test_yaml(fg=4, bg=2, **kwargs: Any):
     data = {
         "chart": "https://github.com/some/chart",
         "images": {"template": "template-tag-{tag}"},
         "namespace": "mynamespace",
         "tags": ["tag1", "tag2"],
         "image-tag": "myimagetag",
-        "cluster": "UNKNOWN",
+        "cluster": "test-cluster",
         "containers": {"fg": {"replicas": fg}, "bg": {"replicas": bg}},
         "environment": {
             "DJANGO_SETTINGS_MODULE": "my.settings.module",
@@ -36,6 +38,10 @@ def create_test_yaml(fg=4, bg=2):
             "MEDIA_CLOUDINARY_PREFIX": "cloudinaryprefix",
         },
     }
+
+    for k, v in kwargs.items():
+        data[k] = v
+
     with open("/tmp/deployment.yml", "w+") as fh:
         fh.write(yaml.dump(data))
 

From bc0a0a1cd65bd215bba35f0a50b746b8f72bfa5d Mon Sep 17 00:00:00 2001
From: William Chu <william.chu@uptickhq.com>
Date: Fri, 11 Oct 2024 10:29:02 +1100
Subject: [PATCH 5/5] chore: add __repr__ to App

---
 gitops/common/app.py | 3 +++
 1 file changed, 3 insertions(+)

diff --git a/gitops/common/app.py b/gitops/common/app.py
index 8e00094..df95fb8 100644
--- a/gitops/common/app.py
+++ b/gitops/common/app.py
@@ -58,6 +58,9 @@ def __eq__(self, other: object) -> bool:
             and json.dumps(self.values, sort_keys=True) == json.dumps(other.values, sort_keys=True)
         )
 
+    def __repr__(self) -> str:
+        return f"App(name={self.name}, cluster={self.cluster}, tags={self.tags})"
+
     def is_inactive(self) -> bool:
         return "inactive" in self.values.get("tags", [])