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,238 @@
|
|
|
1
|
+
"""Build the files of one commit into a snapshot a run can unpack.
|
|
2
|
+
|
|
3
|
+
A snapshot is a gzip tar of the commit's tree, built from Git objects directly.
|
|
4
|
+
It does not use `git archive`, because that follows the repository's own
|
|
5
|
+
.gitattributes, and `export-ignore` or `export-subst` could make the files a
|
|
6
|
+
run gets differ from the commit. The same commit always gives the same bytes,
|
|
7
|
+
so a retried run start sends exactly what the first attempt sent.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import gzip
|
|
11
|
+
import io
|
|
12
|
+
import re
|
|
13
|
+
import subprocess
|
|
14
|
+
import tarfile
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import IO
|
|
18
|
+
|
|
19
|
+
from secondshift.config.constants import (
|
|
20
|
+
MAX_SKILL_FILES,
|
|
21
|
+
MAX_SKILL_SNAPSHOT_BYTES,
|
|
22
|
+
MAX_SKILL_UNPACKED_BYTES,
|
|
23
|
+
MAX_SOURCE_FILES,
|
|
24
|
+
MAX_SOURCE_SNAPSHOT_BYTES,
|
|
25
|
+
MAX_SOURCE_UNPACKED_BYTES,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
REGULAR_FILE_MODES = {"100644": 0o644, "100755": 0o755}
|
|
29
|
+
SYMLINK_MODE = "120000"
|
|
30
|
+
SUBMODULE_MODE = "160000"
|
|
31
|
+
COMMIT_SHA = re.compile(r"[0-9a-f]{40}|[0-9a-f]{64}")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class SnapshotError(RuntimeError):
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class SnapshotLimits:
|
|
40
|
+
files: int
|
|
41
|
+
unpacked_bytes: int
|
|
42
|
+
snapshot_bytes: int
|
|
43
|
+
# What the snapshot is of, in error messages.
|
|
44
|
+
label: str
|
|
45
|
+
# A repository keeps its links and submodules. A skill from another
|
|
46
|
+
# repository may hold only regular files.
|
|
47
|
+
regular_files_only: bool = False
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
REPOSITORY_LIMITS = SnapshotLimits(
|
|
51
|
+
MAX_SOURCE_FILES,
|
|
52
|
+
MAX_SOURCE_UNPACKED_BYTES,
|
|
53
|
+
MAX_SOURCE_SNAPSHOT_BYTES,
|
|
54
|
+
"The commit",
|
|
55
|
+
)
|
|
56
|
+
SKILL_LIMITS = SnapshotLimits(
|
|
57
|
+
MAX_SKILL_FILES,
|
|
58
|
+
MAX_SKILL_UNPACKED_BYTES,
|
|
59
|
+
MAX_SKILL_SNAPSHOT_BYTES,
|
|
60
|
+
"The skill",
|
|
61
|
+
regular_files_only=True,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True)
|
|
66
|
+
class TreeEntry:
|
|
67
|
+
mode: str
|
|
68
|
+
object_id: str
|
|
69
|
+
size: int
|
|
70
|
+
path: str
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def tree_entries(git_dir: Path, commit_sha: str, folder: str = "") -> list[TreeEntry]:
|
|
74
|
+
"""Every file, link and submodule in the commit, in Git's order.
|
|
75
|
+
|
|
76
|
+
With a folder, only what is inside it, with paths relative to it.
|
|
77
|
+
"""
|
|
78
|
+
if not COMMIT_SHA.fullmatch(commit_sha):
|
|
79
|
+
raise SnapshotError("A full commit SHA is required.")
|
|
80
|
+
tree = f"{commit_sha}:{folder}" if folder else f"{commit_sha}^{{tree}}"
|
|
81
|
+
output = run_git_bytes(git_dir, "ls-tree", "-r", "-z", "-l", "--full-tree", tree)
|
|
82
|
+
entries = []
|
|
83
|
+
for record in output.split(b"\0"):
|
|
84
|
+
if not record:
|
|
85
|
+
continue
|
|
86
|
+
header, path = record.split(b"\t", 1)
|
|
87
|
+
mode, _, object_id, size = header.decode().split()
|
|
88
|
+
entries.append(
|
|
89
|
+
TreeEntry(
|
|
90
|
+
mode=mode,
|
|
91
|
+
object_id=object_id,
|
|
92
|
+
size=0 if size == "-" else int(size),
|
|
93
|
+
path=path.decode("utf-8", errors="surrogateescape"),
|
|
94
|
+
)
|
|
95
|
+
)
|
|
96
|
+
return entries
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def write_snapshot(
|
|
100
|
+
git_dir: Path,
|
|
101
|
+
commit_sha: str,
|
|
102
|
+
output: IO[bytes],
|
|
103
|
+
folder: str = "",
|
|
104
|
+
limits: SnapshotLimits = REPOSITORY_LIMITS,
|
|
105
|
+
) -> None:
|
|
106
|
+
"""Write the commit's files, or one folder's, to `output` as a
|
|
107
|
+
reproducible .tar.gz."""
|
|
108
|
+
entries = tree_entries(git_dir, commit_sha, folder)
|
|
109
|
+
if len(entries) > limits.files:
|
|
110
|
+
raise SnapshotError(f"{limits.label} has more than {limits.files:,} files.")
|
|
111
|
+
if sum(entry.size for entry in entries) > limits.unpacked_bytes:
|
|
112
|
+
raise SnapshotError(f"{limits.label}'s files are too large to run.")
|
|
113
|
+
if limits.regular_files_only and (
|
|
114
|
+
links := [
|
|
115
|
+
entry.path for entry in entries if entry.mode not in REGULAR_FILE_MODES
|
|
116
|
+
]
|
|
117
|
+
):
|
|
118
|
+
raise SnapshotError(
|
|
119
|
+
f"{limits.label} may hold only regular files. Not allowed: "
|
|
120
|
+
+ ", ".join(links[:5])
|
|
121
|
+
)
|
|
122
|
+
limited = LimitedWriter(output, limits.snapshot_bytes, limits.label)
|
|
123
|
+
with (
|
|
124
|
+
gzip.GzipFile(
|
|
125
|
+
fileobj=limited, mode="wb", mtime=0, filename="", compresslevel=6
|
|
126
|
+
) as compressed,
|
|
127
|
+
tarfile.open(
|
|
128
|
+
fileobj=compressed, mode="w", format=tarfile.PAX_FORMAT
|
|
129
|
+
) as archive,
|
|
130
|
+
BlobReader(git_dir) as blobs,
|
|
131
|
+
):
|
|
132
|
+
for entry in entries:
|
|
133
|
+
info = tarfile.TarInfo(entry.path)
|
|
134
|
+
info.mtime = 0
|
|
135
|
+
if entry.mode in REGULAR_FILE_MODES:
|
|
136
|
+
info.mode = REGULAR_FILE_MODES[entry.mode]
|
|
137
|
+
info.size = entry.size
|
|
138
|
+
archive.addfile(info, blobs.stream(entry.object_id, entry.size))
|
|
139
|
+
blobs.finish()
|
|
140
|
+
elif entry.mode == SYMLINK_MODE:
|
|
141
|
+
info.type = tarfile.SYMTYPE
|
|
142
|
+
info.mode = 0o777
|
|
143
|
+
info.linkname = blobs.read(entry.object_id, entry.size).decode(
|
|
144
|
+
"utf-8", errors="surrogateescape"
|
|
145
|
+
)
|
|
146
|
+
archive.addfile(info)
|
|
147
|
+
elif entry.mode == SUBMODULE_MODE:
|
|
148
|
+
# Submodule contents are not fetched. Like a checkout without
|
|
149
|
+
# them, the folder exists but is empty.
|
|
150
|
+
info.type = tarfile.DIRTYPE
|
|
151
|
+
info.mode = 0o755
|
|
152
|
+
archive.addfile(info)
|
|
153
|
+
else:
|
|
154
|
+
raise SnapshotError(f"Unsupported Git file mode {entry.mode}.")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class LimitedWriter(io.RawIOBase):
|
|
158
|
+
def __init__(self, output: IO[bytes], limit: int, label: str) -> None:
|
|
159
|
+
self.output = output
|
|
160
|
+
self.limit = limit
|
|
161
|
+
self.remaining = limit
|
|
162
|
+
self.label = label
|
|
163
|
+
|
|
164
|
+
def writable(self) -> bool:
|
|
165
|
+
return True
|
|
166
|
+
|
|
167
|
+
def write(self, data: bytes) -> int: # type: ignore[override]
|
|
168
|
+
self.remaining -= len(data)
|
|
169
|
+
if self.remaining < 0:
|
|
170
|
+
raise SnapshotError(
|
|
171
|
+
f"{self.label}'s files are larger than "
|
|
172
|
+
f"{self.limit // (1024 * 1024)} MiB compressed."
|
|
173
|
+
)
|
|
174
|
+
return self.output.write(data)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class BlobReader:
|
|
178
|
+
"""Reads blobs through one `git cat-file --batch` process."""
|
|
179
|
+
|
|
180
|
+
def __init__(self, git_dir: Path) -> None:
|
|
181
|
+
self.git_dir = git_dir
|
|
182
|
+
self.process: subprocess.Popen[bytes] | None = None
|
|
183
|
+
|
|
184
|
+
def __enter__(self) -> "BlobReader":
|
|
185
|
+
self.process = subprocess.Popen(
|
|
186
|
+
["git", "-C", str(self.git_dir), "cat-file", "--batch"],
|
|
187
|
+
stdin=subprocess.PIPE,
|
|
188
|
+
stdout=subprocess.PIPE,
|
|
189
|
+
stderr=subprocess.DEVNULL,
|
|
190
|
+
)
|
|
191
|
+
return self
|
|
192
|
+
|
|
193
|
+
def __exit__(self, *exc_info: object) -> None:
|
|
194
|
+
if self.process is None:
|
|
195
|
+
return
|
|
196
|
+
if self.process.stdin:
|
|
197
|
+
self.process.stdin.close()
|
|
198
|
+
self.process.kill()
|
|
199
|
+
self.process.wait(timeout=10)
|
|
200
|
+
if self.process.stdout:
|
|
201
|
+
self.process.stdout.close()
|
|
202
|
+
|
|
203
|
+
def stream(self, object_id: str, size: int) -> IO[bytes]:
|
|
204
|
+
"""The blob's content. Read exactly `size` bytes, then call finish()."""
|
|
205
|
+
process = self.process
|
|
206
|
+
if process is None or process.stdin is None or process.stdout is None:
|
|
207
|
+
raise SnapshotError("The blob reader is not open.")
|
|
208
|
+
process.stdin.write(f"{object_id}\n".encode())
|
|
209
|
+
process.stdin.flush()
|
|
210
|
+
header = process.stdout.readline().decode().split()
|
|
211
|
+
if len(header) != 3 or header[1] != "blob" or int(header[2]) != size:
|
|
212
|
+
raise SnapshotError(f"Git could not read object {object_id}.")
|
|
213
|
+
return process.stdout
|
|
214
|
+
|
|
215
|
+
def finish(self) -> None:
|
|
216
|
+
# cat-file ends each object with a newline.
|
|
217
|
+
if self.process and self.process.stdout:
|
|
218
|
+
self.process.stdout.read(1)
|
|
219
|
+
|
|
220
|
+
def read(self, object_id: str, size: int) -> bytes:
|
|
221
|
+
content = self.stream(object_id, size).read(size)
|
|
222
|
+
self.finish()
|
|
223
|
+
return content
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def run_git_bytes(git_dir: Path, *arguments: str) -> bytes:
|
|
227
|
+
try:
|
|
228
|
+
completed = subprocess.run(
|
|
229
|
+
["git", "-C", str(git_dir), *arguments],
|
|
230
|
+
capture_output=True,
|
|
231
|
+
check=False,
|
|
232
|
+
timeout=120,
|
|
233
|
+
)
|
|
234
|
+
except subprocess.TimeoutExpired as error:
|
|
235
|
+
raise SnapshotError("Git timed out while reading the commit.") from error
|
|
236
|
+
if completed.returncode:
|
|
237
|
+
raise SnapshotError("Git could not read the commit.")
|
|
238
|
+
return completed.stdout
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""The snapshot of a commit's files that a run gets.
|
|
2
|
+
|
|
3
|
+
Snapshots are built on the control plane, cached by repository and commit, and
|
|
4
|
+
uploaded to the runner when a run starts. The cache can be deleted at any time.
|
|
5
|
+
A missing snapshot is built again from the mirror, or the local folder. The
|
|
6
|
+
cache is kept under SOURCE_CACHE_MAX_BYTES, least recently used first.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import shutil
|
|
13
|
+
import tempfile
|
|
14
|
+
import time
|
|
15
|
+
from collections.abc import Collection
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from django.conf import settings
|
|
20
|
+
|
|
21
|
+
from secondshift.config.constants import COMMIT_SHA_PATTERN
|
|
22
|
+
from secondshift.repositories.dao import active_repository_ids
|
|
23
|
+
from secondshift.repositories.github_access import github_access
|
|
24
|
+
from secondshift.repositories.github_sync import fetch_github_commit
|
|
25
|
+
from secondshift.repositories.mirrors import has_commit, locked_mirror
|
|
26
|
+
from secondshift.repositories.models import Repository
|
|
27
|
+
from secondshift.repositories.snapshots import (
|
|
28
|
+
REPOSITORY_LIMITS,
|
|
29
|
+
SnapshotError,
|
|
30
|
+
SnapshotLimits,
|
|
31
|
+
write_snapshot,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
COMMIT_SHA = re.compile(COMMIT_SHA_PATTERN)
|
|
35
|
+
# A skill from another repository that no run used for this long is removed.
|
|
36
|
+
SKILL_UNUSED_SECONDS = 30 * 24 * 60 * 60
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class SourceSnapshot:
|
|
41
|
+
path: Path
|
|
42
|
+
sha256: str
|
|
43
|
+
size_bytes: int
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def snapshots_root() -> Path:
|
|
47
|
+
return settings.SOURCE_CACHE_ROOT / "snapshots"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def skill_snapshots_root() -> Path:
|
|
51
|
+
"""Folders of skills from other repositories. See external_skills."""
|
|
52
|
+
return settings.SOURCE_CACHE_ROOT / "skill-snapshots"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def skill_mirrors_root() -> Path:
|
|
56
|
+
return settings.SOURCE_CACHE_ROOT / "skill-mirrors"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def snapshot_path(repository_id: int, commit_sha: str) -> Path:
|
|
60
|
+
return snapshots_root() / str(repository_id) / f"{commit_sha}.tar.gz"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def source_snapshot(repository: Repository, commit_sha: str) -> SourceSnapshot:
|
|
64
|
+
"""The commit's snapshot, built and cached if needed."""
|
|
65
|
+
if not COMMIT_SHA.fullmatch(commit_sha):
|
|
66
|
+
raise SnapshotError("A full commit SHA is required.")
|
|
67
|
+
path = snapshot_path(repository.pk, commit_sha)
|
|
68
|
+
if path.exists():
|
|
69
|
+
# Mark it recently used, so cache cleanup removes older ones first.
|
|
70
|
+
path.touch()
|
|
71
|
+
else:
|
|
72
|
+
build_snapshot(repository, commit_sha, path)
|
|
73
|
+
trim_snapshots(keep=path)
|
|
74
|
+
return SourceSnapshot(
|
|
75
|
+
path=path, sha256=file_sha256(path), size_bytes=path.stat().st_size
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def build_snapshot(repository: Repository, commit_sha: str, path: Path) -> None:
|
|
80
|
+
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
81
|
+
if repository.source_type == Repository.SourceType.LOCAL:
|
|
82
|
+
if not settings.LOCAL_REPOSITORIES_ENABLED:
|
|
83
|
+
raise SnapshotError(
|
|
84
|
+
"Local repositories are available only on a single host."
|
|
85
|
+
)
|
|
86
|
+
write_atomically(Path(repository.local_path), commit_sha, path)
|
|
87
|
+
return
|
|
88
|
+
with locked_mirror(repository.pk) as mirror:
|
|
89
|
+
if not has_commit(mirror, commit_sha):
|
|
90
|
+
fetch_github_commit(github_access(repository), commit_sha, mirror)
|
|
91
|
+
write_atomically(mirror, commit_sha, path)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def write_atomically(
|
|
95
|
+
git_dir: Path,
|
|
96
|
+
commit_sha: str,
|
|
97
|
+
path: Path,
|
|
98
|
+
folder: str = "",
|
|
99
|
+
limits: SnapshotLimits = REPOSITORY_LIMITS,
|
|
100
|
+
) -> None:
|
|
101
|
+
# Two runs of one commit may build at once. Both write the same bytes, and
|
|
102
|
+
# each replaces the file whole, so neither ever reads a partial snapshot.
|
|
103
|
+
descriptor, name = tempfile.mkstemp(dir=path.parent, prefix="building-")
|
|
104
|
+
temporary = Path(name)
|
|
105
|
+
try:
|
|
106
|
+
with os.fdopen(descriptor, "wb") as output:
|
|
107
|
+
write_snapshot(git_dir, commit_sha, output, folder, limits)
|
|
108
|
+
os.replace(temporary, path)
|
|
109
|
+
finally:
|
|
110
|
+
temporary.unlink(missing_ok=True)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def file_sha256(path: Path) -> str:
|
|
114
|
+
digest = hashlib.sha256()
|
|
115
|
+
with path.open("rb") as stream:
|
|
116
|
+
while chunk := stream.read(1024 * 1024):
|
|
117
|
+
digest.update(chunk)
|
|
118
|
+
return digest.hexdigest()
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@dataclass
|
|
122
|
+
class CachePrune:
|
|
123
|
+
snapshots: int = 0
|
|
124
|
+
mirrors: int = 0
|
|
125
|
+
bytes: int = 0
|
|
126
|
+
|
|
127
|
+
def add(self, path: Path) -> None:
|
|
128
|
+
self.bytes += tree_size(path)
|
|
129
|
+
if path.name.endswith(".git"):
|
|
130
|
+
self.mirrors += 1
|
|
131
|
+
else:
|
|
132
|
+
self.snapshots += 1
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def prune_source_cache(dry_run: bool = False) -> CachePrune:
|
|
136
|
+
"""Remove cached sources no repository needs, then trim snapshots to size.
|
|
137
|
+
|
|
138
|
+
Only entries named by a repository ID are touched, and only inside the
|
|
139
|
+
cache folder. Removed repositories lose their mirror and snapshots.
|
|
140
|
+
"""
|
|
141
|
+
pruned = CachePrune()
|
|
142
|
+
active = active_repository_ids()
|
|
143
|
+
removed = set()
|
|
144
|
+
for folder in cache_entries(snapshots_root(), suffix=""):
|
|
145
|
+
if int(folder.name) not in active:
|
|
146
|
+
removed.add(int(folder.name))
|
|
147
|
+
for snapshot in folder.glob("*.tar.gz"):
|
|
148
|
+
pruned.add(snapshot)
|
|
149
|
+
if not dry_run:
|
|
150
|
+
shutil.rmtree(folder)
|
|
151
|
+
for mirror in cache_entries(settings.SOURCE_CACHE_ROOT / "mirrors", ".git"):
|
|
152
|
+
repository_id = int(mirror.name.removesuffix(".git"))
|
|
153
|
+
if repository_id not in active:
|
|
154
|
+
pruned.add(mirror)
|
|
155
|
+
if not dry_run:
|
|
156
|
+
with locked_mirror(repository_id) as path:
|
|
157
|
+
shutil.rmtree(path)
|
|
158
|
+
remove_stale_builds(pruned, dry_run)
|
|
159
|
+
trim_snapshots(pruned, dry_run=dry_run, skip=removed)
|
|
160
|
+
prune_unused_skills(pruned, dry_run)
|
|
161
|
+
return pruned
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def prune_unused_skills(pruned: CachePrune, dry_run: bool) -> None:
|
|
165
|
+
"""Remove skills from other repositories that no run used for a while."""
|
|
166
|
+
cutoff = time.time() - SKILL_UNUSED_SECONDS
|
|
167
|
+
for path in [
|
|
168
|
+
*cached_paths(skill_snapshots_root(), "*.tar.gz"),
|
|
169
|
+
*cached_paths(skill_mirrors_root(), "*.git"),
|
|
170
|
+
]:
|
|
171
|
+
if path.stat().st_mtime >= cutoff:
|
|
172
|
+
continue
|
|
173
|
+
pruned.add(path)
|
|
174
|
+
if dry_run:
|
|
175
|
+
continue
|
|
176
|
+
if path.is_dir():
|
|
177
|
+
shutil.rmtree(path, ignore_errors=True)
|
|
178
|
+
else:
|
|
179
|
+
path.unlink(missing_ok=True)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def cached_paths(root: Path, pattern: str) -> list[Path]:
|
|
183
|
+
if not root.is_dir():
|
|
184
|
+
return []
|
|
185
|
+
return [path for path in root.glob(pattern) if not path.is_symlink()]
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def trim_snapshots(
|
|
189
|
+
pruned: CachePrune | None = None,
|
|
190
|
+
dry_run: bool = False,
|
|
191
|
+
keep: Path | None = None,
|
|
192
|
+
skip: Collection[int] = (),
|
|
193
|
+
) -> CachePrune:
|
|
194
|
+
"""Remove the least recently used snapshots past SOURCE_CACHE_MAX_BYTES."""
|
|
195
|
+
pruned = pruned or CachePrune()
|
|
196
|
+
files = sorted(
|
|
197
|
+
(
|
|
198
|
+
path
|
|
199
|
+
for folder in cache_entries(snapshots_root(), suffix="")
|
|
200
|
+
if int(folder.name) not in skip
|
|
201
|
+
for path in folder.glob("*.tar.gz")
|
|
202
|
+
if path.is_file() and not path.is_symlink()
|
|
203
|
+
),
|
|
204
|
+
key=lambda path: path.stat().st_mtime,
|
|
205
|
+
)
|
|
206
|
+
total = sum(path.stat().st_size for path in files)
|
|
207
|
+
for path in files:
|
|
208
|
+
if total <= settings.SOURCE_CACHE_MAX_BYTES:
|
|
209
|
+
break
|
|
210
|
+
if path == keep:
|
|
211
|
+
continue
|
|
212
|
+
size = path.stat().st_size
|
|
213
|
+
total -= size
|
|
214
|
+
pruned.snapshots += 1
|
|
215
|
+
pruned.bytes += size
|
|
216
|
+
if not dry_run:
|
|
217
|
+
path.unlink(missing_ok=True)
|
|
218
|
+
return pruned
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def remove_stale_builds(pruned: CachePrune, dry_run: bool) -> None:
|
|
222
|
+
"""Remove partial snapshots left by a build that crashed."""
|
|
223
|
+
for folder in cache_entries(snapshots_root(), suffix=""):
|
|
224
|
+
for path in folder.glob("building-*"):
|
|
225
|
+
if path.is_file() and not path.is_symlink() and is_stale(path):
|
|
226
|
+
pruned.bytes += path.stat().st_size
|
|
227
|
+
if not dry_run:
|
|
228
|
+
path.unlink(missing_ok=True)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def cache_entries(root: Path, suffix: str) -> list[Path]:
|
|
232
|
+
"""Folders in the cache named `<repository id><suffix>`, never links."""
|
|
233
|
+
if not root.is_dir():
|
|
234
|
+
return []
|
|
235
|
+
return [
|
|
236
|
+
path
|
|
237
|
+
for path in root.iterdir()
|
|
238
|
+
if path.name.removesuffix(suffix).isdigit()
|
|
239
|
+
and path.name.endswith(suffix)
|
|
240
|
+
and path.is_dir()
|
|
241
|
+
and not path.is_symlink()
|
|
242
|
+
]
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def is_stale(path: Path) -> bool:
|
|
246
|
+
return path.stat().st_mtime < time.time() - 60 * 60
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def tree_size(path: Path) -> int:
|
|
250
|
+
if path.is_file():
|
|
251
|
+
return path.stat().st_size
|
|
252
|
+
return sum(
|
|
253
|
+
(Path(root) / name).lstat().st_size
|
|
254
|
+
for root, _, names in os.walk(path)
|
|
255
|
+
for name in names
|
|
256
|
+
)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""What a repository's sync state is, from what its last syncs saved.
|
|
2
|
+
|
|
3
|
+
A sync saves `checked_sha`, the commit it read, and `last_sync_error` when that
|
|
4
|
+
commit's configuration was broken. A good sync also saves `config_sha`, the
|
|
5
|
+
commit jobs run from. New commits on the branch are reported apart from the
|
|
6
|
+
state, because runs keep using the last good sync.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from enum import StrEnum
|
|
11
|
+
|
|
12
|
+
from secondshift.repositories.models import Repository
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ValidationStatus(StrEnum):
|
|
16
|
+
VALID = "valid"
|
|
17
|
+
WARNING = "warning"
|
|
18
|
+
ERROR = "error"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class SyncState(StrEnum):
|
|
22
|
+
NEVER_SYNCED = "never_synced"
|
|
23
|
+
# A configuration problem before any good sync. Setup is not finished.
|
|
24
|
+
SETUP_PROBLEM = "setup_problem"
|
|
25
|
+
# A configuration problem after a good sync. Jobs keep the last good one.
|
|
26
|
+
BROKEN = "broken"
|
|
27
|
+
SYNCED = "synced"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# How each state reads. A state with no message shows the problem itself.
|
|
31
|
+
STATE_STATUS: dict[SyncState, tuple[ValidationStatus, str]] = {
|
|
32
|
+
SyncState.NEVER_SYNCED: (
|
|
33
|
+
ValidationStatus.WARNING,
|
|
34
|
+
"Sync the repository before enabling schedules.",
|
|
35
|
+
),
|
|
36
|
+
SyncState.SETUP_PROBLEM: (ValidationStatus.WARNING, ""),
|
|
37
|
+
SyncState.BROKEN: (ValidationStatus.ERROR, ""),
|
|
38
|
+
SyncState.SYNCED: (ValidationStatus.VALID, "Repository synchronized."),
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class SourceStatus:
|
|
44
|
+
status: ValidationStatus
|
|
45
|
+
message: str
|
|
46
|
+
needs_sync: bool
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def sync_state(repository: Repository, problem: str) -> SyncState:
|
|
50
|
+
"""`problem` is the configuration problem at the checked commit, if any."""
|
|
51
|
+
if problem:
|
|
52
|
+
return SyncState.BROKEN if repository.config_sha else SyncState.SETUP_PROBLEM
|
|
53
|
+
return SyncState.SYNCED if repository.config_sha else SyncState.NEVER_SYNCED
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def source_status(
|
|
57
|
+
repository: Repository, head: str | None, problem: str
|
|
58
|
+
) -> SourceStatus:
|
|
59
|
+
"""The status of a repository whose branch is at `head`, None when unknown."""
|
|
60
|
+
status, message = STATE_STATUS[sync_state(repository, problem)]
|
|
61
|
+
return SourceStatus(
|
|
62
|
+
status=status,
|
|
63
|
+
message=message or problem,
|
|
64
|
+
# Before the first sync there is nothing to be behind.
|
|
65
|
+
needs_sync=bool(head and repository.checked_sha)
|
|
66
|
+
and head != repository.checked_sha,
|
|
67
|
+
)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# Runner service
|
|
2
|
+
|
|
3
|
+
The runner is a small, conventional Django deployment with its own settings,
|
|
4
|
+
installed apps, database, credentials, and network policy. Reusing Django gives
|
|
5
|
+
it models, generated migrations, transactions, validation, and a typed Ninja
|
|
6
|
+
API without giving it access to the control-plane applications.
|
|
7
|
+
|
|
8
|
+
Only the runner deployment receives `/var/run/docker.sock`. The web, worker,
|
|
9
|
+
gateway, and runner use separate settings and database credentials. The runner
|
|
10
|
+
does not receive provider keys, OAuth credentials, encrypted connection data, or
|
|
11
|
+
the control-plane database URL.
|
|
12
|
+
|
|
13
|
+
Its API is deliberately narrow:
|
|
14
|
+
|
|
15
|
+
- `HEAD /v1/sources/{sha256}` asks whether a source snapshot is already here
|
|
16
|
+
- `PUT /v1/sources/{sha256}` uploads a snapshot of one commit's files
|
|
17
|
+
- `POST /v1/sessions` creates a sandbox from one uploaded snapshot
|
|
18
|
+
- `POST /v1/sessions/{id}/commands` executes one idempotent tool call
|
|
19
|
+
- `POST /v1/sessions/{id}/collection` stops the sandbox and returns its file
|
|
20
|
+
changes and saved files as one archive
|
|
21
|
+
- `DELETE /v1/sessions/{id}` destroys the container and workspace
|
|
22
|
+
- `GET /-/health/` is the only unauthenticated endpoint.
|
|
23
|
+
|
|
24
|
+
All other routes require `SECONDSHIFT_RUNNER_SERVICE_TOKEN`. Session and command
|
|
25
|
+
state live in runner-owned Django models and a runner-only database.
|
|
26
|
+
|
|
27
|
+
The runner keeps no repositories. When a run starts, the control plane uploads a
|
|
28
|
+
snapshot of the run's commit, named by its SHA-256. The runner checks the bytes
|
|
29
|
+
against that name, unpacks them into the session workspace with size, file
|
|
30
|
+
count and entry type limits, and deletes snapshots an hour after their last
|
|
31
|
+
use. The runner never accepts a checkout path or clone credential.
|
|
32
|
+
|
|
33
|
+
Production runners must:
|
|
34
|
+
|
|
35
|
+
- verify `runsc` is registered and refuse to start otherwise
|
|
36
|
+
- unpack only the uploaded snapshot named in the session spec
|
|
37
|
+
- accept only server-owned images, mounts and OCI flags
|
|
38
|
+
- give command sandboxes no network and no credentials
|
|
39
|
+
- use a dedicated sandbox per run with bounded resources
|
|
40
|
+
- keep sandbox images free of the control-plane application and Docker client
|
|
41
|
+
- deduplicate tool calls by their stable run and call identifiers
|
|
42
|
+
- send logs and the final result back through an authenticated channel
|
|
43
|
+
- destroy the workspace after the result is acknowledged.
|
|
44
|
+
|
|
45
|
+
Start the service only after applying its own migrations:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
DJANGO_SETTINGS_MODULE=secondshift.config.settings.runner uv run python manage.py migrate
|
|
49
|
+
SECONDSHIFT_SERVICE=runner uv run gunicorn --config python:secondshift.config.gunicorn
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Required configuration is in `secondshift/config/settings/runner.py`. Production should use
|
|
53
|
+
`runsc`. Standard Docker is reserved for trusted development and single-tenant
|
|
54
|
+
self-hosting. Service startup verifies the selected runtime and fails closed.
|
|
55
|
+
The runner uses the same image as the control plane, with its own command,
|
|
56
|
+
root user override, database and network identity.
|
|
57
|
+
Sandboxes use `SECONDSHIFT_RUNNER_IMAGE` (default: the pinned official
|
|
58
|
+
`python:3.13-slim`, see `secondshift/config/constants.py`). The runner pulls it at startup and
|
|
59
|
+
refuses to start if the pull fails.
|
|
60
|
+
|
|
61
|
+
Runner and control-plane apps share one test suite:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
uv run pytest
|
|
65
|
+
```
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Standalone command runner security boundary."""
|