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 fileProvision_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 a context_type key. Both terms appear in the same functions (drush_hosting_task() reads the Drush context HOSTING_TASK to 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/__call over the $properties array, service plumbing, write_alias().
  • Provision_Context_server — no parent; bootstraps the service API (server.php:13).
  • Provision_Context_platformpublic $parent_key = 'server' (platform.php:12); init_platform() declares root, makefile, make_working_copy.
  • Provision_Context_sitepublic $parent_key = 'platform' (site.php:8); init_site() declares uri, 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 to init_server()/init_platform()/init_site() on the context and its services), then fires hook_provision_context_alter() by reference (provision.context.inc:66-77; hook documented at provision.api.php:96).
  • Bootstrap guard. Calling d() while Drush is still at DRUSH_BOOTSTRAP_NONE returns a DRUSH_BOOTSTRAPPING error (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 $properties array first, with is_oid-marked values routed back through d(); if the name is a plain (non-Aegir) Drush alias, the raw alias record is consulted as a fallback — d('@someplainalias')->uri works.
  • ->options is a pseudo-property: the merged alias record + stdin + options + cli contexts (Context.php:56-58). This is what setProperty() reads during init.
  • The current-command target lands in d() because provision_drush_init() seeds it from the alias (or #name during provision-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 = TRUE explodes 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 __get returns d($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's db_server differs from its web server).

The full round trip, worked

With site_data as the running example — one string from a site node into the backend alias and back:

  1. Frontend value. The site node carries $node->site_data (form + storage are ordinary D7 module work).

  2. Frontend → backend. For any task whose hook_hosting_tasks() entry sets 'provision_save' => TRUE (verify / install / import), drush_hosting_task() fires hook_hosting_TASK_OBJECT_context_options() by reference and then runs provision-save on @$task->ref->hosting_name with $task->context_options as the option set (task.hosting.inc:198-208; hook documented hosting.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
     }
    }
  3. Persistence. Because Provision_Service_site_data::subscribe_site() declared the property, provision-save reads --site_data through setProperty() and write_alias() lands it in <domain>.alias.drushrc.php.

  4. Backend use. Any backend hook now reads it as d()->site_data — the example logs it from drush_site_data_post_provision_install() / ..._post_provision_verify() (drush/site_data.drush.inc:49-58).

  5. Backend → frontend. When the frontend imports contexts (hosting-import), hosting_drush_import() calls every implementer of hook_drush_context_import($context, &$node) to map the property back onto the node (hosting.drush.inc:129,159-161; hook documented hosting.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 any provision-* command whose effective user is root (provision.drush.inc:67-73) — aegir on 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's restart_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, no str_contains(). The shipped examples use array() syntax throughout; match them.
  • BOA's Drush fork deny-filters backend commandfiles by path. For backend identities (aegir, oN) on Octopus instances, *.drush.inc under /data/disk/<user>/{static,distro,platforms} and under /data/disk/<user>/.drush is 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.inc shipped 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 via subscribe_<type>() + setProperty() is dropped from the alias file at the next provision-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 in context_options when the frontend value is empty (as both shipped examples do), and never pass it as a real value.
  • d() needs a bootstrapped Drush. At DRUSH_BOOTSTRAP_NONE it returns a DRUSH_BOOTSTRAPPING error (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_context registry are the source of truth; every provision_save-flagged task rewrites the alias from the node. Hand edits and out-of-band provision-save runs are overwritten on the next verify.
  • Deploy both halves together. A frontend pushing a context_option the backend has no service for persists nothing (silently); a backend property with no hook_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