Files

289 lines
8.5 KiB
TypeScript

import { useMemo, useState } from 'react';
import { Secret } from 'kubernetes-types/core/v1';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { useAuthorizations } from '@/react/hooks/useUser';
import { useSecrets } from '@/react/kubernetes/configs/queries/useSecrets';
import { useEnvironmentRegistries } from '@/react/portainer/environments/queries/useEnvironmentRegistries';
import { Registry } from '@/react/portainer/registries/types/registry';
import { DetailsRow } from '@@/DetailsTable/DetailsRow';
import { InlineLoader } from '@@/InlineLoader';
import { Link } from '@@/Link';
import { DisabledOption } from '@@/form-components/DisabledMultiValue';
import { Tooltip } from '@@/Tip/Tooltip';
import { useUpdateServiceAccountImagePullSecretsMutation } from '../queries/useUpdateServiceAccountImagePullSecretsMutation';
import { ImagePullSecretsEditor } from './ImagePullSecretsEditor';
import { ImagePullSecretsList } from './ImagePullSecretsList';
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 ImagePullSecretName = { name: string };
type Props = {
namespace: string;
name: string;
imagePullSecrets: ImagePullSecretName[];
isSystem?: boolean;
};
export function ImagePullSecretsRow({
namespace,
name,
imagePullSecrets,
isSystem,
}: Props) {
const isDefaultServiceAccount = name === 'default';
const environmentId = useEnvironmentId();
const secretsQuery = useSecrets(environmentId, namespace);
const secrets = secretsQuery.data ?? [];
const { authorized: canEdit } = useAuthorizations('K8sServiceAccountsW');
const { authorized: canUpdateRegistryAccess } = useAuthorizations(
'PortainerRegistryUpdateAccess'
);
const updateMutation =
useUpdateServiceAccountImagePullSecretsMutation(environmentId);
const [isEditing, setIsEditing] = useState(false);
const [selectedNames, setSelectedNames] = useState<string[]>([]);
const {
currentNames,
protectedSecretNames,
secretOptions,
registryBySecretName,
isRegistryDataLoading,
} = useImagePullSecretsState({
environmentId,
namespace,
imagePullSecrets,
isDefaultServiceAccount,
secrets,
canUpdateRegistryAccess,
});
const pullSecretDetails = linkImagePullSecretsToSecrets(
secrets,
imagePullSecrets,
registryBySecretName
);
const isLoading = secretsQuery.isLoading || isRegistryDataLoading;
return (
<DetailsRow
label={
<span className="flex items-center">
Image pull secrets
<Tooltip
message={
name === 'default' ? (
<>
<code>imagePullSecrets</code> from this &apos;default&apos;
service account apply to all <strong>pods</strong> without an
explicit service account in this namespace.
</>
) : (
<>
These <code>imagePullSecrets</code> are inherited by pods
using this service account.
</>
)
}
/>
</span>
}
>
{isLoading ? (
<InlineLoader size="xs">Loading image pull secrets...</InlineLoader>
) : isEditing ? (
<ImagePullSecretsEditor
selectedNames={selectedNames}
secretOptions={secretOptions}
protectedSecretNames={protectedSecretNames}
isSaving={updateMutation.isLoading}
onChange={setSelectedNames}
onSave={handleSave}
onCancel={handleCancel}
/>
) : (
<ImagePullSecretsList
imagePullSecrets={pullSecretDetails}
namespace={namespace}
isDefaultServiceAccount={isDefaultServiceAccount}
canEdit={canEdit}
isSystem={isSystem}
onEdit={handleEdit}
/>
)}
</DetailsRow>
);
function handleEdit() {
setSelectedNames(currentNames);
setIsEditing(true);
}
function handleCancel() {
setIsEditing(false);
}
function handleSave() {
updateMutation.mutate(
{ environmentId, namespace, name, secretNames: selectedNames },
{ onSuccess: () => setIsEditing(false) }
);
}
}
function linkImagePullSecretsToSecrets(
secrets: Secret[],
secretNames: { name: string }[],
registryBySecretName: Record<string, Registry>
) {
return secretNames.map(({ name }) => {
const secret = secrets.find((s) => s.metadata?.name === name);
return { name, secret, registry: registryBySecretName[name] };
});
}
type UseImagePullSecretsStateArgs = {
environmentId: number;
namespace: string;
imagePullSecrets: ImagePullSecretName[];
isDefaultServiceAccount: boolean;
secrets: Secret[];
canUpdateRegistryAccess: boolean;
};
/**
* Builds the derived state needed by the list and inline editor.
* For the default service account, secrets managed by registry access stay
* visible but non-removable here because they are configured elsewhere.
*/
function useImagePullSecretsState({
environmentId,
namespace,
imagePullSecrets,
isDefaultServiceAccount,
secrets,
canUpdateRegistryAccess,
}: UseImagePullSecretsStateArgs) {
const registryData = useImagePullSecretRegistryData(environmentId, namespace);
return useMemo(() => {
const currentNames = imagePullSecrets.map(({ name }) => name);
const linkedDefaultSecretNamesSet = new Set(
isDefaultServiceAccount ? registryData.linkedDefaultSecretNames : []
);
const protectedSecretNames = currentNames.filter((secretName) =>
linkedDefaultSecretNamesSet.has(secretName)
);
const registryDisabledMessage = canUpdateRegistryAccess
? registryAccessAdminMessage
: registryAccessUserMessage;
const secretOptions: DisabledOption<string>[] = secrets
.map((secret) => secret.metadata?.name ?? '')
.filter(Boolean)
.map((name) => {
const isProtected =
isDefaultServiceAccount && linkedDefaultSecretNamesSet.has(name);
return {
value: name,
label: name,
disabled: isProtected,
disabledMessage: isProtected ? registryDisabledMessage : undefined,
};
});
return {
currentNames,
protectedSecretNames,
secretOptions,
registryBySecretName: registryData.registryBySecretName,
isRegistryDataLoading: registryData.isLoading,
};
}, [
canUpdateRegistryAccess,
imagePullSecrets,
isDefaultServiceAccount,
registryData.isLoading,
registryData.linkedDefaultSecretNames,
registryData.registryBySecretName,
secrets,
]);
}
type ImagePullSecretRegistryState = {
linkedDefaultSecretNames: string[];
registryBySecretName: Record<string, Registry>;
};
const emptyRegistryState: ImagePullSecretRegistryState = {
linkedDefaultSecretNames: [],
registryBySecretName: {},
};
function useImagePullSecretRegistryData(
environmentId: number,
namespace: string
) {
const registryStateQuery = useEnvironmentRegistries(environmentId, {
select: (registries) =>
selectImagePullSecretRegistryState(registries, environmentId, namespace),
});
return {
...emptyRegistryState,
...registryStateQuery.data,
isLoading: registryStateQuery.isLoading,
};
}
function selectImagePullSecretRegistryState(
registries: Registry[],
environmentId: number,
namespace: string
): ImagePullSecretRegistryState {
return registries.reduce<ImagePullSecretRegistryState>((state, registry) => {
const secretName = getRegistrySecretName(registry.Id);
const isLinkedToDefaultSA =
registry.RegistryAccesses?.[environmentId]?.Namespaces?.includes(
namespace
);
return {
linkedDefaultSecretNames: isLinkedToDefaultSA
? [...state.linkedDefaultSecretNames, secretName]
: state.linkedDefaultSecretNames,
registryBySecretName: {
...state.registryBySecretName,
[secretName]: registry,
},
};
}, emptyRegistryState);
}
function getRegistrySecretName(registryId: Registry['Id']) {
// Registry access creates image pull secrets using this generated name.
return `registry-${registryId}`;
}