5.0 Upgrade Guide¶
In this document we describe important changes to be aware of when upgrading from orchestrator-core v4.x to v5.0.
If you are not yet using v4.x, please consult the 4.0 upgrade guide first.
If you are using scheduled tasks and/or workflow authorization callbacks, please consult the 4.7 and 4.8 upgrades first as well.
About 5.0¶
In this release, the following breaking changes are introduced:
- GraphQL
scheduledTasksquery no longer returns task created by the decorator - Secret settings for sensitive URIs
- Dependency
pydantic-formslower bound increased from 1.4.0 to 2.4.0 - DB column
engine_settings.running_processesremoved, service functions renamed - The
descriptionparameter in workflow decorators is now deprecated strawberry-graphqlupgrade includes breaking changes
1. GraphQL scheduledTasks query no longer returns task created by the decorator¶
The GraphQL scheduledTasks query no longer returns task created by the decorator, but only tasks that are scheduled via the API.
To see all scheduled tasks, you can run the show-schedule CLI command which will still show
2. Secret settings for sensitive URIs¶
Replace usages of orchestrator.utils.expose_settings.SecretStr with pydantic.SecretStr (previously we also called this type OrchSecretStr)
Three AppSettings fields that contain sensitive connection strings have been changed from plain types to Pydantic Secret subtypes.
These URIs can contain credentials (e.g. database passwords, Redis auth tokens) that should not be leaked.
By wrapping them in Secret types, Pydantic automatically masks their values when the settings object is logged, printed, serialized, or appears in tracebacks — replacing the actual value with '**********'.
This provides better protection against accidental credential exposure in application logs and error reports.
The affected fields and their new types are:
| Field | Old Type | New Type |
|---|---|---|
DATABASE_URI |
PostgresDsn |
SecretPostgresDsn (Secret[PostgresDsn]) |
CACHE_URI |
RedisDsn |
SecretRedisDsn (Secret[RedisDsn]) |
WEBSOCKET_BROADCASTER_URL |
str |
SecretStr |
What changed¶
Previously, you could use these settings values directly:
from orchestrator.settings import app_settings
# Old usage — no longer works
db_url = str(app_settings.DATABASE_URI)
redis_url = str(app_settings.CACHE_URI)
ws_url = app_settings.WEBSOCKET_BROADCASTER_URL
Now, you must call .get_secret_value() to retrieve the underlying value:
from orchestrator.settings import app_settings
# New usage
db_url = str(app_settings.DATABASE_URI.get_secret_value())
redis_url = str(app_settings.CACHE_URI.get_secret_value())
ws_url = app_settings.WEBSOCKET_BROADCASTER_URL.get_secret_value()
What you need to do¶
Search your codebase for any direct references to app_settings.DATABASE_URI, app_settings.CACHE_URI, or app_settings.WEBSOCKET_BROADCASTER_URL (or via a settings parameter of type AppSettings). At each usage site, append .get_secret_value() to extract the actual value.
For example:
# Before
config.set_main_option("sqlalchemy.url", str(app_settings.DATABASE_URI))
cache = create_redis_client(app_settings.CACHE_URI)
broadcaster_type = urlparse(app_settings.WEBSOCKET_BROADCASTER_URL).scheme
# After
config.set_main_option("sqlalchemy.url", str(app_settings.DATABASE_URI.get_secret_value()))
cache = create_redis_client(app_settings.CACHE_URI.get_secret_value())
broadcaster_type = urlparse(app_settings.WEBSOCKET_BROADCASTER_URL.get_secret_value()).scheme
If you assign to these fields in tests or configuration (e.g. overriding DATABASE_URI with a test database URL), wrap the value in the appropriate Secret type:
from orchestrator.settings import SecretPostgresDsn, SecretRedisDsn, app_settings
from pydantic import SecretStr
# Before
app_settings.DATABASE_URI = db_uri
# After
app_settings.DATABASE_URI = SecretPostgresDsn(db_uri)
Warning
Previously custom settings whose names contained the string secret or password would automatically be masked.
This is no longer the case, and you should now use the type SecretStr for these values instead.
3. Dependency pydantic-forms lower bound increased from 1.4.0 to 2.4.0¶
The lower bound for dependency pydantic-forms has increased from 1.4.0 to 2.4.0.
This includes one breaking change from version 2.0, Replace ReadOnlyField with read_only_field and read_only_list.
What changed¶
Previously, the type ReadOnlyField was used for both scalar fields and list fields.
class Form(FormPage):
int_field: ReadOnlyField(1)
bool_field: ReadOnlyField("True", default_type=bool)
list_field: ReadOnlyField(["a", "b"], default_type=list[str])
As you can see, for bool_field and list_field it was necessary to explicitly specify the type.
Now, you must use a different type for either field, and you no longer have to specify the default_type:
class Form(FormPage):
int_field: read_only_field(1)
bool_field: read_only_field(True)
list_field: read_only_list(["a", "b"])
What you need to do¶
If you have pydantic-forms pinned to any version, you will need to change this pin to 2.4.0.
If your pinned version was below 2,0, search your codebase for usages of ReadOnlyField() and replace them with read_only_field() or read_only_list() as described above.
The new types still come from the same module, pydantic_forms.validators.
4. DB column engine_settings.running_processes removed, service functions renamed¶
The running_processes column has been removed from the engine_settings database table.
Previously, this counter was maintained manually on every process start and finish.
This was replaced in v4.8 by WorkerStatusMonitor which continuously determines the running_processes count by inspecting either the threadpool or the celery statistics.
What changed¶
Several internal service functions have been renamed for clarity:
| Old name | New name |
|---|---|
get_engine_settings() |
get_engine_settings_table() |
get_engine_settings_for_update() |
get_engine_settings_table_for_update() |
generate_engine_status_response() (in api/api_v1/endpoints/settings) |
generate_engine_settings_schema() (in services/settings) |
What you need to do¶
If you use the running_processes column in external queries, this column will need to be removed as there is no direct replacement.net
You can instead either retrieve this value through the /settings/status REST endpoint, or through the settings GraphQL query.
If you use the old service functions anywhere, replace them with the new ones as described below.
Before
from orchestrator.services.settings import get_engine_settings, get_engine_settings_for_update
from orchestrator.api.api_v1.endpoints.settings import generate_engine_status_response
engine_settings = get_engine_settings()
engine_settings_for_update = get_engine_settings_for_update()
schema = generate_engine_status_response(engine_settings)
After
from orchestrator.services.settings import get_engine_settings_table, get_engine_settings_table_for_update, generate_engine_settings_schema
engine_settings = get_engine_settings_table()
engine_settings_for_update = get_engine_settings_table_for_update()
schema = generate_engine_settings_schema(engine_settings)
5. The description parameter in workflow decorators is now deprecated¶
The description parameter in @create_workflow, @modify_workflow, @terminate_workflow, @validate_workflow, @reconcile_workflow, @task, and @workflow is now deprecated.
Workflow descriptions are now managed in the database and can be updated via the UI or API.
What changed¶
Previously, workflow descriptions were typically defined both in code and in the database. This could lead to mismatches when the description was updated through the UI. Descriptions are no longer validated against the database by the product validation task.
Passing a description to a workflow decorator still works in v5.0, but now emits a deprecation warning.
What you need to do¶
Remove the description parameter from your workflow decorators and manage workflow descriptions through the database instead.
For example:
# Before
@create_workflow("Create SN8 IP BGP", initial_input_form=initial_input_form_generator)
def create_sn8_ip_bgp() -> StepList:
...
# After
@create_workflow(initial_input_form=initial_input_form_generator)
def create_sn8_ip_bgp() -> StepList:
...
If you want to backfill descriptions for multiple existing workflows during the upgrade, you can do so in a one-off database migration. For example, replace the workflow names and descriptions below with your own values:
WITH workflow_descriptions(workflow_name, workflow_description) AS (
VALUES
('your_create_workflow', 'Create your product'),
('your_modify_workflow', 'Modify your product'),
('your_terminate_workflow', 'Terminate your product')
)
UPDATE workflows AS w
SET description = wd.workflow_description
FROM workflow_descriptions AS wd
WHERE w.name = wd.workflow_name;
6. strawberry-graphql upgrade includes breaking changes¶
What changed¶
FEDERATION_ENABLEDrenamed toFEDERATION_VERSION, defaulting to2.9to maintain compatibility withexample-orchestrator. (strawberry-graphql 0.285.0 breaking change)SERVE_GRAPHQL_UIchanged fromboolto a string (leaving it empty turns it off):graphiql,apollo-sandboxorpathfinder.- Custom scalars no longer accept
NewType, usename=and map to the target type instead. (strawberry scalar docs)
What you need to do¶
remove FEDERATION_ENABLED and set FEDERATION_VERSION to the federation version you need.
Change SERVE_GRAPHQL_UI to the UI you want to use or set it to empty SERVE_GRAPHQL_UI=""
If you have custom scalars, replace the NewType wrapper with name=:
Before:
import strawberry
from typing import NewType
from orchestrator.graphql import SCALAR_OVERRIDES
VlanRangesType = strawberry.scalar(
NewType("VlanRangesType", str),
description="Represent the Orchestrator VlanRanges data type",
serialize=lambda v: v.to_list_of_tuples(),
parse_value=lambda v: v,
)
After:
import strawberry
from orchestrator.graphql import SCALAR_OVERRIDES
VlanRangesType = strawberry.scalar(
name="VlanRangesType",
description="Represent the Orchestrator VlanRanges data type",
serialize=lambda v: v.to_list_of_tuples(),
parse_value=lambda v: v,
)
The
NewTypewrapper andfrom typing import NewTypeare no longer needed and can be removed.
7. LLM-powered search is now the default¶
What changed¶
The LLM-powered search (AI search) is now a mandatory part of orchestrator-core. The following changes were made:
SEARCH_ENABLEDsetting removed – search is always enabled. Remove this environment variable from your configuration.searchpip extra removed –litellmis now a core dependency. Changepip install orchestrator-core[search]to justpip install orchestrator-core.- Search tables managed by alembic – the
ai_search_index,agent_runs,search_queries, andgraph_snapshotstables are now created via an alembic migration instead of at application startup. - TSV search deprecated – the
queryparameter on the subscriptions REST and GraphQL endpoints now emits a deprecation warning. Use the/api/searchendpoint instead. TSV search will be removed in a future 5.x release.
What you need to do¶
1. Prerequisites: install required PostgreSQL extensions
The AI search migration requires several PostgreSQL extensions. If your database user has sufficient privileges, the migration will create them automatically. Otherwise, install them manually before running the migration:
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS ltree;
CREATE EXTENSION IF NOT EXISTS unaccent;
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE EXTENSION IF NOT EXISTS vector;
The vector extension requires pgvector to be installed on your PostgreSQL server. The easiest way to get a PostgreSQL instance with all required extensions is to use the pgvector/pgvector Docker image, which the example-orchestrator uses in its docker-compose.yml:
postgres:
image: pgvector/pgvector:pg17
This image is based on the official PostgreSQL image and includes pgvector pre-installed. The other extensions (uuid-ossp, ltree, unaccent, pg_trgm) are part of the standard PostgreSQL contrib package and are available in all standard PostgreSQL distributions.
If your database user cannot create extensions but the extensions already exist, no action is needed. If you need to force extension creation (e.g., after a PostgreSQL upgrade), set LLM_FORCE_EXTENSION_MIGRATION=true.
2. Run the database migration
python main.py db upgrade heads
This creates the AI search tables. For existing installations that already have these tables (from the runtime migration), the migration uses CREATE TABLE IF NOT EXISTS and is safe to run.
3. Configure your embedding provider (optional)
Search works without any embedding configuration, but semantic search (vector similarity) will be disabled. Text-based search will still function normally.
To enable semantic search, set the following environment variables:
EMBEDDING_API_KEY=sk-... # Your embedding provider's API key
EMBEDDING_MODEL=openai/text-embedding-3-small # Default; see LiteLLM docs for alternatives
EMBEDDING_DIMENSION=1536 # Default; adjust based on your model
EMBEDDING_API_BASE is optional. When omitted, the default API base URL of the provider (as configured by LiteLLM) is used. Set it to point to a custom or self-hosted endpoint:
EMBEDDING_API_BASE=https://your-custom-endpoint.example.com/v1 # Optional
4. Initialize the search index
After the migration, populate the search index by running the CLI indexing commands:
python main.py orchestrator index subscriptions
python main.py orchestrator index products
python main.py orchestrator index processes
python main.py orchestrator index workflows
5. (Optional) Resize embeddings
If you want to use a different embedding dimension than the default (1536), update EMBEDDING_DIMENSION and run the resize command:
python main.py orchestrator embedding resize
Warning
Resizing embeddings will delete all existing embeddings from ai_search_index and search_queries tables. You will need to re-index after resizing.
6. Remove deprecated settings
Remove these environment variables from your configuration:
SEARCH_ENABLED– no longer usedOPENAI_API_KEY– renamed toEMBEDDING_API_KEYOPENAI_BASE_URL– renamed toEMBEDDING_API_BASE- Any
pip installcommands that useorchestrator-core[search]– change toorchestrator-core
8. orchestrator.security.authorize and authorize_websocket now raise¶
What changed¶
These callbacks determine if a user is authorized to make a request or connect a websocket.
OrchestratorCore.register_authorization allows users to register their own Authorization instance.
This could be the oauth2_lib default OPA-based instance, but with auto_error=False,
or a custom Authorization instance entirely.
Previously, orchestrator-core assumed this instance inherited from oauth2_lib’s OPAMixin with auto_error=True,
and did not raise when False was returned from auth_manager.authorize.
Now it does raise on False.
What you need to do¶
If you write your own instance of an Authorization that returns False instead of raising a 403 Forbidden,
this may cause your authorizations to start working (i.e. 403ing on failure) where they previously didn’t.
You should confirm your authorizations work as expected.
9. Workflow and Step callbacks of type Authorizer now expect an AuthContext, not an OIDCUserModel¶
What changed¶
These callbacks (of type Authorizer) determine if a user is authorized to start, resume, or retry a workflow.
Previously, these callbacks should have received an argument of oauth2_lib.fastapi.OIDCUserModel.
Now, they should receive an AuthContext.
OrchestratorCore.register_authorization allows users to register their own Authorization instance.
This could be the oauth2_lib default OPA-based instance, but with auto_error=False,
or a custom Authorization instance entirely.
Previously, orchestrator-core assumed this instance inherited from oauth2_lib’s OPAMixin with auto_error=True,
and did not raise when False was returned from auth_manager.authorize.
Now it does raise on False.
It looks like this:
class AuthContext(BaseModel):
model_config = ConfigDict(arbitrary_types_allowed=True)
user: AuthUserModel | None = None
action: Literal["start_workflow", "resume_workflow", "retry_workflow"]
workflow: AuthWorkflow | None = None
# Should be None for "start_workflow", otherwise set
step: AuthStep | None = None
What you need to do¶
You are affected by this change if you use any of the following:
@workflow(..., auth_callback=foo, retry_auth_callback=bar)@step(..., resume_auth_callback=foo, retry_auth_callback=bar)
There should now be much more information available for making authorization decisions within a callback, but here is how to quickly port your existing logic. If you currently have:
def auth_callback(user: OIDCUserModel | None) -> bool:
return do_something(user)
You would update this function to instead do:
from orchestrator.utils.auth import AuthContext
def auth_callback(context: AuthContext | None) -> bool:
return do_something(context.user)
To leverage the new changes, see the fields on this model and its sub-Protoypes.