Skip to content

Commit bcac031

Browse files
Improved opta ui module (#223)
* Improved opta ui module 1. The service must inherit from the environment always. (sort of-- there is nothing stopping someone from redundantly adding the runx module in the service, but it changes nothing) 2. For the environment, they should only ever need to save the api key once. It should not be required as an envar everytime (stored in AWS parameter store or google secret manager) 3. The service must be able to fetch the stored secret containing the api key 4. It is ok to treat the runx module special (it is, only 6 lines where we run its process or posthook step in the service runs as well) * linting * try * try Co-authored-by: Nitin Aggarwal <nitin@runx.dev> Co-authored-by: Nitin Aggarwal <nitin.agg1909@gmail.com>
1 parent 2e9994d commit bcac031

13 files changed

Lines changed: 299 additions & 117 deletions

File tree

Pipfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ requests = ">=2.0,<3"
3434
kubernetes = ">=12.0,<13"
3535
python-hcl2 = ">=2.0,<3"
3636
yamale = ">=3.0,<4"
37+
google-cloud-secret-manager = "2.4.0"
3738
google-api-python-client = ">=2.0.2,<2.1.0"
3839
oauth2client = ">=4.1.3,<4.2.0"
3940
google-cloud-storage = ">=1.36.2,<1.37.0"

Pipfile.lock

Lines changed: 163 additions & 57 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

examples/aws/service/opta.yml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ environments:
55
max_nodes: 2
66
name: service-1
77
modules:
8-
- type: runx
98
- name: app
109
type: k8s-service
1110
image: kennethreitz/httpbin

examples/gcp/service/opta.yml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ environments:
55
max_nodes: 2
66
name: service-1
77
modules:
8-
- type: runx
98
- name: app
109
type: gcp-k8s-service
1110
image: kennethreitz/httpbin

mypy.ini

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[mypy]
22
warn_redundant_casts = True
3-
warn_unused_ignores = True
3+
warn_unused_ignores = False
44
warn_unreachable = True
55
disallow_untyped_calls = True
66
disallow_untyped_defs = True
@@ -10,7 +10,7 @@ disallow_untyped_decorators = False
1010
no_implicit_optional = True
1111
strict_optional = True
1212
strict_equality = True
13-
implicit_reexport = False
13+
implicit_reexport = True
1414
show_error_context = True
1515
pretty = True
1616
show_traceback = True

opta/commands/destroy.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import boto3
44
import click
55
import yaml
6-
from google.cloud import storage
6+
from google.cloud import storage # type: ignore
77
from google.cloud.exceptions import NotFound
88

99
from opta.amplitude import amplitude_client

opta/core/gcp.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from google.auth import default
66
from google.auth.credentials import Credentials
77
from google.auth.exceptions import DefaultCredentialsError, GoogleAuthError
8-
from google.cloud import storage
8+
from google.cloud import storage # type: ignore
99
from google.cloud.exceptions import NotFound
1010
from google.oauth2 import service_account
1111
from googleapiclient import discovery

opta/core/terraform.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from botocore.exceptions import ClientError
1010
from google.api_core.exceptions import ClientError as GoogleClientError
1111
from google.api_core.exceptions import Conflict
12-
from google.cloud import storage
12+
from google.cloud import storage # type: ignore
1313
from google.cloud.exceptions import NotFound
1414
from googleapiclient import discovery
1515
from googleapiclient.errors import HttpError

opta/layer.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,10 @@ def gen_tf(self, module_idx: int) -> Dict[Any, Any]:
222222
RunxProcessor(module, self).process(module_idx)
223223
else:
224224
ModuleProcessor(module, self).process(module_idx)
225+
if self.parent is not None and self.parent.get_module("runx") is not None:
226+
RunxProcessor(self.parent.get_module("runx"), self).process( # type:ignore
227+
module_idx
228+
)
225229
previous_module_reference = None
226230
for module in self.modules[0 : module_idx + 1]:
227231
ret = deep_merge(module.gen_tf(depends_on=previous_module_reference), ret)
@@ -251,6 +255,10 @@ def post_hook(self, module_idx: int, exception: Optional[Exception]) -> None:
251255
RunxProcessor(module, self).post_hook(module_idx, exception)
252256
else:
253257
ModuleProcessor(module, self).post_hook(module_idx, exception)
258+
if self.parent is not None and self.parent.get_module("runx") is not None:
259+
RunxProcessor(self.parent.get_module("runx"), self).post_hook( # type:ignore
260+
module_idx, exception
261+
)
254262

255263
def metadata_hydration(self) -> Dict[Any, Any]:
256264
parent_name = self.parent.name if self.parent is not None else "nil"

opta/module_processors/runx.py

Lines changed: 97 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,18 @@
44
from datetime import datetime
55
from typing import TYPE_CHECKING, Optional, Tuple
66

7+
import boto3
8+
import click
79
import requests
10+
from botocore.config import Config
811
from getmac import get_mac_address
912
from git.config import GitConfigParser
13+
from google.api_core.exceptions import NotFound
14+
from google.cloud import secretmanager
15+
from mypy_boto3_ssm.client import SSMClient
1016

1117
from opta.constants import VERSION
18+
from opta.core.gcp import GCP
1219
from opta.exceptions import UserErrors
1320
from opta.module_processors.base import ModuleProcessor
1421
from opta.utils import logger
@@ -27,9 +34,6 @@ class RunxProcessor(ModuleProcessor):
2734
def __init__(self, module: "Module", layer: "Layer"):
2835
if module.data["type"] != "runx":
2936
raise Exception(f"The module {module.name} was expected to be of type runx")
30-
if os.environ.get("OPTA_API_KEY") is None:
31-
raise UserErrors("Need opta api key present for this to run")
32-
self.api_key = os.environ.get("OPTA_API_KEY")
3337
self.user_id = GitConfigParser().get_value("user", "email", "no_user")
3438
self.device_id = get_mac_address()
3539
self.os_name = os.name
@@ -38,10 +42,96 @@ def __init__(self, module: "Module", layer: "Layer"):
3842
super(RunxProcessor, self).__init__(module, layer)
3943

4044
def process(self, module_idx: int) -> None:
41-
self.fetch_jwt()
45+
logger.info("Checking for runx api key secret")
46+
if self.fetch_secret() is None:
47+
self.set_secret()
48+
49+
def fetch_secret(self) -> Optional[str]:
50+
if self.layer.cloud == "aws":
51+
return self._fetch_aws_secret()
52+
elif self.layer.cloud == "google":
53+
return self._fetch_gcp_secret()
54+
else:
55+
raise Exception("Can not handle secrets of type")
56+
57+
def _fetch_aws_secret(self) -> Optional[str]:
58+
providers = self.layer.gen_providers(0)
59+
region = providers["provider"]["aws"]["region"]
60+
ssm_client: SSMClient = boto3.client("ssm", config=Config(region_name=region))
61+
try:
62+
parameter = ssm_client.get_parameter(
63+
Name=f"/opta-{self.layer.get_env()}/runx-api-key", WithDecryption=True
64+
)
65+
return parameter["Parameter"]["Value"]
66+
except ssm_client.exceptions.ParameterNotFound:
67+
return None
68+
69+
def _fetch_gcp_secret(self) -> Optional[str]:
70+
credentials, project_id = GCP.get_credentials()
71+
sm_client = secretmanager.SecretManagerServiceClient(credentials=credentials)
72+
name = f"projects/{project_id}/secrets/opta-{self.layer.get_env()}-runx-api-key/versions/1"
73+
try:
74+
# Access the secret version.
75+
response = sm_client.access_secret_version(
76+
request=secretmanager.AccessSecretVersionRequest({"name": name})
77+
)
78+
return response.payload.data.decode("UTF-8")
79+
except NotFound:
80+
return None
81+
82+
def set_secret(self) -> None:
83+
while True:
84+
value = click.prompt("Please enter your runx api key", type=str,)
85+
try:
86+
self.fetch_jwt(value)
87+
except UserErrors:
88+
logger.warn(
89+
"The api key which you passed was invalid, please provide a valid api key from runx"
90+
)
91+
else:
92+
break
93+
if self.layer.cloud == "aws":
94+
return self._set_aws_secret(value)
95+
elif self.layer.cloud == "google":
96+
return self._set_gcp_secret(value)
97+
else:
98+
raise Exception("Can not handle secrets of type")
99+
100+
def _set_aws_secret(self, secret: str) -> None:
101+
providers = self.layer.gen_providers(0)
102+
region = providers["provider"]["aws"]["region"]
103+
ssm_client: SSMClient = boto3.client("ssm", config=Config(region_name=region))
104+
ssm_client.put_parameter(
105+
Name=f"/opta-{self.layer.get_env()}/runx-api-key",
106+
Value=secret,
107+
Type="SecureString",
108+
)
109+
110+
def _set_gcp_secret(self, secret: str) -> None:
111+
credentials, project_id = GCP.get_credentials()
112+
sm_client = secretmanager.SecretManagerServiceClient(credentials=credentials)
113+
sm_secret = sm_client.create_secret(
114+
request=secretmanager.CreateSecretRequest(
115+
{
116+
"parent": f"projects/{project_id}",
117+
"secret_id": f"opta-{self.layer.get_env()}-runx-api-key",
118+
"secret": {"replication": {"automatic": {}}},
119+
}
120+
)
121+
)
122+
sm_client.add_secret_version(
123+
request=secretmanager.AddSecretVersionRequest(
124+
{"parent": sm_secret.name, "payload": {"data": secret.encode("utf-8")}}
125+
)
126+
)
42127

43128
def post_hook(self, module_idx: int, exception: Optional[Exception]) -> None:
44-
validation_data, jwt = self.fetch_jwt()
129+
api_key = self.fetch_secret()
130+
if api_key is None:
131+
raise Exception(
132+
"The api key seems to have just disappeared from the secret storage"
133+
)
134+
validation_data, jwt = self.fetch_jwt(api_key)
45135
is_environment = self.layer.parent is None
46136
url_path = "/config/environments" if is_environment else "/config/services"
47137
body = {
@@ -73,9 +163,9 @@ def post_hook(self, module_idx: int, exception: Optional[Exception]) -> None:
73163
f"Invalid response when attempting to send data to backend: {resp.json()}"
74164
)
75165

76-
def fetch_jwt(self) -> Tuple[dict, str]:
166+
def fetch_jwt(self, api_key: str) -> Tuple[dict, str]:
77167
resp = requests.post(
78-
f"https://{OPTA_DOMAIN}/user/apikeys/validate", json={"api_key": self.api_key}
168+
f"https://{OPTA_DOMAIN}/user/apikeys/validate", json={"api_key": api_key}
79169
)
80170
if resp.status_code == 404:
81171
raise UserErrors(

0 commit comments

Comments
 (0)