From ea667ddcd4e89556d6db9ded969b252bdacfbf08 Mon Sep 17 00:00:00 2001 From: Marek Libra Date: Mon, 6 Jun 2022 10:31:31 +0200 Subject: [PATCH 1/4] MGMT-9754 Allow host static IP configuration --- .../NodeNetworkConfigurationPolicy.ts | 50 +++++ .../src/common/resources/NodeNetworkState.ts | 55 ++++++ ui/backend/src/common/resources/index.ts | 3 + ui/backend/src/common/resources/metadata.ts | 7 + ui/backend/src/common/resources/node.ts | 25 +++ ui/backend/src/common/types.ts | 42 ++++ ui/backend/src/endpoints/changeStaticIps.ts | 160 +++++++++++++++ ui/backend/src/endpoints/configure.ts | 12 +- ui/backend/src/endpoints/index.ts | 3 +- ui/backend/src/endpoints/readiness.ts | 2 +- ui/backend/src/index.ts | 3 +- .../nodenetworkconfigurationpolicy.ts | 19 ++ ui/frontend/package.json | 3 +- .../ApiAddressPage/ApiAddressPage.tsx | 2 +- .../AutomaticManualDecision.tsx | 44 +++++ .../AutomaticManualDecision/index.ts | 1 + .../DomainCertificates.css | 6 +- .../DomainCertificates.tsx | 2 +- .../DomainCertificatesPanel.tsx | 2 +- .../components/HostIPPage/HostIPDecision.tsx | 26 +++ .../HostIPPage/HostIPDecisionPage.tsx | 32 +++ .../components/HostIPPage/HostStaticIP.css | 7 + .../components/HostIPPage/HostStaticIP.tsx | 184 ++++++++++++++++++ .../components/HostIPPage/StaticIPPage.tsx | 38 ++++ .../src/components/HostIPPage/StaticIPs.tsx | 19 ++ .../components/HostIPPage/StaticIPsPanel.tsx | 48 +++++ .../src/components/HostIPPage/index.ts | 2 + .../src/components/IpTriplet/IpTriplet.css | 2 +- .../IpTripletsSelector/IpTripletsSelector.tsx | 10 +- .../src/components/K8SStateContext.tsx | 54 ++++- .../components/PasswordPage/PasswordPage.tsx | 2 +- .../src/components/PersistPage/constants.ts | 1 + .../src/components/PersistPage/persist.ts | 5 + .../components/PersistPage/persistDomain.ts | 2 +- .../PersistPage/persistStaticIPs.ts | 73 +++++++ .../PersistProgress/PersistProgress.tsx | 6 + .../src/components/Settings/Settings.tsx | 6 +- .../components/WelcomePage/WelcomePage.tsx | 4 +- .../components/WelcomePage/initialDataLoad.ts | 122 +++++++++++- ui/frontend/src/components/Wizard/Wizard.tsx | 4 + .../WizardProgress/WizardProgress.tsx | 3 + .../WizardProgress/WizardProgressContext.tsx | 20 +- ui/frontend/src/components/index.ts | 1 + ui/frontend/src/components/types.ts | 5 +- ui/frontend/src/components/utils.ts | 40 +++- .../NodeNetworkConfigurationPolicy.ts | 12 ++ ui/frontend/src/resources/NodeNetworkState.ts | 12 ++ ui/frontend/src/resources/node.ts | 8 + ui/frontend/yarn.lock | 18 ++ 49 files changed, 1169 insertions(+), 38 deletions(-) create mode 100644 ui/backend/src/common/resources/NodeNetworkConfigurationPolicy.ts create mode 100644 ui/backend/src/common/resources/NodeNetworkState.ts create mode 100644 ui/backend/src/common/resources/node.ts create mode 100644 ui/backend/src/endpoints/changeStaticIps.ts create mode 100644 ui/backend/src/resources/nodenetworkconfigurationpolicy.ts create mode 100644 ui/frontend/src/components/AutomaticManualDecision/AutomaticManualDecision.tsx create mode 100644 ui/frontend/src/components/AutomaticManualDecision/index.ts create mode 100644 ui/frontend/src/components/HostIPPage/HostIPDecision.tsx create mode 100644 ui/frontend/src/components/HostIPPage/HostIPDecisionPage.tsx create mode 100644 ui/frontend/src/components/HostIPPage/HostStaticIP.css create mode 100644 ui/frontend/src/components/HostIPPage/HostStaticIP.tsx create mode 100644 ui/frontend/src/components/HostIPPage/StaticIPPage.tsx create mode 100644 ui/frontend/src/components/HostIPPage/StaticIPs.tsx create mode 100644 ui/frontend/src/components/HostIPPage/StaticIPsPanel.tsx create mode 100644 ui/frontend/src/components/HostIPPage/index.ts create mode 100644 ui/frontend/src/components/PersistPage/persistStaticIPs.ts create mode 100644 ui/frontend/src/resources/NodeNetworkConfigurationPolicy.ts create mode 100644 ui/frontend/src/resources/NodeNetworkState.ts create mode 100644 ui/frontend/src/resources/node.ts diff --git a/ui/backend/src/common/resources/NodeNetworkConfigurationPolicy.ts b/ui/backend/src/common/resources/NodeNetworkConfigurationPolicy.ts new file mode 100644 index 000000000..e760eb09c --- /dev/null +++ b/ui/backend/src/common/resources/NodeNetworkConfigurationPolicy.ts @@ -0,0 +1,50 @@ +import { Metadata } from './metadata'; +import { NNRouteConfig } from './NodeNetworkState'; +import { IResource } from './resource'; + +export type NodeNetworkConfigurationPolicyType = 'nmstate.io/v1'; +export const NodeNetworkConfigurationPolicyApiVersion: NodeNetworkConfigurationPolicyType = + 'nmstate.io/v1'; + +export type NodeNetworkConfigurationPolicyKindType = 'NodeNetworkConfigurationPolicy'; +export const NodeNetworkConfigurationPolicyKind: NodeNetworkConfigurationPolicyKindType = + 'NodeNetworkConfigurationPolicy'; + +export type NNCPInterface = { + name: string; + state: 'up' | 'down'; + type?: 'ethernet'; + ipv4: { + address: { + ip: string; + 'prefix-length': number; + }[]; + enabled: boolean; + }; +}; +export interface NodeNetworkConfigurationPolicy extends IResource { + apiVersion: NodeNetworkConfigurationPolicyType; + kind: NodeNetworkConfigurationPolicyKindType; + metadata: Metadata; + + spec: { + nodeSelector?: { + // kubernetes.io/hostname: ztpfw-edgecluster0-cluster-master-0 + [key: string]: string; + }; + desiredState: { + interfaces: NNCPInterface[]; + + 'dns-resolver'?: { + // TODO: don't forget to set ipv4[auto-dns] to false + config: { + server: string[]; + }; + }; + + routes?: { + config?: NNRouteConfig[]; + }; + }; + }; +} diff --git a/ui/backend/src/common/resources/NodeNetworkState.ts b/ui/backend/src/common/resources/NodeNetworkState.ts new file mode 100644 index 000000000..4d8f92738 --- /dev/null +++ b/ui/backend/src/common/resources/NodeNetworkState.ts @@ -0,0 +1,55 @@ +import { Metadata } from './metadata'; +import { IResource } from './resource'; + +export type NodeNetworkStateType = 'nmstate.io/v1beta1'; +export const NodeNetworkStateApiVersion: NodeNetworkStateType = 'nmstate.io/v1beta1'; + +export type NodeNetworkStateKindType = 'NodeNetworkState'; +export const NodeNetworkStateKind: NodeNetworkStateKindType = 'NodeNetworkState'; + +interface NodeNetworkStateInterface { + ipv4: { + address: { + ip: string; + 'prefix-length': number; + }[]; + enabled: false; + }; + // ipv6: {} + type: 'ethernet' | 'anything-else-is-not-important-now'; + name: string; + state: 'down' | 'up'; + mtu: number; + 'mac-address': string; + dhcp: boolean; + enabled: boolean; + 'auto-dns': boolean; + 'auto-gateway': boolean; +} + +export interface NNRouteConfig { + destination: string; // subnet mask + metric: number; + 'next-hop-address': string; // gateway + 'next-hop-interface': string; // interface name +} + +export interface NodeNetworkState extends IResource { + apiVersion: NodeNetworkStateType; + kind: NodeNetworkStateKindType; + metadata: Metadata; + + status?: { + currentState?: { + 'dns-resolver'?: { + running?: { + server?: string[]; + }; + }; + interfaces?: NodeNetworkStateInterface[]; + routes?: { + running?: NNRouteConfig[]; + }; + }; + }; +} diff --git a/ui/backend/src/common/resources/index.ts b/ui/backend/src/common/resources/index.ts index e25a4340b..df779f888 100644 --- a/ui/backend/src/common/resources/index.ts +++ b/ui/backend/src/common/resources/index.ts @@ -11,3 +11,6 @@ export * from './oauthclient'; export * from './deployment'; export * from './pod'; export * from './clusteroperator'; +export * from './NodeNetworkState'; +export * from './NodeNetworkConfigurationPolicy'; +export * from './node'; diff --git a/ui/backend/src/common/resources/metadata.ts b/ui/backend/src/common/resources/metadata.ts index cb2f1ce78..acf16399d 100644 --- a/ui/backend/src/common/resources/metadata.ts +++ b/ui/backend/src/common/resources/metadata.ts @@ -1,3 +1,9 @@ +export interface OwnerReference { + apiVersion: string; + kind: string; + name: string; + uid: string; +} export interface Metadata { name?: string; namespace?: string; @@ -10,4 +16,5 @@ export interface Metadata { deletionTimestamp?: string; selfLink?: string; finalizers?: string[]; + ownerReferences?: OwnerReference[]; } diff --git a/ui/backend/src/common/resources/node.ts b/ui/backend/src/common/resources/node.ts new file mode 100644 index 000000000..12e8e7f06 --- /dev/null +++ b/ui/backend/src/common/resources/node.ts @@ -0,0 +1,25 @@ +import { Metadata } from './metadata'; +import { IResource } from './resource'; + +export type NodeType = 'v1'; +export const NodeApiVersion: NodeType = 'v1'; + +export type NodeKindType = 'Node'; +export const NodeKind: NodeKindType = 'Node'; + +export interface Node extends IResource { + apiVersion: NodeType; + kind: NodeKindType; + metadata: Metadata; + /* + node-role.kubernetes.io/master: "" + node-role.kubernetes.io/worker: "" + */ + + status?: { + addresses?: { + address: string; + type: 'Hostname' | 'InternalIP'; + }[]; + }; +} diff --git a/ui/backend/src/common/types.ts b/ui/backend/src/common/types.ts index 19ebf6a87..47dc5a298 100644 --- a/ui/backend/src/common/types.ts +++ b/ui/backend/src/common/types.ts @@ -12,3 +12,45 @@ export type ChangeDomainInputType = { [key: /* ~ domain */ string]: TlsCertificate; }; }; + +export type HostInterfaceType = { + name: string; // i.e. "eth0" + // type: 'ethernet' | 'bond' | 'linux-bridge'; + // state: 'up' | 'down'; + ipv4: { + // dhcp: boolean; always "false" for our static ips case + // enabled: boolean; always true otherwise not found + address?: { + // We support only one static IP per interface + ip?: string; // regular (dotted) IP form + prefixLength?: number; + validation?: string; + + gateway?: string; + gatewayValidation?: string; // undefined if valid + }; + }; +}; + +export type HostType = { + nodeName: string; // metadata.name of the Node resource + hostname?: string; // kubernetes.io/hostname label in Node or nmstate spec.nodeSelector (optional) + nncpName?: string; // metadata.name of the NodeNetworkConfigurationPolicy (if exists) + + role?: 'control' | 'worker'; // node-role.kubernetes.io/worker , node-role.kubernetes.io/master + + interfaces: HostInterfaceType[]; + dns?: string[]; + + dnsValidation?: string; // undefined if valid +}; +/* +export type Network4Type = { + prefixLength: number; + dns?: string; + gw?: string; +}; +*/ +export type ChangeStaticIpsInputType = { + hosts?: HostType[]; +}; diff --git a/ui/backend/src/endpoints/changeStaticIps.ts b/ui/backend/src/endpoints/changeStaticIps.ts new file mode 100644 index 000000000..f6eec936e --- /dev/null +++ b/ui/backend/src/endpoints/changeStaticIps.ts @@ -0,0 +1,160 @@ +import { Request, Response } from 'express'; + +import { + ChangeStaticIpsInputType, + PatchType, + NodeNetworkConfigurationPolicy, + NNCPInterface, + HostType, + NNRouteConfig, +} from '../frontend-shared'; +import { getToken, unauthorized } from '../k8s'; +import { patchNodeNetworkConfigurationPolicy } from '../resources/nodenetworkconfigurationpolicy'; + +const logger = console; + +const validateHost = (host: HostType): boolean => + !!host.nodeName && + !!host.dns?.length && + !!host.interfaces?.length && + host.interfaces?.every( + (i) => + i.name && i.ipv4?.address?.gateway && i.ipv4?.address?.ip && i.ipv4?.address?.prefixLength, + ); + +const changeStaticIpsImpl = async ( + res: Response, + token: string, + input: ChangeStaticIpsInputType, +): Promise => { + logger.debug('ChangeStaticIps endpoint called, input:', input); + + const hosts = input.hosts; + + if (!hosts?.length) { + res.writeHead(422).end(); + return; + } + + // Store the data + // Optimization: instead of sequential processing wait on all promises (not implemented atm due to debugging) + for (let index = 0; index < hosts.length; index++) { + const host = hosts[index]; + + if (!validateHost(host)) { + logger.error('ChangeStaticIps incorrect input, host: ', host); + res.writeHead(422, `Incorrect host ${host.nodeName}`).end(); + break; + } + + if (host.nncpName) { + // assumption: the spec.nodeSelector is already properly set (otherwise host.nncpName is not provided) - so we can PATCH right away + const dns = host.dns?.length + ? { + config: { + server: host.dns, + }, + } + : undefined; + + const routes = { + config: host.interfaces + .map((i): NNRouteConfig | undefined => { + // const subnet = getSubnetMask(i.ipv4.address?.ip, i.ipv4.address?.prefixLength); + const gateway = i.ipv4.address?.gateway; + + if (!subnet) { + return undefined; + } + + return { + destination: '0.0.0.0/0', // TODO: Can we use this as default?? + metric: 1000, + 'next-hop-address': gateway, + 'next-hop-interface': i.name, + }; + }) + .filter(Boolean) as NNRouteConfig[], + }; + + const desiredState: NodeNetworkConfigurationPolicy['spec']['desiredState'] = { + interfaces: (host.interfaces || []) + ?.map((i): NNCPInterface | undefined => { + const ip = i.ipv4.address?.ip; + const prefixLength = i.ipv4.address?.prefixLength; + + if (!ip || !prefixLength) { + return undefined; + } + + return { + name: i.name, + state: 'up', + ipv4: { + address: [ + { + ip, + 'prefix-length': prefixLength, + }, + ], + enabled: true, + }, + }; + }) + .filter(Boolean) as NNCPInterface[], + 'dns-resolver': dns, + routes, + }; + + const patches: PatchType[] = [ + { + op: 'replace', // let's risk here, maybe we should query the NNCP first and than decide about replace vs. add + path: '/spec/desiredState', + value: desiredState, + }, + ]; + + try { + await patchNodeNetworkConfigurationPolicy(token, { name: host.nncpName }, patches); + } catch (e) { + logger.error( + `Failed to patch NodeNetworkConfigurationPolicy "${host.nncpName}": `, + patches, + e, + ); + res + .writeHead(500, `Failed to patch NodeNetworkConfigurationPolicy "${host.nncpName}"`) + .end(); + return; + } + } else { + // build one from a template + // TODO + } + } + + res.writeHead(200).end(); // All good +}; + +export function changeStaticIps(req: Request, res: Response): void { + logger.debug('changeStaticIps endpoint called'); + const token = getToken(req); + if (!token) return unauthorized(req, res); + + // no need to register server middleware just for that + const body: Buffer[] = []; + req + .on('data', (chunk: Buffer) => { + body.push(chunk); + }) + .on('end', async () => { + try { + const data: string = Buffer.concat(body).toString(); + const encoded = JSON.parse(data) as ChangeStaticIpsInputType; + await changeStaticIpsImpl(res, token, encoded); + } catch (e) { + logger.error('Failed to parse input for changeStaticIps'); + res.writeHead(422).end(); + } + }); +} diff --git a/ui/backend/src/endpoints/configure.ts b/ui/backend/src/endpoints/configure.ts index 118be8145..a7e4d3f1f 100644 --- a/ui/backend/src/endpoints/configure.ts +++ b/ui/backend/src/endpoints/configure.ts @@ -2,10 +2,10 @@ import { Request, Response } from 'express'; import { getOauthInfoPromise } from '../k8s/oauth'; export async function configure(_: Request, res: Response): Promise { - const oauthInfo = await getOauthInfoPromise() - const responsePayload = { - token_endpoint: oauthInfo.token_endpoint, - } - res.setHeader('Content-Type', 'application/json') - res.end(JSON.stringify(responsePayload)) + const oauthInfo = await getOauthInfoPromise(); + const responsePayload = { + token_endpoint: oauthInfo.token_endpoint, + }; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify(responsePayload)); } diff --git a/ui/backend/src/endpoints/index.ts b/ui/backend/src/endpoints/index.ts index 0ba3607b6..9b01c4f81 100644 --- a/ui/backend/src/endpoints/index.ts +++ b/ui/backend/src/endpoints/index.ts @@ -6,4 +6,5 @@ export * from './serve'; export * from './htpasswd'; export * from './changeDomain'; export * from './user'; -export * from './configure'; \ No newline at end of file +export * from './configure'; +export * from './changeStaticIps'; diff --git a/ui/backend/src/endpoints/readiness.ts b/ui/backend/src/endpoints/readiness.ts index 6cfd998b5..d61952d72 100644 --- a/ui/backend/src/endpoints/readiness.ts +++ b/ui/backend/src/endpoints/readiness.ts @@ -1,4 +1,4 @@ -import { liveness } from "./liveness"; +import { liveness } from './liveness'; /* import { Request, Response } from 'express'; diff --git a/ui/backend/src/index.ts b/ui/backend/src/index.ts index 1037a995a..e23fa846b 100644 --- a/ui/backend/src/index.ts +++ b/ui/backend/src/index.ts @@ -73,7 +73,7 @@ const start = () => { const app = express(); - app.use(helmet.frameguard()) // to enable X-Frame-Options header for logout + app.use(helmet.frameguard()); // to enable X-Frame-Options header for logout if (process.env.CORS) { app.use( @@ -93,6 +93,7 @@ const start = () => { app.get(`/logout`, logout); app.post(`/htpasswd`, htpasswd); + app.post(`/changeStaticIps`, changeStaticIps); app.post(`/changeDomain`, changeDomain); app.get('/user', user); diff --git a/ui/backend/src/resources/nodenetworkconfigurationpolicy.ts b/ui/backend/src/resources/nodenetworkconfigurationpolicy.ts new file mode 100644 index 000000000..6cad5f86c --- /dev/null +++ b/ui/backend/src/resources/nodenetworkconfigurationpolicy.ts @@ -0,0 +1,19 @@ +import { + NodeNetworkConfigurationPolicy, + PatchType, + NodeNetworkConfigurationPolicyApiVersion, +} from '../frontend-shared'; +import { getClusterApiUrl, jsonPatch } from '../k8s'; + +export const patchNodeNetworkConfigurationPolicy = ( + token: string, + metadata: { name: string }, + patches: PatchType[], +) => + jsonPatch( + `${getClusterApiUrl()}/apis/${NodeNetworkConfigurationPolicyApiVersion}/nodenetworkConfigurationPolicies/${ + metadata.name + }`, + patches, + token, + ); diff --git a/ui/frontend/package.json b/ui/frontend/package.json index 7f4f058a4..6270f10ea 100644 --- a/ui/frontend/package.json +++ b/ui/frontend/package.json @@ -15,6 +15,7 @@ "@types/react-dom": "^17.0.9", "buffer": "^6.0.3", "file-saver": "^2.0.5", + "ip-address": "^7.1.0", "lodash": "^4.17.21", "react": "^17.0.2", "react-dom": "^17.0.2", @@ -36,7 +37,7 @@ }, "scripts": { "start": "HTTPS=true SSL_CRT_FILE=${TLS_CERT_FILE} SSL_KEY_FILE=${TLS_KEY_FILE} react-scripts start", - "build": "react-scripts build", + "build": "GENERATE_SOURCEMAP=false react-scripts build", "backend-common": "rm -rf src/copy-backend-common ; cp -r ../backend/src/common src/copy-backend-common", "get-sha": "SHA=$(git rev-parse HEAD) ; echo ${SHA} ; echo \"GIT_BUILD_SHA = '${SHA}';\" >> src/sha.ts", "prebuild": "yarn backend-common", diff --git a/ui/frontend/src/components/ApiAddressPage/ApiAddressPage.tsx b/ui/frontend/src/components/ApiAddressPage/ApiAddressPage.tsx index 3381f2cb4..2ccb5d876 100644 --- a/ui/frontend/src/components/ApiAddressPage/ApiAddressPage.tsx +++ b/ui/frontend/src/components/ApiAddressPage/ApiAddressPage.tsx @@ -29,7 +29,7 @@ export const ApiAddressPage: React.FC = () => { middle={} bottom={ !!apiaddr.trim() && apiaddrValidation.valid} /> diff --git a/ui/frontend/src/components/AutomaticManualDecision/AutomaticManualDecision.tsx b/ui/frontend/src/components/AutomaticManualDecision/AutomaticManualDecision.tsx new file mode 100644 index 000000000..210cfd505 --- /dev/null +++ b/ui/frontend/src/components/AutomaticManualDecision/AutomaticManualDecision.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { Radio, Split, SplitItem } from '@patternfly/react-core'; + +export type AutomaticManualDecisionProps = { + id?: string; + isAutomatic: boolean; + setAutomatic: (isAutomatic: boolean) => void; + + labelAutomatic?: string; + labelManual?: string; +}; + +/* TODO: Share this component with the Custom DOmain Certificates patch once merged this or another */ +export const AutomaticManualDecision: React.FC = ({ + id = 'automanual-decision', + isAutomatic, + setAutomatic, + labelAutomatic = 'Automatic', + labelManual = 'Manual', +}) => ( + + {/* TODO: Improve positioning on the Wizard's page */} + + setAutomatic(true)} + /> + + + setAutomatic(false)} + /> + + +); diff --git a/ui/frontend/src/components/AutomaticManualDecision/index.ts b/ui/frontend/src/components/AutomaticManualDecision/index.ts new file mode 100644 index 000000000..05d68caa2 --- /dev/null +++ b/ui/frontend/src/components/AutomaticManualDecision/index.ts @@ -0,0 +1 @@ +export * from './AutomaticManualDecision'; diff --git a/ui/frontend/src/components/DomainCertificatesPage/DomainCertificates.css b/ui/frontend/src/components/DomainCertificatesPage/DomainCertificates.css index 7d2908fa5..164fd8701 100644 --- a/ui/frontend/src/components/DomainCertificatesPage/DomainCertificates.css +++ b/ui/frontend/src/components/DomainCertificatesPage/DomainCertificates.css @@ -1,7 +1,7 @@ -.domain-certificates .pf-c-panel__main { +.page-inner-panel .pf-c-panel__main { max-height: 16rem !important; } -.domain-certificate__item { +.page-inner-panel__item { width: 58rem; } @@ -15,6 +15,6 @@ color: var(--pf-global--danger-color--100); } -.domain-certificates .pf-c-expandable-section__toggle-text { +.page-inner-panel .pf-c-expandable-section__toggle-text { text-align: left; } diff --git a/ui/frontend/src/components/DomainCertificatesPage/DomainCertificates.tsx b/ui/frontend/src/components/DomainCertificatesPage/DomainCertificates.tsx index 65198b0fc..33db28c1f 100644 --- a/ui/frontend/src/components/DomainCertificatesPage/DomainCertificates.tsx +++ b/ui/frontend/src/components/DomainCertificatesPage/DomainCertificates.tsx @@ -18,7 +18,7 @@ export const DomainCertificates: React.FC = () => { If a certificate is not provided, a self-signed one will be automatically generated. - + diff --git a/ui/frontend/src/components/DomainCertificatesPage/DomainCertificatesPanel.tsx b/ui/frontend/src/components/DomainCertificatesPage/DomainCertificatesPanel.tsx index 16b95d619..a93d9ead1 100644 --- a/ui/frontend/src/components/DomainCertificatesPage/DomainCertificatesPanel.tsx +++ b/ui/frontend/src/components/DomainCertificatesPage/DomainCertificatesPanel.tsx @@ -210,7 +210,7 @@ export const DomainCertificatesPanel: React.FC<{ const { domain } = useK8SStateContext(); return ( - + = (props) => { + return ( + + + + How do you want to configure IPv4? <RequiredBadge /> + + + + Choose whether you want to automatically assign IP addresses for hosts of the cluster. + + + + + + ); +}; diff --git a/ui/frontend/src/components/HostIPPage/HostIPDecisionPage.tsx b/ui/frontend/src/components/HostIPPage/HostIPDecisionPage.tsx new file mode 100644 index 000000000..aacfc25c8 --- /dev/null +++ b/ui/frontend/src/components/HostIPPage/HostIPDecisionPage.tsx @@ -0,0 +1,32 @@ +import React from 'react'; + +import { Page } from '../Page'; +import { ContentThreeRows } from '../ContentThreeRows'; +import { WizardProgress, WizardStepType } from '../WizardProgress'; +import { useWizardProgressContext } from '../WizardProgress/WizardProgressContext'; +import { WizardFooter } from '../WizardFooter'; +import { HostIPDecision } from './HostIPDecision'; + +export const HostIPDecisionPage: React.FC = () => { + const { setActiveStep } = useWizardProgressContext(); + React.useEffect(() => setActiveStep('hostips'), [setActiveStep]); + + const [isAutomatic, setAutomatic] = React.useState( + () => true /* TODO: implement this initial state ! */, + ); + + let next: WizardStepType = 'apiaddr'; + if (!isAutomatic) { + next = 'staticips'; + } + + return ( + + } + middle={} + bottom={ true} />} + /> + + ); +}; diff --git a/ui/frontend/src/components/HostIPPage/HostStaticIP.css b/ui/frontend/src/components/HostIPPage/HostStaticIP.css new file mode 100644 index 000000000..eaa81168b --- /dev/null +++ b/ui/frontend/src/components/HostIPPage/HostStaticIP.css @@ -0,0 +1,7 @@ +.host-static-ip-prefix-length { + width: 2.5rem; +} + +.host-static-ip__input { + width: 10rem; +} \ No newline at end of file diff --git a/ui/frontend/src/components/HostIPPage/HostStaticIP.tsx b/ui/frontend/src/components/HostIPPage/HostStaticIP.tsx new file mode 100644 index 000000000..b40f9031a --- /dev/null +++ b/ui/frontend/src/components/HostIPPage/HostStaticIP.tsx @@ -0,0 +1,184 @@ +import React from 'react'; + +import { + ExpandableSection, + Flex, + FlexItem, + Form, + FormGroup, + TextInput, +} from '@patternfly/react-core'; +import { ExclamationCircleIcon } from '@patternfly/react-icons'; +import { global_danger_color_100 as dangerColor } from '@patternfly/react-tokens'; + +import { K8SStateContextData } from '../types'; +import { getSubnetRange } from '../utils'; +import { HostType } from '../../copy-backend-common'; + +import './HostStaticIP.css'; + +const getRole = (host: HostType) => { + if (host.role === 'control') { + return 'control plane'; + } + return host.role || 'default role'; +}; + +// https://docs.openshift.com/container-platform/4.7/networking/k8s_nmstate/k8s-nmstate-about-the-k8s-nmstate-operator.html +// TODO: +// - Force single network configuration for all masters +// - Check if master's IP address is of the expected range +// - allow sharing of network configuration by workers (in the future, so far we have just one worker) +export const HostStaticIP: React.FC<{ + host: HostType; + handleSetHost: K8SStateContextData['handleSetHost']; + isEdit: boolean; + isLeadingControlPlane: boolean; +}> = ({ host, handleSetHost, isEdit, isLeadingControlPlane }) => { + const [isExpanded, setExpanded] = React.useState(false); + + const interfaces = host.interfaces; + const toggleText = `${getRole(host)}: ${host.hostname || 'Host'}`; + + const dns = host.dns || []; + const dnsValidation = host.dnsValidation; + + const canEditSubnet = host.role !== 'control' || isLeadingControlPlane; + + return ( + setExpanded(!isExpanded)} + isExpanded={isExpanded} + displaySize="large" + > +
+ + {interfaces.map((intf) => { + // TODO: eventually extend for multiple addresses per a single intreface + const helperText = + (!intf.ipv4.address?.validation && + getSubnetRange(intf.ipv4.address?.ip, intf.ipv4.address?.prefixLength)) || + 'IP address of the interface.'; + + const idIP = `host-static-ip-${host.hostname}`; + const idPrefixLength = `${idIP}-prefix-length`; + const idGateway = `${idIP}-gw`; + const idDns = `${idIP}-dns`; + + const address = intf.ipv4?.address?.ip; // || TWELVE_SPACES; + const prefixLength = intf.ipv4.address?.prefixLength || ''; + const gateway = intf.ipv4.address?.gateway || ''; + const gatewayValidation = intf.ipv4.address?.gatewayValidation; + + const setAddress = (addr: string) => { + // So far we support only one IP per interface + intf.ipv4.address = intf.ipv4.address || {}; + intf.ipv4.address.ip = addr; + handleSetHost(host); + }; + + const setPrefixLength = (val: string) => { + const prefixLength = parseInt(val); + if (!isNaN(prefixLength) && prefixLength > 0 && prefixLength < 32) { + intf.ipv4.address = intf.ipv4.address || {}; + intf.ipv4.address.prefixLength = prefixLength; + handleSetHost(host); + } + }; + + const setGateway = (val: string) => { + intf.ipv4.address = intf.ipv4.address || {}; + intf.ipv4.address.gateway = val; + handleSetHost(host); + }; + + const setDns = (val: string) => { + const nameservers = val.split(',').map((ns: string) => ns.trim()); + host.dns = nameservers; + handleSetHost(host); + }; + + return ( + + + + + / + + {intf.ipv4.address?.validation && ( + + )} + + + + + + + + + + + + + + ); + })} + +
+
+ ); +}; diff --git a/ui/frontend/src/components/HostIPPage/StaticIPPage.tsx b/ui/frontend/src/components/HostIPPage/StaticIPPage.tsx new file mode 100644 index 000000000..97c92e0c6 --- /dev/null +++ b/ui/frontend/src/components/HostIPPage/StaticIPPage.tsx @@ -0,0 +1,38 @@ +import React from 'react'; + +import { Page } from '../Page'; +import { ContentThreeRows } from '../ContentThreeRows'; +import { WizardProgress } from '../WizardProgress'; +import { useWizardProgressContext } from '../WizardProgress/WizardProgressContext'; +import { WizardFooter } from '../WizardFooter'; +import { StaticIPs } from './StaticIPs'; +import { useK8SStateContext } from '../K8SStateContext'; + +export const StaticIPPage: React.FC = () => { + const { setActiveStep } = useWizardProgressContext(); + const { hosts } = useK8SStateContext(); + + // No special step for that + React.useEffect(() => setActiveStep('hostips'), [setActiveStep]); + + const isNextEnabled = () => { + const failedHost = hosts.find( + (h) => + h.dnsValidation || + h.interfaces.find( + (intf) => !!intf.ipv4.address?.validation || !!intf.ipv4.address?.gatewayValidation, + ), + ); + return !failedHost; + }; + + return ( + + } + middle={} + bottom={} + /> + + ); +}; diff --git a/ui/frontend/src/components/HostIPPage/StaticIPs.tsx b/ui/frontend/src/components/HostIPPage/StaticIPs.tsx new file mode 100644 index 000000000..e7da5327d --- /dev/null +++ b/ui/frontend/src/components/HostIPPage/StaticIPs.tsx @@ -0,0 +1,19 @@ +import React from 'react'; +import { Stack, StackItem, Title } from '@patternfly/react-core'; +import { StaticIPsPanel } from './StaticIPsPanel'; + +export const StaticIPs: React.FC<{ isEdit: boolean }> = ({ isEdit }) => { + return ( + + + Configure your TCP/IP settings for all available hosts + + + All control plane nodes must be on a single subnet. + + + + + + ); +}; diff --git a/ui/frontend/src/components/HostIPPage/StaticIPsPanel.tsx b/ui/frontend/src/components/HostIPPage/StaticIPsPanel.tsx new file mode 100644 index 000000000..e4d486311 --- /dev/null +++ b/ui/frontend/src/components/HostIPPage/StaticIPsPanel.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import { Panel, PanelMain, PanelMainBody } from '@patternfly/react-core'; + +import { useK8SStateContext } from '../K8SStateContext'; +import { HostStaticIP } from './HostStaticIP'; + +export const StaticIPsPanel: React.FC<{ + isScrollable?: boolean; + isEdit?: boolean; +}> = ({ isScrollable, isEdit = true }) => { + const { hosts, handleSetHost } = useK8SStateContext(); + + const sortedHosts = hosts.sort((h1, h2) => { + // First by role, control plane nodes first + if (h1.role !== h2.role) { + if (h1.role === 'control') { + return -1; + } + return 1; + } + + // Then by hostname + return (h1.hostname || h1.nodeName).localeCompare(h2.hostname || h2.nodeName); + }); + + return ( + + + + {sortedHosts.map((h, idx) => { + // Assumption: hosts are sorted by "role", control planes first + const isLeadingControlPlane = idx === 0; + + return ( + + ); + })} + + + + ); +}; diff --git a/ui/frontend/src/components/HostIPPage/index.ts b/ui/frontend/src/components/HostIPPage/index.ts new file mode 100644 index 000000000..4395211d8 --- /dev/null +++ b/ui/frontend/src/components/HostIPPage/index.ts @@ -0,0 +1,2 @@ +export * from './HostIPDecisionPage'; +export * from './StaticIPPage'; diff --git a/ui/frontend/src/components/IpTriplet/IpTriplet.css b/ui/frontend/src/components/IpTriplet/IpTriplet.css index 3fdabafe0..373c2ca54 100644 --- a/ui/frontend/src/components/IpTriplet/IpTriplet.css +++ b/ui/frontend/src/components/IpTriplet/IpTriplet.css @@ -9,7 +9,7 @@ } .ip-triplet__narrow { - width: 4rem; + width: 3.5rem; } .ip-triplet-success { diff --git a/ui/frontend/src/components/IpTripletsSelector/IpTripletsSelector.tsx b/ui/frontend/src/components/IpTripletsSelector/IpTripletsSelector.tsx index c4e7da3b2..e79d923c1 100644 --- a/ui/frontend/src/components/IpTripletsSelector/IpTripletsSelector.tsx +++ b/ui/frontend/src/components/IpTripletsSelector/IpTripletsSelector.tsx @@ -13,19 +13,19 @@ export const IpTripletsSelector: React.FC<{ id?: string; address: string; setAddress: IpTripletProps['setAddress']; - validation: IpTripletSelectorValidationType; + validation?: IpTripletSelectorValidationType; isNarrow?: boolean; isDisabled?: boolean; }> = ({ id = 'ip-triplet', address, setAddress, validation, isNarrow, isDisabled }) => { const [focus, setFocus] = React.useState(0); - const validated = validation.triplets || []; + const validated = validation?.triplets || []; if (isDisabled) { return ; } return ( - <> + {([0, 1, 2, 3] as IpTripletIndex[]).map((position) => ( {position > 0 ? '.' : ''} @@ -42,9 +42,9 @@ export const IpTripletsSelector: React.FC<{ /> ))} - {!validation.valid && ( + {!validation?.valid && ( )} - + ); }; diff --git a/ui/frontend/src/components/K8SStateContext.tsx b/ui/frontend/src/components/K8SStateContext.tsx index 7199f13e1..70e4a61ab 100644 --- a/ui/frontend/src/components/K8SStateContext.tsx +++ b/ui/frontend/src/components/K8SStateContext.tsx @@ -7,13 +7,15 @@ import { K8SStateContextDataFields, CustomCertsValidationType, } from './types'; -import { ChangeDomainInputType, TlsCertificate } from '../copy-backend-common'; +import { ChangeDomainInputType, HostType, TlsCertificate } from '../copy-backend-common'; import { customCertsValidator, domainValidator, + ipAddressValidator, ipTripletAddressValidator, ipWithoutDots, passwordValidator, + prefixLengthValidator, usernameValidator, } from './utils'; @@ -101,6 +103,51 @@ export const K8SStateContextProvider: React.FC<{ [customCertsValidation, customCerts, setCustomCerts], ); + const [hosts, setHosts] = React.useState([]); + const handleSetHost = React.useCallback( + (newHost: HostType) => { + // List of DNS servers + newHost.dnsValidation = undefined; + newHost.dns?.some((dnsIp) => { + const validation = ipAddressValidator(dnsIp); + if (validation) { + newHost.dnsValidation = validation; + return true; // break + } + return false; + }); + + newHost.interfaces?.forEach((intf) => { + // single GW + if (intf.ipv4.address?.gateway) { + intf.ipv4.address.gatewayValidation = ipAddressValidator(intf.ipv4.address.gateway); + } + + // single static IP and subnet prefix + if (intf.ipv4?.address) { + const validation = ipAddressValidator(intf.ipv4?.address?.ip); + intf.ipv4.address.validation = validation; + + if (!validation && intf.ipv4.address.prefixLength !== undefined) { + // prefix-length + intf.ipv4.address.validation = prefixLengthValidator(intf.ipv4.address.prefixLength); + } + } + }); + + // find host by nodeName or add new record + const hostIndex = hosts.findIndex((h) => h.nodeName === newHost.nodeName); + if (hostIndex >= 0) { + hosts[hostIndex] = newHost; + } else { + hosts.push(newHost); + } + + setHosts([...hosts]); + }, + [hosts, setHosts], + ); + const isAllValid = React.useCallback(() => { const result = !usernameValidation && @@ -113,6 +160,8 @@ export const K8SStateContextProvider: React.FC<{ customCertsValidation[d].certValidated === 'error' || customCertsValidation[d].keyValidated === 'error', ); + + // TODO: include result of hosts' validation (static ips) return result; }, [ apiaddrValidation.valid, @@ -131,6 +180,7 @@ export const K8SStateContextProvider: React.FC<{ domain, originalDomain, customCerts, + hosts, }; const fieldValues = React.useRef(_fv); @@ -168,6 +218,8 @@ export const K8SStateContextProvider: React.FC<{ customCertsValidation, setCustomCertificate, + + handleSetHost, }; return {children}; diff --git a/ui/frontend/src/components/PasswordPage/PasswordPage.tsx b/ui/frontend/src/components/PasswordPage/PasswordPage.tsx index 01b22d12c..8df85413d 100644 --- a/ui/frontend/src/components/PasswordPage/PasswordPage.tsx +++ b/ui/frontend/src/components/PasswordPage/PasswordPage.tsx @@ -27,7 +27,7 @@ export const PasswordPage: React.FC = () => { bottom={ !!password && !!validation && !equalityValidationCheck} /> } diff --git a/ui/frontend/src/components/PersistPage/constants.ts b/ui/frontend/src/components/PersistPage/constants.ts index 610064671..75b1b2009 100644 --- a/ui/frontend/src/components/PersistPage/constants.ts +++ b/ui/frontend/src/components/PersistPage/constants.ts @@ -5,6 +5,7 @@ export const RESOURCE_FETCH_TITLE = 'Failed to read resource'; export const PERSIST_IDP = 'Registering new identity HTPasswd provider failed.'; export const KUBEADMIN_REMOVE = 'Kubeadmin removal failed.'; export const KUBEADMIN_REMOVE_OK = 'Kubeadmin removed.'; +export const PERSIST_STATIC_IPS = 'Changing static IP setting failed.'; export const PERSIST_DOMAIN = 'Changing domain failed.'; export const UI_POD_NOT_READY = 'Configuration UI pod is not ready'; export const API_LIVENESS_FAILED_TITLE = 'API can not be reached'; diff --git a/ui/frontend/src/components/PersistPage/persist.ts b/ui/frontend/src/components/PersistPage/persist.ts index b29f1e209..70b6c2384 100644 --- a/ui/frontend/src/components/PersistPage/persist.ts +++ b/ui/frontend/src/components/PersistPage/persist.ts @@ -6,6 +6,7 @@ import { MAX_LIVENESS_CHECK_COUNT, UI_POD_NOT_READY } from './constants'; import { persistDomain } from './persistDomain'; import { persistIdentityProvider, PersistIdentityProviderResult } from './persistIdentityProvider'; import { saveApi, saveIngress } from './persistServices'; +import { persistStaticIPs } from './persistStaticIPs'; import { PersistErrorType } from './types'; import { waitForClusterOperator, waitForZtpfwPodToBeRecreated } from './utils'; @@ -86,6 +87,10 @@ export const persist = async ( return false; } + if (!(await persistStaticIPs(setError, setProgress, state.hosts))) { + return false; + } + if (!(await persistDomain(setError, setProgress, state.domain, state.customCerts))) { return false; } diff --git a/ui/frontend/src/components/PersistPage/persistDomain.ts b/ui/frontend/src/components/PersistPage/persistDomain.ts index bb4db2ea6..53077ceb2 100644 --- a/ui/frontend/src/components/PersistPage/persistDomain.ts +++ b/ui/frontend/src/components/PersistPage/persistDomain.ts @@ -14,7 +14,7 @@ export const persistDomain = async ( ): Promise => { if (!clusterDomain) { console.info('Domain change not requested, so skipping that step.'); - setProgress(PersistSteps.PersistDomain); + setProgress(PersistSteps.ReconcilePersistDomain); return true; // skip } diff --git a/ui/frontend/src/components/PersistPage/persistStaticIPs.ts b/ui/frontend/src/components/PersistPage/persistStaticIPs.ts new file mode 100644 index 000000000..b5d62832d --- /dev/null +++ b/ui/frontend/src/components/PersistPage/persistStaticIPs.ts @@ -0,0 +1,73 @@ +import { ChangeStaticIpsInputType, HostInterfaceType, HostType } from '../../copy-backend-common'; +import { postRequest } from '../../resources'; +import { PersistSteps, UsePersistProgressType } from '../PersistProgress'; +import { delay } from '../utils'; +import { DELAY_BEFORE_FINAL_REDIRECT, PERSIST_STATIC_IPS } from './constants'; + +import { PersistErrorType } from './types'; +import { waitForClusterOperator } from './utils'; + +export const persistStaticIPs = async ( + setError: (error: PersistErrorType) => void, + setProgress: UsePersistProgressType['setProgress'], + hosts?: HostType[], +) => { + if (!hosts?.length) { + console.info('Setting of host static IPs is not requested, so skipping that step.'); + setProgress(PersistSteps.ReconcilePersistStaticIPs); + return true; // skip + } + + const input: ChangeStaticIpsInputType = { + hosts: hosts.map( + // Reduce the data to valid ones only + (h): HostType => ({ + hostname: h.hostname, + nodeName: h.nodeName, + nncpName: h.nncpName, + + dns: h.dns, + + interfaces: h.interfaces.map( + (i): HostInterfaceType => ({ + name: i.name, + ipv4: { + address: { + ip: i.ipv4.address?.ip, + prefixLength: i.ipv4.address?.prefixLength, + gateway: i.ipv4.address?.gateway, + }, + }, + }), + ), + }), + ), + }; + + try { + // Due to complexity, the flow has been moved to backend to decrease risks related to network communication + await postRequest('/setStaticIPs', input).promise; + } catch (e) { + console.error(e); + setError({ + title: PERSIST_STATIC_IPS, + message: `Failed to change static IPs of the hosts.`, + }); + return false; + } + + setProgress(PersistSteps.PersistStaticIPs); + + console.log('Static IPs persisted, blocking progress till reconciled.'); + // Let the operator reconciliation start + await delay(DELAY_BEFORE_FINAL_REDIRECT); + + // TODO: CHANGE FOLLOWING!!! Watch nodenetworkstates for changes + if (!(await waitForClusterOperator(setError, 'authentication'))) { + return false; + } + + setProgress(PersistSteps.ReconcilePersistStaticIPs); + + return true; +}; diff --git a/ui/frontend/src/components/PersistProgress/PersistProgress.tsx b/ui/frontend/src/components/PersistProgress/PersistProgress.tsx index f7abe34fb..aa841a760 100644 --- a/ui/frontend/src/components/PersistProgress/PersistProgress.tsx +++ b/ui/frontend/src/components/PersistProgress/PersistProgress.tsx @@ -20,6 +20,9 @@ export const PersistSteps = { SaveApi: persistStepsCount++, ReconcileSaveApi: persistStepsCount++, + PersistStaticIPs: persistStepsCount++, + ReconcilePersistStaticIPs: persistStepsCount++, + PersistDomain: persistStepsCount++, ReconcilePersistDomain: persistStepsCount++, @@ -37,6 +40,9 @@ PersistStepLabels[PersistSteps.SaveIngress] = 'Saving Ingress IP'; PersistStepLabels[PersistSteps.ReconcileSaveIngress] = 'Waiting for the Ingress IP reconciliation'; PersistStepLabels[PersistSteps.SaveApi] = 'Saving API IP'; PersistStepLabels[PersistSteps.ReconcileSaveApi] = 'Waiting for the API IP to reconcile'; +PersistStepLabels[PersistSteps.PersistStaticIPs] = 'Saving TCP/IP configuration'; +PersistStepLabels[PersistSteps.ReconcilePersistStaticIPs] = + 'Waiting for the TCP/IP configuration to reconcile'; PersistStepLabels[PersistSteps.PersistDomain] = 'Saving domain change'; PersistStepLabels[PersistSteps.ReconcilePersistDomain] = 'Waiting for the domain to reconcile'; PersistStepLabels[PersistSteps.ReconcileUIPod] = 'Final waiting for the configuration pod'; diff --git a/ui/frontend/src/components/Settings/Settings.tsx b/ui/frontend/src/components/Settings/Settings.tsx index b0ad58ddf..225df38f1 100644 --- a/ui/frontend/src/components/Settings/Settings.tsx +++ b/ui/frontend/src/components/Settings/Settings.tsx @@ -50,7 +50,8 @@ export const Settings: React.FC = () => { const [error, setError] = React.useState(); const [isDataLoaded, setDataLoaded] = React.useState(false); const [isReload, setReload] = React.useState(true); - const { handleSetApiaddr, handleSetIngressIp, handleSetDomain, setClean } = useK8SStateContext(); + const { handleSetApiaddr, handleSetIngressIp, handleSetDomain, setClean, handleSetHost } = + useK8SStateContext(); // Following is needed when navigated directly by setting the URL in the browser // It is not needed when navigated from the WelcomePage but let's refresh to show recent data anyway @@ -64,9 +65,10 @@ export const Settings: React.FC = () => { handleSetIngressIp, handleSetDomain, setClean, + handleSetHost, }); } - }, [handleSetApiaddr, handleSetIngressIp, handleSetDomain, isReload, setClean]); + }, [handleSetApiaddr, handleSetIngressIp, handleSetDomain, isReload, setClean, handleSetHost]); return isDataLoaded ? ( diff --git a/ui/frontend/src/components/WelcomePage/WelcomePage.tsx b/ui/frontend/src/components/WelcomePage/WelcomePage.tsx index 6906b9b6a..f2177118c 100644 --- a/ui/frontend/src/components/WelcomePage/WelcomePage.tsx +++ b/ui/frontend/src/components/WelcomePage/WelcomePage.tsx @@ -11,7 +11,8 @@ import { initialDataLoad } from './initialDataLoad'; export const WelcomePage: React.FC = () => { const [nextPage, setNextPage] = React.useState(); const [error, setError] = React.useState(); - const { handleSetApiaddr, handleSetIngressIp, handleSetDomain, setClean } = useK8SStateContext(); + const { handleSetApiaddr, handleSetIngressIp, handleSetDomain, setClean, handleSetHost } = + useK8SStateContext(); // Load initial data, switch between Inital vs. Edit flow React.useEffect( @@ -23,6 +24,7 @@ export const WelcomePage: React.FC = () => { handleSetIngressIp, handleSetDomain, setClean, + handleSetHost, }); }, // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/ui/frontend/src/components/WelcomePage/initialDataLoad.ts b/ui/frontend/src/components/WelcomePage/initialDataLoad.ts index 5714bb4bf..516b2d849 100644 --- a/ui/frontend/src/components/WelcomePage/initialDataLoad.ts +++ b/ui/frontend/src/components/WelcomePage/initialDataLoad.ts @@ -8,7 +8,109 @@ import { ipWithoutDots } from '../utils'; import { getHtpasswdIdentityProvider, getOAuth } from '../../resources/oauth'; import { workaroundUnmarshallObject } from '../../test-utils'; import { getIngressConfig } from '../../resources/ingress'; -import { getClusterDomainFromComponentRoutes, Ingress } from '../../copy-backend-common'; +import { + getClusterDomainFromComponentRoutes, + Ingress, + NodeNetworkState, + NodeNetworkConfigurationPolicy, + Node, + HostType, + HostInterfaceType, +} from '../../copy-backend-common'; +import { getNodeNetworkStates } from '../../resources/NodeNetworkState'; +import { getNodeNetworkConfigurationPolicies } from '../../resources/NodeNetworkConfigurationPolicy'; +import { getNodes } from '../../resources/node'; + +const loadStaticIPs = async ( + handleSetHost: K8SStateContextData['handleSetHost'], + nodes: Node[] = [], + nodeNetworkStates: NodeNetworkState[] = [], + nodeNetworkConfigurationPolicies: NodeNetworkConfigurationPolicy[] = [], +) => { + // query list of nodes + // query nodenetworkstates (owenerReference) + // query NodeNetworkConfigurationPolicy (spec.nodeSelector[kubernetes.io/hostname] === ztpfw-edgecluster0-cluster-master-0 ) + + nodes?.forEach((node) => { + const nodeName = node.metadata.name || 'typescript-workaround-nodename'; + const nns: NodeNetworkState | undefined = nodeNetworkStates.find( + (o) => o.metadata.ownerReferences?.find((or) => or.kind === 'Node')?.name === nodeName, + ); + const nncp: NodeNetworkConfigurationPolicy | undefined = nodeNetworkConfigurationPolicies.find( + (o) => + o.spec?.nodeSelector?.['kubernetes.io/hostname'] === + nodeName /* TODO: verify that this is really nodeName and not node's hostname */, + ); + + if (!nns) { + // AssumptionL there is 1:1 pairing + console.error('A NodeNetworkState is not associated to a Node: ', nns); + return; + } + + const hostname = + node.status?.addresses?.find((a) => a.type === 'Hostname')?.address || nodeName; + + // The node can act as both worker and master at the same time. For our purpose so far, we can be exclusive. Change otherwise. + const role = + node.metadata.labels?.['node-role.kubernetes.io/master'] !== undefined ? 'control' : 'worker'; + + const dns = + nncp?.spec?.desiredState?.['dns-resolver']?.config?.server || + nns.status?.currentState?.['dns-resolver']?.running?.server || + []; + + const host: HostType = { + nodeName, + hostname, + nncpName: nncp?.metadata.name, + role, + interfaces: [], + // -- gateway, // single IP address + dns, // list of IP addresses + }; + + // Take the list of interfaces from actual status (NodeNetworkStatus) + const intfs = nns.status?.currentState?.interfaces?.filter( + (intf) => intf.type === 'ethernet' && intf.state === 'up', + ); + + intfs?.forEach((intf) => { + const nncpIntf = nncp?.spec?.desiredState?.interfaces?.find((o) => o.name === intf.name); + + const nncpIntfRoutes = nncp?.spec?.desiredState?.routes?.config + ?.filter((r) => r['next-hop-interface'] === intf.name) + ?.sort((r1, r2) => r2.metric - r1.metric /* descending */); + const nnsIntfRoutes = nns?.status?.currentState?.routes?.running + ?.filter((r) => r['next-hop-interface'] === intf.name) + ?.sort((r1, r2) => r2.metric - r1.metric /* descending */); + const gateway = + nncpIntfRoutes?.[0]['next-hop-address'] || nnsIntfRoutes?.[0]['next-hop-address'] || ''; + + const hostInterface: HostInterfaceType = { + name: intf.name, + ipv4: { + /* To be filled bellow */ + }, + }; + + const ipv4Address = nncpIntf?.ipv4?.address?.[0] || intf?.ipv4?.address?.[0]; // Take the first-one only + + if (ipv4Address) { + hostInterface.ipv4.address = { + // We support only one static IP per interface + ip: ipv4Address.ip, + prefixLength: ipv4Address['prefix-length'], + gateway, + }; + } + + host.interfaces.push(hostInterface); + }); + + handleSetHost(host); + }); +}; export const initialDataLoad = async ({ setNextPage, @@ -17,6 +119,7 @@ export const initialDataLoad = async ({ handleSetIngressIp, handleSetDomain, setClean, + handleSetHost, }: { setNextPage: (href: string) => void; setError: (message?: string) => void; @@ -24,11 +127,15 @@ export const initialDataLoad = async ({ handleSetIngressIp: K8SStateContextData['handleSetIngressIp']; handleSetDomain: K8SStateContextData['handleSetDomain']; setClean: K8SStateContextData['setClean']; + handleSetHost: K8SStateContextData['handleSetHost']; }) => { console.log('Initial data load'); let ingressService, apiService, oauth; let ingressConfig: Ingress | undefined; + let nodeNetworkStates: NodeNetworkState[] | undefined; + let nodeNetworkConfigurationPolicies: NodeNetworkConfigurationPolicy[] | undefined; + let nodes: Node[] | undefined; try { oauth = await getOAuth().promise; @@ -41,6 +148,9 @@ export const initialDataLoad = async ({ namespace: SERVICE_TEMPLATE_API.metadata.namespace || '', }).promise; ingressConfig = await getIngressConfig().promise; + nodeNetworkStates = await getNodeNetworkStates().promise; + nodeNetworkConfigurationPolicies = await getNodeNetworkConfigurationPolicies().promise; + nodes = await getNodes().promise; } catch (_e) { const e = _e as { message: string; code: number }; if (e.code !== 404) { @@ -55,6 +165,9 @@ export const initialDataLoad = async ({ ingressService = workaroundUnmarshallObject(ingressService); apiService = workaroundUnmarshallObject(apiService); ingressConfig = workaroundUnmarshallObject(ingressConfig); + nodeNetworkStates = workaroundUnmarshallObject(nodeNetworkStates); + nodeNetworkConfigurationPolicies = workaroundUnmarshallObject(nodeNetworkConfigurationPolicies); + nodes = workaroundUnmarshallObject(nodes); handleSetIngressIp( ipWithoutDots( @@ -73,11 +186,16 @@ export const initialDataLoad = async ({ handleSetDomain(currentHostname); } + await loadStaticIPs(handleSetHost, nodes, nodeNetworkStates, nodeNetworkConfigurationPolicies); + setClean(); if (getHtpasswdIdentityProvider(oauth)) { + // DO NOT MERGE FOLLOWING LINE!!! + setNextPage('/wizard/staticips'); + // The Edit flow for the 2nd and later run - setNextPage('/settings'); + // setNextPage('/settings'); } else { // The Wizard for the very first run setNextPage('/wizard/username'); diff --git a/ui/frontend/src/components/Wizard/Wizard.tsx b/ui/frontend/src/components/Wizard/Wizard.tsx index 3698bae93..160f371f9 100644 --- a/ui/frontend/src/components/Wizard/Wizard.tsx +++ b/ui/frontend/src/components/Wizard/Wizard.tsx @@ -4,6 +4,8 @@ import { Routes, Route } from 'react-router'; import { UsernamePage, PasswordPage, + HostIPDecisionPage, + StaticIPPage, ApiAddressPage, IngressIpPage, DomainPage, @@ -24,6 +26,8 @@ export const Wizard: React.FC = () => { } /> } /> + } /> + } /> } /> } /> } /> diff --git a/ui/frontend/src/components/WizardProgress/WizardProgress.tsx b/ui/frontend/src/components/WizardProgress/WizardProgress.tsx index 57c4fadcd..ccfab6b3d 100644 --- a/ui/frontend/src/components/WizardProgress/WizardProgress.tsx +++ b/ui/frontend/src/components/WizardProgress/WizardProgress.tsx @@ -27,6 +27,9 @@ export const WizardProgress: React.FC = () => { > Password + + Networking + API diff --git a/ui/frontend/src/components/WizardProgress/WizardProgressContext.tsx b/ui/frontend/src/components/WizardProgress/WizardProgressContext.tsx index c7933eaa8..ded8d63b3 100644 --- a/ui/frontend/src/components/WizardProgress/WizardProgressContext.tsx +++ b/ui/frontend/src/components/WizardProgress/WizardProgressContext.tsx @@ -7,19 +7,23 @@ type WizardProgressStep = Pick; export type WizardProgressStepType = | 'username' | 'password' + | 'hostips' | 'apiaddr' | 'ingressip' | 'domain' | 'sshkey'; + export type WizardStepType = | WizardProgressStepType | 'persist' | 'domaincertsdecision' - | 'domaincertificates'; + | 'domaincertificates' + | 'staticips'; export type WizardProgressSteps = { username: WizardProgressStep; password: WizardProgressStep; + hostips: WizardProgressStep; apiaddr: WizardProgressStep; ingressip: WizardProgressStep; domain: WizardProgressStep; @@ -34,10 +38,11 @@ export type WizardProgressContextData = { const WIZARD_STEP_INDEXES: { [key in WizardProgressStepType]: number } = { username: 0, password: 1, - apiaddr: 2, - ingressip: 3, - domain: 4, - sshkey: 5, + hostips: 2, + apiaddr: 3, + ingressip: 4, + domain: 5, + sshkey: 6, }; const WizardProgressContext = React.createContext(null); @@ -54,6 +59,10 @@ export const WizardProgressContextProvider: React.FC<{ isCurrent: false, variant: 'pending', }, + hostips: { + isCurrent: false, + variant: 'pending', + }, apiaddr: { isCurrent: false, variant: 'pending', @@ -78,6 +87,7 @@ export const WizardProgressContextProvider: React.FC<{ setActiveStep: (step: WizardProgressStepType) => { if (!steps[step].isCurrent) { + console.log('--- setActiveStep ', step, ', steps: ', steps); const newSteps = { ...steps }; const stepIdx = WIZARD_STEP_INDEXES[step]; diff --git a/ui/frontend/src/components/index.ts b/ui/frontend/src/components/index.ts index 4ba439307..debe108d3 100644 --- a/ui/frontend/src/components/index.ts +++ b/ui/frontend/src/components/index.ts @@ -1,6 +1,7 @@ export * from './WelcomePage'; export * from './UsernamePage'; export * from './PasswordPage'; +export * from './HostIPPage'; export * from './ApiAddressPage'; export * from './IngressIpPage'; export * from './DomainPage'; diff --git a/ui/frontend/src/components/types.ts b/ui/frontend/src/components/types.ts index bfbb95a47..4a4077aa9 100644 --- a/ui/frontend/src/components/types.ts +++ b/ui/frontend/src/components/types.ts @@ -1,5 +1,5 @@ import { FormGroupProps, TextInputProps } from '@patternfly/react-core'; -import { TlsCertificate } from '../copy-backend-common'; +import { HostType, TlsCertificate } from '../copy-backend-common'; import { ChangeDomainInputType } from '../backend-shared'; export type IpTripletIndex = 0 | 1 | 2 | 3; @@ -40,6 +40,7 @@ export type K8SStateContextDataFields = { domain: string; originalDomain?: string; customCerts: ChangeDomainInputType['customCerts']; + hosts: HostType[]; }; export type K8SStateContextData = K8SStateContextDataFields & { @@ -64,4 +65,6 @@ export type K8SStateContextData = K8SStateContextDataFields & { setCustomCertificate: (domain: string, certificate: TlsCertificate) => void; customCertsValidation: CustomCertsValidationType; + + handleSetHost: (newHost: HostType) => void; }; diff --git a/ui/frontend/src/components/utils.ts b/ui/frontend/src/components/utils.ts index c74071f46..21c55f737 100644 --- a/ui/frontend/src/components/utils.ts +++ b/ui/frontend/src/components/utils.ts @@ -1,11 +1,14 @@ import { FormGroupProps } from '@patternfly/react-core'; import { Buffer } from 'buffer'; +import { Address4 } from 'ip-address'; import { DNS_NAME_REGEX, USERNAME_REGEX } from '../backend-shared'; import { TlsCertificate } from '../copy-backend-common'; import { isPasswordPolicyMet } from './PasswordPage/utils'; import { IpTripletSelectorValidationType, K8SStateContextData } from './types'; +export const TWELVE_SPACES = ' '; + export const toBase64 = (str: string) => Buffer.from(str).toString('base64'); export const fromBase64ToUtf8 = (b64Str?: string): string | undefined => b64Str === undefined ? undefined : Buffer.from(b64Str, 'base64').toString('utf8'); @@ -32,7 +35,7 @@ export const ipTripletAddressValidator = ( for (let i = 0; i <= 3; i++) { const triplet = addr.substring(i * 3, (i + 1) * 3).trim(); const num = parseInt(triplet); - const valid = num >= 0 && num <= 255; + const valid = num >= 0 && num <= 255 && /^\d+$/.test(triplet); validation.valid = validation.valid && valid; validation.triplets.push(valid ? 'success' : 'default'); @@ -160,7 +163,29 @@ export const ipWithoutDots = (ip?: string): string => { } console.info('Unrecognized ip address format "', ip, '"'); - return ' '; // 12 characters + return TWELVE_SPACES; +}; + +export const ipAddressValidator = (ipWithDots?: string): string | undefined => { + // TODO: Use ip-address package instead + if (!ipWithDots || !ipWithDots.trim()) { + return 'Provide static IP of the interface.'; + } + + const validation = ipTripletAddressValidator(ipWithoutDots(ipWithDots)); + return validation.valid ? undefined : validation.message; +}; + +export const prefixLengthValidator = (prefLength?: string | number): string | undefined => { + if (prefLength === undefined) { + return undefined; + } + + if (prefLength >= 1 && prefLength <= 32) { + return undefined; + } + + return 'A number between 1 and 32 is expected.'; }; export const delay = (ms: number) => @@ -181,3 +206,14 @@ export const bindOnBeforeUnloadPage = (message: string) => { export const unbindOnBeforeUnloadPage = () => { window.onbeforeunload = null; }; + +export const getSubnetRange = (ip?: string, prefixLength?: number) => { + if (!ip || !prefixLength) { + return undefined; + } + + const subnet = new Address4(`${ip}/${prefixLength}`); + const subnetStart = subnet.startAddress().correctForm(); + const subnetEnd = subnet.endAddress().correctForm(); + return `(${subnetStart} - ${subnetEnd})`; +}; diff --git a/ui/frontend/src/resources/NodeNetworkConfigurationPolicy.ts b/ui/frontend/src/resources/NodeNetworkConfigurationPolicy.ts new file mode 100644 index 000000000..42e50126f --- /dev/null +++ b/ui/frontend/src/resources/NodeNetworkConfigurationPolicy.ts @@ -0,0 +1,12 @@ +import { listClusterResources } from './resource-request'; +import { + NodeNetworkConfigurationPolicyApiVersion, + NodeNetworkConfigurationPolicyKind, + NodeNetworkConfigurationPolicy, +} from '../backend-shared'; + +export const getNodeNetworkConfigurationPolicies = () => + listClusterResources({ + apiVersion: NodeNetworkConfigurationPolicyApiVersion, + kind: NodeNetworkConfigurationPolicyKind, + }); diff --git a/ui/frontend/src/resources/NodeNetworkState.ts b/ui/frontend/src/resources/NodeNetworkState.ts new file mode 100644 index 000000000..4a3a8818c --- /dev/null +++ b/ui/frontend/src/resources/NodeNetworkState.ts @@ -0,0 +1,12 @@ +import { listClusterResources } from './resource-request'; +import { + NodeNetworkState, + NodeNetworkStateKind, + NodeNetworkStateApiVersion, +} from '../backend-shared'; + +export const getNodeNetworkStates = () => + listClusterResources({ + apiVersion: NodeNetworkStateApiVersion, + kind: NodeNetworkStateKind, + }); diff --git a/ui/frontend/src/resources/node.ts b/ui/frontend/src/resources/node.ts new file mode 100644 index 000000000..caed05b2f --- /dev/null +++ b/ui/frontend/src/resources/node.ts @@ -0,0 +1,8 @@ +import { listClusterResources } from './resource-request'; +import { Node, NodeKind, NodeApiVersion } from '../backend-shared'; + +export const getNodes = () => + listClusterResources({ + apiVersion: NodeApiVersion, + kind: NodeKind, + }); diff --git a/ui/frontend/yarn.lock b/ui/frontend/yarn.lock index a16e23980..4254efc3c 100644 --- a/ui/frontend/yarn.lock +++ b/ui/frontend/yarn.lock @@ -5343,6 +5343,14 @@ interpret@^1.0.0: resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.4.0.tgz#665ab8bc4da27a774a40584e812e3e0fa45b1a1e" integrity sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA== +ip-address@^7.1.0: + version "7.1.0" + resolved "https://registry.yarnpkg.com/ip-address/-/ip-address-7.1.0.tgz#4a9c699e75b51cbeb18b38de8ed216efa1a490c5" + integrity sha512-V9pWC/VJf2lsXqP7IWJ+pe3P1/HCYGBMZrrnT62niLGjAfCbeiwXMUxaeHvnVlz19O27pvXP4azs+Pj/A0x+SQ== + dependencies: + jsbn "1.1.0" + sprintf-js "1.1.2" + ip@^1.1.0: version "1.1.5" resolved "https://registry.yarnpkg.com/ip/-/ip-1.1.5.tgz#bdded70114290828c0a039e72ef25f5aaec4354a" @@ -6120,6 +6128,11 @@ js-yaml@^4.1.0: dependencies: argparse "^2.0.1" +jsbn@1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-1.1.0.tgz#b01307cb29b618a1ed26ec79e911f803c4da0040" + integrity sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A== + jsbn@~0.1.0: version "0.1.1" resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513" @@ -8633,6 +8646,11 @@ spdy@^4.0.2: select-hose "^2.0.0" spdy-transport "^3.0.0" +sprintf-js@1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.1.2.tgz#da1765262bf8c0f571749f2ad6c26300207ae673" + integrity sha512-VE0SOVEHCk7Qc8ulkWw3ntAzXuqf7S2lvwQaDLRnUeIEaKNQJzV6BwmLKhOqT61aGhfUMrXeaBk+oDGCzvhcug== + sprintf-js@~1.0.2: version "1.0.3" resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" From f2b498aa35f53dbe70cd5c8292249b38c9fad34c Mon Sep 17 00:00:00 2001 From: Marek Libra Date: Tue, 12 Jul 2022 16:23:46 +0200 Subject: [PATCH 2/4] Patch frontend's webpack.config.js To resolve missing sourcemap error for ip-address package --- ui/frontend/hack/patchWebpack.sh | 16 ++++++++++++++++ ui/frontend/package.json | 3 ++- 2 files changed, 18 insertions(+), 1 deletion(-) create mode 100755 ui/frontend/hack/patchWebpack.sh diff --git a/ui/frontend/hack/patchWebpack.sh b/ui/frontend/hack/patchWebpack.sh new file mode 100755 index 000000000..444122e4c --- /dev/null +++ b/ui/frontend/hack/patchWebpack.sh @@ -0,0 +1,16 @@ +#!/bin/bash + +# exclude: /@babel(?:\/|\\{1,2})runtime/, +# exclude: /@babel(?:\/|\\{1,2})runtime|ip-address/ + +COUNT=$(grep 'exclude: /@babel(?:\\/|\\\\{1,2})runtime/,' node_modules/react-scripts/config/webpack.config.js | wc -l) + +set -x + +if [ x${COUNT} = x2 ] ; then + sed -i "s|)runtime/,$|)runtime\|ip-address/,|g" node_modules/react-scripts/config/webpack.config.js +else + echo '== Unable to patch webpack.config.js' +fi + + diff --git a/ui/frontend/package.json b/ui/frontend/package.json index 6270f10ea..be90450b2 100644 --- a/ui/frontend/package.json +++ b/ui/frontend/package.json @@ -36,8 +36,9 @@ "string.prototype.replaceall": "^1.0.6" }, "scripts": { + "postinstall": "hack/patchWebpack.sh", "start": "HTTPS=true SSL_CRT_FILE=${TLS_CERT_FILE} SSL_KEY_FILE=${TLS_KEY_FILE} react-scripts start", - "build": "GENERATE_SOURCEMAP=false react-scripts build", + "build": "react-scripts build", "backend-common": "rm -rf src/copy-backend-common ; cp -r ../backend/src/common src/copy-backend-common", "get-sha": "SHA=$(git rev-parse HEAD) ; echo ${SHA} ; echo \"GIT_BUILD_SHA = '${SHA}';\" >> src/sha.ts", "prebuild": "yarn backend-common", From 6fdde3abced417a7e7c5085f7c4ab8b3696792b6 Mon Sep 17 00:00:00 2001 From: Marek Libra Date: Wed, 13 Jul 2022 08:57:31 +0200 Subject: [PATCH 3/4] wip --- ui/backend/src/endpoints/changeStaticIps.ts | 10 +++------- ui/backend/src/index.ts | 1 + 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/ui/backend/src/endpoints/changeStaticIps.ts b/ui/backend/src/endpoints/changeStaticIps.ts index f6eec936e..ed9f0226a 100644 --- a/ui/backend/src/endpoints/changeStaticIps.ts +++ b/ui/backend/src/endpoints/changeStaticIps.ts @@ -60,12 +60,7 @@ const changeStaticIpsImpl = async ( const routes = { config: host.interfaces .map((i): NNRouteConfig | undefined => { - // const subnet = getSubnetMask(i.ipv4.address?.ip, i.ipv4.address?.prefixLength); - const gateway = i.ipv4.address?.gateway; - - if (!subnet) { - return undefined; - } + const gateway = i.ipv4.address?.gateway || ''; return { destination: '0.0.0.0/0', // TODO: Can we use this as default?? @@ -108,13 +103,14 @@ const changeStaticIpsImpl = async ( const patches: PatchType[] = [ { - op: 'replace', // let's risk here, maybe we should query the NNCP first and than decide about replace vs. add + op: 'replace', // let's risk here, to be safe we should query the NNCP first and then decide about replace vs. add path: '/spec/desiredState', value: desiredState, }, ]; try { + logger.debug(`-- Patching NNCP ${host.nncpName}: `, patches); await patchNodeNetworkConfigurationPolicy(token, { name: host.nncpName }, patches); } catch (e) { logger.error( diff --git a/ui/backend/src/index.ts b/ui/backend/src/index.ts index e23fa846b..baba17847 100644 --- a/ui/backend/src/index.ts +++ b/ui/backend/src/index.ts @@ -16,6 +16,7 @@ import { changeDomain, user, configure, + changeStaticIps, } from './endpoints'; const PORT = process.env.BACKEND_PORT || 3001; From 3e9bee4fdaee9d55644c3d372ac70815f78345e5 Mon Sep 17 00:00:00 2001 From: Marek Libra Date: Thu, 14 Jul 2022 13:17:15 +0200 Subject: [PATCH 4/4] wip --- ui/backend/src/endpoints/changeStaticIps.ts | 74 ++++++++++++++++--- .../nodenetworkconfigurationpolicy.ts | 19 ++--- ui/backend/src/resources/resourceTemplates.ts | 11 ++- 3 files changed, 82 insertions(+), 22 deletions(-) diff --git a/ui/backend/src/endpoints/changeStaticIps.ts b/ui/backend/src/endpoints/changeStaticIps.ts index ed9f0226a..b15ade129 100644 --- a/ui/backend/src/endpoints/changeStaticIps.ts +++ b/ui/backend/src/endpoints/changeStaticIps.ts @@ -1,3 +1,4 @@ +import { cloneDeep } from 'lodash'; import { Request, Response } from 'express'; import { @@ -9,7 +10,12 @@ import { NNRouteConfig, } from '../frontend-shared'; import { getToken, unauthorized } from '../k8s'; -import { patchNodeNetworkConfigurationPolicy } from '../resources/nodenetworkconfigurationpolicy'; +import { + createNodeNetworkConfigurationPolicy, + patchNodeNetworkConfigurationPolicy, +} from '../resources/nodenetworkconfigurationpolicy'; +import { NNCP_TEMPLATE } from '../resources/resourceTemplates'; +import { patchApiServerConfig } from '../resources/apiserver'; const logger = console; @@ -37,7 +43,7 @@ const changeStaticIpsImpl = async ( } // Store the data - // Optimization: instead of sequential processing wait on all promises (not implemented atm due to debugging) + // Optimization: instead of sequential processing wait on all promises at once (not implemented atm due to debugging) for (let index = 0; index < hosts.length; index++) { const host = hosts[index]; @@ -48,7 +54,19 @@ const changeStaticIpsImpl = async ( } if (host.nncpName) { - // assumption: the spec.nodeSelector is already properly set (otherwise host.nncpName is not provided) - so we can PATCH right away + // assumption: the spec.nodeSelector is already properly set (otherwise host.nncpName would not be provided) - so we can PATCH right away + + const patches: PatchType[] = []; + + patches.push({ + // TODO: remove following, it is used for debugging only: + op: 'replace', + path: '/spec/nodeSelector', + value: { + 'ui-debug-not-mataching-key': 'not-matching-label', + }, + }); + const dns = host.dns?.length ? { config: { @@ -101,13 +119,11 @@ const changeStaticIpsImpl = async ( routes, }; - const patches: PatchType[] = [ - { - op: 'replace', // let's risk here, to be safe we should query the NNCP first and then decide about replace vs. add - path: '/spec/desiredState', - value: desiredState, - }, - ]; + patches.push({ + op: 'replace', // let's risk here, to be safe we should query the NNCP first and then decide about replace vs. add + path: '/spec/desiredState', + value: desiredState, + }); try { logger.debug(`-- Patching NNCP ${host.nncpName}: `, patches); @@ -124,8 +140,42 @@ const changeStaticIpsImpl = async ( return; } } else { - // build one from a template - // TODO + // create one from a template + const nncp = cloneDeep(NNCP_TEMPLATE); + const namePrefix = `${host.nodeName}-`; + nncp.metadata.generateName = namePrefix; + + // TODO: other changes + + try { + const response = await createNodeNetworkConfigurationPolicy(token, nncp); + + if (response.statusCode === 201) { + logger.info('Created a NNCP resource: ', response.body?.metadata?.name); + } else { + logger.error( + `Can not create ${namePrefix} NodeNetworkConfigurationPolicy resource. Response: `, + response, + ); + res + .writeHead( + response.statusCode, + `Can not create ${namePrefix} NodeNetworkConfigurationPolicy resource.`, + ) + .end(); + } + } catch (e) { + logger.error( + `Can not create ${namePrefix} NodeNetworkConfigurationPolicy resource. Internal error: `, + e, + ); + res + .writeHead( + 500, + `Can not create ${namePrefix} NodeNetworkConfigurationPolicy resource. Internal error.`, + ) + .end(); + } } } diff --git a/ui/backend/src/resources/nodenetworkconfigurationpolicy.ts b/ui/backend/src/resources/nodenetworkconfigurationpolicy.ts index 6cad5f86c..76fc09106 100644 --- a/ui/backend/src/resources/nodenetworkconfigurationpolicy.ts +++ b/ui/backend/src/resources/nodenetworkconfigurationpolicy.ts @@ -3,17 +3,18 @@ import { PatchType, NodeNetworkConfigurationPolicyApiVersion, } from '../frontend-shared'; -import { getClusterApiUrl, jsonPatch } from '../k8s'; +import { getClusterApiUrl, jsonPatch, jsonPost } from '../k8s'; + +const getNNCPUrl = () => + `${getClusterApiUrl()}/apis/${NodeNetworkConfigurationPolicyApiVersion}/nodenetworkConfigurationPolicies`; + +export const createNodeNetworkConfigurationPolicy = ( + token: string, + nncp: NodeNetworkConfigurationPolicy, +) => jsonPost(getNNCPUrl(), nncp, token); export const patchNodeNetworkConfigurationPolicy = ( token: string, metadata: { name: string }, patches: PatchType[], -) => - jsonPatch( - `${getClusterApiUrl()}/apis/${NodeNetworkConfigurationPolicyApiVersion}/nodenetworkConfigurationPolicies/${ - metadata.name - }`, - patches, - token, - ); +) => jsonPatch(`${getNNCPUrl()}/${metadata.name}`, patches, token); diff --git a/ui/backend/src/resources/resourceTemplates.ts b/ui/backend/src/resources/resourceTemplates.ts index 155bf6048..522fd5eca 100644 --- a/ui/backend/src/resources/resourceTemplates.ts +++ b/ui/backend/src/resources/resourceTemplates.ts @@ -1,5 +1,10 @@ import { TLS_SECRET_NAMESPACE } from '../constants'; -import { Secret, SecretApiVersion, SecretKind } from '../frontend-shared'; +import { + Secret, + SecretApiVersion, + SecretKind, + NodeNetworkConfigurationPolicy, +} from '../frontend-shared'; export const TLS_SECRET: Secret = { apiVersion: SecretApiVersion, @@ -14,3 +19,7 @@ export const TLS_SECRET: Secret = { }, type: 'kubernetes.io/tls', }; + +export const NNCP_TEMPLATE: NodeNetworkConfigurationPolicy = { + // TODO +};