Add time-limited external access for optional services and database.
Users can open temporary NodePort access with auto-revoke via Bull jobs and a dashboard UI to manage active grants. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,40 @@
|
|||||||
|
-- Temporary external access grants (Redis, RabbitMQ, database)
|
||||||
|
CREATE TYPE service_access_target AS ENUM (
|
||||||
|
'database',
|
||||||
|
'redis',
|
||||||
|
'rabbitmq_amqp',
|
||||||
|
'rabbitmq_management'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TYPE service_access_grant_status AS ENUM (
|
||||||
|
'active',
|
||||||
|
'expired',
|
||||||
|
'revoked'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS service_access_grants (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
"applicationId" UUID NOT NULL REFERENCES applications(id) ON DELETE CASCADE,
|
||||||
|
"userId" UUID NOT NULL,
|
||||||
|
"clusterId" UUID NOT NULL,
|
||||||
|
namespace VARCHAR(255) NOT NULL,
|
||||||
|
target service_access_target NOT NULL,
|
||||||
|
"nodePort" INTEGER NOT NULL,
|
||||||
|
"targetPort" INTEGER NOT NULL,
|
||||||
|
host VARCHAR(255) NOT NULL,
|
||||||
|
"k8sServiceName" VARCHAR(255) NOT NULL,
|
||||||
|
status service_access_grant_status NOT NULL DEFAULT 'active',
|
||||||
|
"expiresAt" TIMESTAMPTZ NOT NULL,
|
||||||
|
"createdAt" TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_service_access_grants_app_target_status
|
||||||
|
ON service_access_grants ("applicationId", target, status);
|
||||||
|
|
||||||
|
INSERT INTO platform_settings (id, key, value, description, "createdAt", "updatedAt")
|
||||||
|
SELECT gen_random_uuid(), 'access_max_duration_minutes', '240',
|
||||||
|
'Maximum duration (minutes) for temporary external service access',
|
||||||
|
NOW(), NOW()
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM platform_settings WHERE key = 'access_max_duration_minutes'
|
||||||
|
);
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { BullModule } from '@nestjs/bull';
|
||||||
|
import { AccessService } from './access.service';
|
||||||
|
import { AccessProcessor } from './access.processor';
|
||||||
|
import { ServiceAccessGrant } from './entities/service-access-grant.entity';
|
||||||
|
import { Application } from '../applications/entities/application.entity';
|
||||||
|
import { PlatformSetting } from '../billing/entities/platform-setting.entity';
|
||||||
|
import { KubernetesModule } from '../kubernetes/kubernetes.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([ServiceAccessGrant, Application, PlatformSetting]),
|
||||||
|
BullModule.registerQueue({ name: 'access-revoke' }),
|
||||||
|
KubernetesModule,
|
||||||
|
],
|
||||||
|
providers: [AccessService, AccessProcessor],
|
||||||
|
exports: [AccessService],
|
||||||
|
})
|
||||||
|
export class AccessModule {}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { Process, Processor } from '@nestjs/bull';
|
||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
import { Job } from 'bull';
|
||||||
|
import { AccessService, RevokeAccessJobData } from './access.service';
|
||||||
|
|
||||||
|
@Processor('access-revoke')
|
||||||
|
export class AccessProcessor {
|
||||||
|
private readonly logger = new Logger(AccessProcessor.name);
|
||||||
|
|
||||||
|
constructor(private readonly accessService: AccessService) {}
|
||||||
|
|
||||||
|
@Process('revoke')
|
||||||
|
async handleRevoke(job: Job<RevokeAccessJobData>) {
|
||||||
|
this.logger.log(`Revoking access grant ${job.data.grantId}`);
|
||||||
|
await this.accessService.revokeGrant(job.data.grantId, undefined, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,334 @@
|
|||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
OnModuleInit,
|
||||||
|
NotFoundException,
|
||||||
|
ConflictException,
|
||||||
|
BadRequestException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { InjectQueue } from '@nestjs/bull';
|
||||||
|
import { Repository, LessThan } from 'typeorm';
|
||||||
|
import { Queue } from 'bull';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import { ServiceAccessGrant } from './entities/service-access-grant.entity';
|
||||||
|
import { Application } from '../applications/entities/application.entity';
|
||||||
|
import { PlatformSetting } from '../billing/entities/platform-setting.entity';
|
||||||
|
import { KubernetesService } from '../kubernetes/kubernetes.service';
|
||||||
|
import {
|
||||||
|
ServiceAccessTarget,
|
||||||
|
ServiceAccessGrantStatus,
|
||||||
|
DatabaseType,
|
||||||
|
AppLifecycleStatus,
|
||||||
|
} from '../common/enums';
|
||||||
|
import { CreateServiceAccessDto } from './dto/service-access.dto';
|
||||||
|
|
||||||
|
export interface RevokeAccessJobData {
|
||||||
|
grantId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MIN_DURATION_MINUTES = 15;
|
||||||
|
const DEFAULT_MAX_DURATION_MINUTES = 240;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AccessService implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger(AccessService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(ServiceAccessGrant)
|
||||||
|
private readonly grantsRepo: Repository<ServiceAccessGrant>,
|
||||||
|
@InjectRepository(Application)
|
||||||
|
private readonly appsRepo: Repository<Application>,
|
||||||
|
@InjectRepository(PlatformSetting)
|
||||||
|
private readonly settingsRepo: Repository<PlatformSetting>,
|
||||||
|
private readonly kubernetesService: KubernetesService,
|
||||||
|
@InjectQueue('access-revoke')
|
||||||
|
private readonly revokeQueue: Queue<RevokeAccessJobData>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async onModuleInit(): Promise<void> {
|
||||||
|
const expired = await this.grantsRepo.find({
|
||||||
|
where: {
|
||||||
|
status: ServiceAccessGrantStatus.ACTIVE,
|
||||||
|
expiresAt: LessThan(new Date()),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
for (const grant of expired) {
|
||||||
|
try {
|
||||||
|
await this.revokeGrant(grant.id, grant.userId, true);
|
||||||
|
} catch (e: any) {
|
||||||
|
this.logger.warn(`Startup expiry failed for grant ${grant.id}: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (expired.length > 0) {
|
||||||
|
this.logger.log(`Expired ${expired.length} stale access grant(s) on startup`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getMaxDurationMinutes(): Promise<number> {
|
||||||
|
try {
|
||||||
|
const setting = await this.settingsRepo.findOne({
|
||||||
|
where: { key: 'access_max_duration_minutes' },
|
||||||
|
});
|
||||||
|
if (setting) {
|
||||||
|
const parsed = parseInt(setting.value, 10);
|
||||||
|
if (!Number.isNaN(parsed) && parsed >= MIN_DURATION_MINUTES) return parsed;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
return DEFAULT_MAX_DURATION_MINUTES;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async validateApplicationForAccess(app: Application): Promise<void> {
|
||||||
|
if (!app.clusterId) {
|
||||||
|
throw new BadRequestException('Application must be deployed to a cluster first');
|
||||||
|
}
|
||||||
|
if (!app.latestImageTag) {
|
||||||
|
throw new BadRequestException('Application must be deployed before requesting external access');
|
||||||
|
}
|
||||||
|
if (app.lifecycleStatus === AppLifecycleStatus.SUSPENDED) {
|
||||||
|
throw new BadRequestException('Application is suspended');
|
||||||
|
}
|
||||||
|
if (app.lifecycleStatus === AppLifecycleStatus.PENDING_DELETION) {
|
||||||
|
throw new BadRequestException('Application is pending deletion');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private validateTargetEnabled(app: Application, target: ServiceAccessTarget): void {
|
||||||
|
switch (target) {
|
||||||
|
case ServiceAccessTarget.DATABASE:
|
||||||
|
if (!app.databaseType || app.databaseType === DatabaseType.NONE) {
|
||||||
|
throw new BadRequestException('Application has no database');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case ServiceAccessTarget.REDIS:
|
||||||
|
if (!app.enableRedis) throw new BadRequestException('Redis is not enabled');
|
||||||
|
break;
|
||||||
|
case ServiceAccessTarget.RABBITMQ_AMQP:
|
||||||
|
case ServiceAccessTarget.RABBITMQ_MANAGEMENT:
|
||||||
|
if (!app.enableRabbitmq) throw new BadRequestException('RabbitMQ is not enabled');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildConnection(
|
||||||
|
target: ServiceAccessTarget,
|
||||||
|
host: string,
|
||||||
|
port: number,
|
||||||
|
creds: Record<string, string | number | undefined>,
|
||||||
|
app: Application,
|
||||||
|
): Record<string, string | number | undefined> {
|
||||||
|
const connection: Record<string, string | number | undefined> = {
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (creds.username) connection.username = creds.username as string;
|
||||||
|
if (creds.password) connection.password = creds.password as string;
|
||||||
|
if (creds.database) connection.database = creds.database as string;
|
||||||
|
|
||||||
|
switch (target) {
|
||||||
|
case ServiceAccessTarget.DATABASE: {
|
||||||
|
const dbType = app.databaseType;
|
||||||
|
const dbName = app.name.replace(/-/g, '_');
|
||||||
|
const user = (creds.username as string) || 'appuser';
|
||||||
|
const pass = creds.password as string;
|
||||||
|
if (dbType === DatabaseType.POSTGRESQL) {
|
||||||
|
connection.url = `postgresql://${user}:${pass}@${host}:${port}/${dbName}`;
|
||||||
|
} else if (dbType === DatabaseType.MONGODB) {
|
||||||
|
connection.url = `mongodb://${user}:${pass}@${host}:${port}/${dbName}?authSource=admin`;
|
||||||
|
} else {
|
||||||
|
connection.url = `mysql://${user}:${pass}@${host}:${port}/${dbName}`;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case ServiceAccessTarget.REDIS:
|
||||||
|
connection.url = creds.password
|
||||||
|
? `redis://:${creds.password}@${host}:${port}`
|
||||||
|
: `redis://${host}:${port}`;
|
||||||
|
break;
|
||||||
|
case ServiceAccessTarget.RABBITMQ_AMQP:
|
||||||
|
connection.url = `amqp://${creds.username || 'appuser'}:${creds.password}@${host}:${port}`;
|
||||||
|
break;
|
||||||
|
case ServiceAccessTarget.RABBITMQ_MANAGEMENT:
|
||||||
|
connection.url = `http://${host}:${port}`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return connection;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findApp(applicationId: string, userId?: string): Promise<Application> {
|
||||||
|
const where: { id: string; userId?: string } = { id: applicationId };
|
||||||
|
if (userId) where.userId = userId;
|
||||||
|
const app = await this.appsRepo.findOne({ where });
|
||||||
|
if (!app) throw new NotFoundException('Application not found');
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createGrant(
|
||||||
|
applicationId: string,
|
||||||
|
userId: string | undefined,
|
||||||
|
dto: CreateServiceAccessDto,
|
||||||
|
) {
|
||||||
|
const app = await this.findApp(applicationId, userId);
|
||||||
|
|
||||||
|
await this.validateApplicationForAccess(app);
|
||||||
|
this.validateTargetEnabled(app, dto.target);
|
||||||
|
|
||||||
|
const maxDuration = await this.getMaxDurationMinutes();
|
||||||
|
if (dto.durationMinutes > maxDuration) {
|
||||||
|
throw new BadRequestException(`Duration cannot exceed ${maxDuration} minutes`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await this.grantsRepo.findOne({
|
||||||
|
where: {
|
||||||
|
applicationId,
|
||||||
|
target: dto.target,
|
||||||
|
status: ServiceAccessGrantStatus.ACTIVE,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`Active access already exists for ${dto.target}. Revoke it first or wait until it expires.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const grantId = uuidv4();
|
||||||
|
const namespace = this.kubernetesService.getUserNamespace(app.userId);
|
||||||
|
const expiresAt = new Date(Date.now() + dto.durationMinutes * 60 * 1000);
|
||||||
|
|
||||||
|
let k8sResult: { host: string; nodePort: number; k8sServiceName: string; targetPort: number };
|
||||||
|
try {
|
||||||
|
k8sResult = await this.kubernetesService.createTemporaryAccess(app, dto.target, grantId);
|
||||||
|
} catch (e: any) {
|
||||||
|
throw new BadRequestException(e.message || 'Failed to open external access');
|
||||||
|
}
|
||||||
|
|
||||||
|
const grant = this.grantsRepo.create({
|
||||||
|
id: grantId,
|
||||||
|
applicationId,
|
||||||
|
userId: app.userId,
|
||||||
|
clusterId: app.clusterId!,
|
||||||
|
namespace,
|
||||||
|
target: dto.target,
|
||||||
|
nodePort: k8sResult.nodePort,
|
||||||
|
targetPort: k8sResult.targetPort,
|
||||||
|
host: k8sResult.host,
|
||||||
|
k8sServiceName: k8sResult.k8sServiceName,
|
||||||
|
status: ServiceAccessGrantStatus.ACTIVE,
|
||||||
|
expiresAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.grantsRepo.save(grant);
|
||||||
|
|
||||||
|
const delayMs = Math.max(0, expiresAt.getTime() - Date.now());
|
||||||
|
await this.revokeQueue.add('revoke', { grantId }, { delay: delayMs, jobId: grantId });
|
||||||
|
|
||||||
|
const creds = await this.kubernetesService.readAccessCredentials(app, dto.target);
|
||||||
|
const connection = this.buildConnection(
|
||||||
|
dto.target,
|
||||||
|
k8sResult.host,
|
||||||
|
k8sResult.nodePort,
|
||||||
|
creds,
|
||||||
|
app,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: grant.id,
|
||||||
|
target: grant.target,
|
||||||
|
host: grant.host,
|
||||||
|
port: grant.nodePort,
|
||||||
|
targetPort: grant.targetPort,
|
||||||
|
expiresAt: grant.expiresAt,
|
||||||
|
status: grant.status,
|
||||||
|
connection,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async listGrants(applicationId: string, userId: string | undefined, includeRecent = false) {
|
||||||
|
const app = await this.findApp(applicationId, userId);
|
||||||
|
|
||||||
|
const where: any = { applicationId };
|
||||||
|
if (!includeRecent) {
|
||||||
|
where.status = ServiceAccessGrantStatus.ACTIVE;
|
||||||
|
}
|
||||||
|
|
||||||
|
const grants = await this.grantsRepo.find({
|
||||||
|
where,
|
||||||
|
order: { createdAt: 'DESC' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const filtered = includeRecent
|
||||||
|
? grants
|
||||||
|
: grants.filter((g) => g.status === ServiceAccessGrantStatus.ACTIVE);
|
||||||
|
|
||||||
|
return Promise.all(
|
||||||
|
filtered.map(async (grant) => {
|
||||||
|
const creds = await this.kubernetesService.readAccessCredentials(app, grant.target);
|
||||||
|
const connection = this.buildConnection(
|
||||||
|
grant.target,
|
||||||
|
grant.host,
|
||||||
|
grant.nodePort,
|
||||||
|
creds,
|
||||||
|
app,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
id: grant.id,
|
||||||
|
target: grant.target,
|
||||||
|
host: grant.host,
|
||||||
|
port: grant.nodePort,
|
||||||
|
targetPort: grant.targetPort,
|
||||||
|
expiresAt: grant.expiresAt,
|
||||||
|
status: grant.status,
|
||||||
|
connection,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async revokeGrant(grantId: string, userId: string | undefined, system = false): Promise<void> {
|
||||||
|
const grant = await this.grantsRepo.findOne({
|
||||||
|
where: { id: grantId },
|
||||||
|
relations: ['application'],
|
||||||
|
});
|
||||||
|
if (!grant) throw new NotFoundException('Access grant not found');
|
||||||
|
if (!system && userId !== undefined && grant.userId !== userId) {
|
||||||
|
throw new NotFoundException('Access grant not found');
|
||||||
|
}
|
||||||
|
if (grant.status !== ServiceAccessGrantStatus.ACTIVE) return;
|
||||||
|
|
||||||
|
await this.kubernetesService.revokeTemporaryAccess(
|
||||||
|
grant.clusterId,
|
||||||
|
grant.namespace,
|
||||||
|
grant.k8sServiceName,
|
||||||
|
);
|
||||||
|
|
||||||
|
grant.status =
|
||||||
|
system && grant.expiresAt.getTime() <= Date.now()
|
||||||
|
? ServiceAccessGrantStatus.EXPIRED
|
||||||
|
: ServiceAccessGrantStatus.REVOKED;
|
||||||
|
await this.grantsRepo.save(grant);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const job = await this.revokeQueue.getJob(grantId);
|
||||||
|
if (job) await job.remove();
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async revokeAllForApplication(applicationId: string): Promise<void> {
|
||||||
|
const grants = await this.grantsRepo.find({
|
||||||
|
where: { applicationId, status: ServiceAccessGrantStatus.ACTIVE },
|
||||||
|
});
|
||||||
|
for (const grant of grants) {
|
||||||
|
await this.revokeGrant(grant.id, undefined, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async findGrantForApp(grantId: string, applicationId: string, userId?: string) {
|
||||||
|
const where: any = { id: grantId, applicationId };
|
||||||
|
if (userId) where.userId = userId;
|
||||||
|
const grant = await this.grantsRepo.findOne({ where });
|
||||||
|
if (!grant) throw new NotFoundException('Access grant not found');
|
||||||
|
return grant;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { IsEnum, IsInt, Min, Max } from 'class-validator';
|
||||||
|
import { ServiceAccessTarget } from '../../common/enums';
|
||||||
|
|
||||||
|
export class CreateServiceAccessDto {
|
||||||
|
@ApiProperty({ enum: ServiceAccessTarget })
|
||||||
|
@IsEnum(ServiceAccessTarget)
|
||||||
|
target: ServiceAccessTarget;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 60, description: 'Access duration in minutes (min 15)' })
|
||||||
|
@IsInt()
|
||||||
|
@Min(15)
|
||||||
|
@Max(1440)
|
||||||
|
durationMinutes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ServiceAccessConnectionDto {
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
host?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
port?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
username?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
password?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
database?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
url?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ServiceAccessGrantResponseDto {
|
||||||
|
@ApiProperty()
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: ServiceAccessTarget })
|
||||||
|
target: ServiceAccessTarget;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
host: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
port: number;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
targetPort: number;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
expiresAt: Date;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
status: string;
|
||||||
|
|
||||||
|
@ApiProperty({ type: ServiceAccessConnectionDto })
|
||||||
|
connection: ServiceAccessConnectionDto;
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
Index,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { ServiceAccessTarget, ServiceAccessGrantStatus } from '../../common/enums';
|
||||||
|
import { Application } from '../../applications/entities/application.entity';
|
||||||
|
|
||||||
|
@Entity('service_access_grants')
|
||||||
|
@Index(['applicationId', 'target', 'status'])
|
||||||
|
export class ServiceAccessGrant {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
applicationId: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Application, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'applicationId' })
|
||||||
|
application: Application;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
userId: string;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
clusterId: string;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
namespace: string;
|
||||||
|
|
||||||
|
@Column({ type: 'enum', enum: ServiceAccessTarget })
|
||||||
|
target: ServiceAccessTarget;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
nodePort: number;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
targetPort: number;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
host: string;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
k8sServiceName: string;
|
||||||
|
|
||||||
|
@Column({ type: 'enum', enum: ServiceAccessGrantStatus, default: ServiceAccessGrantStatus.ACTIVE })
|
||||||
|
status: ServiceAccessGrantStatus;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamptz' })
|
||||||
|
expiresAt: Date;
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
@@ -27,6 +27,8 @@ import { Roles } from '../common/decorators/roles.decorator';
|
|||||||
import { UserRole, DatabaseType } from '../common/enums';
|
import { UserRole, DatabaseType } from '../common/enums';
|
||||||
import { KubernetesService } from '../kubernetes/kubernetes.service';
|
import { KubernetesService } from '../kubernetes/kubernetes.service';
|
||||||
import { DeploymentsService } from '../deployments/deployments.service';
|
import { DeploymentsService } from '../deployments/deployments.service';
|
||||||
|
import { AccessService } from '../access/access.service';
|
||||||
|
import { CreateServiceAccessDto } from '../access/dto/service-access.dto';
|
||||||
|
|
||||||
@ApiTags('Applications')
|
@ApiTags('Applications')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@@ -41,8 +43,17 @@ export class ApplicationsController {
|
|||||||
private readonly kubernetesService: KubernetesService,
|
private readonly kubernetesService: KubernetesService,
|
||||||
@Inject(forwardRef(() => DeploymentsService))
|
@Inject(forwardRef(() => DeploymentsService))
|
||||||
private readonly deploymentsService: DeploymentsService,
|
private readonly deploymentsService: DeploymentsService,
|
||||||
|
private readonly accessService: AccessService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
private isStaff(role: string): boolean {
|
||||||
|
return role === UserRole.ADMIN || role === UserRole.TECHNICAL;
|
||||||
|
}
|
||||||
|
|
||||||
|
private staffUserIdFilter(req: any): string | undefined {
|
||||||
|
return this.isStaff(req.user.role) ? undefined : req.user.id;
|
||||||
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@ApiOperation({ summary: 'Create a new application' })
|
@ApiOperation({ summary: 'Create a new application' })
|
||||||
async create(@Request() req: any, @Body() dto: CreateApplicationDto) {
|
async create(@Request() req: any, @Body() dto: CreateApplicationDto) {
|
||||||
@@ -268,13 +279,41 @@ export class ApplicationsController {
|
|||||||
@Get(':id/preview')
|
@Get(':id/preview')
|
||||||
@ApiOperation({ summary: 'Get preview URL for the deployed application' })
|
@ApiOperation({ summary: 'Get preview URL for the deployed application' })
|
||||||
async getPreview(@Param('id') id: string, @Request() req: any) {
|
async getPreview(@Param('id') id: string, @Request() req: any) {
|
||||||
const app = await this.applicationsService.findOne(
|
const app = await this.applicationsService.findOne(id, this.staffUserIdFilter(req));
|
||||||
id,
|
|
||||||
(req.user.role === UserRole.ADMIN || req.user.role === UserRole.TECHNICAL) ? undefined : req.user.id,
|
|
||||||
);
|
|
||||||
return this.kubernetesService.getPreviewInfo(app);
|
return this.kubernetesService.getPreviewInfo(app);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post(':id/access')
|
||||||
|
@ApiOperation({ summary: 'Open temporary external access to database, Redis, or RabbitMQ' })
|
||||||
|
async createAccess(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Request() req: any,
|
||||||
|
@Body() dto: CreateServiceAccessDto,
|
||||||
|
) {
|
||||||
|
await this.applicationsService.findOne(id, this.staffUserIdFilter(req));
|
||||||
|
return this.accessService.createGrant(id, this.staffUserIdFilter(req), dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id/access')
|
||||||
|
@ApiOperation({ summary: 'List active temporary access grants for an application' })
|
||||||
|
async listAccess(@Param('id') id: string, @Request() req: any) {
|
||||||
|
await this.applicationsService.findOne(id, this.staffUserIdFilter(req));
|
||||||
|
return this.accessService.listGrants(id, this.staffUserIdFilter(req));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id/access/:grantId')
|
||||||
|
@ApiOperation({ summary: 'Revoke temporary external access' })
|
||||||
|
async revokeAccess(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Param('grantId') grantId: string,
|
||||||
|
@Request() req: any,
|
||||||
|
) {
|
||||||
|
const app = await this.applicationsService.findOne(id, this.staffUserIdFilter(req));
|
||||||
|
await this.accessService.findGrantForApp(grantId, id, this.staffUserIdFilter(req));
|
||||||
|
await this.accessService.revokeGrant(grantId, this.staffUserIdFilter(req));
|
||||||
|
return { message: 'Access revoked' };
|
||||||
|
}
|
||||||
|
|
||||||
// ── Custom Domain ─────────────────────────────────────────────
|
// ── Custom Domain ─────────────────────────────────────────────
|
||||||
|
|
||||||
@Post('domain/check-dns')
|
@Post('domain/check-dns')
|
||||||
@@ -351,7 +390,14 @@ export class ApplicationsController {
|
|||||||
// 1. Get the app first
|
// 1. Get the app first
|
||||||
const app = await this.applicationsService.findOne(id, isStaff ? undefined : req.user.id);
|
const app = await this.applicationsService.findOne(id, isStaff ? undefined : req.user.id);
|
||||||
|
|
||||||
// 2. Delete K8s resources (deployment, service, ingress, db, secrets, PVCs)
|
// 2. Revoke temporary access grants
|
||||||
|
try {
|
||||||
|
await this.accessService.revokeAllForApplication(app.id);
|
||||||
|
} catch (e: any) {
|
||||||
|
this.logger.warn(`Access grant cleanup failed for ${app.name}: ${e.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Delete K8s resources (deployment, service, ingress, db, secrets, PVCs)
|
||||||
try {
|
try {
|
||||||
await this.kubernetesService.deleteApplication(app);
|
await this.kubernetesService.deleteApplication(app);
|
||||||
this.logger.log(`Deleted K8s resources for ${app.name}`);
|
this.logger.log(`Deleted K8s resources for ${app.name}`);
|
||||||
@@ -359,14 +405,14 @@ export class ApplicationsController {
|
|||||||
this.logger.warn(`K8s cleanup failed for ${app.name}: ${e.message}`);
|
this.logger.warn(`K8s cleanup failed for ${app.name}: ${e.message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Delete deployment records from DB
|
// 4. Delete deployment records from DB
|
||||||
try {
|
try {
|
||||||
await this.deploymentsService.deleteAllForApplication(app.id);
|
await this.deploymentsService.deleteAllForApplication(app.id);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
this.logger.warn(`Deployment records cleanup failed for ${app.name}: ${e.message}`);
|
this.logger.warn(`Deployment records cleanup failed for ${app.name}: ${e.message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Delete app (also deletes uploaded files)
|
// 5. Delete app (also deletes uploaded files)
|
||||||
await this.applicationsService.delete(id, isStaff ? app.userId : req.user.id);
|
await this.applicationsService.delete(id, isStaff ? app.userId : req.user.id);
|
||||||
|
|
||||||
return { message: `Application "${app.name}" and all resources deleted` };
|
return { message: `Application "${app.name}" and all resources deleted` };
|
||||||
|
|||||||
@@ -8,12 +8,14 @@ import { PlatformSetting } from '../billing/entities/platform-setting.entity';
|
|||||||
import { ClustersModule } from '../clusters/clusters.module';
|
import { ClustersModule } from '../clusters/clusters.module';
|
||||||
import { KubernetesModule } from '../kubernetes/kubernetes.module';
|
import { KubernetesModule } from '../kubernetes/kubernetes.module';
|
||||||
import { DeploymentsModule } from '../deployments/deployments.module';
|
import { DeploymentsModule } from '../deployments/deployments.module';
|
||||||
|
import { AccessModule } from '../access/access.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([Application, PlatformSetting]),
|
TypeOrmModule.forFeature([Application, PlatformSetting]),
|
||||||
ClustersModule,
|
ClustersModule,
|
||||||
KubernetesModule,
|
KubernetesModule,
|
||||||
|
AccessModule,
|
||||||
forwardRef(() => DeploymentsModule),
|
forwardRef(() => DeploymentsModule),
|
||||||
],
|
],
|
||||||
controllers: [ApplicationsController],
|
controllers: [ApplicationsController],
|
||||||
|
|||||||
@@ -51,6 +51,20 @@ export enum OptionalService {
|
|||||||
ELASTICSEARCH = 'elasticsearch',
|
ELASTICSEARCH = 'elasticsearch',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Targets for time-limited external access via temporary NodePort */
|
||||||
|
export enum ServiceAccessTarget {
|
||||||
|
DATABASE = 'database',
|
||||||
|
REDIS = 'redis',
|
||||||
|
RABBITMQ_AMQP = 'rabbitmq_amqp',
|
||||||
|
RABBITMQ_MANAGEMENT = 'rabbitmq_management',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum ServiceAccessGrantStatus {
|
||||||
|
ACTIVE = 'active',
|
||||||
|
EXPIRED = 'expired',
|
||||||
|
REVOKED = 'revoked',
|
||||||
|
}
|
||||||
|
|
||||||
export enum DeploymentStatus {
|
export enum DeploymentStatus {
|
||||||
PENDING = 'pending',
|
PENDING = 'pending',
|
||||||
BUILDING = 'building',
|
BUILDING = 'building',
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { PassThrough } from 'stream';
|
|||||||
import { ClustersService } from '../clusters/clusters.service';
|
import { ClustersService } from '../clusters/clusters.service';
|
||||||
import { Application } from '../applications/entities/application.entity';
|
import { Application } from '../applications/entities/application.entity';
|
||||||
import { ensureAppUrlEnv } from '../applications/app-url.util';
|
import { ensureAppUrlEnv } from '../applications/app-url.util';
|
||||||
import { AppRuntime, DatabaseType, CustomDomainStatus } from '../common/enums';
|
import { AppRuntime, DatabaseType, CustomDomainStatus, ServiceAccessTarget } from '../common/enums';
|
||||||
import { HelmService } from './helm.service';
|
import { HelmService } from './helm.service';
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
@@ -1823,6 +1823,187 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
this.logger.log(`Updated resources for ${target.deploymentName} (${workload}): ${JSON.stringify(resources)}`);
|
this.logger.log(`Updated resources for ${target.deploymentName} (${workload}): ${JSON.stringify(resources)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getUserNamespace(userId: string): string {
|
||||||
|
return `user-${userId.split('-')[0]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getClusterHostIp(kc: k8s.KubeConfig): string {
|
||||||
|
const clusterServer = kc.getCurrentCluster()?.server || '';
|
||||||
|
try {
|
||||||
|
return new URL(clusterServer).hostname;
|
||||||
|
} catch {
|
||||||
|
return '127.0.0.1';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resolveAccessTarget(
|
||||||
|
app: Application,
|
||||||
|
target: ServiceAccessTarget,
|
||||||
|
): { selector: Record<string, string>; targetPort: number; portName?: string } {
|
||||||
|
switch (target) {
|
||||||
|
case ServiceAccessTarget.DATABASE: {
|
||||||
|
if (!app.databaseType || app.databaseType === DatabaseType.NONE) {
|
||||||
|
throw new BadRequestException('Application has no database');
|
||||||
|
}
|
||||||
|
let targetPort = 3306;
|
||||||
|
if (app.databaseType === DatabaseType.POSTGRESQL) targetPort = 5432;
|
||||||
|
else if (app.databaseType === DatabaseType.MONGODB) targetPort = 27017;
|
||||||
|
return { selector: { app: `${app.name}-db` }, targetPort };
|
||||||
|
}
|
||||||
|
case ServiceAccessTarget.REDIS:
|
||||||
|
if (!app.enableRedis) throw new BadRequestException('Redis is not enabled for this application');
|
||||||
|
return { selector: { app: `${app.name}-redis` }, targetPort: 6379 };
|
||||||
|
case ServiceAccessTarget.RABBITMQ_AMQP:
|
||||||
|
if (!app.enableRabbitmq) throw new BadRequestException('RabbitMQ is not enabled for this application');
|
||||||
|
return { selector: { app: `${app.name}-rabbitmq` }, targetPort: 5672, portName: 'amqp' };
|
||||||
|
case ServiceAccessTarget.RABBITMQ_MANAGEMENT:
|
||||||
|
if (!app.enableRabbitmq) throw new BadRequestException('RabbitMQ is not enabled for this application');
|
||||||
|
return { selector: { app: `${app.name}-rabbitmq` }, targetPort: 15672, portName: 'management' };
|
||||||
|
default:
|
||||||
|
throw new BadRequestException(`Unknown access target: ${target}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createTemporaryAccess(
|
||||||
|
app: Application,
|
||||||
|
target: ServiceAccessTarget,
|
||||||
|
grantId: string,
|
||||||
|
): Promise<{ host: string; nodePort: number; k8sServiceName: string; targetPort: number }> {
|
||||||
|
if (!app.clusterId) {
|
||||||
|
throw new BadRequestException('Application is not assigned to a cluster');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { coreApi, kc } = await this.getK8sClient(app.clusterId);
|
||||||
|
const namespace = this.getUserNamespace(app.userId);
|
||||||
|
const { selector, targetPort, portName } = this.resolveAccessTarget(app, target);
|
||||||
|
const shortId = grantId.split('-')[0];
|
||||||
|
const k8sServiceName = `${app.name}-${target}-access-${shortId}`.slice(0, 63);
|
||||||
|
|
||||||
|
const portSpec: k8s.V1ServicePort = {
|
||||||
|
port: targetPort,
|
||||||
|
targetPort: targetPort,
|
||||||
|
protocol: 'TCP',
|
||||||
|
};
|
||||||
|
if (portName) portSpec.name = portName;
|
||||||
|
|
||||||
|
const service: k8s.V1Service = {
|
||||||
|
apiVersion: 'v1',
|
||||||
|
kind: 'Service',
|
||||||
|
metadata: {
|
||||||
|
name: k8sServiceName,
|
||||||
|
namespace,
|
||||||
|
labels: {
|
||||||
|
app: selector.app,
|
||||||
|
'cloudhost.io/access-grant': 'true',
|
||||||
|
'cloudhost.io/grant-id': grantId,
|
||||||
|
'cloudhost.io/application-id': app.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
spec: {
|
||||||
|
type: 'NodePort',
|
||||||
|
selector,
|
||||||
|
ports: [portSpec],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const created = await coreApi.createNamespacedService(namespace, service);
|
||||||
|
const nodePort = created.body.spec?.ports?.[0]?.nodePort;
|
||||||
|
if (!nodePort) {
|
||||||
|
try {
|
||||||
|
await coreApi.deleteNamespacedService(k8sServiceName, namespace);
|
||||||
|
} catch {}
|
||||||
|
throw new Error('Failed to allocate NodePort for temporary access');
|
||||||
|
}
|
||||||
|
|
||||||
|
const host = this.getClusterHostIp(kc);
|
||||||
|
this.logger.log(
|
||||||
|
`Temporary access for ${app.name} target=${target}: ${host}:${nodePort} (service ${k8sServiceName})`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { host, nodePort, k8sServiceName, targetPort };
|
||||||
|
}
|
||||||
|
|
||||||
|
async revokeTemporaryAccess(
|
||||||
|
clusterId: string,
|
||||||
|
namespace: string,
|
||||||
|
k8sServiceName: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const { coreApi } = await this.getK8sClient(clusterId);
|
||||||
|
try {
|
||||||
|
await coreApi.deleteNamespacedService(k8sServiceName, namespace);
|
||||||
|
this.logger.log(`Revoked temporary access service ${k8sServiceName} in ${namespace}`);
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e.statusCode !== 404 && e.response?.statusCode !== 404) {
|
||||||
|
this.logger.warn(`Failed to delete access service ${k8sServiceName}: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteTemporaryAccessServicesForApp(app: Application): Promise<void> {
|
||||||
|
if (!app.clusterId) return;
|
||||||
|
const namespace = this.getUserNamespace(app.userId);
|
||||||
|
const { coreApi } = await this.getK8sClient(app.clusterId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const services = await coreApi.listNamespacedService(
|
||||||
|
namespace,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
'cloudhost.io/access-grant=true',
|
||||||
|
);
|
||||||
|
for (const svc of services.body.items) {
|
||||||
|
const appId = svc.metadata?.labels?.['cloudhost.io/application-id'];
|
||||||
|
if (appId === app.id && svc.metadata?.name) {
|
||||||
|
await this.revokeTemporaryAccess(app.clusterId, namespace, svc.metadata.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
this.logger.warn(`Failed to list temporary access services for ${app.name}: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async readAccessCredentials(
|
||||||
|
app: Application,
|
||||||
|
target: ServiceAccessTarget,
|
||||||
|
): Promise<Record<string, string | number | undefined>> {
|
||||||
|
const namespace = this.getUserNamespace(app.userId);
|
||||||
|
|
||||||
|
switch (target) {
|
||||||
|
case ServiceAccessTarget.DATABASE:
|
||||||
|
return {
|
||||||
|
username: app.dbUsername || 'appuser',
|
||||||
|
password: app.dbPassword || undefined,
|
||||||
|
database: app.name.replace(/-/g, '_'),
|
||||||
|
};
|
||||||
|
case ServiceAccessTarget.REDIS: {
|
||||||
|
if (!app.clusterId) return {};
|
||||||
|
const { coreApi } = await this.getK8sClient(app.clusterId);
|
||||||
|
const secret = await coreApi.readNamespacedSecret(`${app.name}-redis-secret`, namespace);
|
||||||
|
const password = secret.body.data?.password
|
||||||
|
? Buffer.from(secret.body.data.password, 'base64').toString('utf8')
|
||||||
|
: undefined;
|
||||||
|
return { password };
|
||||||
|
}
|
||||||
|
case ServiceAccessTarget.RABBITMQ_AMQP:
|
||||||
|
case ServiceAccessTarget.RABBITMQ_MANAGEMENT: {
|
||||||
|
if (!app.clusterId) return {};
|
||||||
|
const { coreApi } = await this.getK8sClient(app.clusterId);
|
||||||
|
const secret = await coreApi.readNamespacedSecret(`${app.name}-rabbitmq-secret`, namespace);
|
||||||
|
const username = secret.body.data?.username
|
||||||
|
? Buffer.from(secret.body.data.username, 'base64').toString('utf8')
|
||||||
|
: 'appuser';
|
||||||
|
const password = secret.body.data?.password
|
||||||
|
? Buffer.from(secret.body.data.password, 'base64').toString('utf8')
|
||||||
|
: undefined;
|
||||||
|
return { username, password };
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get preview info for a deployed application.
|
* Get preview info for a deployed application.
|
||||||
* Patches the service to NodePort if needed, and returns the access URL.
|
* Patches the service to NodePort if needed, and returns the access URL.
|
||||||
@@ -1834,15 +2015,9 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
ingressUrl?: string;
|
ingressUrl?: string;
|
||||||
}> {
|
}> {
|
||||||
const { coreApi, networkingApi, kc } = await this.getK8sClient(app.clusterId);
|
const { coreApi, networkingApi, kc } = await this.getK8sClient(app.clusterId);
|
||||||
const namespace = `user-${app.userId.split('-')[0]}`;
|
const namespace = this.getUserNamespace(app.userId);
|
||||||
const domain = this.configService.get('platform.domain');
|
const domain = this.configService.get('platform.domain');
|
||||||
const clusterServer = kc.getCurrentCluster()?.server || '';
|
const hostIp = this.getClusterHostIp(kc);
|
||||||
// Extract host IP from cluster API server URL (e.g., https://217.197.107.252:6443 → 217.197.107.252)
|
|
||||||
let hostIp = '127.0.0.1';
|
|
||||||
try {
|
|
||||||
const serverUrl = new URL(clusterServer);
|
|
||||||
hostIp = serverUrl.hostname;
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
// Read current service
|
// Read current service
|
||||||
let nodePort = 0;
|
let nodePort = 0;
|
||||||
@@ -1900,9 +2075,11 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async deleteApplication(app: Application): Promise<void> {
|
async deleteApplication(app: Application): Promise<void> {
|
||||||
const namespace = `user-${app.userId.split('-')[0]}`;
|
const namespace = this.getUserNamespace(app.userId);
|
||||||
const { coreApi, appsApi, networkingApi } = await this.getK8sClient(app.clusterId);
|
const { coreApi, appsApi, networkingApi } = await this.getK8sClient(app.clusterId);
|
||||||
|
|
||||||
|
await this.deleteTemporaryAccessServicesForApp(app);
|
||||||
|
|
||||||
// Step 1: Try Helm uninstall (handles most resources)
|
// Step 1: Try Helm uninstall (handles most resources)
|
||||||
try {
|
try {
|
||||||
const kubeconfig = await this.getKubeconfig(app.clusterId);
|
const kubeconfig = await this.getKubeconfig(app.clusterId);
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ async function bootstrap() {
|
|||||||
const defaults = [
|
const defaults = [
|
||||||
{ key: 'custom_domain_monthly_price_toman', value: '50000', description: 'Monthly price for custom domain addon (Toman)' },
|
{ key: 'custom_domain_monthly_price_toman', value: '50000', description: 'Monthly price for custom domain addon (Toman)' },
|
||||||
{ key: 'platform_cname_target', value: 'apps.cloudhost.ir', description: 'CNAME target shown to users for custom domain setup' },
|
{ key: 'platform_cname_target', value: 'apps.cloudhost.ir', description: 'CNAME target shown to users for custom domain setup' },
|
||||||
|
{ key: 'access_max_duration_minutes', value: '240', description: 'Maximum duration (minutes) for temporary external service access' },
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const d of defaults) {
|
for (const d of defaults) {
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|||||||
import { useParams, useRouter } from 'next/navigation';
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
import api from '@/lib/api';
|
import api from '@/lib/api';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
import type { Application, Deployment, ResourceUsage, ClusterPublic, ClusterPoolPublic, AppSnapshot, K8sRevisionData, K8sRevision } from '@/types';
|
import type { Application, Deployment, ResourceUsage, ClusterPublic, ClusterPoolPublic, AppSnapshot, K8sRevisionData, K8sRevision, ServiceAccessGrant, ServiceAccessTarget } from '@/types';
|
||||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||||
import { Hexagon, Rocket, Play, Square, RotateCw, Globe, Package, Server, Scale, CheckCircle, Clock, XCircle, AlertCircle, Link, GitBranch, KeyRound, FolderUp, BarChart3, ChevronDown, FileText, Monitor, Hammer, Settings, RefreshCw, Upload, Circle, Pin, Database, Eye, EyeOff, Copy, Check, History, Download, RotateCcw, Camera, Trash2, Archive, Zap, Wallet, CreditCard, AlertTriangle } from 'lucide-react';
|
import { Hexagon, Rocket, Play, Square, RotateCw, Globe, Package, Server, Scale, CheckCircle, Clock, XCircle, AlertCircle, Link, GitBranch, KeyRound, FolderUp, BarChart3, ChevronDown, FileText, Monitor, Hammer, Settings, RefreshCw, Upload, Circle, Pin, Database, Eye, EyeOff, Copy, Check, History, Download, RotateCcw, Camera, Trash2, Archive, Zap, Wallet, CreditCard, AlertTriangle, ShieldAlert, ExternalLink } from 'lucide-react';
|
||||||
import { useConfirm } from '@/components/confirm-modal';
|
import { useConfirm } from '@/components/confirm-modal';
|
||||||
import { BuildProgressModal } from '@/components/build-progress-modal';
|
import { BuildProgressModal } from '@/components/build-progress-modal';
|
||||||
|
|
||||||
@@ -91,6 +91,11 @@ export default function AppDetailPage() {
|
|||||||
const [showDomainSetup, setShowDomainSetup] = useState(false);
|
const [showDomainSetup, setShowDomainSetup] = useState(false);
|
||||||
const [customDomainInput, setCustomDomainInput] = useState('');
|
const [customDomainInput, setCustomDomainInput] = useState('');
|
||||||
|
|
||||||
|
const [accessTarget, setAccessTarget] = useState<ServiceAccessTarget>('database');
|
||||||
|
const [accessDuration, setAccessDuration] = useState(60);
|
||||||
|
const [showAccessSecret, setShowAccessSecret] = useState(false);
|
||||||
|
const [accessNow, setAccessNow] = useState(() => Date.now());
|
||||||
|
|
||||||
const { data: app, isLoading } = useQuery<Application>({
|
const { data: app, isLoading } = useQuery<Application>({
|
||||||
queryKey: ['application', appId],
|
queryKey: ['application', appId],
|
||||||
queryFn: () => api.get(`/applications/${appId}`).then((r) => r.data),
|
queryFn: () => api.get(`/applications/${appId}`).then((r) => r.data),
|
||||||
@@ -646,6 +651,77 @@ export default function AppDetailPage() {
|
|||||||
onError: () => toast.error('Failed to get preview URL. Make sure the app is deployed.'),
|
onError: () => toast.error('Failed to get preview URL. Make sure the app is deployed.'),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const accessTargetOptions: { value: ServiceAccessTarget; label: string }[] = [];
|
||||||
|
if (app?.databaseType && app.databaseType !== 'none') {
|
||||||
|
accessTargetOptions.push({ value: 'database', label: 'Database' });
|
||||||
|
}
|
||||||
|
if (app?.enableRedis) accessTargetOptions.push({ value: 'redis', label: 'Redis' });
|
||||||
|
if (app?.enableRabbitmq) {
|
||||||
|
accessTargetOptions.push({ value: 'rabbitmq_amqp', label: 'RabbitMQ (AMQP)' });
|
||||||
|
accessTargetOptions.push({ value: 'rabbitmq_management', label: 'RabbitMQ Management UI' });
|
||||||
|
}
|
||||||
|
const hasAccessTargets = accessTargetOptions.length > 0;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasAccessTargets) return;
|
||||||
|
if (!accessTargetOptions.some((o) => o.value === accessTarget)) {
|
||||||
|
setAccessTarget(accessTargetOptions[0].value);
|
||||||
|
}
|
||||||
|
}, [app?.databaseType, app?.enableRedis, app?.enableRabbitmq]);
|
||||||
|
|
||||||
|
const { data: accessGrants = [], refetch: refetchAccessGrants } = useQuery<ServiceAccessGrant[]>({
|
||||||
|
queryKey: ['access-grants', appId],
|
||||||
|
queryFn: () => api.get(`/applications/${appId}/access`).then((r) => r.data),
|
||||||
|
enabled: hasAccessTargets && !!app?.latestImageTag,
|
||||||
|
refetchInterval: 30000,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!accessGrants.some((g) => g.status === 'active')) return;
|
||||||
|
const t = setInterval(() => setAccessNow(Date.now()), 1000);
|
||||||
|
return () => clearInterval(t);
|
||||||
|
}, [accessGrants]);
|
||||||
|
|
||||||
|
const createAccessMutation = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
api.post(`/applications/${appId}/access`, {
|
||||||
|
target: accessTarget,
|
||||||
|
durationMinutes: accessDuration,
|
||||||
|
}).then((r) => r.data),
|
||||||
|
onSuccess: () => {
|
||||||
|
refetchAccessGrants();
|
||||||
|
toast.success('Temporary external access enabled');
|
||||||
|
},
|
||||||
|
onError: (err: any) => {
|
||||||
|
toast.error(err.response?.data?.message || 'Failed to enable access');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const revokeAccessMutation = useMutation({
|
||||||
|
mutationFn: (grantId: string) =>
|
||||||
|
api.delete(`/applications/${appId}/access/${grantId}`).then((r) => r.data),
|
||||||
|
onSuccess: () => {
|
||||||
|
refetchAccessGrants();
|
||||||
|
toast.success('Access revoked');
|
||||||
|
},
|
||||||
|
onError: () => toast.error('Failed to revoke access'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const accessTargetLabel = (target: ServiceAccessTarget) =>
|
||||||
|
accessTargetOptions.find((o) => o.value === target)?.label || target;
|
||||||
|
|
||||||
|
const formatAccessCountdown = (expiresAt: string) => {
|
||||||
|
const ms = new Date(expiresAt).getTime() - accessNow;
|
||||||
|
if (ms <= 0) return 'Expired';
|
||||||
|
const totalSec = Math.floor(ms / 1000);
|
||||||
|
const h = Math.floor(totalSec / 3600);
|
||||||
|
const m = Math.floor((totalSec % 3600) / 60);
|
||||||
|
const s = totalSec % 60;
|
||||||
|
if (h > 0) return `${h}h ${m}m ${s}s`;
|
||||||
|
if (m > 0) return `${m}m ${s}s`;
|
||||||
|
return `${s}s`;
|
||||||
|
};
|
||||||
|
|
||||||
const uploadMutation = useMutation({
|
const uploadMutation = useMutation({
|
||||||
mutationFn: (file: File) => {
|
mutationFn: (file: File) => {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
@@ -1688,6 +1764,130 @@ export default function AppDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Temporary External Access */}
|
||||||
|
{hasAccessTargets && (
|
||||||
|
<div className="card">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 mb-2 flex items-center gap-2">
|
||||||
|
<ExternalLink className="w-5 h-5" /> Temporary External Access
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-amber-700 bg-amber-50 border border-amber-100 rounded-lg px-3 py-2 mb-4 flex items-start gap-2">
|
||||||
|
<ShieldAlert className="w-4 h-4 shrink-0 mt-0.5" />
|
||||||
|
Opens a NodePort on the cluster node IP. Anyone who can reach that IP may connect until the timer ends. Use short durations only.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{!app?.latestImageTag ? (
|
||||||
|
<p className="text-sm text-gray-500">Deploy the application first to enable external access.</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="bg-gray-50 rounded-xl p-4 mb-4 flex flex-wrap gap-4 items-end">
|
||||||
|
<div className="flex-1 min-w-[160px]">
|
||||||
|
<label className="text-xs font-medium text-gray-600 block mb-1">Service</label>
|
||||||
|
<select
|
||||||
|
value={accessTarget}
|
||||||
|
onChange={(e) => setAccessTarget(e.target.value as ServiceAccessTarget)}
|
||||||
|
className="input w-full text-sm"
|
||||||
|
>
|
||||||
|
{accessTargetOptions.map((o) => (
|
||||||
|
<option key={o.value} value={o.value}>{o.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-[160px]">
|
||||||
|
<label className="text-xs font-medium text-gray-600 block mb-1">Duration</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{[30, 60, 240].map((mins) => (
|
||||||
|
<button
|
||||||
|
key={mins}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setAccessDuration(mins)}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors ${
|
||||||
|
accessDuration === mins
|
||||||
|
? 'bg-primary-600 text-white border-primary-600'
|
||||||
|
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{mins < 60 ? `${mins}m` : `${mins / 60}h`}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => createAccessMutation.mutate()}
|
||||||
|
disabled={createAccessMutation.isPending}
|
||||||
|
className="btn-primary text-sm"
|
||||||
|
>
|
||||||
|
{createAccessMutation.isPending ? 'Opening…' : 'Enable access'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{accessGrants.filter((g) => g.status === 'active').length === 0 ? (
|
||||||
|
<p className="text-sm text-gray-500">No active external access sessions.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{accessGrants
|
||||||
|
.filter((g) => g.status === 'active')
|
||||||
|
.map((grant) => (
|
||||||
|
<div key={grant.id} className="border border-gray-200 rounded-xl p-4 bg-white">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2 mb-3">
|
||||||
|
<div>
|
||||||
|
<span className="text-sm font-semibold text-gray-800">{accessTargetLabel(grant.target)}</span>
|
||||||
|
<span className="ml-2 text-xs text-gray-500">ends in {formatAccessCountdown(grant.expiresAt)}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => revokeAccessMutation.mutate(grant.id)}
|
||||||
|
disabled={revokeAccessMutation.isPending}
|
||||||
|
className="btn-secondary text-xs text-red-600 border-red-200 hover:bg-red-50"
|
||||||
|
>
|
||||||
|
Revoke
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5 text-sm font-mono">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="text-gray-500 text-xs font-sans">Endpoint</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-gray-800">{grant.host}:{grant.port}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => copyToClipboard(`${grant.host}:${grant.port}`, `access-endpoint-${grant.id}`)}
|
||||||
|
className="p-1 text-gray-400 hover:text-gray-600"
|
||||||
|
title="Copy"
|
||||||
|
>
|
||||||
|
{copiedField === `access-endpoint-${grant.id}` ? <Check className="w-3.5 h-3.5 text-green-500" /> : <Copy className="w-3.5 h-3.5" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{grant.connection.url && (
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="text-gray-500 text-xs font-sans">URL</span>
|
||||||
|
<div className="flex items-center gap-2 max-w-[70%]">
|
||||||
|
<span className="text-gray-800 truncate text-xs" title={grant.connection.url}>
|
||||||
|
{showAccessSecret ? grant.connection.url : '••••••••••••'}
|
||||||
|
</span>
|
||||||
|
<button type="button" onClick={() => setShowAccessSecret(!showAccessSecret)} className="p-1 text-gray-400 hover:text-gray-600">
|
||||||
|
{showAccessSecret ? <EyeOff className="w-3.5 h-3.5" /> : <Eye className="w-3.5 h-3.5" />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => copyToClipboard(grant.connection.url || '', `access-url-${grant.id}`)}
|
||||||
|
className="p-1 text-gray-400 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
{copiedField === `access-url-${grant.id}` ? <Check className="w-3.5 h-3.5 text-green-500" /> : <Copy className="w-3.5 h-3.5" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Resource Monitoring & Scaling */}
|
{/* Resource Monitoring & Scaling */}
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
|||||||
@@ -62,6 +62,32 @@ export interface Application {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ServiceAccessTarget =
|
||||||
|
| 'database'
|
||||||
|
| 'redis'
|
||||||
|
| 'rabbitmq_amqp'
|
||||||
|
| 'rabbitmq_management';
|
||||||
|
|
||||||
|
export interface ServiceAccessConnection {
|
||||||
|
host?: string;
|
||||||
|
port?: number;
|
||||||
|
username?: string;
|
||||||
|
password?: string;
|
||||||
|
database?: string;
|
||||||
|
url?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServiceAccessGrant {
|
||||||
|
id: string;
|
||||||
|
target: ServiceAccessTarget;
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
targetPort: number;
|
||||||
|
expiresAt: string;
|
||||||
|
status: 'active' | 'expired' | 'revoked';
|
||||||
|
connection: ServiceAccessConnection;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Deployment {
|
export interface Deployment {
|
||||||
id: string;
|
id: string;
|
||||||
status: DeploymentStatus;
|
status: DeploymentStatus;
|
||||||
|
|||||||
Reference in New Issue
Block a user