[LocalStack] Fixes to secretsmanager's PutSecretValue, CreateSecret, DeleteSecret (#4851)
This commit is contained in:
parent
dce8cc0c04
commit
7194456d0d
@ -120,14 +120,16 @@ class FakeSecret:
|
|||||||
def is_deleted(self):
|
def is_deleted(self):
|
||||||
return self.deleted_date is not None
|
return self.deleted_date is not None
|
||||||
|
|
||||||
def to_short_dict(self, include_version_stages=False):
|
def to_short_dict(self, include_version_stages=False, version_id=None):
|
||||||
|
if not version_id:
|
||||||
|
version_id = self.default_version_id
|
||||||
dct = {
|
dct = {
|
||||||
"ARN": self.arn,
|
"ARN": self.arn,
|
||||||
"Name": self.name,
|
"Name": self.name,
|
||||||
"VersionId": self.default_version_id,
|
"VersionId": version_id,
|
||||||
}
|
}
|
||||||
if include_version_stages:
|
if include_version_stages:
|
||||||
dct["VersionStages"] = self.version_stages
|
dct["VersionStages"] = self.versions[version_id]["version_stages"]
|
||||||
return json.dumps(dct)
|
return json.dumps(dct)
|
||||||
|
|
||||||
def to_dict(self):
|
def to_dict(self):
|
||||||
@ -208,6 +210,14 @@ class SecretsManagerBackend(BaseBackend):
|
|||||||
msg = "ClientRequestToken must be 32-64 characters long."
|
msg = "ClientRequestToken must be 32-64 characters long."
|
||||||
raise InvalidParameterException(msg)
|
raise InvalidParameterException(msg)
|
||||||
|
|
||||||
|
def _from_client_request_token(self, client_request_token):
|
||||||
|
version_id = client_request_token
|
||||||
|
if version_id:
|
||||||
|
self._client_request_token_validator(version_id)
|
||||||
|
else:
|
||||||
|
version_id = str(uuid.uuid4())
|
||||||
|
return version_id
|
||||||
|
|
||||||
def get_secret_value(self, secret_id, version_id, version_stage):
|
def get_secret_value(self, secret_id, version_id, version_stage):
|
||||||
if not self._is_valid_identifier(secret_id):
|
if not self._is_valid_identifier(secret_id):
|
||||||
raise SecretNotFoundException()
|
raise SecretNotFoundException()
|
||||||
@ -309,6 +319,7 @@ class SecretsManagerBackend(BaseBackend):
|
|||||||
description=None,
|
description=None,
|
||||||
tags=None,
|
tags=None,
|
||||||
kms_key_id=None,
|
kms_key_id=None,
|
||||||
|
client_request_token=None,
|
||||||
):
|
):
|
||||||
|
|
||||||
# error if secret exists
|
# error if secret exists
|
||||||
@ -324,6 +335,7 @@ class SecretsManagerBackend(BaseBackend):
|
|||||||
description=description,
|
description=description,
|
||||||
tags=tags,
|
tags=tags,
|
||||||
kms_key_id=kms_key_id,
|
kms_key_id=kms_key_id,
|
||||||
|
version_id=client_request_token,
|
||||||
)
|
)
|
||||||
|
|
||||||
return secret.to_short_dict()
|
return secret.to_short_dict()
|
||||||
@ -343,10 +355,7 @@ class SecretsManagerBackend(BaseBackend):
|
|||||||
if version_stages is None:
|
if version_stages is None:
|
||||||
version_stages = ["AWSCURRENT"]
|
version_stages = ["AWSCURRENT"]
|
||||||
|
|
||||||
if version_id:
|
version_id = self._from_client_request_token(version_id)
|
||||||
self._client_request_token_validator(version_id)
|
|
||||||
else:
|
|
||||||
version_id = str(uuid.uuid4())
|
|
||||||
|
|
||||||
secret_version = {
|
secret_version = {
|
||||||
"createdate": int(time.time()),
|
"createdate": int(time.time()),
|
||||||
@ -365,10 +374,10 @@ class SecretsManagerBackend(BaseBackend):
|
|||||||
|
|
||||||
secret.update(description, tags, kms_key_id, last_changed_date=update_time)
|
secret.update(description, tags, kms_key_id, last_changed_date=update_time)
|
||||||
|
|
||||||
if "AWSPENDING" in version_stages:
|
if "AWSCURRENT" in version_stages:
|
||||||
secret.versions[version_id] = secret_version
|
|
||||||
else:
|
|
||||||
secret.reset_default_version(secret_version, version_id)
|
secret.reset_default_version(secret_version, version_id)
|
||||||
|
else:
|
||||||
|
secret.versions[version_id] = secret_version
|
||||||
else:
|
else:
|
||||||
secret = FakeSecret(
|
secret = FakeSecret(
|
||||||
region_name=self.region,
|
region_name=self.region,
|
||||||
@ -403,17 +412,19 @@ class SecretsManagerBackend(BaseBackend):
|
|||||||
tags = secret.tags
|
tags = secret.tags
|
||||||
description = secret.description
|
description = secret.description
|
||||||
|
|
||||||
|
version_id = self._from_client_request_token(client_request_token)
|
||||||
|
|
||||||
secret = self._add_secret(
|
secret = self._add_secret(
|
||||||
secret_id,
|
secret_id,
|
||||||
secret_string,
|
secret_string,
|
||||||
secret_binary,
|
secret_binary,
|
||||||
version_id=client_request_token,
|
version_id=version_id,
|
||||||
description=description,
|
description=description,
|
||||||
tags=tags,
|
tags=tags,
|
||||||
version_stages=version_stages,
|
version_stages=version_stages,
|
||||||
)
|
)
|
||||||
|
|
||||||
return secret.to_short_dict(include_version_stages=True)
|
return secret.to_short_dict(include_version_stages=True, version_id=version_id)
|
||||||
|
|
||||||
def describe_secret(self, secret_id):
|
def describe_secret(self, secret_id):
|
||||||
if not self._is_valid_identifier(secret_id):
|
if not self._is_valid_identifier(secret_id):
|
||||||
@ -658,21 +669,6 @@ class SecretsManagerBackend(BaseBackend):
|
|||||||
self, secret_id, recovery_window_in_days, force_delete_without_recovery
|
self, secret_id, recovery_window_in_days, force_delete_without_recovery
|
||||||
):
|
):
|
||||||
|
|
||||||
if not self._is_valid_identifier(secret_id):
|
|
||||||
raise SecretNotFoundException()
|
|
||||||
|
|
||||||
if self.secrets[secret_id].is_deleted():
|
|
||||||
raise InvalidRequestException(
|
|
||||||
"An error occurred (InvalidRequestException) when calling the DeleteSecret operation: You tried to \
|
|
||||||
perform the operation on a secret that's currently marked deleted."
|
|
||||||
)
|
|
||||||
|
|
||||||
if recovery_window_in_days and force_delete_without_recovery:
|
|
||||||
raise InvalidParameterException(
|
|
||||||
"An error occurred (InvalidParameterException) when calling the DeleteSecret operation: You can't \
|
|
||||||
use ForceDeleteWithoutRecovery in conjunction with RecoveryWindowInDays."
|
|
||||||
)
|
|
||||||
|
|
||||||
if recovery_window_in_days and (
|
if recovery_window_in_days and (
|
||||||
recovery_window_in_days < 7 or recovery_window_in_days > 30
|
recovery_window_in_days < 7 or recovery_window_in_days > 30
|
||||||
):
|
):
|
||||||
@ -681,22 +677,44 @@ class SecretsManagerBackend(BaseBackend):
|
|||||||
RecoveryWindowInDays value must be between 7 and 30 days (inclusive)."
|
RecoveryWindowInDays value must be between 7 and 30 days (inclusive)."
|
||||||
)
|
)
|
||||||
|
|
||||||
deletion_date = datetime.datetime.utcnow()
|
if recovery_window_in_days and force_delete_without_recovery:
|
||||||
|
raise InvalidParameterException(
|
||||||
|
"An error occurred (InvalidParameterException) when calling the DeleteSecret operation: You can't \
|
||||||
|
use ForceDeleteWithoutRecovery in conjunction with RecoveryWindowInDays."
|
||||||
|
)
|
||||||
|
|
||||||
if force_delete_without_recovery:
|
if not self._is_valid_identifier(secret_id):
|
||||||
secret = self.secrets.pop(secret_id, None)
|
if not force_delete_without_recovery:
|
||||||
|
raise SecretNotFoundException()
|
||||||
|
else:
|
||||||
|
secret = FakeSecret(self.region, secret_id)
|
||||||
|
arn = secret.arn
|
||||||
|
name = secret.name
|
||||||
|
deletion_date = datetime.datetime.utcnow()
|
||||||
|
return arn, name, self._unix_time_secs(deletion_date)
|
||||||
else:
|
else:
|
||||||
deletion_date += datetime.timedelta(days=recovery_window_in_days or 30)
|
if self.secrets[secret_id].is_deleted():
|
||||||
self.secrets[secret_id].delete(self._unix_time_secs(deletion_date))
|
raise InvalidRequestException(
|
||||||
secret = self.secrets.get(secret_id, None)
|
"An error occurred (InvalidRequestException) when calling the DeleteSecret operation: You tried to \
|
||||||
|
perform the operation on a secret that's currently marked deleted."
|
||||||
|
)
|
||||||
|
|
||||||
if not secret:
|
deletion_date = datetime.datetime.utcnow()
|
||||||
raise SecretNotFoundException()
|
|
||||||
|
|
||||||
arn = secret.arn
|
if force_delete_without_recovery:
|
||||||
name = secret.name
|
secret = self.secrets.pop(secret_id, None)
|
||||||
|
else:
|
||||||
|
deletion_date += datetime.timedelta(days=recovery_window_in_days or 30)
|
||||||
|
self.secrets[secret_id].delete(self._unix_time_secs(deletion_date))
|
||||||
|
secret = self.secrets.get(secret_id, None)
|
||||||
|
|
||||||
return arn, name, self._unix_time_secs(deletion_date)
|
if not secret:
|
||||||
|
raise SecretNotFoundException()
|
||||||
|
|
||||||
|
arn = secret.arn
|
||||||
|
name = secret.name
|
||||||
|
|
||||||
|
return arn, name, self._unix_time_secs(deletion_date)
|
||||||
|
|
||||||
def restore_secret(self, secret_id):
|
def restore_secret(self, secret_id):
|
||||||
|
|
||||||
|
@ -45,6 +45,7 @@ class SecretsManagerResponse(BaseResponse):
|
|||||||
description = self._get_param("Description", if_none="")
|
description = self._get_param("Description", if_none="")
|
||||||
tags = self._get_param("Tags", if_none=[])
|
tags = self._get_param("Tags", if_none=[])
|
||||||
kms_key_id = self._get_param("KmsKeyId", if_none=None)
|
kms_key_id = self._get_param("KmsKeyId", if_none=None)
|
||||||
|
client_request_token = self._get_param("ClientRequestToken", if_none=None)
|
||||||
return secretsmanager_backends[self.region].create_secret(
|
return secretsmanager_backends[self.region].create_secret(
|
||||||
name=name,
|
name=name,
|
||||||
secret_string=secret_string,
|
secret_string=secret_string,
|
||||||
@ -52,6 +53,7 @@ class SecretsManagerResponse(BaseResponse):
|
|||||||
description=description,
|
description=description,
|
||||||
tags=tags,
|
tags=tags,
|
||||||
kms_key_id=kms_key_id,
|
kms_key_id=kms_key_id,
|
||||||
|
client_request_token=client_request_token,
|
||||||
)
|
)
|
||||||
|
|
||||||
def update_secret(self):
|
def update_secret(self):
|
||||||
|
@ -26,6 +26,20 @@ def test_get_secret_value():
|
|||||||
assert result["SecretString"] == "foosecret"
|
assert result["SecretString"] == "foosecret"
|
||||||
|
|
||||||
|
|
||||||
|
@mock_secretsmanager
|
||||||
|
def test_create_secret_with_client_request_token():
|
||||||
|
conn = boto3.client("secretsmanager", region_name="us-west-2")
|
||||||
|
|
||||||
|
version_id = "eb41453f-25bb-4025-b7f4-850cfca0ce71"
|
||||||
|
create_dict = conn.create_secret(
|
||||||
|
Name=DEFAULT_SECRET_NAME,
|
||||||
|
SecretString="secret_string",
|
||||||
|
ClientRequestToken=version_id,
|
||||||
|
)
|
||||||
|
assert create_dict
|
||||||
|
assert create_dict["VersionId"] == version_id
|
||||||
|
|
||||||
|
|
||||||
@mock_secretsmanager
|
@mock_secretsmanager
|
||||||
def test_get_secret_value_by_arn():
|
def test_get_secret_value_by_arn():
|
||||||
conn = boto3.client("secretsmanager", region_name="us-west-2")
|
conn = boto3.client("secretsmanager", region_name="us-west-2")
|
||||||
@ -228,6 +242,17 @@ def test_delete_secret_force():
|
|||||||
result = conn.get_secret_value(SecretId="test-secret")
|
result = conn.get_secret_value(SecretId="test-secret")
|
||||||
|
|
||||||
|
|
||||||
|
@mock_secretsmanager
|
||||||
|
def test_delete_secret_force_no_such_secret():
|
||||||
|
conn = boto3.client("secretsmanager", region_name="us-west-2")
|
||||||
|
|
||||||
|
deleted_secret = conn.delete_secret(
|
||||||
|
SecretId=DEFAULT_SECRET_NAME, ForceDeleteWithoutRecovery=True
|
||||||
|
)
|
||||||
|
assert deleted_secret
|
||||||
|
assert deleted_secret["Name"] == DEFAULT_SECRET_NAME
|
||||||
|
|
||||||
|
|
||||||
@mock_secretsmanager
|
@mock_secretsmanager
|
||||||
def test_delete_secret_force_with_arn():
|
def test_delete_secret_force_with_arn():
|
||||||
conn = boto3.client("secretsmanager", region_name="us-west-2")
|
conn = boto3.client("secretsmanager", region_name="us-west-2")
|
||||||
@ -251,7 +276,7 @@ def test_delete_secret_that_does_not_exist():
|
|||||||
conn = boto3.client("secretsmanager", region_name="us-west-2")
|
conn = boto3.client("secretsmanager", region_name="us-west-2")
|
||||||
|
|
||||||
with pytest.raises(ClientError):
|
with pytest.raises(ClientError):
|
||||||
conn.delete_secret(SecretId="i-dont-exist", ForceDeleteWithoutRecovery=True)
|
conn.delete_secret(SecretId="i-dont-exist")
|
||||||
|
|
||||||
|
|
||||||
@mock_secretsmanager
|
@mock_secretsmanager
|
||||||
@ -288,6 +313,18 @@ def test_delete_secret_recovery_window_too_long():
|
|||||||
conn.delete_secret(SecretId="test-secret", RecoveryWindowInDays=31)
|
conn.delete_secret(SecretId="test-secret", RecoveryWindowInDays=31)
|
||||||
|
|
||||||
|
|
||||||
|
@mock_secretsmanager
|
||||||
|
def test_delete_secret_force_no_such_secret_with_invalid_recovery_window():
|
||||||
|
conn = boto3.client("secretsmanager", region_name="us-west-2")
|
||||||
|
|
||||||
|
with pytest.raises(ClientError):
|
||||||
|
conn.delete_secret(
|
||||||
|
SecretId=DEFAULT_SECRET_NAME,
|
||||||
|
ForceDeleteWithoutRecovery=True,
|
||||||
|
RecoveryWindowInDays=4,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@mock_secretsmanager
|
@mock_secretsmanager
|
||||||
def test_delete_secret_that_is_marked_deleted():
|
def test_delete_secret_that_is_marked_deleted():
|
||||||
conn = boto3.client("secretsmanager", region_name="us-west-2")
|
conn = boto3.client("secretsmanager", region_name="us-west-2")
|
||||||
@ -952,6 +989,118 @@ def test_can_list_secret_version_ids():
|
|||||||
assert [first_version_id, second_version_id].sort() == returned_version_ids.sort()
|
assert [first_version_id, second_version_id].sort() == returned_version_ids.sort()
|
||||||
|
|
||||||
|
|
||||||
|
@mock_secretsmanager
|
||||||
|
def test_put_secret_value_version_stages_response():
|
||||||
|
conn = boto3.client("secretsmanager", region_name="us-west-2")
|
||||||
|
|
||||||
|
# Creation.
|
||||||
|
first_version_id = "eb41453f-25bb-4025-b7f4-850cfca0ce71"
|
||||||
|
conn.create_secret(
|
||||||
|
Name=DEFAULT_SECRET_NAME,
|
||||||
|
SecretString="first_secret_string",
|
||||||
|
ClientRequestToken=first_version_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Use PutSecretValue to push a new version with new version stages.
|
||||||
|
second_version_id = "eb41453f-25bb-4025-b7f4-850cfca0ce72"
|
||||||
|
second_version_stages = ["SAMPLESTAGE1", "SAMPLESTAGE0"]
|
||||||
|
second_put_res_dict = conn.put_secret_value(
|
||||||
|
SecretId=DEFAULT_SECRET_NAME,
|
||||||
|
SecretString="second_secret_string",
|
||||||
|
VersionStages=second_version_stages,
|
||||||
|
ClientRequestToken=second_version_id,
|
||||||
|
)
|
||||||
|
assert second_put_res_dict
|
||||||
|
assert second_put_res_dict["VersionId"] == second_version_id
|
||||||
|
assert second_put_res_dict["VersionStages"] == second_version_stages
|
||||||
|
|
||||||
|
|
||||||
|
@mock_secretsmanager
|
||||||
|
def test_put_secret_value_version_stages_pending_response():
|
||||||
|
conn = boto3.client("secretsmanager", region_name="us-west-2")
|
||||||
|
|
||||||
|
# Creation.
|
||||||
|
first_version_id = "eb41453f-25bb-4025-b7f4-850cfca0ce71"
|
||||||
|
conn.create_secret(
|
||||||
|
Name=DEFAULT_SECRET_NAME,
|
||||||
|
SecretString="first_secret_string",
|
||||||
|
ClientRequestToken=first_version_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Use PutSecretValue to push a new version with new version stages.
|
||||||
|
second_version_id = "eb41453f-25bb-4025-b7f4-850cfca0ce72"
|
||||||
|
second_version_stages = ["AWSPENDING"]
|
||||||
|
second_put_res_dict = conn.put_secret_value(
|
||||||
|
SecretId=DEFAULT_SECRET_NAME,
|
||||||
|
SecretString="second_secret_string",
|
||||||
|
VersionStages=second_version_stages,
|
||||||
|
ClientRequestToken=second_version_id,
|
||||||
|
)
|
||||||
|
assert second_put_res_dict
|
||||||
|
assert second_put_res_dict["VersionId"] == second_version_id
|
||||||
|
assert second_put_res_dict["VersionStages"] == second_version_stages
|
||||||
|
|
||||||
|
|
||||||
|
@mock_secretsmanager
|
||||||
|
def test_after_put_secret_value_version_stages_can_get_current():
|
||||||
|
conn = boto3.client("secretsmanager", region_name="us-west-2")
|
||||||
|
|
||||||
|
# Creation.
|
||||||
|
first_version_id = "eb41453f-25bb-4025-b7f4-850cfca0ce71"
|
||||||
|
first_secret_string = "first_secret_string"
|
||||||
|
conn.create_secret(
|
||||||
|
Name=DEFAULT_SECRET_NAME,
|
||||||
|
SecretString=first_secret_string,
|
||||||
|
ClientRequestToken=first_version_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Use PutSecretValue to push a new version with new version stages.
|
||||||
|
second_version_id = "eb41453f-25bb-4025-b7f4-850cfca0ce72"
|
||||||
|
conn.put_secret_value(
|
||||||
|
SecretId=DEFAULT_SECRET_NAME,
|
||||||
|
SecretString="second_secret_string",
|
||||||
|
VersionStages=["SAMPLESTAGE1", "SAMPLESTAGE0"],
|
||||||
|
ClientRequestToken=second_version_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get current.
|
||||||
|
get_dict = conn.get_secret_value(SecretId=DEFAULT_SECRET_NAME)
|
||||||
|
assert get_dict
|
||||||
|
assert get_dict["VersionId"] == first_version_id
|
||||||
|
assert get_dict["SecretString"] == first_secret_string
|
||||||
|
assert get_dict["VersionStages"] == ["AWSCURRENT"]
|
||||||
|
|
||||||
|
|
||||||
|
@mock_secretsmanager
|
||||||
|
def test_after_put_secret_value_version_stages_pending_can_get_current():
|
||||||
|
conn = boto3.client("secretsmanager", region_name="us-west-2")
|
||||||
|
|
||||||
|
# Creation.
|
||||||
|
first_version_id = "eb41453f-25bb-4025-b7f4-850cfca0ce71"
|
||||||
|
first_secret_string = "first_secret_string"
|
||||||
|
conn.create_secret(
|
||||||
|
Name=DEFAULT_SECRET_NAME,
|
||||||
|
SecretString=first_secret_string,
|
||||||
|
ClientRequestToken=first_version_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Use PutSecretValue to push a new version with new version stages.
|
||||||
|
pending_version_id = "eb41453f-25bb-4025-b7f4-850cfca0ce72"
|
||||||
|
conn.put_secret_value(
|
||||||
|
SecretId=DEFAULT_SECRET_NAME,
|
||||||
|
SecretString="second_secret_string",
|
||||||
|
VersionStages=["AWSPENDING"],
|
||||||
|
ClientRequestToken=pending_version_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get current.
|
||||||
|
get_dict = conn.get_secret_value(SecretId=DEFAULT_SECRET_NAME)
|
||||||
|
assert get_dict
|
||||||
|
assert get_dict["VersionId"] == first_version_id
|
||||||
|
assert get_dict["SecretString"] == first_secret_string
|
||||||
|
assert get_dict["VersionStages"] == ["AWSCURRENT"]
|
||||||
|
|
||||||
|
|
||||||
@mock_secretsmanager
|
@mock_secretsmanager
|
||||||
@pytest.mark.parametrize("pass_arn", [True, False])
|
@pytest.mark.parametrize("pass_arn", [True, False])
|
||||||
def test_update_secret(pass_arn):
|
def test_update_secret(pass_arn):
|
||||||
|
@ -774,6 +774,7 @@ def test_update_secret_version_stage(pass_arn):
|
|||||||
headers={"X-Amz-Target": "secretsmanager.PutSecretValue"},
|
headers={"X-Amz-Target": "secretsmanager.PutSecretValue"},
|
||||||
)
|
)
|
||||||
put_secret = json.loads(put_secret.data.decode("utf-8"))
|
put_secret = json.loads(put_secret.data.decode("utf-8"))
|
||||||
|
assert put_secret["VersionStages"] == [custom_stage]
|
||||||
new_version = put_secret["VersionId"]
|
new_version = put_secret["VersionId"]
|
||||||
|
|
||||||
describe_secret = test_client.post(
|
describe_secret = test_client.post(
|
||||||
@ -785,7 +786,7 @@ def test_update_secret_version_stage(pass_arn):
|
|||||||
json_data = json.loads(describe_secret.data.decode("utf-8"))
|
json_data = json.loads(describe_secret.data.decode("utf-8"))
|
||||||
stages = json_data["SecretVersionsToStages"]
|
stages = json_data["SecretVersionsToStages"]
|
||||||
assert len(stages) == 2
|
assert len(stages) == 2
|
||||||
assert stages[initial_version] == ["AWSPREVIOUS"]
|
assert stages[initial_version] == ["AWSCURRENT"]
|
||||||
assert stages[new_version] == [custom_stage]
|
assert stages[new_version] == [custom_stage]
|
||||||
|
|
||||||
test_client.post(
|
test_client.post(
|
||||||
@ -808,7 +809,7 @@ def test_update_secret_version_stage(pass_arn):
|
|||||||
json_data = json.loads(describe_secret.data.decode("utf-8"))
|
json_data = json.loads(describe_secret.data.decode("utf-8"))
|
||||||
stages = json_data["SecretVersionsToStages"]
|
stages = json_data["SecretVersionsToStages"]
|
||||||
assert len(stages) == 2
|
assert len(stages) == 2
|
||||||
assert stages[initial_version] == ["AWSPREVIOUS", custom_stage]
|
assert stages[initial_version] == ["AWSCURRENT", custom_stage]
|
||||||
assert stages[new_version] == []
|
assert stages[new_version] == []
|
||||||
|
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user