feat: implement federation management

What is this MR?

It adds a Federation page to the portal. An operator can now:

  1. Register partner operators, the other telecom platforms we want to share edge resources with.
  2. Create or delete a federation with a partner (a trust relationship between two platforms).
  3. Subscribe to or unsubscribe from the partner's zones, the edge locations it offers us.

It also contains supporting work that the page needed: a shared data table, one shared confirm dialog, role-based API catalogue filtering, and a cleanup of dead code.

Terms

Term Meaning
Partner Another operator's platform, registered in our Federation Manager (FM).
Federation An agreement between us and an active partner. It is identified by a federationContextId.
Zone An edge location the partner offers. Subscribing makes the partner reserve resources for us.
FM Federation Manager, the backend service that talks to partners.

How the pieces fit together

flowchart TD
  A["pages/federation/index.tsx<br/>(route entry)"] --> B["features/federation/federation.tsx<br/>(FederationPage: owns dialog state)"]
  B --> C["components/federation/*<br/>(columns, dialogs, badges, zone list)"]
  B --> D["hooks/usePartners, usePartnerFederation"]
  C --> D
  D --> E["services/federation/*<br/>(partners.ts, federations.ts)"]
  E --> F["federation-request.ts<br/>(fetch + error handling)"]
  F --> G["lib/api-fetch (existing)"]

The GUI never talks to the FM directly. Every call goes through the portal backend, which forwards /api/oeg/* to the OEG with the user's token. The only new config is federation.baseUrl in api.ts.

Code layers (top to bottom)

Each layer only calls the one below it. That is why the components contain no fetch calls.

Layer Files Job
Types types/federation.ts Shapes of the API data
Service services/federation/* One function per endpoint
Hooks usePartners, usePartnerFederation, useSingleFlight Loading state, caching, actions
Validation features/federation/*-schema.ts Zod schemas for the forms
UI components/federation/*, features/federation/federation.tsx What the user sees

Service layer: errors you can react to

federation-request.ts wraps apiFetch and does three things:

  • It sets the JSON headers and parses the body safely.
  • On a non-2xx response it throws a FederationRequestError. The error message is built from the API's ProblemDetails: detail, then title, plus any invalidParams.
  • It exposes isProblem(err, 'some-code'). The code is the last :-segment of problem.type.

isProblem turns "expected" errors into normal answers. In federations.ts:

  • findFederationContextId returns null on federation-context-unknown. That means "not federated", not a failure.
  • findZoneSubscription returns null on zone-not-subscribed. That means "not subscribed".

URL builders (federations(), federation(), zones(), zone()) call encodeURIComponent on every id, so a weird id can't break the path.

partners.ts has listPartners, getPartner, registerPartner (POST) and updatePartner (PATCH).

usePartners: the table's data, loaded in two stages

Listing partners is one call. Finding out whether each is federated costs one call per partner, so it is lazy:

sequenceDiagram
  participant T as DataTable
  participant H as usePartners
  participant FM as API

  H->>FM: GET /partners
  FM-->>H: partners
  H-->>T: rows (federation = "loading")

  T->>H: onVisibleRows(ids on this page)

  loop each id not requested yet
    H->>FM: GET /partners/{id}/federations
    FM-->>H: contextId or "federation-context-unknown"
    H-->>T: badge becomes federated / not-federated / unknown
  end

Details:

  • Only the rows visible on the current page are checked. A requested Set avoids asking twice.
  • A generation counter is bumped on every refresh. A late answer from an older generation is ignored, so stale data can't overwrite fresh data.
  • A failed check shows unknown. It does not break the page.
  • refresh() re-runs the load without blanking the table (refreshing is separate from loading).

usePartnerFederation(partnerId, { zones, partner }): one partner's federation

It loads the federation context and its details. With zones: true it also checks the subscription status of each offered zone. It returns create, remove, subscribe and unsubscribe, and each action reloads afterwards, so the UI always shows server truth rather than guesses.

The cancelled flag in each useEffect stops a closed dialog from setting state.

useSingleFlight: no double-click bugs

useSingleFlight.ts wraps an async action. If it is already running, a second call is ignored. It is used on every submit and confirm button, so a double-click can't create two federations.

The UI

FederationPage is the only stateful orchestrator. It holds which partner is selected for each dialog and renders:

flowchart TD
  PAGE[FederationPage] --> TABLE[DataTable of partners]
  TABLE -->|row menu: Details| D1[PartnerDetailsDialog]
  TABLE -->|Federation| D2[PartnerFederationDialog]
  TABLE -->|Zones<br/>only if federated| D3[PartnerZonesDialog]
  TABLE -->|Edit| D4[PartnerFormDialog edit]
  PAGE -->|Register partner button| D5[PartnerFormDialog create]
  D2 --> D2a[CreateFederationDialog]
  D2 --> D2b[ConfirmActionDialog: delete]
  D3 --> ZL[ZoneList]
  ZL --> D3a[ConfirmActionDialog: unsubscribe]
Component What it does
partner-columns.tsx Table columns: Name (click opens details, searchable by name or MCC-MNC), Registration badge, Federation badge, EWBI endpoint, actions menu. The Zones action only appears when federation === 'federated'.
PartnerFormDialog Register and edit form. On edit it sends only changed fields (PATCH).
PartnerFederationDialog Shows the federation details. It offers Create federation only when the partner is active, otherwise it explains why not. It also offers Delete federation.
CreateFederationDialog Asks for initialDate (defaults to now) and partnerStatusLink.
PartnerZonesDialog / ZoneList Lists the offered zones as Offered or Subscribed, with the reserved CPU, memory and disk, and Subscribe/Unsubscribe buttons.
*-badge.tsx, detail-fields.tsx Small display helpers.

The Federation page reloads the table (refresh) when the federation dialog closes after a change.

Access control: operators only

  • The sidebar item (Handshake icon, in navigation.ts) has roles: ['operator']. The new canSeeNavItem() hides it from other users.
  • The route in router.tsx is wrapped in the existing RoleGuard, so typing the URL by hand sends non-operators to Forbidden.
  • API catalogue: the federation endpoints are in the OEG OpenAPI spec. getApis(includeFederation) and getApi(id, includeFederation) in apis.ts now hide them from non-operators. useAPIs passes isOperator from useAuth(). getApi now returns null instead of throwing when an API is not found.

The OEG base URL also changed from vwip to v0.1alpha1, in api.ts and in tests/unit/test_oeg_proxy.py. This matches the docs rename in the architecture repo.

Shared components added or refactored

ConfirmActionDialog (confirm-action-dialog.tsx)

  • It replaces the three near-identical dialogs confirm-delete-app/deployment/instance-dialog, which are deleted.
  • Its props are title, description, confirmLabel, pendingLabel, failureMessage and onConfirm.
  • It shows a pending state, shows the error inside the dialog, and can't be closed while pending.
  • It is used for delete federation, unsubscribe zone, and the existing app/deployment/instance deletes.

DataTable (data-table.tsx)

  • It replaces the per-feature table components (app-*-table.tsx and app-*-toolbar.tsx), about 1,000 lines of near-copies.
  • It is built on TanStack Table and gives you:
    • name search
    • sorting
    • pagination (10 per page)
    • column show/hide
    • drag-and-drop row reordering (@dnd-kit)
    • a refresh button
    • an optional filters slot, which Federation uses for the Registration filter
    • an onVisibleRows callback, which usePartners uses to load federation states lazily
  • Applications (profiles, instances, deployments) and Federation all use it now, so they look and behave alike.
  • actionsColumn and RowActionsMenu give every table the same "⋯" menu. Items can be false, which hides them, and can add separatorBefore.

return-focus.ts: when a dialog closes, keyboard focus returns to the element that opened it (or to a fallback). It also works with nested dialogs.

Cleanup

  • Deleted dead code: api-columns.tsx, api-search.tsx, api-details.css, catalogue/column.tsx, table-columns.tsx, useAppInstances.ts, unused types and utils, and debug logs.
  • Removed the unused deps next-themes, eslint-config-prettier and eslint-plugin-react. Renamed the package to oop-portal.
  • Normalised file names: app-profile-columns .tsx (it had a space) became app-profile-columns.tsx, dragable-row became draggable-row, and use-mobile and use-sidebar became useIsMobile and useSidebar.
  • useApplications and useDeployments were reworked to match the loading/error/refresh pattern used by usePartners.

Suggested review order

  1. types/federation.ts to learn the data.
  2. services/federation/* to see what the API offers.
  3. hooks/usePartners.ts and usePartnerFederation.ts, the core logic.
  4. features/federation/federation.tsx, then the dialogs.
  5. data-table/* and confirm-action-dialog.tsx, the shared refactors.
  6. Skim the cleanup.

How to try it

  1. Log in as a user with the operator role. The Federation item appears in the sidebar. A non-operator doesn't see it, and /federation shows Forbidden.
  2. Click Register partner. For active status, fill in all three outbound credentials.
  3. Open Federation from the row menu and click Create federation.
  4. When the badge turns Federated, open Zones and subscribe or unsubscribe.
  5. Check Applications, Instances and Deployments still list, filter and delete as before.
Edited by Dimitrios Gogos

Merge request reports

Loading

deployment/templates/NOTES.txt

deleted100644 → 0
+0 −16
Changes for deployment/templates/NOTES.txt: 0 added lines, 16 removed lines.
Original line number Diff line number Diff line
Portal frontend has been deployed.

1. Get the application URL by running these commands:
{{- if contains "NodePort" .Values.service.type }}
  export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "portal.fullname" . }})
  export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}")
  echo http://$NODE_IP:$NODE_PORT
{{- else if contains "LoadBalancer" .Values.service.type }}
  NOTE: It may take a few minutes for the LoadBalancer IP to be available.
  kubectl get --namespace {{ .Release.Namespace }} svc {{ include "portal.fullname" . }} -w
{{- else if contains "ClusterIP" .Values.service.type }}
  kubectl port-forward --namespace {{ .Release.Namespace }} svc/{{ include "portal.fullname" . }} {{ .Values.service.port }}:{{ .Values.service.port }}
  echo "Visit http://127.0.0.1:{{ .Values.service.port }}"
{{- end }}

OEG_SERVICE_URL is set to: {{ .Values.config.oegServiceUrl }}
+84 −20
Changes for deployment/templates/_helpers.tpl: 84 added lines, 20 removed lines.
Original line number Diff line number Diff line
{{- define "portal.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}

{{- define "portal.fullname" -}}
{{- if .Values.fullnameOverride }}
  {{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
  {{- $name := default .Chart.Name .Values.nameOverride }}
  {{- if contains $name .Release.Name }}
{{- else if contains .Chart.Name .Release.Name }}
  {{- .Release.Name | trunc 63 | trimSuffix "-" }}
{{- else }}
    {{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
  {{- end }}
  {{- printf "%s-%s" .Release.Name .Chart.Name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}

{{- define "portal.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{- end }}

{{- define "portal.labels" -}}
helm.sh/chart: {{ include "portal.chart" . }}
helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{ include "portal.selectorLabels" . }}
app.kubernetes.io/version: {{ .Values.image.tag | quote }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
{{- end }}

{{- define "portal.selectorLabels" -}}
app.kubernetes.io/name: {{ include "portal.name" . }}
app.kubernetes.io/name: {{ .Chart.Name }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: portal
{{- end }}

{{/*
Settings shared across the platform live in the umbrella chart's `global` block. Each helper
below takes this chart's own value when set, else the global one, else a built-in default,
so the chart also installs on its own.
*/}}
{{- define "portal.authEnabled" -}}
{{- if dig "auth" "enabled" true (.Values.global | default dict) }}true{{ end -}}
{{- end }}

{{- define "portal.scheme" -}}
{{- dig "scheme" "http" (.Values.global | default dict) -}}
{{- end }}

{{/*
global.publicHost if set, else the first node's ExternalIP, else its InternalIP, as the keycloak
chart does, so both build the same URLs.
*/}}
{{- define "portal.publicHost" -}}
{{- $host := dig "publicHost" "" (.Values.global | default dict) -}}
{{- if not $host -}}
{{- $nodes := lookup "v1" "Node" "" "" -}}
{{- if and $nodes $nodes.items -}}
{{- $addresses := (index $nodes.items 0).status.addresses -}}
{{- range $addresses }}{{ if and (not $host) (eq .type "ExternalIP") }}{{ $host = .address }}{{ end }}{{ end -}}
{{- range $addresses }}{{ if and (not $host) (eq .type "InternalIP") }}{{ $host = .address }}{{ end }}{{ end -}}
{{- end -}}
{{- end -}}
{{- $host | default "localhost" -}}
{{- end }}

{{- define "portal.publicUrl" -}}
{{- if .Values.publicUrl -}}
{{- trimSuffix "/" .Values.publicUrl -}}
{{- else -}}
{{- $port := .Values.service.nodePort | default (dig "nodePorts" "portal" 30082 (.Values.global | default dict)) -}}
{{- printf "%s://%s:%v" (include "portal.scheme" .) (include "portal.publicHost" .) $port -}}
{{- end -}}
{{- end }}

{{/*
Keycloak's public URL: the issuer of every token, and where browsers log in.
*/}}
{{- define "portal.keycloak.publicUrl" -}}
{{- if .Values.keycloak.publicUrl -}}
{{- trimSuffix "/" .Values.keycloak.publicUrl -}}
{{- else -}}
{{- $port := dig "nodePorts" "keycloak" 30081 (.Values.global | default dict) -}}
{{- printf "%s://%s:%v" (include "portal.scheme" .) (include "portal.publicHost" .) $port -}}
{{- end -}}
{{- end }}

{{/*
Keycloak inside the cluster, for the backend's own calls: the public URL may not be reachable
from pods (on kind it is localhost).
*/}}
{{- define "portal.keycloak.internalUrl" -}}
{{- $kc := (.Values.global | default dict).keycloak | default dict -}}
{{- printf "http://%s:%v" ($kc.host | default "keycloak") ($kc.port | default 8080) -}}
{{- end }}

{{- define "portal.db.host" -}}
{{- dig "postgresql" "host" "postgres" (.Values.global | default dict) -}}
{{- end }}

{{- define "portal.db.port" -}}
{{- dig "postgresql" "port" 5432 (.Values.global | default dict) -}}
{{- end }}

{{- define "portal.db.secretName" -}}
{{- dig "postgresql" "secretName" "postgres-credentials" (.Values.global | default dict) -}}
{{- end }}

{{- define "portal.db.waitImage" -}}
{{- dig "images" "postgres" "postgres:18.6-alpine" (.Values.global | default dict) -}}
{{- end }}

{{- define "portal.configMapName" -}}
{{- include "portal.fullname" . }}-config
{{- define "portal.secretName" -}}
{{- include "portal.fullname" . }}-credentials
{{- end }}
+27 −3
Changes for deployment/templates/configmap.yaml: 27 added lines, 3 removed lines.
Original line number Diff line number Diff line
apiVersion: v1
kind: ConfigMap
metadata:
  name: {{ include "portal.configMapName" . }}
  name: {{ include "portal.fullname" . }}
  namespace: {{ .Release.Namespace }}
  labels:
    {{- include "portal.labels" . | nindent 4 }}
    app.kubernetes.io/component: portal
data:
  OEG_SERVICE_URL: {{ .Values.config.oegServiceUrl | quote }}
  APP_VERSION: {{ .Values.image.tag | quote }}
  POSTGRESQL_SETTINGS__CREATE_SCHEMA_ON_STARTUP: "true"
  OEG__BASE_URL: {{ .Values.oeg.url | quote }}
  OEG__PUBLIC_BASE_URL: {{ .Values.oeg.publicUrl | quote }}
  AI2__CHAT_URL: {{ .Values.ai2.chatUrl | quote }}
  API_PRODUCTS__FILE: /etc/portal/api-products.json
  {{- if include "portal.authEnabled" . }}
  {{- $issuer := printf "%s/realms/%s" (include "portal.keycloak.publicUrl" .) .Values.keycloak.realm }}
  {{- $internal := printf "%s/realms/%s" (include "portal.keycloak.internalUrl" .) .Values.keycloak.realm }}
  AUTH__SECURITY_MODE: jwt
  SESSION__HTTPS_ONLY: {{ eq (include "portal.scheme" .) "https" | quote }}
  OIDC__ISSUER: {{ $issuer | quote }}
  OIDC__CLIENT_ID: {{ .Values.keycloak.clientId | quote }}
  OIDC__REDIRECT_URI: {{ printf "%s/api/auth/callback" (include "portal.publicUrl" .) | quote }}
  # Backend-to-Keycloak calls go in-cluster; the issuer stays public.
  OIDC__JWKS_URI: {{ printf "%s/protocol/openid-connect/certs" $internal | quote }}
  OIDC__TOKEN_ENDPOINT: {{ printf "%s/protocol/openid-connect/token" $internal | quote }}
  OIDC__ADMIN_BASE_URL: {{ include "portal.keycloak.internalUrl" . | quote }}
  {{- if .Values.keycloak.provisioner.enabled }}
  PROVISIONING__CLIENT_ID: {{ .Values.keycloak.provisioner.clientId | quote }}
  PROVISIONING__OEG_CLIENT_UUID: {{ .Values.keycloak.provisioner.oegClientId | quote }}
  {{- end }}
  {{- else }}
  AUTH__SECURITY_MODE: none
  {{- end }}
  api-products.json: {{ dict "products" (dig "apiProducts" (list) (.Values.global | default dict)) | toJson | quote }}
+70 −21
Changes for deployment/templates/deployment.yaml: 70 added lines, 21 removed lines.
Original line number Diff line number Diff line
@@ -5,54 +5,103 @@ metadata:
  namespace: {{ .Release.Namespace }}
  labels:
    {{- include "portal.labels" . | nindent 4 }}
    app.kubernetes.io/component: portal
spec:
  replicas: {{ .Values.replicaCount }}
  selector:
    matchLabels:
      {{- include "portal.selectorLabels" . | nindent 6 }}
      app.kubernetes.io/component: portal
  template:
    metadata:
      labels:
        {{- include "portal.selectorLabels" . | nindent 8 }}
        app.kubernetes.io/component: portal
        {{- with .Values.podLabels }}
        {{- toYaml . | nindent 8 }}
        {{- end }}
        # Lets the pod through the postgresql chart's NetworkPolicy.
        {{ include "portal.db.host" . }}-client: "true"
      annotations:
        checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
    spec:
      initContainers:
        # The postgresql chart creates the database in a separate Job: wait until the
        # portal role can log in, so the backend does not crash-loop meanwhile.
        - name: wait-for-database
          image: {{ include "portal.db.waitImage" . | quote }}
          imagePullPolicy: IfNotPresent
          env:
            - name: PGPASSWORD
              valueFrom:
                secretKeyRef:
                  name: {{ include "portal.db.secretName" . }}
                  key: {{ .Values.database.passwordKey }}
          command: ["/bin/sh", "-c"]
          args:
            - |
              until psql -h {{ include "portal.db.host" . }} -p {{ include "portal.db.port" . }} -U {{ .Values.database.user }} -d {{ .Values.database.name }} -c 'SELECT 1' >/dev/null 2>&1; do
                echo "waiting for database {{ .Values.database.name }} at {{ include "portal.db.host" . }}:{{ include "portal.db.port" . }}"
                sleep 3
              done
      containers:
        - name: portal
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
          imagePullPolicy: {{ .Values.image.pullPolicy }}
          ports:
            - name: http
              containerPort: {{ .Values.service.targetPort }}
              containerPort: 8000
              protocol: TCP
          envFrom:
            - configMapRef:
                name: {{ include "portal.fullname" . }}
          env:
            - name: OEG_SERVICE_URL
            - name: DB_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: {{ include "portal.db.secretName" . }}
                  key: {{ .Values.database.passwordKey }}
            - name: POSTGRESQL_SETTINGS__URL
              value: "postgresql+asyncpg://{{ .Values.database.user }}:$(DB_PASSWORD)@{{ include "portal.db.host" . }}:{{ include "portal.db.port" . }}/{{ .Values.database.name }}"
            {{- if include "portal.authEnabled" . }}
            - name: OIDC__CLIENT_SECRET
              valueFrom:
                configMapKeyRef:
                  name: {{ include "portal.configMapName" . }}
                  key: OEG_SERVICE_URL
            {{- with .Values.extraEnv }}
            {{- toYaml . | nindent 12 }}
                secretKeyRef:
                  name: {{ .Values.keycloak.existingSecret }}
                  key: {{ .Values.keycloak.clientId }}-client-secret
            {{- if .Values.keycloak.provisioner.enabled }}
            - name: PROVISIONING__CLIENT_SECRET
              valueFrom:
                secretKeyRef:
                  name: {{ .Values.keycloak.existingSecret }}
                  key: {{ .Values.keycloak.provisioner.clientId }}-client-secret
            - name: PROVISIONING__ENCRYPTION_KEY
              valueFrom:
                secretKeyRef:
                  name: {{ include "portal.secretName" . }}
                  key: api-client-encryption-key
            {{- end }}
            {{- end }}
          volumeMounts:
            - name: api-products
              mountPath: /etc/portal
              readOnly: true
          livenessProbe:
            httpGet:
              path: /
              path: /healthz
              port: http
            initialDelaySeconds: 30
            periodSeconds: 10
          readinessProbe:
            httpGet:
              path: /
              path: /readyz
              port: http
            initialDelaySeconds: 5
            periodSeconds: 5
          securityContext:
            runAsNonRoot: true
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            capabilities:
              drop: ["ALL"]
          resources:
            {{- toYaml .Values.resources | nindent 12 }}
      {{- with .Values.imagePullSecrets }}
      imagePullSecrets:
        {{- toYaml . | nindent 8 }}
      {{- end }}
      volumes:
        - name: api-products
          configMap:
            name: {{ include "portal.fullname" . }}
            items:
              - key: api-products.json
                path: api-products.json
+17 −0
Changes for deployment/templates/secret.yaml: 17 added lines, 0 removed lines.
Original line number Diff line number Diff line
{{- if and (include "portal.authEnabled" .) .Values.keycloak.provisioner.enabled }}
{{- $existing := (lookup "v1" "Secret" .Release.Namespace (include "portal.secretName" .)).data | default dict }}
apiVersion: v1
kind: Secret
metadata:
  name: {{ include "portal.secretName" . }}
  namespace: {{ .Release.Namespace }}
  labels:
    {{- include "portal.labels" . | nindent 4 }}
  annotations:
    # Kept on `helm uninstall`, like the database: the key decrypts what is stored there.
    helm.sh/resource-policy: keep
type: Opaque
data:
  # 256-bit key for API clients' registration tokens, as 64 hex characters.
  api-client-encryption-key: {{ index $existing "api-client-encryption-key" | default (randAlphaNum 64 | sha256sum | b64enc) }}
{{- end }}
Loading
Loading