Commit 06fd546d authored by Dimitrios Gogos's avatar Dimitrios Gogos
Browse files

feat: implement federation service with request handling and partner management

parent 4a1f333d
Loading
Loading
Loading
Loading
+4 −0
Changes for portal-gui/src/app/config/api.ts: 4 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -4,6 +4,10 @@ export const oeg = {
  jsonUrl: '/api/oeg/openapi.json',
};

export const federation = {
  baseUrl: '/api/oeg/platform/v1/federation',
};

export const OEGpaths = {
  application: '/apps',
  edgeCloudZones: '/edge-cloud-zones',
+63 −0
Changes for portal-gui/src/services/federation/federation-request.ts: 63 added lines, 0 removed lines.
Original line number Diff line number Diff line
import { federation } from '@/app/config/api';
import { apiFetch } from '@/lib/api-fetch';
import type { ProblemDetails } from '@/types/federation';

export class FederationRequestError extends Error {
  readonly status: number;
  readonly problem: ProblemDetails | null;

  constructor(message: string, status: number, problem: ProblemDetails | null) {
    super(message);
    this.status = status;
    this.problem = problem;
  }

  get code(): string | null {
    return this.problem?.type?.split(':').at(-1) ?? null;
  }
}

export function isProblem(error: unknown, code: string): boolean {
  return error instanceof FederationRequestError && error.code === code;
}

function parseBody(text: string): unknown {
  if (!text) return undefined;
  try {
    return JSON.parse(text);
  } catch {
    return text;
  }
}

function errorMessage(status: number, problem: ProblemDetails | null): string {
  const message =
    problem?.detail ?? problem?.title ?? `Request failed (${status})`;
  const params = problem?.invalidParams
    ?.map(({ param, reason }) => (reason ? `${param}: ${reason}` : param))
    .join('; ');
  return params ? `${message} (${params})` : message;
}

export async function federationRequest<T>(
  path: string,
  options?: RequestInit,
): Promise<T> {
  const response = await apiFetch(`${federation.baseUrl}${path}`, {
    ...options,
    headers: { 'Content-Type': 'application/json', ...options?.headers },
  });

  const body = parseBody(await response.text());

  if (!response.ok) {
    const problem =
      body && typeof body === 'object' ? (body as ProblemDetails) : null;
    throw new FederationRequestError(
      errorMessage(response.status, problem),
      response.status,
      problem,
    );
  }
  return body as T;
}
+103 −0
Changes for portal-gui/src/services/federation/federations.ts: 103 added lines, 0 removed lines.
Original line number Diff line number Diff line
import type {
  FederationContextIdResponse,
  FederationDetails,
  FederationRequestData,
  FederationResponseData,
  ZoneRegisteredData,
  ZoneRegistrationResponseData,
} from '@/types/federation';
import { federationRequest, isProblem } from './federation-request';

const federations = (partnerOpId: string) =>
  `/partners/${encodeURIComponent(partnerOpId)}/federations`;

const federation = (partnerOpId: string, contextId: string) =>
  `${federations(partnerOpId)}/${encodeURIComponent(contextId)}`;

const zones = (partnerOpId: string, contextId: string) =>
  `${federation(partnerOpId, contextId)}/zones`;

const zone = (partnerOpId: string, contextId: string, zoneId: string) =>
  `${zones(partnerOpId, contextId)}/${encodeURIComponent(zoneId)}`;

// null when there is no federation with this partner yet.
export async function findFederationContextId(
  partnerOpId: string,
): Promise<string | null> {
  try {
    const response = await federationRequest<FederationContextIdResponse>(
      federations(partnerOpId),
    );
    return response.FederationContextId;
  } catch (err) {
    if (isProblem(err, 'federation-context-unknown')) return null;
    throw err;
  }
}

export function createFederation(
  partnerOpId: string,
  request: FederationRequestData,
): Promise<FederationResponseData> {
  return federationRequest<FederationResponseData>(federations(partnerOpId), {
    method: 'POST',
    body: JSON.stringify(request),
  });
}

export function getFederationDetails(
  partnerOpId: string,
  contextId: string,
): Promise<FederationDetails> {
  return federationRequest<FederationDetails>(
    federation(partnerOpId, contextId),
  );
}

export async function deleteFederation(
  partnerOpId: string,
  contextId: string,
): Promise<void> {
  await federationRequest(federation(partnerOpId, contextId), {
    method: 'DELETE',
  });
}

export function subscribeZones(
  partnerOpId: string,
  contextId: string,
  zoneIds: string[],
): Promise<ZoneRegistrationResponseData> {
  return federationRequest<ZoneRegistrationResponseData>(
    zones(partnerOpId, contextId),
    {
      method: 'POST',
      body: JSON.stringify({ acceptedAvailabilityZones: zoneIds }),
    },
  );
}

export async function findZoneSubscription(
  partnerOpId: string,
  contextId: string,
  zoneId: string,
): Promise<ZoneRegisteredData | null> {
  try {
    return await federationRequest<ZoneRegisteredData>(
      zone(partnerOpId, contextId, zoneId),
    );
  } catch (err) {
    if (isProblem(err, 'zone-not-subscribed')) return null;
    throw err;
  }
}

export async function unsubscribeZone(
  partnerOpId: string,
  contextId: string,
  zoneId: string,
): Promise<void> {
  await federationRequest(zone(partnerOpId, contextId, zoneId), {
    method: 'DELETE',
  });
}
+30 −0
Changes for portal-gui/src/services/federation/partners.ts: 30 added lines, 0 removed lines.
Original line number Diff line number Diff line
import type { Partner, PartnerCreate, PartnerUpdate } from '@/types/federation';
import { federationRequest } from './federation-request';

const partner = (partnerOpId: string) =>
  `/partners/${encodeURIComponent(partnerOpId)}`;

export function listPartners(): Promise<Partner[]> {
  return federationRequest<Partner[]>('/partners');
}

export function getPartner(partnerOpId: string): Promise<Partner> {
  return federationRequest<Partner>(partner(partnerOpId));
}

export function registerPartner(request: PartnerCreate): Promise<Partner> {
  return federationRequest<Partner>('/partners', {
    method: 'POST',
    body: JSON.stringify(request),
  });
}

export function updatePartner(
  partnerOpId: string,
  changes: PartnerUpdate,
): Promise<Partner> {
  return federationRequest<Partner>(partner(partnerOpId), {
    method: 'PATCH',
    body: JSON.stringify(changes),
  });
}
+172 −0
Changes for portal-gui/src/types/federation.ts: 172 added lines, 0 removed lines.
Original line number Diff line number Diff line
// Partner registration uses the Federation Manager's own schema (snake_case).

export type PartnerStatus =
  | 'pending'
  | 'active'
  | 'suspended'
  | 'decommissioned';

export type Partner = {
  id: string;
  name: string;
  mcc_mnc: string;
  oauth2_client_id: string;
  base_url: string | null;
  our_client_id: string | null;
  our_client_secret_ref: string | null;
  token_endpoint: string | null;
  status: PartnerStatus;
};

export type PartnerCreate = {
  name: string;
  mcc_mnc: string;
  oauth2_client_id: string;
  base_url: string;
  our_client_id?: string;
  our_client_secret_ref?: string;
  token_endpoint?: string;
  status?: 'pending' | 'active';
};

export type PartnerUpdate = Partial<{
  name: string;
  oauth2_client_id: string;
  base_url: string;
  our_client_id: string | null;
  our_client_secret_ref: string | null;
  token_endpoint: string | null;
  status: PartnerStatus;
}>;

// Federation lifecycle bodies follow GSMA OPG.04 (camelCase).

export type PlatformCapability =
  | 'homeRouting'
  | 'Anchoring'
  | 'serviceAPIs'
  | 'faultMgmt'
  | 'eventMgmt'
  | 'resourceMonitor';

export type MobileNetworkIds = {
  mcc?: string;
  mncs?: string[];
};

export type ServiceEndpoint = {
  port: number;
  fqdn?: string;
  ipv4Addresses?: string[];
  ipv6Addresses?: string[];
};

export type ZoneDetails = {
  zoneId: string;
  geolocation: string;
  geographyDetails: string;
};

export type FederationRequestData = {
  initialDate: string;
  partnerStatusLink: string;
  origOPFederationId?: string;
  origOPCountryCode?: string;
  origOPMobileNetworkCodes?: MobileNetworkIds;
  origOPFixedNetworkCodes?: string[];
};

export type FederationResponseData = {
  federationContextId: string;
  platformCaps: PlatformCapability[];
  partnerOPFederationId?: string;
  partnerOPCountryCode?: string;
  edgeDiscoveryServiceEndPoint?: ServiceEndpoint;
  lcmServiceEndPoint?: ServiceEndpoint;
  partnerOPMobileNetworkCodes?: MobileNetworkIds;
  partnerOPFixedNetworkCodes?: string[];
  offeredAvailabilityZones?: ZoneDetails[];
  federationExpiryDate?: string;
  federationRenewalDate?: string;
};

export type FederationDetails = {
  edgeDiscoveryServiceEndPoint: ServiceEndpoint;
  lcmServiceEndPoint: ServiceEndpoint;
  allowedMobileNetworkIds?: MobileNetworkIds;
  allowedFixedNetworkIds?: string[];
  offeredAvailabilityZones?: ZoneDetails[];
};

export type FederationContextIdResponse = {
  FederationContextId: string;
};

export type GpuInfo = {
  gpuVendorType: 'GPU_PROVIDER_NVIDIA' | 'GPU_PROVIDER_AMD';
  gpuModeName: string;
  gpuMemory: number;
  numGPU: number;
};

export type ComputeResourceInfo = {
  cpuArchType: 'ISA_X86_64' | 'ISA_ARM_64';
  numCPU: string;
  memory: number;
  diskStorage?: number;
  gpu?: GpuInfo[];
  vpu?: number;
  fpga?: number;
  cpuExclusivity?: boolean;
};

export type Flavour = {
  flavourId: string;
  cpuArchType: 'ISA_X86' | 'ISA_X86_64' | 'ISA_ARM_64';
  supportedOSTypes: {
    architecture: string;
    distribution: string;
    version: string;
    license: string;
  }[];
  numCPU: number;
  memorySize: number;
  storageSize: number;
  gpu?: GpuInfo[];
};

export type ZoneRegisteredData = {
  zoneId: string;
  reservedComputeResources: ComputeResourceInfo[];
  computeResourceQuotaLimits: ComputeResourceInfo[];
  flavoursSupported: Flavour[];
  networkResources?: {
    egressBandWidth: number;
    dedicatedNIC: number;
    supportSriov: boolean;
    supportDPDK: boolean;
  };
  zoneServiceLevelObjsInfo?: {
    latencyRanges: { minLatency?: number; maxLatency?: number };
    jitterRanges: { minJitter?: number; maxJitter?: number };
    throughputRanges: { minThroughput?: number; maxThroughput?: number };
  };
};

export type ZoneRegistrationRequestData = {
  acceptedAvailabilityZones: string[];
  availZoneNotifLink?: string;
};

export type ZoneRegistrationResponseData = {
  acceptedZoneResourceInfo: ZoneRegisteredData[];
};

export type ProblemDetails = {
  type?: string;
  title?: string;
  status?: number;
  detail?: string;
  cause?: string;
  invalidParams?: { param: string; reason?: string }[];
};