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: {
|
||||
action: 'activate',
|
||||
cycle,
|
||||
planExpiresAt: payment.planExpiresAt?.toISOString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -269,7 +268,6 @@ export class BillingController {
|
||||
const activated = await this.lifecycleService.activateApp(
|
||||
applicationId,
|
||||
cycle,
|
||||
payment.planExpiresAt,
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Deployment } from './entities/deployment.entity';
|
||||
import { ApplicationsService } from '../applications/applications.service';
|
||||
import { KubernetesService } from '../kubernetes/kubernetes.service';
|
||||
import { BuildService, BuildProgress, BuildCancelledError } from '../build/build.service';
|
||||
import { DeploymentStatus } from '../common/enums';
|
||||
import { AppLifecycleStatus, DeploymentStatus } from '../common/enums';
|
||||
|
||||
@Injectable()
|
||||
export class DeploymentsService {
|
||||
@@ -100,7 +100,15 @@ export class DeploymentsService {
|
||||
percent: 96,
|
||||
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
|
||||
this.buildService.setProgress(deploymentId, {
|
||||
@@ -114,7 +122,7 @@ export class DeploymentsService {
|
||||
finishedAt: new Date(),
|
||||
});
|
||||
} 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`);
|
||||
await this.deploymentsRepository.update(deploymentId, {
|
||||
status: DeploymentStatus.CANCELLED,
|
||||
@@ -142,9 +150,35 @@ export class DeploymentsService {
|
||||
}
|
||||
|
||||
async updateStatus(id: string, status: DeploymentStatus): Promise<void> {
|
||||
if (await this.isDeploymentCancelled(id)) {
|
||||
return;
|
||||
}
|
||||
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[]> {
|
||||
return this.deploymentsRepository.find({
|
||||
where: { applicationId },
|
||||
@@ -242,13 +276,31 @@ export class DeploymentsService {
|
||||
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.errorMessage = 'Cancelled by user';
|
||||
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> {
|
||||
@@ -300,6 +352,8 @@ export class DeploymentsService {
|
||||
throw new NotFoundException('No source code available. Upload code or set a git URL first.');
|
||||
}
|
||||
|
||||
this.ensureRedeployAllowed(app);
|
||||
|
||||
// Create new deployment record
|
||||
const deployment = this.deploymentsRepository.create({
|
||||
applicationId: app.id,
|
||||
|
||||
@@ -894,6 +894,8 @@ export default function AppDetailPage() {
|
||||
const isStopped = latestStatus === 'stopped';
|
||||
const isRunning = latestStatus === 'running';
|
||||
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 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</>}
|
||||
</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">
|
||||
{redeployMutation.isPending ? <><Clock className="w-3 h-3 inline animate-spin" /></> : <><RefreshCw className="w-3 h-3 inline" /> Redeploy</>}
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user