OpenKeel: the sovereign French Cloud solution of the inherent group, with a European reach Discover inherent

Reverse-spec · Services

skyline-console — Rewrite pack

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-apiserver stable/2026.2 @ 4af72bfd4577 (prefix skyline-apiserver:), cinder stable/2026.2 @ c117f9cf0 (prefix cinder:), nova stable/2026.2 @ 8df1922926 (prefix nova:).

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:

  1. How to build the console from this branch and deploy it on Kubernetes so that it works.
  2. 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/

FileContentUse
[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 itthe 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 callswhich 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.jsAST 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.pyjoins action → store → client → HTTP, adds microversionspython3 tools/build_catalogue.py <out.json> <repo> _rewrite stable-2026.2
tools/smoke_server.pystand-in for the skyline-apiserver container (static files with SPA fallback, canned Skyline API, empty OpenStack collections), recording every requestpython3 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.shtools/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

StepDetailSource
ToolchainNode.js 16 ("gallium" LTS) and yarn 1.x, as in the release job; engines says node ≥ 10.22, yarn ≥ 1.22.4playbooks/python-tarball/run.yaml:12, package.json:169
Installyarn install --frozen-lockfileMakefile:75
Buildyarn 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 stampskyline_console/static/commit_id.txt = git commitMakefile:78
Python packagepython3 -m build: skyline_console package carrying static/ (static_path), python -m skyline_console checks itMakefile:81, skyline_console/__init__.py:17, skyline_console/__main__.py:19
Release artefactskyline-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 publicPath is / (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.

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:

RequirementWhySource
Keystone reachable when the pod startsskyline-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 startsthe generator and every profile build use itskyline-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/extensionsevery 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 podthe proxy locations are frozen into nginx.conf at startskyline-apiserver:container/start_service.sh:16
one endpoint per service type per region and interfaceduplicates make the generator failskyline-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 TLSskyline-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_mappingthe 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 ingressimage 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 callthe console sets no client timeout; aggregated extension/* calls and uploads can be longsrc/client/client/request.js:157
noVNC proxy (and Zun wsproxy, serial console) exposed on their own public URLs, with novncproxy_base_url set to itthe console opens Nova's console URL directly in a new window; it never goes through Skylinesrc/pages/compute/containers/Instance/actions/Console.jsx:69
TLS terminated at the ingress, or in nginx with SSL_CERTFILE/SSL_KEYFILEcookies 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 anywayskyline-apiserver:container/start_service.sh:20
browser and server clocks roughly in synctime_expired (server epoch) is compared with the browser clock to decide whether to send the tokensrc/client/client/request.js:56
index.html revalidated after upgradesnginx serves / with expires 1d and Cache-Control: public, including the SPA fallback; bundles are fingerprinted, index.html is notskyline-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 hiddenfor 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 schemeit only sets the scheme of the SSO redirect URL (https by default); it does not switch TLS onskyline-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, Host set 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 needsEvidence
/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 originsrc/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_expsrc/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 usersrc/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_totpsrc/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 countrecorded 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 tokensrc/stores/root.js:159
request headers accepted: X-Openstack-Request-Id: req-<uuid4>, X-Auth-Token, cache-control/pragma: no-cacherecorded 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)

ExpectationWhere 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'ssrc/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.63src/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 valuesrecorded; cinder:cinder/api/openstack/wsgi.py:292
Placement 1.28, Ironic 1.58, Manila 2.55, Zun 1.40 in their own headerssrc/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 levelsrc/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 presentsrc/pages/compute/containers/Instance/actions/Console.jsx:69
Neutron GET routers/{id}/l3-agents may return 404 (OVN) without breaking the router pagesrc/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 409src/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}/importsrc/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

  1. Build check (CI of the OpenKeel chart). Build the console from stable/2026.2 in the image (Node 16, yarn install --frozen-lockfile, yarn build) and fail if skyline_console/static/commit_id.txt does not match the expected commit.
  2. Connection smoke test (no OpenStack needed). Run tools/smoke_server.py on the built static/, 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, then extension/servers. The list refreshed 30.0 s later. Volumes, networks and the overview loaded, with Nova, Cinder and Neutron quotas carrying the expected headers.
  3. Cluster smoke test. Deploy Skyline (§ 3.1) in a kind cluster, then run the console's Cypress suite. Override baseUrl, users and testFiles in test/e2e/config/local_config.yaml, which is merged over config.yaml (test/e2e/utils/index.js:46). Start with test/e2e/integration/pages/login.spec.js and test/e2e/integration/pages/error.spec.js, then the compute, network and storage specs, which cover the flows the OpenKeel skill asks for. cypress.json uses retries: 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 the stable/2026.2 bundle (main.bundle.1793228949.js, the single username@domain login field).
    • Browser check. A deep link to /identity/project-admin redirected to /auth/login?referer=…. Login as e2e-admin worked, 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) and identity/user-group (7/7) all pass.
      • identity/project passes 10/11, and login and error pass 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 field instances. 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.html and 200. Console race. After a login whose referer is an administrator page, the user lands on /base/overview anyway. The cause: updateUser starts the system-roles request without awaiting it (src/stores/root.js:195, src/stores/root.js:212). The admin-page guard then runs while hasAdminPageRole is still false (src/layouts/Base/index.jsx:252). Reloading the deep link afterwards works. This is an upstream bug; there is nothing to configure.
  4. Per-service contract. Feed _rewrite/outbound-calls_stable-2026.2.json to 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

GapImpact
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 servicethe 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 reachableuse 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.6none 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 namesnoted in 01 § 3.10 and in the catalogue's Swift section
the browser's header merge was verified in Chromium onlyFirefox and Safari follow the same Fetch rule for setRequestHeader [inferred]

8. Checklist (Skyline console on OpenKeel)

  • [ ] image built from stable/2026.2 sources (not the committed 2022 bundle, not the master tarball); commit_id.txt checked
  • [ ] 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_type matching cluster-internal URLs
  • [ ] Skyline system account (skyline, project service, role admin) created before the pod; a network endpoint in the catalog (login fails without it)
  • [ ] administrators given a system-scope admin (or reader) role, not only a project role
  • [ ] readiness probe on /api/openstack/skyline/api/v1/config, not on /
  • [ ] service types registered under the default service_mapping names (or the mapping adjusted) so that locations contain nova, 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 expires or 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.message with Quota exceeded

Source: reverse-spec-skyline-console/07-rewrite-pack_stable-2026.2.md. The _rewrite/ and _inventory/ files are not published on the site.