diff --git a/apps/api/internal/services/service/create.go b/apps/api/internal/services/service/create.go index 3ade2753..919b7443 100644 --- a/apps/api/internal/services/service/create.go +++ b/apps/api/internal/services/service/create.go @@ -321,6 +321,12 @@ func (self *ServiceService) CreateService(ctx context.Context, requesterUserID u } } + sourcePorts := ports + skipDetectedPorts := skipsDetectedPorts(addDetectedPorts, input.CronSchedule) + if skipDetectedPorts { + ports = nil + } + // Validate health check if input.HealthCheck != nil { if input.HealthCheck.Port == nil && len(ports) > 0 { @@ -415,6 +421,9 @@ func (self *ServiceService) CreateService(ctx context.Context, requesterUserID u if addDetectedPorts { detectedPorts = ports } + if skipDetectedPorts { + detectedPorts = sourcePorts + } createService, err := self.repo.Service().Create(ctx, tx, &service_repo.CreateServiceInput{ @@ -598,6 +607,12 @@ func resolveAutoDeploy(requested *bool, serviceType schema.ServiceType) *bool { return nil } +// A scheduled service answers no requests, so the ports detected from its source +// stay metadata and never get it a port or a generated domain +func skipsDetectedPorts(detected bool, cronSchedule *string) bool { + return detected && cronSchedule != nil && *cronSchedule != "" +} + // A service that listens on a port is public unless the caller says otherwise. // A database is private unless the caller asks for a public one. func resolveIsPublic(requested *bool, serviceType schema.ServiceType, ports []schema.PortSpec) *bool { diff --git a/apps/api/internal/services/service/create_test.go b/apps/api/internal/services/service/create_test.go index 82a709f8..82246628 100644 --- a/apps/api/internal/services/service/create_test.go +++ b/apps/api/internal/services/service/create_test.go @@ -92,3 +92,17 @@ func TestCleanCreateWatchPaths(t *testing.T) { }) } } + +// A cron service built from a repo that EXPOSEs a port must not get that port, and +// with no port it gets no generated domain +func TestSkipsDetectedPortsForCron(t *testing.T) { + hourly := "0 * * * *" + empty := "" + + assert.True(t, skipsDetectedPorts(true, &hourly)) + assert.False(t, skipsDetectedPorts(false, &hourly), "ports the caller sent are kept") + assert.False(t, skipsDetectedPorts(true, &empty), "an empty schedule runs continuously") + assert.False(t, skipsDetectedPorts(true, nil)) + + assert.Nil(t, resolveIsPublic(nil, schema.ServiceTypeGithub, nil)) +} diff --git a/apps/docs/content/(platform)/services/cron-jobs.mdx b/apps/docs/content/(platform)/services/cron-jobs.mdx index 1ec2dfef..3a52256c 100644 --- a/apps/docs/content/(platform)/services/cron-jobs.mdx +++ b/apps/docs/content/(platform)/services/cron-jobs.mdx @@ -42,7 +42,7 @@ A failed run can send a [webhook](/monitoring/webhooks). Pick **Cron Run Failed* ## What changes with a schedule -A service on a schedule has no replicas, domains or ports, and no health checks. Those settings show why they are unavailable. Its domains stop answering while it has a schedule and come back when the schedule is removed. +A service on a schedule has no replicas, domains or ports, and no health checks. Those settings show why they are unavailable. Its domains stop answering while it has a schedule and come back when the schedule is removed. A service created with a schedule starts without a port or a domain, even when its Dockerfile exposes one. **Stop** suspends the schedule: no runs start until you **Start** it again, and the run history stays.