Deployment and connection contract (rewrite pack) — skyline-console — branch stable/2026.2
Branch
stable/2026.2@806d200392ee— repository https://opendev.org/openstack/skyline-console — analysed on 2026-10-29 Other repositories cited:skyline-apiserverstable/2026.2@4af72bfd4577(prefixskyline-apiserver:),cinderstable/2026.2@c117f9cf0(prefixcinder:),novastable/2026.2@8df1922926(prefixnova:).
Scope agreed with the user. In OpenKeel, Skyline is kept as is. The console is not rewritten in Go. This pack answers two questions instead:
- How to build the console from this branch and deploy it on Kubernetes so that it works.
- What it needs from skyline-apiserver, and through skyline-apiserver's proxy from each OpenStack API, so that the Go rewrites of those APIs keep it working. This is the client contract.
1. Contents of _rewrite/
| File | Content | Use |
|---|---|---|
[outbound-calls_stable-2026.2.md](_rewrite/outbound-calls_stable-2026.2.md), [.json](_rewrite/outbound-calls_stable-2026.2.json) | 577 HTTP calls the console can reach, per service: method, path, microversion headers, client accessor and file:line, store methods that use it | the list of operations each OpenKeel Go service must serve for Skyline; extra e2e cases for gen_e2e.py |
[ui-catalogue_stable-2026.2.md](_rewrite/ui-catalogue_stable-2026.2.md) | 251 routes, 274 pages, 392 actions with policy and HTTP calls | which screen breaks when an operation is missing; smoke-test plan |
[console-model_stable-2026.2.json](_rewrite/console-model_stable-2026.2.json) | raw static extraction (clients, stores, actions, pages, routes) | input of the catalogue; re-run after an upgrade |
[smoke-exchanges_stable-2026.2.json](_rewrite/smoke-exchanges_stable-2026.2.json) | 35 requests recorded from the built console in Chromium against a same-origin stand-in (the first entry is a curl probe) | wire evidence of the connection contract (headers, cookies, paths, microversion merge) |
tools/extract_console.js | AST extractor (Node, @babel/parser 7.24.5, @babel/traverse 7.24.5) | NODE_PATH=<modules> node tools/extract_console.js <repo> <out.json> |
tools/build_catalogue.py | joins action → store → client → HTTP, adds microversions | python3 tools/build_catalogue.py <out.json> <repo> _rewrite stable-2026.2 |
tools/smoke_server.py | stand-in for the skyline-apiserver container (static files with SPA fallback, canned Skyline API, empty OpenStack collections), recording every request | python3 tools/smoke_server.py <static dir> <port> <record.json> |
tools/kind/ | the cluster run of § 6.3: kind config, Dockerfiles (Keystone and skyline-apiserver from stable/2026.2, console from this branch), manifests, Keystone seed (Skyline service user, Cypress users, Neutron catalog entry), Neutron stand-in, deploy.sh | tools/kind/deploy.sh <OpenKeel root> <built console tree>; then Skyline is at http://127.0.0.1:9999 |
Library versions in the lockfile: React 16.14.0, MobX 5.15.7, antd 4.15.6, axios 0.21.4, webpack 4.46.0 (yarn.lock:3172, yarn.lock:12646).
2. Build and packaging
| Step | Detail | Source |
|---|---|---|
| Toolchain | Node.js 16 ("gallium" LTS) and yarn 1.x, as in the release job; engines says node ≥ 10.22, yarn ≥ 1.22.4 | playbooks/python-tarball/run.yaml:12, package.json:169 |
| Install | yarn install --frozen-lockfile | Makefile:75 |
| Build | yarn run build = webpack production config; output skyline_console/static/ with index.html, hashed bundles, .gz siblings (for nginx gzip_static) | package.json, config/webpack.prod.js:34, config/webpack.prod.js:140 |
| Version stamp | skyline_console/static/commit_id.txt = git commit | Makefile:78 |
| Python package | python3 -m build: skyline_console package carrying static/ (static_path), python -m skyline_console checks it | Makefile:81, skyline_console/__init__.py:17, skyline_console/__main__.py:19 |
| Release artefact | skyline-console-<branch>.tar.gz and .whl (stable-2026.2) | playbooks/python-tarball/run.yaml:34 |
Proof on this branch. A clean copy (git archive HEAD) built with Node 16.20.2 and yarn 1.22.19 in 1 min 44 s (install 63 s, webpack 41 s). It produced 45 files, 16 MB, in skyline_console/static/. One workaround was needed: registry.yarnpkg.com is TLS-intercepted on this network, so the copy's lockfile was pointed at registry.npmjs.org. That registry serves the same tarballs, and yarn still checked every package's integrity.
Pitfall 1 — stale bundle in git. skyline_console/static/ is committed with a bundle from 2022 (49 files, last changed by feat: update dist for rc1 release). It lacks chunks that the current source builds, such as Inctance-HA and barbican. Installing the Python package from the git tree without make package ships that old UI. Always build the bundle in the image.
Pitfall 2 — wrong console in the apiserver image. skyline-apiserver's image installs the console from skyline_console/skyline_console.tar.gz (skyline-apiserver:container/Dockerfile:23). Its Makefile downloads skyline-console-master.tar.gz by default (skyline-apiserver:Makefile:20). For stable/2026.2, set SKYLINE_CONSOLE_PACKAGE_URL=https://tarballs.opendev.org/openstack/skyline-console/skyline-console-stable-2026.2.tar.gz, or build the tarball from this branch. The image labels skyline.build_console_commit with the console commit (skyline-apiserver:container/Dockerfile:9): check it.
3. Serving on Kubernetes
The built console is static files, but its runtime contract is strict. Everything must be one origin, at the root path:
- the axios base URL is
/and every API path is absolute (src/client/client/request.js:159,src/client/client/constants.js:60); - webpack
publicPathis/(config/webpack.prod.js:46); - routes are absolute (
/compute/...,/auth/login).
So the console cannot be served under a sub-path (https://host/skyline/) without rebuilding it. It also cannot sit on a different host from the API without CORS and cookie changes, which the code does not support.
3.1 Recommended layout: the skyline-apiserver container as shipped
One pod runs nginx, the static console and gunicorn (skyline-apiserver):
- gunicorn listens on the unix socket
/var/lib/skyline/skyline.sock(skyline-apiserver:etc/gunicorn.py:17); - nginx serves
/from the console, proxies/api/openstack/skyline/to the socket, and proxies one location per catalog endpoint (skyline-apiserver:skyline_apiserver/templates/nginx.conf.j2:83,skyline-apiserver:skyline_apiserver/templates/nginx.conf.j2:91,skyline-apiserver:skyline_apiserver/templates/nginx.conf.j2:111).
The Kubernetes Service exposes nginx's port: 9999 by default, or LISTEN_ADDRESS (skyline-apiserver:container/start_service.sh:17). One Ingress rule / → that Service.
flowchart LR
user[Browser] -->|HTTPS host skyline.example| ing[Ingress /]
ing --> svc[Service :9999]
subgraph pod[skyline pod]
nginx[nginx<br/>generated at start] -->|static| files[(skyline_console/static)]
nginx -->|/api/openstack/skyline/ → unix socket| api[skyline-apiserver gunicorn]
end
svc --> nginx
nginx -->|/api/openstack/regionone/nova/ …| nova[Nova Service]
nginx -->|/api/openstack/regionone/neutron/ …| neutron[Neutron Service]
api -->|REST| ks[Keystone]
user -.->|noVNC URL from Nova| vnc[novncproxy Ingress]What this layout requires:
| Requirement | Why | Source |
|---|---|---|
| Keystone reachable when the pod starts | skyline-nginx-generator reads GET /v3/endpoints and /v3/services with the system account before nginx starts; a failure aborts the start script (set -e). Observed in kind: the pod crash-loops with ConnectFailure until Keystone answers, then starts normally (an init container that waits for Keystone avoids the back-off) | skyline-apiserver:container/start_service.sh:2, skyline-apiserver:container/start_service.sh:23, skyline-apiserver:skyline_apiserver/cmd/generate_nginx.py:118 |
the Skyline system account (system_user_name, default skyline, in project service) exists with the admin role before the pod starts | the generator and every profile build use it | skyline-apiserver:skyline_apiserver/config/openstack.py:56, skyline-apiserver:skyline_apiserver/client/utils.py:58 |
a **network (Neutron) endpoint in the catalog**, answering GET /v2.0/extensions | every login builds the profile with the system account and calls Neutron's extension list. Without a network endpoint, login answers **401 {"detail": "Endpoint not found"}** although Keystone accepted the password (observed in kind) | skyline-apiserver:skyline_apiserver/client/openstack/system.py:78, skyline-apiserver:skyline_apiserver/client/openstack/system.py:79, skyline-apiserver:skyline_apiserver/api/v1/login.py:377 |
administrators hold a system-scope admin or reader role (PUT /v3/system/users/{id}/roles/{role}) | administrator pages open only for a system role; admin on a project is not enough, and the page silently redirects to /base/overview (observed in kind) | src/stores/root.js:155, src/layouts/Base/index.jsx:252 |
| catalog changes → restart the pod | the proxy locations are frozen into nginx.conf at start | skyline-apiserver:container/start_service.sh:16 |
| one endpoint per service type per region and interface | duplicates make the generator fail | skyline-apiserver:skyline_apiserver/cmd/generate_nginx.py:136 |
interface_type: internal with cluster-internal URLs in the catalog (recommended) | nginx proxies to the catalog URL of interface_type (default public); with public URLs every API call hairpins through the external ingress and TLS | skyline-apiserver:skyline_apiserver/config/openstack.py:83 |
service types registered under the names of service_mapping (compute, block-storage, image, network, identity, placement, sharev2, load-balancer…) or a matching service_mapping | the profile's endpoint keys hide or show menus, and the location names must contain nova, cinder, placement, manila, ironic, zun for the microversion headers (§ 5) | skyline-apiserver:skyline_apiserver/config/openstack.py:121, src/client/client/constants.js:150 |
| no request-size limit and no request buffering on the ingress | image upload streams the file through nginx (client_max_body_size 0, proxy_request_buffering off) | skyline-apiserver:skyline_apiserver/templates/nginx.conf.j2:18 |
| ingress read timeout ≥ the slowest API call | the console sets no client timeout; aggregated extension/* calls and uploads can be long | src/client/client/request.js:157 |
noVNC proxy (and Zun wsproxy, serial console) exposed on their own public URLs, with novncproxy_base_url set to it | the console opens Nova's console URL directly in a new window; it never goes through Skyline | src/pages/compute/containers/Instance/actions/Console.jsx:69 |
TLS terminated at the ingress, or in nginx with SSL_CERTFILE/SSL_KEYFILE | cookies carry no Secure flag (confirmed in kind: session and time_expired are set with Path=/; SameSite=lax only, neither Secure nor HttpOnly); use HTTPS end to end anyway | skyline-apiserver:container/start_service.sh:20 |
| browser and server clocks roughly in sync | time_expired (server epoch) is compared with the browser clock to decide whether to send the token | src/client/client/request.js:56 |
| index.html revalidated after upgrades | nginx serves / with expires 1d and Cache-Control: public, including the SPA fallback; bundles are fingerprinted, index.html is not | skyline-apiserver:skyline_apiserver/templates/nginx.conf.j2:87 |
readiness probe on the API, not on / | nginx starts before gunicorn binds its socket, so GET / is ready while API calls still answer 502 (observed in kind for a few seconds after each start). Probe GET /api/openstack/skyline/api/v1/config (public, no Keystone call) or contrib/regions (public, proves the Keystone path) | skyline-apiserver:container/start_service.sh:25, skyline-apiserver:skyline_apiserver/templates/nginx.conf.j2:91 |
| every service the console offers is in the catalog, or its menu is accepted as hidden | for a service missing from profile.endpoints the console builds its URLs from the empty string (/v2.1/servers/1). nginx's SPA fallback answers those with index.html and 200, so the page shows "Unable to get Data" instead of an API error (observed in kind without Nova) | src/client/client/constants.js:48, skyline-apiserver:skyline_apiserver/templates/nginx.conf.j2:86 |
ssl_enabled matches the external scheme | it only sets the scheme of the SSO redirect URL (https by default); it does not switch TLS on | skyline-apiserver:skyline_apiserver/api/v1/login.py:527 |
3.2 Alternative: a separate console container
Possible (any static nginx with try_files $uri $uri/ /index.html and gzip_static on). The Ingress must then reproduce what the generated nginx does:
/api/openstack/skyline/→ skyline-apiserver, prefix stripped;- one
/api/openstack/<region>/<service>/rule per OpenStack API, prefix replaced by the service's root path,Hostset to the upstream; - everything else → the console.
profile.endpoints must point to exactly those paths (skyline-apiserver:skyline_apiserver/client/openstack/system.py:76). This duplicates the generator's logic in the chart and drifts with the catalog; only worth it when the apiserver is replaced.
4. Connection contract with skyline-apiserver
Checked on the wire with the built bundle (_rewrite/smoke-exchanges_stable-2026.2.json), and in the code:
| The console needs | Evidence |
|---|---|
/api/openstack/skyline/api/v1/{profile,policies,login,login/totp,logout,switch_project/{id},switch_region/{r},config,sso,contrib/*,extension/*,query,query_range,settings,setting,setting/{key}} on the page origin | src/client/skyline/index.js:18; recorded login, profile, policies, sso, config, extension/servers, extension/volumes |
401 when there is no session, so that the console shows the login page (recorded: deep link → /auth/login?referer=/compute/instance) | src/core/index.jsx:65 |
login response = profile with keystone_token, plus Set-Cookie session and time_expired (epoch seconds, readable by JavaScript, path /) | src/stores/root.js:117, src/client/client/request.js:58 |
profile fields endpoints, project{id,name}, user{id}, roles[].name, region, regions, base_domains, version, keystone_token_exp | src/stores/root.js:179, src/components/Layout/GlobalHeader/Token.jsx:53 |
policies = {"policies": [{"rule": "<service>:<rule>", "allowed": bool}]} with the service prefixes of policyMap; never empty for a valid user | src/resources/skyline/policy.js:18, src/resources/skyline/policy.js:176 |
login error bodies: detail.totp_required + detail.receipt; detail text with The password is expired and needs to be changed for user; receipt_expired, invalid_totp | src/pages/auth/containers/Login/index.jsx:296 |
extension/* accept limit, marker, sort_keys, sort_dirs, all_projects and resource filters; return the list key plus count | recorded extension/servers?limit=10&sort_keys=created_at&sort_dirs=desc |
the system-role check GET {endpoints.keystone}/v3/system/users/{id}/roles works with the user's own token | src/stores/root.js:159 |
request headers accepted: X-Openstack-Request-Id: req-<uuid4>, X-Auth-Token, cache-control/pragma: no-cache | recorded on every request |
The apiserver side of this contract (and its own findings: every middleware failure becomes 401, which logs the user out) is in [../reverse-spec-skyline-apiserver/07-rewrite-pack_stable-2026.2.md](../reverse-spec-skyline-apiserver/07-rewrite-pack_stable-2026.2.md) § 4.
5. What the OpenStack APIs must accept (for the OpenKeel Go services)
| Expectation | Where the console relies on it |
|---|---|
every call arrives through the proxy with the user's X-Auth-Token and X-Openstack-Request-Id: req-<uuid4>; Host is the upstream's | src/client/client/request.js:51, skyline-apiserver:skyline_apiserver/templates/nginx.conf.j2:120 |
Nova at compute 2.79 by default; actions at 2.30 (os-migrateLive), 2.42 (lock), 2.53 (remote_console), 2.93 (rebuild, rescue); GET os-server-groups at 2.63 | src/client/client/constants.js:90, src/client/client/constants.js:117, src/client/client/constants.js:135 |
the microversion arrives as one header with two values (compute 2.79, compute 2.42). Nova must keep the last value for its service (microversion-parse behaviour) | recorded; nova:nova/api/openstack/wsgi.py:151 |
Cinder at volume 3.59; it gets volume 3.59, volume 3.60 for os-extend and volume 3.59, volume 3.29 for GET messages. The Python Cinder keeps the first value (3.59). A Go Cinder must do the same to behave identically, or must at least accept both values | recorded; cinder:cinder/api/openstack/wsgi.py:292 |
| Placement 1.28, Ironic 1.58, Manila 2.55, Zun 1.40 in their own headers | src/client/client/constants.js:90 |
Nova POST /servers: security_groups[].name may hold a security group ID; block_device_mapping_v2 with source_type image/volume/blank; min_count/max_count + return_reservation_id; hypervisor_hostname; OS-SCH-HNT:scheduler_hints at the top level | src/pages/compute/containers/Instance/actions/StepCreate/index.jsx:689 |
Nova quota error: 403 with {"forbidden": {"message": "...Quota exceeded..."}} | src/pages/compute/containers/Instance/actions/StepCreate/index.jsx:794 |
Nova remote-consoles returns remote_console.url with a query string already present | src/pages/compute/containers/Instance/actions/Console.jsx:69 |
Neutron GET routers/{id}/l3-agents may return 404 (OVN) without breaking the router page | src/stores/neutron/router.js:117 |
Neutron lists accept project_id, limit, marker, sort_key, sort_dir and repeated filters (id=a&id=b) | recorded; src/client/client/request.js:213 |
admin lists: all_projects=true (Nova, Neutron through skyline), all_tenants (Cinder backups, Manila) | src/stores/base.js:360, src/stores/cinder/backup.js:60 |
Keystone: GET /v3/projects/{id} callable by system readers for every project (admin lists); password change errors as {"error": {"message": ...}} | src/stores/base.js:255, src/pages/auth/containers/ChangePassword/index.jsx:89 |
Swift: account path AUTH_<project_id>; container DELETE of a non-empty container answers 409 | src/client/swift/index.js:28, src/stores/swift/container.js:96 |
Glance: PUT images/{id}/file with application/octet-stream (streamed), or POST images/{id}/import | src/stores/glance/image.js:92 |
| any 401 means "session over": a Go service must not answer 401 for anything else (e.g. a policy refusal must be 403) | src/client/client/request.js:42 |
The per-operation list (577 calls) is [_rewrite/outbound-calls_stable-2026.2.md](_rewrite/outbound-calls_stable-2026.2.md). Paths are relative to profile.endpoints[<key>] + version suffix.
6. Conformance strategy
- Build check (CI of the OpenKeel chart). Build the console from
stable/2026.2in the image (Node 16,yarn install --frozen-lockfile,yarn build) and fail ifskyline_console/static/commit_id.txtdoes not match the expected commit. - Connection smoke test (no OpenStack needed). Run
tools/smoke_server.pyon the builtstatic/, then open a deep link, log in, and check the recorded requests against_rewrite/smoke-exchanges_stable-2026.2.json: paths, headers, cookies, microversion merge, auto-refresh at 30 s. Recorded result: 35 requests. The deep link was redirected to login; after login came profile, policies, system roles and Neutron extensions, thenextension/servers. The list refreshed 30.0 s later. Volumes, networks and the overview loaded, with Nova, Cinder and Neutron quotas carrying the expected headers. - Cluster smoke test. Deploy Skyline (§ 3.1) in a kind cluster, then run the console's Cypress suite. Override
baseUrl, users andtestFilesintest/e2e/config/local_config.yaml, which is merged overconfig.yaml(test/e2e/utils/index.js:46). Start withtest/e2e/integration/pages/login.spec.jsandtest/e2e/integration/pages/error.spec.js, then the compute, network and storage specs, which cover the flows the OpenKeel skill asks for.cypress.jsonusesretries: 5, so look at the first failure, not the final status. **Recorded run (2026-10-29,tools/kind/).** kind 0.33.0, Kubernetes 1.37.0. In the cluster:- Keystone
stable/2026.2(Python, SQLite, Fernet); - skyline-apiserver
stable/2026.2, built from its Dockerfile with the stable constraints and the console built from this branch; - a Neutron stand-in that answers only
GET /v2.0/extensions. No other OpenStack service and none of the Go services were deployed. What was observed: - Container start. nginx was generated from the live catalog, with locations
/api/openstack/regionone/keystone/and/neutron/, and served thestable/2026.2bundle (main.bundle.1793228949.js, the single username@domain login field). - Browser check. A deep link to
/identity/project-adminredirected to/auth/login?referer=…. Login ase2e-adminworked, and the administrator Projects page listed Keystone's projects through the nginx proxy. - **Cypress 6.8.0, Electron,
retries: 1:** 7 specs, 34 tests, 31 passing, in 9 min.identity/role,identity/domain,identity/user(9/9: create, detail, system permission, disable, enable, login as the new user, password change, edit, delete) andidentity/user-group(7/7) all pass.identity/projectpasses 10/11, andloginanderrorpass 2/3 and 1/2. The 3 failures all come from services that were not deployed:
login"check menu" clicks the second menu entry and expects Storage, but without Cinder the menu is Home, Compute, Network.error"not found" opens an instance detail, which needs Nova.project"edit quota" waits for the Nova quota fieldinstances. Problems found during the run, now in § 3.1:- login fails without a Neutron endpoint;
- administrator pages need a system-scope role;
- the pod crash-loops while Keystone is down;
- the readiness probe on
/reports ready while the API still returns 502; - a service absent from the catalog makes the console call the root path, which nginx answers with
index.htmland 200. Console race. After a login whoserefereris an administrator page, the user lands on/base/overviewanyway. The cause:updateUserstarts the system-roles request without awaiting it (src/stores/root.js:195,src/stores/root.js:212). The admin-page guard then runs whilehasAdminPageRoleis stillfalse(src/layouts/Base/index.jsx:252). Reloading the deep link afterwards works. This is an upstream bug; there is nothing to configure.
- Keystone
- Per-service contract. Feed
_rewrite/outbound-calls_stable-2026.2.jsonto each Go service's e2e generation as extra cases, filtered by service. A call listed there that a service does not implement breaks the screen shown in_rewrite/ui-catalogue_stable-2026.2.md.
7. Gaps
| Gap | Impact |
|---|---|
| the kind run (§ 6.3) had Keystone and a Neutron stand-in only, all Python or stubs: no Nova, Cinder, Glance, real Neutron, nor any OpenKeel Go service | the identity screens and the container are verified; compute, network and storage screens, microversions through the real nginx, and the Go services remain to be run with tools/kind/ once those services are in the cluster |
| reachability is static: 18 of 392 actions have no resolved call (UI-only or dynamic), and calls behind feature flags are listed as reachable | use the catalogue as an upper bound; Cypress gives the lower bound |
Cinder action keys passed as a third argument (operation(id, data, key)) are not extracted as keys; they are listed by hand in 01 § 3.6 | none for the call list (the path volumes/{id}/action is present) |
generic templates hide two real paths: PUT setting (no key), Swift object paths built from container and object names | noted in 01 § 3.10 and in the catalogue's Swift section |
| the browser's header merge was verified in Chromium only | Firefox and Safari follow the same Fetch rule for setRequestHeader [inferred] |
8. Checklist (Skyline console on OpenKeel)
- [ ] image built from
stable/2026.2sources (not the committed 2022 bundle, not themastertarball);commit_id.txtchecked - [ ] one origin, root path; ingress
/→ skyline pod; no body-size limit, no request buffering, long read timeout - [ ] Keystone reachable at pod start (init container waiting for it); catalog with one endpoint per type, region and interface;
interface_typematching cluster-internal URLs - [ ] Skyline system account (
skyline, projectservice, roleadmin) created before the pod; anetworkendpoint in the catalog (login fails without it) - [ ] administrators given a system-scope
admin(orreader) role, not only a project role - [ ] readiness probe on
/api/openstack/skyline/api/v1/config, not on/ - [ ] service types registered under the default
service_mappingnames (or the mapping adjusted) so that locations containnova,cinder,placement,manilav2,ironic,zun - [ ] noVNC proxy (and other consoles) published on their own public URLs
- [ ] index.html not cached for a day across upgrades (override
expiresor purge) - [ ] smoke test (§ 6.2) passes against the built bundle; Cypress login/error/identity specs pass in kind (done with Keystone only: 31/34, the 3 failures need Nova and Cinder); compute/network/storage specs pass once those services are deployed
- [ ] Go Nova keeps the last microversion value of a merged header; Go Cinder keeps the first (or accepts both identically)
- [ ] Go services answer 401 only for authentication failures; Nova quota errors keep
forbidden.messagewithQuota exceeded
Source: reverse-spec-skyline-console/07-rewrite-pack_stable-2026.2.md. The _rewrite/ and _inventory/ files are not published on the site.