secondshift 0.1.0b12__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (371) hide show
  1. secondshift/__init__.py +1 -0
  2. secondshift/__main__.py +3 -0
  3. secondshift/cli.py +17 -0
  4. secondshift/config/__init__.py +0 -0
  5. secondshift/config/api.py +23 -0
  6. secondshift/config/app.py +62 -0
  7. secondshift/config/constants.py +279 -0
  8. secondshift/config/container_env.py +118 -0
  9. secondshift/config/first_run.py +40 -0
  10. secondshift/config/formatting.py +5 -0
  11. secondshift/config/gunicorn.py +31 -0
  12. secondshift/config/logging.py +40 -0
  13. secondshift/config/settings/__init__.py +0 -0
  14. secondshift/config/settings/base.py +524 -0
  15. secondshift/config/settings/dev.py +14 -0
  16. secondshift/config/settings/local.py +23 -0
  17. secondshift/config/settings/production.py +113 -0
  18. secondshift/config/settings/runner.py +140 -0
  19. secondshift/config/settings/test.py +47 -0
  20. secondshift/config/static_storage.py +45 -0
  21. secondshift/config/urls.py +42 -0
  22. secondshift/connections/__init__.py +0 -0
  23. secondshift/connections/access.py +27 -0
  24. secondshift/connections/admin.py +25 -0
  25. secondshift/connections/agents.py +224 -0
  26. secondshift/connections/api.py +747 -0
  27. secondshift/connections/apps.py +6 -0
  28. secondshift/connections/catalog.py +96 -0
  29. secondshift/connections/checks.py +22 -0
  30. secondshift/connections/dao.py +305 -0
  31. secondshift/connections/errors.py +13 -0
  32. secondshift/connections/execution.py +262 -0
  33. secondshift/connections/external.py +258 -0
  34. secondshift/connections/github.py +123 -0
  35. secondshift/connections/http_api/__init__.py +1 -0
  36. secondshift/connections/http_api/client.py +161 -0
  37. secondshift/connections/http_api/connections.py +187 -0
  38. secondshift/connections/http_api/spec.py +382 -0
  39. secondshift/connections/mcp/__init__.py +1 -0
  40. secondshift/connections/mcp/base.py +139 -0
  41. secondshift/connections/mcp/client.py +353 -0
  42. secondshift/connections/mcp/custom.py +86 -0
  43. secondshift/connections/mcp/github.py +256 -0
  44. secondshift/connections/mcp/oauth.py +352 -0
  45. secondshift/connections/mcp/providers.py +15 -0
  46. secondshift/connections/mcp/schemas.py +77 -0
  47. secondshift/connections/mcp/sentry.py +135 -0
  48. secondshift/connections/mcp/signin.py +206 -0
  49. secondshift/connections/migration_helpers.py +28 -0
  50. secondshift/connections/migrations/0001_initial.py +101 -0
  51. secondshift/connections/migrations/0002_alter_connection_kind.py +18 -0
  52. secondshift/connections/migrations/0003_require_approval.py +23 -0
  53. secondshift/connections/migrations/0004_new_connections_skip_approval.py +18 -0
  54. secondshift/connections/migrations/0005_agentconnection.py +36 -0
  55. secondshift/connections/migrations/0006_remove_is_default.py +23 -0
  56. secondshift/connections/migrations/0007_agent_last_check.py +18 -0
  57. secondshift/connections/migrations/0008_agentcredentialcleanup.py +25 -0
  58. secondshift/connections/migrations/0009_release_0_1_0b11.py +52 -0
  59. secondshift/connections/migrations/0010_connection_attribution.py +18 -0
  60. secondshift/connections/migrations/__init__.py +0 -0
  61. secondshift/connections/model_providers.py +441 -0
  62. secondshift/connections/models.py +246 -0
  63. secondshift/connections/outbound.py +317 -0
  64. secondshift/connections/repository_ref.py +19 -0
  65. secondshift/connections/schemas.py +313 -0
  66. secondshift/connections/serialization.py +100 -0
  67. secondshift/connections/services.py +94 -0
  68. secondshift/connections/tools.py +38 -0
  69. secondshift/connections/webhooks.py +107 -0
  70. secondshift/core/__init__.py +0 -0
  71. secondshift/core/adapters.py +38 -0
  72. secondshift/core/admin.py +5 -0
  73. secondshift/core/api.py +104 -0
  74. secondshift/core/apps.py +20 -0
  75. secondshift/core/launcher.py +311 -0
  76. secondshift/core/local_agents.py +90 -0
  77. secondshift/core/local_output.py +120 -0
  78. secondshift/core/management/__init__.py +0 -0
  79. secondshift/core/management/commands/__init__.py +0 -0
  80. secondshift/core/management/commands/bootstrap_dev.py +33 -0
  81. secondshift/core/management/commands/dev_reset_db.py +204 -0
  82. secondshift/core/management/commands/login.py +23 -0
  83. secondshift/core/management/commands/logs.py +58 -0
  84. secondshift/core/management/commands/status.py +46 -0
  85. secondshift/core/management/commands/up.py +244 -0
  86. secondshift/core/middleware.py +21 -0
  87. secondshift/core/migrations/__init__.py +0 -0
  88. secondshift/core/schemas.py +71 -0
  89. secondshift/core/signin.py +58 -0
  90. secondshift/core/static/core/account.css +149 -0
  91. secondshift/core/static/core/admin.css +135 -0
  92. secondshift/core/templatetags/__init__.py +0 -0
  93. secondshift/core/templatetags/accounts.py +20 -0
  94. secondshift/core/terminal.py +27 -0
  95. secondshift/core/views.py +10 -0
  96. secondshift/gateway/__init__.py +0 -0
  97. secondshift/gateway/apps.py +6 -0
  98. secondshift/gateway/dao.py +137 -0
  99. secondshift/gateway/metering.py +171 -0
  100. secondshift/gateway/migrations/0001_initial.py +54 -0
  101. secondshift/gateway/migrations/0002_modelusage_gateway_usage_created_at_idx.py +19 -0
  102. secondshift/gateway/migrations/__init__.py +0 -0
  103. secondshift/gateway/models.py +61 -0
  104. secondshift/gateway/pricing.py +39 -0
  105. secondshift/gateway/proxy.py +369 -0
  106. secondshift/gateway/tokens.py +68 -0
  107. secondshift/gateway/urls.py +17 -0
  108. secondshift/gateway/views.py +11 -0
  109. secondshift/github/__init__.py +6 -0
  110. secondshift/github/app.py +109 -0
  111. secondshift/github/authors.py +56 -0
  112. secondshift/github/credentials.py +59 -0
  113. secondshift/github/errors.py +2 -0
  114. secondshift/github/git.py +56 -0
  115. secondshift/github/identity.py +35 -0
  116. secondshift/github/installations.py +74 -0
  117. secondshift/github/issues.py +75 -0
  118. secondshift/github/permissions.py +49 -0
  119. secondshift/github/pulls.py +49 -0
  120. secondshift/github/repos.py +52 -0
  121. secondshift/github/rest.py +93 -0
  122. secondshift/github/ruff.toml +16 -0
  123. secondshift/github/tokens.py +52 -0
  124. secondshift/jobs/__init__.py +0 -0
  125. secondshift/jobs/access.py +12 -0
  126. secondshift/jobs/admin.py +83 -0
  127. secondshift/jobs/api.py +184 -0
  128. secondshift/jobs/apps.py +6 -0
  129. secondshift/jobs/automatic_grants.py +115 -0
  130. secondshift/jobs/autosync.py +78 -0
  131. secondshift/jobs/capabilities.py +43 -0
  132. secondshift/jobs/dao.py +258 -0
  133. secondshift/jobs/drafts.py +112 -0
  134. secondshift/jobs/grants.py +116 -0
  135. secondshift/jobs/limits.py +26 -0
  136. secondshift/jobs/management/__init__.py +0 -0
  137. secondshift/jobs/management/commands/__init__.py +0 -0
  138. secondshift/jobs/manifest.py +364 -0
  139. secondshift/jobs/migrations/0001_initial.py +87 -0
  140. secondshift/jobs/migrations/0002_job_history.py +18 -0
  141. secondshift/jobs/migrations/0003_job_skill_text.py +23 -0
  142. secondshift/jobs/migrations/0004_jobgrant_tool_rules.py +18 -0
  143. secondshift/jobs/migrations/0005_job_triggers.py +18 -0
  144. secondshift/jobs/migrations/0006_job_webhook.py +18 -0
  145. secondshift/jobs/migrations/0007_job_deliveries.py +23 -0
  146. secondshift/jobs/migrations/0008_job_removed_at.py +18 -0
  147. secondshift/jobs/migrations/0009_job_subagents.py +18 -0
  148. secondshift/jobs/migrations/0010_job_skill_source.py +18 -0
  149. secondshift/jobs/migrations/__init__.py +0 -0
  150. secondshift/jobs/models.py +199 -0
  151. secondshift/jobs/needs.py +154 -0
  152. secondshift/jobs/plan.py +176 -0
  153. secondshift/jobs/planning.py +386 -0
  154. secondshift/jobs/readiness.py +46 -0
  155. secondshift/jobs/repository_sync.py +199 -0
  156. secondshift/jobs/scheduling.py +26 -0
  157. secondshift/jobs/schemas.py +207 -0
  158. secondshift/jobs/serialization.py +218 -0
  159. secondshift/jobs/skill_needs.py +189 -0
  160. secondshift/jobs/sync.py +252 -0
  161. secondshift/jobs/tool_policy.py +82 -0
  162. secondshift/jobs/tool_rules.py +200 -0
  163. secondshift/mcp_server/__init__.py +6 -0
  164. secondshift/mcp_server/api.py +79 -0
  165. secondshift/mcp_server/apps.py +7 -0
  166. secondshift/mcp_server/audit.py +22 -0
  167. secondshift/mcp_server/core.py +12 -0
  168. secondshift/mcp_server/dao.py +23 -0
  169. secondshift/mcp_server/migrations/0001_initial.py +33 -0
  170. secondshift/mcp_server/migrations/__init__.py +0 -0
  171. secondshift/mcp_server/models.py +40 -0
  172. secondshift/mcp_server/schemas.py +204 -0
  173. secondshift/mcp_server/tools/__init__.py +9 -0
  174. secondshift/mcp_server/tools/jobs.py +106 -0
  175. secondshift/mcp_server/tools/manifests.py +127 -0
  176. secondshift/mcp_server/tools/repositories.py +42 -0
  177. secondshift/mcp_server/tools/runs.py +86 -0
  178. secondshift/mcp_server/tools/tasks.py +103 -0
  179. secondshift/mcp_server/tools_registry.py +81 -0
  180. secondshift/repositories/__init__.py +0 -0
  181. secondshift/repositories/access.py +13 -0
  182. secondshift/repositories/admin.py +19 -0
  183. secondshift/repositories/api.py +296 -0
  184. secondshift/repositories/apps.py +6 -0
  185. secondshift/repositories/dao.py +114 -0
  186. secondshift/repositories/external_skills.py +224 -0
  187. secondshift/repositories/github.py +27 -0
  188. secondshift/repositories/github_access.py +57 -0
  189. secondshift/repositories/github_check.py +279 -0
  190. secondshift/repositories/github_sync.py +181 -0
  191. secondshift/repositories/github_views.py +90 -0
  192. secondshift/repositories/local.py +198 -0
  193. secondshift/repositories/management/__init__.py +0 -0
  194. secondshift/repositories/management/commands/__init__.py +0 -0
  195. secondshift/repositories/management/commands/prune_source_cache.py +15 -0
  196. secondshift/repositories/migrations/0001_initial.py +45 -0
  197. secondshift/repositories/migrations/0002_repository_last_sync_error.py +18 -0
  198. secondshift/repositories/migrations/0003_repository_checked_sha.py +18 -0
  199. secondshift/repositories/migrations/0004_pull_request_settings.py +23 -0
  200. secondshift/repositories/migrations/0005_remove_repository_installation_id.py +17 -0
  201. secondshift/repositories/migrations/0006_allow_issues.py +18 -0
  202. secondshift/repositories/migrations/__init__.py +0 -0
  203. secondshift/repositories/mirrors.py +50 -0
  204. secondshift/repositories/models.py +104 -0
  205. secondshift/repositories/pull_requests.py +199 -0
  206. secondshift/repositories/schemas.py +102 -0
  207. secondshift/repositories/serialization.py +133 -0
  208. secondshift/repositories/skill_source.py +64 -0
  209. secondshift/repositories/snapshots.py +238 -0
  210. secondshift/repositories/sources.py +256 -0
  211. secondshift/repositories/status.py +67 -0
  212. secondshift/runner/README.md +65 -0
  213. secondshift/runner/__init__.py +1 -0
  214. secondshift/runner/agent_credentials.py +89 -0
  215. secondshift/runner/agent_image/Dockerfile +16 -0
  216. secondshift/runner/agent_image/agent.py +132 -0
  217. secondshift/runner/agent_image/claude.py +254 -0
  218. secondshift/runner/agent_image/codex.py +84 -0
  219. secondshift/runner/agent_image/outcomes.py +120 -0
  220. secondshift/runner/agent_image/subagent_hook.py +71 -0
  221. secondshift/runner/agent_protocol.py +41 -0
  222. secondshift/runner/agents.py +254 -0
  223. secondshift/runner/api.py +123 -0
  224. secondshift/runner/apps.py +6 -0
  225. secondshift/runner/client.py +168 -0
  226. secondshift/runner/collection.py +276 -0
  227. secondshift/runner/dao.py +64 -0
  228. secondshift/runner/dev.py +19 -0
  229. secondshift/runner/images.py +57 -0
  230. secondshift/runner/migrations/0001_initial.py +48 -0
  231. secondshift/runner/migrations/0002_session_source_sha256.py +18 -0
  232. secondshift/runner/migrations/0003_session_collect_changes.py +18 -0
  233. secondshift/runner/migrations/0004_session_history_sha256.py +18 -0
  234. secondshift/runner/migrations/0005_require_source_sha256.py +18 -0
  235. secondshift/runner/migrations/0006_session_skill_sha256.py +18 -0
  236. secondshift/runner/migrations/__init__.py +0 -0
  237. secondshift/runner/models.py +53 -0
  238. secondshift/runner/protocol.py +97 -0
  239. secondshift/runner/schemas.py +36 -0
  240. secondshift/runner/services.py +465 -0
  241. secondshift/runner/sources.py +130 -0
  242. secondshift/runner/urls.py +7 -0
  243. secondshift/runs/__init__.py +0 -0
  244. secondshift/runs/access.py +13 -0
  245. secondshift/runs/admin.py +89 -0
  246. secondshift/runs/api.py +211 -0
  247. secondshift/runs/apps.py +11 -0
  248. secondshift/runs/artifacts.py +261 -0
  249. secondshift/runs/dao.py +640 -0
  250. secondshift/runs/deliveries.py +80 -0
  251. secondshift/runs/execution/__init__.py +0 -0
  252. secondshift/runs/execution/access_needs.py +89 -0
  253. secondshift/runs/execution/agent.py +91 -0
  254. secondshift/runs/execution/agent_broker.py +198 -0
  255. secondshift/runs/execution/budgets.py +177 -0
  256. secondshift/runs/execution/cli_agent.py +167 -0
  257. secondshift/runs/execution/consent.py +58 -0
  258. secondshift/runs/execution/deadlines.py +47 -0
  259. secondshift/runs/execution/external_tools.py +84 -0
  260. secondshift/runs/execution/issues.py +99 -0
  261. secondshift/runs/execution/model_gateway.py +90 -0
  262. secondshift/runs/execution/payload.py +63 -0
  263. secondshift/runs/execution/result.py +59 -0
  264. secondshift/runs/execution/run.py +345 -0
  265. secondshift/runs/execution/subagents.py +246 -0
  266. secondshift/runs/execution/tool_broker.py +123 -0
  267. secondshift/runs/execution/workspace_tools.py +248 -0
  268. secondshift/runs/history.py +182 -0
  269. secondshift/runs/inputs.py +49 -0
  270. secondshift/runs/issue_delivery.py +144 -0
  271. secondshift/runs/management/__init__.py +0 -0
  272. secondshift/runs/management/commands/__init__.py +0 -0
  273. secondshift/runs/management/commands/prune_runs.py +12 -0
  274. secondshift/runs/management/commands/run_dbos_worker.py +129 -0
  275. secondshift/runs/migration_helpers.py +313 -0
  276. secondshift/runs/migrations/0001_initial.py +69 -0
  277. secondshift/runs/migrations/0002_run_started_by.py +18 -0
  278. secondshift/runs/migrations/0003_agentlease.py +25 -0
  279. secondshift/runs/migrations/0004_task_assignment.py +39 -0
  280. secondshift/runs/migrations/0005_backfill_tasks.py +19 -0
  281. secondshift/runs/migrations/0006_standalone_tasks.py +50 -0
  282. secondshift/runs/migrations/0007_run_delivery.py +35 -0
  283. secondshift/runs/migrations/0008_run_outputs.py +18 -0
  284. secondshift/runs/migrations/0009_pull_request_delivery.py +28 -0
  285. secondshift/runs/migrations/0010_run_issue.py +32 -0
  286. secondshift/runs/migrations/0011_issues_delivery.py +23 -0
  287. secondshift/runs/migrations/0012_alter_rundelivery_status.py +18 -0
  288. secondshift/runs/migrations/0013_release_0_1_0b11.py +113 -0
  289. secondshift/runs/migrations/0014_run_access_need.py +30 -0
  290. secondshift/runs/migrations/__init__.py +0 -0
  291. secondshift/runs/models.py +340 -0
  292. secondshift/runs/orchestration/__init__.py +0 -0
  293. secondshift/runs/orchestration/admission.py +15 -0
  294. secondshift/runs/orchestration/dbos_runtime.py +43 -0
  295. secondshift/runs/orchestration/dispatch.py +130 -0
  296. secondshift/runs/orchestration/periodic.py +39 -0
  297. secondshift/runs/orchestration/reconciliation.py +98 -0
  298. secondshift/runs/orchestration/schedules.py +51 -0
  299. secondshift/runs/orchestration/workflows.py +480 -0
  300. secondshift/runs/outputs.py +52 -0
  301. secondshift/runs/publishing.py +162 -0
  302. secondshift/runs/queue.py +26 -0
  303. secondshift/runs/reporting/__init__.py +0 -0
  304. secondshift/runs/reporting/collection.py +26 -0
  305. secondshift/runs/reporting/connection_usage.py +45 -0
  306. secondshift/runs/reporting/failures.py +150 -0
  307. secondshift/runs/reporting/health.py +158 -0
  308. secondshift/runs/reporting/notifications.py +258 -0
  309. secondshift/runs/reporting/queries.py +60 -0
  310. secondshift/runs/retention.py +35 -0
  311. secondshift/runs/schemas.py +192 -0
  312. secondshift/runs/serialization.py +245 -0
  313. secondshift/runs/services.py +176 -0
  314. secondshift/runs/signals.py +25 -0
  315. secondshift/runs/submissions.py +236 -0
  316. secondshift/runs/transitions.py +84 -0
  317. secondshift/templates/account/password_reset_help_text.html +1 -0
  318. secondshift/templates/admin/base_site.html +24 -0
  319. secondshift/templates/allauth/elements/provider.html +9 -0
  320. secondshift/templates/allauth/layouts/base.html +67 -0
  321. secondshift/templates/socialaccount/snippets/login.html +9 -0
  322. secondshift/templates/workspaces/invite_unusable.html +10 -0
  323. secondshift/triggers/__init__.py +0 -0
  324. secondshift/triggers/admin.py +19 -0
  325. secondshift/triggers/api.py +116 -0
  326. secondshift/triggers/apps.py +6 -0
  327. secondshift/triggers/dao.py +66 -0
  328. secondshift/triggers/filters.py +48 -0
  329. secondshift/triggers/migrations/0001_initial.py +55 -0
  330. secondshift/triggers/migrations/0002_ignored_events_and_hourly_limit.py +23 -0
  331. secondshift/triggers/migrations/__init__.py +0 -0
  332. secondshift/triggers/models.py +83 -0
  333. secondshift/triggers/schemas.py +58 -0
  334. secondshift/triggers/serialization.py +91 -0
  335. secondshift/triggers/services.py +246 -0
  336. secondshift/triggers/verification.py +84 -0
  337. secondshift/web/assets/index-Bwy0hvk_.js +2419 -0
  338. secondshift/web/assets/index-D27gB9_L.css +1 -0
  339. secondshift/web/favicon.svg +19 -0
  340. secondshift/web/index.html +16 -0
  341. secondshift/web/secondshift-mark.svg +16 -0
  342. secondshift/workspaces/__init__.py +0 -0
  343. secondshift/workspaces/access.py +181 -0
  344. secondshift/workspaces/admin.py +37 -0
  345. secondshift/workspaces/api.py +264 -0
  346. secondshift/workspaces/apps.py +6 -0
  347. secondshift/workspaces/context_processors.py +11 -0
  348. secondshift/workspaces/dao.py +281 -0
  349. secondshift/workspaces/invites.py +150 -0
  350. secondshift/workspaces/limits.py +197 -0
  351. secondshift/workspaces/management/__init__.py +0 -0
  352. secondshift/workspaces/management/commands/__init__.py +0 -0
  353. secondshift/workspaces/management/commands/create_workspace.py +39 -0
  354. secondshift/workspaces/migrations/0001_initial.py +67 -0
  355. secondshift/workspaces/migrations/0002_api_token.py +34 -0
  356. secondshift/workspaces/migrations/0003_execution_default.py +29 -0
  357. secondshift/workspaces/migrations/0004_copy_default_model_key.py +26 -0
  358. secondshift/workspaces/migrations/__init__.py +0 -0
  359. secondshift/workspaces/models.py +169 -0
  360. secondshift/workspaces/owners.py +58 -0
  361. secondshift/workspaces/schemas.py +149 -0
  362. secondshift/workspaces/serialization.py +138 -0
  363. secondshift/workspaces/server_settings.py +140 -0
  364. secondshift/workspaces/services.py +94 -0
  365. secondshift/workspaces/tokens.py +108 -0
  366. secondshift/workspaces/views.py +32 -0
  367. secondshift-0.1.0b12.dist-info/METADATA +270 -0
  368. secondshift-0.1.0b12.dist-info/RECORD +371 -0
  369. secondshift-0.1.0b12.dist-info/WHEEL +4 -0
  370. secondshift-0.1.0b12.dist-info/entry_points.txt +2 -0
  371. secondshift-0.1.0b12.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,58 @@
1
+ from datetime import datetime
2
+
3
+ from ninja import Schema
4
+ from pydantic import Field
5
+
6
+ from secondshift.config.constants import TriggerKind
7
+
8
+
9
+ class TriggerEventSchema(Schema):
10
+ id: int
11
+ outcome: str
12
+ reason: str
13
+ run_id: int | None
14
+ received_at: datetime
15
+ # Requests with the same outcome and reason share one row.
16
+ # received_at is the newest of them.
17
+ count: int = 1
18
+
19
+
20
+ class TriggerSchema(Schema):
21
+ kind: TriggerKind
22
+ # The repository's configuration names this trigger.
23
+ declared: bool
24
+ # Someone turned it on at least once, so it has a URL and a secret.
25
+ set_up: bool
26
+ enabled: bool
27
+ url: str | None
28
+ secret_rotated_at: datetime | None
29
+ events: list[TriggerEventSchema]
30
+ # The cap that applies, the owner's own value if set, and the server's.
31
+ runs_per_hour: int
32
+ custom_runs_per_hour: int | None
33
+ default_runs_per_hour: int
34
+ max_runs_per_hour: int
35
+ # From the repository's webhook settings. repeat_window includes the default.
36
+ skip_when: dict[str, str | int | float | bool]
37
+ repeat_key: str | None
38
+ repeat_window: str | None
39
+
40
+
41
+ class TriggerChangeSchema(Schema):
42
+ trigger: TriggerSchema
43
+ # Only right after it was created or rotated. Never returned again.
44
+ secret: str | None = None
45
+
46
+
47
+ class TriggerEnabledSchema(Schema):
48
+ enabled: bool
49
+
50
+
51
+ class TriggerLimitSchema(Schema):
52
+ # Empty goes back to the server default.
53
+ runs_per_hour: int | None = Field(default=None, ge=1)
54
+
55
+
56
+ class TriggerReceivedSchema(Schema):
57
+ detail: str
58
+ run_id: int | None = None
@@ -0,0 +1,91 @@
1
+ from django.conf import settings
2
+
3
+ from secondshift.config.constants import TriggerKind
4
+ from secondshift.jobs.manifest import WebhookManifest
5
+ from secondshift.jobs.models import Job
6
+ from secondshift.triggers.dao import recent_events
7
+ from secondshift.triggers.models import JobTrigger
8
+ from secondshift.triggers.schemas import (
9
+ TriggerChangeSchema,
10
+ TriggerEventSchema,
11
+ TriggerSchema,
12
+ )
13
+ from secondshift.triggers.services import TriggerChange, runs_per_hour
14
+
15
+ # How many received requests the job page shows for each trigger.
16
+ EVENTS_SHOWN = 20
17
+
18
+
19
+ def trigger_url(trigger: JobTrigger) -> str:
20
+ return f"{settings.FRONTEND_URL.rstrip('/')}/api/triggers/{trigger.public_id}"
21
+
22
+
23
+ def grouped_events(trigger: JobTrigger) -> list[TriggerEventSchema]:
24
+ """The newest requests, with repeats of the same outcome and reason folded
25
+ into one row at their newest time. Rows for started runs stay apart.
26
+
27
+ A burst of ignored repeats, even for several alerts at once, would
28
+ otherwise push the runs they repeat out of view.
29
+ """
30
+ groups: list[TriggerEventSchema] = []
31
+ folded: dict[tuple[str, str], TriggerEventSchema] = {}
32
+ for event in recent_events(trigger, settings.TRIGGER_EVENTS_KEPT * 3):
33
+ key = (event.outcome, event.reason)
34
+ if event.run_id is None and key in folded:
35
+ folded[key].count += 1
36
+ continue
37
+ if len(groups) == EVENTS_SHOWN:
38
+ continue
39
+ row = TriggerEventSchema(
40
+ id=event.pk,
41
+ outcome=event.outcome,
42
+ reason=event.reason,
43
+ run_id=event.run_id,
44
+ received_at=event.received_at,
45
+ )
46
+ groups.append(row)
47
+ if event.run_id is None:
48
+ folded[key] = row
49
+ return groups
50
+
51
+
52
+ def serialize_trigger(
53
+ job: Job, kind: TriggerKind, trigger: JobTrigger | None
54
+ ) -> TriggerSchema:
55
+ events = grouped_events(trigger) if trigger else []
56
+ webhook = (
57
+ job.webhook_settings if kind == TriggerKind.WEBHOOK else None
58
+ ) or WebhookManifest()
59
+ return TriggerSchema(
60
+ kind=kind,
61
+ declared=kind in job.triggers,
62
+ set_up=trigger is not None,
63
+ enabled=bool(trigger and trigger.enabled),
64
+ url=trigger_url(trigger) if trigger else None,
65
+ secret_rotated_at=trigger.secret_rotated_at if trigger else None,
66
+ events=events,
67
+ runs_per_hour=runs_per_hour(trigger),
68
+ custom_runs_per_hour=trigger.max_runs_per_hour if trigger else None,
69
+ default_runs_per_hour=settings.TRIGGER_MAX_RUNS_PER_HOUR,
70
+ max_runs_per_hour=settings.TRIGGER_MAX_RUNS_PER_HOUR_CEILING,
71
+ skip_when=webhook.skip_when,
72
+ repeat_key=webhook.repeat_key,
73
+ repeat_window=webhook.window if webhook.repeat_key else None,
74
+ )
75
+
76
+
77
+ def serialize_job_triggers(job: Job) -> list[TriggerSchema]:
78
+ """Every trigger the job names, and any set up earlier that it no longer names."""
79
+ existing = {TriggerKind(item.kind): item for item in job.job_triggers.all()}
80
+ kinds = [TriggerKind(kind) for kind in job.triggers] + [
81
+ kind for kind in existing if kind not in job.triggers
82
+ ]
83
+ return [serialize_trigger(job, kind, existing.get(kind)) for kind in kinds]
84
+
85
+
86
+ def serialize_change(job: Job, change: TriggerChange) -> TriggerChangeSchema:
87
+ trigger = change.trigger
88
+ return TriggerChangeSchema(
89
+ trigger=serialize_trigger(job, TriggerKind(trigger.kind), trigger),
90
+ secret=change.secret,
91
+ )
@@ -0,0 +1,246 @@
1
+ """Turn job triggers on and off, and start runs from the requests they receive.
2
+
3
+ The repository names the trigger. Turning it on creates its URL and secret.
4
+ """
5
+
6
+ import secrets
7
+ import time
8
+ from collections.abc import Mapping
9
+ from dataclasses import dataclass
10
+ from datetime import timedelta
11
+
12
+ from django.conf import settings
13
+ from django.utils import timezone
14
+
15
+ from secondshift.config.constants import RunStart, TriggerKind
16
+ from secondshift.jobs.manifest import WebhookManifest, duration_seconds
17
+ from secondshift.jobs.models import JOB_REMOVED, Job
18
+ from secondshift.runs.dao import run_for_repeat_key, started_run_count
19
+ from secondshift.runs.models import Run
20
+ from secondshift.runs.orchestration.dispatch import RunEnqueueFailed
21
+ from secondshift.runs.services import RunInProgress, RunNotReady, RunRefused, start_run
22
+ from secondshift.triggers.dao import (
23
+ add_event,
24
+ create_trigger,
25
+ has_recent_event,
26
+ job_trigger,
27
+ )
28
+ from secondshift.triggers.filters import (
29
+ parse_body,
30
+ repeat_key,
31
+ skip_reason,
32
+ )
33
+ from secondshift.triggers.models import JobTrigger, TriggerEvent
34
+ from secondshift.triggers.verification import verify
35
+
36
+ # A failed sign-in is recorded at most this often, so guessing cannot flood
37
+ # the log.
38
+ UNAUTHORIZED_RECORD_SECONDS = 60
39
+
40
+ # Secret scanners can match trigger secrets by this prefix.
41
+ SECRET_PREFIX = "sst_"
42
+
43
+ ENQUEUE_RETRY_SECONDS = 60
44
+
45
+
46
+ class TriggerError(Exception):
47
+ pass
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class TriggerChange:
52
+ trigger: JobTrigger
53
+ # Only when a secret was just created or rotated. It is shown once.
54
+ secret: str | None = None
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class Received:
59
+ """What a trigger request led to. status is the HTTP status to answer."""
60
+
61
+ status: int
62
+ detail: str
63
+ run: Run | None = None
64
+ retry_after: int | None = None
65
+
66
+
67
+ def new_secret() -> str:
68
+ return SECRET_PREFIX + secrets.token_urlsafe(32)
69
+
70
+
71
+ def set_trigger_enabled(job: Job, kind: TriggerKind, enabled: bool) -> TriggerChange:
72
+ """Turn a trigger on or off. The first time on creates its URL and secret."""
73
+ trigger = job_trigger(job, kind)
74
+ if enabled and kind not in job.triggers:
75
+ raise TriggerError(
76
+ f"The job's configuration does not name a {kind} trigger. "
77
+ f"Add triggers: [{kind}] to the job in .secondshift.yaml."
78
+ )
79
+ if trigger is None:
80
+ if not enabled:
81
+ raise TriggerError("This trigger is not set up.")
82
+ secret = new_secret()
83
+ trigger = create_trigger(job, kind, secret, timezone.now())
84
+ return TriggerChange(trigger, secret)
85
+ trigger.enabled = enabled
86
+ trigger.save(update_fields=["enabled", "updated_at"])
87
+ return TriggerChange(trigger)
88
+
89
+
90
+ def rotate_secret(job: Job, kind: TriggerKind) -> TriggerChange:
91
+ """Replace the secret. Requests signed with the old one are refused."""
92
+ trigger = job_trigger(job, kind)
93
+ if trigger is None:
94
+ raise TriggerError("This trigger is not set up.")
95
+ secret = new_secret()
96
+ trigger.secret = secret
97
+ trigger.secret_rotated_at = timezone.now()
98
+ trigger.save(update_fields=["secret", "secret_rotated_at", "updated_at"])
99
+ return TriggerChange(trigger, secret)
100
+
101
+
102
+ def set_runs_per_hour(job: Job, kind: TriggerKind, value: int | None) -> JobTrigger:
103
+ """Set the trigger's hourly cap. None goes back to the server default."""
104
+ trigger = job_trigger(job, kind)
105
+ if trigger is None:
106
+ raise TriggerError("This trigger is not set up.")
107
+ ceiling = settings.TRIGGER_MAX_RUNS_PER_HOUR_CEILING
108
+ if value is not None and value > ceiling:
109
+ raise TriggerError(f"This server allows at most {ceiling} runs per hour.")
110
+ trigger.max_runs_per_hour = value
111
+ trigger.save(update_fields=["max_runs_per_hour", "updated_at"])
112
+ return trigger
113
+
114
+
115
+ def record(
116
+ trigger: JobTrigger, outcome: str, reason: str = "", run: Run | None = None
117
+ ) -> None:
118
+ """Keep the event, and only the newest few of each outcome for each trigger.
119
+
120
+ Kept per outcome, so a flood of refusals never pushes out started runs.
121
+ """
122
+ add_event(trigger, outcome, reason[:500], run, settings.TRIGGER_EVENTS_KEPT)
123
+
124
+
125
+ def refuse(trigger: JobTrigger, status: int, reason: str) -> Received:
126
+ record(trigger, TriggerEvent.Outcome.REFUSED, reason)
127
+ return Received(status, reason)
128
+
129
+
130
+ def ignore(trigger: JobTrigger, reason: str) -> Received:
131
+ """Answer 2xx, so the sender does not retry a request the job skips."""
132
+ record(trigger, TriggerEvent.Outcome.IGNORED, reason)
133
+ return Received(200, reason)
134
+
135
+
136
+ def refuse_unauthorized(trigger: JobTrigger, reason: str) -> Received:
137
+ since = timezone.now() - timedelta(seconds=UNAUTHORIZED_RECORD_SECONDS)
138
+ if not has_recent_event(trigger, TriggerEvent.Outcome.REFUSED, reason, since):
139
+ record(trigger, TriggerEvent.Outcome.REFUSED, reason)
140
+ return Received(401, reason)
141
+
142
+
143
+ def runs_per_hour(trigger: JobTrigger | None) -> int:
144
+ """The owner's cap or the server default, never above the server ceiling."""
145
+ chosen = (trigger and trigger.max_runs_per_hour) or (
146
+ settings.TRIGGER_MAX_RUNS_PER_HOUR
147
+ )
148
+ return min(chosen, settings.TRIGGER_MAX_RUNS_PER_HOUR_CEILING)
149
+
150
+
151
+ def earlier_run(job: Job, key: str, webhook: WebhookManifest) -> Run | None:
152
+ """A run for the same event that is active or finished within the window."""
153
+ since = timezone.now() - timedelta(seconds=duration_seconds(webhook.window))
154
+ return run_for_repeat_key(job, key, since)
155
+
156
+
157
+ def repeat_reason(run: Run) -> str:
158
+ return f"Repeat of the event that started run #{run.pk}."
159
+
160
+
161
+ def not_ready_reason(error: RunNotReady) -> str:
162
+ return "The job is not ready: " + " ".join(error.problems)
163
+
164
+
165
+ def refusal(trigger: JobTrigger) -> Received | None:
166
+ """Why the trigger itself cannot start a run, before the body is read."""
167
+ if not trigger.enabled:
168
+ return refuse(trigger, 409, "This trigger is turned off.")
169
+ job = trigger.job
170
+ if job.repository.removed_at is not None:
171
+ return refuse(trigger, 409, "The job's repository was removed.")
172
+ if job.removed_at is not None:
173
+ return refuse(trigger, 409, JOB_REMOVED)
174
+ if trigger.kind not in job.triggers:
175
+ return refuse(
176
+ trigger,
177
+ 409,
178
+ f"The job's configuration no longer names a {trigger.kind} trigger.",
179
+ )
180
+ return None
181
+
182
+
183
+ def hourly_refusal(trigger: JobTrigger) -> Received | None:
184
+ limit = runs_per_hour(trigger)
185
+ started = started_run_count(
186
+ trigger.job,
187
+ RunStart.WEBHOOK,
188
+ timezone.now() - timedelta(hours=1),
189
+ )
190
+ if started < limit:
191
+ return None
192
+ runs = "run" if limit == 1 else "runs"
193
+ return refuse(
194
+ trigger, 429, f"This trigger already started {limit} {runs} in the last hour."
195
+ )
196
+
197
+
198
+ def start(trigger: JobTrigger, body: bytes, key: str | None) -> Received:
199
+ try:
200
+ run = start_run(trigger.job, RunStart.WEBHOOK, body, key)
201
+ except RunInProgress as error:
202
+ if key is not None and error.run.inputs.repeat_key == key:
203
+ return ignore(trigger, repeat_reason(error.run))
204
+ return refuse(
205
+ trigger, 409, f"Run #{error.run.pk} of this job has not finished."
206
+ )
207
+ except RunNotReady as error:
208
+ return refuse(trigger, 409, not_ready_reason(error))
209
+ except RunRefused as error:
210
+ return refuse(trigger, 409, str(error))
211
+ except RunEnqueueFailed as error:
212
+ reason = "The run could not be queued."
213
+ record(trigger, TriggerEvent.Outcome.REFUSED, reason, run=error.run)
214
+ return Received(503, reason, error.run, retry_after=ENQUEUE_RETRY_SECONDS)
215
+ record(trigger, TriggerEvent.Outcome.STARTED, run=run)
216
+ return Received(202, "Run started.", run)
217
+
218
+
219
+ def receive(trigger: JobTrigger, headers: Mapping[str, str], body: bytes) -> Received:
220
+ """Check one request and start a run if everything allows it.
221
+
222
+ In order: the secret, the trigger, the job's webhook settings (skip and
223
+ repeats), the hourly cap, then the job itself.
224
+ """
225
+ verdict = verify(
226
+ headers,
227
+ body,
228
+ trigger.secret,
229
+ now=time.time(),
230
+ max_age_seconds=settings.TRIGGER_SIGNATURE_MAX_AGE_SECONDS,
231
+ )
232
+ if not verdict.ok:
233
+ return refuse_unauthorized(trigger, verdict.reason)
234
+ if refused := refusal(trigger):
235
+ return refused
236
+ declared = trigger.job.webhook_settings
237
+ data = parse_body(body) if declared else None
238
+ webhook = declared or WebhookManifest()
239
+ if reason := skip_reason(data, webhook.skip_when):
240
+ return ignore(trigger, reason)
241
+ key = repeat_key(data, webhook.repeat_key)
242
+ if key is not None and (earlier := earlier_run(trigger.job, key, webhook)):
243
+ return ignore(trigger, repeat_reason(earlier))
244
+ if refused := hourly_refusal(trigger):
245
+ return refused
246
+ return start(trigger, body, key)
@@ -0,0 +1,84 @@
1
+ """Check that a trigger request came from someone who holds its secret.
2
+
3
+ A request passes with either:
4
+
5
+ - an Authorization header whose credentials are the secret, with any scheme,
6
+ such as "Bearer <secret>".
7
+ - a hex HMAC-SHA256 of the body, keyed with the secret, in SIGNATURE_HEADER.
8
+ With TIMESTAMP_HEADER, the signature covers "<timestamp>:<body>" and the
9
+ timestamp must be recent.
10
+
11
+ Senders that sign with their own header names, such as Grafana, let you set
12
+ the names. Point them at these.
13
+ """
14
+
15
+ import hashlib
16
+ import hmac
17
+ from collections.abc import Mapping
18
+ from dataclasses import dataclass
19
+
20
+ SIGNATURE_HEADER = "X-SecondShift-Signature"
21
+ TIMESTAMP_HEADER = "X-SecondShift-Timestamp"
22
+ SIGNATURE_PREFIX = "sha256="
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class Verdict:
27
+ ok: bool
28
+ reason: str = ""
29
+
30
+
31
+ MISSING = Verdict(False, "The request had no Authorization header or signature.")
32
+ WRONG = Verdict(False, "The request's credentials did not match the secret.")
33
+ STALE = Verdict(False, "The signed timestamp is missing, invalid or too old.")
34
+
35
+
36
+ def header(headers: Mapping[str, str], name: str) -> str | None:
37
+ value = headers.get(name)
38
+ return None if value is None else value.strip()
39
+
40
+
41
+ def same(given: str, expected: str) -> bool:
42
+ return hmac.compare_digest(given.encode(), expected.encode())
43
+
44
+
45
+ def authorization_matches(value: str, secret: str) -> bool:
46
+ parts = value.split(None, 1)
47
+ credentials = parts[1] if len(parts) == 2 else ""
48
+ return bool(credentials) and same(credentials.strip(), secret)
49
+
50
+
51
+ def signature(secret: str, body: bytes, timestamp: str | None) -> str:
52
+ signed = f"{timestamp}:".encode() + body if timestamp is not None else body
53
+ return hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
54
+
55
+
56
+ def fresh(timestamp: str, now: float, max_age_seconds: int) -> bool:
57
+ try:
58
+ sent = float(timestamp)
59
+ except ValueError:
60
+ return False
61
+ return abs(now - sent) <= max_age_seconds
62
+
63
+
64
+ def verify(
65
+ headers: Mapping[str, str],
66
+ body: bytes,
67
+ secret: str,
68
+ *,
69
+ now: float,
70
+ max_age_seconds: int,
71
+ ) -> Verdict:
72
+ authorization = headers.get("Authorization")
73
+ if authorization is not None:
74
+ return Verdict(True) if authorization_matches(authorization, secret) else WRONG
75
+ given = header(headers, SIGNATURE_HEADER)
76
+ if given is None:
77
+ return MISSING
78
+ given = given.removeprefix(SIGNATURE_PREFIX).lower()
79
+ timestamp = header(headers, TIMESTAMP_HEADER)
80
+ if not same(given, signature(secret, body, timestamp)):
81
+ return WRONG
82
+ if timestamp is not None and not fresh(timestamp, now, max_age_seconds):
83
+ return STALE
84
+ return Verdict(True)