feat: dynamic database storage size with PVC expansion
- Add dbStorageSize column to Application entity (default: 1Gi) - Add dbStorageSize to CreateApplicationDto, frontend types - Use dynamic storage size in K8s deployDatabase instead of hardcoded 5Gi - Deploy page: storage size selector with +/- buttons (min 1GB, max 100GB) - Auto-suggest storage based on DB dump file size (3x dump size, min 1GB) - Show DB storage in Review step - App detail: Database Storage section with expand button - GET /applications/:id/db-storage — read current PVC size from K8s - PATCH /applications/:id/db-storage — expand PVC (only increase, no shrink) - PVC resize uses JSON patch on K8s API
This commit is contained in:
@@ -93,6 +93,48 @@ export class ApplicationsController {
|
||||
};
|
||||
}
|
||||
|
||||
@Get(':id/db-storage')
|
||||
@ApiOperation({ summary: 'Get current database PVC storage size' })
|
||||
async getDbStorage(@Param('id') id: string, @Request() req: any) {
|
||||
const isStaff = req.user.role === UserRole.ADMIN || req.user.role === UserRole.TECHNICAL;
|
||||
const app = await this.applicationsService.findOne(id, isStaff ? undefined : req.user.id);
|
||||
|
||||
if (app.databaseType === DatabaseType.NONE) {
|
||||
throw new BadRequestException('This application does not have a database configured');
|
||||
}
|
||||
|
||||
const currentSize = await this.kubernetesService.getDatabasePvcSize(app);
|
||||
return { currentSize, savedSize: app.dbStorageSize || '1Gi' };
|
||||
}
|
||||
|
||||
@Patch(':id/db-storage')
|
||||
@ApiOperation({ summary: 'Resize (expand) database PVC storage' })
|
||||
async resizeDbStorage(
|
||||
@Param('id') id: string,
|
||||
@Request() req: any,
|
||||
@Body() body: { size: string },
|
||||
) {
|
||||
const isStaff = req.user.role === UserRole.ADMIN || req.user.role === UserRole.TECHNICAL;
|
||||
const app = await this.applicationsService.findOne(id, isStaff ? undefined : req.user.id);
|
||||
|
||||
if (app.databaseType === DatabaseType.NONE) {
|
||||
throw new BadRequestException('This application does not have a database configured');
|
||||
}
|
||||
|
||||
if (!body.size || !/^\d+Gi$/.test(body.size)) {
|
||||
throw new BadRequestException('Size must be in format like "1Gi", "5Gi", "10Gi"');
|
||||
}
|
||||
|
||||
const result = await this.kubernetesService.resizeDatabasePvc(app, body.size);
|
||||
|
||||
if (result.success) {
|
||||
// Update the saved size in the DB
|
||||
await this.applicationsService.update(id, app.userId, { dbStorageSize: body.size } as any);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List my applications' })
|
||||
async findAll(@Request() req: any) {
|
||||
|
||||
@@ -57,6 +57,11 @@ export class CreateApplicationDto {
|
||||
@IsString()
|
||||
dbPassword?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: '1Gi', description: 'Database PVC storage size (e.g. 1Gi, 5Gi, 10Gi). Default: 1Gi' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
dbStorageSize?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'https://github.com/user/repo.git' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
|
||||
@@ -44,6 +44,9 @@ export class Application {
|
||||
@Column({ nullable: true })
|
||||
dbPassword: string;
|
||||
|
||||
@Column({ nullable: true, default: '1Gi' })
|
||||
dbStorageSize: string; // PVC storage size for database (e.g. '1Gi', '5Gi', '10Gi')
|
||||
|
||||
@Column({ nullable: true })
|
||||
gitUrl: string;
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ interface ManifestContext {
|
||||
dbUsername: string;
|
||||
dbPassword: string;
|
||||
dbVersion: string;
|
||||
dbStorageSize: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@@ -99,6 +100,7 @@ export class KubernetesService implements OnModuleInit {
|
||||
dbUsername: app.dbUsername || 'appuser',
|
||||
dbPassword: app.dbPassword || this.generatePassword(),
|
||||
dbVersion: app.dbVersion || '',
|
||||
dbStorageSize: app.dbStorageSize || '1Gi',
|
||||
};
|
||||
|
||||
const manifests: Record<string, any> = {};
|
||||
@@ -396,7 +398,7 @@ export class KubernetesService implements OnModuleInit {
|
||||
await this.createDbSecret(coreApi, ctx.namespace, ctx.appName, ctx.dbPassword, ctx.dbUsername);
|
||||
|
||||
// Create PVC for DB
|
||||
await this.createPVC(coreApi, ctx.namespace, dbName, '5Gi');
|
||||
await this.createPVC(coreApi, ctx.namespace, dbName, ctx.dbStorageSize);
|
||||
|
||||
// Deploy database
|
||||
const isPostgres = ctx.databaseType === DatabaseType.POSTGRESQL;
|
||||
@@ -1025,6 +1027,72 @@ export class KubernetesService implements OnModuleInit {
|
||||
return { success: true, logs };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize (expand) the database PVC for an application.
|
||||
* K8s only supports PVC expansion, not shrinking.
|
||||
*/
|
||||
async resizeDatabasePvc(app: Application, newSize: string): Promise<{ success: boolean; message: string }> {
|
||||
const { coreApi } = await this.getK8sClient(app.clusterId);
|
||||
const namespace = `user-${app.userId.split('-')[0]}`;
|
||||
const pvcName = `${app.name}-db`;
|
||||
|
||||
try {
|
||||
// Read current PVC to check current size
|
||||
const currentPvc = await coreApi.readNamespacedPersistentVolumeClaim(pvcName, namespace);
|
||||
const currentSize = currentPvc.body.spec?.resources?.requests?.storage || '1Gi';
|
||||
|
||||
const currentGi = parseInt(currentSize.replace('Gi', ''), 10) || 1;
|
||||
const newGi = parseInt(newSize.replace('Gi', ''), 10) || 1;
|
||||
|
||||
if (newGi <= currentGi) {
|
||||
return { success: false, message: `New size (${newSize}) must be larger than current size (${currentSize})` };
|
||||
}
|
||||
|
||||
// Patch PVC to expand
|
||||
const patch = [
|
||||
{
|
||||
op: 'replace',
|
||||
path: '/spec/resources/requests/storage',
|
||||
value: newSize,
|
||||
},
|
||||
];
|
||||
|
||||
await coreApi.patchNamespacedPersistentVolumeClaim(
|
||||
pvcName,
|
||||
namespace,
|
||||
patch,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{ headers: { 'Content-Type': 'application/json-patch+json' } },
|
||||
);
|
||||
|
||||
this.logger.log(`Resized PVC ${pvcName} from ${currentSize} to ${newSize}`);
|
||||
return { success: true, message: `Database storage expanded from ${currentSize} to ${newSize}` };
|
||||
} catch (e: any) {
|
||||
this.logger.error(`Failed to resize PVC ${pvcName}: ${e.message}`);
|
||||
return { success: false, message: e.body?.message || e.message || 'Failed to resize database storage' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current PVC size for an application's database.
|
||||
*/
|
||||
async getDatabasePvcSize(app: Application): Promise<string> {
|
||||
try {
|
||||
const { coreApi } = await this.getK8sClient(app.clusterId);
|
||||
const namespace = `user-${app.userId.split('-')[0]}`;
|
||||
const pvcName = `${app.name}-db`;
|
||||
|
||||
const pvc = await coreApi.readNamespacedPersistentVolumeClaim(pvcName, namespace);
|
||||
return pvc.body.spec?.resources?.requests?.storage || app.dbStorageSize || '1Gi';
|
||||
} catch {
|
||||
return app.dbStorageSize || '1Gi';
|
||||
}
|
||||
}
|
||||
|
||||
private generatePassword(length = 24): string {
|
||||
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%';
|
||||
let password = '';
|
||||
|
||||
@@ -66,6 +66,8 @@ export default function AppDetailPage() {
|
||||
memoryLimit: '',
|
||||
replicas: 1,
|
||||
});
|
||||
const [dbStorageSize, setDbStorageSize] = useState('1');
|
||||
const [dbStorageLoading, setDbStorageLoading] = useState(false);
|
||||
|
||||
const { data: app, isLoading } = useQuery<Application>({
|
||||
queryKey: ['application', appId],
|
||||
@@ -109,6 +111,36 @@ export default function AppDetailPage() {
|
||||
queryFn: () => api.get('/clusters/pools/public').then((r) => r.data),
|
||||
});
|
||||
|
||||
// Fetch DB storage size
|
||||
const { data: dbStorageData } = useQuery<{ currentSize: string; savedSize: string }>({
|
||||
queryKey: ['db-storage', appId],
|
||||
queryFn: () => api.get(`/applications/${appId}/db-storage`).then((r) => r.data),
|
||||
enabled: !!app && app.databaseType !== 'none',
|
||||
});
|
||||
|
||||
// Sync dbStorageSize state when data loads
|
||||
useEffect(() => {
|
||||
if (dbStorageData?.currentSize) {
|
||||
const sizeNum = parseInt(dbStorageData.currentSize.replace('Gi', ''), 10) || 1;
|
||||
setDbStorageSize(String(sizeNum));
|
||||
}
|
||||
}, [dbStorageData]);
|
||||
|
||||
const resizeDbMutation = useMutation({
|
||||
mutationFn: (size: string) => api.patch(`/applications/${appId}/db-storage`, { size }),
|
||||
onSuccess: (res) => {
|
||||
if (res.data.success) {
|
||||
toast.success(res.data.message || 'Database storage expanded!');
|
||||
queryClient.invalidateQueries({ queryKey: ['db-storage', appId] });
|
||||
} else {
|
||||
toast.error(res.data.message || 'Failed to expand storage');
|
||||
}
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast.error(err.response?.data?.message || 'Failed to resize database storage');
|
||||
},
|
||||
});
|
||||
|
||||
// Sync form when resource data loads
|
||||
useEffect(() => {
|
||||
if (resourceUsage?.configured) {
|
||||
@@ -667,6 +699,71 @@ export default function AppDetailPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Database Storage Management */}
|
||||
<div className="bg-gray-50 rounded-xl p-4 mb-4">
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-3">Database Storage</h3>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-xs text-gray-500">Current Size:</span>
|
||||
<span className="text-sm font-semibold text-gray-800">{dbStorageData?.currentSize || app.dbStorageSize || '1Gi'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center border border-gray-300 rounded-lg overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const current = parseInt(dbStorageSize, 10);
|
||||
const min = parseInt((dbStorageData?.currentSize || '1Gi').replace('Gi', ''), 10) || 1;
|
||||
if (current > min + 1) setDbStorageSize(String(current - 1));
|
||||
}}
|
||||
className="px-3 py-1.5 bg-gray-100 hover:bg-gray-200 text-gray-700 font-bold text-sm transition-colors"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
value={dbStorageSize}
|
||||
onChange={(e) => {
|
||||
const val = Math.max(1, Math.min(100, parseInt(e.target.value, 10) || 1));
|
||||
setDbStorageSize(String(val));
|
||||
}}
|
||||
className="w-14 text-center py-1.5 border-x border-gray-300 text-sm font-semibold focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const current = parseInt(dbStorageSize, 10);
|
||||
if (current < 100) setDbStorageSize(String(current + 1));
|
||||
}}
|
||||
className="px-3 py-1.5 bg-gray-100 hover:bg-gray-200 text-gray-700 font-bold text-sm transition-colors"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-sm text-gray-600">GB</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const newSize = `${parseInt(dbStorageSize, 10)}Gi`;
|
||||
resizeDbMutation.mutate(newSize);
|
||||
}}
|
||||
disabled={
|
||||
resizeDbMutation.isPending ||
|
||||
parseInt(dbStorageSize, 10) <= (parseInt((dbStorageData?.currentSize || '1Gi').replace('Gi', ''), 10) || 1)
|
||||
}
|
||||
className="btn-primary text-xs px-3 py-1.5 disabled:opacity-50"
|
||||
>
|
||||
{resizeDbMutation.isPending ? 'Expanding...' : 'Expand'}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mt-1">فقط امکان افزایش حجم وجود دارد (کاهش ممکن نیست)</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* DB Dump Upload */}
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-3">Restore Database Dump</h3>
|
||||
<div
|
||||
|
||||
@@ -34,6 +34,7 @@ export default function DeployPage() {
|
||||
memoryLimit: '512Mi',
|
||||
replicas: 1,
|
||||
port: 3000,
|
||||
dbStorageSize: '1',
|
||||
});
|
||||
const [envKey, setEnvKey] = useState('');
|
||||
const [envVal, setEnvVal] = useState('');
|
||||
@@ -117,7 +118,12 @@ export default function DeployPage() {
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
createMutation.mutate(form);
|
||||
const payload = { ...form };
|
||||
// Format dbStorageSize with Gi suffix
|
||||
if (payload.databaseType !== 'none' && payload.dbStorageSize) {
|
||||
payload.dbStorageSize = `${parseInt(payload.dbStorageSize, 10) || 1}Gi`;
|
||||
}
|
||||
createMutation.mutate(payload);
|
||||
};
|
||||
|
||||
const handleFileSelect = useCallback((file: File) => {
|
||||
@@ -613,6 +619,8 @@ export default function DeployPage() {
|
||||
toast.error('Max 500MB');
|
||||
} else {
|
||||
setDbDumpFile(f);
|
||||
const sizeGb = Math.max(1, Math.ceil((f.size / (1024 * 1024 * 1024)) * 3));
|
||||
setForm((prev) => ({ ...prev, dbStorageSize: String(sizeGb) }));
|
||||
}
|
||||
}
|
||||
}}
|
||||
@@ -634,6 +642,8 @@ export default function DeployPage() {
|
||||
toast.error('Max 500MB');
|
||||
} else {
|
||||
setDbDumpFile(f);
|
||||
const sizeGb = Math.max(1, Math.ceil((f.size / (1024 * 1024 * 1024)) * 3));
|
||||
setForm((prev) => ({ ...prev, dbStorageSize: String(sizeGb) }));
|
||||
}
|
||||
}
|
||||
e.target.value = '';
|
||||
@@ -657,6 +667,55 @@ export default function DeployPage() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Database Storage Size */}
|
||||
<div className="pt-2">
|
||||
<label className="block text-xs text-gray-500 mb-2">Database Storage Size</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center border border-gray-300 rounded-lg overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const current = parseInt(form.dbStorageSize || '1', 10);
|
||||
if (current > 1) setForm({ ...form, dbStorageSize: String(current - 1) });
|
||||
}}
|
||||
disabled={parseInt(form.dbStorageSize || '1', 10) <= 1}
|
||||
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 font-bold disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
value={form.dbStorageSize || '1'}
|
||||
onChange={(e) => {
|
||||
const val = Math.max(1, Math.min(100, parseInt(e.target.value, 10) || 1));
|
||||
setForm({ ...form, dbStorageSize: String(val) });
|
||||
}}
|
||||
className="w-16 text-center py-2 border-x border-gray-300 text-sm font-semibold focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const current = parseInt(form.dbStorageSize || '1', 10);
|
||||
if (current < 100) setForm({ ...form, dbStorageSize: String(current + 1) });
|
||||
}}
|
||||
disabled={parseInt(form.dbStorageSize || '1', 10) >= 100}
|
||||
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 font-bold disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-700">GB</span>
|
||||
{dbDumpFile && (
|
||||
<span className="text-xs text-blue-500">
|
||||
پیشنهاد بر اساس حجم دامپ ({(dbDumpFile.size / (1024 * 1024 * 1024)).toFixed(2)} GB)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-gray-400">حداقل ۱ گیگابایت • بعد از ساخت فقط امکان افزایش حجم وجود دارد</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -970,6 +1029,16 @@ export default function DeployPage() {
|
||||
<span className="text-sm text-gray-500">DB Password</span>
|
||||
<span className="text-sm font-medium">{form.dbPassword ? '••••••••' : 'Auto-generated'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-gray-500">DB Storage</span>
|
||||
<span className="text-sm font-medium">{form.dbStorageSize || '1'} GB</span>
|
||||
</div>
|
||||
{dbDumpFile && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-gray-500">DB Dump</span>
|
||||
<span className="text-sm font-medium">{dbDumpFile.name} ({(dbDumpFile.size / (1024 * 1024)).toFixed(1)} MB)</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div className="flex justify-between">
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface Application {
|
||||
dbVersion?: string;
|
||||
dbUsername?: string;
|
||||
dbPassword?: string;
|
||||
dbStorageSize?: string;
|
||||
gitUrl?: string;
|
||||
gitToken?: string;
|
||||
gitBranch?: string;
|
||||
@@ -97,6 +98,7 @@ export interface CreateApplicationDto {
|
||||
dbVersion?: string;
|
||||
dbUsername?: string;
|
||||
dbPassword?: string;
|
||||
dbStorageSize?: string;
|
||||
gitUrl?: string;
|
||||
gitToken?: string;
|
||||
gitBranch?: string;
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user