Skip to main content

Events and Listeners

Every infrastructure tool eventually gets a request it was not designed for. Post the plan to Slack. Write an audit record of who deployed what. Fail the build if anything is being destroyed on a Friday. The usual answer is a wrapper script that parses the tool's output, which breaks the next time the output changes.

Octo narrates its whole run as events. Everything you have read in a terminal is a listener subscribed to that stream, and the stream is public. Nothing in the framework has to change for you to build on it.

Listening​

import { Factory, OnEvent, TerraformFoldersWrittenEvent } from '@quadnix/octo';

export class ShopAuditListener {
@OnEvent(TerraformFoldersWrittenEvent)
onFoldersWritten (event: TerraformFoldersWrittenEvent): void {
console.error(`wrote ${event.payload.written.length} folders to ${event.payload.outputDir}`);
}
}

@Factory<ShopAuditListener>(ShopAuditListener)
export class ShopAuditListenerFactory {
private static instance: ShopAuditListener;

static async create (): Promise<ShopAuditListener> {
this.instance ??= new ShopAuditListener();
return this.instance;
}
}

A handler receives every event that is an instance of the type you named, which means subscribing to a base class catches all of its subclasses. @OnEvent(TransactionEvent) catches every transaction event there is, and @OnEvent(Event) catches everything Octo emits.

Handlers can be synchronous or async. The listener class is resolved through Octo's container, so it needs a @Factory alongside it.

The stream​

Sixty event classes, in eleven families. You rarely want a specific one; you want a family. ModuleEvent and ActionEvent are emitted as they are; every other family is abstract, so you listen for it and Octo emits its members.

FamilyEmitted when
CommandEventAn octo command starts, finishes, or fails. One command can run a lifecycle step, or none, as lock does.
ModeEventA lifecycle step starts, finishes, or fails. ModeStartedEvent, ModeFinishedEvent, ModeFailedEvent.
ModeResultEventA mode has a verdict. ValidateModeCompletedEvent carries the whole report; CommitModeCompletedEvent carries what was recorded.
TransactionEventThe graph is being diffed and actions are running. Model diffs, resource diffs, each action.
RegistrationEventA model, resource, anchor, overlay, or action registered itself at boot.
SerializationEventThe model or resource graph was turned into state, or read back from it.
StateEventState files are about to be written or were written, state was refreshed, and the lock was taken, refused, or released.
TerraformEventA Terraform command started or finished, each process started, printed something, or exited, and the folders were written.
HookEventA hook callback ran.
ModuleEventA module's onInit() ran, carrying the module's ID and the inputs it ran with.
ActionEventAn action called this.log(), carrying its message, its metadata, and the node and diff the action ran for.

Two are worth knowing by name. ValidateModeCompletedEvent carries the full validate report, which is what you want for posting a summary onto a pull request. ModeFinishedEvent carries the mode and its duration, and is emitted with emitAndWait, so a listener that writes a file finishes before the process moves on.

Registering a listener​

From the definition file, for the two Octo ships:

settings:
listeners:
- type: JournalEventListener
- type: LoggingEventListener
level: info

LoggingEventListener is the terminal output. JournalEventListener is the journal, below. A new project lists both.

A listener of your own is registered at boot, which means driving Octo from TypeScript:

await octo.initialize(stateProvider, [{ type: ShopAuditListener }]);

There is no way to name your own listener class from octo.yaml today; the definition file's listeners block accepts the two built-in types only.

The journal​

JournalEventListener records every Octo event of every octo command into your state directory, failures included, along with everything Terraform printed and a copy of each state file a command wrote. Octo's state says what your infrastructure is now; the journal says how it got there. It is present when listeners names it, and absent otherwise, like the other two.

Each command writes its own files, and the commands of one lock are kept together: the generate and validate before it, and everything up to the commit or unlock that releases it. When the lock is released, the files move into a folder named after the lock ID. What Octo Saves and When follows the files through a change, and says when each is written.

Emitting your own​

Event is abstract, so an event of your own is a class extending it:

class DeployApprovedEvent extends Event<{ reviewer: string }> {}

EventService.getInstance().emit(new DeployApprovedEvent('deploy approved', { reviewer }));

Useful from inside a module or an action when something happens that your own listeners should react to, and it costs nothing when nobody is listening. The journal records Octo's own events only, so an event of yours reaches your listeners and nothing else.

What to build on it​

The three that come up most, and what each needs.

A pull request comment. Listen for ValidateModeCompletedEvent, take event.payload.report, and post it. The validate journal holds the same report in its ValidateModeCompletedEvent entry, if you would rather read a file than write a listener.

An audit trail. The journal already is one, file by file. To send it somewhere else as it happens, listen for CommandFinishedEvent and CommandFailedEvent: between them you get the command, the duration, and the error, every time.

A gate. A listener cannot stop a run; it observes. To refuse a change, use a hook, which sits in the call path and can throw.

Writing to the right stream​

If you print, print to stderr. Octo's logging listener writes there too, and stdout carries only a command's own output: octo lock prints the lock ID for a shell to capture, and Terraform reads octo run-action and octo get-response as JSON. A line on stdout breaks all three.