| Safe Haskell | None |
|---|---|
| Language | GHC2021 |
Ecluse
Description
Écluse — a supply-chain resilience proxy for package registries.
Écluse (package ecluse) is a lightweight proxy that sits between consumers
(developers, CI) and a package registry, applying a configurable resilience
policy before any dependency reaches a build — without taking on the cost of
hosting packages itself. The name is French for a canal lock: a chamber whose
gates never open at once. That is the posture — not a wall that blocks, but a
controlled passage every dependency is held in and cleared through 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. And Écluse is not a registry: storage is delegated to whatever backend the operator runs (e.g. 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 deliberately ecosystem-agnostic so that 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
latestis 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, and never on the request's critical path.
How the code is organized
É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:
- Domain model — Ecluse.Package (the ecosystem-agnostic package vocabulary the rules reason over), Ecluse.Version (version identity and per-ecosystem ordering), and Ecluse.Ecosystem (the ecosystem tag the rest dispatches on).
- Policy — Ecluse.Rules (deny-by-default evaluation) over the rule types in Ecluse.Rules.Types.
- Protocol boundary — Ecluse.Registry (the registry-protocol handle),
Ecluse.Registry.Npm.Wire and Ecluse.Registry.Npm.Project (the lenient npm
wire decoders and their projection onto the domain model),
Ecluse.Registry.Npm.Route (the npm path grammar), and Ecluse.Server.Route
(the shared serve-action
Routeset and the injected route classifier). - Cloud handles — Ecluse.Credential (minting the mirror-target write token) and Ecluse.Queue (the durable mirror-job hand-off to the worker).
- Mirror worker — Ecluse.Worker (the supervised consume loop that fetches, verifies against the job's integrity digest, and publishes an approved artifact).
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
- run :: IO ()
- runServer :: ServerConfig -> Env -> IO ()
- runWorker :: Env -> IO ()
- npmServerConfig :: ServerConfig
- mountBindingFor :: Ecosystem -> Maybe PackumentDeps -> Maybe MountBinding
- mirrorWriteProvider :: CredentialBackend -> CredentialProviders -> CredentialProvider
- orExit :: (e -> Text) -> Either e a -> IO a
- data BootAborted = BootAborted
- unconfiguredRegistry :: RegistryClient
- unconfiguredCredentials :: CredentialProvider
Entry point
Start Écluse: the entry point the ecluse executable runs (see Main).
It assembles the composition root from configuration: it parses the environment
layer and the optional config document, __validates everything and fails fast at
boot__ on any problem (a malformed env, an unresolved rule policy, a configured
mount with no adapter, a credential reference that does not resolve, or a
mirror-queue backend that is not built in this binary), aggregating the failures so
a single run reports them all. On success it builds the handles — the shared HTTP
Manager, the config-selected mirror queue, the metadata cache, the logger, the
process-global credential provider, and the telemetry substrate (off unless
PROXY_TELEMETRY enables it) — into an Env, derives the served mount bindings,
then runs the
server and the mirror worker concurrently over that single Env (runServer
and runWorker). Bracketing the Env (and the telemetry providers) for the
lifetime of both means their shared resources are torn down along every exit path.
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.
This is the npm-aware composition site: mountBindingFor mounts npm — its path
grammar (Ecluse.Registry.Npm.Route) and its denial renderer
(Ecluse.Registry.Npm.Serve) — into the otherwise ecosystem-neutral web layer
(runServer), so the agnostic server stays closed over the shared
Route set and only this one place names an ecosystem.
Splitting the server into its own binary later reuses this same entry.
runWorker :: Env -> IO () Source #
Run the supervised mirror worker over the composition-root Env: the
consume → fetch → verify → publish → ack loop against the queue, the publish-side
registry client, and the credential handle, in the App orchestration monad. The
loop logic lives in Ecluse.Worker; this is the composition-root entry the
single-process program runs alongside runServer.
npm front door
npmServerConfig :: ServerConfig Source #
The fallback server settings: a single npm mount with no packument-serve
dependencies, so the packument route is the recognised-but-unserved 501 stub.
Exposed so the composed front door can be driven directly without binding a socket
(e.g. embedded in another wai application, or exercised in tests through
application) to assert the routing and the unwired-mount surface; a
real launch derives its bindings from configuration in run.
mountBindingFor :: Ecosystem -> Maybe PackumentDeps -> Maybe MountBinding Source #
Resolve an Ecosystem to its complete MountBinding, or Nothing when that
ecosystem has no adapter wired. The ecosystem selects its path
grammar (the Classifier) and its denial renderer (the
MountRenderer), and its path prefix is derived from it
(prefixFor) rather than configured — so the ecosystem is the single thing that
drives the binding (see docs/architecture/hosting.md → Mounts). The
packument-serve dependencies are passed in (the composition root supplies them once
the per-mount registry set is resolved); Nothing for them leaves the packument
route the recognised-but-unserved 501 stub.
npm is the only ecosystem with an adapter; the others have no registry
client or renderer, so they resolve to Nothing — a loud miss at the call
site rather than a silently half-wired mount.
Composition glue (exposed for direct testing)
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 |
Instances
| Exception BootAborted Source # | |
Defined in Ecluse Methods toException :: BootAborted -> SomeException # fromException :: SomeException -> Maybe BootAborted # displayException :: BootAborted -> String # backtraceDesired :: BootAborted -> Bool # | |
| Show BootAborted Source # | |
Defined in Ecluse Methods showsPrec :: Int -> BootAborted -> ShowS # show :: BootAborted -> String # showList :: [BootAborted] -> ShowS # | |
| Eq BootAborted Source # | |
Defined in Ecluse | |
Default handles
unconfiguredRegistry :: RegistryClient Source #
A registry handle with no backend behind it: every effectful field __refuses
loudly__ (a typed RegistryUnconfigured) and every pure parse* field returns Left, so an
unconfigured fetch/publish or parse fails explicitly rather than silently
returning a fabricated success. It holds the handle slot in the composition root
where a configured backend is selected elsewhere.
unconfiguredCredentials :: CredentialProvider Source #
A credential handle with no backend behind it: a static, non-expiring empty
secret. It holds the CredentialProvider slot in the composition root until a live
backend is selected — for the mirror-target write, and for the private-upstream read
under the service / delegated-cache strategies. The default passthrough
strategy needs no read credential at all (reads forward the caller's own token), so
this empty placeholder is harmless on the serve path there. See
docs/architecture/access-model.md.