Compare commits

...

16 Commits

Author SHA1 Message Date
loveuer
8b655d3496 refactor: reorganize models to pkg/model and add authentication module
- Move ORM models from internal/model to pkg/model organized by module (auth/k8s/registry)
- Add authentication module with login, user management handlers
- Update all import paths to use new model locations
- Add frontend auth pages (Login, UserManagement) and authStore
- Remove deprecated internal/model/model.go
2025-11-20 14:55:48 +08:00
loveuer
a80744c533 Update Go dependencies
- Updated github.com/gofiber/fiber/v3 from beta.2 to rc.2
- Updated k8s.io dependencies
- Updated other indirect dependencies

🤖 Generated with [Qoder][https://qoder.com]
2025-11-17 10:33:57 +08:00
loveuer
704d0fe0bf Fix SSE connection handling and optimize Dockerfile
- Fixed SSE connection not being properly closed when pod logs dialog is closed
- Added proper cleanup for EventSource connections in K8sResourceList.tsx
- Added debugging logs to track SSE connection lifecycle
- Optimized Dockerfile to avoid copying frontend files during Go build stage
- Fixed backend handler to properly use context from request for log streaming

🤖 Generated with [Qoder][https://qoder.com]
2025-11-17 10:33:45 +08:00
loveuer
7a666303be fix: update delete confirmation dialog to show correct resource type and name 2025-11-13 15:32:40 +08:00
loveuer
0536ce9755 feat: add delete functionality for namespace resources 2025-11-13 15:27:54 +08:00
loveuer
8ffb0eec09 fix: implement proper resource deletion for all resource types 2025-11-13 15:24:29 +08:00
loveuer
e82dfec1ba fix: remove ExternalIP column from Service table 2025-11-13 15:19:00 +08:00
loveuer
b7a5f85da2 feat: display NodePort information in Service table 2025-11-13 15:06:23 +08:00
loveuer
488c7b90bf fix: preserve Service spec fields including NodePort information 2025-11-13 14:49:18 +08:00
loveuer
a632f68c29 fix: ensure Kind and APIVersion are set in returned YAML 2025-11-13 14:43:19 +08:00
loveuer
c2bde4a0ff fix: improve resource field cleaning to prevent YAML parsing errors 2025-11-13 11:48:14 +08:00
loveuer
23add6447d fix: clean managedFields and metadata from resource YAML to prevent update conflicts 2025-11-13 11:40:36 +08:00
loveuer
fa0298b4d8 fix: add Actions column to all resource table headers 2025-11-13 11:27:49 +08:00
loveuer
40c10235f6 fix: remove unnecessary log buttons, only keep for pods 2025-11-13 11:15:37 +08:00
loveuer
08be388322 fix: add edit buttons to resource table rows 2025-11-13 10:31:55 +08:00
loveuer
529a90b80d feat: add resource edit functionality for k8s resources
- Add edit button for Deployment, StatefulSet, Service, and ConfigMap resources
- Implement resource fetch API to get YAML representation
- Implement resource update API to apply edited YAML
- Add edit dialog with YAML editor and apply/cancel buttons
- Add tooltip icons for better UX
- Restore K8sResourceApply function with HTTP/1.1 enforcement
- Support for fetching and updating the following resource kinds:
  - Deployment
  - StatefulSet
  - Service
  - ConfigMap

🤖 Generated with [Qoder][https://qoder.com]
2025-11-13 09:25:41 +08:00
38 changed files with 2366 additions and 263 deletions

63
Dockerfile Normal file
View 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
View 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;'

View File

@@ -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>

View File

@@ -28,6 +28,9 @@ import {
Snackbar, Snackbar,
MenuItem, MenuItem,
Select, 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'
@@ -35,6 +38,7 @@ 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 VisibilityIcon from '@mui/icons-material/Visibility'
import DeleteIcon from '@mui/icons-material/Delete' 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' },
@@ -72,9 +76,14 @@ export default function K8sResourceList() {
const [logsDialogOpen, setLogsDialogOpen] = useState(false) const [logsDialogOpen, setLogsDialogOpen] = useState(false)
const [logs, setLogs] = useState<string[]>([]) const [logs, setLogs] = useState<string[]>([])
const [selectedPod, setSelectedPod] = useState<{ name: string; namespace: string } | null>(null) const [selectedPod, setSelectedPod] = useState<{ name: string; namespace: string } | null>(null)
const eventSourceRef = useRef<EventSource | null>(null)
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false) const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [deleteTarget, setDeleteTarget] = useState<{ name: string; namespace: string } | null>(null) const [deleteTarget, setDeleteTarget] = useState<{ name: string; namespace: string } | null>(null)
const [deleting, setDeleting] = useState(false) 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) const logsEndRef = useRef<HTMLDivElement>(null)
useEffect(() => { useEffect(() => {
@@ -90,6 +99,17 @@ export default function K8sResourceList() {
} }
}, [selectedKind, namespace, nameFilter]) }, [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 {
const res = await fetch('/api/v1/k8s/config') const res = await fetch('/api/v1/k8s/config')
@@ -163,24 +183,137 @@ export default function K8sResourceList() {
} }
const handleViewLogs = (podName: string, podNamespace: string) => { const handleViewLogs = (podName: string, podNamespace: string) => {
console.log('handleViewLogs called with:', { podName, podNamespace })
setSelectedPod({ name: podName, namespace: podNamespace }) setSelectedPod({ name: podName, namespace: podNamespace })
setLogs([]) setLogs([])
setLogsDialogOpen(true) 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( const eventSource = new EventSource(
`/api/v1/k8s/pod/logs?name=${encodeURIComponent(podName)}&namespace=${encodeURIComponent(podNamespace)}&tail=1000&follow=true` `/api/v1/k8s/pod/logs?name=${encodeURIComponent(podName)}&namespace=${encodeURIComponent(podNamespace)}&tail=1000&follow=true`
) )
eventSource.onmessage = (event) => { // 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]) setLogs((prev) => [...prev, event.data])
setTimeout(() => logsEndRef.current?.scrollIntoView({ behavior: 'smooth' }), 100) setTimeout(() => logsEndRef.current?.scrollIntoView({ behavior: 'smooth' }), 100)
} }
})
eventSource.onerror = () => { eventSource.onerror = () => {
eventSource.close() console.log('EventSource error occurred')
if (eventSourceRef.current) {
eventSourceRef.current.close()
eventSourceRef.current = null
}
}
} }
return () => eventSource.close() 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 () => { const handleDeletePod = async () => {
@@ -211,6 +344,52 @@ export default function K8sResourceList() {
} }
} }
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) => { const openDeleteDialog = (podName: string, podNamespace: string) => {
setDeleteTarget({ name: podName, namespace: podNamespace }) setDeleteTarget({ name: podName, namespace: podNamespace })
setDeleteDialogOpen(true) setDeleteDialogOpen(true)
@@ -259,22 +438,22 @@ export default function K8sResourceList() {
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', 'Actions'] 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']
} }
} }
@@ -299,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':
@@ -309,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':
@@ -330,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':
@@ -537,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)}
@@ -595,7 +893,7 @@ export default function K8sResourceList() {
<Dialog <Dialog
open={logsDialogOpen} open={logsDialogOpen}
onClose={() => setLogsDialogOpen(false)} onClose={handleCloseLogsDialog}
maxWidth="lg" maxWidth="lg"
fullWidth fullWidth
> >
@@ -604,7 +902,7 @@ export default function K8sResourceList() {
<Typography variant="h6"> <Typography variant="h6">
Pod : {selectedPod?.name} ({selectedPod?.namespace}) Pod : {selectedPod?.name} ({selectedPod?.namespace})
</Typography> </Typography>
<IconButton onClick={() => setLogsDialogOpen(false)}> <IconButton onClick={handleCloseLogsDialog}>
<CloseIcon /> <CloseIcon />
</IconButton> </IconButton>
</Box> </Box>
@@ -636,7 +934,8 @@ export default function K8sResourceList() {
<DialogTitle></DialogTitle> <DialogTitle></DialogTitle>
<DialogContent> <DialogContent>
<Typography> <Typography>
Pod <strong>{deleteTarget?.name}</strong> (namespace: {deleteTarget?.namespace}) {selectedKind.label} <strong>{deleteTarget?.name}</strong>
{deleteTarget?.namespace && selectedKind.key !== 'namespace' ? ` (namespace: ${deleteTarget?.namespace})` : ''}
</Typography> </Typography>
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
@@ -644,7 +943,7 @@ export default function K8sResourceList() {
<Button <Button
variant="contained" variant="contained"
color="error" color="error"
onClick={handleDeletePod} onClick={handleDeleteResource}
disabled={deleting} disabled={deleting}
> >
{deleting ? <CircularProgress size={24} /> : '删除'} {deleting ? <CircularProgress size={24} /> : '删除'}

View 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>
)
}

View 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>
)
}

View 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,
}),
}
)
)

10
go.mod
View File

@@ -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
View File

@@ -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=

View File

@@ -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(&registry_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,17 +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.Get("/pod/logs", k8s.K8sPodLogs(ctx, db, store))
k8sAPI.Delete("/pod/delete", k8s.K8sPodDelete(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)

View File

@@ -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"`
}

View 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
}

View 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,
})
}
}

View 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,
})
}
}

View 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",
})
}
}

View 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
}
}

View File

@@ -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,
} }

View File

@@ -8,13 +8,14 @@ import (
"io" "io"
"strings" "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" appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/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"
@@ -22,11 +23,10 @@ import (
"k8s.io/client-go/rest" "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)
@@ -52,7 +52,7 @@ func getK8sClient(db *gorm.DB) (*kubernetes.Clientset, error) {
} }
func getK8sConfig(db *gorm.DB) (*rest.Config, error) { func getK8sConfig(db *gorm.DB) (*rest.Config, 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)
@@ -308,6 +308,112 @@ func K8sServiceList(ctx context.Context, db *gorm.DB, store store.Store) fiber.H
} }
} }
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 {
@@ -322,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))
} }
@@ -336,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))
@@ -379,34 +489,213 @@ 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", if name == "" || kind == "" {
"Pod": "pods", return resp.R400(c, "", nil, fmt.Errorf("name and kind are required"))
"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 { clientset, err := getK8sClient(db)
return resource if err != nil {
return resp.R500(c, "", nil, err)
} }
return kind + "s" 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 { func K8sPodLogs(ctx context.Context, db *gorm.DB, store store.Store) fiber.Handler {
@@ -437,21 +726,22 @@ func K8sPodLogs(ctx context.Context, db *gorm.DB, store store.Store) fiber.Handl
req := clientset.CoreV1().Pods(namespace).GetLogs(podName, podLogOpts) req := clientset.CoreV1().Pods(namespace).GetLogs(podName, podLogOpts)
logCtx, cancel := context.WithCancel(context.Background()) logCtx, cancel := context.WithCancel(c.Context())
defer cancel()
stream, err := req.Stream(logCtx) stream, err := req.Stream(logCtx)
if err != nil { if err != nil {
cancel()
return resp.R500(c, "", nil, fmt.Errorf("failed to get pod logs: %w", err)) return resp.R500(c, "", nil, fmt.Errorf("failed to get pod logs: %w", err))
} }
defer stream.Close()
// Use the existing SSE manager from resp package // Use the existing SSE manager from resp package
manager := resp.SSE(c, "pod-logs") manager := resp.SSE(c, "pod-logs")
// Start streaming logs in a goroutine // Start streaming logs in a goroutine
go func() { go func() {
defer stream.Close()
defer manager.Close() defer manager.Close()
defer cancel()
reader := bufio.NewReader(stream) reader := bufio.NewReader(stream)
for { for {
@@ -462,20 +752,18 @@ func K8sPodLogs(ctx context.Context, db *gorm.DB, store store.Store) fiber.Handl
line, err := reader.ReadString('\n') line, err := reader.ReadString('\n')
if err != nil { if err != nil {
if err == io.EOF { if err == io.EOF {
manager.Send("[EOF]") manager.JSON(map[string]any{"type": "EOF"})
return return
} }
manager.Send(fmt.Sprintf("error: %v", err)) manager.JSON(map[string]any{"type": "error", "data": err.Error()})
return return
} }
manager.Send(line) manager.JSON(map[string]any{"data": line, "type": "log"})
} }
} }
}() }()
// Return nil since we're handling the response directly return c.SendStreamWriter(manager.Writer())
c.Context().SetBodyStreamWriter(manager.Writer())
return nil
} }
} }
@@ -510,3 +798,161 @@ func K8sPodDelete(ctx context.Context, db *gorm.DB, store store.Store) fiber.Han
}) })
} }
} }
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,
})
}
}

View File

@@ -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

View File

@@ -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(&registryConfig).Error; err == nil { if err := db.Where("key = ?", "registry_address").First(&registryConfig).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 := &registry.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,

View File

@@ -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 != "" {

View File

@@ -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,
} }

View File

@@ -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(&registryConfig).Error; err == nil { if err := db.Where("key = ?", "registry_address").First(&registryConfig).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")

View File

@@ -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(&registry.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(&registry.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(&registry.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(&registry.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(&registry.Tag{
Repository: repo, Repository: repo,
Tag: tag, Tag: tag,
Digest: manifestDigest, Digest: manifestDigest,

View File

@@ -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(&registryConfig).Error; err == nil { if err := db.Where("key = ?", "registry_address").First(&registryConfig).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(&registry.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

View File

@@ -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(&registry.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(&registry.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(&registry.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(&registry.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(&registry.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(&registry.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(&registry.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(&registry.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(&registry.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(&registry.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(&registry.Tag{
Repository: repoName, Repository: repoName,
Tag: tag, Tag: tag,
Digest: manifestDigest, Digest: manifestDigest,

View File

@@ -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"
@@ -77,7 +77,7 @@ func HandleManifest(c fiber.Ctx, db *gorm.DB, store store.Store) error {
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(&registryConfig).Error; err == nil { if err := db.Where("key = ?", "registry_address").First(&registryConfig).Error; err == nil {
registryAddress = registryConfig.Value registryAddress = registryConfig.Value
@@ -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,7 +217,7 @@ 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) {
@@ -233,7 +233,7 @@ func handleManifestGet(c fiber.Ctx, db *gorm.DB, store store.Store, repo string,
} }
// ???? 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,7 +302,7 @@ 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) {
@@ -318,7 +318,7 @@ func handleManifestHead(c fiber.Ctx, db *gorm.DB, store store.Store, repo string
} }
// ???? 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")
@@ -360,7 +360,7 @@ func handleManifestDelete(c fiber.Ctx, db *gorm.DB, store store.Store, repo stri
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")
@@ -370,13 +370,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(&registry.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")
@@ -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(&registry.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)

View File

@@ -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{}, &registry.Repository{},
&model.Blob{}, &registry.Blob{},
&model.Manifest{}, &registry.Manifest{},
&model.Tag{}, &registry.Tag{},
&model.BlobUpload{}, &registry.BlobUpload{},
&model.RegistryConfig{}, &registry.RegistryConfig{},
); err != nil { ); err != nil {
log.Fatalf("failed to migrate database: %v", err) log.Fatalf("failed to migrate database: %v", err)
} }

View File

@@ -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(&registryConfig).Error; err == nil { if err := db.Where("key = ?", "registry_address").First(&registryConfig).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
View 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
View 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
View 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"`
}

View 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"` // ???????????????
}

View 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"` // ?????
}

View 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
}

View 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?
}

View 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
View 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
}