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.
- secondshift/__init__.py +1 -0
- secondshift/__main__.py +3 -0
- secondshift/cli.py +17 -0
- secondshift/config/__init__.py +0 -0
- secondshift/config/api.py +23 -0
- secondshift/config/app.py +62 -0
- secondshift/config/constants.py +279 -0
- secondshift/config/container_env.py +118 -0
- secondshift/config/first_run.py +40 -0
- secondshift/config/formatting.py +5 -0
- secondshift/config/gunicorn.py +31 -0
- secondshift/config/logging.py +40 -0
- secondshift/config/settings/__init__.py +0 -0
- secondshift/config/settings/base.py +524 -0
- secondshift/config/settings/dev.py +14 -0
- secondshift/config/settings/local.py +23 -0
- secondshift/config/settings/production.py +113 -0
- secondshift/config/settings/runner.py +140 -0
- secondshift/config/settings/test.py +47 -0
- secondshift/config/static_storage.py +45 -0
- secondshift/config/urls.py +42 -0
- secondshift/connections/__init__.py +0 -0
- secondshift/connections/access.py +27 -0
- secondshift/connections/admin.py +25 -0
- secondshift/connections/agents.py +224 -0
- secondshift/connections/api.py +747 -0
- secondshift/connections/apps.py +6 -0
- secondshift/connections/catalog.py +96 -0
- secondshift/connections/checks.py +22 -0
- secondshift/connections/dao.py +305 -0
- secondshift/connections/errors.py +13 -0
- secondshift/connections/execution.py +262 -0
- secondshift/connections/external.py +258 -0
- secondshift/connections/github.py +123 -0
- secondshift/connections/http_api/__init__.py +1 -0
- secondshift/connections/http_api/client.py +161 -0
- secondshift/connections/http_api/connections.py +187 -0
- secondshift/connections/http_api/spec.py +382 -0
- secondshift/connections/mcp/__init__.py +1 -0
- secondshift/connections/mcp/base.py +139 -0
- secondshift/connections/mcp/client.py +353 -0
- secondshift/connections/mcp/custom.py +86 -0
- secondshift/connections/mcp/github.py +256 -0
- secondshift/connections/mcp/oauth.py +352 -0
- secondshift/connections/mcp/providers.py +15 -0
- secondshift/connections/mcp/schemas.py +77 -0
- secondshift/connections/mcp/sentry.py +135 -0
- secondshift/connections/mcp/signin.py +206 -0
- secondshift/connections/migration_helpers.py +28 -0
- secondshift/connections/migrations/0001_initial.py +101 -0
- secondshift/connections/migrations/0002_alter_connection_kind.py +18 -0
- secondshift/connections/migrations/0003_require_approval.py +23 -0
- secondshift/connections/migrations/0004_new_connections_skip_approval.py +18 -0
- secondshift/connections/migrations/0005_agentconnection.py +36 -0
- secondshift/connections/migrations/0006_remove_is_default.py +23 -0
- secondshift/connections/migrations/0007_agent_last_check.py +18 -0
- secondshift/connections/migrations/0008_agentcredentialcleanup.py +25 -0
- secondshift/connections/migrations/0009_release_0_1_0b11.py +52 -0
- secondshift/connections/migrations/0010_connection_attribution.py +18 -0
- secondshift/connections/migrations/__init__.py +0 -0
- secondshift/connections/model_providers.py +441 -0
- secondshift/connections/models.py +246 -0
- secondshift/connections/outbound.py +317 -0
- secondshift/connections/repository_ref.py +19 -0
- secondshift/connections/schemas.py +313 -0
- secondshift/connections/serialization.py +100 -0
- secondshift/connections/services.py +94 -0
- secondshift/connections/tools.py +38 -0
- secondshift/connections/webhooks.py +107 -0
- secondshift/core/__init__.py +0 -0
- secondshift/core/adapters.py +38 -0
- secondshift/core/admin.py +5 -0
- secondshift/core/api.py +104 -0
- secondshift/core/apps.py +20 -0
- secondshift/core/launcher.py +311 -0
- secondshift/core/local_agents.py +90 -0
- secondshift/core/local_output.py +120 -0
- secondshift/core/management/__init__.py +0 -0
- secondshift/core/management/commands/__init__.py +0 -0
- secondshift/core/management/commands/bootstrap_dev.py +33 -0
- secondshift/core/management/commands/dev_reset_db.py +204 -0
- secondshift/core/management/commands/login.py +23 -0
- secondshift/core/management/commands/logs.py +58 -0
- secondshift/core/management/commands/status.py +46 -0
- secondshift/core/management/commands/up.py +244 -0
- secondshift/core/middleware.py +21 -0
- secondshift/core/migrations/__init__.py +0 -0
- secondshift/core/schemas.py +71 -0
- secondshift/core/signin.py +58 -0
- secondshift/core/static/core/account.css +149 -0
- secondshift/core/static/core/admin.css +135 -0
- secondshift/core/templatetags/__init__.py +0 -0
- secondshift/core/templatetags/accounts.py +20 -0
- secondshift/core/terminal.py +27 -0
- secondshift/core/views.py +10 -0
- secondshift/gateway/__init__.py +0 -0
- secondshift/gateway/apps.py +6 -0
- secondshift/gateway/dao.py +137 -0
- secondshift/gateway/metering.py +171 -0
- secondshift/gateway/migrations/0001_initial.py +54 -0
- secondshift/gateway/migrations/0002_modelusage_gateway_usage_created_at_idx.py +19 -0
- secondshift/gateway/migrations/__init__.py +0 -0
- secondshift/gateway/models.py +61 -0
- secondshift/gateway/pricing.py +39 -0
- secondshift/gateway/proxy.py +369 -0
- secondshift/gateway/tokens.py +68 -0
- secondshift/gateway/urls.py +17 -0
- secondshift/gateway/views.py +11 -0
- secondshift/github/__init__.py +6 -0
- secondshift/github/app.py +109 -0
- secondshift/github/authors.py +56 -0
- secondshift/github/credentials.py +59 -0
- secondshift/github/errors.py +2 -0
- secondshift/github/git.py +56 -0
- secondshift/github/identity.py +35 -0
- secondshift/github/installations.py +74 -0
- secondshift/github/issues.py +75 -0
- secondshift/github/permissions.py +49 -0
- secondshift/github/pulls.py +49 -0
- secondshift/github/repos.py +52 -0
- secondshift/github/rest.py +93 -0
- secondshift/github/ruff.toml +16 -0
- secondshift/github/tokens.py +52 -0
- secondshift/jobs/__init__.py +0 -0
- secondshift/jobs/access.py +12 -0
- secondshift/jobs/admin.py +83 -0
- secondshift/jobs/api.py +184 -0
- secondshift/jobs/apps.py +6 -0
- secondshift/jobs/automatic_grants.py +115 -0
- secondshift/jobs/autosync.py +78 -0
- secondshift/jobs/capabilities.py +43 -0
- secondshift/jobs/dao.py +258 -0
- secondshift/jobs/drafts.py +112 -0
- secondshift/jobs/grants.py +116 -0
- secondshift/jobs/limits.py +26 -0
- secondshift/jobs/management/__init__.py +0 -0
- secondshift/jobs/management/commands/__init__.py +0 -0
- secondshift/jobs/manifest.py +364 -0
- secondshift/jobs/migrations/0001_initial.py +87 -0
- secondshift/jobs/migrations/0002_job_history.py +18 -0
- secondshift/jobs/migrations/0003_job_skill_text.py +23 -0
- secondshift/jobs/migrations/0004_jobgrant_tool_rules.py +18 -0
- secondshift/jobs/migrations/0005_job_triggers.py +18 -0
- secondshift/jobs/migrations/0006_job_webhook.py +18 -0
- secondshift/jobs/migrations/0007_job_deliveries.py +23 -0
- secondshift/jobs/migrations/0008_job_removed_at.py +18 -0
- secondshift/jobs/migrations/0009_job_subagents.py +18 -0
- secondshift/jobs/migrations/0010_job_skill_source.py +18 -0
- secondshift/jobs/migrations/__init__.py +0 -0
- secondshift/jobs/models.py +199 -0
- secondshift/jobs/needs.py +154 -0
- secondshift/jobs/plan.py +176 -0
- secondshift/jobs/planning.py +386 -0
- secondshift/jobs/readiness.py +46 -0
- secondshift/jobs/repository_sync.py +199 -0
- secondshift/jobs/scheduling.py +26 -0
- secondshift/jobs/schemas.py +207 -0
- secondshift/jobs/serialization.py +218 -0
- secondshift/jobs/skill_needs.py +189 -0
- secondshift/jobs/sync.py +252 -0
- secondshift/jobs/tool_policy.py +82 -0
- secondshift/jobs/tool_rules.py +200 -0
- secondshift/mcp_server/__init__.py +6 -0
- secondshift/mcp_server/api.py +79 -0
- secondshift/mcp_server/apps.py +7 -0
- secondshift/mcp_server/audit.py +22 -0
- secondshift/mcp_server/core.py +12 -0
- secondshift/mcp_server/dao.py +23 -0
- secondshift/mcp_server/migrations/0001_initial.py +33 -0
- secondshift/mcp_server/migrations/__init__.py +0 -0
- secondshift/mcp_server/models.py +40 -0
- secondshift/mcp_server/schemas.py +204 -0
- secondshift/mcp_server/tools/__init__.py +9 -0
- secondshift/mcp_server/tools/jobs.py +106 -0
- secondshift/mcp_server/tools/manifests.py +127 -0
- secondshift/mcp_server/tools/repositories.py +42 -0
- secondshift/mcp_server/tools/runs.py +86 -0
- secondshift/mcp_server/tools/tasks.py +103 -0
- secondshift/mcp_server/tools_registry.py +81 -0
- secondshift/repositories/__init__.py +0 -0
- secondshift/repositories/access.py +13 -0
- secondshift/repositories/admin.py +19 -0
- secondshift/repositories/api.py +296 -0
- secondshift/repositories/apps.py +6 -0
- secondshift/repositories/dao.py +114 -0
- secondshift/repositories/external_skills.py +224 -0
- secondshift/repositories/github.py +27 -0
- secondshift/repositories/github_access.py +57 -0
- secondshift/repositories/github_check.py +279 -0
- secondshift/repositories/github_sync.py +181 -0
- secondshift/repositories/github_views.py +90 -0
- secondshift/repositories/local.py +198 -0
- secondshift/repositories/management/__init__.py +0 -0
- secondshift/repositories/management/commands/__init__.py +0 -0
- secondshift/repositories/management/commands/prune_source_cache.py +15 -0
- secondshift/repositories/migrations/0001_initial.py +45 -0
- secondshift/repositories/migrations/0002_repository_last_sync_error.py +18 -0
- secondshift/repositories/migrations/0003_repository_checked_sha.py +18 -0
- secondshift/repositories/migrations/0004_pull_request_settings.py +23 -0
- secondshift/repositories/migrations/0005_remove_repository_installation_id.py +17 -0
- secondshift/repositories/migrations/0006_allow_issues.py +18 -0
- secondshift/repositories/migrations/__init__.py +0 -0
- secondshift/repositories/mirrors.py +50 -0
- secondshift/repositories/models.py +104 -0
- secondshift/repositories/pull_requests.py +199 -0
- secondshift/repositories/schemas.py +102 -0
- secondshift/repositories/serialization.py +133 -0
- secondshift/repositories/skill_source.py +64 -0
- secondshift/repositories/snapshots.py +238 -0
- secondshift/repositories/sources.py +256 -0
- secondshift/repositories/status.py +67 -0
- secondshift/runner/README.md +65 -0
- secondshift/runner/__init__.py +1 -0
- secondshift/runner/agent_credentials.py +89 -0
- secondshift/runner/agent_image/Dockerfile +16 -0
- secondshift/runner/agent_image/agent.py +132 -0
- secondshift/runner/agent_image/claude.py +254 -0
- secondshift/runner/agent_image/codex.py +84 -0
- secondshift/runner/agent_image/outcomes.py +120 -0
- secondshift/runner/agent_image/subagent_hook.py +71 -0
- secondshift/runner/agent_protocol.py +41 -0
- secondshift/runner/agents.py +254 -0
- secondshift/runner/api.py +123 -0
- secondshift/runner/apps.py +6 -0
- secondshift/runner/client.py +168 -0
- secondshift/runner/collection.py +276 -0
- secondshift/runner/dao.py +64 -0
- secondshift/runner/dev.py +19 -0
- secondshift/runner/images.py +57 -0
- secondshift/runner/migrations/0001_initial.py +48 -0
- secondshift/runner/migrations/0002_session_source_sha256.py +18 -0
- secondshift/runner/migrations/0003_session_collect_changes.py +18 -0
- secondshift/runner/migrations/0004_session_history_sha256.py +18 -0
- secondshift/runner/migrations/0005_require_source_sha256.py +18 -0
- secondshift/runner/migrations/0006_session_skill_sha256.py +18 -0
- secondshift/runner/migrations/__init__.py +0 -0
- secondshift/runner/models.py +53 -0
- secondshift/runner/protocol.py +97 -0
- secondshift/runner/schemas.py +36 -0
- secondshift/runner/services.py +465 -0
- secondshift/runner/sources.py +130 -0
- secondshift/runner/urls.py +7 -0
- secondshift/runs/__init__.py +0 -0
- secondshift/runs/access.py +13 -0
- secondshift/runs/admin.py +89 -0
- secondshift/runs/api.py +211 -0
- secondshift/runs/apps.py +11 -0
- secondshift/runs/artifacts.py +261 -0
- secondshift/runs/dao.py +640 -0
- secondshift/runs/deliveries.py +80 -0
- secondshift/runs/execution/__init__.py +0 -0
- secondshift/runs/execution/access_needs.py +89 -0
- secondshift/runs/execution/agent.py +91 -0
- secondshift/runs/execution/agent_broker.py +198 -0
- secondshift/runs/execution/budgets.py +177 -0
- secondshift/runs/execution/cli_agent.py +167 -0
- secondshift/runs/execution/consent.py +58 -0
- secondshift/runs/execution/deadlines.py +47 -0
- secondshift/runs/execution/external_tools.py +84 -0
- secondshift/runs/execution/issues.py +99 -0
- secondshift/runs/execution/model_gateway.py +90 -0
- secondshift/runs/execution/payload.py +63 -0
- secondshift/runs/execution/result.py +59 -0
- secondshift/runs/execution/run.py +345 -0
- secondshift/runs/execution/subagents.py +246 -0
- secondshift/runs/execution/tool_broker.py +123 -0
- secondshift/runs/execution/workspace_tools.py +248 -0
- secondshift/runs/history.py +182 -0
- secondshift/runs/inputs.py +49 -0
- secondshift/runs/issue_delivery.py +144 -0
- secondshift/runs/management/__init__.py +0 -0
- secondshift/runs/management/commands/__init__.py +0 -0
- secondshift/runs/management/commands/prune_runs.py +12 -0
- secondshift/runs/management/commands/run_dbos_worker.py +129 -0
- secondshift/runs/migration_helpers.py +313 -0
- secondshift/runs/migrations/0001_initial.py +69 -0
- secondshift/runs/migrations/0002_run_started_by.py +18 -0
- secondshift/runs/migrations/0003_agentlease.py +25 -0
- secondshift/runs/migrations/0004_task_assignment.py +39 -0
- secondshift/runs/migrations/0005_backfill_tasks.py +19 -0
- secondshift/runs/migrations/0006_standalone_tasks.py +50 -0
- secondshift/runs/migrations/0007_run_delivery.py +35 -0
- secondshift/runs/migrations/0008_run_outputs.py +18 -0
- secondshift/runs/migrations/0009_pull_request_delivery.py +28 -0
- secondshift/runs/migrations/0010_run_issue.py +32 -0
- secondshift/runs/migrations/0011_issues_delivery.py +23 -0
- secondshift/runs/migrations/0012_alter_rundelivery_status.py +18 -0
- secondshift/runs/migrations/0013_release_0_1_0b11.py +113 -0
- secondshift/runs/migrations/0014_run_access_need.py +30 -0
- secondshift/runs/migrations/__init__.py +0 -0
- secondshift/runs/models.py +340 -0
- secondshift/runs/orchestration/__init__.py +0 -0
- secondshift/runs/orchestration/admission.py +15 -0
- secondshift/runs/orchestration/dbos_runtime.py +43 -0
- secondshift/runs/orchestration/dispatch.py +130 -0
- secondshift/runs/orchestration/periodic.py +39 -0
- secondshift/runs/orchestration/reconciliation.py +98 -0
- secondshift/runs/orchestration/schedules.py +51 -0
- secondshift/runs/orchestration/workflows.py +480 -0
- secondshift/runs/outputs.py +52 -0
- secondshift/runs/publishing.py +162 -0
- secondshift/runs/queue.py +26 -0
- secondshift/runs/reporting/__init__.py +0 -0
- secondshift/runs/reporting/collection.py +26 -0
- secondshift/runs/reporting/connection_usage.py +45 -0
- secondshift/runs/reporting/failures.py +150 -0
- secondshift/runs/reporting/health.py +158 -0
- secondshift/runs/reporting/notifications.py +258 -0
- secondshift/runs/reporting/queries.py +60 -0
- secondshift/runs/retention.py +35 -0
- secondshift/runs/schemas.py +192 -0
- secondshift/runs/serialization.py +245 -0
- secondshift/runs/services.py +176 -0
- secondshift/runs/signals.py +25 -0
- secondshift/runs/submissions.py +236 -0
- secondshift/runs/transitions.py +84 -0
- secondshift/templates/account/password_reset_help_text.html +1 -0
- secondshift/templates/admin/base_site.html +24 -0
- secondshift/templates/allauth/elements/provider.html +9 -0
- secondshift/templates/allauth/layouts/base.html +67 -0
- secondshift/templates/socialaccount/snippets/login.html +9 -0
- secondshift/templates/workspaces/invite_unusable.html +10 -0
- secondshift/triggers/__init__.py +0 -0
- secondshift/triggers/admin.py +19 -0
- secondshift/triggers/api.py +116 -0
- secondshift/triggers/apps.py +6 -0
- secondshift/triggers/dao.py +66 -0
- secondshift/triggers/filters.py +48 -0
- secondshift/triggers/migrations/0001_initial.py +55 -0
- secondshift/triggers/migrations/0002_ignored_events_and_hourly_limit.py +23 -0
- secondshift/triggers/migrations/__init__.py +0 -0
- secondshift/triggers/models.py +83 -0
- secondshift/triggers/schemas.py +58 -0
- secondshift/triggers/serialization.py +91 -0
- secondshift/triggers/services.py +246 -0
- secondshift/triggers/verification.py +84 -0
- secondshift/web/assets/index-Bwy0hvk_.js +2419 -0
- secondshift/web/assets/index-D27gB9_L.css +1 -0
- secondshift/web/favicon.svg +19 -0
- secondshift/web/index.html +16 -0
- secondshift/web/secondshift-mark.svg +16 -0
- secondshift/workspaces/__init__.py +0 -0
- secondshift/workspaces/access.py +181 -0
- secondshift/workspaces/admin.py +37 -0
- secondshift/workspaces/api.py +264 -0
- secondshift/workspaces/apps.py +6 -0
- secondshift/workspaces/context_processors.py +11 -0
- secondshift/workspaces/dao.py +281 -0
- secondshift/workspaces/invites.py +150 -0
- secondshift/workspaces/limits.py +197 -0
- secondshift/workspaces/management/__init__.py +0 -0
- secondshift/workspaces/management/commands/__init__.py +0 -0
- secondshift/workspaces/management/commands/create_workspace.py +39 -0
- secondshift/workspaces/migrations/0001_initial.py +67 -0
- secondshift/workspaces/migrations/0002_api_token.py +34 -0
- secondshift/workspaces/migrations/0003_execution_default.py +29 -0
- secondshift/workspaces/migrations/0004_copy_default_model_key.py +26 -0
- secondshift/workspaces/migrations/__init__.py +0 -0
- secondshift/workspaces/models.py +169 -0
- secondshift/workspaces/owners.py +58 -0
- secondshift/workspaces/schemas.py +149 -0
- secondshift/workspaces/serialization.py +138 -0
- secondshift/workspaces/server_settings.py +140 -0
- secondshift/workspaces/services.py +94 -0
- secondshift/workspaces/tokens.py +108 -0
- secondshift/workspaces/views.py +32 -0
- secondshift-0.1.0b12.dist-info/METADATA +270 -0
- secondshift-0.1.0b12.dist-info/RECORD +371 -0
- secondshift-0.1.0b12.dist-info/WHEEL +4 -0
- secondshift-0.1.0b12.dist-info/entry_points.txt +2 -0
- 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)
|