ADR-0007: Incremental services layer with typed models¶
Status: Accepted (migration complete 2026-04-28) Date: 2026-04-20 Supersedes: ADR-0004
Context¶
ADR-0004 deferred the services layer because the cost of refactoring all 63 command modules at once was disproportionate to the immediate gain. Two months later the friction it predicted has materialised:
- 740-odd raw
client.get/post/...calls scattered across commands made the recent N+1 hunt (cleanup, watch, export) harder than it should have been: each fix had to be re-derived from the URL string. - The
dict[str, Any]access pattern (2 136.get()calls) meanssrv.get("nmae", "")typos slip through to runtime; mypy can't help because everything isAny. - Tests mock at the
OrcaClientboundary; they assert on the URL string passed toclient.get. Renaming an endpoint or changing pagination scheme breaks the assertions in cosmetic ways.
The original concern — multi-day refactor with high regression risk — remains valid only if we attempt the migration in one shot.
Decision¶
Introduce two new layers, incrementally, one resource at a time:
orca_cli/models/<resource>.py—TypedDicts describing the subset of each Nova/Cinder/Neutron/etc. payload that orca actually reads.total=Falseeverywhere — fields are added as commands need them.orca_cli/services/<resource>.py— a class wrappingOrcaClientthat exposes typed methods (list,get,delete,reboot, ...) and owns the URL construction for that resource.
Migration order is driven by pain, not by alphabetical resource name.
The first migration is server (66 raw HTTP calls, 2235 LOC, the most
exercised module). Each migration ships as one commit per command being
refactored, never a "rewrite the whole module" PR. Commands that haven't
been migrated keep calling OrcaClient directly — there is no flag day.
The criteria for not introducing a service for a given resource:
- Single-call wrapper (
get_token,get_catalog) where the service would just be a one-liner over the client. Stay direct. - Resource that orca only reads, never writes, with one or two calls total. The service overhead exceeds the gain.
Consequences¶
- Positive: typed return values mean autocomplete, mypy catches field-name typos, and refactors propagate to compile-time errors.
- Positive: URL construction lives in one place per resource; an
OpenStack version bump touches
services/<resource>.py, not 30 command handlers. - Positive: tests can assert on
service.list.return_value = [<typed Server>]rather than mockingclient.getURL-strings. Less brittle, more readable. - Negative / trade-off: the codebase has two patterns for several
weeks (or months) until every resource is migrated. The boundary is
visible — a command either imports
services.Xor callsclient.getdirectly. We accept the ugliness; a flag-day rewrite was the alternative we already rejected. - Negative / trade-off:
TypedDictdoesn't enforce field presence at runtime —srv["status"]still raisesKeyErrorif missing. The contract is "if this field exists, then it has this type". For presence guarantees we'd needdataclass+ factory functions; that conversion can come later if the runtime safety pays off.
Migration tracking¶
When migrating a resource, leave a one-line entry below so future ADR readers can see how far along the work is.
- 2026-04-20 —
server: ServerService + Server TypedDict introduced. All 59 server subcommands now go through the service for compute API calls. Fiveclient.*calls remain incommands/server.py: four cross-service (Glance image lookup, Cinder volume lookup, Cinder snapshot create, used byssh user-detection/clone/snapshot) to migrate when ImageService and VolumeService land, plus one bulkPUT /servers/{id}/tagscall (the service exposes per-tag add/delete operations only). - 2026-04-20 —
volume: VolumeService + Volume / VolumeSnapshot / VolumeBackup / VolumeAttachment TypedDicts. All 71 subcommands now go through the service. Remainingclient.volume_urlreferences in commands/volume.py are URLs passed towait_for_resource— not HTTP calls. Service covers volumes, snapshots, backups, attachments, types (+ access + extra-specs), QoS, transfers, messages, groups, group snapshots, group types, services. - 2026-04-22 —
image: ImageService + Image / ImageMember / ImageTask / ImageStore TypedDicts. All 24orca imagesubcommands go through the service (CRUD, upload/stage/download streaming, deactivate/ reactivate, tags, members/sharing, import API, cache admin, multi- backend stores info, async tasks). Cross-service Glance lookups incommands/server.py(SSH user detection),commands/overview.py,commands/export.py,commands/project.py(purge + delete) andcommands/find.py(universal search) now route through ImageService too. Onlycommands/doctor.pykeeps a rawclient.image_urlreference — it is an intentional health-probe URL, not a business call. - 2026-04-22 — Residual cleanups on migrated resources: added
ServerService.set_tags/delete_all_tagsto cover the bulkPUT /servers/{id}/tagsreplacement (previously the only remaining directclient.putincommands/server.py), and routed thevolume treeserver-name lookup throughServerService.findinstead of a direct/servers/detailcall. - 2026-04-22 — IdentityService extension: Keystone enforcement
limits. Added
find_registered_limits/get_registered_limit/create_registered_limits(batched POST) /update_registered_limit/delete_registered_limitand their project-scoped twinsfind_limits/get_limit/create_limits/update_limit/delete_limit. TypedDicts: RegisteredLimit and Limit. Migratedcommands/limit.py— the last_iam()helper in the Keystone set is gone. - 2026-04-22 — Final cross-service cleanup: routed every remaining
direct
client.get/post/put/deletecall that targeted a*_urlwith an existing service wrapper through that service. Touchedcommands/watch.py,audit.py,ip_whois.py,cleanup.py(dropped the dead_collecthelper),project.py(same),overview.py,export.py,find.py(dropped the dead_safe_listhelper),network.py(topology + trace server lookups). Only documented exceptions keep directclient.*: health probes indoctor.py, Swift binary streaming + account metadata POST inobject_store.py, Designate text/dns import/ export inzone.py, Barbican text/plain payload GET insecret.py, Placement single-inventory GET + bulk DELETE-all inplacement.py, and the auth flow inauth.py. - 2026-04-22 —
quota(cross-service): no dedicated service; theorca quotacommand routes each slice through its owning service —ComputeService.get_limits(Nova), a newVolumeService.get_limits(Cinder absolute limits), and newNetworkService.find_quotas/get_quota(Neutron), with the Neutron usage counts going through the existingfind_*methods. - 2026-04-22 —
placement: PlacementService + ResourceProvider / Inventory / ProviderUsages / ResourceClass / Trait / Allocation / AllocationCandidate TypedDicts. Resource providers (CRUD), inventories, usages (per-provider + per-project), resource classes, traits (global + per-provider), allocations per consumer, allocation candidates, provider aggregates. Service owns theOpenStack-API-Version: placement 1.6header. Migratedcommands/placement.py— the_url()and_ph()helpers are gone. Single-inventory GET + bulk DELETE-all inventories keep directclient.*calls (no service methods for them yet). - 2026-04-22 —
container-infra(Magnum): ContainerInfraService + Cluster / ClusterTemplate / NodeGroup TypedDicts. Clusters (CRUD + JSON Patch update withapplication/json-patch+json+ upgrade action), cluster templates (CRUD), node groups per-cluster (CRUD + JSON Patch update). Migratedcommands/cluster.py— the_magnum()helper is gone. - 2026-04-22 —
telemetry(Gnocchi metric + Aodh alarm + Nova instance-actions): MetricService, AlarmService, and newfind_instance_actions/get_instance_actionmethods on ServerService. Shared TypedDicts inmodels/telemetry.py(GnocchiResource / GnocchiMetric / ArchivePolicy / GnocchiResourceType / Alarm / AlarmHistoryEntry). Migratedcommands/metric.py(drops_gnocchi()),commands/alarm.py(drops_url(), keeps fetch-merge-put onalarm set),commands/event.py(drops directclient.compute_url, routes through ServerService). - 2026-04-22 —
rating(CloudKitty): RatingService + RatingModule / HashmapService / HashmapField / HashmapMapping / HashmapThreshold / HashmapGroup / RatingSummary TypedDicts. Info (config + metrics), summary + dataframes (v1/v2), quotes, rating modules (fetch-merge- put onmodule set), hashmap sub-API (services/fields/mappings/thresholds/groups). Migratedcommands/rating.py— the_url()helper and the duplicated hashmap prefix constant are gone. - 2026-04-22 —
key-manager(Barbican): KeyManagerService + Secret / SecretContainer / Order / Acl TypedDicts. Secrets, secret ACLs, secret containers, orders. Migratedcommands/secret.py(drops_barbican()helper, consolidatessecret_refUUID extractor) and the secret branch inproject.py. The raw text/plainsecret get-payloadkeeps itsclient._httpcall — the service exposes JSON-bodied methods only. - 2026-04-22 —
dns(Designate): DnsService + Zone / Recordset / ZoneTransferRequest / Tld TypedDicts. Zones CRUD, recordsets CRUD (per-zone + cross-zone), export/import tasks, transfer requests + accepts, reverse-PTR floating-ip lookup, TLDs. Migratedcommands/zone.py,commands/recordset.py(both drop local_dns()helpers), plus the dns-zone branches inproject.pycleanup +_delete_one. Zone export/import that streams raw text/dns bodies still usesclient._httpdirectly. - 2026-04-22 —
object-store(Swift): ObjectStoreService + Container / ObjectEntry TypedDicts. Account/container/object CRUD, HEAD metadata reads, POST metadata writes (with full-name header keys), and anobject_url(container, name)helper for streaming upload/download. Migratedcommands/object_store.pyandcommands/container.py(dropped the local_head/_post_no_body/_swifthelpers), plus the Swift container branch ofcommands/project.pycleanup. Binary up/download still usesclient._httpdirectly (streaming), which is intentional — the service provides the URL only. - 2026-04-22 —
load-balancer(Octavia): LoadBalancerService + LoadBalancer / Listener / Pool / Member / HealthMonitor / L7Policy / L7Rule / Amphora TypedDicts. Migratedcommands/loadbalancer.py(lb CRUD + stats + status, listeners, pools + members, health monitors, L7 policies + rules, admin amphorae + failover) and the cross-service Octavia callers incommands/cleanup.py,commands/project.pyandcommands/ip_whois.py. - 2026-04-22 —
orchestration(Heat): OrchestrationService + Stack / StackResource / StackEvent / StackOutput TypedDicts. Migratedcommands/stack.py(stack CRUD + actions + abandon; resources, events, outputs, templates, validate, resource types) and the Heat callers incommands/cleanup.py(failed-stack discovery + delete) andcommands/project.py(project cleanup + _delete_one stack branch). - 2026-04-22 —
identity(Keystone v3): IdentityService + Project / User / Role (+ RoleAssignment / RoleInference) / Domain / Group / Credential / ApplicationCredential / Endpoint / EndpointGroup / Service / Region / Policy / IdentityProvider / FederationProtocol / Mapping / ServiceProvider / Trust / AccessRule TypedDicts. Migrated 16 command modules: project, user, role (+ grants + assignments + implied roles), domain, group (+ membership), credential, application_credential, endpoint, endpoint_group (+ project attachments), service, region, policy, federation (identity-provider/protocol/mapping/service-provider, PUT upsert), trust (immutable; no update method), token (revoke only;issuestays on cached token state), access_rule (read-only per-user). The service preserves the historical split between callers that prepend/v3toclient.identity_urland callers that use it directly (credentials, endpoints, services, regions, OS-TRUST). Cross-service Keystone lookups incommands/project.py cleanup(project-by-name / project-by-id resolution) route through IdentityService too.commands/catalog.pystill readsclient._catalogfrom the auth token state — no HTTP call. - 2026-04-22 —
compute(Nova, non-server): ComputeService + Flavor (+ FlavorAccess) / Keypair / Aggregate / Hypervisor (+ statistics) / AvailabilityZone / ComputeService / ServerGroup / TenantUsage / AbsoluteLimits TypedDicts. Eight command modules migrated:commands/flavor.py(incl. extra-specs + tenant-access actions),commands/keypair.py,commands/aggregate.py(incl. add/remove host, set/unset metadata, cache-image),commands/hypervisor.py(list/show/statistics/usage ranking),commands/availability_zone.py,commands/compute_service.py,commands/server_group.py,commands/usage.py,commands/limits.py(absolute),commands/quota.py(Nova portion). Cross-serviceos-keypairscallers inoverview.py,export.py,find.pyroute through ComputeService. ServerService stays the owner of/servers. - 2026-04-22 —
network: NetworkService + Network / Subnet / Port / Router / FloatingIp / SecurityGroup (+ SecurityGroupRule) / SubnetPool / Trunk (+ TrunkSubPort) / QosPolicy (+ QosRule) / Agent / RbacPolicy / Segment / AutoAllocatedTopology TypedDicts. All six Neutron command modules (commands/network.pywith 50+ subcommands — networks, subnets, ports, routers with add/remove/set/unset subgroups, agents, RBAC, segments, auto-allocated-topology, plus topology/trace diagnostics;commands/floating_ip.py,commands/security_group.py,commands/subnet_pool.py,commands/trunk.py,commands/qos_policy.py) now go through the service. Cross-service Neutron fetches incommands/overview.py,commands/export.py,commands/cleanup.py,commands/find.pyandcommands/project.py(incl. the router interface detach loop before delete) route through NetworkService too. Only non-Neutron callers (Nova os-keypairs, Cinder, Heat, Octavia, Swift, Designate, Barbican) keep directclient.*calls until their respective services land. - 2026-04-28 — Final migration sweep, ADR-0007 reaches 100 %:
auth.token-revokerouted through the existingIdentityService.revoke_token.- PlacementService gains
get_inventory(single resource_class GET) anddelete_all_inventories(bulk DELETE); the two remaining holdouts incommands/placement.pyare gone. - Streaming I/O is now expressed in services.
OrcaClientexposesput_stream(rewritten — explicitcontent_length, no retry because streamed bodies cannot be replayed),post_stream,post_no_body,head_request, andget_streamwithextra_headers. ObjectStoreService gainsupload_object/download_object/fetch_object_bytes/post_account_metadata. ImageService.upload/stage now take acontentiterable and an optionalcontent_length. DnsService gainsfetch_export_text/import_zone_textfor the Designate text/dns endpoints. KeyManagerService gainsget_secret_payloadfor the Barbican text/plain endpoint. - The auth-state attributes commands previously read off the client
(
_token,_token_data,_catalog,_auth_url,_region_name,_interface,_project_id) are now public properties onOrcaClient._authenticate()is exposed asauthenticate(). orca doctorreachability probes and quota checks route through Compute / Volume / Network / Image services.NetworkService.get_quota_detailscovers the/quotas/{id}/detailsendpoint that was missing.- BackupService + models/backup.py introduced: 24 Freezer
operations (backups, jobs, sessions, clients, actions) finally
have a service.
commands/backup.pyno longer issues raw HTTP. - A ratchet test
(
tests/test_no_private_client_api_in_commands.py) walks every command module and fails if a future change reintroducesclient._<anything>. - Result: zero
client.get/post/put/patch/deleteand zeroclient._*accesses remain inorca_cli/commands/. Every command module is service-only.