clear-skies-aws 2.0.11__py3-none-any.whl → 2.0.13__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-2.0.11.dist-info → clear_skies_aws-2.0.13.dist-info}/METADATA +2 -2
- {clear_skies_aws-2.0.11.dist-info → clear_skies_aws-2.0.13.dist-info}/RECORD +17 -19
- clearskies_aws/backends/backend.py +17 -21
- clearskies_aws/backends/sqs_backend.py +7 -10
- clearskies_aws/contexts/__init__.py +2 -0
- clearskies_aws/contexts/lambda_step_function.py +129 -0
- clearskies_aws/di/inject/__init__.py +2 -1
- clearskies_aws/input_outputs/__init__.py +2 -0
- clearskies_aws/input_outputs/lambda_step_function.py +208 -0
- clearskies_aws/secrets/__init__.py +4 -4
- clearskies_aws/secrets/cache_storage/__init__.py +9 -0
- clearskies_aws/secrets/cache_storage/parameter_store_cache.py +118 -0
- clearskies_aws/secrets/parameter_store.py +144 -8
- clearskies_aws/secrets/secrets.py +18 -1
- clearskies_aws/secrets/secrets_manager.py +104 -7
- clearskies_aws/secrets/additional_configs/__init__.py +0 -62
- clearskies_aws/secrets/additional_configs/iam_db_auth.py +0 -39
- clearskies_aws/secrets/additional_configs/iam_db_auth_with_ssm.py +0 -96
- clearskies_aws/secrets/additional_configs/mysql_connection_dynamic_producer_via_ssh_cert_bastion.py +0 -80
- clearskies_aws/secrets/additional_configs/mysql_connection_dynamic_producer_via_ssm_bastion.py +0 -162
- clearskies_aws/secrets/akeyless_with_ssm_cache.py +0 -60
- {clear_skies_aws-2.0.11.dist-info → clear_skies_aws-2.0.13.dist-info}/WHEEL +0 -0
- {clear_skies_aws-2.0.11.dist-info → clear_skies_aws-2.0.13.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,96 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import time
|
|
4
|
-
|
|
5
|
-
import clearskies
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
class IAMDBAuthWithSSM(clearskies.di.AdditionalConfig):
|
|
9
|
-
def provide_subprocess(self):
|
|
10
|
-
import subprocess
|
|
11
|
-
|
|
12
|
-
return subprocess
|
|
13
|
-
|
|
14
|
-
def provide_socket(self):
|
|
15
|
-
import socket
|
|
16
|
-
|
|
17
|
-
return socket
|
|
18
|
-
|
|
19
|
-
def provide_connection_details(self, environment, subprocess, socket, boto3):
|
|
20
|
-
local_port = self.open_tunnel(environment, subprocess, socket, boto3)
|
|
21
|
-
|
|
22
|
-
return {
|
|
23
|
-
"host": "127.0.0.1",
|
|
24
|
-
"database": environment.get("db_database"),
|
|
25
|
-
"username": environment.get("db_username"),
|
|
26
|
-
"password": self.get_password(environment, boto3),
|
|
27
|
-
"ssl_ca": "rds-cert-bundle.pem",
|
|
28
|
-
"port": local_port,
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
def get_password(self, environment, boto3):
|
|
32
|
-
endpoint = environment.get("db_endpoint")
|
|
33
|
-
username = environment.get("db_username")
|
|
34
|
-
region = environment.get("db_region")
|
|
35
|
-
|
|
36
|
-
rds_api = boto3.Session().client("rds", region_name=region)
|
|
37
|
-
return rds_api.generate_db_auth_token(DBHostname=endpoint, Port="3306", DBUsername=username, Region=region)
|
|
38
|
-
|
|
39
|
-
def open_tunnel(self, environment, subprocess, socket, boto3):
|
|
40
|
-
endpoint = environment.get("db_endpoint")
|
|
41
|
-
region = environment.get("db_region")
|
|
42
|
-
instance_name = environment.get("instance_name")
|
|
43
|
-
local_proxy_port = int(environment.get("local_proxy_port", "9000"))
|
|
44
|
-
|
|
45
|
-
ec2_api = boto3.client("ec2", region_name=region)
|
|
46
|
-
running_instances = ec2_api.describe_instances(
|
|
47
|
-
Filters=[
|
|
48
|
-
{"Name": "tag:Name", "Values": [instance_name]},
|
|
49
|
-
{"Name": "instance-state-name", "Values": ["running"]},
|
|
50
|
-
],
|
|
51
|
-
)
|
|
52
|
-
instance_ids = []
|
|
53
|
-
for reservation in running_instances["Reservations"]:
|
|
54
|
-
for instance in reservation["Instances"]:
|
|
55
|
-
instance_ids.append(instance["InstanceId"])
|
|
56
|
-
|
|
57
|
-
if len(instance_ids) == 0:
|
|
58
|
-
raise ValueError("Failed to launch SSM tunnel! Cannot find bastion!")
|
|
59
|
-
|
|
60
|
-
instance_id = instance_ids.pop()
|
|
61
|
-
self._connect_to_bastion(local_proxy_port, instance_id, endpoint, subprocess, socket)
|
|
62
|
-
return local_proxy_port
|
|
63
|
-
|
|
64
|
-
def _connect_to_bastion(self, local_proxy_port, instance_id, endpoint, subprocess, socket):
|
|
65
|
-
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
66
|
-
result = sock.connect_ex(("127.0.0.1", local_proxy_port))
|
|
67
|
-
if result == 0:
|
|
68
|
-
sock.close()
|
|
69
|
-
return
|
|
70
|
-
|
|
71
|
-
tunnel_command = [
|
|
72
|
-
"aws",
|
|
73
|
-
"--region",
|
|
74
|
-
"us-east-1",
|
|
75
|
-
"ssm",
|
|
76
|
-
"start-session",
|
|
77
|
-
"--target",
|
|
78
|
-
"{}".format(instance_id),
|
|
79
|
-
"--document-name",
|
|
80
|
-
"AWS-StartPortForwardingSessionToRemoteHost",
|
|
81
|
-
'--parameters={{"host":["{}"], "portNumber":["3306"],"localPortNumber":["{}"]}}'.format(
|
|
82
|
-
endpoint, local_proxy_port
|
|
83
|
-
),
|
|
84
|
-
]
|
|
85
|
-
|
|
86
|
-
subprocess.Popen(tunnel_command)
|
|
87
|
-
connected = False
|
|
88
|
-
attempts = 0
|
|
89
|
-
while not connected and attempts < 6:
|
|
90
|
-
attempts += 1
|
|
91
|
-
time.sleep(0.5)
|
|
92
|
-
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
93
|
-
result = sock.connect_ex(("127.0.0.1", local_proxy_port))
|
|
94
|
-
if result == 0:
|
|
95
|
-
return
|
|
96
|
-
raise ValueError("Failed to launch SSM tunnel with command: " + " ".join(tunnel_command))
|
clearskies_aws/secrets/additional_configs/mysql_connection_dynamic_producer_via_ssh_cert_bastion.py
DELETED
|
@@ -1,80 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import os
|
|
4
|
-
import socket
|
|
5
|
-
import subprocess
|
|
6
|
-
import time
|
|
7
|
-
from pathlib import Path
|
|
8
|
-
|
|
9
|
-
from clearskies.secrets.additional_configs import MySQLConnectionDynamicProducerViaSSHCertBastion as Base
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
class MySQLConnectionDynamicProducerViaSSHCertBastion(Base):
|
|
13
|
-
_config = None
|
|
14
|
-
_boto3 = None
|
|
15
|
-
|
|
16
|
-
def __init__(
|
|
17
|
-
self,
|
|
18
|
-
producer_name=None,
|
|
19
|
-
bastion_region=None,
|
|
20
|
-
bastion_name=None,
|
|
21
|
-
bastion_host=None,
|
|
22
|
-
bastion_username=None,
|
|
23
|
-
public_key_file_path=None,
|
|
24
|
-
local_proxy_port=None,
|
|
25
|
-
cert_issuer_name=None,
|
|
26
|
-
database_host=None,
|
|
27
|
-
database_name=None,
|
|
28
|
-
):
|
|
29
|
-
# not using kwargs because I want the argument list to be explicit
|
|
30
|
-
self.config = {
|
|
31
|
-
"producer_name": producer_name,
|
|
32
|
-
"bastion_host": bastion_host,
|
|
33
|
-
"bastion_region": bastion_region,
|
|
34
|
-
"bastion_name": bastion_name,
|
|
35
|
-
"bastion_username": bastion_username,
|
|
36
|
-
"public_key_file_path": public_key_file_path,
|
|
37
|
-
"local_proxy_port": local_proxy_port,
|
|
38
|
-
"cert_issuer_name": cert_issuer_name,
|
|
39
|
-
"database_host": database_host,
|
|
40
|
-
"database_name": database_name,
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
def provide_connection_details(self, environment, secrets, boto3):
|
|
44
|
-
self._boto3 = boto3
|
|
45
|
-
return super().provide_connection_details(environment, secrets)
|
|
46
|
-
|
|
47
|
-
def _get_bastion_host(self, environment):
|
|
48
|
-
bastion_host = self._fetch_config(environment, "bastion_host", "akeyless_mysql_bastion_host", default="")
|
|
49
|
-
bastion_name = self._fetch_config(environment, "bastion_name", "akeyless_mysql_bastion_name", default="")
|
|
50
|
-
if bastion_host:
|
|
51
|
-
return bastion_host
|
|
52
|
-
if bastion_name:
|
|
53
|
-
bastion_region = self._fetch_config(environment, "bastion_region", "akeyless_mysql_bastion_region")
|
|
54
|
-
return self._public_ip_from_name(bastion_name, bastion_region)
|
|
55
|
-
raise ValueError(
|
|
56
|
-
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'."
|
|
57
|
-
)
|
|
58
|
-
|
|
59
|
-
def _public_ip_from_name(self, bastion_name, bastion_region):
|
|
60
|
-
ec2 = self._boto3.client("ec2", region_name=bastion_region)
|
|
61
|
-
response = ec2.describe_instances(
|
|
62
|
-
Filters=[
|
|
63
|
-
{"Name": "tag:Name", "Values": [bastion_name]},
|
|
64
|
-
{"Name": "instance-state-name", "Values": ["running"]},
|
|
65
|
-
],
|
|
66
|
-
)
|
|
67
|
-
if not response.get("Reservations"):
|
|
68
|
-
raise ValueError(
|
|
69
|
-
f"Could not find a running instance with the designated bastion name, '{bastion_name}' in region '{bastion_region}'"
|
|
70
|
-
)
|
|
71
|
-
if not response.get("Reservations")[0].get("Instances"):
|
|
72
|
-
raise ValueError(
|
|
73
|
-
f"Could not find a running instance with the designated bastion name, '{bastion_name}' in region '{bastion_region}'"
|
|
74
|
-
)
|
|
75
|
-
instance = response.get("Reservations")[0].get("Instances")[0]
|
|
76
|
-
if not instance.get("PublicIpAddress"):
|
|
77
|
-
raise ValueError(
|
|
78
|
-
f"I found the bastion instance with a name of '{bastion_name}' in region '{bastion_region}', but it doesn't have a public IP address"
|
|
79
|
-
)
|
|
80
|
-
return instance.get("PublicIpAddress")
|
clearskies_aws/secrets/additional_configs/mysql_connection_dynamic_producer_via_ssm_bastion.py
DELETED
|
@@ -1,162 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import os
|
|
4
|
-
import socket
|
|
5
|
-
import subprocess
|
|
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
|
-
|
|
14
|
-
class MySQLConnectionDynamicProducerViaSSMBastion(Base):
|
|
15
|
-
_config = None
|
|
16
|
-
_boto3 = None
|
|
17
|
-
|
|
18
|
-
def __init__(
|
|
19
|
-
self,
|
|
20
|
-
producer_name=None,
|
|
21
|
-
bastion_region=None,
|
|
22
|
-
bastion_name=None,
|
|
23
|
-
bastion_username=None,
|
|
24
|
-
bastion_instance_id=None,
|
|
25
|
-
public_key_file_path=None,
|
|
26
|
-
local_proxy_port=None,
|
|
27
|
-
database_host=None,
|
|
28
|
-
database_name=None,
|
|
29
|
-
):
|
|
30
|
-
# not using kwargs because I want the argument list to be explicit
|
|
31
|
-
self.config = {
|
|
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,
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
def provide_connection_details(self, environment, secrets, boto3):
|
|
44
|
-
self._boto3 = boto3
|
|
45
|
-
if not secrets:
|
|
46
|
-
raise ValueError(
|
|
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()"
|
|
48
|
-
)
|
|
49
|
-
|
|
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")
|
|
52
|
-
bastion_instance_id = self._get_bastion_instance_id(environment)
|
|
53
|
-
public_key_file_path = self._fetch_config(
|
|
54
|
-
environment, "public_key_file_path", "mysql_bastion_public_key_file_path"
|
|
55
|
-
)
|
|
56
|
-
local_proxy_port = self._fetch_config(
|
|
57
|
-
environment, "local_proxy_port", "akeyless_mysql_bastion_local_proxy_port", default=8888
|
|
58
|
-
)
|
|
59
|
-
database_host = self._fetch_config(environment, "database_host", "db_host")
|
|
60
|
-
database_name = self._fetch_config(environment, "database_name", "db_database")
|
|
61
|
-
|
|
62
|
-
# Create the SSH tunnel (yeah, it's obnoxious)
|
|
63
|
-
self._create_tunnel(
|
|
64
|
-
secrets,
|
|
65
|
-
bastion_instance_id,
|
|
66
|
-
bastion_username,
|
|
67
|
-
bastion_region,
|
|
68
|
-
public_key_file_path,
|
|
69
|
-
local_proxy_port,
|
|
70
|
-
database_host,
|
|
71
|
-
)
|
|
72
|
-
|
|
73
|
-
# and now we can fetch credentials
|
|
74
|
-
credentials = secrets.get_dynamic_secret(producer_name)
|
|
75
|
-
|
|
76
|
-
return {
|
|
77
|
-
"username": credentials["user"],
|
|
78
|
-
"password": credentials["password"],
|
|
79
|
-
"host": "127.0.0.1",
|
|
80
|
-
"database": database_name,
|
|
81
|
-
"port": local_proxy_port,
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
def _get_bastion_instance_id(self, environment):
|
|
85
|
-
bastion_instance_id = self._fetch_config(
|
|
86
|
-
environment, "bastion_instance_id", "mysql_bastion_instance_id", default=""
|
|
87
|
-
)
|
|
88
|
-
bastion_name = self._fetch_config(environment, "bastion_name", "mysql_bastion_name", default="")
|
|
89
|
-
if bastion_instance_id:
|
|
90
|
-
return bastion_instance_id
|
|
91
|
-
if bastion_name:
|
|
92
|
-
bastion_region = self._fetch_config(environment, "bastion_region", "mysql_bastion_region")
|
|
93
|
-
return self._instance_id_from_name(bastion_name, bastion_region)
|
|
94
|
-
raise ValueError(
|
|
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'."
|
|
96
|
-
)
|
|
97
|
-
|
|
98
|
-
def _instance_id_from_name(self, bastion_name, bastion_region):
|
|
99
|
-
ec2 = self._boto3.client("ec2", region_name=bastion_region)
|
|
100
|
-
response = ec2.describe_instances(
|
|
101
|
-
Filters=[
|
|
102
|
-
{"Name": "tag:Name", "Values": [bastion_name]},
|
|
103
|
-
{"Name": "instance-state-name", "Values": ["running"]},
|
|
104
|
-
],
|
|
105
|
-
)
|
|
106
|
-
if not response.get("Reservations"):
|
|
107
|
-
raise ValueError(
|
|
108
|
-
f"Could not find a running instance with the designated bastion name, '{bastion_name}' in region '{bastion_region}'"
|
|
109
|
-
)
|
|
110
|
-
if not response.get("Reservations")[0].get("Instances"):
|
|
111
|
-
raise ValueError(
|
|
112
|
-
f"Could not find a running instance with the designated bastion name, '{bastion_name}' in region '{bastion_region}'"
|
|
113
|
-
)
|
|
114
|
-
return response.get("Reservations")[0].get("Instances")[0]["InstanceId"]
|
|
115
|
-
|
|
116
|
-
def _create_tunnel(
|
|
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,
|
|
125
|
-
):
|
|
126
|
-
# first see if the socket is already open, since we don't close it.
|
|
127
|
-
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
128
|
-
result = sock.connect_ex(("127.0.0.1", local_proxy_port))
|
|
129
|
-
if result == 0:
|
|
130
|
-
sock.close()
|
|
131
|
-
return
|
|
132
|
-
|
|
133
|
-
# and now we can do this thing.
|
|
134
|
-
tunnel_command = [
|
|
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}",
|
|
146
|
-
]
|
|
147
|
-
my_env = os.environ.copy()
|
|
148
|
-
my_env["AWS_DEFAULT_REGION"] = bastion_region
|
|
149
|
-
subprocess.Popen(tunnel_command)
|
|
150
|
-
connected = False
|
|
151
|
-
attempts = 0
|
|
152
|
-
while not connected and attempts < 6:
|
|
153
|
-
attempts += 1
|
|
154
|
-
time.sleep(0.5)
|
|
155
|
-
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
156
|
-
result = sock.connect_ex(("127.0.0.1", local_proxy_port))
|
|
157
|
-
if result == 0:
|
|
158
|
-
connected = True
|
|
159
|
-
if not connected:
|
|
160
|
-
raise ValueError(
|
|
161
|
-
"Failed to open SSH tunnel. The following command was used: \n" + " ".join(tunnel_command)
|
|
162
|
-
)
|
|
@@ -1,60 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import re
|
|
4
|
-
from typing import Any
|
|
5
|
-
|
|
6
|
-
from clearskies.secrets.akeyless import Akeyless
|
|
7
|
-
from types_boto3_ssm import SSMClient
|
|
8
|
-
|
|
9
|
-
from clearskies_aws.secrets import parameter_store
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
class AkeylessWithSsmCache(parameter_store.ParameterStore, Akeyless):
|
|
13
|
-
def get(self, path: str, refresh: bool = False) -> str | None: # type: ignore[override]
|
|
14
|
-
# AWS SSM parameter paths only allow a-z, A-Z, 0-9, -, _, ., /, @, and :
|
|
15
|
-
# Replace any disallowed characters with hyphens
|
|
16
|
-
ssm_name = re.sub(r"[^a-zA-Z0-9\-_\./@:]", "-", path)
|
|
17
|
-
# if we're not forcing a refresh, then see if it is in paramater store
|
|
18
|
-
if not refresh:
|
|
19
|
-
missing = False
|
|
20
|
-
try:
|
|
21
|
-
response = self.ssm.get_parameter(Name=ssm_name, WithDecryption=True)
|
|
22
|
-
except self.ssm.exceptions.ParameterNotFound:
|
|
23
|
-
missing = True
|
|
24
|
-
if not missing:
|
|
25
|
-
value = response["Parameter"].get("Value", "")
|
|
26
|
-
if value:
|
|
27
|
-
return value
|
|
28
|
-
|
|
29
|
-
# otherwise get it out of Akeyless
|
|
30
|
-
value = str(super().get(path))
|
|
31
|
-
|
|
32
|
-
# and make sure and store the new value in parameter store
|
|
33
|
-
if value:
|
|
34
|
-
self.ssm.put_parameter(
|
|
35
|
-
Name=ssm_name,
|
|
36
|
-
Value=value,
|
|
37
|
-
Type="SecureString",
|
|
38
|
-
Overwrite=True,
|
|
39
|
-
)
|
|
40
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|