Require paid access before redeploy.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -254,7 +254,6 @@ export class BillingController {
|
|||||||
metadata: {
|
metadata: {
|
||||||
action: 'activate',
|
action: 'activate',
|
||||||
cycle,
|
cycle,
|
||||||
planExpiresAt: payment.planExpiresAt?.toISOString(),
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -269,7 +268,6 @@ export class BillingController {
|
|||||||
const activated = await this.lifecycleService.activateApp(
|
const activated = await this.lifecycleService.activateApp(
|
||||||
applicationId,
|
applicationId,
|
||||||
cycle,
|
cycle,
|
||||||
payment.planExpiresAt,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { Deployment } from './entities/deployment.entity';
|
|||||||
import { ApplicationsService } from '../applications/applications.service';
|
import { ApplicationsService } from '../applications/applications.service';
|
||||||
import { KubernetesService } from '../kubernetes/kubernetes.service';
|
import { KubernetesService } from '../kubernetes/kubernetes.service';
|
||||||
import { BuildService, BuildProgress, BuildCancelledError } from '../build/build.service';
|
import { BuildService, BuildProgress, BuildCancelledError } from '../build/build.service';
|
||||||
import { DeploymentStatus } from '../common/enums';
|
import { AppLifecycleStatus, DeploymentStatus } from '../common/enums';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DeploymentsService {
|
export class DeploymentsService {
|
||||||
@@ -100,7 +100,15 @@ export class DeploymentsService {
|
|||||||
percent: 96,
|
percent: 96,
|
||||||
message: 'Waiting for all application pods to become ready...',
|
message: 'Waiting for all application pods to become ready...',
|
||||||
});
|
});
|
||||||
await this.kubernetesService.waitForApplicationReady(app);
|
await this.kubernetesService.waitForApplicationReady(
|
||||||
|
app,
|
||||||
|
600_000,
|
||||||
|
() => this.isDeploymentCancelled(deploymentId),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (await this.isDeploymentCancelled(deploymentId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Step 5: Mark success
|
// Step 5: Mark success
|
||||||
this.buildService.setProgress(deploymentId, {
|
this.buildService.setProgress(deploymentId, {
|
||||||
@@ -114,7 +122,7 @@ export class DeploymentsService {
|
|||||||
finishedAt: new Date(),
|
finishedAt: new Date(),
|
||||||
});
|
});
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error instanceof BuildCancelledError || error?.name === 'BuildCancelledError') {
|
if (this.isCancellationError(error) || await this.isDeploymentCancelled(deploymentId)) {
|
||||||
this.logger.log(`Deployment ${deploymentId} cancelled by user`);
|
this.logger.log(`Deployment ${deploymentId} cancelled by user`);
|
||||||
await this.deploymentsRepository.update(deploymentId, {
|
await this.deploymentsRepository.update(deploymentId, {
|
||||||
status: DeploymentStatus.CANCELLED,
|
status: DeploymentStatus.CANCELLED,
|
||||||
@@ -142,9 +150,35 @@ export class DeploymentsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async updateStatus(id: string, status: DeploymentStatus): Promise<void> {
|
async updateStatus(id: string, status: DeploymentStatus): Promise<void> {
|
||||||
|
if (await this.isDeploymentCancelled(id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
await this.deploymentsRepository.update(id, { status });
|
await this.deploymentsRepository.update(id, { status });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async isDeploymentCancelled(id: string): Promise<boolean> {
|
||||||
|
const deployment = await this.deploymentsRepository.findOne({ where: { id } });
|
||||||
|
return deployment?.status === DeploymentStatus.CANCELLED || deployment?.errorMessage === 'Cancelled by user';
|
||||||
|
}
|
||||||
|
|
||||||
|
private isCancellationError(error: any): boolean {
|
||||||
|
return error instanceof BuildCancelledError ||
|
||||||
|
error?.name === 'BuildCancelledError' ||
|
||||||
|
String(error?.message || '').toLowerCase().includes('cancelled');
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureRedeployAllowed(app: any): void {
|
||||||
|
if (!app.billingCycle) return;
|
||||||
|
|
||||||
|
const isActive = app.lifecycleStatus === AppLifecycleStatus.ACTIVE;
|
||||||
|
const expiresAt = app.planExpiresAt ? new Date(app.planExpiresAt) : null;
|
||||||
|
const hasPaidTimeRemaining = !!expiresAt && expiresAt > new Date();
|
||||||
|
|
||||||
|
if (!isActive || !hasPaidTimeRemaining) {
|
||||||
|
throw new BadRequestException('Payment must be completed successfully before redeploying this application.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async findByApplication(applicationId: string): Promise<Deployment[]> {
|
async findByApplication(applicationId: string): Promise<Deployment[]> {
|
||||||
return this.deploymentsRepository.find({
|
return this.deploymentsRepository.find({
|
||||||
where: { applicationId },
|
where: { applicationId },
|
||||||
@@ -242,13 +276,31 @@ export class DeploymentsService {
|
|||||||
throw new BadRequestException('No deployment in progress to cancel');
|
throw new BadRequestException('No deployment in progress to cancel');
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.buildService.cancelBuild(latest.id);
|
|
||||||
await this.buildService.cleanupBuildResourcesForApp(app);
|
|
||||||
|
|
||||||
latest.status = DeploymentStatus.CANCELLED;
|
latest.status = DeploymentStatus.CANCELLED;
|
||||||
latest.errorMessage = 'Cancelled by user';
|
latest.errorMessage = 'Cancelled by user';
|
||||||
latest.finishedAt = new Date();
|
latest.finishedAt = new Date();
|
||||||
return this.deploymentsRepository.save(latest);
|
await this.deploymentsRepository.save(latest);
|
||||||
|
this.buildService.setProgress(latest.id, { phase: 'cancelled', percent: 0, message: 'Cancelled by user' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.buildService.cancelBuild(latest.id);
|
||||||
|
} catch (error: any) {
|
||||||
|
this.logger.warn(`Failed to cancel build resources for ${latest.id}: ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.buildService.cleanupBuildResourcesForApp(app);
|
||||||
|
} catch (error: any) {
|
||||||
|
this.logger.warn(`Failed to cleanup build resources for ${app.name}: ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.kubernetesService.deleteApplication(app);
|
||||||
|
} catch (error: any) {
|
||||||
|
this.logger.warn(`Failed to cleanup deployed resources for cancelled app ${app.name}: ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.deploymentsRepository.findOneOrFail({ where: { id: latest.id } });
|
||||||
}
|
}
|
||||||
|
|
||||||
async stopDeployment(applicationId: string, userId: string): Promise<Deployment | null> {
|
async stopDeployment(applicationId: string, userId: string): Promise<Deployment | null> {
|
||||||
@@ -300,6 +352,8 @@ export class DeploymentsService {
|
|||||||
throw new NotFoundException('No source code available. Upload code or set a git URL first.');
|
throw new NotFoundException('No source code available. Upload code or set a git URL first.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.ensureRedeployAllowed(app);
|
||||||
|
|
||||||
// Create new deployment record
|
// Create new deployment record
|
||||||
const deployment = this.deploymentsRepository.create({
|
const deployment = this.deploymentsRepository.create({
|
||||||
applicationId: app.id,
|
applicationId: app.id,
|
||||||
|
|||||||
@@ -894,6 +894,8 @@ export default function AppDetailPage() {
|
|||||||
const isStopped = latestStatus === 'stopped';
|
const isStopped = latestStatus === 'stopped';
|
||||||
const isRunning = latestStatus === 'running';
|
const isRunning = latestStatus === 'running';
|
||||||
const isInProgress = latestStatus === 'building' || latestStatus === 'deploying' || latestStatus === 'pending';
|
const isInProgress = latestStatus === 'building' || latestStatus === 'deploying' || latestStatus === 'pending';
|
||||||
|
const hasPaidAccess = !app.billingCycle ||
|
||||||
|
(app.lifecycleStatus === 'active' && (!app.planExpiresAt || new Date(app.planExpiresAt) > new Date()));
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
const ok = await confirm({
|
const ok = await confirm({
|
||||||
@@ -956,7 +958,7 @@ export default function AppDetailPage() {
|
|||||||
{restartMutation.isPending ? <><Clock className="w-3 h-3 inline animate-spin" /></> : <><RotateCw className="w-3 h-3 inline" /> Restart</>}
|
{restartMutation.isPending ? <><Clock className="w-3 h-3 inline animate-spin" /></> : <><RotateCw className="w-3 h-3 inline" /> Restart</>}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{!isInProgress && (
|
{!isInProgress && hasPaidAccess && (
|
||||||
<button onClick={() => redeployMutation.mutate()} disabled={redeployMutation.isPending} className="btn-primary text-sm disabled:opacity-50" title="Rebuild from latest source code">
|
<button onClick={() => redeployMutation.mutate()} disabled={redeployMutation.isPending} className="btn-primary text-sm disabled:opacity-50" title="Rebuild from latest source code">
|
||||||
{redeployMutation.isPending ? <><Clock className="w-3 h-3 inline animate-spin" /></> : <><RefreshCw className="w-3 h-3 inline" /> Redeploy</>}
|
{redeployMutation.isPending ? <><Clock className="w-3 h-3 inline animate-spin" /></> : <><RefreshCw className="w-3 h-3 inline" /> Redeploy</>}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user