Extending Aegir: contexts, hooks, module pairs
An Aegir extension is almost always a pair of modules: a Drupal 7 module in
the Hostmaster frontend that implements the hosting.api.php surface, and a
Drush 8 commandfile (plus optional Provision_* classes) on the backend that
implements the provision.api.php / Drush-hook surface. The two halves never
call each other directly — everything crosses through one Drush process chain
(the task round-trip) and one data model (contexts, persisted as
Drush alias files). This page is the extension contract: the contexts model,
the d() accessor, how a property travels frontend → backend → frontend, which
hook surface owns which job, and the traps. The canonical reference
implementations ship in the hosting repo under example/:
| Example | Frontend module | Backend half | Demonstrates |
|---|---|---|---|
example/example_service |
hosting_example |
drush/example.drush.inc + Provision_Service_example{,_basic} + config template |
A complete new service type with an implementation, port, restart cmd, config file |
example/server_data |
hosting_server_data |
drush/server_data.drush.inc + Provision_Service_server_data |
One custom property persisted on server contexts |
example/site_data |
hosting_site_data |
drush/site_data.drush.inc + Provision_Service_site_data |
One custom property persisted on site contexts |
Read site_data first — at ~120 lines total it is the smallest complete
round trip, and the skeleton below is modelled on it.
Contexts — the data model
Every server, platform and site the backend can act on has a context: a
named, typed, persistent property bag. On disk a context is a Drush alias
file — Provision_Config_Drushrc_Alias::filename() returns
drush_server_home() . '/.drush/' . $aliasname . '.alias.drushrc.php'
(Alias.php:35-37), so the backend's context store is simply the instance
user's alias directory (/var/aegir/.drush/ for the Master, ${HOME}/.drush/
= /data/disk/<user>/.drush/ per Octopus instance):
/var/aegir/.drush/
server_master.alias.drushrc.php
server_localhost.alias.drushrc.php
platform_<Name>.alias.drushrc.php
hostmaster.alias.drushrc.php ← the frontend site's own context
<site-domain>.alias.drushrc.php ← one per hosted site
A simplified site alias body:
$aliases['foo.example.com'] = array(
'context_type' => 'site',
'platform' => '@platform_Drupal10',
'server' => '@server_master',
'db_server' => '@server_localhost',
'uri' => 'foo.example.com',
'root' => '/data/disk/o1/static/d10',
'site_enabled' => TRUE,
'profile' => 'standard',
);
platform, server and db_server hold the names of other contexts;
accessed through d() they come back as full context objects, not strings
(see is_oid).
Naming trap. Drush has its own unrelated concept called "context" (
drush_get_context()/drush_set_context()— per-process option state). Aegir contexts are Drush aliases with acontext_typekey. Both terms appear in the same functions (drush_hosting_task()reads the Drush contextHOSTING_TASKto run a task against an Aegir context); keep them separate.
Types, classes, parents
provision_context_factory() (provision.context.inc:113) reads
context_type from the alias record (default server) and instantiates
Provision_Context_{$type}. The hierarchy is three subclasses over one base:
Provision_Context(Provision/Context.php:12) — magic__get/__set/__callover the$propertiesarray, service plumbing,write_alias().Provision_Context_server— no parent; bootstraps the service API (server.php:13).Provision_Context_platform—public $parent_key = 'server'(platform.php:12);init_platform()declaresroot,makefile,make_working_copy.Provision_Context_site—public $parent_key = 'platform'(site.php:8);init_site()declaresuri,site_enabled,language,client_name,aliases,redirection,cron_key,drush_aliases,profile,install_method(site.php:25-48).
init() (Context.php:185-210) stamps context_type, declares the
parent_key property as a context reference, forces server = '@server_master'
on every context (Context.php:196-198), then gives each declared service
class its subscribe_{$type}() callback. Because parent_key chains
site → platform → server, service lookups and property traversal walk upward
automatically (service() / get_services(), Context.php:270-323).
Who names contexts — the frontend registry
Context names are minted frontend-side and mapped to nodes in the
hosting_context table (hosting_context_register(),
hosting.module:1155); hosting_context_name($nid) returns the @-prefixed
name (hosting.module:1214). Conventions, per the registering code:
- servers →
server_<name>(hosting_server.module:578) - platforms →
platform_+ the node title stripped of non-word characters (hosting_platform.module:584) - sites → the bare domain (
hosting_site.nodeapi.inc:345) - the frontend itself →
@hostmaster
The node graph is authoritative: provision-save re-exports the node's data
over the alias file on every provision_save-flagged task. Never hand-edit
an alias file — your edit lives until the next verify.
The d() accessor
function & d($name = NULL, $_root_object = FALSE, $allow_creation = TRUE)
(provision.context.inc:23.) The single entry point to contexts in backend
code:
$ctx = d(); // context of the current command's target alias
$hm = d('@hostmaster'); // named context, loaded + cached on first use
$all = d('all'); // every context instantiated so far
// Traversal: each hop is a full context object, not a string.
$db = d('@hostmaster')->platform->server; // via parent_key chain
$port = d('@hostmaster')->db_server->db_port;
Mechanics worth knowing before you rely on them:
- Static instance cache. First access runs the factory, then
method_invoke('init'),type_invoke('init')(which fans out toinit_server()/init_platform()/init_site()on the context and its services), then fireshook_provision_context_alter()by reference (provision.context.inc:66-77; hook documented atprovision.api.php:96). - Bootstrap guard. Calling
d()while Drush is still atDRUSH_BOOTSTRAP_NONEreturns aDRUSH_BOOTSTRAPPINGerror (provision.context.inc:41-44). Call it from hooks and command callbacks, never at commandfile parse scope. - Reads go through
__get()(Context.php:55-78): the$propertiesarray first, withis_oid-marked values routed back throughd(); if the name is a plain (non-Aegir) Drush alias, the raw alias record is consulted as a fallback —d('@someplainalias')->uriworks. ->optionsis a pseudo-property: the merged alias record + stdin + options + cli contexts (Context.php:56-58). This is whatsetProperty()reads during init.- The current-command target lands in
d()becauseprovision_drush_init()seeds it from the alias (or#nameduringprovision-save) as the root object (provision.drush.inc:51-57).
Declaring properties — services and subscribe hooks
Setting $context->foo = 'bar' stores into $properties and would even be
written out once — but the durable contract is declaration at init time,
because on every load/save cycle only setProperty()-declared fields are
re-read from the options/alias record into $properties, and
write_alias() regenerates the file from $properties alone
(Context.php:240-243). An undeclared property survives reads (via the
alias-record fallback) exactly until the next provision-save rewrites the
file without it. Declaration is done by a tiny service class pair:
// drush/Provision/Service/site/data.php (PSR-0: Provision_Service_site_data)
class Provision_Service_site_data extends Provision_Service {
public $service = 'site_data';
// Add the needed properties to the site context.
static function subscribe_site($context) {
$context->setProperty('site_data');
}
}
// drush/site_data.drush.inc
function site_data_provision_services() {
site_data_provision_register_autoload();
return array('site_data' => NULL); // hook_provision_services()
}
hook_provision_services() (provision.api.php:82) declares the service;
Provision_Context::init() then calls
Provision_Service_<service>::subscribe_<type>($this) for every declared
service that implements it (Context.php:200-208) — implement
subscribe_server / subscribe_platform / subscribe_site to pick which
context types carry your property. The autoload boilerplate registers your
Provision/ directory for the PSR-0 Provision_ prefix via
provision_autoload_register_prefix('Provision_', dirname(__FILE__))
(provision.inc:42; boilerplate in server_data.drush.inc:13-27 — copy it
verbatim, underscores map to subdirectories, hence
Provision/Service/site/data.php).
setProperty($field, $default = NULL, $array = FALSE, $force = FALSE)
(Context.php:215-235) semantics:
- value present in
->options→ stored; the literal string'null'resets to$default(the sanctioned "unset" sentinel over the option wire); $array = TRUEexplodes comma-separated scalar input;- absent →
$default.
Two related declarations on the context:
is_oid($name)(Context.php:83-85) — marks the property as the name of another context, so__getreturnsd($value)instead of the string.service_subscribe($service, $name)(Context.php:251-253) — routes$this->service($service)to the named context's service (how a site'sdb_serverdiffers from its webserver).
The full round trip, worked
With site_data as the running example — one string from a site node into the
backend alias and back:
-
Frontend value. The site node carries
$node->site_data(form + storage are ordinary D7 module work). -
Frontend → backend. For any task whose
hook_hosting_tasks()entry sets'provision_save' => TRUE(verify / install / import),drush_hosting_task()fireshook_hosting_TASK_OBJECT_context_options()by reference and then runsprovision-saveon@$task->ref->hosting_namewith$task->context_optionsas the option set (task.hosting.inc:198-208; hook documentedhosting.api.php:218):// hosting_site_data.drush.inc — frontend half, runs under @hostmaster function hosting_site_data_hosting_site_context_options(&$task) { if (!empty($task->ref->site_data)) { $task->context_options['site_data'] = $task->ref->site_data; } else { $task->context_options['site_data'] = 'null'; // reset sentinel } } -
Persistence. Because
Provision_Service_site_data::subscribe_site()declared the property,provision-savereads--site_datathroughsetProperty()andwrite_alias()lands it in<domain>.alias.drushrc.php. -
Backend use. Any backend hook now reads it as
d()->site_data— the example logs it fromdrush_site_data_post_provision_install()/..._post_provision_verify()(drush/site_data.drush.inc:49-58). -
Backend → frontend. When the frontend imports contexts (
hosting-import),hosting_drush_import()calls every implementer ofhook_drush_context_import($context, &$node)to map the property back onto the node (hosting.drush.inc:129,159-161; hook documentedhosting.api.php:71):function hosting_site_data_drush_context_import($context, &$node) { if ($context->type == 'site' && !empty($context->site_data)) { $node->site_data = $context->site_data; } }
Implement 2 and 5 together — the hook_hosting_TASK_OBJECT_context_options
doc block says exactly this: options pushed to the backend without a matching
import hook are lost on re-import.
After the provision-save, drush_hosting_task() invokes the actual command
via provision_backend_invoke($alias, $task->task_command, …)
(task.hosting.inc:226; a thin drush_invoke_process() wrapper with
dispatch-using-alias, provision.inc:1274), captures the backend output into
the HOSTING_DRUSH_OUTPUT Drush context, and on delete tasks removes the
context with provision-save --delete (task.hosting.inc:233).
Which hook surface for which job
| Job | Surface | Where |
|---|---|---|
| New task type in the UI | hook_hosting_tasks() — keys title, command (defaults provision-TASKTYPE), dialog, hidden, access callback, provision_save |
hosting_task.api.php:42 |
| Task confirmation form + validation | hosting_task_TASK_TYPE_form() / ..._form_validate() |
hosting_task.api.php:82,94 |
| Queue a task from code | hosting_add_task($nid, $type, $args) |
hosting_task.module:550 |
| New backend command | hook_drush_command() in your backend commandfile; name it provision-* to inherit Provision's preflight (root refusal + load abort) |
provision.drush.inc:91 for the pattern |
| Bracket an existing backend command | Drush command-hook chain: for provision-install implement drush_MYEXT_pre_provision_install() / drush_MYEXT_provision_install() / drush_MYEXT_post_provision_install(); any completed hook's _rollback twin runs in reverse on failure |
_drush_invoke_hooks(), command.inc:306 (variations :371-377) |
| Persist data on a context | hook_provision_services() + Provision_Service subclass with subscribe_<type>() |
above |
| Alter a context as it loads | hook_provision_context_alter(&$context) |
provision.api.php:96 |
| Inject/alter rendered config (vhost etc.) | template + alter chain: hook_provision_config_load_templates(_alter), hook_provision_config_variables_alter, drush_hook_provision_nginx_vhost_config and friends |
provision.api.php:220-311; detail on Provision API |
| New service type end-to-end | frontend hook_hosting_service_type() / hook_hosting_service() + hostingService_* classes; backend Provision_Service_* + config template |
example/example_service, both halves |
| Feature toggle on the Features page | hook_hosting_feature() |
hosting.api.php:110 |
| New dispatch queue | hook_hosting_queues(); each queue auto-registers a hosting-<queue> Drush command |
hosting.api.php:138; hosting.drush.inc:41-54 |
| React to task success (frontend) | hook_post_hosting_TASK_TYPE_task($task, $data) — $data is the backend Drush output |
hosting.api.php:325; fired task.hosting.inc:259-265 |
| React to task failure/rollback (frontend) | hook_hosting_TASK_TYPE_task_rollback($task, $data) |
hosting.api.php:246; fired task.hosting.inc:247-253 |
| Veto/permit site domains | hook_allow_domain($url, $params) |
hosting.api.php:35 |
Frontend per-module surfaces beyond hosting.api.php proper:
hosting_alias.api.php, hosting_quota.api.php, hosting_server.api.php,
hosting_site.api.php, hosting_task.api.php — mapped module-by-module on
the hosting module suite leaf, with the hook
detail on Hosting API.
Minimal paired-extension skeleton
A custom widget property on site contexts, following site_data exactly.
Note the deliberate namespace split: the frontend module is hosting_widget
(hooks prefixed hosting_widget_), the backend commandfile is widget
(Drush hooks prefixed drush_widget_).
hosting_widget/ ← frontend: D7 module in the hostmaster site
hosting_widget.info (core = 7.x; dependencies[] = hosting_site)
hosting_widget.module (form/storage for $node->widget)
hosting_widget.drush.inc (the two frontend bridge hooks)
drush/ ← backend: deployed to the instance's Drush path
widget.drush.inc
Provision/Service/widget.php (PSR-0: Provision_Service_widget)
// hosting_widget.drush.inc
function hosting_widget_hosting_site_context_options(&$task) {
$task->context_options['widget'] =
!empty($task->ref->widget) ? $task->ref->widget : 'null';
}
function hosting_widget_drush_context_import($context, &$node) {
if ($context->type == 'site' && !empty($context->widget)) {
$node->widget = $context->widget;
}
}
// drush/Provision/Service/widget.php
class Provision_Service_widget extends Provision_Service {
public $service = 'widget';
static function subscribe_site($context) {
$context->setProperty('widget');
}
}
// drush/widget.drush.inc
function widget_drush_init() {
widget_provision_register_autoload();
}
function widget_provision_register_autoload() {
static $loaded = FALSE;
if (!$loaded) {
$loaded = TRUE;
$list = drush_commandfile_list();
$provision_dir = dirname($list['provision']);
if (is_readable($provision_dir . '/provision.inc')) {
include_once $provision_dir . '/provision.inc';
include_once $provision_dir . '/provision.service.inc';
if (function_exists('provision_autoload_register_prefix')) {
provision_autoload_register_prefix('Provision_', dirname(__FILE__));
}
}
}
}
function widget_provision_services() {
widget_provision_register_autoload();
return array('widget' => NULL);
}
// Do something with the value on every verify.
function drush_widget_post_provision_verify() {
drush_log('widget: ' . d()->widget);
}
Deployment on a BOA box: the frontend half is a Hostmaster module — bundling
it into the shipped profile is make-chain work (four-place change; see the
hostmaster frontend maintainer contract); a
one-off install goes into the instance site's own sites/<domain>/modules
and is enabled per instance. The backend half must land where the instance
user's Drush actually loads commandfiles — see the deny-filter pitfall below.
Pitfalls
- The backend runs as the instance user, never root.
_provision_drush_check_user()hard-aborts anyprovision-*command whose effective user is root (provision.drush.inc:67-73) —aegiron the Master,o1/o2/… per Octopus instance. The only sanctioned root crossing is the sudo whitelist for the web-server restart command (example.sudoers; executed via the service'srestart_cmd,Provision/Service.php:276-302). Anything genuinely needing root — CSF, filesystem ownership outside the instance tree, system packages — belongs in BOA's bash layer, not in a Provision extension. Design accordingly: an extension that shells out expecting root will fail at runtime, not review. - PHP 5.6 is the floor for both halves. The whole Aegir layer must stay
parse- and runtime-clean on PHP 5.6 (see the topic chapter): no
??, no scalar type hints, no arrow functions, nostr_contains(). The shipped examples usearray()syntax throughout; match them. - BOA's Drush fork deny-filters backend commandfiles by path. For backend
identities (
aegir,oN) on Octopus instances,*.drush.incunder/data/disk/<user>/{static,distro,platforms}and under/data/disk/<user>/.drushis not loaded, with the carve-out.drush/{sys,usr,xts}/where BOA installs Provision and Aegir contrib extensions (boa_extension_filter.inc:65-82);/var/aegir/is not denied. Consequence: a backend*.drush.incshipped inside a platform or site tree silently vanishes for the backend — deploy it under the instance's.drush/usr/(or/var/aegir/.drush/on the Master). Full mechanics on Drush fork internals. - Undeclared properties don't round-trip.
write_alias()dumps only$properties; a value never declared viasubscribe_<type>()+setProperty()is dropped from the alias file at the nextprovision-save. If a value "worked yesterday and is gone after verify", this is why. - The
'null'string is a live sentinel.setProperty()treats the literal string'null'as "reset to default" (Context.php:219) — use it deliberately incontext_optionswhen the frontend value is empty (as both shipped examples do), and never pass it as a real value. d()needs a bootstrapped Drush. AtDRUSH_BOOTSTRAP_NONEit returns aDRUSH_BOOTSTRAPPINGerror (provision.context.inc:41-44). Keep context access inside hooks/commands; the in-code comment about "two senior Aegir developers, three full days" is there for a reason.- Alias files are generated artifacts. The frontend node graph +
hosting_contextregistry are the source of truth; everyprovision_save-flagged task rewrites the alias from the node. Hand edits and out-of-bandprovision-saveruns are overwritten on the next verify. - Deploy both halves together. A frontend pushing a
context_optionthe backend has no service for persists nothing (silently); a backend property with nohook_drush_context_import()implementation is wiped from the node side on re-import. Skew between halves fails quietly, always in the direction of data loss.
Related
- Aegir backend APIs (topic chapter) — the task round-trip both module halves plug into, and the PHP 5.6 contract.
- Provision API — the full backend hook surface, config/template alter chain and a deeper provision-extension skeleton.
- Hosting API — the full frontend hook surface and feature/queue mechanics.
- Hosting module suite map — where your
frontend module sits among the
hosting_*modules. - Hostmaster frontend internals — bundling a
frontend module into the shipped profile (make chain,
.infomanifest). - Drush fork internals — the extension deny-filter your backend commandfile must clear.