Skip to content

Adding a New Service

The current tree ships s3, emr-serverless, glue, and athena; Glue and Athena are linked by immutable table-navigation messages, and EMR includes the read-only browser, job-run logs, and clone-job-run modal. This doc is the pattern for the next ones (EC2, IAM, Lambda, ...). For richer references than S3 (dedicated domain client + per-service VM subtree + per-service UI widget tree + service-specific modal), read src/aws_tui/services/emr_serverless/, src/aws_tui/services/glue/, and src/aws_tui/services/athena/ alongside s3/.

aws-tui's service-plugin spine keeps service construction additive: a new folder under src/aws_tui/services/<name>/ and one registration call in composition.py. Today the app shell still owns the view factory, so non-DualPane services also need an explicit route in AwsTuiApp._mount_initial_service_view / _mount_service_view until the planned service-owned view-factory contract lands.

1. The Service protocol

Declared in src/aws_tui/vm/services_protocol.py, re-exported from src/aws_tui/services/__init__.py:

from typing import Any, Protocol
from aws_tui.infra.connection_resolver import Connection

class Service(Protocol):
    descriptor: ServiceDescriptor  # id, label, icon

    def supports(self, connection: Connection) -> bool:
        """True if this service can run against the given connection."""

    def build_vm(self, connection: Connection) -> Any:
        """Construct the service's content VM tree for this connection."""

The descriptor is a ClassVar so the registry can introspect it without instantiating. build_vm is structurally typed as -> Any on the protocol so the vm/ layer never has to import vmx.ComponentVM just to spell the bound; concrete services return whatever VMx VM they actually host (S3Service.build_vm returns DualPaneVM, see the §2 template below). ContentHostVM only needs a construct → destruct → dispose surface.

2. Steps

  1. Create the folder.

    src/aws_tui/services/<name>/
        __init__.py
        service.py    # implements the Service protocol
    
  2. Implement Service in service.py.

    from typing import ClassVar
    from aws_tui.vm.services_protocol import ServiceDescriptor
    from aws_tui.infra.connection_resolver import Connection
    from aws_tui.infra.aws_session import AwsSession
    
    class EC2Service:
        descriptor: ClassVar[ServiceDescriptor] = ServiceDescriptor(
            id="ec2",
            label="EC2",
            icon="EC2",
        )
    
        def __init__(self, *, aws_session: AwsSession, ...) -> None:
            self._aws_session = aws_session
    
        def supports(self, connection: Connection) -> bool:
            return connection.kind == "aws"  # EC2 isn't S3-compat
    
        def build_vm(self, connection: Connection) -> ComponentVM:
            return InstancesPaneVM(self._aws_session, connection)
    

    The service rail renders descriptor.label for service rows. The icon field remains reserved descriptor metadata; Settings is the current exception and renders its gear icon to keep the rail narrow. Do not choose an emoji or assume terminal cell width as part of a service's navigation contract.

  3. Register in src/aws_tui/composition.py (near the existing s3_service = S3Service(...) block):

    ec2_service = EC2Service(aws_session=aws_session, ...)
    registry.register(cast("Service", ec2_service))
    
  4. Add app-shell view routing if the service does not return a DualPaneVM. build_service_view(...) currently maps emr-serverless to EmrServerlessPage, glue to GluePage, athena to AthenaPage, and s3 to DualPane. Add the matching widget factory branch and pass it from both app mount paths.

  5. Reuse existing VM families where possible:

    • For storage-like services (lists with hierarchy): the file- manager VMs in vm/file_manager/ work as-is — write a new FileSystemProvider (see src/aws_tui/domain/filesystem.py for the protocol, and §3 / §4 of the design spec for the architectural shape) and reuse PaneVM + DualPaneVM.
    • For flat resource lists (EC2 instances, IAM users): write a new ListPaneVM under vm/<service>/ and a corresponding widget family under ui/widgets/<service>/.
    • Reuse ServiceSourceHeader and ContextPicker for shared source and context controls, and ServiceTabStrip for a one-stop segmented view selector. ContextPicker and specialized wrappers such as EMR's ApplicationPicker must compose OverlayOptionList, retain their compact trigger footprint while open, and leave parent and sibling regions unchanged. Keep that transient overlay geometry in the Textual view. Extend FocusCoordinatorVM instead of creating another focus authority. Add an enclosing context frame only when multiple dependent controls form one coherent region; a standalone source control does not require a second frame.
  6. Layer rules. Services live one layer above domain, so they may import from domain/, infra/, and the public VM surface (vm/services_protocol.py, vm/messages.py, and the file-manager VMs in vm/file_manager/ for storage-like services that reuse PaneVM/DualPaneVM). The only hard ban is ui/ (no Textual widget imports) and Textual itself — enforced by scripts/check-layers.sh. See §3 below for the full cheat-sheet.

  7. Tests. Add unit tests under tests/unit/services/<name>/ and, if your service touches AWS, integration tests under tests/integration/test_<name>_*.py. Use a modeled fake for deterministic behavior and a vendor container only where an independent implementation materially strengthens the contract.

  8. Update docs. Add any vendor / API quirks to docs/connections.md. Update the README's features list.

  9. Commands, palette, and cross-service state. Declare service-specific action IDs in HintLegendVM, including compact labels, complete shortcut / effect / execution / prerequisite tooltip metadata, and deterministic fitting priorities. The Commands pane remains one content row; reserve the protected [:] more command-palette hint and [q] quit at narrow widths, while hidden actions remain bound and palette-visible. Publish immutable typed requests for app-level cross-service state; the composition root owns delivery and Textual integration. Buttons, keys, and palette entries must dispatch the same registered action rather than calling a VM through a separate path.

For cross-service links, publish an immutable VM message carrying plain identifiers and source identity. The app composition root resolves the destination connection and owns RootVM switching plus Textual mounting. OpenS3LocationRequest carries connection, region, URI, pane, and reveal intent. OpenAthenaTableRequest and OpenGlueTableRequest carry a shared TableRef; the Athena request may add a non-negative snapshot ID. app.py rejects missing connections or region mismatches, serializes table handoffs, and reuses registered service factories. Do not pass clients, VMs, widgets, raw SDK responses, or credentials in cross-service messages. A navigation handoff may prefill destination state, but it must document whether it executes or mutates anything; Glue's Shift+Q and Shift+V actions prefill bounded Athena SQL and do not execute it.

3. Layer rules cheat-sheet for services

A service module may import from:

  • aws_tui.infra.* (aws_session, config_store, log_sink, …)
  • aws_tui.domain.* (filesystem, journal, …)
  • aws_tui.vm.services_protocol (Service, ServiceDescriptor, ServiceRegistry) — re-exported as aws_tui.services.*
  • aws_tui.vm.messages (for pushing on the hub)
  • aws_tui.vm.file_manager.* (the public VMs — PaneVM, DualPaneVM, etc. — for storage-like services that reuse the file-manager scaffolding; see services/s3/service.py for the pattern that composes DualPaneVM(left=PaneVM(S3FS), right=PaneVM(LocalFS))).
  • vmx.*

A service module may not import from:

  • aws_tui.ui.* (no Textual widget code)
  • textual.* directly

These bans are enforced by scripts/check-layers.sh.

4. Future: entry-point discovery

v1.1 promotes the registry to importlib.metadata.entry_points(group="aws_tui.services") so third- party packages can ship services without forking. The same Service protocol applies.

5. Reference: the shipped services

5.1. S3

src/aws_tui/services/s3/service.py is the first concrete service. Read its S3Service implementation end to end:

  • descriptor declares id = "s3", label "S3", and retained icon metadata. The service rail renders the label, not the icon.
  • supports() accepts both aws and s3-compatible connections.
  • build_vm(connection) composes DualPaneVM(left=PaneVM(S3FS), right=PaneVM(LocalFS)) each call.
  • An optional s3_fs_factory test hook lets unit tests swap S3FS for InMemoryFS so no AWS calls leak in CI.
  • bind_hub(hub) late-wires the hub since the service is registered before RootVM has its hub.

5.2. EMR Serverless

src/aws_tui/services/emr_serverless/service.py is the second shipped service and demonstrates the richer per-service pattern:

  • descriptor declares id = "emr-serverless", label "EMR", and retained fire-icon metadata. The service rail renders "EMR"; the icon does not define service-row width or alignment.
  • supports() is AWS-only (connection.kind == "aws").
  • Domain client lives at domain/emr_serverless.py (async EmrServerlessClient facade over aioboto3, with read-only verbs list_applications / list_job_runs / get_job_run plus the write-side start_job_run (added PR #83 for the clone flow), dedicated _map_boto_error adapter).
  • VM subtree at vm/emr_serverless/ (EmrServerlessPageVM orchestrates ApplicationsVM + JobRunsVM + JobRunDetailVM + JobRunLogsVM; JobRunCloneVM sits alongside, instantiated per modal-mount).
  • UI widget tree at ui/widgets/emr_serverless/ (ApplicationPicker + JobRunsPane + JobRunDetailPane + JobRunLogsPane + EmrServerlessPage composer + JobRunCloneModal + LogFilterModal). ApplicationPicker uses the shared screen-overlaid option list, so opening or closing it leaves the runs, detail, and logs regions unchanged.
  • Three independent production set_interval pollers (apps 60 s / runs 60 s with 6:1 cadence decay while no run is active / detail 30 s with terminal-state suppression). Demo mode uses shorter 30 s / 30 s / 5 s cadences so sample data feels live.
  • Service-specific modal pattern. JobRunCloneModal is pushed via app.push_screen. The app.py::action_copy priority binding short-circuits to the EMR clone path when EMR is mounted (parallel to the dual-pane hijack pattern for Tab / arrow keys). Service-specific keymap + chip wiring: KeymapStore.DEFAULT_BINDINGS adds the action id ("emr.clone": ("c",)); HintLegendVM._SERVICE_ACTIONS["emr-serverless"] lists the action id; _ACTION_LABELS["emr.clone"] = "clone" gives it a human label in the Commands strip. Future services with their own actions follow the same three-touch-point pattern: default binding + service-actions tuple + action label.

5.3. AWS Glue

src/aws_tui/services/glue/service.py is the third shipped service and the compact read-only page reference:

  • descriptor declares id = "glue" and supports() accepts only AWS connections.
  • Every build_vm(connection) returns a fresh GluePageVM; the long-lived service retains only ServiceSelectionStore, keyed by service id, connection name, and region.
  • domain/glue.py owns boto response mapping and pagination for databases/tables/partitions/statistics, jobs/runs, and crawlers. Raw boto responses never enter the VM or view layers.
  • vm/glue/ separates Catalog, Jobs, and Crawlers behavior; the Textual tree lives under ui/widgets/glue/. GlueCatalogVM additionally owns GlueIcebergVM; it binds only when TableDetail.table_format is ICEBERG.
  • glue_client_factory injects profile-keyed in-memory clients for tests and demo mode without changing production composition.
  • _ContextualIcebergInspector revalidates a remembered enabled Athena workgroup in the same connection and region, then constructs IcebergInspector with a matching QueryContext. Each metadata tab is loaded independently, so one denied or malformed metadata table does not erase successful siblings.
  • The selected table's S3 handoff is an immutable message, not a service-to-service import. Its app subscriber resolves the exact connection name, verifies the region, rebuilds S3 through RootVM, navigates the requested pane, and focuses it.
  • Shift+Q opens a visible selected table in Athena with exact quoted SELECT * ... LIMIT 5 SQL. Shift+V, the corresponding palette entry, and the Iceberg arrow button open a visible selected snapshot with FOR VERSION AS OF <snapshot-id>. Both actions use the same registered action and typed request path, preserve exact source identity, and never execute the generated query.

5.4. Amazon Athena

AthenaService in src/aws_tui/services/athena/service.py is the query-service reference:

  • descriptor declares id = "athena"; supports() accepts only AWS connections. A build_vm(connection) call makes a fresh AthenaPageVM, while ServiceSelectionStore retains only connection- and region-scoped UI selections.
  • domain/athena.py performs one paginated AWS request at a time and maps workgroups, catalogs, databases, tables, query history/detail, runtime statistics, result pages, named queries, and prepared statements. It owns the start_query_execution / stop_query_execution boundary; views and VMs never receive raw boto responses.
  • domain/sql_policy.py parses one Athena-dialect statement before dispatch. It accepts SELECT roots and set operations (including VALUES operands), the implemented bounded SHOW and DESCRIBE forms, and non-ANALYZE EXPLAIN of an allowed statement. Standalone VALUES is rejected. The parser is defense in depth, not an authorization system.
  • vm/athena/ separates Query, History, Results, and Saved behavior; the Textual widget tree lives in ui/widgets/athena/. A result-to-S3 handoff is an OpenS3LocationRequest carrying the execution's exact connection and region, not an Athena-to-S3 import.
  • AthenaPageVM.open_table(...) discovers the exact catalog/database in bounded pages, updates context, and inserts a quoted SELECT * ... LIMIT 5 statement. A snapshot request adds FOR VERSION AS OF <id>. It never executes the generated SQL. open_table_in_glue() publishes OpenGlueTableRequest only when the current read-only SQL exposes one unambiguous table reference.