Interactions — skyline-apiserver — branch stable/2026.2
Branch
stable/2026.2@4af72bfd4577— repository https://opendev.org/openstack/skyline-apiserver — analysed on 2026-10-28
skyline-apiserver has no message bus and no cache server. It talks to Keystone (a lot), to Nova, Cinder, Glance, Neutron and Prometheus over HTTP, and to its own small SQL database. The sequences below were recorded by running the real application against a fake OpenStack (_rewrite/tools/outbound_capture.py, results in _rewrite/outbound-calls_stable-2026.2.md): they are exact in order, method, path, query and headers; response bodies of real services are [inferred] from the client libraries.
1. Keystone sessions and tokens
Three kinds of credentials are used. Which one a call uses matters for the rewrite: it decides whose permissions apply.
| Session | How it is built | Used for | Defined at |
|---|---|---|---|
| System session | password of openstack.system_user_name (default skyline) in system_user_domain, scoped to project system_project (default service) in system_project_domain, against openstack.keystone_url; one per worker process, cached in a global and re-authenticated by keystoneauth when the token nears expiry (reauthenticate=True); each request wraps it in a new Session carrying the client address | catalog reading (every identity endpoint lookup), validation of user tokens, users and projects lookups, domains, regions, Neutron extensions, recycle bin, compute services, Glance images, Cinder lookups by ID | skyline_apiserver/client/utils.py:58 |
| User session | the user's project-scoped token, re-scoped to profile.project.id with the token method; get_auth_ref forces a POST /v3/auth/tokens every time it is built | the user's own lists (servers, volumes, snapshots, ports, networks), project names with all_projects, logout, policy credentials | skyline_apiserver/client/utils.py:36 |
| Unscoped / login sessions | password, TOTP + receipt, or token (WebSSO, re-scope) | login steps | skyline_apiserver/api/v1/login.py:247, skyline_apiserver/api/v1/login.py:202, skyline_apiserver/client/openstack/system.py:30 |
Common to all sessions: TLS CA = default.cafile (empty means the system store), timeout 30 s (skyline_apiserver/types/constants.py:43), no retry configured in Skyline (keystoneauth defaults: no retry on HTTP errors, connection retries 0) [inferred], endpoint interface openstack.interface_type (default public, skyline_apiserver/config/openstack.py:83).
Endpoint discovery. Every service endpoint is taken from the system session's catalog filtered by region and interface (skyline_apiserver/client/utils.py:111), even when the call is then made with the user session. An empty answer raises ValueError("Endpoint not found"). Note that keystone_url is only used for the system password authentication; all other identity calls go to the identity endpoint of the catalog for the session's region.
Version discovery. Each keystoneclient.Client(...) creation runs a GET <identity endpoint> (version document) before the real call (skyline_apiserver/client/utils.py:131); openstacksdk runs a GET <image endpoint> without token before listing images (skyline_apiserver/client/utils.py:147). Nova, Cinder and Neutron clients are given endpoint_override and do no discovery.
Request headers (recorded):
| Header | Value | Sent to |
|---|---|---|
X-Auth-Token | token of the session (system, user or login) | all except unauthenticated discovery and password/TOTP authentication |
X-Subject-Token | the user token being validated | GET /v3/auth/tokens |
X-OpenStack-Request-ID | the inbound X-Openstack-Request-Id, empty string when the client sent none | Nova, Cinder, Glance, Neutron (via the clients' global_request_id) |
X-OpenStack-Nova-API-Version + OpenStack-API-Version | 2.79 / compute 2.79 | Nova (skyline_apiserver/types/constants.py:20) |
OpenStack-API-Version | volume 3.59 | Cinder (skyline_apiserver/types/constants.py:21) |
Openstack-Auth-Receipt | receipt from the first login step | Keystone, TOTP step |
Forwarded | for=<client address>;by=<client library> (keystoneauth original_ip) | all OpenStack calls; the address is secure_proxy_addr_header if configured, else the socket peer (skyline_apiserver/api/deps.py:28) |
The user-facing Keystone requests carry no OpenStack-API-Version; KEYSTONE_API_VERSION = "3.13" (skyline_apiserver/types/constants.py:19) only selects the client class.
Sample frames (recorded)
System password authentication (first call of a worker):
POST /identity/v3/auth/tokens HTTP/1.1
Content-Type: application/json
Forwarded: for=10.0.0.7;by=keystoneauth1/5.17.0 python-requests/2.34.2 CPython/3.13.0
{"auth": {"identity": {"methods": ["password"], "password": {"user": {"name": "skyline", "domain": {"name": "Default"}, "password": "***"}}},
"scope": {"project": {"name": "service", "domain": {"name": "Default"}}}}}
Validation of the user's token on every authenticated request (skyline_apiserver/core/security.py:66):
GET /identity/v3/auth/tokens HTTP/1.1
X-Auth-Token: <system token>
X-Subject-Token: <user token from the JWT or X-Auth-Token>
Re-scoping (user session, switch_project, login):
POST /identity/v3/auth/tokens HTTP/1.1
Content-Type: application/json
{"auth": {"identity": {"methods": ["token"], "token": {"id": "<user token>"}}, "scope": {"project": {"id": "<project id>"}}}}
TOTP second step (skyline_apiserver/api/v1/login.py:215):
POST /identity/v3/auth/tokens HTTP/1.1
Openstack-Auth-Receipt: <receipt>
{"auth": {"identity": {"methods": ["totp"], "totp": {"user": {"name": "mfa", "domain": {"name": "Default"}, "passcode": "123456"}}}}}
The first step answered 401 with header Openstack-Auth-Receipt and body {"receipt": {...}, "required_auth_methods": [["password", "totp"]]}; keystoneauth raises MissingAuthMethods from it (skyline_apiserver/api/v1/login.py:287).
2. Outbound REST per endpoint
Paths are relative to the catalog endpoint of each service. Session: S = system, U = user (re-scoped), L = login (unscoped or being scoped). Every authenticated endpoint starts with the middleware's validation pair GET /identity/v3 (S) + GET /v3/auth/tokens (S), not repeated below. Counts are the totals of the recorded scenarios, validation pair included; each keystoneclient call is preceded by a GET /identity/v3 discovery (not always repeated below).
| Inbound endpoint | Outbound calls in order (session) | Count | Defined at |
|---|---|---|---|
POST /login | POST /v3/auth/tokens password, unscoped (L) → GET /identity/v3 (L) → GET /v3/auth/projects (L) → validation pair (S) → GET /identity/v3, GET /v3/users/{uid} (S) → POST /v3/auth/tokens token→project (L) → validation pair (S) → POST /v3/auth/tokens token→project (U, region list) → Neutron GET /v2.0/extensions (S) → GET /identity/v3, GET /v3/users/{uid}/projects (S) → GET /identity/v3, GET /v3/users/{uid} (S) | 16 | skyline_apiserver/api/v1/login.py:395 |
POST /login/totp | POST /v3/auth/tokens TOTP + receipt (L), then as /login from the second call | 17 | skyline_apiserver/api/v1/login.py:448 |
POST /websso | POST /v3/auth/tokens token method, no scope, with the posted token (L) → GET /identity/v3, GET /v3/auth/projects (L) and the rest as /login | 16 | skyline_apiserver/api/v1/login.py:577 |
GET /profile | POST /v3/auth/tokens (U, region list) → Neutron GET /v2.0/extensions (S) → GET /v3/users/{uid}/projects (S) → GET /v3/users/{uid} (S) (+ discovery each) | 8 | skyline_apiserver/api/v1/login.py:638 |
POST /logout | validation pair again (S, re-parse of the JWT) → POST /v3/auth/tokens (U) → GET /identity/v3 (U) → DELETE /v3/auth/tokens with X-Subject-Token = user token (U) | 7 | skyline_apiserver/api/v1/login.py:664 |
POST /switch_project/{id} | POST /v3/auth/tokens token→{id} (L) → validation pair (S) → /profile enrichment | 11 | skyline_apiserver/api/v1/login.py:699 |
POST /switch_region/{r} | POST /v3/auth/tokens (U, region list) → validation through the identity endpoint of {r} (S) → /profile enrichment | 11 | skyline_apiserver/api/v1/login.py:756 |
GET /extension/servers | POST /v3/auth/tokens (U) → [all_projects + project_name: GET /v3/projects?name= (S)] → Nova GET /servers/detail?… (U) → Glance GET / (none) + GET /v2/images?id=in:<≤100 ids> (S) per batch → Cinder GET /volumes/detail?all_tenants=True&id=[<≤100 ids>] (S) per batch → [all_projects: GET /v3/projects (U)] | 7–10 | skyline_apiserver/api/v1/extension.py:62 |
GET /extension/recycle_servers | POST /v3/auth/tokens (U, unused) → Nova GET /servers/detail?all_tenants=True&deleted=True&project_id=<current>&status=soft_deleted (S) → Glance and Cinder as servers (S) → [all_projects: GET /v3/projects (U)] | 4+ | skyline_apiserver/api/v1/extension.py:284 |
GET /extension/volumes | POST /v3/auth/tokens (U) → Cinder GET /volumes/detail?with_count=True&sort=… (U; S if uuid or reader with all_projects) → per attached server: Nova GET /servers/detail?uuid=<id> and GET /servers/detail?uuid=<id>&status=soft_deleted&deleted=True (U) | 4 + 2·servers | skyline_apiserver/api/v1/extension.py:501 |
GET /extension/volume_snapshots | POST /v3/auth/tokens (U) → [uuid, own project: Cinder GET /snapshots/{uuid} (U)] → Cinder GET /snapshots/detail?with_count=True… (U, S with uuid) → [all_projects: GET /v3/projects (U)] → Cinder GET /volumes/detail?all_tenants=True&id=[…] (S) per batch → Cinder GET /volumes/detail?all_tenants=True&snapshot_id=[…] (S) per batch | 5–8 | skyline_apiserver/api/v1/extension.py:677 |
GET /extension/ports | POST /v3/auth/tokens (U) → [network_name: Neutron GET /v2.0/networks?name= (U)] → Neutron GET /v2.0/ports?… (U) → GET /v2.0/networks?shared=True (U) → GET /v2.0/networks?project_id=…&id=… per batch of 100 (U) → per server port: Nova GET /servers/detail?uuid=<id> (U) | 5 + servers | skyline_apiserver/api/v1/extension.py:867 |
GET /extension/compute-services | Nova GET /os-services?binary=&host= (S) | 1 | skyline_apiserver/api/v1/extension.py:1055 |
GET /contrib/keystone_endpoints | [first call of the worker: system password auth] → per region: Neutron GET /v2.0/extensions (S) | regions | skyline_apiserver/api/v1/contrib.py:44 |
GET /contrib/domains | GET /identity/v3 + GET /v3/domains?enabled=True (S) | 2 | skyline_apiserver/client/openstack/system.py:109 |
GET /contrib/regions | none beyond the system authentication (catalog of the system token) | 0–1 | skyline_apiserver/client/openstack/system.py:123 |
GET /policies, POST /policies/check | POST /v3/auth/tokens token→project (U) → POST /v3/auth/tokens token→{"system": {"all": true}} (U) | 2 | skyline_apiserver/client/utils.py:88 |
GET /query, GET /query_range | Prometheus GET <prometheus_endpoint>/api/v1/query[_range]?…, optional basic auth, TLS not verified, httpx default timeout (5 s) [inferred], new connection per call | 1 | skyline_apiserver/utils/httpclient.py:24 |
GET /config, GET /sso, settings | none | 0 | — |
Calls by target service
| Target | Method + path | Query / body | Session | Timeout / retry | Errors handled | Defined at |
|---|---|---|---|---|---|---|
| Keystone | POST /v3/auth/tokens | password, TOTP + receipt, token; scope project (ID or name), system or none | L, U, S | 30 s / none | MissingAuthMethods, Unauthorized, other → 401 | skyline_apiserver/api/v1/login.py:277 |
| Keystone | GET /v3/auth/tokens | X-Subject-Token | S | 30 s | → 401 | skyline_apiserver/client/openstack/keystone.py:87 |
| Keystone | DELETE /v3/auth/tokens | X-Subject-Token | U | 30 s | swallowed | skyline_apiserver/client/openstack/keystone.py:57 |
| Keystone | GET /v3/auth/projects | — | L | 30 s | → 401 | skyline_apiserver/api/v1/login.py:296 |
| Keystone | GET /v3/users/{id} | — | S | 30 s | → 401 | skyline_apiserver/client/openstack/keystone.py:92 |
| Keystone | GET /v3/users/{id}/projects | — | S | 30 s | → 401 | skyline_apiserver/client/openstack/system.py:94 |
| Keystone | GET /v3/projects | name=; user → /v3/users/{id}/projects when not all_projects | S or U | 30 s | 401 / 500 | skyline_apiserver/client/openstack/keystone.py:28 |
| Keystone | GET /v3/domains | enabled=True | S | 30 s | → 500 | skyline_apiserver/client/openstack/system.py:109 |
| Nova | GET /servers/detail | name, status, host, flavor, project_id, all_tenants, uuid, ip, deleted, limit, marker, sort_key, sort_dir (null values dropped) | U or S | 30 s | 400, 401, 403, else 500 | skyline_apiserver/client/openstack/nova.py:29 |
| Nova | GET /os-services | binary, host | S | 30 s | 401, else 500 | skyline_apiserver/client/openstack/nova.py:74 |
| Cinder | GET /volumes/detail | with_count, name, multiattach, status, all_tenants, project_id, bootable, id=[…], snapshot_id=[…], limit, marker, sort | U or S | 30 s | 401, else 500 | skyline_apiserver/client/openstack/cinder.py:29 |
| Cinder | GET /snapshots/detail | with_count, name, status, volume_id, all_tenants, project_id, id, limit, marker, sort | U or S | 30 s | 401, else 500 | skyline_apiserver/client/openstack/cinder.py:62 |
| Cinder | GET /snapshots/{id} | — | U | 30 s | 401; 404 re-raised and caught by the handler | skyline_apiserver/client/openstack/cinder.py:95 |
| Glance | GET /v2/images | id=in:<id>,<id>… | S | 30 s | 401, else 500 | skyline_apiserver/client/openstack/glance.py:28 |
| Neutron | GET /v2.0/extensions | — | S | 30 s | → 401 (profile) / 500 (contrib) | skyline_apiserver/client/openstack/system.py:79 |
| Neutron | GET /v2.0/networks | name, shared=True, project_id, id (repeated) | U | 30 s | 401, else 500 | skyline_apiserver/client/openstack/neutron.py:29 |
| Neutron | GET /v2.0/ports | limit, marker, project_id, name, status, device_owner (repeated), id (repeated), network_id (repeated), device_id, sort_key/sort_dir (repeated) | U | 30 s | 401, else 500 | skyline_apiserver/client/openstack/neutron.py:54 |
| Prometheus | GET /api/v1/query, /api/v1/query_range | passed through | none / basic auth | httpx default | non-200 status forwarded | skyline_apiserver/api/v1/prometheus.py:132 |
Wire details a re-implementation must reproduce:
- Cinder list filters
idandsnapshot_idare sent as the Python repr of a list:id=['a3e5…', 'b1c2…'](python-cinderclient formatssearch_optswithstr()). Cinder evaluates every filter value withast.literal_eval(cinder:cinder/volume/api.py:2369), which turns it into anINfilter. - Glance's
idfilter uses thein:operator:id=in:<id1>,<id2>. - Neutron is mandatory for login. Every profile build calls Neutron
GET /v2.0/extensionswith the system session (skyline_apiserver/client/openstack/system.py:78). If the catalog has nonetworkendpoint in the region,get_endpointraisesValueError("Endpoint not found")(skyline_apiserver/client/utils.py:120), and_patch_profileturns it into a 401 (skyline_apiserver/api/v1/login.py:377):POST /loginanswers 401{"detail": "Endpoint not found"}although Keystone has already issued the token. Checked in kind by the skyline-console reverse-spec session (reverse-spec-skyline-console/07-rewrite-pack_stable-2026.2.md§ 3.1, § 6.3). - Neutron list filters with several values are repeated keys (
id=a&id=b);sort_key/sort_dirare repeated in pairs. - Booleans to Cinder are sent as the strings
True/False(skyline_apiserver/api/v1/extension.py:578). limit/markerare passed through to the service; Skyline fetches one page only (Nova and Neutron) and Cinder'scountis the total.
Load per inbound call
The recorded counts give the cost model: every authenticated call = 2 Keystone calls (discovery + validation) + 1 SQL DELETE + 1 SQL SELECT (cookie path); every endpoint that builds a user session adds a re-scope POST /v3/auth/tokens; profile-returning endpoints add 6 more Keystone and 1 Neutron call. Nothing is cached between requests except the system token (per worker) and the policy files (by modification time). Keystone validation results are not cached (no keystonemiddleware, no memcached).
3. Database
| Table | Columns | Role | Written by | Read by |
|---|---|---|---|---|
revoked_token | uuid VARCHAR(128) PK, expire INTEGER (epoch s) | sessions logged out before their expiry | POST /logout (skyline_apiserver/db/api.py:56); expired rows deleted on every authenticated request (skyline_apiserver/db/api.py:65) | middleware, cookie path (skyline_apiserver/db/api.py:41) |
settings | key VARCHAR(128) PK, value JSON NULL | run-time overrides of setting.* | PUT /setting (upsert, skyline_apiserver/db/api.py:93), DELETE /setting/{key} (skyline_apiserver/db/api.py:111) | GET /settings, GET /setting/{key} |
- Engine: synchronous SQLAlchemy,
database_url(defaultsqlite:////tmp/skyline.db;mysql://…for MariaDB, mapped tomysql+pymysqlby Alembic only,skyline_apiserver/db/alembic/env.py:33);pool_pre_pingfor non-SQLite (skyline_apiserver/db/base.py:71). Thedatabases,aiomysqlandaiosqliterequirements are not used by the code [inferred: leftovers of the former async layer]. - Transactions: none in practice.
db.transaction()opens a session that no query uses (skyline_apiserver/db/base.py:36); eachexecute/fetch_*opens its own session and commits (skyline_apiserver/db/base.py:40). TheSELECT … FOR UPDATEinupdate_settingis committed before theINSERT/UPDATEruns. - Queries (reconstructed SQL):
-- every authenticated request
DELETE FROM revoked_token WHERE expire < :now_minus_1;
SELECT count(revoked_token.uuid) AS revoked_count FROM revoked_token WHERE revoked_token.uuid = :uuid; -- cookie path only
-- logout
INSERT INTO revoked_token (uuid, expire) VALUES (:uuid, :exp);
-- settings
SELECT settings.key, settings.value FROM settings;
SELECT settings.key, settings.value FROM settings WHERE settings.key = :key;
SELECT settings.key, settings.value FROM settings WHERE settings.key = :key FOR UPDATE; -- then
INSERT INTO settings (key, value) VALUES (:key, :value); -- or UPDATE settings SET value=:value WHERE key=:key
DELETE FROM settings WHERE settings.key = :key;
- Migrations: Alembic
000creates both tables with non-unique indexes;001replaces them with primary keyspk_revoked_token,pk_settings(skyline_apiserver/db/alembic/versions/001_add_primary_keys_to_skyline_tables.py:32). The migrated schema equals the model DDL except constraint names (_rewrite/schema-sqlite-migrated_stable-2026.2.sql).
4. nginx reverse proxy (deployment glue)
skyline-nginx-generator (skyline_apiserver/cmd/generate_nginx.py:230), run at container start, reads the full endpoint list from Keystone with the system account (GET /v3/endpoints?interface=<interface_type>, GET /v3/services, skyline_apiserver/cmd/generate_nginx.py:118) and renders skyline_apiserver/templates/nginx.conf.j2:
| Location | Target | Notes |
|---|---|---|
/ | skyline-console static files, SPA fallback to index.html | skyline_apiserver/templates/nginx.conf.j2:83 |
/api/openstack/skyline/ | http://skyline/ (unix socket /var/lib/skyline/skyline.sock) | prefix stripped (skyline_apiserver/templates/nginx.conf.j2:91) |
/api/v1/ | http://skyline/api/v1/ | direct access, same API (skyline_apiserver/templates/nginx.conf.j2:101) |
/<nginx_prefix>/<region lower>/<service>/ | the endpoint URL with its version and %(project_id)s parts stripped | one per catalog endpoint of a mapped service type; Host rewritten; TLS 1.2/1.3 and SNI for https targets (skyline_apiserver/templates/nginx.conf.j2:111) |
The paths in the profile's endpoints are exactly these locations (skyline_apiserver/client/openstack/system.py:76); the console prefixes its OpenStack calls with them (skyline-console:src/client/client/constants.js:46). Two endpoints of the same service type in one region make the generator fail (skyline_apiserver/cmd/generate_nginx.py:136). The proxy forwards the browser's X-Auth-Token unchanged: the OpenStack services authenticate the user themselves.
5. Files and other resources
| Resource | Access | Defined at |
|---|---|---|
$OS_CONFIG_DIR/skyline.yaml (default /etc/skyline/skyline.yaml) | read once per worker at start; missing file → start-up fails | skyline_apiserver/config/base.py:108 |
<policy_file_path>/<service>_<policy_file_suffix> | stat on every rule evaluation, re-read when modified | skyline_apiserver/policy/base.py:105 |
<log_dir>/<log_file> | append | skyline_apiserver/main.py:47 |
/var/lib/skyline/skyline.sock | gunicorn listen socket | etc/gunicorn.py:17 |
/etc/nginx/nginx.conf | written by the generator | container/start_service.sh:16 |
No subprocess, no privileged operation, no outbound connection other than those listed.
Source: reverse-spec-skyline-apiserver/03-interactions_stable-2026.2.md. The _rewrite/ and _inventory/ files are not published on the site.