@jr2/orchestrator 0.3.0 → 0.5.1
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.
- package/README.md +20 -2
- package/bin/server.ts +7 -4
- package/console/canvas.ts +27 -14
- package/console/components/app.ts +12 -1
- package/console/components/fleet.ts +52 -6
- package/console/components/machine-pane.ts +128 -2
- package/console/components/nav.ts +1 -1
- package/console/components/schema-form.ts +12 -4
- package/console/json-field.ts +23 -0
- package/console/main.ts +122 -39
- package/console/store.ts +321 -21
- package/console/style.css +111 -0
- package/docs/agents-block.md +5 -0
- package/docs/concepts/agents-and-turns.md +442 -0
- package/docs/concepts/architecture.md +230 -0
- package/docs/concepts/charts.md +143 -0
- package/docs/concepts/composition.md +357 -0
- package/docs/concepts/converge.md +254 -0
- package/docs/concepts/durable-runs.md +189 -0
- package/docs/concepts/effects.md +603 -0
- package/docs/concepts/gates.md +208 -0
- package/docs/concepts/machines-and-workflows.md +289 -0
- package/docs/concepts/pools-and-sources.md +230 -0
- package/docs/concepts/secrets-and-trust.md +222 -0
- package/docs/concepts/the-menu.md +378 -0
- package/docs/concepts/workspaces.md +678 -0
- package/docs/get-started/a-model-decides.md +563 -0
- package/docs/get-started/an-agent-in-a-sandbox.md +381 -0
- package/docs/get-started/connect-a-model.md +248 -0
- package/docs/get-started/create-an-instance.md +194 -0
- package/docs/get-started/have-your-assistant-write-a-machine.md +922 -0
- package/docs/get-started/install.md +126 -0
- package/docs/guides/add-tools-to-the-sandbox.md +295 -0
- package/docs/guides/answer-a-gate.md +304 -0
- package/docs/guides/bind-open-parts.md +223 -0
- package/docs/guides/cancel-a-run.md +146 -0
- package/docs/guides/clean-up.md +206 -0
- package/docs/guides/fix-a-failed-converge.md +361 -0
- package/docs/guides/give-the-cluster-git-credentials.md +222 -0
- package/docs/guides/iterate-on-a-machine-on-kind.md +190 -0
- package/docs/guides/let-an-agent-write-a-chart.md +226 -0
- package/docs/guides/look-inside-a-sandbox.md +174 -0
- package/docs/guides/mirror-the-kit-images.md +176 -0
- package/docs/guides/move-from-kind-to-a-shared-cluster.md +302 -0
- package/docs/guides/open-the-console.md +91 -0
- package/docs/guides/publish-a-machine-as-an-npm-package.md +350 -0
- package/docs/guides/push-and-sign-a-branch.md +486 -0
- package/docs/guides/run-a-chart-by-name.md +270 -0
- package/docs/guides/run-a-command-or-call-a-url.md +544 -0
- package/docs/guides/run-one-worker-per-item.md +321 -0
- package/docs/guides/set-an-agents-access-and-dials.md +375 -0
- package/docs/guides/size-a-workspace.md +204 -0
- package/docs/guides/start-a-run-from-a-run.md +237 -0
- package/docs/guides/start-a-run.md +207 -0
- package/docs/guides/upgrade-the-kit-version.md +240 -0
- package/docs/guides/use-a-hosted-provider.md +208 -0
- package/docs/guides/use-a-local-model.md +313 -0
- package/docs/guides/watch-a-run.md +326 -0
- package/docs/guides/write-a-workflow.md +318 -0
- package/docs/index.md +44 -0
- package/docs/llms.txt +216 -0
- package/docs/reference/authoring-surface/agent.md +128 -0
- package/docs/reference/authoring-surface/answer.md +140 -0
- package/docs/reference/authoring-surface/arg.md +60 -0
- package/docs/reference/authoring-surface/basic.md +62 -0
- package/docs/reference/authoring-surface/bearer.md +53 -0
- package/docs/reference/authoring-surface/chartschema.md +23 -0
- package/docs/reference/authoring-surface/chartschemafor.md +48 -0
- package/docs/reference/authoring-surface/command.md +191 -0
- package/docs/reference/authoring-surface/customize.md +64 -0
- package/docs/reference/authoring-surface/defineconfig.md +41 -0
- package/docs/reference/authoring-surface/defineevent.md +114 -0
- package/docs/reference/authoring-surface/dispatch.md +126 -0
- package/docs/reference/authoring-surface/fail.md +83 -0
- package/docs/reference/authoring-surface/file.md +122 -0
- package/docs/reference/authoring-surface/frompick.md +277 -0
- package/docs/reference/authoring-surface/http.md +159 -0
- package/docs/reference/authoring-surface/isfailure.md +58 -0
- package/docs/reference/authoring-surface/jr2setup.md +237 -0
- package/docs/reference/authoring-surface/open.md +74 -0
- package/docs/reference/authoring-surface/openbody.md +86 -0
- package/docs/reference/authoring-surface/paramsof.md +125 -0
- package/docs/reference/authoring-surface/pool.md +186 -0
- package/docs/reference/authoring-surface/push.md +112 -0
- package/docs/reference/authoring-surface/secret.md +118 -0
- package/docs/reference/authoring-surface/sentby.md +83 -0
- package/docs/reference/authoring-surface/source.md +51 -0
- package/docs/reference/authoring-surface/text.md +62 -0
- package/docs/reference/authoring-surface/type-agentdeclaration.md +27 -0
- package/docs/reference/authoring-surface/type-agentdefinition.md +80 -0
- package/docs/reference/authoring-surface/type-agentlogic.md +15 -0
- package/docs/reference/authoring-surface/type-agentslots.md +12 -0
- package/docs/reference/authoring-surface/type-answeraction.md +12 -0
- package/docs/reference/authoring-surface/type-arg.md +19 -0
- package/docs/reference/authoring-surface/type-argnames.md +12 -0
- package/docs/reference/authoring-surface/type-binding.md +62 -0
- package/docs/reference/authoring-surface/type-bindingof.md +12 -0
- package/docs/reference/authoring-surface/type-bodyacceptsdoor.md +15 -0
- package/docs/reference/authoring-surface/type-bodyacceptsslots.md +17 -0
- package/docs/reference/authoring-surface/type-ceilings.md +41 -0
- package/docs/reference/authoring-surface/type-chainlink.md +19 -0
- package/docs/reference/authoring-surface/type-chartaction.md +46 -0
- package/docs/reference/authoring-surface/type-chartagent.md +28 -0
- package/docs/reference/authoring-surface/type-chartevent.md +20 -0
- package/docs/reference/authoring-surface/type-chartguard.md +20 -0
- package/docs/reference/authoring-surface/type-chartinvoke.md +28 -0
- package/docs/reference/authoring-surface/type-chartjson.md +14 -0
- package/docs/reference/authoring-surface/type-chartstate.md +46 -0
- package/docs/reference/authoring-surface/type-charttext.md +40 -0
- package/docs/reference/authoring-surface/type-charttransition.md +22 -0
- package/docs/reference/authoring-surface/type-charttransitions.md +14 -0
- package/docs/reference/authoring-surface/type-chartvalue.md +15 -0
- package/docs/reference/authoring-surface/type-childat.md +12 -0
- package/docs/reference/authoring-surface/type-childslots.md +12 -0
- package/docs/reference/authoring-surface/type-commandenv.md +15 -0
- package/docs/reference/authoring-surface/type-commandinput.md +19 -0
- package/docs/reference/authoring-surface/type-commandoptions.md +78 -0
- package/docs/reference/authoring-surface/type-commandoutput.md +104 -0
- package/docs/reference/authoring-surface/type-customize.md +109 -0
- package/docs/reference/authoring-surface/type-dispatchdefinition.md +39 -0
- package/docs/reference/authoring-surface/type-dispatchinput.md +14 -0
- package/docs/reference/authoring-surface/type-dispatchopenparts.md +14 -0
- package/docs/reference/authoring-surface/type-dispatchoutput.md +65 -0
- package/docs/reference/authoring-surface/type-duration.md +15 -0
- package/docs/reference/authoring-surface/type-effectbinding.md +93 -0
- package/docs/reference/authoring-surface/type-effectlogic.md +12 -0
- package/docs/reference/authoring-surface/type-envopenparts.md +14 -0
- package/docs/reference/authoring-surface/type-eventaudience.md +27 -0
- package/docs/reference/authoring-surface/type-eventdef.md +45 -0
- package/docs/reference/authoring-surface/type-eventfrom.md +16 -0
- package/docs/reference/authoring-surface/type-eventsemantics.md +26 -0
- package/docs/reference/authoring-surface/type-execoutcome.md +34 -0
- package/docs/reference/authoring-surface/type-failslot.md +14 -0
- package/docs/reference/authoring-surface/type-failure.md +19 -0
- package/docs/reference/authoring-surface/type-failureinput.md +20 -0
- package/docs/reference/authoring-surface/type-filebody.md +41 -0
- package/docs/reference/authoring-surface/type-frominput.md +14 -0
- package/docs/reference/authoring-surface/type-hostinjectedinput.md +58 -0
- package/docs/reference/authoring-surface/type-httpdefinition.md +101 -0
- package/docs/reference/authoring-surface/type-httpinput.md +12 -0
- package/docs/reference/authoring-surface/type-httpmethod.md +14 -0
- package/docs/reference/authoring-surface/type-httpopenparts.md +12 -0
- package/docs/reference/authoring-surface/type-httpoutput.md +89 -0
- package/docs/reference/authoring-surface/type-httprequestparts.md +33 -0
- package/docs/reference/authoring-surface/type-jr2config.md +126 -0
- package/docs/reference/authoring-surface/type-lends.md +38 -0
- package/docs/reference/authoring-surface/type-logicat.md +12 -0
- package/docs/reference/authoring-surface/type-markedif.md +12 -0
- package/docs/reference/authoring-surface/type-mergedactors.md +12 -0
- package/docs/reference/authoring-surface/type-namedsecret.md +25 -0
- package/docs/reference/authoring-surface/type-noslotspec.md +26 -0
- package/docs/reference/authoring-surface/type-noslotworkspaceoptions.md +32 -0
- package/docs/reference/authoring-surface/type-openbodydeclaration.md +40 -0
- package/docs/reference/authoring-surface/type-openbodylogic.md +14 -0
- package/docs/reference/authoring-surface/type-openbodyoutput.md +42 -0
- package/docs/reference/authoring-surface/type-openbodyslot.md +18 -0
- package/docs/reference/authoring-surface/type-opencommandinput.md +17 -0
- package/docs/reference/authoring-surface/type-openeffect.md +12 -0
- package/docs/reference/authoring-surface/type-openeffectslots.md +12 -0
- package/docs/reference/authoring-surface/type-openof.md +12 -0
- package/docs/reference/authoring-surface/type-opensecret.md +23 -0
- package/docs/reference/authoring-surface/type-opensecretmark.md +12 -0
- package/docs/reference/authoring-surface/type-permissivenoslotworkspaceoptions.md +31 -0
- package/docs/reference/authoring-surface/type-permissiveworkspaceoptions.md +32 -0
- package/docs/reference/authoring-surface/type-poolmachine.md +28 -0
- package/docs/reference/authoring-surface/type-poolspec.md +60 -0
- package/docs/reference/authoring-surface/type-pushinput.md +15 -0
- package/docs/reference/authoring-surface/type-pushoptions.md +79 -0
- package/docs/reference/authoring-surface/type-pushoutput.md +124 -0
- package/docs/reference/authoring-surface/type-pushwireoutput.md +12 -0
- package/docs/reference/authoring-surface/type-quantity.md +14 -0
- package/docs/reference/authoring-surface/type-reached.md +12 -0
- package/docs/reference/authoring-surface/type-reposlot.md +70 -0
- package/docs/reference/authoring-surface/type-reposlotsof.md +16 -0
- package/docs/reference/authoring-surface/type-sandboxoptions.md +77 -0
- package/docs/reference/authoring-surface/type-secret.md +25 -0
- package/docs/reference/authoring-surface/type-sender.md +19 -0
- package/docs/reference/authoring-surface/type-sentbyguards.md +12 -0
- package/docs/reference/authoring-surface/type-size.md +85 -0
- package/docs/reference/authoring-surface/type-slothandles.md +32 -0
- package/docs/reference/authoring-surface/type-sourcenextresult.md +17 -0
- package/docs/reference/authoring-surface/type-sourcespec.md +44 -0
- package/docs/reference/authoring-surface/type-syncstate.md +12 -0
- package/docs/reference/authoring-surface/type-text.md +19 -0
- package/docs/reference/authoring-surface/type-textpart.md +24 -0
- package/docs/reference/authoring-surface/type-thinkinglevel.md +39 -0
- package/docs/reference/authoring-surface/type-toparameterizedobject.md +14 -0
- package/docs/reference/authoring-surface/type-toprovidedactor.md +14 -0
- package/docs/reference/authoring-surface/type-unknown.md +21 -0
- package/docs/reference/authoring-surface/type-usercontainer.md +77 -0
- package/docs/reference/authoring-surface/type-withoutemptyindex.md +20 -0
- package/docs/reference/authoring-surface/type-workflowevent.md +46 -0
- package/docs/reference/authoring-surface/type-workspaceaccess.md +16 -0
- package/docs/reference/authoring-surface/type-workspaced.md +94 -0
- package/docs/reference/authoring-surface/type-workspacehandles.md +23 -0
- package/docs/reference/authoring-surface/type-workspacemachine.md +29 -0
- package/docs/reference/authoring-surface/type-workspaceof.md +13 -0
- package/docs/reference/authoring-surface/type-workspaceoptions.md +39 -0
- package/docs/reference/authoring-surface/type-workspacespec.md +37 -0
- package/docs/reference/authoring-surface/workspace.md +259 -0
- package/docs/reference/authoring-surface.md +197 -0
- package/docs/reference/cli.md +433 -0
- package/docs/reference/config.md +756 -0
- package/docs/reference/decisions/0001-operator-owned-generic-sandbox.md +113 -0
- package/docs/reference/decisions/0002-duplex-streaming-actor.md +110 -0
- package/docs/reference/decisions/0004-worktree-provisioning-model.md +124 -0
- package/docs/reference/decisions/0005-sandbox-pod-composition.md +230 -0
- package/docs/reference/decisions/0006-agent-control-surface.md +47 -0
- package/docs/reference/decisions/0007-durable-machine-state.md +109 -0
- package/docs/reference/decisions/0008-orchestrator-as-deployed-app.md +60 -0
- package/docs/reference/decisions/0009-cli-and-instance-interface.md +275 -0
- package/docs/reference/decisions/0010-bdd-acceptance-tests.md +160 -0
- package/docs/reference/decisions/0011-workflow-defined-events.md +141 -0
- package/docs/reference/decisions/0012-workspace-wrapper-machine.md +103 -0
- package/docs/reference/decisions/0013-the-agent-reaches-its-machine-through-a-container-it-cannot-read.md +157 -0
- package/docs/reference/decisions/0014-observation-is-open-run-state-is-not.md +95 -0
- package/docs/reference/decisions/0015-authoring-surface-absorbs-the-mechanism.md +265 -0
- package/docs/reference/decisions/0016-agent-turn-mechanics-are-internal.md +124 -0
- package/docs/reference/decisions/0017-pool-over-a-source-port.md +69 -0
- package/docs/reference/decisions/0018-instance-agents-are-definitions-jr2-assembles-the-harness.md +111 -0
- package/docs/reference/decisions/0019-one-converging-command-against-the-current-context.md +275 -0
- package/docs/reference/decisions/0020-private-ca-trust-is-a-named-concept.md +60 -0
- package/docs/reference/decisions/0021-workspace-continuity-is-a-lease-that-answers-back.md +100 -0
- package/docs/reference/decisions/0022-observation-is-a-level-triggered-feed.md +103 -0
- package/docs/reference/decisions/0023-the-harness-prints-the-conversation.md +176 -0
- package/docs/reference/decisions/0024-an-agents-turn-ends-with-the-state-that-asked-for-it.md +439 -0
- package/docs/reference/decisions/0025-cancel-ends-a-run-stop-parks-it.md +65 -0
- package/docs/reference/decisions/0026-a-turn-that-is-over-is-never-prompted.md +62 -0
- package/docs/reference/decisions/0027-the-harness-is-jr2s-own-server-flue-retires-the-wire-stays.md +237 -0
- package/docs/reference/decisions/0028-what-an-agent-may-do-to-the-workspace-is-part-of-its-definition.md +89 -0
- package/docs/reference/decisions/0029-a-menu-is-fixed-for-a-conversation-and-a-turn-is-told-its-allowed-picks.md +154 -0
- package/docs/reference/decisions/0030-a-snapshot-names-the-machine-it-was-written-under.md +100 -0
- package/docs/reference/decisions/0031-menu-only-agents-run-on-the-instance-harness.md +190 -0
- package/docs/reference/decisions/0032-the-console-unlocks-with-the-instance-token.md +61 -0
- package/docs/reference/decisions/0033-a-machine-declares-the-input-that-starts-it.md +127 -0
- package/docs/reference/decisions/0034-the-console-renders-with-preact-and-the-server-erases-the-types.md +57 -0
- package/docs/reference/decisions/0035-a-runaway-turn-is-ended-rerolled-once-then-a-fault.md +92 -0
- package/docs/reference/decisions/0036-a-turn-compacts-its-own-context-mid-flight.md +138 -0
- package/docs/reference/decisions/0037-an-instance-builds-its-sandbox-images-jr2-injects-the-harness.md +196 -0
- package/docs/reference/decisions/0038-jr2-up-builds-every-image-it-deploys.md +170 -0
- package/docs/reference/decisions/0039-image-garbage-collects-by-reachability.md +115 -0
- package/docs/reference/decisions/0041-a-build-the-host-already-holds-is-not-spent-again.md +64 -0
- package/docs/reference/decisions/0042-ready-is-not-routable-an-admission-retries-its-connection.md +142 -0
- package/docs/reference/decisions/0043-the-kit-is-tested-as-installed-a-local-registry-stands-in-for-npm.md +170 -0
- package/docs/reference/decisions/0044-kit-images-live-at-a-canonical-home-a-self-host-mirrors-it.md +85 -0
- package/docs/reference/decisions/0045-the-platform-joins-the-image-address-and-the-cluster-chooses-it.md +82 -0
- package/docs/reference/decisions/0046-a-converge-failure-carries-its-diagnosis.md +34 -0
- package/docs/reference/decisions/0047-the-git-ssh-key-source-is-the-users-choice.md +47 -0
- package/docs/reference/decisions/0048-the-orchestrator-boots-without-its-repos.md +36 -0
- package/docs/reference/decisions/0049-a-machine-carries-its-parts-and-composes-by-invoke.md +126 -0
- package/docs/reference/decisions/0050-what-code-names-config-declares-what-the-cli-names-discovery-finds.md +73 -0
- package/docs/reference/decisions/0051-a-repo-is-a-slot-on-the-workspace-and-a-cache-on-the-node.md +227 -0
- package/docs/reference/decisions/0052-a-sandbox-lands-where-an-ordinary-pod-lands-and-the-cache-follows-it.md +81 -0
- package/docs/reference/decisions/0053-a-fetch-inside-the-pod-asks-the-node-cache-and-the-cache-asks-the-remote.md +111 -0
- package/docs/reference/decisions/0054-the-kit-ships-machines-and-a-shipped-machine-leaves-its-parts-open.md +126 -0
- package/docs/reference/decisions/0055-a-release-is-a-pushed-tag-and-the-publish-guard-is-the-credential.md +107 -0
- package/docs/reference/decisions/0056-a-global-jr2-hands-off-to-the-instances-own-and-an-instance-has-one-kit-version.md +62 -0
- package/docs/reference/decisions/0057-a-turn-is-its-frame-its-dials-and-whether-it-continues.md +152 -0
- package/docs/reference/decisions/0058-a-harness-answers-the-orchestrator-alone.md +106 -0
- package/docs/reference/decisions/0059-a-harness-holds-stand-ins-and-the-custodian-holds-the-keys.md +384 -0
- package/docs/reference/decisions/0060-a-workspace-states-its-size-and-jr2-reserves-all-of-it.md +111 -0
- package/docs/reference/decisions/0061-a-memory-kill-ends-the-agents-processes-not-the-harness.md +83 -0
- package/docs/reference/decisions/0062-jr2-briefs-the-agent-on-its-seat-never-on-its-task.md +212 -0
- package/docs/reference/decisions/0063-the-orchestrator-watches-the-cluster.md +83 -0
- package/docs/reference/decisions/0064-a-workspace-waits-for-capacity.md +79 -0
- package/docs/reference/decisions/0065-a-persist-survives-a-crash-and-history-is-a-week.md +65 -0
- package/docs/reference/decisions/0066-a-model-tier-runs-a-real-model-and-a-change-to-what-it-reads-runs-the-tier.md +550 -0
- package/docs/reference/decisions/0067-an-effect-is-an-actor-slot-and-runs-at-most-once-unless-marked-idempotent.md +430 -0
- package/docs/reference/decisions/0068-an-effector-holds-an-effects-secrets-and-signs-its-wire.md +464 -0
- package/docs/reference/decisions/0069-the-orchestrator-is-a-kit-image-and-an-instance-publishes-its-assembly.md +180 -0
- package/docs/reference/decisions/0070-a-machine-lends-its-parts-to-a-chart.md +323 -0
- package/docs/reference/decisions/0071-a-run-starts-a-run-through-a-dispatch-effect.md +183 -0
- package/docs/reference/decisions/0072-the-docs-ship-inside-the-kit-and-an-instances-agents-md-indexes-them.md +144 -0
- package/docs/reference/decisions/0073-a-machine-fails-a-run-with-fail-and-an-uncaught-failure-ends-it-failed.md +122 -0
- package/docs/reference/decisions/0074-an-unrouted-mechanism-event-is-refused-at-load-and-faults-the-run.md +76 -0
- package/docs/reference/decisions.md +83 -0
- package/docs/reference/glossary.md +648 -0
- package/docs/reference/http-api.md +1348 -0
- package/docs/reference/machines.md +190 -0
- package/docs/reference/mechanism-events.md +72 -0
- package/docs/why.md +206 -0
- package/package.json +6 -4
- package/src/actor.ts +976 -61
- package/src/agent.ts +114 -38
- package/src/ambient.ts +4 -1
- package/src/assembly-checks.ts +347 -0
- package/src/assembly-tar.ts +494 -0
- package/src/assembly.ts +721 -0
- package/src/cause.ts +234 -0
- package/src/chart.ts +2182 -0
- package/src/charts.ts +748 -0
- package/src/config.ts +520 -162
- package/src/content-hash.ts +105 -0
- package/src/custodian.lua +6 -5
- package/src/custodian.ts +143 -39
- package/src/customize.ts +388 -55
- package/src/dispatch-effect.ts +945 -0
- package/src/door-compat.ts +217 -0
- package/src/durability.ts +11 -4
- package/src/effect-checks.ts +557 -0
- package/src/effect.ts +1202 -0
- package/src/effector-wire.ts +674 -0
- package/src/fail.ts +209 -0
- package/src/fingerprint.ts +4 -3
- package/src/gate.ts +8 -0
- package/src/git-clone.ts +65 -0
- package/src/git-push.ts +336 -0
- package/src/harness-client.ts +764 -48
- package/src/held-secrets.ts +82 -26
- package/src/holds.ts +156 -0
- package/src/http-effect.ts +1423 -0
- package/src/http.ts +916 -113
- package/src/images.ts +47 -21
- package/src/index.ts +62 -43
- package/src/instance.ts +505 -59
- package/src/internal.ts +84 -0
- package/src/kube-client.ts +333 -0
- package/src/machine-doc.ts +106 -6
- package/src/mechanism-routes.ts +168 -0
- package/src/model-keys.generated.ts +53 -0
- package/src/model-keys.ts +18 -58
- package/src/mount-digest.ts +53 -0
- package/src/names.ts +66 -8
- package/src/notices.ts +141 -0
- package/src/open-body.ts +1580 -0
- package/src/open-effect.ts +217 -0
- package/src/open.ts +59 -6
- package/src/parts.ts +709 -41
- package/src/party.ts +534 -0
- package/src/pick.ts +761 -0
- package/src/pool.ts +248 -37
- package/src/registration.ts +382 -19
- package/src/repo-fetch.ts +60 -85
- package/src/repos.ts +65 -75
- package/src/rest.ts +133 -0
- package/src/run-host.ts +2383 -115
- package/src/run-output.ts +38 -0
- package/src/sandbox-effect.ts +1126 -0
- package/src/sandbox-effector.ts +111 -0
- package/src/sandbox-kube.ts +1721 -0
- package/src/sandbox-watch.ts +260 -0
- package/src/secret-placement.ts +529 -0
- package/src/server.ts +284 -84
- package/src/setup.ts +819 -117
- package/src/size.ts +454 -0
- package/src/snapshot-store.ts +115 -20
- package/src/stub-harness.ts +49 -16
- package/src/sync-client.ts +54 -0
- package/src/timers.ts +191 -0
- package/src/tokens.ts +35 -4
- package/src/vocabulary.ts +135 -15
- package/src/wire.ts +238 -10
- package/src/workspace.ts +1195 -338
- package/src/sandbox-kubectl.ts +0 -1108
package/src/fail.ts
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// The `fail()` Part (ADR-0073): the end that a Machine chooses for work that did not succeed — a
|
|
2
|
+
// Failure (GLOSSARY.md). It is a Part and not an Effect, because it does no work outside the
|
|
3
|
+
// Machine: its actor throws an error that carries a brand, and xstate sends that error up through
|
|
4
|
+
// each parent Machine by its own rules. An `onError` on the way catches it; `isFailure` tells it
|
|
5
|
+
// from a defect. When no state catches it, the run host ends the run `failed`.
|
|
6
|
+
//
|
|
7
|
+
// The brand is a registry symbol (`Symbol.for`), not a module symbol, for the reason the walk is
|
|
8
|
+
// structural (parts.ts): an Instance resolves its OWN `@jr2/orchestrator`, so the copy that throws
|
|
9
|
+
// and the copy that reads the error (the run host, the CLI's walk) can be two module instances. The
|
|
10
|
+
// brand is not exported: an author makes a Failure only with `fail()`.
|
|
11
|
+
|
|
12
|
+
import { fromPromise, type PromiseActorLogic } from "xstate";
|
|
13
|
+
|
|
14
|
+
const FAILURE: unique symbol = Symbol.for("jr2.failure") as never;
|
|
15
|
+
const FAIL_SLOT: unique symbol = Symbol.for("jr2.fail.slot") as never;
|
|
16
|
+
|
|
17
|
+
/** What a Failure carries: `reason`, text for a person, and `detail`, optional JSON for a machine
|
|
18
|
+
* reader. The input of `fail()` and the output of a run that ends `failed`. */
|
|
19
|
+
export type FailureInput = { reason: string; detail?: unknown };
|
|
20
|
+
|
|
21
|
+
/** A Failure as `isFailure` narrows an error to it: the payload's fields, on the error itself. */
|
|
22
|
+
export type Failure = { readonly reason: string; readonly detail?: unknown };
|
|
23
|
+
|
|
24
|
+
/** Make the branded error. Only this module makes one. */
|
|
25
|
+
function failure(input: FailureInput): Error & Failure {
|
|
26
|
+
const error = new Error(input.reason) as Error & Failure & { [FAILURE]: true };
|
|
27
|
+
error.name = "Failure";
|
|
28
|
+
Object.defineProperty(error, "reason", { value: input.reason, enumerable: true });
|
|
29
|
+
if (input.detail !== undefined) Object.defineProperty(error, "detail", { value: input.detail, enumerable: true });
|
|
30
|
+
Object.defineProperty(error, FAILURE, { value: true });
|
|
31
|
+
return error;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Refuse an input that is not a Failure's. A defect of the Machine, not a Failure: it ends the run
|
|
35
|
+
* `error`, with this text as the fault. */
|
|
36
|
+
function checked(raw: unknown): FailureInput {
|
|
37
|
+
const input = raw as Partial<FailureInput> | undefined;
|
|
38
|
+
if (typeof input !== "object" || input === null || typeof input.reason !== "string" || input.reason === "") {
|
|
39
|
+
throw new Error(
|
|
40
|
+
`fail: the input must be { reason: string; detail?: unknown } with a reason that is not empty (got ${describe(raw)}) — ` +
|
|
41
|
+
`invoke: { src: "fail", input: { reason: "the tests failed" } } (ADR-0073)`,
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
return input.detail === undefined ? { reason: input.reason } : { reason: input.reason, detail: input.detail };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function describe(value: unknown): string {
|
|
48
|
+
try {
|
|
49
|
+
return JSON.stringify(value) ?? String(value);
|
|
50
|
+
} catch {
|
|
51
|
+
return String(value);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The actor logic that throws. Unbranded: what `workspace()` invokes to send a caught Failure up
|
|
56
|
+
* again, which is the wrapper's mechanism and no state of the author's that fails the run. */
|
|
57
|
+
export const failLogic: PromiseActorLogic<never, FailureInput> = fromPromise<never, FailureInput>(async ({ input }) => {
|
|
58
|
+
throw failure(checked(input));
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
/** The logic `fail()` returns: {@link failLogic} with the brand the walk reads. */
|
|
62
|
+
export type FailSlot = PromiseActorLogic<never, FailureInput> & { readonly [FAIL_SLOT]: true };
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Fail the run: the Part a Machine invokes to end work that did not succeed. Put it in `actors`
|
|
66
|
+
* under a name, and invoke it from any state, at any depth: `actors: { fail: fail() }`, then
|
|
67
|
+
* `invoke: { src: "fail", input: { reason } }`.
|
|
68
|
+
*
|
|
69
|
+
* The input is `{ reason: string; detail?: unknown }`. `reason` is text for a person, and it must
|
|
70
|
+
* not be empty. `detail` is optional JSON for a machine reader.
|
|
71
|
+
*
|
|
72
|
+
* The Failure goes up from the state that invokes `fail`, through each parent Machine, as an error
|
|
73
|
+
* does. An `onError` on the way catches it; {@link isFailure} tells it from a defect. When no state
|
|
74
|
+
* catches it, the run ends `failed`: its output is `{ reason, detail }`, and `jr2 run` exits 1. A
|
|
75
|
+
* `workspace()` that a Failure goes through deletes its Sandbox first.
|
|
76
|
+
*
|
|
77
|
+
* To fail, invoke `fail`. Do not throw from a promise to fail: a throw is a defect, and the run
|
|
78
|
+
* ends `error`. A final state means `done`: do not use one for work that did not succeed. To keep
|
|
79
|
+
* the work for a person to read, wait at a Gate before the state that fails.
|
|
80
|
+
*
|
|
81
|
+
* It refuses an input that is not a Failure's, and the run ends `error`: ``fail: the input must be
|
|
82
|
+
* { reason: string; detail?: unknown } with a reason that is not empty (got <value>) — invoke:
|
|
83
|
+
* { src: "fail", input: { reason: "the tests failed" } } (ADR-0073)``.
|
|
84
|
+
*
|
|
85
|
+
* @returns The actor logic of the slot.
|
|
86
|
+
*
|
|
87
|
+
* @example
|
|
88
|
+
* ```ts
|
|
89
|
+
* import { z } from "zod";
|
|
90
|
+
* import { command, fail, jr2Setup, workspace, type Workspaced } from "@jr2/orchestrator";
|
|
91
|
+
*
|
|
92
|
+
* const runInput = z.object({ branch: z.string() });
|
|
93
|
+
*
|
|
94
|
+
* const body = jr2Setup({
|
|
95
|
+
* types: {} as { input: Workspaced<z.infer<typeof runInput>, "app"> },
|
|
96
|
+
* actors: {
|
|
97
|
+
* tests: command(["npm", "test"], { timeout: "15m" }),
|
|
98
|
+
* fail: fail(),
|
|
99
|
+
* },
|
|
100
|
+
* }).createMachine({
|
|
101
|
+
* id: "check",
|
|
102
|
+
* on: { "workspace.lost": ".lost" },
|
|
103
|
+
* initial: "testing",
|
|
104
|
+
* states: {
|
|
105
|
+
* testing: {
|
|
106
|
+
* invoke: {
|
|
107
|
+
* src: "tests",
|
|
108
|
+
* onDone: [
|
|
109
|
+
* {
|
|
110
|
+
* guard: ({ event }) => event.output.outcome === "exited" && event.output.code === 0,
|
|
111
|
+
* target: "passed",
|
|
112
|
+
* },
|
|
113
|
+
* { target: "failing" },
|
|
114
|
+
* ],
|
|
115
|
+
* },
|
|
116
|
+
* },
|
|
117
|
+
* // Not a final state: the run ends `failed`, and the Workspace deletes the Sandbox.
|
|
118
|
+
* failing: { invoke: { src: "fail", input: { reason: "npm test did not pass" } } },
|
|
119
|
+
* passed: { type: "final" },
|
|
120
|
+
* lost: { invoke: { src: "fail", input: { reason: "the Sandbox is gone" } } },
|
|
121
|
+
* },
|
|
122
|
+
* });
|
|
123
|
+
*
|
|
124
|
+
* export const machine = workspace(body, {
|
|
125
|
+
* input: runInput,
|
|
126
|
+
* repos: { app: "https://github.com/you/app.git" },
|
|
127
|
+
* spec: ({ input }) => ({ branch: input.branch }),
|
|
128
|
+
* });
|
|
129
|
+
* ```
|
|
130
|
+
*
|
|
131
|
+
* @see docs/reference/decisions/0073-a-machine-fails-a-run-with-fail-and-an-uncaught-failure-ends-it-failed.md
|
|
132
|
+
*/
|
|
133
|
+
export function fail(): FailSlot {
|
|
134
|
+
// Logic of its own per slot, so the brand is on this slot's logic and on no other's.
|
|
135
|
+
const logic = fromPromise<never, FailureInput>(async ({ input }) => {
|
|
136
|
+
throw failure(checked(input));
|
|
137
|
+
});
|
|
138
|
+
return Object.defineProperty(logic, FAIL_SLOT, { value: true }) as FailSlot;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Is this actor logic a `fail()` slot? A test on the brand, like `isAgent`: what the walk reads to
|
|
142
|
+
* list each state that can fail the run (ADR-0073). */
|
|
143
|
+
export function isFailSlot(logic: unknown): logic is FailSlot {
|
|
144
|
+
return typeof logic === "object" && logic !== null && (logic as { [FAIL_SLOT]?: unknown })[FAIL_SLOT] === true;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Is this error a Failure, which a state invoked `fail` to make? A defect — any other error — is
|
|
149
|
+
* not. Use it in the guard of an `onError` to catch a Failure and let a defect go up, and read the
|
|
150
|
+
* Failure's `reason` and `detail` from the error it narrows.
|
|
151
|
+
*
|
|
152
|
+
* @param error - The error of an error event: `event.error` in an `onError`.
|
|
153
|
+
* @returns True for a Failure, with `error` narrowed to `{ reason, detail? }`.
|
|
154
|
+
*
|
|
155
|
+
* @example
|
|
156
|
+
* ```ts
|
|
157
|
+
* import { z } from "zod";
|
|
158
|
+
* import { defineEvent, fail, isFailure, jr2Setup } from "@jr2/orchestrator";
|
|
159
|
+
*
|
|
160
|
+
* const retry = defineEvent({ name: "retry", description: "Try the plan again.", input: z.object({}) });
|
|
161
|
+
*
|
|
162
|
+
* const attempt = jr2Setup({ actors: { fail: fail() } }).createMachine({
|
|
163
|
+
* id: "attempt",
|
|
164
|
+
* initial: "stuck",
|
|
165
|
+
* states: { stuck: { invoke: { src: "fail", input: { reason: "no plan fits the ticket" } } } },
|
|
166
|
+
* });
|
|
167
|
+
*
|
|
168
|
+
* export const machine = jr2Setup({ events: [retry], actors: { attempt } }).createMachine({
|
|
169
|
+
* id: "planner",
|
|
170
|
+
* initial: "planning",
|
|
171
|
+
* states: {
|
|
172
|
+
* planning: {
|
|
173
|
+
* invoke: {
|
|
174
|
+
* src: "attempt",
|
|
175
|
+
* onDone: "planned",
|
|
176
|
+
* // A Failure waits for a person; a defect has no route, so it goes up and the run ends `error`.
|
|
177
|
+
* onError: { guard: ({ event }) => isFailure(event.error), target: "asking" },
|
|
178
|
+
* },
|
|
179
|
+
* },
|
|
180
|
+
* asking: { invoke: { src: "gate" }, on: { retry: "planning" } },
|
|
181
|
+
* planned: { type: "final" },
|
|
182
|
+
* },
|
|
183
|
+
* });
|
|
184
|
+
* ```
|
|
185
|
+
*
|
|
186
|
+
* @see docs/reference/decisions/0073-a-machine-fails-a-run-with-fail-and-an-uncaught-failure-ends-it-failed.md
|
|
187
|
+
*/
|
|
188
|
+
export function isFailure(error: unknown): error is Failure {
|
|
189
|
+
return (
|
|
190
|
+
typeof error === "object" &&
|
|
191
|
+
error !== null &&
|
|
192
|
+
(error as { [FAILURE]?: unknown })[FAILURE] === true &&
|
|
193
|
+
typeof (error as { reason?: unknown }).reason === "string"
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** The name of the guard that every Chart has (ADR-0070, ADR-0073): {@link isFailure} on the
|
|
198
|
+
* event's `error`, so a Chart's `onError` can tell a Failure from a defect. It is a predicate and
|
|
199
|
+
* gives no capability, so it is never lent, as `jr2.sentBy` is not. */
|
|
200
|
+
export const FAILURE_GUARD = "jr2.failure";
|
|
201
|
+
|
|
202
|
+
/** The implementation of {@link FAILURE_GUARD}: true when the event's `error` is a Failure. */
|
|
203
|
+
export const failureGuard = ({ event }: { event: { error?: unknown } }): boolean => isFailure(event.error);
|
|
204
|
+
|
|
205
|
+
/** A Failure's payload as plain JSON data: what a failed run's output is, and what `workspace()`
|
|
206
|
+
* keeps to send it up again. */
|
|
207
|
+
export function failurePayload(error: Failure): FailureInput {
|
|
208
|
+
return error.detail === undefined ? { reason: error.reason } : { reason: error.reason, detail: error.detail };
|
|
209
|
+
}
|
package/src/fingerprint.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// The content address of a Machine's SHAPE (ADR-0030). A persisted snapshot names the Machine it
|
|
2
|
-
// was written under; restore compares and refuses a mismatch
|
|
3
|
-
//
|
|
4
|
-
// a
|
|
2
|
+
// was written under; restore compares and refuses a mismatch. A run restores inside the Assembly
|
|
3
|
+
// its snapshot names (ADR-0069), and the same bytes give the same Machine, so inside one Assembly
|
|
4
|
+
// a mismatch means a hash collision or a hand-edited volume. An unstamped blob is still matched to
|
|
5
|
+
// its Machine by workflow name alone, and there the check is the only guard.
|
|
5
6
|
//
|
|
6
7
|
// What it hashes is RESTORABILITY, not behavior: the facts that decide whether an old snapshot can
|
|
7
8
|
// still be interpreted — state ids and nesting, which state is initial, what each state invokes and
|
package/src/gate.ts
CHANGED
|
@@ -70,6 +70,14 @@ export const gate = fromCallback<DeliveredEvent, GateInput | undefined>(({ input
|
|
|
70
70
|
path,
|
|
71
71
|
defs,
|
|
72
72
|
meta: input.meta,
|
|
73
|
+
// A Gate's caller sends as the Gate's invoke id (ADR-0015): static in the config (authored, or
|
|
74
|
+
// named by the walk), the same after a restore, and checkable at load — unlike the
|
|
75
|
+
// caller-facing `id`, which may be computed at run time.
|
|
76
|
+
sender: { party: "gate", id: self.id },
|
|
77
|
+
invokeId: self.id,
|
|
78
|
+
// The Machine that invoked the Gate: the one whose selection says which writing a caller's
|
|
79
|
+
// event would run (ADR-0029's question, asked for the Gate's caller too).
|
|
80
|
+
invoker: self._parent,
|
|
73
81
|
deliver: (event) => sendBack(event),
|
|
74
82
|
});
|
|
75
83
|
return dispose;
|
package/src/git-clone.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// The clone credentials (ADR-0051, ADR-0068): the token a Repo's cache clone spends. `jr2 up`
|
|
2
|
+
// declares and writes every Secret that holds a git credential; the Orchestrator matches and names,
|
|
3
|
+
// and holds no git credential.
|
|
4
|
+
//
|
|
5
|
+
// - `jr2 up` writes each `git.credentials` entry's token, read from `.env`, into that entry's
|
|
6
|
+
// own Secret, `jr2-git-<hash of match>` (`gitTokenSecretName`), in Flux's key names
|
|
7
|
+
// (`username`/`password`), as it writes the entry's push Secret. An entry whose token variable
|
|
8
|
+
// is unset gets no Secret. And it writes `git-clone.json` in `jr2-held`: which clone Secrets it
|
|
9
|
+
// wrote, names only.
|
|
10
|
+
// - The Orchestrator resolves a Repo's url against `git.credentials` and names the matched
|
|
11
|
+
// entry's Secret as the Repo CR's `secretRef` when `git-clone.json` says it exists. Otherwise
|
|
12
|
+
// the CR carries no `secretRef` and the clone is anonymous.
|
|
13
|
+
// - The operator's cache agent reads the Secret the `secretRef` names, and spends it.
|
|
14
|
+
|
|
15
|
+
import { readFile } from "node:fs/promises";
|
|
16
|
+
import { gitTokenSecretName, type GitCredential } from "./config.ts";
|
|
17
|
+
|
|
18
|
+
/** The `jr2-held` key of the clone manifest. */
|
|
19
|
+
export const GIT_CLONE_MANIFEST_KEY = "git-clone.json";
|
|
20
|
+
/** The label every entry's clone Secret carries, so a stale one is found. */
|
|
21
|
+
export const LABEL_GIT_CLONE = "jr2.dev/git-clone";
|
|
22
|
+
|
|
23
|
+
/** `git-clone.json`: the clone Secrets `jr2 up` wrote, by name. */
|
|
24
|
+
export type GitCloneManifest = { version: 1; secrets: string[] };
|
|
25
|
+
|
|
26
|
+
/** One entry's clone Secret, as `jr2 up` writes it. */
|
|
27
|
+
export type GitCloneSecret = { match: string; name: string; data: { username: string; password: string } };
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Each `git.credentials` entry's clone Secret: the entries with a `token` whose variable is set.
|
|
31
|
+
* The values are plain strings; the caller encodes them. Two entries never share a name.
|
|
32
|
+
*/
|
|
33
|
+
export function gitCloneSecretsOf(
|
|
34
|
+
list: readonly GitCredential[],
|
|
35
|
+
env: (name: string) => string | undefined,
|
|
36
|
+
): GitCloneSecret[] {
|
|
37
|
+
const out: GitCloneSecret[] = [];
|
|
38
|
+
for (const entry of list) {
|
|
39
|
+
if (entry.token === undefined) continue;
|
|
40
|
+
const value = env(entry.token);
|
|
41
|
+
if (!value) continue;
|
|
42
|
+
out.push({
|
|
43
|
+
match: entry.match,
|
|
44
|
+
name: gitTokenSecretName(entry.match),
|
|
45
|
+
data: { username: "x-access-token", password: value },
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The manifest the Orchestrator reads, or an empty one when `jr2 up` wrote no clone Secret. */
|
|
52
|
+
export async function readGitCloneManifest(path: string): Promise<GitCloneManifest> {
|
|
53
|
+
let raw: string;
|
|
54
|
+
try {
|
|
55
|
+
raw = await readFile(path, "utf8");
|
|
56
|
+
} catch (err) {
|
|
57
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return { version: 1, secrets: [] };
|
|
58
|
+
throw err;
|
|
59
|
+
}
|
|
60
|
+
const parsed = JSON.parse(raw) as GitCloneManifest;
|
|
61
|
+
if (parsed.version !== 1 || !Array.isArray(parsed.secrets)) {
|
|
62
|
+
throw new Error(`${path} is not a clone manifest jr2 reads (version 1)`);
|
|
63
|
+
}
|
|
64
|
+
return parsed;
|
|
65
|
+
}
|
package/src/git-push.ts
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
// The push credentials (ADR-0051, ADR-0067): a `push()` spends the `git.credentials` entry its
|
|
2
|
+
// Repo Slot's url matches — longest prefix, the attach match — and that entry's push credential
|
|
3
|
+
// reaches the pod by reference, placed by the pod's maker when the url is known.
|
|
4
|
+
//
|
|
5
|
+
// - `jr2 up` writes each entry's push and sign credentials into that entry's own Secret,
|
|
6
|
+
// `jr2-git-push-<hash of match>`, never per Workspace: Flux's key names where Flux has them
|
|
7
|
+
// (`username`/`password` for a token, `identity`/`identity.pub`/`known_hosts` for an ssh key),
|
|
8
|
+
// `sign.identity` or `sign.private.asc` for the signing key, and `entry.json` — what the entry
|
|
9
|
+
// is, names only. A token is the `.env` value; a key is copied as data from the Secret the entry
|
|
10
|
+
// names, never decoded. And it writes the manifest `git-push.json` in `jr2-held`: which entries
|
|
11
|
+
// it wrote and what each can spend, names only.
|
|
12
|
+
// - At provision the Orchestrator resolves each pushed slot's url against the fence and names the
|
|
13
|
+
// matched entry's Secret on the Sandbox CR (`gitCredentials`, the Repo CR's `secretRef` shape);
|
|
14
|
+
// the operator projects it into the Sandbox Effector at `@git/<its name>/`. Static, per-run and
|
|
15
|
+
// `customize()`-bound urls take this one path, and the Orchestrator reads no value.
|
|
16
|
+
//
|
|
17
|
+
// The refusal is one function for both sides: `jr2 up` for a static Binding, from the entry as
|
|
18
|
+
// written; the provision for any url, from what `jr2 up` wrote.
|
|
19
|
+
|
|
20
|
+
import { createHash } from "node:crypto";
|
|
21
|
+
import { readFile } from "node:fs/promises";
|
|
22
|
+
import { matchCredential, type GitCredential } from "./config.ts";
|
|
23
|
+
import { repoIdentity } from "./repo-identity.ts";
|
|
24
|
+
|
|
25
|
+
/** The `jr2-held` key of the push manifest. */
|
|
26
|
+
export const GIT_PUSH_MANIFEST_KEY = "git-push.json";
|
|
27
|
+
/** The label every entry's push Secret carries, so a stale one is found. */
|
|
28
|
+
export const LABEL_GIT_PUSH = "jr2.dev/git-push";
|
|
29
|
+
|
|
30
|
+
/** One entry's push Secret: deterministic in `match`, so a redeploy finds its own and two entries
|
|
31
|
+
* never share one. Apart from the clone token's Secret (`jr2-git-<hash>`), which the Orchestrator
|
|
32
|
+
* writes for the cache agent. */
|
|
33
|
+
export function gitPushSecretName(match: string): string {
|
|
34
|
+
return `jr2-git-push-${createHash("sha256").update(match).digest("hex").slice(0, 8)}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The keys of an entry's push Secret — the operator projects exactly these, each when present. */
|
|
38
|
+
export const GIT_PUSH_KEYS = {
|
|
39
|
+
entry: "entry.json",
|
|
40
|
+
username: "username",
|
|
41
|
+
password: "password",
|
|
42
|
+
identity: "identity",
|
|
43
|
+
identityPub: "identity.pub",
|
|
44
|
+
knownHosts: "known_hosts",
|
|
45
|
+
signSsh: "sign.identity",
|
|
46
|
+
signGpg: "sign.private.asc",
|
|
47
|
+
} as const;
|
|
48
|
+
|
|
49
|
+
export type PushKind = "ssh" | "token";
|
|
50
|
+
|
|
51
|
+
/** One entry's push Secret as `jr2 up` wrote it: names only. */
|
|
52
|
+
export type GitPushEntry = {
|
|
53
|
+
match: string;
|
|
54
|
+
/** Its place in `git.credentials`: a tie on the match's length goes to the first. */
|
|
55
|
+
order: number;
|
|
56
|
+
secret: string;
|
|
57
|
+
/** What it can push with. */
|
|
58
|
+
push: PushKind[];
|
|
59
|
+
sign?: "ssh" | "gpg";
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/** `git-push.json`: every entry `jr2 up` wrote a push Secret for. */
|
|
63
|
+
export type GitPushManifest = { version: 1; entries: GitPushEntry[] };
|
|
64
|
+
|
|
65
|
+
/** The push credentials an entry declares: its `push` field alone when it has one; otherwise the
|
|
66
|
+
* clone credentials it holds, each spent over its own scheme. None: it admits a clone, never a push. */
|
|
67
|
+
export function declaredPushKinds(entry: GitCredential): PushKind[] {
|
|
68
|
+
if (entry.push) return ["sshKey" in entry.push ? "ssh" : "token"];
|
|
69
|
+
return [...(entry.sshKey ? (["ssh"] as const) : []), ...(entry.token ? (["token"] as const) : [])];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** What a push of one url needs from the entry it matches. */
|
|
73
|
+
export type PushNeed = {
|
|
74
|
+
/** For the message: `workflow "w": push "p" pushes Repo Slot "app" (url)`. */
|
|
75
|
+
at: string;
|
|
76
|
+
url: string;
|
|
77
|
+
sign: boolean;
|
|
78
|
+
/** It squashes: unsigned, the entry's `committer` makes the commit (ADR-0067). */
|
|
79
|
+
squash?: boolean;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Why a push of `need.url` cannot be spent, with the fix that works, or undefined. `kinds` says
|
|
84
|
+
* what an entry can push with — as written (`jr2 up`, `declaredPushKinds`) or as placed (the
|
|
85
|
+
* provision, the manifest; undefined when `jr2 up` placed nothing for it); `signs` whether it holds
|
|
86
|
+
* a signing key. Refused: a local path and `git://` (jr2 never pushes there), `http://` (a
|
|
87
|
+
* credential over cleartext http is the credential in the clear: a token goes over https only and
|
|
88
|
+
* an ssh key over ssh only), a url no entry matches, an entry whose credentials the url's scheme
|
|
89
|
+
* cannot spend, `sign: true` on an entry with no `sign` field, and an unsigned squash on an entry
|
|
90
|
+
* with neither `sign` nor `committer` (ADR-0067: the squashed commit is new, and needs a committer).
|
|
91
|
+
*
|
|
92
|
+
* Each fix is one the next check accepts: the match it names is a prefix of the url's identity;
|
|
93
|
+
* an `http://` url is told the url its entry CAN push to, or the https url and the token to add;
|
|
94
|
+
* a field it names to add never takes away a credential the entry already pushes with.
|
|
95
|
+
*/
|
|
96
|
+
export function pushRefusal(
|
|
97
|
+
need: PushNeed,
|
|
98
|
+
list: readonly GitCredential[],
|
|
99
|
+
kinds: (entry: GitCredential) => readonly PushKind[] | undefined,
|
|
100
|
+
signs: (entry: GitCredential) => boolean,
|
|
101
|
+
): { refusal: string } | { entry: GitCredential } {
|
|
102
|
+
const { at, url } = need;
|
|
103
|
+
const { scheme, identity } = repoIdentity(url);
|
|
104
|
+
if (scheme === "local") {
|
|
105
|
+
return {
|
|
106
|
+
refusal: `${at}, a local path — jr2 never pushes to one: git's local transport runs that repository's config and hooks (ADR-0067)`,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
if (scheme === "git") {
|
|
110
|
+
return { refusal: `${at} over git://, which carries no credential — bind the slot over https or ssh (ADR-0051)` };
|
|
111
|
+
}
|
|
112
|
+
const entry = matchCredential(identity, list);
|
|
113
|
+
const held = entry === undefined ? [] : (kinds(entry) ?? []);
|
|
114
|
+
if (scheme === "http") {
|
|
115
|
+
const why =
|
|
116
|
+
`${at} over http://, and jr2 sends a push credential over https or ssh only — a token over http is the ` +
|
|
117
|
+
"token in the clear, and no ssh key is spent over http";
|
|
118
|
+
if (entry === undefined) {
|
|
119
|
+
return {
|
|
120
|
+
refusal:
|
|
121
|
+
`${why}; bind the slot to the https url, and add the entry it then spends: ` +
|
|
122
|
+
`git: { credentials: [{ match: "${matchFor(identity)}", token: "${TOKEN_ENV}" }] } (ADR-0051)`,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
if (held.includes("token")) {
|
|
126
|
+
return {
|
|
127
|
+
refusal: `${why}; bind the slot to the https url (git.credentials entry "${entry.match}" then pushes with its token) (ADR-0051)`,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
if (held.includes("ssh")) {
|
|
131
|
+
return {
|
|
132
|
+
refusal: `${why}; bind the slot to the ssh url (git.credentials entry "${entry.match}" then pushes with its ssh key) (ADR-0051)`,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
return {
|
|
136
|
+
refusal:
|
|
137
|
+
`${why}; bind the slot to the https url, and add ${addLine(entry, "token")} to git.credentials entry ` +
|
|
138
|
+
`"${entry.match}" — it holds no push credential (ADR-0051)`,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
const want: PushKind = scheme === "ssh" ? "ssh" : "token";
|
|
142
|
+
if (entry === undefined) {
|
|
143
|
+
const field = want === "ssh" ? `sshKey: "${SSH_SECRET}"` : `token: "${TOKEN_ENV}"`;
|
|
144
|
+
return {
|
|
145
|
+
refusal:
|
|
146
|
+
`${at}, and no git.credentials entry matches "${identity}" — a push spends the entry its url ` +
|
|
147
|
+
`matches; add one: git: { credentials: [{ match: "${matchFor(identity)}", ${field} }] } (ADR-0051)`,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
if (!held.includes(want)) {
|
|
151
|
+
const other: PushKind = want === "ssh" ? "token" : "ssh";
|
|
152
|
+
const why = `${at}, and git.credentials entry "${entry.match}" holds no credential a push over ${scheme} can spend`;
|
|
153
|
+
// Its `push` field is the one credential it pushes with, and the field names one: the url that
|
|
154
|
+
// spends it is the fix, as a second credential there would take the first away.
|
|
155
|
+
if (entry.push !== undefined && held.includes(other)) {
|
|
156
|
+
return {
|
|
157
|
+
refusal:
|
|
158
|
+
`${why} — its push field names ${other === "ssh" ? "an ssh key" : "a token"}, and a push field names ` +
|
|
159
|
+
`one credential; bind the slot to ${other === "ssh" ? "the ssh url" : "the https url"} (ADR-0051)`,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
return {
|
|
163
|
+
refusal:
|
|
164
|
+
held.length === 0
|
|
165
|
+
? `${why} — an entry with none admits a clone, never a push; add ${addLine(entry, want)} to it (ADR-0051)`
|
|
166
|
+
: `${why}; add ${addLine(entry, want)} to it (ADR-0051)`,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
if (need.sign && !signs(entry)) {
|
|
170
|
+
return {
|
|
171
|
+
refusal:
|
|
172
|
+
`${at} signed, and git.credentials entry "${entry.match}" holds no sign field — add ` +
|
|
173
|
+
`sign: { sshKey: "<a Secret with identity>", name: "<committer name>", email: "<committer email>" } ` +
|
|
174
|
+
"to it; the email must belong to the forge account that holds the key (ADR-0051, ADR-0067)",
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
if (need.squash && !need.sign && entry.sign === undefined && entry.committer === undefined) {
|
|
178
|
+
return {
|
|
179
|
+
refusal:
|
|
180
|
+
`${at} squashed without sign, and git.credentials entry "${entry.match}" holds neither sign nor committer ` +
|
|
181
|
+
"— the squashed commit is new and needs a committer; add " +
|
|
182
|
+
`committer: { name: "<committer name>", email: "<committer email>" } to it, or sign the push — ` +
|
|
183
|
+
`push({ squash: true, sign: true }) — and add sign: { sshKey: "<a Secret with identity>", ` +
|
|
184
|
+
`name: "<committer name>", email: "<committer email>" } to it (ADR-0051, ADR-0067)`,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
return { entry };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const TOKEN_ENV = "<the env var that holds the token>";
|
|
191
|
+
const SSH_SECRET = "<a Secret with identity and known_hosts>";
|
|
192
|
+
|
|
193
|
+
/** The `match` a new entry needs to catch `identity`: its host and every directory above the
|
|
194
|
+
* repository — always a prefix of the identity (`gitea.local/app` → `gitea.local/`). */
|
|
195
|
+
function matchFor(identity: string): string {
|
|
196
|
+
return identity.slice(0, identity.lastIndexOf("/") + 1);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** The field that gives `entry` a push credential of `kind` and keeps each one it pushes with: a
|
|
200
|
+
* clone credential (`token`, `sshKey`) when it holds one and no `push` field — a clone credential
|
|
201
|
+
* pushes too, and a `push` field would replace it — else a `push` field. */
|
|
202
|
+
function addLine(entry: GitCredential, kind: PushKind): string {
|
|
203
|
+
const value = kind === "ssh" ? `sshKey: "${SSH_SECRET}"` : `token: "${TOKEN_ENV}"`;
|
|
204
|
+
if (entry.push === undefined && (entry.token !== undefined || entry.sshKey !== undefined)) return value;
|
|
205
|
+
return `push: { ${value} }`;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** The refusal against the manifest `jr2 up` wrote: what the provision checks for any url. An entry
|
|
209
|
+
* whose config holds what the push needs, and whose Secret `jr2 up` wrote without it — a token
|
|
210
|
+
* unset, a key's Secret absent at the last `jr2 up` — is refused as that, never as a field to add. */
|
|
211
|
+
export function placedPushRefusal(
|
|
212
|
+
need: PushNeed,
|
|
213
|
+
list: readonly GitCredential[],
|
|
214
|
+
manifest: GitPushManifest,
|
|
215
|
+
): { refusal: string } | { entry: GitPushEntry } {
|
|
216
|
+
const placed = (entry: GitCredential) => manifest.entries.find((e) => e.match === entry.match);
|
|
217
|
+
const out = pushRefusal(
|
|
218
|
+
need,
|
|
219
|
+
list,
|
|
220
|
+
(entry) => placed(entry)?.push,
|
|
221
|
+
(entry) => placed(entry)?.sign !== undefined,
|
|
222
|
+
);
|
|
223
|
+
if (!("refusal" in out)) return { entry: placed(out.entry)! };
|
|
224
|
+
const written = pushRefusal(need, list, declaredPushKinds, (entry) => entry.sign !== undefined);
|
|
225
|
+
// An http:// url is never pushed: the url to bind instead is read from the entry as written, so
|
|
226
|
+
// a token whose variable was unset is named as the entry's, and the https url then says so.
|
|
227
|
+
if (repoIdentity(need.url).scheme === "http" && "refusal" in written) return written;
|
|
228
|
+
// As written, would the entry push? Then what is missing is what `jr2 up` could not place.
|
|
229
|
+
if ("refusal" in written) return out;
|
|
230
|
+
const entry = written.entry;
|
|
231
|
+
const what =
|
|
232
|
+
placed(entry) === undefined
|
|
233
|
+
? "no push credential"
|
|
234
|
+
: need.sign && placed(entry)!.sign === undefined
|
|
235
|
+
? "no signing key"
|
|
236
|
+
: "no credential for this url's scheme";
|
|
237
|
+
return {
|
|
238
|
+
refusal:
|
|
239
|
+
`${need.at}, and jr2 up placed ${what} for git.credentials entry "${entry.match}" — its token's variable was ` +
|
|
240
|
+
"not set, or a Secret it names did not exist, when jr2 up last ran; set it or create it, and run jr2 up again " +
|
|
241
|
+
"(ADR-0051)",
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** One key of an entry's push Secret, and where its value comes from: a token's value, or a key of
|
|
246
|
+
* the Secret the entry names, copied as data. */
|
|
247
|
+
export type GitPushSource =
|
|
248
|
+
| { key: string; literal: string }
|
|
249
|
+
| { key: string; from: { secret: string; key: string }; optional?: boolean };
|
|
250
|
+
|
|
251
|
+
/** One entry's push Secret, as `jr2 up` plans it before it reads the cluster. */
|
|
252
|
+
export type GitPushPlan = {
|
|
253
|
+
entry: GitPushEntry;
|
|
254
|
+
sources: GitPushSource[];
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Each `git.credentials` entry's push Secret (ADR-0051): the entries whose declared push credentials
|
|
259
|
+
* `jr2 up` can place. A token whose variable is unset is left out of its entry, as a kind it
|
|
260
|
+
* cannot push with; an entry with nothing left is not written. `skipped` says why, by match.
|
|
261
|
+
*/
|
|
262
|
+
export function gitPushPlansOf(
|
|
263
|
+
list: readonly GitCredential[],
|
|
264
|
+
env: (name: string) => string | undefined,
|
|
265
|
+
): { plans: GitPushPlan[]; skipped: Map<string, string> } {
|
|
266
|
+
const plans: GitPushPlan[] = [];
|
|
267
|
+
const skipped = new Map<string, string>();
|
|
268
|
+
list.forEach((entry, order) => {
|
|
269
|
+
const sources: GitPushSource[] = [];
|
|
270
|
+
const push: PushKind[] = [];
|
|
271
|
+
const token = entry.push ? ("token" in entry.push ? entry.push.token : undefined) : entry.token;
|
|
272
|
+
const sshKey = entry.push ? ("sshKey" in entry.push ? entry.push.sshKey : undefined) : entry.sshKey;
|
|
273
|
+
if (sshKey !== undefined) {
|
|
274
|
+
push.push("ssh");
|
|
275
|
+
sources.push(
|
|
276
|
+
{ key: GIT_PUSH_KEYS.identity, from: { secret: sshKey, key: "identity" } },
|
|
277
|
+
{ key: GIT_PUSH_KEYS.identityPub, from: { secret: sshKey, key: "identity.pub" }, optional: true },
|
|
278
|
+
{ key: GIT_PUSH_KEYS.knownHosts, from: { secret: sshKey, key: "known_hosts" } },
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
if (token !== undefined) {
|
|
282
|
+
const value = env(token);
|
|
283
|
+
if (value) {
|
|
284
|
+
push.push("token");
|
|
285
|
+
sources.push(
|
|
286
|
+
{ key: GIT_PUSH_KEYS.username, literal: "x-access-token" },
|
|
287
|
+
{ key: GIT_PUSH_KEYS.password, literal: value },
|
|
288
|
+
);
|
|
289
|
+
} else {
|
|
290
|
+
skipped.set(entry.match, `its token's variable ${token} is not set — set it in .env`);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
if (push.length === 0) return;
|
|
294
|
+
const sign = entry.sign;
|
|
295
|
+
if (sign) {
|
|
296
|
+
sources.push(
|
|
297
|
+
"gpgKey" in sign
|
|
298
|
+
? { key: GIT_PUSH_KEYS.signGpg, from: { secret: sign.gpgKey, key: "private.asc" } }
|
|
299
|
+
: { key: GIT_PUSH_KEYS.signSsh, from: { secret: sign.sshKey, key: "identity" } },
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
const placed: GitPushEntry = {
|
|
303
|
+
match: entry.match,
|
|
304
|
+
order,
|
|
305
|
+
secret: gitPushSecretName(entry.match),
|
|
306
|
+
push: push.sort(),
|
|
307
|
+
...(sign ? { sign: "gpgKey" in sign ? ("gpg" as const) : ("ssh" as const) } : {}),
|
|
308
|
+
};
|
|
309
|
+
const entryJson = {
|
|
310
|
+
match: entry.match,
|
|
311
|
+
order,
|
|
312
|
+
push: placed.push,
|
|
313
|
+
...(sign ? { sign: { format: placed.sign, name: sign.name, email: sign.email } } : {}),
|
|
314
|
+
...(entry.committer ? { committer: { name: entry.committer.name, email: entry.committer.email } } : {}),
|
|
315
|
+
};
|
|
316
|
+
sources.unshift({ key: GIT_PUSH_KEYS.entry, literal: JSON.stringify(entryJson) });
|
|
317
|
+
plans.push({ entry: placed, sources });
|
|
318
|
+
});
|
|
319
|
+
return { plans, skipped };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** The manifest the provision reads, or none when `jr2 up` wrote no push Secret. */
|
|
323
|
+
export async function readGitPushManifest(path: string): Promise<GitPushManifest> {
|
|
324
|
+
let raw: string;
|
|
325
|
+
try {
|
|
326
|
+
raw = await readFile(path, "utf8");
|
|
327
|
+
} catch (err) {
|
|
328
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return { version: 1, entries: [] };
|
|
329
|
+
throw err;
|
|
330
|
+
}
|
|
331
|
+
const parsed = JSON.parse(raw) as GitPushManifest;
|
|
332
|
+
if (parsed.version !== 1 || !Array.isArray(parsed.entries)) {
|
|
333
|
+
throw new Error(`${path} is not a push manifest jr2 reads (version 1)`);
|
|
334
|
+
}
|
|
335
|
+
return parsed;
|
|
336
|
+
}
|