Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion frontend/desktop/public/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -453,5 +453,9 @@
"update_success": "Updated successfully",
"update_failed": "Failed to update"
}
}
},
"refresh_kubeconfig": "Refresh Kubeconfig",
"kubeconfig_rotating": "Rotating Kubeconfig...",
"kubeconfig_rotated_successfully": "Kubeconfig refreshed successfully",
"kubeconfig_rotation_failed": "Failed to refresh Kubeconfig"
}
6 changes: 5 additions & 1 deletion frontend/desktop/public/locales/zh/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -445,5 +445,9 @@
"update_success": "更新成功",
"update_failed": "更新失败"
}
}
},
"refresh_kubeconfig": "刷新 Kubeconfig",
"kubeconfig_rotating": "正在刷新 Kubeconfig...",
"kubeconfig_rotated_successfully": "Kubeconfig 刷新成功",
"kubeconfig_rotation_failed": "Kubeconfig 刷新失败"
}
7 changes: 5 additions & 2 deletions frontend/desktop/src/api/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { IEmailCheckParams } from '@/schema/email';
import { SmsType } from '@/services/backend/db/verifyCode';
import { RegionResourceType } from '@/services/backend/svc/checkResource';
import request from '@/services/request';
import useSessionStore from '@/stores/session';
import { ApiResp, Region } from '@/types';
import { AdClickData } from '@/types/adClick';
import { BIND_STATUS } from '@/types/response/bind';
Expand All @@ -15,7 +14,6 @@ import { USER_MERGE_STATUS } from '@/types/response/merge';
import { UNBIND_STATUS } from '@/types/response/unbind';
import { SemData } from '@/types/sem';
import { ValueOf } from '@/types/tools';
import { TUserExist } from '@/types/user';
import { type AxiosInstance } from 'axios';
import { ProviderType } from 'prisma/global/generated/client';
import { SubscriptionInfoResponse, WorkspacesPlansResponse } from '@/types/plan';
Expand Down Expand Up @@ -284,3 +282,8 @@ export const getWorkspacesPlans = (workspaces: string[]) =>
workspaces
}
});

export const _rotateKubeconfig = (request: AxiosInstance) => () =>
request.post<never, ApiResp<{ kubeconfig: string }>>('/api/auth/rotateKubeconfig');

export const rotateKubeconfig = _rotateKubeconfig(request);
108 changes: 95 additions & 13 deletions frontend/desktop/src/components/account/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ import {
MenuList,
Text,
useBreakpointValue,
useDisclosure
useDisclosure,
useToast
} from '@chakra-ui/react';
import { useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'next-i18next';
Expand All @@ -31,10 +32,10 @@ import {
Copy,
Dock,
FileCode,
Gift,
Globe,
LogOut,
ReceiptText,
RefreshCw,
User
} from 'lucide-react';
import AccountCenter from './AccountCenter';
Expand All @@ -46,6 +47,7 @@ import { Badge } from '@sealos/shadcn-ui/badge';
import { cn } from '@sealos/shadcn-ui';
import { getPlanBackgroundClass } from '@/utils/styling';
import { AlertSettings } from './AlertSettings';
import { rotateKubeconfig } from '@/api/auth';

const baseItemStyle = {
minW: '36px',
Expand All @@ -63,10 +65,11 @@ export default function Account() {
const router = useRouter();
const { copyData } = useCopyData();
const { t } = useTranslation();
const { delSession, session, setToken } = useSessionStore();
const { delSession, session, setToken, setSessionProp } = useSessionStore();
const user = session?.user;
const queryclient = useQueryClient();
const kubeconfig = session?.kubeconfig || '';
const toast = useToast();
const showDisclosure = useDisclosure();
const [, setNotificationAmount] = useState(0);
const { openDesktopApp, autolaunch } = useAppStore();
Expand All @@ -75,6 +78,7 @@ export default function Account() {
const onAmount = useCallback((amount: number) => setNotificationAmount(amount), []);
const [showNsId, setShowNsId] = useState(false);
const [alertSettingsOpen, setAlertSettingsOpen] = useState(false);
const [isRotatingKubeconfig, setIsRotatingKubeconfig] = useState(false);

const emailAlertEnabled = layoutConfig?.common.emailAlertEnabled && authConfig?.idp.email.enabled;
const phoneAlertEnabled = layoutConfig?.common.phoneAlertEnabled && authConfig?.idp.sms.enabled;
Expand All @@ -89,6 +93,51 @@ export default function Account() {
setToken('');
};

const handleRotateKubeconfig = async (e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation();
if (isRotatingKubeconfig) return;

try {
setIsRotatingKubeconfig(true);
toast({
title: t('kubeconfig_rotating'),
status: 'info',
duration: 3000,
isClosable: true,
position: 'top'
});

const res = await rotateKubeconfig();

if (res.code === 200 && res.data?.kubeconfig) {
// Update session with new kubeconfig
setSessionProp('kubeconfig', res.data.kubeconfig);

toast({
title: t('kubeconfig_rotated_successfully'),
status: 'success',
duration: 3000,
isClosable: true,
position: 'top'
});
} else {
throw new Error(res.message || 'Failed to rotate kubeconfig');
}
} catch (error: any) {
console.error('Failed to rotate kubeconfig:', error);
toast({
title: t('kubeconfig_rotation_failed'),
description: error?.message || 'An error occurred',
status: 'error',
duration: 5000,
isClosable: true,
position: 'top'
});
} finally {
setIsRotatingKubeconfig(false);
}
};

const openCostcenterApp = ({ page = 'plan', mode = '' }: { page?: string; mode?: string }) => {
openDesktopApp({
appKey: 'system-costcenter',
Expand Down Expand Up @@ -381,16 +430,49 @@ export default function Account() {
Kubeconfig
</Text>
</Flex>
<Box
p="2px"
cursor="pointer"
onClick={(e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation();
kubeconfig && copyData(kubeconfig);
}}
>
<Copy size={16} color="#737373" />
</Box>
<Flex alignItems="center" gap="4px">
<Box
p="2px"
cursor={isRotatingKubeconfig ? 'not-allowed' : 'pointer'}
onClick={handleRotateKubeconfig}
opacity={isRotatingKubeconfig ? 0.5 : 1}
transition="opacity 0.2s"
title={t('refresh_kubeconfig')}
_hover={{
color: 'blue.600'
}}
color="#737373"
sx={
isRotatingKubeconfig
? {
'& svg': {
animation: 'spin 1s linear infinite'
},
'@keyframes spin': {
from: { transform: 'rotate(0deg)' },
to: { transform: 'rotate(360deg)' }
}
}
: {}
}
>
<RefreshCw size={16} />
</Box>
<Box
p="2px"
cursor="pointer"
onClick={(e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation();
kubeconfig && copyData(kubeconfig);
}}
_hover={{
color: 'blue.600'
}}
color="#737373"
>
<Copy size={16} />
</Box>
</Flex>
</Flex>
</MenuItem>

Expand Down
153 changes: 153 additions & 0 deletions frontend/desktop/src/pages/api/auth/rotateKubeconfig.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import { NextApiRequest, NextApiResponse } from 'next';
import { jsonRes } from '@/services/backend/response';
import { verifyAccessToken } from '@/services/backend/auth';
import { K8sApiDefault } from '@/services/backend/kubernetes/admin';
import * as k8s from '@kubernetes/client-node';
import { k8sRFC3339Time } from '@/utils/format';
import { switchKubeconfigNamespace } from '@/utils/switchKubeconfigNamespace';

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
try {
const regionUser = await verifyAccessToken(req.headers);
if (!regionUser) {
return jsonRes(res, {
code: 401,
message: 'invalid token'
});
}

const group = 'user.sealos.io';
const version = 'v1';
const plural = 'users';
const k8s_username = regionUser.userCrName;

// NOTE:
// Rotating kubeconfig requires patching cluster-scoped User CR (`users.user.sealos.io`).
// The current workspace kubeconfig is usually a namespaced ServiceAccount and may not have RBAC to patch cluster resources.
// So we use the server's admin kubeconfig to perform the patch, and only switch the returned kubeconfig to workspace.
const adminKc = K8sApiDefault();
const client = adminKc.makeApiClient(k8s.CustomObjectsApi);
const rotateTime = k8sRFC3339Time(new Date());

// Capture kubeconfig BEFORE patch to avoid race:
// if rotation completes before our first poll, we still detect the change.
let previousKubeconfig: string | undefined;
try {
const pre = await client.getClusterCustomObjectStatus(group, version, plural, k8s_username);
const preBody = pre.body as any;
if (preBody?.status?.kubeConfig) {
previousKubeconfig = preBody.status.kubeConfig as string;
}
} catch (err) {
console.warn('Failed to read previous kubeconfig before rotation:', err);
}

const patches = [
{
op: 'add',
path: '/spec/kubeConfigRotateAt',
value: rotateTime
}
];

await client.patchClusterCustomObject(
group,
version,
plural,
k8s_username,
patches,
undefined,
undefined,
undefined,
{
headers: {
'Content-Type': 'application/json-patch+json'
}
}
);

const newKubeconfig = await watchKubeconfigUpdate(
adminKc,
group,
version,
plural,
k8s_username,
previousKubeconfig
);

if (!newKubeconfig) {
throw new Error('Failed to get updated kubeconfig');
}

const kubeconfig = switchKubeconfigNamespace(newKubeconfig, regionUser.workspaceId);

return jsonRes(res, {
code: 200,
message: 'Kubeconfig rotated successfully',
data: {
kubeconfig
}
});
} catch (err: any) {
console.error('Failed to rotate kubeconfig:', err);
return jsonRes(res, {
message: err?.body?.message || err?.message || 'Failed to rotate kubeconfig',
code: 500
});
}
}

async function watchKubeconfigUpdate(
kc: k8s.KubeConfig,
group: string,
version: string,
plural: string,
name: string,
previousKubeconfig?: string,
interval = 5000,
timeout = 60000
): Promise<string | null> {
let lastSeenPhase: string | undefined;
let lastSeenMessage: string | undefined;
const startTime = Date.now();
const client = kc.makeApiClient(k8s.CustomObjectsApi);

while (true) {
await new Promise((resolve) => setTimeout(resolve, interval));

try {
const data = await client.getClusterCustomObjectStatus(group, version, plural, name);
const body = data.body as any;

const phase = body?.status?.phase as string | undefined;
const conds = body?.status?.conditions as any[] | undefined;
const message =
(Array.isArray(conds) && conds.length > 0 ? conds[conds.length - 1]?.message : undefined) ||
undefined;

if (phase && phase !== lastSeenPhase) lastSeenPhase = phase;
if (message && message !== lastSeenMessage) lastSeenMessage = message;

const currentKubeconfig = body?.status?.kubeConfig as string | undefined;
if (currentKubeconfig) {
if (previousKubeconfig === undefined) {
return currentKubeconfig;
}
if (currentKubeconfig !== previousKubeconfig) {
return currentKubeconfig;
}
}
} catch (err) {}

if (Date.now() - startTime >= timeout) {
console.error(
`Timed out after ${timeout}ms waiting for kubeconfig update` +
(lastSeenPhase ? `; last phase=${lastSeenPhase}` : '') +
(lastSeenMessage ? `; last message=${lastSeenMessage}` : '')
);
break;
}
}

return null;
}
4 changes: 2 additions & 2 deletions frontend/desktop/src/services/backend/kubernetes/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ async function watchClusterObject({
return body.status.kubeConfig as string;
}
} catch (err) {
console.error(`Failed to get status for ${name}: ${err}`);
console.error(err);
}
if (Date.now() - startTime >= timeout) {
console.error(`Timed out after ${timeout} ms.`);
Expand Down Expand Up @@ -88,7 +88,7 @@ async function watchCustomClusterObject({
return body;
}
} catch (err) {
console.error(`Failed to get status for ${name}: ${err}`);
console.error(err);
}
if (Date.now() - startTime >= timeout) {
console.error(`Timed out after ${timeout} ms.`);
Expand Down
6 changes: 6 additions & 0 deletions frontend/desktop/src/utils/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ export const formatTime = (time: string | number | Date, format = 'YYYY-MM-DD HH
export const k8sFormatTime = (time: string | number | Date) => {
return dayjs(time).format('TYYMM-DDTHH-mm-ss');
};

// RFC3339 format without milliseconds (Go layout: 2006-01-02T15:04:05Z07:00)
// Use this for CRD/spec fields that are parsed as time.
export const k8sRFC3339Time = (time: string | number | Date) => {
return dayjs(time).format('YYYY-MM-DDTHH:mm:ssZ');
};
// 1¥=10000
export const formatMoney = (mone: number) => {
return mone / 1000000;
Expand Down
Loading
Loading