ecluse
Safe HaskellNone
LanguageGHC2021

Ecluse

Description

Écluse (package ecluse) sits between consumers (developers, CI) and a package registry, applying a configurable resilience policy before any dependency reaches a build, without hosting packages itself. The name is French for a canal lock: a chamber whose gates never open at once. Every dependency is held and cleared through that controlled passage before it is admitted to a build.

The goal is resilience, not malware detection: shrink the blast radius of a bad publish (a hijacked maintainer account, a race-to-publish, a typosquat) rather than promise to recognise malice. Écluse is not a registry: storage is delegated to whatever backend the operator runs (AWS CodeArtifact, GCP Artifact Registry), and Écluse only governs what may be fetched from, and mirrored to, those backends. npm is the first ecosystem; the domain model is ecosystem-agnostic so PyPI and RubyGems can follow.

How a request is cleared

Écluse speaks a registry's native protocol across three read-path registries (the client's, a private upstream of already-vetted packages, and the public registry), and the two request shapes use them differently:

  • A tarball request is gated for that one version: a private-upstream hit is streamed unfiltered (already vetted); on a miss, the proxy fetches the version's public metadata, evaluates the rules, and either streams it from public and enqueues an asynchronous mirror job or returns a denial.
  • A packument (metadata) request is a merge: the private and public upstreams are fetched in parallel, public versions are filtered by the rules while private versions are trusted, and the two are combined into one document (private wins a version collision, an integrity divergence is flagged as a supply-chain signal, and latest is repointed to the newest survivor).

Two properties run through both shapes: the rules engine is deny by default (a version is admitted only if some rule allows it and none denies it), and mirroring is demand-driven, so only versions actually pulled are mirrored, never on the request's critical path.

How the code is organised

Écluse is a functional core with effects at the edges: the policy and protocol logic is pure and trivially testable, and IO is confined to a thin shell. Swappable backends sit behind handles (records of functions chosen at a single composition root), so a new cloud or a new ecosystem is an added implementation behind an existing handle, not a structural change.

The library's vocabulary, roughly from the pure core outward:

run is the entry point the ecluse executable invokes (see Main). It lives in the library, not in app/Main.hs, so the composition root is a single importable unit and app/Main.hs stays a thin shell that only calls it.

Further reading

docs/architecture.md is the systems-design index: the vision, the end-to-end request lifecycle, and a map to the per-concern design documents. CONTRIBUTING.md covers the codebase layout and testing strategy, and STYLE.md the coding and documentation conventions.

Synopsis

Entry point

run :: IO () Source #

The typed process supervisor

data ProcessOutcome Source #

How one whole service run ended: the typed outer perimeter of the process, each constructor owning one exit code (exitCodeFor) so an orchestrator reads the ending from the status alone.

Constructors

ShutdownRequested

The services drained and returned (a graceful shutdown): exit 0.

ServiceExited Text

A service failed up with the carried rendered fault: exit 1.

BootFault

The boot aborted (BootAborted; the boot phase already reported its errors to standard error): exit 2.

RunCancelled

The run was cancelled from outside (a kill, an interrupt): exit 3.

Instances

Instances details
Show ProcessOutcome Source # 
Instance details

Defined in Ecluse

Eq ProcessOutcome Source # 
Instance details

Defined in Ecluse

superviseProcess :: IO () -> IO ProcessOutcome Source #

Run the whole service under the typed process perimeter and classify its ending as a ProcessOutcome -- the one place the process's exception channel is read, so nothing above it interprets exceptions.

The classification, in order: a normal return is ShutdownRequested (warp's graceful drain returns); BootAborted is BootFault; an ExitCode rethrows (a deliberate exit request keeps its code, the local-dev halt's 130 included); the recognised kill deliveries (ThreadKilled, UserInterrupt) are RunCancelled; any other asynchronous exception is not ours to interpret and propagates -- so a timeout or an async cancellation wrapped around run by a test keeps its own semantics -- and every remaining synchronous escape is ServiceExited with its rendered detail.

This is the one deliberate base-try in the codebase: the process perimeter must observe asynchronous delivery to classify a kill, which the async-hygienic unliftio catches deliberately refuse to hand over (they would rethrow the kill and the classification arm could never run). The rethrows go through the base throwIO for the same reason: what leaves here async must leave async.

exitCodeFor :: ProcessOutcome -> ExitCode Source #

The process exit status each ProcessOutcome owns.

Split-ready services

runServer :: ServerConfig -> Env -> IO () Source #

Run the proxy's HTTP front door over the composition-root Env with the config-derived ServerConfig.

The mount wiring behind the served bindings comes from the ecosystem adapter registry: mountBindingFor resolves each configured ecosystem through adapterFor and projects the resolved adapter's serve surface into the otherwise ecosystem-neutral web layer (runServer), so the agnostic server stays closed over the shared Route set. Splitting the server into its own binary later reuses this same entry.

runWorker :: WorkerPolicies -> Env -> IO () Source #

Run the supervised mirror worker over the composition-root Env and the per-ecosystem bundles: the consume → probe → re-evaluate → fetch → verify → publish → ack loop against the queue, in the worker monad (WorkerM) over the worker runtime (workerRuntimeOf). The bundles carry the same prepared rules, artifact request formation, and public origin the serve path gates with, plus each mount's married mirror-write capability, so the worker re-runs current policy against a job before mirroring it and publishes through the job ecosystem's own protocol and target.

This is the composition-root hoist point: it resolves the request-independent dd correlation object (the service identity; no span is active at the worker entry) and installs it as the worker's initial katip context, then discharges the loop to IO through runWorkerM, the worker analogue of the serve path's runHandler boundary. The loop logic lives in Ecluse.Core.Worker; the single-process program runs this alongside runServer.

npm front door

mountBindingFor :: Ecosystem -> PackumentDeps -> Maybe PublishDeps -> Maybe MountBinding Source #

Resolve an Ecosystem to its complete MountBinding, or Nothing when that ecosystem has no registered adapter. The adapter registry (adapterFor) answers which ecosystems this build supports; the resolved adapter's serve surface supplies the router (the MountRouter), and the path prefix is derived from the ecosystem (prefixFor) rather than configured, so the ecosystem is the single thing that drives the binding (see docs/architecture/web-layer.md → "Multi-ecosystem mounts"). The composition root supplies the packument-serve dependencies once the per-mount registry set is resolved.

An ecosystem with no registered adapter resolves to Nothing: a loud miss at the call site rather than a silently half-wired mount.

Composition glue (exposed for direct testing)

orExit :: (e -> Text) -> Either e a -> IO a Source #

data BootAborted Source #

Raised to abort start-up after a boot phase has reported its aggregated failure to stderr. A distinct type -- rather than a bare exitFailure -- so the abort is observable in a test without the process actually exiting; uncaught, it propagates to main and the runtime exits non-zero, the operator-facing fail-fast.

Constructors

BootAborted