Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Pipeline Property Substitution #2052

Merged
merged 24 commits into from
Jun 20, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
de08f3e
initial substitution logic, without array manipulations
damoodamoo Jun 14, 2022
1aea240
gitea templates updated for testing
damoodamoo Jun 14, 2022
e280e2e
outputs as complex types rather than just strings
damoodamoo Jun 15, 2022
6f16531
remove / replace wip
damoodamoo Jun 15, 2022
95c1994
array logic with tests
damoodamoo Jun 16, 2022
825575e
refactored for clarity, moved substitutions into retry block
damoodamoo Jun 16, 2022
2d1d493
moved gitea to using a pipeline
damoodamoo Jun 16, 2022
87b01b4
updated docs
damoodamoo Jun 16, 2022
8b8b541
api version bump
damoodamoo Jun 16, 2022
b3cc7d6
main merge
damoodamoo Jun 16, 2022
3b779b1
rp bump
damoodamoo Jun 16, 2022
348028d
gitea version
damoodamoo Jun 16, 2022
3e500c8
Merge branch 'main' into damoo/1679-pipeline-substitution
damoodamoo Jun 16, 2022
09b62d5
Merge branch 'main' into damoo/1679-pipeline-substitution
ross-p-smith Jun 16, 2022
e6749ad
Update docs/tre-templates/pipeline-templates/pipeline-schema.md
damoodamoo Jun 17, 2022
e6ff9b4
Update api_app/tests_ma/test_service_bus/test_resource_request_sender.py
damoodamoo Jun 17, 2022
ab2d0d3
Update resource_processor/vmss_porter/runner.py
damoodamoo Jun 17, 2022
37f14d2
Update api_app/tests_ma/test_service_bus/test_resource_request_sender.py
damoodamoo Jun 17, 2022
4169c55
Merge branch 'main' into damoo/1679-pipeline-substitution
damoodamoo Jun 17, 2022
a21eb29
renamed gitea + nexus rule collections names to avoid clashes
damoodamoo Jun 20, 2022
a04b3f8
Merge branch 'damoo/1679-pipeline-substitution' of github.com:microso…
damoodamoo Jun 20, 2022
f0839dd
Merge branch 'main' into damoo/1679-pipeline-substitution
damoodamoo Jun 20, 2022
8f480a5
api version bump
damoodamoo Jun 20, 2022
0c60bf7
Merge branch 'damoo/1679-pipeline-substitution' of github.com:microso…
damoodamoo Jun 20, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion api_app/_version.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "0.3.14"
__version__ = "0.3.15"
4 changes: 2 additions & 2 deletions api_app/models/domain/resource.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from enum import Enum
from typing import List, Optional
from typing import List, Optional, Union
from pydantic import Field
from models.domain.azuretremodel import AzureTREModel
from models.domain.request_action import RequestAction
Expand Down Expand Up @@ -70,4 +70,4 @@ def get_resource_request_message_payload(self, operation_id: str, step_id: str,

class Output(AzureTREModel):
Name: str = Field(title="", description="")
Value: str = Field(title="", description="")
Value: Union[list, dict, str] = Field(None, title="", description="")
6 changes: 4 additions & 2 deletions api_app/models/domain/resource_template.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from typing import Dict, Any, List, Optional
from typing import Dict, Any, List, Optional, Union

from pydantic import Field

Expand Down Expand Up @@ -34,7 +34,9 @@ class CustomAction(AzureTREModel):
class PipelineStepProperty(AzureTREModel):
name: str = Field(title="name", description="name of the property to update")
type: str = Field(title="type", description="data type of the property to update")
value: str = Field(title="value", description="value to use in substitution for the property to update")
value: Union[dict, str] = Field(None, title="value", description="value to use in substitution for the property to update")
arraySubstitutionAction: Optional[str] = Field("", title="Array Substitution Action", description="How to treat existing values of this property in an array [overwrite | append | replace | remove]")
arrayMatchField: Optional[str] = Field("", title="Array match field", description="Name of the field to use for finding an item in an array - to replace/remove it")


class PipelineStep(AzureTREModel):
Expand Down
2 changes: 1 addition & 1 deletion api_app/service_bus/deployment_status_update.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ def create_updated_resource_document(resource: dict, message: DeploymentStatusUp

# although outputs are likely to be relevant when resources are moving to "deployed" status,
# lets not limit when we update them and have the resource process make that decision.
output_dict = {output.Name: output.Value.strip("'").strip('"') for output in message.outputs}
output_dict = {output.Name: output.Value.strip("'").strip('"') if isinstance(output.Value, str) else output.Value for output in message.outputs}
resource["properties"].update(output_dict)

# if deleted - mark as isActive = False
Expand Down
31 changes: 16 additions & 15 deletions api_app/service_bus/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
from azure.servicebus import ServiceBusMessage
from azure.servicebus.aio import ServiceBusClient
from contextlib import asynccontextmanager

from pydantic import parse_obj_as
from service_bus.substitutions import substitute_properties
from models.domain.resource_template import PipelineStep
from models.domain.operation import OperationStep
from models.domain.resource import Resource, ResourceType
Expand Down Expand Up @@ -52,7 +52,7 @@ async def send_deployment_message(content, correlation_id, session_id, action):


def update_resource_for_step(operation_step: OperationStep, resource_repo: ResourceRepository, resource_template_repo: ResourceTemplateRepository, primary_resource_id: str, resource_to_update_id: str, primary_action: str, user: User) -> Resource:
# create properties dict - for now we create a basic, string only dict to use as a patch

# get primary resource to use in substitutions
primary_resource = resource_repo.get_resource_by_id(primary_resource_id)

Expand All @@ -77,20 +77,16 @@ def update_resource_for_step(operation_step: OperationStep, resource_repo: Resou
if template_step is None:
raise f"Cannot find step with id of {operation_step.stepId} in template {primary_resource.templateName} for action {primary_action}"

# TODO: actual substitution logic #1679
properties = {}
for prop in template_step.properties:
properties[prop.name] = prop.value

if template_step.resourceAction == "upgrade":
resource_to_send = try_upgrade_with_retries(
num_retries=3,
attempt_count=0,
resource_repo=resource_repo,
resource_template_repo=resource_template_repo,
properties=properties,
user=user,
resource_to_update_id=resource_to_update_id
resource_to_update_id=resource_to_update_id,
template_step=template_step,
primary_resource=primary_resource
)

return resource_to_send
Expand All @@ -99,14 +95,15 @@ def update_resource_for_step(operation_step: OperationStep, resource_repo: Resou
raise Exception("Only upgrade is currently supported for pipeline steps")


def try_upgrade_with_retries(num_retries: int, attempt_count: int, resource_repo: ResourceRepository, resource_template_repo: ResourceTemplateRepository, properties: dict, user: User, resource_to_update_id: str) -> Resource:
def try_upgrade_with_retries(num_retries: int, attempt_count: int, resource_repo: ResourceRepository, resource_template_repo: ResourceTemplateRepository, user: User, resource_to_update_id: str, template_step: PipelineStep, primary_resource: Resource) -> Resource:
try:
return try_upgrade(
resource_repo=resource_repo,
resource_template_repo=resource_template_repo,
properties=properties,
user=user,
resource_to_update_id=resource_to_update_id
resource_to_update_id=resource_to_update_id,
template_step=template_step,
primary_resource=primary_resource
)
except CosmosAccessConditionFailedError as e:
logging.warn(f"Etag mismatch for {resource_to_update_id}. Retrying.")
Expand All @@ -116,17 +113,21 @@ def try_upgrade_with_retries(num_retries: int, attempt_count: int, resource_repo
attempt_count=(attempt_count + 1),
resource_repo=resource_repo,
resource_template_repo=resource_template_repo,
properties=properties,
user=user,
resource_to_update_id=resource_to_update_id
resource_to_update_id=resource_to_update_id,
template_step=template_step,
primary_resource=primary_resource
)
else:
raise e


def try_upgrade(resource_repo: ResourceRepository, resource_template_repo: ResourceTemplateRepository, properties: dict, user: User, resource_to_update_id: str) -> Resource:
def try_upgrade(resource_repo: ResourceRepository, resource_template_repo: ResourceTemplateRepository, user: User, resource_to_update_id: str, template_step: PipelineStep, primary_resource: Resource) -> Resource:
resource_to_update = resource_repo.get_resource_by_id(resource_to_update_id)

# substitute values into new property bag for update
properties = substitute_properties(template_step, primary_resource, resource_to_update)

# get the template for the resource to upgrade
parent_service_name = ""
if resource_to_update.resourceType == ResourceType.UserResource:
Expand Down
103 changes: 103 additions & 0 deletions api_app/service_bus/substitutions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
from typing import Union
from models.domain.resource_template import PipelineStep
from models.domain.resource import Resource


def substitute_properties(template_step: PipelineStep, primary_resource: Resource, resource_to_update: Resource) -> dict:
properties = {}
primary_resource_dict = primary_resource.dict()

for prop in template_step.properties:
val = prop.value
if isinstance(prop.value, dict):
val = recurse_object(prop.value, primary_resource_dict)

if prop.type == 'array':
if prop.name in resource_to_update.properties:
existing_arr = resource_to_update.properties[prop.name]
else:
existing_arr = []

if prop.arraySubstitutionAction == 'overwrite':
existing_arr = [val]

if prop.arraySubstitutionAction == 'append':
existing_arr.append(val)

if prop.arraySubstitutionAction == 'remove':
item_index = find_item_index(existing_arr, prop.arrayMatchField, val)
if item_index > -1:
del existing_arr[item_index]

if prop.arraySubstitutionAction == 'replace':
item_index = find_item_index(existing_arr, prop.arrayMatchField, val)
if item_index > -1:
existing_arr[item_index] = val
else:
existing_arr.append(val)

properties[prop.name] = existing_arr

else:
properties[prop.name] = val

else:
val = substitute_value(val, primary_resource_dict)
properties[prop.name] = val

return properties


def find_item_index(array: list, arrayMatchField: str, val: dict) -> int:
for i in range(0, len(array)):
if array[i][arrayMatchField] == val[arrayMatchField]:
return i
return -1


def recurse_object(obj: dict, primary_resource_dict: dict) -> dict:
for prop in obj:
if isinstance(obj[prop], list):
for i in range(0, len(obj[prop])):
obj[prop][i] = recurse_object(obj[prop][i], primary_resource_dict)
if isinstance(obj[prop], dict):
obj[prop] = recurse_object(obj[prop])
else:
obj[prop] = substitute_value(obj[prop], primary_resource_dict)

return obj


def substitute_value(val: str, primary_resource_dict: dict) -> Union[dict, list, str]:
if "{{" not in val:
return val

val = val.replace("{{ ", "{{").replace(" }}", "}}")

# if the value being substituted in is a simple type, we can return it in the string, to allow for concatenation
# like "This was deployed by {{ resource.id }}"
# else if the value being injected in is a dict/list - we shouldn't try to concatenate that, we'll return the true value and drop any surrounding text

# extract the tokens to replace
tokens = []
parts = val.split("{{")
for p in parts:
if len(p) > 0 and "}}" in p:
t = p[0:p.index("}}")]
tokens.append(t)

for t in tokens:
# t = "resource.properties.prop_1"
p = t.split(".")
if p[0] == "resource":
prop_to_get = primary_resource_dict
for i in range(1, len(p)):
prop_to_get = prop_to_get[p[i]]

# if the value to inject is actually an object / list - just return it, else replace the value in the string
if isinstance(prop_to_get, dict) or isinstance(prop_to_get, list):
return prop_to_get
else:
val = val.replace("{{" + t + "}}", str(prop_to_get))

return val
89 changes: 88 additions & 1 deletion api_app/tests_ma/conftest.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import uuid
import pytest
from models.domain.resource import Resource
from models.domain.user_resource import UserResource
from models.domain.shared_service import SharedService
from tests_ma.test_api.test_routes.test_resource_helpers import FAKE_CREATE_TIMESTAMP
Expand Down Expand Up @@ -192,7 +193,7 @@ def user_resource_template_in_response(input_user_resource_template):


@pytest.fixture
def multi_step_resource_template(basic_shared_service_template):
def multi_step_resource_template(basic_shared_service_template) -> ResourceTemplate:
return ResourceTemplate(
id="123",
name="template1",
Expand Down Expand Up @@ -316,3 +317,89 @@ def multi_step_operation(test_user, basic_shared_service_template, basic_shared_
)
]
)


@pytest.fixture
def primary_resource() -> Resource:
return Resource(
id="123",
name="test resource",
isEnabled=True,
templateName="template name",
templateVersion="7",
resourceType="workspace",
_etag="",
properties={
"display_name": "test_resource name",
"address_prefix": ["172.0.0.1", "192.168.0.1"],
"fqdn": ["*pypi.org", "files.pythonhosted.org", "security.ubuntu.com"],
"my_protocol": "MyCoolProtocol"
},
)


@pytest.fixture
def resource_to_update() -> Resource:
return Resource(
id="123",
name="Firewall",
isEnabled=True,
templateName="template name",
templateVersion="7",
resourceType="workspace",
_etag="",
properties={},
)


@pytest.fixture
def pipeline_step() -> PipelineStep:
return PipelineStep(
properties=[
PipelineStepProperty(
name="rule_collections",
type="array",
arraySubstitutionAction="overwrite",
arrayMatchField="name",
value={
"name": "arc-web_app_subnet_nexus_api",
damoodamoo marked this conversation as resolved.
Show resolved Hide resolved
"action": "Allow",
"rules": [
{
"name": "nexus-package-sources-api",
"description": "Deployed by {{ resource.id }}",
"protocols": [
{"port": "443", "type": "Https"},
{"port": "80", "type": "{{ resource.properties.my_protocol }}"},
],
"target_fqdns": "{{ resource.properties.fqdn }}",
"source_addresses": "{{ resource.properties.address_prefix }}",
}
]
}
)
]
)


@pytest.fixture
def simple_pipeline_step() -> PipelineStep:
return PipelineStep(
properties=[
PipelineStepProperty(
name="just_text",
type="string",
value="Updated by {{resource.id}}"
),
PipelineStepProperty(
name="just_text_2",
type="string",
value="No substitution, just a fixed string here"
),
PipelineStepProperty(
name="just_text_3",
type="string",
value="Multiple substitutions -> {{resource.id}} and {{resource.templateName}}"
)
]
)
Loading