diff --git a/setup.cfg b/setup.cfg
index 7d7cc2aed..8fb9afdca 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -83,6 +83,8 @@ mistral.actions =
     tripleo.baremetal.probe_node = tripleo_common.actions.baremetal:ProbeNode
     tripleo.baremetal_deploy.check_existing_instances = tripleo_common.actions.baremetal_deploy:CheckExistingInstancesAction
     tripleo.baremetal_deploy.deploy_node = tripleo_common.actions.baremetal_deploy:DeployNodeAction
+    tripleo.baremetal_deploy.expand_roles = tripleo_common.actions.baremetal_deploy:ExpandRolesAction
+    tripleo.baremetal_deploy.populate_environment = tripleo_common.actions.baremetal_deploy:PopulateEnvironmentAction
     tripleo.baremetal_deploy.reserve_nodes = tripleo_common.actions.baremetal_deploy:ReserveNodesAction
     tripleo.baremetal_deploy.undeploy_instance = tripleo_common.actions.baremetal_deploy:UndeployInstanceAction
     tripleo.baremetal_deploy.wait_for_deploy = tripleo_common.actions.baremetal_deploy:WaitForDeploymentAction
diff --git a/tripleo_common/actions/baremetal_deploy.py b/tripleo_common/actions/baremetal_deploy.py
index f5a929921..54b110377 100644
--- a/tripleo_common/actions/baremetal_deploy.py
+++ b/tripleo_common/actions/baremetal_deploy.py
@@ -12,6 +12,7 @@
 # License for the specific language governing permissions and limitations
 # under the License.
 
+import copy
 import logging
 
 import jsonschema
@@ -32,42 +33,62 @@ def _provisioner(context):
     return metalsmith.Provisioner(session=session)
 
 
+_INSTANCE_SCHEMA = {
+    'type': 'object',
+    'properties': {
+        'capabilities': {'type': 'object'},
+        'hostname': {'type': 'string',
+                     'minLength': 2,
+                     'maxLength': 255},
+        'image': {'type': 'string'},
+        'image_checksum': {'type': 'string'},
+        'image_kernel': {'type': 'string'},
+        'image_ramdisk': {'type': 'string'},
+        'name': {'type': 'string'},
+        'nics': {'type': 'array',
+                 'items': {'type': 'object',
+                           'properties': {
+                               'network': {'type': 'string'},
+                               'port': {'type': 'string'},
+                               'fixed_ip': {'type': 'string'},
+                               'subnet': {'type': 'string'},
+                           },
+                           'additionalProperties': False}},
+        'profile': {'type': 'string'},
+        'resource_class': {'type': 'string'},
+        'root_size_gb': {'type': 'integer', 'minimum': 4},
+        'swap_size_mb': {'type': 'integer', 'minimum': 64},
+        'traits': {'type': 'array',
+                   'items': {'type': 'string'}},
+    },
+    'additionalProperties': False,
+}
+
+
 _INSTANCES_INPUT_SCHEMA = {
+    'type': 'array',
+    'items': _INSTANCE_SCHEMA
+}
+"""JSON schema of the instances list."""
+
+
+_ROLES_INPUT_SCHEMA = {
     'type': 'array',
     'items': {
         'type': 'object',
         'properties': {
-            'capabilities': {'type': 'object'},
-            'hostname': {'type': 'string',
-                         'minLength': 2,
-                         'maxLength': 255},
-            'image': {'type': 'string'},
-            'image_checksum': {'type': 'string'},
-            'image_kernel': {'type': 'string'},
-            'image_ramdisk': {'type': 'string'},
             'name': {'type': 'string'},
-            'nics': {'type': 'array',
-                     'items': {'type': 'object',
-                               'properties': {
-                                   'network': {'type': 'string'},
-                                   'port': {'type': 'string'},
-                                   'fixed_ip': {'type': 'string'},
-                                   'subnet': {'type': 'string'},
-                               },
-                               'additionalProperties': False}},
+            'count': {'type': 'integer', 'minimum': 0},
             'profile': {'type': 'string'},
-            'resource_class': {'type': 'string'},
-            'root_size_gb': {'type': 'integer', 'minimum': 4},
-            'swap_size_mb': {'type': 'integer', 'minimum': 64},
-            'traits': {'type': 'array',
-                       'items': {'type': 'string'}},
+            'hostname_format': {'type': 'string'},
+            'instances': {'type': 'array',
+                          'items': _INSTANCE_SCHEMA},
         },
         'additionalProperties': False,
-        # Host name is required, but defaults to name in _validate_instances
-        'required': ['hostname'],
+        'required': ['name'],
     }
 }
-"""JSON schema of the input for these actions."""
+"""JSON schema of the roles list."""
 
 
 class CheckExistingInstancesAction(base.TripleOAction):
@@ -113,7 +134,8 @@ class CheckExistingInstancesAction(base.TripleOAction):
                              "hostname") % (request['hostname'], instance.uuid)
                     return actions.Result(error=error)
 
-                found.append(instance.to_dict())
+                found.append(_instance_to_dict(provisioner.connection,
+                                               instance))
 
         if found:
             LOG.info('Found existing instances: %s',
@@ -241,7 +263,7 @@ class DeployNodeAction(base.TripleOAction):
 
         LOG.info('Started provisioning of %s on node %s',
                  self.instance, self.node)
-        return instance.to_dict()
+        return _instance_to_dict(provisioner.connection, instance)
 
 
 class WaitForDeploymentAction(base.TripleOAction):
@@ -269,7 +291,7 @@ class WaitForDeploymentAction(base.TripleOAction):
             )
         LOG.info('Successfully provisioned instance %s',
                  self.instance['hostname'])
-        return instance.to_dict()
+        return _instance_to_dict(provisioner.connection, instance)
 
 
 class UndeployInstanceAction(base.TripleOAction):
@@ -295,11 +317,101 @@ class UndeployInstanceAction(base.TripleOAction):
         LOG.info('Successfully unprovisioned %s', instance.hostname)
 
 
+class ExpandRolesAction(base.TripleOAction):
+    """Convert a baremetal_deployment file to list of instances."""
+
+    def __init__(self, roles, stackname='overcloud'):
+        super(ExpandRolesAction, self).__init__()
+        self.roles = roles
+        self.stackname = stackname
+
+    def run(self, context):
+        try:
+            _validate_roles(self.roles, stackname=self.stackname)
+        except Exception as exc:
+            LOG.error('Failed to validate the request. %s', exc)
+            return actions.Result(error=six.text_type(exc))
+
+        instances = []
+        parameter_defaults = {'HostnameMap': {}}
+        for role in self.roles:
+            name = role['name']
+            hostname_format = role.get('hostname_format')
+            if not hostname_format:
+                hostname_format = '%stackname%-{}-%index%'.format(
+                    'novacompute' if name == 'Compute' else name.lower())
+            # NOTE(dtantsur): our hostname format may differ from THT defaults,
+            # so override it in the resulting environment
+            parameter_defaults['%sDeployedServerHostnameFormat' % name] = (
+                hostname_format)
+
+            if 'instances' in role:
+                parameter_defaults['%sDeployedServerCount' % name] = len(
+                    role['instances'])
+                # TODO(dtantsur): ordering-dependent logic here, can we do
+                # better?
+                for index, instance in enumerate(role['instances']):
+                    # _validate_instances ensures that either of these is
+                    # not empty
+                    hostname = instance.get('hostname') or instance.get('name')
+                    gen_name = (hostname_format.replace('%index%', str(index))
+                                .replace('%stackname%', self.stackname))
+                    parameter_defaults['HostnameMap'][gen_name] = hostname
+                    instances.append(instance)
+            else:
+                count = role.get('count', 1)
+                parameter_defaults['%sDeployedServerCount' % name] = count
+                if not count:
+                    continue
+
+                for index in range(count):
+                    hostname = (hostname_format.replace('%index%', str(index))
+                                .replace('%stackname%', self.stackname))
+                    inst = {'hostname': hostname}
+                    if 'profile' in role:
+                        inst['profile'] = role['profile']
+                    instances.append(inst)
+                    parameter_defaults['HostnameMap'][hostname] = hostname
+
+        return {'instances': instances,
+                'environment': {'parameter_defaults': parameter_defaults}}
+
+
+class PopulateEnvironmentAction(base.TripleOAction):
+    """Populate the resulting environment file.
+
+    Fills in DeployedServerPortMap with the IP addresses of the nodes.
+    """
+
+    def __init__(self, environment, port_map, ctlplane_network='ctlplane'):
+        super(PopulateEnvironmentAction, self).__init__()
+        self.environment = environment
+        self.port_map = port_map
+        self.ctlplane_network = ctlplane_network
+
+    def run(self, context):
+        port_map = (self.environment.setdefault('parameter_defaults', {})
+                    .setdefault('DeployedServerPortMap', {}))
+        for hostname, nets in self.port_map.items():
+            ctlplane = nets.get(self.ctlplane_network)
+            if not ctlplane:
+                LOG.warning('No ctlplane ports information for %s', hostname)
+                continue
+
+            port_map['%s-%s' % (hostname, self.ctlplane_network)] = ctlplane
+
+        return self.environment
+
+
 def _validate_instances(instances, default_image='overcloud-full'):
     for inst in instances:
         if inst.get('name') and not inst.get('hostname'):
             inst['hostname'] = inst['name']
 
+        if not inst.get('hostname'):
+            raise ValueError('Either hostname or name is required in %s'
+                             % inst)
+
         # Set the default image so that the source validation can succeed.
         inst.setdefault('image', default_image)
 
@@ -323,6 +435,23 @@ def _validate_instances(instances, default_image='overcloud-full'):
             names.add(inst['name'])
 
 
+def _validate_roles(roles, stackname='overcloud'):
+    jsonschema.validate(roles, _ROLES_INPUT_SCHEMA)
+
+    for item in roles:
+        if 'count' in item and 'instances' in item:
+            raise ValueError("Count and instances cannot be provided together")
+
+        if 'instances' in item:
+            for index, inst in enumerate(item['instances']):
+                if 'profile' not in inst and 'profile' in item:
+                    inst['profile'] = item['profile']
+
+            # NOTE(dtantsur): make a copy since _validate_instances modifies
+            # the original, and we don't need it at this stage.
+            _validate_instances(copy.deepcopy(item['instances']))
+
+
 def _release_nodes(provisioner, nodes):
     for node in nodes:
         LOG.debug('Removing reservation from node %s', node)
@@ -339,3 +468,19 @@ def _get_source(instance):
                           kernel=instance.get('image_kernel'),
                           ramdisk=instance.get('image_ramdisk'),
                           checksum=instance.get('image_checksum'))
+
+
+def _instance_to_dict(connection, instance):
+    """Convert an instance to a dict, adding ports information."""
+    result = instance.to_dict()
+    result['port_map'] = {}
+    for nic in instance.nics():
+        for ip in nic.fixed_ips:
+            net_name = getattr(nic.network, 'name', None) or nic.network.id
+            subnet = connection.network.get_subnet(ip['subnet_id'])
+            net_info = result['port_map'].setdefault(
+                net_name, {'network': nic.network.to_dict(),
+                           'fixed_ips': [], 'subnets': []})
+            net_info['fixed_ips'].append({'ip_address': ip['ip_address']})
+            net_info['subnets'].append(subnet.to_dict())
+    return result
diff --git a/tripleo_common/tests/actions/test_baremetal_deploy.py b/tripleo_common/tests/actions/test_baremetal_deploy.py
index 7ed0a76d0..2e8a05610 100644
--- a/tripleo_common/tests/actions/test_baremetal_deploy.py
+++ b/tripleo_common/tests/actions/test_baremetal_deploy.py
@@ -56,7 +56,7 @@ class TestReserveNodes(base.TestCase):
         action = baremetal_deploy.ReserveNodesAction(instances)
         result = action.run(mock.Mock())
 
-        self.assertIn("'hostname' is a required property", result.error)
+        self.assertIn("hostname or name is required", result.error)
         self.assertFalse(mock_pr.return_value.reserve_node.called)
         self.assertFalse(mock_pr.return_value.unprovision_node.called)
 
@@ -421,7 +421,7 @@ class TestCheckExistingInstances(base.TestCase):
         action = baremetal_deploy.CheckExistingInstancesAction(instances)
         result = action.run(mock.Mock())
 
-        self.assertIn("'hostname' is a required property", result.error)
+        self.assertIn("hostname or name is required", result.error)
         self.assertFalse(mock_pr.return_value.show_instance.called)
 
     def test_hostname_mismatch(self, mock_pr):
@@ -453,14 +453,50 @@ class TestWaitForDeployment(base.TestCase):
 
     def test_success(self, mock_pr):
         pr = mock_pr.return_value
+        inst = pr.wait_for_provisioning.return_value[0]
+        inst.to_dict.return_value = {'hostname': 'compute.cloud'}
+
         action = baremetal_deploy.WaitForDeploymentAction(
             {'hostname': 'compute.cloud', 'uuid': 'uuid1'})
         result = action.run(mock.Mock())
 
         pr.wait_for_provisioning.assert_called_once_with(['uuid1'],
                                                          timeout=3600)
-        inst = pr.wait_for_provisioning.return_value[0]
         self.assertIs(result, inst.to_dict.return_value)
+        self.assertEqual({}, result['port_map'])
+
+    def test_with_nics(self, mock_pr):
+        pr = mock_pr.return_value
+        net_mock = mock.Mock()
+        net_mock.name = 'ctlplane'
+        net_mock.to_dict.return_value = {'tags': ['foo']}
+        inst = pr.wait_for_provisioning.return_value[0]
+        inst.nics.return_value = [
+            mock.Mock(fixed_ips=[{'ip_address': '1.2.3.5',
+                                  'subnet_id': 'abcd'}],
+                      network=net_mock)
+        ]
+        pr.connection.network.get_subnet.return_value.to_dict.return_value = {
+            'cidr': '1.2.3.0/24'
+        }
+        inst.to_dict.return_value = {'hostname': 'compute.cloud'}
+
+        action = baremetal_deploy.WaitForDeploymentAction(
+            {'hostname': 'compute.cloud', 'uuid': 'uuid1'})
+        result = action.run(mock.Mock())
+
+        pr.wait_for_provisioning.assert_called_once_with(['uuid1'],
+                                                         timeout=3600)
+        self.assertIs(result, inst.to_dict.return_value)
+        self.assertEqual(
+            {
+                'ctlplane': {
+                    'network': {'tags': ['foo']},
+                    'fixed_ips': [{'ip_address': '1.2.3.5'}],
+                    'subnets': [{'cidr': '1.2.3.0/24'}],
+                }
+            },
+            result['port_map'])
 
     def test_failure(self, mock_pr):
         pr = mock_pr.return_value
@@ -497,3 +533,200 @@ class TestUndeployInstance(base.TestCase):
 
         pr.show_instance.assert_called_once_with('inst1')
         self.assertFalse(pr.unprovision_node.called)
+
+
+class TestExpandRoles(base.TestCase):
+
+    def test_simple(self):
+        roles = [
+            {'name': 'Compute'},
+            {'name': 'Controller'},
+        ]
+        action = baremetal_deploy.ExpandRolesAction(roles)
+        result = action.run(mock.Mock())
+        self.assertEqual(
+            [
+                {'hostname': 'overcloud-novacompute-0'},
+                {'hostname': 'overcloud-controller-0'},
+            ],
+            result['instances'])
+        self.assertEqual(
+            {
+                'ComputeDeployedServerHostnameFormat':
+                '%stackname%-novacompute-%index%',
+                'ComputeDeployedServerCount': 1,
+                'ControllerDeployedServerHostnameFormat':
+                '%stackname%-controller-%index%',
+                'ControllerDeployedServerCount': 1,
+                'HostnameMap': {
+                    'overcloud-novacompute-0': 'overcloud-novacompute-0',
+                    'overcloud-controller-0': 'overcloud-controller-0'
+                }
+            },
+            result['environment']['parameter_defaults'])
+
+    def test_with_parameters(self):
+        roles = [
+            {'name': 'Compute', 'count': 2, 'profile': 'compute',
+             'hostname_format': 'compute-%index%.example.com'},
+            {'name': 'Controller', 'count': 3, 'profile': 'control',
+             'hostname_format': 'controller-%index%.example.com'},
+        ]
+        action = baremetal_deploy.ExpandRolesAction(roles)
+        result = action.run(mock.Mock())
+        self.assertEqual(
+            [
+                {'hostname': 'compute-0.example.com', 'profile': 'compute'},
+                {'hostname': 'compute-1.example.com', 'profile': 'compute'},
+                {'hostname': 'controller-0.example.com', 'profile': 'control'},
+                {'hostname': 'controller-1.example.com', 'profile': 'control'},
+                {'hostname': 'controller-2.example.com', 'profile': 'control'},
+            ],
+            result['instances'])
+        self.assertEqual(
+            {
+                'ComputeDeployedServerHostnameFormat':
+                'compute-%index%.example.com',
+                'ComputeDeployedServerCount': 2,
+                'ControllerDeployedServerHostnameFormat':
+                'controller-%index%.example.com',
+                'ControllerDeployedServerCount': 3,
+                'HostnameMap': {
+                    'compute-0.example.com': 'compute-0.example.com',
+                    'compute-1.example.com': 'compute-1.example.com',
+                    'controller-0.example.com': 'controller-0.example.com',
+                    'controller-1.example.com': 'controller-1.example.com',
+                    'controller-2.example.com': 'controller-2.example.com',
+                }
+            },
+            result['environment']['parameter_defaults'])
+
+    def test_explicit_instances(self):
+        roles = [
+            {'name': 'Compute', 'count': 2, 'profile': 'compute',
+             'hostname_format': 'compute-%index%.example.com'},
+            {'name': 'Controller',
+             'profile': 'control',
+             'instances': [
+                 {'hostname': 'controller-X.example.com',
+                  'profile': 'control-X'},
+                 {'name': 'node-0', 'traits': ['CUSTOM_FOO'],
+                  'nics': [{'subnet': 'leaf-2'}]},
+             ]},
+        ]
+        action = baremetal_deploy.ExpandRolesAction(roles)
+        result = action.run(mock.Mock())
+        self.assertEqual(
+            [
+                {'hostname': 'compute-0.example.com', 'profile': 'compute'},
+                {'hostname': 'compute-1.example.com', 'profile': 'compute'},
+                {'hostname': 'controller-X.example.com',
+                 'profile': 'control-X'},
+                # Name provides the default for hostname later on.
+                {'name': 'node-0', 'profile': 'control',
+                 'traits': ['CUSTOM_FOO'], 'nics': [{'subnet': 'leaf-2'}]},
+            ],
+            result['instances'])
+        self.assertEqual(
+            {
+                'ComputeDeployedServerHostnameFormat':
+                'compute-%index%.example.com',
+                'ComputeDeployedServerCount': 2,
+                'ControllerDeployedServerHostnameFormat':
+                '%stackname%-controller-%index%',
+                'ControllerDeployedServerCount': 2,
+                'HostnameMap': {
+                    'compute-0.example.com': 'compute-0.example.com',
+                    'compute-1.example.com': 'compute-1.example.com',
+                    'overcloud-controller-0': 'controller-X.example.com',
+                    'overcloud-controller-1': 'node-0',
+                }
+            },
+            result['environment']['parameter_defaults'])
+
+    def test_count_with_instances(self):
+        roles = [
+            {'name': 'Compute', 'count': 2, 'profile': 'compute',
+             'hostname_format': 'compute-%index%.example.com'},
+            {'name': 'Controller',
+             'profile': 'control',
+             # Count makes no sense with instances and thus is disallowed.
+             'count': 3,
+             'instances': [
+                 {'hostname': 'controller-X.example.com',
+                  'profile': 'control-X'},
+                 {'name': 'node-0', 'traits': ['CUSTOM_FOO'],
+                  'nics': [{'subnet': 'leaf-2'}]},
+             ]},
+        ]
+        action = baremetal_deploy.ExpandRolesAction(roles)
+        result = action.run(mock.Mock())
+        self.assertIn("Count and instances cannot be provided together",
+                      result.error)
+
+    def test_instances_without_hostname(self):
+        roles = [
+            {'name': 'Compute', 'count': 2, 'profile': 'compute',
+             'hostname_format': 'compute-%index%.example.com'},
+            {'name': 'Controller',
+             'profile': 'control',
+             'instances': [
+                 {'profile': 'control-X'},  # missing hostname here
+                 {'name': 'node-0', 'traits': ['CUSTOM_FOO'],
+                  'nics': [{'subnet': 'leaf-2'}]},
+             ]},
+        ]
+        action = baremetal_deploy.ExpandRolesAction(roles)
+        result = action.run(mock.Mock())
+        self.assertIn("Either hostname or name is required", result.error)
+
+
+class TestPopulateEnvironment(base.TestCase):
+
+    def test_success(self):
+        port_map = {
+            'compute-0': {
+                'ctlplane': {
+                    'network': {'tags': ['foo']},
+                    'fixed_ips': [{'ip_address': '1.2.3.5'}],
+                    'subnets': [{'cidr': '1.2.3.0/24'}],
+                },
+                'foobar': {
+                    'network': {},
+                    'fixed_ips': [{'ip_address': '1.2.4.5'}],
+                    'subnets': [{'cidr': '1.2.4.0/24'}],
+                },
+            },
+            'controller-0': {
+                'ctlplane': {
+                    'network': {'tags': ['foo']},
+                    'fixed_ips': [{'ip_address': '1.2.3.4'}],
+                    'subnets': [{'cidr': '1.2.3.0/24'}],
+                },
+                'foobar': {
+                    'network': {},
+                    'fixed_ips': [{'ip_address': '1.2.4.4'}],
+                    'subnets': [{'cidr': '1.2.4.0/24'}],
+                },
+            },
+        }
+        action = baremetal_deploy.PopulateEnvironmentAction({}, port_map)
+        result = action.run(mock.Mock())
+        self.assertEqual(
+            {
+                'parameter_defaults': {
+                    'DeployedServerPortMap': {
+                        'compute-0-ctlplane': {
+                            'fixed_ips': [{'ip_address': '1.2.3.5'}],
+                            'subnets': [{'cidr': '1.2.3.0/24'}],
+                            'network': {'tags': ['foo']},
+                        },
+                        'controller-0-ctlplane': {
+                            'fixed_ips': [{'ip_address': '1.2.3.4'}],
+                            'subnets': [{'cidr': '1.2.3.0/24'}],
+                            'network': {'tags': ['foo']},
+                        },
+                    }
+                }
+            },
+            result)
diff --git a/workbooks/baremetal_deploy.yaml b/workbooks/baremetal_deploy.yaml
index ee534b74f..107ebbf16 100644
--- a/workbooks/baremetal_deploy.yaml
+++ b/workbooks/baremetal_deploy.yaml
@@ -133,6 +133,7 @@ workflows:
         publish:
           ctlplane_ips: <% $.all_instances.toDict($.hostname, $.ip_addresses.ctlplane[0]) %>
           instances: <% $.all_instances.toDict($.hostname, $) %>
+          port_map: <% $.all_instances.toDict($.hostname, $.port_map) %>
         on-complete: send_message
 
       send_message:
@@ -146,12 +147,14 @@ workflows:
           payload:
             ctlplane_ips: <% $.get('ctlplane_ips', {}) %>
             instances: <% $.get('instances', {}) %>
+            port_map: <% $.get('port_map', {}) %>
 
     output:
       ctlplane_ips: <% $.ctlplane_ips %>
       existing_instances: <% $.existing_instances.toDict($.hostname, $) %>
       instances: <% $.instances %>
       new_instances: <% $.new_instances.toDict($.hostname, $) %>
+      port_map: <% $.port_map %>
 
 
   undeploy_instances:
@@ -188,3 +191,89 @@ workflows:
           status: <% $.get('status', 'SUCCESS') %>
           message: <% $.get('message', '') %>
           execution: <% execution() %>
+
+
+  deploy_roles:
+    description: Deploy roles on bare metal nodes.
+
+    input:
+      - roles
+      - plan: overcloud
+      - ctlplane_network: ctlplane
+      - ssh_keys: []
+      - ssh_user_name: heat-admin
+      - timeout: 3600
+      - concurrency: 20
+      - queue_name: tripleo
+
+    tags:
+      - tripleo-common-managed
+
+    tasks:
+
+      expand_roles:
+        action: tripleo.baremetal_deploy.expand_roles
+        input:
+          roles: <% $.roles %>
+          stackname: <% $.plan %>
+        publish:
+          input_instances: <% task().result.instances %>
+          environment: <% task().result.environment %>
+        publish-on-error:
+          status: FAILED
+          message: <% task().result %>
+        on-success: deploy_instances
+        on-error: send_message
+
+      deploy_instances:
+        workflow: tripleo.baremetal_deploy.v1.deploy_instances
+        input:
+          instances: <% $.input_instances %>
+          ssh_keys: <% $.ssh_keys %>
+          ssh_user_name: <% $.ssh_user_name %>
+          timeout: <% $.timeout %>
+          queue_name: <% $.queue_name %>
+        publish:
+          ctlplane_ips: <% task().result.ctlplane_ips %>
+          instances: <% task().result.instances %>
+          new_instances: <% task().result.new_instances %>
+          existing_instances: <% task().result.existing_instances %>
+          port_map: <% task().result.port_map %>
+        publish-on-error:
+          status: FAILED
+          message: <% task().result %>
+        on-success: populate_environment
+        on-error: send_message
+
+      populate_environment:
+        action: tripleo.baremetal_deploy.populate_environment
+        input:
+          ctlplane_network: <% $.ctlplane_network %>
+          environment: <% $.environment %>
+          port_map: <% $.port_map %>
+        publish:
+          environment: <% task().result %>
+        publish-on-error:
+          status: FAILED
+          message: <% task().result %>
+        on-complete: send_message
+
+      send_message:
+        workflow: tripleo.messaging.v1.send
+        input:
+          queue_name: <% $.queue_name %>
+          type: <% execution().name %>
+          status: <% $.get('status', 'SUCCESS') %>
+          message: <% $.get('message', '') %>
+          execution: <% execution() %>
+          payload:
+            environment: <% $.get('environment', {}) %>
+            instances: <% $.get('instances', {}) %>
+
+    output:
+      ctlplane_ips: <% $.ctlplane_ips %>
+      environment: <% $.environment %>
+      existing_instances: <% $.existing_instances %>
+      instances: <% $.instances %>
+      new_instances: <% $.new_instances %>
+      port_map: <% $.port_map %>