feat: auth
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
"""Authentication primitives shared across services.
|
||||
|
||||
Houses the HS256 JWT issuer/verifier, bcrypt password helpers, and the
|
||||
single canonical active-membership loader. The previous implementation
|
||||
lived inline inside ``backend/jupyter.py`` and ``backend/dependencies.py``;
|
||||
moving it here lets the schedule service verify the same tokens (when
|
||||
service-to-service auth is reintroduced) and keeps the dependency
|
||||
inversion clean.
|
||||
|
||||
Service-to-service HTTP calls in this repository do NOT currently
|
||||
authenticate at the application layer (see ``docker-compose.yml``: only
|
||||
the gateway exposes a host port). The JWT and membership helpers are
|
||||
used exclusively by user-facing endpoints.
|
||||
"""
|
||||
@@ -0,0 +1,141 @@
|
||||
"""HS256 JWT issuance and verification.
|
||||
|
||||
The implementation is intentionally minimal: a hand-rolled HS256
|
||||
signer/verifier so the project does not depend on PyJWT. It deliberately
|
||||
ignores the ``alg`` header on the verify side and always recomputes
|
||||
HMAC-SHA256, which means a forged ``"alg":"none"`` token still fails
|
||||
signature validation.
|
||||
|
||||
Token payload contract:
|
||||
sub - user_id (CHAR(26) ULID)
|
||||
exp - unix seconds; mandatory
|
||||
iat - unix seconds; mandatory (used for last_logout_at checks if added later)
|
||||
|
||||
Any other claim is preserved by the verifier but not interpreted here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from common.config import settings
|
||||
|
||||
|
||||
JWT_SECRET: str = settings.jwt_secret
|
||||
JWT_ALGORITHM: str = "HS256"
|
||||
DEFAULT_TTL_SECONDS: int = 24 * 60 * 60
|
||||
|
||||
|
||||
def _b64encode(raw: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
|
||||
|
||||
|
||||
def _b64decode(value: str) -> bytes:
|
||||
padding = "=" * (-len(value) % 4)
|
||||
return base64.urlsafe_b64decode(value + padding)
|
||||
|
||||
|
||||
def issue_jwt(
|
||||
user_id: str,
|
||||
*,
|
||||
ttl_seconds: int = DEFAULT_TTL_SECONDS,
|
||||
extra_claims: dict[str, Any] | None = None,
|
||||
now: int | None = None,
|
||||
) -> str:
|
||||
"""Sign a JWT for ``user_id`` and return the compact serialization.
|
||||
|
||||
The header is fixed ``{"alg":"HS256","typ":"JWT"}``; the signature
|
||||
uses HMAC-SHA256 over ``{header_b64}.{payload_b64}`` keyed by
|
||||
``settings.jwt_secret``.
|
||||
|
||||
``ttl_seconds`` defaults to 24h. ``extra_claims`` is merged into the
|
||||
payload after ``sub``/``iat``/``exp`` are populated and would
|
||||
overwrite those if callers passed the same keys — kept simple on
|
||||
purpose so we never accidentally bypass the contract.
|
||||
"""
|
||||
if not user_id:
|
||||
raise ValueError("user_id is required")
|
||||
|
||||
issued_at = int(time.time()) if now is None else int(now)
|
||||
payload: dict[str, Any] = {
|
||||
"sub": user_id,
|
||||
"iat": issued_at,
|
||||
"exp": issued_at + int(ttl_seconds),
|
||||
}
|
||||
if extra_claims:
|
||||
payload.update(extra_claims)
|
||||
|
||||
header = {"alg": JWT_ALGORITHM, "typ": "JWT"}
|
||||
header_b64 = _b64encode(json.dumps(header, separators=(",", ":")).encode())
|
||||
payload_b64 = _b64encode(json.dumps(payload, separators=(",", ":")).encode())
|
||||
signing_input = f"{header_b64}.{payload_b64}".encode()
|
||||
signature = hmac.new(
|
||||
JWT_SECRET.encode(),
|
||||
signing_input,
|
||||
hashlib.sha256,
|
||||
).digest()
|
||||
signature_b64 = _b64encode(signature)
|
||||
return f"{header_b64}.{payload_b64}.{signature_b64}"
|
||||
|
||||
|
||||
class JwtError(Exception):
|
||||
"""Raised on missing / malformed / expired / wrong-signature tokens."""
|
||||
|
||||
|
||||
def verify_jwt_token(token: str | None) -> dict[str, Any]:
|
||||
"""Verify an HS256-signed JWT and return its payload.
|
||||
|
||||
The function is the inverse of :func:`issue_jwt`. The ``alg`` header
|
||||
is read for completeness but the signature is always recomputed
|
||||
under HS256 — a token claiming ``alg":"none"`` is rejected because
|
||||
its signature segment will not match a recomputed HMAC.
|
||||
|
||||
Raises :class:`JwtError` on every failure mode; callers translate to
|
||||
401 in HTTP contexts.
|
||||
"""
|
||||
if not token:
|
||||
raise JwtError("missing authentication token")
|
||||
|
||||
try:
|
||||
header_b64, payload_b64, signature_b64 = token.split(".", 2)
|
||||
except ValueError as exc:
|
||||
raise JwtError("malformed token") from exc
|
||||
|
||||
signing_input = f"{header_b64}.{payload_b64}".encode()
|
||||
expected = hmac.new(
|
||||
JWT_SECRET.encode(),
|
||||
signing_input,
|
||||
hashlib.sha256,
|
||||
).digest()
|
||||
try:
|
||||
signature = _b64decode(signature_b64)
|
||||
except Exception as exc: # pragma: no cover - malformed b64
|
||||
raise JwtError("malformed signature") from exc
|
||||
if not hmac.compare_digest(expected, signature):
|
||||
raise JwtError("signature mismatch")
|
||||
|
||||
try:
|
||||
payload = json.loads(_b64decode(payload_b64))
|
||||
except Exception as exc:
|
||||
raise JwtError("malformed payload") from exc
|
||||
|
||||
exp = payload.get("exp")
|
||||
if not isinstance(exp, (int, float)) or exp < time.time():
|
||||
raise JwtError("token expired")
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_TTL_SECONDS",
|
||||
"JWT_ALGORITHM",
|
||||
"JWT_SECRET",
|
||||
"JwtError",
|
||||
"issue_jwt",
|
||||
"verify_jwt_token",
|
||||
]
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Active workspace-membership loader.
|
||||
|
||||
Single canonical implementation that verifies a user is currently a
|
||||
member of the given workspace. Used by the dependency-injection layer
|
||||
in the backend; ``jupyter.py`` previously carried its own near-duplicate
|
||||
that is now reduced to a thin caller.
|
||||
|
||||
The query joins Users / WorkspaceMembers / Workspaces / Roles so
|
||||
callers receive the role they need for ``is_admin``-style checks
|
||||
without a second round-trip.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from common.db.models import Roles, Users, WorkspaceMembers, Workspaces
|
||||
|
||||
|
||||
class MembershipError(Exception):
|
||||
"""Raised when the user is not an active member of the workspace."""
|
||||
|
||||
|
||||
async def load_active_membership(
|
||||
session: AsyncSession,
|
||||
user_id: str,
|
||||
workspace_id: str,
|
||||
) -> tuple[Users, Workspaces, Roles]:
|
||||
"""Return ``(user, workspace, role)`` for an active membership.
|
||||
|
||||
All four conditions must hold: ``Users.status == 'active'``,
|
||||
``WorkspaceMembers.member_status == 'active'``,
|
||||
``Workspaces.status == 'active'``, and the row exists at all.
|
||||
Raises :class:`MembershipError` otherwise.
|
||||
"""
|
||||
statement = (
|
||||
select(Users, Workspaces, Roles)
|
||||
.join(
|
||||
WorkspaceMembers,
|
||||
WorkspaceMembers.user_id == Users.user_id,
|
||||
)
|
||||
.join(
|
||||
Workspaces,
|
||||
Workspaces.workspace_id == WorkspaceMembers.workspace_id,
|
||||
)
|
||||
.join(
|
||||
Roles,
|
||||
Roles.role_id == WorkspaceMembers.role_id,
|
||||
)
|
||||
.where(
|
||||
Users.user_id == user_id,
|
||||
Users.status == "active",
|
||||
WorkspaceMembers.workspace_id == workspace_id,
|
||||
WorkspaceMembers.member_status == "active",
|
||||
Workspaces.status == "active",
|
||||
)
|
||||
)
|
||||
row = (await session.execute(statement)).one_or_none()
|
||||
if row is None:
|
||||
raise MembershipError("active workspace membership is required")
|
||||
user, workspace, role = row
|
||||
return user, workspace, role
|
||||
|
||||
|
||||
__all__ = [
|
||||
"MembershipError",
|
||||
"load_active_membership",
|
||||
]
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Bcrypt password hashing helpers.
|
||||
|
||||
Uses passlib's :class:`CryptContext` so the algorithm choice stays in
|
||||
one place — when (not if) we move to argon2 we change the ``schemes``
|
||||
list and existing hashes still verify.
|
||||
|
||||
The :data:`make_unusable_password` helper returns a bcrypt hash of a
|
||||
random 32-byte secret. It is intentionally verifiable (to keep the
|
||||
``verify_password`` path symmetric) but cannot be matched by any
|
||||
human-supplied plaintext, so it is safe to assign to service accounts
|
||||
that should never log in interactively.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
|
||||
from passlib.context import CryptContext
|
||||
|
||||
|
||||
_crypt_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
|
||||
def hash_password(plain: str) -> str:
|
||||
"""Hash ``plain`` with bcrypt and return the encoded digest."""
|
||||
if not plain:
|
||||
raise ValueError("plain must be a non-empty string")
|
||||
return _crypt_context.hash(plain)
|
||||
|
||||
|
||||
def verify_password(plain: str, hashed: str) -> bool:
|
||||
"""Return True iff ``plain`` matches ``hashed`` under the active scheme."""
|
||||
if not plain or not hashed:
|
||||
return False
|
||||
try:
|
||||
return _crypt_context.verify(plain, hashed)
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def make_unusable_password() -> str:
|
||||
"""Return a bcrypt hash of a random 32-byte secret.
|
||||
|
||||
Service accounts store this so the ``Users.password_hash`` column is
|
||||
populated and any stray login attempt is rejected by the password
|
||||
check (random secret → impossible to brute force offline).
|
||||
"""
|
||||
return _crypt_context.hash(secrets.token_urlsafe(32))
|
||||
|
||||
|
||||
__all__ = [
|
||||
"hash_password",
|
||||
"make_unusable_password",
|
||||
"verify_password",
|
||||
]
|
||||
@@ -0,0 +1,370 @@
|
||||
"""Shared schedule-trigger logic.
|
||||
|
||||
Both the user-facing manual run endpoint (``backend.schedule_runs``)
|
||||
and the schedule service's cron tick handler call into this module to
|
||||
materialize a ``ScheduleRuns`` row plus the corresponding
|
||||
``schedule.run.requested`` outbox event. The outbox is the single
|
||||
source of truth for run dispatch — the schedule executor polls MySQL
|
||||
and picks the row up.
|
||||
|
||||
The schedule executor does NOT take any application-layer auth from
|
||||
this codebase. Service-to-service calls on the shared Docker network
|
||||
are intentionally unauthenticated; the ``triggered_by`` field stores
|
||||
the user_id that originated the run (a human for manual runs, the
|
||||
fixed ``_system_cron`` user for cron ticks) and the executor
|
||||
re-verifies that user against ``Users.status='active'`` before doing
|
||||
work.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from typing import Any, Literal
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from common.db.models import (
|
||||
ScheduleEdges,
|
||||
ScheduleNodes,
|
||||
ScheduleRuns,
|
||||
Schedules,
|
||||
Scripts,
|
||||
Versions,
|
||||
)
|
||||
from common.eventing import add_outbox_event, utcnow
|
||||
from common.ids import new_ulid
|
||||
|
||||
|
||||
# A stable user_id used for cron-triggered runs. The corresponding
|
||||
# ``Users`` row is seeded by the auth-bootstrap migration so any audit
|
||||
# query joining on ``ScheduleRuns.triggered_by`` still resolves.
|
||||
SYSTEM_CRON_USER_ID = "01HZZZZZZZZZZZZZZZZZZZZZZCR"
|
||||
|
||||
|
||||
TriggerType = Literal["manual", "cron", "api"]
|
||||
|
||||
|
||||
class TriggerError(Exception):
|
||||
"""Raised when a run cannot be created. Subclasses carry the
|
||||
appropriate HTTP status when surfaced from the backend router."""
|
||||
|
||||
|
||||
class ScheduleNotFound(TriggerError):
|
||||
pass
|
||||
|
||||
|
||||
class InvalidDag(TriggerError):
|
||||
def __init__(self, errors: list[dict[str, Any]]) -> None:
|
||||
super().__init__("schedule must contain a valid non-empty DAG")
|
||||
self.errors = errors
|
||||
|
||||
|
||||
class DagTooLarge(TriggerError):
|
||||
pass
|
||||
|
||||
|
||||
class InvalidNodeArguments(TriggerError):
|
||||
def __init__(self, message: str) -> None:
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
|
||||
|
||||
def normalize_idempotency_key(
|
||||
workspace_id: str,
|
||||
schedule_id: str,
|
||||
value: str,
|
||||
*,
|
||||
min_length: int = 8,
|
||||
) -> str:
|
||||
"""SHA-256 the (workspace, schedule, header) triple and prefix a
|
||||
version tag. Centralized so the schedule service and the backend
|
||||
router produce the same key.
|
||||
"""
|
||||
normalized = value.strip()
|
||||
if len(normalized) < min_length:
|
||||
raise TriggerError(
|
||||
f"Idempotency-Key must contain at least {min_length} characters"
|
||||
)
|
||||
digest = hashlib.sha256(
|
||||
f"{workspace_id}:{schedule_id}:{normalized}".encode("utf-8")
|
||||
).hexdigest()
|
||||
return f"run:v1:{digest}"
|
||||
|
||||
|
||||
def parse_node_arguments(value: dict[str, Any] | None) -> list[str]:
|
||||
"""Turn a node's ``arguments_json`` dict into a list of CLI args.
|
||||
|
||||
Mirrors the backend's old ``_arguments`` helper but raises
|
||||
:class:`InvalidNodeArguments` instead of an HTTP exception, so
|
||||
the schedule service can use it without importing FastAPI.
|
||||
"""
|
||||
payload = value or {}
|
||||
raw = payload.get("_args")
|
||||
result: list[str] = [str(item) for item in raw] if isinstance(raw, list) else []
|
||||
for key, item in payload.items():
|
||||
if key == "_args":
|
||||
continue
|
||||
option = f"--{key.replace('_', '-')}"
|
||||
if item is True:
|
||||
result.append(option)
|
||||
elif item is False or item is None:
|
||||
continue
|
||||
elif isinstance(item, list):
|
||||
for list_item in item:
|
||||
result.extend((option, str(list_item)))
|
||||
elif isinstance(item, (str, int, float)):
|
||||
result.extend((option, str(item)))
|
||||
else:
|
||||
raise InvalidNodeArguments(
|
||||
f"node argument {key!r} must be a scalar or list"
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
async def _load_schedule(
|
||||
session: AsyncSession,
|
||||
schedule_id: str,
|
||||
workspace_id: str,
|
||||
*,
|
||||
for_update: bool = False,
|
||||
) -> Schedules:
|
||||
statement = select(Schedules).where(
|
||||
Schedules.schedule_id == schedule_id,
|
||||
Schedules.workspace_id == workspace_id,
|
||||
Schedules.deleted_at.is_(None),
|
||||
)
|
||||
if for_update:
|
||||
statement = statement.with_for_update()
|
||||
item = await session.scalar(statement)
|
||||
if item is None:
|
||||
raise ScheduleNotFound("schedule not found")
|
||||
return item
|
||||
|
||||
|
||||
async def _load_graph(
|
||||
session: AsyncSession,
|
||||
schedule_id: str,
|
||||
) -> tuple[
|
||||
list[tuple[ScheduleNodes, Versions, Scripts]],
|
||||
list[ScheduleEdges],
|
||||
]:
|
||||
node_rows = (
|
||||
await session.execute(
|
||||
select(ScheduleNodes, Versions, Scripts)
|
||||
.join(Versions, Versions.versions_id == ScheduleNodes.versions_id)
|
||||
.join(Scripts, Scripts.script_id == Versions.script_id)
|
||||
.where(ScheduleNodes.schedule_id == schedule_id)
|
||||
.order_by(ScheduleNodes.created_at, ScheduleNodes.node_key)
|
||||
)
|
||||
).all()
|
||||
edges = list(
|
||||
(
|
||||
await session.scalars(
|
||||
select(ScheduleEdges)
|
||||
.where(ScheduleEdges.schedule_id == schedule_id)
|
||||
.order_by(ScheduleEdges.created_at, ScheduleEdges.edge_id)
|
||||
)
|
||||
).all()
|
||||
)
|
||||
return list(node_rows), edges
|
||||
|
||||
|
||||
def _validate_dag(
|
||||
nodes: list[ScheduleNodes],
|
||||
edges: list[ScheduleEdges],
|
||||
*,
|
||||
max_nodes: int = 100,
|
||||
max_edges: int = 500,
|
||||
) -> list[dict[str, Any]]:
|
||||
errors: list[dict[str, Any]] = []
|
||||
if not nodes:
|
||||
errors.append(
|
||||
{
|
||||
"code": "DAG_EMPTY",
|
||||
"message": "schedule must contain at least one node",
|
||||
}
|
||||
)
|
||||
return errors
|
||||
if len(nodes) > max_nodes or len(edges) > max_edges:
|
||||
raise DagTooLarge(
|
||||
f"schedule exceeds the v1 execution size limit "
|
||||
f"({len(nodes)} nodes / {len(edges)} edges > {max_nodes}/{max_edges})"
|
||||
)
|
||||
|
||||
# Cycle detection via Kahn's algorithm.
|
||||
in_degree: dict[str, int] = {n.node_id: 0 for n in nodes}
|
||||
adjacency: dict[str, list[str]] = {n.node_id: [] for n in nodes}
|
||||
for edge in edges:
|
||||
if edge.source_node_id not in in_degree or edge.target_node_id not in in_degree:
|
||||
errors.append(
|
||||
{
|
||||
"code": "DAG_EDGE_REFERENCES_MISSING_NODE",
|
||||
"message": f"edge {edge.edge_id} references unknown node",
|
||||
"edge_id": edge.edge_id,
|
||||
}
|
||||
)
|
||||
continue
|
||||
adjacency[edge.source_node_id].append(edge.target_node_id)
|
||||
in_degree[edge.target_node_id] += 1
|
||||
queue = [nid for nid, d in in_degree.items() if d == 0]
|
||||
ordered: list[str] = []
|
||||
while queue:
|
||||
queue.sort()
|
||||
current = queue.pop(0)
|
||||
ordered.append(current)
|
||||
for neighbor in adjacency[current]:
|
||||
in_degree[neighbor] -= 1
|
||||
if in_degree[neighbor] == 0:
|
||||
queue.append(neighbor)
|
||||
if len(ordered) != len(nodes):
|
||||
cycle_nodes = [nid for nid, d in in_degree.items() if d > 0]
|
||||
errors.append(
|
||||
{
|
||||
"code": "DAG_CYCLE",
|
||||
"message": "schedule graph contains a directed cycle",
|
||||
"node_ids": cycle_nodes,
|
||||
}
|
||||
)
|
||||
return errors
|
||||
|
||||
|
||||
def _build_snapshot(
|
||||
schedule: Schedules,
|
||||
node_rows: list[tuple[ScheduleNodes, Versions, Scripts]],
|
||||
edges: list[ScheduleEdges],
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"schedule_name": schedule.schedule_name,
|
||||
"workflow_version": schedule.workflow_version,
|
||||
"max_concurrency": schedule.max_concurrency,
|
||||
"failure_policy": schedule.failure_policy,
|
||||
"nodes": [
|
||||
{
|
||||
"node_id": node.node_id,
|
||||
"node_key": node.node_key,
|
||||
"versions_id": version.versions_id,
|
||||
"script_type": script.script_type,
|
||||
"artifact_object_id": version.artifact_object_id,
|
||||
"artifact_path": version.artifact_path,
|
||||
"timeout_seconds": node.timeout_seconds,
|
||||
"retry_count": node.retry_count,
|
||||
"retry_interval_sec": node.retry_interval_sec,
|
||||
"arguments": parse_node_arguments(node.arguments_json),
|
||||
}
|
||||
for node, version, script in node_rows
|
||||
],
|
||||
"edges": [
|
||||
{
|
||||
"source_node_id": edge.source_node_id,
|
||||
"target_node_id": edge.target_node_id,
|
||||
}
|
||||
for edge in edges
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
async def create_scheduled_run(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
schedule_id: str,
|
||||
workspace_id: str,
|
||||
triggered_by_user_id: str,
|
||||
trigger_type: TriggerType,
|
||||
idempotency_key: str,
|
||||
trace_id: str | None = None,
|
||||
) -> tuple[ScheduleRuns, bool]:
|
||||
"""Create a ``ScheduleRuns`` row + outbox event in this session.
|
||||
|
||||
Returns ``(run, is_new)``. ``is_new=False`` means the
|
||||
idempotency_key was already used and the existing run is returned
|
||||
unchanged. ``triggered_by_user_id`` is stored as-is — for cron
|
||||
triggers pass :data:`SYSTEM_CRON_USER_ID`.
|
||||
|
||||
The caller owns the transaction: ``create_scheduled_run`` flushes
|
||||
the new row to surface the unique-constraint violation on
|
||||
``idempotency_key`` deterministically, then leaves the commit to
|
||||
the caller's session lifecycle. Both the backend's
|
||||
``request_context`` (which uses ``session_scope``) and the
|
||||
schedule service's own session scope can wrap this call.
|
||||
"""
|
||||
# 1) Existing run short-circuit (re-using a known idempotency key).
|
||||
existing = await session.scalar(
|
||||
select(ScheduleRuns).where(ScheduleRuns.idempotency_key == idempotency_key)
|
||||
)
|
||||
if existing is not None:
|
||||
if (
|
||||
existing.workspace_id != workspace_id
|
||||
or existing.schedule_id != schedule_id
|
||||
):
|
||||
raise TriggerError("Idempotency-Key belongs to another schedule run")
|
||||
return existing, False
|
||||
|
||||
# 2) Lock + load schedule for the duration of this transaction.
|
||||
schedule = await _load_schedule(
|
||||
session, schedule_id, workspace_id, for_update=True,
|
||||
)
|
||||
|
||||
# 3) Build snapshot (validates node arguments eagerly).
|
||||
node_rows, edges = await _load_graph(session, schedule_id)
|
||||
snapshot = _build_snapshot(schedule, node_rows, edges)
|
||||
|
||||
# 4) Validate DAG after snapshot so parse_node_arguments errors
|
||||
# surface first.
|
||||
errors = _validate_dag(
|
||||
[n for n, _v, _s in node_rows], edges,
|
||||
)
|
||||
if errors:
|
||||
raise InvalidDag(errors)
|
||||
|
||||
# 5) Persist run + outbox.
|
||||
now = utcnow()
|
||||
run = ScheduleRuns(
|
||||
run_id=new_ulid(),
|
||||
schedule_id=schedule.schedule_id,
|
||||
workspace_id=schedule.workspace_id,
|
||||
workflow_version=schedule.workflow_version,
|
||||
trigger_type=trigger_type,
|
||||
idempotency_key=idempotency_key,
|
||||
run_status="queued",
|
||||
state_version=0,
|
||||
schedule_snapshot=snapshot,
|
||||
queued_at=now,
|
||||
triggered_by=triggered_by_user_id,
|
||||
)
|
||||
session.add(run)
|
||||
schedule.last_run_at = now
|
||||
|
||||
await add_outbox_event(
|
||||
session,
|
||||
event_type="schedule.run.requested",
|
||||
producer="platform-api",
|
||||
trace_id=trace_id,
|
||||
aggregate_type="schedule_run",
|
||||
aggregate_id=run.run_id,
|
||||
idempotency_key=idempotency_key,
|
||||
payload={
|
||||
"workspace_id": run.workspace_id,
|
||||
"schedule_id": run.schedule_id,
|
||||
"run_id": run.run_id,
|
||||
"workflow_version": run.workflow_version,
|
||||
"trigger_type": run.trigger_type,
|
||||
"triggered_by": run.triggered_by,
|
||||
"schedule_snapshot": snapshot,
|
||||
},
|
||||
)
|
||||
await session.flush()
|
||||
return run, True
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DagTooLarge",
|
||||
"InvalidDag",
|
||||
"InvalidNodeArguments",
|
||||
"SYSTEM_CRON_USER_ID",
|
||||
"ScheduleNotFound",
|
||||
"TriggerError",
|
||||
"create_scheduled_run",
|
||||
"normalize_idempotency_key",
|
||||
"parse_node_arguments",
|
||||
]
|
||||
Reference in New Issue
Block a user