Compare commits

...

3 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
38 changed files with 1646 additions and 246 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 { Routes, Route, Link } from 'react-router-dom'
import { Container, Typography, Box, AppBar, Toolbar, Button, Stack, Menu, MenuItem, Avatar } from '@mui/material'
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 { useAuthStore } from './stores/authStore'
import RegistryImageList from './pages/RegistryImageList'
import K8sResourceList from './pages/K8sResourceList'
import UserManagement from './pages/UserManagement'
import Login from './pages/Login'
function App() {
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 (
<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="/registry/image"></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>
</AppBar>
<Container maxWidth="xl" sx={{ mt: 4, mb: 4 }}>
<Routes>
<Route path="/registry/image" element={<RegistryImageList />} />
<Route path="/k8s/resources" element={<K8sResourceList />} />
<Route path="/users" element={<UserManagement />} />
<Route path="/" element={
<Box>
<Typography variant="h4" component="h1" gutterBottom>

View File

@@ -76,6 +76,7 @@ export default function K8sResourceList() {
const [logsDialogOpen, setLogsDialogOpen] = useState(false)
const [logs, setLogs] = useState<string[]>([])
const [selectedPod, setSelectedPod] = useState<{ name: string; namespace: string } | null>(null)
const eventSourceRef = useRef<EventSource | null>(null)
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [deleteTarget, setDeleteTarget] = useState<{ name: string; namespace: string } | null>(null)
const [deleting, setDeleting] = useState(false)
@@ -98,6 +99,17 @@ export default function K8sResourceList() {
}
}, [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 () => {
try {
const res = await fetch('/api/v1/k8s/config')
@@ -171,24 +183,62 @@ export default function K8sResourceList() {
}
const handleViewLogs = (podName: string, podNamespace: string) => {
console.log('handleViewLogs called with:', { podName, podNamespace })
setSelectedPod({ name: podName, namespace: podNamespace })
setLogs([])
setLogsDialogOpen(true)
// Close any existing connection
if (eventSourceRef.current) {
console.log('Closing existing EventSource connection')
eventSourceRef.current.close()
eventSourceRef.current = null
}
const eventSource = new EventSource(
`/api/v1/k8s/pod/logs?name=${encodeURIComponent(podName)}&namespace=${encodeURIComponent(podNamespace)}&tail=1000&follow=true`
)
eventSource.onmessage = (event) => {
setLogs((prev) => [...prev, event.data])
setTimeout(() => logsEndRef.current?.scrollIntoView({ behavior: 'smooth' }), 100)
}
// Save reference to the EventSource
eventSourceRef.current = eventSource
// Listen for the specific event type 'pod-logs'
eventSource.addEventListener('pod-logs', (event: MessageEvent) => {
try {
const message = JSON.parse(event.data)
if (message.type === 'log') {
setLogs((prev) => [...prev, message.data])
setTimeout(() => logsEndRef.current?.scrollIntoView({ behavior: 'smooth' }), 100)
} else if (message.type === 'EOF') {
// Handle end of stream if needed
} else if (message.type === 'error') {
setLogs((prev) => [...prev, `Error: ${message.data}`])
}
} catch (e) {
// If parsing fails, treat as plain text (fallback)
setLogs((prev) => [...prev, event.data])
setTimeout(() => logsEndRef.current?.scrollIntoView({ behavior: 'smooth' }), 100)
}
})
eventSource.onerror = () => {
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 () => {
@@ -843,7 +893,7 @@ export default function K8sResourceList() {
<Dialog
open={logsDialogOpen}
onClose={() => setLogsDialogOpen(false)}
onClose={handleCloseLogsDialog}
maxWidth="lg"
fullWidth
>
@@ -852,7 +902,7 @@ export default function K8sResourceList() {
<Typography variant="h6">
Pod : {selectedPod?.name} ({selectedPod?.namespace})
</Typography>
<IconButton onClick={() => setLogsDialogOpen(false)}>
<IconButton onClick={handleCloseLogsDialog}>
<CloseIcon />
</IconButton>
</Box>

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 (
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/google/go-containerregistry v0.20.6
github.com/jedib0t/go-pretty/v6 v6.7.1
@@ -12,6 +12,7 @@ require (
golang.org/x/crypto v0.43.0
golang.org/x/net v0.46.0
gorm.io/gorm v1.31.1
k8s.io/api v0.34.1
k8s.io/apimachinery v0.34.1
k8s.io/client-go v0.34.1
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/jsonreference v0.20.2 // 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/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/uuid v1.6.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/opencontainers/go-digest v1.0.0 // 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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/sirupsen/logrus v1.9.3 // 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/fasthttp v1.65.0 // 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/inf.v0 v0.9.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/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // 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.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
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/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-beta.2/go.mod h1:w7sdfTY0okjZ1oVH6rSOGvuACUIt0By1iK0HKUb3uqM=
github.com/gofiber/fiber/v3 v3.0.0-rc.2 h1:5I3RQ7XygDBfWRlMhkATjyJKupMmfMAVmnsrgo6wmc0=
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/go.mod h1:Y1g08g7gvST49bbjHJ1AVqcsmg93912R/tbKWhn6V3E=
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/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
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/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
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/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
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/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
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/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
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.2.3/go.mod h1:6khjYnkx73f7VQU7wjcFS9DFjs+59naVWJv1TB7qdOI=
github.com/shamaton/msgpack/v2 v2.3.1 h1:R3QNLIGA/tbdczNMZ5PCRxrXvy+fnzsIaHG4kKMgWYo=
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/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
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.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
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/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.65.0 h1:j/u3uzFEGFfRxw79iYzJN+TteTJwbYkru9uDp3d0Yf8=

View File

@@ -7,9 +7,10 @@ import (
"net"
"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/registry"
registry_model "gitea.loveuer.com/loveuer/cluster/pkg/model/registry"
"gitea.loveuer.com/loveuer/cluster/pkg/store"
"github.com/gofiber/fiber/v3"
"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
// 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)
}
// Initialize auth module
if err := auth.Init(ctx, db); err != nil {
log.Printf("Warning: failed to initialize auth module: %v", err)
}
// Initialize k8s module
if err := k8s.Init(ctx, db, store); err != nil {
log.Printf("Warning: failed to initialize k8s module: %v", err)
@@ -87,6 +93,20 @@ func Init(ctx context.Context, address string, db *gorm.DB, store store.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)
if err != nil {
return fn, fmt.Errorf("failed to listen on %s: %w", address, err)

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"
"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/store"
"github.com/gofiber/fiber/v3"
@@ -13,7 +13,7 @@ import (
func ClusterConfigGet(ctx context.Context, db *gorm.DB, store store.Store) fiber.Handler {
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 == gorm.ErrRecordNotFound {
@@ -40,7 +40,7 @@ func ClusterConfigSet(ctx context.Context, db *gorm.DB, store store.Store) fiber
return resp.R400(c, "", nil, err)
}
config := model.ClusterConfig{
config := k8s.ClusterConfig{
Key: "kubeconfig",
Value: req.Kubeconfig,
}

View File

@@ -8,13 +8,14 @@ import (
"io"
"strings"
"gitea.loveuer.com/loveuer/cluster/internal/model"
k8sModel "gitea.loveuer.com/loveuer/cluster/pkg/model/k8s"
"gitea.loveuer.com/loveuer/cluster/pkg/resp"
"gitea.loveuer.com/loveuer/cluster/pkg/store"
"github.com/gofiber/fiber/v3"
"gorm.io/gorm"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/client-go/dynamic"
@@ -22,11 +23,10 @@ import (
"k8s.io/client-go/rest"
"k8s.io/client-go/tools/clientcmd"
"sigs.k8s.io/yaml"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
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 {
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) {
var config model.ClusterConfig
var config k8sModel.ClusterConfig
if err := db.Where("key = ?", "kubeconfig").First(&config).Error; err != nil {
return nil, fmt.Errorf("kubeconfig not found: %w", err)
@@ -386,25 +386,25 @@ func K8sResourceGet(ctx context.Context, db *gorm.DB, store store.Store) fiber.H
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",
"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 {
@@ -428,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"))
}
var config model.ClusterConfig
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))
}
@@ -636,7 +636,7 @@ func K8sResourceUpdate(ctx context.Context, db *gorm.DB, store store.Store) fibe
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 {
return resp.R500(c, "", nil, fmt.Errorf("kubeconfig not found: %w", err))
}
@@ -726,21 +726,22 @@ func K8sPodLogs(ctx context.Context, db *gorm.DB, store store.Store) fiber.Handl
req := clientset.CoreV1().Pods(namespace).GetLogs(podName, podLogOpts)
logCtx, cancel := context.WithCancel(context.Background())
defer cancel()
logCtx, cancel := context.WithCancel(c.Context())
stream, err := req.Stream(logCtx)
if err != nil {
cancel()
return resp.R500(c, "", nil, fmt.Errorf("failed to get pod logs: %w", err))
}
defer stream.Close()
// Use the existing SSE manager from resp package
manager := resp.SSE(c, "pod-logs")
// Start streaming logs in a goroutine
go func() {
defer stream.Close()
defer manager.Close()
defer cancel()
reader := bufio.NewReader(stream)
for {
@@ -751,20 +752,18 @@ func K8sPodLogs(ctx context.Context, db *gorm.DB, store store.Store) fiber.Handl
line, err := reader.ReadString('\n')
if err != nil {
if err == io.EOF {
manager.Send("[EOF]")
manager.JSON(map[string]any{"type": "EOF"})
return
}
manager.Send(fmt.Sprintf("error: %v", err))
manager.JSON(map[string]any{"type": "error", "data": err.Error()})
return
}
manager.Send(line)
manager.JSON(map[string]any{"data": line, "type": "log"})
}
}
}()
// Return nil since we're handling the response directly
c.Context().SetBodyStreamWriter(manager.Writer())
return nil
return c.SendStreamWriter(manager.Writer())
}
}

View File

@@ -4,14 +4,14 @@ import (
"context"
"log"
"gitea.loveuer.com/loveuer/cluster/internal/model"
k8s "gitea.loveuer.com/loveuer/cluster/pkg/model/k8s"
"gitea.loveuer.com/loveuer/cluster/pkg/store"
"gorm.io/gorm"
)
func Init(ctx context.Context, db *gorm.DB, store store.Store) error {
if err := db.AutoMigrate(
&model.ClusterConfig{},
&k8s.ClusterConfig{},
); err != nil {
log.Fatalf("failed to migrate k8s database: %v", err)
return err

View File

@@ -10,7 +10,7 @@ import (
"strconv"
"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/store"
"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], "/")
// Strip registry_address prefix from repo if present
var registryConfig model.RegistryConfig
var registryConfig registry.RegistryConfig
registryAddress := ""
if err := db.Where("key = ?", "registry_address").First(&registryConfig).Error; err == nil {
registryAddress = registryConfig.Value
@@ -120,7 +120,7 @@ func handleBlobUploadStart(c fiber.Ctx, db *gorm.DB, store store.Store, repo str
uuid := hex.EncodeToString(uuidBytes)
// ??????
upload := &model.BlobUpload{
upload := &registry.BlobUpload{
UUID: uuid,
Repository: repo,
Path: uuid, // ?? UUID ??????
@@ -150,7 +150,7 @@ func handleBlobUploadStart(c fiber.Ctx, db *gorm.DB, store store.Store, repo str
// handleBlobUploadChunk ?? blob ???
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 == gorm.ErrRecordNotFound {
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 ??
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 == gorm.ErrRecordNotFound {
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 ??
var blob model.Blob
var blob registry.Blob
if err := db.Where("digest = ?", digest).First(&blob).Error; err != nil {
if err == gorm.ErrRecordNotFound {
blob = model.Blob{
blob = registry.Blob{
Digest: digest,
Size: size,
Repository: repo,

View File

@@ -4,7 +4,7 @@ import (
"strconv"
"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/store"
"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")
// ????
var repos []model.Repository
var repos []registry.Repository
query := db.Order("name ASC").Limit(n + 1)
if last != "" {

View File

@@ -4,7 +4,7 @@ import (
"context"
"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/store"
"github.com/gofiber/fiber/v3"
@@ -14,7 +14,7 @@ import (
// RegistryConfigGet returns the registry configuration
func RegistryConfigGet(ctx context.Context, db *gorm.DB, store store.Store) fiber.Handler {
return func(c fiber.Ctx) error {
var configs []model.RegistryConfig
var configs []registry.RegistryConfig
if err := db.Find(&configs).Error; err != nil {
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
var config model.RegistryConfig
var config registry.RegistryConfig
err := db.Where("key = ?", req.Key).First(&config).Error
if err == gorm.ErrRecordNotFound {
// Create new config
config = model.RegistryConfig{
config = registry.RegistryConfig{
Key: req.Key,
Value: req.Value,
}

View File

@@ -12,7 +12,7 @@ import (
"strings"
"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/store"
"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)
// Get current registry_address to strip it from the request
var registryConfig model.RegistryConfig
var registryConfig registry.RegistryConfig
registryAddress := ""
if err := db.Where("key = ?", "registry_address").First(&registryConfig).Error; err == nil {
registryAddress = registryConfig.Value
@@ -71,7 +71,7 @@ func RegistryImageDownload(ctx context.Context, db *gorm.DB, store store.Store)
// Find the repository
t1 := time.Now()
var repositoryModel model.Repository
var repositoryModel registry.Repository
if err := db.Where("name = ?", repository).First(&repositoryModel).Error; err != nil {
if err == gorm.ErrRecordNotFound {
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
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 == gorm.ErrRecordNotFound {
// 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
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 == gorm.ErrRecordNotFound {
return resp.R404(c, "MANIFEST_NOT_FOUND", nil, "manifest not found")

View File

@@ -14,7 +14,7 @@ import (
"strings"
"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/store"
"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))
// 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)
}
@@ -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)
}
if err := db.Create(&model.Blob{
if err := db.Create(&registry.Blob{
Digest: digest,
Size: manifest.Config.Size,
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)
}
if err := db.Create(&model.Blob{
if err := db.Create(&registry.Blob{
Digest: layerDigest,
Size: layerDesc.Size,
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)
}
if err := db.Create(&model.Manifest{
if err := db.Create(&registry.Manifest{
Repository: repo,
Tag: tag,
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)
}
if err := db.Create(&model.Tag{
if err := db.Create(&registry.Tag{
Repository: repo,
Tag: tag,
Digest: manifestDigest,

View File

@@ -3,7 +3,7 @@ package registry
import (
"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/store"
"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", "")
// Get current registry_address setting
var registryConfig model.RegistryConfig
var registryConfig registry.RegistryConfig
registryAddress := ""
if err := db.Where("key = ?", "registry_address").First(&registryConfig).Error; err == nil {
registryAddress = registryConfig.Value
@@ -26,7 +26,7 @@ func RegistryImageList(ctx context.Context, db *gorm.DB, store store.Store) fibe
registryAddress = "localhost:9119"
}
var repositories []model.Repository
var repositories []registry.Repository
// Query all repositories from the database
query := db
@@ -41,7 +41,7 @@ func RegistryImageList(ctx context.Context, db *gorm.DB, store store.Store) fibe
var result []map[string]interface{}
for _, repo := range repositories {
// 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 {
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 {
Total int64
}
err := db.Model(&model.Blob{}).
err := db.Model(&registry.Blob{}).
Where("repository = ?", repo.Name).
Select("COALESCE(SUM(size), 0) as total").
Scan(&sizeResult).Error

View File

@@ -10,7 +10,7 @@ import (
"io"
"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/store"
"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
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))
}
@@ -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))
}
if err := db.Create(&model.Blob{
if err := db.Create(&registry.Blob{
Digest: configDigest,
Size: int64(len(configContent)),
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))
}
if err := db.Create(&model.Blob{
if err := db.Create(&registry.Blob{
Digest: digest,
Size: int64(len(content)),
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))
}
if err := db.Create(&model.Manifest{
if err := db.Create(&registry.Manifest{
Repository: repoName,
Tag: tag,
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))
}
if err := db.Create(&model.Tag{
if err := db.Create(&registry.Tag{
Repository: repoName,
Tag: tag,
Digest: manifestDigest,
@@ -277,7 +277,7 @@ func handleOCIFormat(c fiber.Ctx, db *gorm.DB, store store.Store, ociIndex *OCII
}
// 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))
}
@@ -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 {
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,
Size: int64(len(indexJSON)),
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 {
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,
Size: ociManifest.Config.Size,
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 {
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,
Size: layer.Size,
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))
}
if err := db.Create(&model.Manifest{
if err := db.Create(&registry.Manifest{
Repository: repoName,
Tag: tag,
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))
}
if err := db.Create(&model.Tag{
if err := db.Create(&registry.Tag{
Repository: repoName,
Tag: tag,
Digest: manifestDigest,

View File

@@ -7,7 +7,7 @@ import (
"fmt"
"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/store"
"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], "/")
// Strip registry_address prefix from repo if present
var registryConfig model.RegistryConfig
var registryConfig registry.RegistryConfig
registryAddress := ""
if err := db.Where("key = ?", "registry_address").First(&registryConfig).Error; err == nil {
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 == gorm.ErrRecordNotFound {
repository = model.Repository{Name: repo}
repository = registry.Repository{Name: repo}
if err := db.Create(&repository).Error; err != nil {
return resp.R500(c, "", nil, err)
}
@@ -158,11 +158,11 @@ func handleManifestPut(c fiber.Ctx, db *gorm.DB, store store.Store, repo string,
}
// ?? manifest ?????
var manifest model.Manifest
var manifest registry.Manifest
if err := db.Where("digest = ?", digest).First(&manifest).Error; err != nil {
if err == gorm.ErrRecordNotFound {
// ???? manifest ??
manifest = model.Manifest{
manifest = registry.Manifest{
Repository: repo,
Tag: tag,
Digest: digest,
@@ -186,10 +186,10 @@ func handleManifestPut(c fiber.Ctx, db *gorm.DB, store store.Store, repo string,
}
// ????? 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 == gorm.ErrRecordNotFound {
tagRecord = model.Tag{
tagRecord = registry.Tag{
Repository: repo,
Tag: tag,
Digest: digest,
@@ -217,7 +217,7 @@ func handleManifestPut(c fiber.Ctx, db *gorm.DB, store store.Store, repo string,
// handleManifestGet ?? manifest
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 ??????????????????????
if isDigestFormat(tag) {
@@ -233,7 +233,7 @@ func handleManifestGet(c fiber.Ctx, db *gorm.DB, store store.Store, repo string,
}
// ???? 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 == gorm.ErrRecordNotFound {
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 {
// ?? 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 == gorm.ErrRecordNotFound {
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 ????
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 ??????????????????????
if isDigestFormat(tag) {
@@ -318,7 +318,7 @@ func handleManifestHead(c fiber.Ctx, db *gorm.DB, store store.Store, repo string
}
// ???? 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 == gorm.ErrRecordNotFound {
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 {
// ?? 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 == gorm.ErrRecordNotFound {
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
// ???? 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 == gorm.ErrRecordNotFound {
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
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)
}
// ??? tag ??????? manifest ??
if count == 0 {
var manifest model.Manifest
var manifest registry.Manifest
if err := db.Where("digest = ?", digest).First(&manifest).Error; err != nil {
if err == gorm.ErrRecordNotFound {
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 {
// ?? 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 == gorm.ErrRecordNotFound {
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
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)
}
// ?????? tag ????? manifest ??
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.Delete(&manifest).Error; err != nil {
return resp.R500(c, "", nil, err)

View File

@@ -5,7 +5,7 @@ import (
"log"
"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/store"
"github.com/gofiber/fiber/v3"
@@ -14,12 +14,12 @@ import (
func Registry(ctx context.Context, db *gorm.DB, store store.Store) fiber.Handler {
if err := db.AutoMigrate(
&model.Repository{},
&model.Blob{},
&model.Manifest{},
&model.Tag{},
&model.BlobUpload{},
&model.RegistryConfig{},
&registry.Repository{},
&registry.Blob{},
&registry.Manifest{},
&registry.Tag{},
&registry.BlobUpload{},
&registry.RegistryConfig{},
); err != nil {
log.Fatalf("failed to migrate database: %v", err)
}

View File

@@ -4,7 +4,7 @@ import (
"strconv"
"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/store"
"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], "/")
// Strip registry_address prefix from repo if present
var registryConfig model.RegistryConfig
var registryConfig registry.RegistryConfig
registryAddress := ""
if err := db.Where("key = ?", "registry_address").First(&registryConfig).Error; err == nil {
registryAddress = registryConfig.Value
@@ -58,7 +58,7 @@ func HandleTags(c fiber.Ctx, db *gorm.DB, store store.Store) error {
last := c.Query("last")
// ?? tags
var tags []model.Tag
var tags []registry.Tag
query := db.Where("repository = ?", repo).Order("tag ASC").Limit(n + 1)
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
}