1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-07-24 15:59:41 +02:00

refactor(app): details widget migration [EE-5352] (#8886)

This commit is contained in:
Ali 2023-05-29 15:06:14 +12:00 committed by GitHub
parent fdd79cece8
commit af77e33993
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
57 changed files with 2046 additions and 1079 deletions

View file

@ -0,0 +1,74 @@
import { Move } from 'lucide-react';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { Icon } from '@@/Icon';
import { TextTip } from '@@/Tip/TextTip';
import { Tooltip } from '@@/Tip/Tooltip';
import { Application } from '../../types';
import { useApplicationHorizontalPodAutoscalers } from '../../application.queries';
type Props = {
environmentId: EnvironmentId;
namespace: string;
appName: string;
app?: Application;
};
export function ApplicationAutoScalingTable({
environmentId,
namespace,
appName,
app,
}: Props) {
const { data: appAutoScalar } = useApplicationHorizontalPodAutoscalers(
environmentId,
namespace,
appName,
app
);
return (
<>
<div className="text-muted mb-4 flex items-center">
<Icon icon={Move} className="!mr-2" />
Auto-scaling
</div>
{!appAutoScalar && (
<TextTip color="blue">
This application does not have an autoscaling policy defined.
</TextTip>
)}
{appAutoScalar && (
<div className="mt-4 w-3/5">
<table className="table">
<tbody>
<tr className="text-muted">
<td className="w-1/3">Minimum instances</td>
<td className="w-1/3">Maximum instances</td>
<td className="w-1/3">
<div className="flex min-w-max items-center gap-1">
Target CPU usage
<Tooltip message="The autoscaler will ensure enough instances are running to maintain an average CPU usage across all instances." />
</div>
</td>
</tr>
<tr>
<td data-cy="k8sAppDetail-minReplicas">
{appAutoScalar.spec?.minReplicas}
</td>
<td data-cy="k8sAppDetail-maxReplicas">
{appAutoScalar.spec?.maxReplicas}
</td>
<td data-cy="k8sAppDetail-targetCPU">
{appAutoScalar.spec?.targetCPUUtilizationPercentage}%
</td>
</tr>
</tbody>
</table>
</div>
)}
</>
);
}

View file

@ -0,0 +1,142 @@
import { Pencil, Plus } from 'lucide-react';
import { useCurrentStateAndParams } from '@uirouter/react';
import { Pod } from 'kubernetes-types/core/v1';
import { Authorized } from '@/react/hooks/useUser';
import { useStackFile } from '@/react/common/stacks/stack.service';
import { Widget, WidgetBody } from '@@/Widget';
import { Button } from '@@/buttons';
import { Link } from '@@/Link';
import { Icon } from '@@/Icon';
import {
useApplication,
useApplicationServices,
} from '../../application.queries';
import { isSystemNamespace } from '../../../namespaces/utils';
import { applicationIsKind, isExternalApplication } from '../../utils';
import { appStackIdLabel } from '../../constants';
import { RestartApplicationButton } from './RestartApplicationButton';
import { RedeployApplicationButton } from './RedeployApplicationButton';
import { RollbackApplicationButton } from './RollbackApplicationButton';
import { ApplicationServicesTable } from './ApplicationServicesTable';
import { ApplicationIngressesTable } from './ApplicationIngressesTable';
import { ApplicationAutoScalingTable } from './ApplicationAutoScalingTable';
import { ApplicationEnvVarsTable } from './ApplicationEnvVarsTable';
import { ApplicationVolumeConfigsTable } from './ApplicationVolumeConfigsTable';
import { ApplicationPersistentDataTable } from './ApplicationPersistentDataTable';
export function ApplicationDetailsWidget() {
const stateAndParams = useCurrentStateAndParams();
const {
params: {
namespace,
name,
'resource-type': resourceType,
endpointId: environmentId,
},
} = stateAndParams;
// get app info
const appQuery = useApplication(environmentId, namespace, name, resourceType);
const app = appQuery.data;
const externalApp = app && isExternalApplication(app);
const appStackId = Number(app?.metadata?.labels?.[appStackIdLabel]);
const appStackFileQuery = useStackFile(appStackId);
const { data: appServices } = useApplicationServices(
environmentId,
namespace,
name,
app
);
return (
<Widget>
<WidgetBody>
{!isSystemNamespace(namespace) && (
<div className="mb-4 flex flex-wrap gap-2">
<Authorized authorizations="K8sApplicationDetailsW">
<Link to="kubernetes.applications.application.edit">
<Button
type="button"
color="light"
size="small"
className="hover:decoration-none !ml-0"
data-cy="k8sAppDetail-editAppButton"
>
<Icon icon={Pencil} className="mr-1" />
{externalApp
? 'Edit external application'
: 'Edit this application'}
</Button>
</Link>
</Authorized>
{!applicationIsKind<Pod>('Pod', app) && (
<>
<RestartApplicationButton />
<RedeployApplicationButton
environmentId={environmentId}
namespace={namespace}
appName={name}
app={app}
/>
</>
)}
{!externalApp && (
<RollbackApplicationButton
environmentId={environmentId}
namespace={namespace}
appName={name}
app={app}
/>
)}
{appStackFileQuery.data && (
<Link
to="kubernetes.templates.custom.new"
params={{
fileContent: appStackFileQuery.data.StackFileContent,
}}
>
<Button
type="button"
color="primary"
size="small"
className="hover:decoration-none !ml-0"
data-cy="k8sAppDetail-createCustomTemplateButton"
>
<Icon icon={Plus} className="mr-1" />
Create template from application
</Button>
</Link>
)}
</div>
)}
<ApplicationServicesTable
environmentId={environmentId}
appServices={appServices}
/>
<ApplicationIngressesTable
appServices={appServices}
environmentId={environmentId}
namespace={namespace}
/>
<ApplicationAutoScalingTable
environmentId={environmentId}
namespace={namespace}
appName={name}
app={app}
/>
<ApplicationEnvVarsTable namespace={namespace} app={app} />
<ApplicationVolumeConfigsTable namespace={namespace} app={app} />
<ApplicationPersistentDataTable
environmentId={environmentId}
namespace={namespace}
appName={name}
app={app}
/>
</WidgetBody>
</Widget>
);
}

View file

@ -0,0 +1,172 @@
import { EnvVar, Pod } from 'kubernetes-types/core/v1';
import { Asterisk, File, Key } from 'lucide-react';
import { Icon } from '@@/Icon';
import { TextTip } from '@@/Tip/TextTip';
import { Link } from '@@/Link';
import { Application } from '../../types';
import { applicationIsKind } from '../../utils';
type Props = {
namespace: string;
app?: Application;
};
export function ApplicationEnvVarsTable({ namespace, app }: Props) {
const appEnvVars = getApplicationEnvironmentVariables(app);
return (
<>
<div className="text-muted mb-4 mt-6 flex items-center">
<Icon icon={File} className="!mr-2" />
Configuration
</div>
{appEnvVars.length === 0 && (
<TextTip color="blue">
This application is not using any environment variable or
configuration.
</TextTip>
)}
{appEnvVars.length > 0 && (
<table className="table">
<tbody>
<tr className="text-muted">
<td className="w-1/4">Container</td>
<td className="w-1/4">Environment variable</td>
<td className="w-1/4">Value</td>
<td className="w-1/4">Configuration</td>
</tr>
{appEnvVars.map((envVar, index) => (
<tr key={index}>
<td data-cy="k8sAppDetail-containerName">
{envVar.containerName}
{envVar.isInitContainer && (
<span>
<Icon icon={Asterisk} className="!ml-1" />
{envVar.valueFrom?.fieldRef?.fieldPath} (
<a
href="https://kubernetes.io/docs/concepts/workloads/pods/init-containers/"
target="_blank"
rel="noopener noreferrer"
>
init container
</a>
)
</span>
)}
</td>
<td data-cy="k8sAppDetail-envVarName">{envVar.name}</td>
<td data-cy="k8sAppDetail-envVarValue">
{envVar.value && <span>{envVar.value}</span>}
{envVar.valueFrom?.fieldRef?.fieldPath && (
<span>
<Icon icon={Asterisk} className="!ml-1" />
{envVar.valueFrom.fieldRef.fieldPath} (
<a
href="https://kubernetes.io/docs/tasks/inject-data-application/downward-api-volume-expose-pod-information/"
target="_blank"
rel="noopener noreferrer"
>
downward API
</a>
)
</span>
)}
{envVar.valueFrom?.secretKeyRef?.key && (
<span className="flex items-center">
<Icon icon={Key} className="!mr-1" />
{envVar.valueFrom.secretKeyRef.key}
</span>
)}
{envVar.valueFrom?.configMapKeyRef?.key && (
<span className="flex items-center">
<Icon icon={Key} className="!mr-1" />
{envVar.valueFrom.configMapKeyRef.key}
</span>
)}
{!envVar.value && !envVar.valueFrom && <span>-</span>}
</td>
<td data-cy="k8sAppDetail-configName">
{!envVar.valueFrom?.configMapKeyRef?.name &&
!envVar.valueFrom?.secretKeyRef?.name && <span>-</span>}
{envVar.valueFrom?.configMapKeyRef && (
<span>
<Link
to="kubernetes.configurations.configuration"
params={{
name: envVar.valueFrom.configMapKeyRef.name,
namespace,
}}
className="flex items-center"
>
<Icon icon={File} className="!mr-1" />
{envVar.valueFrom.configMapKeyRef.name}
</Link>
</span>
)}
{envVar.valueFrom?.secretKeyRef && (
<span>
<Link
to="kubernetes.configurations.configuration"
params={{
name: envVar.valueFrom.secretKeyRef.name,
namespace,
}}
className="flex items-center"
>
<Icon icon={File} className="!mr-1" />
{envVar.valueFrom.secretKeyRef.name}
</Link>
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
</>
);
}
interface ContainerEnvVar extends EnvVar {
containerName: string;
isInitContainer: boolean;
}
function getApplicationEnvironmentVariables(
app?: Application
): ContainerEnvVar[] {
if (!app) {
return [];
}
const podSpec = applicationIsKind<Pod>('Pod', app)
? app.spec
: app.spec?.template?.spec;
const appContainers = podSpec?.containers || [];
const appInitContainers = podSpec?.initContainers || [];
// get all the environment variables for each container
const appContainersEnvVars =
appContainers?.flatMap(
(container) =>
container?.env?.map((envVar) => ({
...envVar,
containerName: container.name,
isInitContainer: false,
})) || []
) || [];
const appInitContainersEnvVars =
appInitContainers?.flatMap(
(container) =>
container?.env?.map((envVar) => ({
...envVar,
containerName: container.name,
isInitContainer: true,
})) || []
) || [];
return [...appContainersEnvVars, ...appInitContainersEnvVars];
}

View file

@ -0,0 +1,125 @@
import { Service } from 'kubernetes-types/core/v1';
import { useMemo } from 'react';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { useIngresses } from '@/react/kubernetes/ingresses/queries';
import { Ingress } from '@/react/kubernetes/ingresses/types';
import { Authorized } from '@/react/hooks/useUser';
import { Link } from '@@/Link';
type Props = {
environmentId: EnvironmentId;
namespace: string;
appServices?: Service[];
};
export function ApplicationIngressesTable({
environmentId,
namespace,
appServices,
}: Props) {
const namespaceIngresses = useIngresses(environmentId, [namespace]);
// getIngressPathsForAppServices could be expensive, so memoize it
const ingressPathsForAppServices = useMemo(
() => getIngressPathsForAppServices(namespaceIngresses.data, appServices),
[namespaceIngresses.data, appServices]
);
if (!ingressPathsForAppServices.length) {
return null;
}
return (
<table className="mt-4 table">
<tbody>
<tr className="text-muted">
<td className="w-[15%]">Ingress name</td>
<td className="w-[10%]">Service name</td>
<td className="w-[10%]">Host</td>
<td className="w-[10%]">Port</td>
<td className="w-[10%]">Path</td>
<td className="w-[15%]">HTTP Route</td>
</tr>
{ingressPathsForAppServices.map((ingressPath, index) => (
<tr key={index}>
<td>
<Authorized authorizations="K8sIngressesW">
<Link
to="kubernetes.ingresses.edit"
params={{ name: ingressPath.ingressName, namespace }}
>
{ingressPath.ingressName}
</Link>
</Authorized>
</td>
<td>{ingressPath.serviceName}</td>
<td>{ingressPath.host}</td>
<td>{ingressPath.port}</td>
<td>{ingressPath.path}</td>
<td>
<a
target="_blank"
rel="noopener noreferrer"
href={`${ingressPath.secure ? 'https' : 'http'}://${
ingressPath.host
}${ingressPath.path}`}
>
{ingressPath.host}
{ingressPath.path}
</a>
</td>
</tr>
))}
</tbody>
</table>
);
}
type IngressPath = {
ingressName: string;
serviceName: string;
port: number;
secure: boolean;
host: string;
path: string;
};
function getIngressPathsForAppServices(
ingresses?: Ingress[],
services?: Service[]
): IngressPath[] {
if (!ingresses || !services) {
return [];
}
const matchingIngressesPaths = ingresses.flatMap((ingress) => {
// for each ingress get an array of ingress paths that match the app services
const matchingIngressPaths = ingress.Paths.filter((path) =>
services?.some((service) => {
const servicePorts = service.spec?.ports?.map((port) => port.port);
// include the ingress if the ingress path has a matching service name and port
return (
path.ServiceName === service.metadata?.name &&
servicePorts?.includes(path.Port)
);
})
).map((path) => {
const secure =
(ingress.TLS &&
ingress.TLS.filter(
(tls) => tls.Hosts && tls.Hosts.includes(path.Host)
).length > 0) ??
false;
return {
ingressName: ingress.Name,
serviceName: path.ServiceName,
port: path.Port,
secure,
host: path.Host,
path: path.Path,
};
});
return matchingIngressPaths;
});
return matchingIngressesPaths;
}

View file

@ -0,0 +1,268 @@
import { useMemo } from 'react';
import { Asterisk, Box, Boxes, Database } from 'lucide-react';
import { Container, Pod, Volume } from 'kubernetes-types/core/v1';
import { StatefulSet } from 'kubernetes-types/apps/v1';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { Icon } from '@@/Icon';
import { TextTip } from '@@/Tip/TextTip';
import { Tooltip } from '@@/Tip/Tooltip';
import { Link } from '@@/Link';
import { Application } from '../../types';
import { applicationIsKind } from '../../utils';
import { useApplicationPods } from '../../application.queries';
type Props = {
environmentId: EnvironmentId;
namespace: string;
appName: string;
app?: Application;
};
export function ApplicationPersistentDataTable({
namespace,
app,
environmentId,
appName,
}: Props) {
const { data: pods } = useApplicationPods(
environmentId,
namespace,
appName,
app
);
const persistedFolders = useMemo(
() => getPersistedFolders(app, pods),
[app, pods]
);
const dataAccessPolicy = getDataAccessPolicy(app);
return (
<>
<div className="text-muted mb-4 mt-6 flex items-center">
<Icon icon={Database} className="!mr-2 !shrink-0" />
Data persistence
</div>
{!persistedFolders.length && (
<TextTip color="blue">
This application has no persisted folders.
</TextTip>
)}
{persistedFolders.length > 0 && (
<>
<div className="small text-muted vertical-center mb-4">
Data access policy:
{dataAccessPolicy === 'isolated' && (
<>
<Icon icon={Boxes} />
Isolated
<Tooltip message="All the instances of this application are using their own data." />
</>
)}
{dataAccessPolicy === 'shared' && (
<>
<Icon icon={Box} />
Shared
<Tooltip message="All the instances of this application are sharing the same data." />
</>
)}
</div>
{dataAccessPolicy === 'isolated' && (
<table className="table">
<thead>
<tr className="text-muted">
<td className="w-1/4">Container name</td>
<td className="w-1/4">Pod name</td>
<td className="w-1/4">Persisted folder</td>
<td className="w-1/4">Persistence</td>
</tr>
</thead>
<tbody>
{persistedFolders.map((persistedFolder, index) => (
<tr key={index}>
<td>
{persistedFolder.volumeMount.container.name}
{persistedFolder.isContainerInit && (
<span>
<Icon icon={Asterisk} className="!mr-1" />(
<a
href="https://kubernetes.io/docs/concepts/workloads/pods/init-containers/"
target="_blank"
rel="noopener noreferrer"
>
init container
</a>
)
</span>
)}
</td>
<td>{persistedFolder.volumeMount?.pod?.metadata?.name}</td>
<td>{persistedFolder.volumeMount.mountPath}</td>
<td>
{persistedFolder.volume.persistentVolumeClaim && (
<Link
className="hyperlink flex items-center"
to="kubernetes.volumes.volume"
params={{
name: `${persistedFolder.volume.persistentVolumeClaim.claimName}-${persistedFolder.volumeMount?.pod?.metadata?.name}`,
namespace,
}}
>
<Icon icon={Database} className="!mr-1 shrink-0" />
{`${persistedFolder.volume.persistentVolumeClaim.claimName}-${persistedFolder.volumeMount?.pod?.metadata?.name}`}
</Link>
)}
{persistedFolder.volume.hostPath &&
`${persistedFolder.volume.hostPath.path} on host filesystem`}
</td>
</tr>
))}
</tbody>
</table>
)}
{dataAccessPolicy === 'shared' && (
<table className="table">
<thead>
<tr className="text-muted">
<td className="w-1/3">Persisted folder</td>
<td className="w-2/3">Persistence</td>
</tr>
</thead>
<tbody className="border-t-0">
{persistedFolders.map((persistedFolder, index) => (
<tr key={index}>
<td data-cy="k8sAppDetail-volMountPath">
{persistedFolder.volumeMount.mountPath}
</td>
<td>
{persistedFolder.volume.persistentVolumeClaim && (
<Link
className="hyperlink flex items-center"
to="kubernetes.volumes.volume"
params={{
name: persistedFolder.volume.persistentVolumeClaim
.claimName,
namespace,
}}
>
<Icon icon={Database} className="!mr-1 shrink-0" />
{
persistedFolder.volume.persistentVolumeClaim
.claimName
}
</Link>
)}
{persistedFolder.volume.hostPath &&
`${persistedFolder.volume.hostPath.path} on host filesystem`}
</td>
</tr>
))}
</tbody>
</table>
)}
</>
)}
</>
);
}
function getDataAccessPolicy(app?: Application) {
if (!app || applicationIsKind<Pod>('Pod', app)) {
return 'none';
}
if (applicationIsKind<StatefulSet>('StatefulSet', app)) {
return 'isolated';
}
return 'shared';
}
function getPodsMatchingContainer(pods: Pod[], container: Container) {
const matchingPods = pods.filter((pod) => {
const podContainers = pod.spec?.containers || [];
const podInitContainers = pod.spec?.initContainers || [];
const podAllContainers = [...podContainers, ...podInitContainers];
return podAllContainers.some(
(podContainer) =>
podContainer.name === container.name &&
podContainer.image === container.image
);
});
return matchingPods;
}
function getPersistedFolders(app?: Application, pods?: Pod[]) {
if (!app || !pods) {
return [];
}
const podSpec = applicationIsKind<Pod>('Pod', app)
? app.spec
: app.spec?.template?.spec;
const appVolumes = podSpec?.volumes || [];
const appVolumeClaimVolumes = getVolumeClaimTemplates(app, appVolumes);
const appAllVolumes = [...appVolumes, ...appVolumeClaimVolumes];
const appContainers = podSpec?.containers || [];
const appInitContainers = podSpec?.initContainers || [];
const appAllContainers = [...appContainers, ...appInitContainers];
// for each volume, find the volumeMounts that match it
const persistedFolders = appAllVolumes.flatMap((volume) => {
if (volume.persistentVolumeClaim || volume.hostPath) {
const volumeMounts = appAllContainers.flatMap((container) => {
const matchingPods = getPodsMatchingContainer(pods, container);
return (
container.volumeMounts?.flatMap(
(containerVolumeMount) =>
matchingPods.map((pod) => ({
...containerVolumeMount,
container,
pod,
})) || []
) || []
);
});
const uniqueMatchingVolumeMounts = volumeMounts.filter(
(volumeMount, index, self) =>
self.indexOf(volumeMount) === index && // remove volumeMounts with duplicate names
volumeMount.name === volume.name // remove volumeMounts that don't match the volume
);
return uniqueMatchingVolumeMounts.map((volumeMount) => ({
volume,
volumeMount,
isContainerInit: appInitContainers.some(
(container) => container.name === volumeMount.container.name
),
}));
}
return [];
});
return persistedFolders;
}
function getVolumeClaimTemplates(app: Application, volumes: Volume[]) {
if (
applicationIsKind<StatefulSet>('StatefulSet', app) &&
app.spec?.volumeClaimTemplates
) {
const volumeClaimTemplates: Volume[] = app.spec.volumeClaimTemplates.map(
(vc) => ({
name: vc.metadata?.name || '',
persistentVolumeClaim: { claimName: vc.metadata?.name || '' },
})
);
const newPVC = volumeClaimTemplates.filter(
(vc) =>
!volumes.find(
(v) =>
v.persistentVolumeClaim?.claimName ===
vc.persistentVolumeClaim?.claimName
)
);
return newPVC;
}
return [];
}

View file

@ -0,0 +1,130 @@
import { Service } from 'kubernetes-types/core/v1';
import { ExternalLink } from 'lucide-react';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { useEnvironment } from '@/react/portainer/environments/queries';
import { Icon } from '@@/Icon';
import { TextTip } from '@@/Tip/TextTip';
type Props = {
environmentId: EnvironmentId;
appServices?: Service[];
};
export function ApplicationServicesTable({
environmentId,
appServices,
}: Props) {
const { data: environment } = useEnvironment(environmentId);
return (
<>
<div className="text-muted mb-4 flex items-center">
<Icon icon={ExternalLink} className="!mr-2" />
Accessing the application
</div>
{appServices && appServices.length === 0 && (
<TextTip color="blue" className="mb-4">
This application is not exposing any port.
</TextTip>
)}
{appServices && appServices.length > 0 && (
<>
<TextTip color="blue" className="mb-4">
This application is exposed through service(s) as below:
</TextTip>
<table className="table">
<tbody>
<tr className="text-muted">
<td className="w-[15%]">Service name</td>
<td className="w-[10%]">Type</td>
<td className="w-[10%]">Cluster IP</td>
<td className="w-[10%]">External IP</td>
<td className="w-[10%]">Container port</td>
<td className="w-[15%]">Service port(s)</td>
</tr>
{appServices.map((service) => (
<tr key={service.metadata?.name}>
<td>{service.metadata?.name}</td>
<td>{service.spec?.type}</td>
<td>{service.spec?.clusterIP}</td>
{service.spec?.type === 'LoadBalancer' && (
<td>
{service.status?.loadBalancer?.ingress?.[0] &&
service.spec?.ports?.[0] && (
<a
className="vertical-center hyperlink"
target="_blank"
rel="noopener noreferrer"
href={`http://${service.status.loadBalancer.ingress[0].ip}:${service.spec.ports[0].port}`}
>
<Icon icon={ExternalLink} className="!mr-1" />
<span data-cy="k8sAppDetail-containerPort">
Access
</span>
</a>
)}
{!service.status?.loadBalancer?.ingress && (
<div>
{service.spec.externalIPs?.[0]
? service.spec.externalIPs[0]
: 'pending...'}
</div>
)}
</td>
)}
{service.spec?.type !== 'LoadBalancer' && (
<td>
{service.spec?.externalIPs?.[0]
? service.spec.externalIPs[0]
: '-'}
</td>
)}
<td data-cy="k8sAppDetail-containerPort">
{service.spec?.ports?.map((port) => (
<div key={port.port}>{port.targetPort}</div>
))}
</td>
<td>
{service.spec?.ports?.map((port) => (
<div key={port.port}>
{environment?.PublicURL && port.nodePort && (
<a
className="vertical-center hyperlink"
href={`http://${environment?.PublicURL}:${port.nodePort}`}
target="_blank"
rel="noopener noreferrer"
>
<Icon icon={ExternalLink} className="!mr-1" />
<span data-cy="k8sAppDetail-containerPort">
{port.port}
</span>
<span>{port.nodePort ? ' : ' : ''}</span>
<span data-cy="k8sAppDetail-nodePort">
{port.nodePort}/{port.protocol}
</span>
</a>
)}
{!environment?.PublicURL && (
<div>
<span data-cy="k8sAppDetail-servicePort">
{port.port}
</span>
<span>{port.nodePort ? ' : ' : ''}</span>
<span data-cy="k8sAppDetail-nodePort">
{port.nodePort}/{port.protocol}
</span>
</div>
)}
</div>
))}
</td>
</tr>
))}
</tbody>
</table>
</>
)}
</>
);
}

View file

@ -0,0 +1,148 @@
import { KeyToPath, Pod } from 'kubernetes-types/core/v1';
import { Asterisk, Plus } from 'lucide-react';
import { Icon } from '@@/Icon';
import { Link } from '@@/Link';
import { Application } from '../../types';
import { applicationIsKind } from '../../utils';
type Props = {
namespace: string;
app?: Application;
};
export function ApplicationVolumeConfigsTable({ namespace, app }: Props) {
const containerVolumeConfigs = getApplicationVolumeConfigs(app);
if (containerVolumeConfigs.length === 0) {
return null;
}
return (
<table className="table">
<tbody>
<tr className="text-muted">
<td className="w-1/4">Container</td>
<td className="w-1/4">Configuration path</td>
<td className="w-1/4">Value</td>
<td className="w-1/4">Configuration</td>
</tr>
{containerVolumeConfigs.map(
(
{
containerVolumeMount,
isInitContainer,
containerName,
item,
volumeConfigName,
},
index
) => (
<tr key={index}>
<td>
{containerName}
{isInitContainer && (
<span>
<Icon icon={Asterisk} />(
<a
href="https://kubernetes.io/docs/concepts/workloads/pods/init-containers/"
target="_blank"
rel="noopener noreferrer"
>
init container
</a>
)
</span>
)}
</td>
<td>
{item.path
? `${containerVolumeMount?.mountPath}/${item.path}`
: `${containerVolumeMount?.mountPath}`}
</td>
<td>
{item.key && (
<div className="flex items-center">
<Icon icon={Plus} className="!mr-1" />
{item.key}
</div>
)}
{!item.key && '-'}
</td>
<td>
{volumeConfigName && (
<Link
className="flex items-center"
to="kubernetes.configurations.configuration"
params={{ name: volumeConfigName, namespace }}
>
<Icon icon={Plus} className="!mr-1" />
{volumeConfigName}
</Link>
)}
{!volumeConfigName && '-'}
</td>
</tr>
)
)}
</tbody>
</table>
);
}
// getApplicationVolumeConfigs returns a list of volume configs / secrets for each container and each item within the matching volume
function getApplicationVolumeConfigs(app?: Application) {
if (!app) {
return [];
}
const podSpec = applicationIsKind<Pod>('Pod', app)
? app.spec
: app.spec?.template?.spec;
const appContainers = podSpec?.containers || [];
const appInitContainers = podSpec?.initContainers || [];
const appVolumes = podSpec?.volumes || [];
const allContainers = [...appContainers, ...appInitContainers];
const appVolumeConfigs = allContainers.flatMap((container) => {
// for each container, get the volume mount paths
const matchingVolumes = appVolumes
// filter app volumes by config map or secret
.filter((volume) => volume.configMap || volume.secret)
.flatMap((volume) => {
// flatten by volume items if there are any
const volConfigMapItems =
volume.configMap?.items || volume.secret?.items || [];
const volumeConfigName =
volume.configMap?.name || volume.secret?.secretName;
const containerVolumeMount = container.volumeMounts?.find(
(volumeMount) => volumeMount.name === volume.name
);
if (volConfigMapItems.length === 0) {
return [
{
volumeConfigName,
containerVolumeMount,
containerName: container.name,
isInitContainer: appInitContainers.includes(container),
item: {} as KeyToPath,
},
];
}
// if there are items, return a volume config for each item
return volConfigMapItems.map((item) => ({
volumeConfigName,
containerVolumeMount,
containerName: container.name,
isInitContainer: appInitContainers.includes(container),
item,
}));
})
// only return the app volumes where the container volumeMounts include the volume name (from map step above)
.filter((volume) => volume.containerVolumeMount);
return matchingVolumes;
});
return appVolumeConfigs;
}

View file

@ -0,0 +1,101 @@
import { RotateCw } from 'lucide-react';
import { Pod } from 'kubernetes-types/core/v1';
import { useRouter } from '@uirouter/react';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { notifySuccess, notifyError } from '@/portainer/services/notifications';
import { Authorized } from '@/react/hooks/useUser';
import { confirm } from '@@/modals/confirm';
import { ModalType } from '@@/modals';
import { buildConfirmButton } from '@@/modals/utils';
import { Button } from '@@/buttons';
import { Icon } from '@@/Icon';
import { useRedeployApplicationMutation } from '../../application.queries';
import { Application } from '../../types';
import {
applicationIsKind,
matchLabelsToLabelSelectorValue,
} from '../../utils';
type Props = {
environmentId: EnvironmentId;
namespace: string;
appName: string;
app?: Application;
};
export function RedeployApplicationButton({
environmentId,
namespace,
appName,
app,
}: Props) {
const router = useRouter();
const redeployAppMutation = useRedeployApplicationMutation(
environmentId,
namespace,
appName
);
return (
<Authorized authorizations="K8sPodDelete">
<Button
type="button"
size="small"
color="light"
className="!ml-0"
disabled={redeployAppMutation.isLoading || !app}
onClick={() => redeployApplication()}
data-cy="k8sAppDetail-redeployButton"
>
<Icon icon={RotateCw} className="mr-1" />
Redeploy
</Button>
</Authorized>
);
async function redeployApplication() {
// validate
if (!app || applicationIsKind<Pod>('Pod', app)) {
return;
}
try {
if (!app?.spec?.selector?.matchLabels) {
throw new Error(
`Application has no 'matchLabels' selector to redeploy pods.`
);
}
} catch (error) {
notifyError('Failure', error as Error);
return;
}
// confirm the action
const confirmed = await confirm({
title: 'Are you sure?',
modalType: ModalType.Warn,
confirmButton: buildConfirmButton('Redeploy'),
message:
'Redeploying terminates and restarts the application, which will cause service interruption. Do you wish to continue?',
});
if (!confirmed) {
return;
}
// using the matchlabels object, delete the associated pods with redeployAppMutation
const labelSelector = matchLabelsToLabelSelectorValue(
app?.spec?.selector?.matchLabels
);
redeployAppMutation.mutateAsync(
{ labelSelector },
{
onSuccess: () => {
notifySuccess('Success', 'Application successfully redeployed');
router.stateService.reload();
},
}
);
}
}

View file

@ -0,0 +1,19 @@
import { RefreshCw } from 'lucide-react';
import { FeatureId } from '@/react/portainer/feature-flags/enums';
import { BETeaserButton } from '@@/BETeaserButton';
export function RestartApplicationButton() {
return (
<BETeaserButton
buttonClassName="!ml-0"
data-cy="k8sAppDetail-restartButton"
heading="Rolling restart"
icon={RefreshCw}
featureId={FeatureId.K8S_ROLLING_RESTART}
message="A rolling restart of the application is performed."
buttonText="Rolling restart"
/>
);
}

View file

@ -0,0 +1,130 @@
import { Pod } from 'kubernetes-types/core/v1';
import { RotateCcw } from 'lucide-react';
import { useRouter } from '@uirouter/react';
import { Authorized } from '@/react/hooks/useUser';
import { notifySuccess, notifyError } from '@/portainer/services/notifications';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { Button } from '@@/buttons';
import { Icon } from '@@/Icon';
import { confirm } from '@@/modals/confirm';
import { ModalType } from '@@/modals';
import { buildConfirmButton } from '@@/modals/utils';
import {
useApplicationRevisionList,
usePatchApplicationMutation,
} from '../../application.queries';
import {
applicationIsKind,
getRollbackPatchPayload,
matchLabelsToLabelSelectorValue,
} from '../../utils';
import { Application } from '../../types';
import { appDeployMethodLabel } from '../../constants';
type Props = {
environmentId: EnvironmentId;
namespace: string;
appName: string;
app?: Application;
};
export function RollbackApplicationButton({
environmentId,
namespace,
appName,
app,
}: Props) {
const router = useRouter();
const labelSelector = applicationIsKind<Pod>('Pod', app)
? ''
: matchLabelsToLabelSelectorValue(app?.spec?.selector?.matchLabels);
const appRevisionListQuery = useApplicationRevisionList(
environmentId,
namespace,
appName,
app?.metadata?.uid,
labelSelector,
app?.kind
);
const appRevisionList = appRevisionListQuery.data;
const appRevisions = appRevisionList?.items;
const appDeployMethod =
app?.metadata?.labels?.[appDeployMethodLabel] || 'application form';
const patchAppMutation = usePatchApplicationMutation(
environmentId,
namespace,
appName
);
return (
<Authorized authorizations="K8sApplicationDetailsW">
<Button
ng-if="!ctrl.isExternalApplication()"
type="button"
color="light"
size="small"
className="!ml-0"
disabled={
!app ||
!appRevisions ||
appRevisions?.length < 2 ||
appDeployMethod !== 'application form' ||
patchAppMutation.isLoading
}
onClick={() => rollbackApplication()}
data-cy="k8sAppDetail-rollbackButton"
>
<Icon icon={RotateCcw} className="mr-1" />
Rollback to previous configuration
</Button>
</Authorized>
);
async function rollbackApplication() {
// exit early if the application is a pod or there are no revisions
if (
!app?.kind ||
applicationIsKind<Pod>('Pod', app) ||
!appRevisionList?.items?.length
) {
return;
}
// confirm the action
const confirmed = await confirm({
title: 'Are you sure?',
modalType: ModalType.Warn,
confirmButton: buildConfirmButton('Rollback'),
message:
'Rolling back the application to a previous configuration may cause service interruption. Do you wish to continue?',
});
if (!confirmed) {
return;
}
try {
const patch = getRollbackPatchPayload(app, appRevisionList);
patchAppMutation.mutateAsync(
{ appKind: app.kind, patch },
{
onSuccess: () => {
notifySuccess('Success', 'Application successfully rolled back');
router.stateService.reload();
},
onError: (error) =>
notifyError(
'Failure',
error as Error,
'Unable to rollback the application'
),
}
);
} catch (error) {
notifyError('Failure', error as Error);
}
}
}

View file

@ -0,0 +1 @@
export { ApplicationDetailsWidget } from './ApplicationDetailsWidget';

View file

@ -32,7 +32,7 @@ import {
useApplication,
usePatchApplicationMutation,
} from '../application.queries';
import { Application } from '../types';
import { Application, ApplicationPatch } from '../types';
export function ApplicationSummaryWidget() {
const stateAndParams = useCurrentStateAndParams();
@ -263,14 +263,18 @@ export function ApplicationSummaryWidget() {
);
async function patchApplicationNote() {
const path = `/metadata/annotations/${appNoteAnnotation}`;
const value = applicationNoteFormValues;
const patch: ApplicationPatch = [
{
op: 'replace',
path: `/metadata/annotations/${appNoteAnnotation}`,
value: 'applicationNoteFormValues',
},
];
if (application?.kind) {
try {
await patchApplicationMutation.mutateAsync({
appKind: application.kind,
path,
value,
patch,
});
notifySuccess('Success', 'Application successfully updated');
} catch (error) {

View file

@ -1 +1,2 @@
export { ApplicationSummaryWidget } from './ApplicationSummaryWidget';
export { ApplicationDetailsWidget } from './ApplicationDetailsWidget/ApplicationDetailsWidget';

View file

@ -1,14 +1,21 @@
import { useMutation, useQuery } from 'react-query';
import { Pod } from 'kubernetes-types/core/v1';
import { queryClient, withError } from '@/react-tools/react-query';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { getNamespaceServices } from '../ServicesView/service';
import {
getApplicationsForCluster,
getApplication,
patchApplication,
getApplicationRevisionList,
} from './application.service';
import { AppKind } from './types';
import type { AppKind, Application, ApplicationPatch } from './types';
import { deletePod, getNamespacePods } from './pod.service';
import { getNamespaceHorizontalPodAutoscalers } from './autoscaling.service';
import { applicationIsKind, matchLabelsToLabelSelectorValue } from './utils';
const queryKeys = {
applicationsForCluster: (environmentId: EnvironmentId) => [
@ -29,6 +36,73 @@ const queryKeys = {
namespace,
name,
],
applicationRevisions: (
environmentId: EnvironmentId,
namespace: string,
name: string,
labelSelector?: string
) => [
'environments',
environmentId,
'kubernetes',
'applications',
namespace,
name,
'revisions',
labelSelector,
],
applicationServices: (
environmentId: EnvironmentId,
namespace: string,
name: string
) => [
'environments',
environmentId,
'kubernetes',
'applications',
namespace,
name,
'services',
],
ingressesForApplication: (
environmentId: EnvironmentId,
namespace: string,
name: string
) => [
'environments',
environmentId,
'kubernetes',
'applications',
namespace,
name,
'ingresses',
],
applicationHorizontalPodAutoscalers: (
environmentId: EnvironmentId,
namespace: string,
name: string
) => [
'environments',
environmentId,
'kubernetes',
'applications',
namespace,
name,
'horizontalpodautoscalers',
],
applicationPods: (
environmentId: EnvironmentId,
namespace: string,
name: string
) => [
'environments',
environmentId,
'kubernetes',
'applications',
namespace,
name,
'pods',
],
};
// useQuery to get a list of all applications from an array of namespaces
@ -62,6 +136,161 @@ export function useApplication(
);
}
// test if I can get the previous revision
// useQuery to get an application's previous revision by environmentId, namespace, appKind and labelSelector
export function useApplicationRevisionList(
environmentId: EnvironmentId,
namespace: string,
name: string,
deploymentUid?: string,
labelSelector?: string,
appKind?: AppKind
) {
return useQuery(
queryKeys.applicationRevisions(
environmentId,
namespace,
name,
labelSelector
),
() =>
getApplicationRevisionList(
environmentId,
namespace,
deploymentUid,
appKind,
labelSelector
),
{
...withError('Unable to retrieve application revisions'),
enabled: !!labelSelector && !!appKind && !!deploymentUid,
}
);
}
// useApplicationServices returns a query for services that are related to the application (this doesn't include ingresses)
// Filtering the services by the application selector labels is done in the front end because:
// - The label selector query param in the kubernetes API filters by metadata.labels, but we need to filter the services by spec.selector
// - The field selector query param in the kubernetes API can filter the services by spec.selector, but it doesn't support chaining with 'OR',
// so we can't filter by services with at least one matching label. See: https://kubernetes.io/docs/concepts/overview/working-with-objects/field-selectors/#chained-selectors
export function useApplicationServices(
environmentId: EnvironmentId,
namespace: string,
appName: string,
app?: Application
) {
return useQuery(
queryKeys.applicationServices(environmentId, namespace, appName),
async () => {
if (!app) {
return [];
}
// get the selector labels for the application
const appSelectorLabels = applicationIsKind<Pod>('Pod', app)
? app.metadata?.labels
: app.spec?.template?.metadata?.labels;
// get all services in the namespace and filter them by the application selector labels
const services = await getNamespaceServices(environmentId, namespace);
const filteredServices = services.filter((service) => {
if (service.spec?.selector && appSelectorLabels) {
const serviceSelectorLabels = service.spec.selector;
// include the service if the service selector label matches at least one application selector label
return Object.keys(appSelectorLabels).some(
(key) =>
serviceSelectorLabels[key] &&
serviceSelectorLabels[key] === appSelectorLabels[key]
);
}
return false;
});
return filteredServices;
},
{ ...withError(`Unable to get services for ${appName}`), enabled: !!app }
);
}
// useApplicationHorizontalPodAutoscalers returns a query for horizontal pod autoscalers that are related to the application
export function useApplicationHorizontalPodAutoscalers(
environmentId: EnvironmentId,
namespace: string,
appName: string,
app?: Application
) {
return useQuery(
queryKeys.applicationHorizontalPodAutoscalers(
environmentId,
namespace,
appName
),
async () => {
if (!app) {
return null;
}
const horizontalPodAutoscalers =
await getNamespaceHorizontalPodAutoscalers(environmentId, namespace);
const filteredHorizontalPodAutoscalers =
horizontalPodAutoscalers.find((horizontalPodAutoscaler) => {
const scaleTargetRef = horizontalPodAutoscaler.spec?.scaleTargetRef;
if (scaleTargetRef) {
const scaleTargetRefName = scaleTargetRef.name;
const scaleTargetRefKind = scaleTargetRef.kind;
// include the horizontal pod autoscaler if the scale target ref name and kind match the application name and kind
return (
scaleTargetRefName === app.metadata?.name &&
scaleTargetRefKind === app.kind
);
}
return false;
}) || null;
return filteredHorizontalPodAutoscalers;
},
{
...withError(
`Unable to get horizontal pod autoscalers${
app ? ` for ${app.metadata?.name}` : ''
}`
),
enabled: !!app,
}
);
}
// useApplicationPods returns a query for pods that are related to the application by the application selector labels
export function useApplicationPods(
environmentId: EnvironmentId,
namespace: string,
appName: string,
app?: Application
) {
return useQuery(
queryKeys.applicationPods(environmentId, namespace, appName),
async () => {
if (applicationIsKind<Pod>('Pod', app)) {
return [app];
}
const appSelector = app?.spec?.selector;
const labelSelector = matchLabelsToLabelSelectorValue(
appSelector?.matchLabels
);
// get all pods in the namespace using the application selector as the label selector query param
const pods = await getNamespacePods(
environmentId,
namespace,
labelSelector
);
return pods;
},
{
...withError(`Unable to get pods for ${appName}`),
enabled: !!app,
}
);
}
// useQuery to patch an application by environmentId, namespace, name and patch payload
export function usePatchApplicationMutation(
environmentId: EnvironmentId,
@ -69,22 +298,54 @@ export function usePatchApplicationMutation(
name: string
) {
return useMutation(
({
appKind,
path,
value,
}: {
appKind: AppKind;
path: string;
value: string;
}) =>
patchApplication(environmentId, namespace, appKind, name, path, value),
({ appKind, patch }: { appKind: AppKind; patch: ApplicationPatch }) =>
patchApplication(environmentId, namespace, appKind, name, patch),
{
onSuccess: () => {
queryClient.invalidateQueries(
queryKeys.application(environmentId, namespace, name)
);
},
// patch application is used for patching and rollbacks, so handle the error where it's used instead of here
}
);
}
// useRedeployApplicationMutation gets all the pods for an application (using the matchLabels field in the labelSelector query param) and then deletes all of them, so that they are recreated
export function useRedeployApplicationMutation(
environmentId: number,
namespace: string,
name: string
) {
return useMutation(
async ({ labelSelector }: { labelSelector: string }) => {
try {
// get only the pods that match the labelSelector for the application
const pods = await getNamespacePods(
environmentId,
namespace,
labelSelector
);
// delete all the pods to redeploy the application
await Promise.all(
pods.map((pod) => {
if (pod?.metadata?.name) {
return deletePod(environmentId, namespace, pod.metadata.name);
}
return Promise.resolve();
})
);
} catch (error) {
throw new Error(`Unable to redeploy application: ${error}`);
}
},
{
onSuccess: () => {
queryClient.invalidateQueries(
queryKeys.application(environmentId, namespace, name)
);
},
...withError('Unable to redeploy application'),
}
);
}

View file

@ -5,15 +5,23 @@ import {
Deployment,
DaemonSet,
StatefulSet,
ReplicaSetList,
ControllerRevisionList,
} from 'kubernetes-types/apps/v1';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { isFulfilled } from '@/portainer/helpers/promise-utils';
import { getPod, getPods, patchPod } from './pod.service';
import { getNakedPods } from './utils';
import { AppKind, Application, ApplicationList } from './types';
import { getPod, getNamespacePods, patchPod } from './pod.service';
import { filterRevisionsByOwnerUid, getNakedPods } from './utils';
import {
AppKind,
Application,
ApplicationList,
ApplicationPatch,
} from './types';
import { appRevisionAnnotation } from './constants';
// This file contains services for Kubernetes apps/v1 resources (Deployments, DaemonSets, StatefulSets)
@ -58,7 +66,7 @@ async function getApplicationsForNamespace(
namespace,
'StatefulSet'
),
getPods(environmentId, namespace),
getNamespacePods(environmentId, namespace),
]);
// find all pods which are 'naked' (not owned by a deployment, daemonset or statefulset)
const nakedPods = getNakedPods(pods, deployments, daemonSets, statefulSets);
@ -147,8 +155,7 @@ export async function patchApplication(
namespace: string,
appKind: AppKind,
name: string,
path: string,
value: string
patch: ApplicationPatch
) {
try {
switch (appKind) {
@ -158,8 +165,7 @@ export async function patchApplication(
namespace,
appKind,
name,
path,
value
patch
);
case 'DaemonSet':
return await patchApplicationByKind<DaemonSet>(
@ -167,8 +173,8 @@ export async function patchApplication(
namespace,
appKind,
name,
path,
value
patch,
'application/strategic-merge-patch+json'
);
case 'StatefulSet':
return await patchApplicationByKind<StatefulSet>(
@ -176,11 +182,11 @@ export async function patchApplication(
namespace,
appKind,
name,
path,
value
patch,
'application/strategic-merge-patch+json'
);
case 'Pod':
return await patchPod(environmentId, namespace, name, path, value);
return await patchPod(environmentId, namespace, name, patch);
default:
throw new Error(`Unknown application kind ${appKind}`);
}
@ -197,23 +203,16 @@ async function patchApplicationByKind<T extends Application>(
namespace: string,
appKind: 'Deployment' | 'DaemonSet' | 'StatefulSet',
name: string,
path: string,
value: string
patch: ApplicationPatch,
contentType = 'application/json-patch+json'
) {
const payload = [
{
op: 'replace',
path,
value,
},
];
try {
const res = await axios.patch<T>(
buildUrl(environmentId, namespace, `${appKind}s`, name),
payload,
patch,
{
headers: {
'Content-Type': 'application/json-patch+json',
'Content-Type': contentType,
},
}
);
@ -254,10 +253,120 @@ async function getApplicationsByKind<T extends ApplicationList>(
}
}
export async function getApplicationRevisionList(
environmentId: EnvironmentId,
namespace: string,
deploymentUid?: string,
appKind?: AppKind,
labelSelector?: string
) {
if (!deploymentUid) {
throw new Error('deploymentUid is required');
}
try {
switch (appKind) {
case 'Deployment': {
const replicaSetList = await getReplicaSetList(
environmentId,
namespace,
labelSelector
);
const replicaSets = replicaSetList.items;
// keep only replicaset(s) which are owned by the deployment with the given uid
const replicaSetsWithOwnerId = filterRevisionsByOwnerUid(
replicaSets,
deploymentUid
);
// keep only replicaset(s) that have been a version of the Deployment
const replicaSetsWithRevisionAnnotations =
replicaSetsWithOwnerId.filter(
(rs) => !!rs.metadata?.annotations?.[appRevisionAnnotation]
);
return {
...replicaSetList,
items: replicaSetsWithRevisionAnnotations,
} as ReplicaSetList;
}
case 'DaemonSet':
case 'StatefulSet': {
const controllerRevisionList = await getControllerRevisionList(
environmentId,
namespace,
labelSelector
);
const controllerRevisions = controllerRevisionList.items;
// ensure the controller reference(s) is owned by the deployment with the given uid
const controllerRevisionsWithOwnerId = filterRevisionsByOwnerUid(
controllerRevisions,
deploymentUid
);
return {
...controllerRevisionList,
items: controllerRevisionsWithOwnerId,
} as ControllerRevisionList;
}
default:
throw new Error(`Unknown application kind ${appKind}`);
}
} catch (e) {
throw parseAxiosError(
e as Error,
`Unable to retrieve revisions for ${appKind}`
);
}
}
export async function getReplicaSetList(
environmentId: EnvironmentId,
namespace: string,
labelSelector?: string
) {
try {
const { data } = await axios.get<ReplicaSetList>(
buildUrl(environmentId, namespace, 'ReplicaSets'),
{
params: {
labelSelector,
},
}
);
return data;
} catch (e) {
throw parseAxiosError(e as Error, 'Unable to retrieve ReplicaSets');
}
}
export async function getControllerRevisionList(
environmentId: EnvironmentId,
namespace: string,
labelSelector?: string
) {
try {
const { data } = await axios.get<ControllerRevisionList>(
buildUrl(environmentId, namespace, 'ControllerRevisions'),
{
params: {
labelSelector,
},
}
);
return data;
} catch (e) {
throw parseAxiosError(e as Error, 'Unable to retrieve ControllerRevisions');
}
}
function buildUrl(
environmentId: EnvironmentId,
namespace: string,
appKind: 'Deployments' | 'DaemonSets' | 'StatefulSets',
appKind:
| 'Deployments'
| 'DaemonSets'
| 'StatefulSets'
| 'ReplicaSets'
| 'ControllerRevisions',
name?: string
) {
let baseUrl = `/endpoints/${environmentId}/kubernetes/apis/apps/v1/namespaces/${namespace}/${appKind.toLowerCase()}`;

View file

@ -0,0 +1,14 @@
import { HorizontalPodAutoscalerList } from 'kubernetes-types/autoscaling/v1';
import axios from '@/portainer/services/axios';
import { EnvironmentId } from '@/react/portainer/environments/types';
export async function getNamespaceHorizontalPodAutoscalers(
environmentId: EnvironmentId,
namespace: string
) {
const { data: autoScalarList } = await axios.get<HorizontalPodAutoscalerList>(
`/endpoints/${environmentId}/kubernetes/apis/autoscaling/v1/namespaces/${namespace}/horizontalpodautoscalers`
);
return autoScalarList.items;
}

View file

@ -2,9 +2,25 @@ import { AppKind, DeploymentType } from './types';
// Portainer specific labels
export const appStackNameLabel = 'io.portainer.kubernetes.application.stack';
export const appStackIdLabel = 'io.portainer.kubernetes.application.stackid';
export const appOwnerLabel = 'io.portainer.kubernetes.application.owner';
export const appNoteAnnotation = 'io.portainer.kubernetes.application.note';
export const appDeployMethodLabel = 'io.portainer.kubernetes.application.kind';
export const defaultDeploymentUniqueLabel = 'pod-template-hash';
export const appRevisionAnnotation = 'deployment.kubernetes.io/revision';
// unchangedAnnotationKeysForRollbackPatch lists the annotations that should be preserved from the deployment and not
// copied from the replicaset when rolling a deployment back
export const unchangedAnnotationKeysForRollbackPatch = [
'kubectl.kubernetes.io/last-applied-configuration',
appRevisionAnnotation,
'deployment.kubernetes.io/revision-history',
'deployment.kubernetes.io/desired-replicas',
'deployment.kubernetes.io/max-replicas',
'deprecated.deployment.rollback.to',
'deprecated.daemonset.template.generation',
];
export const appKindToDeploymentTypeMap: Record<
AppKind,

View file

@ -1,12 +1,23 @@
import { Pod, PodList } from 'kubernetes-types/core/v1';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { EnvironmentId } from '@/react/portainer/environments/types';
import axios, { parseAxiosError } from '@/portainer/services/axios';
export async function getPods(environmentId: EnvironmentId, namespace: string) {
import { ApplicationPatch } from './types';
export async function getNamespacePods(
environmentId: EnvironmentId,
namespace: string,
labelSelector?: string
) {
try {
const { data } = await axios.get<PodList>(
buildUrl(environmentId, namespace)
buildUrl(environmentId, namespace),
{
params: {
labelSelector,
},
}
);
return data.items;
} catch (e) {
@ -33,20 +44,12 @@ export async function patchPod(
environmentId: EnvironmentId,
namespace: string,
name: string,
path: string,
value: string
patch: ApplicationPatch
) {
const payload = [
{
op: 'replace',
path,
value,
},
];
try {
return await axios.patch<Pod>(
buildUrl(environmentId, namespace, name),
payload,
patch,
{
headers: {
'Content-Type': 'application/json-patch+json',
@ -58,6 +61,18 @@ export async function patchPod(
}
}
export async function deletePod(
environmentId: EnvironmentId,
namespace: string,
name: string
) {
try {
return await axios.delete<Pod>(buildUrl(environmentId, namespace, name));
} catch (e) {
throw parseAxiosError(e as Error, 'Unable to delete pod');
}
}
export function buildUrl(
environmentId: EnvironmentId,
namespace: string,

View file

@ -5,11 +5,18 @@ import {
DeploymentList,
StatefulSet,
StatefulSetList,
ReplicaSet,
ControllerRevision,
} from 'kubernetes-types/apps/v1';
import { Pod, PodList } from 'kubernetes-types/core/v1';
import { RawExtension } from 'kubernetes-types/runtime';
export type Application = Deployment | DaemonSet | StatefulSet | Pod;
// Revisions are have the previous application state and are used for rolling back applications to their previous state.
// Deployments use ReplicaSets, StatefulSets and DaemonSets use ControllerRevisions, and Pods don't have revisions.
export type Revision = ReplicaSet | ControllerRevision;
export type ApplicationList =
| DeploymentList
| DaemonSetList
@ -19,3 +26,11 @@ export type ApplicationList =
export type AppKind = 'Deployment' | 'DaemonSet' | 'StatefulSet' | 'Pod';
export type DeploymentType = 'Replicated' | 'Global';
type Patch = {
op: 'replace' | 'add' | 'remove';
path: string;
value: string | number | boolean | null | Record<string, unknown>;
}[];
export type ApplicationPatch = Patch | RawExtension;

View file

@ -1,18 +1,32 @@
import { Deployment, DaemonSet, StatefulSet } from 'kubernetes-types/apps/v1';
import {
Deployment,
DaemonSet,
StatefulSet,
ReplicaSet,
ReplicaSetList,
ControllerRevisionList,
ControllerRevision,
} from 'kubernetes-types/apps/v1';
import { Pod } from 'kubernetes-types/core/v1';
import filesizeParser from 'filesize-parser';
import { Application } from './types';
import { appOwnerLabel } from './constants';
import { Application, ApplicationPatch, Revision } from './types';
import {
appOwnerLabel,
defaultDeploymentUniqueLabel,
unchangedAnnotationKeysForRollbackPatch,
appRevisionAnnotation,
} from './constants';
// naked pods are pods which are not owned by a deployment, daemonset, statefulset or replicaset
// https://kubernetes.io/docs/concepts/configuration/overview/#naked-pods-vs-replicasets-deployments-and-jobs
// getNakedPods returns an array of naked pods from an array of pods, deployments, daemonsets and statefulsets
export function getNakedPods(
pods: Pod[],
deployments: Deployment[],
daemonSets: DaemonSet[],
statefulSets: StatefulSet[]
) {
// naked pods are pods which are not owned by a deployment, daemonset, statefulset or replicaset
// https://kubernetes.io/docs/concepts/configuration/overview/#naked-pods-vs-replicasets-deployments-and-jobs
const appLabels = [
...deployments.map((deployment) => deployment.spec?.selector.matchLabels),
...daemonSets.map((daemonSet) => daemonSet.spec?.selector.matchLabels),
@ -37,7 +51,7 @@ export function getNakedPods(
return nakedPods;
}
// type guard to check if an application is a deployment, daemonset statefulset or pod
// type guard to check if an application is a deployment, daemonset, statefulset or pod
export function applicationIsKind<T extends Application>(
appKind: 'Deployment' | 'DaemonSet' | 'StatefulSet' | 'Pod',
application?: Application
@ -165,3 +179,151 @@ export function getResourceLimits(application: Application) {
return limits;
}
// matchLabelsToLabelSelectorValue converts a map of labels to a label selector value that can be used in the
// labelSelector param for the kube api to filter kube resources by labels
export function matchLabelsToLabelSelectorValue(obj?: Record<string, string>) {
if (!obj) return '';
return Object.entries(obj)
.map(([key, value]) => `${key}=${value}`)
.join(',');
}
// filterRevisionsByOwnerUid filters a list of revisions to only include revisions that have the given uid in their
// ownerReferences
export function filterRevisionsByOwnerUid<T extends Revision>(
revisions: T[],
uid: string
) {
return revisions.filter((revision) => {
const ownerReferencesUids =
revision.metadata?.ownerReferences?.map((or) => or.uid) || [];
return ownerReferencesUids.includes(uid);
});
}
// getRollbackPatchPayload returns the patch payload to rollback a deployment to the previous revision
// the patch should be able to update the deployment's template to the previous revision's template
export function getRollbackPatchPayload(
application: Deployment | StatefulSet | DaemonSet,
revisionList: ReplicaSetList | ControllerRevisionList
): ApplicationPatch {
switch (revisionList.kind) {
case 'ControllerRevisionList': {
const previousRevision = getPreviousControllerRevision(
revisionList.items
);
if (!previousRevision.data) {
throw new Error('No data found in the previous revision.');
}
return previousRevision.data;
}
case 'ReplicaSetList': {
const previousRevision = getPreviousReplicaSetRevision(
revisionList.items
);
// remove hash label before patching back into the deployment
const revisionTemplate = previousRevision.spec?.template;
if (revisionTemplate?.metadata?.labels) {
delete revisionTemplate.metadata.labels[defaultDeploymentUniqueLabel];
}
// build the patch payload for the deployment from the replica set
// keep the annotations to skip from the deployment, in the patch
const applicationAnnotations = application.metadata?.annotations || {};
const applicationAnnotationsInPatch =
unchangedAnnotationKeysForRollbackPatch.reduce((acc, annotationKey) => {
if (applicationAnnotations[annotationKey]) {
acc[annotationKey] = applicationAnnotations[annotationKey];
}
return acc;
}, {} as Record<string, string>);
// add any annotations from the target revision that shouldn't be skipped
const revisionAnnotations = previousRevision.metadata?.annotations || {};
const revisionAnnotationsInPatch = Object.entries(
revisionAnnotations
).reduce((acc, [annotationKey, annotationValue]) => {
if (!unchangedAnnotationKeysForRollbackPatch.includes(annotationKey)) {
acc[annotationKey] = annotationValue;
}
return acc;
}, {} as Record<string, string>);
const patchAnnotations = {
...applicationAnnotationsInPatch,
...revisionAnnotationsInPatch,
};
// Create a patch of the Deployment that replaces spec.template
const deploymentRollbackPatch = [
{
op: 'replace',
path: '/spec/template',
value: revisionTemplate,
},
{
op: 'replace',
path: '/metadata/annotations',
value: patchAnnotations,
},
].filter((p) => !!p.value); // remove any patch that has no value
return deploymentRollbackPatch;
}
default:
throw new Error(`Unknown revision list kind ${revisionList.kind}.`);
}
}
function getPreviousReplicaSetRevision(replicaSets: ReplicaSet[]) {
// sort replicaset(s) using the revision annotation number (old to new).
// Kubectl uses the same revision annotation key to determine the previous version
// (see the Revision function, and where it's used https://github.com/kubernetes/kubectl/blob/27ec3dafa658d8873b3d9287421d636048b51921/pkg/util/deployment/deployment.go#LL70C11-L70C11)
const sortedReplicaSets = replicaSets.sort((a, b) => {
const aRevision =
Number(a.metadata?.annotations?.[appRevisionAnnotation]) || 0;
const bRevision =
Number(b.metadata?.annotations?.[appRevisionAnnotation]) || 0;
return aRevision - bRevision;
});
// if there are less than 2 revisions, there is no previous revision to rollback to
if (sortedReplicaSets.length < 2) {
throw new Error(
'There are no previous revisions to rollback to. Please check the application revisions.'
);
}
// get the second to last revision
const previousRevision = sortedReplicaSets[sortedReplicaSets.length - 2];
return previousRevision;
}
function getPreviousControllerRevision(
controllerRevisions: ControllerRevision[]
) {
// sort the list of ControllerRevisions by revision, using the creationTimestamp as a tie breaker (old to new)
const sortedControllerRevisions = controllerRevisions.sort((a, b) => {
if (a.revision === b.revision) {
return (
new Date(a.metadata?.creationTimestamp || '').getTime() -
new Date(b.metadata?.creationTimestamp || '').getTime()
);
}
return a.revision - b.revision;
});
// if there are less than 2 revisions, there is no previous revision to rollback to
if (sortedControllerRevisions.length < 2) {
throw new Error(
'There are no previous revisions to rollback to. Please check the application revisions.'
);
}
// get the second to last revision
const previousRevision =
sortedControllerRevisions[sortedControllerRevisions.length - 2];
return previousRevision;
}