Compare commits
18 Commits
db28bc0425
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b655d3496 | ||
|
|
a80744c533 | ||
|
|
704d0fe0bf | ||
|
|
7a666303be | ||
|
|
0536ce9755 | ||
|
|
8ffb0eec09 | ||
|
|
e82dfec1ba | ||
|
|
b7a5f85da2 | ||
|
|
488c7b90bf | ||
|
|
a632f68c29 | ||
|
|
c2bde4a0ff | ||
|
|
23add6447d | ||
|
|
fa0298b4d8 | ||
|
|
40c10235f6 | ||
|
|
08be388322 | ||
|
|
529a90b80d | ||
|
|
7d2e2ab842 | ||
|
|
54ed79cea3 |
63
Dockerfile
Normal file
63
Dockerfile
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
# Multi-stage build for Cluster application with Go backend and React frontend
|
||||||
|
|
||||||
|
# Frontend build stage
|
||||||
|
FROM node:18 AS frontend-build
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy package files
|
||||||
|
COPY frontend/package.json frontend/pnpm-lock.yaml ./
|
||||||
|
|
||||||
|
# Install pnpm globally
|
||||||
|
RUN npm install -g pnpm
|
||||||
|
|
||||||
|
# Install frontend dependencies
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
# Copy frontend source
|
||||||
|
COPY frontend/ .
|
||||||
|
|
||||||
|
# Build frontend
|
||||||
|
RUN pnpm run build
|
||||||
|
|
||||||
|
# Backend build stage
|
||||||
|
FROM golang:1.22 AS backend-build
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy go mod files
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
|
||||||
|
# Download dependencies
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
# Copy only backend source code
|
||||||
|
COPY main.go ./
|
||||||
|
COPY internal/ ./internal/
|
||||||
|
COPY pkg/ ./pkg/
|
||||||
|
|
||||||
|
# Build backend
|
||||||
|
RUN go build -o cluster .
|
||||||
|
|
||||||
|
# Final stage - Nginx server
|
||||||
|
FROM nginx:latest
|
||||||
|
|
||||||
|
# Copy nginx configuration
|
||||||
|
COPY nginx.conf /etc/nginx/nginx.conf
|
||||||
|
|
||||||
|
# Copy backend binary
|
||||||
|
COPY --from=backend-build /app/cluster /app/cluster
|
||||||
|
|
||||||
|
# Copy frontend build
|
||||||
|
COPY --from=frontend-build /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
# Create data directory
|
||||||
|
RUN mkdir -p /app/x-storage
|
||||||
|
|
||||||
|
# Expose ports
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
# Start backend and nginx
|
||||||
|
COPY docker-entrypoint.sh /docker-entrypoint.sh
|
||||||
|
RUN chmod +x /docker-entrypoint.sh
|
||||||
|
ENTRYPOINT ["/docker-entrypoint.sh"]
|
||||||
10
docker-entrypoint.sh
Executable file
10
docker-entrypoint.sh
Executable file
@@ -0,0 +1,10 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
# Start the Go backend in the background
|
||||||
|
/app/cluster -address 127.0.0.1:9119 -data-dir /data &
|
||||||
|
|
||||||
|
# Wait a moment for backend to start
|
||||||
|
sleep 2
|
||||||
|
|
||||||
|
# Start nginx in the foreground
|
||||||
|
nginx -g 'daemon off;'
|
||||||
@@ -1,11 +1,40 @@
|
|||||||
import { Container, Typography, Box, AppBar, Toolbar, Button, Stack } from '@mui/material'
|
import { Container, Typography, Box, AppBar, Toolbar, Button, Stack, Menu, MenuItem, Avatar } from '@mui/material'
|
||||||
import { Routes, Route, Link } from 'react-router-dom'
|
import { Routes, Route, Link, Navigate } from 'react-router-dom'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { AccountCircle, Logout } from '@mui/icons-material'
|
||||||
import { useAppStore } from './stores/appStore'
|
import { useAppStore } from './stores/appStore'
|
||||||
|
import { useAuthStore } from './stores/authStore'
|
||||||
import RegistryImageList from './pages/RegistryImageList'
|
import RegistryImageList from './pages/RegistryImageList'
|
||||||
import K8sResourceList from './pages/K8sResourceList'
|
import K8sResourceList from './pages/K8sResourceList'
|
||||||
|
import UserManagement from './pages/UserManagement'
|
||||||
|
import Login from './pages/Login'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const { count, increment, decrement, reset } = useAppStore()
|
const { count, increment, decrement, reset } = useAppStore()
|
||||||
|
const { isAuthenticated, logout, username } = useAuthStore()
|
||||||
|
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null)
|
||||||
|
|
||||||
|
const handleMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
|
||||||
|
setAnchorEl(event.currentTarget)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMenuClose = () => {
|
||||||
|
setAnchorEl(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
handleMenuClose()
|
||||||
|
logout()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<Login />} />
|
||||||
|
<Route path="*" element={<Navigate to="/login" replace />} />
|
||||||
|
</Routes>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ flexGrow: 1, minHeight: '100vh' }}>
|
<Box sx={{ flexGrow: 1, minHeight: '100vh' }}>
|
||||||
@@ -17,12 +46,54 @@ function App() {
|
|||||||
<Button color="inherit" component={Link} to="/">首页</Button>
|
<Button color="inherit" component={Link} to="/">首页</Button>
|
||||||
<Button color="inherit" component={Link} to="/registry/image">镜像列表</Button>
|
<Button color="inherit" component={Link} to="/registry/image">镜像列表</Button>
|
||||||
<Button color="inherit" component={Link} to="/k8s/resources">集群资源</Button>
|
<Button color="inherit" component={Link} to="/k8s/resources">集群资源</Button>
|
||||||
|
<Button color="inherit" component={Link} to="/users">用户管理</Button>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
ml: 2,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 1,
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: '4px 12px',
|
||||||
|
borderRadius: 1,
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
onClick={handleMenuOpen}
|
||||||
|
>
|
||||||
|
<Avatar sx={{ width: 32, height: 32, bgcolor: 'secondary.main' }}>
|
||||||
|
{username?.charAt(0).toUpperCase()}
|
||||||
|
</Avatar>
|
||||||
|
<Typography variant="body2">
|
||||||
|
{username}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Menu
|
||||||
|
anchorEl={anchorEl}
|
||||||
|
open={Boolean(anchorEl)}
|
||||||
|
onClose={handleMenuClose}
|
||||||
|
anchorOrigin={{
|
||||||
|
vertical: 'bottom',
|
||||||
|
horizontal: 'right',
|
||||||
|
}}
|
||||||
|
transformOrigin={{
|
||||||
|
vertical: 'top',
|
||||||
|
horizontal: 'right',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MenuItem onClick={handleLogout}>
|
||||||
|
<Logout fontSize="small" sx={{ mr: 1 }} />
|
||||||
|
退出登录
|
||||||
|
</MenuItem>
|
||||||
|
</Menu>
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
</AppBar>
|
</AppBar>
|
||||||
<Container maxWidth="xl" sx={{ mt: 4, mb: 4 }}>
|
<Container maxWidth="xl" sx={{ mt: 4, mb: 4 }}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/registry/image" element={<RegistryImageList />} />
|
<Route path="/registry/image" element={<RegistryImageList />} />
|
||||||
<Route path="/k8s/resources" element={<K8sResourceList />} />
|
<Route path="/k8s/resources" element={<K8sResourceList />} />
|
||||||
|
<Route path="/users" element={<UserManagement />} />
|
||||||
<Route path="/" element={
|
<Route path="/" element={
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="h4" component="h1" gutterBottom>
|
<Typography variant="h4" component="h1" gutterBottom>
|
||||||
|
|||||||
@@ -26,11 +26,19 @@ import {
|
|||||||
DialogContent,
|
DialogContent,
|
||||||
DialogActions,
|
DialogActions,
|
||||||
Snackbar,
|
Snackbar,
|
||||||
|
MenuItem,
|
||||||
|
Select,
|
||||||
|
AppBar,
|
||||||
|
Toolbar,
|
||||||
|
Tooltip,
|
||||||
} from '@mui/material'
|
} from '@mui/material'
|
||||||
import SettingsIcon from '@mui/icons-material/Settings'
|
import SettingsIcon from '@mui/icons-material/Settings'
|
||||||
import CloseIcon from '@mui/icons-material/Close'
|
import CloseIcon from '@mui/icons-material/Close'
|
||||||
import AddIcon from '@mui/icons-material/Add'
|
import AddIcon from '@mui/icons-material/Add'
|
||||||
import UploadFileIcon from '@mui/icons-material/UploadFile'
|
import UploadFileIcon from '@mui/icons-material/UploadFile'
|
||||||
|
import VisibilityIcon from '@mui/icons-material/Visibility'
|
||||||
|
import DeleteIcon from '@mui/icons-material/Delete'
|
||||||
|
import EditIcon from '@mui/icons-material/Edit'
|
||||||
|
|
||||||
const KINDS = [
|
const KINDS = [
|
||||||
{ key: 'namespace', label: 'Namespace', endpoint: '/api/v1/k8s/namespace/list' },
|
{ key: 'namespace', label: 'Namespace', endpoint: '/api/v1/k8s/namespace/list' },
|
||||||
@@ -54,6 +62,8 @@ export default function K8sResourceList() {
|
|||||||
const [kubeconfig, setKubeconfig] = useState('')
|
const [kubeconfig, setKubeconfig] = useState('')
|
||||||
const [kubeconfigError, setKubeconfigError] = useState(false)
|
const [kubeconfigError, setKubeconfigError] = useState(false)
|
||||||
const [namespace, setNamespace] = useState('')
|
const [namespace, setNamespace] = useState('')
|
||||||
|
const [namespaces, setNamespaces] = useState<string[]>([])
|
||||||
|
const [nameFilter, setNameFilter] = useState('')
|
||||||
const [createDialogOpen, setCreateDialogOpen] = useState(false)
|
const [createDialogOpen, setCreateDialogOpen] = useState(false)
|
||||||
const [yamlContent, setYamlContent] = useState('')
|
const [yamlContent, setYamlContent] = useState('')
|
||||||
const [applyLoading, setApplyLoading] = useState(false)
|
const [applyLoading, setApplyLoading] = useState(false)
|
||||||
@@ -63,6 +73,18 @@ export default function K8sResourceList() {
|
|||||||
severity: 'success',
|
severity: 'success',
|
||||||
})
|
})
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const [logsDialogOpen, setLogsDialogOpen] = useState(false)
|
||||||
|
const [logs, setLogs] = useState<string[]>([])
|
||||||
|
const [selectedPod, setSelectedPod] = useState<{ name: string; namespace: string } | null>(null)
|
||||||
|
const eventSourceRef = useRef<EventSource | null>(null)
|
||||||
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<{ name: string; namespace: string } | null>(null)
|
||||||
|
const [deleting, setDeleting] = useState(false)
|
||||||
|
const [editDialogOpen, setEditDialogOpen] = useState(false)
|
||||||
|
const [editResource, setEditResource] = useState<{ name: string; namespace: string; kind: string; yaml: string } | null>(null)
|
||||||
|
const [editYaml, setEditYaml] = useState('')
|
||||||
|
const [editing, setEditing] = useState(false)
|
||||||
|
const logsEndRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchKubeconfig()
|
fetchKubeconfig()
|
||||||
@@ -71,8 +93,22 @@ export default function K8sResourceList() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (kubeconfig) {
|
if (kubeconfig) {
|
||||||
fetchResources()
|
fetchResources()
|
||||||
|
if (selectedKind.key !== 'namespace' && selectedKind.key !== 'pv') {
|
||||||
|
fetchNamespaces()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [selectedKind, namespace])
|
}, [selectedKind, namespace, nameFilter])
|
||||||
|
|
||||||
|
// Clean up SSE connection on component unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (eventSourceRef.current) {
|
||||||
|
console.log('Cleaning up SSE connection on component unmount')
|
||||||
|
eventSourceRef.current.close()
|
||||||
|
eventSourceRef.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
const fetchKubeconfig = async () => {
|
const fetchKubeconfig = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -146,6 +182,219 @@ export default function K8sResourceList() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleViewLogs = (podName: string, podNamespace: string) => {
|
||||||
|
console.log('handleViewLogs called with:', { podName, podNamespace })
|
||||||
|
setSelectedPod({ name: podName, namespace: podNamespace })
|
||||||
|
setLogs([])
|
||||||
|
setLogsDialogOpen(true)
|
||||||
|
|
||||||
|
// Close any existing connection
|
||||||
|
if (eventSourceRef.current) {
|
||||||
|
console.log('Closing existing EventSource connection')
|
||||||
|
eventSourceRef.current.close()
|
||||||
|
eventSourceRef.current = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventSource = new EventSource(
|
||||||
|
`/api/v1/k8s/pod/logs?name=${encodeURIComponent(podName)}&namespace=${encodeURIComponent(podNamespace)}&tail=1000&follow=true`
|
||||||
|
)
|
||||||
|
|
||||||
|
// Save reference to the EventSource
|
||||||
|
eventSourceRef.current = eventSource
|
||||||
|
|
||||||
|
// Listen for the specific event type 'pod-logs'
|
||||||
|
eventSource.addEventListener('pod-logs', (event: MessageEvent) => {
|
||||||
|
try {
|
||||||
|
const message = JSON.parse(event.data)
|
||||||
|
if (message.type === 'log') {
|
||||||
|
setLogs((prev) => [...prev, message.data])
|
||||||
|
setTimeout(() => logsEndRef.current?.scrollIntoView({ behavior: 'smooth' }), 100)
|
||||||
|
} else if (message.type === 'EOF') {
|
||||||
|
// Handle end of stream if needed
|
||||||
|
} else if (message.type === 'error') {
|
||||||
|
setLogs((prev) => [...prev, `Error: ${message.data}`])
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// If parsing fails, treat as plain text (fallback)
|
||||||
|
setLogs((prev) => [...prev, event.data])
|
||||||
|
setTimeout(() => logsEndRef.current?.scrollIntoView({ behavior: 'smooth' }), 100)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
eventSource.onerror = () => {
|
||||||
|
console.log('EventSource error occurred')
|
||||||
|
if (eventSourceRef.current) {
|
||||||
|
eventSourceRef.current.close()
|
||||||
|
eventSourceRef.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCloseLogsDialog = () => {
|
||||||
|
console.log('handleCloseLogsDialog called')
|
||||||
|
// Close the EventSource connection if it exists
|
||||||
|
if (eventSourceRef.current) {
|
||||||
|
console.log('Closing EventSource connection')
|
||||||
|
eventSourceRef.current.close()
|
||||||
|
eventSourceRef.current = null
|
||||||
|
}
|
||||||
|
setLogsDialogOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteResource = async () => {
|
||||||
|
if (!deleteTarget) return
|
||||||
|
|
||||||
|
setDeleting(true)
|
||||||
|
try {
|
||||||
|
let endpoint = '';
|
||||||
|
let kind = '';
|
||||||
|
let requestBody = {
|
||||||
|
name: deleteTarget.name,
|
||||||
|
namespace: deleteTarget.namespace
|
||||||
|
};
|
||||||
|
|
||||||
|
// Determine the correct endpoint based on the selected resource kind
|
||||||
|
switch (selectedKind.key) {
|
||||||
|
case 'pod':
|
||||||
|
endpoint = '/api/v1/k8s/pod/delete'
|
||||||
|
kind = 'Pod'
|
||||||
|
break
|
||||||
|
case 'deployment':
|
||||||
|
endpoint = '/api/v1/k8s/deployment/delete'
|
||||||
|
kind = 'Deployment'
|
||||||
|
break
|
||||||
|
case 'statefulset':
|
||||||
|
endpoint = '/api/v1/k8s/statefulset/delete'
|
||||||
|
kind = 'StatefulSet'
|
||||||
|
break
|
||||||
|
case 'service':
|
||||||
|
endpoint = '/api/v1/k8s/service/delete'
|
||||||
|
kind = 'Service'
|
||||||
|
break
|
||||||
|
case 'configmap':
|
||||||
|
endpoint = '/api/v1/k8s/configmap/delete'
|
||||||
|
kind = 'ConfigMap'
|
||||||
|
break
|
||||||
|
case 'namespace':
|
||||||
|
endpoint = '/api/v1/k8s/namespace/delete'
|
||||||
|
kind = 'Namespace'
|
||||||
|
// Namespace doesn't need namespace field
|
||||||
|
requestBody = { name: deleteTarget.name }
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
throw new Error(`Unsupported resource kind: ${selectedKind.key}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(endpoint, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(requestBody),
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await res.json()
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(result.err || `Failed to delete ${kind}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
setSnackbar({
|
||||||
|
open: true,
|
||||||
|
message: `${kind} 删除成功`,
|
||||||
|
severity: 'success'
|
||||||
|
})
|
||||||
|
setDeleteDialogOpen(false)
|
||||||
|
setDeleteTarget(null)
|
||||||
|
fetchResources()
|
||||||
|
} catch (e: any) {
|
||||||
|
setSnackbar({
|
||||||
|
open: true,
|
||||||
|
message: `删除失败: ${e.message}`,
|
||||||
|
severity: 'error'
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setDeleting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeletePod = async () => {
|
||||||
|
if (!deleteTarget) return
|
||||||
|
|
||||||
|
setDeleting(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/k8s/pod/delete', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name: deleteTarget.name, namespace: deleteTarget.namespace }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await res.json()
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(result.err || 'Failed to delete pod')
|
||||||
|
}
|
||||||
|
|
||||||
|
setSnackbar({ open: true, message: 'Pod 删除成功', severity: 'success' })
|
||||||
|
setDeleteDialogOpen(false)
|
||||||
|
setDeleteTarget(null)
|
||||||
|
fetchResources()
|
||||||
|
} catch (e: any) {
|
||||||
|
setSnackbar({ open: true, message: `删除失败: ${e.message}`, severity: 'error' })
|
||||||
|
} finally {
|
||||||
|
setDeleting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEditResource = async (name: string, namespace: string, kind: string) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/v1/k8s/resource/get?name=${encodeURIComponent(name)}&namespace=${encodeURIComponent(namespace)}&kind=${encodeURIComponent(kind)}`)
|
||||||
|
const result = await res.json()
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(result.err || 'Failed to get resource')
|
||||||
|
}
|
||||||
|
|
||||||
|
setEditResource({ name, namespace, kind, yaml: result.data.yaml })
|
||||||
|
setEditYaml(result.data.yaml)
|
||||||
|
setEditDialogOpen(true)
|
||||||
|
} catch (e: any) {
|
||||||
|
setSnackbar({ open: true, message: `获取资源失败: ${e.message}`, severity: 'error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleApplyEdit = async () => {
|
||||||
|
if (!editResource) return
|
||||||
|
|
||||||
|
setEditing(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/k8s/resource/update', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ yaml: editYaml }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await res.json()
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(result.err || 'Failed to update resource')
|
||||||
|
}
|
||||||
|
|
||||||
|
setSnackbar({ open: true, message: '资源更新成功', severity: 'success' })
|
||||||
|
setEditDialogOpen(false)
|
||||||
|
setEditResource(null)
|
||||||
|
setEditYaml('')
|
||||||
|
fetchResources()
|
||||||
|
} catch (e: any) {
|
||||||
|
setSnackbar({ open: true, message: `更新失败: ${e.message}`, severity: 'error' })
|
||||||
|
} finally {
|
||||||
|
setEditing(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openDeleteDialog = (podName: string, podNamespace: string) => {
|
||||||
|
setDeleteTarget({ name: podName, namespace: podNamespace })
|
||||||
|
setDeleteDialogOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
const fetchResources = async () => {
|
const fetchResources = async () => {
|
||||||
if (!kubeconfig) {
|
if (!kubeconfig) {
|
||||||
setKubeconfigError(true)
|
setKubeconfigError(true)
|
||||||
@@ -159,6 +408,9 @@ export default function K8sResourceList() {
|
|||||||
if (namespace && selectedKind.key !== 'namespace' && selectedKind.key !== 'pv') {
|
if (namespace && selectedKind.key !== 'namespace' && selectedKind.key !== 'pv') {
|
||||||
url.searchParams.set('namespace', namespace)
|
url.searchParams.set('namespace', namespace)
|
||||||
}
|
}
|
||||||
|
if (nameFilter) {
|
||||||
|
url.searchParams.set('name', nameFilter)
|
||||||
|
}
|
||||||
|
|
||||||
const res = await fetch(url.toString())
|
const res = await fetch(url.toString())
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
@@ -171,25 +423,37 @@ export default function K8sResourceList() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fetchNamespaces = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/k8s/namespace/list')
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
|
const result = await res.json()
|
||||||
|
const namespaceList = result.data?.items?.map((ns: any) => ns.metadata.name) || []
|
||||||
|
setNamespaces(namespaceList)
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('Failed to fetch namespaces:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const getResourceColumns = () => {
|
const getResourceColumns = () => {
|
||||||
switch (selectedKind.key) {
|
switch (selectedKind.key) {
|
||||||
case 'namespace':
|
case 'namespace':
|
||||||
return ['Name', 'Status', 'Age']
|
return ['Name', 'Status', 'Age', 'Actions']
|
||||||
case 'deployment':
|
case 'deployment':
|
||||||
case 'statefulset':
|
case 'statefulset':
|
||||||
return ['Name', 'Namespace', 'Replicas', 'Age']
|
return ['Name', 'Namespace', 'Replicas', 'Age', 'Actions']
|
||||||
case 'service':
|
case 'service':
|
||||||
return ['Name', 'Namespace', 'Type', 'Cluster IP', 'External IP', 'Ports', 'Age']
|
return ['Name', 'Namespace', 'Type', 'Cluster IP', 'Ports', 'NodePort', 'Age', 'Actions']
|
||||||
case 'configmap':
|
case 'configmap':
|
||||||
return ['Name', 'Namespace', 'Data Keys', 'Age']
|
return ['Name', 'Namespace', 'Data Keys', 'Age', 'Actions']
|
||||||
case 'pod':
|
case 'pod':
|
||||||
return ['Name', 'Namespace', 'Status', 'IP', 'Node', 'Age']
|
return ['Name', 'Namespace', 'Status', 'IP', 'Node', 'Age', 'Actions']
|
||||||
case 'pv':
|
case 'pv':
|
||||||
return ['Name', 'Capacity', 'Access Modes', 'Status', 'Claim', 'Age']
|
return ['Name', 'Capacity', 'Access Modes', 'Status', 'Claim', 'Age', 'Actions']
|
||||||
case 'pvc':
|
case 'pvc':
|
||||||
return ['Name', 'Namespace', 'Status', 'Volume', 'Capacity', 'Access Modes', 'Age']
|
return ['Name', 'Namespace', 'Status', 'Volume', 'Capacity', 'Access Modes', 'Age', 'Actions']
|
||||||
default:
|
default:
|
||||||
return ['Name']
|
return ['Name', 'Actions']
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,6 +478,17 @@ export default function K8sResourceList() {
|
|||||||
<TableCell>{metadata.name || '-'}</TableCell>
|
<TableCell>{metadata.name || '-'}</TableCell>
|
||||||
<TableCell>{status.phase || '-'}</TableCell>
|
<TableCell>{status.phase || '-'}</TableCell>
|
||||||
<TableCell>{getAge(metadata.creationTimestamp)}</TableCell>
|
<TableCell>{getAge(metadata.creationTimestamp)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Tooltip title="删除">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
color="error"
|
||||||
|
onClick={() => openDeleteDialog(metadata.name, metadata.namespace)}
|
||||||
|
>
|
||||||
|
<DeleteIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)
|
)
|
||||||
case 'deployment':
|
case 'deployment':
|
||||||
@@ -224,18 +499,67 @@ export default function K8sResourceList() {
|
|||||||
<TableCell>{metadata.namespace || '-'}</TableCell>
|
<TableCell>{metadata.namespace || '-'}</TableCell>
|
||||||
<TableCell>{`${status.readyReplicas || 0}/${spec.replicas || 0}`}</TableCell>
|
<TableCell>{`${status.readyReplicas || 0}/${spec.replicas || 0}`}</TableCell>
|
||||||
<TableCell>{getAge(metadata.creationTimestamp)}</TableCell>
|
<TableCell>{getAge(metadata.creationTimestamp)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Tooltip title="编辑">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => handleEditResource(metadata.name, metadata.namespace, selectedKind.label)}
|
||||||
|
>
|
||||||
|
<EditIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="删除">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
color="error"
|
||||||
|
onClick={() => openDeleteDialog(metadata.name, metadata.namespace)}
|
||||||
|
>
|
||||||
|
<DeleteIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)
|
)
|
||||||
case 'service':
|
case 'service':
|
||||||
|
// Format ports display
|
||||||
|
let portsDisplay = '-';
|
||||||
|
let nodePortsDisplay = '-';
|
||||||
|
if (spec.ports && spec.ports.length > 0) {
|
||||||
|
portsDisplay = spec.ports.map((p: any) => `${p.port}/${p.protocol}`).join(', ');
|
||||||
|
const nodePorts = spec.ports.filter((p: any) => p.nodePort).map((p: any) => p.nodePort);
|
||||||
|
if (nodePorts.length > 0) {
|
||||||
|
nodePortsDisplay = nodePorts.join(', ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRow key={metadata.uid}>
|
<TableRow key={metadata.uid}>
|
||||||
<TableCell>{metadata.name || '-'}</TableCell>
|
<TableCell>{metadata.name || '-'}</TableCell>
|
||||||
<TableCell>{metadata.namespace || '-'}</TableCell>
|
<TableCell>{metadata.namespace || '-'}</TableCell>
|
||||||
<TableCell>{spec.type || '-'}</TableCell>
|
<TableCell>{spec.type || '-'}</TableCell>
|
||||||
<TableCell>{spec.clusterIP || '-'}</TableCell>
|
<TableCell>{spec.clusterIP || '-'}</TableCell>
|
||||||
<TableCell>{spec.externalIPs?.join(', ') || status.loadBalancer?.ingress?.map((i: any) => i.ip || i.hostname).join(', ') || '-'}</TableCell>
|
<TableCell>{portsDisplay}</TableCell>
|
||||||
<TableCell>{spec.ports?.map((p: any) => `${p.port}/${p.protocol}`).join(', ') || '-'}</TableCell>
|
<TableCell>{nodePortsDisplay}</TableCell>
|
||||||
<TableCell>{getAge(metadata.creationTimestamp)}</TableCell>
|
<TableCell>{getAge(metadata.creationTimestamp)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Tooltip title="编辑">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => handleEditResource(metadata.name, metadata.namespace, selectedKind.label)}
|
||||||
|
>
|
||||||
|
<EditIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="删除">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
color="error"
|
||||||
|
onClick={() => openDeleteDialog(metadata.name, metadata.namespace)}
|
||||||
|
>
|
||||||
|
<DeleteIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)
|
)
|
||||||
case 'configmap':
|
case 'configmap':
|
||||||
@@ -245,6 +569,25 @@ export default function K8sResourceList() {
|
|||||||
<TableCell>{metadata.namespace || '-'}</TableCell>
|
<TableCell>{metadata.namespace || '-'}</TableCell>
|
||||||
<TableCell>{Object.keys(resource.data || {}).length}</TableCell>
|
<TableCell>{Object.keys(resource.data || {}).length}</TableCell>
|
||||||
<TableCell>{getAge(metadata.creationTimestamp)}</TableCell>
|
<TableCell>{getAge(metadata.creationTimestamp)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Tooltip title="编辑">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => handleEditResource(metadata.name, metadata.namespace, selectedKind.label)}
|
||||||
|
>
|
||||||
|
<EditIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="删除">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
color="error"
|
||||||
|
onClick={() => openDeleteDialog(metadata.name, metadata.namespace)}
|
||||||
|
>
|
||||||
|
<DeleteIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)
|
)
|
||||||
case 'pod':
|
case 'pod':
|
||||||
@@ -256,6 +599,23 @@ export default function K8sResourceList() {
|
|||||||
<TableCell>{status.podIP || '-'}</TableCell>
|
<TableCell>{status.podIP || '-'}</TableCell>
|
||||||
<TableCell>{spec.nodeName || '-'}</TableCell>
|
<TableCell>{spec.nodeName || '-'}</TableCell>
|
||||||
<TableCell>{getAge(metadata.creationTimestamp)}</TableCell>
|
<TableCell>{getAge(metadata.creationTimestamp)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => handleViewLogs(metadata.name, metadata.namespace)}
|
||||||
|
title="查看日志"
|
||||||
|
>
|
||||||
|
<VisibilityIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
color="error"
|
||||||
|
onClick={() => openDeleteDialog(metadata.name, metadata.namespace)}
|
||||||
|
title="删除"
|
||||||
|
>
|
||||||
|
<DeleteIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)
|
)
|
||||||
case 'pv':
|
case 'pv':
|
||||||
@@ -332,13 +692,35 @@ export default function K8sResourceList() {
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{selectedKind.key !== 'namespace' && selectedKind.key !== 'pv' && (
|
{selectedKind.key !== 'namespace' && selectedKind.key !== 'pv' && (
|
||||||
<Box sx={{ mb: 2 }}>
|
<Box sx={{ mb: 2, display: 'flex', gap: 2 }}>
|
||||||
<TextField
|
<TextField
|
||||||
placeholder="Namespace (空则查询所有)"
|
select
|
||||||
|
label="Namespace"
|
||||||
value={namespace}
|
value={namespace}
|
||||||
onChange={(e) => setNamespace(e.target.value)}
|
onChange={(e) => setNamespace(e.target.value)}
|
||||||
size="small"
|
size="small"
|
||||||
sx={{ width: 300 }}
|
sx={{ width: 200 }}
|
||||||
|
SelectProps={{
|
||||||
|
displayEmpty: true,
|
||||||
|
}}
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
|
>
|
||||||
|
<MenuItem value="">
|
||||||
|
<em>所有命名空间</em>
|
||||||
|
</MenuItem>
|
||||||
|
{namespaces.map((ns) => (
|
||||||
|
<MenuItem key={ns} value={ns}>
|
||||||
|
{ns}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
<TextField
|
||||||
|
label="名称过滤"
|
||||||
|
placeholder="按名称过滤"
|
||||||
|
value={nameFilter}
|
||||||
|
onChange={(e) => setNameFilter(e.target.value)}
|
||||||
|
size="small"
|
||||||
|
sx={{ width: 200 }}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
@@ -413,6 +795,46 @@ export default function K8sResourceList() {
|
|||||||
</Box>
|
</Box>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={editDialogOpen}
|
||||||
|
onClose={() => setEditDialogOpen(false)}
|
||||||
|
maxWidth="md"
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
<DialogTitle>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<Typography variant="h6">
|
||||||
|
编辑 {editResource?.kind}: {editResource?.name} ({editResource?.namespace})
|
||||||
|
</Typography>
|
||||||
|
<IconButton onClick={() => setEditDialogOpen(false)}>
|
||||||
|
<CloseIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<TextField
|
||||||
|
multiline
|
||||||
|
rows={20}
|
||||||
|
value={editYaml}
|
||||||
|
onChange={(e) => setEditYaml(e.target.value)}
|
||||||
|
placeholder="YAML 内容..."
|
||||||
|
fullWidth
|
||||||
|
variant="outlined"
|
||||||
|
sx={{ fontFamily: 'monospace', fontSize: '0.875rem' }}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setEditDialogOpen(false)}>取消</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={handleApplyEdit}
|
||||||
|
disabled={editing}
|
||||||
|
>
|
||||||
|
{editing ? <CircularProgress size={24} /> : '应用'}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
<Dialog
|
<Dialog
|
||||||
open={createDialogOpen}
|
open={createDialogOpen}
|
||||||
onClose={() => setCreateDialogOpen(false)}
|
onClose={() => setCreateDialogOpen(false)}
|
||||||
@@ -469,6 +891,66 @@ export default function K8sResourceList() {
|
|||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={logsDialogOpen}
|
||||||
|
onClose={handleCloseLogsDialog}
|
||||||
|
maxWidth="lg"
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
<DialogTitle>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<Typography variant="h6">
|
||||||
|
Pod 日志: {selectedPod?.name} ({selectedPod?.namespace})
|
||||||
|
</Typography>
|
||||||
|
<IconButton onClick={handleCloseLogsDialog}>
|
||||||
|
<CloseIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Paper
|
||||||
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
bgcolor: '#1e1e1e',
|
||||||
|
color: '#d4d4d4',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
maxHeight: '60vh',
|
||||||
|
overflow: 'auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{logs.length === 0 && <Typography>等待日志...</Typography>}
|
||||||
|
{logs.map((log, index) => (
|
||||||
|
<Box key={index} sx={{ whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
|
||||||
|
{log}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
<div ref={logsEndRef} />
|
||||||
|
</Paper>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog open={deleteDialogOpen} onClose={() => setDeleteDialogOpen(false)}>
|
||||||
|
<DialogTitle>确认删除</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Typography>
|
||||||
|
确定要删除 {selectedKind.label} <strong>{deleteTarget?.name}</strong>
|
||||||
|
{deleteTarget?.namespace && selectedKind.key !== 'namespace' ? ` (namespace: ${deleteTarget?.namespace})` : ''} 吗?
|
||||||
|
</Typography>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setDeleteDialogOpen(false)}>取消</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="error"
|
||||||
|
onClick={handleDeleteResource}
|
||||||
|
disabled={deleting}
|
||||||
|
>
|
||||||
|
{deleting ? <CircularProgress size={24} /> : '删除'}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
<Snackbar
|
<Snackbar
|
||||||
open={snackbar.open}
|
open={snackbar.open}
|
||||||
autoHideDuration={6000}
|
autoHideDuration={6000}
|
||||||
|
|||||||
132
frontend/src/pages/Login.tsx
Normal file
132
frontend/src/pages/Login.tsx
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Paper,
|
||||||
|
TextField,
|
||||||
|
Button,
|
||||||
|
Typography,
|
||||||
|
Alert,
|
||||||
|
CircularProgress,
|
||||||
|
Container,
|
||||||
|
InputAdornment,
|
||||||
|
IconButton,
|
||||||
|
} from '@mui/material'
|
||||||
|
import { Visibility, VisibilityOff } from '@mui/icons-material'
|
||||||
|
import { useAuthStore } from '../stores/authStore'
|
||||||
|
|
||||||
|
export default function Login() {
|
||||||
|
const [username, setUsername] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
|
const { login, loading, error } = useAuthStore()
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
await login(username, password)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container maxWidth="sm">
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
minHeight: '100vh',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
position: 'relative',
|
||||||
|
'&::before': {
|
||||||
|
content: '""',
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
backgroundImage: 'url(/api/v1/auth/wallpaper)',
|
||||||
|
backgroundSize: 'cover',
|
||||||
|
backgroundPosition: 'center',
|
||||||
|
backgroundRepeat: 'no-repeat',
|
||||||
|
filter: 'blur(8px)',
|
||||||
|
zIndex: -2,
|
||||||
|
},
|
||||||
|
'&::after': {
|
||||||
|
content: '""',
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.3)',
|
||||||
|
zIndex: -1,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Paper
|
||||||
|
elevation={3}
|
||||||
|
sx={{
|
||||||
|
p: 4,
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: 400,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="h4" component="h1" gutterBottom align="center">
|
||||||
|
Cluster
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" align="center" sx={{ mb: 3 }}>
|
||||||
|
登录到容器镜像仓库管理系统
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" sx={{ mb: 2 }}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="用户名"
|
||||||
|
variant="outlined"
|
||||||
|
margin="normal"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
disabled={loading}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="密码"
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
variant="outlined"
|
||||||
|
margin="normal"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
disabled={loading}
|
||||||
|
InputProps={{
|
||||||
|
endAdornment: (
|
||||||
|
<InputAdornment position="end">
|
||||||
|
<IconButton
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
edge="end"
|
||||||
|
>
|
||||||
|
{showPassword ? <VisibilityOff /> : <Visibility />}
|
||||||
|
</IconButton>
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
type="submit"
|
||||||
|
variant="contained"
|
||||||
|
size="large"
|
||||||
|
disabled={loading || !username || !password}
|
||||||
|
sx={{ mt: 3 }}
|
||||||
|
>
|
||||||
|
{loading ? <CircularProgress size={24} /> : '登录'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}
|
||||||
353
frontend/src/pages/UserManagement.tsx
Normal file
353
frontend/src/pages/UserManagement.tsx
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
Paper,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
CircularProgress,
|
||||||
|
Alert,
|
||||||
|
Button,
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogActions,
|
||||||
|
TextField,
|
||||||
|
IconButton,
|
||||||
|
Chip,
|
||||||
|
FormControl,
|
||||||
|
InputLabel,
|
||||||
|
Select,
|
||||||
|
MenuItem,
|
||||||
|
Checkbox,
|
||||||
|
FormGroup,
|
||||||
|
FormControlLabel,
|
||||||
|
} from '@mui/material'
|
||||||
|
import {
|
||||||
|
Add as AddIcon,
|
||||||
|
Edit as EditIcon,
|
||||||
|
Delete as DeleteIcon,
|
||||||
|
} from '@mui/icons-material'
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: number
|
||||||
|
username: string
|
||||||
|
email: string
|
||||||
|
nickname: string
|
||||||
|
role: string
|
||||||
|
status: string
|
||||||
|
permissions: string
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserFormData {
|
||||||
|
username: string
|
||||||
|
password: string
|
||||||
|
email: string
|
||||||
|
nickname: string
|
||||||
|
role: string
|
||||||
|
status: string
|
||||||
|
permissions: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const PERMISSION_OPTIONS = [
|
||||||
|
{ value: 'registry_read', label: '镜像仓库 - 读' },
|
||||||
|
{ value: 'registry_write', label: '镜像仓库 - 写' },
|
||||||
|
{ value: 'cluster_read', label: '集群管理 - 读' },
|
||||||
|
{ value: 'cluster_write', label: '集群管理 - 写' },
|
||||||
|
{ value: 'user_read', label: '用户管理 - 读' },
|
||||||
|
{ value: 'user_write', label: '用户管理 - 写' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function UserManagement() {
|
||||||
|
const [users, setUsers] = useState<User[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false)
|
||||||
|
const [editingUser, setEditingUser] = useState<User | null>(null)
|
||||||
|
const [formData, setFormData] = useState<UserFormData>({
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
email: '',
|
||||||
|
nickname: '',
|
||||||
|
role: 'user',
|
||||||
|
status: 'active',
|
||||||
|
permissions: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
const fetchUsers = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/auth/user/list')
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
|
const result = await res.json()
|
||||||
|
setUsers(result.data?.users || [])
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchUsers()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleOpenDialog = (user?: User) => {
|
||||||
|
if (user) {
|
||||||
|
setEditingUser(user)
|
||||||
|
setFormData({
|
||||||
|
username: user.username,
|
||||||
|
password: '',
|
||||||
|
email: user.email,
|
||||||
|
nickname: user.nickname,
|
||||||
|
role: user.role,
|
||||||
|
status: user.status,
|
||||||
|
permissions: user.permissions ? user.permissions.split(',') : [],
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
setEditingUser(null)
|
||||||
|
setFormData({
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
email: '',
|
||||||
|
nickname: '',
|
||||||
|
role: 'user',
|
||||||
|
status: 'active',
|
||||||
|
permissions: [],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
setDialogOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCloseDialog = () => {
|
||||||
|
setDialogOpen(false)
|
||||||
|
setEditingUser(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
...formData,
|
||||||
|
permissions: formData.permissions.join(','),
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = editingUser
|
||||||
|
? `/api/v1/auth/user/update/${editingUser.id}`
|
||||||
|
: '/api/v1/auth/user/create'
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const errorData = await res.json().catch(() => ({}))
|
||||||
|
throw new Error(errorData.message || `操作失败: HTTP ${res.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
await fetchUsers()
|
||||||
|
handleCloseDialog()
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (userId: number) => {
|
||||||
|
if (!window.confirm('确定要删除该用户吗?')) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/v1/auth/user/delete/${userId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
|
await fetchUsers()
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePermissionToggle = (permission: string) => {
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
permissions: prev.permissions.includes(permission)
|
||||||
|
? prev.permissions.filter((p) => p !== permission)
|
||||||
|
: [...prev.permissions, permission],
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Box display="flex" justifyContent="center" alignItems="center" minHeight="400px">
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Box display="flex" justifyContent="space-between" alignItems="center" mb={3}>
|
||||||
|
<Typography variant="h4" component="h1">
|
||||||
|
用户管理
|
||||||
|
</Typography>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<AddIcon />}
|
||||||
|
onClick={() => handleOpenDialog()}
|
||||||
|
>
|
||||||
|
新建用户
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TableContainer component={Paper}>
|
||||||
|
<Table>
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>用户名</TableCell>
|
||||||
|
<TableCell>昵称</TableCell>
|
||||||
|
<TableCell>邮箱</TableCell>
|
||||||
|
<TableCell>角色</TableCell>
|
||||||
|
<TableCell>状态</TableCell>
|
||||||
|
<TableCell>权限</TableCell>
|
||||||
|
<TableCell>创建时间</TableCell>
|
||||||
|
<TableCell align="right">操作</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{users.map((user) => (
|
||||||
|
<TableRow key={user.id}>
|
||||||
|
<TableCell>{user.username}</TableCell>
|
||||||
|
<TableCell>{user.nickname}</TableCell>
|
||||||
|
<TableCell>{user.email}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Chip
|
||||||
|
label={user.role === 'admin' ? '管理员' : '普通用户'}
|
||||||
|
color={user.role === 'admin' ? 'primary' : 'default'}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Chip
|
||||||
|
label={user.status === 'active' ? '激活' : '禁用'}
|
||||||
|
color={user.status === 'active' ? 'success' : 'default'}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{user.permissions?.split(',').map((p) => (
|
||||||
|
<Chip key={p} label={p} size="small" sx={{ mr: 0.5, mb: 0.5 }} />
|
||||||
|
))}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{new Date(user.created_at).toLocaleString()}</TableCell>
|
||||||
|
<TableCell align="right">
|
||||||
|
<IconButton size="small" onClick={() => handleOpenDialog(user)}>
|
||||||
|
<EditIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton size="small" onClick={() => handleDelete(user.id)}>
|
||||||
|
<DeleteIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
|
||||||
|
<Dialog open={dialogOpen} onClose={handleCloseDialog} maxWidth="sm" fullWidth>
|
||||||
|
<DialogTitle>{editingUser ? '编辑用户' : '新建用户'}</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
margin="normal"
|
||||||
|
label="用户名"
|
||||||
|
value={formData.username}
|
||||||
|
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
|
||||||
|
disabled={!!editingUser}
|
||||||
|
/>
|
||||||
|
{!editingUser && (
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
margin="normal"
|
||||||
|
label="密码"
|
||||||
|
type="password"
|
||||||
|
value={formData.password}
|
||||||
|
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
margin="normal"
|
||||||
|
label="邮箱"
|
||||||
|
value={formData.email}
|
||||||
|
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
margin="normal"
|
||||||
|
label="昵称"
|
||||||
|
value={formData.nickname}
|
||||||
|
onChange={(e) => setFormData({ ...formData, nickname: e.target.value })}
|
||||||
|
/>
|
||||||
|
<FormControl fullWidth margin="normal">
|
||||||
|
<InputLabel>角色</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={formData.role}
|
||||||
|
label="角色"
|
||||||
|
onChange={(e) => setFormData({ ...formData, role: e.target.value })}
|
||||||
|
>
|
||||||
|
<MenuItem value="user">普通用户</MenuItem>
|
||||||
|
<MenuItem value="admin">管理员</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<FormControl fullWidth margin="normal">
|
||||||
|
<InputLabel>状态</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={formData.status}
|
||||||
|
label="状态"
|
||||||
|
onChange={(e) => setFormData({ ...formData, status: e.target.value })}
|
||||||
|
>
|
||||||
|
<MenuItem value="active">激活</MenuItem>
|
||||||
|
<MenuItem value="inactive">禁用</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<Typography variant="subtitle2" sx={{ mt: 2, mb: 1 }}>
|
||||||
|
权限
|
||||||
|
</Typography>
|
||||||
|
<FormGroup>
|
||||||
|
{PERMISSION_OPTIONS.map((perm) => (
|
||||||
|
<FormControlLabel
|
||||||
|
key={perm.value}
|
||||||
|
control={
|
||||||
|
<Checkbox
|
||||||
|
checked={formData.permissions.includes(perm.value)}
|
||||||
|
onChange={() => handlePermissionToggle(perm.value)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={perm.label}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</FormGroup>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={handleCloseDialog}>取消</Button>
|
||||||
|
<Button onClick={handleSubmit} variant="contained">
|
||||||
|
{editingUser ? '保存' : '创建'}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
77
frontend/src/stores/authStore.ts
Normal file
77
frontend/src/stores/authStore.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
import { persist } from 'zustand/middleware'
|
||||||
|
|
||||||
|
interface AuthState {
|
||||||
|
token: string | null
|
||||||
|
username: string | null
|
||||||
|
loading: boolean
|
||||||
|
error: string | null
|
||||||
|
isAuthenticated: boolean
|
||||||
|
login: (username: string, password: string) => Promise<void>
|
||||||
|
logout: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuthStore = create<AuthState>()(
|
||||||
|
persist(
|
||||||
|
(set) => ({
|
||||||
|
token: null,
|
||||||
|
username: null,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
isAuthenticated: false,
|
||||||
|
|
||||||
|
login: async (username: string, password: string) => {
|
||||||
|
set({ loading: true, error: null })
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const errorData = await res.json().catch(() => ({}))
|
||||||
|
throw new Error(errorData.message || `登录失败: HTTP ${res.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await res.json()
|
||||||
|
const { token } = result.data
|
||||||
|
|
||||||
|
set({
|
||||||
|
token,
|
||||||
|
username,
|
||||||
|
isAuthenticated: true,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
})
|
||||||
|
} catch (error: any) {
|
||||||
|
set({
|
||||||
|
loading: false,
|
||||||
|
error: error.message || '登录失败',
|
||||||
|
isAuthenticated: false,
|
||||||
|
})
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
logout: () => {
|
||||||
|
set({
|
||||||
|
token: null,
|
||||||
|
username: null,
|
||||||
|
isAuthenticated: false,
|
||||||
|
error: null,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'auth-storage',
|
||||||
|
partialize: (state) => ({
|
||||||
|
token: state.token,
|
||||||
|
username: state.username,
|
||||||
|
isAuthenticated: state.isAuthenticated,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -11,6 +11,8 @@ export default defineConfig({
|
|||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://127.0.0.1:9119',
|
target: 'http://127.0.0.1:9119',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
|
ws: true,
|
||||||
|
timeout: 0,
|
||||||
// Removed rewrite so /api prefix is preserved for backend route /api/v1/...
|
// Removed rewrite so /api prefix is preserved for backend route /api/v1/...
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
10
go.mod
10
go.mod
@@ -4,7 +4,7 @@ go 1.25.0
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/glebarez/sqlite v1.11.0
|
github.com/glebarez/sqlite v1.11.0
|
||||||
github.com/gofiber/fiber/v3 v3.0.0-beta.2
|
github.com/gofiber/fiber/v3 v3.0.0-rc.2
|
||||||
github.com/gofrs/uuid v4.4.0+incompatible
|
github.com/gofrs/uuid v4.4.0+incompatible
|
||||||
github.com/google/go-containerregistry v0.20.6
|
github.com/google/go-containerregistry v0.20.6
|
||||||
github.com/jedib0t/go-pretty/v6 v6.7.1
|
github.com/jedib0t/go-pretty/v6 v6.7.1
|
||||||
@@ -12,6 +12,7 @@ require (
|
|||||||
golang.org/x/crypto v0.43.0
|
golang.org/x/crypto v0.43.0
|
||||||
golang.org/x/net v0.46.0
|
golang.org/x/net v0.46.0
|
||||||
gorm.io/gorm v1.31.1
|
gorm.io/gorm v1.31.1
|
||||||
|
k8s.io/api v0.34.1
|
||||||
k8s.io/apimachinery v0.34.1
|
k8s.io/apimachinery v0.34.1
|
||||||
k8s.io/client-go v0.34.1
|
k8s.io/client-go v0.34.1
|
||||||
sigs.k8s.io/yaml v1.6.0
|
sigs.k8s.io/yaml v1.6.0
|
||||||
@@ -32,8 +33,11 @@ require (
|
|||||||
github.com/go-openapi/jsonpointer v0.21.0 // indirect
|
github.com/go-openapi/jsonpointer v0.21.0 // indirect
|
||||||
github.com/go-openapi/jsonreference v0.20.2 // indirect
|
github.com/go-openapi/jsonreference v0.20.2 // indirect
|
||||||
github.com/go-openapi/swag v0.23.0 // indirect
|
github.com/go-openapi/swag v0.23.0 // indirect
|
||||||
|
github.com/go-resty/resty/v2 v2.16.5 // indirect
|
||||||
|
github.com/gofiber/schema v1.6.0 // indirect
|
||||||
github.com/gofiber/utils/v2 v2.0.0-rc.1 // indirect
|
github.com/gofiber/utils/v2 v2.0.0-rc.1 // indirect
|
||||||
github.com/gogo/protobuf v1.3.2 // indirect
|
github.com/gogo/protobuf v1.3.2 // indirect
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
|
||||||
github.com/google/gnostic-models v0.7.0 // indirect
|
github.com/google/gnostic-models v0.7.0 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
@@ -52,12 +56,13 @@ require (
|
|||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||||
github.com/opencontainers/image-spec v1.1.1 // indirect
|
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||||
|
github.com/philhofer/fwd v1.2.0 // indirect
|
||||||
github.com/pkg/errors v0.9.1 // indirect
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
github.com/sirupsen/logrus v1.9.3 // indirect
|
github.com/sirupsen/logrus v1.9.3 // indirect
|
||||||
github.com/spf13/pflag v1.0.9 // indirect
|
github.com/spf13/pflag v1.0.9 // indirect
|
||||||
github.com/stretchr/testify v1.11.1 // indirect
|
github.com/tinylib/msgp v1.4.0 // indirect
|
||||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||||
github.com/valyala/fasthttp v1.65.0 // indirect
|
github.com/valyala/fasthttp v1.65.0 // indirect
|
||||||
github.com/vbatts/tar-split v0.12.1 // indirect
|
github.com/vbatts/tar-split v0.12.1 // indirect
|
||||||
@@ -74,7 +79,6 @@ require (
|
|||||||
gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect
|
gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect
|
||||||
gopkg.in/inf.v0 v0.9.1 // indirect
|
gopkg.in/inf.v0 v0.9.1 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
k8s.io/api v0.34.1 // indirect
|
|
||||||
k8s.io/klog/v2 v2.130.1 // indirect
|
k8s.io/klog/v2 v2.130.1 // indirect
|
||||||
k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect
|
k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect
|
||||||
k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect
|
k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect
|
||||||
|
|||||||
18
go.sum
18
go.sum
@@ -33,16 +33,22 @@ github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En
|
|||||||
github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=
|
github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=
|
||||||
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
|
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
|
||||||
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
|
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
|
||||||
|
github.com/go-resty/resty/v2 v2.16.5 h1:hBKqmWrr7uRc3euHVqmh1HTHcKn99Smr7o5spptdhTM=
|
||||||
|
github.com/go-resty/resty/v2 v2.16.5/go.mod h1:hkJtXbA2iKHzJheXYvQ8snQES5ZLGKMwQ07xAwp/fiA=
|
||||||
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
||||||
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
|
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
|
||||||
github.com/gofiber/fiber/v3 v3.0.0-beta.2 h1:mVVgt8PTaHGup3NGl/+7U7nEoZaXJ5OComV4E+HpAao=
|
github.com/gofiber/fiber/v3 v3.0.0-rc.2 h1:5I3RQ7XygDBfWRlMhkATjyJKupMmfMAVmnsrgo6wmc0=
|
||||||
github.com/gofiber/fiber/v3 v3.0.0-beta.2/go.mod h1:w7sdfTY0okjZ1oVH6rSOGvuACUIt0By1iK0HKUb3uqM=
|
github.com/gofiber/fiber/v3 v3.0.0-rc.2/go.mod h1:EHKwhVCONMruJTOmvSPSy0CdACJ3uqCY8vGaBXft8yg=
|
||||||
|
github.com/gofiber/schema v1.6.0 h1:rAgVDFwhndtC+hgV7Vu5ItQCn7eC2mBA4Eu1/ZTiEYY=
|
||||||
|
github.com/gofiber/schema v1.6.0/go.mod h1:WNZWpQx8LlPSK7ZaX0OqOh+nQo/eW2OevsXs1VZfs/s=
|
||||||
github.com/gofiber/utils/v2 v2.0.0-rc.1 h1:b77K5Rk9+Pjdxz4HlwEBnS7u5nikhx7armQB8xPds4s=
|
github.com/gofiber/utils/v2 v2.0.0-rc.1 h1:b77K5Rk9+Pjdxz4HlwEBnS7u5nikhx7armQB8xPds4s=
|
||||||
github.com/gofiber/utils/v2 v2.0.0-rc.1/go.mod h1:Y1g08g7gvST49bbjHJ1AVqcsmg93912R/tbKWhn6V3E=
|
github.com/gofiber/utils/v2 v2.0.0-rc.1/go.mod h1:Y1g08g7gvST49bbjHJ1AVqcsmg93912R/tbKWhn6V3E=
|
||||||
github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA=
|
github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA=
|
||||||
github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
|
github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
|
||||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo=
|
github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo=
|
||||||
github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
|
github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
@@ -103,6 +109,8 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8
|
|||||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||||
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
||||||
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
|
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
|
||||||
|
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||||
|
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
@@ -116,8 +124,8 @@ github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUc
|
|||||||
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
||||||
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
|
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
github.com/shamaton/msgpack/v2 v2.2.3 h1:uDOHmxQySlvlUYfQwdjxyybAOzjlQsD1Vjy+4jmO9NM=
|
github.com/shamaton/msgpack/v2 v2.3.1 h1:R3QNLIGA/tbdczNMZ5PCRxrXvy+fnzsIaHG4kKMgWYo=
|
||||||
github.com/shamaton/msgpack/v2 v2.2.3/go.mod h1:6khjYnkx73f7VQU7wjcFS9DFjs+59naVWJv1TB7qdOI=
|
github.com/shamaton/msgpack/v2 v2.3.1/go.mod h1:6khjYnkx73f7VQU7wjcFS9DFjs+59naVWJv1TB7qdOI=
|
||||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||||
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
|
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
|
||||||
@@ -136,6 +144,8 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO
|
|||||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
github.com/tinylib/msgp v1.4.0 h1:SYOeDRiydzOw9kSiwdYp9UcBgPFtLU2WDHaJXyHruf8=
|
||||||
|
github.com/tinylib/msgp v1.4.0/go.mod h1:cvjFkb4RiC8qSBOPMGPSzSAx47nAsfhLVTCZZNuHv5o=
|
||||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||||
github.com/valyala/fasthttp v1.65.0 h1:j/u3uzFEGFfRxw79iYzJN+TteTJwbYkru9uDp3d0Yf8=
|
github.com/valyala/fasthttp v1.65.0 h1:j/u3uzFEGFfRxw79iYzJN+TteTJwbYkru9uDp3d0Yf8=
|
||||||
|
|||||||
@@ -7,9 +7,10 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
|
|
||||||
"gitea.loveuer.com/loveuer/cluster/internal/middleware"
|
"gitea.loveuer.com/loveuer/cluster/internal/middleware"
|
||||||
"gitea.loveuer.com/loveuer/cluster/internal/model"
|
"gitea.loveuer.com/loveuer/cluster/internal/module/auth"
|
||||||
"gitea.loveuer.com/loveuer/cluster/internal/module/k8s"
|
"gitea.loveuer.com/loveuer/cluster/internal/module/k8s"
|
||||||
"gitea.loveuer.com/loveuer/cluster/internal/module/registry"
|
"gitea.loveuer.com/loveuer/cluster/internal/module/registry"
|
||||||
|
registry_model "gitea.loveuer.com/loveuer/cluster/pkg/model/registry"
|
||||||
"gitea.loveuer.com/loveuer/cluster/pkg/store"
|
"gitea.loveuer.com/loveuer/cluster/pkg/store"
|
||||||
"github.com/gofiber/fiber/v3"
|
"github.com/gofiber/fiber/v3"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -33,10 +34,15 @@ func Init(ctx context.Context, address string, db *gorm.DB, store store.Store) (
|
|||||||
|
|
||||||
// Ensure database migration for RegistryConfig
|
// Ensure database migration for RegistryConfig
|
||||||
// This is done here to ensure the table exists before config APIs are called
|
// This is done here to ensure the table exists before config APIs are called
|
||||||
if err := db.AutoMigrate(&model.RegistryConfig{}); err != nil {
|
if err := db.AutoMigrate(®istry_model.RegistryConfig{}); err != nil {
|
||||||
log.Printf("Warning: failed to migrate RegistryConfig: %v", err)
|
log.Printf("Warning: failed to migrate RegistryConfig: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialize auth module
|
||||||
|
if err := auth.Init(ctx, db); err != nil {
|
||||||
|
log.Printf("Warning: failed to initialize auth module: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize k8s module
|
// Initialize k8s module
|
||||||
if err := k8s.Init(ctx, db, store); err != nil {
|
if err := k8s.Init(ctx, db, store); err != nil {
|
||||||
log.Printf("Warning: failed to initialize k8s module: %v", err)
|
log.Printf("Warning: failed to initialize k8s module: %v", err)
|
||||||
@@ -67,15 +73,38 @@ func Init(ctx context.Context, address string, db *gorm.DB, store store.Store) (
|
|||||||
k8sAPI.Post("/config", k8s.ClusterConfigSet(ctx, db, store))
|
k8sAPI.Post("/config", k8s.ClusterConfigSet(ctx, db, store))
|
||||||
// resource operations
|
// resource operations
|
||||||
k8sAPI.Post("/resource/apply", k8s.K8sResourceApply(ctx, db, store))
|
k8sAPI.Post("/resource/apply", k8s.K8sResourceApply(ctx, db, store))
|
||||||
|
k8sAPI.Get("/resource/get", k8s.K8sResourceFetch(ctx, db, store))
|
||||||
|
k8sAPI.Post("/resource/update", k8s.K8sResourceUpdate(ctx, db, store))
|
||||||
// resource list
|
// resource list
|
||||||
k8sAPI.Get("/namespace/list", k8s.K8sNamespaceList(ctx, db, store))
|
k8sAPI.Get("/namespace/list", k8s.K8sNamespaceList(ctx, db, store))
|
||||||
|
k8sAPI.Delete("/namespace/delete", k8s.K8sNamespaceDelete(ctx, db, store))
|
||||||
k8sAPI.Get("/deployment/list", k8s.K8sDeploymentList(ctx, db, store))
|
k8sAPI.Get("/deployment/list", k8s.K8sDeploymentList(ctx, db, store))
|
||||||
|
k8sAPI.Delete("/deployment/delete", k8s.K8sDeploymentDelete(ctx, db, store))
|
||||||
k8sAPI.Get("/statefulset/list", k8s.K8sStatefulSetList(ctx, db, store))
|
k8sAPI.Get("/statefulset/list", k8s.K8sStatefulSetList(ctx, db, store))
|
||||||
|
k8sAPI.Delete("/statefulset/delete", k8s.K8sStatefulSetDelete(ctx, db, store))
|
||||||
k8sAPI.Get("/configmap/list", k8s.K8sConfigMapList(ctx, db, store))
|
k8sAPI.Get("/configmap/list", k8s.K8sConfigMapList(ctx, db, store))
|
||||||
|
k8sAPI.Delete("/configmap/delete", k8s.K8sConfigMapDelete(ctx, db, store))
|
||||||
k8sAPI.Get("/pod/list", k8s.K8sPodList(ctx, db, store))
|
k8sAPI.Get("/pod/list", k8s.K8sPodList(ctx, db, store))
|
||||||
|
k8sAPI.Get("/pod/logs", k8s.K8sPodLogs(ctx, db, store))
|
||||||
|
k8sAPI.Delete("/pod/delete", k8s.K8sPodDelete(ctx, db, store))
|
||||||
k8sAPI.Get("/pv/list", k8s.K8sPVList(ctx, db, store))
|
k8sAPI.Get("/pv/list", k8s.K8sPVList(ctx, db, store))
|
||||||
k8sAPI.Get("/pvc/list", k8s.K8sPVCList(ctx, db, store))
|
k8sAPI.Get("/pvc/list", k8s.K8sPVCList(ctx, db, store))
|
||||||
k8sAPI.Get("/service/list", k8s.K8sServiceList(ctx, db, store))
|
k8sAPI.Get("/service/list", k8s.K8sServiceList(ctx, db, store))
|
||||||
|
k8sAPI.Delete("/service/delete", k8s.K8sServiceDelete(ctx, db, store))
|
||||||
|
}
|
||||||
|
|
||||||
|
// auth apis
|
||||||
|
{
|
||||||
|
authAPI := app.Group("/api/v1/auth")
|
||||||
|
authAPI.Post("/login", auth.Login(ctx, db))
|
||||||
|
authAPI.Get("/wallpaper", auth.Wallpaper(ctx))
|
||||||
|
authAPI.Get("/current", auth.GetCurrentUser(ctx))
|
||||||
|
|
||||||
|
// user management
|
||||||
|
authAPI.Get("/user/list", auth.UserList(ctx, db))
|
||||||
|
authAPI.Post("/user/create", auth.UserCreate(ctx, db))
|
||||||
|
authAPI.Post("/user/update/:id", auth.UserUpdate(ctx, db))
|
||||||
|
authAPI.Delete("/user/delete/:id", auth.UserDelete(ctx, db))
|
||||||
}
|
}
|
||||||
|
|
||||||
ln, err = net.Listen("tcp", address)
|
ln, err = net.Listen("tcp", address)
|
||||||
|
|||||||
@@ -1,92 +0,0 @@
|
|||||||
package model
|
|
||||||
|
|
||||||
import (
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Repository ????
|
|
||||||
type Repository struct {
|
|
||||||
ID uint `gorm:"primarykey" json:"id"`
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
|
||||||
|
|
||||||
Name string `gorm:"uniqueIndex;not null" json:"name"` // ?????? "library/nginx"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Blob blob ??
|
|
||||||
type Blob struct {
|
|
||||||
ID uint `gorm:"primarykey" json:"id"`
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
|
||||||
|
|
||||||
Digest string `gorm:"uniqueIndex;not null" json:"digest"` // SHA256 digest
|
|
||||||
Size int64 `gorm:"not null" json:"size"` // ??????
|
|
||||||
MediaType string `json:"media_type"` // ????
|
|
||||||
Repository string `gorm:"index" json:"repository"` // ???????????????
|
|
||||||
}
|
|
||||||
|
|
||||||
// Manifest manifest ??
|
|
||||||
type Manifest struct {
|
|
||||||
ID uint `gorm:"primarykey" json:"id"`
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
|
||||||
|
|
||||||
Repository string `gorm:"index;not null" json:"repository"` // ????
|
|
||||||
Tag string `gorm:"index;not null" json:"tag"` // tag ??
|
|
||||||
Digest string `gorm:"uniqueIndex;not null" json:"digest"` // manifest digest
|
|
||||||
MediaType string `json:"media_type"` // ????
|
|
||||||
Size int64 `gorm:"not null" json:"size"` // manifest ??
|
|
||||||
Content []byte `gorm:"type:blob" json:"-"` // manifest ???JSON?
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tag tag ??????????
|
|
||||||
type Tag struct {
|
|
||||||
ID uint `gorm:"primarykey" json:"id"`
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
|
||||||
|
|
||||||
Repository string `gorm:"index;not null" json:"repository"` // ????
|
|
||||||
Tag string `gorm:"index;not null" json:"tag"` // tag ??
|
|
||||||
Digest string `gorm:"not null" json:"digest"` // ??? manifest digest
|
|
||||||
}
|
|
||||||
|
|
||||||
// BlobUpload ????? blob ??
|
|
||||||
type BlobUpload struct {
|
|
||||||
ID uint `gorm:"primarykey" json:"id"`
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
|
||||||
|
|
||||||
UUID string `gorm:"uniqueIndex;not null" json:"uuid"` // ???? UUID
|
|
||||||
Repository string `gorm:"index;not null" json:"repository"` // ????
|
|
||||||
Path string `gorm:"not null" json:"path"` // ??????
|
|
||||||
Size int64 `gorm:"default:0" json:"size"` // ?????
|
|
||||||
}
|
|
||||||
|
|
||||||
// RegistryConfig registry ?????
|
|
||||||
type RegistryConfig struct {
|
|
||||||
ID uint `gorm:"primarykey" json:"id"`
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
|
||||||
|
|
||||||
Key string `gorm:"uniqueIndex;not null" json:"key"` // ???? key
|
|
||||||
Value string `gorm:"type:text" json:"value"` // ???? value
|
|
||||||
}
|
|
||||||
|
|
||||||
// ClusterConfig k8s cluster configuration
|
|
||||||
type ClusterConfig struct {
|
|
||||||
ID uint `gorm:"primarykey" json:"id"`
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
|
||||||
|
|
||||||
Key string `gorm:"uniqueIndex;not null" json:"key"`
|
|
||||||
Value string `gorm:"type:text" json:"value"`
|
|
||||||
}
|
|
||||||
41
internal/module/auth/auth.go
Normal file
41
internal/module/auth/auth.go
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
auth "gitea.loveuer.com/loveuer/cluster/pkg/model/auth"
|
||||||
|
"gitea.loveuer.com/loveuer/cluster/pkg/tool"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Init(ctx context.Context, db *gorm.DB) error {
|
||||||
|
if err := db.AutoMigrate(&auth.User{}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var count int64
|
||||||
|
if err := db.Model(&auth.User{}).Count(&count).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if count == 0 {
|
||||||
|
defaultAdmin := &auth.User{
|
||||||
|
Username: "admin",
|
||||||
|
Password: tool.NewPassword("cluster"),
|
||||||
|
Email: "admin@cluster.local",
|
||||||
|
Nickname: "Administrator",
|
||||||
|
Role: "admin",
|
||||||
|
Status: "active",
|
||||||
|
Permissions: "registry_read,registry_write,cluster_read,cluster_write",
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.Create(defaultAdmin).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Println("[Auth] Default admin user created: username=admin, password=cluster")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
42
internal/module/auth/handler.current.go
Normal file
42
internal/module/auth/handler.current.go
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"gitea.loveuer.com/loveuer/cluster/pkg/resp"
|
||||||
|
"github.com/gofiber/fiber/v3"
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
func GetCurrentUser(ctx context.Context) fiber.Handler {
|
||||||
|
return func(c fiber.Ctx) error {
|
||||||
|
authHeader := c.Get("Authorization")
|
||||||
|
if authHeader == "" {
|
||||||
|
return resp.R401(c, "MISSING_TOKEN", nil, "authorization token is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenString := authHeader
|
||||||
|
if len(authHeader) > 7 && authHeader[:7] == "Bearer " {
|
||||||
|
tokenString = authHeader[7:]
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
|
||||||
|
return []byte(JWTSecret), nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil || !token.Valid {
|
||||||
|
return resp.R401(c, "INVALID_TOKEN", nil, "invalid or expired token")
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, ok := token.Claims.(*Claims)
|
||||||
|
if !ok {
|
||||||
|
return resp.R401(c, "INVALID_CLAIMS", nil, "invalid token claims")
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp.R200(c, map[string]interface{}{
|
||||||
|
"user_id": claims.UserID,
|
||||||
|
"username": claims.Username,
|
||||||
|
"role": claims.Role,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
97
internal/module/auth/handler.login.go
Normal file
97
internal/module/auth/handler.login.go
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
authModel "gitea.loveuer.com/loveuer/cluster/pkg/model/auth"
|
||||||
|
"gitea.loveuer.com/loveuer/cluster/pkg/resp"
|
||||||
|
"gitea.loveuer.com/loveuer/cluster/pkg/tool"
|
||||||
|
"github.com/gofiber/fiber/v3"
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
JWTSecret = "cluster-secret-key-change-in-production"
|
||||||
|
TokenDuration = 7 * 24 * time.Hour
|
||||||
|
)
|
||||||
|
|
||||||
|
type LoginRequest struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LoginResponse struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Nickname string `json:"nickname"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Claims struct {
|
||||||
|
UserID uint `json:"user_id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
jwt.RegisteredClaims
|
||||||
|
}
|
||||||
|
|
||||||
|
func Login(ctx context.Context, db *gorm.DB) fiber.Handler {
|
||||||
|
return func(c fiber.Ctx) error {
|
||||||
|
var req LoginRequest
|
||||||
|
|
||||||
|
body := c.Body()
|
||||||
|
if len(body) == 0 {
|
||||||
|
return resp.R400(c, "EMPTY_BODY", nil, "request body is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(body, &req); err != nil {
|
||||||
|
return resp.R400(c, "INVALID_REQUEST", nil, "invalid request body")
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Username == "" || req.Password == "" {
|
||||||
|
return resp.R400(c, "MISSING_CREDENTIALS", nil, "username and password are required")
|
||||||
|
}
|
||||||
|
|
||||||
|
var user authModel.User
|
||||||
|
if err := db.Where("username = ?", req.Username).First(&user).Error; err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return resp.R401(c, "INVALID_CREDENTIALS", nil, "invalid username or password")
|
||||||
|
}
|
||||||
|
return resp.R500(c, "", nil, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
if user.Status != "active" {
|
||||||
|
return resp.R403(c, "USER_INACTIVE", nil, "user account is inactive")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !tool.ComparePassword(req.Password, user.Password) {
|
||||||
|
return resp.R401(c, "INVALID_CREDENTIALS", nil, "invalid username or password")
|
||||||
|
}
|
||||||
|
|
||||||
|
claims := &Claims{
|
||||||
|
UserID: user.ID,
|
||||||
|
Username: user.Username,
|
||||||
|
Role: user.Role,
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(TokenDuration)),
|
||||||
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||||
|
NotBefore: jwt.NewNumericDate(time.Now()),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
|
tokenString, err := token.SignedString([]byte(JWTSecret))
|
||||||
|
if err != nil {
|
||||||
|
return resp.R500(c, "", nil, "failed to generate token")
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp.R200(c, LoginResponse{
|
||||||
|
Token: tokenString,
|
||||||
|
Username: user.Username,
|
||||||
|
Nickname: user.Nickname,
|
||||||
|
Role: user.Role,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
207
internal/module/auth/handler.user.go
Normal file
207
internal/module/auth/handler.user.go
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
auth "gitea.loveuer.com/loveuer/cluster/pkg/model/auth"
|
||||||
|
"gitea.loveuer.com/loveuer/cluster/pkg/resp"
|
||||||
|
"gitea.loveuer.com/loveuer/cluster/pkg/tool"
|
||||||
|
"github.com/gofiber/fiber/v3"
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func UserList(ctx context.Context, db *gorm.DB) fiber.Handler {
|
||||||
|
return func(c fiber.Ctx) error {
|
||||||
|
var users []auth.User
|
||||||
|
if err := db.Find(&users).Error; err != nil {
|
||||||
|
return resp.R500(c, "", nil, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp.R200(c, map[string]interface{}{
|
||||||
|
"users": users,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func UserCreate(ctx context.Context, db *gorm.DB) fiber.Handler {
|
||||||
|
return func(c fiber.Ctx) error {
|
||||||
|
var req struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Nickname string `json:"nickname"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Permissions string `json:"permissions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
body := c.Body()
|
||||||
|
if len(body) == 0 {
|
||||||
|
return resp.R400(c, "EMPTY_BODY", nil, "request body is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(body, &req); err != nil {
|
||||||
|
return resp.R400(c, "INVALID_REQUEST", nil, "invalid request body")
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Username == "" || req.Password == "" {
|
||||||
|
return resp.R400(c, "MISSING_FIELDS", nil, "username and password are required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tool.CheckPassword(req.Password); err != nil {
|
||||||
|
return resp.R400(c, "WEAK_PASSWORD", nil, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
var existing auth.User
|
||||||
|
if err := db.Unscoped().Where("username = ?", req.Username).First(&existing).Error; err == nil {
|
||||||
|
return resp.R400(c, "USER_EXISTS", nil, "username already exists")
|
||||||
|
}
|
||||||
|
|
||||||
|
user := &auth.User{
|
||||||
|
Username: req.Username,
|
||||||
|
Password: tool.NewPassword(req.Password),
|
||||||
|
Email: req.Email,
|
||||||
|
Nickname: req.Nickname,
|
||||||
|
Role: req.Role,
|
||||||
|
Status: req.Status,
|
||||||
|
Permissions: req.Permissions,
|
||||||
|
}
|
||||||
|
|
||||||
|
if user.Role == "" {
|
||||||
|
user.Role = "user"
|
||||||
|
}
|
||||||
|
if user.Status == "" {
|
||||||
|
user.Status = "active"
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.Create(user).Error; err != nil {
|
||||||
|
return resp.R500(c, "", nil, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp.R200(c, map[string]interface{}{
|
||||||
|
"user": user,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func UserUpdate(ctx context.Context, db *gorm.DB) fiber.Handler {
|
||||||
|
return func(c fiber.Ctx) error {
|
||||||
|
userID := c.Params("id")
|
||||||
|
if userID == "" {
|
||||||
|
return resp.R400(c, "MISSING_ID", nil, "user id is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
authHeader := c.Get("Authorization")
|
||||||
|
var currentUserID uint
|
||||||
|
if authHeader != "" {
|
||||||
|
tokenString := authHeader
|
||||||
|
if len(authHeader) > 7 && authHeader[:7] == "Bearer " {
|
||||||
|
tokenString = authHeader[7:]
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
|
||||||
|
return []byte(JWTSecret), nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err == nil && token.Valid {
|
||||||
|
if claims, ok := token.Claims.(*Claims); ok {
|
||||||
|
currentUserID = claims.UserID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
targetUserID, _ := strconv.ParseUint(userID, 10, 32)
|
||||||
|
isSelf := currentUserID == uint(targetUserID)
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
Nickname string `json:"nickname"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Permissions string `json:"permissions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
body := c.Body()
|
||||||
|
if len(body) == 0 {
|
||||||
|
return resp.R400(c, "EMPTY_BODY", nil, "request body is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(body, &req); err != nil {
|
||||||
|
return resp.R400(c, "INVALID_REQUEST", nil, "invalid request body")
|
||||||
|
}
|
||||||
|
|
||||||
|
var user auth.User
|
||||||
|
if err := db.First(&user, userID).Error; err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return resp.R404(c, "USER_NOT_FOUND", nil, "user not found")
|
||||||
|
}
|
||||||
|
return resp.R500(c, "", nil, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
user.Email = req.Email
|
||||||
|
user.Nickname = req.Nickname
|
||||||
|
|
||||||
|
if req.Password != "" {
|
||||||
|
if err := tool.CheckPassword(req.Password); err != nil {
|
||||||
|
return resp.R400(c, "WEAK_PASSWORD", nil, err.Error())
|
||||||
|
}
|
||||||
|
user.Password = tool.NewPassword(req.Password)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isSelf {
|
||||||
|
user.Role = req.Role
|
||||||
|
user.Status = req.Status
|
||||||
|
user.Permissions = req.Permissions
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.Save(&user).Error; err != nil {
|
||||||
|
return resp.R500(c, "", nil, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp.R200(c, map[string]interface{}{
|
||||||
|
"user": user,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func UserDelete(ctx context.Context, db *gorm.DB) fiber.Handler {
|
||||||
|
return func(c fiber.Ctx) error {
|
||||||
|
userID := c.Params("id")
|
||||||
|
if userID == "" {
|
||||||
|
return resp.R400(c, "MISSING_ID", nil, "user id is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
var user auth.User
|
||||||
|
if err := db.First(&user, userID).Error; err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return resp.R404(c, "USER_NOT_FOUND", nil, "user not found")
|
||||||
|
}
|
||||||
|
return resp.R500(c, "", nil, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
if user.Username == "admin" {
|
||||||
|
return resp.R403(c, "CANNOT_DELETE_ADMIN", nil, "cannot delete admin user")
|
||||||
|
}
|
||||||
|
|
||||||
|
var count int64
|
||||||
|
if err := db.Model(&auth.User{}).Count(&count).Error; err != nil {
|
||||||
|
return resp.R500(c, "", nil, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
if count <= 1 {
|
||||||
|
return resp.R403(c, "LAST_USER", nil, "cannot delete the last user")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.Delete(&user).Error; err != nil {
|
||||||
|
return resp.R500(c, "", nil, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp.R200(c, map[string]interface{}{
|
||||||
|
"message": "user deleted successfully",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
108
internal/module/auth/wallpaper.go
Normal file
108
internal/module/auth/wallpaper.go
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.loveuer.com/loveuer/cluster/pkg/resp"
|
||||||
|
"github.com/go-resty/resty/v2"
|
||||||
|
"github.com/gofiber/fiber/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Wallpaper(ctx context.Context) fiber.Handler {
|
||||||
|
type Result struct {
|
||||||
|
Images []struct {
|
||||||
|
Startdate string `json:"startdate"`
|
||||||
|
Fullstartdate string `json:"fullstartdate"`
|
||||||
|
Enddate string `json:"enddate"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Urlbase string `json:"urlbase"`
|
||||||
|
Copyright string `json:"copyright"`
|
||||||
|
Copyrightlink string `json:"copyrightlink"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Quiz string `json:"quiz"`
|
||||||
|
Wp bool `json:"wp"`
|
||||||
|
Hsh string `json:"hsh"`
|
||||||
|
Drk int `json:"drk"`
|
||||||
|
Top int `json:"top"`
|
||||||
|
Bot int `json:"bot"`
|
||||||
|
} `json:"images"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Store struct {
|
||||||
|
sync.Mutex
|
||||||
|
Date string `json:"date"`
|
||||||
|
Body []byte `json:"body"`
|
||||||
|
Headers map[string]string `json:"headers"`
|
||||||
|
}
|
||||||
|
|
||||||
|
client := resty.New().SetTLSClientConfig(&tls.Config{InsecureSkipVerify: true})
|
||||||
|
apiUrl := "https://cn.bing.com/HPImageArchive.aspx?format=js&idx=0&n=1"
|
||||||
|
imgUrlPrefix := "https://cn.bing.com"
|
||||||
|
store := &Store{}
|
||||||
|
|
||||||
|
get := func() ([]byte, map[string]string, error) {
|
||||||
|
var (
|
||||||
|
err error
|
||||||
|
rr *resty.Response
|
||||||
|
result = new(Result)
|
||||||
|
headers = make(map[string]string)
|
||||||
|
date = time.Now().Format("2006-01-02")
|
||||||
|
)
|
||||||
|
|
||||||
|
if store.Date == date {
|
||||||
|
return store.Body, store.Headers, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err = client.R().
|
||||||
|
SetResult(result).
|
||||||
|
Get(apiUrl); err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("[BingWallpaper] get %s err: %w", apiUrl, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(result.Images) == 0 {
|
||||||
|
err = errors.New("[BingWallpaper]: image length = 0")
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
address := fmt.Sprintf("%s%s", imgUrlPrefix, result.Images[0].URL)
|
||||||
|
|
||||||
|
if rr, err = client.R().
|
||||||
|
Get(address); err != nil {
|
||||||
|
err = fmt.Errorf("[BingWallpaper] get image body: %s err: %w", address, err)
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for key := range rr.Header() {
|
||||||
|
headers[key] = strings.Join(rr.Header()[key], ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
store.Lock()
|
||||||
|
store.Date = date
|
||||||
|
store.Body = rr.Body()
|
||||||
|
store.Headers = headers
|
||||||
|
store.Unlock()
|
||||||
|
|
||||||
|
return rr.Body(), headers, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return func(c fiber.Ctx) error {
|
||||||
|
bs, headers, err := get()
|
||||||
|
if err != nil {
|
||||||
|
return resp.R500(c, "", nil, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
for key := range headers {
|
||||||
|
c.Set(key, headers[key])
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = c.Write(bs)
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
|
||||||
"gitea.loveuer.com/loveuer/cluster/internal/model"
|
k8s "gitea.loveuer.com/loveuer/cluster/pkg/model/k8s"
|
||||||
"gitea.loveuer.com/loveuer/cluster/pkg/resp"
|
"gitea.loveuer.com/loveuer/cluster/pkg/resp"
|
||||||
"gitea.loveuer.com/loveuer/cluster/pkg/store"
|
"gitea.loveuer.com/loveuer/cluster/pkg/store"
|
||||||
"github.com/gofiber/fiber/v3"
|
"github.com/gofiber/fiber/v3"
|
||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
|
|
||||||
func ClusterConfigGet(ctx context.Context, db *gorm.DB, store store.Store) fiber.Handler {
|
func ClusterConfigGet(ctx context.Context, db *gorm.DB, store store.Store) fiber.Handler {
|
||||||
return func(c fiber.Ctx) error {
|
return func(c fiber.Ctx) error {
|
||||||
var config model.ClusterConfig
|
var config k8s.ClusterConfig
|
||||||
|
|
||||||
if err := db.Where("key = ?", "kubeconfig").First(&config).Error; err != nil {
|
if err := db.Where("key = ?", "kubeconfig").First(&config).Error; err != nil {
|
||||||
if err == gorm.ErrRecordNotFound {
|
if err == gorm.ErrRecordNotFound {
|
||||||
@@ -40,7 +40,7 @@ func ClusterConfigSet(ctx context.Context, db *gorm.DB, store store.Store) fiber
|
|||||||
return resp.R400(c, "", nil, err)
|
return resp.R400(c, "", nil, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
config := model.ClusterConfig{
|
config := k8s.ClusterConfig{
|
||||||
Key: "kubeconfig",
|
Key: "kubeconfig",
|
||||||
Value: req.Kubeconfig,
|
Value: req.Kubeconfig,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,33 @@
|
|||||||
package k8s
|
package k8s
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"gitea.loveuer.com/loveuer/cluster/internal/model"
|
k8sModel "gitea.loveuer.com/loveuer/cluster/pkg/model/k8s"
|
||||||
"gitea.loveuer.com/loveuer/cluster/pkg/resp"
|
"gitea.loveuer.com/loveuer/cluster/pkg/resp"
|
||||||
"gitea.loveuer.com/loveuer/cluster/pkg/store"
|
"gitea.loveuer.com/loveuer/cluster/pkg/store"
|
||||||
"github.com/gofiber/fiber/v3"
|
"github.com/gofiber/fiber/v3"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
appsv1 "k8s.io/api/apps/v1"
|
||||||
|
corev1 "k8s.io/api/core/v1"
|
||||||
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||||
"k8s.io/client-go/dynamic"
|
"k8s.io/client-go/dynamic"
|
||||||
"k8s.io/client-go/kubernetes"
|
"k8s.io/client-go/kubernetes"
|
||||||
|
"k8s.io/client-go/rest"
|
||||||
"k8s.io/client-go/tools/clientcmd"
|
"k8s.io/client-go/tools/clientcmd"
|
||||||
"sigs.k8s.io/yaml"
|
"sigs.k8s.io/yaml"
|
||||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func getK8sClient(db *gorm.DB) (*kubernetes.Clientset, error) {
|
func getK8sClient(db *gorm.DB) (*kubernetes.Clientset, error) {
|
||||||
var config model.ClusterConfig
|
var config k8sModel.ClusterConfig
|
||||||
|
|
||||||
if err := db.Where("key = ?", "kubeconfig").First(&config).Error; err != nil {
|
if err := db.Where("key = ?", "kubeconfig").First(&config).Error; err != nil {
|
||||||
return nil, fmt.Errorf("kubeconfig not found: %w", err)
|
return nil, fmt.Errorf("kubeconfig not found: %w", err)
|
||||||
}
|
}
|
||||||
@@ -35,6 +41,8 @@ func getK8sClient(db *gorm.DB) (*kubernetes.Clientset, error) {
|
|||||||
return nil, fmt.Errorf("failed to parse kubeconfig: %w", err)
|
return nil, fmt.Errorf("failed to parse kubeconfig: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clientConfig.Timeout = 0
|
||||||
|
|
||||||
clientset, err := kubernetes.NewForConfig(clientConfig)
|
clientset, err := kubernetes.NewForConfig(clientConfig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create k8s client: %w", err)
|
return nil, fmt.Errorf("failed to create k8s client: %w", err)
|
||||||
@@ -43,6 +51,29 @@ func getK8sClient(db *gorm.DB) (*kubernetes.Clientset, error) {
|
|||||||
return clientset, nil
|
return clientset, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getK8sConfig(db *gorm.DB) (*rest.Config, error) {
|
||||||
|
var config k8sModel.ClusterConfig
|
||||||
|
|
||||||
|
if err := db.Where("key = ?", "kubeconfig").First(&config).Error; err != nil {
|
||||||
|
return nil, fmt.Errorf("kubeconfig not found: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.Value == "" {
|
||||||
|
return nil, fmt.Errorf("kubeconfig is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
clientConfig, err := clientcmd.RESTConfigFromKubeConfig([]byte(config.Value))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse kubeconfig: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disable HTTP/2 to avoid stream closing issues
|
||||||
|
clientConfig.TLSClientConfig.NextProtos = []string{"http/1.1"}
|
||||||
|
clientConfig.Timeout = 0
|
||||||
|
|
||||||
|
return clientConfig, nil
|
||||||
|
}
|
||||||
|
|
||||||
func K8sNamespaceList(ctx context.Context, db *gorm.DB, store store.Store) fiber.Handler {
|
func K8sNamespaceList(ctx context.Context, db *gorm.DB, store store.Store) fiber.Handler {
|
||||||
return func(c fiber.Ctx) error {
|
return func(c fiber.Ctx) error {
|
||||||
clientset, err := getK8sClient(db)
|
clientset, err := getK8sClient(db)
|
||||||
@@ -64,7 +95,8 @@ func K8sNamespaceList(ctx context.Context, db *gorm.DB, store store.Store) fiber
|
|||||||
func K8sDeploymentList(ctx context.Context, db *gorm.DB, store store.Store) fiber.Handler {
|
func K8sDeploymentList(ctx context.Context, db *gorm.DB, store store.Store) fiber.Handler {
|
||||||
return func(c fiber.Ctx) error {
|
return func(c fiber.Ctx) error {
|
||||||
namespace := c.Query("namespace", "")
|
namespace := c.Query("namespace", "")
|
||||||
|
name := c.Query("name", "")
|
||||||
|
|
||||||
clientset, err := getK8sClient(db)
|
clientset, err := getK8sClient(db)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return resp.R500(c, "", nil, err)
|
return resp.R500(c, "", nil, err)
|
||||||
@@ -75,8 +107,20 @@ func K8sDeploymentList(ctx context.Context, db *gorm.DB, store store.Store) fibe
|
|||||||
return resp.R500(c, "", nil, err)
|
return resp.R500(c, "", nil, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Filter by name if provided
|
||||||
|
var filtered []appsv1.Deployment
|
||||||
|
if name != "" {
|
||||||
|
for _, deployment := range deployments.Items {
|
||||||
|
if strings.Contains(deployment.Name, name) {
|
||||||
|
filtered = append(filtered, deployment)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
filtered = deployments.Items
|
||||||
|
}
|
||||||
|
|
||||||
return resp.R200(c, map[string]any{
|
return resp.R200(c, map[string]any{
|
||||||
"items": deployments.Items,
|
"items": filtered,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -84,7 +128,8 @@ func K8sDeploymentList(ctx context.Context, db *gorm.DB, store store.Store) fibe
|
|||||||
func K8sStatefulSetList(ctx context.Context, db *gorm.DB, store store.Store) fiber.Handler {
|
func K8sStatefulSetList(ctx context.Context, db *gorm.DB, store store.Store) fiber.Handler {
|
||||||
return func(c fiber.Ctx) error {
|
return func(c fiber.Ctx) error {
|
||||||
namespace := c.Query("namespace", "")
|
namespace := c.Query("namespace", "")
|
||||||
|
name := c.Query("name", "")
|
||||||
|
|
||||||
clientset, err := getK8sClient(db)
|
clientset, err := getK8sClient(db)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return resp.R500(c, "", nil, err)
|
return resp.R500(c, "", nil, err)
|
||||||
@@ -95,8 +140,20 @@ func K8sStatefulSetList(ctx context.Context, db *gorm.DB, store store.Store) fib
|
|||||||
return resp.R500(c, "", nil, err)
|
return resp.R500(c, "", nil, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Filter by name if provided
|
||||||
|
var filtered []appsv1.StatefulSet
|
||||||
|
if name != "" {
|
||||||
|
for _, statefulset := range statefulsets.Items {
|
||||||
|
if strings.Contains(statefulset.Name, name) {
|
||||||
|
filtered = append(filtered, statefulset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
filtered = statefulsets.Items
|
||||||
|
}
|
||||||
|
|
||||||
return resp.R200(c, map[string]any{
|
return resp.R200(c, map[string]any{
|
||||||
"items": statefulsets.Items,
|
"items": filtered,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -104,7 +161,8 @@ func K8sStatefulSetList(ctx context.Context, db *gorm.DB, store store.Store) fib
|
|||||||
func K8sConfigMapList(ctx context.Context, db *gorm.DB, store store.Store) fiber.Handler {
|
func K8sConfigMapList(ctx context.Context, db *gorm.DB, store store.Store) fiber.Handler {
|
||||||
return func(c fiber.Ctx) error {
|
return func(c fiber.Ctx) error {
|
||||||
namespace := c.Query("namespace", "")
|
namespace := c.Query("namespace", "")
|
||||||
|
name := c.Query("name", "")
|
||||||
|
|
||||||
clientset, err := getK8sClient(db)
|
clientset, err := getK8sClient(db)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return resp.R500(c, "", nil, err)
|
return resp.R500(c, "", nil, err)
|
||||||
@@ -115,8 +173,20 @@ func K8sConfigMapList(ctx context.Context, db *gorm.DB, store store.Store) fiber
|
|||||||
return resp.R500(c, "", nil, err)
|
return resp.R500(c, "", nil, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Filter by name if provided
|
||||||
|
var filtered []corev1.ConfigMap
|
||||||
|
if name != "" {
|
||||||
|
for _, configmap := range configmaps.Items {
|
||||||
|
if strings.Contains(configmap.Name, name) {
|
||||||
|
filtered = append(filtered, configmap)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
filtered = configmaps.Items
|
||||||
|
}
|
||||||
|
|
||||||
return resp.R200(c, map[string]any{
|
return resp.R200(c, map[string]any{
|
||||||
"items": configmaps.Items,
|
"items": filtered,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -124,7 +194,8 @@ func K8sConfigMapList(ctx context.Context, db *gorm.DB, store store.Store) fiber
|
|||||||
func K8sPodList(ctx context.Context, db *gorm.DB, store store.Store) fiber.Handler {
|
func K8sPodList(ctx context.Context, db *gorm.DB, store store.Store) fiber.Handler {
|
||||||
return func(c fiber.Ctx) error {
|
return func(c fiber.Ctx) error {
|
||||||
namespace := c.Query("namespace", "")
|
namespace := c.Query("namespace", "")
|
||||||
|
name := c.Query("name", "")
|
||||||
|
|
||||||
clientset, err := getK8sClient(db)
|
clientset, err := getK8sClient(db)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return resp.R500(c, "", nil, err)
|
return resp.R500(c, "", nil, err)
|
||||||
@@ -135,8 +206,20 @@ func K8sPodList(ctx context.Context, db *gorm.DB, store store.Store) fiber.Handl
|
|||||||
return resp.R500(c, "", nil, err)
|
return resp.R500(c, "", nil, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Filter by name if provided
|
||||||
|
var filtered []corev1.Pod
|
||||||
|
if name != "" {
|
||||||
|
for _, pod := range pods.Items {
|
||||||
|
if strings.Contains(pod.Name, name) {
|
||||||
|
filtered = append(filtered, pod)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
filtered = pods.Items
|
||||||
|
}
|
||||||
|
|
||||||
return resp.R200(c, map[string]any{
|
return resp.R200(c, map[string]any{
|
||||||
"items": pods.Items,
|
"items": filtered,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -162,7 +245,8 @@ func K8sPVList(ctx context.Context, db *gorm.DB, store store.Store) fiber.Handle
|
|||||||
func K8sPVCList(ctx context.Context, db *gorm.DB, store store.Store) fiber.Handler {
|
func K8sPVCList(ctx context.Context, db *gorm.DB, store store.Store) fiber.Handler {
|
||||||
return func(c fiber.Ctx) error {
|
return func(c fiber.Ctx) error {
|
||||||
namespace := c.Query("namespace", "")
|
namespace := c.Query("namespace", "")
|
||||||
|
name := c.Query("name", "")
|
||||||
|
|
||||||
clientset, err := getK8sClient(db)
|
clientset, err := getK8sClient(db)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return resp.R500(c, "", nil, err)
|
return resp.R500(c, "", nil, err)
|
||||||
@@ -173,8 +257,20 @@ func K8sPVCList(ctx context.Context, db *gorm.DB, store store.Store) fiber.Handl
|
|||||||
return resp.R500(c, "", nil, err)
|
return resp.R500(c, "", nil, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Filter by name if provided
|
||||||
|
var filtered []corev1.PersistentVolumeClaim
|
||||||
|
if name != "" {
|
||||||
|
for _, pvc := range pvcs.Items {
|
||||||
|
if strings.Contains(pvc.Name, name) {
|
||||||
|
filtered = append(filtered, pvc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
filtered = pvcs.Items
|
||||||
|
}
|
||||||
|
|
||||||
return resp.R200(c, map[string]any{
|
return resp.R200(c, map[string]any{
|
||||||
"items": pvcs.Items,
|
"items": filtered,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -182,7 +278,8 @@ func K8sPVCList(ctx context.Context, db *gorm.DB, store store.Store) fiber.Handl
|
|||||||
func K8sServiceList(ctx context.Context, db *gorm.DB, store store.Store) fiber.Handler {
|
func K8sServiceList(ctx context.Context, db *gorm.DB, store store.Store) fiber.Handler {
|
||||||
return func(c fiber.Ctx) error {
|
return func(c fiber.Ctx) error {
|
||||||
namespace := c.Query("namespace", "")
|
namespace := c.Query("namespace", "")
|
||||||
|
name := c.Query("name", "")
|
||||||
|
|
||||||
clientset, err := getK8sClient(db)
|
clientset, err := getK8sClient(db)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return resp.R500(c, "", nil, err)
|
return resp.R500(c, "", nil, err)
|
||||||
@@ -193,12 +290,130 @@ func K8sServiceList(ctx context.Context, db *gorm.DB, store store.Store) fiber.H
|
|||||||
return resp.R500(c, "", nil, err)
|
return resp.R500(c, "", nil, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Filter by name if provided
|
||||||
|
var filtered []corev1.Service
|
||||||
|
if name != "" {
|
||||||
|
for _, service := range services.Items {
|
||||||
|
if strings.Contains(service.Name, name) {
|
||||||
|
filtered = append(filtered, service)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
filtered = services.Items
|
||||||
|
}
|
||||||
|
|
||||||
return resp.R200(c, map[string]any{
|
return resp.R200(c, map[string]any{
|
||||||
"items": services.Items,
|
"items": filtered,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func K8sResourceGet(ctx context.Context, db *gorm.DB, store store.Store) fiber.Handler {
|
||||||
|
return func(c fiber.Ctx) error {
|
||||||
|
name := c.Query("name", "")
|
||||||
|
namespace := c.Query("namespace", "")
|
||||||
|
kind := c.Query("kind", "")
|
||||||
|
|
||||||
|
if name == "" || kind == "" {
|
||||||
|
return resp.R400(c, "", nil, fmt.Errorf("name and kind are required"))
|
||||||
|
}
|
||||||
|
|
||||||
|
clientset, err := getK8sClient(db)
|
||||||
|
if err != nil {
|
||||||
|
return resp.R500(c, "", nil, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var yamlData []byte
|
||||||
|
|
||||||
|
switch kind {
|
||||||
|
case "Deployment":
|
||||||
|
var deployment *appsv1.Deployment
|
||||||
|
if namespace != "" {
|
||||||
|
deployment, err = clientset.AppsV1().Deployments(namespace).Get(c.Context(), name, metav1.GetOptions{})
|
||||||
|
} else {
|
||||||
|
return resp.R400(c, "", nil, fmt.Errorf("namespace is required for Deployment"))
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return resp.R500(c, "", nil, fmt.Errorf("failed to get deployment: %w", err))
|
||||||
|
}
|
||||||
|
yamlData, err = yaml.Marshal(deployment)
|
||||||
|
case "StatefulSet":
|
||||||
|
var statefulset *appsv1.StatefulSet
|
||||||
|
if namespace != "" {
|
||||||
|
statefulset, err = clientset.AppsV1().StatefulSets(namespace).Get(c.Context(), name, metav1.GetOptions{})
|
||||||
|
} else {
|
||||||
|
return resp.R400(c, "", nil, fmt.Errorf("namespace is required for StatefulSet"))
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return resp.R500(c, "", nil, fmt.Errorf("failed to get statefulset: %w", err))
|
||||||
|
}
|
||||||
|
yamlData, err = yaml.Marshal(statefulset)
|
||||||
|
case "Service":
|
||||||
|
var service *corev1.Service
|
||||||
|
if namespace != "" {
|
||||||
|
service, err = clientset.CoreV1().Services(namespace).Get(c.Context(), name, metav1.GetOptions{})
|
||||||
|
} else {
|
||||||
|
return resp.R400(c, "", nil, fmt.Errorf("namespace is required for Service"))
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return resp.R500(c, "", nil, fmt.Errorf("failed to get service: %w", err))
|
||||||
|
}
|
||||||
|
yamlData, err = yaml.Marshal(service)
|
||||||
|
case "ConfigMap":
|
||||||
|
var configmap *corev1.ConfigMap
|
||||||
|
if namespace != "" {
|
||||||
|
configmap, err = clientset.CoreV1().ConfigMaps(namespace).Get(c.Context(), name, metav1.GetOptions{})
|
||||||
|
} else {
|
||||||
|
return resp.R400(c, "", nil, fmt.Errorf("namespace is required for ConfigMap"))
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return resp.R500(c, "", nil, fmt.Errorf("failed to get configmap: %w", err))
|
||||||
|
}
|
||||||
|
yamlData, err = yaml.Marshal(configmap)
|
||||||
|
default:
|
||||||
|
return resp.R400(c, "", nil, fmt.Errorf("unsupported resource kind: %s", kind))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return resp.R500(c, "", nil, fmt.Errorf("failed to marshal resource to yaml: %w", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp.R200(c, map[string]any{
|
||||||
|
"yaml": string(yamlData),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getResourceName(kind string) string {
|
||||||
|
kindToResource := map[string]string{
|
||||||
|
"Namespace": "namespaces",
|
||||||
|
"Deployment": "deployments",
|
||||||
|
"StatefulSet": "statefulsets",
|
||||||
|
"Service": "services",
|
||||||
|
"ConfigMap": "configmaps",
|
||||||
|
"Pod": "pods",
|
||||||
|
"PersistentVolume": "persistentvolumes",
|
||||||
|
"PersistentVolumeClaim": "persistentvolumeclaims",
|
||||||
|
"Secret": "secrets",
|
||||||
|
"Ingress": "ingresses",
|
||||||
|
"DaemonSet": "daemonsets",
|
||||||
|
"Job": "jobs",
|
||||||
|
"CronJob": "cronjobs",
|
||||||
|
"ReplicaSet": "replicasets",
|
||||||
|
"ServiceAccount": "serviceaccounts",
|
||||||
|
"Role": "roles",
|
||||||
|
"RoleBinding": "rolebindings",
|
||||||
|
"ClusterRole": "clusterroles",
|
||||||
|
"ClusterRoleBinding": "clusterrolebindings",
|
||||||
|
}
|
||||||
|
|
||||||
|
if resource, ok := kindToResource[kind]; ok {
|
||||||
|
return resource
|
||||||
|
}
|
||||||
|
|
||||||
|
return kind + "s"
|
||||||
|
}
|
||||||
|
|
||||||
func K8sResourceApply(ctx context.Context, db *gorm.DB, store store.Store) fiber.Handler {
|
func K8sResourceApply(ctx context.Context, db *gorm.DB, store store.Store) fiber.Handler {
|
||||||
return func(c fiber.Ctx) error {
|
return func(c fiber.Ctx) error {
|
||||||
var req struct {
|
var req struct {
|
||||||
@@ -213,7 +428,7 @@ func K8sResourceApply(ctx context.Context, db *gorm.DB, store store.Store) fiber
|
|||||||
return resp.R400(c, "", nil, fmt.Errorf("yaml content is empty"))
|
return resp.R400(c, "", nil, fmt.Errorf("yaml content is empty"))
|
||||||
}
|
}
|
||||||
|
|
||||||
var config model.ClusterConfig
|
var config k8sModel.ClusterConfig
|
||||||
if err := db.Where("key = ?", "kubeconfig").First(&config).Error; err != nil {
|
if err := db.Where("key = ?", "kubeconfig").First(&config).Error; err != nil {
|
||||||
return resp.R500(c, "", nil, fmt.Errorf("kubeconfig not found: %w", err))
|
return resp.R500(c, "", nil, fmt.Errorf("kubeconfig not found: %w", err))
|
||||||
}
|
}
|
||||||
@@ -227,6 +442,10 @@ func K8sResourceApply(ctx context.Context, db *gorm.DB, store store.Store) fiber
|
|||||||
return resp.R500(c, "", nil, fmt.Errorf("failed to parse kubeconfig: %w", err))
|
return resp.R500(c, "", nil, fmt.Errorf("failed to parse kubeconfig: %w", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Force HTTP/1.1 to avoid stream closing issues
|
||||||
|
clientConfig.TLSClientConfig.NextProtos = []string{"http/1.1"}
|
||||||
|
clientConfig.Timeout = 0
|
||||||
|
|
||||||
dynamicClient, err := dynamic.NewForConfig(clientConfig)
|
dynamicClient, err := dynamic.NewForConfig(clientConfig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return resp.R500(c, "", nil, fmt.Errorf("failed to create dynamic client: %w", err))
|
return resp.R500(c, "", nil, fmt.Errorf("failed to create dynamic client: %w", err))
|
||||||
@@ -270,32 +489,470 @@ func K8sResourceApply(ctx context.Context, db *gorm.DB, store store.Store) fiber
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func getResourceName(kind string) string {
|
func K8sResourceFetch(ctx context.Context, db *gorm.DB, store store.Store) fiber.Handler {
|
||||||
kindToResource := map[string]string{
|
return func(c fiber.Ctx) error {
|
||||||
"Namespace": "namespaces",
|
name := c.Query("name", "")
|
||||||
"Deployment": "deployments",
|
namespace := c.Query("namespace", "")
|
||||||
"StatefulSet": "statefulsets",
|
kind := c.Query("kind", "")
|
||||||
"Service": "services",
|
|
||||||
"ConfigMap": "configmaps",
|
|
||||||
"Pod": "pods",
|
|
||||||
"PersistentVolume": "persistentvolumes",
|
|
||||||
"PersistentVolumeClaim": "persistentvolumeclaims",
|
|
||||||
"Secret": "secrets",
|
|
||||||
"Ingress": "ingresses",
|
|
||||||
"DaemonSet": "daemonsets",
|
|
||||||
"Job": "jobs",
|
|
||||||
"CronJob": "cronjobs",
|
|
||||||
"ReplicaSet": "replicasets",
|
|
||||||
"ServiceAccount": "serviceaccounts",
|
|
||||||
"Role": "roles",
|
|
||||||
"RoleBinding": "rolebindings",
|
|
||||||
"ClusterRole": "clusterroles",
|
|
||||||
"ClusterRoleBinding": "clusterrolebindings",
|
|
||||||
}
|
|
||||||
|
|
||||||
if resource, ok := kindToResource[kind]; ok {
|
if name == "" || kind == "" {
|
||||||
return resource
|
return resp.R400(c, "", nil, fmt.Errorf("name and kind are required"))
|
||||||
}
|
}
|
||||||
|
|
||||||
return kind + "s"
|
clientset, err := getK8sClient(db)
|
||||||
|
if err != nil {
|
||||||
|
return resp.R500(c, "", nil, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var yamlData []byte
|
||||||
|
|
||||||
|
switch kind {
|
||||||
|
case "Deployment":
|
||||||
|
var deployment *appsv1.Deployment
|
||||||
|
if namespace != "" {
|
||||||
|
deployment, err = clientset.AppsV1().Deployments(namespace).Get(c.Context(), name, metav1.GetOptions{})
|
||||||
|
} else {
|
||||||
|
return resp.R400(c, "", nil, fmt.Errorf("namespace is required for Deployment"))
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return resp.R500(c, "", nil, fmt.Errorf("failed to get deployment: %w", err))
|
||||||
|
}
|
||||||
|
// Ensure Kind and APIVersion are set
|
||||||
|
if deployment.Kind == "" {
|
||||||
|
deployment.Kind = "Deployment"
|
||||||
|
}
|
||||||
|
if deployment.APIVersion == "" {
|
||||||
|
deployment.APIVersion = "apps/v1"
|
||||||
|
}
|
||||||
|
// Clean up managed fields and other metadata that cause conflicts
|
||||||
|
deployment.ManagedFields = nil
|
||||||
|
deployment.ResourceVersion = ""
|
||||||
|
deployment.UID = ""
|
||||||
|
deployment.CreationTimestamp = metav1.Time{}
|
||||||
|
deployment.SelfLink = ""
|
||||||
|
deployment.Generation = 0
|
||||||
|
yamlData, err = yaml.Marshal(deployment)
|
||||||
|
case "StatefulSet":
|
||||||
|
var statefulset *appsv1.StatefulSet
|
||||||
|
if namespace != "" {
|
||||||
|
statefulset, err = clientset.AppsV1().StatefulSets(namespace).Get(c.Context(), name, metav1.GetOptions{})
|
||||||
|
} else {
|
||||||
|
return resp.R400(c, "", nil, fmt.Errorf("namespace is required for StatefulSet"))
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return resp.R500(c, "", nil, fmt.Errorf("failed to get statefulset: %w", err))
|
||||||
|
}
|
||||||
|
// Ensure Kind and APIVersion are set
|
||||||
|
if statefulset.Kind == "" {
|
||||||
|
statefulset.Kind = "StatefulSet"
|
||||||
|
}
|
||||||
|
if statefulset.APIVersion == "" {
|
||||||
|
statefulset.APIVersion = "apps/v1"
|
||||||
|
}
|
||||||
|
// Clean up managed fields and other metadata that cause conflicts
|
||||||
|
statefulset.ManagedFields = nil
|
||||||
|
statefulset.ResourceVersion = ""
|
||||||
|
statefulset.UID = ""
|
||||||
|
statefulset.CreationTimestamp = metav1.Time{}
|
||||||
|
statefulset.SelfLink = ""
|
||||||
|
statefulset.Generation = 0
|
||||||
|
yamlData, err = yaml.Marshal(statefulset)
|
||||||
|
case "Service":
|
||||||
|
var service *corev1.Service
|
||||||
|
if namespace != "" {
|
||||||
|
service, err = clientset.CoreV1().Services(namespace).Get(c.Context(), name, metav1.GetOptions{})
|
||||||
|
} else {
|
||||||
|
return resp.R400(c, "", nil, fmt.Errorf("namespace is required for Service"))
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return resp.R500(c, "", nil, fmt.Errorf("failed to get service: %w", err))
|
||||||
|
}
|
||||||
|
// Ensure Kind and APIVersion are set
|
||||||
|
if service.Kind == "" {
|
||||||
|
service.Kind = "Service"
|
||||||
|
}
|
||||||
|
if service.APIVersion == "" {
|
||||||
|
service.APIVersion = "v1"
|
||||||
|
}
|
||||||
|
// Clean up managed fields and other metadata that cause conflicts
|
||||||
|
service.ManagedFields = nil
|
||||||
|
service.ResourceVersion = ""
|
||||||
|
service.UID = ""
|
||||||
|
service.CreationTimestamp = metav1.Time{}
|
||||||
|
service.SelfLink = ""
|
||||||
|
service.Generation = 0
|
||||||
|
// Don't clean spec fields as they contain important information like NodePort
|
||||||
|
yamlData, err = yaml.Marshal(service)
|
||||||
|
case "ConfigMap":
|
||||||
|
var configmap *corev1.ConfigMap
|
||||||
|
if namespace != "" {
|
||||||
|
configmap, err = clientset.CoreV1().ConfigMaps(namespace).Get(c.Context(), name, metav1.GetOptions{})
|
||||||
|
} else {
|
||||||
|
return resp.R400(c, "", nil, fmt.Errorf("namespace is required for ConfigMap"))
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return resp.R500(c, "", nil, fmt.Errorf("failed to get configmap: %w", err))
|
||||||
|
}
|
||||||
|
// Ensure Kind and APIVersion are set
|
||||||
|
if configmap.Kind == "" {
|
||||||
|
configmap.Kind = "ConfigMap"
|
||||||
|
}
|
||||||
|
if configmap.APIVersion == "" {
|
||||||
|
configmap.APIVersion = "v1"
|
||||||
|
}
|
||||||
|
// Clean up managed fields and other metadata that cause conflicts
|
||||||
|
configmap.ManagedFields = nil
|
||||||
|
configmap.ResourceVersion = ""
|
||||||
|
configmap.UID = ""
|
||||||
|
configmap.CreationTimestamp = metav1.Time{}
|
||||||
|
configmap.SelfLink = ""
|
||||||
|
configmap.Generation = 0
|
||||||
|
yamlData, err = yaml.Marshal(configmap)
|
||||||
|
default:
|
||||||
|
return resp.R400(c, "", nil, fmt.Errorf("unsupported resource kind: %s", kind))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return resp.R500(c, "", nil, fmt.Errorf("failed to marshal resource to yaml: %w", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp.R200(c, map[string]any{
|
||||||
|
"yaml": string(yamlData),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func K8sResourceUpdate(ctx context.Context, db *gorm.DB, store store.Store) fiber.Handler {
|
||||||
|
return func(c fiber.Ctx) error {
|
||||||
|
var req struct {
|
||||||
|
Yaml string `json:"yaml"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(c.Body(), &req); err != nil {
|
||||||
|
return resp.R400(c, "", nil, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Yaml == "" {
|
||||||
|
return resp.R400(c, "", nil, fmt.Errorf("yaml content is empty"))
|
||||||
|
}
|
||||||
|
|
||||||
|
var config k8sModel.ClusterConfig
|
||||||
|
if err := db.Where("key = ?", "kubeconfig").First(&config).Error; err != nil {
|
||||||
|
return resp.R500(c, "", nil, fmt.Errorf("kubeconfig not found: %w", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.Value == "" {
|
||||||
|
return resp.R500(c, "", nil, fmt.Errorf("kubeconfig is empty"))
|
||||||
|
}
|
||||||
|
|
||||||
|
clientConfig, err := clientcmd.RESTConfigFromKubeConfig([]byte(config.Value))
|
||||||
|
if err != nil {
|
||||||
|
return resp.R500(c, "", nil, fmt.Errorf("failed to parse kubeconfig: %w", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Force HTTP/1.1 to avoid stream closing issues
|
||||||
|
clientConfig.TLSClientConfig.NextProtos = []string{"http/1.1"}
|
||||||
|
clientConfig.Timeout = 0
|
||||||
|
|
||||||
|
dynamicClient, err := dynamic.NewForConfig(clientConfig)
|
||||||
|
if err != nil {
|
||||||
|
return resp.R500(c, "", nil, fmt.Errorf("failed to create dynamic client: %w", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
var obj unstructured.Unstructured
|
||||||
|
if err := yaml.Unmarshal([]byte(req.Yaml), &obj); err != nil {
|
||||||
|
return resp.R400(c, "", nil, fmt.Errorf("failed to parse yaml: %w", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
gvk := obj.GroupVersionKind()
|
||||||
|
namespace := obj.GetNamespace()
|
||||||
|
name := obj.GetName()
|
||||||
|
|
||||||
|
if name == "" {
|
||||||
|
return resp.R400(c, "", nil, fmt.Errorf("resource name is required"))
|
||||||
|
}
|
||||||
|
|
||||||
|
gvr := schema.GroupVersionResource{
|
||||||
|
Group: gvk.Group,
|
||||||
|
Version: gvk.Version,
|
||||||
|
Resource: getResourceName(gvk.Kind),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the resource
|
||||||
|
var result *unstructured.Unstructured
|
||||||
|
if namespace != "" {
|
||||||
|
result, err = dynamicClient.Resource(gvr).Namespace(namespace).Update(c.Context(), &obj, metav1.UpdateOptions{})
|
||||||
|
} else {
|
||||||
|
result, err = dynamicClient.Resource(gvr).Update(c.Context(), &obj, metav1.UpdateOptions{})
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return resp.R500(c, "", nil, fmt.Errorf("failed to update resource: %w", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp.R200(c, map[string]any{
|
||||||
|
"name": result.GetName(),
|
||||||
|
"namespace": result.GetNamespace(),
|
||||||
|
"kind": result.GetKind(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func K8sPodLogs(ctx context.Context, db *gorm.DB, store store.Store) fiber.Handler {
|
||||||
|
return func(c fiber.Ctx) error {
|
||||||
|
podName := c.Query("name", "")
|
||||||
|
namespace := c.Query("namespace", "")
|
||||||
|
tailLines := int64(1000)
|
||||||
|
follow := c.Query("follow", "") == "true"
|
||||||
|
|
||||||
|
if podName == "" || namespace == "" {
|
||||||
|
return resp.R400(c, "", nil, fmt.Errorf("name and namespace are required"))
|
||||||
|
}
|
||||||
|
|
||||||
|
restConfig, err := getK8sConfig(db)
|
||||||
|
if err != nil {
|
||||||
|
return resp.R500(c, "", nil, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
clientset, err := kubernetes.NewForConfig(restConfig)
|
||||||
|
if err != nil {
|
||||||
|
return resp.R500(c, "", nil, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
podLogOpts := &corev1.PodLogOptions{
|
||||||
|
TailLines: &tailLines,
|
||||||
|
Follow: follow,
|
||||||
|
}
|
||||||
|
|
||||||
|
req := clientset.CoreV1().Pods(namespace).GetLogs(podName, podLogOpts)
|
||||||
|
|
||||||
|
logCtx, cancel := context.WithCancel(c.Context())
|
||||||
|
|
||||||
|
stream, err := req.Stream(logCtx)
|
||||||
|
if err != nil {
|
||||||
|
cancel()
|
||||||
|
return resp.R500(c, "", nil, fmt.Errorf("failed to get pod logs: %w", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the existing SSE manager from resp package
|
||||||
|
manager := resp.SSE(c, "pod-logs")
|
||||||
|
|
||||||
|
// Start streaming logs in a goroutine
|
||||||
|
go func() {
|
||||||
|
defer stream.Close()
|
||||||
|
defer manager.Close()
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
reader := bufio.NewReader(stream)
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-logCtx.Done():
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
line, err := reader.ReadString('\n')
|
||||||
|
if err != nil {
|
||||||
|
if err == io.EOF {
|
||||||
|
manager.JSON(map[string]any{"type": "EOF"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
manager.JSON(map[string]any{"type": "error", "data": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
manager.JSON(map[string]any{"data": line, "type": "log"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return c.SendStreamWriter(manager.Writer())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func K8sPodDelete(ctx context.Context, db *gorm.DB, store store.Store) fiber.Handler {
|
||||||
|
return func(c fiber.Ctx) error {
|
||||||
|
var req struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Namespace string `json:"namespace"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(c.Body(), &req); err != nil {
|
||||||
|
return resp.R400(c, "", nil, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Name == "" || req.Namespace == "" {
|
||||||
|
return resp.R400(c, "", nil, fmt.Errorf("name and namespace are required"))
|
||||||
|
}
|
||||||
|
|
||||||
|
clientset, err := getK8sClient(db)
|
||||||
|
if err != nil {
|
||||||
|
return resp.R500(c, "", nil, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = clientset.CoreV1().Pods(req.Namespace).Delete(c.Context(), req.Name, metav1.DeleteOptions{})
|
||||||
|
if err != nil {
|
||||||
|
return resp.R500(c, "", nil, fmt.Errorf("failed to delete pod: %w", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp.R200(c, map[string]any{
|
||||||
|
"name": req.Name,
|
||||||
|
"namespace": req.Namespace,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func K8sDeploymentDelete(ctx context.Context, db *gorm.DB, store store.Store) fiber.Handler {
|
||||||
|
return func(c fiber.Ctx) error {
|
||||||
|
var req struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Namespace string `json:"namespace"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(c.Body(), &req); err != nil {
|
||||||
|
return resp.R400(c, "", nil, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Name == "" || req.Namespace == "" {
|
||||||
|
return resp.R400(c, "", nil, fmt.Errorf("name and namespace are required"))
|
||||||
|
}
|
||||||
|
|
||||||
|
clientset, err := getK8sClient(db)
|
||||||
|
if err != nil {
|
||||||
|
return resp.R500(c, "", nil, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = clientset.AppsV1().Deployments(req.Namespace).Delete(c.Context(), req.Name, metav1.DeleteOptions{})
|
||||||
|
if err != nil {
|
||||||
|
return resp.R500(c, "", nil, fmt.Errorf("failed to delete deployment: %w", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp.R200(c, map[string]any{
|
||||||
|
"name": req.Name,
|
||||||
|
"namespace": req.Namespace,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func K8sStatefulSetDelete(ctx context.Context, db *gorm.DB, store store.Store) fiber.Handler {
|
||||||
|
return func(c fiber.Ctx) error {
|
||||||
|
var req struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Namespace string `json:"namespace"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(c.Body(), &req); err != nil {
|
||||||
|
return resp.R400(c, "", nil, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Name == "" || req.Namespace == "" {
|
||||||
|
return resp.R400(c, "", nil, fmt.Errorf("name and namespace are required"))
|
||||||
|
}
|
||||||
|
|
||||||
|
clientset, err := getK8sClient(db)
|
||||||
|
if err != nil {
|
||||||
|
return resp.R500(c, "", nil, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = clientset.AppsV1().StatefulSets(req.Namespace).Delete(c.Context(), req.Name, metav1.DeleteOptions{})
|
||||||
|
if err != nil {
|
||||||
|
return resp.R500(c, "", nil, fmt.Errorf("failed to delete statefulset: %w", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp.R200(c, map[string]any{
|
||||||
|
"name": req.Name,
|
||||||
|
"namespace": req.Namespace,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func K8sServiceDelete(ctx context.Context, db *gorm.DB, store store.Store) fiber.Handler {
|
||||||
|
return func(c fiber.Ctx) error {
|
||||||
|
var req struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Namespace string `json:"namespace"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(c.Body(), &req); err != nil {
|
||||||
|
return resp.R400(c, "", nil, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Name == "" || req.Namespace == "" {
|
||||||
|
return resp.R400(c, "", nil, fmt.Errorf("name and namespace are required"))
|
||||||
|
}
|
||||||
|
|
||||||
|
clientset, err := getK8sClient(db)
|
||||||
|
if err != nil {
|
||||||
|
return resp.R500(c, "", nil, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = clientset.CoreV1().Services(req.Namespace).Delete(c.Context(), req.Name, metav1.DeleteOptions{})
|
||||||
|
if err != nil {
|
||||||
|
return resp.R500(c, "", nil, fmt.Errorf("failed to delete service: %w", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp.R200(c, map[string]any{
|
||||||
|
"name": req.Name,
|
||||||
|
"namespace": req.Namespace,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func K8sConfigMapDelete(ctx context.Context, db *gorm.DB, store store.Store) fiber.Handler {
|
||||||
|
return func(c fiber.Ctx) error {
|
||||||
|
var req struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Namespace string `json:"namespace"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(c.Body(), &req); err != nil {
|
||||||
|
return resp.R400(c, "", nil, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Name == "" || req.Namespace == "" {
|
||||||
|
return resp.R400(c, "", nil, fmt.Errorf("name and namespace are required"))
|
||||||
|
}
|
||||||
|
|
||||||
|
clientset, err := getK8sClient(db)
|
||||||
|
if err != nil {
|
||||||
|
return resp.R500(c, "", nil, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = clientset.CoreV1().ConfigMaps(req.Namespace).Delete(c.Context(), req.Name, metav1.DeleteOptions{})
|
||||||
|
if err != nil {
|
||||||
|
return resp.R500(c, "", nil, fmt.Errorf("failed to delete configmap: %w", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp.R200(c, map[string]any{
|
||||||
|
"name": req.Name,
|
||||||
|
"namespace": req.Namespace,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func K8sNamespaceDelete(ctx context.Context, db *gorm.DB, store store.Store) fiber.Handler {
|
||||||
|
return func(c fiber.Ctx) error {
|
||||||
|
var req struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(c.Body(), &req); err != nil {
|
||||||
|
return resp.R400(c, "", nil, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Name == "" {
|
||||||
|
return resp.R400(c, "", nil, fmt.Errorf("name is required"))
|
||||||
|
}
|
||||||
|
|
||||||
|
clientset, err := getK8sClient(db)
|
||||||
|
if err != nil {
|
||||||
|
return resp.R500(c, "", nil, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = clientset.CoreV1().Namespaces().Delete(c.Context(), req.Name, metav1.DeleteOptions{})
|
||||||
|
if err != nil {
|
||||||
|
return resp.R500(c, "", nil, fmt.Errorf("failed to delete namespace: %w", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp.R200(c, map[string]any{
|
||||||
|
"name": req.Name,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,14 +4,14 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"log"
|
"log"
|
||||||
|
|
||||||
"gitea.loveuer.com/loveuer/cluster/internal/model"
|
k8s "gitea.loveuer.com/loveuer/cluster/pkg/model/k8s"
|
||||||
"gitea.loveuer.com/loveuer/cluster/pkg/store"
|
"gitea.loveuer.com/loveuer/cluster/pkg/store"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
func Init(ctx context.Context, db *gorm.DB, store store.Store) error {
|
func Init(ctx context.Context, db *gorm.DB, store store.Store) error {
|
||||||
if err := db.AutoMigrate(
|
if err := db.AutoMigrate(
|
||||||
&model.ClusterConfig{},
|
&k8s.ClusterConfig{},
|
||||||
); err != nil {
|
); err != nil {
|
||||||
log.Fatalf("failed to migrate k8s database: %v", err)
|
log.Fatalf("failed to migrate k8s database: %v", err)
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.loveuer.com/loveuer/cluster/internal/model"
|
"gitea.loveuer.com/loveuer/cluster/pkg/model/registry"
|
||||||
"gitea.loveuer.com/loveuer/cluster/pkg/resp"
|
"gitea.loveuer.com/loveuer/cluster/pkg/resp"
|
||||||
"gitea.loveuer.com/loveuer/cluster/pkg/store"
|
"gitea.loveuer.com/loveuer/cluster/pkg/store"
|
||||||
"github.com/gofiber/fiber/v3"
|
"github.com/gofiber/fiber/v3"
|
||||||
@@ -51,7 +51,7 @@ func HandleBlobs(c fiber.Ctx, db *gorm.DB, store store.Store) error {
|
|||||||
repo := strings.Join(parts[:blobsIndex], "/")
|
repo := strings.Join(parts[:blobsIndex], "/")
|
||||||
|
|
||||||
// Strip registry_address prefix from repo if present
|
// Strip registry_address prefix from repo if present
|
||||||
var registryConfig model.RegistryConfig
|
var registryConfig registry.RegistryConfig
|
||||||
registryAddress := ""
|
registryAddress := ""
|
||||||
if err := db.Where("key = ?", "registry_address").First(®istryConfig).Error; err == nil {
|
if err := db.Where("key = ?", "registry_address").First(®istryConfig).Error; err == nil {
|
||||||
registryAddress = registryConfig.Value
|
registryAddress = registryConfig.Value
|
||||||
@@ -120,7 +120,7 @@ func handleBlobUploadStart(c fiber.Ctx, db *gorm.DB, store store.Store, repo str
|
|||||||
uuid := hex.EncodeToString(uuidBytes)
|
uuid := hex.EncodeToString(uuidBytes)
|
||||||
|
|
||||||
// ??????
|
// ??????
|
||||||
upload := &model.BlobUpload{
|
upload := ®istry.BlobUpload{
|
||||||
UUID: uuid,
|
UUID: uuid,
|
||||||
Repository: repo,
|
Repository: repo,
|
||||||
Path: uuid, // ?? UUID ??????
|
Path: uuid, // ?? UUID ??????
|
||||||
@@ -150,7 +150,7 @@ func handleBlobUploadStart(c fiber.Ctx, db *gorm.DB, store store.Store, repo str
|
|||||||
// handleBlobUploadChunk ?? blob ???
|
// handleBlobUploadChunk ?? blob ???
|
||||||
func handleBlobUploadChunk(c fiber.Ctx, db *gorm.DB, store store.Store, repo string, uuid string) error {
|
func handleBlobUploadChunk(c fiber.Ctx, db *gorm.DB, store store.Store, repo string, uuid string) error {
|
||||||
// ??????
|
// ??????
|
||||||
var upload model.BlobUpload
|
var upload registry.BlobUpload
|
||||||
if err := db.Where("uuid = ? AND repository = ?", uuid, repo).First(&upload).Error; err != nil {
|
if err := db.Where("uuid = ? AND repository = ?", uuid, repo).First(&upload).Error; err != nil {
|
||||||
if err == gorm.ErrRecordNotFound {
|
if err == gorm.ErrRecordNotFound {
|
||||||
return resp.R404(c, "UPLOAD_NOT_FOUND", nil, "upload session not found")
|
return resp.R404(c, "UPLOAD_NOT_FOUND", nil, "upload session not found")
|
||||||
@@ -187,7 +187,7 @@ func handleBlobUploadChunk(c fiber.Ctx, db *gorm.DB, store store.Store, repo str
|
|||||||
// handleBlobUploadComplete ?? blob ??
|
// handleBlobUploadComplete ?? blob ??
|
||||||
func handleBlobUploadComplete(c fiber.Ctx, db *gorm.DB, store store.Store, repo string, uuid string, digest string) error {
|
func handleBlobUploadComplete(c fiber.Ctx, db *gorm.DB, store store.Store, repo string, uuid string, digest string) error {
|
||||||
// ??????
|
// ??????
|
||||||
var upload model.BlobUpload
|
var upload registry.BlobUpload
|
||||||
if err := db.Where("uuid = ? AND repository = ?", uuid, repo).First(&upload).Error; err != nil {
|
if err := db.Where("uuid = ? AND repository = ?", uuid, repo).First(&upload).Error; err != nil {
|
||||||
if err == gorm.ErrRecordNotFound {
|
if err == gorm.ErrRecordNotFound {
|
||||||
return resp.R404(c, "UPLOAD_NOT_FOUND", nil, "upload session not found")
|
return resp.R404(c, "UPLOAD_NOT_FOUND", nil, "upload session not found")
|
||||||
@@ -215,10 +215,10 @@ func handleBlobUploadComplete(c fiber.Ctx, db *gorm.DB, store store.Store, repo
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ????? blob ??
|
// ????? blob ??
|
||||||
var blob model.Blob
|
var blob registry.Blob
|
||||||
if err := db.Where("digest = ?", digest).First(&blob).Error; err != nil {
|
if err := db.Where("digest = ?", digest).First(&blob).Error; err != nil {
|
||||||
if err == gorm.ErrRecordNotFound {
|
if err == gorm.ErrRecordNotFound {
|
||||||
blob = model.Blob{
|
blob = registry.Blob{
|
||||||
Digest: digest,
|
Digest: digest,
|
||||||
Size: size,
|
Size: size,
|
||||||
Repository: repo,
|
Repository: repo,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.loveuer.com/loveuer/cluster/internal/model"
|
"gitea.loveuer.com/loveuer/cluster/pkg/model/registry"
|
||||||
"gitea.loveuer.com/loveuer/cluster/pkg/resp"
|
"gitea.loveuer.com/loveuer/cluster/pkg/resp"
|
||||||
"gitea.loveuer.com/loveuer/cluster/pkg/store"
|
"gitea.loveuer.com/loveuer/cluster/pkg/store"
|
||||||
"github.com/gofiber/fiber/v3"
|
"github.com/gofiber/fiber/v3"
|
||||||
@@ -31,7 +31,7 @@ func HandleCatalog(c fiber.Ctx, db *gorm.DB, store store.Store) error {
|
|||||||
last := c.Query("last")
|
last := c.Query("last")
|
||||||
|
|
||||||
// ????
|
// ????
|
||||||
var repos []model.Repository
|
var repos []registry.Repository
|
||||||
query := db.Order("name ASC").Limit(n + 1)
|
query := db.Order("name ASC").Limit(n + 1)
|
||||||
|
|
||||||
if last != "" {
|
if last != "" {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
|
||||||
"gitea.loveuer.com/loveuer/cluster/internal/model"
|
"gitea.loveuer.com/loveuer/cluster/pkg/model/registry"
|
||||||
"gitea.loveuer.com/loveuer/cluster/pkg/resp"
|
"gitea.loveuer.com/loveuer/cluster/pkg/resp"
|
||||||
"gitea.loveuer.com/loveuer/cluster/pkg/store"
|
"gitea.loveuer.com/loveuer/cluster/pkg/store"
|
||||||
"github.com/gofiber/fiber/v3"
|
"github.com/gofiber/fiber/v3"
|
||||||
@@ -14,7 +14,7 @@ import (
|
|||||||
// RegistryConfigGet returns the registry configuration
|
// RegistryConfigGet returns the registry configuration
|
||||||
func RegistryConfigGet(ctx context.Context, db *gorm.DB, store store.Store) fiber.Handler {
|
func RegistryConfigGet(ctx context.Context, db *gorm.DB, store store.Store) fiber.Handler {
|
||||||
return func(c fiber.Ctx) error {
|
return func(c fiber.Ctx) error {
|
||||||
var configs []model.RegistryConfig
|
var configs []registry.RegistryConfig
|
||||||
if err := db.Find(&configs).Error; err != nil {
|
if err := db.Find(&configs).Error; err != nil {
|
||||||
return resp.R500(c, "", nil, err)
|
return resp.R500(c, "", nil, err)
|
||||||
}
|
}
|
||||||
@@ -53,11 +53,11 @@ func RegistryConfigSet(ctx context.Context, db *gorm.DB, store store.Store) fibe
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Find or create config
|
// Find or create config
|
||||||
var config model.RegistryConfig
|
var config registry.RegistryConfig
|
||||||
err := db.Where("key = ?", req.Key).First(&config).Error
|
err := db.Where("key = ?", req.Key).First(&config).Error
|
||||||
if err == gorm.ErrRecordNotFound {
|
if err == gorm.ErrRecordNotFound {
|
||||||
// Create new config
|
// Create new config
|
||||||
config = model.RegistryConfig{
|
config = registry.RegistryConfig{
|
||||||
Key: req.Key,
|
Key: req.Key,
|
||||||
Value: req.Value,
|
Value: req.Value,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.loveuer.com/loveuer/cluster/internal/model"
|
"gitea.loveuer.com/loveuer/cluster/pkg/model/registry"
|
||||||
"gitea.loveuer.com/loveuer/cluster/pkg/resp"
|
"gitea.loveuer.com/loveuer/cluster/pkg/resp"
|
||||||
"gitea.loveuer.com/loveuer/cluster/pkg/store"
|
"gitea.loveuer.com/loveuer/cluster/pkg/store"
|
||||||
"github.com/gofiber/fiber/v3"
|
"github.com/gofiber/fiber/v3"
|
||||||
@@ -39,7 +39,7 @@ func RegistryImageDownload(ctx context.Context, db *gorm.DB, store store.Store)
|
|||||||
log.Printf("[Download] Start downloading: %s", fullImageName)
|
log.Printf("[Download] Start downloading: %s", fullImageName)
|
||||||
|
|
||||||
// Get current registry_address to strip it from the request
|
// Get current registry_address to strip it from the request
|
||||||
var registryConfig model.RegistryConfig
|
var registryConfig registry.RegistryConfig
|
||||||
registryAddress := ""
|
registryAddress := ""
|
||||||
if err := db.Where("key = ?", "registry_address").First(®istryConfig).Error; err == nil {
|
if err := db.Where("key = ?", "registry_address").First(®istryConfig).Error; err == nil {
|
||||||
registryAddress = registryConfig.Value
|
registryAddress = registryConfig.Value
|
||||||
@@ -71,7 +71,7 @@ func RegistryImageDownload(ctx context.Context, db *gorm.DB, store store.Store)
|
|||||||
|
|
||||||
// Find the repository
|
// Find the repository
|
||||||
t1 := time.Now()
|
t1 := time.Now()
|
||||||
var repositoryModel model.Repository
|
var repositoryModel registry.Repository
|
||||||
if err := db.Where("name = ?", repository).First(&repositoryModel).Error; err != nil {
|
if err := db.Where("name = ?", repository).First(&repositoryModel).Error; err != nil {
|
||||||
if err == gorm.ErrRecordNotFound {
|
if err == gorm.ErrRecordNotFound {
|
||||||
return resp.R404(c, "IMAGE_NOT_FOUND", nil, fmt.Sprintf("image %s not found", repository))
|
return resp.R404(c, "IMAGE_NOT_FOUND", nil, fmt.Sprintf("image %s not found", repository))
|
||||||
@@ -82,7 +82,7 @@ func RegistryImageDownload(ctx context.Context, db *gorm.DB, store store.Store)
|
|||||||
|
|
||||||
// Find the tag record
|
// Find the tag record
|
||||||
t2 := time.Now()
|
t2 := time.Now()
|
||||||
var tagRecord model.Tag
|
var tagRecord registry.Tag
|
||||||
if err := db.Where("repository = ? AND tag = ?", repository, tag).First(&tagRecord).Error; err != nil {
|
if err := db.Where("repository = ? AND tag = ?", repository, tag).First(&tagRecord).Error; err != nil {
|
||||||
if err == gorm.ErrRecordNotFound {
|
if err == gorm.ErrRecordNotFound {
|
||||||
// Try to get the first available tag
|
// Try to get the first available tag
|
||||||
@@ -102,7 +102,7 @@ func RegistryImageDownload(ctx context.Context, db *gorm.DB, store store.Store)
|
|||||||
|
|
||||||
// Get the manifest
|
// Get the manifest
|
||||||
t3 := time.Now()
|
t3 := time.Now()
|
||||||
var manifest model.Manifest
|
var manifest registry.Manifest
|
||||||
if err := db.Where("digest = ?", tagRecord.Digest).First(&manifest).Error; err != nil {
|
if err := db.Where("digest = ?", tagRecord.Digest).First(&manifest).Error; err != nil {
|
||||||
if err == gorm.ErrRecordNotFound {
|
if err == gorm.ErrRecordNotFound {
|
||||||
return resp.R404(c, "MANIFEST_NOT_FOUND", nil, "manifest not found")
|
return resp.R404(c, "MANIFEST_NOT_FOUND", nil, "manifest not found")
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.loveuer.com/loveuer/cluster/internal/model"
|
"gitea.loveuer.com/loveuer/cluster/pkg/model/registry"
|
||||||
"gitea.loveuer.com/loveuer/cluster/pkg/resp"
|
"gitea.loveuer.com/loveuer/cluster/pkg/resp"
|
||||||
"gitea.loveuer.com/loveuer/cluster/pkg/store"
|
"gitea.loveuer.com/loveuer/cluster/pkg/store"
|
||||||
"github.com/gofiber/fiber/v3"
|
"github.com/gofiber/fiber/v3"
|
||||||
@@ -161,7 +161,7 @@ func pullImage(ctx context.Context, db *gorm.DB, store store.Store, repo string,
|
|||||||
log.Printf("[PullImage] Got manifest with %d layers", len(manifest.Layers))
|
log.Printf("[PullImage] Got manifest with %d layers", len(manifest.Layers))
|
||||||
|
|
||||||
// Create repository
|
// Create repository
|
||||||
if err := db.FirstOrCreate(&model.Repository{}, model.Repository{Name: repo}).Error; err != nil {
|
if err := db.FirstOrCreate(®istry.Repository{}, registry.Repository{Name: repo}).Error; err != nil {
|
||||||
return nil, fmt.Errorf("failed to create repository: %w", err)
|
return nil, fmt.Errorf("failed to create repository: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,7 +195,7 @@ func pullImage(ctx context.Context, db *gorm.DB, store store.Store, repo string,
|
|||||||
return nil, fmt.Errorf("failed to write config blob: %w", err)
|
return nil, fmt.Errorf("failed to write config blob: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := db.Create(&model.Blob{
|
if err := db.Create(®istry.Blob{
|
||||||
Digest: digest,
|
Digest: digest,
|
||||||
Size: manifest.Config.Size,
|
Size: manifest.Config.Size,
|
||||||
MediaType: "application/vnd.docker.container.image.v1+json",
|
MediaType: "application/vnd.docker.container.image.v1+json",
|
||||||
@@ -229,7 +229,7 @@ func pullImage(ctx context.Context, db *gorm.DB, store store.Store, repo string,
|
|||||||
return nil, fmt.Errorf("failed to write layer blob %d: %w", idx, err)
|
return nil, fmt.Errorf("failed to write layer blob %d: %w", idx, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := db.Create(&model.Blob{
|
if err := db.Create(®istry.Blob{
|
||||||
Digest: layerDigest,
|
Digest: layerDigest,
|
||||||
Size: layerDesc.Size,
|
Size: layerDesc.Size,
|
||||||
MediaType: string(layerDesc.MediaType),
|
MediaType: string(layerDesc.MediaType),
|
||||||
@@ -275,7 +275,7 @@ func pullImage(ctx context.Context, db *gorm.DB, store store.Store, repo string,
|
|||||||
return nil, fmt.Errorf("failed to write manifest: %w", err)
|
return nil, fmt.Errorf("failed to write manifest: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := db.Create(&model.Manifest{
|
if err := db.Create(®istry.Manifest{
|
||||||
Repository: repo,
|
Repository: repo,
|
||||||
Tag: tag,
|
Tag: tag,
|
||||||
Digest: manifestDigest,
|
Digest: manifestDigest,
|
||||||
@@ -286,7 +286,7 @@ func pullImage(ctx context.Context, db *gorm.DB, store store.Store, repo string,
|
|||||||
return nil, fmt.Errorf("failed to save manifest: %w", err)
|
return nil, fmt.Errorf("failed to save manifest: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := db.Create(&model.Tag{
|
if err := db.Create(®istry.Tag{
|
||||||
Repository: repo,
|
Repository: repo,
|
||||||
Tag: tag,
|
Tag: tag,
|
||||||
Digest: manifestDigest,
|
Digest: manifestDigest,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package registry
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
"gitea.loveuer.com/loveuer/cluster/internal/model"
|
"gitea.loveuer.com/loveuer/cluster/pkg/model/registry"
|
||||||
"gitea.loveuer.com/loveuer/cluster/pkg/resp"
|
"gitea.loveuer.com/loveuer/cluster/pkg/resp"
|
||||||
"gitea.loveuer.com/loveuer/cluster/pkg/store"
|
"gitea.loveuer.com/loveuer/cluster/pkg/store"
|
||||||
"github.com/gofiber/fiber/v3"
|
"github.com/gofiber/fiber/v3"
|
||||||
@@ -17,7 +17,7 @@ func RegistryImageList(ctx context.Context, db *gorm.DB, store store.Store) fibe
|
|||||||
filter := c.Query("filter", "")
|
filter := c.Query("filter", "")
|
||||||
|
|
||||||
// Get current registry_address setting
|
// Get current registry_address setting
|
||||||
var registryConfig model.RegistryConfig
|
var registryConfig registry.RegistryConfig
|
||||||
registryAddress := ""
|
registryAddress := ""
|
||||||
if err := db.Where("key = ?", "registry_address").First(®istryConfig).Error; err == nil {
|
if err := db.Where("key = ?", "registry_address").First(®istryConfig).Error; err == nil {
|
||||||
registryAddress = registryConfig.Value
|
registryAddress = registryConfig.Value
|
||||||
@@ -26,7 +26,7 @@ func RegistryImageList(ctx context.Context, db *gorm.DB, store store.Store) fibe
|
|||||||
registryAddress = "localhost:9119"
|
registryAddress = "localhost:9119"
|
||||||
}
|
}
|
||||||
|
|
||||||
var repositories []model.Repository
|
var repositories []registry.Repository
|
||||||
|
|
||||||
// Query all repositories from the database
|
// Query all repositories from the database
|
||||||
query := db
|
query := db
|
||||||
@@ -41,7 +41,7 @@ func RegistryImageList(ctx context.Context, db *gorm.DB, store store.Store) fibe
|
|||||||
var result []map[string]interface{}
|
var result []map[string]interface{}
|
||||||
for _, repo := range repositories {
|
for _, repo := range repositories {
|
||||||
// Get all tags for this repository
|
// Get all tags for this repository
|
||||||
var tags []model.Tag
|
var tags []registry.Tag
|
||||||
if err := db.Where("repository = ?", repo.Name).Find(&tags).Error; err != nil {
|
if err := db.Where("repository = ?", repo.Name).Find(&tags).Error; err != nil {
|
||||||
continue // Skip this repository if we can't get tags
|
continue // Skip this repository if we can't get tags
|
||||||
}
|
}
|
||||||
@@ -56,7 +56,7 @@ func RegistryImageList(ctx context.Context, db *gorm.DB, store store.Store) fibe
|
|||||||
var sizeResult struct {
|
var sizeResult struct {
|
||||||
Total int64
|
Total int64
|
||||||
}
|
}
|
||||||
err := db.Model(&model.Blob{}).
|
err := db.Model(®istry.Blob{}).
|
||||||
Where("repository = ?", repo.Name).
|
Where("repository = ?", repo.Name).
|
||||||
Select("COALESCE(SUM(size), 0) as total").
|
Select("COALESCE(SUM(size), 0) as total").
|
||||||
Scan(&sizeResult).Error
|
Scan(&sizeResult).Error
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.loveuer.com/loveuer/cluster/internal/model"
|
"gitea.loveuer.com/loveuer/cluster/pkg/model/registry"
|
||||||
"gitea.loveuer.com/loveuer/cluster/pkg/resp"
|
"gitea.loveuer.com/loveuer/cluster/pkg/resp"
|
||||||
"gitea.loveuer.com/loveuer/cluster/pkg/store"
|
"gitea.loveuer.com/loveuer/cluster/pkg/store"
|
||||||
"github.com/gofiber/fiber/v3"
|
"github.com/gofiber/fiber/v3"
|
||||||
@@ -142,7 +142,7 @@ func RegistryImageUpload(ctx context.Context, db *gorm.DB, store store.Store) fi
|
|||||||
// This allows registry_address to be changed without breaking existing images
|
// This allows registry_address to be changed without breaking existing images
|
||||||
repoName := originalRepo
|
repoName := originalRepo
|
||||||
|
|
||||||
if err := db.FirstOrCreate(&model.Repository{}, model.Repository{Name: repoName}).Error; err != nil {
|
if err := db.FirstOrCreate(®istry.Repository{}, registry.Repository{Name: repoName}).Error; err != nil {
|
||||||
return resp.R500(c, "", nil, fmt.Errorf("failed to create repository: %w", err))
|
return resp.R500(c, "", nil, fmt.Errorf("failed to create repository: %w", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,7 +155,7 @@ func RegistryImageUpload(ctx context.Context, db *gorm.DB, store store.Store) fi
|
|||||||
return resp.R500(c, "", nil, fmt.Errorf("failed to write config blob: %w", err))
|
return resp.R500(c, "", nil, fmt.Errorf("failed to write config blob: %w", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := db.Create(&model.Blob{
|
if err := db.Create(®istry.Blob{
|
||||||
Digest: configDigest,
|
Digest: configDigest,
|
||||||
Size: int64(len(configContent)),
|
Size: int64(len(configContent)),
|
||||||
MediaType: "application/vnd.docker.container.image.v1+json",
|
MediaType: "application/vnd.docker.container.image.v1+json",
|
||||||
@@ -179,7 +179,7 @@ func RegistryImageUpload(ctx context.Context, db *gorm.DB, store store.Store) fi
|
|||||||
return resp.R500(c, "", nil, fmt.Errorf("failed to write layer blob: %w", err))
|
return resp.R500(c, "", nil, fmt.Errorf("failed to write layer blob: %w", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := db.Create(&model.Blob{
|
if err := db.Create(®istry.Blob{
|
||||||
Digest: digest,
|
Digest: digest,
|
||||||
Size: int64(len(content)),
|
Size: int64(len(content)),
|
||||||
MediaType: "application/vnd.docker.image.rootfs.diff.tar.gzip",
|
MediaType: "application/vnd.docker.image.rootfs.diff.tar.gzip",
|
||||||
@@ -218,7 +218,7 @@ func RegistryImageUpload(ctx context.Context, db *gorm.DB, store store.Store) fi
|
|||||||
return resp.R500(c, "", nil, fmt.Errorf("failed to write manifest: %w", err))
|
return resp.R500(c, "", nil, fmt.Errorf("failed to write manifest: %w", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := db.Create(&model.Manifest{
|
if err := db.Create(®istry.Manifest{
|
||||||
Repository: repoName,
|
Repository: repoName,
|
||||||
Tag: tag,
|
Tag: tag,
|
||||||
Digest: manifestDigest,
|
Digest: manifestDigest,
|
||||||
@@ -229,7 +229,7 @@ func RegistryImageUpload(ctx context.Context, db *gorm.DB, store store.Store) fi
|
|||||||
return resp.R500(c, "", nil, fmt.Errorf("failed to save manifest: %w", err))
|
return resp.R500(c, "", nil, fmt.Errorf("failed to save manifest: %w", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := db.Create(&model.Tag{
|
if err := db.Create(®istry.Tag{
|
||||||
Repository: repoName,
|
Repository: repoName,
|
||||||
Tag: tag,
|
Tag: tag,
|
||||||
Digest: manifestDigest,
|
Digest: manifestDigest,
|
||||||
@@ -277,7 +277,7 @@ func handleOCIFormat(c fiber.Ctx, db *gorm.DB, store store.Store, ociIndex *OCII
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create repository
|
// Create repository
|
||||||
if err := db.FirstOrCreate(&model.Repository{}, model.Repository{Name: repoName}).Error; err != nil {
|
if err := db.FirstOrCreate(®istry.Repository{}, registry.Repository{Name: repoName}).Error; err != nil {
|
||||||
return resp.R500(c, "", nil, fmt.Errorf("failed to create repository: %w", err))
|
return resp.R500(c, "", nil, fmt.Errorf("failed to create repository: %w", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,7 +291,7 @@ func handleOCIFormat(c fiber.Ctx, db *gorm.DB, store store.Store, ociIndex *OCII
|
|||||||
if err := store.WriteBlob(c.Context(), indexDigest, strings.NewReader(string(indexJSON))); err != nil {
|
if err := store.WriteBlob(c.Context(), indexDigest, strings.NewReader(string(indexJSON))); err != nil {
|
||||||
return resp.R500(c, "", nil, fmt.Errorf("failed to write index blob: %w", err))
|
return resp.R500(c, "", nil, fmt.Errorf("failed to write index blob: %w", err))
|
||||||
}
|
}
|
||||||
if err := db.Create(&model.Blob{
|
if err := db.Create(®istry.Blob{
|
||||||
Digest: indexDigest,
|
Digest: indexDigest,
|
||||||
Size: int64(len(indexJSON)),
|
Size: int64(len(indexJSON)),
|
||||||
MediaType: "application/vnd.oci.image.index.v1+json",
|
MediaType: "application/vnd.oci.image.index.v1+json",
|
||||||
@@ -323,7 +323,7 @@ func handleOCIFormat(c fiber.Ctx, db *gorm.DB, store store.Store, ociIndex *OCII
|
|||||||
if err := store.WriteBlob(c.Context(), ociManifest.Config.Digest, strings.NewReader(string(configContent))); err != nil {
|
if err := store.WriteBlob(c.Context(), ociManifest.Config.Digest, strings.NewReader(string(configContent))); err != nil {
|
||||||
return resp.R500(c, "", nil, fmt.Errorf("failed to write config blob: %w", err))
|
return resp.R500(c, "", nil, fmt.Errorf("failed to write config blob: %w", err))
|
||||||
}
|
}
|
||||||
if err := db.Create(&model.Blob{
|
if err := db.Create(®istry.Blob{
|
||||||
Digest: ociManifest.Config.Digest,
|
Digest: ociManifest.Config.Digest,
|
||||||
Size: ociManifest.Config.Size,
|
Size: ociManifest.Config.Size,
|
||||||
MediaType: ociManifest.Config.MediaType,
|
MediaType: ociManifest.Config.MediaType,
|
||||||
@@ -343,7 +343,7 @@ func handleOCIFormat(c fiber.Ctx, db *gorm.DB, store store.Store, ociIndex *OCII
|
|||||||
if err := store.WriteBlob(c.Context(), layer.Digest, strings.NewReader(string(layerContent))); err != nil {
|
if err := store.WriteBlob(c.Context(), layer.Digest, strings.NewReader(string(layerContent))); err != nil {
|
||||||
return resp.R500(c, "", nil, fmt.Errorf("failed to write layer blob: %w", err))
|
return resp.R500(c, "", nil, fmt.Errorf("failed to write layer blob: %w", err))
|
||||||
}
|
}
|
||||||
if err := db.Create(&model.Blob{
|
if err := db.Create(®istry.Blob{
|
||||||
Digest: layer.Digest,
|
Digest: layer.Digest,
|
||||||
Size: layer.Size,
|
Size: layer.Size,
|
||||||
MediaType: layer.MediaType,
|
MediaType: layer.MediaType,
|
||||||
@@ -387,7 +387,7 @@ func handleOCIFormat(c fiber.Ctx, db *gorm.DB, store store.Store, ociIndex *OCII
|
|||||||
return resp.R500(c, "", nil, fmt.Errorf("failed to write manifest: %w", err))
|
return resp.R500(c, "", nil, fmt.Errorf("failed to write manifest: %w", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := db.Create(&model.Manifest{
|
if err := db.Create(®istry.Manifest{
|
||||||
Repository: repoName,
|
Repository: repoName,
|
||||||
Tag: tag,
|
Tag: tag,
|
||||||
Digest: manifestDigest,
|
Digest: manifestDigest,
|
||||||
@@ -398,7 +398,7 @@ func handleOCIFormat(c fiber.Ctx, db *gorm.DB, store store.Store, ociIndex *OCII
|
|||||||
return resp.R500(c, "", nil, fmt.Errorf("failed to save manifest: %w", err))
|
return resp.R500(c, "", nil, fmt.Errorf("failed to save manifest: %w", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := db.Create(&model.Tag{
|
if err := db.Create(®istry.Tag{
|
||||||
Repository: repoName,
|
Repository: repoName,
|
||||||
Tag: tag,
|
Tag: tag,
|
||||||
Digest: manifestDigest,
|
Digest: manifestDigest,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.loveuer.com/loveuer/cluster/internal/model"
|
"gitea.loveuer.com/loveuer/cluster/pkg/model/registry"
|
||||||
"gitea.loveuer.com/loveuer/cluster/pkg/resp"
|
"gitea.loveuer.com/loveuer/cluster/pkg/resp"
|
||||||
"gitea.loveuer.com/loveuer/cluster/pkg/store"
|
"gitea.loveuer.com/loveuer/cluster/pkg/store"
|
||||||
"github.com/gofiber/fiber/v3"
|
"github.com/gofiber/fiber/v3"
|
||||||
@@ -20,28 +20,28 @@ func isDigestFormat(s string) bool {
|
|||||||
if len(parts) != 2 {
|
if len(parts) != 2 {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
algo := parts[0]
|
algo := parts[0]
|
||||||
hash := parts[1]
|
hash := parts[1]
|
||||||
|
|
||||||
// Check algorithm
|
// Check algorithm
|
||||||
if algo != "sha256" {
|
if algo != "sha256" {
|
||||||
// Could be extended to support other algorithms like sha512
|
// Could be extended to support other algorithms like sha512
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check that hash is a valid hex string of expected length (64 for sha256)
|
// Check that hash is a valid hex string of expected length (64 for sha256)
|
||||||
if len(hash) != 64 {
|
if len(hash) != 64 {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify it's all hex characters
|
// Verify it's all hex characters
|
||||||
for _, r := range hash {
|
for _, r := range hash {
|
||||||
if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f')) {
|
if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f')) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,9 +75,9 @@ func HandleManifest(c fiber.Ctx, db *gorm.DB, store store.Store) error {
|
|||||||
|
|
||||||
// ???? manifests ???????
|
// ???? manifests ???????
|
||||||
repo := strings.Join(parts[:manifestsIndex], "/")
|
repo := strings.Join(parts[:manifestsIndex], "/")
|
||||||
|
|
||||||
// Strip registry_address prefix from repo if present
|
// Strip registry_address prefix from repo if present
|
||||||
var registryConfig model.RegistryConfig
|
var registryConfig registry.RegistryConfig
|
||||||
registryAddress := ""
|
registryAddress := ""
|
||||||
if err := db.Where("key = ?", "registry_address").First(®istryConfig).Error; err == nil {
|
if err := db.Where("key = ?", "registry_address").First(®istryConfig).Error; err == nil {
|
||||||
registryAddress = registryConfig.Value
|
registryAddress = registryConfig.Value
|
||||||
@@ -85,7 +85,7 @@ func HandleManifest(c fiber.Ctx, db *gorm.DB, store store.Store) error {
|
|||||||
if registryAddress != "" && strings.HasPrefix(repo, registryAddress+"/") {
|
if registryAddress != "" && strings.HasPrefix(repo, registryAddress+"/") {
|
||||||
repo = strings.TrimPrefix(repo, registryAddress+"/")
|
repo = strings.TrimPrefix(repo, registryAddress+"/")
|
||||||
}
|
}
|
||||||
|
|
||||||
// tag ? manifests ?????
|
// tag ? manifests ?????
|
||||||
tag := parts[manifestsIndex+1]
|
tag := parts[manifestsIndex+1]
|
||||||
|
|
||||||
@@ -140,10 +140,10 @@ func handleManifestPut(c fiber.Ctx, db *gorm.DB, store store.Store, repo string,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ??????
|
// ??????
|
||||||
var repository model.Repository
|
var repository registry.Repository
|
||||||
if err := db.Where("name = ?", repo).First(&repository).Error; err != nil {
|
if err := db.Where("name = ?", repo).First(&repository).Error; err != nil {
|
||||||
if err == gorm.ErrRecordNotFound {
|
if err == gorm.ErrRecordNotFound {
|
||||||
repository = model.Repository{Name: repo}
|
repository = registry.Repository{Name: repo}
|
||||||
if err := db.Create(&repository).Error; err != nil {
|
if err := db.Create(&repository).Error; err != nil {
|
||||||
return resp.R500(c, "", nil, err)
|
return resp.R500(c, "", nil, err)
|
||||||
}
|
}
|
||||||
@@ -158,11 +158,11 @@ func handleManifestPut(c fiber.Ctx, db *gorm.DB, store store.Store, repo string,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ?? manifest ?????
|
// ?? manifest ?????
|
||||||
var manifest model.Manifest
|
var manifest registry.Manifest
|
||||||
if err := db.Where("digest = ?", digest).First(&manifest).Error; err != nil {
|
if err := db.Where("digest = ?", digest).First(&manifest).Error; err != nil {
|
||||||
if err == gorm.ErrRecordNotFound {
|
if err == gorm.ErrRecordNotFound {
|
||||||
// ???? manifest ??
|
// ???? manifest ??
|
||||||
manifest = model.Manifest{
|
manifest = registry.Manifest{
|
||||||
Repository: repo,
|
Repository: repo,
|
||||||
Tag: tag,
|
Tag: tag,
|
||||||
Digest: digest,
|
Digest: digest,
|
||||||
@@ -186,10 +186,10 @@ func handleManifestPut(c fiber.Ctx, db *gorm.DB, store store.Store, repo string,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ????? tag ??
|
// ????? tag ??
|
||||||
var tagRecord model.Tag
|
var tagRecord registry.Tag
|
||||||
if err := db.Where("repository = ? AND tag = ?", repo, tag).First(&tagRecord).Error; err != nil {
|
if err := db.Where("repository = ? AND tag = ?", repo, tag).First(&tagRecord).Error; err != nil {
|
||||||
if err == gorm.ErrRecordNotFound {
|
if err == gorm.ErrRecordNotFound {
|
||||||
tagRecord = model.Tag{
|
tagRecord = registry.Tag{
|
||||||
Repository: repo,
|
Repository: repo,
|
||||||
Tag: tag,
|
Tag: tag,
|
||||||
Digest: digest,
|
Digest: digest,
|
||||||
@@ -217,13 +217,13 @@ func handleManifestPut(c fiber.Ctx, db *gorm.DB, store store.Store, repo string,
|
|||||||
|
|
||||||
// handleManifestGet ?? manifest
|
// handleManifestGet ?? manifest
|
||||||
func handleManifestGet(c fiber.Ctx, db *gorm.DB, store store.Store, repo string, tag string) error {
|
func handleManifestGet(c fiber.Ctx, db *gorm.DB, store store.Store, repo string, tag string) error {
|
||||||
var manifest model.Manifest
|
var manifest registry.Manifest
|
||||||
|
|
||||||
// ?? tag ??????????????????????
|
// ?? tag ??????????????????????
|
||||||
if isDigestFormat(tag) {
|
if isDigestFormat(tag) {
|
||||||
// ?? digest ???????????? repository
|
// ?? digest ???????????? repository
|
||||||
digest := tag
|
digest := tag
|
||||||
|
|
||||||
// ?? manifest ???
|
// ?? manifest ???
|
||||||
if err := db.Where("digest = ?", digest).First(&manifest).Error; err != nil {
|
if err := db.Where("digest = ?", digest).First(&manifest).Error; err != nil {
|
||||||
if err == gorm.ErrRecordNotFound {
|
if err == gorm.ErrRecordNotFound {
|
||||||
@@ -231,9 +231,9 @@ func handleManifestGet(c fiber.Ctx, db *gorm.DB, store store.Store, repo string,
|
|||||||
}
|
}
|
||||||
return resp.R500(c, "", nil, err)
|
return resp.R500(c, "", nil, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ???? manifest ?????????? repository ??
|
// ???? manifest ?????????? repository ??
|
||||||
var tagRecord model.Tag
|
var tagRecord registry.Tag
|
||||||
if err := db.Where("repository = ? AND digest = ?", repo, digest).First(&tagRecord).Error; err != nil {
|
if err := db.Where("repository = ? AND digest = ?", repo, digest).First(&tagRecord).Error; err != nil {
|
||||||
if err == gorm.ErrRecordNotFound {
|
if err == gorm.ErrRecordNotFound {
|
||||||
return resp.R404(c, "MANIFEST_NOT_FOUND", nil, "manifest not found in this repository")
|
return resp.R404(c, "MANIFEST_NOT_FOUND", nil, "manifest not found in this repository")
|
||||||
@@ -242,7 +242,7 @@ func handleManifestGet(c fiber.Ctx, db *gorm.DB, store store.Store, repo string,
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// ?? tag ???? tag ?????????
|
// ?? tag ???? tag ?????????
|
||||||
var tagRecord model.Tag
|
var tagRecord registry.Tag
|
||||||
if err := db.Where("repository = ? AND tag = ?", repo, tag).First(&tagRecord).Error; err != nil {
|
if err := db.Where("repository = ? AND tag = ?", repo, tag).First(&tagRecord).Error; err != nil {
|
||||||
if err == gorm.ErrRecordNotFound {
|
if err == gorm.ErrRecordNotFound {
|
||||||
return resp.R404(c, "MANIFEST_NOT_FOUND", nil, "manifest not found")
|
return resp.R404(c, "MANIFEST_NOT_FOUND", nil, "manifest not found")
|
||||||
@@ -302,13 +302,13 @@ func handleManifestGet(c fiber.Ctx, db *gorm.DB, store store.Store, repo string,
|
|||||||
|
|
||||||
// handleManifestHead ?? manifest ????
|
// handleManifestHead ?? manifest ????
|
||||||
func handleManifestHead(c fiber.Ctx, db *gorm.DB, store store.Store, repo string, tag string) error {
|
func handleManifestHead(c fiber.Ctx, db *gorm.DB, store store.Store, repo string, tag string) error {
|
||||||
var manifest model.Manifest
|
var manifest registry.Manifest
|
||||||
|
|
||||||
// ?? tag ??????????????????????
|
// ?? tag ??????????????????????
|
||||||
if isDigestFormat(tag) {
|
if isDigestFormat(tag) {
|
||||||
// ?? digest ???????????? repository
|
// ?? digest ???????????? repository
|
||||||
digest := tag
|
digest := tag
|
||||||
|
|
||||||
// ?? manifest ???
|
// ?? manifest ???
|
||||||
if err := db.Where("digest = ?", digest).First(&manifest).Error; err != nil {
|
if err := db.Where("digest = ?", digest).First(&manifest).Error; err != nil {
|
||||||
if err == gorm.ErrRecordNotFound {
|
if err == gorm.ErrRecordNotFound {
|
||||||
@@ -316,9 +316,9 @@ func handleManifestHead(c fiber.Ctx, db *gorm.DB, store store.Store, repo string
|
|||||||
}
|
}
|
||||||
return resp.R500(c, "", nil, err)
|
return resp.R500(c, "", nil, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ???? manifest ?????????? repository ??
|
// ???? manifest ?????????? repository ??
|
||||||
var tagRecord model.Tag
|
var tagRecord registry.Tag
|
||||||
if err := db.Where("repository = ? AND digest = ?", repo, digest).First(&tagRecord).Error; err != nil {
|
if err := db.Where("repository = ? AND digest = ?", repo, digest).First(&tagRecord).Error; err != nil {
|
||||||
if err == gorm.ErrRecordNotFound {
|
if err == gorm.ErrRecordNotFound {
|
||||||
return resp.R404(c, "MANIFEST_NOT_FOUND", nil, "manifest not found in this repository")
|
return resp.R404(c, "MANIFEST_NOT_FOUND", nil, "manifest not found in this repository")
|
||||||
@@ -327,7 +327,7 @@ func handleManifestHead(c fiber.Ctx, db *gorm.DB, store store.Store, repo string
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// ?? tag ???? tag ?????????
|
// ?? tag ???? tag ?????????
|
||||||
var tagRecord model.Tag
|
var tagRecord registry.Tag
|
||||||
if err := db.Where("repository = ? AND tag = ?", repo, tag).First(&tagRecord).Error; err != nil {
|
if err := db.Where("repository = ? AND tag = ?", repo, tag).First(&tagRecord).Error; err != nil {
|
||||||
if err == gorm.ErrRecordNotFound {
|
if err == gorm.ErrRecordNotFound {
|
||||||
return resp.R404(c, "MANIFEST_NOT_FOUND", nil, "manifest not found")
|
return resp.R404(c, "MANIFEST_NOT_FOUND", nil, "manifest not found")
|
||||||
@@ -358,32 +358,32 @@ func handleManifestDelete(c fiber.Ctx, db *gorm.DB, store store.Store, repo stri
|
|||||||
if isDigestFormat(tag) {
|
if isDigestFormat(tag) {
|
||||||
// ?? digest ???????????? repository
|
// ?? digest ???????????? repository
|
||||||
digest = tag
|
digest = tag
|
||||||
|
|
||||||
// ???? manifest ?????????? repository ??
|
// ???? manifest ?????????? repository ??
|
||||||
var tagRecord model.Tag
|
var tagRecord registry.Tag
|
||||||
if err := db.Where("repository = ? AND digest = ?", repo, digest).First(&tagRecord).Error; err != nil {
|
if err := db.Where("repository = ? AND digest = ?", repo, digest).First(&tagRecord).Error; err != nil {
|
||||||
if err == gorm.ErrRecordNotFound {
|
if err == gorm.ErrRecordNotFound {
|
||||||
return resp.R404(c, "MANIFEST_NOT_FOUND", nil, "manifest not found in this repository")
|
return resp.R404(c, "MANIFEST_NOT_FOUND", nil, "manifest not found in this repository")
|
||||||
}
|
}
|
||||||
return resp.R500(c, "", nil, err)
|
return resp.R500(c, "", nil, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ???????? tag ??? manifest
|
// ???????? tag ??? manifest
|
||||||
var count int64
|
var count int64
|
||||||
if err := db.Model(&model.Tag{}).Where("digest = ?", digest).Count(&count).Error; err != nil {
|
if err := db.Model(®istry.Tag{}).Where("digest = ?", digest).Count(&count).Error; err != nil {
|
||||||
return resp.R500(c, "", nil, err)
|
return resp.R500(c, "", nil, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ??? tag ??????? manifest ??
|
// ??? tag ??????? manifest ??
|
||||||
if count == 0 {
|
if count == 0 {
|
||||||
var manifest model.Manifest
|
var manifest registry.Manifest
|
||||||
if err := db.Where("digest = ?", digest).First(&manifest).Error; err != nil {
|
if err := db.Where("digest = ?", digest).First(&manifest).Error; err != nil {
|
||||||
if err == gorm.ErrRecordNotFound {
|
if err == gorm.ErrRecordNotFound {
|
||||||
return resp.R404(c, "MANIFEST_NOT_FOUND", nil, "manifest not found")
|
return resp.R404(c, "MANIFEST_NOT_FOUND", nil, "manifest not found")
|
||||||
}
|
}
|
||||||
return resp.R500(c, "", nil, err)
|
return resp.R500(c, "", nil, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := db.Delete(&manifest).Error; err != nil {
|
if err := db.Delete(&manifest).Error; err != nil {
|
||||||
return resp.R500(c, "", nil, err)
|
return resp.R500(c, "", nil, err)
|
||||||
}
|
}
|
||||||
@@ -394,7 +394,7 @@ func handleManifestDelete(c fiber.Ctx, db *gorm.DB, store store.Store, repo stri
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// ?? tag ???? tag ?????????
|
// ?? tag ???? tag ?????????
|
||||||
var tagRecord model.Tag
|
var tagRecord registry.Tag
|
||||||
if err := db.Where("repository = ? AND tag = ?", repo, tag).First(&tagRecord).Error; err != nil {
|
if err := db.Where("repository = ? AND tag = ?", repo, tag).First(&tagRecord).Error; err != nil {
|
||||||
if err == gorm.ErrRecordNotFound {
|
if err == gorm.ErrRecordNotFound {
|
||||||
return resp.R404(c, "MANIFEST_NOT_FOUND", nil, "manifest not found")
|
return resp.R404(c, "MANIFEST_NOT_FOUND", nil, "manifest not found")
|
||||||
@@ -411,13 +411,13 @@ func handleManifestDelete(c fiber.Ctx, db *gorm.DB, store store.Store, repo stri
|
|||||||
|
|
||||||
// ???????? tag ??? manifest
|
// ???????? tag ??? manifest
|
||||||
var count int64
|
var count int64
|
||||||
if err := db.Model(&model.Tag{}).Where("digest = ?", digest).Count(&count).Error; err != nil {
|
if err := db.Model(®istry.Tag{}).Where("digest = ?", digest).Count(&count).Error; err != nil {
|
||||||
return resp.R500(c, "", nil, err)
|
return resp.R500(c, "", nil, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ?????? tag ????? manifest ??
|
// ?????? tag ????? manifest ??
|
||||||
if count == 0 {
|
if count == 0 {
|
||||||
var manifest model.Manifest
|
var manifest registry.Manifest
|
||||||
if err := db.Where("digest = ?", digest).First(&manifest).Error; err == nil {
|
if err := db.Where("digest = ?", digest).First(&manifest).Error; err == nil {
|
||||||
if err := db.Delete(&manifest).Error; err != nil {
|
if err := db.Delete(&manifest).Error; err != nil {
|
||||||
return resp.R500(c, "", nil, err)
|
return resp.R500(c, "", nil, err)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.loveuer.com/loveuer/cluster/internal/model"
|
registry "gitea.loveuer.com/loveuer/cluster/pkg/model/registry"
|
||||||
"gitea.loveuer.com/loveuer/cluster/pkg/resp"
|
"gitea.loveuer.com/loveuer/cluster/pkg/resp"
|
||||||
"gitea.loveuer.com/loveuer/cluster/pkg/store"
|
"gitea.loveuer.com/loveuer/cluster/pkg/store"
|
||||||
"github.com/gofiber/fiber/v3"
|
"github.com/gofiber/fiber/v3"
|
||||||
@@ -14,12 +14,12 @@ import (
|
|||||||
|
|
||||||
func Registry(ctx context.Context, db *gorm.DB, store store.Store) fiber.Handler {
|
func Registry(ctx context.Context, db *gorm.DB, store store.Store) fiber.Handler {
|
||||||
if err := db.AutoMigrate(
|
if err := db.AutoMigrate(
|
||||||
&model.Repository{},
|
®istry.Repository{},
|
||||||
&model.Blob{},
|
®istry.Blob{},
|
||||||
&model.Manifest{},
|
®istry.Manifest{},
|
||||||
&model.Tag{},
|
®istry.Tag{},
|
||||||
&model.BlobUpload{},
|
®istry.BlobUpload{},
|
||||||
&model.RegistryConfig{},
|
®istry.RegistryConfig{},
|
||||||
); err != nil {
|
); err != nil {
|
||||||
log.Fatalf("failed to migrate database: %v", err)
|
log.Fatalf("failed to migrate database: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.loveuer.com/loveuer/cluster/internal/model"
|
"gitea.loveuer.com/loveuer/cluster/pkg/model/registry"
|
||||||
"gitea.loveuer.com/loveuer/cluster/pkg/resp"
|
"gitea.loveuer.com/loveuer/cluster/pkg/resp"
|
||||||
"gitea.loveuer.com/loveuer/cluster/pkg/store"
|
"gitea.loveuer.com/loveuer/cluster/pkg/store"
|
||||||
"github.com/gofiber/fiber/v3"
|
"github.com/gofiber/fiber/v3"
|
||||||
@@ -40,7 +40,7 @@ func HandleTags(c fiber.Ctx, db *gorm.DB, store store.Store) error {
|
|||||||
repo := strings.Join(parts[:tagsIndex], "/")
|
repo := strings.Join(parts[:tagsIndex], "/")
|
||||||
|
|
||||||
// Strip registry_address prefix from repo if present
|
// Strip registry_address prefix from repo if present
|
||||||
var registryConfig model.RegistryConfig
|
var registryConfig registry.RegistryConfig
|
||||||
registryAddress := ""
|
registryAddress := ""
|
||||||
if err := db.Where("key = ?", "registry_address").First(®istryConfig).Error; err == nil {
|
if err := db.Where("key = ?", "registry_address").First(®istryConfig).Error; err == nil {
|
||||||
registryAddress = registryConfig.Value
|
registryAddress = registryConfig.Value
|
||||||
@@ -58,7 +58,7 @@ func HandleTags(c fiber.Ctx, db *gorm.DB, store store.Store) error {
|
|||||||
last := c.Query("last")
|
last := c.Query("last")
|
||||||
|
|
||||||
// ?? tags
|
// ?? tags
|
||||||
var tags []model.Tag
|
var tags []registry.Tag
|
||||||
query := db.Where("repository = ?", repo).Order("tag ASC").Limit(n + 1)
|
query := db.Where("repository = ?", repo).Order("tag ASC").Limit(n + 1)
|
||||||
|
|
||||||
if last != "" {
|
if last != "" {
|
||||||
|
|||||||
51
nginx.conf
Normal file
51
nginx.conf
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
events {
|
||||||
|
worker_connections 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
http {
|
||||||
|
include /etc/nginx/mime.types;
|
||||||
|
default_type application/octet-stream;
|
||||||
|
|
||||||
|
upstream backend {
|
||||||
|
server 127.0.0.1:9119;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
|
||||||
|
# Serve static files
|
||||||
|
location / {
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Proxy API requests to backend
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://backend;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Proxy OCI registry v2 requests to backend
|
||||||
|
location /v2/ {
|
||||||
|
proxy_pass http://backend;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Proxy registry requests to backend
|
||||||
|
location /registry/ {
|
||||||
|
proxy_pass http://backend;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
23
pkg/model/auth/user.go
Normal file
23
pkg/model/auth/user.go
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// User 用户模型
|
||||||
|
type User struct {
|
||||||
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||||
|
|
||||||
|
Username string `gorm:"uniqueIndex;not null" json:"username"`
|
||||||
|
Password string `gorm:"not null" json:"-"`
|
||||||
|
Email string `gorm:"index" json:"email"`
|
||||||
|
Nickname string `json:"nickname"`
|
||||||
|
Role string `gorm:"default:'user'" json:"role"`
|
||||||
|
Status string `gorm:"default:'active'" json:"status"`
|
||||||
|
Permissions string `gorm:"type:text" json:"permissions"`
|
||||||
|
}
|
||||||
18
pkg/model/k8s/config.go
Normal file
18
pkg/model/k8s/config.go
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
package k8s
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ClusterConfig k8s cluster configuration
|
||||||
|
type ClusterConfig struct {
|
||||||
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||||
|
|
||||||
|
Key string `gorm:"uniqueIndex;not null" json:"key"`
|
||||||
|
Value string `gorm:"type:text" json:"value"`
|
||||||
|
}
|
||||||
20
pkg/model/registry/blob.go
Normal file
20
pkg/model/registry/blob.go
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
package registry
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Blob blob ??
|
||||||
|
type Blob struct {
|
||||||
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||||
|
|
||||||
|
Digest string `gorm:"uniqueIndex;not null" json:"digest"` // SHA256 digest
|
||||||
|
Size int64 `gorm:"not null" json:"size"` // ??????
|
||||||
|
MediaType string `json:"media_type"` // ????
|
||||||
|
Repository string `gorm:"index" json:"repository"` // ???????????????
|
||||||
|
}
|
||||||
20
pkg/model/registry/blob_upload.go
Normal file
20
pkg/model/registry/blob_upload.go
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
package registry
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BlobUpload ????? blob ??
|
||||||
|
type BlobUpload struct {
|
||||||
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||||
|
|
||||||
|
UUID string `gorm:"uniqueIndex;not null" json:"uuid"` // ???? UUID
|
||||||
|
Repository string `gorm:"index;not null" json:"repository"` // ????
|
||||||
|
Path string `gorm:"not null" json:"path"` // ??????
|
||||||
|
Size int64 `gorm:"default:0" json:"size"` // ?????
|
||||||
|
}
|
||||||
18
pkg/model/registry/config.go
Normal file
18
pkg/model/registry/config.go
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
package registry
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RegistryConfig registry ?????
|
||||||
|
type RegistryConfig struct {
|
||||||
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||||
|
|
||||||
|
Key string `gorm:"uniqueIndex;not null" json:"key"` // ???? key
|
||||||
|
Value string `gorm:"type:text" json:"value"` // ???? value
|
||||||
|
}
|
||||||
22
pkg/model/registry/manifest.go
Normal file
22
pkg/model/registry/manifest.go
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
package registry
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Manifest manifest ??
|
||||||
|
type Manifest struct {
|
||||||
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||||
|
|
||||||
|
Repository string `gorm:"index;not null" json:"repository"` // ????
|
||||||
|
Tag string `gorm:"index;not null" json:"tag"` // tag ??
|
||||||
|
Digest string `gorm:"uniqueIndex;not null" json:"digest"` // manifest digest
|
||||||
|
MediaType string `json:"media_type"` // ????
|
||||||
|
Size int64 `gorm:"not null" json:"size"` // manifest ??
|
||||||
|
Content []byte `gorm:"type:blob" json:"-"` // manifest ???JSON?
|
||||||
|
}
|
||||||
17
pkg/model/registry/repository.go
Normal file
17
pkg/model/registry/repository.go
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
package registry
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Repository ????
|
||||||
|
type Repository struct {
|
||||||
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||||
|
|
||||||
|
Name string `gorm:"uniqueIndex;not null" json:"name"` // ?????? "library/nginx"
|
||||||
|
}
|
||||||
19
pkg/model/registry/tag.go
Normal file
19
pkg/model/registry/tag.go
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
package registry
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Tag tag ??????????
|
||||||
|
type Tag struct {
|
||||||
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||||
|
|
||||||
|
Repository string `gorm:"index;not null" json:"repository"` // ????
|
||||||
|
Tag string `gorm:"index;not null" json:"tag"` // tag ??
|
||||||
|
Digest string `gorm:"not null" json:"digest"` // ??? manifest digest
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user