Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions docs/services/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -41,12 +41,12 @@ Add these service secrets from the web UI:

| Secret | Required | Description |
| --- | --- | --- |
| `CRON_BASE_URL` | Yes | Public HTTP or HTTPS origin joined with each configured path |
| `CRON_BASE_URL` | Yes | HTTP or HTTPS origin joined with each configured path |
| `CRON_SECRET` | No | Sent as `Authorization: Bearer <value>`; requires an HTTPS base URL |

The control plane only connects to public destinations. It blocks private and reserved addresses, does not follow redirects, and gives each request a 10-second deadline. Cron requests do not retry. If the control plane misses multiple intervals, it sends only the latest due occurrence instead of backfilling every missed run.
The destination must be reachable from the control plane. Cron requests do not follow redirects or retry, and each request has a 10-second deadline. If the control plane misses multiple intervals, it sends only the latest due occurrence instead of backfilling every missed run.

Missing or invalid configuration produces a skipped run. Redirects and non-2xx responses produce failed runs. The read-only **Crons** section in service configuration shows the latest result. Full history is available in [service logs](/infrastructure/logging) and follows the configured log retention period.
Missing or invalid configuration produces a skipped run. Redirects and non-2xx responses produce failed runs. The **Crons** section in service configuration shows the latest result. Full history is available in [service logs](/infrastructure/logging) and follows the configured log retention period.

`tc apply` treats `service.crons` as the complete desired list. Removing the field or setting it to an empty list removes all cron definitions. Cron-only changes take effect without creating a deployment revision.

Expand Down
34 changes: 34 additions & 0 deletions web/actions/crons.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
"use server";

import { and, eq, isNull } from "drizzle-orm";
import { db } from "@/db";
import { serviceCrons, services } from "@/db/schema";
import { requireDeveloperRole } from "@/lib/auth";
import { inngest } from "@/lib/inngest/client";
import { inngestEvents } from "@/lib/inngest/events";

export async function runServiceCron(cronId: string) {
await requireDeveloperRole();
const cron = await db
.select({ id: serviceCrons.id, schedule: serviceCrons.schedule })
.from(serviceCrons)
.innerJoin(
services,
and(eq(serviceCrons.serviceId, services.id), isNull(services.deletedAt)),
)
.where(eq(serviceCrons.id, cronId))
.limit(1)
.then((rows) => rows[0]);
if (!cron) throw new Error("Cron job not found");

await inngest.send(
inngestEvents.serviceCronExecute.create({
cronId: cron.id,
schedule: cron.schedule,
scheduledFor: new Date().toISOString(),
source: "manual",
}),
);

return { success: true };
}
95 changes: 81 additions & 14 deletions web/components/service/details/crons-section.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,29 @@
"use client";

import cronstrue from "cronstrue";
import { Ban, CheckCircle2, XCircle } from "lucide-react";
import { memo } from "react";
import {
AlertTriangle,
Ban,
CheckCircle2,
Loader2,
Play,
XCircle,
} from "lucide-react";
import { memo, useState } from "react";
import { toast } from "sonner";
import useSWR from "swr";
import { runServiceCron } from "@/actions/crons";
import { LocalDate } from "@/components/core/local-date";
import { ConfigSection } from "@/components/service/details/config-section";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { StatusBadge } from "@/components/ui/status-badge";
import type { ServiceCron, ServiceWithDetails as Service } from "@/db/types";
import type {
Secret,
ServiceCron,
ServiceWithDetails as Service,
} from "@/db/types";
import { fetcher } from "@/lib/fetcher";

const STATUS_CONFIG = {
succeeded: {
Expand Down Expand Up @@ -54,6 +71,28 @@ export const CronsSection = memo(function CronsSection({
service: Service;
}) {
const crons = service.crons ?? [];
const { data: secrets } = useSWR<Pick<Secret, "id" | "key" | "createdAt">[]>(
crons.length > 0 ? `/api/services/${service.id}/secrets` : null,
fetcher,
);
const hasCronBaseUrl = secrets?.some(
(secret) => secret.key === "CRON_BASE_URL",
);
const [runningCronId, setRunningCronId] = useState<string | null>(null);

const handleRun = async (cronId: string) => {
setRunningCronId(cronId);
try {
await runServiceCron(cronId);
toast.success("Cron run queued");
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Failed to queue cron run",
);
} finally {
setRunningCronId(null);
}
};

return (
<ConfigSection
Expand All @@ -63,11 +102,23 @@ export const CronsSection = memo(function CronsSection({
>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Cron jobs are managed in{" "}
<code className="font-mono">techulus.yml</code>. Schedules run in UTC
and are read-only here.
Cron schedules are configured in{" "}
<code className="font-mono">techulus.yml</code> and run in UTC.
</p>

{crons.length > 0 && secrets !== undefined && !hasCronBaseUrl && (
<Alert className="border-yellow-500/50 bg-yellow-500/10">
<AlertTriangle className="text-yellow-600" />
<AlertTitle className="text-yellow-700 dark:text-yellow-500">
Cron base URL required
</AlertTitle>
<AlertDescription className="text-yellow-700/80 dark:text-yellow-500/80">
Cron jobs will not run until the{" "}
<code className="font-mono">CRON_BASE_URL</code> secret is set.
</AlertDescription>
</Alert>
)}

{crons.length === 0 ? (
<p className="text-sm text-muted-foreground">
No cron jobs configured.
Expand All @@ -76,14 +127,30 @@ export const CronsSection = memo(function CronsSection({
<div className="space-y-3">
{crons.map((cron) => (
<div key={cron.id} className="space-y-3 rounded-md border p-3">
<div className="space-y-1">
<p className="break-all font-mono text-sm font-medium">
{cron.path}
</p>
<p className="font-mono text-xs">{cron.schedule}</p>
<p className="text-xs text-muted-foreground">
{describeSchedule(cron.schedule)} (UTC)
</p>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 space-y-1">
<p className="break-all font-mono text-sm font-medium">
{cron.path}
</p>
<p className="font-mono text-xs">{cron.schedule}</p>
<p className="text-xs text-muted-foreground">
{describeSchedule(cron.schedule)} (UTC)
</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => handleRun(cron.id)}
disabled={runningCronId !== null}
>
{runningCronId === cron.id ? (
<Loader2 className="animate-spin" />
) : (
<Play />
)}
{runningCronId === cron.id ? "Queuing…" : "Run now"}
</Button>
</div>

{cron.lastStatus ? (
Expand Down
14 changes: 12 additions & 2 deletions web/components/settings/email-settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ type AlertField =
| "serverOfflineAlert"
| "buildFailure"
| "deploymentFailure"
| "deploymentMovedAlert";
| "deploymentMovedAlert"
| "cronFailure";

type AlertSetting = {
field: AlertField;
Expand Down Expand Up @@ -49,13 +50,19 @@ const ALERT_SETTINGS: AlertSetting[] = [
description:
"Receive a notification when offline replicas need manual recovery",
},
{
field: "cronFailure",
label: "Cron Failure Alert",
description: "Receive a notification when a cron run fails",
},
];

type State = {
serverOfflineAlert: boolean;
buildFailure: boolean;
deploymentFailure: boolean;
deploymentMovedAlert: boolean;
cronFailure: boolean;
isSavingAlerts: boolean;
};

Expand All @@ -70,6 +77,7 @@ function createInitialState(props: Props): State {
buildFailure: alertsConfig?.buildFailure ?? true,
deploymentFailure: alertsConfig?.deploymentFailure ?? true,
deploymentMovedAlert: alertsConfig?.deploymentMovedAlert ?? true,
cronFailure: alertsConfig?.cronFailure ?? true,
isSavingAlerts: false,
};
}
Expand Down Expand Up @@ -99,6 +107,7 @@ export function EmailSettings({ initialAlertsConfig }: Props) {
buildFailure: state.buildFailure,
deploymentFailure: state.deploymentFailure,
deploymentMovedAlert: state.deploymentMovedAlert,
cronFailure: state.cronFailure,
});
toast.success("Alert settings saved");
router.refresh();
Expand All @@ -120,7 +129,8 @@ export function EmailSettings({ initialAlertsConfig }: Props) {
state.deploymentFailure !==
(initialAlertsConfig?.deploymentFailure ?? true) ||
state.deploymentMovedAlert !==
(initialAlertsConfig?.deploymentMovedAlert ?? true);
(initialAlertsConfig?.deploymentMovedAlert ?? true) ||
state.cronFailure !== (initialAlertsConfig?.cronFailure ?? true);

return (
<div className="space-y-6">
Expand Down
57 changes: 57 additions & 0 deletions web/lib/email/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,60 @@ async function sendBuildFailureAlert(
});
}

type CronFailureAlertOptions = {
to: string;
serviceId: string;
path: string;
statusCode: number | null;
error: string | null;
};

async function sendCronFailureAlert(
options: CronFailureAlertOptions,
): Promise<void> {
const [result] = await db
.select({
serviceName: services.name,
projectName: projects.name,
projectSlug: projects.slug,
envName: environments.name,
})
.from(services)
.innerJoin(projects, eq(projects.id, services.projectId))
.innerJoin(environments, eq(environments.id, services.environmentId))
.where(eq(services.id, options.serviceId));

if (!result) return;

const baseUrl = getAppBaseUrl();
const serviceUrl = baseUrl
? `${baseUrl}/dashboard/projects/${result.projectSlug}/${result.envName}/services/${options.serviceId}`
: undefined;
const details = [
{ label: "Service", value: result.serviceName },
{ label: "Project", value: result.projectName },
{ label: "Cron Path", value: options.path },
...(options.statusCode !== null
? [{ label: "HTTP Status", value: String(options.statusCode) }]
: []),
...(options.error ? [{ label: "Error", value: options.error }] : []),
];

await sendAlert({
to: options.to,
subject: `Cron Failed: ${result.serviceName}`,
template: Alert({
bannerText: "CRON FAILED",
heading: "Cron Failure Alert",
description: `The cron job "${options.path}" for service "${result.serviceName}" in project "${result.projectName}" has failed.`,
details,
buttonText: serviceUrl ? "View Service" : undefined,
buttonUrl: serviceUrl,
baseUrl,
}),
});
}

type DeploymentFailureAlertOptions = {
to: string;
serviceId: string;
Expand Down Expand Up @@ -400,6 +454,9 @@ export async function deliverNotificationEmail(
case "build.failed":
await sendBuildFailureAlert({ ...event, to });
return;
case "cron.failed":
await sendCronFailureAlert({ ...event, to });
return;
case "deployment.failed":
await sendDeploymentFailureAlert({ ...event, to });
}
Expand Down
8 changes: 8 additions & 0 deletions web/lib/inngest/events/notification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,14 @@ export type NotificationEvent =
serverId: string | null;
failedStage?: string;
}
| {
kind: "cron.failed";
occurrenceId: string;
serviceId: string;
path: string;
statusCode: number | null;
error: string | null;
}
| {
kind: "member.invited";
occurrenceId: string;
Expand Down
7 changes: 6 additions & 1 deletion web/lib/inngest/events/service-cron.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
export type ServiceCronEvents = {
"service-cron/execute": {
data: { cronId: string; schedule: string; scheduledFor: string };
data: {
cronId: string;
schedule: string;
scheduledFor: string;
source: "scheduled" | "manual";
};
};
};
1 change: 1 addition & 0 deletions web/lib/inngest/functions/crons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,7 @@ export const serviceCronDispatcher = inngest.createFunction(
cronId: row.id,
schedule: row.schedule,
scheduledFor: occurrence.toISOString(),
source: "scheduled",
},
});
await db
Expand Down
1 change: 1 addition & 0 deletions web/lib/inngest/functions/service-cron-workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export const serviceCronWorkflow = inngest.createFunction(
event.data.cronId,
event.data.schedule,
new Date(event.data.scheduledFor),
event.data.source,
),
),
);
9 changes: 9 additions & 0 deletions web/lib/notifications/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ export async function notificationEventIsEnabled(event: NotificationEvent) {
return config?.buildFailure !== false;
case "deployment.failed":
return config?.deploymentFailure !== false;
case "cron.failed":
return config?.cronFailure !== false;
}
}

Expand Down Expand Up @@ -81,6 +83,13 @@ export async function renderInAppNotification(event: NotificationEvent) {
href: `${serviceHref}/builds/${event.buildId}`,
};
}
if (event.kind === "cron.failed") {
return {
title: `Cron failed: ${context.serviceName}`,
body: `${event.path}: ${event.error ?? "Cron request failed"}`,
href: serviceHref,
};
}
return {
title: `Deployment failed: ${context.serviceName}`,
body: event.failedStage
Expand Down
Loading
Loading