Skip to content

About

A cleanroom drop-in replacement for pcf-scheduler

Resources

Security policy

Stars

1 star

Watchers

37 watching

Forks

Repository files navigation

OCF Scheduler

A standalone Go service that provides cron-style scheduling for Cloud Foundry applications. It enables users to schedule CF tasks (jobs) and HTTP calls using cron expressions with timezone support.

Features

  • Schedule Cloud Foundry tasks (jobs) to run on cron expressions

  • Schedule HTTP calls to run on cron expressions

  • Timezone-aware scheduling with RFC 9636 timezone support

  • Execution history tracking for both jobs and calls

  • Configurable worker pool for concurrent execution

  • Automatic cleanup of old execution records

  • UAA-based authentication and authorization

  • An optional Open Service Broker API, so a space turns the scheduler on with cf create-service and an app joins it with cf bind-service

Architecture

graph LR
    Client([Client]) -->|REST API| API[API Server<br/>Echo]
    API -->|CRUD| PG[(PostgreSQL)]
    API -->|Add/Remove| Cron[Cron Engine]
    Cron -->|Dispatch| WP[Worker Pool]
    WP -->|Run Task| CF[CF API]
    WP -->|HTTP Request| Target[HTTP Endpoint]
    API -->|Auth| UAA[UAA]
Loading

On startup, the scheduler loads all enabled schedules from PostgreSQL and registers them with the cron engine. When a schedule fires, the cron engine dispatches the work to the worker pool, which either creates a CF task via the CF API or makes an HTTP call to the configured endpoint.

Project Structure

Directory Description
cmd/scheduler/ Main scheduler service entry point and version metadata
broker/ Open Service Broker API, its catalog, and its configuration
cmd/tzlist/ Timezone list generation utility
cmd/cli/ CLI testing tool for interacting with the scheduler for local development
cmd/jeuaa/ Mock UAA server for local development
cmd/jecfapi/ Mock CF API server for local development
core/ Domain models (Job, Call, Schedule, Execution) and service interfaces
http/ Echo HTTP server, route handlers, and response presenters
postgres/ PostgreSQL implementations of service interfaces and database migrations
cron/ Cron scheduling engine with timezone support
cf/ Cloud Foundry integration (UAA auth, CF API info, task execution)
combined/ Combined run service that routes to job or call runners
logger/ Logrus-based logging service with custom formatter
workflows/ Business logic workflows
mock/ Mock implementations for testing
features/ Integration/feature tests
scripts/ Utility scripts (blanket test runner, shait digest generator)
ci/ Concourse CI pipeline configuration, tasks, and scripts

Getting Started

Prerequisites

  • Go 1.25+ (see go.mod)

  • PostgreSQL

  • Cloud Foundry deployment with UAA

  • Docker and Docker Compose (optional, for local development)

Docker Development Environment

A docker-compose.yml is provided for local development and testing without a real Cloud Foundry deployment. It spins up mock UAA and CF API services alongside PostgreSQL:

docker compose up dev          # Run the scheduler with mock services
docker compose up test         # Run the test suite
Service Description Port
dev Scheduler service (built from Dockerfile.dev) 8000
test Test runner (built from Dockerfile.test) —
postgres-dev PostgreSQL for development —
postgres-test PostgreSQL for tests —
uaa Mock UAA server (built from Dockerfile.uaa) 8001
cf-api Mock CF API server (built from Dockerfile.cfapi) 8002

The dev service pre-configures all required environment variables (DATABASE_URL, UAA_ENDPOINT, CF_ENDPOINT, CLIENT_ID, CLIENT_SECRET) so you can start the scheduler immediately.

Configuration

Variable Required Default Description
DATABASE_URL Yes PostgreSQL connection string
CF_ENDPOINT Yes Cloud Foundry API URL
UAA_ENDPOINT Yes UAA server URL
CLIENT_ID Yes UAA client ID
CLIENT_SECRET Yes UAA client secret
SCHEDULER_PORT No 8000 HTTP listen port
SCHEDULER_WORKERS No 20 Worker pool size (minimum 10); values below 10 or non-numeric values log a warning and fall back to 20
LOG_LEVEL No info Log level (see below)
SCHEDULER_BROKER_ENABLED No false Serves the service broker under /v2 when it is true
SCHEDULER_BROKER_USERNAME When the broker is on The basic auth username that Cloud Controller sends to the broker
SCHEDULER_BROKER_PASSWORD When the broker is on The basic auth password that Cloud Controller sends to the broker, at least 16 characters long
SCHEDULER_BROKER_API_URL When the broker is on The scheduler URL that binding credentials hand to apps, such as https://scheduler.system.example.com
SCHEDULER_BROKER_REQUIRE_BINDING No false Refuses to create or run jobs, calls, and schedules for an app that isn't bound, when it is true

The scheduler refuses to start when the broker settings don't fit together. That happens when SCHEDULER_BROKER_ENABLED is true and the username, password, or API URL is missing, when the password is shorter than 16 characters, when the username starts or ends with whitespace, when the API URL isn't an absolute http or https URL, when a boolean setting holds anything other than a boolean, and when SCHEDULER_BROKER_REQUIRE_BINDING is true while the broker is off. The startup log names the broker user, the API URL, and the require_binding setting, and it never prints the password.

Log Levels

The LOG_LEVEL environment variable accepts a single keyword and is parsed by logrus.ParseLevel(), which is case-insensitive. The accepted values, ordered from most to least verbose:

Value Description
trace Very fine-grained diagnostic output
debug Debugging information
info General operational messages (default)
warn Potential issues that deserve attention
warning Alias for warn — both are accepted
error Errors that need investigation
fatal Critical errors — the process will exit after logging
panic Critical errors — the process will panic after logging

Setting a log level enables messages at that level and all higher-severity levels above it. Messages below the configured level are suppressed. For example, setting LOG_LEVEL=warn will output warn, error, fatal, and panic messages, but suppress info, debug, and trace.

If LOG_LEVEL is not set or is empty, the default is info. If an invalid value is provided, a warning is logged and the level falls back to info.

Building

make build        # Build for current platform
make release      # Build for all target platforms
make test         # Run tests
make clean        # Remove built executables

Makefile Reference

Targets

Target Description
all Clean and build (default target)
build Build for current platform; automatically adds a dev prerelease tag
cli Build the CLI tool (sch binary)
release Cross-compile for all target platforms and create tar.gz packages
test Run the test suite via scripts/blanket
clean Remove built executables (./tzlist, ./scheduler)
distclean Remove all release directories and packages (includes clean)
distbuild Create release directory structure for all targets
debug_version Display all computed version variables (useful for troubleshooting)

Public Variables

These can be overridden on the command line (e.g., make build VERSION=1.2.3):

Variable Default Description
VERSION (auto from git tag + patch bump) Full version string (e.g., 1.2.3, 1.2.3-alpha)
APP_NAME scheduler Binary output name
CMD_PATH cmd Command source directory
RELEASE_ROOT releases Release output directory
TARGETS linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 Cross-compile target platforms
MODULE github.com/cloudfoundry-community/ocf-scheduler Go module path
CGO_ENABLED 0 Set to 1 to enable CGO (default produces static binaries)
SEMVER_MAJOR (auto) Major version override
SEMVER_MINOR (auto) Minor version override
SEMVER_PATCH (auto) Patch version override
SEMVER_PRERELEASE (auto) Prerelease label override
SEMVER_BUILDMETA (auto) Build metadata override

Private Variables

These are computed internally and not intended for direct override:

Variable Description
PROJECT Hardcoded project name (ocf-scheduler)
GOOS / GOARCH Detected from go env for the current platform
GOMODULECMD Go package path for ldflags injection (main)
TESTFILES List of Go packages (excluding vendor)
CLEAN_VERSION VERSION with v prefix stripped
HAS_BUILDMETA / VERSION_BUILDMETA Buildmeta detection and extraction from VERSION
HAS_PRERELEASE / VERSION_PRERELEASE Prerelease detection and extraction from VERSION
VERSION_ONLY Semantic version without prerelease or buildmeta
VERSION_SPLIT Version parts as a Make word list (major minor patch)
BUILD_DATE ISO 8601 timestamp of the build
BUILD_VCS_URL Git remote origin URL
BUILD_VCS_ID Short git commit hash
BUILD_VCS_ID_DATE ISO date of the git commit
GO_LDFLAGS Linker flags that inject version metadata into the binary
SEMVER_VERSION Fully assembled semantic version string

Macros and Functions

Name Type Description
is_not_number Function Strips digits from a string; used to validate that version components are numeric
distbuild Macro Creates a release directory for a given OS/ARCH combination under RELEASE_ROOT
build-target Macro Cross-compiles binaries for a specific OS/ARCH and generates SHA1/SHA256 digests via scripts/shait
package-target Macro Creates a tar.gz package for a specific OS/ARCH release directory

Version Auto-Detection

When VERSION is not specified, the Makefile:

  1. Reads the latest git tag (e.g., v1.2.3)
  2. Strips the v prefix
  3. Validates the tag has exactly 3 numeric parts (major.minor.patch)
  4. Auto-increments the patch number by 1

For example, if the latest tag is v1.2.3, the computed version becomes 1.2.4. The build target additionally appends -dev as the prerelease label (e.g., 1.2.4-dev).

Development Workflow

Local Build

make build

Compiles scheduler and tzlist binaries for your current platform. The version is automatically tagged with a -dev prerelease label (e.g., 1.2.4-dev).

To build with a specific version:

make build VERSION=1.2.3
make build VERSION=1.2.3-beta

Running Tests

make test

Runs the test suite via scripts/blanket, which supports scoped test execution:

./scripts/blanket func          # All tests, display per-function coverage
./scripts/blanket html          # All tests, open coverage report in browser
./scripts/blanket func unit     # Unit tests only
./scripts/blanket func integration  # Integration tests only

Debugging Version Computation

make debug_version

Prints all computed version variables (VERSION, CLEAN_VERSION, VERSION_SPLIT, SEMVER_*, etc.) — useful when troubleshooting version-related build issues.

Cross-Compiling Locally

make release

Builds for all target platforms (linux/amd64, linux/arm64, darwin/amd64, darwin/arm64), generates SHA1/SHA256 digests, and creates tar.gz packages under the releases/ directory.

To build a specific version as a release:

make release VERSION=1.2.3

Cleaning Up

make clean       # Remove built executables (./tzlist, ./scheduler)
make distclean   # Remove all release artifacts and executables

Service Broker

The scheduler can serve the Open Service Broker API on its own port, under /v2, so that Cloud Foundry users manage it with the usual cf commands. The broker is off unless SCHEDULER_BROKER_ENABLED is true, and the settings it needs are in Configuration.

Registering the broker

An operator registers the broker with Cloud Foundry and then makes its plan visible.

cf create-service-broker scheduler <SCHEDULER_BROKER_USERNAME> <SCHEDULER_BROKER_PASSWORD> https://scheduler.system.example.com
cf enable-service-access scheduler

The catalog offers one service, scheduler, with one free plan, standard. Their IDs are fixed, so a broker registered once keeps working across upgrades.

How the broker behaves

Every request must carry X-Broker-API-Version 2.13 or later. A request without it, or with an older version, gets 412 Precondition Failed before its credentials are checked. A request with the wrong credentials gets 401 Unauthorized. The broker credentials open only /v2, and the scheduler API still wants a UAA token.

  • Creating a service instance turns the scheduler on for the instance's space. A space holds only one instance, so a second cf create-service in the same space gets 409 Conflict. When Cloud Controller no longer knows the space's existing instance, which happens after cf purge-service-instance, the broker replaces it as if it had been deleted.

  • Binding an app lets that app be scheduled. The binding credentials hold api_url, instance_id, and app_guid, and they never hold a secret, because each caller still authenticates with their own UAA token. The app must live in the instance's space. A service key binds no app and carries only api_url and instance_id.

  • Unbinding an app's last binding pauses the app's schedules. The broker disables each enabled schedule on the app's jobs and calls, marks it as paused by the unbind, and takes it out of cron. Jobs, calls, schedules, and execution history all stay.

  • Binding the app again resumes only the schedules that the unbind paused. A schedule that its owner disabled stays disabled.

  • Deleting the instance removes its bindings first, which pauses each app that loses its last binding, and then removes the instance. The space's jobs, calls, schedules, and execution history all stay, even when the space itself is deleted. An admin can find and remove them later, as Cleaning up orphaned jobs and calls describes.

Every operation finishes before the broker answers, so the broker never answers 202 Accepted and has no operation to poll. Changing the plan isn't supported.

Rolling back

Version 2.2.0 adds three database migrations, 1791828900, 1791828901, and 1791828902, which create the service_instances and service_bindings tables and add the schedules.paused_by column. Releases before 2.2.0 refuse to start against a database that holds a migration they don't know, so rolling back from 2.2.0 to a 2.1.x release needs those three migrations reverted first. The scheduler has no command that runs their down steps, so we run them by hand.

First, stop every scheduler instance. Then connect to the scheduler's database with psql and run the following, which drops the two tables and the column and deletes the three rows that sql-migrate keeps in gorp_migrations:

BEGIN;
-- Optional: let the schedules that an unbind paused run again on 2.1.x.
UPDATE schedules SET enabled = true WHERE paused_by = 'unbind';
ALTER TABLE schedules DROP COLUMN IF EXISTS paused_by;
DROP TABLE IF EXISTS service_bindings;
DROP TABLE IF EXISTS service_instances;
DELETE FROM gorp_migrations WHERE id IN ('1791828900', '1791828901', '1791828902');
COMMIT;

Deleting the rows alone would let 2.1.x start, but a later upgrade to 2.2.0 would then fail to create tables that already exist, so the procedure drops them too. It removes every service instance and binding, and Cloud Controller still lists the instances until they're purged with cf purge-service-instance. Without the optional UPDATE, the schedules that an unbind paused stay disabled after the rollback, because the mark that would resume them goes with the column. After the transaction commits, deploy the 2.1.x release. From 2.2.0 on, the scheduler starts against a database that a newer release has migrated, so later rollbacks don't need this step.

Requiring a binding

When SCHEDULER_BROKER_REQUIRE_BINDING is true, an app must be bound before anyone can create a job or a call for it, add a schedule to one, or run one. The routes that this covers are POST /jobs, POST /calls, POST /jobs/:guid/schedules, POST /calls/:guid/schedules, POST /jobs/:guid/execute, and POST /calls/:guid/execute. They check the caller's space access first, as they always do, and then answer 403 Forbidden with a binding_required error for an app that isn't bound. A caller with cloud_controller.admin passes regardless. Reading, listing, and deleting stay open, so the owner of an unbound app can still see and clean up what it has.

Cleaning up orphaned jobs and calls

When an app is deleted, or its whole space is, the scheduler keeps the app's jobs, calls, schedules, and execution history. Deprovisioning the space's scheduler service instance doesn't remove them either. A job whose app is gone fails each time it runs, and a call whose app is gone goes on calling its URL. The scheduler never deletes this data by itself, so an admin finds it and deletes it with two routes or the matching CLI commands.

The same routes also let us find and delete schedules whose job or call is no longer stored. A schedule like that can be left behind when a delete fails partway. The scheduler skips it when it starts, but the schedule stays in the database until someone removes it.

Both routes need a token with the cloud_controller.admin scope that belongs to an admin user, such as the one cf oauth-token prints. A token that a client gets with client credentials is refused with 401 Unauthorized, even when it carries that scope, because the scheduler asks UAA which user owns the token. A caller whose token can't be verified gets 401 Unauthorized, and any other caller gets 403 Forbidden with an empty body.

How the scheduler decides that an app is gone

The scheduler asks Cloud Controller for each app with GET /v3/apps/:guid. It treats an app as gone only when Cloud Controller returns its own not-found error for that app. Any other answer means the scheduler can't tell, and that includes a 403, a 5xx, a timeout, and a 404 that carries no Cloud Controller error, such as the one a router sends for a route it doesn't know. The scheduler never counts any of those as a deleted app.

Cloud Controller also answers 404 for an app that the asking client isn't allowed to see. The scheduler asks with its own UAA client, the one set by CLIENT_ID and CLIENT_SECRET, so if that client can't read every app in the foundation, each app outside its reach looks deleted. To guard against that, both routes read the scopes on the scheduler's own Cloud Controller token before they ask about any app. The token must carry one of the following scopes:

  • cloud_controller.admin

  • cloud_controller.admin_read_only

  • cloud_controller.global_auditor

When the token carries none of them, the route answers 500 Internal Server Error with a message that names all three, and it lists and deletes nothing. When the scheduler can't get a token from UAA, or can't decode the one it gets, the route answers 502 Bad Gateway and does nothing either. To fix the first case, we grant the scheduler's UAA client one of those scopes as an authority. The scheduler fetches the token for this check separately from the one its Cloud Controller client holds, and each is kept until it expires. After we grant or remove any of the three scopes, we must restart the scheduler. Until we do, the Cloud Controller client may still hold a token without the scope, and then the routes can treat apps that the client cannot see as gone.

Orphan schedules never involve Cloud Controller, so the check applies only when a request would ask about an app. A list runs it only when there are jobs or calls to look up, and a delete runs it only when one of its GUIDs names a job or call, or can't be looked up. A request that deals only with orphan schedules works without the scope.

Listing them

GET /orphans returns every job and call, in every space, whose app is gone, and every schedule whose job or call is gone. The scheduler asks about each distinct app once per request, with up to ten lookups running at a time. If any of those lookups fails, the scheduler cancels the ones still running, and the whole request fails with 502 Bad Gateway and returns no list, because a partial list could pass for a complete one. A failed database query answers 500 Internal Server Error.

A successful answer is 200 OK with a body like the following:

{
  "resources": [
    {
      "guid": "9a1c1a56-0d0c-4f86-9a8c-5d4d1f3b2f10",
      "type": "job",
      "name": "nightly-backup",
      "app_guid": "6f5b3a3e-2b8e-4c8f-a8a2-0e6f4f1f6c11",
      "space_guid": "1d8f6c2a-7b4e-4a51-9b0e-3c2a6b7d9e12",
      "schedules": [
        {
          "guid": "c0e4b7f2-5a1d-4f3e-8b6c-2d9a7e1f4b13",
          "enabled": true,
          "expression": "0 2 * * *",
          "expression_type": "cron_expression",
          "created_at": "2026-03-01T02:00:00Z",
          "updated_at": "2026-03-01T02:00:00Z"
        }
      ]
    }
  ],
  "orphan_schedules": [
    {
      "guid": "7b3e9d1a-4c2f-4e8b-a6d0-1f5c8e2a9b15",
      "enabled": true,
      "expression": "30 4 * * *",
      "expression_type": "cron_expression",
      "ref_type": "job",
      "ref_guid": "3c7a2e9f-6b1d-4f0a-8e5c-9d2b4a6f1e16",
      "created_at": "2026-03-02T04:30:00Z",
      "updated_at": "2026-03-02T04:30:00Z"
    }
  ]
}

In resources, the type is job or call. That list is sorted by space, then app, then type, then name.

Each entry in orphan_schedules is a schedule whose job or call isn't stored. Its ref_type says whether it belonged to a job or a call, and ref_guid is the GUID of that job or call. The scheduler finds these schedules in its own database without asking Cloud Controller anything. The list is sorted by ref_type, then ref_guid, then the schedule's GUID. A scheduler older than this field leaves it out of the answer.

Both lists are empty when nothing is orphaned. Listing never changes anything.

Deleting them

POST /orphans/delete deletes the jobs, calls, and orphan schedules that the request names. There's no way to delete everything at once, so the body must list the GUIDs, mixing jobs, calls, and schedules freely:

{
  "guids": [
    "9a1c1a56-0d0c-4f86-9a8c-5d4d1f3b2f10",
    "5e2d8c4b-3f1a-4b7e-9c6d-8a0f2e4b6d14"
  ]
}

For each GUID, the scheduler finds the job or call and asks Cloud Controller about its app again, so an app that has come back since the list keeps its data. It deletes the job or call only when that check says the app is not found. It removes the schedules first, taking each one out of the database and out of cron, and then deletes the job or call itself, in the same order as DELETE /jobs/:guid and DELETE /calls/:guid. Execution history stays, as it does for those routes.

After it deletes the job or call, the scheduler looks for its schedules once more and removes any it finds, because someone may have created a schedule while the delete was running. The job and call delete routes do the same.

A route that creates a schedule does the matching check from its side. Once it has stored the schedule, it looks the job or call up again, and if the job or call is gone, it removes the schedule and answers 404 Not Found. If that second lookup fails for another reason, such as a lost database connection, the route tries it up to three times, waiting a moment between tries. If every try fails, the route keeps the schedule and answers 201 Created, because the job or call was there a moment before, and an error would only invite a retry that creates a second schedule. Should the job or call turn out to be gone after all, the schedule keeps firing until it's deleted or the scheduler restarts, and it shows up in orphan_schedules meanwhile.

Whenever the scheduler knows that a schedule's job or call is gone, it takes the schedule out of cron even if it can't delete the schedule's row. The row then shows up in orphan_schedules, and the scheduler never loads it into cron again, because it loads schedules only through their job or call.

These checks keep a schedule from outliving its job or call when one scheduler instance runs, which is how the release and the kit deploy it. With more than one instance, the stored schedules still stay right, but each instance keeps its own cron. If one instance creates a schedule while another deletes its job or call, the second instance removes the row but can't reach the first one's cron, so the schedule keeps firing there until that instance restarts.

A GUID that names no job or call is looked up as a schedule. The scheduler deletes that schedule only when its job or call is still gone, and it takes the schedule out of the database and out of cron the same way. When the job or call exists, the schedule is left alone.

The answer is 200 OK with one result for each distinct GUID, in the order they were sent:

{
  "results": [
    { "guid": "9a1c1a56-0d0c-4f86-9a8c-5d4d1f3b2f10", "type": "job", "result": "deleted" },
    { "guid": "5e2d8c4b-3f1a-4b7e-9c6d-8a0f2e4b6d14", "type": "call", "result": "app_exists", "message": "app 6f5b3a3e-2b8e-4c8f-a8a2-0e6f4f1f6c11 exists" }
  ]
}

The result field takes one of the following values:

  • deleted means the job or call and all of its schedules are gone, or, for a schedule, that the schedule is gone. If a schedule created during the delete couldn't be removed, the message says how many were left, and they show up in orphan_schedules. If the scheduler couldn't check for such schedules at all, the message says that instead, and any it missed show up in orphan_schedules too.

  • app_exists means Cloud Controller still knows the app, so nothing was deleted.

  • parent_exists means the GUID names a schedule whose job or call is still stored, so nothing was deleted. The type is schedule, and the message names the job or call.

  • not_found means no job, call, or schedule has that GUID.

  • failed means the scheduler couldn't look the job, call, or schedule up, couldn't check its app, or couldn't finish deleting it. The message field says which step failed. When a check fails, nothing is deleted. When a later step fails, some schedules may already be gone, and sending the GUID again finishes the delete.

A body that isn't a JSON object, or one whose guids list is missing or empty, gets 400 Bad Request with an error body, and nothing is deleted.

Using the CLI

The sch CLI wraps both routes. It reads the scheduler's URL from SCHEDULER_URL and an admin's token from SCHEDULER_TOKEN:

export SCHEDULER_URL=https://scheduler.system.example.com
export SCHEDULER_TOKEN="$(cf oauth-token)"
sch orphans list
sch orphans del 9a1c1a56-0d0c-4f86-9a8c-5d4d1f3b2f10 5e2d8c4b-3f1a-4b7e-9c6d-8a0f2e4b6d14

sch orphans list prints one line for each orphaned job or call, with its type, name, GUID, app, space, and how many of its schedules are enabled. Below that, it prints one line for each schedule whose job or call is gone, with the schedule's GUID, whether it belonged to a job or a call, that job or call's GUID, its expression, and whether it's enabled. sch orphans del takes job, call, and schedule GUIDs. It prints the result for each GUID, and it exits non-zero when any of them wasn't deleted.

When the scheduler refuses the token with 401 Unauthorized, the orphans commands say so and tell us to refresh SCHEDULER_TOKEN with cf oauth-token as an admin user. When it refuses with 403 Forbidden, they say that the token's user isn't an admin. The other sch commands are open to space members, so for those the CLI says only that the token was rejected and should be refreshed with cf oauth-token, or that the token's user isn't allowed to do what was asked.

Operations Notes

Executions that stay PENDING

Before a job or call runs, the scheduler marks its execution as started. If the database doesn't answer, it tries five times, waiting one second before the second try and doubling the wait after each failure, which comes to about fifteen seconds in all. If every try fails, the scheduler skips the run, because we would rather miss one run than start a task or a call that nothing records.

When the scheduler skips a run, it notes why on the execution and then marks the execution FAILED, with the same retries. So the execution ends up FAILED whenever the database answers within that time, and its message says that the run was skipped.

A manual run behaves the same way. POST /jobs/:guid/execute and POST /calls/:guid/execute store the new execution as PENDING, queue the run, and answer 201 Created with the execution as it was stored. The run goes on in the background, so the caller holds a PENDING execution whatever happens when the scheduler tries to start it. If the start fails, that execution moves from PENDING to FAILED as soon as the database allows it.

If the database stays down through both rounds of retries, the execution can stay PENDING for good. The scheduler logs both errors with the execution's GUID, and the automatic cleanup of old executions leaves the row alone, because it removes only executions that succeeded or failed. We can find such an execution in the job's or call's history, and we can treat it as a run that never happened.

Tasks that never start

The scheduler follows a job's Cloud Foundry task until it ends, but it waits only 15 minutes for a task that Cloud Controller still reports as PENDING. After that, it asks Cloud Controller to cancel the task, so that the task can't start later with nothing to record how it ends. Then it marks the execution FAILED with a message saying the task never started. If the cancel fails, the scheduler logs the error and adds it to that message, because Cloud Controller may then still run the task. A task that has started and is RUNNING has no time limit.

API Reference

Note: The following HTTP API is an internal interface used by the scheduler service and its companion CLI. It is not a public or stable API — endpoints, request/response formats, and behavior may change without notice between releases. This documentation is provided for developer reference only.

Authentication

All endpoints (except GET /) require a valid UAA bearer token in the Authorization header:

Authorization: bearer <UAA_TOKEN>

Requests without a valid token receive a 401 Unauthorized response.

Authorization

A caller may act on a job, call, or schedule only in a space where they hold the space_developer role, and that holds for reading as well as for creating, running, and deleting. Callers whose token carries the cloud_controller.admin scope may act in every space. Space managers and auditors have no access. The scheduler looks the role up through the Cloud Controller v3 API on every request and denies the request if the lookup fails.

  • POST /jobs?app_guid= and POST /calls?app_guid= check the space that the app belongs to and answer 403 Forbidden when the caller is not a developer there.

  • GET /jobs?space_guid= and GET /calls?space_guid= check the named space and answer 403 Forbidden in the same way, including when space_guid is missing.

  • Routes that name a job or call by GUID check the space it was created in and answer 404 Not Found, so they don't reveal another tenant's jobs or calls. A schedule GUID must also belong to the job or call in the path.

  • GET /orphans and POST /orphans/delete need the cloud_controller.admin scope and answer 403 Forbidden to every other caller. Whenever they need to ask Cloud Controller about an app, they also refuse to run until the scheduler's own Cloud Controller token can read every app, as How the scheduler decides that an app is gone explains.

  • GET /scheduler-time-zones and POST /schedules/validate with only an expression need a space_developer or space_manager role in any space. Validating against a job or call checks that job's or call's space.

A call's auth_header is stored and sent when the call runs, but no response ever returns it, not even to the call's owner or to an admin.

Request Bodies

Create Job (POST /jobs?app_guid=):

{
  "name": "my-job",
  "command": "rake db:migrate"
}

Optional fields: disk_in_mb, memory_in_mb, log_rate_in_bps.

Create Call (POST /calls?app_guid=):

{
  "name": "my-call",
  "url": "https://example.com/webhook",
  "auth_header": "bearer <token>"
}

Create Schedule (POST /jobs/:guid/schedules or POST /calls/:guid/schedules):

{
  "expression": "0 30 9 * * *",
  "expression_type": "cron_expression"
}

The expression field accepts cron expressions with optional timezone support.

Response Format

Collection endpoints return a paginated envelope:

{
  "pagination": {
    "total_pages": 1,
    "total_results": 2,
    "first": { "href": "first" },
    "last": { "href": "last" },
    "next": { "href": "next" },
    "previous": { "href": "previous" }
  },
  "resources": [ ... ]
}

Single-resource endpoints return the entity directly as JSON.

Jobs

Method Path Description
POST /jobs?app_guid= Create a job
GET /jobs?space_guid= List all jobs in a space
GET /jobs/:guid Get a job
DELETE /jobs/:guid Delete a job
POST /jobs/:guid/execute Execute a job immediately

Job Schedules

Method Path Description
POST /jobs/:guid/schedules Create a schedule for a job
GET /jobs/:guid/schedules List schedules for a job
DELETE /jobs/:guid/schedules/:schedule_guid Delete a job schedule

Job Executions

Method Path Description
GET /jobs/:guid/history List execution history for a job
GET /jobs/:guid/schedules/:schedule_guid/history List execution history for a job schedule

Calls

Method Path Description
POST /calls?app_guid= Create a call
GET /calls?space_guid= List all calls in a space
GET /calls/:guid Get a call
DELETE /calls/:guid Delete a call
POST /calls/:guid/execute Execute a call immediately

Call Schedules

Method Path Description
POST /calls/:guid/schedules Create a schedule for a call
GET /calls/:guid/schedules List schedules for a call
DELETE /calls/:guid/schedules/:schedule_guid Delete a call schedule

Call Executions

Method Path Description
GET /calls/:guid/history List execution history for a call
GET /calls/:guid/schedules/:schedule_guid/history List execution history for a call schedule

Other

Method Path Description
GET / Health check
GET /scheduler-time-zones List available timezones

Orphans

Method Path Description
GET /orphans List the jobs and calls whose app is gone, and the schedules whose job or call is gone, for admins only
POST /orphans/delete Delete the named jobs and calls whose app is gone, and the named schedules whose job or call is gone, for admins only

CI/CD Pipeline

The project uses Concourse CI for automated builds, testing, and releases. All pipeline configuration lives in the ci/ directory.

Pipeline Groups

Group Jobs Purpose
build-ocf-scheduler-releases build, ship-prerelease, test, prepare, ship-release Main release pipeline
test-ocf-scheduler-pull-requests test-pr Automated PR testing
version major, minor, patch Manual version bumps

Release Pipeline Flow

git push to develop
        │
        ▼
      build ──────► ship-prerelease (GitHub pre-release)
        │
        ▼
      test
        │
        ▼
     prepare ────► generates release notes
        │
        ▼
    ship-release ─► merge to main, GitHub release, bump patch version

Pipeline Jobs

Job Trigger Description
build Automatic on push to develop Cross-compiles binaries, uploads artifacts to S3, bumps RC version
test Automatic after build passes Validates build artifacts
ship-prerelease After build passes Creates a GitHub prerelease with build artifacts
prepare Automatic after test passes Generates release notes from commit messages, pushes to release-notes repo
ship-release Manual Builds final release, merges develop into main, creates GitHub release, bumps patch version
test-pr Automatic on pull request Runs tests and reports pass/fail status back to GitHub
major Manual Bumps the major version number
minor Manual Bumps the minor version number
patch Manual Bumps the patch version number

Pipeline Configuration

Pipeline settings are defined in ci/settings.yml and merged with the pipeline templates in ci/pipeline/ using spruce. Key settings:

  • Source branch: develop (meta.github.branch)
  • Release branch: main (meta.github.main-branch)
  • Notifications: Slack webhooks on job failures
  • Artifact storage: S3 bucket for build artifacts
  • Distribution: GitHub releases for final and pre-release builds

How CI Builds Use the Makefile

The CI build script (ci/scripts/build) invokes the same Makefile targets used for local development:

make release VERSION=$VERSION APP_NAME=$APP_NAME CGO_ENABLED=$CGO_ENABLED

The VERSION is injected from the Concourse version resource, ensuring consistent versioning between CI and the released artifacts.

Contributing

Issues and pull requests are welcome at github.com/cloudfoundry-community/ocf-scheduler.

Branch Strategy

  • develop — Active development branch. All pull requests should target this branch.
  • main — Stable release branch. Merged from develop automatically during the ship-release pipeline job. Do not push directly to main.

Pull Request Workflow

  1. Fork the repository and create a feature branch from develop
  2. Make your changes and ensure tests pass (make test)
  3. Open a pull request targeting develop
  4. The Concourse test-pr job will automatically run tests and report status on your PR
  5. Once approved and merged, the CI pipeline handles building, testing, and releasing

License

This project is licensed under either of these, at your option:

See NOTICE for the copyright notices.

About

A cleanroom drop-in replacement for pcf-scheduler

Resources

Security policy

Stars

1 star

Watchers

37 watching

Forks

Releases

Packages

Used by

Contributors

Languages