diff --git a/cli/internal/cli/app.go b/cli/internal/cli/app.go
index 990de668..5660d86e 100644
--- a/cli/internal/cli/app.go
+++ b/cli/internal/cli/app.go
@@ -436,6 +436,7 @@ func (a *App) linkCommand() *cobra.Command {
HealthCheck *manifest.HealthCheck `json:"healthCheck"`
StartCommand *string `json:"startCommand"`
Resources *manifest.Resources `json:"resources"`
+ Crons []manifest.Cron `json:"crons"`
} `json:"current"`
Management *serviceManagement `json:"management"`
}
@@ -467,7 +468,7 @@ func (a *App) linkCommand() *cobra.Command {
}
}
}
- m := manifest.Manifest{APIVersion: "v1", Target: &manifest.Target{ServiceID: service.ID}, Service: manifest.Service{Name: service.Name, Source: service.Source, Hostname: cfg.Current.Hostname, Ports: ports, Replicas: cfg.Current.Replicas, Placement: placement, HealthCheck: cfg.Current.HealthCheck, StartCommand: cfg.Current.StartCommand, Resources: cfg.Current.Resources}}
+ m := manifest.Manifest{APIVersion: "v1", Target: &manifest.Target{ServiceID: service.ID}, Service: manifest.Service{Name: service.Name, Source: service.Source, Hostname: cfg.Current.Hostname, Ports: ports, Replicas: cfg.Current.Replicas, Placement: placement, HealthCheck: cfg.Current.HealthCheck, StartCommand: cfg.Current.StartCommand, Resources: cfg.Current.Resources, Crons: cfg.Current.Crons}}
if existing != nil {
if existing.Manifest.Linked() && existing.Manifest.Target.ServiceID != service.ID {
return fmt.Errorf("manifest is linked to service %s; remove target.serviceId before relinking", existing.Manifest.Target.ServiceID)
@@ -516,7 +517,11 @@ func (a *App) applyCommand() *cobra.Command {
if placement == nil {
return errors.New("service.placement is required")
}
- body := map[string]any{"name": loaded.Manifest.Service.Name, "source": sourcePatch(loaded.Manifest.Service.Source), "hostname": loaded.Manifest.Service.Hostname, "ports": loaded.Manifest.Service.Ports, "healthCheck": loaded.Manifest.Service.HealthCheck, "startCommand": loaded.Manifest.Service.StartCommand, "resources": loaded.Manifest.Service.Resources}
+ crons := loaded.Manifest.Service.Crons
+ if crons == nil {
+ crons = []manifest.Cron{}
+ }
+ body := map[string]any{"name": loaded.Manifest.Service.Name, "source": sourcePatch(loaded.Manifest.Service.Source), "hostname": loaded.Manifest.Service.Hostname, "ports": loaded.Manifest.Service.Ports, "healthCheck": loaded.Manifest.Service.HealthCheck, "startCommand": loaded.Manifest.Service.StartCommand, "resources": loaded.Manifest.Service.Resources, "crons": crons}
if placement.Mode == "automatic" {
body["placement"] = map[string]any{"mode": "automatic", "replicas": loaded.Manifest.Service.Replicas}
} else {
@@ -636,7 +641,7 @@ func (a *App) finishLink(path string, existing *manifest.Loaded, service service
}
return a.printLinked(path, target)
}
- m := manifest.Manifest{APIVersion: "v1", Target: &manifest.Target{ServiceID: serviceID}, Service: manifest.Service{Name: service.Name, Source: service.Source, Hostname: service.Hostname, Ports: service.Ports, Replicas: service.Replicas, Placement: service.Placement, HealthCheck: service.HealthCheck, StartCommand: service.StartCommand, Resources: service.Resources}}
+ m := manifest.Manifest{APIVersion: "v1", Target: &manifest.Target{ServiceID: serviceID}, Service: manifest.Service{Name: service.Name, Source: service.Source, Hostname: service.Hostname, Ports: service.Ports, Replicas: service.Replicas, Placement: service.Placement, HealthCheck: service.HealthCheck, StartCommand: service.StartCommand, Resources: service.Resources, Crons: service.Crons}}
if err := manifest.Save(path, m); err != nil {
return err
}
diff --git a/cli/internal/cli/app_test.go b/cli/internal/cli/app_test.go
index 81cd36d2..84cde7cf 100644
--- a/cli/internal/cli/app_test.go
+++ b/cli/internal/cli/app_test.go
@@ -335,9 +335,12 @@ func TestApplyExactNestedPatchForSources(t *testing.T) {
}
source := body["source"].(map[string]any)
placement := body["placement"].(map[string]any)
- if source["type"] != tc.sourceType || placement["mode"] != "automatic" || placement["replicas"] != float64(2) || len(body) != 8 {
+ if source["type"] != tc.sourceType || placement["mode"] != "automatic" || placement["replicas"] != float64(2) || len(body) != 9 {
t.Fatalf("body=%#v", body)
}
+ if crons, ok := body["crons"].([]any); !ok || len(crons) != 0 {
+ t.Fatalf("omitted crons must be sent as an empty replacement: %#v", body["crons"])
+ }
if tc.name == "github_clear_root" {
rootDir, present := source["rootDir"]
if !present || rootDir != nil {
@@ -348,6 +351,35 @@ func TestApplyExactNestedPatchForSources(t *testing.T) {
}
}
+func TestLinkAndApplyRoundTripCrons(t *testing.T) {
+ d := t.TempDir()
+ var applied map[string]any
+ s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodGet {
+ w.Write([]byte(`{"target":{"project":{"slug":"app"},"environment":{"name":"prod"},"service":{"id":"s","name":"web"}},"service":{"id":"s","name":"web","source":{"type":"image","image":"nginx"},"ports":[],"replicas":1,"placement":{"mode":"automatic"},"hostname":"web","healthCheck":null,"startCommand":null,"resources":null,"crons":[{"path":"/jobs/nightly","schedule":"0 5 * * *"}]},"management":{"patchable":true,"blockers":[]}}`))
+ return
+ }
+ json.NewDecoder(r.Body).Decode(&applied)
+ w.Write([]byte(`{"action":"noop","currentVersion":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","desiredVersion":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","changes":[]}`))
+ }))
+ defer s.Close()
+ writeConfig(t, s.URL)
+ app, _ := testApp(t, d, s.Client())
+ if err := execute(app, "link", "--service", "s"); err != nil {
+ t.Fatal(err)
+ }
+ loaded, err := manifest.Load(d)
+ if err != nil || !reflect.DeepEqual(loaded.Manifest.Service.Crons, []manifest.Cron{{Path: "/jobs/nightly", Schedule: "0 5 * * *"}}) {
+ t.Fatalf("crons=%#v err=%v", loaded.Manifest.Service.Crons, err)
+ }
+ if err := execute(app, "apply", "--yes"); err != nil {
+ t.Fatal(err)
+ }
+ if !reflect.DeepEqual(applied["crons"], []any{map[string]any{"path": "/jobs/nightly", "schedule": "0 5 * * *"}}) {
+ t.Fatalf("applied crons=%#v", applied["crons"])
+ }
+}
+
func TestApplyPlacementPayloads(t *testing.T) {
for _, tc := range []struct {
name, yaml string
diff --git a/cli/internal/cli/types.go b/cli/internal/cli/types.go
index c3155b1b..8405faa4 100644
--- a/cli/internal/cli/types.go
+++ b/cli/internal/cli/types.go
@@ -51,6 +51,7 @@ type serviceItem struct {
HealthCheck *manifest.HealthCheck `json:"healthCheck"`
StartCommand *string `json:"startCommand"`
Resources *manifest.Resources `json:"resources"`
+ Crons []manifest.Cron `json:"crons"`
}
type targetProject struct {
ID string `json:"id"`
diff --git a/cli/internal/manifest/manifest.go b/cli/internal/manifest/manifest.go
index 876a6238..37aca305 100644
--- a/cli/internal/manifest/manifest.go
+++ b/cli/internal/manifest/manifest.go
@@ -37,6 +37,11 @@ type Service struct {
HealthCheck *HealthCheck `json:"healthCheck" yaml:"healthCheck"`
StartCommand *string `json:"startCommand" yaml:"startCommand"`
Resources *Resources `json:"resources,omitempty" yaml:"resources,omitempty"`
+ Crons []Cron `json:"crons,omitempty" yaml:"crons,omitempty"`
+}
+type Cron struct {
+ Path string `json:"path" yaml:"path"`
+ Schedule string `json:"schedule" yaml:"schedule"`
}
type Placement struct {
Mode string `json:"mode" yaml:"mode"`
@@ -141,6 +146,10 @@ func ApplyDefaults(m *Manifest) {
if m.Service.Ports == nil {
m.Service.Ports = []Port{}
}
+ for i := range m.Service.Crons {
+ m.Service.Crons[i].Path = strings.TrimSpace(m.Service.Crons[i].Path)
+ m.Service.Crons[i].Schedule = strings.TrimSpace(m.Service.Crons[i].Schedule)
+ }
if m.Service.Replicas == 0 {
m.Service.Replicas = 1
}
@@ -307,6 +316,43 @@ func Validate(m Manifest) error {
return errors.New("service.resources.memoryMb must be between 64 and 65536")
}
}
+ seenCronPaths := make(map[string]struct{}, len(m.Service.Crons))
+ for i, cron := range m.Service.Crons {
+ if err := validateCronPath(cron.Path); err != nil {
+ return fmt.Errorf("service.crons[%d].path: %w", i, err)
+ }
+ if _, exists := seenCronPaths[cron.Path]; exists {
+ return fmt.Errorf("service.crons[%d].path must be unique", i)
+ }
+ seenCronPaths[cron.Path] = struct{}{}
+ if len(cron.Schedule) > 255 || len(strings.Fields(cron.Schedule)) != 5 {
+ return fmt.Errorf("service.crons[%d].schedule must be a five-field cron expression", i)
+ }
+ }
+ return nil
+}
+
+func validateCronPath(value string) error {
+ if value == "" || len(value) > 2048 || !strings.HasPrefix(value, "/") || strings.HasPrefix(value, "//") {
+ return errors.New("must begin with exactly one slash and be at most 2048 characters")
+ }
+ decoded, err := url.PathUnescape(value)
+ if err != nil {
+ return errors.New("must contain valid URL escapes")
+ }
+ if !strings.HasPrefix(decoded, "/") || strings.HasPrefix(decoded, "//") || strings.ContainsAny(decoded, "?#\\") {
+ return errors.New("cannot contain a host override, query, fragment, or backslash")
+ }
+ for _, r := range decoded {
+ if r < 0x20 || r == 0x7f {
+ return errors.New("cannot contain control characters")
+ }
+ }
+ for _, segment := range strings.Split(decoded, "/") {
+ if segment == "." || segment == ".." {
+ return errors.New("cannot contain '.' or '..' segments")
+ }
+ }
return nil
}
func (m Manifest) Linked() bool {
diff --git a/cli/internal/manifest/manifest_test.go b/cli/internal/manifest/manifest_test.go
index b7c5103a..365c808b 100644
--- a/cli/internal/manifest/manifest_test.go
+++ b/cli/internal/manifest/manifest_test.go
@@ -12,16 +12,37 @@ func base() Manifest {
func TestDefaultsAndRoundTrip(t *testing.T) {
m := base()
m.Service.Replicas = 0
+ m.Service.Crons = []Cron{{Path: " /api/cron/digest ", Schedule: " 0 5 * * * "}}
b, e := Marshal(m)
if e != nil {
t.Fatal(e)
}
got, e := Parse(b)
- if e != nil || got.Service.Replicas != 1 || got.Service.Ports == nil {
+ if e != nil || got.Service.Replicas != 1 || got.Service.Ports == nil || got.Service.Crons[0] != (Cron{Path: "/api/cron/digest", Schedule: "0 5 * * *"}) {
t.Fatalf("got=%#v err=%v", got, e)
}
}
+func TestCronValidation(t *testing.T) {
+ invalidPaths := []string{"//host/job", "/job?x=1", "/job#part", `/job\\next`, "/a/../b", "/a/%2e%2e/b", "/%2fhost", "/bad%zz", "/line%0Abreak"}
+ for _, path := range invalidPaths {
+ m := base()
+ m.Service.Crons = []Cron{{Path: path, Schedule: "0 5 * * *"}}
+ if err := Validate(m); err == nil {
+ t.Fatalf("invalid cron path %q accepted", path)
+ }
+ }
+ m := base()
+ m.Service.Crons = []Cron{{Path: "/jobs/nightly", Schedule: "0 5 * * *"}, {Path: "/jobs/nightly", Schedule: "0 6 * * *"}}
+ if err := Validate(m); err == nil || !strings.Contains(err.Error(), "unique") {
+ t.Fatalf("duplicate path error = %v", err)
+ }
+ m.Service.Crons = []Cron{{Path: "/jobs/nightly", Schedule: "0 5 * *"}}
+ if err := Validate(m); err == nil || !strings.Contains(err.Error(), "five-field") {
+ t.Fatalf("schedule error = %v", err)
+ }
+}
+
func TestHostnameValidationAndSlugify(t *testing.T) {
for _, value := range []string{"", "Upper", "two words", "-leading", "trailing-", "two--hyphens", strings.Repeat("a", 64)} {
m := base()
diff --git a/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/configuration/page.tsx b/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/configuration/page.tsx
index 6e1f93db..a97fb142 100644
--- a/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/configuration/page.tsx
+++ b/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/configuration/page.tsx
@@ -9,6 +9,7 @@ import { DeleteConfirmationDialog } from "@/components/core/delete-confirmation-
import { EditableText } from "@/components/core/editable-text";
import { LocalDate } from "@/components/core/local-date";
import { ConfigSection } from "@/components/service/details/config-section";
+import { CronsSection } from "@/components/service/details/crons-section";
import { HealthCheckSection } from "@/components/service/details/health-check-section";
import { NetworkingSection } from "@/components/service/details/networking-section";
import { ReplicasSection } from "@/components/service/details/replicas-section";
@@ -119,6 +120,8 @@ export default function ConfigurationPage() {
+
+
diff --git a/web/app/api/inngest/route.ts b/web/app/api/inngest/route.ts
index 99212b54..3f82d6a0 100644
--- a/web/app/api/inngest/route.ts
+++ b/web/app/api/inngest/route.ts
@@ -24,6 +24,8 @@ import {
scheduledDeploymentsCheck,
serviceDeletionWorkflow,
serviceCommandRetention,
+ serviceCronDispatcher,
+ serviceCronWorkflow,
serviceRestoreWorkflow,
staleItemsCleanup,
staleServerCheck,
@@ -58,5 +60,7 @@ export const { GET, POST, PUT } = serve({
expiredDeletedServicesPurge,
notificationDelivery,
notificationRetention,
+ serviceCronDispatcher,
+ serviceCronWorkflow,
],
});
diff --git a/web/app/api/projects/[id]/services/route.ts b/web/app/api/projects/[id]/services/route.ts
index cb44107a..9ad6b75f 100644
--- a/web/app/api/projects/[id]/services/route.ts
+++ b/web/app/api/projects/[id]/services/route.ts
@@ -12,6 +12,7 @@ import {
servers,
servicePorts,
serviceReplicas,
+ serviceCrons,
serviceRevisions,
services,
serviceVolumes,
@@ -161,6 +162,20 @@ export async function GET(
: and(eq(services.projectId, projectId), isNull(services.deletedAt)),
)
.orderBy(services.createdAt);
+ const cronRows =
+ servicesList.length > 0
+ ? await db
+ .select()
+ .from(serviceCrons)
+ .where(
+ inArray(
+ serviceCrons.serviceId,
+ servicesList.map((service) => service.id),
+ ),
+ )
+ .orderBy(serviceCrons.path)
+ : [];
+ const cronsByService = Map.groupBy(cronRows, (cron) => cron.serviceId);
const result = await Promise.all(
servicesList.map(async (service) => {
@@ -401,6 +416,7 @@ export async function GET(
return {
...service,
ports,
+ crons: cronsByService.get(service.id) ?? [],
configuredReplicas: replicas,
deployments: deploymentsWithDetails,
secrets: serviceSecrets,
diff --git a/web/app/api/services/[id]/logs/route.ts b/web/app/api/services/[id]/logs/route.ts
index 49bcd40b..f4d3f314 100644
--- a/web/app/api/services/[id]/logs/route.ts
+++ b/web/app/api/services/[id]/logs/route.ts
@@ -34,7 +34,9 @@ export async function GET(
const serverId = url.searchParams.get("serverId") || undefined;
const logTypeParam = url.searchParams.get("type");
const logType =
- logTypeParam === "container" || logTypeParam === "http"
+ logTypeParam === "container" ||
+ logTypeParam === "http" ||
+ logTypeParam === "cron"
? (logTypeParam as LogType)
: undefined;
diff --git a/web/components/service/details/crons-section.tsx b/web/components/service/details/crons-section.tsx
new file mode 100644
index 00000000..3d7a34e7
--- /dev/null
+++ b/web/components/service/details/crons-section.tsx
@@ -0,0 +1,124 @@
+"use client";
+
+import cronstrue from "cronstrue";
+import { Ban, CheckCircle2, XCircle } from "lucide-react";
+import { memo } from "react";
+import { LocalDate } from "@/components/core/local-date";
+import { ConfigSection } from "@/components/service/details/config-section";
+import { StatusBadge } from "@/components/ui/status-badge";
+import type { ServiceCron, ServiceWithDetails as Service } from "@/db/types";
+
+const STATUS_CONFIG = {
+ succeeded: {
+ icon: CheckCircle2,
+ label: "Succeeded",
+ className: "text-green-500",
+ },
+ failed: { icon: XCircle, label: "Failed", className: "text-red-500" },
+ skipped: { icon: Ban, label: "Skipped", className: "text-orange-500" },
+} as const;
+
+function describeSchedule(schedule: string) {
+ try {
+ return cronstrue.toString(schedule, { verbose: true });
+ } catch {
+ return "Invalid cron expression";
+ }
+}
+
+function CronStatus({
+ status,
+}: {
+ status: NonNullable
;
+}) {
+ const config = STATUS_CONFIG[status];
+ return (
+
+ );
+}
+
+function formatDuration(durationMs: number) {
+ return durationMs < 1000
+ ? `${durationMs}ms`
+ : `${(durationMs / 1000).toFixed(2)}s`;
+}
+
+export const CronsSection = memo(function CronsSection({
+ service,
+}: {
+ service: Service;
+}) {
+ const crons = service.crons ?? [];
+
+ return (
+ 0 ? `${crons.length}` : "None"}
+ summaryMuted={crons.length === 0}
+ >
+
+
+ Cron jobs are managed in{" "}
+ techulus.yml. Schedules run in UTC
+ and are read-only here.
+
+
+ {crons.length === 0 ? (
+
+ No cron jobs configured.
+
+ ) : (
+
+ {crons.map((cron) => (
+
+
+
+ {cron.path}
+
+
{cron.schedule}
+
+ {describeSchedule(cron.schedule)} (UTC)
+
+
+
+ {cron.lastStatus ? (
+
+
+
+
+
+
+
+
+ HTTP status: {cron.lastStatusCode ?? "—"}
+
+ Duration:{" "}
+ {cron.lastDurationMs == null
+ ? "—"
+ : formatDuration(cron.lastDurationMs)}
+
+
+ {cron.lastError && (
+
+ {cron.lastError}
+
+ )}
+
+ ) : (
+
Never run.
+ )}
+
+ ))}
+
+ )}
+
+
+ );
+});
diff --git a/web/db/schema.ts b/web/db/schema.ts
index 82f23b87..5a80770d 100644
--- a/web/db/schema.ts
+++ b/web/db/schema.ts
@@ -602,6 +602,41 @@ export const services = pgTable(
],
);
+export const serviceCrons = pgTable(
+ "service_crons",
+ {
+ id: text("id").primaryKey(),
+ serviceId: text("service_id")
+ .notNull()
+ .references(() => services.id, { onDelete: "cascade" }),
+ path: text("path").notNull(),
+ schedule: text("schedule").notNull(),
+ nextScheduledFor: timestamp("next_scheduled_for", {
+ withTimezone: true,
+ }).notNull(),
+ lastScheduledFor: timestamp("last_scheduled_for", { withTimezone: true }),
+ lastAttemptedFor: timestamp("last_attempted_for", { withTimezone: true }),
+ lastStartedAt: timestamp("last_started_at", { withTimezone: true }),
+ lastFinishedAt: timestamp("last_finished_at", { withTimezone: true }),
+ lastStatus: text("last_status", {
+ enum: ["succeeded", "failed", "skipped"],
+ }),
+ lastStatusCode: integer("last_status_code"),
+ lastDurationMs: integer("last_duration_ms"),
+ lastError: text("last_error"),
+ createdAt: timestamp("created_at", { withTimezone: true })
+ .defaultNow()
+ .notNull(),
+ },
+ (table) => [
+ uniqueIndex("service_crons_service_path_unique_idx").on(
+ table.serviceId,
+ table.path,
+ ),
+ index("service_crons_due_scan_idx").on(table.nextScheduledFor),
+ ],
+);
+
export const serviceReplicas = pgTable(
"service_replicas",
{
diff --git a/web/db/types.ts b/web/db/types.ts
index 875fedf4..95849e6d 100644
--- a/web/db/types.ts
+++ b/web/db/types.ts
@@ -13,6 +13,7 @@ import type {
servicePorts,
serviceReplicas,
serviceCommands,
+ serviceCrons,
services,
serviceVolumes,
user,
@@ -27,6 +28,7 @@ export type ServicePort = typeof servicePorts.$inferSelect;
export type ServiceVolume = typeof serviceVolumes.$inferSelect;
export type ServiceReplica = typeof serviceReplicas.$inferSelect;
export type ServiceCommand = typeof serviceCommands.$inferSelect;
+export type ServiceCron = typeof serviceCrons.$inferSelect;
export type Secret = typeof secrets.$inferSelect;
export type Deployment = typeof deployments.$inferSelect;
export type DeploymentPort = typeof deploymentPorts.$inferSelect;
@@ -47,6 +49,7 @@ export type ServiceWithDetails = Service & {
activeConfig?: DeployedConfig | null;
currentSource: SourceConfig;
ports: ServicePort[];
+ crons: ServiceCron[];
configuredReplicas: Array<
ServiceReplica & { serverName: string; serverIsProxy: boolean }
>;
diff --git a/web/lib/inngest/events/index.ts b/web/lib/inngest/events/index.ts
index 0bf59f8d..4e284e71 100644
--- a/web/lib/inngest/events/index.ts
+++ b/web/lib/inngest/events/index.ts
@@ -8,6 +8,7 @@ export type { ResourceEvents } from "./resource";
export type { RestoreEvents } from "./restore";
export type { RolloutEvents } from "./rollout";
export type { ServiceDeletionEvents } from "./service-deletion";
+export type { ServiceCronEvents } from "./service-cron";
import type { BackupEvents } from "./backup";
import type { BuildEvents } from "./build";
@@ -17,6 +18,7 @@ import type { ResourceEvents } from "./resource";
import type { RestoreEvents } from "./restore";
import type { RolloutEvents } from "./rollout";
import type { ServiceDeletionEvents } from "./service-deletion";
+import type { ServiceCronEvents } from "./service-cron";
export type Events = RolloutEvents &
MigrationEvents &
@@ -25,7 +27,8 @@ export type Events = RolloutEvents &
BuildEvents &
ServiceDeletionEvents &
ResourceEvents &
- NotificationEvents;
+ NotificationEvents &
+ ServiceCronEvents;
type EventName = keyof Events & string;
type EventData = Events[TName]["data"];
@@ -61,4 +64,5 @@ export const inngestEvents = {
manifestCompleted: defineEvent("manifest/completed"),
manifestFailed: defineEvent("manifest/failed"),
notificationRequested: defineEvent("notification/requested"),
+ serviceCronExecute: defineEvent("service-cron/execute"),
};
diff --git a/web/lib/inngest/events/service-cron.ts b/web/lib/inngest/events/service-cron.ts
new file mode 100644
index 00000000..49fc3e4a
--- /dev/null
+++ b/web/lib/inngest/events/service-cron.ts
@@ -0,0 +1,5 @@
+export type ServiceCronEvents = {
+ "service-cron/execute": {
+ data: { cronId: string; schedule: string; scheduledFor: string };
+ };
+};
diff --git a/web/lib/inngest/functions/crons.ts b/web/lib/inngest/functions/crons.ts
index ddcd433b..89b5a9b7 100644
--- a/web/lib/inngest/functions/crons.ts
+++ b/web/lib/inngest/functions/crons.ts
@@ -1,4 +1,7 @@
+import { and, asc, eq, isNull, lte } from "drizzle-orm";
import { cron } from "inngest";
+import { db } from "@/db";
+import { serviceCrons, services } from "@/db/schema";
import {
cleanupExpiredChallenges,
renewExpiringCertificates,
@@ -19,6 +22,11 @@ import {
runAutoscalingController,
} from "@/lib/scheduler";
import { inngest } from "../client";
+import {
+ cronEventId,
+ latestDueOccurrence,
+ nextOccurrenceAfter,
+} from "@/lib/service-crons";
export const staleServerCheck = inngest.createFunction(
{
@@ -206,3 +214,58 @@ export const serviceCommandRetention = inngest.createFunction(
async ({ step }) =>
step.run("cleanup-service-commands", cleanupOldServiceCommands),
);
+
+export const serviceCronDispatcher = inngest.createFunction(
+ {
+ id: "cron-service-cron-dispatcher",
+ triggers: [cron("* * * * *")],
+ singleton: { mode: "skip" },
+ },
+ async ({ step }) =>
+ step.run("dispatch-service-crons", async () => {
+ const now = new Date();
+ const rows = await db
+ .select({ cron: serviceCrons })
+ .from(serviceCrons)
+ .innerJoin(
+ services,
+ and(
+ eq(serviceCrons.serviceId, services.id),
+ isNull(services.deletedAt),
+ ),
+ )
+ .where(lte(serviceCrons.nextScheduledFor, now))
+ .orderBy(asc(serviceCrons.nextScheduledFor))
+ .limit(100)
+ .then((results) => results.map(({ cron }) => cron));
+ for (const row of rows) {
+ const occurrence = latestDueOccurrence(
+ row.schedule,
+ new Date(row.nextScheduledFor.getTime() - 1),
+ now,
+ );
+ if (!occurrence) continue;
+ await inngest.send({
+ id: cronEventId(row.id, occurrence),
+ name: "service-cron/execute",
+ data: {
+ cronId: row.id,
+ schedule: row.schedule,
+ scheduledFor: occurrence.toISOString(),
+ },
+ });
+ await db
+ .update(serviceCrons)
+ .set({
+ lastScheduledFor: occurrence,
+ nextScheduledFor: nextOccurrenceAfter(row.schedule, now),
+ })
+ .where(
+ and(
+ eq(serviceCrons.id, row.id),
+ eq(serviceCrons.nextScheduledFor, row.nextScheduledFor),
+ ),
+ );
+ }
+ }),
+);
diff --git a/web/lib/inngest/functions/index.ts b/web/lib/inngest/functions/index.ts
index 51caadfa..88a7b203 100644
--- a/web/lib/inngest/functions/index.ts
+++ b/web/lib/inngest/functions/index.ts
@@ -9,6 +9,7 @@ export {
controlPlaneUpdateCheck,
notificationRetention,
serviceCommandRetention,
+ serviceCronDispatcher,
oldBackupsCleanup,
registryArtifactRetention,
scheduledBackupsCheck,
@@ -16,6 +17,7 @@ export {
staleItemsCleanup,
staleServerCheck,
} from "./crons";
+export { serviceCronWorkflow } from "./service-cron-workflow";
export { migrationWorkflow } from "./migration-workflow";
export { notificationDelivery } from "./notification-delivery";
export { onDeploymentFailed } from "./on-deployment-failed";
diff --git a/web/lib/inngest/functions/service-cron-workflow.ts b/web/lib/inngest/functions/service-cron-workflow.ts
new file mode 100644
index 00000000..3bec2513
--- /dev/null
+++ b/web/lib/inngest/functions/service-cron-workflow.ts
@@ -0,0 +1,20 @@
+import { executeServiceCron } from "@/lib/service-crons";
+import { inngest } from "../client";
+import { inngestEvents } from "../events";
+
+export const serviceCronWorkflow = inngest.createFunction(
+ {
+ id: "service-cron-execute",
+ retries: 0,
+ concurrency: [{ limit: 1, key: "event.data.cronId" }],
+ triggers: [inngestEvents.serviceCronExecute],
+ },
+ async ({ event, step }) =>
+ step.run("execute-service-cron", () =>
+ executeServiceCron(
+ event.data.cronId,
+ event.data.schedule,
+ new Date(event.data.scheduledFor),
+ ),
+ ),
+);
diff --git a/web/lib/public-api-routes.ts b/web/lib/public-api-routes.ts
index 66234516..2ea2f759 100644
--- a/web/lib/public-api-routes.ts
+++ b/web/lib/public-api-routes.ts
@@ -133,6 +133,7 @@ export async function getServiceDetails(
startCommand: current.startCommand,
resources:
current.resources.cpuCores == null ? null : current.resources,
+ crons: current.crons,
},
management: configuration.management,
});
diff --git a/web/lib/public-api.ts b/web/lib/public-api.ts
index a6159dce..e9993ea1 100644
--- a/web/lib/public-api.ts
+++ b/web/lib/public-api.ts
@@ -1,5 +1,15 @@
import { createHash, randomUUID } from "node:crypto";
-import { and, desc, eq, inArray, isNull, ne, sql } from "drizzle-orm";
+import {
+ and,
+ desc,
+ eq,
+ inArray,
+ isNull,
+ ne,
+ notInArray,
+ sql,
+} from "drizzle-orm";
+import { CronExpressionParser } from "cron-parser";
import { z } from "zod";
import { db } from "@/db";
import {
@@ -8,6 +18,7 @@ import {
githubRepos,
projects,
servers,
+ serviceCrons,
servicePorts,
serviceReplicas,
serviceRevisions,
@@ -27,6 +38,59 @@ import {
const githubPathPart = /^[A-Za-z0-9_.-]+$/;
const windowsAbsolutePath = /^[A-Za-z]:[\\/]/;
+export function nextOccurrenceAfter(schedule: string, after: Date): Date {
+ return CronExpressionParser.parse(schedule, {
+ currentDate: after,
+ tz: "UTC",
+ })
+ .next()
+ .toDate();
+}
+
+export function isSafeCronPath(value: string): boolean {
+ if (!value.startsWith("/") || value.startsWith("//") || value.length > 2048)
+ return false;
+ let decoded: string;
+ try {
+ decoded = decodeURIComponent(value);
+ } catch {
+ return false;
+ }
+ if (
+ !decoded.startsWith("/") ||
+ decoded.startsWith("//") ||
+ // eslint-disable-next-line no-control-regex -- Manifest paths must reject control characters.
+ /[?#\\\u0000-\u001f\u007f]/.test(decoded)
+ )
+ return false;
+ return !decoded.split("/").some((part) => part === "." || part === "..");
+}
+
+export const publicCronSchema = z.strictObject({
+ path: z
+ .string()
+ .trim()
+ .min(1)
+ .max(2048)
+ .refine(isSafeCronPath, "Invalid cron path"),
+ schedule: z
+ .string()
+ .trim()
+ .min(1)
+ .max(255)
+ .refine((value) => value.split(/\s+/).length === 5, {
+ message: "Schedule must contain exactly five cron fields",
+ })
+ .refine((value) => {
+ try {
+ CronExpressionParser.parse(value, { tz: "UTC" });
+ return true;
+ } catch {
+ return false;
+ }
+ }, "Invalid UTC cron expression"),
+});
+
export function canonicalGitHubRepository(value: string): string {
let url: URL;
try {
@@ -314,7 +378,7 @@ function sanitizeSpec(specification: unknown) {
}
export async function safeConfiguration(service: NestedService) {
- const [repo, ports, volumes, placements, activeDeployment] =
+ const [repo, ports, crons, volumes, placements, activeDeployment] =
await Promise.all([
db
.select()
@@ -326,6 +390,10 @@ export async function safeConfiguration(service: NestedService) {
.select()
.from(servicePorts)
.where(eq(servicePorts.serviceId, service.id)),
+ db
+ .select({ path: serviceCrons.path, schedule: serviceCrons.schedule })
+ .from(serviceCrons)
+ .where(eq(serviceCrons.serviceId, service.id)),
db
.select({
name: serviceVolumes.name,
@@ -374,6 +442,9 @@ export async function safeConfiguration(service: NestedService) {
a.name.localeCompare(b.name, "en") ||
a.containerPath.localeCompare(b.containerPath, "en"),
);
+ const sortedCrons = crons.toSorted((a, b) =>
+ a.path.localeCompare(b.path, "en"),
+ );
const replicaCount = getServiceTotalReplicas({
...service,
configuredReplicas: sortedPlacements,
@@ -428,6 +499,7 @@ export async function safeConfiguration(service: NestedService) {
tlsPassthrough: port.tlsPassthrough,
})),
volumes: sortedVolumes,
+ crons: sortedCrons,
serverless: {
enabled: service.serverlessEnabled,
sleepAfterSeconds: service.serverlessSleepAfterSeconds,
@@ -628,6 +700,16 @@ export const replaceConfigurationSchema = z.strictObject({
"CPU and memory limits must both be set or both be null",
)
.nullable(),
+ crons: z
+ .array(publicCronSchema)
+ .max(100)
+ .superRefine((crons, context) => {
+ if (new Set(crons.map((cron) => cron.path)).size !== crons.length)
+ context.addIssue({
+ code: "custom",
+ message: "Cron paths must be unique",
+ });
+ }),
});
type PublicApiDomainError = Error & { code: string; status: number };
@@ -664,6 +746,9 @@ function healthCheckFromService(service: NestedService) {
}
type ReplacementInput = z.infer;
+type CanonicalReplacementInput = Omit & {
+ crons?: ReplacementInput["crons"];
+};
type ConfigurationChange = { field: string; from: unknown; to: unknown };
function canonicalPlanSource(source: PublicSource) {
@@ -680,6 +765,7 @@ function canonicalReplacementState(
source: ReturnType,
ports: Array<{ port: number; isPublic: boolean; domain: string | null }>,
placements: Array<{ serverId: string; count: number }>,
+ crons: Array<{ path: string; schedule: string }>,
) {
const resources =
service.resourceCpuLimit == null && service.resourceMemoryLimitMb == null
@@ -726,11 +812,14 @@ function canonicalReplacementState(
healthCheck: healthCheckFromService(service),
startCommand: service.startCommand?.trim() || null,
resources,
+ crons: crons
+ .map(({ path, schedule }) => ({ path, schedule }))
+ .toSorted((a, b) => a.path.localeCompare(b.path, "en")),
serverless: { enabled: service.serverlessEnabled },
};
}
-export function canonicalDesired(input: ReplacementInput) {
+export function canonicalDesired(input: CanonicalReplacementInput) {
return {
...input,
source: canonicalPlanSource(input.source),
@@ -762,6 +851,9 @@ export function canonicalDesired(input: ReplacementInput) {
},
}
: input.placement,
+ crons: (input.crons ?? []).toSorted((a, b) =>
+ a.path.localeCompare(b.path, "en"),
+ ),
};
}
@@ -807,12 +899,19 @@ function configurationChanges(
}
export function planCanonicalConfiguration(
- current: ReturnType,
- desiredInput: ReplacementInput,
+ current: Omit, "crons"> & {
+ crons?: Array<{ path: string; schedule: string }>;
+ },
+ desiredInput: CanonicalReplacementInput,
) {
+ const { serverless, ...currentWithoutServerless } = current;
const canonicalCurrent = {
- ...current,
+ ...currentWithoutServerless,
source: canonicalPlanSource(current.source),
+ crons: (current.crons ?? [])
+ .map(({ path, schedule }) => ({ path, schedule }))
+ .toSorted((a, b) => a.path.localeCompare(b.path, "en")),
+ serverless,
};
const desiredConfiguration = canonicalDesired(desiredInput);
const desired = {
@@ -880,7 +979,7 @@ async function replaceConfigurationInternal(
.limit(1)
.then((rows) => rows[0]);
if (!persisted) domainError("Service not found", "NOT_FOUND", 404);
- const [ports, volumes, placements, repo] = await Promise.all([
+ const [ports, volumes, placements, repo, crons] = await Promise.all([
tx
.select()
.from(servicePorts)
@@ -899,6 +998,10 @@ async function replaceConfigurationInternal(
.where(eq(githubRepos.serviceId, service.id))
.limit(1)
.then((rows) => rows[0]),
+ tx
+ .select()
+ .from(serviceCrons)
+ .where(eq(serviceCrons.serviceId, service.id)),
]);
const source = resolvePersistedSourceFromRows(persisted, repo);
const currentState = canonicalReplacementState(
@@ -906,6 +1009,7 @@ async function replaceConfigurationInternal(
source,
ports,
placements,
+ crons,
);
if (
input.source.type === "image" &&
@@ -1300,6 +1404,42 @@ async function replaceConfigurationInternal(
}
}
}
+ const cronsByPath = new Map(crons.map((cron) => [cron.path, cron]));
+ const appliedAt = new Date();
+ for (const cron of input.crons) {
+ const existing = cronsByPath.get(cron.path);
+ if (!existing) {
+ await tx.insert(serviceCrons).values({
+ id: randomUUID(),
+ serviceId: service.id,
+ ...cron,
+ nextScheduledFor: nextOccurrenceAfter(cron.schedule, appliedAt),
+ });
+ } else if (existing.schedule !== cron.schedule) {
+ await tx
+ .update(serviceCrons)
+ .set({
+ schedule: cron.schedule,
+ nextScheduledFor: nextOccurrenceAfter(cron.schedule, appliedAt),
+ })
+ .where(eq(serviceCrons.id, existing.id));
+ }
+ }
+ if (input.crons.length === 0) {
+ await tx
+ .delete(serviceCrons)
+ .where(eq(serviceCrons.serviceId, service.id));
+ } else {
+ await tx.delete(serviceCrons).where(
+ and(
+ eq(serviceCrons.serviceId, service.id),
+ notInArray(
+ serviceCrons.path,
+ input.crons.map((cron) => cron.path),
+ ),
+ ),
+ );
+ }
return { targetServiceName: persisted.name, ...plan };
});
diff --git a/web/lib/service-crons.ts b/web/lib/service-crons.ts
new file mode 100644
index 00000000..78d0d8db
--- /dev/null
+++ b/web/lib/service-crons.ts
@@ -0,0 +1,362 @@
+import { lookup as dnsLookup } from "node:dns/promises";
+import * as http from "node:http";
+import * as https from "node:https";
+import { isIP } from "node:net";
+import { and, eq, inArray, isNull, lt, or } from "drizzle-orm";
+import { CronExpressionParser } from "cron-parser";
+import { Address4, Address6 } from "ip-address";
+import { db } from "@/db";
+import { secrets, serviceCrons, services } from "@/db/schema";
+import { decryptSecret } from "@/lib/crypto";
+import { isSafeCronPath, nextOccurrenceAfter } from "@/lib/public-api";
+import { ingestCronLog, type CronLog } from "@/lib/victoria-logs";
+
+const MAX_ERROR = 500;
+const EXECUTION_BUDGET_MS = 10_000;
+const blockedV4 = [
+ "0.0.0.0/8",
+ "10.0.0.0/8",
+ "100.64.0.0/10",
+ "127.0.0.0/8",
+ "169.254.0.0/16",
+ "172.16.0.0/12",
+ "192.0.0.0/24",
+ "192.0.2.0/24",
+ "192.88.99.0/24",
+ "192.168.0.0/16",
+ "198.18.0.0/15",
+ "198.51.100.0/24",
+ "203.0.113.0/24",
+ "224.0.0.0/4",
+ "240.0.0.0/4",
+].map((value) => new Address4(value));
+const blockedV6 = ["2001::/23", "2001:db8::/32", "3fff::/20"].map(
+ (value) => new Address6(value),
+);
+
+export type ResolvedAddress = { address: string; family: 4 | 6 };
+export type CronRequestResult = {
+ status: "succeeded" | "failed";
+ statusCode: number | null;
+ error: string | null;
+};
+
+export { nextOccurrenceAfter };
+
+export function latestDueOccurrence(
+ schedule: string,
+ cursor: Date,
+ now: Date,
+): Date | null {
+ try {
+ const occurrence = CronExpressionParser.parse(schedule, {
+ currentDate: new Date(now.getTime() + 1),
+ tz: "UTC",
+ })
+ .prev()
+ .toDate();
+ return occurrence > cursor && occurrence <= now ? occurrence : null;
+ } catch {
+ return null;
+ }
+}
+
+export function cronEventId(cronId: string, scheduledFor: Date): string {
+ return `service-cron:${cronId}:${scheduledFor.toISOString()}`;
+}
+
+export function sanitizeCronError(error: unknown): string {
+ const message =
+ error instanceof Error ? error.message : "Cron request failed";
+ // eslint-disable-next-line no-control-regex -- Strip unsafe control characters from persisted errors.
+ return message.replace(/[\u0000-\u001f\u007f]/g, " ").slice(0, MAX_ERROR);
+}
+
+export function isGlobalAddress(value: string): boolean {
+ try {
+ if (isIP(value) === 4) {
+ const address = new Address4(value);
+ return !blockedV4.some((range) => address.isInSubnet(range));
+ }
+ if (isIP(value) === 6) {
+ const address = new Address6(value);
+ if (address.is4()) return isGlobalAddress(address.to4().address);
+ return (
+ address.isInSubnet(new Address6("2000::/3")) &&
+ !blockedV6.some((range) => address.isInSubnet(range))
+ );
+ }
+ } catch {}
+ return false;
+}
+
+export function parseCronUrl(base: string, path: string): URL {
+ if (!isSafeCronPath(path)) throw new Error("Invalid cron path");
+ let url: URL;
+ try {
+ url = new URL(base);
+ } catch {
+ throw new Error("Invalid CRON_BASE_URL");
+ }
+ if (
+ (url.protocol !== "http:" && url.protocol !== "https:") ||
+ url.username ||
+ url.password ||
+ url.search ||
+ url.hash
+ )
+ throw new Error("Invalid CRON_BASE_URL");
+ return new URL(path, `${url.origin}/`);
+}
+
+export async function resolvePublicAddresses(
+ hostname: string,
+ lookup: (
+ hostname: string,
+ options: { all: true; verbatim: true },
+ ) => Promise = async (host, options) =>
+ (await dnsLookup(host, options)).map((answer) => ({
+ address: answer.address,
+ family: answer.family as 4 | 6,
+ })),
+): Promise {
+ const literal = isIP(hostname);
+ let answers: ResolvedAddress[];
+ try {
+ answers = literal
+ ? [{ address: hostname, family: literal as 4 | 6 }]
+ : await lookup(hostname, { all: true, verbatim: true });
+ } catch {
+ throw new Error("DNS lookup failed");
+ }
+ if (
+ !answers.length ||
+ answers.some(({ address }) => !isGlobalAddress(address))
+ )
+ throw new Error("Cron destination is not public");
+ return answers;
+}
+
+type RequestImpl = typeof http.request;
+export function validateCronTransport(url: URL, secret?: string): void {
+ if (secret && url.protocol === "http:")
+ throw new Error("CRON_SECRET requires HTTPS");
+}
+
+export async function performCronGet(
+ url: URL,
+ addresses: ResolvedAddress[],
+ secret: string | undefined,
+ timeoutMs: number,
+ requestImpl: RequestImpl = url.protocol === "https:"
+ ? https.request
+ : http.request,
+): Promise {
+ return new Promise((resolve) => {
+ let settled = false;
+ const finish = (result: CronRequestResult) => {
+ if (settled) return;
+ settled = true;
+ resolve(result);
+ };
+ if (timeoutMs <= 0)
+ return finish({
+ status: "failed",
+ statusCode: null,
+ error: "Cron request timed out",
+ });
+ const requestOptions: http.RequestOptions & { autoSelectFamily: boolean } =
+ {
+ method: "GET",
+ agent: false,
+ autoSelectFamily: false,
+ headers: secret ? { Authorization: `Bearer ${secret}` } : undefined,
+ lookup: (_hostname, options, callback) => {
+ const selected = addresses[0];
+ if (options?.all) callback(null, addresses);
+ else callback(null, selected.address, selected.family);
+ },
+ ...(url.protocol === "https:" ? { servername: url.hostname } : {}),
+ };
+ const req = requestImpl(url, requestOptions, (response) => {
+ const code = response.statusCode ?? null;
+ response.destroy();
+ finish(
+ code != null && code >= 200 && code < 300
+ ? { status: "succeeded", statusCode: code, error: null }
+ : {
+ status: "failed",
+ statusCode: code,
+ error: `HTTP status ${code ?? "unknown"}`,
+ },
+ );
+ });
+ const timer = setTimeout(() => {
+ finish({
+ status: "failed",
+ statusCode: null,
+ error: "Cron request timed out",
+ });
+ req.destroy();
+ }, timeoutMs);
+ timer.unref?.();
+ req.once("error", () =>
+ finish({
+ status: "failed",
+ statusCode: null,
+ error: "Cron request failed",
+ }),
+ );
+ req.once("close", () => {
+ clearTimeout(timer);
+ if (!settled)
+ finish({
+ status: "failed",
+ statusCode: null,
+ error: "Cron request closed",
+ });
+ });
+ req.end();
+ });
+}
+
+async function withinDeadline(
+ promise: Promise,
+ deadline: number,
+): Promise {
+ const remaining = deadline - Date.now();
+ if (remaining <= 0) throw new Error("Cron request timed out");
+ let timer: ReturnType;
+ try {
+ return await Promise.race([
+ promise,
+ new Promise((_, reject) => {
+ timer = setTimeout(
+ () => reject(new Error("Cron request timed out")),
+ remaining,
+ );
+ }),
+ ]);
+ } finally {
+ clearTimeout(timer!);
+ }
+}
+
+export async function executeServiceCron(
+ cronId: string,
+ schedule: string,
+ scheduledFor: Date,
+) {
+ const deadline = Date.now() + EXECUTION_BUDGET_MS;
+ const row = await db
+ .select({ cron: serviceCrons, serviceId: services.id })
+ .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 (!row || row.cron.schedule !== schedule) return { stale: true as const };
+ const startedAt = new Date();
+ const claimed = await db
+ .update(serviceCrons)
+ .set({ lastAttemptedFor: scheduledFor, lastStartedAt: startedAt })
+ .where(
+ and(
+ eq(serviceCrons.id, cronId),
+ eq(serviceCrons.schedule, schedule),
+ or(
+ isNull(serviceCrons.lastAttemptedFor),
+ lt(serviceCrons.lastAttemptedFor, scheduledFor),
+ ),
+ ),
+ )
+ .returning({ id: serviceCrons.id });
+ if (!claimed.length) return { stale: true as const };
+ let status: "succeeded" | "failed" | "skipped" = "skipped";
+ let statusCode: number | null = null;
+ let error: string | null = null;
+ let base = "";
+ let secret: string | undefined;
+ try {
+ const values = await db
+ .select()
+ .from(secrets)
+ .where(
+ and(
+ eq(secrets.serviceId, row.serviceId),
+ inArray(secrets.key, ["CRON_BASE_URL", "CRON_SECRET"]),
+ ),
+ );
+ const encrypted = new Map(
+ values.map((value) => [value.key, value.encryptedValue]),
+ );
+ base = encrypted.get("CRON_BASE_URL")
+ ? await decryptSecret(encrypted.get("CRON_BASE_URL")!)
+ : "";
+ secret = encrypted.get("CRON_SECRET")
+ ? await decryptSecret(encrypted.get("CRON_SECRET")!)
+ : undefined;
+ } catch {
+ status = "failed";
+ error = "Cron configuration could not be loaded";
+ }
+ if (error === null) {
+ try {
+ if (!base.trim()) throw new Error("CRON_BASE_URL is not configured");
+ const url = parseCronUrl(base.trim(), row.cron.path);
+ validateCronTransport(url, secret);
+ try {
+ const addresses = await withinDeadline(
+ resolvePublicAddresses(url.hostname),
+ deadline,
+ );
+ ({ status, statusCode, error } = await performCronGet(
+ url,
+ addresses,
+ secret,
+ deadline - Date.now(),
+ ));
+ } catch (cause) {
+ const message = sanitizeCronError(cause);
+ status =
+ message === "Cron destination is not public" ? "skipped" : "failed";
+ error = message;
+ }
+ } catch (cause) {
+ error = sanitizeCronError(cause);
+ status = "skipped";
+ }
+ }
+ const finishedAt = new Date();
+ const durationMs = Math.max(0, finishedAt.getTime() - startedAt.getTime());
+ await db
+ .update(serviceCrons)
+ .set({
+ lastFinishedAt: finishedAt,
+ lastStatus: status,
+ lastStatusCode: statusCode,
+ lastDurationMs: durationMs,
+ lastError: error,
+ })
+ .where(eq(serviceCrons.id, cronId));
+ const log: CronLog = {
+ _msg: `Cron ${status}`,
+ _time: finishedAt.toISOString(),
+ service_id: row.serviceId,
+ cron_id: cronId,
+ path: row.cron.path,
+ scheduled_for: scheduledFor.toISOString(),
+ started_at: startedAt.toISOString(),
+ finished_at: finishedAt.toISOString(),
+ result: status,
+ status: statusCode,
+ duration_ms: durationMs,
+ error,
+ log_type: "cron",
+ };
+ await ingestCronLog(log);
+ return { stale: false as const, status, statusCode, error };
+}
diff --git a/web/lib/victoria-logs.ts b/web/lib/victoria-logs.ts
index 6fd293f3..482e5f6a 100644
--- a/web/lib/victoria-logs.ts
+++ b/web/lib/victoria-logs.ts
@@ -20,7 +20,7 @@ function getQueryEndpoint(): EndpointConfig | undefined {
return parseEndpoint(endpoint);
}
-export type LogType = "container" | "http";
+export type LogType = "container" | "http" | "cron";
type LogSearchField = "_msg" | "path" | "method" | "status" | "client_ip";
export type StoredLog = {
@@ -114,8 +114,10 @@ function buildServiceLogFilter(options: QueryLogsByServiceOptions): string {
let query = formatLogSqlExactFilter("service_id", serviceId);
if (logType === "http") {
query += ` log_type:http`;
+ } else if (logType === "cron") {
+ query += ` log_type:cron`;
} else if (logType === "container") {
- query += ` -log_type:http -log_type:build -log_type:rollout`;
+ query += ` -log_type:http -log_type:build -log_type:rollout -log_type:cron`;
} else {
query += ` -log_type:build -log_type:rollout`;
}
@@ -354,6 +356,42 @@ export type RolloutLog = {
log_type: "rollout";
};
+export type CronLog = {
+ _msg: string;
+ _time: string;
+ service_id: string;
+ cron_id: string;
+ path: string;
+ scheduled_for: string;
+ started_at: string;
+ finished_at: string;
+ result: "succeeded" | "failed" | "skipped";
+ status: number | null;
+ duration_ms: number;
+ error: string | null;
+ log_type: "cron";
+};
+
+export async function ingestCronLog(entry: CronLog): Promise {
+ try {
+ const endpoint = getQueryEndpoint();
+ if (!endpoint) return;
+ const options = buildFetchOptions(endpoint);
+ await fetch(`${endpoint.url}/insert/jsonline`, {
+ ...options,
+ method: "POST",
+ body: `${JSON.stringify(entry)}\n`,
+ headers: {
+ ...((options.headers as Record) || {}),
+ "Content-Type": "application/json",
+ },
+ signal: AbortSignal.timeout(5_000),
+ });
+ } catch {
+ // Execution history is best-effort and must not affect the request result.
+ }
+}
+
export async function ingestRolloutLog(
rolloutId: string,
serviceId: string,
diff --git a/web/tests/inngest-route.test.ts b/web/tests/inngest-route.test.ts
index 316bd55d..e532acea 100644
--- a/web/tests/inngest-route.test.ts
+++ b/web/tests/inngest-route.test.ts
@@ -28,6 +28,8 @@ const mocks = vi.hoisted(() => {
scheduledBackupsCheck: { id: "scheduled-backups-check" },
scheduledDeploymentsCheck: { id: "scheduled-deployments-check" },
serviceCommandRetention: { id: "service-command-retention" },
+ serviceCronDispatcher: { id: "service-cron-dispatcher" },
+ serviceCronWorkflow: { id: "service-cron-workflow" },
serviceDeletionWorkflow: { id: "service-deletion-workflow" },
serviceRestoreWorkflow: { id: "service-restore-workflow" },
staleItemsCleanup: { id: "stale-items-cleanup" },
diff --git a/web/tests/log-routes.test.ts b/web/tests/log-routes.test.ts
index 144c1031..e53f9146 100644
--- a/web/tests/log-routes.test.ts
+++ b/web/tests/log-routes.test.ts
@@ -69,6 +69,18 @@ describe("log routes", () => {
});
});
+ it("accepts cron as a service log type", async () => {
+ const { GET, queryLogsByService } = await loadServiceLogsRoute();
+ const response = await GET(
+ new Request("http://localhost/api/services/service-1/logs?type=cron"),
+ { params: Promise.resolve({ id: "service-1" }) },
+ );
+ expect(response.status).toBe(200);
+ expect(queryLogsByService).toHaveBeenCalledWith(
+ expect.objectContaining({ logType: "cron" }),
+ );
+ });
+
it("passes a validated after cursor to the deployment query", async () => {
const { GET, queryLogsByDeployment } = await loadDeploymentLogsRoute();
const cursor = "2026-07-10T01:02:03Z";
diff --git a/web/tests/public-api-configuration.test.ts b/web/tests/public-api-configuration.test.ts
index 3d01ccef..ad890425 100644
--- a/web/tests/public-api-configuration.test.ts
+++ b/web/tests/public-api-configuration.test.ts
@@ -34,6 +34,7 @@ describe("public API configuration state", () => {
[],
[],
[],
+ [],
[{ serverId: "server-1", serverName: "Sydney", count: 1 }],
[{ id: "deployment-1", revisionId: "revision-1" }],
[
diff --git a/web/tests/public-api-plan.test.ts b/web/tests/public-api-plan.test.ts
index c9813780..a1939ee5 100644
--- a/web/tests/public-api-plan.test.ts
+++ b/web/tests/public-api-plan.test.ts
@@ -135,6 +135,80 @@ describe("configuration plan protocol", () => {
).toEqual(["a", "z"]);
});
+ it("sorts crons by path and includes schedule changes in the plan", () => {
+ const desired = canonicalDesired({
+ name: "web",
+ source: { type: "image", image: "nginx" },
+ hostname: "web",
+ ports: [],
+ placement: { mode: "automatic", replicas: 1 },
+ healthCheck: null,
+ startCommand: null,
+ resources: null,
+ crons: [
+ { path: "/z", schedule: "0 5 * * *" },
+ { path: "/a", schedule: "0 6 * * *" },
+ ],
+ });
+ expect(desired.crons.map((cron) => cron.path)).toEqual(["/a", "/z"]);
+
+ const result = planCanonicalConfiguration(
+ {
+ ...desired,
+ serverless: { enabled: false },
+ crons: [{ path: "/a", schedule: "0 7 * * *" }],
+ },
+ { ...desired, source: { type: "image", image: "nginx" } },
+ );
+ expect(result.changes).toContainEqual({
+ field: "crons",
+ from: [{ path: "/a", schedule: "0 7 * * *" }],
+ to: desired.crons,
+ });
+ });
+
+ it("excludes cron runtime summaries from configuration identity", () => {
+ const definition = { path: "/job", schedule: "0 5 * * *" };
+ const currentCron = {
+ ...definition,
+ id: "cron-1",
+ lastStatus: "succeeded",
+ lastStatusCode: 204,
+ };
+ const failedCurrentCron = {
+ ...currentCron,
+ lastStatus: "failed",
+ lastStatusCode: 500,
+ };
+ const base = {
+ name: "web",
+ source: { type: "image" as const, image: "nginx" },
+ hostname: "web",
+ ports: [],
+ placement: { mode: "automatic" as const, replicas: 1 },
+ healthCheck: null,
+ startCommand: null,
+ resources: null,
+ };
+
+ const first = planCanonicalConfiguration(
+ { ...base, crons: [currentCron], serverless: { enabled: false } },
+ { ...base, crons: [definition] },
+ );
+ const second = planCanonicalConfiguration(
+ {
+ ...base,
+ crons: [failedCurrentCron],
+ serverless: { enabled: false },
+ },
+ { ...base, crons: [definition] },
+ );
+
+ expect(first.action).toBe("noop");
+ expect(first.changes).toEqual([]);
+ expect(second.currentVersion).toBe(first.currentVersion);
+ });
+
it("reports every managed field change, including removals and null clears", () => {
const current = {
name: "old-web",
diff --git a/web/tests/public-api-source.test.ts b/web/tests/public-api-source.test.ts
index 202f880f..d758ed99 100644
--- a/web/tests/public-api-source.test.ts
+++ b/web/tests/public-api-source.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import {
canonicalGitHubRepository,
+ isSafeCronPath,
isSafeRepositoryRoot,
publicSourceSchema,
replaceConfigurationSchema,
@@ -15,6 +16,7 @@ const completeConfiguration = (overrides: Record = {}) => ({
healthCheck: null,
startCommand: null,
resources: null,
+ crons: [],
...overrides,
});
@@ -194,3 +196,52 @@ describe("public API placement schema", () => {
).toBe(false);
});
});
+
+describe("public API cron schema", () => {
+ it.each([
+ "//host/job",
+ "/job?x=1",
+ "/job#part",
+ "/job\\next",
+ "/a/../b",
+ "/a/%2e%2e/b",
+ "/%2fhost",
+ "/bad%zz",
+ "/line%0Abreak",
+ ])("rejects unsafe cron path %j", (path) => {
+ expect(isSafeCronPath(path)).toBe(false);
+ });
+
+ it("accepts valid unique five-field UTC cron definitions", () => {
+ expect(
+ replaceConfigurationSchema.safeParse(
+ completeConfiguration({
+ crons: [{ path: "/jobs/nightly", schedule: "0 5 * * *" }],
+ }),
+ ).success,
+ ).toBe(true);
+ });
+
+ it.each([
+ [{ path: "/jobs/nightly", schedule: "0 5 * *" }, "non-five-field schedule"],
+ [
+ { path: "/jobs/nightly", schedule: "99 5 * * *" },
+ "invalid cron expression",
+ ],
+ ])("rejects %s", (cron) => {
+ expect(
+ replaceConfigurationSchema.safeParse(
+ completeConfiguration({ crons: [cron] }),
+ ).success,
+ ).toBe(false);
+ });
+
+ it("rejects duplicate paths", () => {
+ const cron = { path: "/jobs/nightly", schedule: "0 5 * * *" };
+ expect(
+ replaceConfigurationSchema.safeParse(
+ completeConfiguration({ crons: [cron, cron] }),
+ ).success,
+ ).toBe(false);
+ });
+});
diff --git a/web/tests/service-crons.test.ts b/web/tests/service-crons.test.ts
new file mode 100644
index 00000000..351f054e
--- /dev/null
+++ b/web/tests/service-crons.test.ts
@@ -0,0 +1,213 @@
+import { EventEmitter } from "node:events";
+import type { ClientRequest, IncomingMessage, RequestOptions } from "node:http";
+import { describe, expect, it, vi } from "vitest";
+import {
+ cronEventId,
+ isGlobalAddress,
+ latestDueOccurrence,
+ nextOccurrenceAfter,
+ parseCronUrl,
+ performCronGet,
+ resolvePublicAddresses,
+ validateCronTransport,
+} from "@/lib/service-crons";
+
+describe("service cron scheduling and SSRF validation", () => {
+ it("returns the first UTC occurrence strictly after the supplied instant", () => {
+ expect(
+ nextOccurrenceAfter(
+ "0 5 * * *",
+ new Date("2026-08-06T05:00:00.000Z"),
+ ).toISOString(),
+ ).toBe("2026-08-07T05:00:00.000Z");
+ expect(
+ nextOccurrenceAfter(
+ "30 23 * * *",
+ new Date("2026-08-06T22:00:00-04:00"),
+ ).toISOString(),
+ ).toBe("2026-08-07T23:30:00.000Z");
+ });
+
+ it("coalesces missed intervals to the latest UTC occurrence", () => {
+ const due = latestDueOccurrence(
+ "* * * * *",
+ new Date("2000-01-01Z"),
+ new Date("2026-08-06T10:05:30Z"),
+ );
+ expect(due?.toISOString()).toBe("2026-08-06T10:05:00.000Z");
+ expect(latestDueOccurrence("* * * * *", due!, due!)).toBeNull();
+ expect(cronEventId("cron-1", due!)).toBe(cronEventId("cron-1", due!));
+ });
+
+ it.each([
+ ["ftp://example.com", "/job"],
+ ["https://user:pass@example.com", "/job"],
+ ["https://example.com?x=1", "/job"],
+ ["https://example.com#x", "/job"],
+ ["https://example.com", "//host/job"],
+ ["https://example.com", "/a/%2e%2e/job"],
+ ])("rejects unsafe URL %s %s", (base, path) =>
+ expect(() => parseCronUrl(base, path)).toThrow(),
+ );
+
+ it("joins a safe path while preserving the origin hostname", () => {
+ expect(parseCronUrl("https://example.com", "/jobs/nightly").href).toBe(
+ "https://example.com/jobs/nightly",
+ );
+ });
+
+ it.each([
+ "10.0.0.1",
+ "127.0.0.1",
+ "169.254.1.1",
+ "192.0.2.1",
+ "192.88.99.1",
+ "::1",
+ "fc00::1",
+ "fe80::1",
+ "2001::1",
+ "2001:db8::1",
+ "3fff::1",
+ "::ffff:10.0.0.1",
+ ])("rejects non-global address %s", (address) =>
+ expect(isGlobalAddress(address)).toBe(false),
+ );
+
+ it("rejects mixed DNS results", async () => {
+ const lookup = vi.fn(async () => [
+ { address: "8.8.8.8", family: 4 as const },
+ { address: "10.0.0.1", family: 4 as const },
+ ]);
+ await expect(resolvePublicAddresses("example.com", lookup)).rejects.toThrow(
+ "not public",
+ );
+ });
+
+ it("rejects a secret over cleartext HTTP", () => {
+ expect(() =>
+ validateCronTransport(new URL("http://example.com/job"), "not-logged"),
+ ).toThrow("requires HTTPS");
+ expect(() =>
+ validateCronTransport(new URL("http://example.com/job")),
+ ).not.toThrow();
+ });
+
+ it.each([false, true])(
+ "pins lookup for all=%s without pooling",
+ async (all) => {
+ let options!: RequestOptions & { autoSelectFamily?: boolean };
+ const request = fakeRequest(204, (received) => {
+ options = received;
+ });
+ const result = performCronGet(
+ new URL("https://example.com/job"),
+ [
+ { address: "8.8.8.8", family: 4 },
+ { address: "2001:4860:4860::8888", family: 6 },
+ ],
+ undefined,
+ 1_000,
+ request,
+ );
+ const callback = vi.fn();
+ options.lookup!("example.com", { all }, callback);
+ if (all)
+ expect(callback).toHaveBeenCalledWith(null, [
+ { address: "8.8.8.8", family: 4 },
+ { address: "2001:4860:4860::8888", family: 6 },
+ ]);
+ else expect(callback).toHaveBeenCalledWith(null, "8.8.8.8", 4);
+ expect(options).toMatchObject({
+ agent: false,
+ autoSelectFamily: false,
+ servername: "example.com",
+ });
+ expect(await result).toEqual({
+ status: "succeeded",
+ statusCode: 204,
+ error: null,
+ });
+ expect(request).toHaveBeenCalledTimes(1);
+ },
+ );
+
+ it.each([302, 404])(
+ "classifies HTTP %s at headers without retaining a body",
+ async (code) => {
+ let destroyed = false;
+ const request = fakeRequest(code, undefined, () => {
+ destroyed = true;
+ });
+ await expect(
+ performCronGet(
+ new URL("https://example.com/job"),
+ [{ address: "8.8.8.8", family: 4 }],
+ undefined,
+ 1_000,
+ request,
+ ),
+ ).resolves.toMatchObject({ status: "failed", statusCode: code });
+ expect(destroyed).toBe(true);
+ expect(request).toHaveBeenCalledTimes(1);
+ },
+ );
+
+ it("settles once when the absolute request timeout expires", async () => {
+ vi.useFakeTimers();
+ try {
+ const request = vi.fn(() => {
+ const req = new EventEmitter() as ClientRequest;
+ req.end = vi.fn(() => req) as unknown as ClientRequest["end"];
+ req.destroy = vi.fn(() => req) as ClientRequest["destroy"];
+ return req;
+ }) as unknown as typeof import("node:http").request;
+ const result = performCronGet(
+ new URL("https://example.com/job"),
+ [{ address: "8.8.8.8", family: 4 }],
+ undefined,
+ 25,
+ request,
+ );
+ await vi.advanceTimersByTimeAsync(25);
+ await expect(result).resolves.toEqual({
+ status: "failed",
+ statusCode: null,
+ error: "Cron request timed out",
+ });
+ expect(request).toHaveBeenCalledTimes(1);
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+});
+
+function fakeRequest(
+ statusCode: number,
+ onOptions?: (
+ options: RequestOptions & { autoSelectFamily?: boolean },
+ ) => void,
+ onResponseDestroy?: () => void,
+) {
+ return vi.fn(
+ (
+ _url: URL,
+ options: RequestOptions,
+ callback: (response: IncomingMessage) => void,
+ ) => {
+ onOptions?.(options);
+ const req = new EventEmitter() as ClientRequest;
+ req.end = vi.fn(() => {
+ const response = new EventEmitter() as IncomingMessage;
+ response.statusCode = statusCode;
+ response.destroy = vi.fn(() => {
+ onResponseDestroy?.();
+ return response;
+ }) as IncomingMessage["destroy"];
+ callback(response);
+ return req;
+ }) as unknown as ClientRequest["end"];
+ req.destroy = vi.fn(() => req) as ClientRequest["destroy"];
+ return req;
+ },
+ ) as unknown as typeof import("node:http").request;
+}
diff --git a/web/tests/victoria-logs.test.ts b/web/tests/victoria-logs.test.ts
index 4e2b62f3..070b8a03 100644
--- a/web/tests/victoria-logs.test.ts
+++ b/web/tests/victoria-logs.test.ts
@@ -356,6 +356,66 @@ describe("VictoriaLogs queries", () => {
expect(query).toContain('_msg:~"(?i)connection lost"');
}
});
+
+ it("isolates cron, HTTP, and container service log filters", async () => {
+ const { queryLogsByService } = await loadVictoriaLogs();
+ const queries: string[] = [];
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async (input: string | URL | Request) => {
+ queries.push(new URL(String(input)).searchParams.get("query") || "");
+ return jsonLinesResponse([]);
+ }),
+ );
+ await queryLogsByService({
+ serviceId: "service-1",
+ limit: 1,
+ logType: "cron",
+ });
+ await queryLogsByService({
+ serviceId: "service-1",
+ limit: 1,
+ logType: "http",
+ });
+ await queryLogsByService({
+ serviceId: "service-1",
+ limit: 1,
+ logType: "container",
+ });
+ expect(queries[0]).toContain("log_type:cron");
+ expect(queries[1]).toContain("log_type:http");
+ expect(queries[2]).toContain("-log_type:cron");
+ });
+
+ it("ingests only supplied cron metadata with a five-second deadline", async () => {
+ const { ingestCronLog } = await loadVictoriaLogs();
+ const fetchMock = vi.fn(
+ async (_input: string | URL | Request, _init?: RequestInit) =>
+ new Response(null, { status: 204 }),
+ );
+ vi.stubGlobal("fetch", fetchMock);
+ await ingestCronLog({
+ _msg: "Cron succeeded",
+ _time: "2026-08-06T10:00:01Z",
+ service_id: "service-1",
+ cron_id: "cron-1",
+ path: "/job",
+ scheduled_for: "2026-08-06T10:00:00Z",
+ started_at: "2026-08-06T10:00:00Z",
+ finished_at: "2026-08-06T10:00:01Z",
+ result: "succeeded",
+ status: 204,
+ duration_ms: 1000,
+ error: null,
+ log_type: "cron",
+ });
+ const [url, init] = fetchMock.mock.calls[0]!;
+ expect(String(url)).toBe("http://victoria.test/insert/jsonline");
+ expect(init?.signal).toBeInstanceOf(AbortSignal);
+ expect(init?.body).toContain('"status":204');
+ expect(init?.body).not.toContain("Authorization");
+ expect(init?.body).not.toContain("CRON_BASE_URL");
+ });
});
async function loadVictoriaLogs() {