Boot action signal API

- Implement boot action signal API
- Implement boot action signal unit tests
- Implement orchestrator step to wait for boot action reports
  before marking a deploy_nodes task a complete

Change-Id: I0098e66dd2cd65f349274914dd25cbdf44194f78
This commit is contained in:
Scott Hussey 2017-11-02 15:37:35 -05:00
parent f4dba218ac
commit adf07eead8
17 changed files with 492 additions and 81 deletions

View File

@ -13,17 +13,51 @@
# limitations under the License.
"""Handle resources for boot action API endpoints. """
import falcon
import ulid2
import tarfile
import io
import logging
import jsonschema
import json
import ulid2
import falcon
from drydock_provisioner.objects.fields import ActionResult
import drydock_provisioner.objects as objects
from .base import StatefulResource
logger = logging.getLogger('drydock')
class BootactionResource(StatefulResource):
bootaction_schema = {
'$schema': 'http://json-schema.org/schema#',
'type': 'object',
'additionalProperties': False,
'properties': {
'status': {
'type': 'string',
'enum': ['Failure', 'Success', 'failure', 'success'],
},
'details': {
'type': 'array',
'items': {
'type': 'object',
'additionalProperties': True,
'properties': {
'message': {
'type': 'string',
},
'error': {
'type': 'boolean',
}
},
'required': ['message', 'error'],
},
},
},
}
def __init__(self, orchestrator=None, **kwargs):
super().__init__(**kwargs)
self.orchestrator = orchestrator
@ -38,6 +72,60 @@ class BootactionResource(StatefulResource):
:param resp: falcone response
:param action_id: ULID ID of the boot action
"""
try:
ba_entry = self.state_manager.get_boot_action(action_id)
except Exception as ex:
self.logger.error(
"Error querying for boot action %s" % action_id, exc_info=ex)
raise falcon.HTTPInternalServerError(str(ex))
if ba_entry is None:
raise falcon.HTTPNotFound()
BootactionUtils.check_auth(ba_entry, req)
try:
json_body = self.req_json(req)
jsonschema.validate(json_body,
BootactionResource.bootaction_schema)
except Exception as ex:
self.logger.error("Error processing boot action body", exc_info=ex)
raise falcon.HTTPBadRequest(description="Error processing body.")
if ba_entry.get('action_status').lower() != ActionResult.Incomplete:
self.logger.info(
"Attempt to update boot action %s after status finalized." %
action_id)
raise falcon.HTTPConflict(
description=
"Action %s status finalized, not available for update." %
action_id)
for m in json_body.get('details', []):
rm = objects.TaskStatusMessage(
m.get('message'), m.get('error'), 'bootaction', action_id)
for f, v in m.items():
if f not in ['message', 'error']:
rm['extra'] = dict()
rm['extra'][f] = v
self.state_manager.post_result_message(ba_entry['task_id'], rm)
new_status = json_body.get('status', None)
if new_status is not None:
self.state_manager.put_bootaction_status(
action_id, action_status=new_status.lower())
ba_entry = self.state_manager.get_boot_action(action_id)
ba_entry.pop('identity_key')
resp.status = falcon.HTTP_200
resp.content_type = 'application/json'
ba_entry['task_id'] = str(ba_entry['task_id'])
ba_entry['action_id'] = ulid2.encode_ulid_base32(
ba_entry['action_id'])
resp.body = json.dumps(ba_entry)
return
class BootactionAssetsResource(StatefulResource):
@ -79,18 +167,19 @@ class BootactionAssetsResource(StatefulResource):
task.design_ref)
assets = list()
ba_status_list = self.state_manager.get_boot_actions_for_node(
hostname)
for ba in site_design.bootactions:
if hostname in ba.target_nodes:
action_id = ulid2.generate_binary_ulid()
ba_status = ba_status_list.get(ba.name, None)
action_id = ba_status.get('action_id')
assets.extend(
ba.render_assets(
hostname,
site_design,
action_id,
type_filter=asset_type_filter))
self.state_manager.post_boot_action(
hostname, ba_ctx['task_id'], ba_ctx['identity_key'],
action_id)
tarball = BootactionUtils.tarbuilder(asset_list=assets)
resp.set_header('Content-Type', 'application/gzip')
@ -112,7 +201,7 @@ class BootactionUnitsResource(BootactionAssetsResource):
def on_get(self, req, resp, hostname):
self.logger.debug(
"Accessing boot action units resource for host %s." % hostname)
super().do_get(req, resp, hostname, 'unit')
self.do_get(req, resp, hostname, 'unit')
class BootactionFilesResource(BootactionAssetsResource):
@ -120,7 +209,7 @@ class BootactionFilesResource(BootactionAssetsResource):
super().__init__(**kwargs)
def on_get(self, req, resp, hostname):
super().do_get(req, resp, hostname, 'file')
self.do_get(req, resp, hostname, 'file')
class BootactionUtils(object):

View File

@ -30,6 +30,7 @@ class OrchestratorAction(BaseDrydockEnum):
PrepareNodes = 'prepare_nodes'
DeployNodes = 'deploy_nodes'
DestroyNodes = 'destroy_nodes'
BootactionReports = 'bootaction_reports'
# OOB driver actions
ValidateOobServices = 'validate_oob_services'
@ -63,12 +64,12 @@ class OrchestratorAction(BaseDrydockEnum):
ConfigurePortProduction = 'config_port_production'
ALL = (Noop, ValidateDesign, VerifySite, PrepareSite, VerifyNodes,
PrepareNodes, DeployNodes, DestroyNodes, ConfigNodePxe, SetNodeBoot,
PowerOffNode, PowerOnNode, PowerCycleNode, InterrogateOob,
CreateNetworkTemplate, CreateStorageTemplate, CreateBootMedia,
PrepareHardwareConfig, ConfigureHardware, InterrogateNode,
ApplyNodeNetworking, ApplyNodeStorage, ApplyNodePlatform,
DeployNode, DestroyNode)
PrepareNodes, DeployNodes, BootactionReports, DestroyNodes,
ConfigNodePxe, SetNodeBoot, PowerOffNode, PowerOnNode,
PowerCycleNode, InterrogateOob, CreateNetworkTemplate,
CreateStorageTemplate, CreateBootMedia, PrepareHardwareConfig,
ConfigureHardware, InterrogateNode, ApplyNodeNetworking,
ApplyNodeStorage, ApplyNodePlatform, DeployNode, DestroyNode)
class OrchestratorActionField(fields.BaseEnumField):

View File

@ -277,19 +277,26 @@ class Task(object):
else:
self.logger.debug("Skipping subtask due to action filter.")
def align_result(self):
"""Align the result of this task with the combined results of all the subtasks."""
def align_result(self, action_filter=None):
"""Align the result of this task with the combined results of all the subtasks.
:param action_filter: string action name to filter subtasks on
"""
for st in self.statemgr.get_complete_subtasks(self.task_id):
if st.get_result() in [
hd_fields.ActionResult.Success,
hd_fields.ActionResult.PartialSuccess
]:
self.success()
if st.get_result() in [
hd_fields.ActionResult.Failure,
hd_fields.ActionResult.PartialSuccess
]:
self.failure()
if action_filter is None or (action_filter is not None
and st.action == action_filter):
if st.get_result() in [
hd_fields.ActionResult.Success,
hd_fields.ActionResult.PartialSuccess
]:
self.success()
if st.get_result() in [
hd_fields.ActionResult.Failure,
hd_fields.ActionResult.PartialSuccess
]:
self.failure()
else:
self.logger.debug("Skipping subtask due to action filter.")
def add_status_msg(self, **kwargs):
"""Add a status message to this task's result status."""

View File

@ -14,6 +14,7 @@
"""Actions for the Orchestrator level of the Drydock workflow."""
import time
import datetime
import logging
import concurrent.futures
import uuid
@ -718,7 +719,101 @@ class DeployNodes(BaseAction):
"Unable to configure platform on any nodes, skipping deploy subtask"
)
node_deploy_task.bubble_results(
action_filter=hd_fields.OrchestratorAction.DeployNode)
if len(node_deploy_task.result.successes) > 0:
node_bootaction_task = self.orchestrator.create_task(
design_ref=self.task.design_ref,
action=hd_fields.OrchestratorAction.BootactionReport,
node_filter=node_deploy_task.node_filter_from_successes())
action = BootactionReports(node_bootaction_task, self.orchestrator,
self.state_manager)
action.start()
self.task.align_result(
action_filter=hd_fields.OrchestratorAction.BootactionReport)
self.task.set_status(hd_fields.TaskStatus.Complete)
self.task.align_result()
self.task.save()
return
class BootactionReports(BaseAction):
"""Wait for nodes to report status of boot action."""
def start(self):
self.task.set_status(hd_fields.TaskStatus.Running)
self.task.save()
poll_start = datetime.utcnow()
still_running = True
timeout = datetime.timedelta(
minutes=config.config_mgr.conf.timeouts.bootaction_final_status)
running_time = datetime.utcnow() - poll_start
nodelist = [
n.get_id() for n in self.orchestrator.get_target_nodes(self.task)
]
while running_time < timeout:
still_running = False
for n in nodelist:
bas = self.state_manager.get_boot_actions_for_node(n)
running_bas = {
k: v
for (k, v) in bas.items()
if v.get('action_status') ==
hd_fields.ActionResult.Incomplete
}
if len(running_bas) > 0:
still_running = True
break
if still_running:
time.sleep(config.config_mgr.conf.poll_interval)
running_time = datetime.utcnow()
for n in nodelist:
bas = self.state_manager.get_boot_actions_for_node(n)
success_bas = {
k: v
for (k, v) in bas.items()
if v.get('action_status') == hd_fields.ActionResult.Success
}
running_bas = {
k: v
for (k, v) in bas.items()
if v.get('action_status') == hd_fields.ActionResult.Incomplete
}
failure_bas = {
k: v
for (k, v) in bas.items()
if v.get('action_status') == hd_fields.ActionResult.Failure
}
for ba in success_bas.values():
self.task.add_status_msg(
msg="Boot action %s completed with status %s" %
(ba['action_name'], ba['action_status']),
error=False,
ctx=n,
ctx_type='node')
for ba in failure_bas.values():
self.task.add_status_msg(
msg="Boot action %s completed with status %s" %
(ba['action_name'], ba['action_status']),
error=True,
ctx=n,
ctx_type='node')
for ba in running_bas.values():
self.task.add_status_msg(
msg="Boot action %s timed out." % (ba['action_name']),
error=True,
ctx=n,
ctx_type='node')
if len(failure_bas) == 0 and len(running_bas) == 0:
self.task.success(focus=n)
else:
self.task.failure(focus=n)
self.task.save()
return

View File

@ -17,6 +17,7 @@ import time
import importlib
import logging
import uuid
import ulid2
import concurrent.futures
import os
@ -556,11 +557,16 @@ class Orchestrator(object):
if site_design.bootactions is None:
return None
identity_key = None
for ba in site_design.bootactions:
if nodename in ba.target_nodes:
identity_key = os.urandom(32)
self.state_manager.post_boot_action_context(
nodename, task.get_id(), identity_key)
return identity_key
if identity_key is None:
identity_key = os.urandom(32)
self.state_manager.post_boot_action_context(
nodename, task.get_id(), identity_key)
action_id = ulid2.generate_binary_ulid()
self.state_manager.post_boot_action(
nodename, task.get_id(), identity_key, action_id, ba.name)
return None
return identity_key

View File

@ -49,7 +49,7 @@ class ResultMessage(ExtendTable):
__schema__ = [
Column('sequence', Integer, primary_key=True),
Column('task_id', pg.BYTEA(16)),
Column('message', String(128)),
Column('message', String(1024)),
Column('error', Boolean),
Column('context', String(64)),
Column('context_type', String(16)),
@ -89,7 +89,8 @@ class BootActionStatus(ExtendTable):
__schema__ = [
Column('node_name', String(32)),
Column('bootaction_id', pg.BYTEA(16), primary_key=True),
Column('action_id', pg.BYTEA(16), primary_key=True),
Column('action_name', String(64)),
Column('task_id', pg.BYTEA(16)),
Column('identity_key', pg.BYTEA(32)),
Column('action_status', String(32)),

View File

@ -30,8 +30,6 @@ from .design import resolver
from drydock_provisioner import config
from drydock_provisioner.error import StateError
class DrydockState(object):
def __init__(self):
@ -491,20 +489,22 @@ class DrydockState(object):
task_id,
identity_key,
action_id,
action_name,
action_status=hd_fields.ActionResult.Incomplete):
"""Post a individual boot action.
:param nodename: The name of the node the boot action is running on
:param task_id: The uuid.UUID task_id of the task that instigated the node deployment
:param identity_key: A 256-bit key the node must provide when accessing the boot action API
:param action_id: The string ULID id of the boot action
:param action_id: The 32-byte ULID id of the boot action
:param action_status: The status of the action.
"""
try:
with self.db_engine.connect() as conn:
query = self.ba_status_tbl.insert().values(
node_name=nodename,
bootaction_id=action_id,
action_id=action_id,
action_name=action_name,
task_id=task_id.bytes,
identity_key=identity_key,
action_status=action_status)
@ -513,6 +513,55 @@ class DrydockState(object):
except Exception as ex:
self.logger.error(
"Error saving boot action %s." % action_id, exc_info=ex)
return False
def put_bootaction_status(self,
action_id,
action_status=hd_fields.ActionResult.Incomplete):
"""Update the status of a bootaction.
:param action_id: string ULID ID of the boot action
:param action_status: The string statu to set for the boot action
"""
try:
with self.db_engine.connect() as conn:
query = self.ba_status_tbl.update().where(
self.ba_status_tbl.c.action_id == ulid2.decode_ulid_base32(
action_id)).values(action_status=action_status)
conn.execute(query)
return True
except Exception as ex:
self.logger.error(
"Error updating boot action %s status." % action_id,
exc_info=ex)
return False
def get_boot_actions_for_node(self, nodename):
"""Query for getting all boot action statuses for a node.
Return a dictionary of boot action dictionaries keyed by the
boot action name.
:param nodename: string nodename of the target node
"""
try:
with self.db_engine.connect() as conn:
query = self.ba_status_tbl.select().where(
self.ba_status_tbl.c.node_name == nodename)
rs = conn.execute(query)
actions = dict()
for r in rs:
ba_dict = dict(r)
ba_dict['action_id'] = bytes(ba_dict['action_id'])
ba_dict['identity_key'] = bytes(ba_dict['identity_key'])
ba_dict['task_id'] = uuid.UUID(bytes=ba_dict['task_id'])
actions[ba_dict.get('action_name', 'undefined')] = ba_dict
return actions
except Exception as ex:
self.logger.error(
"Error selecting boot actions for node %s" % nodename,
exc_info=ex)
return None
def get_boot_action(self, action_id):
"""Query for a single boot action by ID.
@ -522,52 +571,18 @@ class DrydockState(object):
try:
with self.db_engine.connect() as conn:
query = self.ba_status_tbl.select().where(
bootaction_id=ulid2.decode_ulid_base32(action_id))
self.ba_status_tbl.c.action_id == ulid2.decode_ulid_base32(
action_id))
rs = conn.execute(query)
r = rs.fetchone()
if r is not None:
ba_dict = dict(r)
ba_dict['bootaction_id'] = bytes(ba_dict['bootaction_id'])
ba_dict['identity_key'] = bytes(
ba_dict['identity_key']).hex()
ba_dict['action_id'] = bytes(ba_dict['action_id'])
ba_dict['identity_key'] = bytes(ba_dict['identity_key'])
ba_dict['task_id'] = uuid.UUID(bytes=ba_dict['task_id'])
return ba_dict
else:
return None
except Exception as ex:
self.logger.error(
"Error querying boot action %s" % action_id, exc_info=ex)
def post_promenade_part(self, part):
my_lock = self.promenade_lock.acquire(blocking=True, timeout=10)
if my_lock:
if self.promenade.get(part.target, None) is not None:
self.promenade[part.target].append(part.obj_to_primitive())
else:
self.promenade[part.target] = [part.obj_to_primitive()]
self.promenade_lock.release()
return None
else:
raise StateError("Could not acquire lock")
def get_promenade_parts(self, target):
parts = self.promenade.get(target, None)
if parts is not None:
return [
objects.PromenadeConfig.obj_from_primitive(p) for p in parts
]
else:
# Return an empty list just to play nice with extend
return []
def set_bootdata_key(self, hostname, design_id, data_key):
my_lock = self.bootdata_lock.acquire(blocking=True, timeout=10)
if my_lock:
self.bootdata[hostname] = {'design_id': design_id, 'key': data_key}
self.bootdata_lock.release()
return None
else:
raise StateError("Could not acquire lock")
def get_bootdata_key(self, hostname):
return self.bootdata.get(hostname, None)

View File

@ -14,7 +14,6 @@
"""Generic testing for the orchestrator."""
from falcon import testing
import pytest
import os
import tarfile
import io
import falcon
@ -74,9 +73,7 @@ class TestClass(object):
test_task = test_orchestrator.create_task(
action=hd_fields.OrchestratorAction.Noop, design_ref=design_ref)
id_key = os.urandom(32)
blank_state.post_boot_action_context('compute01',
test_task.get_id(), id_key)
id_key = test_orchestrator.create_bootaction_context('compute01', test_task)
ba_ctx = dict(
nodename='compute01',

View File

@ -0,0 +1,123 @@
# Copyright 2017 AT&T Intellectual Property. All other rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Generic testing for the orchestrator."""
from falcon import testing
import pytest
import os
import json
import ulid2
import falcon
import drydock_provisioner.objects.fields as hd_fields
from drydock_provisioner.control.api import start_api
class TestClass(object):
def test_bootaction_detail(self, falcontest, seed_bootaction_status):
"""Test that the API allows boot action detail messages."""
url = "/api/v1.0/bootactions/%s" % seed_bootaction_status['action_id']
hdr = {
'X-Bootaction-Key': "%s" % seed_bootaction_status['identity_key'],
'Content-Type': 'application/json',
}
body = {
'details': [
{
'message': 'Test message.',
'error': True,
},
]
}
result = falcontest.simulate_post(
url, headers=hdr, body=json.dumps(body))
assert result.status == falcon.HTTP_200
def test_bootaction_status(self, falcontest, seed_bootaction_status):
"""Test that the API allows boot action status updates."""
url = "/api/v1.0/bootactions/%s" % seed_bootaction_status['action_id']
hdr = {
'X-Bootaction-Key': "%s" % seed_bootaction_status['identity_key'],
'Content-Type': 'application/json',
}
body = {
'status': 'Success',
'details': [
{
'message': 'Test message.',
'error': True,
},
]
}
result = falcontest.simulate_post(
url, headers=hdr, body=json.dumps(body))
assert result.status == falcon.HTTP_200
result = falcontest.simulate_post(
url, headers=hdr, body=json.dumps(body))
assert result.status == falcon.HTTP_409
def test_bootaction_schema(self, falcontest, seed_bootaction_status):
"""Test that the API allows boot action status updates."""
url = "/api/v1.0/bootactions/%s" % seed_bootaction_status['action_id']
hdr = {
'X-Bootaction-Key': "%s" % seed_bootaction_status['identity_key'],
'Content-Type': 'application/json',
}
body = {
'foo': 'Success',
}
result = falcontest.simulate_post(
url, headers=hdr, body=json.dumps(body))
assert result.status == falcon.HTTP_400
@pytest.fixture()
def seed_bootaction_status(self, blank_state, test_orchestrator,
input_files):
"""Add a task and boot action to the database for testing."""
input_file = input_files.join("fullsite.yaml")
design_ref = "file://%s" % input_file
test_task = test_orchestrator.create_task(
action=hd_fields.OrchestratorAction.Noop, design_ref=design_ref)
id_key = os.urandom(32)
action_id = ulid2.generate_binary_ulid()
blank_state.post_boot_action('compute01',
test_task.get_id(), id_key, action_id, 'helloworld')
ba = dict(
nodename='compute01',
task_id=test_task.get_id(),
identity_key=id_key.hex(),
action_id=ulid2.encode_ulid_base32(action_id))
return ba
@pytest.fixture()
def falcontest(self, drydock_state, test_ingester, test_orchestrator):
"""Create a test harness for the the Falcon API framework."""
return testing.TestClient(
start_api(
state_manager=drydock_state,
ingester=test_ingester,
orchestrator=test_orchestrator))

View File

@ -0,0 +1,70 @@
# Copyright 2017 AT&T Intellectual Property. All other rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Test postgres integration for task management."""
import pytest
import os
import ulid2
from drydock_provisioner import objects
class TestPostgres(object):
def test_bootaction_post(self, populateddb, drydock_state):
"""Test that a boot action status can be added."""
id_key = os.urandom(32)
action_id = ulid2.generate_binary_ulid()
nodename = 'testnode'
result = drydock_state.post_boot_action(nodename,
populateddb.get_id(), id_key,
action_id,
'helloworld')
assert result
def test_bootaction_put(self, populateddb, drydock_state):
"""Test that a boot action status can be updated."""
id_key = os.urandom(32)
action_id = ulid2.generate_binary_ulid()
nodename = 'testnode'
drydock_state.post_boot_action(nodename,
populateddb.get_id(), id_key, action_id, 'helloworld')
result = drydock_state.put_bootaction_status(
ulid2.encode_ulid_base32(action_id),
action_status=objects.fields.ActionResult.Success)
assert result
def test_bootaction_get(self, populateddb, drydock_state):
"""Test that a boot action status can be retrieved."""
id_key = os.urandom(32)
action_id = ulid2.generate_binary_ulid()
nodename = 'testnode'
drydock_state.post_boot_action(nodename,
populateddb.get_id(), id_key, action_id, 'helloworld')
ba = drydock_state.get_boot_action(ulid2.encode_ulid_base32(action_id))
assert ba.get('identity_key') == id_key
@pytest.fixture(scope='function')
def populateddb(self, blank_state):
"""Add dummy task to test against."""
task = objects.Task(
action='prepare_site', design_ref='http://test.com/design')
blank_state.post_task(task)
return task

View File

@ -19,6 +19,7 @@ from drydock_provisioner.ingester.ingester import Ingester
from drydock_provisioner.statemgmt.state import DrydockState
import drydock_provisioner.objects as objects
class TestClass(object):
def test_bootaction_render(self, input_files, setup):
objects.register_all()

View File

@ -14,6 +14,7 @@
import drydock_provisioner.objects as objects
class TestClass(object):
def test_bootaction_scoping_blankfilter(self, input_files,
test_orchestrator):

View File

@ -16,6 +16,7 @@ from drydock_provisioner.ingester.ingester import Ingester
from drydock_provisioner.statemgmt.state import DrydockState
from drydock_provisioner.orchestrator.orchestrator import Orchestrator
class TestClass(object):
def test_design_inheritance(self, input_files, setup):
input_file = input_files.join("fullsite.yaml")

View File

@ -17,6 +17,7 @@ from drydock_provisioner.ingester.ingester import Ingester
from drydock_provisioner.statemgmt.state import DrydockState
import drydock_provisioner.objects as objects
class TestClass(object):
def test_ingest_full_site(self, input_files, setup):
objects.register_all()

View File

@ -17,6 +17,7 @@ from drydock_provisioner.ingester.ingester import Ingester
from drydock_provisioner.statemgmt.state import DrydockState
import drydock_provisioner.objects as objects
class TestClass(object):
def test_bootaction_parse(self, input_files, setup):
objects.register_all()

View File

@ -15,6 +15,7 @@
from drydock_provisioner.ingester.plugins.yaml import YamlIngester
class TestClass(object):
def test_ingest_singledoc(self, input_files):
input_file = input_files.join("singledoc.yaml")

View File

@ -17,6 +17,7 @@ from drydock_provisioner.ingester.ingester import Ingester
from drydock_provisioner.statemgmt.state import DrydockState
import drydock_provisioner.objects as objects
class TestClass(object):
def test_node_filter_obj(self, input_files, setup, test_orchestrator):
input_file = input_files.join("fullsite.yaml")