clear-skies-aws 1.10.2__py3-none-any.whl → 2.0.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {clear_skies_aws-1.10.2.dist-info → clear_skies_aws-2.0.2.dist-info}/METADATA +36 -35
- clear_skies_aws-2.0.2.dist-info/RECORD +63 -0
- {clear_skies_aws-1.10.2.dist-info → clear_skies_aws-2.0.2.dist-info}/WHEEL +1 -1
- clear_skies_aws-2.0.2.dist-info/licenses/LICENSE +21 -0
- clearskies_aws/__init__.py +15 -2
- clearskies_aws/actions/__init__.py +13 -106
- clearskies_aws/actions/action_aws.py +74 -57
- clearskies_aws/actions/assume_role.py +43 -30
- clearskies_aws/actions/ses.py +82 -73
- clearskies_aws/actions/sns.py +27 -30
- clearskies_aws/actions/sqs.py +32 -33
- clearskies_aws/actions/step_function.py +38 -31
- clearskies_aws/backends/__init__.py +11 -4
- clearskies_aws/backends/backend.py +106 -0
- clearskies_aws/backends/dynamo_db_backend.py +150 -155
- clearskies_aws/backends/dynamo_db_condition_parser.py +40 -80
- clearskies_aws/backends/dynamo_db_parti_ql_backend.py +179 -337
- clearskies_aws/backends/sqs_backend.py +32 -51
- clearskies_aws/configs/__init__.py +0 -0
- clearskies_aws/contexts/__init__.py +23 -10
- clearskies_aws/contexts/cli_web_socket_mock.py +19 -0
- clearskies_aws/contexts/lambda_alb.py +76 -0
- clearskies_aws/contexts/lambda_api_gateway.py +75 -28
- clearskies_aws/contexts/lambda_api_gateway_web_socket.py +56 -29
- clearskies_aws/contexts/lambda_invocation.py +15 -44
- clearskies_aws/contexts/lambda_sns.py +8 -33
- clearskies_aws/contexts/lambda_sqs_standard_partial_batch.py +14 -36
- clearskies_aws/di/__init__.py +6 -1
- clearskies_aws/di/aws_additional_config_auto_import.py +37 -0
- clearskies_aws/di/inject/__init__.py +6 -0
- clearskies_aws/di/inject/boto3.py +15 -0
- clearskies_aws/di/inject/boto3_session.py +13 -0
- clearskies_aws/di/inject/parameter_store.py +15 -0
- clearskies_aws/{handlers → endpoints}/secrets_manager_rotation.py +76 -55
- clearskies_aws/endpoints/simple_body_routing.py +41 -0
- clearskies_aws/input_outputs/__init__.py +21 -8
- clearskies_aws/input_outputs/{cli_websocket_mock.py → cli_web_socket_mock.py} +9 -3
- clearskies_aws/input_outputs/lambda_alb.py +53 -0
- clearskies_aws/input_outputs/lambda_api_gateway.py +106 -88
- clearskies_aws/input_outputs/lambda_api_gateway_web_socket.py +69 -6
- clearskies_aws/input_outputs/lambda_input_output.py +87 -0
- clearskies_aws/input_outputs/lambda_invocation.py +77 -26
- clearskies_aws/input_outputs/lambda_sns.py +66 -39
- clearskies_aws/input_outputs/lambda_sqs_standard.py +70 -40
- clearskies_aws/mocks/actions/ses.py +25 -19
- clearskies_aws/mocks/actions/sns.py +18 -12
- clearskies_aws/mocks/actions/sqs.py +18 -12
- clearskies_aws/mocks/actions/step_function.py +19 -13
- clearskies_aws/models/__init__.py +0 -0
- clearskies_aws/models/web_socket_connection_model.py +182 -0
- clearskies_aws/secrets/__init__.py +13 -7
- clearskies_aws/secrets/additional_configs/__init__.py +10 -2
- clearskies_aws/secrets/additional_configs/iam_db_auth.py +26 -16
- clearskies_aws/secrets/additional_configs/iam_db_auth_with_ssm.py +43 -39
- clearskies_aws/secrets/additional_configs/mysql_connection_dynamic_producer_via_ssh_cert_bastion.py +30 -31
- clearskies_aws/secrets/additional_configs/mysql_connection_dynamic_producer_via_ssm_bastion.py +70 -49
- clearskies_aws/secrets/akeyless_with_ssm_cache.py +32 -18
- clearskies_aws/secrets/parameter_store.py +34 -32
- clearskies_aws/secrets/secrets.py +16 -0
- clearskies_aws/secrets/secrets_manager.py +78 -57
- clear_skies_aws-1.10.2.dist-info/LICENSE +0 -7
- clear_skies_aws-1.10.2.dist-info/RECORD +0 -71
- clearskies_aws/actions/assume_role_test.py +0 -72
- clearskies_aws/actions/ses_test.py +0 -89
- clearskies_aws/actions/sns_test.py +0 -77
- clearskies_aws/actions/sqs_test.py +0 -127
- clearskies_aws/actions/step_function_test.py +0 -103
- clearskies_aws/backends/dynamo_db_backend_test.py +0 -300
- clearskies_aws/backends/dynamo_db_condition_parser_test.py +0 -266
- clearskies_aws/backends/dynamo_db_parti_ql_backend_test.py +0 -544
- clearskies_aws/backends/sqs_backend_test.py +0 -31
- clearskies_aws/contexts/cli.py +0 -19
- clearskies_aws/contexts/cli_websocket_mock.py +0 -33
- clearskies_aws/contexts/lambda_elb.py +0 -30
- clearskies_aws/contexts/lambda_http_gateway.py +0 -30
- clearskies_aws/contexts/lambda_sqs_standard_partial_batch_test.py +0 -66
- clearskies_aws/contexts/wsgi.py +0 -19
- clearskies_aws/di/standard_dependencies.py +0 -60
- clearskies_aws/handlers/simple_body_routing.py +0 -39
- clearskies_aws/input_outputs/lambda_api_gateway_test.py +0 -87
- clearskies_aws/input_outputs/lambda_elb.py +0 -21
- clearskies_aws/input_outputs/lambda_http_gateway.py +0 -12
- clearskies_aws/secrets/parameter_store_test.py +0 -18
- clearskies_aws/secrets/secrets_manager_test.py +0 -18
- clearskies_aws/web_socket_connection_model.py +0 -43
- clearskies_aws/{handlers → endpoints}/__init__.py +1 -1
clearskies_aws/secrets/additional_configs/mysql_connection_dynamic_producer_via_ssm_bastion.py
CHANGED
|
@@ -1,9 +1,16 @@
|
|
|
1
|
-
from
|
|
2
|
-
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
3
4
|
import socket
|
|
4
5
|
import subprocess
|
|
5
|
-
import os
|
|
6
6
|
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from .mysql_connection_dynamic_producer_via_ssh_cert_bastion import (
|
|
10
|
+
MySQLConnectionDynamicProducerViaSSHCertBastion as Base,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
7
14
|
class MySQLConnectionDynamicProducerViaSSMBastion(Base):
|
|
8
15
|
_config = None
|
|
9
16
|
_boto3 = None
|
|
@@ -18,19 +25,19 @@ class MySQLConnectionDynamicProducerViaSSMBastion(Base):
|
|
|
18
25
|
public_key_file_path=None,
|
|
19
26
|
local_proxy_port=None,
|
|
20
27
|
database_host=None,
|
|
21
|
-
database_name=None
|
|
28
|
+
database_name=None,
|
|
22
29
|
):
|
|
23
30
|
# not using kwargs because I want the argument list to be explicit
|
|
24
31
|
self.config = {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
"producer_name": producer_name,
|
|
33
|
+
"bastion_instance_id": bastion_instance_id,
|
|
34
|
+
"bastion_region": bastion_region,
|
|
35
|
+
"bastion_name": bastion_name,
|
|
36
|
+
"bastion_username": bastion_username,
|
|
37
|
+
"public_key_file_path": public_key_file_path,
|
|
38
|
+
"local_proxy_port": local_proxy_port,
|
|
39
|
+
"database_host": database_host,
|
|
40
|
+
"database_name": database_name,
|
|
34
41
|
}
|
|
35
42
|
|
|
36
43
|
def provide_connection_details(self, environment, secrets, boto3):
|
|
@@ -40,91 +47,105 @@ class MySQLConnectionDynamicProducerViaSSMBastion(Base):
|
|
|
40
47
|
"I was asked to connect to a database via an AKeyless dynamic producer but AKeyless itself wasn't configured. Try setting the AKeyless auth method via clearskies.secrets.akeyless_[jwt|saml|aws_iam]_auth()"
|
|
41
48
|
)
|
|
42
49
|
|
|
43
|
-
producer_name = self._fetch_config(environment,
|
|
44
|
-
bastion_username = self._fetch_config(environment,
|
|
50
|
+
producer_name = self._fetch_config(environment, "producer_name", "akeyless_mysql_dynamic_producer")
|
|
51
|
+
bastion_username = self._fetch_config(environment, "bastion_username", "mysql_bastion_username", default="ssm")
|
|
45
52
|
bastion_instance_id = self._get_bastion_instance_id(environment)
|
|
46
53
|
public_key_file_path = self._fetch_config(
|
|
47
|
-
environment,
|
|
54
|
+
environment, "public_key_file_path", "mysql_bastion_public_key_file_path"
|
|
48
55
|
)
|
|
49
56
|
local_proxy_port = self._fetch_config(
|
|
50
|
-
environment,
|
|
57
|
+
environment, "local_proxy_port", "akeyless_mysql_bastion_local_proxy_port", default=8888
|
|
51
58
|
)
|
|
52
|
-
database_host = self._fetch_config(environment,
|
|
53
|
-
database_name = self._fetch_config(environment,
|
|
59
|
+
database_host = self._fetch_config(environment, "database_host", "db_host")
|
|
60
|
+
database_name = self._fetch_config(environment, "database_name", "db_database")
|
|
54
61
|
|
|
55
62
|
# Create the SSH tunnel (yeah, it's obnoxious)
|
|
56
63
|
self._create_tunnel(
|
|
57
|
-
secrets,
|
|
58
|
-
|
|
64
|
+
secrets,
|
|
65
|
+
bastion_instance_id,
|
|
66
|
+
bastion_username,
|
|
67
|
+
bastion_region,
|
|
68
|
+
public_key_file_path,
|
|
69
|
+
local_proxy_port,
|
|
70
|
+
database_host,
|
|
59
71
|
)
|
|
60
72
|
|
|
61
73
|
# and now we can fetch credentials
|
|
62
74
|
credentials = secrets.get_dynamic_secret(producer_name)
|
|
63
75
|
|
|
64
76
|
return {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
77
|
+
"username": credentials["user"],
|
|
78
|
+
"password": credentials["password"],
|
|
79
|
+
"host": "127.0.0.1",
|
|
80
|
+
"database": database_name,
|
|
81
|
+
"port": local_proxy_port,
|
|
70
82
|
}
|
|
71
83
|
|
|
72
84
|
def _get_bastion_instance_id(self, environment):
|
|
73
85
|
bastion_instance_id = self._fetch_config(
|
|
74
|
-
environment,
|
|
86
|
+
environment, "bastion_instance_id", "mysql_bastion_instance_id", default=""
|
|
75
87
|
)
|
|
76
|
-
bastion_name = self._fetch_config(environment,
|
|
88
|
+
bastion_name = self._fetch_config(environment, "bastion_name", "mysql_bastion_name", default="")
|
|
77
89
|
if bastion_instance_id:
|
|
78
90
|
return bastion_instance_id
|
|
79
91
|
if bastion_name:
|
|
80
|
-
bastion_region = self._fetch_config(environment,
|
|
92
|
+
bastion_region = self._fetch_config(environment, "bastion_region", "mysql_bastion_region")
|
|
81
93
|
return self._instance_id_from_name(bastion_name, bastion_region)
|
|
82
94
|
raise ValueError(
|
|
83
95
|
f"I was asked to connect to a database via an AKeyless dynamic producer through an SSH bastion with certificate auth, but I'm missing some configuration. I need either the bastion host or the name of the instance in AWS. These can be set in the call to `clearskies.backends.akeyless_aws.mysql_connection_dynamic_producer_via_ssh_cert_bastion()` by providing the 'bastion_host' or 'bastion_name' argument, or by setting an environment variable named 'akeyless_mysql_bastion_host' or 'akeyless_mysql_bastion_name'."
|
|
84
96
|
)
|
|
85
97
|
|
|
86
98
|
def _instance_id_from_name(self, bastion_name, bastion_region):
|
|
87
|
-
ec2 = self._boto3.client(
|
|
99
|
+
ec2 = self._boto3.client("ec2", region_name=bastion_region)
|
|
88
100
|
response = ec2.describe_instances(
|
|
89
101
|
Filters=[
|
|
90
|
-
{
|
|
91
|
-
|
|
92
|
-
'Values': [bastion_name]
|
|
93
|
-
},
|
|
94
|
-
{
|
|
95
|
-
'Name': 'instance-state-name',
|
|
96
|
-
'Values': ['running']
|
|
97
|
-
},
|
|
102
|
+
{"Name": "tag:Name", "Values": [bastion_name]},
|
|
103
|
+
{"Name": "instance-state-name", "Values": ["running"]},
|
|
98
104
|
],
|
|
99
105
|
)
|
|
100
|
-
if not response.get(
|
|
106
|
+
if not response.get("Reservations"):
|
|
101
107
|
raise ValueError(
|
|
102
108
|
f"Could not find a running instance with the designated bastion name, '{bastion_name}' in region '{bastion_region}'"
|
|
103
109
|
)
|
|
104
|
-
if not response.get(
|
|
110
|
+
if not response.get("Reservations")[0].get("Instances"):
|
|
105
111
|
raise ValueError(
|
|
106
112
|
f"Could not find a running instance with the designated bastion name, '{bastion_name}' in region '{bastion_region}'"
|
|
107
113
|
)
|
|
108
|
-
return response.get(
|
|
114
|
+
return response.get("Reservations")[0].get("Instances")[0]["InstanceId"]
|
|
109
115
|
|
|
110
116
|
def _create_tunnel(
|
|
111
|
-
self,
|
|
112
|
-
|
|
117
|
+
self,
|
|
118
|
+
secrets,
|
|
119
|
+
bastion_instance_id,
|
|
120
|
+
bastion_username,
|
|
121
|
+
bastion_region,
|
|
122
|
+
public_key_file_path,
|
|
123
|
+
local_proxy_port,
|
|
124
|
+
database_host,
|
|
113
125
|
):
|
|
114
126
|
# first see if the socket is already open, since we don't close it.
|
|
115
127
|
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
116
|
-
result = sock.connect_ex((
|
|
128
|
+
result = sock.connect_ex(("127.0.0.1", local_proxy_port))
|
|
117
129
|
if result == 0:
|
|
118
130
|
sock.close()
|
|
119
131
|
return
|
|
120
132
|
|
|
121
133
|
# and now we can do this thing.
|
|
122
134
|
tunnel_command = [
|
|
123
|
-
|
|
124
|
-
|
|
135
|
+
"ssh",
|
|
136
|
+
"-i",
|
|
137
|
+
public_key_file_path,
|
|
138
|
+
"-o",
|
|
139
|
+
"ConnectTimeout=2",
|
|
140
|
+
"-N",
|
|
141
|
+
"-L",
|
|
142
|
+
f"{local_proxy_port}:{database_host}:3306",
|
|
143
|
+
"-p",
|
|
144
|
+
"22",
|
|
145
|
+
f"{bastion_username}@{bastion_instance_id}",
|
|
125
146
|
]
|
|
126
147
|
my_env = os.environ.copy()
|
|
127
|
-
my_env[
|
|
148
|
+
my_env["AWS_DEFAULT_REGION"] = bastion_region
|
|
128
149
|
subprocess.Popen(tunnel_command)
|
|
129
150
|
connected = False
|
|
130
151
|
attempts = 0
|
|
@@ -132,10 +153,10 @@ class MySQLConnectionDynamicProducerViaSSMBastion(Base):
|
|
|
132
153
|
attempts += 1
|
|
133
154
|
time.sleep(0.5)
|
|
134
155
|
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
135
|
-
result = sock.connect_ex((
|
|
156
|
+
result = sock.connect_ex(("127.0.0.1", local_proxy_port))
|
|
136
157
|
if result == 0:
|
|
137
158
|
connected = True
|
|
138
159
|
if not connected:
|
|
139
160
|
raise ValueError(
|
|
140
|
-
|
|
161
|
+
"Failed to open SSH tunnel. The following command was used: \n" + " ".join(tunnel_command)
|
|
141
162
|
)
|
|
@@ -1,21 +1,16 @@
|
|
|
1
|
-
import
|
|
1
|
+
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
import re
|
|
4
|
+
from typing import Any
|
|
4
5
|
|
|
6
|
+
from clearskies.secrets.akeyless import Akeyless
|
|
7
|
+
from types_boto3_ssm import SSMClient
|
|
5
8
|
|
|
6
|
-
|
|
7
|
-
_boto3 = None
|
|
9
|
+
from clearskies_aws.secrets import parameter_store
|
|
8
10
|
|
|
9
|
-
def __init__(self, requests, environment, boto3):
|
|
10
|
-
super().__init__(requests, environment)
|
|
11
|
-
self._boto3 = boto3
|
|
12
|
-
if not self._environment.get("AWS_REGION", True):
|
|
13
|
-
raise ValueError(
|
|
14
|
-
"To use parameter store you must use set the 'AWS_REGION' environment variable"
|
|
15
|
-
)
|
|
16
11
|
|
|
17
|
-
|
|
18
|
-
|
|
12
|
+
class AkeylessWithSsmCache(parameter_store.ParameterStore, Akeyless):
|
|
13
|
+
def get(self, path: str, refresh: bool = False) -> str | None: # type: ignore[override]
|
|
19
14
|
# AWS SSM parameter paths only allow a-z, A-Z, 0-9, -, _, ., /, @, and :
|
|
20
15
|
# Replace any disallowed characters with hyphens
|
|
21
16
|
ssm_name = re.sub(r"[^a-zA-Z0-9\-_\./@:]", "-", path)
|
|
@@ -23,20 +18,20 @@ class AkeylessWithSsmCache(AKeyless):
|
|
|
23
18
|
if not refresh:
|
|
24
19
|
missing = False
|
|
25
20
|
try:
|
|
26
|
-
response = ssm.get_parameter(Name=ssm_name, WithDecryption=True)
|
|
27
|
-
except ssm.exceptions.ParameterNotFound:
|
|
21
|
+
response = self.ssm.get_parameter(Name=ssm_name, WithDecryption=True)
|
|
22
|
+
except self.ssm.exceptions.ParameterNotFound:
|
|
28
23
|
missing = True
|
|
29
24
|
if not missing:
|
|
30
|
-
value = response["Parameter"]
|
|
25
|
+
value = response["Parameter"].get("Value", "")
|
|
31
26
|
if value:
|
|
32
27
|
return value
|
|
33
28
|
|
|
34
29
|
# otherwise get it out of Akeyless
|
|
35
|
-
value = super().get(path)
|
|
30
|
+
value = str(super().get(path))
|
|
36
31
|
|
|
37
32
|
# and make sure and store the new value in parameter store
|
|
38
33
|
if value:
|
|
39
|
-
ssm.put_parameter(
|
|
34
|
+
self.ssm.put_parameter(
|
|
40
35
|
Name=ssm_name,
|
|
41
36
|
Value=value,
|
|
42
37
|
Type="SecureString",
|
|
@@ -44,3 +39,22 @@ class AkeylessWithSsmCache(AKeyless):
|
|
|
44
39
|
)
|
|
45
40
|
|
|
46
41
|
return value
|
|
42
|
+
|
|
43
|
+
def update(self, path: str, value: Any) -> bool: # type: ignore[override]
|
|
44
|
+
res = self._api.update_secret_val(
|
|
45
|
+
self.akeyless.UpdateSecretVal(name=path, value=str(value), token=self._get_token())
|
|
46
|
+
)
|
|
47
|
+
self.ssm.put_parameter(
|
|
48
|
+
Name=re.sub(r"[^a-zA-Z0-9\-_\./@:]", "-", path),
|
|
49
|
+
Value=value,
|
|
50
|
+
Type="SecureString",
|
|
51
|
+
Overwrite=True,
|
|
52
|
+
)
|
|
53
|
+
return True
|
|
54
|
+
|
|
55
|
+
def upsert(self, path: str, value: Any) -> bool: # type: ignore[override]
|
|
56
|
+
try:
|
|
57
|
+
self.update(path, value)
|
|
58
|
+
except Exception as e:
|
|
59
|
+
self.create(path, value)
|
|
60
|
+
return True
|
|
@@ -1,50 +1,52 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
1
3
|
from botocore.exceptions import ClientError
|
|
2
|
-
from clearskies.
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
self.
|
|
14
|
-
|
|
15
|
-
def create(self, path, value):
|
|
4
|
+
from clearskies.exceptions.not_found import NotFound
|
|
5
|
+
from types_boto3_ssm import SSMClient
|
|
6
|
+
|
|
7
|
+
from clearskies_aws.secrets import secrets
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ParameterStore(secrets.Secrets):
|
|
11
|
+
ssm: SSMClient
|
|
12
|
+
|
|
13
|
+
def __init__(self):
|
|
14
|
+
super().__init__()
|
|
15
|
+
self.ssm = self.boto3.client("ssm", region_name=self.environment.get("AWS_REGION"))
|
|
16
|
+
|
|
17
|
+
def create(self, path: str, value: str) -> bool:
|
|
16
18
|
return self.update(path, value)
|
|
17
19
|
|
|
18
|
-
def get(self, path, silent_if_not_found=False):
|
|
20
|
+
def get(self, path: str, silent_if_not_found: bool = False) -> str | None: # type: ignore[override]
|
|
19
21
|
try:
|
|
20
|
-
result = self.
|
|
22
|
+
result = self.ssm.get_parameter(Name=path, WithDecryption=True)
|
|
21
23
|
except ClientError as e:
|
|
22
|
-
|
|
24
|
+
error = e.response.get("Error", {})
|
|
25
|
+
if error.get("Code") == "ResourceNotFoundException":
|
|
23
26
|
if silent_if_not_found:
|
|
24
27
|
return None
|
|
25
|
-
raise NotFound(
|
|
26
|
-
f"Cound not find secret '{secret_id}' with version '{version}' and stage '{version_stage}'"
|
|
27
|
-
)
|
|
28
|
+
raise NotFound(f"Could not find secret '{path}' in parameter store")
|
|
28
29
|
raise e
|
|
29
|
-
return result[
|
|
30
|
+
return result["Parameter"].get("Value", "")
|
|
30
31
|
|
|
31
|
-
def list_secrets(self, path):
|
|
32
|
-
response = self.
|
|
33
|
-
return [parameter[
|
|
32
|
+
def list_secrets(self, path: str) -> list[str]:
|
|
33
|
+
response = self.ssm.get_parameters_by_path(Path=path, Recursive=False)
|
|
34
|
+
return [parameter["Name"] for parameter in response["Parameters"] if "Name" in parameter]
|
|
34
35
|
|
|
35
|
-
def update(self, path, value):
|
|
36
|
-
response = self.
|
|
36
|
+
def update(self, path: str, value: str) -> bool: # type: ignore[override]
|
|
37
|
+
response = self.ssm.put_parameter(
|
|
37
38
|
Name=path,
|
|
38
39
|
Value=value,
|
|
39
|
-
Type=
|
|
40
|
+
Type="String",
|
|
40
41
|
Overwrite=True,
|
|
41
42
|
)
|
|
42
43
|
return True
|
|
43
44
|
|
|
44
|
-
def upsert(self, path, value):
|
|
45
|
+
def upsert(self, path: str, value: str) -> bool: # type: ignore[override]
|
|
45
46
|
return self.update(path, value)
|
|
46
47
|
|
|
47
|
-
def list_sub_folders(
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
48
|
+
def list_sub_folders(
|
|
49
|
+
self,
|
|
50
|
+
path: str,
|
|
51
|
+
) -> list[str]: # type: ignore[override]
|
|
52
|
+
raise NotImplementedError("Parameter store doesn't support list_sub_folders.")
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from clearskies.di.inject import Di, Environment
|
|
4
|
+
from clearskies.secrets import Secrets as BaseSecrets
|
|
5
|
+
|
|
6
|
+
from clearskies_aws.di import inject
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Secrets(BaseSecrets):
|
|
10
|
+
boto3 = inject.Boto3()
|
|
11
|
+
environment = Environment()
|
|
12
|
+
|
|
13
|
+
def __init__(self):
|
|
14
|
+
super().__init__()
|
|
15
|
+
if not self.environment.get("AWS_REGION", True):
|
|
16
|
+
raise ValueError("To use secrets manager you must use set the 'AWS_REGION' environment variable")
|
|
@@ -1,75 +1,96 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
1
5
|
from botocore.exceptions import ClientError
|
|
2
|
-
from clearskies.
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
_environment = None
|
|
6
|
-
_secrets_manager = None
|
|
7
|
-
|
|
8
|
-
def __init__(self, boto3, environment):
|
|
9
|
-
self._boto3 = boto3
|
|
10
|
-
self._environment = environment
|
|
11
|
-
if not self._environment.get('AWS_REGION', True):
|
|
12
|
-
raise ValueError("To use secrets manager you must use set the 'AWS_REGION' environment variable")
|
|
13
|
-
self._secrets_manager = self._boto3.client('secretsmanager', region_name=self._environment.get('AWS_REGION'))
|
|
14
|
-
|
|
15
|
-
def create(self, secret_id, value, kms_key_id=None):
|
|
16
|
-
calling_parameters = {
|
|
17
|
-
'SecretId': secret_id,
|
|
18
|
-
'SecretString': value,
|
|
19
|
-
'KmsKeyId': kms_key_id,
|
|
20
|
-
}
|
|
21
|
-
calling_parameters = {key: value for (key, value) in calling_parameters.items() if value}
|
|
22
|
-
result = self._secrets_manager.create_secret(**calling_parameters)
|
|
6
|
+
from clearskies.exceptions.not_found import NotFound
|
|
7
|
+
from types_boto3_secretsmanager import SecretsManagerClient
|
|
8
|
+
from types_boto3_secretsmanager.type_defs import SecretListEntryTypeDef
|
|
23
9
|
|
|
24
|
-
|
|
10
|
+
from clearskies_aws.secrets import secrets
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SecretsManager(secrets.Secrets):
|
|
14
|
+
secrets_manager: SecretsManagerClient
|
|
15
|
+
|
|
16
|
+
def __init__(self):
|
|
17
|
+
super().__init__()
|
|
18
|
+
self.secrets_manager = self.boto3.client("secretsmanager", region_name=self.environment.get("AWS_REGION"))
|
|
19
|
+
|
|
20
|
+
def create(self, secret_id: str, value: Any, kms_key_id: str | None = None) -> bool:
|
|
25
21
|
calling_parameters = {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
22
|
+
"SecretId": secret_id,
|
|
23
|
+
"SecretString": value,
|
|
24
|
+
"KmsKeyId": kms_key_id,
|
|
29
25
|
}
|
|
30
26
|
calling_parameters = {key: value for (key, value) in calling_parameters.items() if value}
|
|
27
|
+
result = self.secrets_manager.create_secret(**calling_parameters)
|
|
28
|
+
return bool(result.get("ARN"))
|
|
29
|
+
|
|
30
|
+
def get( # type: ignore[override]
|
|
31
|
+
self,
|
|
32
|
+
secret_id: str,
|
|
33
|
+
version_id: str | None = None,
|
|
34
|
+
version_stage: str | None = None,
|
|
35
|
+
silent_if_not_found: bool = False,
|
|
36
|
+
) -> str | bytes | None:
|
|
37
|
+
calling_parameters = {"SecretId": secret_id}
|
|
38
|
+
|
|
39
|
+
# Only add optional parameters if they are not None
|
|
40
|
+
if version_id:
|
|
41
|
+
calling_parameters["VersionId"] = version_id
|
|
42
|
+
if version_stage:
|
|
43
|
+
calling_parameters["VersionStage"] = version_stage
|
|
44
|
+
|
|
31
45
|
try:
|
|
32
|
-
result = self.
|
|
46
|
+
result = self.secrets_manager.get_secret_value(**calling_parameters)
|
|
33
47
|
except ClientError as e:
|
|
34
|
-
|
|
48
|
+
error = e.response.get("Error", {})
|
|
49
|
+
if error.get("Code") == "ResourceNotFoundException":
|
|
35
50
|
if silent_if_not_found:
|
|
36
51
|
return None
|
|
37
52
|
raise NotFound(
|
|
38
|
-
f"
|
|
53
|
+
f"Could not find secret '{secret_id}' with version '{version_id}' and stage '{version_stage}'"
|
|
39
54
|
)
|
|
40
55
|
raise e
|
|
41
|
-
if result.get(
|
|
42
|
-
return result.get(
|
|
43
|
-
return result.get(
|
|
44
|
-
|
|
45
|
-
def list_secrets(self, path):
|
|
46
|
-
results = self.
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
56
|
+
if result.get("SecretString"):
|
|
57
|
+
return result.get("SecretString")
|
|
58
|
+
return result.get("SecretBinary")
|
|
59
|
+
|
|
60
|
+
def list_secrets(self, path: str) -> list[SecretListEntryTypeDef]: # type: ignore[override]
|
|
61
|
+
results = self.secrets_manager.list_secrets(
|
|
62
|
+
Filters=[
|
|
63
|
+
{
|
|
64
|
+
"Key": "name",
|
|
65
|
+
"Values": [path],
|
|
66
|
+
},
|
|
67
|
+
],
|
|
68
|
+
)
|
|
69
|
+
return results["SecretList"]
|
|
70
|
+
|
|
71
|
+
def update(self, secret_id: str, value: str, kms_key_id: str | None = None) -> bool: # type: ignore[override]
|
|
55
72
|
calling_parameters = {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
'KmsKeyId': kms_key_id,
|
|
73
|
+
"SecretId": secret_id,
|
|
74
|
+
"SecretString": value,
|
|
59
75
|
}
|
|
60
|
-
|
|
61
|
-
|
|
76
|
+
if kms_key_id:
|
|
77
|
+
# If no KMS key is provided, we should not include it in the parameters
|
|
78
|
+
calling_parameters["KmsKeyId"] = kms_key_id
|
|
79
|
+
|
|
80
|
+
result = self.secrets_manager.update_secret(**calling_parameters)
|
|
81
|
+
return bool(result.get("ARN"))
|
|
62
82
|
|
|
63
|
-
def upsert(self, secret_id, value, kms_key_id=None):
|
|
83
|
+
def upsert(self, secret_id: str, value: str, kms_key_id: str | None = None) -> bool: # type: ignore[override]
|
|
64
84
|
calling_parameters = {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
'KmsKeyId': kms_key_id,
|
|
85
|
+
"SecretId": secret_id,
|
|
86
|
+
"SecretString": value,
|
|
68
87
|
}
|
|
69
|
-
|
|
70
|
-
|
|
88
|
+
if kms_key_id:
|
|
89
|
+
# If no KMS key is provided, we should not include it in the parameters
|
|
90
|
+
calling_parameters["KmsKeyId"] = kms_key_id
|
|
71
91
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
92
|
+
result = self.secrets_manager.put_secret_value(**calling_parameters)
|
|
93
|
+
return bool(result.get("ARN"))
|
|
94
|
+
|
|
95
|
+
def list_sub_folders(self, path: str, value: str) -> list[str]: # type: ignore[override]
|
|
96
|
+
raise NotImplementedError("Secrets Manager doesn't support list_sub_folders.")
|
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
Copyright 2021 Conor Mancone
|
|
2
|
-
|
|
3
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
4
|
-
|
|
5
|
-
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
6
|
-
|
|
7
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
@@ -1,71 +0,0 @@
|
|
|
1
|
-
clearskies_aws/__init__.py,sha256=BZKL4SIyxx4MXpyP5hmwsMo2oJatyv5c2wjJOPzKBQ4,134
|
|
2
|
-
clearskies_aws/actions/__init__.py,sha256=w_IWJg4UHV9Opv69zheH7ZHj1BnCzYLlm2JhFvHQCf8,2998
|
|
3
|
-
clearskies_aws/actions/action_aws.py,sha256=zBXsvhBDDm2dAU4MVlub84Ld1YNZ6ey8_zf4OeTe8go,4254
|
|
4
|
-
clearskies_aws/actions/assume_role.py,sha256=tZHKMTQaImj_EAWormFZdIPkR_db7yBwOJxYaUyuGdA,4085
|
|
5
|
-
clearskies_aws/actions/assume_role_test.py,sha256=AoXWRtApRZz_oPDj6QhT0ERmYjKOHOXLNet-MgbbeB8,2451
|
|
6
|
-
clearskies_aws/actions/ses.py,sha256=xJ0NEFPP-8kARPGjDv1yf_bhAiaIIM1mDzMxY22GE5E,7925
|
|
7
|
-
clearskies_aws/actions/ses_test.py,sha256=lyNbqI46Ld_ZUKtWLgSwwfSOFksQ39H4hzPS3eTx7nk,3076
|
|
8
|
-
clearskies_aws/actions/sns.py,sha256=00E-eCQuq3Ygscyia928K8jX8rhDZrl5kyasM0st_uI,2288
|
|
9
|
-
clearskies_aws/actions/sns_test.py,sha256=iqlaZOA-0YHPOu52-XqpCIAgAv1JZodDdlm4YYYhj8U,2306
|
|
10
|
-
clearskies_aws/actions/sqs.py,sha256=i-QyyFy-ImivXk-m-Bn9Mh5BF5qODthUyZAQVRsb8vs,3392
|
|
11
|
-
clearskies_aws/actions/sqs_test.py,sha256=aazVN-bY6E17Pu3hDwLldj6YPirbqXjM6LfYFomUVmQ,4040
|
|
12
|
-
clearskies_aws/actions/step_function.py,sha256=wiDgGdOWdY0zVoClDsX08YFZqTR0bK3ydpyj0Qa7w_s,2599
|
|
13
|
-
clearskies_aws/actions/step_function_test.py,sha256=0gRYLDVRt2R-Cv-5Pr2ui4Gbp8rg9nPhQY4ERALg6qU,3837
|
|
14
|
-
clearskies_aws/backends/__init__.py,sha256=t98x6QNF2bvKzVI_owS4L3eG5JNVtsLS9c2fBw8uh9s,379
|
|
15
|
-
clearskies_aws/backends/dynamo_db_backend.py,sha256=i-Na3McQDbyCYmQewBPKgbWEd3A3pf2wfmLs6FsRJ48,29487
|
|
16
|
-
clearskies_aws/backends/dynamo_db_backend_test.py,sha256=wtymsOEVzO9y7_Whu9cCRyioRTmSimbLmRQSt3J4cik,13106
|
|
17
|
-
clearskies_aws/backends/dynamo_db_condition_parser.py,sha256=v-DO4ijdjiHwBsSDF3dUgqQw5twCcG-5NA1aq7kUYQ0,13239
|
|
18
|
-
clearskies_aws/backends/dynamo_db_condition_parser_test.py,sha256=n7snumB8GxyMBVlj-HL7JrsNj-ZJ91c5gdik0UNM5Ag,12664
|
|
19
|
-
clearskies_aws/backends/dynamo_db_parti_ql_backend.py,sha256=pVd_N7XFf9PcTAm6rhMxih_sj9Ix6RkRj3LwmFy_Mr4,48544
|
|
20
|
-
clearskies_aws/backends/dynamo_db_parti_ql_backend_test.py,sha256=7gH-RDh7JeaiEGLXsOB512SLmOp0hjdtWQ1cMmexvDM,23649
|
|
21
|
-
clearskies_aws/backends/sqs_backend.py,sha256=hT1JCvCMU76Q4Ir7OeX8U8eAMLIu1_tMwGgaN9rpsPk,2726
|
|
22
|
-
clearskies_aws/backends/sqs_backend_test.py,sha256=iCuHVVqZIR_PDGUUMZIkpir8yyJy3dcPR00AWR2N25U,1138
|
|
23
|
-
clearskies_aws/contexts/__init__.py,sha256=YjwRaSoAqC-nmo3aFB8rzyuiHSP9mi77FVM-8RF0hRE,472
|
|
24
|
-
clearskies_aws/contexts/cli.py,sha256=11Giwl10ydackFo0clpTnHZP-HTmJnvtpbhxJf74jDA,506
|
|
25
|
-
clearskies_aws/contexts/cli_websocket_mock.py,sha256=W5Ujp9UIz4QG2A33OTpozFLihF5SaN2uUhwf17-qj3g,1085
|
|
26
|
-
clearskies_aws/contexts/lambda_api_gateway.py,sha256=zYO_g4K87XYlQeZLYwF7_mzYbZCUzaZYaelpfcfeuTw,1002
|
|
27
|
-
clearskies_aws/contexts/lambda_api_gateway_web_socket.py,sha256=5vTe8uWgHzQ11aToBeRHoFymYufUsTXBLC6sE6zUt9Q,1067
|
|
28
|
-
clearskies_aws/contexts/lambda_elb.py,sha256=R4tyuRPjuTHNgmyzDqxy-VNePsIdXRH-mUMyWEOMky4,952
|
|
29
|
-
clearskies_aws/contexts/lambda_http_gateway.py,sha256=9gmlTTPkxrtLmOQa7uUojCk9tFGg5kJGDB7GiAXx-bQ,1009
|
|
30
|
-
clearskies_aws/contexts/lambda_invocation.py,sha256=U_S3cuY3G1_7ktzGiFTiO2LUnqauqvEDfKKEhYdPtpg,1336
|
|
31
|
-
clearskies_aws/contexts/lambda_sns.py,sha256=crIo3S78N8xfthZsv0l5ooNlpF21ETjoWmHe1WCGQ4w,1364
|
|
32
|
-
clearskies_aws/contexts/lambda_sqs_standard_partial_batch.py,sha256=iypcI4BpxV4IHcKKUWDxK3HuGyaYiP5YPwpFAm4auCw,1801
|
|
33
|
-
clearskies_aws/contexts/lambda_sqs_standard_partial_batch_test.py,sha256=SkEIV_cgxCi-1zC3WXsRNyG6bCt7LB_6CWpxvB8QPxo,1855
|
|
34
|
-
clearskies_aws/contexts/wsgi.py,sha256=wmHnDIfcrQN0I547uE2mlHHQtOLOpLfpbKpBJkw5puY,510
|
|
35
|
-
clearskies_aws/di/__init__.py,sha256=KLq6G-CKR-Vdk9LZe5TijW_U-McJlrp1QuIXqmyAL-A,56
|
|
36
|
-
clearskies_aws/di/standard_dependencies.py,sha256=TRST5BxxPQpWJyvXnAwCRJWYRDXC7qM05X3ffYI7EXc,2023
|
|
37
|
-
clearskies_aws/handlers/__init__.py,sha256=E2x04QGy5dfaDIB5Xu8IRWwgypIrgGEZibijtKWe8jM,112
|
|
38
|
-
clearskies_aws/handlers/secrets_manager_rotation.py,sha256=KV4XWKar4gT0Xu0eM_D75ECzYkjXxjeZtkOopPRG9rc,7471
|
|
39
|
-
clearskies_aws/handlers/simple_body_routing.py,sha256=FGdeDY8nZIGfCOn0oOHi6EOP1h4xXcBP3W9fH0NuBlc,1449
|
|
40
|
-
clearskies_aws/input_outputs/__init__.py,sha256=Tf8kWN95nwUbvlVaboYT06ICtSuZd0MPPzwWhO5iQGs,385
|
|
41
|
-
clearskies_aws/input_outputs/cli_websocket_mock.py,sha256=J3PDYnFxOzqYhmlxWFcobbb4McKpsGduIBq6jdSAx5Y,434
|
|
42
|
-
clearskies_aws/input_outputs/lambda_api_gateway.py,sha256=Xi9IdT0u20IMbaIJMBtF2wmsNLCDvmEUVoYB9IRqV00,3479
|
|
43
|
-
clearskies_aws/input_outputs/lambda_api_gateway_test.py,sha256=B4tUxTqnAEeV0D0VRMu0HyphOJKNij-LtFd2ERcnKI8,2979
|
|
44
|
-
clearskies_aws/input_outputs/lambda_api_gateway_web_socket.py,sha256=kG2fys9X7TailvlOBzIMItuSI8rlvDbBD4AXtI091hM,308
|
|
45
|
-
clearskies_aws/input_outputs/lambda_elb.py,sha256=b675h0DxTGo9YscXN4mwzRGzP3hNSie4iPRKOg3kKPI,750
|
|
46
|
-
clearskies_aws/input_outputs/lambda_http_gateway.py,sha256=YQk7GlQWtQjDiKWhX5jvXc3un1eq2sm1v_jl6iseNoQ,692
|
|
47
|
-
clearskies_aws/input_outputs/lambda_invocation.py,sha256=yF3rYJ-r0YYzlh--33fGE-I-pzyhQxDmDo1TUQxCu00,1122
|
|
48
|
-
clearskies_aws/input_outputs/lambda_sns.py,sha256=0m1dfVCSEftcovGAEWhU9ig4txX9duLkTXbdtjIzSAw,2055
|
|
49
|
-
clearskies_aws/input_outputs/lambda_sqs_standard.py,sha256=RjsOqzyUovTuO3Nb9CSiWnVaHHxJiPmvOmuQbmpIW38,1974
|
|
50
|
-
clearskies_aws/mocks/__init__.py,sha256=mn764gINN667tYoJfnsM6HjAAhCsO_kZ6E-fUwdLY50,22
|
|
51
|
-
clearskies_aws/mocks/actions/__init__.py,sha256=to1r8B365Et2PRVfUWWnJGt7Hdr8vwwQuNyZvTSTP6g,152
|
|
52
|
-
clearskies_aws/mocks/actions/ses.py,sha256=sCCNk_WdnQbINOzg8E31xyoeoEuUQeX-RwPbJ9ueNik,937
|
|
53
|
-
clearskies_aws/mocks/actions/sns.py,sha256=eFnLf-ZIDCL0b62uKDBbPSYXZs1Mc3kRaGv_gPt-8rA,658
|
|
54
|
-
clearskies_aws/mocks/actions/sqs.py,sha256=7cwGX7XUhzfGzDJYso2UkUBhMaugvxEqeNKbh9hOark,662
|
|
55
|
-
clearskies_aws/mocks/actions/step_function.py,sha256=1JbdsVwPea1wmNnTnlgeWzGaQNBAkJl-2-Y5PG3uORI,888
|
|
56
|
-
clearskies_aws/secrets/__init__.py,sha256=MF-e-9FTfnJ64UdZAmYiOxqOl1vSxvWA-FlFFv05CKE,326
|
|
57
|
-
clearskies_aws/secrets/additional_configs/__init__.py,sha256=ejLAqwFTP6Xc5aXJAUpBhK1xUtmLzEmexHBuOOmgNBc,1919
|
|
58
|
-
clearskies_aws/secrets/additional_configs/iam_db_auth.py,sha256=K6eLjo_D0uSxtCfqTAqGshDi3uz_iF-T0sDSJLyoTew,1082
|
|
59
|
-
clearskies_aws/secrets/additional_configs/iam_db_auth_with_ssm.py,sha256=hzvR_WBwSoLcMdGXwhqkvKMKmjXzhZgimPWfm2MWSZQ,3467
|
|
60
|
-
clearskies_aws/secrets/additional_configs/mysql_connection_dynamic_producer_via_ssh_cert_bastion.py,sha256=L2-E8Tm6BDHV-yJJ_M_Lo72jNY7uBaTp8SPrRuekcUE,3776
|
|
61
|
-
clearskies_aws/secrets/additional_configs/mysql_connection_dynamic_producer_via_ssm_bastion.py,sha256=Llvg8uQW8J-qndAlDDtg9TY_Tvu2h9tUzchxxUtaRik,6444
|
|
62
|
-
clearskies_aws/secrets/akeyless_with_ssm_cache.py,sha256=MFAnajwO_XtZWMZHxsHFZBZa8V9vQGH0NSK4I6uVaow,1559
|
|
63
|
-
clearskies_aws/secrets/parameter_store.py,sha256=lxBlp_9d2-vVjGNfl5859XzZCcLVMMrZtlJG8lKGVPA,1810
|
|
64
|
-
clearskies_aws/secrets/parameter_store_test.py,sha256=35fTNau4tq_D4elMwyyByIiLesnmn05QhC_X1FVQXsM,763
|
|
65
|
-
clearskies_aws/secrets/secrets_manager.py,sha256=jlpfAFC23EeSpm50L8B-yrXg4IROQq-M_90zzXDp_ak,3056
|
|
66
|
-
clearskies_aws/secrets/secrets_manager_test.py,sha256=__YSe-YRbbE1S1SBvZZFQd3brIX5DPX2_wE9MI_Ezx0,788
|
|
67
|
-
clearskies_aws/web_socket_connection_model.py,sha256=d_Au_Pu7YXBfc7_lbuI7zz4MZ8ZOOwGM0oooppEofcI,1776
|
|
68
|
-
clear_skies_aws-1.10.2.dist-info/LICENSE,sha256=3Ehd0g3YOpCj8sqj0Xjq5qbOtjjgk9qzhhD9YjRQgOA,1053
|
|
69
|
-
clear_skies_aws-1.10.2.dist-info/METADATA,sha256=jgygnKXGfTw4rejQAglNblRFraGfcX0VeRcwG7NYGyk,8784
|
|
70
|
-
clear_skies_aws-1.10.2.dist-info/WHEEL,sha256=fGIA9gx4Qxk2KDKeNJCbOEwSrmLtjWCwzBz351GyrPQ,88
|
|
71
|
-
clear_skies_aws-1.10.2.dist-info/RECORD,,
|