openlanes 3.0.1 → 3.1.0
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.
- checksums.yaml +4 -4
- data/Brewfile +42 -0
- data/agents/bb8-resolver.md +49 -0
- data/agents/c3po-operator.md +4 -0
- data/agents/chewie-verifier.md +85 -0
- data/agents/hansolo-reviewer.md +20 -15
- data/agents/lando-broker.md +81 -19
- data/agents/leah-researcher.md +6 -6
- data/agents/luke-backend.md +41 -20
- data/agents/{palpatine-planner.md → luthen-rael-planner.md} +15 -13
- data/agents/palpatine-risk-assessor.md +112 -0
- data/agents/r2d2-mechanic.md +5 -1
- data/agents/rey-frontend.md +38 -19
- data/agents/yoda-writer.md +7 -7
- data/evals/cases/chewie-verifier/verify-medium/fail/plan/plan.md +7 -0
- data/evals/cases/chewie-verifier/verify-medium/fail/plan/pull-requests.md +10 -0
- data/evals/cases/chewie-verifier/verify-medium/fail/plan/spec.md +5 -0
- data/evals/cases/chewie-verifier/verify-medium/fail/plan/verification/1/final.png +0 -0
- data/evals/cases/chewie-verifier/verify-medium/fail/plan/verification/1/report.md +7 -0
- data/evals/cases/chewie-verifier/verify-medium/fail/repo/bin/greet +1 -0
- data/evals/cases/chewie-verifier/verify-medium/fail/run.json +1 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/plan/plan.md +7 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/plan/pull-requests.md +10 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/plan/spec.md +5 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/plan/verification/1/final.png +0 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/plan/verification/1/report.md +14 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/plan/verification/1/step-01.png +0 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/repo/bin/greet +1 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/run.json +1 -0
- data/evals/cases/chewie-verifier/verify-medium.yml +48 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/implementation-plan.md +5 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/plan.md +3 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/pull-requests.md +12 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/spec.md +5 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/repo/lib/cart.rb +10 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/repo/spec/cart_spec.rb +9 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/run.json +1 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/implementation-plan.md +5 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/plan.md +3 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/pull-requests.md +15 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/spec.md +5 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/repo/lib/cart.rb +10 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/repo/spec/cart_spec.rb +9 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/run.json +1 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum.yml +76 -0
- data/evals/cases/luthen-rael-planner/plan-deep/fail/plan/plan.md +8 -0
- data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan.md +2 -2
- data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep.yml +1 -1
- data/evals/cases/luthen-rael-planner/plan-fast/fail/plan/plan.md +12 -0
- data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan.md +2 -2
- data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast.yml +1 -1
- data/evals/cases/palpatine-risk-assessor/risk-medium/fail/plan/plan.md +20 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/fail/plan/pull-requests.md +5 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/fail/plan/spec.md +5 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/fail/repo/db/migrate/20261020120000_drop_legacy_email.rb +5 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/fail/run.json +1 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/pass/plan/plan.md +25 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/pass/plan/pull-requests.md +5 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/pass/plan/spec.md +5 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/pass/repo/db/migrate/20261020120000_drop_legacy_email.rb +5 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/pass/run.json +1 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium.yml +47 -0
- data/evals/cases/yoda-writer/spec-medium/pass/plan/spec.md +1 -1
- data/lib/openlanes/adapters/base.rb +18 -4
- data/lib/openlanes/adapters/capabilities.rb +29 -0
- data/lib/openlanes/adapters/claude.rb +14 -11
- data/lib/openlanes/adapters/codex.rb +9 -0
- data/lib/openlanes/adapters/pi.rb +10 -0
- data/lib/openlanes/adapters/stream.rb +145 -1
- data/lib/openlanes/adapters.rb +7 -0
- data/lib/openlanes/agents/agent.rb +4 -3
- data/lib/openlanes/agents/roster.rb +14 -2
- data/lib/openlanes/agents/routing.rb +43 -3
- data/lib/openlanes/apps/definition.rb +152 -0
- data/lib/openlanes/apps/framing.rb +114 -0
- data/lib/openlanes/apps/listing.rb +60 -0
- data/lib/openlanes/apps/ports.rb +168 -0
- data/lib/openlanes/apps/probe.rb +63 -0
- data/lib/openlanes/apps/process_spec.rb +39 -0
- data/lib/openlanes/apps/runner.rb +174 -0
- data/lib/openlanes/apps/status.rb +62 -0
- data/lib/openlanes/apps/supervisor.rb +277 -0
- data/lib/openlanes/apps/watcher.rb +50 -0
- data/lib/openlanes/apps.rb +41 -0
- data/lib/openlanes/cli/api/api.rb +10 -0
- data/lib/openlanes/cli/api/subcommands/docs.rb +63 -0
- data/lib/openlanes/cli/app/app.rb +31 -0
- data/lib/openlanes/cli/app/subcommands/start.rb +114 -0
- data/lib/openlanes/cli/app/subcommands/status.rb +38 -0
- data/lib/openlanes/cli/app/subcommands/stop.rb +44 -0
- data/lib/openlanes/cli/base.rb +89 -16
- data/lib/openlanes/cli/coverage/coverage.rb +68 -0
- data/lib/openlanes/cli/create/create.rb +11 -265
- data/lib/openlanes/cli/eval/eval.rb +4 -1
- data/lib/openlanes/cli/init/init.rb +39 -2
- data/lib/openlanes/cli/linear/subcommands/import.rb +1 -1
- data/lib/openlanes/cli/linear/subcommands/projects.rb +1 -1
- data/lib/openlanes/cli/list_plans/list_plans.rb +8 -2
- data/lib/openlanes/cli/mail/subcommands/ack.rb +52 -0
- data/lib/openlanes/cli/mail/subcommands/render.rb +31 -0
- data/lib/openlanes/cli/mail/subcommands/send.rb +26 -0
- data/lib/openlanes/cli/resync/subcommands/dirs.rb +53 -12
- data/lib/openlanes/cli/resync/subcommands/prs.rb +8 -69
- data/lib/openlanes/cli/review/review.rb +72 -0
- data/lib/openlanes/cli/run/run.rb +22 -486
- data/lib/openlanes/cli/setup/setup.rb +51 -0
- data/lib/openlanes/cli/start/start.rb +7 -51
- data/lib/openlanes/cli/state/state.rb +1 -1
- data/lib/openlanes/cli/state/subcommands/resume.rb +61 -0
- data/lib/openlanes/cli/state/subcommands/sign.rb +1 -1
- data/lib/openlanes/cli/unblock/unblock.rb +11 -205
- data/lib/openlanes/cli/web/web.rb +84 -8
- data/lib/openlanes/cli/worktree/worktree.rb +1 -1
- data/lib/openlanes/cli/worktree/worktree_prune.rb +77 -0
- data/lib/openlanes/cli.rb +37 -2
- data/lib/openlanes/coverage/cobertura.rb +49 -0
- data/lib/openlanes/coverage/config.rb +194 -0
- data/lib/openlanes/coverage/istanbul.rb +31 -0
- data/lib/openlanes/coverage/lcov.rb +38 -0
- data/lib/openlanes/coverage/report.rb +71 -0
- data/lib/openlanes/coverage/simplecov.rb +70 -0
- data/lib/openlanes/coverage.rb +33 -0
- data/lib/openlanes/engine/agent_locks.rb +231 -0
- data/lib/openlanes/engine/arbitration.rb +25 -0
- data/lib/openlanes/engine/board.rb +36 -5
- data/lib/openlanes/engine/conversions.rb +160 -0
- data/lib/openlanes/engine/coverage_gate.rb +217 -0
- data/lib/openlanes/engine/dispatcher.rb +1175 -117
- data/lib/openlanes/engine/event_journal/replay.rb +96 -0
- data/lib/openlanes/engine/event_journal.rb +148 -0
- data/lib/openlanes/engine/extensions.rb +271 -0
- data/lib/openlanes/engine/merging.rb +459 -0
- data/lib/openlanes/engine/narrator.rb +6 -6
- data/lib/openlanes/engine/plan_locks.rb +114 -0
- data/lib/openlanes/engine/pr_outcomes.rb +161 -0
- data/lib/openlanes/engine/pr_watch.rb +83 -0
- data/lib/openlanes/engine/progress_log.rb +12 -120
- data/lib/openlanes/engine/quality_question.rb +125 -0
- data/lib/openlanes/engine/rebase.rb +200 -0
- data/lib/openlanes/engine/run_workflow.rb +776 -0
- data/lib/openlanes/engine/runner.rb +186 -15
- data/lib/openlanes/engine/start_workflow.rb +77 -0
- data/lib/openlanes/engine/state_file.rb +101 -34
- data/lib/openlanes/engine/tally.rb +1 -1
- data/lib/openlanes/engine/verification/app_session.rb +104 -0
- data/lib/openlanes/engine/verification/driver.rb +314 -0
- data/lib/openlanes/engine/verification/outcome.rb +516 -0
- data/lib/openlanes/engine/verification/report.rb +95 -0
- data/lib/openlanes/engine/verification/round.rb +100 -0
- data/lib/openlanes/engine/verification/screenshots.rb +120 -0
- data/lib/openlanes/engine/verification.rb +57 -0
- data/lib/openlanes/engine.rb +30 -1
- data/lib/openlanes/execution/boundary.rb +212 -0
- data/lib/openlanes/execution/child.rb +4 -2
- data/lib/openlanes/execution/clock.rb +36 -6
- data/lib/openlanes/execution/control.rb +32 -0
- data/lib/openlanes/execution/executor.rb +114 -35
- data/lib/openlanes/execution/process_start.rb +63 -0
- data/lib/openlanes/execution/prompt.rb +223 -34
- data/lib/openlanes/execution/transcript.rb +159 -8
- data/lib/openlanes/execution.rb +21 -0
- data/lib/openlanes/lifecycle/adoption.rb +25 -8
- data/lib/openlanes/lifecycle/aging.rb +166 -0
- data/lib/openlanes/lifecycle/allocator.rb +216 -0
- data/lib/openlanes/lifecycle/brief.rb +32 -2
- data/lib/openlanes/lifecycle/completion.rb +12 -6
- data/lib/openlanes/lifecycle/create_workflow.rb +272 -0
- data/lib/openlanes/lifecycle/creator.rb +17 -13
- data/lib/openlanes/lifecycle/lanes.rb +1 -1
- data/lib/openlanes/lifecycle/resync.rb +170 -42
- data/lib/openlanes/lifecycle/unblock_workflow.rb +234 -0
- data/lib/openlanes/lifecycle/unblocker.rb +76 -2
- data/lib/openlanes/lifecycle.rb +37 -1
- data/lib/openlanes/linear/mapping.rb +4 -1
- data/lib/openlanes/linear/unit.rb +1 -1
- data/lib/openlanes/live/chat.rb +60 -11
- data/lib/openlanes/live/choice_converter.rb +166 -0
- data/lib/openlanes/live/choice_judge.rb +109 -0
- data/lib/openlanes/live/claims.rb +9 -1
- data/lib/openlanes/live/command.rb +13 -10
- data/lib/openlanes/live/commands.rb +217 -30
- data/lib/openlanes/live/copilot/slash.rb +82 -0
- data/lib/openlanes/live/copilot.rb +38 -4
- data/lib/openlanes/live/event_bus/base.rb +52 -0
- data/lib/openlanes/live/event_bus/memory_bus.rb +14 -0
- data/lib/openlanes/live/event_bus/redis_bus.rb +10 -0
- data/lib/openlanes/live/event_bus/transaction.rb +27 -0
- data/lib/openlanes/live/event_bus.rb +29 -0
- data/lib/openlanes/live/interviewer.rb +146 -0
- data/lib/openlanes/live/keys.rb +56 -0
- data/lib/openlanes/live/mail.rb +143 -0
- data/lib/openlanes/live/memory_store.rb +12 -1
- data/lib/openlanes/live/news.rb +61 -0
- data/lib/openlanes/live/node.rb +20 -0
- data/lib/openlanes/live/outage.rb +225 -0
- data/lib/openlanes/live/outage_report.rb +89 -0
- data/lib/openlanes/live/publisher.rb +42 -2
- data/lib/openlanes/live/run_record.rb +39 -1
- data/lib/openlanes/live/run_state.rb +19 -2
- data/lib/openlanes/live/session.rb +31 -3
- data/lib/openlanes/live/snapshot.rb +103 -42
- data/lib/openlanes/live/stations.rb +4 -4
- data/lib/openlanes/live/store.rb +18 -1
- data/lib/openlanes/live/subscriber.rb +22 -2
- data/lib/openlanes/live.rb +66 -1
- data/lib/openlanes/plans/blocked_file.rb +117 -0
- data/lib/openlanes/plans/choices.rb +228 -0
- data/lib/openlanes/plans/dependencies.rb +304 -0
- data/lib/openlanes/plans/feature.rb +75 -16
- data/lib/openlanes/plans/journal.rb +169 -0
- data/lib/openlanes/plans/last_known_state.rb +82 -0
- data/lib/openlanes/plans/lock.rb +171 -0
- data/lib/openlanes/plans/mailbox.rb +145 -4
- data/lib/openlanes/plans/plan_state.rb +590 -9
- data/lib/openlanes/plans/pull_request_machine.rb +163 -0
- data/lib/openlanes/plans/review_label.rb +139 -0
- data/lib/openlanes/plans/spec.rb +239 -3
- data/lib/openlanes/plans/state_machine.rb +63 -30
- data/lib/openlanes/plans/status.rb +60 -11
- data/lib/openlanes/plans/subject.rb +4 -0
- data/lib/openlanes/plans/tree.rb +101 -10
- data/lib/openlanes/plans/verdict.rb +124 -0
- data/lib/openlanes/plans.rb +19 -0
- data/lib/openlanes/presentation/api_docs.rb +76 -0
- data/lib/openlanes/presentation/documentation.rb +105 -12
- data/lib/openlanes/presentation/pr_changes.rb +90 -0
- data/lib/openlanes/presentation/reporter.rb +64 -5
- data/lib/openlanes/presentation.rb +20 -1
- data/lib/openlanes/setup/check.rb +16 -0
- data/lib/openlanes/setup/machine.rb +179 -0
- data/lib/openlanes/setup.rb +34 -0
- data/lib/openlanes/support/clock.rb +57 -0
- data/lib/openlanes/support/config.rb +318 -6
- data/lib/openlanes/support/log.rb +206 -0
- data/lib/openlanes/support/refusal.rb +15 -0
- data/lib/openlanes/support/runtime.rb +61 -0
- data/lib/openlanes/support/spawner.rb +51 -0
- data/lib/openlanes/support/ui.rb +39 -24
- data/lib/openlanes/vcs/ci_gate.rb +31 -0
- data/lib/openlanes/vcs/github.rb +362 -12
- data/lib/openlanes/vcs/label_migration.rb +132 -0
- data/lib/openlanes/vcs/label_sync.rb +75 -0
- data/lib/openlanes/vcs/publisher.rb +226 -8
- data/lib/openlanes/vcs/refresh.rb +337 -0
- data/lib/openlanes/vcs/rename_date.rb +83 -0
- data/lib/openlanes/vcs/review.rb +66 -0
- data/lib/openlanes/vcs/worktree.rb +167 -6
- data/lib/openlanes/vcs.rb +18 -0
- data/lib/openlanes/version.rb +1 -1
- data/lib/openlanes/web/admin_auth.rb +104 -0
- data/lib/openlanes/web/app.rb +115 -16
- data/lib/openlanes/web/context.rb +15 -9
- data/lib/openlanes/web/demo.rb +29 -14
- data/lib/openlanes/web/demo_board.rb +63 -5
- data/lib/openlanes/web/events.rb +86 -7
- data/lib/openlanes/web/launcher.rb +69 -7
- data/lib/openlanes/web/listen.rb +133 -0
- data/lib/openlanes/web/plan_files.rb +136 -0
- data/lib/openlanes/web/request_log.rb +108 -0
- data/lib/openlanes/web/server.rb +29 -10
- data/lib/openlanes/web.rb +6 -4
- data/lib/openlanes.rb +45 -7
- data/openlanes.gemspec +9 -4
- data/schemas/journal-event.schema.json +72 -0
- data/schemas/plan-state.schema.json +163 -2
- data/skills/openlanes/SKILL.md +2 -2
- data/skills/openlanes-documents/SKILL.md +4 -4
- data/skills/openlanes-documents/references/blocked-template.md +2 -2
- data/skills/openlanes-documents/references/closing-templates.md +1 -1
- data/skills/openlanes-documents/references/plan-template.md +12 -4
- data/skills/openlanes-documents/references/retroactive-spec.md +1 -1
- data/skills/openlanes-run/SKILL.md +5 -5
- data/skills/openlanes-spec/SKILL.md +1 -1
- data/skills/openlanes-status/SKILL.md +1 -1
- data/web/dist/assets/{index-BFPI-zXI.js → index-690ayLS4.js} +2 -2
- data/web/dist/assets/index-6ZmW4slI.js +199 -0
- data/web/dist/assets/{index-bpsFIpXE.js → index-B5KzlXp1.js} +2 -2
- data/web/dist/assets/{index-DtA46i32.js → index-BAo_v9bU.js} +2 -2
- data/web/dist/assets/{index-CnJfB0l2.js → index-BDm7g-xG.js} +2 -2
- data/web/dist/assets/{index-DXgeajd6.js → index-BFH9bC7v.js} +2 -2
- data/web/dist/assets/{index-DH0gDlee.js → index-BVHPV7z4.js} +2 -2
- data/web/dist/assets/{index-DaSjQ-H4.js → index-B_CPqjnA.js} +2 -2
- data/web/dist/assets/{index-Dbxvk-EC.js → index-BhwBH5ct.js} +2 -2
- data/web/dist/assets/{index-6MZkmh3k.js → index-Bq5AdwEt.js} +2 -2
- data/web/dist/assets/{index-B5CKJPm-.js → index-CAW6ClM2.js} +2 -2
- data/web/dist/assets/index-Cp86u27u.css +1 -0
- data/web/dist/assets/{index-CnvkZu5Y.js → index-Culm4pMO.js} +2 -2
- data/web/dist/assets/{index-IqD8mBos.js → index-DElDJfp1.js} +2 -2
- data/web/dist/assets/{index-C3zc4gFe.js → index-Dja7osNW.js} +2 -2
- data/web/dist/assets/{index-C2jwoWVV.js → index-DjhU3FzU.js} +2 -2
- data/web/dist/assets/{index-pvJi-JJO.js → index-Dutbxj4o.js} +2 -2
- data/web/dist/assets/{index-BF6QYroq.js → index-RkDhPMDt.js} +2 -2
- data/web/dist/assets/{index-DbueqOK5.js → index-_mptZjlC.js} +2 -2
- data/web/dist/index.html +2 -2
- data/web/src/api/fixtures/board.json +203 -28
- metadata +1053 -889
- data/bin/factory-e2e +0 -157
- data/bin/plan-number +0 -164
- data/bin/setup +0 -26
- data/evals/cases/palpatine-planner/plan-deep/fail/plan/plan.md +0 -8
- data/evals/cases/palpatine-planner/plan-fast/fail/plan/plan.md +0 -12
- data/web/dist/assets/index-BYJCWlk0.js +0 -198
- data/web/dist/assets/index-CjInBi5X.css +0 -1
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/plan/plan-backend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/plan/spec.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/run.json +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan-backend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan-frontend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/spec.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/run.json +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/plan-backend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/plan-frontend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/spec.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/run.json +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan-backend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan-frontend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/spec.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/run.json +0 -0
- /data/exe/{depot → ol} +0 -0
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenLanes
|
|
4
|
+
module Apps
|
|
5
|
+
# The `app:` block of `.openlanes.yml`: what kind of app a project builds
|
|
6
|
+
# and the processes that run it, in the order they start.
|
|
7
|
+
#
|
|
8
|
+
# Written by luke on a plan's first build (M12) and only ever read here.
|
|
9
|
+
# It is new surface that every project will copy, so anything it does not
|
|
10
|
+
# know is refused with the file named, exactly as {Config} refuses an
|
|
11
|
+
# unknown top-level key: a `restart-on:` typo silently ignored is a
|
|
12
|
+
# watcher that never fires and nobody can say why.
|
|
13
|
+
#
|
|
14
|
+
# @!attribute [r] kind
|
|
15
|
+
# @return [String, nil] free text, `web` or `desktop` (M11); recorded,
|
|
16
|
+
# not interpreted here
|
|
17
|
+
# @!attribute [r] processes
|
|
18
|
+
# @return [Array<OpenLanes::Apps::ProcessSpec>] in start order
|
|
19
|
+
Definition = Data.define(:kind, :processes) do
|
|
20
|
+
# @return [OpenLanes::Apps::ProcessSpec, nil] the process marked
|
|
21
|
+
# `preview: true`; nil when none is, which means no Preview tab
|
|
22
|
+
def preview = processes.find(&:preview?)
|
|
23
|
+
|
|
24
|
+
# @param name [String]
|
|
25
|
+
# @return [OpenLanes::Apps::ProcessSpec, nil]
|
|
26
|
+
def process(name) = processes.find { it.name == name.to_s }
|
|
27
|
+
|
|
28
|
+
# Every process the Preview tab's Restart backend button restarts:
|
|
29
|
+
# all but the one it shows, in declared order.
|
|
30
|
+
#
|
|
31
|
+
# @return [Array<OpenLanes::Apps::ProcessSpec>]
|
|
32
|
+
def backend = processes.reject(&:preview?)
|
|
33
|
+
|
|
34
|
+
# @return [Hash] as `.openlanes.yml` spells it
|
|
35
|
+
def to_h = { "kind" => kind, "processes" => processes.map(&:to_h) }.compact
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
class Definition
|
|
39
|
+
# Keys the `app:` mapping may hold.
|
|
40
|
+
KEYS = %w[kind processes].freeze
|
|
41
|
+
|
|
42
|
+
# Keys each process may hold.
|
|
43
|
+
PROCESS_KEYS = %w[name start ready restart_on preview].freeze
|
|
44
|
+
|
|
45
|
+
# A process name: what `PORT_<NAME>`, the Preview tab and `target:`
|
|
46
|
+
# all carry, so nothing a shell or a URL would trip over.
|
|
47
|
+
NAME = /\A[A-Za-z0-9][A-Za-z0-9_-]*\z/
|
|
48
|
+
|
|
49
|
+
class << self
|
|
50
|
+
# @param value [Object] the parsed `app:` value
|
|
51
|
+
# @param path [String] the file, for every error
|
|
52
|
+
# @return [OpenLanes::Apps::Definition]
|
|
53
|
+
# @raise [OpenLanes::Error] naming the file and what is wrong
|
|
54
|
+
def parse(value, path:)
|
|
55
|
+
fail_with(path, "app: must be a mapping with processes:") unless value.is_a?(Hash)
|
|
56
|
+
|
|
57
|
+
data = value.transform_keys(&:to_s)
|
|
58
|
+
unknown!(path, "app", data.keys - KEYS, KEYS)
|
|
59
|
+
kind = data["kind"]
|
|
60
|
+
fail_with(path, "app.kind: #{kind.inspect} is not a word like web") unless kind.nil? || kind.is_a?(String)
|
|
61
|
+
|
|
62
|
+
list = data["processes"]
|
|
63
|
+
fail_with(path, "app.processes: must be a list of at least one process") unless list.is_a?(Array) && !list.empty?
|
|
64
|
+
|
|
65
|
+
processes = list.each_with_index.map { |entry, index| process(entry, index, path) }
|
|
66
|
+
checked(new(kind: kind&.strip, processes:), path)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
# @return [OpenLanes::Apps::ProcessSpec]
|
|
72
|
+
def process(entry, index, path)
|
|
73
|
+
where = "app.processes[#{index}]"
|
|
74
|
+
fail_with(path, "#{where}: must be a mapping with name: and start:") unless entry.is_a?(Hash)
|
|
75
|
+
|
|
76
|
+
data = entry.transform_keys(&:to_s)
|
|
77
|
+
unknown!(path, where, data.keys - PROCESS_KEYS, PROCESS_KEYS)
|
|
78
|
+
name = data["name"]
|
|
79
|
+
fail_with(path, "#{where}: needs name:, letters, digits, - or _") unless name.is_a?(String) && name.match?(NAME)
|
|
80
|
+
|
|
81
|
+
ProcessSpec.new(name:,
|
|
82
|
+
start: start(data["start"], "app.processes.#{name}", path),
|
|
83
|
+
ready: ready(data["ready"], "app.processes.#{name}", path),
|
|
84
|
+
restart_on: restart_on(data["restart_on"], "app.processes.#{name}", path),
|
|
85
|
+
preview: preview(data["preview"], "app.processes.#{name}", path))
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# @return [String]
|
|
89
|
+
def start(value, where, path)
|
|
90
|
+
return value.strip if value.is_a?(String) && !value.strip.empty?
|
|
91
|
+
|
|
92
|
+
fail_with(path, "#{where}: needs start:, the shell line that runs it")
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# @return [String, nil]
|
|
96
|
+
def ready(value, where, path)
|
|
97
|
+
return nil if value.nil?
|
|
98
|
+
return value if value.is_a?(String) && value.start_with?("/")
|
|
99
|
+
|
|
100
|
+
fail_with(path, "#{where}.ready: #{value.inspect} is not an HTTP path like /up")
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# @return [Array<String>]
|
|
104
|
+
def restart_on(value, where, path)
|
|
105
|
+
globs = value.is_a?(String) ? [value] : value
|
|
106
|
+
return [] if globs.nil?
|
|
107
|
+
return globs if globs.is_a?(Array) && globs.all? { it.is_a?(String) && !it.strip.empty? }
|
|
108
|
+
|
|
109
|
+
fail_with(path, "#{where}.restart_on: must be a glob or a list of globs")
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# @return [Boolean]
|
|
113
|
+
def preview(value, where, path)
|
|
114
|
+
return value == true if [true, false, nil].include?(value)
|
|
115
|
+
|
|
116
|
+
fail_with(path, "#{where}.preview: #{value.inspect} is not true or false")
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Two processes that one name, one `PORT_` variable or two preview
|
|
120
|
+
# flags would confuse.
|
|
121
|
+
#
|
|
122
|
+
# @return [OpenLanes::Apps::Definition]
|
|
123
|
+
def checked(definition, path)
|
|
124
|
+
names = definition.processes.map(&:name)
|
|
125
|
+
twice = names.select { names.count(it) > 1 }.uniq
|
|
126
|
+
fail_with(path, "app.processes: #{twice.join(", ")} named twice") unless twice.empty?
|
|
127
|
+
|
|
128
|
+
envs = definition.processes.group_by(&:port_env).select { _2.size > 1 }
|
|
129
|
+
envs.each do |env, specs|
|
|
130
|
+
fail_with(path, "app.processes: #{specs.map(&:name).join(" and ")} would both be #{env}")
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
previews = definition.processes.select(&:preview?)
|
|
134
|
+
if previews.size > 1
|
|
135
|
+
fail_with(path, "app.processes: only one process may be preview: true, not #{previews.map(&:name).join(" and ")}")
|
|
136
|
+
end
|
|
137
|
+
definition
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# @return [void]
|
|
141
|
+
def unknown!(path, where, unknown, known)
|
|
142
|
+
return if unknown.empty?
|
|
143
|
+
|
|
144
|
+
fail_with(path, "#{where}: unknown key#{"s" unless unknown.size == 1} #{unknown.join(", ")}; known: #{known.join(", ")}")
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# @return [void] never returns
|
|
148
|
+
def fail_with(path, message) = raise(Error, "#{path}: #{message}")
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
module OpenLanes
|
|
7
|
+
module Apps
|
|
8
|
+
# Whether the Preview tab may show an app in an iframe, or must open it in
|
|
9
|
+
# a window of its own (M13, plan 012 item 11).
|
|
10
|
+
#
|
|
11
|
+
# The page cannot find out: a cross-origin frame's DOM is closed to it
|
|
12
|
+
# and a refused frame still fires `load`. So the runner asks the app
|
|
13
|
+
# itself, once its preview process is ready and after each restart, and
|
|
14
|
+
# reads the two headers that refuse framing:
|
|
15
|
+
#
|
|
16
|
+
# - `X-Frame-Options`: `DENY` refuses everyone, and `SAMEORIGIN` refuses
|
|
17
|
+
# the control room too, whose origin differs from the app's by port.
|
|
18
|
+
# `ALLOW-FROM` is obsolete and ignored by browsers, so it frames.
|
|
19
|
+
# - `Content-Security-Policy: frame-ancestors`: refuses unless one of its
|
|
20
|
+
# sources matches the control room's origin. `'self'` is the app's own
|
|
21
|
+
# origin, so it refuses too.
|
|
22
|
+
#
|
|
23
|
+
# Either one refusing sends the tab to a window. Browsers let
|
|
24
|
+
# `frame-ancestors` override `X-Frame-Options`; this stays on the
|
|
25
|
+
# cautious side, because a window always works and a refused frame is a
|
|
26
|
+
# blank tab nobody can explain.
|
|
27
|
+
module Framing
|
|
28
|
+
# The control room's origin unless told otherwise.
|
|
29
|
+
ORIGIN = "http://#{Status::HOST}:11000".freeze
|
|
30
|
+
|
|
31
|
+
# Default ports, for a source that names none.
|
|
32
|
+
DEFAULT_PORTS = { "http" => 80, "https" => 443 }.freeze
|
|
33
|
+
|
|
34
|
+
module_function
|
|
35
|
+
|
|
36
|
+
# @param headers [Hash{String => String, Array<String>}] any case
|
|
37
|
+
# @param origin [String] the page that embeds it
|
|
38
|
+
# @return [Symbol] `:frame` or `:window`
|
|
39
|
+
def verdict(headers, origin: ORIGIN)
|
|
40
|
+
values = headers.to_h { |name, value| [name.to_s.downcase, Array(value).join(",")] }
|
|
41
|
+
refused = x_frame_refuses?(values["x-frame-options"]) || ancestors_refuse?(values["content-security-policy"], URI(origin))
|
|
42
|
+
refused ? :window : :frame
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# One GET of the URL the tab would open, then {.verdict}. An app that
|
|
46
|
+
# cannot be asked opens in a window: it may work there, and an iframe
|
|
47
|
+
# would show nothing either way.
|
|
48
|
+
#
|
|
49
|
+
# @param url [String]
|
|
50
|
+
# @param origin [String]
|
|
51
|
+
# @return [Symbol]
|
|
52
|
+
def check(url, origin: ORIGIN)
|
|
53
|
+
uri = URI(url)
|
|
54
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
55
|
+
http.open_timeout = Probe::TIMEOUT
|
|
56
|
+
http.read_timeout = Probe::TIMEOUT
|
|
57
|
+
response = http.request_get(uri.request_uri)
|
|
58
|
+
verdict(response.to_hash, origin:)
|
|
59
|
+
rescue SystemCallError, IOError, Timeout::Error, Net::HTTPBadResponse, SocketError, URI::Error
|
|
60
|
+
:window
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# @param value [String, nil]
|
|
64
|
+
# @return [Boolean]
|
|
65
|
+
def x_frame_refuses?(value)
|
|
66
|
+
value.to_s.split(",").map { it.strip.upcase }.intersect?(%w[DENY SAMEORIGIN])
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Every policy is enforced, so any one whose `frame-ancestors` leaves
|
|
70
|
+
# the origin out refuses it. A policy without the directive allows
|
|
71
|
+
# framing: it has no `default-src` fallback.
|
|
72
|
+
#
|
|
73
|
+
# @param value [String, nil] one or more policies, comma-joined
|
|
74
|
+
# @param origin [URI::Generic]
|
|
75
|
+
# @return [Boolean]
|
|
76
|
+
def ancestors_refuse?(value, origin)
|
|
77
|
+
value.to_s.split(",").any? do |policy|
|
|
78
|
+
directive = policy.split(";").map(&:strip).find { it.downcase.start_with?("frame-ancestors") }
|
|
79
|
+
directive && directive.split[1..].none? { allows?(it, origin) }
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# @param source [String] one source expression
|
|
84
|
+
# @param origin [URI::Generic]
|
|
85
|
+
# @return [Boolean]
|
|
86
|
+
def allows?(source, origin)
|
|
87
|
+
text = source.strip.downcase
|
|
88
|
+
return true if text == "*"
|
|
89
|
+
return text.delete_suffix(":") == origin.scheme if text.match?(/\A[a-z][a-z0-9+.-]*:\z/)
|
|
90
|
+
return false if text.start_with?("'")
|
|
91
|
+
|
|
92
|
+
host_allows?(text, origin)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# @return [Boolean]
|
|
96
|
+
def host_allows?(text, origin)
|
|
97
|
+
match = text.match(%r{\A(?:(?<scheme>[a-z][a-z0-9+.-]*)://)?(?<host>[^:/]+)(?::(?<port>\d+|\*))?(?:/.*)?\z})
|
|
98
|
+
return false unless match
|
|
99
|
+
return false if match[:scheme] && match[:scheme] != origin.scheme
|
|
100
|
+
return false unless host_matches?(match[:host], origin.host)
|
|
101
|
+
|
|
102
|
+
port = match[:port]
|
|
103
|
+
port == "*" || (port || DEFAULT_PORTS[match[:scheme] || origin.scheme]).to_i == origin.port
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# @return [Boolean]
|
|
107
|
+
def host_matches?(pattern, host)
|
|
108
|
+
return host == pattern unless pattern.start_with?("*.")
|
|
109
|
+
|
|
110
|
+
host.end_with?(pattern.delete_prefix("*"))
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module OpenLanes
|
|
6
|
+
module Apps
|
|
7
|
+
# The apps running on this machine, as the control room reads them from
|
|
8
|
+
# Redis: one hash per machine (`Live::Keys#apps`), a field per plan, its
|
|
9
|
+
# value the plan's process statuses and a heartbeat.
|
|
10
|
+
#
|
|
11
|
+
# Per machine rather than per checkout, because the control room runs in
|
|
12
|
+
# the main checkout and an app runs in the plan's worktree; per machine
|
|
13
|
+
# rather than per repository, because `127.0.0.1:11101` means nothing on
|
|
14
|
+
# another one.
|
|
15
|
+
#
|
|
16
|
+
# A runner killed with KILL never removes its field, so a field whose
|
|
17
|
+
# heartbeat is older than {TTL} is dropped by whoever reads it, the way
|
|
18
|
+
# `Live.board` drops a node that stopped heartbeating.
|
|
19
|
+
module Listing
|
|
20
|
+
# Seconds a plan's entry outlives its last heartbeat.
|
|
21
|
+
TTL = 30
|
|
22
|
+
|
|
23
|
+
module_function
|
|
24
|
+
|
|
25
|
+
# @param store [OpenLanes::Live::Store, OpenLanes::Live::MemoryStore]
|
|
26
|
+
# @param keys [OpenLanes::Live::Keys]
|
|
27
|
+
# @param plan [String]
|
|
28
|
+
# @param statuses [Array<OpenLanes::Apps::Status>]
|
|
29
|
+
# @param now [Time]
|
|
30
|
+
# @return [void]
|
|
31
|
+
def write(store:, keys:, plan:, statuses:, now: Time.now)
|
|
32
|
+
store.hset(keys.apps, plan, JSON.generate("heartbeat" => now.to_f, "processes" => statuses.map(&:to_h)))
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# @return [void]
|
|
36
|
+
def remove(store:, keys:, plan:) = store.hdel(keys.apps, plan)
|
|
37
|
+
|
|
38
|
+
# Every live process, oldest plan first, in each plan's start order.
|
|
39
|
+
#
|
|
40
|
+
# @param store [OpenLanes::Live::Store, OpenLanes::Live::MemoryStore]
|
|
41
|
+
# @param keys [OpenLanes::Live::Keys]
|
|
42
|
+
# @param now [Time]
|
|
43
|
+
# @return [Array<Hash>] `<app>` hashes, as {Status#to_h} wrote them
|
|
44
|
+
def read(store:, keys:, now: Time.now)
|
|
45
|
+
entries = store.hgetall(keys.apps).transform_values { parse(it) }
|
|
46
|
+
stale = entries.select { |_, entry| entry.nil? || now.to_f - entry["heartbeat"].to_f > TTL }.keys
|
|
47
|
+
store.hdel(keys.apps, *stale) unless stale.empty?
|
|
48
|
+
entries.except(*stale).sort.flat_map { |_, entry| Array(entry["processes"]) }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# @return [Hash, nil] nil for a field that is not ours to read
|
|
52
|
+
def parse(json)
|
|
53
|
+
data = JSON.parse(json.to_s)
|
|
54
|
+
data.is_a?(Hash) ? data : nil
|
|
55
|
+
rescue JSON::ParserError
|
|
56
|
+
nil
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
require "socket"
|
|
6
|
+
require "time"
|
|
7
|
+
|
|
8
|
+
module OpenLanes
|
|
9
|
+
module Apps
|
|
10
|
+
# Every port an app holds on this machine, in one file (plan 012).
|
|
11
|
+
#
|
|
12
|
+
# One file per machine, not per checkout: a worktree isolates files, not
|
|
13
|
+
# ports, and two worktrees of one app asking for 11100 each would both
|
|
14
|
+
# be told yes by anything that only looked at its own checkout.
|
|
15
|
+
#
|
|
16
|
+
# A port is reserved under an exclusive `flock` and also probed, both by
|
|
17
|
+
# connecting to it and by binding it: something that never registered
|
|
18
|
+
# here (a dev server started by hand, the control room) still holds it.
|
|
19
|
+
# The probe and the spawn that follows it leave a gap no lock covers, so
|
|
20
|
+
# {Supervisor} retries a start that lost that race.
|
|
21
|
+
#
|
|
22
|
+
# An entry belongs to the pid that reserved it. Once that process is
|
|
23
|
+
# gone the entry counts as free, which is how a runner killed with KILL
|
|
24
|
+
# gives its ports back without anyone cleaning up after it.
|
|
25
|
+
class Ports
|
|
26
|
+
# The first port an app is given (M12).
|
|
27
|
+
FLOOR = 11_100
|
|
28
|
+
|
|
29
|
+
# The last. Not configurable: per-plan ranges are a non-goal.
|
|
30
|
+
CEILING = 11_999
|
|
31
|
+
|
|
32
|
+
# Overrides where the registry lives; the suite and the Cypress run
|
|
33
|
+
# point it at a temp file.
|
|
34
|
+
FILE_ENV = "OPENLANES_PORTS_FILE"
|
|
35
|
+
|
|
36
|
+
# Every port in the range is held.
|
|
37
|
+
class Exhausted < Error; end
|
|
38
|
+
|
|
39
|
+
class << self
|
|
40
|
+
# `$OPENLANES_PORTS_FILE`, else `${XDG_STATE_HOME:-~/.local/state}/openlanes/ports.json`.
|
|
41
|
+
#
|
|
42
|
+
# @param env [Hash]
|
|
43
|
+
# @return [String]
|
|
44
|
+
def default_path(env = ENV)
|
|
45
|
+
return env[FILE_ENV] unless env[FILE_ENV].to_s.strip.empty?
|
|
46
|
+
|
|
47
|
+
state = env["XDG_STATE_HOME"].to_s.strip
|
|
48
|
+
File.join(state.empty? ? File.expand_path("~/.local/state") : state, "openlanes", "ports.json")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Nothing answers on the port and it can be bound on 127.0.0.1. The
|
|
52
|
+
# connect catches a listener on another address, which macOS lets a
|
|
53
|
+
# SO_REUSEADDR bind on 127.0.0.1 sit beside.
|
|
54
|
+
#
|
|
55
|
+
# @param port [Integer]
|
|
56
|
+
# @return [Boolean]
|
|
57
|
+
def free?(port)
|
|
58
|
+
return false if answering?(port)
|
|
59
|
+
|
|
60
|
+
TCPServer.new(Status::HOST, port).close
|
|
61
|
+
true
|
|
62
|
+
rescue SystemCallError
|
|
63
|
+
false
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# @param port [Integer]
|
|
67
|
+
# @return [Boolean]
|
|
68
|
+
def answering?(port) = Probe.listening?(port, timeout: 0.2)
|
|
69
|
+
|
|
70
|
+
# @param pid [Integer]
|
|
71
|
+
# @return [Boolean]
|
|
72
|
+
def alive?(pid)
|
|
73
|
+
Process.kill(0, pid.to_i)
|
|
74
|
+
true
|
|
75
|
+
rescue Errno::ESRCH, RangeError
|
|
76
|
+
false
|
|
77
|
+
rescue Errno::EPERM
|
|
78
|
+
true
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# @param path [String] the registry file
|
|
83
|
+
# @param owner [Integer] whose reservations these are
|
|
84
|
+
# @param floor [Integer]
|
|
85
|
+
# @param ceiling [Integer]
|
|
86
|
+
# @param free [#call] a port to whether it can be taken
|
|
87
|
+
# @param alive [#call] a pid to whether it still runs
|
|
88
|
+
def initialize(path: self.class.default_path, owner: Process.pid, floor: FLOOR, ceiling: CEILING,
|
|
89
|
+
free: self.class.method(:free?), alive: self.class.method(:alive?))
|
|
90
|
+
@path = path
|
|
91
|
+
@owner = owner
|
|
92
|
+
@floor = floor
|
|
93
|
+
@ceiling = ceiling
|
|
94
|
+
@free = free
|
|
95
|
+
@alive = alive
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# @return [String]
|
|
99
|
+
attr_reader :path
|
|
100
|
+
|
|
101
|
+
# The lowest port at or above the floor that nobody holds.
|
|
102
|
+
#
|
|
103
|
+
# @param root [String] the checkout the app runs in
|
|
104
|
+
# @param plan [String]
|
|
105
|
+
# @param process [String]
|
|
106
|
+
# @return [Integer]
|
|
107
|
+
# @raise [OpenLanes::Apps::Ports::Exhausted]
|
|
108
|
+
def reserve(root:, plan:, process:)
|
|
109
|
+
locked do |entries|
|
|
110
|
+
port = (@floor..@ceiling).find { !entries.key?(it) && @free.call(it) }
|
|
111
|
+
raise Exhausted, "every port from #{@floor} to #{@ceiling} is taken" unless port
|
|
112
|
+
|
|
113
|
+
entries[port] = { "owner" => @owner, "root" => root, "plan" => plan, "process" => process, "at" => Time.now.utc.iso8601 }
|
|
114
|
+
port
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# @param port [Integer]
|
|
119
|
+
# @return [void]
|
|
120
|
+
def release(port) = locked { it.delete(port.to_i) }
|
|
121
|
+
|
|
122
|
+
# Everything one process holds.
|
|
123
|
+
#
|
|
124
|
+
# @param pid [Integer]
|
|
125
|
+
# @return [Array<Integer>] the ports released
|
|
126
|
+
def release_owner(pid = @owner)
|
|
127
|
+
locked do |entries|
|
|
128
|
+
entries.select { |_, entry| entry["owner"] == pid }.keys.tap { |ports| ports.each { entries.delete(it) } }
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Every live reservation, by port.
|
|
133
|
+
#
|
|
134
|
+
# @return [Hash{Integer => Hash}] `owner`, `root`, `plan`, `process`, `at`
|
|
135
|
+
def entries = locked(&:dup)
|
|
136
|
+
|
|
137
|
+
private
|
|
138
|
+
|
|
139
|
+
# Read, hand the live entries to the block, write back what it left.
|
|
140
|
+
# Dead owners' entries are dropped on the way.
|
|
141
|
+
#
|
|
142
|
+
# @yieldparam entries [Hash{Integer => Hash}]
|
|
143
|
+
# @return [Object] the block's value
|
|
144
|
+
def locked
|
|
145
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
146
|
+
File.open(path, File::RDWR | File::CREAT, 0o644) do |file|
|
|
147
|
+
file.flock(File::LOCK_EX)
|
|
148
|
+
entries = parse(file.read).select { |_, entry| @alive.call(entry["owner"]) }
|
|
149
|
+
yield(entries).tap do
|
|
150
|
+
file.rewind
|
|
151
|
+
file.truncate(0)
|
|
152
|
+
file.write(JSON.pretty_generate(entries.sort.to_h { [it.first.to_s, it.last] }))
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# @return [Hash{Integer => Hash}]
|
|
158
|
+
def parse(text)
|
|
159
|
+
data = text.strip.empty? ? {} : JSON.parse(text)
|
|
160
|
+
data.is_a?(Hash) ? data.to_h { |port, entry| [port.to_i, entry] }.select { |_, entry| entry.is_a?(Hash) } : {}
|
|
161
|
+
rescue JSON::ParserError
|
|
162
|
+
# A file somebody broke by hand is a registry with nothing in it; the
|
|
163
|
+
# probes still keep a taken port from being handed out twice.
|
|
164
|
+
{}
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "socket"
|
|
5
|
+
|
|
6
|
+
module OpenLanes
|
|
7
|
+
module Apps
|
|
8
|
+
# Whether a process is ready (plan 012, decision 1): something accepts a
|
|
9
|
+
# TCP connection on its port, and, when its `ready:` names a path, a GET
|
|
10
|
+
# of that path answers 2xx or 3xx.
|
|
11
|
+
#
|
|
12
|
+
# Only ever on the port the process was given. An app that moved to
|
|
13
|
+
# another port (Vite without `--strictPort` does) therefore never
|
|
14
|
+
# becomes ready, which is how a moved port fails the start rather than
|
|
15
|
+
# leaving the Preview tab pointing at nothing.
|
|
16
|
+
module Probe
|
|
17
|
+
# Seconds a connect, and a read, may take.
|
|
18
|
+
TIMEOUT = 1
|
|
19
|
+
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
# @param port [Integer]
|
|
23
|
+
# @param path [String, nil]
|
|
24
|
+
# @return [Boolean]
|
|
25
|
+
def ready?(port, path: nil)
|
|
26
|
+
return false unless listening?(port)
|
|
27
|
+
return true if path.nil?
|
|
28
|
+
|
|
29
|
+
status(port, path).between?(200, 399)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Something accepted a connection on the port. Asking the socket for
|
|
33
|
+
# its peer is the part that matters: behind a network filter (Little
|
|
34
|
+
# Snitch on macOS) `Socket.tcp` with `connect_timeout` returns a socket
|
|
35
|
+
# for a refused connect, and only `getpeername` then says EINVAL. Without
|
|
36
|
+
# it every port on the machine reads as taken.
|
|
37
|
+
#
|
|
38
|
+
# @param port [Integer]
|
|
39
|
+
# @param timeout [Numeric] seconds the connect may take
|
|
40
|
+
# @return [Boolean]
|
|
41
|
+
def listening?(port, timeout: TIMEOUT)
|
|
42
|
+
Socket.tcp(Status::HOST, port, connect_timeout: timeout) do |socket|
|
|
43
|
+
socket.remote_address
|
|
44
|
+
true
|
|
45
|
+
end
|
|
46
|
+
rescue SystemCallError, IOError, SocketError
|
|
47
|
+
false
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# @param port [Integer]
|
|
51
|
+
# @param path [String]
|
|
52
|
+
# @return [Integer] the status, 0 when nothing answered
|
|
53
|
+
def status(port, path)
|
|
54
|
+
http = Net::HTTP.new(Status::HOST, port)
|
|
55
|
+
http.open_timeout = TIMEOUT
|
|
56
|
+
http.read_timeout = TIMEOUT
|
|
57
|
+
http.request_get(path).code.to_i
|
|
58
|
+
rescue SystemCallError, IOError, Timeout::Error, Net::HTTPBadResponse, SocketError
|
|
59
|
+
0
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenLanes
|
|
4
|
+
module Apps
|
|
5
|
+
# One process of an app, as `.openlanes.yml` declares it under
|
|
6
|
+
# `app.processes` (M12).
|
|
7
|
+
#
|
|
8
|
+
# @!attribute [r] name
|
|
9
|
+
# @return [String] `api`, `web`; unique within the app
|
|
10
|
+
# @!attribute [r] start
|
|
11
|
+
# @return [String] a shell line, run by `sh -c` in the checkout, so
|
|
12
|
+
# `$PORT_WEB` expands there
|
|
13
|
+
# @!attribute [r] ready
|
|
14
|
+
# @return [String, nil] an HTTP path that must answer 2xx or 3xx; nil
|
|
15
|
+
# means a TCP connect is enough
|
|
16
|
+
# @!attribute [r] restart_on
|
|
17
|
+
# @return [Array<String>] `Dir.glob` patterns, relative to the checkout
|
|
18
|
+
# @!attribute [r] preview
|
|
19
|
+
# @return [Boolean] the one process the Preview tab shows
|
|
20
|
+
ProcessSpec = Data.define(:name, :start, :ready, :restart_on, :preview) do
|
|
21
|
+
def initialize(name:, start:, ready: nil, restart_on: [], preview: false) = super
|
|
22
|
+
|
|
23
|
+
# `PORT_<NAME>`: upcased, anything not a letter or digit as `_`, so
|
|
24
|
+
# `api-v2` is `PORT_API_V2` and a shell can expand it.
|
|
25
|
+
#
|
|
26
|
+
# @return [String]
|
|
27
|
+
def port_env = "PORT_#{name.upcase.gsub(/[^A-Z0-9]/, "_")}"
|
|
28
|
+
|
|
29
|
+
# @return [Boolean]
|
|
30
|
+
def preview? = preview == true
|
|
31
|
+
|
|
32
|
+
# @return [Hash] as `.openlanes.yml` spells it, defaults left out
|
|
33
|
+
def to_h
|
|
34
|
+
{ "name" => name, "start" => start, "ready" => ready,
|
|
35
|
+
"restart_on" => (restart_on unless restart_on.empty?), "preview" => (true if preview?) }.compact
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|