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.
| Family | Emitted when |
|---|---|
CommandEvent | An octo command starts, finishes, or fails. One command can run a lifecycle step, or none, as lock does. |
ModeEvent | A lifecycle step starts, finishes, or fails. ModeStartedEvent, ModeFinishedEvent, ModeFailedEvent. |
ModeResultEvent | A mode has a verdict. ValidateModeCompletedEvent carries the whole report; CommitModeCompletedEvent carries what was recorded. |
TransactionEvent | The graph is being diffed and actions are running. Model diffs, resource diffs, each action. |
RegistrationEvent | A model, resource, anchor, overlay, or action registered itself at boot. |
SerializationEvent | The model or resource graph was turned into state, or read back from it. |
StateEvent | State files are about to be written or were written, state was refreshed, and the lock was taken, refused, or released. |
TerraformEvent | A Terraform command started or finished, each process started, printed something, or exited, and the folders were written. |
HookEvent | A hook callback ran. |
ModuleEvent | A module's onInit() ran, carrying the module's ID and the inputs it ran with. |
ActionEvent | An 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.