282 lines
8.9 KiB
TypeScript
282 lines
8.9 KiB
TypeScript
import { PencilIcon } from 'lucide-react';
|
|
import { useState } from 'react';
|
|
import { compact } from 'lodash';
|
|
|
|
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
|
import { useAuthorizations } from '@/react/hooks/useUser';
|
|
import { useGetAllServiceAccountsQuery } from '@/react/kubernetes/more-resources/ServiceAccountsView/ServiceAccountsDatatable/queries/useGetAllServiceAccountsQuery';
|
|
import {
|
|
SAImagePullSecretsUpdate,
|
|
useUpdateLinkedServiceAccountsMutation,
|
|
} from '@/react/kubernetes/configs/secrets/queries/useUpdateLinkedServiceAccountsMutation';
|
|
|
|
import { Badge } from '@@/Badge';
|
|
import { Button } from '@@/buttons/Button';
|
|
import { LoadingButton } from '@@/buttons/LoadingButton';
|
|
import { DetailsRow } from '@@/DetailsTable/DetailsRow';
|
|
import { Link } from '@@/Link';
|
|
import { MultiSelect } from '@@/form-components/PortainerSelect';
|
|
import {
|
|
DisabledMultiValue,
|
|
DisabledMultiValueRemove,
|
|
DisabledOption,
|
|
preserveProtectedValues,
|
|
} from '@@/form-components/DisabledMultiValue';
|
|
import { Tooltip } from '@@/Tip/Tooltip';
|
|
import { InlineLoader } from '@@/InlineLoader';
|
|
|
|
import { useSecretsLinkedToDefaultSA } from '../queries/useSecretsLinkedToDefaultSA';
|
|
|
|
const MAX_VISIBLE_SERVICE_ACCOUNTS = 5;
|
|
|
|
const registryAccessAdminMessage = (
|
|
<span>
|
|
This secret is linked to the default service account from the{' '}
|
|
<Link to="kubernetes.registries" data-cy="registry-access-link">
|
|
registry access
|
|
</Link>{' '}
|
|
settings to allow pods to pull images automatically in this namespace.
|
|
Update the settings to remove the link.
|
|
</span>
|
|
);
|
|
|
|
const registryAccessUserMessage = (
|
|
<span>
|
|
This secret is linked to the default service account from the registry
|
|
access settings to allow pods to pull images automatically in this
|
|
namespace. Contact your administrator to update the settings.
|
|
</span>
|
|
);
|
|
|
|
type Props = {
|
|
secretName: string;
|
|
namespace: string;
|
|
isSystem: boolean;
|
|
};
|
|
|
|
type ServiceAccount = {
|
|
name: string;
|
|
imagePullSecrets?: { name: string }[];
|
|
};
|
|
|
|
export function LinkedServiceAccountsRow({
|
|
secretName,
|
|
namespace,
|
|
isSystem,
|
|
}: Props) {
|
|
const environmentId = useEnvironmentId();
|
|
const serviceAccountsQuery = useGetAllServiceAccountsQuery(environmentId);
|
|
const allServiceAccounts = serviceAccountsQuery.data ?? [];
|
|
|
|
const secretsBoundToDefaultSA = useSecretsLinkedToDefaultSA(namespace);
|
|
const isSecretLinkedToDefaultSA =
|
|
secretsBoundToDefaultSA.data?.includes(secretName);
|
|
const isLoading =
|
|
secretsBoundToDefaultSA.isLoading || serviceAccountsQuery.isLoading;
|
|
|
|
const namespaceSAs: ServiceAccount[] = allServiceAccounts.filter(
|
|
(sa) => sa.namespace === namespace
|
|
);
|
|
|
|
return (
|
|
<DetailsRow
|
|
label={
|
|
<span className="flex items-center">
|
|
Linked service accounts
|
|
<Tooltip message="Service accounts that use this secret as an image pull secret." />
|
|
</span>
|
|
}
|
|
>
|
|
{isLoading ? (
|
|
<InlineLoader>Loading service accounts...</InlineLoader>
|
|
) : (
|
|
<LinkedServiceAccountsContent
|
|
secretName={secretName}
|
|
namespace={namespace}
|
|
isSystem={isSystem}
|
|
namespaceSAs={namespaceSAs}
|
|
isSecretLinkedToDefaultSA={isSecretLinkedToDefaultSA}
|
|
/>
|
|
)}
|
|
</DetailsRow>
|
|
);
|
|
}
|
|
|
|
type LinkedServiceAccountsContentProps = {
|
|
secretName: string;
|
|
namespace: string;
|
|
isSystem: boolean;
|
|
namespaceSAs: ServiceAccount[];
|
|
isSecretLinkedToDefaultSA?: boolean;
|
|
};
|
|
|
|
function LinkedServiceAccountsContent({
|
|
secretName,
|
|
namespace,
|
|
isSystem,
|
|
namespaceSAs,
|
|
isSecretLinkedToDefaultSA,
|
|
}: LinkedServiceAccountsContentProps) {
|
|
const environmentId = useEnvironmentId();
|
|
const { authorized: canEdit } = useAuthorizations('K8sServiceAccountsW');
|
|
const { authorized: canUpdateRegistryAccess } = useAuthorizations(
|
|
'PortainerRegistryUpdateAccess'
|
|
);
|
|
const linkedSAsMutation =
|
|
useUpdateLinkedServiceAccountsMutation(environmentId);
|
|
const [isEditing, setIsEditing] = useState(false);
|
|
const [selectedSANames, setSelectedSANames] = useState<string[]>([]);
|
|
|
|
const linkedNames = namespaceSAs
|
|
.filter((sa) => sa.imagePullSecrets?.some((s) => s.name === secretName))
|
|
.map((sa) => sa.name);
|
|
const protectedSANames =
|
|
isSecretLinkedToDefaultSA && linkedNames.includes('default')
|
|
? ['default']
|
|
: [];
|
|
const registryDisabledMessage = canUpdateRegistryAccess
|
|
? registryAccessAdminMessage
|
|
: registryAccessUserMessage;
|
|
const saOptions: DisabledOption<string>[] = namespaceSAs.map((sa) => {
|
|
const isProtected = isSecretLinkedToDefaultSA && sa.name === 'default';
|
|
return {
|
|
value: sa.name,
|
|
label: sa.name,
|
|
disabled: isProtected,
|
|
disabledMessage: isProtected ? registryDisabledMessage : undefined,
|
|
};
|
|
});
|
|
const visible = linkedNames.slice(0, MAX_VISIBLE_SERVICE_ACCOUNTS);
|
|
const hidden = linkedNames.slice(MAX_VISIBLE_SERVICE_ACCOUNTS);
|
|
|
|
return isEditing ? (
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<MultiSelect<string>
|
|
value={selectedSANames}
|
|
options={saOptions}
|
|
onChange={(names) =>
|
|
setSelectedSANames(preserveProtectedValues(names, protectedSANames))
|
|
}
|
|
placeholder="Select service accounts..."
|
|
disabled={linkedSAsMutation.isLoading}
|
|
data-cy="k8sSecretDetail-linkedSAs-select"
|
|
components={{
|
|
MultiValue: DisabledMultiValue,
|
|
MultiValueRemove: DisabledMultiValueRemove,
|
|
}}
|
|
/>
|
|
<div className="inline-flex gap-2">
|
|
<LoadingButton
|
|
size="small"
|
|
isLoading={linkedSAsMutation.isLoading}
|
|
loadingText="Saving..."
|
|
onClick={handleSave}
|
|
data-cy="k8sSecretDetail-linkedSAs-save"
|
|
// match the height of the portainer-select component
|
|
className="h-[34px]"
|
|
>
|
|
Save
|
|
</LoadingButton>
|
|
<Button
|
|
size="small"
|
|
color="default"
|
|
disabled={linkedSAsMutation.isLoading}
|
|
onClick={() => setIsEditing(false)}
|
|
data-cy="k8sSecretDetail-linkedSAs-cancel"
|
|
// match the height of the portainer-select component
|
|
className="h-[34px]"
|
|
>
|
|
Cancel
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="flex items-center gap-2">
|
|
{visible.length > 0 ? (
|
|
<div className="flex h-fit flex-wrap gap-2">
|
|
{visible.map((saName) => (
|
|
<Badge key={saName} type="info" className="min-w-max">
|
|
<Link
|
|
to="kubernetes.moreResources.serviceAccounts.serviceAccount"
|
|
params={{ namespace, name: saName }}
|
|
data-cy={`linked-service-account-link-${saName}`}
|
|
className="!text-inherit"
|
|
>
|
|
{saName}
|
|
</Link>
|
|
</Badge>
|
|
))}
|
|
{hidden.length > 0 && (
|
|
<Badge type="muted" className="min-w-max cursor-default">
|
|
+ {hidden.length} more
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<span className="text-muted">
|
|
No service accounts linked. Without a link, pods must specify this
|
|
secret manually. Link to a{' '}
|
|
<Link
|
|
to="kubernetes.moreResources.serviceAccounts"
|
|
data-cy="service-account-link"
|
|
>
|
|
service account
|
|
</Link>{' '}
|
|
so pods using it can pull images automatically - or link to the{' '}
|
|
<code>default</code> service account to cover all pods in this
|
|
namespace that don't specify one.
|
|
</span>
|
|
)}
|
|
{canEdit && !isSystem && (
|
|
<div>
|
|
<Button
|
|
color="light"
|
|
size="small"
|
|
icon={PencilIcon}
|
|
onClick={() => {
|
|
setSelectedSANames(linkedNames);
|
|
setIsEditing(true);
|
|
}}
|
|
data-cy="k8sSecretDetail-linkedSAs-edit"
|
|
// match the height of the portainer-select component
|
|
className="h-[34px]"
|
|
>
|
|
Edit
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
|
|
function handleSave() {
|
|
const previousSet = new Set(linkedNames);
|
|
const nextSet = new Set(selectedSANames);
|
|
|
|
const toAdd = selectedSANames.filter((n) => !previousSet.has(n));
|
|
const toRemove = linkedNames.filter((n) => !nextSet.has(n));
|
|
|
|
const updates: Array<SAImagePullSecretsUpdate | undefined> = [
|
|
...toAdd,
|
|
...toRemove,
|
|
].map((saName) => {
|
|
const sa = namespaceSAs.find((s) => s.name === saName);
|
|
if (!sa) {
|
|
return undefined;
|
|
}
|
|
const currentSecrets = sa.imagePullSecrets?.map((s) => s.name) ?? [];
|
|
const newSecrets = toAdd.includes(saName)
|
|
? [...new Set([...currentSecrets, secretName])]
|
|
: currentSecrets.filter((s) => s !== secretName);
|
|
return { saName, namespace, newSecrets };
|
|
});
|
|
|
|
linkedSAsMutation.mutate(compact(updates), {
|
|
onSuccess: ({ rejectedItems }) => {
|
|
if (rejectedItems.length === 0) {
|
|
setIsEditing(false);
|
|
}
|
|
},
|
|
});
|
|
}
|
|
}
|